"""The Architect agent — generates and revises thesis options using Claude. This is the one step that runs on the frontier model (Claude) rather than the local Qwen, because sharpening narrative is exactly Claude's strength. Thesis content is non-sensitive house messaging, so it goes to Claude directly with no redaction. The agent reads the current thesis and writes new VARIANT drafts back through architect_tools; nothing it produces is canonical until a human approves. Flexibility by design (per Grant): a node can hold any number of competing options at once. generate_options adds N drafts to a node's variant group; partners keep, refine, branch, or retire them freely. Requires ANTHROPIC_API_KEY in the environment (wired as a StartOS config secret on the box). Fails gracefully with a clear message if the key or SDK is absent. """ import json import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import architect_tools as at # noqa: E402 MODEL = os.environ.get("CRM_ARCHITECT_MODEL", "claude-opus-4-8") VOICE = ("Direct, concrete, conviction-driven. Never use 'bet'/'betting'/'gamble' " "language (we invest with conviction). No em dashes. No 'X, not Y' " "constructions. No kitchen-sink lists. Plain sentences a serious LP can " "verify in their head, with real specifics where possible.") def _client(): try: from anthropic import Anthropic except Exception: raise RuntimeError("anthropic SDK not installed in this environment") key = os.environ.get("ANTHROPIC_API_KEY") if not key: raise RuntimeError("ANTHROPIC_API_KEY not configured") return Anthropic(api_key=key) def _render_thesis(thesis): """Flatten the current thesis tree into text for the (cacheable) system prompt.""" line = thesis.get("line", {}) lines = [f"Thesis line: {line.get('name')} ({line.get('line_key')})"] def walk(nodes, depth=0): for n in nodes: body = (n.get("body") or "").strip() tag = n.get("node_type") var = f" [variant_group={n['variant_group']}]" if n.get("variant_group") else "" lines.append(f"{' ' * depth}- {tag}{var}: {n.get('title') or ''} {body}".rstrip()) walk(n.get("children", []), depth + 1) walk(thesis.get("tree", [])) return "\n".join(lines) def _system(thesis): text = ("You are the Architect, the in-house copilot that sharpens Ten31's investment " "thesis with the partners. Ten31 invests in critical infrastructure across bitcoin, " "AI, energy, and freedom technologies, with scarcity as the connecting idea. " f"VOICE RULES (follow exactly): {VOICE}\n\n" "Here is the current working thesis:\n" + _render_thesis(thesis)) # Cache the thesis context so iterating across requests is cheap. return [{"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}] def _parse_options(text): text = re.sub(r"^```(json)?|```$", "", text.strip(), flags=re.MULTILINE).strip() m = re.search(r"\[.*\]", text, re.DOTALL) if not m: return [] try: arr = json.loads(m.group(0)) return [o for o in arr if isinstance(o, dict) and o.get("text")] except json.JSONDecodeError: return [] def _call(thesis, user_text, max_tokens=1800): resp = _client().messages.create(model=MODEL, max_tokens=max_tokens, system=_system(thesis), messages=[{"role": "user", "content": user_text}]) return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text") def generate_options(line_key, node_id, n=3, guidance="", db=None): """Generate N fresh alternative versions of one node and add them as variant drafts in that node's variant group.""" thesis = at.get_thesis(line_key, db=db) if thesis.get("error"): return thesis node = at.get_node(node_id, db=db) if node.get("error"): return node user = (f'Generate {n} distinct alternative versions of this {node["node_type"]} ' f'(current text: "{(node.get("body") or "").strip()}").\n' f'Guidance from the partner: {guidance.strip() or "sharpen it and make it more concrete"}.\n' f'Each option should be genuinely different in angle, not minor rewordings. ' f'Return ONLY a JSON array of {n} objects, each {{"text": "...", "rationale": "one short line"}}.') raw = _call(thesis, user) options = _parse_options(raw)[:n] if not options: return {"error": "no_options_parsed", "raw": raw[:300]} group = node.get("variant_group") or node_id # Make sure the original node shares the group, so the UI shows them together. if not node.get("variant_group"): at.upsert_thesis_node(node["line_id"], node["node_type"], node.get("body") or "", title=node.get("title"), node_id=node_id, variant_group=group, change_reason="grouped for variants", db=db) created = [] for opt in options: r = at.upsert_thesis_node(node["line_id"], node["node_type"], opt["text"], title=node.get("title"), parent_id=node.get("parent_id"), variant_group=group, meta={"architect_generated": True, "rationale": opt.get("rationale"), "guidance": guidance}, db=db) created.append({"id": r.get("id"), "text": opt["text"], "rationale": opt.get("rationale")}) return {"node_id": node_id, "variant_group": group, "generated": len(created), "options": created} def revise(line_key, node_id, feedback, n=2, db=None): """Revise one node in light of partner feedback, producing N options that address it (kept as variant drafts).""" return generate_options(line_key, node_id, n=n, guidance=f"Address this partner feedback directly: {feedback}", db=db) def status(): """Whether the Architect can run (key + SDK present).""" try: _client() return {"ready": True, "model": MODEL} except Exception as exc: return {"ready": False, "reason": str(exc)}