import Foundation struct PersistedRecording { let audioURL: URL let rawURL: URL } struct TrainingHistorySnapshot { var audioSegments: [Int: URL] = [:] var rawSegments: [Int: URL] = [:] var scores: [Int: ImitationAssessmentResult] = [:] } @MainActor final class TrainingHistoryManager: ObservableObject { @Published private(set) var historyVersion = 0 let store = TrainingHistoryStore() func notifyHistoryChanged() { historyVersion += 1 } } final class TrainingHistoryStore { private let root: URL init() { let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] root = documents.appendingPathComponent("TrainingHistory", isDirectory: true) try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) } func normalizedVideoHash(_ videoHash: String) -> String { let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF") let filtered = videoHash.lowercased().unicodeScalars.filter { allowed.contains($0) } return String(String.UnicodeScalarView(filtered)) } private var userDirectory: URL { let identifier = normalizedVideoHash(AppConfig.currentUserId) return root.appendingPathComponent(identifier.isEmpty ? "anonymous" : identifier, isDirectory: true) } func load(videoHash: String) -> TrainingHistorySnapshot { let directory = courseDirectory(videoHash) var snapshot = TrainingHistorySnapshot() for index in 0..<500 { let audio = directory.appendingPathComponent(String(format: "segment-%04d.m4a", index)) let raw = directory.appendingPathComponent(String(format: "raw-%04d.caf", index)) if FileManager.default.fileExists(atPath: audio.path) { snapshot.audioSegments[index] = audio } if FileManager.default.fileExists(atPath: raw.path) { snapshot.rawSegments[index] = raw } } let scoreFile = directory.appendingPathComponent("scores.json") if let data = try? Data(contentsOf: scoreFile), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { for (key, value) in object { guard let index = Int(key), index >= 0 else { continue } if let payload = value as? [String: Any], let payloadData = try? JSONSerialization.data(withJSONObject: payload), let score = try? JSONDecoder().decode(ImitationAssessmentResult.self, from: payloadData) { snapshot.scores[index] = score } } } return snapshot } func saveSegment( videoHash: String, sentenceIndex: Int, audioURL: URL, rawURL: URL ) throws -> PersistedRecording { let directory = courseDirectory(videoHash) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let savedAudio = directory.appendingPathComponent( String(format: "segment-%04d.m4a", sentenceIndex) ) let savedRaw = directory.appendingPathComponent( String(format: "raw-%04d.caf", sentenceIndex) ) try? FileManager.default.removeItem(at: savedAudio) try? FileManager.default.removeItem(at: savedRaw) try FileManager.default.moveItem(at: audioURL, to: savedAudio) try FileManager.default.moveItem(at: rawURL, to: savedRaw) return PersistedRecording(audioURL: savedAudio, rawURL: savedRaw) } func audioDestination(videoHash: String, sentenceIndex: Int) -> URL { courseDirectory(videoHash).appendingPathComponent( String(format: "segment-%04d.m4a", sentenceIndex) ) } func saveScores( videoHash: String, scores: [Int: ImitationAssessmentResult] ) { let directory = courseDirectory(videoHash) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) var object: [String: Any] = [:] for (index, score) in scores { object[String(index)] = Self.payload(score) } let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) try data.write( to: directory.appendingPathComponent("scores.json"), options: [.atomic] ) } catch { // History persistence is best-effort; the server remains the source of record. } } func removeScore(videoHash: String, sentenceIndex: Int) { var snapshot = load(videoHash: videoHash) snapshot.scores[sentenceIndex] = nil saveScores(videoHash: videoHash, scores: snapshot.scores) } func removeAllAudio(videoHash: String) { let snapshot = load(videoHash: videoHash) for url in snapshot.audioSegments.values { try? FileManager.default.removeItem(at: url) } for url in snapshot.rawSegments.values { try? FileManager.default.removeItem(at: url) } } private func courseDirectory(_ videoHash: String) -> URL { userDirectory.appendingPathComponent(normalizedVideoHash(videoHash), isDirectory: true) } private static func payload(_ score: ImitationAssessmentResult) -> [String: Any] { var value: [String: Any] = ["overall_score": score.overallScore] if let item = score.passed { value["passed"] = item } if let item = score.passScore { value["pass_score"] = item } if let item = score.contentScore { value["content_score"] = item } if let item = score.completenessScore { value["completeness_score"] = item } if let item = score.fluencyScore { value["fluency_score"] = item } if let item = score.pronunciationScore { value["pronunciation_score"] = item } if let item = score.prosodyScore { value["prosody_score"] = item } if let item = score.durationScore { value["duration_score"] = item } if let item = score.pauseScore { value["pause_score"] = item } if let item = score.speechRateScore { value["speech_rate_score"] = item } if let item = score.referenceText { value["reference_text"] = item } if let item = score.recognizedText { value["recognized_text"] = item } if let item = score.durationRatio { value["duration_ratio"] = item } if !score.missingTokens.isEmpty { value["missing_tokens"] = score.missingTokens } if !score.extraTokens.isEmpty { value["extra_tokens"] = score.extraTokens } if !score.substitutions.isEmpty { value["substitutions"] = score.substitutions.map { ["expected": $0.expected, "actual": $0.actual] } } if let item = score.feedback { value["feedback"] = item } if !score.details.isEmpty { value["details"] = score.details } return value } }