● Zion Boggan
repos/Darwin/cortex/synthesis.py
zionboggan.com ↗
340 lines · python
History for this file →
1
"""Darwin Cortex - synthesis layer.
2
 
3
Two paths:
4
1. Deterministic ranked-merge (always works, no LLM required):
5
   top-k hits → templated answer + receipts.
6
 
7
2. LLM synthesis (pluggable, gated behind synthesize=True + backend availability):
8
   - qwen3:8b on 3060 (http://REDACTED-IP:11434/api/chat, think:false) - primary
9
   - Claude Opus (gated behind DARWIN_CLAUDE_API_KEY + DARWIN_ENABLE_OPUS=true) - hard questions
10
 
11
Current 3060 state: may be DOWN → graceful fallback to deterministic path.
12
"""
13
from __future__ import annotations
14
 
15
import os
16
from typing import Optional
17
 
18
import httpx
19
 
20
from models import Hit, Receipt
21
 
22
QWEN_URL = os.environ.get("DARWIN_QWEN_URL", "http://REDACTED-IP:11434/api/chat")
23
QWEN_MODEL = os.environ.get("DARWIN_QWEN_MODEL", "qwen3:8b")
24
QWEN_TIMEOUT = float(os.environ.get("DARWIN_QWEN_TIMEOUT", "20"))
25
QWEN_KEEP_ALIVE = os.environ.get("DARWIN_QWEN_KEEP_ALIVE", "30m")
26
 
27
ENABLE_OPUS = os.environ.get("DARWIN_ENABLE_OPUS", "false").lower() == "true"
28
CLAUDE_API_KEY = os.environ.get("DARWIN_CLAUDE_API_KEY", "")
29
 
30
 
31
def _make_receipts(hits: list[Hit], max_receipts: int = 6) -> list[Receipt]:
32
    receipts = []
33
    for i, h in enumerate(hits[:max_receipts]):
34
        receipts.append(Receipt(
35
            id=f"r{i+1}",
36
            source=h.engine,
37
            path=h.path,
38
            last_seen=h.last_seen,
39
            confidence=h.confidence,
40
            snippet=h.snippet[:200],
41
        ))
42
    return receipts
43
 
44
 
45
def _clean_snippet(text: str, limit: int = 240) -> str:
46
    """Flatten a snippet into one prose-ish sentence-fragment: strip markdown
47
    bullets/headers/backticks and collapse whitespace."""
48
    import re
49
 
50
    t = re.sub(r"[`*#>_]", "", text or "")
51
    t = re.sub(r"\s+", " ", t).strip()
52
    t = re.sub(r"^(?:[-•]\s*|\d+\.\s*)", "", t)
53
    if len(t) > limit:
54
        t = t[: limit - 1].rstrip() + "…"
55
    return t
56
 
57
 
58
_DESC_KEY_PRIORITY = ("summary", "description", "desc", "note", "title")
59
_JUNK_KEYS = {
60
    "date", "ts", "timestamp", "updated", "created", "endpoint", "url", "urls",
61
    "uri", "backups", "backup", "registered_agents", "agents", "service",
62
    "shim", "status", "state", "id", "ids", "path", "paths", "score", "engine",
63
    "source", "authority", "authority_tier", "type", "kind", "version",
64
}
65
 
66
 
67
def _extract_descriptive(snippet: str, title: str = "", limit: int = 400) -> tuple[str, int]:
68
    """Pull the single most descriptive value out of a snippet.
69
 
70
    Darwin memory-graph snippets are often pipe-delimited metadata like
71
    "status: DEPLOYED | summary: <real prose> | date: ...". This splits on
72
    " | ", parses "key: value" pairs, and returns the value of the highest-
73
    priority descriptive key (summary > description > desc > note > title,
74
    then the first non-junk value). Junk keys (date/endpoint/status/...) are
75
    never chosen. Returns (text, rank) where lower rank = more descriptive.
76
    Falls back to the cleaned snippet (or title) when not pipe-delimited.
77
    """
78
    s = _clean_snippet(snippet or "", limit=limit)
79
    if " | " in s:
80
        pairs: dict[str, str] = {}
81
        order: list[str] = []
82
        for part in s.split(" | "):
83
            k, sep, v = part.partition(":")
84
            k = k.strip().lower()
85
            v = v.strip()
86
            if k and sep and v:
87
                pairs[k] = v
88
                if k not in order:
89
                    order.append(k)
90
        if pairs:
91
            for rank, pk in enumerate(_DESC_KEY_PRIORITY):
92
                if pairs.get(pk):
93
                    return pairs[pk], rank
94
            for k in order:
95
                if k not in _JUNK_KEYS and pairs[k]:
96
                    return pairs[k], len(_DESC_KEY_PRIORITY)
97
            return "", 999
98
    if s:
99
        return s, len(_DESC_KEY_PRIORITY)
100
    return _clean_snippet(title or "", limit=limit), len(_DESC_KEY_PRIORITY)
101
 
102
 
103
def _strip_prose_junk(text: str) -> str:
104
    """Defensive net: ensure no pipe-chains, graph arrows, or raw URLs survive
105
    into the final prose. _extract_descriptive should already have handled
106
    pipes, but we never emit "key: value | key: value", "--[...]-->", or
107
    http/memory:// URLs regardless."""
108
    import re
109
 
110
    t = text or ""
111
    t = re.sub(r"--\s*\[[^\]]*\]\s*--*>?", "", t)
112
    t = t.split(" | ")[0]
113
    t = re.sub(r"https?://\S+", "", t)
114
    t = re.sub(r"\b(?:memory|file|s3|ftp)://\S+", "", t)
115
    t = re.sub(r"\s+", " ", t).strip(" ;,|")
116
    return t
117
 
118
 
119
def _is_noise(text: str) -> bool:
120
    """True if the extracted text is graph-relation noise, empty, or just an
121
    id/url - i.e. not worth a clause in the answer."""
122
    import re
123
 
124
    t = (text or "").strip()
125
    if not t:
126
        return True
127
    if re.search(r"--\s*\[[^\]]*\]\s*--*>?", t):
128
        return True
129
    if re.match(r"^(https?|memory|file|s3|ftp)://", t, re.I):
130
        return True
131
    if len(t) < 8:
132
        return True
133
    return False
134
 
135
 
136
def _to_sentence(text: str) -> str:
137
    """Make a fragment read as a standalone sentence: leading cap + terminal
138
    punctuation."""
139
    t = (text or "").strip()
140
    if not t:
141
        return t
142
    t = t[0].upper() + t[1:]
143
    if t[-1] not in ".!?":
144
        t += "."
145
    return t
146
 
147
 
148
def deterministic_answer(query: str, hits: list[Hit]) -> tuple[str, list[Receipt]]:
149
    """Deterministic (no-LLM) fallback answer.
150
 
151
    Parses each top hit's snippet (handling Darwin's pipe-delimited
152
    "key: value | key: value" metadata format), extracts the single most
153
    descriptive value per hit, drops graph-relation / id-only noise, then
154
    composes a short answer that LEADS with the most descriptive fact and
155
    folds in 0-2 more distinct facts - each tagged with an inline [rN] marker
156
    that maps back to its receipt. Never emits raw pipe-junk, graph arrows,
157
    or URLs in the prose."""
158
    receipts = _make_receipts(hits)
159
    if not hits:
160
        answer = f"I couldn't find anything in the indexed sources for \"{query}\"."
161
        return answer, []
162
 
163
    facts: list[tuple[int, str, int]] = []
164
    seen: set[str] = set()
165
    for i, h in enumerate(hits[:3]):
166
        text, rank = _extract_descriptive(h.snippet or "", h.title or "")
167
        if _is_noise(text):
168
            continue
169
        text = _strip_prose_junk(text)
170
        if _is_noise(text):
171
            continue
172
        key = text[:60].lower()
173
        if key in seen:
174
            continue
175
        seen.add(key)
176
        facts.append((i, text, rank))
177
 
178
    if not facts:
179
        first = _clean_snippet(hits[0].title or hits[0].path or "the top reference", limit=80)
180
        answer = (
181
            f'I have indexed references to "{query}" but no source with a clear '
182
            f'description - the top reference is {first}. [r1]'
183
        )
184
        return answer, receipts
185
 
186
    facts.sort(key=lambda f: (f[2], -len(f[1])))
187
    best = facts[0]
188
    rest = facts[1:3]
189
 
190
    parts = [f"{_to_sentence(best[1])} [r{best[0] + 1}]"]
191
    for idx, text, _rank in rest:
192
        parts.append(f"{_to_sentence(text)} [r{idx + 1}]")
193
 
194
    answer = " ".join(parts)
195
    return answer, receipts
196
 
197
 
198
async def _try_qwen(query: str, context: str, prefer_state: bool = False) -> Optional[str]:
199
    """Try qwen3:8b. Returns None if unavailable."""
200
    if prefer_state:
201
        prompt = (
202
            f"You are Darwin, a second-brain assistant. The context below is the "
203
            f"REAL, LIVE state of the system - the highest authority (live infra "
204
            f"outranks any document).\n\n"
205
            f"HOW TO READ THE LIVE STATE:\n"
206
            f"- It is split into SEPARATE sections that describe DIFFERENT things: "
207
            f"engine/node health (up/down), data-source staleness, ingestion-source "
208
            f"staleness, active agents, and tmux sessions. Do NOT conflate them. "
209
            f"\"no data-source stale\" is NOT the same as \"no ingestion source stale\"; "
210
            f"\"all nodes up\" is NOT the same as \"no stale data\". Each section is "
211
            f"independent.\n"
212
            f"- Scan the ENTIRE block, every section, before answering. The answer to "
213
            f"\"what's stale\" or \"what's down\" may live in a later section - never stop "
214
            f"at the first line that mentions staleness or health.\n\n"
215
            f"ANSWER RULES:\n"
216
            f"1. Lead with the direct answer in your first sentence.\n"
217
            f"2. NAME the specific offending items with their real numbers - e.g. which "
218
            f"exact ingestion source is stale and how many hours (\"ct243-media (~135h "
219
            f"stale)\"), which exact nodes are down, how many of N. Never generalize as "
220
            f"\"nothing is stale\" or \"all up\" while ANY line in the block flags an item "
221
            f"STALE or down - enumerate those items by name. \"nothing\" is only valid if "
222
            f"zero sections report any STALE/down item.\n"
223
            f"3. Stay concise: 1-4 sentences, plain prose.\n"
224
            f"4. Do NOT add [rN] receipt markers - this is live infra, not receipts.\n"
225
            f"5. If the question asks about a category the block genuinely says nothing "
226
            f"about, say so in one sentence.\n\n"
227
            f"Live state:\n{context}\n\nQuestion: {query}\n\nAnswer:"
228
        )
229
    else:
230
        prompt = (
231
            f"You are Darwin, a personal second-brain assistant. A colleague asked "
232
            f"you a question and you are translating the answer for them - explain it "
233
            f"clearly, like a knowledgeable colleague, NOT like a search results page.\n\n"
234
            f"RULES:\n"
235
            f"1. ANSWER the question directly. Lead with the answer in your very first "
236
            f"sentence - no preamble, no \"based on the sources\", no \"here is\".\n"
237
            f"2. SYNTHESIZE across ALL the context entries collectively to form one "
238
            f"coherent explanation. Do NOT quote or recite a single source verbatim. "
239
            f"Do NOT output a numbered list of results. Do NOT say which source is "
240
            f"\"most relevant\" or \"most recent\".\n"
241
            f"3. Stay grounded: every factual claim must come from the context - never "
242
            f"invent. Right after the clause a piece of context supports, drop an inline "
243
            f"marker like [r1], [r2], ... (context entries are numbered [1], [2], ... so "
244
            f"map [1]->[r1], [2]->[r2], etc.).\n"
245
            f"4. Be concise: 2-5 sentences, flowing prose. No meta commentary.\n"
246
            f"5. If a [LIVE STATE] block is present it is the HIGHEST authority and "
247
            f"overrides any document - prefer it on conflicts.\n"
248
            f"6. If the context genuinely does not answer the question, say so briefly "
249
            f"in one sentence.\n\n"
250
            f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:"
251
        )
252
    payload = {
253
        "model": QWEN_MODEL,
254
        "messages": [{"role": "user", "content": prompt}],
255
        "stream": False,
256
        "keep_alive": QWEN_KEEP_ALIVE,
257
        "options": {"think": False},
258
    }
259
    try:
260
        async with httpx.AsyncClient(timeout=QWEN_TIMEOUT) as client:
261
            resp = await client.post(QWEN_URL, json=payload)
262
            resp.raise_for_status()
263
            data = resp.json()
264
            content = (data.get("message") or {}).get("content", "")
265
            content = content.strip()
266
            return content or None
267
    except Exception:
268
        return None
269
 
270
 
271
async def warmup() -> bool:
272
    """Fire a tiny ping so the model loads onto the GPU at service start and
273
    stays resident (keep_alive). Best-effort; never raises."""
274
    payload = {
275
        "model": QWEN_MODEL,
276
        "messages": [{"role": "user", "content": "ok"}],
277
        "stream": False,
278
        "keep_alive": QWEN_KEEP_ALIVE,
279
        "options": {"think": False, "num_predict": 1},
280
    }
281
    try:
282
        async with httpx.AsyncClient(timeout=max(QWEN_TIMEOUT, 60.0)) as client:
283
            resp = await client.post(QWEN_URL, json=payload)
284
            resp.raise_for_status()
285
            return True
286
    except Exception:
287
        return False
288
 
289
 
290
async def synthesize(
291
    query: str,
292
    hits: list[Hit],
293
    synthesize_flag: bool = True,
294
    live_state: Optional[str] = None,
295
    state_first: bool = False,
296
) -> tuple[Optional[str], list[Receipt]]:
297
    """Return (answer_str_or_None, receipts).
298
 
299
    Falls back to deterministic if LLM unavailable or synthesize=False.
300
 
301
    live_state: an optional compact LIVE-STATE block. Per Darwin's source-of-truth
302
        hierarchy live infra is the HIGHEST authority, so when present it is fed to
303
        the synthesizer as the top-authority context (above RAG hits).
304
    state_first: when True the query is a status/state question - the live snapshot
305
        leads the answer, and a cold LLM falls back to a snapshot-driven answer.
306
    """
307
    receipts = _make_receipts(hits)
308
 
309
    if state_first and live_state:
310
        from state import get_snapshot, deterministic_state_answer
311
        snap = await get_snapshot()
312
        if synthesize_flag:
313
            llm_answer = await _try_qwen(query, live_state, prefer_state=True)
314
            if llm_answer:
315
                return llm_answer, receipts
316
        return deterministic_state_answer(query, snap), receipts
317
 
318
    if not synthesize_flag or not hits:
319
        if hits:
320
            answer, receipts = deterministic_answer(query, hits)
321
            return answer, receipts
322
        return None, []
323
 
324
    context_parts = []
325
    if live_state:
326
        context_parts.append(f"[LIVE STATE - highest authority]\n{live_state}")
327
    for i, h in enumerate(hits[:8]):
328
        context_parts.append(
329
            f"[{i+1}] Source: {h.engine} ({h.authority_tier}), "
330
            f"path: {h.path}, score: {h.score:.3f}\n{h.snippet}"
331
        )
332
    context = "\n\n".join(context_parts)
333
 
334
    llm_answer = await _try_qwen(query, context)
335
    if llm_answer:
336
        return llm_answer, receipts
337
 
338
 
339
    answer, receipts = deterministic_answer(query, hits)
340
    return answer, receipts