20 KiB
03 — Data Model
On-disk layout
Default root: %LOCALAPPDATA%\WhispAssist\ (user-configurable, FR-STORE-2).
%LOCALAPPDATA%\WhispAssist\
├── wa.db # SQLite index (relations, metadata, FTS)
├── settings.json # app settings (theme, provider, storage, presets)
├── models\ # downloaded models (whisper, diarization)
│ ├── whisper-base.q5.bin
│ ├── seg-pyannote-3.0.onnx
│ └── spk-eres2net.onnx
└── meetings\
└── <meeting_id>\ # one folder per meeting (uuid)
├── audio.wav # canonical recording — present ONLY if "Record" was on (ADR-0009)
├── transcript.json # canonical transcript (segments+speakers+timings)
├── manual_notes.json # raw user-authored notes captured live during recording
├── notes.md # the final notes document: manual notes + transcript, merged at finalize
├── summary.json # LLM summary, decisions, action items (if generated)
└── briefs/ # feature briefs distilled from this meeting (ADR-0011), if any
└── <brief_id>.json # agent-ready spec served via the MCP `get_feature_brief` tool
A bundle export (export_meeting / bulk_export_meetings with format: "bundle", FR-STORE-4)
copies a meeting's audio.wav, transcript.json, notes.md, and summary.json (all decrypted)
into a destination folder plus a meeting.json manifest (the MeetingBundle: title, timestamps,
duration, language/backend/model, tags, speakers, and confirmed action items). import_meeting_bundle
reconstructs each such folder under a fresh meeting id — the portable format for moving recordings
between computers.
Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On
finalize, it is kept as audio.wav if "Record this meeting" was on, or deleted if not
(FR-REC-1/4) — deletion happens only after transcript.json is finalized. transcript.json and
summary.json are derived and regenerable (regenerable only while the audio still exists —
i.e. for recorded meetings).
notes.md is generated once, at finalize, by merging manual_notes.json (freeform notes
typed live during the recording, plus any per-moment annotations — see below) with the rendered,
speaker-tagged transcript (notes::MarkdownNotes::merge). After that it is the user's own
document, freely editable via update_notes exactly like before this changed — nothing
re-renders or overwrites it afterward. In particular, renaming or merging a speaker after finalize
updates the speakers table and the live UI display, but does not retroactively rewrite text
already baked into notes.md (same as any other manual edit isn't retroactively touched either —
this was a pre-existing clobber bug this redesign also fixes: renaming a speaker used to silently
overwrite the whole file). A crash-recovery finalize (T2.8) and a post-finalize batch
re-transcription (T3.8) both re-render notes.md from scratch and so both re-read
manual_notes.json from disk to fold the same manual notes back in.
manual_notes.json
{
"schema": 1,
"freeform_md": "string — the user's running notes, typed live in the Notes pane while recording",
"segment_notes": [
{ "anchor_ms": 12345, "text": "string", "created_at": 1735000000, "updated_at": 1735000010 }
]
}
segment_notes[].anchor_ms is a timestamp into the recording (a clicked transcript segment's
start_ms), not a segment id — a later batch re-transcription can renumber/regenerate segment
ids, but never moves the moment in time a note was attached to. At merge time, each note is placed
right after whichever transcript paragraph's time span contains its anchor_ms; a note whose
anchor doesn't land inside any paragraph surfaces under an "Other notes" section instead of being
silently dropped. Written to disk on every edit via update_live_notes/set_segment_note
(04-api-contracts.md) — live-session only, same write-through-for-crash-safety spirit as
transcript.json accumulating during recording.
SQLite schema (wa.db)
-- A meeting is the top-level record.
CREATE TABLE meetings (
id TEXT PRIMARY KEY, -- uuid v4
title TEXT NOT NULL DEFAULT 'Untitled meeting',
started_at INTEGER NOT NULL, -- unix epoch seconds
ended_at INTEGER, -- null while recording
duration_secs INTEGER, -- finalized on stop
folder_path TEXT NOT NULL, -- absolute path to meeting folder
audio_path TEXT, -- audio.wav; NULL if not retained (ADR-0009)
recorded INTEGER NOT NULL DEFAULT 0, -- 1 = audio retained as .wav, 0 = transcript-only
status TEXT NOT NULL, -- recording|transcribing|ready|recovering|error
language TEXT, -- detected/selected language code
backend_used TEXT, -- npu|nvidia|amd|intel|cpu
model_used TEXT, -- e.g. whisper-base
calendar_event_id TEXT, -- FK -> calendar_events.id (nullable)
template_id TEXT, -- note-template id (T8.1, FR-NOTE-5); catalog is
-- a built-in Rust list (notes::templates), not a table
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Internal speakers detected per meeting (S1, S2, …) and their assigned names.
CREATE TABLE speakers (
id TEXT PRIMARY KEY, -- uuid
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
label TEXT NOT NULL, -- 'S1','S2',… (internal, stable per meeting)
display_name TEXT, -- user/participant name (nullable)
participant_id TEXT REFERENCES participants(id), -- if mapped to a calendar attendee
color TEXT, -- UI color hint
merged_into TEXT, -- non-null: this label folds into another label's
-- row at render/export time (T4.5, FR-SPK-3); segment
-- speaker IDs in storage are never rewritten (FR-SPK-5)
UNIQUE(meeting_id, label)
);
-- People known from calendar/.pst; reused across meetings for continuity.
CREATE TABLE participants (
id TEXT PRIMARY KEY, -- uuid
name TEXT NOT NULL,
email TEXT, -- nullable
UNIQUE(name, email)
);
CREATE TABLE meeting_participants ( -- attendee list per meeting
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
participant_id TEXT NOT NULL REFERENCES participants(id),
role TEXT, -- organizer|required|optional
PRIMARY KEY (meeting_id, participant_id)
);
-- Calendar events imported from .pst (or future sources).
CREATE TABLE calendar_events (
id TEXT PRIMARY KEY, -- uuid (stable from source uid if available)
source TEXT NOT NULL, -- pst|graph|ics
subject TEXT,
organizer TEXT,
starts_at INTEGER,
ends_at INTEGER,
description TEXT,
raw_uid TEXT -- source's own id for dedup
);
-- Re-importing the same event (matched by source+raw_uid) updates it in place (T6.2).
CREATE UNIQUE INDEX idx_calendar_events_source_uid ON calendar_events(source, raw_uid)
WHERE raw_uid IS NOT NULL;
-- Attendees per imported calendar event, populated at import time (T6.1) —
-- distinct from meeting_participants, which links a *recording* to people
-- once a meeting is attached to an event (T6.3/T6.6).
CREATE TABLE calendar_event_participants (
calendar_event_id TEXT NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
participant_id TEXT NOT NULL REFERENCES participants(id),
role TEXT, -- organizer|required|optional
PRIMARY KEY (calendar_event_id, participant_id)
);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE meeting_tags (
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (meeting_id, tag_id)
);
-- Action items parsed from LLM output, then user-confirmed.
CREATE TABLE action_items (
id TEXT PRIMARY KEY,
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
text TEXT NOT NULL,
owner TEXT, -- assignee name (nullable)
due_at INTEGER, -- nullable
confirmed INTEGER NOT NULL DEFAULT 0, -- 0=suggested, 1=confirmed by user
reminder_set INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Feature briefs distilled from a meeting for coding-agent handoff (ADR-0011). The JSON body
-- lives in the meeting's briefs/ folder; this table indexes it for the MCP tools.
CREATE TABLE feature_briefs (
id TEXT PRIMARY KEY, -- uuid
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
title TEXT NOT NULL,
target_repo TEXT, -- optional repo hint for the agent
path TEXT NOT NULL, -- briefs/<id>.json
exposed INTEGER NOT NULL DEFAULT 0, -- visible to the MCP server? (scope control, FR-MCP-3)
created_at INTEGER NOT NULL
);
-- Audit of what an MCP client (agent) read (FR-MCP-5).
CREATE TABLE mcp_access_log (
id TEXT PRIMARY KEY,
at INTEGER NOT NULL,
tool TEXT NOT NULL, -- e.g. get_feature_brief
meeting_id TEXT, -- subject, if any
client TEXT -- client-reported name, if provided
);
-- Configured sync/upload destinations (ADR-0010). Credentials are NOT stored here —
-- only a reference into the OS credential store (FR-SYNC-6).
CREATE TABLE sync_targets (
id TEXT PRIMARY KEY, -- uuid
name TEXT NOT NULL, -- user label, e.g. "Home Nextcloud"
kind TEXT NOT NULL, -- webdav|onedrive|dropbox|box
provider_hint TEXT, -- nextcloud|owncloud|cloudreve|seafile|synology|generic
base_url TEXT, -- WebDAV URL (kind=webdav)
remote_base_path TEXT NOT NULL DEFAULT '/WhispAssist',
username TEXT, -- WebDAV username (secret is in credential store)
credential_ref TEXT NOT NULL, -- key into OS credential store
enabled INTEGER NOT NULL DEFAULT 0, -- off by default (FR-SYNC-1)
upload_transcript INTEGER NOT NULL DEFAULT 1,
upload_notes INTEGER NOT NULL DEFAULT 1,
upload_summary INTEGER NOT NULL DEFAULT 1,
upload_recording INTEGER NOT NULL DEFAULT 0, -- only meaningful if a meeting is recorded
trigger_on_finalize INTEGER NOT NULL DEFAULT 1,
allow_plaintext_lan INTEGER NOT NULL DEFAULT 0, -- FR-SYNC-7
encrypt_before_upload INTEGER NOT NULL DEFAULT 0, -- FR-SYNC-10
created_at INTEGER NOT NULL
);
-- One upload job per (artifact, target). The durable queue (FR-SYNC-5).
CREATE TABLE sync_jobs (
id TEXT PRIMARY KEY, -- uuid
target_id TEXT NOT NULL REFERENCES sync_targets(id) ON DELETE CASCADE,
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
artifact TEXT NOT NULL, -- transcript|notes|summary|recording
local_path TEXT NOT NULL,
remote_path TEXT NOT NULL,
sha256 TEXT, -- skip-if-unchanged idempotency
status TEXT NOT NULL, -- pending|uploading|done|failed|skipped
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_attempt_at INTEGER, -- backoff schedule
bytes_total INTEGER,
bytes_sent INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
UNIQUE(target_id, meeting_id, artifact)
);
-- Full-text search over transcript + notes (FR-SEARCH-1).
CREATE VIRTUAL TABLE meeting_fts USING fts5(
meeting_id UNINDEXED,
title,
transcript_text,
notes_text,
tokenize = 'porter unicode61'
);
CREATE INDEX idx_meetings_started ON meetings(started_at DESC);
CREATE INDEX idx_speakers_meeting ON speakers(meeting_id);
CREATE INDEX idx_action_meeting ON action_items(meeting_id);
CREATE INDEX idx_syncjobs_status ON sync_jobs(status, next_attempt_at);
CREATE INDEX idx_briefs_meeting ON feature_briefs(meeting_id);
Schema migrations are versioned (e.g. sqlx::migrate!), each migration numbered and forward-only.
transcript.json (canonical transcript)
{
"schema": 1,
"meeting_id": "f1c2…",
"language": "en",
"model": "whisper-base",
"backend": "nvidia",
"segments": [
{
"id": 0,
"start_ms": 1240, // offset from recording start
"end_ms": 4880,
"speaker": "S1", // internal speaker id; name resolved at render time
"text": "Let's start with the roadmap.",
"confidence": 0.91, // optional
"interim": false, // true while streaming, false once finalized
},
],
"speakers": [
{ "label": "S1", "display_name": "Alex" },
{ "label": "S2", "display_name": null },
],
}
Names are stored as a mapping and applied at render/export — segments keep the internal speaker
label so re-diarization or renaming never requires rewriting every segment (FR-SPK-5).
summary.json
{
"schema": 1,
"generated_at": 1751299200,
"provider": "ollama",
"model": "llama3",
"summary_md": "## Summary\n…",
"decisions": ["Adopt Tauri for the shell"],
"action_items": [
// Same shape as the `action_items` table row (ActionItem) minus the row
// never having existed yet: `id`/`due_at` are null until the user
// reviews and confirms a drafted item (FR-LLM-3), at which point
// `confirm_action_items` creates the real row.
{
"id": null,
"text": "Send the API contract draft",
"owner": "Jordan",
"due_at": null,
"confirmed": false,
},
],
}
briefs/<brief_id>.json (feature brief — ADR-0011)
Agent-ready spec the MCP get_feature_brief tool returns. Designed to drop straight into a coding
agent's context. Written by create_feature_brief (M1); one file per brief under the meeting's
briefs/ folder, sealed at rest with the vault when unlocked (T8.8), exactly like
summary.json. The feature_briefs table indexes it (id, meeting_id, title, target_repo, path,
exposed) for list_feature_briefs and the MCP scope check — the file is the source of truth; the
row is the index. The IPC FeatureBrief type (04-api-contracts.md) is the subset returned to the
UI / MCP: everything below except the schema/provenance envelope (generated_at, provider,
model, source).
{
"schema": 1,
"id": "b7a1…",
"meeting_id": "f1c2…",
"generated_at": 1751299200, // envelope: which model distilled this, and when
"provider": "ollama",
"model": "llama3",
"title": "Bulk CSV export for the reporting view",
"problem": "Customer can't get their data out for offline analysis.",
"desired_outcome": "One-click CSV export of the current filtered report.",
"acceptance_criteria": [
"Export button on the report toolbar",
"Respects active filters and column order",
"Streams large exports without blocking the UI",
],
"target_repo": "acme/reporting-web", // optional hint the user supplies at create time
"context_excerpts": [
// verbatim transcript quotes that ground the request — NOT model paraphrase;
// the builder selects them from the real transcript (speaker = resolved display name)
{ "speaker": "Customer", "text": "We really need to pull this into our own spreadsheets." },
],
"source": { "meeting_title": "Acme quarterly sync", "at": 1751299200 },
}
Field presence: title, problem, desired_outcome are always strings (the builder falls back to
the meeting title / "" on a sparse model reply); acceptance_criteria and context_excerpts may
be empty arrays. Every context_excerpts[].text is a verbatim substring of a real transcript
segment (the M1 grounding invariant, asserted by the golden-transcript test).
settings.json
{
"theme": "system", // system|light|dark
"storage_root": "%LOCALAPPDATA%\\WhispAssist",
"retention": { "max_age_days": 90, "max_size_gb": 10 },
"transcription": { "model": "base", "language": "auto", "batch_reprocess": false },
"hardware": { "preferred_backend": "auto", "low_overhead": false },
"recording": {
"default_record": false, // "Record this meeting" default — OFF (FR-REC-1)
"consent_acknowledged": false, // set true after the one-time consent notice (FR-REC-2)
},
"llm": {
"provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011; "openai" not yet wired)
"endpoint": "http://localhost:11434",
"model": "llama3",
"stream": true,
// API keys for hosted providers (anthropic|openai) live in the OS credential store, not here.
"hosted_ai_acknowledged": false, // one-time "data leaves your device" notice ack (T10.3, ADR-0011)
},
// Sync target rows live in wa.db (sync_targets); secrets live in the OS credential store.
// settings.json only holds the global default. No credentials here (FR-SYNC-6).
"sync": { "enabled": false }, // master off switch — OFF by default (FR-SYNC-1)
// Local MCP server (ADR-0011). OFF by default; inbound loopback only; token in credential store.
"mcp": {
"enabled": false, // FR-MCP-1
"transport": "http", // http (127.0.0.1/mcp) | stdio
"port": 4849,
"expose": "selected", // none | selected | all (scope control, FR-MCP-3)
"expose_recordings": false, // never serve .wav unless explicitly true (FR-MCP-3)
},
"privacy": { "encrypt_at_rest": false },
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6). Opt-in, explicit consent via OAuth
// PKCE — OFF by default. `credential_ref` points into the OS credential store; the token itself
// is never written here (same invariant as sync credentials, FR-SYNC-6).
"calendar": { "graph_enabled": false, "graph_credential_ref": null },
}
Retention & recovery semantics
- Retention (FR-STORE-2): a background job deletes whole meeting folders + rows once a meeting
exceeds the age/size policy, oldest first; never runs while a meeting is
recording/transcribing. - Recovery (FR-REL-1): on startup, any meeting whose working WAV still exists but whose
transcript.jsonis missing/partial is markedrecovering; the user can re-run transcription from the audio. (A cleanly finalized non-recorded meeting has no audio and needs no recovery; an interrupted one still has its working WAV, so it is always recoverable.) - Sync (FR-SYNC-5): on startup and after each finalize, the
syncservice scanssync_jobsforpending/failedjobs whosenext_attempt_athas passed and resumes them with backoff. Deleting a meeting cascades to itssync_jobs(local rows only; already-uploaded remote copies are left to the user/server).