+ |
{new Date(job.created_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
-
+
{job.meta?.conversation_id ? job.meta.conversation_id.substring(0, 8) : '—'}
|
-
-
+
+
{job.job_id}
|
-
- {getJobTypeShort(job.job_type)}
+ |
+
+ {getJobTypeShort(job.job_type)}
+
|
-
+
{getStatusIcon(job.status)}
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
-
+
|
- |
-
+
{job.status === 'failed' && (
-
+
)}
-
+
{(job.status === 'queued' || job.status === 'started') && (
-
+
)}
{job.status === 'finished' && (
-
+
)}
|
@@ -2346,29 +2292,21 @@ const Queue: React.FC = () => {
{/* Pagination */}
{pagination.total > pagination.limit && (
-
-
+
+
Showing {pagination.offset + 1} to {Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} results
-
)}
-
+
{/* Old Jobs Table and Pagination - Removed in favor of session-based view above */}
{/* Job Details Modal */}
@@ -2393,46 +2331,46 @@ const Queue: React.FC = () => {
-
- {selectedJob.job_id}
+
+ {selectedJob.job_id}
-
-
+
+
{getStatusIcon(selectedJob.status)}
{selectedJob.status.charAt(0).toUpperCase() + selectedJob.status.slice(1)}
-
+
{selectedJob.description && (
-
- {selectedJob.description}
+
+ {selectedJob.description}
)}
{selectedJob.func_name && (
-
- {selectedJob.func_name}
+
+ {selectedJob.func_name}
)}
-
- {selectedJob.created_at ? formatDate(selectedJob.created_at) : '-'}
+
+ {selectedJob.created_at ? formatDate(selectedJob.created_at) : '-'}
-
- {selectedJob.started_at ? formatDate(selectedJob.started_at) : '-'}
+
+ {selectedJob.started_at ? formatDate(selectedJob.started_at) : '-'}
-
- {selectedJob.ended_at ? formatDate(selectedJob.ended_at) : '-'}
+
+ {selectedJob.ended_at ? formatDate(selectedJob.ended_at) : '-'}
{selectedJob.args && selectedJob.args.length > 0 && (
-
-
+
+
{JSON.stringify(selectedJob.args, null, 2)}
@@ -2440,8 +2378,8 @@ const Queue: React.FC = () => {
{selectedJob.kwargs && Object.keys(selectedJob.kwargs).length > 0 && (
-
-
+
+
{JSON.stringify(selectedJob.kwargs, null, 2)}
@@ -2449,8 +2387,8 @@ const Queue: React.FC = () => {
{selectedJob.error_message && (
-
-
+
+
{selectedJob.error_message}
@@ -2458,8 +2396,8 @@ const Queue: React.FC = () => {
{selectedJob.result && (
-
-
+
+
{JSON.stringify(selectedJob.result, null, 2)}
@@ -2468,11 +2406,11 @@ const Queue: React.FC = () => {
{/* Formatted Job Metadata - Job-specific displays */}
{selectedJob.meta && Object.keys(selectedJob.meta).length > 0 && (
-
+
{/* open_conversation_job formatted metadata */}
{selectedJob.func_name?.includes('open_conversation_job') && (
-
+
{selectedJob.meta.word_count !== undefined && (
Word Count: {selectedJob.meta.word_count}
@@ -2506,7 +2444,7 @@ const Queue: React.FC = () => {
{selectedJob.meta.transcript && (
Transcript:
-
+
"{selectedJob.meta.transcript}"
@@ -2516,7 +2454,7 @@ const Queue: React.FC = () => {
{/* process_memory_job formatted metadata */}
{selectedJob.func_name?.includes('process_memory_job') && selectedJob.meta.memory_details && selectedJob.meta.memory_details.length > 0 && (
-
+
Memories Created: {selectedJob.meta.memories_created || selectedJob.meta.memory_details.length}
@@ -2529,7 +2467,7 @@ const Queue: React.FC = () => {
Memory Details:
{selectedJob.meta.memory_details.map((mem: any, idx: number) => (
-
+
{mem.text}
))}
@@ -2540,7 +2478,7 @@ const Queue: React.FC = () => {
{/* stream_speech_detection_job formatted metadata */}
{selectedJob.func_name?.includes('stream_speech_detection_job') && (
-
+
{selectedJob.meta.speech_detected_at && (
Speech Detected At: {new Date(selectedJob.meta.speech_detected_at).toLocaleString()}
@@ -2561,7 +2499,7 @@ const Queue: React.FC = () => {
{/* transcribe_full_audio_job formatted metadata */}
{selectedJob.func_name?.includes('transcribe_full_audio_job') && (selectedJob.meta.title || selectedJob.meta.summary) && (
-
+
{selectedJob.meta.title && (
Title: {selectedJob.meta.title}
@@ -2592,10 +2530,10 @@ const Queue: React.FC = () => {
{/* Raw JSON metadata (collapsible) */}
-
+
Raw Metadata JSON
-
+
{JSON.stringify(selectedJob.meta, null, 2)}
@@ -2623,37 +2561,37 @@ const Queue: React.FC = () => {
-
- {new Date(selectedEvent.timestamp * 1000).toLocaleString()}
+
+ {new Date(selectedEvent.timestamp * 1000).toLocaleString()}
-
+
{selectedEvent.event}
-
- {selectedEvent.user_id}
+
+ {selectedEvent.user_id}
{selectedEvent.metadata?.client_id && (
-
- {selectedEvent.metadata.client_id}
+
+ {selectedEvent.metadata.client_id}
)}
-
+
{(selectedEvent.plugins_executed || []).map((p, i) => {
const skipped = !!p.data?.skipped;
- const tone = skipped
- ? { card: 'bg-gray-50 border-gray-200', badge: 'bg-gray-100 text-gray-600', text: 'text-gray-700', label: 'Skipped' }
+ const tone: { card: string; badge: StateTone; text: string; label: string } = skipped
+ ? { card: 'bg-gray-50 border-gray-200 dark:bg-gray-900/40 dark:border-gray-700', badge: 'neutral', text: 'text-gray-700 dark:text-gray-300', label: 'Skipped' }
: p.success
- ? { card: 'bg-green-50 border-green-200', badge: 'bg-green-100 text-green-700', text: 'text-green-800', label: 'OK' }
- : { card: 'bg-red-50 border-red-200', badge: 'bg-red-100 text-red-700', text: 'text-red-800', label: 'Error' };
+ ? { card: 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800', badge: 'success', text: 'text-green-800 dark:text-green-300', label: 'OK' }
+ : { card: 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800', badge: 'danger', text: 'text-red-800 dark:text-red-300', label: 'Error' };
// Show the plugin's structured output minus the skip flags we
// already render via the badge/detail.
const { skipped: _s, skip_reason: _r, detail, ...restData } = p.data || {};
@@ -2661,15 +2599,13 @@ const Queue: React.FC = () => {
{skipped
- ?
+ ?
: p.success
- ?
- :
+ ?
+ :
}
- {p.plugin_id}
-
- {tone.label}
-
+ {p.plugin_id}
+ {tone.label}
{(p.message || detail) && (
@@ -2677,7 +2613,7 @@ const Queue: React.FC = () => {
)}
{Object.keys(restData).length > 0 && (
-
+
{JSON.stringify(restData, null, 2)}
)}
@@ -2689,10 +2625,10 @@ const Queue: React.FC = () => {
{selectedEvent.metadata && Object.keys(selectedEvent.metadata).length > 0 && (
-
+
Raw Metadata
-
+
{JSON.stringify(selectedEvent.metadata, null, 2)}
@@ -2744,12 +2680,9 @@ const Queue: React.FC = () => {
}
>
-
-
-
- This will permanently remove jobs from the database
-
-
+ }>
+ This will permanently remove jobs from the database
+
@@ -2761,14 +2694,15 @@ const Queue: React.FC = () => {
onChange={() => setFlushSettings(prev => ({ ...prev, flush_all: false }))}
className="text-blue-600"
/>
- Flush old inactive jobs (recommended)
+ Flush old inactive jobs (recommended)
{!flushSettings.flush_all && (
-
+
-
-
+ Job statuses to remove:
+ {/* Checkbox renders an inline-flex label, so the group needs
+ an explicit flex column — space-y-* alone does not separate them. */}
+
{['finished', 'failed', 'canceled'].map(status => (
{
onChange={() => setFlushSettings(prev => ({ ...prev, flush_all: true }))}
className="text-red-600"
/>
- Flush ALL jobs (DANGER!)
+ Flush ALL jobs (DANGER!)
{flushSettings.flush_all && (
-
-
+
+
⚠️ This will flush queued, started, deferred, scheduled, and canceled jobs.
{!flushSettings.include_failed && !flushSettings.include_finished &&
" Failed and finished jobs preserved for debugging."}
-
+
setFlushSettings(prev => ({ ...prev, include_failed: e.target.checked }))}
- label={Also flush failed jobs}
+ label={Also flush failed jobs}
/>
setFlushSettings(prev => ({ ...prev, include_finished: e.target.checked }))}
- label={Also flush finished jobs}
+ label={Also flush finished jobs}
/>
@@ -2852,31 +2788,31 @@ const Queue: React.FC = () => {
{/* Preview (dry run) of exactly what this flush would remove */}
{flushPreview && (
-
-
+
+
{flushPreview.total_matched} job{flushPreview.total_matched === 1 ? '' : 's'} will be removed
{typeof flushPreview.redis_keys_matched === 'number' &&
` + ${flushPreview.redis_keys_matched} Redis key${flushPreview.redis_keys_matched === 1 ? '' : 's'}`}
{!!flushPreview.skipped_session_level && (
- {flushPreview.skipped_session_level} session-level skipped
+ {flushPreview.skipped_session_level} session-level skipped
)}
{flushPreview.jobs.length === 0 ? (
- Nothing matches these settings.
+ Nothing matches these settings.
) : (
-
+
{flushPreview.jobs.map((job: any) => (
- {job.job_type}
- {job.job_id?.substring(0, 8)}
- {job.client_id && {job.client_id}}
+ {job.job_type}
+ {job.job_id?.substring(0, 8)}
+ {job.client_id && {job.client_id}}
- {job.status}
- {typeof job.age_hours === 'number' && {job.age_hours}h}
+ {job.status}
+ {typeof job.age_hours === 'number' && {job.age_hours}h}
))}
From 8b3b9785b87b33ad4a83fe96a57ccb72ca56bc43 Mon Sep 17 00:00:00 2001
From: Ankush Malaker <43288948+AnkushMalaker@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:06:37 +0000
Subject: [PATCH 15/18] feat(tray): recorder update/revert from the
chronicle-latest prerelease
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The screenpipe fork's CI publishes prebuilt recorder CLIs under the
rolling chronicle-latest prerelease with a manifest carrying the fork
commit and per-asset sha256s. recorder_update.py consumes it: download,
sha256-verify, swap into ~/.local/lib/screenpipe-cli-chronicle/current/,
repoint the screenpipe symlink, restart the service. The prior build
stays in previous/, so revert is a directory swap. The tray gains
Update recorder… / Revert recorder update actions (download runs on a
worker thread; refresh() renders its state); headless nodes use
python -m chronicle_tray.recorder_update check|install|revert.
Documented in docs/screenpipe.md, including the macOS TCC re-prompt
caveat for ad-hoc-signed builds.
---
docs/screenpipe.md | 17 ++
.../chronicle_tray/recorder_update.py | 249 ++++++++++++++++++
.../chronicle_tray/sections/screenpipe.py | 71 +++++
3 files changed, 337 insertions(+)
create mode 100644 extras/chronicle-tray/chronicle_tray/recorder_update.py
diff --git a/docs/screenpipe.md b/docs/screenpipe.md
index 66e5c7b94..457dd890b 100644
--- a/docs/screenpipe.md
+++ b/docs/screenpipe.md
@@ -238,6 +238,23 @@ always sent). Both Linux and macOS expose **View Logs** for the
current Chronicle desktop process. System-service history remains in the native
service manager:
+### Updating the recorder (prebuilt, no toolchain)
+
+The fork's CI publishes prebuilt recorder CLIs (linux-x86_64 and macos-aarch64)
+under the rolling `chronicle-latest` prerelease on `AnkushMalaker/screenpipe` on
+every push to its `chronicle` branch, with a `manifest.json` carrying the fork
+commit and per-asset sha256s. The tray's **Update recorder…** consumes it:
+download → sha256 verify → swap into `~/.local/lib/screenpipe-cli-chronicle/current/`
+→ repoint the `screenpipe` symlink → restart the service. The prior build stays
+in `previous/`, so **Revert recorder update** is a directory swap. Headless
+nodes can run the same flow with
+`uv run --project extras/chronicle-tray python -m chronicle_tray.recorder_update check|install|revert`.
+`install-local.sh` / `install-cli-local.sh` in the fork remain the build-from-source
+path for recorder development. macOS caveat: the CLI is ad-hoc signed, so its
+CDHash changes each build and TCC re-prompts for Screen Recording after an
+update — same as a local rebuild; only an Apple Developer certificate would
+remove that.
+
```bash
systemctl --user status screenpipe.service chronicle-screenpipe.service chronicle-desktop.service
journalctl --user -u screenpipe.service -u chronicle-screenpipe.service -u chronicle-desktop.service
diff --git a/extras/chronicle-tray/chronicle_tray/recorder_update.py b/extras/chronicle-tray/chronicle_tray/recorder_update.py
new file mode 100644
index 000000000..87b7f28c3
--- /dev/null
+++ b/extras/chronicle-tray/chronicle_tray/recorder_update.py
@@ -0,0 +1,249 @@
+"""Update the ScreenPipe recorder from the fork's prebuilt rolling release.
+
+Capture nodes run the recorder CLI as a service on both platforms; the binary
+itself comes from the AnkushMalaker/screenpipe fork (branch `chronicle`), whose
+CI publishes prebuilt tarballs under the rolling `chronicle-latest` release.
+This module turns an update into download → verify → swap → restart, so a node
+never needs a Rust toolchain.
+
+The install keeps a stable path — ` /current/bin/screenpipe` — and points
+the `screenpipe` symlink on PATH at it once. Updates replace `current/` (the
+old one becomes `previous/`, so revert is a directory swap), which keeps the
+path the service units captured at install time valid forever.
+
+Runnable headless as well: `python -m chronicle_tray.recorder_update
+check|install|revert` does the same thing the tray menu does.
+"""
+
+import hashlib
+import json
+import logging
+import os
+import platform
+import shutil
+import sys
+import tarfile
+import tempfile
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+RELEASE_BASE = (
+ "https://github.com/AnkushMalaker/screenpipe/releases/download/chronicle-latest"
+)
+STATE_DIR = Path.home() / ".local/lib/screenpipe-cli-chronicle"
+CURRENT = STATE_DIR / "current"
+PREVIOUS = STATE_DIR / "previous"
+INSTALLED_JSON = STATE_DIR / "installed.json"
+PREVIOUS_JSON = STATE_DIR / "previous.json"
+# Where the previous non-Chronicle install went when we took over the link
+# (e.g. the npm CLI's real binary). Recorded so a revert past our own history
+# is still possible by hand.
+DISPLACED_JSON = STATE_DIR / "displaced.json"
+DEFAULT_LINK = Path.home() / ".local/bin/screenpipe"
+_TIMEOUT = 30
+
+
+class RecorderUpdateError(RuntimeError):
+ """A condition the menu should show verbatim, not a bug."""
+
+
+def _asset_key() -> str:
+ machine = platform.machine().lower()
+ if sys.platform == "darwin":
+ if machine != "arm64":
+ raise RecorderUpdateError("no prebuilt recorder for Intel macs")
+ return "macos-aarch64"
+ if sys.platform.startswith("linux"):
+ if machine != "x86_64":
+ raise RecorderUpdateError(f"no prebuilt recorder for linux/{machine}")
+ return "linux-x86_64"
+ raise RecorderUpdateError(f"unsupported platform {sys.platform}")
+
+
+def fetch_manifest() -> dict:
+ try:
+ with urllib.request.urlopen(
+ f"{RELEASE_BASE}/manifest.json", timeout=_TIMEOUT
+ ) as r:
+ manifest = json.load(r)
+ except urllib.error.HTTPError as error:
+ if error.code == 404:
+ raise RecorderUpdateError(
+ "no chronicle-latest release published yet"
+ ) from error
+ raise
+ if _asset_key() not in manifest.get("assets", {}):
+ raise RecorderUpdateError(f"release has no asset for {_asset_key()}")
+ return manifest
+
+
+def installed() -> dict | None:
+ """Manifest snapshot of the build we installed, or None if the recorder
+ on PATH is not ours (npm CLI, local cargo build, nothing at all)."""
+ if not INSTALLED_JSON.exists():
+ return None
+ link = shutil.which("screenpipe")
+ if link is None or Path(link).resolve() != (CURRENT / "bin/screenpipe").resolve():
+ return None
+ return json.loads(INSTALLED_JSON.read_text())
+
+
+def check() -> tuple[dict | None, dict, bool]:
+ """(installed, latest, update_available). An unmanaged install always
+ counts as updatable — that is the migration path off the npm CLI."""
+ latest = fetch_manifest()
+ current = installed()
+ return current, latest, current is None or current["commit"] != latest["commit"]
+
+
+def _download_verified(manifest: dict, dest: Path) -> None:
+ asset = manifest["assets"][_asset_key()]
+ url = f"{RELEASE_BASE}/{asset['name']}"
+ digest = hashlib.sha256()
+ with urllib.request.urlopen(url, timeout=_TIMEOUT) as r, open(dest, "wb") as f:
+ while chunk := r.read(1 << 20):
+ digest.update(chunk)
+ f.write(chunk)
+ if digest.hexdigest() != asset["sha256"]:
+ raise RecorderUpdateError("downloaded recorder failed its sha256 check")
+
+
+def _take_over_link() -> Path:
+ """Point the `screenpipe` on PATH at current/bin/screenpipe.
+
+ A symlink (npm install, install-cli-local.sh) is repointed; a real binary
+ is set aside into the state dir first. Its old target is recorded in
+ displaced.json either way.
+ """
+ target = CURRENT / "bin/screenpipe"
+ found = shutil.which("screenpipe")
+ link = Path(found) if found else DEFAULT_LINK
+ if link.resolve() == target.resolve():
+ return link
+ displaced: dict = {"link": str(link)}
+ if link.is_symlink():
+ displaced["target"] = os.readlink(link)
+ link.unlink()
+ elif link.exists():
+ set_aside = STATE_DIR / f"displaced-{link.name}"
+ shutil.move(link, set_aside)
+ displaced["moved_to"] = str(set_aside)
+ link.parent.mkdir(parents=True, exist_ok=True)
+ DISPLACED_JSON.write_text(json.dumps(displaced, indent=1))
+ tmp = link.parent / f".{link.name}.chronicle-tmp"
+ tmp.unlink(missing_ok=True)
+ tmp.symlink_to(target)
+ tmp.replace(link)
+ return link
+
+
+def _restart_recorder() -> None:
+ """Restart the recorder service if it is installed and running; a node
+ that has never installed the service just gets the new binary."""
+ try:
+ from chronicle_tray.paths import add_repo_root
+
+ add_repo_root()
+ import clients
+
+ status = clients.component_status("screenpipe")
+ if status["installed"] and status["active"]:
+ clients.component_action("screenpipe", "restart")
+ except Exception:
+ logger.exception("recorder restart failed; restart it manually")
+
+
+def install(manifest: dict | None = None) -> dict:
+ """Download the latest build, swap it in, restart the service."""
+ manifest = manifest or fetch_manifest()
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ staging = Path(tempfile.mkdtemp(prefix="recorder-", dir=STATE_DIR))
+ try:
+ tarball = staging / "recorder.tar.gz"
+ _download_verified(manifest, tarball)
+ unpacked = staging / "unpacked"
+ with tarfile.open(tarball) as tar:
+ tar.extractall(unpacked, filter="data")
+ binary = unpacked / "bin/screenpipe"
+ if not binary.is_file():
+ raise RecorderUpdateError("tarball has no bin/screenpipe")
+ binary.chmod(0o755)
+ # The swap itself: current → previous, unpacked → current. The service
+ # keeps running on deleted inodes until the restart below.
+ if PREVIOUS.exists():
+ shutil.rmtree(PREVIOUS)
+ if CURRENT.exists():
+ CURRENT.replace(PREVIOUS)
+ if INSTALLED_JSON.exists():
+ INSTALLED_JSON.replace(PREVIOUS_JSON)
+ unpacked.replace(CURRENT)
+ INSTALLED_JSON.write_text(
+ json.dumps(
+ {**manifest, "installed_at": datetime.now(timezone.utc).isoformat()},
+ indent=1,
+ )
+ )
+ finally:
+ shutil.rmtree(staging, ignore_errors=True)
+ _take_over_link()
+ _restart_recorder()
+ return manifest
+
+
+def can_revert() -> bool:
+ return (PREVIOUS / "bin/screenpipe").is_file()
+
+
+def revert() -> dict | None:
+ """Swap current/ and previous/ back and restart. Returns the manifest of
+ the build now active, if known."""
+ if not can_revert():
+ raise RecorderUpdateError("no previous recorder build to revert to")
+ swap = STATE_DIR / "swap"
+ if swap.exists():
+ shutil.rmtree(swap)
+ CURRENT.replace(swap)
+ PREVIOUS.replace(CURRENT)
+ swap.replace(PREVIOUS)
+ now_active = None
+ if PREVIOUS_JSON.exists():
+ now_active = json.loads(PREVIOUS_JSON.read_text())
+ stash = INSTALLED_JSON.read_text() if INSTALLED_JSON.exists() else None
+ INSTALLED_JSON.write_text(json.dumps(now_active, indent=1))
+ if stash is not None:
+ PREVIOUS_JSON.write_text(stash)
+ else:
+ PREVIOUS_JSON.unlink()
+ _take_over_link()
+ _restart_recorder()
+ return now_active
+
+
+def _main() -> int:
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
+ verb = sys.argv[1] if len(sys.argv) > 1 else "check"
+ if verb == "check":
+ current, latest, available = check()
+ have = current["describe"] if current else "unmanaged or not installed"
+ print(f"installed: {have}")
+ print(f"latest: {latest['describe']} (built {latest['built_at']})")
+ print("update available" if available else "up to date")
+ return 0
+ if verb == "install":
+ manifest = install()
+ print(f"installed {manifest['describe']} ({manifest['commit'][:8]})")
+ return 0
+ if verb == "revert":
+ manifest = revert()
+ print(f"reverted to {manifest['describe'] if manifest else 'previous build'}")
+ return 0
+ print(f"usage: {sys.argv[0]} check|install|revert", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(_main())
diff --git a/extras/chronicle-tray/chronicle_tray/sections/screenpipe.py b/extras/chronicle-tray/chronicle_tray/sections/screenpipe.py
index 254ca5fc6..e113461d9 100644
--- a/extras/chronicle-tray/chronicle_tray/sections/screenpipe.py
+++ b/extras/chronicle-tray/chronicle_tray/sections/screenpipe.py
@@ -15,8 +15,10 @@
import shutil
import sqlite3
import subprocess
+import threading
from pathlib import Path
+from chronicle_tray import recorder_update
from chronicle_tray.capture_settings_dialog import CaptureSettingsDialog
from chronicle_tray.paths import add_repo_root
from chronicle_tray.screenpipe_settings import (
@@ -100,6 +102,11 @@ def __init__(self) -> None:
self.settings_action = None
# What to restore when the master audio switch is turned back on.
self.audio_restore = ("both", "both")
+ self.update_action = None
+ self.revert_action = None
+ # Written by the update worker thread, rendered by refresh().
+ self._update_lock = threading.Lock()
+ self._update_state = {"busy": False, "message": ""}
def available(self) -> tuple[bool, str]:
if shutil.which("screenpipe") or SCREENPIPE_DB.exists():
@@ -167,6 +174,10 @@ def _capture_actions(self, menu: QMenu) -> None:
self.settings_action = menu.addAction(
"Capture settings…", self._open_capture_settings
)
+ self.update_action = menu.addAction("Update recorder…", self._update_recorder)
+ self.revert_action = menu.addAction(
+ "Revert recorder update", self._revert_recorder
+ )
def _unit(self, verb: str, component: str) -> None:
if (
@@ -272,6 +283,65 @@ def _refresh_capture_settings(self) -> None:
action.setEnabled(False)
self.settings_action.setEnabled(False)
+ def _update_recorder(self) -> None:
+ self._run_update_step(self._update_worker)
+
+ def _revert_recorder(self) -> None:
+ self._run_update_step(self._revert_worker)
+
+ def _run_update_step(self, worker) -> None:
+ """Start ``worker`` on a daemon thread; refresh() renders its state.
+
+ The download is tens of MB, so it cannot run on the menu action
+ directly. One step at a time — a second click while busy is ignored
+ rather than queued.
+ """
+ with self._update_lock:
+ if self._update_state["busy"]:
+ return
+ self._set_update_state(True, "working…")
+ threading.Thread(target=worker, daemon=True).start()
+ self.refresh()
+
+ def _set_update_state(self, busy: bool, message: str) -> None:
+ with self._update_lock:
+ self._update_state = {"busy": busy, "message": message}
+
+ def _update_worker(self) -> None:
+ try:
+ _current, latest, available = recorder_update.check()
+ if not available:
+ self._set_update_state(False, f"up to date ({latest['describe']})")
+ return
+ self._set_update_state(True, "downloading…")
+ recorder_update.install(latest)
+ self._set_update_state(False, f"updated to {latest['describe']}")
+ except Exception as error: # rendered in the menu, never raised into Qt
+ logger.exception("recorder update failed")
+ self._set_update_state(False, f"update failed: {error}")
+
+ def _revert_worker(self) -> None:
+ try:
+ manifest = recorder_update.revert()
+ name = manifest["describe"] if manifest else "previous build"
+ self._set_update_state(False, f"reverted to {name}")
+ except Exception as error:
+ logger.exception("recorder revert failed")
+ self._set_update_state(False, f"revert failed: {error}")
+
+ def _refresh_update_actions(self) -> None:
+ if self.update_action is None:
+ return
+ with self._update_lock:
+ busy = self._update_state["busy"]
+ message = self._update_state["message"]
+ label = "Update recorder…"
+ if message:
+ label = f"Update recorder… ({message})"
+ self.update_action.setText(label)
+ self.update_action.setEnabled(not busy)
+ self.revert_action.setEnabled(not busy and recorder_update.can_revert())
+
def _collector_state(self) -> str:
return _component_state(COLLECTOR)
@@ -289,6 +359,7 @@ def refresh(self) -> None:
actions["stop"].setEnabled(active)
self.stats_item.setText(_stats())
self._refresh_capture_settings()
+ self._refresh_update_actions()
def tooltip(self) -> str:
return f"Collector: {self._collector_state()}"
From 1a699d157e97815188f1730616e0acd978ae0642 Mon Sep 17 00:00:00 2001
From: Ankush Malaker <43288948+AnkushMalaker@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:06:47 +0000
Subject: [PATCH 16/18] docs: sync AGENTS.md and memories.md with shipped
features
- AGENTS.md: Immich integration in the wizard feature list, service-
profile testing (make test PROFILE=..., cassettes, no credential-gated
tests), and the compose-stack.md pointer.
- memories.md: document the person_photos cron job that embeds Immich
face-crop thumbnails into People notes via the vault's _media store.
---
AGENTS.md | 9 ++++++++-
docs/backend/memories.md | 2 ++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1f4db7897..26629feca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -54,6 +54,7 @@ Chronicle includes an **interactive setup wizard** for easy configuration. The w
- Memory configuration (agentic Markdown vault — Chronicle's single memory provider)
- Network configuration and HTTPS setup
- Optional services (speaker recognition, Parakeet ASR)
+- Immich photo library integration (photo discovery + person photos in the vault)
### Quick Start
```bash
@@ -128,7 +129,11 @@ All test operations are managed through a simple Makefile interface:
cd tests
# Full test workflow (recommended)
-make test # Start containers + run all tests
+make test # Start containers + run all tests (profile: mock, no credentials)
+
+# Same suite against real backing services (see tests/profiles.yml)
+make test PROFILE=deepgram-openai # real Deepgram STT + real OpenAI LLM
+make test PROFILE=deepgram-openai-speaker # ...plus the real speaker service
# Or step by step
make start # Start test containers (with health checks)
@@ -588,6 +593,7 @@ tailscale ip -4
### Testing Strategy
- **Makefile-Based**: All test operations through simple `make` commands (`make test`, `make start`, `make stop`)
+- **One suite, N service profiles**: tests are never selected by whether an API key is present. `tests/profiles.yml` declares which backing services are real for a run; stubs replay recorded real responses from `tests/cassettes/`, so the same assertions hold with or without credentials. Do not add a tag or a skip to work around a missing key — record a cassette (`make record-cassettes`) or fix the stub.
- **Log Preservation**: Container logs always saved before cleanup (never lose debugging info)
- **End-to-End Integration**: Robot Framework validates complete audio processing pipeline
- **Environment Flexibility**: Tests work with both local .env files and CI environment variables
@@ -634,6 +640,7 @@ For detailed technical documentation, see:
- **[@docs/podman.md](docs/podman.md)**: Running with Podman instead of Docker (engine selection, rootless/GPU setup)
- **[@docs/screenpipe.md](docs/screenpipe.md)**: ScreenPipe capture-node architecture, services, desktop controls, and troubleshooting
- **[@docs/audio-pipeline-architecture.md](docs/audio-pipeline-architecture.md)**: Audio pipeline design
+- **[@docs/backend/compose-stack.md](docs/backend/compose-stack.md)**: Backend compose services, shared mounts, and profiles
- **[@docs/backend/auth.md](docs/backend/auth.md)**: Authentication architecture
- **[@docs/backend/memories.md](docs/backend/memories.md)**: Memory system documentation
- **[@docs/backend/plugin-development-guide.md](docs/backend/plugin-development-guide.md)**: Plugin development guide
diff --git a/docs/backend/memories.md b/docs/backend/memories.md
index bb70780f5..3f6f3949b 100644
--- a/docs/backend/memories.md
+++ b/docs/backend/memories.md
@@ -61,6 +61,8 @@ It is per-user (keyed by the MongoDB ObjectId `user_id`) and organized into note
These are ordinary Markdown files — readable, editable, and grep-able. Because the vault is the system of record, memories survive as durable text rather than as opaque vector rows.
+If an Immich photo library is configured (`IMMICH_URL`/`IMMICH_API_KEY`, offered by the setup wizard), the `person_photos` cron job (`services/person_photos.py`) matches each `People/.md` note against Immich's people API, stores the person's face-crop thumbnail content-addressed under the vault's `_media/` directory, and embeds a small photo at the top of the note.
+
## Write path: the memory agent
Memory extraction runs as part of the post-conversation RQ pipeline. After a conversation closes, `memory_extraction_job` calls `memory_service.add_memory()`, which invokes the **write agent** (`_add_memory_agent` in `providers/chronicle.py`).
From 8ab28329bc55e6bfc3b9a20c4c448283c1c9f2f7 Mon Sep 17 00:00:00 2001
From: Ankush Malaker <43288948+AnkushMalaker@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:06:47 +0000
Subject: [PATCH 17/18] fix(plugins): let HA_URL override the Home Assistant
URL in the template
The template hardcoded host.docker.internal:8123, which is wrong the
moment Home Assistant lives on another node; reference ${HA_URL} with
the old value as the default.
---
config/plugins.yml.template | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/config/plugins.yml.template b/config/plugins.yml.template
index 6f3f9db7a..84ace07e2 100644
--- a/config/plugins.yml.template
+++ b/config/plugins.yml.template
@@ -30,7 +30,7 @@ plugins:
type: keyword_anywhere # Trigger when keyword appears anywhere in transcript
keywords: # Support multiple keywords
- vivi # Example: "turn off the lights, vivi"
- ha_url: http://host.docker.internal:8123 # Your Home Assistant URL
+ ha_url: ${HA_URL:-http://host.docker.internal:8123} # Your Home Assistant URL (set HA_URL in .env)
ha_token: ${HA_TOKEN} # ALWAYS use env var - never paste actual token here!
# To get a long-lived token:
# 1. Go to Home Assistant → Profile → Security tab
From 9c8ef71e2ae591cf25ae63c77ca61c127189f67f Mon Sep 17 00:00:00 2001
From: Ankush Malaker <43288948+AnkushMalaker@users.noreply.github.com>
Date: Wed, 5 Aug 2026 23:04:33 +0000
Subject: [PATCH 18/18] feat(data-audit): verify and curate annotation exports
before they ship
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The export flow could select and export, but never showed what would
actually be in the zip — the user shipped datasets to annotators sight
unseen and had no record of what was already sent. Three additions close
the curation loop:
- Contents preview: POST /export/preview is a synchronous dry-run
returning the exact clips (boundaries, durations, sliced transcripts)
the current settings would produce. The plan computation moved into
utils/export_planning.plan_conversation_clips, shared verbatim by the
export job, so the preview cannot drift from the export. Unanalyzed
conversations are reported skipped rather than VAD'd inline.
- Clip-level curation: the modal's Dataset contents panel renders the
plan live (auto-refreshing as params/screen change) with per-clip
playback via the gapless player, the transcript slice (a "no
transcript" badge marks clips an annotator would receive silent), and
an include toggle. Unticked clips go to /export as dropped_ranges —
same carving as privacy ranges, separate accounting (params.curated,
dropped_seconds): "not worth annotating" vs "too sensitive to share".
- Export history: the listing joins the on-disk export.json metadata so
each row carries last_export (an "exported" chip) and an
exported=never|exported filter scopes curation to un-shipped audio.
Deleting an export naturally un-marks its conversations.
Verified end-to-end on live data: 19-clip preview, 1 clip dropped,
export produced 18 clips with dropped_seconds attributed to the right
conversation, badges appeared, delete un-marked them.
---
backends/advanced/Docs/data-audit.md | 30 ++
.../controllers/data_audit_controller.py | 148 +++++++-
.../routers/modules/data_audit_routes.py | 54 ++-
.../utils/export_planning.py | 189 ++++++++++
.../workers/data_audit_jobs.py | 189 ++++------
.../advanced/tests/test_export_planning.py | 342 ++++++++++++++++++
.../src/components/dataAudit/AuditTable.tsx | 7 +
.../src/components/dataAudit/ExportModal.tsx | 321 +++++++++++++++-
.../src/components/dataAudit/filters.tsx | 45 +++
backends/advanced/webui/src/services/api.ts | 61 +++-
10 files changed, 1253 insertions(+), 133 deletions(-)
create mode 100644 backends/advanced/src/advanced_omi_backend/utils/export_planning.py
create mode 100644 backends/advanced/tests/test_export_planning.py
diff --git a/backends/advanced/Docs/data-audit.md b/backends/advanced/Docs/data-audit.md
index 6997d2432..611f9f77c 100644
--- a/backends/advanced/Docs/data-audit.md
+++ b/backends/advanced/Docs/data-audit.md
@@ -135,6 +135,36 @@ records by `clip_id` + `conversation_id` + source times. Helpers live in
the Data Audit toolbar (exports selected rows; modal also lists/downloads/
deletes server-side exports).
+### Contents preview (verify before shipping)
+
+`POST /export/preview` (same body minus `sensitivity_policy`) is a synchronous
+dry-run: it returns the exact clips the settings would produce — boundaries,
+durations, and the sliced transcript each manifest record would carry —
+without writing any audio. Preview and job share one code path
+(`utils/export_planning.plan_conversation_clips`), so the preview cannot drift
+from the export. Unanalyzed conversations are reported skipped
+(`not analyzed`) rather than analyzed inline; the export job still runs VAD
+for them.
+
+The modal's **Dataset contents** panel renders this plan live (auto-refreshing
+as params or screen withholdings change): per-clip play (gapless player over
+the clip's range), the transcript slice (a `no transcript` badge marks clips
+an annotator would receive silent), and an include checkbox per clip. Unticked
+clips are passed to `/export` as `dropped_ranges`
+(`{conversation_id: [[start, end], …]}`) and carved out exactly like privacy
+ranges but accounted separately (`params.curated`, per-conversation and total
+`dropped_seconds`): one bucket means "too sensitive to share", the other "not
+worth annotating".
+
+### Export history
+
+The listing joins the on-disk `export.json` metadata to mark conversations a
+previous export actually shipped (skipped ones don't count): each row carries
+`last_export` (chipped `exported` in the table), and the `exported=never|exported`
+filter scopes a curation session to un-shipped audio. Reading history from the
+export directories means deleting an export naturally un-marks its
+conversations — no second source of truth.
+
## Privacy screen (shareability gate)
Before sharing audio + transcripts with an outside annotator, the export can
diff --git a/backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py b/backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py
index 9aa01bf42..6afb0c2ef 100644
--- a/backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py
+++ b/backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py
@@ -14,7 +14,7 @@
import statistics
import uuid
from datetime import datetime, timezone
-from typing import Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple
from fastapi.responses import FileResponse, JSONResponse
@@ -38,6 +38,7 @@
EXPORTS_DIR,
META_NAME,
ZIP_NAME,
+ active_segments,
export_dir,
new_export_id,
validate_export_id,
@@ -55,6 +56,10 @@
AudioValidationError,
validate_and_prepare_audio,
)
+from advanced_omi_backend.utils.export_planning import (
+ export_eligibility,
+ plan_conversation_clips,
+)
from advanced_omi_backend.utils.transcript_slicing import (
build_transcript_text,
shift_segments,
@@ -198,6 +203,38 @@ def _vad_stale(va: Optional[dict], duration: float) -> bool:
return not audio_cache_duration_matches(cached, duration)
+def _latest_exports_by_conversation(user: User) -> Dict[str, dict]:
+ """conversation_id → the most recent export that actually shipped it.
+
+ Read from the on-disk export metadata (the audit trail the export job
+ already writes) rather than a new Mongo field, so deleting an export
+ directory naturally un-marks its conversations. Conversations that were
+ selected but skipped don't count as exported. Scoped by the same
+ ownership rule as ``list_exports``.
+ """
+ latest: Dict[str, dict] = {}
+ if not EXPORTS_DIR.is_dir():
+ return latest
+ for meta_path in EXPORTS_DIR.glob(f"*/{META_NAME}"):
+ try:
+ meta = json.loads(meta_path.read_text())
+ except Exception:
+ continue
+ if not user.is_superuser and meta.get("created_by") != str(user.user_id):
+ continue
+ created_at = meta.get("created_at") or ""
+ for conv in meta.get("conversations", []):
+ if conv.get("skipped_reason"):
+ continue
+ cid = conv.get("conversation_id")
+ if cid and created_at > (latest.get(cid, {}).get("created_at") or ""):
+ latest[cid] = {
+ "export_id": meta.get("export_id"),
+ "created_at": created_at,
+ }
+ return latest
+
+
async def list_for_audit(
user: User,
speech_threshold: float = 0.5,
@@ -210,6 +247,7 @@ async def list_for_audit(
include_speakers: Optional[List[str]] = None,
exclude_speakers: Optional[List[str]] = None,
dataset_id: Optional[str] = None,
+ exported: Optional[str] = None,
archived_only: bool = False,
hide_failed: bool = False,
hide_reviewed: bool = False,
@@ -225,6 +263,12 @@ async def list_for_audit(
the ``exclude_speakers``. Speech bounds exclude unanalyzed conversations;
``max_speech_fraction=1`` / ``min_speech_fraction=0`` / ``max_duration=0``
disable the respective bound.
+
+ ``exported`` filters on annotation-export history (from the on-disk export
+ metadata): ``never`` keeps only conversations no export has shipped,
+ ``exported`` only those a previous export contains. Each row carries
+ ``last_export`` either way, so curation sessions can skip audio already
+ sent to annotators.
"""
try:
base: dict = {} if user.is_superuser else {"user_id": str(user.user_id)}
@@ -303,6 +347,7 @@ async def list_for_audit(
include_set = set(include_speakers or [])
exclude_set = set(exclude_speakers or [])
+ export_history = _latest_exports_by_conversation(user)
matched: List[dict] = []
# Speakers present anywhere in the scanned working set (before the
# compound predicate), so the filter UI offers exactly the labels that
@@ -347,6 +392,11 @@ async def list_for_audit(
# speech segment already has an identified_as).
if hide_reviewed and unknown_count == 0:
continue
+ in_export = doc.get("conversation_id") in export_history
+ if exported == "never" and in_export:
+ continue
+ if exported == "exported" and not in_export:
+ continue
created_at = doc.get("created_at")
archived_at = doc.get("audio_archived_at")
@@ -374,6 +424,7 @@ async def list_for_audit(
"derived_operation": (
derived_from.get("operation") if derived_from else None
),
+ "last_export": export_history.get(doc.get("conversation_id")),
"audio_archived": doc.get("audio_archived", False),
"audio_archived_at": (
archived_at.isoformat() if archived_at else None
@@ -1705,6 +1756,96 @@ async def get_default_sensitivity_policy():
return {"policy": get_sensitivity_policy()}
+async def preview_export(
+ user: User,
+ conversation_ids: List[str],
+ mode: str = "clips",
+ pad_seconds: float = 1.0,
+ speech_threshold: float = 0.5,
+ merge_gap_seconds: float = 3.0,
+ excluded_ranges: Optional[Dict[str, List[List[float]]]] = None,
+):
+ """Dry-run of the export: the exact clips it would produce, without
+ writing any audio.
+
+ Runs the same plan computation as the export job
+ (``utils/export_planning.plan_conversation_clips``), so the boundaries,
+ durations, and sliced transcripts returned here are byte-for-byte what
+ the manifest would contain. Unanalyzed conversations are reported as
+ skipped (``not analyzed``) rather than analyzed inline — VAD over a long
+ recording is too slow for a synchronous endpoint; the UI points at the
+ Analyze button.
+ """
+ excluded_ranges = excluded_ranges or {}
+ user_id = str(user.user_id)
+ conversations: List[dict] = []
+ totals = {"clip_count": 0, "total_clip_seconds": 0.0, "excluded_seconds": 0.0}
+
+ try:
+ for cid in dict.fromkeys(conversation_ids):
+ conv = await Conversation.find_one(Conversation.conversation_id == cid)
+ entry: Dict[str, Any] = {
+ "conversation_id": cid,
+ "title": conv.title if conv else None,
+ "client_id": conv.client_id if conv else None,
+ "created_at": (
+ conv.created_at.isoformat() if conv and conv.created_at else None
+ ),
+ }
+ skipped = export_eligibility(conv, user_id, user.is_superuser)
+ if not skipped:
+ plan = await plan_conversation_clips(
+ conv,
+ mode,
+ pad_seconds,
+ speech_threshold,
+ merge_gap_seconds,
+ excluded_ranges.get(cid),
+ )
+ skipped = plan.skipped_reason
+ if skipped:
+ entry["skipped_reason"] = skipped
+ conversations.append(entry)
+ continue
+
+ segments = active_segments(conv)
+ clips = []
+ for clip in plan.clips:
+ sliced = slice_segments(segments, clip.start, clip.end)
+ clips.append(
+ {
+ "clip_index": clip.clip_index,
+ "clip_id": f"{cid}_{clip.clip_index:03d}",
+ "start": round(clip.start, 2),
+ "end": round(clip.end, 2),
+ "duration_seconds": round(clip.duration, 2),
+ "text": build_transcript_text(sliced),
+ "segment_count": len(sliced),
+ }
+ )
+ entry["clips"] = clips
+ entry["sample_rate"] = plan.sample_rate
+ entry["clip_seconds"] = round(plan.clip_seconds, 2)
+ entry["excluded_seconds"] = plan.excluded_seconds
+ totals["clip_count"] += len(clips)
+ totals["total_clip_seconds"] += plan.clip_seconds
+ totals["excluded_seconds"] += plan.excluded_seconds
+ conversations.append(entry)
+
+ totals["total_clip_seconds"] = round(totals["total_clip_seconds"], 2)
+ totals["excluded_seconds"] = round(totals["excluded_seconds"], 2)
+ totals["conversation_count"] = len(conversations)
+ totals["exported_conversations"] = sum(
+ 1 for c in conversations if "skipped_reason" not in c
+ )
+ return {"conversations": conversations, "totals": totals}
+ except Exception as e:
+ logger.exception(f"Error previewing annotation export: {e}")
+ return JSONResponse(
+ status_code=500, content={"error": "Error previewing export"}
+ )
+
+
async def start_export(
user: User,
conversation_ids: List[str],
@@ -1713,12 +1854,14 @@ async def start_export(
speech_threshold: float = 0.5,
merge_gap_seconds: float = 3.0,
excluded_ranges: Optional[Dict[str, List[List[float]]]] = None,
+ dropped_ranges: Optional[Dict[str, List[List[float]]]] = None,
sensitivity_policy: Optional[str] = None,
):
"""Enqueue the annotation-dataset export job for selected conversations.
``excluded_ranges`` maps conversation_id → withheld ``[start, end]`` ranges
- confirmed from the privacy screen; those are carved out of the export.
+ confirmed from the privacy screen; ``dropped_ranges`` → clips the user
+ unticked in the export preview. Both are carved out of the export.
"""
try:
export_id = new_export_id()
@@ -1732,6 +1875,7 @@ async def start_export(
speech_threshold=speech_threshold,
merge_gap_seconds=merge_gap_seconds,
excluded_ranges=excluded_ranges,
+ dropped_ranges=dropped_ranges,
sensitivity_policy=sensitivity_policy,
job_timeout=3600,
result_ttl=JOB_RESULT_TTL,
diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/data_audit_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/data_audit_routes.py
index 3acdfd2e1..d81ddfd83 100644
--- a/backends/advanced/src/advanced_omi_backend/routers/modules/data_audit_routes.py
+++ b/backends/advanced/src/advanced_omi_backend/routers/modules/data_audit_routes.py
@@ -101,11 +101,30 @@ class ExportRequest(BaseModel):
description="conversation_id → withheld [start, end] time ranges (seconds) "
"from the privacy screen; carved out of the exported audio + transcript",
)
+ dropped_ranges: Dict[str, List[List[float]]] = Field(
+ default_factory=dict,
+ description="conversation_id → [start, end] ranges of clips the user "
+ "unticked in the export preview; removed from the export (accounted "
+ "separately from privacy withholdings)",
+ )
sensitivity_policy: Optional[str] = Field(
None, description="Policy used for the screen (recorded in export metadata)"
)
+class ExportPreviewRequest(BaseModel):
+ conversation_ids: List[str] = Field(..., min_length=1, max_length=200)
+ mode: str = Field("clips", pattern="^(clips|full)$")
+ pad_seconds: float = Field(1.0, ge=0.0, le=10.0)
+ speech_threshold: float = Field(0.5, ge=0.0, le=1.0)
+ merge_gap_seconds: float = Field(3.0, ge=0.0, le=60.0)
+ excluded_ranges: Dict[str, List[List[float]]] = Field(
+ default_factory=dict,
+ description="Privacy-screen withholdings to apply to the preview, so the "
+ "clips shown match what the export would produce",
+ )
+
+
@router.post("/analyze")
async def analyze(
body: AnalyzeRequest,
@@ -159,6 +178,12 @@ async def list_conversations(
max_length=200,
description="Only conversations imported from this annotation dataset",
),
+ exported: Optional[str] = Query(
+ None,
+ pattern="^(never|exported)$",
+ description="Filter on annotation-export history: never = not in any "
+ "export, exported = shipped by a previous export",
+ ),
archived_only: bool = Query(
False,
description="List archived metadata stubs instead of active conversations",
@@ -192,6 +217,7 @@ def _csv(v: Optional[str]) -> Optional[list]:
include_speakers=_csv(include_speakers),
exclude_speakers=_csv(exclude_speakers),
dataset_id=dataset_id,
+ exported=exported,
archived_only=archived_only,
hide_failed=hide_failed,
hide_reviewed=hide_reviewed,
@@ -814,6 +840,28 @@ async def screen_export(
)
+@router.post("/export/preview")
+async def preview_export(
+ body: ExportPreviewRequest,
+ current_user: User = Depends(current_active_user),
+):
+ """Dry-run of the export: the exact clips (boundaries, durations, sliced
+ transcripts) the current settings would produce, computed synchronously
+ without writing any audio. Unanalyzed conversations are reported skipped
+ (``not analyzed``) — run /analyze first. Untick clips here and pass their
+ ranges to /export as ``dropped_ranges``.
+ """
+ return await data_audit_controller.preview_export(
+ current_user,
+ body.conversation_ids,
+ mode=body.mode,
+ pad_seconds=body.pad_seconds,
+ speech_threshold=body.speech_threshold,
+ merge_gap_seconds=body.merge_gap_seconds,
+ excluded_ranges=body.excluded_ranges,
+ )
+
+
@router.post("/export")
async def start_export(
body: ExportRequest,
@@ -822,8 +870,9 @@ async def start_export(
"""Enqueue an annotation-dataset export: WAV audio + transcript manifest,
zipped for download. Mode ``clips`` cuts one padded WAV per VAD speech
region (silence cropped); mode ``full`` exports each conversation as a
- single untouched WAV. ``excluded_ranges`` from the privacy screen are
- carved out of the exported audio + transcript.
+ single untouched WAV. ``excluded_ranges`` from the privacy screen and
+ ``dropped_ranges`` from the export preview are carved out of the exported
+ audio + transcript.
Poll job status via /api/queue/jobs/{id}/status, then download from
/api/data-audit/exports/{export_id}/download.
@@ -836,6 +885,7 @@ async def start_export(
speech_threshold=body.speech_threshold,
merge_gap_seconds=body.merge_gap_seconds,
excluded_ranges=body.excluded_ranges,
+ dropped_ranges=body.dropped_ranges,
sensitivity_policy=body.sensitivity_policy,
)
diff --git a/backends/advanced/src/advanced_omi_backend/utils/export_planning.py b/backends/advanced/src/advanced_omi_backend/utils/export_planning.py
new file mode 100644
index 000000000..a4f2bfff6
--- /dev/null
+++ b/backends/advanced/src/advanced_omi_backend/utils/export_planning.py
@@ -0,0 +1,189 @@
+"""Clip-plan computation for annotation exports.
+
+One code path decides what an annotation export will contain — which speech
+regions become clips, at which boundaries, with which transcript slices —
+shared by the export RQ job (``workers/data_audit_jobs.py``, which renders the
+plan to WAVs in a zip) and the synchronous preview endpoint (which returns the
+plan for the user to verify and curate before anything is written). Keeping
+both on the same functions means the preview can never drift from the export.
+
+Two kinds of range carving, deliberately accounted separately:
+
+- ``excluded_ranges`` — privacy-screen withholdings; reported as
+ ``excluded_seconds`` ("withheld").
+- ``dropped_ranges`` — clips the user unticked in the export preview; reported
+ as ``dropped_seconds``. Same subtraction mechanics, different meaning: one is
+ "too sensitive to share", the other is "not worth annotating".
+"""
+
+import logging
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+
+from advanced_omi_backend.models.audio_chunk import AudioChunkDocument
+from advanced_omi_backend.models.conversation import Conversation
+from advanced_omi_backend.utils.vad_analysis import (
+ frame_speech_intervals,
+ merge_speech_regions,
+ subtract_intervals,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ClipPlan:
+ """One planned clip: a speech region and its position in the source."""
+
+ clip_index: int
+ start: float
+ end: float
+
+ @property
+ def duration(self) -> float:
+ return self.end - self.start
+
+
+@dataclass
+class ConversationPlan:
+ """The export plan for one conversation (or the reason it has none)."""
+
+ conversation: Optional[Conversation]
+ clips: List[ClipPlan] = field(default_factory=list)
+ sample_rate: int = 16000
+ excluded_seconds: float = 0.0 # privacy-screen withholdings
+ dropped_seconds: float = 0.0 # preview-dropped clips
+ skipped_reason: Optional[str] = None
+
+ @property
+ def clip_seconds(self) -> float:
+ return sum(c.duration for c in self.clips)
+
+
+def export_eligibility(
+ conv: Optional[Conversation], user_id: str, is_superuser: bool
+) -> Optional[str]:
+ """Why this conversation cannot be exported, or None if it can."""
+ if not conv:
+ return "not found"
+ if not is_superuser and conv.user_id != user_id:
+ return "access forbidden"
+ if conv.deleted:
+ return "deleted"
+ if conv.audio_archived:
+ return "audio archived"
+ if not conv.audio_chunks_count:
+ return "no audio"
+ return None
+
+
+async def collect_raw_intervals(
+ conversation_id: str, threshold: float
+) -> Tuple[Optional[List[List[float]]], float, int]:
+ """Raw speech intervals from cached chunk frame scores (streaming cursor).
+
+ Returns (intervals, last_chunk_end_seconds, sample_rate); intervals is
+ None when any chunk lacks VAD scores (caller should analyze first).
+ """
+ collection = AudioChunkDocument.get_pymongo_collection()
+ cursor = collection.find(
+ {"conversation_id": conversation_id},
+ {
+ "start_time": 1,
+ "end_time": 1,
+ "sample_rate": 1,
+ "vad.scores": 1,
+ "vad.frame_hop_ms": 1,
+ },
+ ).sort("chunk_index", 1)
+
+ intervals: List[List[float]] = []
+ last_end = 0.0
+ sample_rate = 16000
+ first = True
+ async for chunk in cursor:
+ if first:
+ sample_rate = int(chunk.get("sample_rate") or 16000)
+ first = False
+ vad = chunk.get("vad")
+ if not vad or vad.get("scores") is None:
+ return None, 0.0, sample_rate
+ intervals.extend(
+ frame_speech_intervals(
+ vad["scores"],
+ float(vad["frame_hop_ms"]) / 1000.0,
+ float(chunk["start_time"]),
+ threshold=threshold,
+ )
+ )
+ last_end = float(chunk["end_time"])
+ return intervals, last_end, sample_rate
+
+
+async def plan_conversation_clips(
+ conv: Conversation,
+ mode: str,
+ pad_seconds: float,
+ speech_threshold: float,
+ merge_gap_seconds: float,
+ excluded_ranges: Optional[List[List[float]]] = None,
+ dropped_ranges: Optional[List[List[float]]] = None,
+) -> ConversationPlan:
+ """Compute the clip plan for one eligible conversation.
+
+ Mode ``clips``: one region per VAD speech run, padded and gap-merged at
+ the requested settings (the cached ``speech_regions`` use the default
+ 0.3s pad, so regions are always re-merged here). Mode ``full``: a single
+ region spanning the whole recording.
+
+ ``excluded_ranges`` (privacy screen) and ``dropped_ranges`` (preview
+ curation) are subtracted **after** padding/merge so padding cannot
+ re-expose a cut. A dropped clip's exact [start, end] therefore removes
+ precisely that region.
+
+ Unanalyzed audio yields ``skipped_reason='not analyzed'`` — the caller
+ decides whether to run VAD (the export job does; the synchronous preview
+ endpoint does not, pointing the user at the Analyze button instead).
+ """
+ cid = conv.conversation_id
+
+ if mode == "full":
+ duration = conv.audio_total_duration or 0.0
+ if duration <= 0:
+ return ConversationPlan(conv, skipped_reason="no audio duration")
+ regions: List[List[float]] = [[0.0, duration]]
+ first = await AudioChunkDocument.find_one(
+ AudioChunkDocument.conversation_id == cid
+ )
+ sample_rate = first.sample_rate if first else 16000
+ else:
+ intervals, last_end, sample_rate = await collect_raw_intervals(
+ cid, speech_threshold
+ )
+ if intervals is None:
+ return ConversationPlan(
+ conv, sample_rate=sample_rate, skipped_reason="not analyzed"
+ )
+ duration = conv.audio_total_duration or last_end
+ regions = merge_speech_regions(
+ intervals,
+ duration,
+ pad_seconds=pad_seconds,
+ merge_gap_seconds=merge_gap_seconds,
+ )
+
+ kept = sum(t1 - t0 for t0, t1 in regions)
+ if excluded_ranges:
+ regions = subtract_intervals(regions, excluded_ranges)
+ after_privacy = sum(t1 - t0 for t0, t1 in regions)
+ if dropped_ranges:
+ regions = subtract_intervals(regions, dropped_ranges)
+ after_drop = sum(t1 - t0 for t0, t1 in regions)
+
+ return ConversationPlan(
+ conv,
+ clips=[ClipPlan(i, t0, t1) for i, (t0, t1) in enumerate(regions)],
+ sample_rate=sample_rate,
+ excluded_seconds=round(max(0.0, kept - after_privacy), 2),
+ dropped_seconds=round(max(0.0, after_privacy - after_drop), 2),
+ )
diff --git a/backends/advanced/src/advanced_omi_backend/workers/data_audit_jobs.py b/backends/advanced/src/advanced_omi_backend/workers/data_audit_jobs.py
index bed4640f2..bca4832d8 100644
--- a/backends/advanced/src/advanced_omi_backend/workers/data_audit_jobs.py
+++ b/backends/advanced/src/advanced_omi_backend/workers/data_audit_jobs.py
@@ -28,7 +28,6 @@
archive_conversation_audio_doc,
)
from advanced_omi_backend.llm_client import async_generate
-from advanced_omi_backend.models.audio_chunk import AudioChunkDocument
from advanced_omi_backend.models.conversation import Conversation
from advanced_omi_backend.models.job import async_job
from advanced_omi_backend.services.observability.system_events import record_event_sync
@@ -45,6 +44,10 @@
audio_cache_duration_matches,
reconstruct_audio_segment,
)
+from advanced_omi_backend.utils.export_planning import (
+ export_eligibility,
+ plan_conversation_clips,
+)
from advanced_omi_backend.utils.sensitivity_screening import (
DEFAULT_SENSITIVITY_POLICY,
build_screening_prompt,
@@ -52,12 +55,7 @@
screenable_segments,
)
from advanced_omi_backend.utils.transcript_slicing import slice_segments
-from advanced_omi_backend.utils.vad_analysis import (
- analyze_conversation_audio,
- frame_speech_intervals,
- merge_speech_regions,
- subtract_intervals,
-)
+from advanced_omi_backend.utils.vad_analysis import analyze_conversation_audio
logger = logging.getLogger(__name__)
@@ -445,49 +443,6 @@ def _progress(done: int, label: str) -> None:
return summary
-async def _collect_raw_intervals(
- conversation_id: str, threshold: float
-) -> Tuple[Optional[List[List[float]]], float, int]:
- """Raw speech intervals from cached chunk frame scores (streaming cursor).
-
- Returns (intervals, last_chunk_end_seconds, sample_rate); intervals is
- None when any chunk lacks VAD scores (caller should analyze first).
- """
- collection = AudioChunkDocument.get_pymongo_collection()
- cursor = collection.find(
- {"conversation_id": conversation_id},
- {
- "start_time": 1,
- "end_time": 1,
- "sample_rate": 1,
- "vad.scores": 1,
- "vad.frame_hop_ms": 1,
- },
- ).sort("chunk_index", 1)
-
- intervals: List[List[float]] = []
- last_end = 0.0
- sample_rate = 16000
- first = True
- async for chunk in cursor:
- if first:
- sample_rate = int(chunk.get("sample_rate") or 16000)
- first = False
- vad = chunk.get("vad")
- if not vad or vad.get("scores") is None:
- return None, 0.0, sample_rate
- intervals.extend(
- frame_speech_intervals(
- vad["scores"],
- float(vad["frame_hop_ms"]) / 1000.0,
- float(chunk["start_time"]),
- threshold=threshold,
- )
- )
- last_end = float(chunk["end_time"])
- return intervals, last_end, sample_rate
-
-
async def _export_conversation_clips(
zf: zipfile.ZipFile,
conv: Conversation,
@@ -496,82 +451,65 @@ async def _export_conversation_clips(
speech_threshold: float,
merge_gap_seconds: float,
excluded_ranges: Optional[List[List[float]]] = None,
-) -> Tuple[List[dict], float, float]:
+ dropped_ranges: Optional[List[List[float]]] = None,
+) -> Tuple[List[dict], float, float, float]:
"""Write the conversation's WAV clip(s) into the zip; return its manifest
- records, total clipped seconds, and excluded (withheld) seconds.
+ records, total clipped seconds, excluded (privacy-withheld) seconds, and
+ dropped (preview-unticked) seconds.
- Mode ``clips``: one padded WAV per VAD speech region (silence cropped).
- Mode ``full``: a single untouched WAV spanning the whole conversation —
- no VAD needed.
-
- ``excluded_ranges`` (absolute conversation seconds, from the privacy
- screen) are carved out of the regions so the withheld audio + its
- transcript never enter a clip.
+ The clip boundaries come from ``plan_conversation_clips`` — the same
+ computation the preview endpoint serves — so what the user approved is
+ exactly what gets written. Unanalyzed audio gets VAD run inline
+ (idempotent) and the plan retried.
"""
- cid = conv.conversation_id
-
- if mode == "full":
- duration = conv.audio_total_duration or 0.0
- if duration <= 0:
- raise ValueError("Conversation has no audio duration")
- regions = [[0.0, duration]]
- first = await AudioChunkDocument.find_one(
- AudioChunkDocument.conversation_id == cid
- )
- sample_rate = first.sample_rate if first else 16000
- else:
- intervals, last_end, sample_rate = await _collect_raw_intervals(
- cid, speech_threshold
- )
- if intervals is None:
- # Unanalyzed audio — run VAD inline (idempotent), then retry.
- if await _analyze_and_store(conv) is None:
- raise ValueError("VAD analysis failed")
- intervals, last_end, sample_rate = await _collect_raw_intervals(
- cid, speech_threshold
- )
- if intervals is None:
- raise ValueError("VAD scores missing after analysis")
-
- duration = conv.audio_total_duration or last_end
- # Cached speech_regions are built with the default 0.3s pad — always
- # re-merge here so the export honors the requested padding.
- regions = merge_speech_regions(
- intervals,
- duration,
- pad_seconds=pad_seconds,
- merge_gap_seconds=merge_gap_seconds,
+ plan = await plan_conversation_clips(
+ conv,
+ mode,
+ pad_seconds,
+ speech_threshold,
+ merge_gap_seconds,
+ excluded_ranges,
+ dropped_ranges,
+ )
+ if plan.skipped_reason == "not analyzed":
+ if await _analyze_and_store(conv) is None:
+ raise ValueError("VAD analysis failed")
+ plan = await plan_conversation_clips(
+ conv,
+ mode,
+ pad_seconds,
+ speech_threshold,
+ merge_gap_seconds,
+ excluded_ranges,
+ dropped_ranges,
)
-
- # Carve out privacy-screened ranges so withheld audio/transcript is
- # never written. Done after padding/merge so padding can't re-expose a cut.
- kept_seconds = sum(t1 - t0 for t0, t1 in regions)
- if excluded_ranges:
- regions = subtract_intervals(regions, excluded_ranges)
- excluded_seconds = kept_seconds - sum(t1 - t0 for t0, t1 in regions)
+ if plan.skipped_reason == "not analyzed":
+ raise ValueError("VAD scores missing after analysis")
+ if plan.skipped_reason:
+ raise ValueError(plan.skipped_reason.capitalize())
segments = active_segments(conv)
created_at = conv.created_at.isoformat() if conv.created_at else None
records: List[dict] = []
- clip_seconds = 0.0
- for i, (t0, t1) in enumerate(regions):
- wav = await reconstruct_audio_segment(cid, t0, t1)
+ for clip in plan.clips:
+ wav = await reconstruct_audio_segment(
+ conv.conversation_id, clip.start, clip.end
+ )
record = build_clip_record(
- conversation_id=cid,
+ conversation_id=conv.conversation_id,
conversation_title=conv.title,
client_id=conv.client_id,
conversation_created_at=created_at,
- clip_index=i,
- region_start=t0,
- region_end=t1,
- sample_rate=sample_rate,
- segments=slice_segments(segments, t0, t1),
+ clip_index=clip.clip_index,
+ region_start=clip.start,
+ region_end=clip.end,
+ sample_rate=plan.sample_rate,
+ segments=slice_segments(segments, clip.start, clip.end),
)
zf.writestr(record["audio_path"], wav)
records.append(record)
- clip_seconds += t1 - t0
- return records, clip_seconds, round(max(0.0, excluded_seconds), 2)
+ return records, plan.clip_seconds, plan.excluded_seconds, plan.dropped_seconds
@async_job(redis=False, beanie=True, timeout=3600)
@@ -584,6 +522,7 @@ async def export_annotation_dataset_job(
speech_threshold: float = 0.5,
merge_gap_seconds: float = 3.0,
excluded_ranges: Optional[Dict[str, List[List[float]]]] = None,
+ dropped_ranges: Optional[Dict[str, List[List[float]]]] = None,
sensitivity_policy: Optional[str] = None,
) -> Dict[str, Any]:
"""Build an annotation dataset zip for the selected conversations.
@@ -597,15 +536,17 @@ async def export_annotation_dataset_job(
the download endpoint.
``excluded_ranges`` maps ``conversation_id`` → withheld time ranges (from
- the privacy screen); those ranges are carved out of each conversation's
- audio and transcript. ``sensitivity_policy`` is recorded in the metadata
- for auditability.
+ the privacy screen) and ``dropped_ranges`` → clips the user unticked in
+ the export preview; both are carved out of each conversation's audio and
+ transcript, accounted separately. ``sensitivity_policy`` is recorded in
+ the metadata for auditability.
Per-conversation failures are recorded as ``skipped_reason``; the job
only raises on export-level failures (e.g. disk errors).
"""
start = time.time()
excluded_ranges = excluded_ranges or {}
+ dropped_ranges = dropped_ranges or {}
user = await User.get(PydanticObjectId(user_id))
is_super = bool(user and user.is_superuser)
@@ -617,6 +558,7 @@ async def export_annotation_dataset_job(
manifest_records: List[dict] = []
total_clip_seconds = 0.0
total_excluded_seconds = 0.0
+ total_dropped_seconds = 0.0
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for cid in dict.fromkeys(conversation_ids):
@@ -630,19 +572,12 @@ async def export_annotation_dataset_job(
summary["title"] = conv.title
summary["client_id"] = conv.client_id
- if not conv:
- summary["skipped_reason"] = "not found"
- elif not is_super and conv.user_id != user_id:
- summary["skipped_reason"] = "access forbidden"
- elif conv.deleted:
- summary["skipped_reason"] = "deleted"
- elif conv.audio_archived:
- summary["skipped_reason"] = "audio archived"
- elif not conv.audio_chunks_count:
- summary["skipped_reason"] = "no audio"
+ skipped = export_eligibility(conv, user_id, is_super)
+ if skipped:
+ summary["skipped_reason"] = skipped
else:
try:
- records, clip_seconds, excluded_seconds = (
+ records, clip_seconds, excluded_seconds, dropped_seconds = (
await _export_conversation_clips(
zf,
conv,
@@ -651,15 +586,19 @@ async def export_annotation_dataset_job(
speech_threshold,
merge_gap_seconds,
excluded_ranges.get(cid),
+ dropped_ranges.get(cid),
)
)
manifest_records.extend(records)
total_clip_seconds += clip_seconds
total_excluded_seconds += excluded_seconds
+ total_dropped_seconds += dropped_seconds
summary["clip_count"] = len(records)
summary["clip_seconds"] = round(clip_seconds, 2)
if excluded_seconds > 0:
summary["excluded_seconds"] = excluded_seconds
+ if dropped_seconds > 0:
+ summary["dropped_seconds"] = dropped_seconds
except Exception as e:
logger.exception(f"Export failed for conversation {cid[:12]}")
summary["skipped_reason"] = f"error: {e}"
@@ -678,6 +617,7 @@ async def export_annotation_dataset_job(
"merge_gap_seconds": merge_gap_seconds,
"screened": bool(excluded_ranges),
"sensitivity_policy": sensitivity_policy if excluded_ranges else None,
+ "curated": bool(dropped_ranges),
},
"conversations": conv_summaries,
"totals": {
@@ -686,6 +626,7 @@ async def export_annotation_dataset_job(
"clip_count": len(manifest_records),
"total_clip_seconds": round(total_clip_seconds, 2),
"excluded_seconds": round(total_excluded_seconds, 2),
+ "dropped_seconds": round(total_dropped_seconds, 2),
},
}
zf.writestr(
diff --git a/backends/advanced/tests/test_export_planning.py b/backends/advanced/tests/test_export_planning.py
new file mode 100644
index 000000000..a69df7c87
--- /dev/null
+++ b/backends/advanced/tests/test_export_planning.py
@@ -0,0 +1,342 @@
+"""Tests for the shared export clip planner and the export-preview flow.
+
+The planner (``utils/export_planning.py``) is the single source of clip
+boundaries for both the export job and the preview endpoint, so these tests
+pin the properties the preview → curate → export loop relies on: preview and
+export agree, dropped clips disappear exactly, and privacy vs curation
+carve-outs are accounted separately.
+"""
+
+import json
+from datetime import datetime, timezone
+from types import SimpleNamespace
+
+import pytest
+
+from advanced_omi_backend.controllers import data_audit_controller
+from advanced_omi_backend.models.audio_chunk import AudioChunkDocument
+from advanced_omi_backend.models.conversation import Conversation
+from advanced_omi_backend.utils.export_planning import (
+ export_eligibility,
+ plan_conversation_clips,
+)
+
+
+def _conv(**overrides):
+ base = dict(
+ conversation_id="conv-1",
+ title="Test conversation",
+ client_id="user01-phone",
+ created_at=datetime(2026, 8, 1, tzinfo=timezone.utc),
+ user_id="user-1",
+ deleted=False,
+ audio_archived=False,
+ audio_chunks_count=3,
+ audio_total_duration=60.0,
+ transcript_versions=[],
+ active_transcript_version=None,
+ )
+ base.update(overrides)
+ return SimpleNamespace(**base)
+
+
+class _ChunkCursor:
+ def __init__(self, docs):
+ self.docs = docs
+
+ def sort(self, *_args):
+ return self
+
+ def __aiter__(self):
+ self._it = iter(self.docs)
+ return self
+
+ async def __anext__(self):
+ try:
+ return next(self._it)
+ except StopIteration:
+ raise StopAsyncIteration
+
+
+def _chunk(start: float, end: float, scores, hop_ms: float = 100.0):
+ return {
+ "start_time": start,
+ "end_time": end,
+ "sample_rate": 16000,
+ "vad": {"scores": scores, "frame_hop_ms": hop_ms},
+ }
+
+
+def _mock_chunks(monkeypatch, docs):
+ collection = SimpleNamespace(find=lambda *_a, **_k: _ChunkCursor(docs))
+ monkeypatch.setattr(
+ AudioChunkDocument, "get_pymongo_collection", lambda: collection
+ )
+
+
+# Two speech runs: 2.0–5.0s and 20.0–24.0s (frames at 100ms hop).
+def _two_region_chunks():
+ scores = [0.0] * 600
+ for i in range(20, 50):
+ scores[i] = 0.9
+ for i in range(200, 240):
+ scores[i] = 0.9
+ return [_chunk(0.0, 60.0, scores)]
+
+
+class TestPlanConversationClips:
+ @pytest.mark.asyncio
+ async def test_clips_mode_pads_and_keeps_separate_regions(self, monkeypatch):
+ _mock_chunks(monkeypatch, _two_region_chunks())
+ plan = await plan_conversation_clips(
+ _conv(),
+ "clips",
+ pad_seconds=1.0,
+ speech_threshold=0.5,
+ merge_gap_seconds=3.0,
+ )
+ assert plan.skipped_reason is None
+ assert [(c.start, c.end) for c in plan.clips] == [(1.0, 6.0), (19.0, 25.0)]
+ assert plan.excluded_seconds == 0.0
+ assert plan.dropped_seconds == 0.0
+ assert plan.sample_rate == 16000
+
+ @pytest.mark.asyncio
+ async def test_wide_merge_gap_joins_regions(self, monkeypatch):
+ _mock_chunks(monkeypatch, _two_region_chunks())
+ plan = await plan_conversation_clips(
+ _conv(),
+ "clips",
+ pad_seconds=1.0,
+ speech_threshold=0.5,
+ merge_gap_seconds=30.0,
+ )
+ assert [(c.start, c.end) for c in plan.clips] == [(1.0, 25.0)]
+
+ @pytest.mark.asyncio
+ async def test_unanalyzed_audio_is_reported_not_analyzed(self, monkeypatch):
+ chunk = _chunk(0.0, 60.0, [0.9] * 600)
+ chunk["vad"] = None
+ _mock_chunks(monkeypatch, [chunk])
+ plan = await plan_conversation_clips(
+ _conv(),
+ "clips",
+ pad_seconds=1.0,
+ speech_threshold=0.5,
+ merge_gap_seconds=3.0,
+ )
+ assert plan.skipped_reason == "not analyzed"
+ assert plan.clips == []
+
+ @pytest.mark.asyncio
+ async def test_dropping_a_previewed_clip_removes_exactly_that_clip(
+ self, monkeypatch
+ ):
+ """The curation contract: unticking a clip in the preview and passing
+ its exact [start, end] as a dropped range removes that clip and only
+ that clip from a recomputed plan."""
+ _mock_chunks(monkeypatch, _two_region_chunks())
+ preview = await plan_conversation_clips(
+ _conv(),
+ "clips",
+ 1.0,
+ 0.5,
+ 3.0,
+ )
+ dropped = preview.clips[0]
+ plan = await plan_conversation_clips(
+ _conv(),
+ "clips",
+ 1.0,
+ 0.5,
+ 3.0,
+ dropped_ranges=[[dropped.start, dropped.end]],
+ )
+ assert [(c.start, c.end) for c in plan.clips] == [(19.0, 25.0)]
+ assert plan.dropped_seconds == 5.0
+ assert plan.excluded_seconds == 0.0
+
+ @pytest.mark.asyncio
+ async def test_privacy_and_curation_carves_are_accounted_separately(
+ self, monkeypatch
+ ):
+ _mock_chunks(monkeypatch, _two_region_chunks())
+ plan = await plan_conversation_clips(
+ _conv(),
+ "clips",
+ 1.0,
+ 0.5,
+ 3.0,
+ excluded_ranges=[[2.0, 4.0]], # privacy: carve inside clip 1
+ dropped_ranges=[[19.0, 25.0]], # curation: drop clip 2 whole
+ )
+ assert plan.excluded_seconds == 2.0
+ assert plan.dropped_seconds == 6.0
+ # Clip 1 splits around the privacy cut; clip 2 is gone.
+ assert [(c.start, c.end) for c in plan.clips] == [(1.0, 2.0), (4.0, 6.0)]
+
+ @pytest.mark.asyncio
+ async def test_full_mode_is_one_untouched_region(self, monkeypatch):
+ async def _no_chunk(*_a, **_k):
+ return None
+
+ # Uninitialized Beanie models raise on field access — give the class a
+ # plain attribute so the planner's find_one filter expression evaluates.
+ monkeypatch.setattr(
+ AudioChunkDocument, "conversation_id", "field", raising=False
+ )
+ monkeypatch.setattr(AudioChunkDocument, "find_one", _no_chunk)
+ plan = await plan_conversation_clips(
+ _conv(audio_total_duration=42.5),
+ "full",
+ 1.0,
+ 0.5,
+ 3.0,
+ )
+ assert [(c.start, c.end) for c in plan.clips] == [(0.0, 42.5)]
+
+
+class TestExportEligibility:
+ def test_owner_with_audio_is_eligible(self):
+ assert export_eligibility(_conv(), "user-1", False) is None
+
+ def test_reasons(self):
+ assert export_eligibility(None, "user-1", False) == "not found"
+ assert (
+ export_eligibility(_conv(user_id="other"), "user-1", False)
+ == "access forbidden"
+ )
+ assert export_eligibility(_conv(user_id="other"), "user-1", True) is None
+ assert export_eligibility(_conv(deleted=True), "user-1", False) == "deleted"
+ assert (
+ export_eligibility(_conv(audio_archived=True), "user-1", False)
+ == "audio archived"
+ )
+ assert (
+ export_eligibility(_conv(audio_chunks_count=0), "user-1", False)
+ == "no audio"
+ )
+
+
+class _FakeConversationCls:
+ """Stands in for the Beanie model in controller tests: the field's
+ ``==`` returns the queried id so ``find_one`` can look it up."""
+
+ docs: dict = {}
+
+ class _Field:
+ def __eq__(self, other):
+ return other
+
+ conversation_id = _Field()
+
+ @classmethod
+ async def find_one(cls, cid):
+ return cls.docs.get(cid)
+
+
+def _segment(start, end, text, speaker="speaker_0"):
+ return Conversation.SpeakerSegment(start=start, end=end, text=text, speaker=speaker)
+
+
+class TestPreviewExport:
+ @pytest.mark.asyncio
+ async def test_preview_returns_clips_with_sliced_transcripts(self, monkeypatch):
+ segments = [
+ _segment(2.0, 4.0, "hello there"),
+ _segment(21.0, 23.0, "second clip words"),
+ ]
+ version = SimpleNamespace(version_id="v1", segments=segments)
+ conv = _conv(transcript_versions=[version], active_transcript_version="v1")
+ _FakeConversationCls.docs = {"conv-1": conv}
+ monkeypatch.setattr(data_audit_controller, "Conversation", _FakeConversationCls)
+ _mock_chunks(monkeypatch, _two_region_chunks())
+ user = SimpleNamespace(is_superuser=False, user_id="user-1")
+
+ result = await data_audit_controller.preview_export(
+ user,
+ ["conv-1", "missing"],
+ mode="clips",
+ )
+
+ assert result["totals"]["conversation_count"] == 2
+ assert result["totals"]["exported_conversations"] == 1
+ assert result["totals"]["clip_count"] == 2
+ previewed, missing = result["conversations"]
+ assert missing["skipped_reason"] == "not found"
+ clips = previewed["clips"]
+ assert [c["clip_id"] for c in clips] == ["conv-1_000", "conv-1_001"]
+ assert clips[0]["text"] == "hello there"
+ assert clips[1]["text"] == "second clip words"
+ assert clips[0]["segment_count"] == 1
+ assert previewed["clip_seconds"] == 11.0
+
+ @pytest.mark.asyncio
+ async def test_preview_reports_unanalyzed_instead_of_running_vad(self, monkeypatch):
+ conv = _conv()
+ _FakeConversationCls.docs = {"conv-1": conv}
+ monkeypatch.setattr(data_audit_controller, "Conversation", _FakeConversationCls)
+ chunk = _chunk(0.0, 60.0, [])
+ chunk["vad"] = None
+ _mock_chunks(monkeypatch, [chunk])
+ user = SimpleNamespace(is_superuser=False, user_id="user-1")
+
+ result = await data_audit_controller.preview_export(user, ["conv-1"])
+
+ assert result["conversations"][0]["skipped_reason"] == "not analyzed"
+ assert result["totals"]["exported_conversations"] == 0
+
+
+class TestLatestExportsByConversation:
+ def _write_export(self, exports_dir, export_id, created_at, conversations):
+ d = exports_dir / export_id
+ d.mkdir(parents=True)
+ (d / "export.json").write_text(
+ json.dumps(
+ {
+ "export_id": export_id,
+ "created_at": created_at,
+ "created_by": "user-1",
+ "conversations": conversations,
+ }
+ )
+ )
+
+ def test_latest_export_wins_and_skipped_do_not_count(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(data_audit_controller, "EXPORTS_DIR", tmp_path)
+ self._write_export(
+ tmp_path,
+ "annotation_20260601_000000_aaaa",
+ "2026-06-01T00:00:00+00:00",
+ [
+ {"conversation_id": "c1"},
+ {"conversation_id": "c2", "skipped_reason": "no audio"},
+ ],
+ )
+ self._write_export(
+ tmp_path,
+ "annotation_20260701_000000_bbbb",
+ "2026-07-01T00:00:00+00:00",
+ [{"conversation_id": "c1"}, {"conversation_id": "c3"}],
+ )
+ user = SimpleNamespace(is_superuser=False, user_id="user-1")
+
+ latest = data_audit_controller._latest_exports_by_conversation(user)
+
+ assert latest["c1"]["export_id"] == "annotation_20260701_000000_bbbb"
+ assert latest["c3"]["export_id"] == "annotation_20260701_000000_bbbb"
+ assert "c2" not in latest # skipped conversations were never shipped
+
+ def test_other_users_exports_are_invisible(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(data_audit_controller, "EXPORTS_DIR", tmp_path)
+ self._write_export(
+ tmp_path,
+ "annotation_20260601_000000_aaaa",
+ "2026-06-01T00:00:00+00:00",
+ [{"conversation_id": "c1"}],
+ )
+ stranger = SimpleNamespace(is_superuser=False, user_id="user-2")
+ superuser = SimpleNamespace(is_superuser=True, user_id="admin")
+
+ assert data_audit_controller._latest_exports_by_conversation(stranger) == {}
+ assert "c1" in data_audit_controller._latest_exports_by_conversation(superuser)
diff --git a/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx b/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx
index d09ae705d..b96ba39e5 100644
--- a/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx
@@ -257,6 +257,13 @@ export default function AuditTable({
{r.derived_operation && (
{r.derived_operation}
)}
+ {r.last_export && (
+
+ exported
+
+ )}
{(() => {
const chip = processingStatusChip(r.processing_status, r.failure_stage)
return chip ? (
diff --git a/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx b/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx
index 9c3dd973b..b9430810f 100644
--- a/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx
@@ -5,17 +5,22 @@ import {
HelpCircle,
Loader2,
PackageOpen,
+ Pause,
+ Play,
ShieldCheck,
Trash2,
} from 'lucide-react'
import {
AuditConversation,
+ ExportPreviewClip,
+ ExportPreviewResult,
ExportRecord,
ScreenConversationReport,
ScreenResult,
dataAuditApi,
} from '../../services/api'
-import { Alert, Button, Modal, Textarea } from '../../components/ui'
+import { Alert, Button, Modal, StateBadge, Textarea } from '../../components/ui'
+import { useGaplessPlayer } from '../../hooks/useGaplessPlayer'
import { useJobPolling } from '../../hooks/useJobPolling'
import { formatDate, formatDuration } from './format'
@@ -46,6 +51,12 @@ function Hint({ text }: { text: string }) {
// A flagged segment is keyed by conversation + its segment index.
const segKey = (cid: string, index: number) => `${cid}:${index}`
+// A previewed clip is keyed by conversation + its exact boundaries, so a
+// boundary change after re-preview (different clip) naturally resets the
+// include/drop decision instead of applying it to the wrong audio.
+const clipKey = (cid: string, clip: ExportPreviewClip) =>
+ `${cid}@${clip.start}-${clip.end}`
+
// 16 kHz mono 16-bit PCM — what exported WAV clips contain (pre-zip).
const WAV_BYTES_PER_SECOND = 32000
@@ -80,6 +91,14 @@ export default function ExportModal({ selected, onClose }: Props) {
// Flagged segments the user has chosen to withhold (default: all flagged).
const [excluded, setExcluded] = useState>(new Set())
+ // Contents preview (dry-run): the exact clips the current settings would
+ // ship, from the same plan computation the export job runs.
+ const [preview, setPreview] = useState(null)
+ const [previewing, setPreviewing] = useState(false)
+ const [previewError, setPreviewError] = useState(null)
+ // Clips unticked in the preview (keyed by exact boundaries — see clipKey).
+ const [dropped, setDropped] = useState>(new Set())
+
// Run state
const [exporting, setExporting] = useState(false)
const [status, setStatus] = useState(null)
@@ -246,6 +265,21 @@ export default function ExportModal({ selected, onClose }: Props) {
return ranges
}
+ // dropped_ranges for the export request: each unticked clip's exact
+ // [start, end], so the job carves out precisely what the user reviewed away.
+ const buildDroppedRanges = (): Record => {
+ const ranges: Record = {}
+ for (const conv of preview?.conversations ?? []) {
+ const picked = (conv.clips ?? []).filter((c) =>
+ dropped.has(clipKey(conv.conversation_id, c))
+ )
+ if (picked.length) {
+ ranges[conv.conversation_id] = picked.map((c) => [c.start, c.end])
+ }
+ }
+ return ranges
+ }
+
const runExport = async () => {
setExporting(true)
setError(null)
@@ -260,6 +294,7 @@ export default function ExportModal({ selected, onClose }: Props) {
speech_threshold: speechThreshold,
merge_gap_seconds: mergeGap,
excluded_ranges: excludedRanges,
+ dropped_ranges: buildDroppedRanges(),
sensitivity_policy:
screenEnabled && Object.keys(excludedRanges).length ? policy : null,
}
@@ -310,6 +345,78 @@ export default function ExportModal({ selected, onClose }: Props) {
const totalFlagged = screenResult?.totals.flagged_segments ?? 0
const totalExcluded = excluded.size
+ // ── Contents preview ──────────────────────────────────────────────────────
+ // Auto-refresh (debounced) whenever anything that changes the plan changes:
+ // selection, mode, clip params, or the privacy-screen withholdings. The
+ // response is the export job's own plan computation, so what's listed here
+ // is exactly what the zip would contain.
+ const screenRanges = screenEnabled && resultValid ? buildExcludedRanges() : {}
+ const previewSig = JSON.stringify({
+ idsSig,
+ mode,
+ padSeconds,
+ speechThreshold,
+ mergeGap,
+ screenRanges,
+ })
+ useEffect(() => {
+ if (selected.length === 0) return
+ let cancelled = false
+ setPreviewing(true)
+ setPreviewError(null)
+ const timer = setTimeout(() => {
+ dataAuditApi
+ .previewExport(
+ selected.map((c) => c.conversation_id),
+ {
+ mode,
+ pad_seconds: padSeconds,
+ speech_threshold: speechThreshold,
+ merge_gap_seconds: mergeGap,
+ excluded_ranges: screenRanges,
+ }
+ )
+ .then((res) => {
+ if (cancelled) return
+ setPreview(res.data)
+ setPreviewing(false)
+ })
+ .catch((e) => {
+ if (cancelled) return
+ setPreviewError(e?.response?.data?.error || 'Failed to preview export contents')
+ setPreviewing(false)
+ })
+ }, 400)
+ return () => {
+ cancelled = true
+ clearTimeout(timer)
+ }
+ // previewSig captures every input the request uses
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [previewSig])
+
+ // Live totals for what will actually ship (preview minus unticked clips).
+ const included = (() => {
+ let clips = 0
+ let seconds = 0
+ let droppedClips = 0
+ let droppedSeconds = 0
+ let unanalyzed = 0
+ for (const conv of preview?.conversations ?? []) {
+ if (conv.skipped_reason === 'not analyzed') unanalyzed += 1
+ for (const c of conv.clips ?? []) {
+ if (dropped.has(clipKey(conv.conversation_id, c))) {
+ droppedClips += 1
+ droppedSeconds += c.duration_seconds
+ } else {
+ clips += 1
+ seconds += c.duration_seconds
+ }
+ }
+ }
+ return { clips, seconds, droppedClips, droppedSeconds, unanalyzed }
+ })()
+
// Dataset-impact estimate, live-updated as withhold toggles change.
// Baseline = what the export would contain without the screen: full mode is
// the exact summed duration; clips mode estimates speech via the cached
@@ -356,7 +463,7 @@ export default function ExportModal({ selected, onClose }: Props) {
onClose={onClose}
title="Export for annotation"
icon={}
- maxWidthClassName="max-w-2xl"
+ maxWidthClassName="max-w-3xl"
className="max-h-[85vh] overflow-y-auto"
footer={
@@ -545,12 +652,30 @@ export default function ExportModal({ selected, onClose }: Props) {
)}
+ {/* Contents preview: listen + curate exactly what will ship */}
+ {selected.length > 0 && (
+
+ setDropped((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ return next
+ })
+ }
+ />
+ )}
+
: undefined}
>
{screening
@@ -561,7 +686,12 @@ export default function ExportModal({ selected, onClose }: Props) {
? status || 'Exporting…'
: needsScreenFirst
? `Screen ${selected.length} conversation${selected.length === 1 ? '' : 's'}`
- : `Export ${selected.length} conversation${selected.length === 1 ? '' : 's'}` +
+ : (preview
+ ? `Export ${included.clips} clip${included.clips === 1 ? '' : 's'} · ${formatDuration(included.seconds)}`
+ : `Export ${selected.length} conversation${selected.length === 1 ? '' : 's'}`) +
+ (included.droppedClips > 0
+ ? ` · ${included.droppedClips} dropped`
+ : '') +
(screenEnabled && totalExcluded > 0
? ` · withholding ${totalExcluded} segment${totalExcluded === 1 ? '' : 's'}`
: '')}
@@ -668,6 +798,189 @@ export default function ExportModal({ selected, onClose }: Props) {
)
}
+/**
+ * Contents preview: every clip the export would produce, grouped by
+ * conversation — play it, read its transcript slice, and untick the junk.
+ * Backed by the export job's own plan computation, so this list IS the
+ * dataset; unticked clips are carved out via dropped_ranges.
+ */
+function PreviewPanel({
+ preview,
+ previewing,
+ error,
+ dropped,
+ onToggle,
+}: {
+ preview: ExportPreviewResult | null
+ previewing: boolean
+ error: string | null
+ dropped: Set
+ onToggle: (key: string) => void
+}) {
+ const player = useGaplessPlayer()
+ // Which clip this panel last started (play state itself lives in the player).
+ const [playingKey, setPlayingKey] = useState(null)
+ const lastCidRef = useRef(null)
+
+ // Stop playback this panel started when the modal closes.
+ useEffect(() => {
+ return () => {
+ if (lastCidRef.current && player.isActive(lastCidRef.current)) player.stop()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const togglePlay = (cid: string, clip: ExportPreviewClip) => {
+ const key = clipKey(cid, clip)
+ if (playingKey === key && player.isActive(cid)) {
+ if (player.isPlaying) {
+ player.pause()
+ return
+ }
+ if (player.isPaused) {
+ player.resume()
+ return
+ }
+ }
+ lastCidRef.current = cid
+ setPlayingKey(key)
+ player.playProgram(cid, [{ start: clip.start, end: clip.end }])
+ }
+
+ if (error) {
+ return (
+ }>
+ {error}
+
+ )
+ }
+ if (!preview) {
+ return (
+
+
+ Computing dataset contents…
+
+ )
+ }
+
+ const unanalyzed = preview.conversations.filter(
+ (c) => c.skipped_reason === 'not analyzed'
+ )
+ const otherSkipped = preview.conversations.filter(
+ (c) => c.skipped_reason && c.skipped_reason !== 'not analyzed'
+ )
+
+ return (
+
+
+
+ Dataset contents
+
+
+
+ {previewing && }
+
+ {preview.totals.clip_count} clip{preview.totals.clip_count === 1 ? '' : 's'} ·{' '}
+ {formatDuration(preview.totals.total_clip_seconds)} ·{' '}
+ {formatBytes(preview.totals.total_clip_seconds * WAV_BYTES_PER_SECOND)}
+
+
+
+
+ {unanalyzed.length > 0 && (
+
+ {unanalyzed.length} conversation{unanalyzed.length === 1 ? '' : 's'} not
+ analyzed — the export will run VAD and include them unreviewed. Run{' '}
+ Analyze audio first to preview them here.
+
+ )}
+ {otherSkipped.length > 0 && (
+
+ {otherSkipped.length} skipped:{' '}
+ {otherSkipped
+ .map((c) => `${c.title || c.conversation_id.slice(0, 8)} (${c.skipped_reason})`)
+ .join(', ')}
+
+ )}
+
+
+ {preview.conversations
+ .filter((c) => (c.clips?.length ?? 0) > 0)
+ .map((conv) => (
+
+
+
+ {conv.title || conv.conversation_id.slice(0, 8)}
+
+
+ {conv.clips!.length} clip{conv.clips!.length === 1 ? '' : 's'} ·{' '}
+ {formatDuration(conv.clip_seconds ?? 0)}
+
+
+ {conv.clips!.map((clip) => {
+ const key = clipKey(conv.conversation_id, clip)
+ const isDropped = dropped.has(key)
+ const isCurrent =
+ playingKey === key && player.isActive(conv.conversation_id)
+ const playing = isCurrent && player.isPlaying
+ return (
+
+ onToggle(key)}
+ title={isDropped ? 'Include this clip' : 'Drop this clip from the export'}
+ className="mt-1"
+ />
+ togglePlay(conv.conversation_id, clip)}
+ title={playing ? 'Pause' : 'Play this clip'}
+ aria-label={playing ? 'Pause clip' : `Play clip ${clip.clip_index + 1}`}
+ className={`flex-shrink-0 p-1 rounded-full transition-colors ${
+ isCurrent
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
+ : 'text-gray-400 hover:text-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700'
+ }`}
+ >
+ {playing ? : }
+
+
+ {formatDuration(clip.duration_seconds)}
+
+
+ {clip.text ? (
+
+ {clip.text}
+
+ ) : (
+
+ no transcript
+
+ )}
+
+
+ )
+ })}
+
+ ))}
+
+
+ )
+}
+
/** Review panel: flagged segments grouped by conversation, each a withhold toggle. */
function ScreenReview({
report,
diff --git a/backends/advanced/webui/src/components/dataAudit/filters.tsx b/backends/advanced/webui/src/components/dataAudit/filters.tsx
index 2bd84ed7b..f472cf01c 100644
--- a/backends/advanced/webui/src/components/dataAudit/filters.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/filters.tsx
@@ -15,6 +15,7 @@ import {
FileArchive,
LucideIcon,
Mic,
+ PackageOpen,
Search,
Users,
} from 'lucide-react'
@@ -408,6 +409,49 @@ const datasetFilter: FilterDef = {
),
}
+// ---------------------------------------------------------------------------
+// Export history (from the on-disk annotation-export metadata)
+// ---------------------------------------------------------------------------
+
+type ExportedValue = '' | 'never' | 'exported'
+
+const exportedFilter: FilterDef = {
+ key: 'exported',
+ label: 'Export history',
+ icon: PackageOpen,
+ defaultValue: '',
+ isActive: (v) => v !== '',
+ chipLabel: (v) => (v === 'never' ? 'Not yet exported' : 'Previously exported'),
+ toParams: (v) => ({ exported: v || undefined }),
+ Editor: ({ value, onChange }) => (
+
+ {(
+ [
+ { key: '', label: 'All conversations' },
+ { key: 'never', label: 'Not yet exported' },
+ { key: 'exported', label: 'Previously exported' },
+ ] as const
+ ).map((opt) => (
+
+ ))}
+
+ Whether a previous annotation export shipped the conversation.
+
+
+ ),
+}
+
// ---------------------------------------------------------------------------
// Hide failed (processing_status == 'failed')
// ---------------------------------------------------------------------------
@@ -444,6 +488,7 @@ export const AUDIT_FILTERS: FilterDef[] = [
speakersFilter,
dateFilter,
datasetFilter,
+ exportedFilter,
hideFailedFilter,
hideReviewedFilter,
]
diff --git a/backends/advanced/webui/src/services/api.ts b/backends/advanced/webui/src/services/api.ts
index feaf21081..048125327 100644
--- a/backends/advanced/webui/src/services/api.ts
+++ b/backends/advanced/webui/src/services/api.ts
@@ -843,6 +843,9 @@ export interface AuditConversation {
analyzed: boolean
speech_fraction: number | null
derived_operation: 'split' | 'merge' | null
+ // Most recent annotation export that shipped this conversation (null = never
+ // exported) — lets curation sessions skip audio already sent to annotators.
+ last_export: { export_id: string; created_at: string } | null
audio_archived: boolean
audio_archived_at: string | null
archive_reason: string | null
@@ -1051,6 +1054,42 @@ export interface ExportRecord {
zip_ready: boolean
}
+// One clip the export would produce, as returned by the preview dry-run.
+export interface ExportPreviewClip {
+ clip_index: number
+ clip_id: string
+ start: number
+ end: number
+ duration_seconds: number
+ // The sliced transcript this clip's manifest record would carry ('' = no
+ // transcript covers the clip).
+ text: string
+ segment_count: number
+}
+
+export interface ExportPreviewConversation {
+ conversation_id: string
+ title: string | null
+ client_id: string | null
+ created_at: string | null
+ skipped_reason?: string
+ clips?: ExportPreviewClip[]
+ sample_rate?: number
+ clip_seconds?: number
+ excluded_seconds?: number
+}
+
+export interface ExportPreviewResult {
+ conversations: ExportPreviewConversation[]
+ totals: {
+ conversation_count: number
+ exported_conversations: number
+ clip_count: number
+ total_clip_seconds: number
+ excluded_seconds: number
+ }
+}
+
// One transcript segment flagged by the privacy screen as too sensitive to share.
export interface ScreenFlaggedSegment {
index: number
@@ -1776,9 +1815,28 @@ export const dataAuditApi = {
policy: policy ?? null,
}),
+ // Dry-run of the export: the exact clips (boundaries + sliced transcripts)
+ // the current settings would produce, without writing any audio. Runs the
+ // same plan computation as the export job, so what it shows is what ships.
+ previewExport: (
+ conversationIds: string[],
+ params?: {
+ mode?: 'clips' | 'full'
+ pad_seconds?: number
+ speech_threshold?: number
+ merge_gap_seconds?: number
+ excluded_ranges?: Record
+ }
+ ) =>
+ api.post('/api/data-audit/export/preview', {
+ conversation_ids: conversationIds,
+ ...params,
+ }),
+
// Enqueue an annotation-dataset export (speech-cropped clips + manifest).
// `excluded_ranges` (conversation_id → withheld [start,end] ranges from the
- // privacy screen) are carved out of the exported audio + transcript.
+ // privacy screen) and `dropped_ranges` (clips unticked in the preview) are
+ // carved out of the exported audio + transcript.
// Returns { job_id, export_id, status }.
startExport: (
conversationIds: string[],
@@ -1788,6 +1846,7 @@ export const dataAuditApi = {
speech_threshold?: number
merge_gap_seconds?: number
excluded_ranges?: Record
+ dropped_ranges?: Record
sensitivity_policy?: string | null
}
) =>
|