Skip to content

Annotation-export curation loop, person merge service, and August sync - #346

Merged
AnkushMalaker merged 19 commits into
devfrom
feat/annotation-export-curation
Aug 6, 2026
Merged

Annotation-export curation loop, person merge service, and August sync#346
AnkushMalaker merged 19 commits into
devfrom
feat/annotation-export-curation

Conversation

@AnkushMalaker

Copy link
Copy Markdown
Collaborator

Batches the work from both machines (kraken + rainbow, synced to a common tip) plus the new annotation-export curation loop.

Annotation export: verify and curate before shipping (9c8ef71)

The export flow could select and export, but never showed what would actually be in the zip — datasets went to annotators sight unseen, with no record of what was already sent.

  • Contents previewPOST /api/data-audit/export/preview is a synchronous dry-run returning the exact clips (boundaries, durations, sliced transcripts) the current settings would produce. Plan computation lives in utils/export_planning.plan_conversation_clips, shared verbatim by the export job, so the preview cannot drift from the export.
  • Clip-level curation — the ExportModal's Dataset contents panel renders the plan live with per-clip playback (gapless player), transcript slices (no transcript badge for clips an annotator would receive silent), and include toggles. Unticked clips are carved out via dropped_ranges, accounted separately from privacy withholdings (params.curated, dropped_seconds).
  • Export history — the audit listing joins on-disk export.json metadata: rows carry last_export (an exported chip) and an exported=never|exported filter scopes curation to un-shipped audio. Deleting an export un-marks its conversations.

Verified end-to-end on live data: 19-clip preview → 1 dropped → 18-clip zip with correct accounting; badges appear and clear on delete.

Memory: deterministic person identity + merge, Obsidian companion

  • PersonMergeService: locked, previewable merge of People notes (metadata/facts/media/backlinks) with plan tokens (409 on stale state) and file-level rollback; the memory agent's rename-collision path now delegates to it.
  • Duplicate-person suggestions + symmetric distinct_from decisions; new /api/memories/people/* endpoints.
  • extras/obsidian-chronicle: a small Obsidian plugin that previews and submits these merges from a synced vault — no LLM in the execution path.

Fixes

  • Immich: restore UTC on naive Mongo timestamps before the asset-search window; treat Immich as a polled source (not a heartbeat agent) so it stops flipping to Disconnected between scans.
  • DNS: pin 100.100.100.100 first in x-public-dns — public-only resolvers broke every *.ts.net lookup inside containers; new check_container_magicdns doctor check.
  • Transcription: decide stream resume causally (session status + end marker) instead of entry age alone, fixing a 30s conversation stall when the completion flag was cleared for a finished session.
  • Setup: install chronicle-setup editable — uv's env cache silently pinned a stale wheel, ignoring source edits.
  • Codex memory executor: yield to the interactive Codex quota (max_used_percent) by falling back to the direct agent, plus token-usage telemetry.
  • Observability: report the node agent itself going unreachable as a system event.

Docs / chores

  • docs/backend/compose-stack.md as the canonical compose reference; compose comments slimmed to pointers.
  • Conversation-ID search + sturdier ID copy; Queue page rebuilt on shared UI primitives; tray recorder update/revert; drop unused deepgram extra; HA_URL template override.

Tests

  • 452 backend unit tests pass, including new test_export_planning.py (12), test_person_merge.py/test_person_identity.py (13), Immich/device-input regressions (9).

9999 is a popular default port for other tools, so the test stack's
mock-streaming-stt kept colliding with unrelated services on dev hosts.
8879 is unclaimed by anything common. Port changed end-to-end (container,
host publish, healthcheck, configs, robot keywords, verify script).
MongoDB returns the newest DeviceInputItem.captured_at as a naive datetime,
so subtracting the 48h overlap window produced a naive bound that Immich
read in an unintended zone — silently shifting which assets a scan sees.

Normalize through _as_utc() before computing the window.
_effective_source_status() aged every online source out to offline once its
last_seen_at went stale, which is right for ScreenPipe capture agents that
heartbeat continuously. Chronicle polls Immich on a schedule instead, so its
last_seen_at means last successful sync — and a healthy library kept flipping
to Disconnected between scans.

Exempt the immich provider from the staleness check (an explicit error status
still wins), and label it in the Timeline as Connected/Disconnected with
synced rather than seen.
Merging duplicate People notes was previously an LLM-mediated vault_tools
path; the actual mutation now lives in a deterministic, previewable service
that both the memory agent and external clients share.

- person_merge.py: PersonMergeService — locked merge of metadata, facts,
  media, and backlinks, with a plan token so apply executes exactly the
  previewed plan (409 on stale state) and file-level rollback on failure.
- person_identity.py: conservative duplicate-person suggestions from
  name/alias/context evidence, plus a symmetric distinct_from annotation
  in the People frontmatter that blocks future merge previews.
- person_merge_actions.py + memory_routes.py: /api/memories/people/
  suggestions, identity, merge/preview, and merge endpoints; preview
  and apply are separate steps and nothing merges automatically.
- vault_tools.py: the agent rename-collision path delegates to the same
  PersonMergeService instead of its own migrate-and-delete logic.
- audit: new obsidian_action cause (human_external actor) so explicit
  semantic actions are distinguishable from Syncthing sync edits.
- agent/__init__.py: lazy exports so deterministic helpers can import
  section_edit without initializing the LLM agents.
A deliberately small Obsidian community plugin (extras/obsidian-chronicle)
for explicit maintenance of a synced Chronicle vault. It runs no LLM and
never mutates Markdown itself: it gathers intent plus local file hashes,
previews the operation through the backend person-merge API, and submits
the exact approved plan. Stale local state surfaces as the same HTTP 409
conflict the server uses for its own plan tokens.

First action: Merge current person… from a People/ note, with a preview
of fact/link counts and metadata conflicts before confirming.

Docs: docs/obsidian-companion.md, linked from docs/README.md and the
memory-system page.
Deepgram is called over its HTTP/WebSocket APIs directly; nothing imports
deepgram-sdk anymore. Remove the extra from both backends' pyproject/locks
and the --extra deepgram flags from every Dockerfile stage.
…octor check

docs/backend/compose-stack.md becomes the canonical reference for the
advanced backend stack; docker-compose.yml's long inline explanations are
slimmed to pointers at its anchors.

The functional half: pinning only public resolvers in x-public-dns broke
every *.ts.net lookup inside containers — MagicDNS names are served solely
by 100.100.100.100, so services addressed by tailnet name (remote ASR,
Immich) failed with "Name or service not known" while public and container
names kept working. List Tailscale's resolver first; public resolvers stay
as fallbacks for tailnet-less hosts.

services.py doctor grows check_container_magicdns — resolving this node's
own MagicDNS name from inside a container — because check_container_dns
passes on public upstreams while every tailnet name fails, so both probes
are needed. Documented in podman.md alongside the aardvark caveat, with
the asymmetric host-vs-container diagnosis recipe.
uv run --with-requirements caches the environment it builds and reuses it
while the requirements text is unchanged, so a non-editable path dependency
stays frozen at the sources it was first built from — edits to
chronicle_setup/ were silently ignored by the wizard, every init.py, and
services.py doctor, which kept running a stale wheel from ~/.cache/uv.
Measured on uv 0.6.16, neither `uv cache clean` nor cache-keys invalidates
it; only --reinstall-package does. An editable install links to the source
tree instead. Rationale recorded in init-system.md with a probe command.
The codex executor shares one account-wide weekly budget with the user's
interactive Codex sessions, and Chronicle's background recording is the
cheaper consumer to give up: a yielded run still records the conversation
via the direct (metered API) memory agent, while a blocked interactive
session is stuck for days.

- codex_quota.py reads account/rateLimits/read; memory.codex.
  max_used_percent (default 80) is the share Chronicle may consume before
  yielding, limit_id selects the metered bucket for non-default models.
  The probe fails OPEN — it optimises ahead of Codex's own limit error,
  so an unreadable quota must not stop memory extraction.
- Token usage is summed from turn.completed events (the only place the
  CLI reports cost) into MemoryAgentResult.usage and emitted as a child
  codex_turn LLM span: current Langfuse drops usage from invoke_agent
  spans, so the parent span only mirrors it for filtering.
- Quota snapshot attributes ride on the agent span for observability.
…lone

Discovery self-heals a stale transcription:complete flag by re-attaching
when the stream looks alive. Recency alone cannot answer that question:
finalize_session flushes residual audio and appends the end marker as its
last act, so at the exact moment the flag is set the newest entry is
milliseconds old — a closing session is indistinguishable from a resuming
one by age. Guessing wrong is expensive in one direction: clearing the
flag revokes the handshake open_conversation_job waits on and no
replacement ever arrives, stalling the conversation for the job's full
30s wait.

_session_resumed now consults two causal facts first — the session must
still be ACTIVE (the producer appends inside a WATCH/MULTI conditioned on
active status, so a departed session can never write again) and the tail
must not carry the end marker (appended strictly before the consumer can
set the flag). Only then does recency answer the question it is actually
good at: whether audio is flowing right now. Errors decline to re-attach,
costing streaming transcription rather than corrupting the handshake.
The agent is the only component that sees host-level faults — dead
container DNS, a logged-out Tailscale, a stale socket mount — so silently
returning when it cannot be reached meant the exact failure mode the
health poller exists to catch produced no signal at all. Record the
reachable/unreachable transition as a system event (warning on loss, info
resolution on return) keyed under one incident, like every other state
this poller tracks, so a persistent outage does not spam the ledger.
observation_curation carried its own copy of the content-addressed _media
promotion that vault_media now owns (person photos introduced the shared
one). Delete the private copy and call the shared helper for both Immich
originals and retained ScreenPipe frames.
Pasting a conversation ID from a log or system event into the search box
now finds the conversation: an `id` category (over conversation_id) joins
title/summary/speakers in the search endpoint, enabled by default and
individually toggleable in the field dropdown.

On the detail page the ID copy button gains a non-secure-context fallback
(execCommand path for plain-HTTP LAN access, where navigator.clipboard is
absent), visible copied/failed feedback, and an accessible name. Search
debounce widens 300→800ms so regex queries don't fire per keystroke.
Replace the page's hand-rolled cards, badges, and stat tiles with the
Espresso primitives (StatCard, StateBadge, MetadataChip, Alert, Label,
IconButton), with dark-mode variants throughout. The dead priority filter
is gone — the backend stubs every job's priority to "normal", so it could
only ever match everything or nothing.

Expanding a conversation adds to the dashboard query key, which used to
make the whole page blank into its loading spinner as a cold query;
keepPreviousData keeps the previous result rendered while the wider
payload loads.
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.
- 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.
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.
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.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a0412a4-2055-44b7-80b4-55ad5a97a9dc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@AnkushMalaker
AnkushMalaker merged commit 7c05a9f into dev Aug 6, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant