Docs · Meeting analysis

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:

  1. Browser capture. The web app uses MediaRecorder with a 5-second timeslice. 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.
  2. Chunk upload. Each slice is posted to POST /api/meeting/save-chunk as 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}.webm and the partial transcript to transcript_{idx}.json. The chunk store is what the post-session phase will later reconstruct the full session from.
  3. 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 with diarize=true when 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.
  4. 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"
    }
  5. Client-side dedup. Live detection can fire the same conflict more than once as the window slides. The browser keeps a seenSentences Set keyed on a normalized hash of statement + 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.
  6. 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-contradiction with 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:

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:

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.