How the pipeline reads a meeting.
Two phases. Eleven stages. One signed report.
Two phases
A Felarity meeting runs in two clearly separated phases. The live phase is what your participants and operators see in the room: rolling transcription, speaker turns, and intel cards that appear as conflicts and patterns are detected. The post-session phase begins the moment someone clicks Stop & Save. That is where the heavier forensic work happens — full-session diarization, acoustic measurement, NLI re-scoring, topology analysis, council re-read with speaker context, and finally an Ed25519-signed attestation chain. The live phase is biased toward latency. The post-session phase is biased toward defensibility. They share storage but they do not share assumptions.
Both phases stream their progress over Server-Sent Events. Nothing about a Felarity meeting is opaque to the operator — every stage emits a typed event with a timestamp, and every event ends up in the final report under pipeline_trace.
Live phase
The live phase runs while the meeting is in progress. It is designed to feel like a smart notepad that keeps up with the room and surfaces conflicts as they happen, without ever blocking the conversation. The stages, in order:
- Browser capture. The web app uses
MediaRecorderwith a 5-secondtimeslice. Each slice is a self-contained WebM blob with the original init segment prepended so it can be transcoded and decoded independently of the slices around it. Slices are queued client-side and uploaded in order; a dropped or retried upload never corrupts the slice that follows. - Chunk upload. Each slice is posted to
POST /api/meeting/save-chunkas multipart form data. The request carries the audio blob, the partial transcript the browser already has, the session ID, the speaker hint if any, and the monotonic chunk index. The server writes the chunk to/tmp/felarity_sessions/{session_id}/chunk_{idx}.webmand the partial transcript totranscript_{idx}.json. The chunk store is what the post-session phase will later reconstruct the full session from. - Transcription (Pro and Enterprise). If the workspace is on Pro or Enterprise, the chunk is forwarded to
POST /api/meeting/transcribe, which calls Whisper large-v3 on a dedicated GPU. Diarization is requested withdiarize=truewhen the workspace allows it. Free workspaces use the browser's built-in speech APIs and skip this step; Felarity will still analyze the resulting transcript, but the audio is never sent to Whisper. - Live contradiction stream. Once a rolling buffer of finalized text reaches the configured window, the client opens an SSE connection to
POST /api/meeting/analyze. The server runs a fast council pass against the current transcript window and emits one event per intel card. Each card carries the fields below — these are the contract the front-end renders against:event: contradiction data: { "detection_id": "ctr_01HX9Q...", "session_id": "ses_01HX9P...", "statement": "We never signed an NDA with Mercato.", "conflicting_statement": "I emailed the Mercato NDA on March 4th.", "speakers": ["spk_0", "spk_1"], "confidence": 0.86, "detection_ts": "2026-06-07T14:21:08.412Z", "window_start_ts": "2026-06-07T14:18:50.000Z", "stage": "live" } - Client-side dedup. Live detection can fire the same conflict more than once as the window slides. The browser keeps a
seenSentencesSetkeyed on a normalized hash ofstatement+conflicting_statement; cards already in the set are dropped before they hit the UI. Init-segment leading repeats from the WebM container are also stripped before the dedup key is computed, so a rebroadcast of the same opening phrase does not surface as a contradiction with itself. - Operator-saved contradictions. If an operator promotes a live card to the record (or flags one the model missed), the client calls
POST /api/meeting/save-contradictionwith the card payload. Saved contradictions are persisted alongside the audio and become first-class inputs to the post-session phase rather than ephemeral UI state.
The live phase intentionally does not attempt attribution beyond the diarizer's rolling guess. Binding a sentence to a person is a post-session decision; doing it under time pressure is how you get the wrong name next to a quote.
Post-session phase
The post-session phase is triggered by POST /api/meeting/stop, which opens an SSE channel and runs the eleven stages below in order. Each stage emits at least one progress event with a stage name and a structured output. The final post_complete event carries the report ID, which is then retrievable via GET /api/reports/<id>/{json,markdown,pdf}.
| Stage | What it does | Output schema (abridged) |
|---|---|---|
post_concat |
Concatenates all chunk_*.webm slices into a single continuous audio file, validating container headers and dropping any slice that fails decode. |
{ duration_s, sample_rate, chunks_used, chunks_dropped } |
post_diarize |
Runs pyannote/speaker-diarization-3.1 on the concatenated audio. Replaces the live phase's rolling guesses with stable speaker labels for the whole session. |
{ segments: [{ start_s, end_s, speaker_id }], num_speakers } |
post_acoustic |
Measures per-speaker speech rate (wpm), pause ratio, pitch range, jitter, shimmer, and stress markers (filled pauses, breath events). | { speaker_id: { wpm, pause_ratio, pitch_hz_p10_p90, jitter, shimmer, stress_index } } |
post_confrontation |
Classifies each turn that immediately follows a direct question or accusation into direct_answer, deflection, partial, non_responsive. |
{ events: [{ turn_id, speaker_id, prompt_speaker_id, class, confidence }] } |
post_samples |
Extracts a representative 4-second WAV sample per speaker for operator listen-back; served from GET /api/meeting/sample/{sid}/{spk}. |
{ speaker_id: { sample_url, sample_start_s, sample_duration_s } } |
post_attribute |
Binds each live and saved contradiction to a stable speaker ID using the post-session diarization, not the live guess. Live attributions that disagree with the post-session result are flagged. | { contradictions: [{ detection_id, statement_speaker, conflict_speaker, attribution_revised }] } |
post_nli |
Re-scores every detected contradiction pair with DeBERTa-v3 NLI on CPU (~200 ms per pair). Pairs below the configured entailment threshold are demoted to unverified in the final report. |
{ scores: [{ detection_id, label, entailment, contradiction, neutral }] } |
post_topology |
Builds a NetworkX graph of speakers and contradictions, then classifies the pattern — isolated, cluster, chain, star, cycle — and computes per-speaker centrality. |
{ graph: { nodes, edges }, pattern, centrality: { speaker_id: score } } |
post_council |
Runs the deep council (27B-class analysts) over the full transcript with speaker labels, acoustic markers, and topology context attached. Produces the narrative section of the report. | { analysts: [{ role, summary, findings }], synthesis } |
post_attest |
Builds the 8-node SHA-256 Merkle attestation chain (audio capture → transcription → contradiction detection (pre-attribution) → diarization → attribution binding → acoustic analysis → topology → final report) and signs the root with the workspace's Ed25519 key. | { nodes: [{ index, name, sha256 }], merkle_root, signature, public_key_fingerprint } |
post_complete |
Writes the final JSON, Markdown, and PDF artifacts, registers them with the report store, and emits the terminal event. | { report_id, ready: true, artifacts: ["json","markdown","pdf","attestation"] } |
Stage order is fixed by design. Attribution must happen after post-session diarization, NLI must happen after attribution, and the council re-read must happen after NLI so that the analysts never see a contradiction the NLI layer already demoted. Reordering these stages would invalidate the attestation contract.
Outputs
Every completed session produces four artifacts, all retrievable by report ID:
- JSON —
GET /api/reports/<id>/json. The full structured record: transcript, speaker stats, contradictions with NLI scores, topology, acoustic features, council summary, and the attestation chain. This is the canonical form; the others are derived from it. - Markdown —
GET /api/reports/<id>/markdown. Human-readable, paste-into-a-ticket form. Useful for incident response and counsel hand-off. - PDF —
GET /api/reports/<id>/pdf. Print-ready with the attestation chain and signature on the last page. - Attestation chain JSON — embedded in the report and independently verifiable against
POST /api/verify. The public key is atGET /api/verify/public-keyand mirrored at/.well-known/felarity-signing-key.pem.
Report contents
Below are the shapes a report exposes. Field names are stable across versions; new fields are additive.
contradictions[]
{
"detection_id": "ctr_01HX9Q...",
"statement": "We never signed an NDA with Mercato.",
"conflicting_statement": "I emailed the Mercato NDA on March 4th.",
"statement_speaker": "spk_0",
"conflict_speaker": "spk_1",
"first_seen_ts": "2026-06-07T14:21:08.412Z",
"nli": { "label": "contradiction", "entailment": 0.02, "contradiction": 0.91, "neutral": 0.07 },
"council_finding_id": "fnd_01HX9R...",
"attribution_revised": false,
"stage_first_detected": "live"
}
speaker_stats
{
"spk_0": {
"display_name": null,
"total_speak_s": 612.4,
"turn_count": 47,
"wpm": 138,
"pause_ratio": 0.18,
"confrontation_responses": { "direct_answer": 6, "deflection": 3, "partial": 2, "non_responsive": 1 },
"centrality": 0.42
}
}
topology
{
"pattern": "star",
"nodes": [{ "id": "spk_0", "degree": 4 }, { "id": "spk_1", "degree": 1 }],
"edges": [{ "source": "spk_0", "target": "spk_1", "weight": 2, "detection_ids": ["ctr_01HX9Q...", "ctr_01HX9Q..."] }],
"summary": "One speaker is the center of all detected conflicts; not a mutual dispute."
}
acoustic_features
{
"spk_0": {
"pitch_hz_p10_p90": [98, 167],
"jitter": 0.0042,
"shimmer": 0.038,
"stress_index": 0.31,
"filled_pauses_per_min": 4.1,
"breath_events_per_min": 7.3
}
}
council_summary
{
"analysts": [
{ "role": "legal_risk", "summary": "...", "findings": ["fnd_01HX9R..."] },
{ "role": "behavioral_pattern", "summary": "...", "findings": [] },
{ "role": "operational_truth", "summary": "...", "findings": ["fnd_01HX9S..."] },
{ "role": "narrative_coherence", "summary": "...", "findings": [] }
],
"synthesis": "Two contradictions survived NLI re-scoring. Both bind to spk_0. Confrontation pattern is consistent with deflection under direct questioning."
}
attestation
{
"nodes": [
{ "index": 1, "name": "audio_capture", "sha256": "..." },
{ "index": 2, "name": "transcription", "sha256": "..." },
{ "index": 3, "name": "contradiction_detection_pre_attribution", "sha256": "..." },
{ "index": 4, "name": "diarization", "sha256": "..." },
{ "index": 5, "name": "attribution_binding", "sha256": "..." },
{ "index": 6, "name": "acoustic_analysis", "sha256": "..." },
{ "index": 7, "name": "topology", "sha256": "..." },
{ "index": 8, "name": "final_report", "sha256": "..." }
],
"merkle_root": "9f3c...e1",
"signature": "ed25519:...",
"public_key_fingerprint": "SHA256:7Hh+..."
}
Where analysis runs
By default, every stage of the meeting pipeline runs on Felarity-operated infrastructure. Whisper, pyannote, DeBERTa-v3, the council models, the NetworkX topology, and the Ed25519 signing all execute inside our boundary. PHI-eligible workspaces never route to third-party LLMs. If your workspace is flagged for HIPAA handling, the gateway refuses to forward any transcript content to an external model provider, full stop — including for the council stage.
Enterprise customers can opt into BYOK for the council stage only. In that configuration, your Anthropic or OpenAI API key is held in our secret store, used exclusively for your workspace, and never crosses into another tenant. Live and post-session retrieval, NLI, diarization, acoustic analysis, topology, and attestation always remain on Felarity infrastructure regardless of BYOK selection. The attestation chain records which provider produced the council narrative so the report is self-describing.
Boundary, in one sentence
Audio, transcripts, and PHI never leave Felarity infrastructure unless an Enterprise admin has explicitly enabled BYOK for the council stage, and even then only the council prompt crosses the boundary — never the raw audio, never the diarization, never the attestation inputs.
Limits
The pipeline has soft limits to keep latency predictable and hard limits to keep the attestation chain auditable:
- Chunk size. Live chunks are 5 seconds.
/api/meeting/save-chunkaccepts up to 20 MB per request; in practice a 5-second WebM Opus chunk is well under 200 KB. - Session length per tier. Free workspaces are capped at 30 minutes per session. Pro is capped at 4 hours. Enterprise has no hard cap but the pipeline will recursively re-chunk any session longer than 4 hours — the audio is split into overlapping 3-hour windows for the heavy stages (NLI, council), then stitched back into a single report with a single attestation root. Topology and acoustic stages always run over the full session.
- Long-session behavior. When recursive re-chunking is used, each window's council pass is included in the report under
council_summary.windows[]and the synthesis runs over the concatenated findings. The attestation chain treats the stitched outputs as the canonical artifact for node 8. - Live SSE timeouts.
/api/meeting/analyzeis capped at 300 seconds per connection; the client reconnects with the current window position./api/meeting/stopis capped at 600 seconds per connection because the post-session pipeline is long-running by nature; if the connection drops, progress can be resumed by re-requesting the same session ID. - Operator overrides. An operator can mark a contradiction as
not_a_contradictionat any time beforepost_completefires; the NLI re-score will skip it and the attestation chain will record the override.
If you want to read the corresponding code paths, start at /ai_felarity/core/post_session.py for the orchestration, /ai_felarity/core/nli.py for the DeBERTa-v3 layer, /ai_felarity/core/topology.py for the NetworkX pattern classifier, and /ai_felarity/core/attestation.py for the 8-node Merkle chain. The route handlers live in /ai_felarity/routes/chat.py.