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:
@@ -80,4 +80,20 @@ struct AudioNotePayloadTests {
|
||||
@Test func decodeRejectsEmptyMetadata() {
|
||||
#expect(AudioNotePayload(metadata: [:]) == nil)
|
||||
}
|
||||
|
||||
// MARK: - IngestAck
|
||||
|
||||
@Test func ackRoundTrips() {
|
||||
let ack = IngestAck(id: "01J8Z3M9K7QW2R4T6Y8B0C1D2E")
|
||||
let decoded = IngestAck(userInfo: ack.userInfo())
|
||||
#expect(decoded == ack)
|
||||
}
|
||||
|
||||
@Test func ackRejectsNewerVersionAndForeignUserInfo() {
|
||||
var dict = IngestAck(id: "01J8Z3M9K7QW2R4T6Y8B0C1D2E").userInfo()
|
||||
dict["ackV"] = IngestAck.currentVersion + 1
|
||||
#expect(IngestAck(userInfo: dict) == nil)
|
||||
#expect(IngestAck(userInfo: [:]) == nil)
|
||||
#expect(IngestAck(userInfo: ["unrelated": true]) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +77,13 @@ struct NoteDocumentTests {
|
||||
/// A file stamped with a newer schema (and carrying a field this build
|
||||
/// doesn't model) must be quarantined by the gate — Codable silently drops
|
||||
/// the unknown key, so partial-decoding then rewriting would downgrade it.
|
||||
@Test func forwardGateQuarantinesV3Fixture() throws {
|
||||
@Test func forwardGateQuarantinesNewerFixture() throws {
|
||||
let id = ULID(millisecondsSinceEpoch: 1_700_000_000_000, randomHi: 3, randomLo: 4)
|
||||
let json = """
|
||||
{
|
||||
"meta": {
|
||||
"id": "\(id.stringValue)",
|
||||
"schemaVersion": 3,
|
||||
"schemaVersion": \(Note.currentSchemaVersion + 1),
|
||||
"createdAt": "2023-11-14T22:13:20Z",
|
||||
"modifiedAt": "2023-11-14T22:13:20Z"
|
||||
},
|
||||
@@ -98,7 +98,7 @@ struct NoteDocumentTests {
|
||||
}
|
||||
"""
|
||||
let note = try DocumentCoder.decode(Note.self, from: Data(json.utf8))
|
||||
#expect(note.meta.schemaVersion == 3)
|
||||
#expect(note.meta.schemaVersion == Note.currentSchemaVersion + 1)
|
||||
#expect(!note.isReadable)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
import SwiftData
|
||||
import Testing
|
||||
@testable import Notes
|
||||
|
||||
/// The v3 transcript model: attempts accumulate on `AudioInfo`, the latest one
|
||||
/// is displayed, `payload.text` stays purely user writing, and v2 files whose
|
||||
/// transcript was baked into `text` migrate at read time.
|
||||
@MainActor
|
||||
struct TranscriptionAttemptTests {
|
||||
private func makeEngine() throws -> (engine: SyncEngine, container: ModelContainer) {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: NoteEntity.self, configurations: config)
|
||||
return (SyncEngine(modelContainer: container), container)
|
||||
}
|
||||
|
||||
private func attempt(_ text: String, locale: String = "en-US", confidence: Double? = 0.9) -> Note.TranscriptionAttempt {
|
||||
Note.TranscriptionAttempt(
|
||||
text: text,
|
||||
localeID: locale,
|
||||
confidence: confidence,
|
||||
transcribedAt: Date(timeIntervalSince1970: 1_700_000_100),
|
||||
languageWasForced: false
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
@Test func latestAttemptWins() {
|
||||
var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
|
||||
audio.attempts = [attempt("first"), attempt("второй", locale: "ru-RU")]
|
||||
#expect(audio.latestAttempt?.text == "второй")
|
||||
}
|
||||
|
||||
@Test func displayTextPrefersUserTextThenTranscript() {
|
||||
var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
|
||||
audio.attempts = [attempt("spoken words")]
|
||||
var note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
|
||||
#expect(note.displayText == "spoken words")
|
||||
#expect(note.title == "spoken words")
|
||||
note.payload.text = "my own words"
|
||||
#expect(note.displayText == "my own words")
|
||||
}
|
||||
|
||||
/// A v2 file (no `attempts` key) must decode with empty attempts, not fail.
|
||||
@Test func v2AudioInfoDecodesWithoutAttemptsKey() throws {
|
||||
let json = """
|
||||
{"duration": 5, "transcriberDeviceID": "d", "transcriptionStatus": "done", "transcriptionLocaleID": "en-US"}
|
||||
"""
|
||||
let audio = try JSONDecoder().decode(Note.AudioInfo.self, from: Data(json.utf8))
|
||||
#expect(audio.attempts.isEmpty)
|
||||
#expect(audio.transcriptionStatus == .done)
|
||||
}
|
||||
|
||||
@Test func attemptsSurviveCodableRoundTrip() throws {
|
||||
var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
|
||||
audio.attempts = [attempt("hello", confidence: nil), attempt("привет", locale: "ru-RU")]
|
||||
let note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
|
||||
let decoded = try DocumentCoder.decode(Note.self, from: DocumentCoder.encode(note))
|
||||
#expect(decoded.payload.audio?.attempts.count == 2)
|
||||
#expect(decoded.payload.audio?.latestAttempt?.localeID == "ru-RU")
|
||||
#expect(decoded.payload.audio?.attempts.first?.confidence == nil)
|
||||
}
|
||||
|
||||
// MARK: - v2 → v3 migration
|
||||
|
||||
@Test func migrationMovesCompletedTranscriptIntoAttempts() throws {
|
||||
let (engine, container) = try makeEngine()
|
||||
defer { withExtendedLifetime(container) {} }
|
||||
var note = Note.create(
|
||||
text: "the old transcript",
|
||||
source: .transcribed,
|
||||
context: .empty,
|
||||
audio: Note.AudioInfo(
|
||||
duration: 5,
|
||||
transcriberDeviceID: "d",
|
||||
transcriptionStatus: .done,
|
||||
transcriptionLocaleID: "en-US",
|
||||
transcribedAt: Date(timeIntervalSince1970: 1_700_000_100)
|
||||
)
|
||||
)
|
||||
note.meta.schemaVersion = 2
|
||||
|
||||
let migrated = engine.migrateIfNeeded(note)
|
||||
#expect(migrated.meta.schemaVersion == Note.currentSchemaVersion)
|
||||
#expect(migrated.payload.text.isEmpty)
|
||||
#expect(migrated.payload.audio?.attempts.count == 1)
|
||||
#expect(migrated.payload.audio?.latestAttempt?.text == "the old transcript")
|
||||
#expect(migrated.payload.audio?.latestAttempt?.localeID == "en-US")
|
||||
#expect(migrated.payload.audio?.latestAttempt?.confidence == nil)
|
||||
#expect(migrated.displayText == "the old transcript")
|
||||
}
|
||||
|
||||
@Test func migrationLeavesPendingAndTextNotesAlone() throws {
|
||||
let (engine, container) = try makeEngine()
|
||||
defer { withExtendedLifetime(container) {} }
|
||||
|
||||
var pending = Note.create(
|
||||
text: "",
|
||||
source: .transcribed,
|
||||
context: .empty,
|
||||
audio: Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .pending)
|
||||
)
|
||||
pending.meta.schemaVersion = 2
|
||||
let migratedPending = engine.migrateIfNeeded(pending)
|
||||
#expect(migratedPending.payload.audio?.attempts.isEmpty == true)
|
||||
#expect(migratedPending.meta.schemaVersion == Note.currentSchemaVersion)
|
||||
|
||||
var textNote = Note.create(text: "just typing", context: .empty)
|
||||
textNote.meta.schemaVersion = 1
|
||||
let migratedText = engine.migrateIfNeeded(textNote)
|
||||
#expect(migratedText.payload.text == "just typing")
|
||||
#expect(migratedText.meta.schemaVersion == Note.currentSchemaVersion)
|
||||
}
|
||||
|
||||
// MARK: - Cache columns
|
||||
|
||||
@Test func mapperCachesLatestAttempt() throws {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: NoteEntity.self, configurations: config)
|
||||
defer { withExtendedLifetime(container) {} }
|
||||
let context = container.mainContext
|
||||
|
||||
var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done, transcriptionLocaleID: "ru-RU")
|
||||
audio.attempts = [attempt("first draft"), attempt("окончательный", locale: "ru-RU", confidence: 0.8)]
|
||||
let note = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
|
||||
NoteMapper.upsert(note, relativePath: note.relativePath, into: context)
|
||||
|
||||
let entity = try #require(NoteMapper.fetch(id: note.meta.id.stringValue, in: context))
|
||||
#expect(entity.transcriptText == "окончательный")
|
||||
#expect(entity.transcriptConfidence == 0.8)
|
||||
#expect(entity.displayText == "окончательный")
|
||||
#expect(entity.title == "окончательный")
|
||||
}
|
||||
|
||||
// MARK: - Search scoring
|
||||
|
||||
@Test func searchRanksUserTextAboveTranscript() throws {
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: NoteEntity.self, configurations: config)
|
||||
defer { withExtendedLifetime(container) {} }
|
||||
let context = container.mainContext
|
||||
|
||||
let typed = Note.create(text: "coffee order", context: .empty)
|
||||
var audio = Note.AudioInfo(duration: 5, transcriberDeviceID: "d", transcriptionStatus: .done)
|
||||
audio.attempts = [attempt("coffee with sam", confidence: 0.9)]
|
||||
let voice = Note.create(text: "", source: .transcribed, context: .empty, audio: audio)
|
||||
NoteMapper.upsert(typed, relativePath: typed.relativePath, into: context)
|
||||
NoteMapper.upsert(voice, relativePath: voice.relativePath, into: context)
|
||||
|
||||
let typedEntity = try #require(NoteMapper.fetch(id: typed.meta.id.stringValue, in: context))
|
||||
let voiceEntity = try #require(NoteMapper.fetch(id: voice.meta.id.stringValue, in: context))
|
||||
|
||||
let typedScore = try #require(typedEntity.searchScore(for: "coffee"))
|
||||
let voiceScore = try #require(voiceEntity.searchScore(for: "coffee"))
|
||||
#expect(typedScore > voiceScore)
|
||||
#expect(typedEntity.searchScore(for: "zzz") == nil)
|
||||
|
||||
// Higher-confidence transcripts outrank shakier ones.
|
||||
var lowAudio = audio
|
||||
lowAudio.attempts = [attempt("coffee maybe", confidence: 0.2)]
|
||||
voiceEntity.audio = lowAudio
|
||||
let lowScore = try #require(voiceEntity.searchScore(for: "coffee"))
|
||||
#expect(voiceScore > lowScore)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user