| 1 | """Darwin Cortex Gateway - FastAPI app. |
| 2 | |
| 3 | Port :9890 (HTTP API). MCP SSE shim on :9891 via mcp_server.py + mcp-proxy. |
| 4 | |
| 5 | Start: |
| 6 | uvicorn main:app --host 0.0.0.0 --port 9890 |
| 7 | """ |
| 8 | from __future__ import annotations |
| 9 | |
| 10 | import asyncio |
| 11 | import sys |
| 12 | import os |
| 13 | import time |
| 14 | from datetime import datetime, timezone |
| 15 | from typing import Optional |
| 16 | |
| 17 | from fastapi import FastAPI |
| 18 | from fastapi.middleware.cors import CORSMiddleware |
| 19 | |
| 20 | sys.path.insert(0, os.path.dirname(__file__)) |
| 21 | |
| 22 | from models import AskRequest, AskResponse, EngineStatus, HealthResponse, Hit, Receipt |
| 23 | from ranking import rank_hits, detect_conflicts |
| 24 | from synthesis import synthesize, warmup as synth_warmup |
| 25 | import state as live_state |
| 26 | from adapters.rag import RagAdapter |
| 27 | from adapters.cbm import CbmAdapter |
| 28 | from adapters.serena import SerenaAdapter |
| 29 | from adapters.memgraph import MemgraphAdapter |
| 30 | from adapters.inbox import InboxAdapter |
| 31 | from adapters.sources import source_statuses |
| 32 | |
| 33 | app = FastAPI(title="Darwin Cortex", version="1.0.0") |
| 34 | |
| 35 | app.add_middleware( |
| 36 | CORSMiddleware, |
| 37 | allow_origins=["*"], |
| 38 | allow_methods=["*"], |
| 39 | allow_headers=["*"], |
| 40 | ) |
| 41 | |
| 42 | ALL_ADAPTERS = { |
| 43 | "rag": RagAdapter(), |
| 44 | "cbm": CbmAdapter(), |
| 45 | "serena": SerenaAdapter(), |
| 46 | "memgraph": MemgraphAdapter(), |
| 47 | "inbox": InboxAdapter(), |
| 48 | } |
| 49 | |
| 50 | |
| 51 | @app.on_event("startup") |
| 52 | async def _startup() -> None: |
| 53 | """Best-effort: warm the LLM synthesizer onto the GPU so the first real |
| 54 | /ask hits a resident model instead of a cold load.""" |
| 55 | asyncio.create_task(synth_warmup()) |
| 56 | |
| 57 | |
| 58 | @app.get("/health", response_model=HealthResponse) |
| 59 | async def health() -> HealthResponse: |
| 60 | """Whole-organism liveness: the 5 query engines PLUS the real ingestion |
| 61 | data-sources (proxmox/white-pc/3060/browser-history/agents) with live |
| 62 | per-source freshness from the homelab-rag manifest. Same EngineStatus |
| 63 | schema for every entry; data-source entries are additive and never replace |
| 64 | the engine entries, so the response stays backward-compatible.""" |
| 65 | async def probe(key: str, adapter) -> EngineStatus: |
| 66 | _, status = await adapter.query("ping", k=1) |
| 67 | return status |
| 68 | |
| 69 | engine_task = asyncio.gather(*(probe(k, v) for k, v in ALL_ADAPTERS.items())) |
| 70 | sources_task = source_statuses() |
| 71 | engine_statuses, data_sources = await asyncio.gather( |
| 72 | engine_task, sources_task, return_exceptions=True, |
| 73 | ) |
| 74 | |
| 75 | if isinstance(engine_statuses, Exception): |
| 76 | engine_statuses = [] |
| 77 | if isinstance(data_sources, Exception): |
| 78 | data_sources = [] |
| 79 | |
| 80 | all_statuses = list(engine_statuses) + list(data_sources) |
| 81 | ok = any(s.status == "up" for s in all_statuses) |
| 82 | return HealthResponse(engines=all_statuses, ok=ok) |
| 83 | |
| 84 | |
| 85 | @app.post("/ask", response_model=AskResponse) |
| 86 | async def ask(req: AskRequest) -> AskResponse: |
| 87 | """Fan out to all engines, reconcile, synthesize.""" |
| 88 | t0 = time.monotonic() |
| 89 | |
| 90 | scope = req.scope or list(ALL_ADAPTERS.keys()) |
| 91 | adapters = {k: v for k, v in ALL_ADAPTERS.items() if k in scope} |
| 92 | |
| 93 | state_first = live_state.is_state_query(req.query) |
| 94 | snapshot_task = asyncio.create_task(live_state.get_snapshot()) |
| 95 | |
| 96 | async def run_adapter(key: str, adapter): |
| 97 | return await adapter.query(req.query, k=req.k) |
| 98 | |
| 99 | tasks = {k: asyncio.create_task(run_adapter(k, v)) for k, v in adapters.items()} |
| 100 | results = await asyncio.gather(*tasks.values(), return_exceptions=True) |
| 101 | |
| 102 | try: |
| 103 | snapshot = await snapshot_task |
| 104 | state_block = live_state.render_block(snapshot) |
| 105 | except Exception: |
| 106 | snapshot, state_block = None, None |
| 107 | |
| 108 | all_hits: list[Hit] = [] |
| 109 | engine_statuses: list[EngineStatus] = [] |
| 110 | |
| 111 | for (key, _), result in zip(tasks.items(), results): |
| 112 | if isinstance(result, Exception): |
| 113 | engine_statuses.append(EngineStatus(engine=key, status="down")) |
| 114 | continue |
| 115 | hits, status = result |
| 116 | all_hits.extend(hits) |
| 117 | engine_statuses.append(status) |
| 118 | |
| 119 | ranked = rank_hits(all_hits, k=req.k * len(adapters)) |
| 120 | conflicts = detect_conflicts(ranked) |
| 121 | |
| 122 | answer: Optional[str] = None |
| 123 | receipts = [] |
| 124 | if req.mode in ("chat", "both"): |
| 125 | answer, receipts = await synthesize( |
| 126 | req.query, |
| 127 | ranked[:req.k], |
| 128 | req.synthesize, |
| 129 | live_state=state_block, |
| 130 | state_first=state_first and state_block is not None, |
| 131 | ) |
| 132 | if state_first and state_block: |
| 133 | receipts = [Receipt( |
| 134 | id="r0", |
| 135 | source="live-state", |
| 136 | path="cortex:/health + deck + darwin-term", |
| 137 | last_seen=datetime.now(timezone.utc), |
| 138 | confidence=1.0, |
| 139 | snippet=state_block[:200], |
| 140 | )] + list(receipts) |
| 141 | |
| 142 | took_ms = int((time.monotonic() - t0) * 1000) |
| 143 | |
| 144 | return AskResponse( |
| 145 | answer=answer, |
| 146 | receipts=receipts, |
| 147 | hits=ranked[:req.k], |
| 148 | conflicts=conflicts, |
| 149 | engines=engine_statuses, |
| 150 | took_ms=took_ms, |
| 151 | ) |
| 152 | |
| 153 | |
| 154 | @app.get("/") |
| 155 | async def root(): |
| 156 | return {"service": "darwin-cortex", "version": "1.0.0", "docs": "/docs"} |