Skip to content

Commit 0d4c322

Browse files
committed
fix: separate identities that shared a runtime representation
Seven confirmed defects from the typed-contracts audit, each one two semantically different values wearing the same type and name. Speaker tenant. speaker_recognition_client sent a literal user_id="1" on every diarization, identification and wake-word gating call, so every Chronicle user resolved to one shared gallery. The speaker service now takes Chronicle's own user id as its tenant (users.id and the three FKs become String) and the client passes it. Callers that had no tenant to give — guided enrollment, fine-tuning, drift, enrollment health — thread the owning user instead of defaulting to 1. registered_clients. Stored as a {client_id: {...}} mapping while the inverse lookup queried registered_clients.client_id, which MongoDB resolves across array elements but not object keys, so it matched nothing for every real device. Disconnects never stamped last_seen and admins could not act on another user's device. Now a typed list[RegisteredClient]. ClientId. The upload controller had a second generate_client_id that interpolated the raw device name, so one device got two identities depending on how its audio arrived. One constructor now; server-generated labels use synthetic_client_id, which does not sanitize a name that was never user input. End reason vs processing trigger. file_upload, reprocess_orphan, reprocess_transcript and rebound were passed as end_reason while none is an EndReason member, so each was stored as UNKNOWN while the emitted event still reported the raw string. They travel in ProcessingTrigger now, and a reprocess passes no end reason at all, so a recording keeps the reason it really ended with. Transcription provider vs mode. The streaming consumer wrote provider=b"streaming" — a mode — which persisted as the transcript version's provider AND model, losing which service produced it. provider, mode and model are three fields now. The "deepgram" fallback is gone: a guessed provider is indistinguishable from a real one afterwards. Timeline audio claims. Split deep-copied every audio range onto both halves, so both played the whole original; merge never unioned them, so absorbed episodes' audio died with their documents. Ranges are now clipped on split and unioned on merge. A chunk with no captured_at (3% here) can be placed on neither side, so it stays with the head rather than being dropped or double-claimed. Durable spool identity. The phone's spool-file id travelled as durable_session_id and came back as session_id, colliding with the backend SessionId that means something else. Now spool_segment_id end to end. Also fixed, same class, found while working: - DerivedOperation was {split, merge} but maybe_trim_silence passes "silence_trim", so the remnant's lineage record raised at construction and silence trimming crashed outright. - The wake-word service could never start: identities.py is committed but absent from both the .dockerignore allowlist and the Dockerfile COPY. - A truncated day write was either mislabelled `written` or retried three times and settled `skipped`. DayWriteOutcome.PARTIAL is a third terminal state so it is neither.
1 parent 2590985 commit 0d4c322

59 files changed

Lines changed: 2460 additions & 763 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/hooks/useAudioStreamer.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,16 +222,16 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
222222
}, [setStateSafe]);
223223

224224
const sendDurablePacket = useCallback((packet: SpoolPacket) => {
225-
pendingPacketsRef.current.set(`${packet.sessionId}:${packet.sequence}`, packet);
225+
pendingPacketsRef.current.set(`${packet.segmentId}:${packet.sequence}`, packet);
226226
outboundChainRef.current = outboundChainRef.current.then(async () => {
227227
if (websocketRef.current?.readyState !== WebSocket.OPEN) return;
228228
await sendWyomingEvent(
229229
{
230230
type: 'audio-chunk',
231231
data: {
232232
...AUDIO_FORMAT,
233-
durable_session_id: packet.sessionId,
234-
durable_sequence: packet.sequence,
233+
spool_segment_id: packet.segmentId,
234+
spool_sequence: packet.sequence,
235235
captured_at_ms: packet.capturedAtMs,
236236
},
237237
},
@@ -426,7 +426,7 @@ export const useAudioStreamer = (options?: UseAudioStreamerOptions): UseAudioStr
426426
return;
427427
}
428428
if (msg.type === 'audio-ack') {
429-
const key = `${msg.session_id}:${msg.sequence}`;
429+
const key = `${msg.spool_segment_id}:${msg.sequence}`;
430430
const packet = pendingPacketsRef.current.get(key);
431431
if (packet) {
432432
pendingPacketsRef.current.delete(key);

app/src/services/durableAudioSpool.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ const ACK_PREFIX = 'chronicle.audioSpool.ack.';
88

99
export interface SpoolPacket {
1010
fileName: string;
11-
sessionId: string;
11+
/**
12+
* Identity of the spool *file* this packet was written to, not the backend audio
13+
* session. It was called `sessionId` and sent as `durable_session_id`, which the
14+
* backend echoed back as `session_id` — three names for a spool segment, all of
15+
* them colliding with the real WebSocket SessionId that means something else.
16+
*/
17+
segmentId: string;
1218
sequence: number;
1319
capturedAtMs: number;
1420
payload: Uint8Array;
@@ -17,12 +23,12 @@ export interface SpoolPacket {
1723
interface ActiveSegment {
1824
file: File;
1925
handle: FileHandle;
20-
sessionId: string;
26+
segmentId: string;
2127
startedAtMs: number;
2228
nextSequence: number;
2329
}
2430

25-
const makeSessionId = (): string =>
31+
const makeSegmentId = (): string =>
2632
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
2733

2834
/**
@@ -50,13 +56,13 @@ class DurableAudioSpool {
5056

5157
private startSegment(capturedAtMs: number): ActiveSegment {
5258
this.ensureDirectory();
53-
const sessionId = makeSessionId();
54-
const file = new File(this.directory, `${sessionId}.spool`);
59+
const segmentId = makeSegmentId();
60+
const file = new File(this.directory, `${segmentId}.spool`);
5561
file.create({ overwrite: false, intermediates: true });
5662
const active = {
5763
file,
5864
handle: file.open(),
59-
sessionId,
65+
segmentId,
6066
startedAtMs: capturedAtMs,
6167
nextSequence: 0,
6268
};
@@ -82,7 +88,7 @@ class DurableAudioSpool {
8288

8389
return {
8490
fileName: segment.file.name,
85-
sessionId: segment.sessionId,
91+
segmentId: segment.segmentId,
8692
sequence,
8793
capturedAtMs,
8894
payload,
@@ -97,7 +103,7 @@ class DurableAudioSpool {
97103
.filter((entry): entry is File => entry instanceof File && entry.name.endsWith('.spool'));
98104

99105
for (const file of files) {
100-
const sessionId = file.name.slice(0, -'.spool'.length);
106+
const segmentId = file.name.slice(0, -'.spool'.length);
101107
const acknowledged = Number(await AsyncStorage.getItem(`${ACK_PREFIX}${file.name}`) ?? '-1');
102108
const bytes = file.bytesSync();
103109
let offset = 0;
@@ -113,7 +119,7 @@ class DurableAudioSpool {
113119
if (sequence > acknowledged) {
114120
packets.push({
115121
fileName: file.name,
116-
sessionId,
122+
segmentId,
117123
sequence,
118124
capturedAtMs,
119125
payload: bytes.slice(offset + HEADER_BYTES, end),

backends/advanced/src/advanced_omi_backend/client_manager.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,10 +454,48 @@ async def some_endpoint(client_manager: ClientManager = Depends(get_client_manag
454454
return client_manager
455455

456456

457+
def user_id_prefix(user: "User") -> str:
458+
"""The user-scoping prefix of every client id: last 6 of the ObjectId.
459+
460+
The single place this slice is taken. Constructing it inline is how the upload
461+
path grew a second, differently-normalizing ``generate_client_id`` that gave the
462+
same physical device a different identity than the WebSocket path did.
463+
"""
464+
return str(user.id)[-6:]
465+
466+
467+
def owns_client_id(user: "User", client_id: str) -> bool:
468+
"""Whether ``client_id`` belongs to ``user`` by its prefix.
469+
470+
Prefix ownership is a *weak* check — it is derived from a 6-character slice, so it
471+
is only safe where the caller has already established the user. Use the registry
472+
(``User.has_client`` / ``get_user_by_client_id``) when the answer must be
473+
authoritative.
474+
"""
475+
return (client_id or "").startswith(user_id_prefix(user))
476+
477+
478+
def synthetic_client_id(user: "User", purpose: str) -> str:
479+
"""A client id for server-generated work that has no device behind it.
480+
481+
Kept apart from ``generate_client_id`` rather than folded into it: that function
482+
sanitizes and truncates *user-supplied* device names to 10 characters, which would
483+
silently rewrite a server-controlled label (``annotation-import`` →
484+
``annotation``) and split existing data across two ids. ``purpose`` is a literal in
485+
Chronicle's own source, so it needs neither sanitizing nor bounding.
486+
"""
487+
return f"{user_id_prefix(user)}-{purpose}"
488+
489+
457490
def generate_client_id(user: "User", device_name: Optional[str] = None) -> str:
458491
"""
459492
Generate a STABLE client_id in the format: user_id_suffix-device_suffix
460493
494+
The one constructor for a *device's* identity, on every ingress path. The upload
495+
controller used to have its own copy that interpolated the raw device name, so
496+
``MyPhone!`` became ``abc123-MyPhone!`` on upload and ``abc123-myphone`` over the
497+
WebSocket — one physical device, two identities, two registry entries.
498+
461499
The client_id is deterministic for a given (user, device_name): the same device
462500
reconnecting always maps to the same id. This is the device's stable identity —
463501
a reconnect reuses it (and evict-on-reconnect collapses any lingering connection
@@ -478,8 +516,7 @@ def generate_client_id(user: "User", device_name: Optional[str] = None) -> str:
478516
client_id as user_id_suffix-device_suffix (or user_id_suffix-<uuid> when no
479517
device name is supplied).
480518
"""
481-
# Use last 6 characters of MongoDB ObjectId as user identifier
482-
user_id_suffix = str(user.id)[-6:]
519+
user_id_suffix = user_id_prefix(user)
483520

484521
if device_name:
485522
# Sanitize device name: lowercase, alphanumeric + hyphens only, max 10 chars

backends/advanced/src/advanced_omi_backend/controllers/audio_controller.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from fastapi.responses import JSONResponse
1717
from rq import Retry
1818

19+
from advanced_omi_backend.client_manager import generate_client_id
1920
from advanced_omi_backend.controllers.queue_controller import (
2021
JOB_RESULT_TTL,
2122
start_post_conversation_jobs,
@@ -38,13 +39,6 @@
3839
audio_logger = logging.getLogger("audio_processing")
3940

4041

41-
def generate_client_id(user: User, device_name: str) -> str:
42-
"""Generate client ID for uploaded files."""
43-
logger.debug(f"Generating client ID - user.id={user.id}, type={type(user.id)}")
44-
user_id_suffix = str(user.id)[-6:]
45-
return f"{user_id_suffix}-{device_name}"
46-
47-
4842
async def upload_and_process_audio_files(
4943
user: User,
5044
files: list[UploadFile],

backends/advanced/src/advanced_omi_backend/controllers/client_controller.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from advanced_omi_backend.config import WS_IDLE_TIMEOUT_SECS
1616
from advanced_omi_backend.users import (
17+
RegisteredClient,
1718
User,
1819
forget_client_for_user,
1920
get_user_by_client_id,
@@ -23,29 +24,30 @@
2324
logger = logging.getLogger(__name__)
2425

2526

26-
def _device_view(entry: dict, client_manager: ClientManager, now: float) -> dict:
27+
def _device_view(
28+
entry: RegisteredClient, client_manager: ClientManager, now: float
29+
) -> dict:
2730
"""Shape one registry device joined with its live connection state.
2831
2932
`connected` and `last_seen` come from the in-memory ClientState when the device is
3033
live (authoritative), falling back to the persisted registry timestamp when offline.
3134
"""
32-
client_id = entry["client_id"]
35+
client_id = entry.client_id
3336
state = client_manager.get_client(client_id)
3437
if state is not None:
3538
last_seen = max(0.0, now - state.last_activity)
3639
connected = last_seen < WS_IDLE_TIMEOUT_SECS
3740
has_active = bool(state.stream_session_id) or state.batch_started
3841
else:
39-
last = entry.get("last_seen")
40-
last_seen = max(0.0, now - last.timestamp()) if last is not None else None
42+
last_seen = max(0.0, now - entry.last_seen.timestamp())
4143
connected = False
4244
has_active = False
4345
return {
4446
"client_id": client_id,
45-
"device_name": entry.get("device_name"),
46-
"name": entry.get("name") or entry.get("device_name") or client_id,
47+
"device_name": entry.device_name,
48+
"name": entry.name or entry.device_name or client_id,
4749
"connected": connected,
48-
"last_seen": round(last_seen, 1) if last_seen is not None else None,
50+
"last_seen": round(last_seen, 1),
4951
"has_active_conversation": has_active,
5052
}
5153

@@ -61,7 +63,7 @@ async def list_devices(user: User, client_manager: ClientManager) -> dict:
6163
devices = []
6264
for u in users:
6365
owner_email = u.email
64-
for entry in u.registered_clients.values():
66+
for entry in u.registered_clients:
6567
view = _device_view(entry, client_manager, now)
6668
view["user_email"] = owner_email
6769
devices.append(view)
@@ -77,7 +79,7 @@ async def rename_device(user: User, client_id: str, name: str):
7779
status_code=400, content={"error": "name must not be empty"}
7880
)
7981

80-
owner = user if client_id in user.registered_clients else None
82+
owner = user if user.has_client(client_id) else None
8183
if owner is None and user.is_superuser:
8284
owner = await get_user_by_client_id(client_id)
8385
if owner is None:
@@ -91,7 +93,7 @@ async def rename_device(user: User, client_id: str, name: str):
9193
async def forget_device(user: User, client_id: str, client_manager: ClientManager):
9294
"""Remove a device from the registry. A currently-connected device is also evicted
9395
so it doesn't immediately re-appear from its live ClientState."""
94-
owner = user if client_id in user.registered_clients else None
96+
owner = user if user.has_client(client_id) else None
9597
if owner is None and user.is_superuser:
9698
owner = await get_user_by_client_id(client_id)
9799
if owner is None:

backends/advanced/src/advanced_omi_backend/controllers/conversation_controller.py

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from advanced_omi_backend.controllers.queue_controller import (
2222
JOB_RESULT_TTL,
2323
conversation_edit_chain_in_flight,
24-
default_queue,
24+
enqueue_summary_job_bundle,
2525
memory_queue,
2626
post_conv_enqueue_kwargs,
2727
start_post_conversation_jobs,
@@ -45,7 +45,6 @@
4545
)
4646
from advanced_omi_backend.services.plugin_service import get_plugin_router
4747
from advanced_omi_backend.users import User
48-
from advanced_omi_backend.workers.conversation_jobs import generate_title_summary_job
4948
from advanced_omi_backend.workers.memory_jobs import (
5049
enqueue_memory_processing,
5150
process_memory_job,
@@ -979,10 +978,13 @@ def _enqueue_transcript_reprocessing(
979978
user_id: str,
980979
source: str,
981980
job_id_prefix: str,
982-
end_reason: str,
981+
trigger: str,
983982
) -> tuple:
984983
"""Enqueue transcribe job + post-conversation chain.
985984
985+
``end_reason`` is deliberately not passed: the conversation already ended for a
986+
real reason, and re-transcribing it does not change how the recording ended.
987+
986988
Returns (version_id, transcript_job, post_jobs dict).
987989
"""
988990
version_id = str(uuid.uuid4())
@@ -1004,7 +1006,7 @@ def _enqueue_transcript_reprocessing(
10041006
user_id=user_id,
10051007
transcript_version_id=version_id,
10061008
depends_on_job=transcript_job,
1007-
end_reason=end_reason,
1009+
trigger=trigger,
10081010
memory_cause=MemoryCause.TRANSCRIPT_REPROCESS,
10091011
)
10101012

@@ -1051,9 +1053,9 @@ def _enqueue_speaker_reprocessing_chain(
10511053
source_version_id: str,
10521054
diarization_source: str | None = None,
10531055
) -> dict:
1054-
"""Enqueue speaker -> memory -> title_summary chain.
1056+
"""Enqueue speaker -> memory -> ordered summary bundle.
10551057
1056-
Returns dict with keys: speaker, memory, title_summary (job IDs).
1058+
Returns speaker, memory, title, short-summary, and detailed-summary job IDs.
10571059
"""
10581060
speaker_job = transcription_queue.enqueue(
10591061
recognise_speakers_job,
@@ -1103,27 +1105,22 @@ def _enqueue_speaker_reprocessing_chain(
11031105
f"Chained memory job {memory_job.id} after speaker job {speaker_job.id}"
11041106
)
11051107

1106-
title_summary_job = default_queue.enqueue(
1107-
generate_title_summary_job,
1108+
summary_jobs = enqueue_summary_job_bundle(
11081109
conversation_id,
1109-
job_timeout=300,
1110-
result_ttl=JOB_RESULT_TTL,
1111-
job_id=f"title_summary_{conversation_id[:12]}",
1112-
description=f"Regenerate title/summary for {conversation_id[:8]}",
1113-
**post_conv_enqueue_kwargs(
1114-
"title_summary",
1115-
{"conversation_id": conversation_id},
1116-
depends_on=memory_job,
1117-
),
1110+
depends_on=memory_job,
1111+
meta={"conversation_id": conversation_id, "trigger": "speaker_reprocess"},
11181112
)
11191113
logger.info(
1120-
f"Chained title/summary job {title_summary_job.id} after memory job {memory_job.id}"
1114+
f"Chained summary bundle {[job.id for job in summary_jobs.values()]} "
1115+
f"after memory job {memory_job.id}"
11211116
)
11221117

11231118
return {
11241119
"speaker": speaker_job.id,
11251120
"memory": memory_job.id,
1126-
"title_summary": title_summary_job.id,
1121+
"title": summary_jobs["title"].id,
1122+
"short_summary": summary_jobs["short_summary"].id,
1123+
"detailed_summary": summary_jobs["detailed_summary"].id,
11271124
}
11281125

11291126

@@ -1224,7 +1221,7 @@ async def reprocess_orphan(conversation_id: str, user: User):
12241221
user_id=str(user.user_id),
12251222
source="reprocess_orphan",
12261223
job_id_prefix="orphan_transcribe",
1227-
end_reason="reprocess_orphan",
1224+
trigger=Conversation.ProcessingTrigger.REPROCESS_ORPHAN.value,
12281225
)
12291226

12301227
logger.info(
@@ -1236,7 +1233,9 @@ async def reprocess_orphan(conversation_id: str, user: User):
12361233
content={
12371234
"message": f"Orphan reprocessing started for conversation {conversation_id}",
12381235
"job_id": transcript_job.id,
1239-
"title_summary_job_id": post_jobs.get("title_summary"),
1236+
"title_job_id": post_jobs.get("title"),
1237+
"short_summary_job_id": post_jobs.get("short_summary"),
1238+
"detailed_summary_job_id": post_jobs.get("detailed_summary"),
12401239
"version_id": version_id,
12411240
"status": "queued",
12421241
}
@@ -1281,7 +1280,7 @@ async def reprocess_transcript(conversation_id: str, user: User):
12811280
user_id=str(user.user_id),
12821281
source="reprocess",
12831282
job_id_prefix="reprocess",
1284-
end_reason="reprocess_transcript",
1283+
trigger=Conversation.ProcessingTrigger.REPROCESS_TRANSCRIPT.value,
12851284
)
12861285

12871286
logger.info(
@@ -1293,7 +1292,9 @@ async def reprocess_transcript(conversation_id: str, user: User):
12931292
content={
12941293
"message": f"Transcript reprocessing started for conversation {conversation_id}",
12951294
"job_id": transcript_job.id,
1296-
"title_summary_job_id": post_jobs.get("title_summary"),
1295+
"title_job_id": post_jobs.get("title"),
1296+
"short_summary_job_id": post_jobs.get("short_summary"),
1297+
"detailed_summary_job_id": post_jobs.get("detailed_summary"),
12971298
"version_id": version_id,
12981299
"status": "queued",
12991300
}
@@ -1519,7 +1520,9 @@ async def reprocess_speakers(
15191520
"message": "Speaker reprocessing started",
15201521
"job_id": job_ids["speaker"],
15211522
"memory_job_id": job_ids["memory"],
1522-
"title_summary_job_id": job_ids["title_summary"],
1523+
"title_job_id": job_ids["title"],
1524+
"short_summary_job_id": job_ids["short_summary"],
1525+
"detailed_summary_job_id": job_ids["detailed_summary"],
15231526
"version_id": new_version_id,
15241527
"source_version_id": source_version_id,
15251528
"diarization_source": diarization_source,

0 commit comments

Comments
 (0)