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 <noreply@anthropic.com>
This commit is contained in:
Keysat
2026-06-05 13:25:47 -05:00
parent 91361042e7
commit dd25bbc08d
5 changed files with 281 additions and 1 deletions
+137
View File
@@ -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)}
+17
View File
@@ -95,6 +95,23 @@ def get_node(node_id, db=None):
return dict(r) if r else {"error": "not_found", "node_id": node_id} 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): def get_node_history(node_id, db=None):
c = _conn(db) c = _conn(db)
rows = [dict(r) for r in c.execute( rows = [dict(r) for r in c.execute(
+113
View File
@@ -53,6 +53,16 @@ try:
except Exception: except Exception:
entity_jobs = None 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 ──────────────────────────────────────────────────────────── # ─── Configuration ────────────────────────────────────────────────────────────
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 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]) return self.handle_get_thesis_version(user, path.split('/')[-1])
if re.match(r'^/api/thesis/[^/]+/canonical$', path): if re.match(r'^/api/thesis/[^/]+/canonical$', path):
return self.handle_get_canonical_thesis(user, path.split('/')[-2]) 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 ─── # ─── Entity-merge review queue ───
if path == '/api/entities/merge-candidates': if path == '/api/entities/merge-candidates':
@@ -1830,6 +1846,15 @@ class CRMHandler(BaseHTTPRequestHandler):
# ─── Architect thesis review (Phase 1, human approval gate) ─── # ─── Architect thesis review (Phase 1, human approval gate) ───
if re.match(r'^/api/thesis/versions/[^/]+/review$', path): if re.match(r'^/api/thesis/versions/[^/]+/review$', path):
return self.handle_thesis_review(user, path.split('/')[-2], body) 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) ─── # ─── UI-triggered index jobs + entity-merge decisions (Phase 1) ───
if path == '/api/index/rebuild': if path == '/api/index/rebuild':
@@ -3539,6 +3564,94 @@ class CRMHandler(BaseHTTPRequestHandler):
return self.send_error_json(res['error'], 400) return self.send_error_json(res['error'], 400)
return self.send_json({"data": res}) 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) ─── # ─── Architect thesis (Phase 1) ───
def handle_list_thesis_lines(self, user): def handle_list_thesis_lines(self, user):
if thesis_review is None: if thesis_review is None:
+2 -1
View File
@@ -49,7 +49,8 @@ RUN apt-get update \
RUN pip install --no-cache-dir \ RUN pip install --no-cache-dir \
cryptography==42.0.5 \ cryptography==42.0.5 \
fastembed==0.4.2 \ fastembed==0.4.2 \
mcp==1.2.0 mcp==1.2.0 \
anthropic
# ── Application source ────────────────────────────────────────── # ── Application source ──────────────────────────────────────────
# Whole backend tree: server.py + all top-level modules (core_migrations, # Whole backend tree: server.py + all top-level modules (core_migrations,
+12
View File
@@ -57,6 +57,18 @@ else
echo "[entrypoint] Gmail integration: DISABLED (no key at $GMAIL_SA_KEY)" echo "[entrypoint] Gmail integration: DISABLED (no key at $GMAIL_SA_KEY)"
fi 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 ────────────────────────────── # ── Phase-0 ingest / retrieval env ──────────────────────────────
# These are consumed by the ingest pipeline (backend/ingest/) and the MCP # These are consumed by the ingest pipeline (backend/ingest/) and the MCP
# server (backend/mcp/) — NOT by the CRM web server, which ignores them. # server (backend/mcp/) — NOT by the CRM web server, which ignores them.