fixed some bugs

This commit is contained in:
2026-09-07 11:42:53 +08:00
parent aca36c78c1
commit 9230db3b69
12 changed files with 883 additions and 14 deletions

View File

@@ -0,0 +1,167 @@
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
}
}

View File

@@ -0,0 +1,177 @@
import Foundation
struct UserResultSummary: Identifiable, Decodable {
let attemptId: String
let videoHash: String
let courseTitle: String
let sentenceIndex: Int
let sentenceText: String
let overallScore: Float
let passed: Bool
let createdAt: String
var id: String { attemptId }
private enum CodingKeys: String, CodingKey {
case overallScore = "overall_score"
case courseTitle = "course_title"
case sentenceIndex = "sentence_index"
case sentenceText = "sentence_text"
case createdAt = "created_at"
case attemptId = "attempt_id"
case videoHash = "video_hash"
case passed
}
}
struct UserDubShareSummary: Identifiable, Decodable {
let shareId: String
let videoHash: String
let courseTitle: String
let title: String
let segmentCount: Int
let averageScore: Float
let createdAt: String
var id: String { shareId }
private enum CodingKeys: String, CodingKey {
case averageScore = "average_score"
case courseTitle = "course_title"
case segmentCount = "segment_count"
case createdAt = "created_at"
case shareId = "share_id"
case videoHash = "video_hash"
case title
}
}
struct DubShareSegmentDetail: Decodable {
let index: Int
let overallScore: Float
let recognizedText: String
let scoreDetails: [String: Float]
let audioURL: String
private enum CodingKeys: String, CodingKey {
case overallScore = "overall_score"
case recognizedText = "recognized_text"
case scoreDetails = "score_details"
case audioURL = "audio_url"
case index
}
}
struct DubShareDetail: Decodable {
let shareId: String
let videoHash: String
let title: String
let segments: [DubShareSegmentDetail]
private enum CodingKeys: String, CodingKey {
case shareId = "share_id"
case videoHash = "video_hash"
case title
case segments
}
}
enum UserHistoryError: LocalizedError {
case badStatus(Int, String)
var errorDescription: String? {
switch self {
case .badStatus(let code, let body):
return "HTTP \(code): \(body.prefix(200))"
}
}
}
enum UserHistoryAPI {
static func results(token: String) async throws -> [UserResultSummary] {
try await getJSON([UserResultSummary].self, path: "api/v1/me/results", token: token)
}
static func dubShares(token: String) async throws -> [UserDubShareSummary] {
try await getJSON([UserDubShareSummary].self, path: "api/v1/me/dub-shares", token: token)
}
static func dubShareDetail(token: String, shareId: String) async throws -> DubShareDetail {
try await getJSON(
DubShareDetail.self,
path: "api/v1/dub-shares/\(shareId)",
token: token
)
}
static func deleteResult(token: String, attemptId: String) async throws {
try await delete(path: "api/v1/me/results/\(attemptId)", token: token)
}
static func deleteDubShare(token: String, shareId: String) async throws {
try await delete(path: "api/v1/me/dub-shares/\(shareId)", token: token)
}
static func downloadAudio(
token: String,
audioPath: String,
destination: URL
) async throws {
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: audioPath,
method: "GET",
timeout: AppConfig.readTimeout,
headers: authorizationHeaders(token: token)
)
guard (200...299).contains(result.statusCode) else {
throw UserHistoryError.badStatus(
result.statusCode,
String(data: result.body, encoding: .utf8) ?? ""
)
}
try? FileManager.default.removeItem(at: destination)
try result.body.write(to: destination, options: .atomic)
}
private static func getJSON<T: Decodable>(
_ type: T.Type,
path: String,
token: String
) async throws -> T {
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "GET",
timeout: AppConfig.readTimeout,
headers: authorizationHeaders(token: token)
)
guard (200...299).contains(result.statusCode) else {
throw UserHistoryError.badStatus(
result.statusCode,
String(data: result.body, encoding: .utf8) ?? ""
)
}
return try JSONDecoder().decode(T.self, from: result.body)
}
private static func delete(path: String, token: String) async throws {
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: path,
method: "DELETE",
timeout: AppConfig.readTimeout,
headers: authorizationHeaders(token: token)
)
guard (200...299).contains(result.statusCode) else {
throw UserHistoryError.badStatus(
result.statusCode,
String(data: result.body, encoding: .utf8) ?? ""
)
}
}
private static func authorizationHeaders(token: String) -> [String: String] {
token.isEmpty ? [:] : ["Authorization": "Bearer \(token)"]
}
}