add evidence-grounded ai assistance

This commit is contained in:
Marco0300
2026-09-03 12:16:12 +02:00
parent c93dbd1ab4
commit c0909132f2
12 changed files with 352 additions and 2 deletions
+12
View File
@@ -179,3 +179,15 @@ Every CRM mutation and report/export operation emits an audit record with tenant
## Remaining limitations and production migration work
SQLite is a pilot store with an MVP job/event schema but no production migration runner, durable queue, scheduler, worker lease/recovery, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Website scanning remains subject to pilot limits: no production egress proxy/isolation, distributed crawl coordinator, hardened resolver, or compliance-grade scan-history retention. Production work must add SSRF/DNS-rebinding/redirect-chain tests, egress policy, authenticated history/cache isolation, budget/abuse enforcement, durable result retention/deletion, observability, reviewed content/robots/terms policy, and the Phase 7 PSL/DNS/availability controls. Redis/Celery/Postgres remain future options, not implemented dependencies.
## Phase 13 optional AI assistance API contract
AI assistance is an optional, authenticated, tenant-scoped drafting capability. It may summarize or classify already-held evidence and propose review text; it must not discover facts, verify identity/ownership/deliverability, calculate authoritative score/eligibility, mutate CRM state, merge records, acquire domains, send messages, create campaigns, schedule follow-ups, or perform any autonomous CRM/outreach action. The current Compose runtime does not configure an AI provider or expose a production AI worker; any future route must be explicitly enabled and documented rather than inferred from a provider setting.
Provider selection is server-side and deny-by-default. A primary provider and optional fallback may be configured only from an allowlist of approved provider IDs. Each provider registration must include capability/purpose, model/version, tenant/data-class scope, processing region and retention terms, timeout/token/request budgets, rate and cost limits, approval owner/expiry, health/circuit state, and operational enablement. Fallback may run only for the same approved purpose and input data class; it must preserve the same tenant scope, redaction policy, evidence set, citation contract, and authority level. Provider credentials are secrets and must never appear in request payloads, prompts, responses, logs, Compose, or committed environment files. Provider outage, timeout, quota, policy rejection, or expired approval returns an explicit `unavailable`/`unknown` result and does not silently invent facts or present invented facts as supported claims.
Before a request leaves the tenant boundary, minimize and redact data: include only the bounded evidence fields required for the task; remove credentials, tokens, session data, secrets, unnecessary contact values, raw page bodies, and unrelated personal data; and enforce input/output size limits. Store a redacted request/policy fingerprint, not a sensitive prompt. Every suggestion must carry tenant-scoped evidence IDs/citations, a hash of the exact evidence snapshot used, observed/captured times, uncertainty/conflict reasons, provider/model/version, and policy version. Hashes and citations provide reproducible lineage, not truth or independent verification. If evidence is absent, suppressed, stale, conflicting, blocked, or uncertain, preserve that state and return no unsupported claim.
AI output is untrusted until an authorized human approves it. Approval/rejection must be an explicit tenant-scoped operation with actor, time, reason, output/version, evidence hash/citation set, and before/after value in the audit trail. At approval time re-check authorization, suppression, evidence freshness, provider/policy approval, and hash equality; changed evidence requires a new review. A rejected or expired suggestion must not be applied by retry, fallback, cache, or background work. Approval never converts a citation into proof, consent, deliverability, or outreach permission.
AI records, prompts/fingerprints, outputs, citations, evidence snapshots, approvals, and audit events require explicit retention classes, deletion/legal-hold behavior, tenant-keyed access, and redacted operational logs. Preserve enough hashed lineage to explain an approved result without retaining unnecessary source content. Cross-tenant business, evidence, suggestion, approval, provider, job, cache, and audit IDs behave as not found. Production requires provider contracts/DPA review, secret isolation, immutable/tamper-evident audit, deletion verification, cost/rate monitoring, prompt-injection and hallucination tests, human-review SLAs, kill-switch procedures, and durable worker/retry semantics; none are implied by the current MVP.
+93
View File
@@ -0,0 +1,93 @@
"""Evidence-bounded AI assistance primitives for Phase 13.
This module deliberately has no network or model dependency. The local provider is
an auditable formatter over stored records; other providers are reported as
not_configured rather than guessed at.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from typing import Any
MAX_INPUT_ITEMS = 100
MAX_FIELD_CHARS = 500
MAX_OUTPUT_CHARS = 12_000
SUPPORTED_KINDS = ("summary", "qualification_explanation", "missing_data_questions", "research_note")
_SECRET_RE = re.compile(r"(?i)(password|passwd|secret|token|api[_-]?key|authorization|private[_-]?key|credential)\s*[:=]\s*[^\s,;]+")
def _text(value: Any, limit: int = MAX_FIELD_CHARS) -> str:
value = "" if value is None else str(value)
value = _SECRET_RE.sub(r"\1: [REDACTED]", value)
return value[:limit]
def redact(value: Any) -> Any:
if isinstance(value, dict):
return {str(k)[:80]: ("[REDACTED]" if re.search(r"(?i)(password|passwd|secret|token|api[_-]?key|authorization|private[_-]?key|credential)", str(k)) else redact(v)) for k, v in list(value.items())[:100]}
if isinstance(value, list):
return [redact(v) for v in value[:MAX_INPUT_ITEMS]]
if isinstance(value, str):
return _text(value)
return value
def evidence_hashes(evidence: list[dict[str, Any]]) -> list[str]:
return [hashlib.sha256(json.dumps(redact(item), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest() for item in evidence]
def _citation(item: dict[str, Any]) -> dict[str, Any]:
return {"evidence_id": int(item["id"]), "kind": _text(item.get("kind", "evidence"), 80), "url": _text(item.get("url", ""), 500)}
def _claim(item: dict[str, Any]) -> str:
return _text(item.get("claim", ""), MAX_FIELD_CHARS).strip()
def build_local_suggestions(business: dict[str, Any], scans: list[dict[str, Any]], contacts: list[dict[str, Any]], evidence: list[dict[str, Any]], score_history: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""Create deterministic suggestions using only supplied stored data.
Every claim-bearing item cites one or more rows from ``evidence``. No
contact details are emitted, and contacts are used only as aggregate counts.
"""
evidence = [redact(x) for x in evidence[:MAX_INPUT_ITEMS] if _claim(x)]
citations = [_citation(x) for x in evidence]
claims = [_claim(x) for x in evidence]
suggestions: list[dict[str, Any]] = []
name = _text(business.get("name", "this business"), 200)
if claims:
joined = " ".join(f"{claim} [evidence:{item['id']}]" for claim, item in zip(claims[:5], evidence[:5]))
suggestions.append({"type": "summary", "text": f"Stored evidence for {name}: {joined}", "citations": citations[:5]})
score = business.get("score")
if score is not None:
suggestions.append({"type": "qualification_explanation", "text": f"The stored qualification score is {_text(score, 30)}; review the cited evidence before relying on it. [evidence:{evidence[0]['id']}]", "citations": citations[:1]})
missing = []
if not _text(business.get("website", "")).strip(): missing.append("official website")
if not contacts: missing.append("public contact evidence")
if missing:
suggestions.append({"type": "missing_data_questions", "text": "Confirm whether the following data is available: " + ", ".join(missing) + f". [evidence:{evidence[0]['id']}]", "citations": citations[:1]})
suggestions.append({"type": "research_note", "text": f"Draft note: independently verify the stored claims for {name}; do not infer facts beyond the cited records. [evidence:{evidence[0]['id']}]", "citations": citations[:1]})
else:
# No claim is fabricated. A question is safe but has no citation, so
# return no suggestions and let the caller expose the missing-data state.
suggestions = []
output = {"provider": "local", "version": "deterministic-v1", "suggestions": suggestions, "grounded": True, "claim_policy": "stored_evidence_only"}
encoded = json.dumps(output, sort_keys=True, ensure_ascii=False)
return json.loads(encoded[:MAX_OUTPUT_CHARS]) if len(encoded) <= MAX_OUTPUT_CHARS else {"provider": "local", "version": "deterministic-v1", "suggestions": suggestions[:1], "grounded": True, "claim_policy": "stored_evidence_only"}
def provider_name() -> str | None:
value = os.environ.get("AI_PROVIDER", "").strip().lower()
return value or None
def generate(business: dict[str, Any], scans: list[dict[str, Any]], contacts: list[dict[str, Any]], evidence: list[dict[str, Any]], score_history: list[dict[str, Any]] | None = None) -> tuple[str, str, str, dict[str, Any]]:
provider = provider_name()
hashes = evidence_hashes(evidence)
metadata = {"input_counts": {"business": 1, "scans": min(len(scans), MAX_INPUT_ITEMS), "contacts": min(len(contacts), MAX_INPUT_ITEMS), "evidence": min(len(evidence), MAX_INPUT_ITEMS)}, "redacted": True, "max_input_items": MAX_INPUT_ITEMS, "max_field_chars": MAX_FIELD_CHARS, "evidence_hashes": hashes}
if provider not in {"local", "deterministic"}:
return "not_configured", provider or "", "", metadata
return "succeeded", "local", "deterministic-v1", {**metadata, "output": build_local_suggestions(business, scans, contacts, evidence, score_history)}
+66
View File
@@ -13,6 +13,7 @@ if __package__ in (None, ""):
from app.website_scanner import scan_website, validate_url
from app.contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS
from app.scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION
from app.ai_assistance import generate as generate_ai, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS
else:
from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses
from .sources import adapter_for, contains_secret
@@ -20,6 +21,7 @@ else:
from .website_scanner import scan_website, validate_url
from .contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS
from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION
from .ai_assistance import generate as generate_ai, MAX_INPUT_ITEMS, MAX_OUTPUT_CHARS
ORGANIZATION_ID = "demo-tenant"
SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql"
SESSION_DAYS = 7
@@ -296,6 +298,66 @@ class ApiHandler(BaseHTTPRequestHandler):
rows=db.execute(sql,params).fetchall(); return self.send_json(200,{"organization_id":org,"items":[dict(r) for r in rows]})
except ValueError as exc: return self.send_json(400,{"error":str(exc)})
def _ai_run_json(self, row, suggestions=None):
item = row_json(row)
for key in ("input_evidence_hashes_json", "prompt_metadata_json", "data_minimization_json", "output_json"):
source = item.pop(key, "{}" if key != "input_evidence_hashes_json" else "[]")
try: item[key[:-5] if key.endswith("_json") else key] = json.loads(source or ("{}" if key != "input_evidence_hashes_json" else "[]"))
except (TypeError, ValueError): item[key[:-5] if key.endswith("_json") else key] = {} if key != "input_evidence_hashes_json" else []
if suggestions is not None: item["suggestions"] = suggestions
return item
def suggest_ai(self, bid, payload, db, user):
org = user["organization_id"]
business = self.business(db, bid, org)
if not business: return self.send_json(404, {"error": "not_found"})
business_dict = row_json(business)
suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1", (org,))]
contacts = [dict(r) for r in db.execute("SELECT * FROM contacts WHERE business_id=? AND organization_id=?", (bid, org))]
extracted = [dict(r) for r in db.execute("SELECT * FROM contact_extractions WHERE business_id=? AND organization_id=?", (bid, org))]
if is_suppressed(business_dict, suppressions) or any(bool(c.get("do_not_contact") or c.get("suppressed")) for c in contacts + extracted):
return self.send_json(409, {"error": "ai_blocked_suppressed_business", "business_id": bid})
try:
requested = int(payload.get("max_items", MAX_INPUT_ITEMS))
if requested < 1 or requested > MAX_INPUT_ITEMS: raise ValueError
except (ValueError, TypeError): return self.send_json(400, {"error": "invalid_limits"})
scans = [dict(r) for r in db.execute("SELECT id,input_url,classification,result_json,scanned_at FROM website_scans WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT ?", (bid, org, requested)).fetchall()]
evidence = [dict(r) for r in db.execute("SELECT id,kind,url,claim,created_at FROM evidence WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT ?", (bid, org, requested)).fetchall()]
history = [dict(r) for r in db.execute("SELECT score,eligible,priority_band,score_version,explanations_json,created_at FROM score_history WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT ?", (bid, org, requested)).fetchall()]
status, provider, version, metadata = generate_ai(business_dict, scans[:requested], contacts[:requested], evidence[:requested], history[:requested])
output = metadata.pop("output", {}) if status == "succeeded" else {"suggestions": [], "grounded": True, "claim_policy": "stored_evidence_only"}
hashes = metadata.get("evidence_hashes", [])
cur = db.execute("INSERT INTO ai_runs(organization_id,business_id,input_evidence_hashes_json,model,provider,version,prompt_metadata_json,data_minimization_json,status,approval_state,output_json,actor_user_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", (org, bid, json.dumps(hashes, sort_keys=True), "local-deterministic" if provider else "", provider, version, json.dumps({"request": {"max_items": requested}, "output_limit": MAX_OUTPUT_CHARS}, sort_keys=True), json.dumps(metadata, sort_keys=True), status, "pending", json.dumps(output, sort_keys=True), user["id"]))
run_id = cur.lastrowid
for suggestion in output.get("suggestions", []):
db.execute("INSERT INTO ai_suggestions(ai_run_id,organization_id,business_id,suggestion_type,citations_json,output_json) VALUES(?,?,?,?,?,?)", (run_id, org, bid, str(suggestion.get("type", ""))[:80], json.dumps(suggestion.get("citations", []), sort_keys=True), json.dumps(suggestion, sort_keys=True)))
self.audit(db, user, "ai.suggested", str(run_id)); db.commit()
row = db.execute("SELECT * FROM ai_runs WHERE id=? AND organization_id=?", (run_id, org)).fetchone()
return self.send_json(201, self._ai_run_json(row, output.get("suggestions", [])))
def list_ai_runs(self, db, user, query):
try:
limit = int((query.get("page_size") or [50])[0]); offset = max(0, int((query.get("offset") or [0])[0]))
if limit < 1 or limit > 100: raise ValueError
except (ValueError, TypeError): return self.send_json(400, {"error": "invalid_pagination"})
rows = db.execute("SELECT * FROM ai_runs WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?", (user["organization_id"], limit + 1, offset)).fetchall()
items = []
for row in rows[:limit]:
suggestions = [json.loads(x[0]) for x in db.execute("SELECT output_json FROM ai_suggestions WHERE ai_run_id=? AND organization_id=? ORDER BY id", (row["id"], user["organization_id"]))]
items.append(self._ai_run_json(row, suggestions))
return self.send_json(200, {"organization_id": user["organization_id"], "items": items, "limit": limit, "offset": offset, "has_more": len(rows) > limit})
def decide_ai(self, run_id, decision, db, user):
row = db.execute("SELECT * FROM ai_runs WHERE id=? AND organization_id=?", (run_id, user["organization_id"])).fetchone()
if not row: return self.send_json(404, {"error": "not_found"})
if decision == "approve" and row["status"] != "succeeded": return self.send_json(409, {"error": "run_not_approvable"})
if row["approval_state"] != "pending": return self.send_json(409, {"error": "already_decided"})
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
if decision == "approve": db.execute("UPDATE ai_runs SET approval_state='approved',approved_at=? WHERE id=? AND organization_id=?", (now, run_id, user["organization_id"]))
else: db.execute("UPDATE ai_runs SET approval_state='rejected',rejected_at=? WHERE id=? AND organization_id=?", (now, run_id, user["organization_id"]))
self.audit(db, user, "ai." + decision, str(run_id)); db.commit()
return self.send_json(200, self._ai_run_json(db.execute("SELECT * FROM ai_runs WHERE id=?", (run_id,)).fetchone()))
def do_GET(self):
parsed=urlparse(self.path); path=parsed.path.rstrip("/")
if path=="/api/v1/health/live": return self.send_json(200,{"status":"ok","organization_id":ORGANIZATION_ID})
@@ -331,6 +393,7 @@ class ApiHandler(BaseHTTPRequestHandler):
if path=="/api/v1/outcomes": return self.send_json(200,{"items":sorted(self.OUTCOMES)})
if path=="/api/v1/interactions": return self.list_interactions(db,org,parse_qs(parsed.query))
if path=="/api/v1/suppressions": return self.list_suppressions(db,org,parse_qs(parsed.query))
if path=="/api/v1/ai-runs": return self.list_ai_runs(db,user,parse_qs(parsed.query))
if path in ("/api/v1/reports/pipeline","/api/v1/reports/outcomes","/api/v1/reports/activity"): return self.report(db,org,path.rsplit('/',1)[1],parse_qs(parsed.query))
if path.startswith("/api/v1/jobs/"): return self.get_job_route(db,org,path,parse_qs(parsed.query))
if path.startswith("/api/v1/businesses/"):
@@ -691,6 +754,9 @@ class ApiHandler(BaseHTTPRequestHandler):
payload=self.read_json(); org=user["organization_id"]
if path=="/api/v1/saved-filters": return self.save_filter(payload,db,user)
if path=="/api/v1/businesses/bulk-review": return self.bulk_review(payload,db,user)
bits_ai=path.split("/")
if len(bits_ai)==7 and bits_ai[:4]==["","api","v1","businesses"] and bits_ai[5]=="ai" and bits_ai[6]=="suggest": return self.suggest_ai(int(bits_ai[4]) if bits_ai[4].isdigit() else -1,payload,db,user)
if len(bits_ai)==6 and bits_ai[:4]==["","api","v1","ai-runs"] and bits_ai[4].isdigit() and bits_ai[5] in {"approve","reject"}: return self.decide_ai(int(bits_ai[4]),bits_ai[5],db,user)
if path=="/api/v1/score-rules": return self.create_score_rule(payload,db,user)
if len(path.split("/"))==7 and path.split("/")[3:]==["businesses",path.split("/")[4],"score","recalculate"]: return self.recalculate_score(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user)
if path.startswith("/api/v1/jobs/"):
+26
View File
@@ -254,3 +254,29 @@ CREATE TABLE IF NOT EXISTS saved_filters (
UNIQUE(organization_id,user_id,name)
);
CREATE INDEX IF NOT EXISTS idx_saved_filters_org_user ON saved_filters(organization_id,user_id,updated_at DESC,id DESC);
-- Phase 13 optional, evidence-bounded AI assistance. Outputs are drafts only.
CREATE TABLE IF NOT EXISTS ai_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
business_id INTEGER REFERENCES businesses(id) ON DELETE SET NULL,
input_evidence_hashes_json TEXT NOT NULL DEFAULT '[]',
model TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', version TEXT NOT NULL DEFAULT '',
prompt_metadata_json TEXT NOT NULL DEFAULT '{}', data_minimization_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL CHECK(status IN ('not_configured','succeeded','failed','rejected')),
approval_state TEXT NOT NULL DEFAULT 'pending' CHECK(approval_state IN ('pending','approved','rejected')),
output_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, approved_at TEXT, rejected_at TEXT,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_ai_runs_org_created ON ai_runs(organization_id,created_at DESC,id DESC);
CREATE INDEX IF NOT EXISTS idx_ai_runs_business ON ai_runs(organization_id,business_id,created_at DESC,id DESC);
CREATE TABLE IF NOT EXISTS ai_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ai_run_id INTEGER NOT NULL REFERENCES ai_runs(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
business_id INTEGER REFERENCES businesses(id) ON DELETE SET NULL,
suggestion_type TEXT NOT NULL, citations_json TEXT NOT NULL DEFAULT '[]', output_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ai_suggestions_run ON ai_suggestions(organization_id,ai_run_id,id);
+82
View File
@@ -0,0 +1,82 @@
import json
import os
import sqlite3
import threading
import unittest
from http.client import HTTPConnection
from tempfile import TemporaryDirectory
from app.ai_assistance import build_local_suggestions, generate
from app.main import create_server
from app.main import hash_password
class Phase13ApiTests(unittest.TestCase):
def setUp(self):
self.tmp = TemporaryDirectory()
self.old = {key: os.environ.get(key) for key in ("AI_PROVIDER", "BOOTSTRAP_ADMIN_EMAIL", "BOOTSTRAP_ADMIN_PASSWORD")}
os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "ai-owner@example.test"
os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "ai-password"
os.environ["AI_PROVIDER"] = "local"
self.server = create_server("127.0.0.1", 0, self.tmp.name + "/ai.db")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start()
self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3); self.cookie = None
self.request("POST", "/api/v1/auth/login", {"email": "ai-owner@example.test", "password": "ai-password"})
def tearDown(self):
self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2); self.tmp.cleanup()
for key, value in self.old.items():
if value is None: os.environ.pop(key, None)
else: os.environ[key] = value
def request(self, method, path, payload=None):
body = json.dumps(payload).encode() if payload is not None else None; headers = {"Content-Type": "application/json"}
if self.cookie: headers["Cookie"] = self.cookie
self.conn.request(method, path, body, headers); response = self.conn.getresponse(); cookie = response.getheader("Set-Cookie")
if cookie: self.cookie = cookie.split(";", 1)[0]
return response.status, json.loads(response.read() or b"{}")
def test_local_fallback_is_deterministic_and_evidence_bounded(self):
args = ({"id": 4, "name": "Acme", "score": 10}, [], [], [{"id": 2, "kind": "source", "url": "https://source.test", "claim": "Makes widgets"}])
first = build_local_suggestions(*args); second = build_local_suggestions(*args)
self.assertEqual(first, second)
self.assertIn("[evidence:2]", first["suggestions"][0]["text"])
self.assertNotIn("customers", json.dumps(first).lower())
def test_no_provider_returns_not_configured_without_output(self):
os.environ.pop("AI_PROVIDER", None)
status, provider, version, metadata = generate({"name": "Acme"}, [], [], [])
self.assertEqual(status, "not_configured"); self.assertEqual(provider, ""); self.assertEqual(version, "")
self.assertNotIn("output", metadata)
def test_endpoint_persists_citations_and_approval_without_crm_write(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Evidence Co", "website": "https://evidence.test"}); self.assertEqual(status, 201)
bid = business["id"]
self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://source.test", "claim": "Serves Cape Town"})
status, run = self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {}); self.assertEqual(status, 201)
self.assertTrue(all(s["citations"] for s in run["suggestions"]))
self.assertEqual(self.request("POST", f"/api/v1/ai-runs/{run['id']}/approve", {})[1]["approval_state"], "approved")
self.assertEqual(self.request("POST", f"/api/v1/ai-runs/{run['id']}/reject", {})[0], 409)
db = sqlite3.connect(self.tmp.name + "/ai.db")
self.assertEqual(db.execute("SELECT COUNT(*) FROM pipeline_entries").fetchone()[0], 0); self.assertEqual(db.execute("SELECT COUNT(*) FROM interactions").fetchone()[0], 0); db.close()
def test_limits_and_suppressed_business_are_safe(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Safe Co"}); self.assertEqual(status, 201)
bid = business["id"]
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {"max_items": 101})[0], 400)
self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "safe.test"})
# Directly mark the business with the suppressed domain to exercise the AI guard.
db = sqlite3.connect(self.tmp.name + "/ai.db"); db.execute("UPDATE businesses SET website_domain='safe.test' WHERE id=?", (bid,)); db.commit(); db.close()
self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/ai/suggest", {})[0], 409)
def test_tenant_isolation_applies_to_ai_runs_and_business_suggestions(self):
status, business = self.request("POST", "/api/v1/businesses", {"name": "Tenant A"}); self.assertEqual(status, 201)
ph, salt = hash_password("other-password")
db = sqlite3.connect(self.tmp.name + "/ai.db")
db.execute("INSERT INTO organizations (id,name) VALUES (?,?)", ("other-tenant", "Other"))
db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant", "other-ai@example.test", ph, salt, "owner")); db.commit(); db.close()
self.cookie = None; self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other-ai@example.test", "password": "other-password"})[0], 200)
self.assertEqual(self.request("POST", f"/api/v1/businesses/{business['id']}/ai/suggest", {})[0], 404)
self.assertEqual(self.request("GET", "/api/v1/ai-runs")[1]["items"], [])
if __name__ == "__main__": unittest.main()