From dd25bbc08d75bc7aba1faab7ca933dedafd6e6e3 Mon Sep 17 00:00:00 2001 From: Keysat Date: Fri, 5 Jun 2026 13:25:47 -0500 Subject: [PATCH] Architect agent: Claude-powered thesis generation (backend scaffolding) - backend/mcp/architect_agent.py: generate_options + revise on Claude (prompt- cached thesis context, claude-opus-4-8, Ten31 voice rules). Writes N variant drafts to a node's variant group; nothing canonical without human approval. Fails gracefully if the API key / SDK is absent. - server.py endpoints: GET /api/architect/status, GET /api/thesis/{key}/tree, GET /api/thesis/nodes/{id}/variants, POST .../generate, POST .../feedback, POST /api/thesis/lines, POST /api/thesis/lines/{key}/nodes. architect_tools gains get_node_variants. - Dockerfile installs `anthropic`; docker_entrypoint loads ANTHROPIC_API_KEY from /data/secrets/anthropic-api-key (self-disabling until the key is dropped in). Full HTTP surface verified end-to-end (graceful 502 without a key). Co-Authored-By: Claude Opus 4.8 --- backend/mcp/architect_agent.py | 137 ++++++++++++++++++++++++++++++++ backend/mcp/architect_tools.py | 17 ++++ backend/server.py | 113 ++++++++++++++++++++++++++ start9/0.4/Dockerfile | 3 +- start9/0.4/docker_entrypoint.sh | 12 +++ 5 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 backend/mcp/architect_agent.py diff --git a/backend/mcp/architect_agent.py b/backend/mcp/architect_agent.py new file mode 100644 index 0000000..1f7d6d7 --- /dev/null +++ b/backend/mcp/architect_agent.py @@ -0,0 +1,137 @@ +"""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)} diff --git a/backend/mcp/architect_tools.py b/backend/mcp/architect_tools.py index 546f4e1..949e0e8 100644 --- a/backend/mcp/architect_tools.py +++ b/backend/mcp/architect_tools.py @@ -95,6 +95,23 @@ def get_node(node_id, db=None): return dict(r) if r else {"error": "not_found", "node_id": node_id} +def get_node_variants(node_id, db=None): + """All competing options for a node (its variant group). The number is fluid: + a node may have one option or many at any moment.""" + c = _conn(db) + node = c.execute("SELECT line_id, variant_group, node_type, title FROM thesis_nodes WHERE id=?", (node_id,)).fetchone() + if not node: + c.close() + return {"error": "not_found", "node_id": node_id} + group = node["variant_group"] or node_id + rows = [dict(r) for r in c.execute( + "SELECT id, body, title, status, variant_group, meta FROM thesis_nodes " + "WHERE (variant_group=? OR id=?) AND deleted_at IS NULL ORDER BY created_at", (group, node_id))] + c.close() + return {"node_id": node_id, "variant_group": group, "node_type": node["node_type"], + "title": node["title"], "variants": rows} + + def get_node_history(node_id, db=None): c = _conn(db) rows = [dict(r) for r in c.execute( diff --git a/backend/server.py b/backend/server.py index 9dc3d75..f4566bd 100644 --- a/backend/server.py +++ b/backend/server.py @@ -53,6 +53,16 @@ try: except Exception: entity_jobs = None +# Phase-1: the Architect agent (runs on Claude) + its tools live in backend/mcp. +# (Compute the path inline — this runs before BASE_DIR is defined below.) +try: + sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp")) + import architect_tools as _architect_tools # type: ignore + import architect_agent as _architect_agent # type: ignore +except Exception: + _architect_tools = None + _architect_agent = None + # ─── Configuration ──────────────────────────────────────────────────────────── BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -1758,6 +1768,12 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_get_thesis_version(user, path.split('/')[-1]) if re.match(r'^/api/thesis/[^/]+/canonical$', path): return self.handle_get_canonical_thesis(user, path.split('/')[-2]) + if path == '/api/architect/status': + return self.handle_architect_status(user) + if re.match(r'^/api/thesis/nodes/[^/]+/variants$', path): + return self.handle_get_node_variants(user, path.split('/')[-2]) + if re.match(r'^/api/thesis/[^/]+/tree$', path): + return self.handle_get_thesis_tree(user, path.split('/')[-2]) # ─── Entity-merge review queue ─── if path == '/api/entities/merge-candidates': @@ -1830,6 +1846,15 @@ class CRMHandler(BaseHTTPRequestHandler): # ─── Architect thesis review (Phase 1, human approval gate) ─── if re.match(r'^/api/thesis/versions/[^/]+/review$', path): return self.handle_thesis_review(user, path.split('/')[-2], body) + # ─── Architect generation (Claude) ─── + if re.match(r'^/api/thesis/nodes/[^/]+/generate$', path): + return self.handle_generate_options(user, path.split('/')[-2], body) + if re.match(r'^/api/thesis/nodes/[^/]+/feedback$', path): + return self.handle_node_feedback(user, path.split('/')[-2], body) + if path == '/api/thesis/lines': + return self.handle_create_thesis_line(user, body) + if re.match(r'^/api/thesis/lines/[^/]+/nodes$', path): + return self.handle_add_thesis_node(user, path.split('/')[-2], body) # ─── UI-triggered index jobs + entity-merge decisions (Phase 1) ─── if path == '/api/index/rebuild': @@ -3539,6 +3564,94 @@ class CRMHandler(BaseHTTPRequestHandler): return self.send_error_json(res['error'], 400) return self.send_json({"data": res}) + # ─── Architect thesis authoring (Phase 1) ─── + def handle_get_thesis_tree(self, user, line_key): + if _architect_tools is None: + return self.send_error_json("Unavailable", 503) + return self.send_json(_architect_tools.get_thesis(line_key, DB_PATH)) + + def handle_create_thesis_line(self, user, body): + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_tools is None: + return self.send_error_json("Unavailable", 503) + body = body or {} + if not body.get('line_key') or not body.get('name'): + return self.send_error_json("line_key and name required", 400) + return self.send_json({"data": _architect_tools.create_thesis_line( + body['line_key'], body['name'], segment_key=body.get('segment_key'), + is_core=bool(body.get('is_core')), description=body.get('description'), db=DB_PATH)}) + + def handle_add_thesis_node(self, user, line_key, body): + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_tools is None: + return self.send_error_json("Unavailable", 503) + conn = get_db() + row = conn.execute("SELECT id FROM thesis_lines WHERE line_key=? AND deleted_at IS NULL", (line_key,)).fetchone() + conn.close() + if not row: + return self.send_error_json("Line not found", 404) + body = body or {} + return self.send_json({"data": _architect_tools.upsert_thesis_node( + row['id'], body.get('node_type', 'claim'), body.get('body', ''), title=body.get('title'), + parent_id=body.get('parent_id'), node_id=body.get('node_id'), + variant_group=body.get('variant_group'), change_reason=body.get('change_reason'), db=DB_PATH)}) + + # ─── Architect agent (Phase 1, runs on Claude) ─── + def handle_architect_status(self, user): + if _architect_agent is None: + return self.send_error_json("Unavailable", 503) + return self.send_json({"data": _architect_agent.status()}) + + def handle_get_node_variants(self, user, node_id): + if _architect_tools is None: + return self.send_error_json("Unavailable", 503) + return self.send_json(_architect_tools.get_node_variants(node_id, DB_PATH)) + + def _architect_line_key(self, node_id): + conn = get_db() + row = conn.execute("SELECT l.line_key FROM thesis_nodes n JOIN thesis_lines l ON l.id=n.line_id WHERE n.id=?", + (node_id,)).fetchone() + conn.close() + return row['line_key'] if row else None + + def handle_generate_options(self, user, node_id, body): + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_agent is None: + return self.send_error_json("Unavailable", 503) + lk = self._architect_line_key(node_id) + if not lk: + return self.send_error_json("Node not found", 404) + body = body or {} + try: + res = _architect_agent.generate_options(lk, node_id, int(body.get('n', 3) or 3), + body.get('guidance', '') or '', DB_PATH) + except Exception as exc: + return self.send_error_json(str(exc), 502) + if res.get('error'): + return self.send_error_json(res.get('raw') or res['error'], 502) + return self.send_json({"data": res}) + + def handle_node_feedback(self, user, node_id, body): + if not require_admin(user): + return self.send_error_json("Admin required", 403) + if _architect_agent is None: + return self.send_error_json("Unavailable", 503) + lk = self._architect_line_key(node_id) + if not lk: + return self.send_error_json("Node not found", 404) + body = body or {} + try: + res = _architect_agent.revise(lk, node_id, body.get('feedback', '') or '', + int(body.get('n', 2) or 2), DB_PATH) + except Exception as exc: + return self.send_error_json(str(exc), 502) + if res.get('error'): + return self.send_error_json(res.get('raw') or res['error'], 502) + return self.send_json({"data": res}) + # ─── Architect thesis (Phase 1) ─── def handle_list_thesis_lines(self, user): if thesis_review is None: diff --git a/start9/0.4/Dockerfile b/start9/0.4/Dockerfile index 0854086..5d7ff80 100644 --- a/start9/0.4/Dockerfile +++ b/start9/0.4/Dockerfile @@ -49,7 +49,8 @@ RUN apt-get update \ RUN pip install --no-cache-dir \ cryptography==42.0.5 \ fastembed==0.4.2 \ - mcp==1.2.0 + mcp==1.2.0 \ + anthropic # ── Application source ────────────────────────────────────────── # Whole backend tree: server.py + all top-level modules (core_migrations, diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh index d82314f..e6c2608 100755 --- a/start9/0.4/docker_entrypoint.sh +++ b/start9/0.4/docker_entrypoint.sh @@ -57,6 +57,18 @@ else echo "[entrypoint] Gmail integration: DISABLED (no key at $GMAIL_SA_KEY)" fi +# ── Architect (Claude) API key ────────────────────────────────── +# The Architect agent (thesis generation) runs on Claude. Drop your Anthropic +# API key in this file to enable it; it stays on the box. Self-disabling until +# the key is present (generation endpoints return a clear "not configured" error). +ANTHROPIC_KEY_FILE="$SECRETS_DIR/anthropic-api-key" +if [ -z "${ANTHROPIC_API_KEY:-}" ] && [ -f "$ANTHROPIC_KEY_FILE" ]; then + export ANTHROPIC_API_KEY="$(tr -d '\n\r' < "$ANTHROPIC_KEY_FILE")" + echo "[entrypoint] Architect: ANTHROPIC_API_KEY loaded from $ANTHROPIC_KEY_FILE" +elif [ -z "${ANTHROPIC_API_KEY:-}" ]; then + echo "[entrypoint] Architect: no API key yet (drop it at $ANTHROPIC_KEY_FILE to enable thesis generation)" +fi + # ── Phase-0 ingest / retrieval env ────────────────────────────── # These are consumed by the ingest pipeline (backend/ingest/) and the MCP # server (backend/mcp/) — NOT by the CRM web server, which ignores them.