add evidence-grounded ai assistance
This commit is contained in:
@@ -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)}
|
||||
@@ -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/"):
|
||||
|
||||
Reference in New Issue
Block a user