Decouple transcripts from note text; end-to-end watch transfer acks

Schema v3: transcription attempts accumulate on AudioInfo (text, locale,
confidence, forced flag) — latest attempt is displayed, payload.text is
purely user writing, v2 notes migrate at read time. Editor shows the
transcript in its own live-updating section with insert-into-note; search
matches transcripts weighted by confidence. Watch recordings are now
deleted only on the phone's durable-ingest ack (transferUserInfo), closing
every phone-side loss window; failed sidecar writes unstage instead of
orphaning, and unreadable inbox recordings are surfaced in logs.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
This commit is contained in:
2026-07-16 12:29:49 -04:00
parent c29d25748a
commit c5c90e9441
15 changed files with 546 additions and 55 deletions
+73 -6
View File
@@ -40,10 +40,13 @@ struct CaptureContext: Codable, Sendable, Equatable {
/// One note = one JSON file in the iCloud container, filed under
/// `Records/YYYY/MM/<ULID>.json` (UTC-bucketed by the ULID's timestamp).
struct Note: SyncDocument, VersionedDocument, Equatable {
/// v2 added `Payload.audio` for voice notes. Because it is optional, a v1
/// file decodes unchanged (missing key nil) and `migrateIfNeeded` only
/// bumps the stamped version no field back-fill is needed.
static let currentSchemaVersion = 2
/// v2 added `Payload.audio` for voice notes. v3 moved transcripts out of
/// `payload.text` into `AudioInfo.attempts`: text is now purely the user's
/// own writing, and every transcription attempt is preserved. v1/v2 files
/// decode unchanged (missing keys nil/empty); `migrateIfNeeded` back-fills
/// v2 voice notes by moving a completed transcript from `text` into
/// `attempts` at read time.
static let currentSchemaVersion = 3
struct Meta: Codable, Sendable, Equatable {
var id: ULID
@@ -66,6 +69,24 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
case failed
}
/// One finished transcription of a voice note. Attempts accumulate a
/// re-transcription appends rather than overwrites, so no machine output is
/// ever lost and the user can be shown alternatives.
struct TranscriptionAttempt: Codable, Sendable, Equatable {
var text: String
/// BCP-47 locale the transcript was produced in.
var localeID: String
/// Character-weighted mean confidence 01, or nil when the engine
/// emitted none (`DictationTranscriber` locales need not report it).
/// Confidences are not comparable across locales/engines display and
/// search-weighting metadata, never a cross-attempt selector.
var confidence: Double?
var transcribedAt: Date
/// Whether the user forced this language (re-transcribe menu) rather
/// than auto-detection picking it.
var languageWasForced: Bool
}
/// The audio-note sidecar metadata. Present only on voice notes; the audio
/// bytes themselves live in a `.m4a` next to the note JSON (path derived,
/// never stored see `audioRelativePath`).
@@ -79,6 +100,45 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
/// BCP-47 locale the transcript was produced in; nil while pending.
var transcriptionLocaleID: String?
var transcribedAt: Date?
/// Every finished transcription, oldest first. The **latest** attempt is
/// the displayed one a forced re-transcribe is a statement of intent,
/// so recency wins, not confidence (which isn't cross-locale comparable).
var attempts: [TranscriptionAttempt] = []
/// The attempt currently shown for this note.
var latestAttempt: TranscriptionAttempt? { attempts.last }
private enum CodingKeys: String, CodingKey {
case duration, transcriberDeviceID, transcriptionStatus,
transcriptionLocaleID, transcribedAt, attempts
}
init(
duration: TimeInterval,
transcriberDeviceID: String,
transcriptionStatus: TranscriptionStatus,
transcriptionLocaleID: String? = nil,
transcribedAt: Date? = nil,
attempts: [TranscriptionAttempt] = []
) {
self.duration = duration
self.transcriberDeviceID = transcriberDeviceID
self.transcriptionStatus = transcriptionStatus
self.transcriptionLocaleID = transcriptionLocaleID
self.transcribedAt = transcribedAt
self.attempts = attempts
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
duration = try container.decode(TimeInterval.self, forKey: .duration)
transcriberDeviceID = try container.decode(String.self, forKey: .transcriberDeviceID)
transcriptionStatus = try container.decode(TranscriptionStatus.self, forKey: .transcriptionStatus)
transcriptionLocaleID = try container.decodeIfPresent(String.self, forKey: .transcriptionLocaleID)
transcribedAt = try container.decodeIfPresent(Date.self, forKey: .transcribedAt)
// Absent on v2 files decode to empty, back-filled by migration.
attempts = try container.decodeIfPresent([TranscriptionAttempt].self, forKey: .attempts) ?? []
}
}
struct Payload: Codable, Sendable, Equatable {
@@ -167,9 +227,16 @@ struct Note: SyncDocument, VersionedDocument, Equatable {
)
}
/// First line of the text, used as the display title.
/// What a reader should see as this note's body: the user's own text when
/// they wrote any, else the latest transcript. Transcripts live in
/// `audio.attempts`, never in `payload.text`.
var displayText: String {
payload.text.isEmpty ? (payload.audio?.latestAttempt?.text ?? "") : payload.text
}
/// First line of the display text, used as the display title.
var title: String {
payload.text
displayText
.split(separator: "\n", omittingEmptySubsequences: true)
.first.map(String.init) ?? ""
}
+30 -6
View File
@@ -40,6 +40,10 @@ final class NoteEntity {
var transcriptionStatusRaw: String?
var transcriptionLocaleID: String?
var transcriberDeviceID: String?
// Latest transcription attempt, cached flat for list display and search.
// The full attempt history lives only in the file (like `transcribedAt`).
var transcriptText: String?
var transcriptConfidence: Double?
init(id: String, jsonRelativePath: String) {
self.id = id
@@ -82,19 +86,31 @@ extension NoteEntity {
}
/// Reconstructs `AudioInfo` from the flattened columns (nil for a text
/// note). `transcribedAt` is not cached, so it comes back nil even for a
/// completed transcription acceptable for the cache-only offline fallback,
/// where the file (which carries it) simply wasn't reachable.
/// note). `transcribedAt` and the attempt history are not fully cached
/// only the latest attempt survives (with a placeholder date), acceptable
/// for the cache-only offline fallback, where the file (which carries the
/// full history) simply wasn't reachable.
var audio: Note.AudioInfo? {
get {
guard hasAudio else { return nil }
var attempts: [Note.TranscriptionAttempt] = []
if let transcriptText {
attempts = [Note.TranscriptionAttempt(
text: transcriptText,
localeID: transcriptionLocaleID ?? "",
confidence: transcriptConfidence,
transcribedAt: modifiedAt,
languageWasForced: false
)]
}
return Note.AudioInfo(
duration: audioDuration ?? 0,
transcriberDeviceID: transcriberDeviceID ?? "",
transcriptionStatus: transcriptionStatusRaw
.flatMap(Note.TranscriptionStatus.init(rawValue:)) ?? .pending,
transcriptionLocaleID: transcriptionLocaleID,
transcribedAt: nil
transcribedAt: nil,
attempts: attempts
)
}
set {
@@ -103,12 +119,20 @@ extension NoteEntity {
transcriberDeviceID = newValue?.transcriberDeviceID
transcriptionStatusRaw = newValue?.transcriptionStatus.rawValue
transcriptionLocaleID = newValue?.transcriptionLocaleID
transcriptText = newValue?.latestAttempt?.text
transcriptConfidence = newValue?.latestAttempt?.confidence
}
}
/// First line of the text, used as the display title.
/// What a reader should see as this note's body: the user's own text when
/// they wrote any, else the latest transcript.
var displayText: String {
text.isEmpty ? (transcriptText ?? "") : text
}
/// First line of the display text, used as the display title.
var title: String {
text.split(separator: "\n", omittingEmptySubsequences: true)
displayText.split(separator: "\n", omittingEmptySubsequences: true)
.first.map(String.init) ?? ""
}
}