Docs

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 room

1 · The five layers (apps/api)

Direction is statically enforced by tests/test_layers.py — a layer may import only from the layers below it.

LayerDirMay importRole
ontologyontology/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)
semanticsemantic/siblings onlylanguage ↔ concepts. vocabulary (norm / stem / typo-tolerance / cue banks / parse_summon), matcher (match_entities scoring + lexically_anchored, the anchor gate)
contextcontext/ontology, semantic, ports, configper-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)
executionexecution/anythingtransports + 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
apiapi/routers/anythingthe REST surface (rooms, admin)
llmllm/ports, configthe 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:

  1. on_timer → broadcast timer
  2. expiry check → end(room, "timeout")
  3. on_check_nudges → is a scheduled/idle nudge due? → on_moderate
  4. on_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:

  • @h summon → 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 plain nlu_busy bool — a lock would queue, not drop) → the anchor gate: every claimed section is re-matched and must pass lexically_anchored against the original text, else it's rejected. 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 downBehaviour
LLMmoderator → canned; NLU → lexical only; @h → verbatim excerpt
embedderretrieval → keyword overlap
PDF text extractionsection heuristics on whatever text came back
the tick's moderator/summary tasksdrop, 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

On this page