| 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 |