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:
+73
-6
@@ -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 0…1, 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) ?? ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user