"""Architect grounding (Phase 1 Workstream D) — ground the thesis in real LP feedback WITHOUT leaking identity, by routing the Claude-facing synthesis through the redaction boundary. The corpus is a defensibility oracle, not a generator. Flow (redaction-rehydration.md): retrieve (local CRM) -> MINIMIZE local Qwen condenses raw feedback into anonymous objection THEMES ("first ask if Claude needs the record content at all") -> SCRUB tokenize any residual identifiers (defense-in-depth, leak-proof core) -> REASON Claude synthesizes the strongest honest rebuttals on the DE-IDENTIFIED register (placeholders only) -> RE-HYDRATE map placeholders back locally -> HUMAN a partner reviews before anything is used (guardrail #4). The Architect only drafts; nothing is sent outbound. Every scrub/rehydrate is logged (counts only) to interaction_log. minimize_fn / claude_fn are injectable so the boundary enforcement is testable offline without Spark or Claude. """ import os import sys sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "redaction")) from client import Boundary # noqa: E402 _MINIMIZE_SYSTEM = ( "You are a local privacy-preserving summarizer running on Ten31's own infrastructure. " "Given raw notes about investor conversations, extract ONLY the recurring objections, " "concerns, and sentiment as generic themes. STRIP every identifier: no names, firms, " "emails, amounts, dates, or places. Output a short de-identified bullet list of distinct " "objection themes with a rough frequency. Never include who said it.") _NER_SYSTEM = ( "You are a local NER extractor running on Ten31's own infrastructure. Return STRICT " "JSON only: {\"entities\":[{\"text\":\"...\",\"type\":\"PERSON|ORG|LOC\"}]}. Extract every " "person name, organization/firm/fund name, and specific location mentioned. Include " "partial names and nicknames. No commentary, JSON only.") def _minimize_local(feedback_items, segment_key): """Condense raw LP feedback into anonymous objection themes on the LOCAL model (Spark Control /v1/chat/completions). RAISES if the local model is unreachable — the caller must then FAIL CLOSED, because without the local model neither this nor the NER backstop can run, and raw prose must never reach Claude unprotected.""" raw = "\n".join(f"- {t}" for t in feedback_items if t) sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest")) import llm # noqa: E402 out = llm.chat(f"Segment: {segment_key or 'all'}\nRaw notes:\n{raw}\n\nDe-identified objection themes:", system=_MINIMIZE_SYSTEM, max_tokens=500) if not (out or "").strip(): raise RuntimeError("local minimize produced no output") return out def _ner_local(text): """Local-Qwen NER backstop for UNKNOWN names not in the CRM dictionary. Returns [(surface, type)]. RAISES on a connection failure (caller fails closed); an empty result (model reachable, found nothing) is fine.""" sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ingest")) import llm # noqa: E402 data = llm.chat_json(f"Text:\n{text}\n\nEntities JSON:", system=_NER_SYSTEM, max_tokens=700) if not data: return [] return [(e.get("text"), e.get("type")) for e in (data.get("entities") or []) if e.get("text")] def _synthesize_claude(register_text, segment_key): """Claude drafts the strongest honest rebuttals over the DE-IDENTIFIED register. Reuses the Architect's Claude client. Raises if not configured (handled by caller).""" sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import architect_agent as aa # noqa: E402 client = aa._client() user = ( # NB: the raw segment_key is intentionally NOT interpolated here — it is an # admin free-text field and must not become an un-scrubbed channel to Claude. "Below is a DE-IDENTIFIED register of recurring LP objections for one of our LP " "segments. Every identifier is a placeholder like [PERSON_1] or " "[ORG_1]; keep all placeholders intact and never invent new ones.\n\n" f"{register_text}\n\n" "For each distinct objection, draft Ten31's strongest HONEST rebuttal grounded in our " "thesis, and flag each as 'substantiated' or 'hand-wavy'. Also do a brief counter-evidence " "sweep (where might the objection be right?). Return readable markdown.") resp = client.messages.create( model=aa.MODEL, max_tokens=2000, system=[{"type": "text", "text": "You are the Architect, pressure-testing Ten31's thesis against real LP " "objections. " + aa.VOICE, "cache_control": {"type": "ephemeral"}}], messages=[{"role": "user", "content": user}]) return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") def ground_objections(feedback_items, segment_key=None, db_path=None, actor="architect", minimize_fn=None, claude_fn=None, ner_fn="default", conn=None): """Build a grounded objection register through the redaction boundary. FAILS CLOSED at every step: no Claude call happens unless the local minimize ran and the scrub (dict + regex + NER backstop) succeeded; a hallucinated token in Claude's output quarantines the draft. Always flagged needs_human_review (guardrail #4).""" minimize_fn = minimize_fn or _minimize_local claude_fn = claude_fn or _synthesize_claude if ner_fn == "default": ner_fn = _ner_local # local-Qwen backstop for unknown names (None disables it, for tests) base = {"segment_key": segment_key, "needs_human_review": True} # 1) MINIMIZE locally. If the local model is unreachable -> FAIL CLOSED (no raw onward). try: themes = minimize_fn(feedback_items, segment_key) except Exception as exc: return {**base, "status": "local_model_unavailable", "reason": str(exc)} # 2) SCRUB. The Boundary fails closed if db_path is missing or the dictionary can't be # built, and the NER backstop propagates a local-model failure here too. try: boundary = Boundary(db_path=db_path, actor=actor, ner_fn=ner_fn) # bucket=False: tokenize amounts/dates REVERSIBLY. The objection register needs no # magnitudes, so opaque placeholders both remove any inference signal AND avoid the # irreversible substance corruption a coarse bucket could introduce on a misparse. scrubbed = boundary.scrub([themes], task_id=f"ground:{segment_key or 'all'}", bucket=False, conn=conn) except Exception as exc: return {**base, "status": "scrub_unavailable", "reason": str(exc)} register = scrubbed["items"][0] handle = scrubbed["handle"] out = {**base, "scrubbed_register": register, "scrub_stats": scrubbed["stats"]} # 3) REASON on Claude over the de-identified register only. try: draft = claude_fn(register, segment_key) except Exception as exc: boundary.forget(handle) return {**out, "status": "claude_not_configured", "reason": str(exc)} # 4) RE-HYDRATE (strict). A residual placeholder = a Claude-hallucinated/smuggled token # -> HARD STOP: quarantine the draft, never surface a de-anonymized version. rehy = boundary.rehydrate(draft, handle, strict=True, conn=conn) boundary.forget(handle) if rehy.get("error"): return {**out, "status": "rehydrate_failed", "draft_quarantined": True, "unknown_tokens": rehy.get("unknown_tokens")} return {**out, "status": "ok", "draft": rehy["text"]}