17 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)
├── notes.md # user-editable Markdown notes
├── 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
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,
notes.md, and summary.json are derived and regenerable (regenerable only while the audio
still exists — i.e. for recorded meetings).
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).