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:
@@ -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)}
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user