Architecture
The five backend layers, room lifecycle, message pipeline, and RAG/knowledge-graph pipeline.
Two apps, one wire contract (see Protocol).
apps/rooms Next.js 16 · Tailwind v4 · lib/align (framework-free socket client + store)
apps/api FastAPI + python-socketio · in-memory rooms · one asyncio task per room1 · The five layers (apps/api)
Direction is statically enforced by tests/test_layers.py — a layer may import only from
the layers below it.
| Layer | Dir | May import | Role |
|---|---|---|---|
| ontology | ontology/ | nothing (siblings only) | the business relation map — pure rules, exact strings. core (lifecycle blockers), goals/docreview (section lifecycle + the decision-bubble grammar), dials, annotations (the lexical command grammar), moderation_policy (nudge thresholds + canned copy) |
| semantic | semantic/ | siblings only | language ↔ concepts. vocabulary (norm / stem / typo-tolerance / cue banks / parse_summon), matcher (match_entities scoring + lexically_anchored, the anchor gate) |
| context | context/ | ontology, semantic, ports, config | per-interaction assembly. The LLM arrives as an injected LLMPort — never an import. interpreter (the DST pipeline), moderator (persona + due-checkers), knowledge (RAG + graph + summaries), intel (decisions/risks/actions extraction), reranker (RRF) |
| execution | execution/ | anything | transports + state. rooms (the Room dataclass + the 1 Hz tick), presenter (public_room_state = the wire shape, + the five tick handlers), sio (Socket.IO handlers), parsers/, pdf_annotator, session_archive |
| api | api/routers/ | anything | the REST surface (rooms, admin) |
| llm | llm/ | ports, config | the LLMPort implementations — openai_compat (Groq etc.), embeddings (fastembed), null (fail-soft default) |
main.py is the composition root: it binds the real ports and mounts
socketio.ASGIApp(sio, other_asgi_app=fastapi_app).
2 · Room lifecycle
setup → chat → ended, reaped 30 min after end. start_chat spawns one asyncio.Task
per room — a drift-corrected 1 s loop that, in order:
on_timer→ broadcasttimer- expiry check →
end(room, "timeout") on_check_nudges→ is a scheduled/idle nudge due? →on_moderateon_topic_tick→ lane-B summary if a section matured
Nothing in the loop is awaited against the LLM — on_moderate / on_topic_tick schedule
their own tasks so a 12 s generation can't stall the countdown.
Two clocks (clock.py): now_ms() (epoch, crosses the wire) and mono_ms() (monotonic,
all scheduling). Node conflated them under Date.now() — an NTP step would fire every nudge
at once.
3 · Message pipeline (interpreter.py)
Chat is broadcast first — the UI never waits on the LLM. Then:
@hsummon →summon_route→annotate(lexical grammar, deterministic) /scope(deterministic answer) /rag(grounded) /persona.- otherwise → Stage 1 lexical (
match_entities+ cue banks, first rule wins) →goal.reduce→ effects. Then, unless Stage 1 resolved a decisive intent, Stage 2: strict-JSON LLM NLU (single-flight per room via a plainnlu_busybool — a lock would queue, not drop) → the anchor gate: every claimed section is re-matched and must passlexically_anchoredagainst the original text, else it'srejected. Effects fire twice, by design.
4 · Moderator (moderator.py)
Triggers: kickoff (prefers a RAG briefing), time50/75/90 (deferred if the bot spoke
< 8 s ago), idle (25 → 50 → 100 s backoff, max 3, reset on human speech), progress (a
section confirmed), answer (@h). complete / timeout / stopped are always canned.
Guard rails: mod_busy drops a second trigger (never queues — a 50 % nudge can be
permanently lost); presenter.moderate_async reserves last_moderator_at before
generating; a parrot guard drops a line that echoes recent chat.
5 · RAG + knowledge graph (knowledge.py)
Upload → pdfplumber text → chunk_text (800 chars, 150 overlap, cap 80) → embed each chunk
(local ONNX, background task) + per-chunk {s,p,o} triple extraction (cap 24 chunks).
Question → condense if anaphoric (is_anaphoric) → parallel retrieval + graph lookup →
retrieval fuses dense (cosine) + lexical (keyword overlap) via RRF, with a relevance floor
(RAG_MIN_SCORE, recalibrated for bge-small — the Node 0.42 was for nomic-embed-text).
Excerpts are ordered ascending by score — best evidence last, because small models weight
the nearest material most. Graph facts already spelled out in the excerpts are dropped.
Fail-soft ladder: no embedder → keyword overlap; nothing relevant → a truthful "not in the document"; LLM down → the best excerpt quoted verbatim.
Lane B (summarize_topic) runs off the same 1 s tick on a lighter model
(LLM_SUMMARY_MODEL) with its own summary_busy guard and an 8 s per-section cooldown, so it
can never starve the moderator. It summarises from the section's own message buffer
(sec.msgs, cap 12) + that section's grounded excerpt — never global chat.
6 · Review record (pdf_annotator.py)
pdfplumber.extract_words() locates each reviewed section's heading. A reportlab overlay
draws a sticky-note card per annotation (participant colour, folded corner, soft shadow,
leader line to a colour dot on the heading) and is merged onto the original page; a matching
/Text PDF annotation is added at the far-left margin. A branded recap page (the
#0969FD → #10B6FE wordmark gradient) is appended: section status pills + the colour-chipped
annotation list. Streams are uncompressed so the record stays greppable.
6b · Decisions / Risks / Actions ledger (context/intel.py)
Off the same 1 s tick (debounced: ~every 4 messages, on a section move, on
intel:refresh, and once at session end), one extract_json pass over the
transcript + review scope returns {summary, decisions, risks, actions}.
Section-confirmation votes are folded in as source:"vote" decisions; evidence
spans are attached from the RAG index. ontology/intel.py owns the pure parts:
the id-stability merge (a card keeps its id and any manual status across re-runs),
compute_coverage (% of scope covered) and compute_score (an explainable
0–100 quality score: coverage 45 % · consensus 25 % · risk hygiene 20 % · action
hygiene 10 %). The 5-tab room UI (CHAT · DOCUMENT · DECISIONS · RISKS · ACTIONS)
renders this snapshot.
Review templates (ontology/templates.py — general / contract / prd / policy
/ investment-memo / compliance) tune three strings with no control-flow change:
the moderator persona lens, the risk-extraction focus, and a crew checklist.
7 · Fail-soft doctrine
| Component down | Behaviour |
|---|---|
| LLM | moderator → canned; NLU → lexical only; @h → verbatim excerpt |
| embedder | retrieval → keyword overlap |
| PDF text extraction | section heuristics on whatever text came back |
| the tick's moderator/summary tasks | drop, never block the countdown |
is_available() is tri-state None | True | False — None means not probed yet. Callers
test is False, never not x (a lint check enforces this).
8 · Deferred: the enterprise trust tier
config.py carries the flags (tier, trust_*) and they're all off. The egress guard,
per-room ephemeral keys, artefact manifest, and the document-blind Knowledge Resident
(a second service) are a clean seam, not implemented in v1.
9 · Single-process constraint
Rooms and the Socket.IO fan-out are in-memory. main.py refuses to start with
WEB_CONCURRENCY != 1. Horizontal scale needs AsyncRedisManager + externalised room state
— documented as the only path, out of scope.
Last updated on
