diff --git a/Documentation/Validation.md b/Documentation/Validation.md index af4656f..7d191dc 100644 --- a/Documentation/Validation.md +++ b/Documentation/Validation.md @@ -1,3 +1,11 @@ +# 1.5.0 validation · 2026-10-02 + +- 62 Swift tests pass on Xcode 27.0 / macOS 27.0.1. New coverage: correction anchoring across curly quotes, full-width punctuation, whitespace and case; invented spans dropped without failing the review; DeepSeek streamed usage (including cache hits and reasoning tokens) and Codex turn-completion usage; required `expressions` in the Codex schema; single-judge quick review routing, aggregation and persistence; legacy reports decoding as full reviews; one exam time-up signal per crossing; clearing an untouched submitted draft; word/character revision diffs; parent-then-earlier revision baselines; built-in bank uniqueness and practice index; review-card sync idempotence and the 1/2/4/8/16-day schedule; backup export/restore round trip with images, cards and drafts, and a second restore that adds nothing. +- Two existing expectations changed by design: an invented correction span no longer rejects the whole review, and a successfully graded, unchanged draft starts fresh. +- An isolated QA copy (separate bundle identifier, store and defaults) was seeded through the new backup restore with six synthetic reviews, then each page was captured: review score, comparison card and word diff, annotated essay, quick-review score card, History deltas and 单评 label, Statistics trends, Mistakes trends and mastery, Practice start and a revealed drill card, question bank sheet, immersive countdown, and Settings. Layout issues found there (sidebar badge truncation, struck words joining neighbours) were fixed. The user's database and defaults were not used. +- No paid live grading was run. DeepSeek usage relies on `stream_options.include_usage`; Codex usage parsing is lenient and absent usage is not an error. Model compliance with the new `expressions` field is untested live. +- macOS version 1.5.0 (8), Universal arm64 + x86_64. SwiftData adds the `ReviewCard` model and defaulted `SavedQuestion.year` / `label`; existing stores migrate lightly. Prompt version 1.3.0. + # 1.4.0 validation · 2026-09-13 - 49 Swift tests pass on Xcode 26.6 / macOS 26.6.2. New coverage includes completion-order progress, concurrent independent inputs, immutable submission and rewrite-parent snapshots, editing a new draft during grading, duplicate-submit rejection, cancellation, judge-specific failure, Codex preflight failure, and no partial score persistence. diff --git a/README.md b/README.md index 6457ce7..2c4ea83 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ WriteBench 把练习收敛为一条清晰的路径:选题、作答、评阅、 | 专注写作 | 认真评阅 | 留下进步 | | :--- | :--- | :--- | | 原生全屏作答,自动保存草稿 | 三位评审独立阅读同一份原文 | 同题目录与逐稿修改路径 | -| 考研、六级使用横线答题区,默认关闭实时词数 | 原题要求、语言、组织与语域分别反馈 | 错误归类、趋势与练习统计 | -| 手写稿识别后先校对,再提交 | 本机取中位数,显示评审分歧 | 所有原稿保留在自己的设备上 | +| 考研、六级使用横线答题区,可开启考试倒计时 | 修改直接高亮在原文上,可点击查看 | 与上一稿对比:分差、维度变化、逐词改动 | +| 内置原创题库,随机抽未练过的题 | 本机取中位数;草稿可用快速单评 | 错题与表达的间隔复习、趋势统计 | +| 手写稿识别后先校对,再提交 | 记录每次评阅的用量与用时 | 一键导出 / 恢复全部本地数据 | ### 支持的考试 @@ -136,7 +137,7 @@ platforms/android/ Android Studio 项目与手机界面 scripts/ 构建、图标生成与显式联调脚本 ``` -macOS 自动化测试覆盖 49 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。 +macOS 自动化测试覆盖 62 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。
更多文档 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 3b37670..a1094ee 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,18 @@ +# 1.5.0 · 练习闭环:限时、对比、复习 + +- **评分更稳**:评审引用原文时出现弯引号、全角标点、多余空格或大小写差异,会自动对齐到原文;个别找不到的修改建议只丢弃该条,不再让整份评阅失败。 +- **考试限时**:Settings 可开启倒计时(考研小作文 15 分钟、大作文 30 分钟、雅思 Task 2 40 分钟等),最后 5 分钟变色,到点提示音;可选到点自动交卷,或继续作答并显示超时。准备页新增“清空重来”。 +- **原文标注**:逐句修改直接高亮在你的原文上,主要 / 次要问题分色,点击高亮处查看修改和理由。 +- **与上一稿对比**:评阅页新增“与上一稿对比”,显示总分与各维度的分差、主要问题数量变化,以及逐词新增 / 删除对比。History 每一稿旁显示相对上一稿的分差。 +- **统计看变化**:新增近期变化(近几篇比之前几篇,百分点)、最近 3 篇平均线、四个维度趋势图、评阅用时与用量汇总;可按三评 / 快速单评筛选。Mistakes 显示每类错误的近期变化,可标记“已掌握”并隐藏。 +- **数据备份**:Settings 可导出全部作文、评阅、草稿、题库、目录信息、复习卡和图片为一个 JSON;恢复时只添加本机没有的记录,不覆盖现有内容。不含 API Key 与 Codex 登录信息。 +- **用量与快速单评**:每次评阅记录各评审的 token 用量与用时;填入 DeepSeek 价格后显示估算花费。答题页新增“快速单评”(⇧⌘↩),只请一位评审,适合草稿,可在 Settings 选择由哪位评审负责。 +- **题库**:内置题目扩充为每个题型 4–6 道原创题,标注已练次数与最近得分;支持随机抽一道未练过的题,我的题库可填写年份与标签并筛选。 +- **间隔复习**:新增 Practice 页。逐句修改与评审推荐的表达自动生成复习卡,按 1 / 2 / 4 / 8 / 16 天间隔安排;错题看原句写改法,表达看中文写英文。表达库支持搜索、手动添加和移除。侧栏显示待复习数量。 +- 成功评阅后,未再修改的已交草稿自动清空并重置计时,原文保留在 History。评审提示词升级到 1.3.0,新增推荐表达字段;旧记录照常读取。 + +macOS 15+。数据库新增复习卡与题库年份 / 标签字段,旧数据自动迁移。 + # 1.4.0 · 后台评阅与逐稿修改路径 - 交卷后立即回到准备页,三位评审在后台独立工作。顶部显示真实的完成进度(0/3 → 3/3)、等待时间、取消和结果入口,完成时不强制弹出评阅。 diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj index a1b736e..cf43afa 100644 --- a/WriteBench.xcodeproj/project.pbxproj +++ b/WriteBench.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0108E61BCDE2D4568F4D5C8B /* ProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */; }; + 042D9A8FAAC22D71BDF6F7E4 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16DC3843F83A5135DD60057 /* ReviewCard.swift */; }; 0B2FE86F272731005E6A17BF /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3647AF5325DFD1CE43543B /* Theme.swift */; }; 143462C249149F8D7065D80D /* AppLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018639A9F1452A7617A5A0A2 /* AppLifecycle.swift */; }; 14D92D7829215C6748E48951 /* StreamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306B86B1159828782A062144 /* StreamingTests.swift */; }; @@ -15,11 +16,13 @@ 1E1C317A4096B9830C2A3786 /* ExamTaskBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78510A02F0F827272F84695 /* ExamTaskBadge.swift */; }; 21B1D6631C778F17583209A5 /* ielts_task2.md in Resources */ = {isa = PBXBuildFile; fileRef = AA7BA8561FCE7DD2841C904B /* ielts_task2.md */; }; 27C8DB78E1B529A54B818ABD /* Exam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEB65E44D0601AEC6053865 /* Exam.swift */; }; + 291B90A1A7DDA40A4591BE8A /* QuestionBank.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E6EE384D62168057B908A3 /* QuestionBank.swift */; }; 2B8AA947E573B4AD052F6F95 /* OCRService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F892E2F452C5999A8C08043 /* OCRService.swift */; }; 3197344EF97F73265170A114 /* WorkspaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9238EAE831BF69E5F210D72A /* WorkspaceView.swift */; }; 32367F4CF29D3A467EFA453D /* MockGradingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296A6193086826B2B071503F /* MockGradingService.swift */; }; 361B2B881C01F86CC3A784DC /* cet6_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = 6CED406B3EAB7FA3947795C5 /* cet6_translation.md */; }; 380960FEBBC3C2CDC16C3147 /* BackgroundGradingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A07798AF0255AB8EAFF76A /* BackgroundGradingTests.swift */; }; + 3D7E00CED5955E5E709BAF28 /* RevisionViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E5B6AA16B1E69446387608 /* RevisionViews.swift */; }; 3FF816DF3394076A7EAFCA8E /* QuestionLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26C9A0B8BBB075532D147B45 /* QuestionLibraryView.swift */; }; 448090E99953CC5C5F9C2F40 /* DeepSeekStreaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BCC37FAB6E0DDC543B0D48B /* DeepSeekStreaming.swift */; }; 473B2ECB957707EDB2BF41AA /* BackgroundGradingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */; }; @@ -43,7 +46,10 @@ 96EB80336D8E7D70E3839817 /* kaoyan_english1_small.md in Resources */ = {isa = PBXBuildFile; fileRef = 6A7B637179FC6E271F31DBF4 /* kaoyan_english1_small.md */; }; 9EA1454709EC8802D43C7CA4 /* HistoryFolderEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419DE9ABE9E26F14128B9B1E /* HistoryFolderEditor.swift */; }; A104DD7F66FF512A80BA9717 /* QuestionImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */; }; + A54BFAD371DEB925FCA1963B /* PracticeLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E568498AF07D6BF8C1335 /* PracticeLoopTests.swift */; }; A840C857A17F4257AD96C9AF /* ReviewTextExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A81F1C48B2FAE8914576B /* ReviewTextExporter.swift */; }; + A97D07FFDB81393349741F89 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F1B7E5BB4C4D7689B346DE /* PracticeView.swift */; }; + C118D4E53ACEC303DE46EA50 /* CorrectionMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1020C698B4DA8C1C47852419 /* CorrectionMatcher.swift */; }; C66E0F5339DADB4AE603BD5D /* GradingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */; }; C7DF6658F1F355665EEC175A /* GradingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD296324E8F63CD0B525458 /* GradingProvider.swift */; }; CB760C24CAE63253B9400E19 /* WriteBenchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679F1D25813DBB4821B089EF /* WriteBenchTests.swift */; }; @@ -57,11 +63,13 @@ DCC333A3FAF19BC2ECC79BA0 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */; }; DEBA962949C4AADDFCB2C5D0 /* ielts_task1.md in Resources */ = {isa = PBXBuildFile; fileRef = 405A85F2A07E72B5AD277D96 /* ielts_task1.md */; }; DEF094BE47996A62C68AB985 /* CodexJudgeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27300883AE5AE3DD46C36924 /* CodexJudgeService.swift */; }; + E0495C7F8744F99633CD2EF5 /* UsageCost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B358A2E1043646F5A65FA4 /* UsageCost.swift */; }; E32387900468FFCA09760D27 /* kaoyan_english1_large.md in Resources */ = {isa = PBXBuildFile; fileRef = ACA34C8DE60D85ABFA45116F /* kaoyan_english1_large.md */; }; E7928726F27B1B2EB5341AEB /* EssaySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8874F23C092B83269A6E56 /* EssaySession.swift */; }; ECFAF47EFB1BFE1187B43C7D /* PracticeRail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876B3B648AA84D3EBCAD18D8 /* PracticeRail.swift */; }; F2DBAF31C0FA351AB0D126DB /* HistoryReviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 879136039903990A8065C3BD /* HistoryReviewTests.swift */; }; F59451A7E3C70F80EF599140 /* DeepSeekCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = D46587A10C9A9B126523FD04 /* DeepSeekCredentials.swift */; }; + FAEA4C1F539996D40D1AD105 /* BackupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888D43292E376FCF9480159D /* BackupService.swift */; }; FBEB290B54B4DA0E050F77E1 /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 781A9E69AB1A93F99198A60E /* StatisticsView.swift */; }; /* End PBXBuildFile section */ @@ -81,6 +89,7 @@ 0A8874F23C092B83269A6E56 /* EssaySession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EssaySession.swift; sourceTree = ""; }; 0F9DB3748FBE72E213C6EB37 /* ReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewView.swift; sourceTree = ""; }; 100F5B726342F502CA8C4207 /* LocalProcessRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalProcessRunner.swift; sourceTree = ""; }; + 1020C698B4DA8C1C47852419 /* CorrectionMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionMatcher.swift; sourceTree = ""; }; 11E52B4F1877F33DAEDBED32 /* EssayHistoryGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EssayHistoryGroup.swift; sourceTree = ""; }; 161DE1C1158C7D408EAD0C20 /* WriteBenchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WriteBenchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; @@ -108,19 +117,24 @@ 6A7B637179FC6E271F31DBF4 /* kaoyan_english1_small.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english1_small.md; sourceTree = ""; }; 6CED406B3EAB7FA3947795C5 /* cet6_translation.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = cet6_translation.md; sourceTree = ""; }; 70A5D3F3824C11B7B262C308 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 71E6EE384D62168057B908A3 /* QuestionBank.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionBank.swift; sourceTree = ""; }; 781A9E69AB1A93F99198A60E /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = ""; }; + 84F1B7E5BB4C4D7689B346DE /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = ""; }; 876B3B648AA84D3EBCAD18D8 /* PracticeRail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeRail.swift; sourceTree = ""; }; 879136039903990A8065C3BD /* HistoryReviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryReviewTests.swift; sourceTree = ""; }; + 888D43292E376FCF9480159D /* BackupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupService.swift; sourceTree = ""; }; 89F9C06884EE52989CB4491F /* EssayFolderMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EssayFolderMetadata.swift; sourceTree = ""; }; 8F892E2F452C5999A8C08043 /* OCRService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRService.swift; sourceTree = ""; }; 9238EAE831BF69E5F210D72A /* WorkspaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceView.swift; sourceTree = ""; }; 9AA993471F8CFF92BD9B2956 /* BrandArt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandArt.swift; sourceTree = ""; }; 9B600C2CBBB1B477ABB65C42 /* ImmersiveWritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveWritingView.swift; sourceTree = ""; }; + 9F5E568498AF07D6BF8C1335 /* PracticeLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeLoopTests.swift; sourceTree = ""; }; A399A503863E6199B340A50C /* DeepSeekClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekClient.swift; sourceTree = ""; }; AA7BA8561FCE7DD2841C904B /* ielts_task2.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ielts_task2.md; sourceTree = ""; }; ACA34C8DE60D85ABFA45116F /* kaoyan_english1_large.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english1_large.md; sourceTree = ""; }; AD3CEB6EC11C595CA1EA79D6 /* kaoyan_english1_translation.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english1_translation.md; sourceTree = ""; }; AEF0EC53CAA982107C3AA320 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B16DC3843F83A5135DD60057 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = ""; }; BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderTests.swift; sourceTree = ""; }; C00C1A68BA4227431C3B8884 /* WritingStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingStore.swift; sourceTree = ""; }; C6B92D96525F9799F42C2EDE /* WriteBench.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WriteBench.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -128,8 +142,10 @@ CFD296324E8F63CD0B525458 /* GradingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingProvider.swift; sourceTree = ""; }; D46587A10C9A9B126523FD04 /* DeepSeekCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekCredentials.swift; sourceTree = ""; }; D78510A02F0F827272F84695 /* ExamTaskBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamTaskBadge.swift; sourceTree = ""; }; + D7E5B6AA16B1E69446387608 /* RevisionViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevisionViews.swift; sourceTree = ""; }; DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingModels.swift; sourceTree = ""; }; DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingView.swift; sourceTree = ""; }; + E6B358A2E1043646F5A65FA4 /* UsageCost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageCost.swift; sourceTree = ""; }; E94CDCC1049F7BD4C287AF84 /* WriteBench.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WriteBench.entitlements; sourceTree = ""; }; EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionTextReader.swift; sourceTree = ""; }; F3788E5EB782131E544DDEDC /* WritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingView.swift; sourceTree = ""; }; @@ -153,6 +169,7 @@ F7B9132C06548AC9B8B44070 /* ReviewOutline.swift */, 029A81F1C48B2FAE8914576B /* ReviewTextExporter.swift */, 0F9DB3748FBE72E213C6EB37 /* ReviewView.swift */, + D7E5B6AA16B1E69446387608 /* RevisionViews.swift */, ); path = Review; sourceTree = ""; @@ -179,12 +196,29 @@ path = Settings; sourceTree = ""; }; + 3A0C05461AD591503FF267A4 /* Practice */ = { + isa = PBXGroup; + children = ( + 84F1B7E5BB4C4D7689B346DE /* PracticeView.swift */, + ); + path = Practice; + sourceTree = ""; + }; + 4C321A284C596AEE9DEE2CB8 /* Backup */ = { + isa = PBXGroup; + children = ( + 888D43292E376FCF9480159D /* BackupService.swift */, + ); + path = Backup; + sourceTree = ""; + }; 59584BB59D280C5141746843 /* Models */ = { isa = PBXGroup; children = ( 31DECD6B9F0B3A54C27F4535 /* BackgroundGradingJob.swift */, 5AEB65E44D0601AEC6053865 /* Exam.swift */, DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */, + 71E6EE384D62168057B908A3 /* QuestionBank.swift */, ); path = Models; sourceTree = ""; @@ -201,8 +235,10 @@ 6C1FD4E39240395189A0EE57 /* Grading */ = { isa = PBXGroup; children = ( + 1020C698B4DA8C1C47852419 /* CorrectionMatcher.swift */, CFD296324E8F63CD0B525458 /* GradingProvider.swift */, 634F608AF0DF72D3DE51E438 /* GradingService.swift */, + E6B358A2E1043646F5A65FA4 /* UsageCost.swift */, ); path = Grading; sourceTree = ""; @@ -232,6 +268,7 @@ 34A07798AF0255AB8EAFF76A /* BackgroundGradingTests.swift */, 879136039903990A8065C3BD /* HistoryReviewTests.swift */, 296A6193086826B2B071503F /* MockGradingService.swift */, + 9F5E568498AF07D6BF8C1335 /* PracticeLoopTests.swift */, BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */, 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */, 306B86B1159828782A062144 /* StreamingTests.swift */, @@ -268,6 +305,7 @@ children = ( 89F9C06884EE52989CB4491F /* EssayFolderMetadata.swift */, 0A8874F23C092B83269A6E56 /* EssaySession.swift */, + B16DC3843F83A5135DD60057 /* ReviewCard.swift */, ); path = Persistence; sourceTree = ""; @@ -326,6 +364,7 @@ DBA8F5BDA3B0730A44F6788A /* Services */ = { isa = PBXGroup; children = ( + 4C321A284C596AEE9DEE2CB8 /* Backup */, E2A118C9C3713319FBFF2693 /* Codex */, EDA45D75F74FEFF4ADDE5BDB /* DeepSeek */, 6C1FD4E39240395189A0EE57 /* Grading */, @@ -360,6 +399,7 @@ children = ( A90CF1CE3C57C22A6291790B /* History */, 8B0E934F4B4A8BD953E7FE15 /* Mistakes */, + 3A0C05461AD591503FF267A4 /* Practice */, 1E299B61878E0D3292AC4569 /* Review */, 2E5047437EC0A43579042929 /* Settings */, 9D0207DDA532A7EF6A19A9E7 /* Statistics */, @@ -482,8 +522,10 @@ 143462C249149F8D7065D80D /* AppLifecycle.swift in Sources */, 892F119876541FA3E8699E90 /* BackgroundGradingJob.swift in Sources */, 473B2ECB957707EDB2BF41AA /* BackgroundGradingView.swift in Sources */, + FAEA4C1F539996D40D1AD105 /* BackupService.swift in Sources */, 7E4B43903D948A9E26E11E8D /* BrandArt.swift in Sources */, DEF094BE47996A62C68AB985 /* CodexJudgeService.swift in Sources */, + C118D4E53ACEC303DE46EA50 /* CorrectionMatcher.swift in Sources */, 6C1F7BEC5994E87DFA1CEF27 /* DeepSeekClient.swift in Sources */, F59451A7E3C70F80EF599140 /* DeepSeekCredentials.swift in Sources */, 448090E99953CC5C5F9C2F40 /* DeepSeekStreaming.swift in Sources */, @@ -505,15 +547,20 @@ 2B8AA947E573B4AD052F6F95 /* OCRService.swift in Sources */, 86CD1B5AEC144D4283491FC5 /* PlainTextEditor.swift in Sources */, ECFAF47EFB1BFE1187B43C7D /* PracticeRail.swift in Sources */, + A97D07FFDB81393349741F89 /* PracticeView.swift in Sources */, + 291B90A1A7DDA40A4591BE8A /* QuestionBank.swift in Sources */, 3FF816DF3394076A7EAFCA8E /* QuestionLibraryView.swift in Sources */, 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */, + 042D9A8FAAC22D71BDF6F7E4 /* ReviewCard.swift in Sources */, DB3DEEA68D82D9F8BECF108E /* ReviewOutline.swift in Sources */, A840C857A17F4257AD96C9AF /* ReviewTextExporter.swift in Sources */, 55C7907B2F577F01BC57A08B /* ReviewView.swift in Sources */, + 3D7E00CED5955E5E709BAF28 /* RevisionViews.swift in Sources */, 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */, FBEB290B54B4DA0E050F77E1 /* StatisticsView.swift in Sources */, DB8D1A55F0E264D96E7E1D8D /* TextQuestionImportView.swift in Sources */, 0B2FE86F272731005E6A17BF /* Theme.swift in Sources */, + E0495C7F8744F99633CD2EF5 /* UsageCost.swift in Sources */, 7ADD51F051853BB04DC26A4D /* WindowImmersionBridge.swift in Sources */, 3197344EF97F73265170A114 /* WorkspaceView.swift in Sources */, DB6452447E988C02C326B0BE /* WriteBenchApp.swift in Sources */, @@ -529,6 +576,7 @@ 380960FEBBC3C2CDC16C3147 /* BackgroundGradingTests.swift in Sources */, F2DBAF31C0FA351AB0D126DB /* HistoryReviewTests.swift in Sources */, 32367F4CF29D3A467EFA453D /* MockGradingService.swift in Sources */, + A54BFAD371DEB925FCA1963B /* PracticeLoopTests.swift in Sources */, 0108E61BCDE2D4568F4D5C8B /* ProviderTests.swift in Sources */, A104DD7F66FF512A80BA9717 /* QuestionImportTests.swift in Sources */, 14D92D7829215C6748E48951 /* StreamingTests.swift in Sources */, @@ -599,7 +647,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; @@ -614,7 +662,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -695,7 +743,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 7; + CURRENT_PROJECT_VERSION = 8; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_HARDENED_RUNTIME = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -716,7 +764,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.5.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/WriteBench/App/WorkspaceView.swift b/WriteBench/App/WorkspaceView.swift index 2b1185b..3225ee6 100644 --- a/WriteBench/App/WorkspaceView.swift +++ b/WriteBench/App/WorkspaceView.swift @@ -2,9 +2,9 @@ import SwiftUI import SwiftData enum Destination: String, CaseIterable, Identifiable { - case write = "Write", history = "History", statistics = "Statistics", mistakes = "Mistakes", settings = "Settings" + case write = "Write", history = "History", statistics = "Statistics", mistakes = "Mistakes", practice = "Practice", settings = "Settings" var id: String { rawValue } - var symbol: String { switch self { case .write: "square.and.pencil"; case .history: "clock"; case .statistics: "chart.bar.xaxis"; case .mistakes: "text.badge.xmark"; case .settings: "gearshape" } } + var symbol: String { switch self { case .write: "square.and.pencil"; case .history: "clock"; case .statistics: "chart.bar.xaxis"; case .mistakes: "text.badge.xmark"; case .practice: "rectangle.stack"; case .settings: "gearshape" } } } struct WorkspaceView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -12,6 +12,8 @@ struct WorkspaceView: View { @Environment(\.scenePhase) private var scenePhase @State private var review: EssaySession? @State private var destination: Destination = .write + @Query(filter: #Predicate { !$0.mastered && !$0.archived }) private var learningCards: [ReviewCard] + private var dueCount: Int { let now = Date(); return learningCards.filter { $0.dueDate <= now }.count } @Bindable var store: WritingStore var body: some View { GeometryReader { geometry in @@ -29,6 +31,7 @@ struct WorkspaceView: View { switch destination { case .statistics: StatisticsView() case .mistakes: MistakesView(onOpen: { review = $0 }) + case .practice: PracticeView() case .settings: SettingsView() case .history: HistoryView(onOpen: { review = $0 }) case .write: EmptyView() @@ -39,7 +42,8 @@ struct WorkspaceView: View { }.frame(minWidth: 980, minHeight: 700) .animation(reduceMotion ? nil : .easeInOut(duration: 0.18), value: store.isInSession) .background(WindowImmersionBridge(isImmersed: store.isInSession)) - .task { store.attach(context) } + .task { store.attach(context); _ = try? ReviewCardSync.sync(context) } + .onChange(of: store.gradingJob?.phase == .completed) { _, done in if done { _ = try? ReviewCardSync.sync(context) } } .onChange(of: scenePhase) { _, phase in if phase != .active { store.tick(); store.persistDraft() } } .onChange(of: destination) { _, next in if next != .write { store.tick(); store.persistDraft() } } .sheet(item: $review) { session in ReviewView(session: session) { if !store.isInSession || store.leaveAnswering() { store.beginRewrite($0); destination = .write; review = nil } } } @@ -64,8 +68,15 @@ struct WorkspaceView: View { Button { destination = item } label: { HStack(spacing: 17) { Image(systemName: item.symbol).font(.system(size: 19, weight: .regular)).frame(width: 23) - Text(item.rawValue).font(.system(size: 16, weight: destination == item ? .medium : .regular)) - Spacer() + .overlay(alignment: .topTrailing) { + if item == .practice && dueCount > 0 { + Text(dueCount > 99 ? "99+" : "\(dueCount)").font(.system(size: 9, weight: .bold)).monospacedDigit().foregroundStyle(.white) + .padding(.horizontal, 4).padding(.vertical, 1).background(WB.blue, in: Capsule()).fixedSize() + .offset(x: 9, y: -7).accessibilityLabel("\(dueCount) 张待复习") + } + } + Text(item.rawValue).font(.system(size: 16, weight: destination == item ? .medium : .regular)).lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) }.padding(.horizontal, 16).frame(height: 49) .foregroundStyle(destination == item ? WB.blue : WB.secondary) .background(destination == item ? Color(red: 0.87, green: 0.91, blue: 1) : .clear, in: RoundedRectangle(cornerRadius: 13)) diff --git a/WriteBench/App/WriteBenchApp.swift b/WriteBench/App/WriteBenchApp.swift index 45b1be2..df81dc1 100644 --- a/WriteBench/App/WriteBenchApp.swift +++ b/WriteBench/App/WriteBenchApp.swift @@ -17,7 +17,7 @@ import SwiftData do { // Hosted tests must never open or migrate the user's active database. if Self.isTestHost { - container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, + container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, ReviewCard.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) storageError = nil return @@ -28,7 +28,7 @@ import SwiftData let folder = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("WriteBench", isDirectory: true) try fm.createDirectory(at: folder, withIntermediateDirectories: true) let storeURL = fm.fileExists(atPath: legacy.path) ? legacy : folder.appendingPathComponent("default.store") - container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, configurations: ModelConfiguration(url: storeURL)) + container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, ReviewCard.self, configurations: ModelConfiguration(url: storeURL)) storageError = nil if UserDefaults.standard.string(forKey: "deepSeekModel") == "deepseek-v4-flash" { UserDefaults.standard.set(DeepSeekClient.defaultModel, forKey: "deepSeekModel") } } @@ -41,6 +41,9 @@ import SwiftData } .windowStyle(.hiddenTitleBar).windowToolbarStyle(.unified).defaultSize(width: 1440, height: 900) .commands { CommandGroup(replacing: .newItem) {} } - Settings { SettingsView().frame(width: 780, height: 760).background(WB.canvas).preferredColorScheme(.light) } + Settings { + if let container { SettingsView().frame(width: 780, height: 760).background(WB.canvas).preferredColorScheme(.light).modelContainer(container) } + else { SettingsView(showsBackup: false).frame(width: 780, height: 760).background(WB.canvas).preferredColorScheme(.light) } + } } } diff --git a/WriteBench/Features/History/HistoryView.swift b/WriteBench/Features/History/HistoryView.swift index 3db5a86..827180e 100644 --- a/WriteBench/Features/History/HistoryView.swift +++ b/WriteBench/Features/History/HistoryView.swift @@ -147,8 +147,14 @@ struct HistoryView: View { Text(session.date.formatted(date: .abbreviated, time: .shortened)) .font(.system(size: 11)).foregroundStyle(WB.secondary) }.frame(maxWidth: .infinity, alignment: .leading) - Text("\(session.finalScore.scoreText) / \(Int(session.task.maxScore))") - .font(.system(size: 13, weight: .medium)).foregroundStyle(WB.blue).frame(width: 120) + HStack(spacing: 6) { + Text("\(session.finalScore.scoreText) / \(Int(session.task.maxScore))").font(.system(size: 13, weight: .medium)).foregroundStyle(WB.blue) + if let previous = baseline(of: session, index: index, in: group) { + let delta = session.finalScore - previous.finalScore + Text(RevisionComparisonCard.signed(delta)).font(.system(size: 11, weight: .medium)).foregroundStyle(RevisionComparisonCard.color(delta)) + .help("相比第 \((group.versions.firstIndex { $0.id == previous.id } ?? 0) + 1) 稿") + } + }.frame(width: 120) confidence(session).frame(width: 66) Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(WB.secondary).frame(width: 10) }.padding(.vertical, 15).padding(.leading, 64).padding(.trailing, 60) @@ -157,7 +163,12 @@ struct HistoryView: View { } } } + /// The recorded parent, or the previous attempt for legacy records. + private func baseline(of session: EssaySession, index: Int, in group: EssayHistoryGroup) -> EssaySession? { + if let parent = session.parentSessionID, let found = group.versions.first(where: { $0.id == parent && $0.id != session.id }) { return found } + return index > 0 ? group.versions[index - 1] : nil + } private func confidence(_ session: EssaySession) -> some View { - Text(session.confidence).font(.system(size: 12)).foregroundStyle(session.confidence == "Low" ? WB.amber : WB.green) + Text(Confidence.label(session.confidence)).font(.system(size: 12)).foregroundStyle(session.confidence == "Low" ? WB.amber : WB.green) } } diff --git a/WriteBench/Features/Mistakes/MistakesView.swift b/WriteBench/Features/Mistakes/MistakesView.swift index 4beb124..7055db2 100644 --- a/WriteBench/Features/Mistakes/MistakesView.swift +++ b/WriteBench/Features/Mistakes/MistakesView.swift @@ -2,36 +2,104 @@ import SwiftUI import SwiftData struct MistakesView: View { + @Environment(\.modelContext) private var context @Query(sort: \EssaySession.date, order: .reverse) private var sessions: [EssaySession] + @Query private var cards: [ReviewCard] var onOpen: (EssaySession) -> Void @State private var selected: MistakeCategory? - private var examples: [(session: EssaySession, correction: Correction)] { sessions.filter { !$0.isDemo }.flatMap { session in (session.report?.corrections ?? []).map { (session, $0) } } } - private var categories: [(category: MistakeCategory, count: Int)] { MistakeCategory.allCases.map { c in (c, examples.filter { $0.correction.category == c }.count) }.sorted { $0.count == $1.count ? $0.category.rawValue < $1.category.rawValue : $0.count > $1.count } } + @State private var hideMastered = true + private struct Example: Identifiable { + let session: EssaySession + let correction: Correction + let card: ReviewCard? + var id: String { session.id.uuidString + correction.id } + var mastered: Bool { card?.mastered == true } + } + private struct Trend { let recent: Double; let earlier: Double; let window: Int } var body: some View { + let byKey = Dictionary(cards.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first }) + let real = sessions.filter { !$0.isDemo } + let reports = real.map { ($0, $0.report?.corrections ?? []) } + let examples = reports.flatMap { session, corrections in + corrections.map { Example(session: session, correction: $0, card: byKey[ReviewCardSync.mistakeKey(session: session.id, correction: $0)]) } + } + let visible = examples.filter { !hideMastered || !$0.mastered } + let categories = summarize(visible, reports) ScrollView { VStack(alignment: .leading, spacing: 24) { - SectionHeading(title: "Notice the pattern. Change it.", subtitle: "Mistakes · The feedback that matters, collected from your essays.") + HStack(alignment: .top) { + SectionHeading(title: "Notice the pattern. Change it.", subtitle: "Mistakes · The feedback that matters, collected from your essays.") + Spacer() + Toggle("隐藏已掌握", isOn: $hideMastered).toggleStyle(.checkbox).font(.system(size: 12)) + } if examples.isEmpty { Card { EmptyState(symbol: "text.badge.checkmark", title: "A little more aware, every time.", detail: "Corrections from real reviews appear here, grouped by category. Demo examples are excluded.") } } else { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 12)], spacing: 12) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 190), spacing: 12)], spacing: 12) { ForEach(categories, id: \.category) { item in Button { selected = selected == item.category ? nil : item.category } label: { - HStack { Text(item.category.rawValue).font(.system(size: 13, weight: .medium)); Spacer(); Text("\(item.count)").font(.system(size: 20, weight: .semibold, design: .rounded)) }.padding(18).foregroundStyle(selected == item.category ? WB.blue : WB.ink).background(selected == item.category ? WB.tint : .white, in: RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(selected == item.category ? WB.blue.opacity(0.65) : WB.line)) + VStack(alignment: .leading, spacing: 8) { + HStack { Text(item.category.rawValue).font(.system(size: 13, weight: .medium)); Spacer(); Text("\(item.count)").font(.system(size: 20, weight: .semibold, design: .rounded)) } + if let trend = item.trend { trendLabel(trend) } + }.padding(18).foregroundStyle(selected == item.category ? WB.blue : WB.ink).background(selected == item.category ? WB.tint : .white, in: RoundedRectangle(cornerRadius: 14)).overlay(RoundedRectangle(cornerRadius: 14).stroke(selected == item.category ? WB.blue.opacity(0.65) : WB.line)) }.buttonStyle(.plain) } } HStack { Text(selected?.rawValue ?? "All corrections").font(.system(size: 18, weight: .semibold)); Spacer(); if selected != nil { Button("Show all") { selected = nil }.buttonStyle(QuietButtonStyle()) } } - ForEach(Array(examples.filter { selected == nil || $0.correction.category == selected }.enumerated()), id: \.offset) { _, item in + ForEach(visible.filter { selected == nil || $0.correction.category == selected }) { item in Card(padding: 18) { VStack(alignment: .leading, spacing: 14) { - HStack { Text(item.session.task.fullTitle).font(.system(size: 12, weight: .medium)); Text(item.session.date.formatted(date: .abbreviated, time: .omitted)).font(.system(size: 11)).foregroundStyle(WB.secondary); Spacer(); Button("Open essay →") { onOpen(item.session) }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.blue) } - CorrectionRow(correction: item.correction) + HStack { + Text(item.session.task.fullTitle).font(.system(size: 12, weight: .medium)) + Text(item.session.date.formatted(date: .abbreviated, time: .omitted)).font(.system(size: 11)).foregroundStyle(WB.secondary) + Spacer() + Button { toggleMastered(item) } label: { + Label(item.mastered ? "已掌握" : "标记已掌握", systemImage: item.mastered ? "checkmark.circle.fill" : "circle") + }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(item.mastered ? WB.green : WB.secondary) + Button("Open essay →") { onOpen(item.session) }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.blue) + } + CorrectionRow(correction: item.correction).opacity(item.mastered ? 0.6 : 1) } } } } }.padding(32) } + .task { _ = try? ReviewCardSync.sync(context) } + } + private struct CategorySummary { let category: MistakeCategory; let count: Int; let trend: Trend? } + private func summarize(_ visible: [Example], _ reports: [(EssaySession, [Correction])]) -> [CategorySummary] { + var summaries: [CategorySummary] = [] + for category in MistakeCategory.allCases { + let count = visible.filter { $0.correction.category == category }.count + let change = trend(category, reports) + if count > 0 || change != nil { summaries.append(CategorySummary(category: category, count: count, trend: change)) } + } + return summaries.sorted { $0.count == $1.count ? $0.category.rawValue < $1.category.rawValue : $0.count > $1.count } + } + /// Corrections per essay in the latest attempts versus the attempts before them. + private func trend(_ category: MistakeCategory, _ reports: [(EssaySession, [Correction])]) -> Trend? { + let window = min(5, reports.count / 2) + guard window >= 2 else { return nil } + func rate(_ slice: ArraySlice<(EssaySession, [Correction])>) -> Double { Double(slice.reduce(0) { $0 + $1.1.filter { $0.category == category }.count }) / Double(slice.count) } + let recent = rate(reports.prefix(window)), earlier = rate(reports.dropFirst(window).prefix(window)) + return recent == 0 && earlier == 0 ? nil : Trend(recent: recent, earlier: earlier, window: window) + } + private func trendLabel(_ trend: Trend) -> some View { + let fewer = trend.recent < trend.earlier, same = trend.recent == trend.earlier + return Label("近 \(trend.window) 篇 \(String(format: "%.1f", trend.recent)) / 篇,之前 \(String(format: "%.1f", trend.earlier))", + systemImage: same ? "equal" : fewer ? "arrow.down.right" : "arrow.up.right") + .font(.system(size: 10)).foregroundStyle(same ? WB.secondary : fewer ? WB.green : WB.amber) + } + private func toggleMastered(_ item: Example) { + var card = item.card + if card == nil { + _ = try? ReviewCardSync.sync(context) + let key = ReviewCardSync.mistakeKey(session: item.session.id, correction: item.correction) + card = try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.key == key })).first + } + guard let card else { return } + SpacedRepetition.setMastered(card, !card.mastered) + try? context.save() } } diff --git a/WriteBench/Features/Practice/PracticeView.swift b/WriteBench/Features/Practice/PracticeView.swift new file mode 100644 index 0000000..adc557f --- /dev/null +++ b/WriteBench/Features/Practice/PracticeView.swift @@ -0,0 +1,223 @@ +import SwiftUI +import SwiftData + +/// Spaced review of past corrections and saved expressions. +struct PracticeView: View { + enum Tab: String, CaseIterable, Identifiable { case review = "间隔复习", expressions = "表达库"; var id: String { rawValue } } + @Environment(\.modelContext) private var context + @Query(sort: \ReviewCard.dueDate) private var cards: [ReviewCard] + @State private var tab: Tab = .review + @State private var kind: ReviewCardKind? + @State private var queue: [ReviewCard] = [] + @State private var position = 0 + @State private var reviewed = 0 + @State private var remembered = 0 + @State private var attempt = "" + @State private var revealed = false + @State private var inSession = false + @State private var search = "" + @State private var addingExpression = false + @State private var error: String? + @FocusState private var answerFocused: Bool + + private var active: [ReviewCard] { cards.filter { !$0.archived } } + private func due(_ kind: ReviewCardKind?) -> [ReviewCard] { active.filter { $0.isDue && (kind == nil || $0.cardKind == kind) } } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .top) { + SectionHeading(title: "Practice what you missed.", subtitle: "复习 · 错题与表达按 1 / 2 / 4 / 8 / 16 天间隔重复,隔 16 天仍记得即掌握。") + Spacer() + Picker("", selection: $tab) { ForEach(Tab.allCases) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented).labelsHidden().fixedSize() + } + if tab == .review { reviewTab } else { expressionTab } + if let error { Text(error).font(.system(size: 12)).foregroundStyle(WB.amber) } + }.padding(32) + } + .task { do { try ReviewCardSync.sync(context) } catch { self.error = error.localizedDescription } } + .sheet(isPresented: $addingExpression) { ExpressionEditor() } + } + + // MARK: Review + + @ViewBuilder private var reviewTab: some View { + HStack(spacing: 16) { + metric("待复习", "\(due(nil).count)", note: "错题 \(due(.mistake).count) · 表达 \(due(.expression).count)") + metric("学习中", "\(active.filter { !$0.mastered }.count)", note: "尚未掌握的卡片") + metric("已掌握", "\(active.filter(\.mastered).count)", note: "可在错题或表达库中取消") + } + if inSession, position < queue.count { reviewCard(queue[position]) } + else if inSession { finished } + else { startCard } + } + private var startCard: some View { + Card { + VStack(alignment: .leading, spacing: 16) { + Text("开始一轮复习").font(.system(size: 18, weight: .semibold)) + Picker("内容", selection: $kind) { + Text("全部").tag(Optional.none) + Text("错题").tag(Optional(ReviewCardKind.mistake)) + Text("表达").tag(Optional(ReviewCardKind.expression)) + }.pickerStyle(.segmented).labelsHidden().fixedSize() + Text(due(kind).isEmpty ? (active.isEmpty ? "完成一次评阅后,逐句修改和推荐表达会自动生成复习卡。" : "今天没有到期的卡片。明天再来,或去表达库添加新的表达。") + : "错题:看原句,写出改正后的说法。表达:看中文释义,写出英文表达。先自己写,再看答案,按实际情况选择“记住了”或“还没记住”。") + .font(.system(size: 13)).foregroundStyle(WB.secondary).lineSpacing(4) + HStack { Spacer(); Button("开始复习 \(due(kind).count) 张") { start() }.buttonStyle(PrimaryButtonStyle()).disabled(due(kind).isEmpty).accessibilityIdentifier("startPractice") } + } + } + } + private func reviewCard(_ card: ReviewCard) -> some View { + Card(padding: 28) { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 10) { + Text(card.cardKind == .mistake ? "错题 · \(card.category)" : "表达").font(.system(size: 12, weight: .semibold)).foregroundStyle(WB.blue) + if let task = card.task { Text(task.fullTitle).font(.system(size: 11)).foregroundStyle(WB.secondary) } + if card.lapseCount > 0 { Text("忘记过 \(card.lapseCount) 次").font(.system(size: 11)).foregroundStyle(WB.amber) } + Spacer() + Text("\(position + 1) / \(queue.count)").font(.system(size: 12)).monospacedDigit().foregroundStyle(WB.secondary) + Button("结束") { inSession = false }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary) + } + if card.cardKind == .mistake { + Text("原句").font(.system(size: 12)).foregroundStyle(WB.secondary) + Text(highlighted(card.prompt, in: card.context)).font(.system(size: 18)).lineSpacing(6).textSelection(.enabled) + Text("改正标记的部分:").font(.system(size: 12)).foregroundStyle(WB.secondary) + } else { + Text(card.prompt).font(.system(size: 20, weight: .medium)).textSelection(.enabled) + Text("写出对应的表达:").font(.system(size: 12)).foregroundStyle(WB.secondary) + } + TextField(card.cardKind == .mistake ? "改正后的写法" : "Expression", text: $attempt) + .textFieldStyle(.plain).font(.system(size: 16)).padding(12) + .background(WB.canvas, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(WB.line)) + .focused($answerFocused).disabled(revealed).onSubmit { if !revealed { revealed = true } } + .accessibilityIdentifier("practiceAnswer") + if revealed { + VStack(alignment: .leading, spacing: 10) { + if !attempt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Label(CorrectionMatcher.equivalent(attempt, card.answer) ? "与参考答案一致" : "与参考答案不同,可对照判断是否同样正确", + systemImage: CorrectionMatcher.equivalent(attempt, card.answer) ? "checkmark.circle" : "arrow.left.arrow.right") + .font(.system(size: 12)).foregroundStyle(CorrectionMatcher.equivalent(attempt, card.answer) ? WB.green : WB.secondary) + } + Text(card.answer).font(.system(size: 18, weight: .semibold)).foregroundStyle(WB.ink).textSelection(.enabled) + if !card.explanation.isEmpty { Text(card.explanation).font(.system(size: 13)).foregroundStyle(WB.secondary).lineSpacing(4).textSelection(.enabled) } + if card.cardKind == .expression && !card.context.isEmpty { Text(card.context).font(.system(size: 13)).italic().foregroundStyle(WB.secondary).textSelection(.enabled) } + }.padding(16).frame(maxWidth: .infinity, alignment: .leading).background(WB.tint.opacity(0.6), in: RoundedRectangle(cornerRadius: 12)) + HStack { + Button("标记为已掌握") { SpacedRepetition.setMastered(card, true); advance(remembered: true) }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary) + Spacer() + Button("还没记住") { grade(card, .again) }.buttonStyle(QuietButtonStyle()).keyboardShortcut("1", modifiers: []) + Button("记住了") { grade(card, .good) }.buttonStyle(PrimaryButtonStyle()).keyboardShortcut("2", modifiers: []) + } + } else { + HStack { Spacer(); Button("显示答案") { revealed = true }.buttonStyle(PrimaryButtonStyle()).keyboardShortcut(.return, modifiers: .command) } + } + } + } + } + private var finished: some View { + Card { + VStack(alignment: .leading, spacing: 14) { + Text("本轮完成").font(.system(size: 20, weight: .semibold)) + Text("复习 \(reviewed) 次,记住 \(remembered) 次。没记住的卡片 10 分钟后会再次到期。").font(.system(size: 13)).foregroundStyle(WB.secondary) + HStack { Spacer(); Button("返回") { inSession = false }.buttonStyle(QuietButtonStyle()); Button("再来一轮") { start() }.buttonStyle(PrimaryButtonStyle()).disabled(due(kind).isEmpty) } + } + } + } + private func highlighted(_ span: String, in sentence: String) -> AttributedString { + var text = AttributedString(sentence) + if let range = CorrectionMatcher.range(of: span, in: sentence), let lower = AttributedString.Index(range.lowerBound, within: text), + let upper = AttributedString.Index(range.upperBound, within: text) { + text[lower.. $1.createdAt } + HStack { + Image(systemName: "magnifyingglass").foregroundStyle(WB.secondary) + TextField("搜索表达或释义", text: $search).textFieldStyle(.plain) + Spacer() + Button { addingExpression = true } label: { Label("添加表达", systemImage: "plus") }.buttonStyle(QuietButtonStyle()) + }.padding(14).background(.white, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(WB.line)) + if expressions.isEmpty { + Card { EmptyState(symbol: "text.quote", title: "积累自己的表达", detail: "评审会从改进作文中推荐值得记住的表达,也可以手动添加。") } + } + ForEach(expressions) { card in + Card(padding: 18) { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 7) { + Text(card.answer).font(.system(size: 15, weight: .semibold)).textSelection(.enabled) + Text(card.prompt).font(.system(size: 13)).foregroundStyle(WB.secondary).textSelection(.enabled) + if !card.context.isEmpty { Text(card.context).font(.system(size: 12)).italic().foregroundStyle(WB.secondary).textSelection(.enabled) } + } + Spacer() + VStack(alignment: .trailing, spacing: 8) { + Text(card.mastered ? "已掌握" : "熟练度 \(card.box) / \(SpacedRepetition.intervals.count)").font(.system(size: 11)).foregroundStyle(card.mastered ? WB.green : WB.secondary) + Menu { + Button(card.mastered ? "重新学习" : "标记为已掌握") { SpacedRepetition.setMastered(card, !card.mastered); save() } + Button("从表达库移除", role: .destructive) { card.archived = true; save() } + } label: { Image(systemName: "ellipsis").foregroundStyle(WB.secondary) }.menuStyle(.borderlessButton).menuIndicator(.hidden).frame(width: 24) + } + } + } + } + } + private func save() { do { try context.save() } catch { self.error = error.localizedDescription } } + private func metric(_ title: String, _ value: String, note: String) -> some View { + Card(padding: 20) { + VStack(alignment: .leading, spacing: 10) { + Text(title).font(.system(size: 13)).foregroundStyle(WB.secondary) + Text(value).font(.system(size: 28, weight: .semibold, design: .rounded)) + Text(note).font(.system(size: 10)).foregroundStyle(WB.secondary) + } + } + } +} + +private struct ExpressionEditor: View { + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + @State private var phrase = "" + @State private var meaning = "" + @State private var example = "" + @State private var task: WritingTask = .ieltsTask2 + @State private var error: String? + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("添加表达").font(.system(size: 20, weight: .semibold)) + TextField("表达,例如:strike a balance between A and B", text: $phrase).textFieldStyle(.roundedBorder) + TextField("中文释义", text: $meaning).textFieldStyle(.roundedBorder) + TextField("例句(可选)", text: $example).textFieldStyle(.roundedBorder) + Picker("题型", selection: $task) { ForEach(WritingTask.allCases) { Text($0.fullTitle).tag($0) } }.frame(width: 300) + if let error { Text(error).font(.system(size: 12)).foregroundStyle(WB.amber) } + HStack { + Spacer() + Button("取消") { dismiss() }.buttonStyle(QuietButtonStyle()).keyboardShortcut(.cancelAction) + Button("添加") { + do { try ReviewCardSync.addExpression(phrase: phrase, meaning: meaning, example: example, task: task, in: context); dismiss() } + catch { self.error = error.localizedDescription } + }.buttonStyle(PrimaryButtonStyle()).keyboardShortcut(.defaultAction) + } + }.padding(28).frame(width: 520).background(WB.canvas) + } +} diff --git a/WriteBench/Features/Review/BackgroundGradingView.swift b/WriteBench/Features/Review/BackgroundGradingView.swift index 9d54e29..54798e0 100644 --- a/WriteBench/Features/Review/BackgroundGradingView.swift +++ b/WriteBench/Features/Review/BackgroundGradingView.swift @@ -19,7 +19,7 @@ struct BackgroundGradingView: View { Text("\(job.submission.input.task.fullTitle) · \(job.submission.countText)").font(.system(size: 11)).foregroundStyle(WB.secondary).lineLimit(1) } Spacer(minLength: 8) - Text("\(job.completedCount)/3 位完成 · \(job.elapsedText(at: clock.date))").font(.system(size: 11)).monospacedDigit().foregroundStyle(WB.secondary) + Text("\(job.isQuick ? "快速单评 · " : "")\(job.completedCount)/\(job.total) 位完成 · \(job.elapsedText(at: clock.date))").font(.system(size: 11)).monospacedDigit().foregroundStyle(WB.secondary) Button(job.phase.isRunning ? "查看进度" : "评阅详情") { showingDetails = true }.buttonStyle(QuietButtonStyle()).accessibilityIdentifier("showGradingProgress") if job.phase.isRunning { Button("取消", action: onCancel).buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary).disabled(job.phase == .cancelling) @@ -28,8 +28,8 @@ struct BackgroundGradingView: View { IconButton(symbol: "xmark", help: "收起评阅状态", action: onDismiss) } } - ProgressView(value: Double(job.completedCount), total: 3).tint(WB.blue) - .accessibilityLabel("已完成 \(job.completedCount) 位评审,共 3 位") + ProgressView(value: Double(job.completedCount), total: Double(job.total)).tint(WB.blue) + .accessibilityLabel("已完成 \(job.completedCount) 位评审,共 \(job.total) 位") }.padding(.horizontal, 32).padding(.vertical, 12).background(WB.tint.opacity(0.65)) } .sheet(isPresented: $showingDetails, onDismiss: { @@ -57,8 +57,8 @@ struct GradingProgressView: View { }.padding(24) ScrollView { VStack(alignment: .leading, spacing: 16) { - Text(job.phase == .completed ? "三位独立评审已完成,完整结论与总分已保存到历史。" : job.phase == .failed || job.phase == .cancelled ? "本次未生成总分。已收到的评语片段不作为最终评分,提交原稿保留在下方。" : "进度按实际完成的评审计数。下方为实时评语,总分将在三位评审全部完成后生成。").font(.system(size: 12)).foregroundStyle(WB.secondary) - ForEach(Judge.allCases) { judge in + Text(job.phase == .completed ? (job.isQuick ? "快速单评已完成,结论与分数已保存到历史。" : "三位独立评审已完成,完整结论与总分已保存到历史。") : job.phase == .failed || job.phase == .cancelled ? "本次未生成总分。已收到的评语片段不作为最终评分,提交原稿保留在下方。" : job.isQuick ? "快速单评:只请一位评审,速度更快、花费更少,没有评审一致性。" : "进度按实际完成的评审计数。下方为实时评语,总分将在三位评审全部完成后生成。").font(.system(size: 12)).foregroundStyle(WB.secondary) + ForEach(job.activeJudges) { judge in Card(padding: 20) { VStack(alignment: .leading, spacing: 12) { HStack { diff --git a/WriteBench/Features/Review/ReviewOutline.swift b/WriteBench/Features/Review/ReviewOutline.swift index ae826f7..7b313c3 100644 --- a/WriteBench/Features/Review/ReviewOutline.swift +++ b/WriteBench/Features/Review/ReviewOutline.swift @@ -1,11 +1,12 @@ import SwiftUI enum ReviewSection: String, CaseIterable, Identifiable { - case overview, feedback, examiners, corrections, improved, rewrite + case overview, progress, feedback, examiners, corrections, improved, rewrite var id: String { rawValue } func title(isTranslation: Bool) -> String { switch self { case .overview: "总分与结论" + case .progress: "与上一稿对比" case .feedback: "优点与不足" case .examiners: "评审意见" case .corrections: "逐句修改" @@ -18,12 +19,13 @@ enum ReviewSection: String, CaseIterable, Identifiable { struct ReviewOutline: View { let selection: ReviewSection let isTranslation: Bool + var sections: [ReviewSection] = ReviewSection.allCases var onSelect: (ReviewSection) -> Void var body: some View { VStack(alignment: .leading, spacing: 5) { Text("本篇目录").font(.system(size: 11, weight: .medium)).foregroundStyle(WB.secondary) .padding(.horizontal, 12).padding(.bottom, 13) - ForEach(ReviewSection.allCases) { section in + ForEach(sections) { section in Button { onSelect(section) } label: { Text(section.title(isTranslation: isTranslation)) .font(.system(size: 12, weight: selection == section ? .medium : .regular)) diff --git a/WriteBench/Features/Review/ReviewTextExporter.swift b/WriteBench/Features/Review/ReviewTextExporter.swift index b1d5bc0..81d041c 100644 --- a/WriteBench/Features/Review/ReviewTextExporter.swift +++ b/WriteBench/Features/Review/ReviewTextExporter.swift @@ -6,7 +6,7 @@ import Foundation guard let report = session.report else { return nil } var sections = [ "WriteBench · \(session.task.fullTitle)\n\(session.date.formatted(date: .abbreviated, time: .shortened))", - "\(report.isDemo ? "演示评分 · " : "")最终得分:\(report.finalScore.scoreText) / \(Int(session.task.maxScore))\n置信度:\(report.confidence.rawValue)\n评审分差:\(report.spread.scoreText)", + "\(report.isDemo ? "演示评分 · " : "")最终得分:\(report.finalScore.scoreText) / \(Int(session.task.maxScore))\n" + (report.gradingMode == .quick ? "评阅方式:快速单评(一位评审)" : "置信度:\(report.confidence.rawValue)\n评审分差:\(report.spread.scoreText)"), "评阅结论(中位分评审)\n\(report.conclusion)", feedback("写得好的地方", report.strengths), feedback("不足的地方", report.weaknesses), @@ -20,7 +20,7 @@ import Foundation if !result.response.minorErrors.isEmpty { lines.append(feedback("次要问题", result.response.minorErrors)) } return lines.joined(separator: "\n") } - sections.append("三位评审的独立意见\n\n" + reviewers.joined(separator: "\n\n")) + sections.append((report.gradingMode == .quick ? "评审意见" : "三位评审的独立意见") + "\n\n" + reviewers.joined(separator: "\n\n")) let corrections = report.corrections.enumerated().map { index, correction in "\(index + 1). \(correction.category.rawValue) · \(correction.severity == .major ? "主要" : "次要")\n原句:\(correction.original)\n修改:\(correction.corrected)\n说明:\(correction.explanation)" } diff --git a/WriteBench/Features/Review/ReviewView.swift b/WriteBench/Features/Review/ReviewView.swift index 8cbb1be..4c581b1 100644 --- a/WriteBench/Features/Review/ReviewView.swift +++ b/WriteBench/Features/Review/ReviewView.swift @@ -3,6 +3,8 @@ import SwiftData struct ReviewView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var context + @State private var baseline: (session: EssaySession, label: String)? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Bindable var session: EssaySession var onRewrite: (EssaySession) -> Void @@ -34,7 +36,7 @@ struct ReviewView: View { ScrollViewReader { proxy in HStack(spacing: 0) { if session.report != nil { - ReviewOutline(selection: readingSection, isTranslation: session.task.isTranslation) { section in + ReviewOutline(selection: readingSection, isTranslation: session.task.isTranslation, sections: sections) { section in navigationTarget = section withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) { readingSection = section @@ -46,7 +48,7 @@ struct ReviewView: View { .coordinateSpace(name: "reviewReadingArea") .onPreferenceChange(ReviewSectionPositions.self) { positions in guard !atEnd, navigationTarget == nil else { return } - readingSection = ReviewSection.allCases.last { (positions[$0] ?? .infinity) <= 32 } ?? .overview + readingSection = sections.last { (positions[$0] ?? .infinity) <= 32 } ?? .overview } .onScrollGeometryChange(for: Bool.self) { geometry in geometry.contentSize.height > geometry.containerSize.height && @@ -62,7 +64,13 @@ struct ReviewView: View { } }.frame(minWidth: 940, idealWidth: 1080, minHeight: 620, idealHeight: 840).background(WB.canvas).foregroundStyle(WB.ink) .onDisappear { copyFeedbackTask?.cancel() } + .task(id: session.id) { + let subtype = session.subtype + let candidates = (try? context.fetch(FetchDescriptor(predicate: #Predicate { $0.subtype == subtype }))) ?? [] + baseline = RevisionBaseline.find(for: session, among: candidates) + } } + private var sections: [ReviewSection] { ReviewSection.allCases.filter { $0 != .progress || baseline != nil } } private var reportContent: some View { VStack(alignment: .leading, spacing: 22) { if let report = session.report { @@ -70,6 +78,7 @@ struct ReviewView: View { Label("演示模式 · 示例分数,不代表真实写作水平,不计入统计。", systemImage: "info.circle").font(.system(size: 13)).foregroundStyle(WB.secondary).padding(15).frame(maxWidth: .infinity, alignment: .leading).background(WB.tint, in: RoundedRectangle(cornerRadius: 12)) } scoreCard(report).reviewAnchor(.overview) + if let baseline { RevisionComparisonCard(session: session, baseline: baseline.session, baselineNumber: baseline.label).reviewAnchor(.progress) } Card { VStack(alignment: .leading, spacing: 16) { Text("评阅结论").font(.system(size: 18, weight: .semibold)) @@ -93,6 +102,11 @@ struct ReviewView: View { Text(reviewer.judge.role).font(.system(size: 11)).foregroundStyle(WB.secondary) Text(reviewer.provider?.title ?? reviewer.model).font(.system(size: 10)).foregroundStyle(WB.secondary) Text("\(reviewer.model) · \(reviewer.reasoningEffort?.uppercased() ?? "")").font(.system(size: 10)).foregroundStyle(WB.secondary).lineLimit(1).help(reviewer.model) + if reviewer.usage != nil || reviewer.duration != nil { + Text([reviewer.duration.map { UsageCost.duration($0) }, reviewer.usage.map { "\(UsageCost.tokens($0.input + $0.output)) tokens" }].compactMap { $0 }.joined(separator: " · ")) + .font(.system(size: 10)).foregroundStyle(WB.secondary).lineLimit(1) + .help(reviewer.usage.map(UsageCost.summary) ?? "") + } } } } @@ -123,6 +137,7 @@ struct ReviewView: View { Card { VStack(alignment: .leading, spacing: 18) { HStack { Text("Sentence corrections").font(.system(size: 18, weight: .semibold)); Spacer(); Text("\(report.corrections.count) suggestions").font(.system(size: 12)).foregroundStyle(WB.secondary) } + if !report.corrections.isEmpty { AnnotatedEssayView(essay: session.originalEssay, corrections: report.corrections) } if report.corrections.isEmpty { Text(report.isDemo ? "演示只包含少量本地示例规则。真实逐句修改请使用 DeepSeek 评分。" : "评审未标注逐句修改。请结合上方评语检查任务完成情况。").font(.system(size: 14)).foregroundStyle(WB.secondary) } ForEach(report.corrections) { correction in CorrectionRow(correction: correction) } } @@ -132,6 +147,16 @@ struct ReviewView: View { HStack { Text(session.task.isTranslation ? "参考改译" : "Improved version").font(.system(size: 18, weight: .semibold)); Spacer(); Button { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(session.correctedEssay, forType: .string) } label: { Label("Copy", systemImage: "doc.on.doc") }.buttonStyle(QuietButtonStyle()) } Text(session.task.isTranslation ? "结合原文检查译义与表达,参考译文并非唯一正确答案。" : "Language reviewer’s suggested revision").font(.system(size: 12)).foregroundStyle(WB.secondary) Text(session.correctedEssay).font(.system(size: 15)).lineSpacing(7).textSelection(.enabled) + if !report.expressions.isEmpty { + Divider().padding(.vertical, 4) + HStack { Text("值得记住的表达").font(.system(size: 14, weight: .semibold)); Spacer(); Text("已加入复习 · 表达库").font(.system(size: 11)).foregroundStyle(WB.secondary) } + ForEach(report.expressions, id: \.self) { expression in + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 10) { Text(expression.phrase).font(.system(size: 14, weight: .medium)); Text(expression.meaning).font(.system(size: 12)).foregroundStyle(WB.secondary) } + if !expression.example.isEmpty { Text(expression.example).font(.system(size: 12)).italic().foregroundStyle(WB.secondary) } + }.textSelection(.enabled) + } + } } }.reviewAnchor(.improved) Card { @@ -156,7 +181,7 @@ struct ReviewView: View { HStack { Spacer(); Button(session.finalRewrite.isEmpty ? "开始重写" : "继续重写") { onRewrite(session) }.buttonStyle(PrimaryButtonStyle()).accessibilityIdentifier("startRewrite") } } }.reviewAnchor(.rewrite) - Text("\(session.task.targetLanguage == "Simplified Chinese" ? "\(session.originalEssay.count) characters" : "\(session.wordCount) words") · \(Int(session.writingDuration / 60)) min · \(session.inputMode.capitalized) · \(session.date.formatted(date: .abbreviated, time: .shortened))\nRubric \(session.rubricVersion) · Prompt \(session.graderPromptVersion) · \(session.modelName)").font(.system(size: 10)).foregroundStyle(WB.secondary).textSelection(.enabled) + Text("\(session.task.targetLanguage == "Simplified Chinese" ? "\(session.originalEssay.count) characters" : "\(session.wordCount) words") · \(Int(session.writingDuration / 60)) min · \(session.inputMode.capitalized) · \(session.date.formatted(date: .abbreviated, time: .shortened))\nRubric \(session.rubricVersion) · Prompt \(session.graderPromptVersion) · \(session.modelName)\(usageLine(report))").font(.system(size: 10)).foregroundStyle(WB.secondary).textSelection(.enabled) } else { EmptyState(symbol: "exclamationmark.triangle", title: "Unable to read this review", detail: "The saved review data is invalid. Your original question and essay are preserved below.") Card { VStack(alignment: .leading, spacing: 20) { Text(session.question).foregroundStyle(WB.secondary); Text(session.originalEssay) }.textSelection(.enabled) } @@ -176,13 +201,28 @@ struct ReviewView: View { Spacer() VStack(alignment: .trailing, spacing: 13) { Label(report.confidence.title, systemImage: report.confidence == .low ? "exclamationmark.circle" : "checkmark.shield").font(.system(size: 14, weight: .medium)).foregroundStyle(report.confidence == .low ? WB.amber : WB.green).padding(.horizontal, 14).padding(.vertical, 9).background((report.confidence == .low ? WB.amber : WB.green).opacity(0.08), in: Capsule()) - Text("Median of 3 independent reviewers").font(.system(size: 12)).foregroundStyle(WB.secondary) - Text("Reviewer spread: \(report.spread.scoreText)").font(.system(size: 11)).foregroundStyle(WB.secondary) + if report.gradingMode == .quick { + Text("一位评审 · 适合草稿,与三评分数对比时仅供参考").font(.system(size: 12)).foregroundStyle(WB.secondary) + } else { + Text("Median of 3 independent reviewers").font(.system(size: 12)).foregroundStyle(WB.secondary) + Text("Reviewer spread: \(report.spread.scoreText)").font(.system(size: 11)).foregroundStyle(WB.secondary) + } if report.confidence == .low { Text("Reviewer disagreement · inspect each review").font(.system(size: 11)).foregroundStyle(WB.amber) } } } } } + private func usageLine(_ report: GradingReport) -> String { + var parts: [String] = [] + if let duration = report.duration { parts.append("评阅用时 \(UsageCost.duration(duration))") } + for provider in GradingProvider.allCases { + let usages = report.reviewers.filter { $0.provider == provider }.compactMap(\.usage) + guard let first = usages.first else { continue } + parts.append("\(provider.title) \(UsageCost.summary(usages.dropFirst().reduce(first, +)))") + } + if let cost = UsageCost.cost(report) { parts.append("DeepSeek 约 \(UsageCost.yuan(cost))") } + return parts.isEmpty ? "" : "\n" + parts.joined(separator: " · ") + } private func dimension(_ title: String, value: Double) -> some View { HStack(spacing: 20) { Text(title).font(.system(size: 13)).frame(width: 125, alignment: .leading) diff --git a/WriteBench/Features/Review/RevisionViews.swift b/WriteBench/Features/Review/RevisionViews.swift new file mode 100644 index 0000000..4cd9e7e --- /dev/null +++ b/WriteBench/Features/Review/RevisionViews.swift @@ -0,0 +1,170 @@ +import SwiftUI + +/// Word-level comparison of two drafts. English splits into words; Chinese compares character by character. +enum RevisionDiff { + struct Result { + var text: AttributedString + var inserted: Int + var removed: Int + } + static let removedColor = Color(red: 0.72, green: 0.24, blue: 0.2) + static func tokens(_ text: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: #"[A-Za-z0-9]+(?:[’'\-][A-Za-z0-9]+)*|\s+|."#, options: [.dotMatchesLineSeparators]) else { return text.map(String.init) } + return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { Range($0.range, in: text).map { String(text[$0]) } } + } + static func compare(_ old: String, _ new: String) -> Result { + let before = tokens(old), after = tokens(new) + let difference = after.difference(from: before) + var removed = Set(), inserted = Set() + for change in difference { + switch change { + case .remove(let offset, _, _): removed.insert(offset) + case .insert(let offset, _, _): inserted.insert(offset) + } + } + var output = AttributedString(), i = 0, j = 0 + let isWord: (String) -> Bool = { !$0.allSatisfy(\.isWhitespace) } + while i < before.count || j < after.count { + if i < before.count, removed.contains(i) { + if isWord(before[i]) { + // Removed whitespace is not drawn, so keep struck words apart from their neighbours. + if let last = output.characters.last, !last.isWhitespace { output += AttributedString(" ") } + var run = AttributedString(before[i]) + run.foregroundColor = removedColor; run.strikethroughStyle = .single; run.backgroundColor = removedColor.opacity(0.08) + output += run + if j < after.count, isWord(after[j]) { output += AttributedString(" ") } + } + i += 1 + } else if j < after.count, inserted.contains(j) { + var run = AttributedString(after[j]) + if isWord(after[j]) { run.foregroundColor = WB.green; run.backgroundColor = WB.green.opacity(0.1) } + output += run + j += 1 + } else { + if j < after.count { output += AttributedString(after[j]) } + i += 1; j += 1 + } + } + return Result(text: output, inserted: inserted.filter { isWord(after[$0]) }.count, removed: removed.filter { isWord(before[$0]) }.count) + } +} + +/// The student's answer with every locatable correction highlighted. Clicking a highlight shows that correction. +struct AnnotatedEssayView: View { + let essay: String + let corrections: [Correction] + @State private var selected: Int? + private static let scheme = "writebench-correction" + + private var annotated: (text: AttributedString, located: Int) { + var text = AttributedString(essay), taken: [Range] = [], located = 0 + for (index, correction) in corrections.enumerated() { + guard let range = CorrectionMatcher.range(of: correction.original, in: essay), !taken.contains(where: { $0.overlaps(range) }), + let lower = AttributedString.Index(range.lowerBound, within: text), let upper = AttributedString.Index(range.upperBound, within: text) else { continue } + taken.append(range); located += 1 + let color = correction.severity == .major ? WB.amber : WB.blue + text[lower.. some View { + HStack(spacing: 5) { RoundedRectangle(cornerRadius: 2).fill(color.opacity(0.25)).frame(width: 14, height: 9); Text(title) }.font(.system(size: 11)).foregroundStyle(WB.secondary) + } +} + +/// How this draft moved relative to the draft it was based on. +struct RevisionComparisonCard: View { + let session: EssaySession + let baseline: EssaySession + let baselineNumber: String + @State private var showDiff = true + var body: some View { + let current = session.report, previous = baseline.report + let delta = session.finalScore - baseline.finalScore + Card { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .firstTextBaseline) { + Text("与\(baselineNumber)相比").font(.system(size: 18, weight: .semibold)) + Spacer() + Text("\(baseline.date.formatted(date: .abbreviated, time: .shortened)) → \(session.date.formatted(date: .abbreviated, time: .shortened))").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + HStack(alignment: .firstTextBaseline, spacing: 14) { + Text(Self.signed(delta)).font(.system(size: 40, weight: .semibold, design: .rounded)).foregroundStyle(Self.color(delta)) + Text("分 · \(baseline.finalScore.scoreText) → \(session.finalScore.scoreText) / \(Int(session.task.maxScore))").font(.system(size: 14)).foregroundStyle(WB.secondary) + Spacer() + if current?.gradingMode != previous?.gradingMode { + Text("两稿评阅方式不同,分差仅供参考").font(.system(size: 11)).foregroundStyle(WB.amber) + } + } + if let current, let previous { + HStack(spacing: 12) { + change(session.task.isTranslation ? "译义" : "任务", previous.dimension(\.taskCompletion), current.dimension(\.taskCompletion)) + change("语言", previous.dimension(\.language), current.dimension(\.language)) + change("连贯", previous.dimension(\.coherence), current.dimension(\.coherence)) + change("语域", previous.dimension(\.register), current.dimension(\.register)) + change("主要问题", Double(previous.corrections.filter { $0.severity == .major }.count), Double(current.corrections.filter { $0.severity == .major }.count), lowerIsBetter: true, integer: true) + } + } + let diff = RevisionDiff.compare(baseline.originalEssay, session.originalEssay) + DisclosureGroup(isExpanded: $showDiff) { + Text(diff.text).font(.system(size: 14)).lineSpacing(6).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding(.top, 12) + } label: { + Text("逐词对比 · 新增 \(diff.inserted) · 删除 \(diff.removed)").font(.system(size: 13, weight: .medium)) + } + } + } + } + private func change(_ title: String, _ old: Double, _ new: Double, lowerIsBetter: Bool = false, integer: Bool = false) -> some View { + let delta = new - old + return VStack(alignment: .leading, spacing: 5) { + Text(title).font(.system(size: 11)).foregroundStyle(WB.secondary) + Text(integer ? "\(Int(old)) → \(Int(new))" : "\(old.scoreText) → \(new.scoreText)").font(.system(size: 13, weight: .medium)).monospacedDigit() + Text(delta == 0 ? "持平" : integer ? (delta > 0 ? "+\(Int(delta))" : "\(Int(delta))") : Self.signed(delta)).font(.system(size: 11)).foregroundStyle(Self.color(lowerIsBetter ? -delta : delta)) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading).background(WB.canvas, in: RoundedRectangle(cornerRadius: 10)) + } + static func signed(_ value: Double) -> String { value > 0 ? "+\(value.scoreText)" : value < 0 ? value.scoreText : "±0" } + static func color(_ value: Double) -> Color { value > 0 ? WB.green : value < 0 ? RevisionDiff.removedColor : WB.secondary } +} + +/// The draft a review should be compared with: its recorded parent, else the latest earlier attempt at the same question. +@MainActor enum RevisionBaseline { + static func find(for session: EssaySession, among sessions: [EssaySession]) -> (session: EssaySession, label: String)? { + let group = sessions.filter { + !$0.isDemo && $0.subtype == session.subtype && QuestionBank.key($0.question) == QuestionBank.key(session.question) && $0.questionImage == session.questionImage + }.sorted { $0.date == $1.date ? $0.id.uuidString < $1.id.uuidString : $0.date < $1.date } + let baseline: EssaySession? + if let parentID = session.parentSessionID, parentID != session.id, let parent = sessions.first(where: { $0.id == parentID }) { baseline = parent } + else { baseline = group.last { $0.date < session.date } } + guard let baseline else { return nil } + if let index = group.firstIndex(where: { $0.id == baseline.id }) { return (baseline, "第 \(index + 1) 稿") } + return (baseline, "上一稿") + } +} diff --git a/WriteBench/Features/Settings/SettingsView.swift b/WriteBench/Features/Settings/SettingsView.swift index 1505e11..dfb5897 100644 --- a/WriteBench/Features/Settings/SettingsView.swift +++ b/WriteBench/Features/Settings/SettingsView.swift @@ -1,7 +1,16 @@ import SwiftUI +import SwiftData +import UniformTypeIdentifiers struct SettingsView: View { + var showsBackup = true @AppStorage("showLiveWordCount") private var showLiveWordCount = false + @AppStorage("examTimeLimit") private var examTimeLimit = false + @AppStorage("autoSubmitAtLimit") private var autoSubmitAtLimit = false + @AppStorage("quickJudge") private var quickJudge = Judge.b + @AppStorage(UsageCost.inputKey) private var priceInput = 0.0 + @AppStorage(UsageCost.cachedKey) private var priceCached = 0.0 + @AppStorage(UsageCost.outputKey) private var priceOutput = 0.0 @AppStorage("deepSeekModel") private var model = DeepSeekClient.defaultModel @AppStorage("judgeProviderA") private var providerA = GradingProvider.deepSeek @AppStorage("judgeProviderB") private var providerB = GradingProvider.deepSeek @@ -27,6 +36,11 @@ struct SettingsView: View { Label("答题偏好", systemImage: "textformat.123").font(.system(size: 18, weight: .semibold)).labelStyle(BlueIconLabelStyle()) Toggle("答题时显示词数", isOn: $showLiveWordCount).toggleStyle(.switch).accessibilityIdentifier("showLiveWordCountSetting") Text("默认关闭,交卷后再显示本次词数。开启后,所有考试的答题页显示实时计数;中文译文显示字符数。").font(.system(size: 12)).foregroundStyle(WB.secondary) + Divider().padding(.vertical, 4) + Toggle("考试限时(倒计时)", isOn: $examTimeLimit).toggleStyle(.switch).accessibilityIdentifier("examTimeLimitSetting") + Toggle("到点自动交卷(三位评审)", isOn: $autoSubmitAtLimit).toggleStyle(.checkbox).disabled(!examTimeLimit).padding(.leading, 2) + Text("开启后答题页显示剩余时间,最后 5 分钟变色,到点提示音;不自动交卷时可继续作答并显示超时。限时:" + WritingTask.allCases.map { "\($0.exam == .ielts ? "雅思" : $0.exam == .cet6 ? "六级" : "考研")\($0.title) \($0.suggestedMinutes)" }.joined(separator: " · ") + " 分钟。") + .font(.system(size: 12)).foregroundStyle(WB.secondary).lineSpacing(3) } } providerCard @@ -43,6 +57,12 @@ struct SettingsView: View { Text("直接填写即可使用。默认只在本次运行中保留;无需输入 Mac 密码。").font(.system(size: 11)).foregroundStyle(WB.secondary) HStack { Text("Model").font(.system(size: 12, weight: .medium)).frame(width: 70, alignment: .leading); TextField("Model ID", text: $model).textFieldStyle(.roundedBorder).frame(maxWidth: 340) } Text("默认:\(DeepSeekClient.defaultModel) · MAX 思考。模型 ID 可修改,不会自动切换。").font(.system(size: 11)).foregroundStyle(WB.secondary) + HStack(spacing: 10) { + Text("价格").font(.system(size: 12, weight: .medium)).frame(width: 70, alignment: .leading) + priceField("输入", $priceInput); priceField("缓存命中", $priceCached); priceField("输出", $priceOutput) + Text("元 / 百万 tokens").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + Text("按 DeepSeek 官网当前价格填写后,评阅页和统计页显示估算花费;留空只显示 token 数。").font(.system(size: 11)).foregroundStyle(WB.secondary) HStack { Button { testConnection() } label: { Label(testing ? "Connecting…" : "Test connection", systemImage: "network") }.buttonStyle(QuietButtonStyle()).disabled(!keyExists || testing) if keyExists { Button("清除 Key") { Task { do { try await DeepSeekCredentials.forget(); keyExists = false; failed = false; status = "API Key 已移除。" } catch { showError(error) } } }.buttonStyle(QuietButtonStyle()) } @@ -60,6 +80,7 @@ struct SettingsView: View { settingsNote("Exam scales", "英语一:小作文 / 10,大作文 / 20;CET-6 写作原始分 / 15;IELTS 单项任务 band / 9。") } } + if showsBackup { BackupCard() } HStack(spacing: 10) { BrandMark(size: 24); Text("WriteBench \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")").font(.system(size: 12, weight: .medium)); Text("Made for a more deliberate writing practice.").font(.system(size: 11)).foregroundStyle(WB.secondary) }.padding(.top, 4) }.frame(maxWidth: 860).padding(32).frame(maxWidth: .infinity, alignment: .leading) }.task { await DeepSeekCredentials.restoreRememberedKey(); keyExists = DeepSeekCredentials.hasSessionKey } @@ -72,9 +93,21 @@ struct SettingsView: View { judgePicker(.b, selection: $providerB) judgePicker(.c, selection: $providerC) Text("三位评审独立阅读相同的原题和作文,本机取中位数。任一评审失败都不会生成总分或自动切换服务。").font(.system(size: 12)).foregroundStyle(WB.secondary).lineSpacing(4) + Divider() + HStack { + VStack(alignment: .leading, spacing: 4) { Text("快速单评").font(.system(size: 13, weight: .semibold)); Text("答题页“快速单评”或 ⇧⌘↩ · 只请一位评审,适合草稿").font(.system(size: 11)).foregroundStyle(WB.secondary) } + Spacer() + Picker("快速单评", selection: $quickJudge) { + ForEach(Judge.allCases) { judge in Text("\(judge.title) · \(provider(judge).title)").tag(judge) } + }.labelsHidden().frame(width: 240).accessibilityIdentifier("quickJudge") + } } } } + private func provider(_ judge: Judge) -> GradingProvider { switch judge { case .a: providerA; case .b: providerB; case .c: providerC } } + private func priceField(_ title: String, _ value: Binding) -> some View { + TextField(title, value: value, format: .number.precision(.fractionLength(0...4))).textFieldStyle(.roundedBorder).frame(width: 74).help(title) + } private func judgePicker(_ judge: Judge, selection: Binding) -> some View { HStack { VStack(alignment: .leading, spacing: 4) { Text(judge.title).font(.system(size: 13, weight: .semibold)); Text(judge.role).font(.system(size: 11)).foregroundStyle(WB.secondary) } @@ -136,3 +169,41 @@ struct SettingsView: View { } } } + +private struct BackupCard: View { + @Environment(\.modelContext) private var context + @State private var status: String? + @State private var failed = false + var body: some View { + Card { + VStack(alignment: .leading, spacing: 14) { + Label("数据备份", systemImage: "arrow.up.arrow.down.circle").font(.system(size: 18, weight: .semibold)).labelStyle(BlueIconLabelStyle()) + Text("导出为一个 JSON 文件,包含全部作文、评阅、草稿、题库、目录信息、复习卡和图片;不含 API Key 与 Codex 登录信息。恢复时只添加本机没有的记录,不覆盖现有内容。") + .font(.system(size: 12)).foregroundStyle(WB.secondary).lineSpacing(4) + HStack { + Button { export() } label: { Label("导出备份…", systemImage: "square.and.arrow.up") }.buttonStyle(QuietButtonStyle()).accessibilityIdentifier("exportBackup") + Button { restore() } label: { Label("从备份恢复…", systemImage: "square.and.arrow.down") }.buttonStyle(QuietButtonStyle()).accessibilityIdentifier("restoreBackup") + Spacer() + } + if let status { Label(status, systemImage: failed ? "exclamationmark.circle" : "checkmark.circle").font(.system(size: 12)).foregroundStyle(failed ? WB.amber : WB.green).textSelection(.enabled) } + } + } + } + private func export() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json]; panel.nameFieldStringValue = BackupService.suggestedFileName; panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let data = try BackupService.export(from: context) + try data.write(to: url, options: .atomic) + failed = false; status = "已导出到 \(url.lastPathComponent)(\(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)))。" + } catch { failed = true; status = error.localizedDescription } + } + private func restore() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json]; panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { failed = false; status = try BackupService.restore(try Data(contentsOf: url), into: context).text } + catch { failed = true; status = error.localizedDescription } + } +} diff --git a/WriteBench/Features/Statistics/StatisticsView.swift b/WriteBench/Features/Statistics/StatisticsView.swift index 9384ea8..15f3fdc 100644 --- a/WriteBench/Features/Statistics/StatisticsView.swift +++ b/WriteBench/Features/Statistics/StatisticsView.swift @@ -3,57 +3,153 @@ import SwiftData import Charts struct StatisticsView: View { + enum ModeFilter: String, CaseIterable, Identifiable { case all = "全部评阅", full = "三评", quick = "快速单评"; var id: String { rawValue } } + private struct Point: Identifiable { + let session: EssaySession + let report: GradingReport? + var id: UUID { session.id } + var date: Date { session.date } + var percent: Double { session.finalScore / session.task.maxScore * 100 } + var mode: GradingMode { report?.gradingMode ?? .full } + } + private struct DimensionPoint: Identifiable { + let id = UUID() + let date: Date + let name: String + let value: Double + } + private static let dimensionColors: KeyValuePairs = ["任务 / 译义": WB.blue, "语言": WB.green, "连贯": WB.amber, "语域": Color(red: 0.5, green: 0.38, blue: 0.78)] + @Query(sort: \EssaySession.date) private var sessions: [EssaySession] @State private var task: WritingTask? - private var real: [EssaySession] { sessions.filter { !$0.isDemo && (task == nil || $0.subtype == task?.rawValue) } } - private var average: Double { real.isEmpty ? 0 : real.reduce(0) { $0 + $1.finalScore / $1.task.maxScore * 100 } / Double(real.count) } - private var averageMinutes: Double { real.isEmpty ? 0 : real.reduce(0) { $0 + $1.writingDuration / 60 } / Double(real.count) } - private var categories: [(category: MistakeCategory, count: Int)] { MistakeCategory.allCases.map { category in (category, real.reduce(0) { $0 + ($1.report?.corrections.filter { $0.category == category }.count ?? 0) }) }.filter { $0.count > 0 }.sorted { $0.count > $1.count } } + @State private var mode: ModeFilter = .all + private var points: [Point] { + sessions.filter { !$0.isDemo && (task == nil || $0.subtype == task?.rawValue) }.map { Point(session: $0, report: $0.report) } + .filter { mode == .all || ($0.mode == .quick) == (mode == .quick) } + } var body: some View { + let points = points ScrollView { VStack(alignment: .leading, spacing: 24) { HStack(alignment: .top) { - SectionHeading(title: "Small steps. Visible progress.", subtitle: "Statistics · A clearer picture of your writing practice.") + SectionHeading(title: "Small steps. Visible progress.", subtitle: "Statistics · 看自己和自己比:趋势、变化与常犯的问题。") Spacer() + Picker("评阅方式", selection: $mode) { ForEach(ModeFilter.allCases) { Text($0.rawValue).tag($0) } }.labelsHidden().frame(width: 110) Picker("Task", selection: $task) { Text("All tasks").tag(Optional.none); ForEach(WritingTask.allCases) { Text($0.fullTitle).tag(Optional($0)) } }.frame(width: 210) } HStack(spacing: 16) { - metric("Essays", value: "\(real.count)", symbol: "doc.text", note: "Completed reviews") - metric("Average score", value: real.isEmpty ? "—" : String(format: "%.0f%%", average), symbol: "chart.line.uptrend.xyaxis", note: "Normalized to each task’s maximum") - metric("Writing time", value: real.isEmpty ? "—" : String(format: "%.1f min", averageMinutes), symbol: "stopwatch", note: "Average per essay") + metric("Essays", value: "\(points.count)", symbol: "doc.text", note: "Completed reviews") + metric("Average score", value: points.isEmpty ? "—" : String(format: "%.0f%%", average(points)), symbol: "chart.line.uptrend.xyaxis", note: "Normalized to each task’s maximum") + recentChange(points) + metric("Writing time", value: points.isEmpty ? "—" : String(format: "%.1f min", points.reduce(0) { $0 + $1.session.writingDuration / 60 } / Double(points.count)), symbol: "stopwatch", note: "Average per essay") } - Card { - VStack(alignment: .leading, spacing: 22) { - HStack { Text("Score trend").font(.system(size: 18, weight: .semibold)); Spacer(); Text("% of task maximum").font(.system(size: 11)).foregroundStyle(WB.secondary) } - if real.isEmpty { EmptyState(symbol: "chart.xyaxis.line", title: "Progress needs a first point.", detail: "Complete a DeepSeek review to start your score trend. Demo results are excluded.") } - else { - Chart(real) { session in - LineMark(x: .value("Date", session.date), y: .value("Score %", session.finalScore / session.task.maxScore * 100)).foregroundStyle(WB.blue.opacity(0.7)).lineStyle(StrokeStyle(lineWidth: 2)).interpolationMethod(.linear) - PointMark(x: .value("Date", session.date), y: .value("Score %", session.finalScore / session.task.maxScore * 100)).foregroundStyle(WB.blue).symbolSize(40) - .accessibilityLabel("\(session.task.fullTitle), \(session.date.formatted(date: .abbreviated, time: .omitted))") - .accessibilityValue("\(session.finalScore.scoreText) out of \(Int(session.task.maxScore))") - }.chartYScale(domain: 0...100).chartYAxis { AxisMarks(values: [0, 25, 50, 75, 100]) { value in AxisGridLine().foregroundStyle(WB.line); AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } } } }.frame(height: 265) + scoreTrend(points) + dimensionTrend(points) + patterns(points) + usage(points) + Text("分数换算为各题型满分的百分比,便于跨题型比较自己的变化;IELTS 单项任务分不是完整 Writing 成绩。快速单评只有一位评审,与三评混看时请留意。同一篇中重复的修改只计一次。") + .font(.system(size: 11)).foregroundStyle(WB.secondary).lineSpacing(4) + }.padding(32) + } + } + private func average(_ points: some Collection) -> Double { points.isEmpty ? 0 : points.reduce(0) { $0 + $1.percent } / Double(points.count) } + @ViewBuilder private func recentChange(_ points: [Point]) -> some View { + let window = min(5, points.count / 2) + if window >= 2 { + let recent = average(points.suffix(window)), earlier = average(points.dropLast(window).suffix(window)), delta = recent - earlier + metric("Recent change", value: String(format: "%@%.1f", delta >= 0 ? "+" : "", delta), symbol: delta >= 0 ? "arrow.up.right" : "arrow.down.right", + note: "近 \(window) 篇比之前 \(window) 篇 · 百分点", tint: delta > 0 ? WB.green : delta < 0 ? WB.amber : WB.blue) + } else { + metric("Recent change", value: "—", symbol: "arrow.up.right", note: "至少 4 篇后显示近期变化") + } + } + private func scoreTrend(_ points: [Point]) -> some View { + Card { + VStack(alignment: .leading, spacing: 22) { + HStack { Text("Score trend").font(.system(size: 18, weight: .semibold)); Spacer(); Text("% of task maximum · 虚线为最近 3 篇平均").font(.system(size: 11)).foregroundStyle(WB.secondary) } + if points.isEmpty { EmptyState(symbol: "chart.xyaxis.line", title: "Progress needs a first point.", detail: "Complete a review to start your score trend.") } + else { + Chart { + ForEach(points) { point in + LineMark(x: .value("Date", point.date), y: .value("Score %", point.percent), series: .value("Series", "得分")) + .foregroundStyle(WB.blue.opacity(0.45)).lineStyle(StrokeStyle(lineWidth: 1.5)) + PointMark(x: .value("Date", point.date), y: .value("Score %", point.percent)) + .foregroundStyle(point.mode == .quick ? WB.blue.opacity(0.4) : WB.blue).symbolSize(point.mode == .quick ? 28 : 40) + .accessibilityLabel("\(point.session.task.fullTitle), \(point.date.formatted(date: .abbreviated, time: .omitted))\(point.mode == .quick ? ", 快速单评" : "")") + .accessibilityValue("\(point.session.finalScore.scoreText) out of \(Int(point.session.task.maxScore))") } - } - } - Card { - VStack(alignment: .leading, spacing: 20) { - Text("Patterns to work on").font(.system(size: 18, weight: .semibold)) - if categories.isEmpty { Text("Recurring correction categories will appear after your first review.").font(.system(size: 14)).foregroundStyle(WB.secondary).padding(.vertical, 20) } - else { - Chart(categories, id: \.category) { item in BarMark(x: .value("Corrections", item.count), y: .value("Category", item.category.rawValue)).foregroundStyle(WB.blue.opacity(0.7)).cornerRadius(4).annotation(position: .trailing) { Text("\(item.count)").font(.system(size: 11)).foregroundStyle(WB.secondary) } }.chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) }.frame(height: CGFloat(max(3, categories.count)) * 35) + ForEach(Array(points.enumerated()), id: \.element.id) { index, point in + LineMark(x: .value("Date", point.date), y: .value("Score %", average(points[max(0, index - 2)...index])), series: .value("Series", "平均")) + .foregroundStyle(WB.green).lineStyle(StrokeStyle(lineWidth: 2, dash: [5, 4])) } - } + }.chartYScale(domain: 0...100).chartYAxis { AxisMarks(values: [0, 25, 50, 75, 100]) { value in AxisGridLine().foregroundStyle(WB.line); AxisValueLabel { if let number = value.as(Int.self) { Text("\(number)%") } } } }.frame(height: 265) } - Text("Demo reviews are excluded. Scores are normalized to compare different task scales; an IELTS task score is not an overall Writing band. Duplicate reviewer corrections count once per essay.").font(.system(size: 11)).foregroundStyle(WB.secondary).lineSpacing(4) - }.padding(32) + } + } + } + private func dimensionTrend(_ points: [Point]) -> some View { + let names = Self.dimensionColors.map(\.key) + let data = points.flatMap { point -> [DimensionPoint] in + guard let report = point.report else { return [] } + let values = [report.dimension(\.taskCompletion), report.dimension(\.language), report.dimension(\.coherence), report.dimension(\.register)] + return zip(names, values).map { DimensionPoint(date: point.date, name: $0.0, value: $0.1) } } + return Card { + VStack(alignment: .leading, spacing: 18) { + HStack { Text("Dimensions").font(.system(size: 18, weight: .semibold)); Spacer(); Text("诊断分 / 10 · 三评取中位数").font(.system(size: 11)).foregroundStyle(WB.secondary) } + if data.isEmpty { Text("完成评阅后显示各维度的变化。").font(.system(size: 14)).foregroundStyle(WB.secondary).padding(.vertical, 20) } + else { + Chart(data) { item in + LineMark(x: .value("Date", item.date), y: .value("Score", item.value)).foregroundStyle(by: .value("维度", item.name)).interpolationMethod(.monotone) + PointMark(x: .value("Date", item.date), y: .value("Score", item.value)).foregroundStyle(by: .value("维度", item.name)).symbolSize(18) + }.chartForegroundStyleScale(Self.dimensionColors).chartYScale(domain: 0...10).chartLegend(position: .top, alignment: .leading).frame(height: 230) + } + } + } + } + private func patterns(_ points: [Point]) -> some View { + let categories = MistakeCategory.allCases.map { category in + (category: category, count: points.reduce(0) { $0 + ($1.report?.corrections.filter { $0.category == category }.count ?? 0) }) + }.filter { $0.count > 0 }.sorted { $0.count > $1.count } + return Card { + VStack(alignment: .leading, spacing: 20) { + HStack { Text("Patterns to work on").font(.system(size: 18, weight: .semibold)); Spacer(); Text("在 Mistakes 中查看每类的近期变化").font(.system(size: 11)).foregroundStyle(WB.secondary) } + if categories.isEmpty { Text("Recurring correction categories will appear after your first review.").font(.system(size: 14)).foregroundStyle(WB.secondary).padding(.vertical, 20) } + else { + Chart(categories, id: \.category) { item in BarMark(x: .value("Corrections", item.count), y: .value("Category", item.category.rawValue)).foregroundStyle(WB.blue.opacity(0.7)).cornerRadius(4).annotation(position: .trailing) { Text("\(item.count)").font(.system(size: 11)).foregroundStyle(WB.secondary) } }.chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) }.frame(height: CGFloat(max(3, categories.count)) * 35) + } + } + } + } + @ViewBuilder private func usage(_ points: [Point]) -> some View { + let reports = points.compactMap(\.report) + let durations = reports.compactMap(\.duration) + let byProvider = GradingProvider.allCases.compactMap { provider -> (GradingProvider, TokenUsage)? in + let usages = reports.flatMap(\.reviewers).filter { $0.provider == provider }.compactMap(\.usage) + guard let first = usages.first else { return nil } + return (provider, usages.dropFirst().reduce(first, +)) + } + let costs = reports.compactMap { UsageCost.cost($0) } + if !byProvider.isEmpty || !durations.isEmpty { + Card { + VStack(alignment: .leading, spacing: 12) { + Text("Review cost").font(.system(size: 18, weight: .semibold)) + if !durations.isEmpty { usageRow("平均评阅用时", UsageCost.duration(durations.reduce(0, +) / Double(durations.count)) + " · \(durations.count) 次有记录") } + ForEach(byProvider, id: \.0) { provider, usage in usageRow(provider.title, UsageCost.summary(usage)) } + if !costs.isEmpty { usageRow("DeepSeek 估算", "合计 \(UsageCost.yuan(costs.reduce(0, +))) · 平均每篇 \(UsageCost.yuan(costs.reduce(0, +) / Double(costs.count)))") } + Text("1.5 之前的评阅没有用量记录。花费按设置中填写的 DeepSeek 价格估算;Codex 使用 ChatGPT 额度。").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + } + } + } + private func usageRow(_ title: String, _ value: String) -> some View { + HStack(alignment: .top, spacing: 16) { Text(title).font(.system(size: 12, weight: .medium)).frame(width: 140, alignment: .leading); Text(value).font(.system(size: 12)).foregroundStyle(WB.secondary).textSelection(.enabled) } } - private func metric(_ title: String, value: String, symbol: String, note: String) -> some View { + private func metric(_ title: String, value: String, symbol: String, note: String, tint: Color = WB.blue) -> some View { Card(padding: 22) { VStack(alignment: .leading, spacing: 15) { - HStack { Text(title).font(.system(size: 13)).foregroundStyle(WB.secondary); Spacer(); Image(systemName: symbol).foregroundStyle(WB.blue) } - Text(value).font(.system(size: 30, weight: .semibold, design: .rounded)).minimumScaleFactor(0.7).lineLimit(1) + HStack { Text(title).font(.system(size: 13)).foregroundStyle(WB.secondary); Spacer(); Image(systemName: symbol).foregroundStyle(tint) } + Text(value).font(.system(size: 30, weight: .semibold, design: .rounded)).foregroundStyle(tint == WB.blue ? WB.ink : tint).minimumScaleFactor(0.7).lineLimit(1) Text(note).font(.system(size: 10)).foregroundStyle(WB.secondary) } } diff --git a/WriteBench/Features/Writing/ImmersiveWritingView.swift b/WriteBench/Features/Writing/ImmersiveWritingView.swift index 58736e0..fc36c76 100644 --- a/WriteBench/Features/Writing/ImmersiveWritingView.swift +++ b/WriteBench/Features/Writing/ImmersiveWritingView.swift @@ -3,9 +3,11 @@ import SwiftUI /// The only workspace that can edit or submit an answer. struct ImmersiveWritingView: View { @AppStorage("showLiveWordCount") private var showLiveWordCount = false + @AppStorage("examTimeLimit") private var examTimeLimit = false @Bindable var store: WritingStore var isRecognizing: Bool var onSubmit: () -> Void + var onQuickSubmit: () -> Void var onImport: () -> Void var onCancelOCR: () -> Void var body: some View { @@ -16,7 +18,10 @@ struct ImmersiveWritingView: View { Spacer() Text(store.task.fullTitle).font(.system(size: 13, weight: .medium)).foregroundStyle(WB.secondary) Spacer() - Label(store.timerText, systemImage: "clock").font(.system(size: 14)).monospacedDigit().foregroundStyle(WB.ink).accessibilityLabel("作答用时 \(store.timerText)") + timer + Button("快速单评", action: onQuickSubmit).buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary) + .disabled(store.essay.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.isGrading || isRecognizing) + .keyboardShortcut(.return, modifiers: [.command, .shift]).help("只请一位评审,适合草稿;⇧⌘↩").accessibilityIdentifier("quickHandIn") Button("交卷", action: onSubmit).buttonStyle(ExamSubmitStyle()).disabled(store.essay.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.isGrading || isRecognizing).keyboardShortcut(.return, modifiers: .command).accessibilityIdentifier("handInEssay") }.padding(.horizontal, 32).frame(height: 62) Rectangle().fill(Color.black.opacity(0.07)).frame(height: 1) @@ -37,6 +42,18 @@ struct ImmersiveWritingView: View { } } } + @ViewBuilder private var timer: some View { + if examTimeLimit { + let limit = TimeInterval(store.task.suggestedMinutes * 60), remaining = limit - store.elapsed + let text = remaining >= 0 ? "剩余 \(WritingStore.clock(remaining))" : "超时 +\(WritingStore.clock(-remaining))" + Label(text, systemImage: remaining >= 0 ? "timer" : "exclamationmark.circle").font(.system(size: 14, weight: remaining < 300 ? .medium : .regular)).monospacedDigit() + .foregroundStyle(remaining < 300 ? WB.amber : WB.ink) + .help("考试限时 \(store.task.suggestedMinutes) 分钟 · 已用 \(store.timerText)") + .accessibilityLabel(remaining >= 0 ? "剩余时间 \(WritingStore.clock(remaining))" : "已超时 \(WritingStore.clock(-remaining))") + } else { + Label(store.timerText, systemImage: "clock").font(.system(size: 14)).monospacedDigit().foregroundStyle(WB.ink).accessibilityLabel("作答用时 \(store.timerText)") + } + } private var question: some View { VStack(alignment: .leading, spacing: 22) { Text("试题").font(.system(size: 12, weight: .medium)).foregroundStyle(WB.secondary) diff --git a/WriteBench/Features/Writing/QuestionLibraryView.swift b/WriteBench/Features/Writing/QuestionLibraryView.swift index 257a693..d5c6177 100644 --- a/WriteBench/Features/Writing/QuestionLibraryView.swift +++ b/WriteBench/Features/Writing/QuestionLibraryView.swift @@ -2,32 +2,132 @@ import SwiftUI import SwiftData struct QuestionLibraryView: View { + enum Source: String, CaseIterable, Identifiable { case builtIn = "内置题目", saved = "我的题库"; var id: String { rawValue } } @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @Query(sort: \SavedQuestion.date, order: .reverse) private var questions: [SavedQuestion] + @Query(filter: #Predicate { !$0.isDemo }) private var sessions: [EssaySession] @Bindable var store: WritingStore + @State private var source: Source = .builtIn + @State private var task: WritingTask? + @State private var search = "" + @State private var unpractisedOnly = false + @State private var label: String? @State private var title = "" + @State private var year = "" + @State private var newLabel = "" @State private var error: String? + + private struct Item: Identifiable { + let id: String + let task: WritingTask + let title: String + let prompt: String + var image: Data? = nil + var year: Int? = nil + var label = "" + var saved: SavedQuestion? = nil + } + private var currentTask: WritingTask { task ?? store.task } + private var practiceIndex: PracticeIndex { PracticeIndex(sessions) } + private var labels: [String] { Array(Set(questions.filter { $0.subtype == currentTask.rawValue }.map(\.label).filter { !$0.isEmpty })).sorted() } + private func items(_ index: PracticeIndex) -> [Item] { + let all: [Item] = source == .builtIn + ? QuestionBank.questions(for: currentTask).map { Item(id: $0.id, task: $0.task, title: $0.title, prompt: $0.prompt) } + : questions.filter { $0.subtype == currentTask.rawValue }.map { + Item(id: $0.id.uuidString, task: currentTask, title: $0.title, prompt: $0.prompt, image: $0.image, year: $0.year, label: $0.label, saved: $0) + } + let query = search.trimmingCharacters(in: .whitespacesAndNewlines) + return all.filter { item in + (query.isEmpty || [item.title, item.prompt, item.label].contains { $0.localizedCaseInsensitiveContains(query) }) + && (!unpractisedOnly || index.attempts(item.task, item.prompt).isEmpty) + && (source == .builtIn || label == nil || item.label == label) + } + } var body: some View { - VStack(alignment: .leading, spacing: 20) { - HStack { SectionHeading(title: "我的真题库", subtitle: "保存你导入的题目。内置示例均为原创练习。") ; Spacer(); IconButton(symbol: "xmark", help: "Close") { dismiss() } } - HStack { TextField("题目名称,例如:2024 英语一 · 邀请信", text: $title).textFieldStyle(.roundedBorder); Button("保存当前题目") { save() }.buttonStyle(QuietButtonStyle()).disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.question.isEmpty) } + let index = practiceIndex, visible = items(index) + VStack(alignment: .leading, spacing: 16) { + HStack { + SectionHeading(title: "题库", subtitle: "内置题目均为原创练习;导入或识别的题目可保存到我的题库。") + Spacer() + IconButton(symbol: "xmark", help: "Close") { dismiss() } + } + HStack(spacing: 12) { + Picker("来源", selection: $source) { ForEach(Source.allCases) { Text($0.rawValue).tag($0) } }.pickerStyle(.segmented).labelsHidden().fixedSize() + Picker("题型", selection: Binding(get: { currentTask }, set: { task = $0; label = nil })) { + ForEach(store.task.exam.tasks) { Text($0.title).tag($0) } + }.labelsHidden().frame(width: 140) + if source == .saved && !labels.isEmpty { + Picker("标签", selection: $label) { + Text("全部标签").tag(Optional.none) + ForEach(labels, id: \.self) { Text($0).tag(Optional($0)) } + }.labelsHidden().frame(width: 120) + } + Spacer() + Toggle("只看未练习", isOn: $unpractisedOnly).toggleStyle(.checkbox).font(.system(size: 12)) + Button { if let pick = (visible.filter { index.attempts($0.task, $0.prompt).isEmpty }.randomElement() ?? visible.randomElement()) { use(pick) } } label: { + Label("随机一道", systemImage: "dice") + }.buttonStyle(QuietButtonStyle()).disabled(visible.isEmpty).help("优先抽取还没练过的题") + } + HStack { + Image(systemName: "magnifyingglass").foregroundStyle(WB.secondary) + TextField("搜索题目", text: $search).textFieldStyle(.plain) + }.padding(10).background(.white, in: RoundedRectangle(cornerRadius: 10)).overlay(RoundedRectangle(cornerRadius: 10).stroke(WB.line)) ScrollView { - VStack(spacing: 12) { - ForEach(questions.filter { WritingTask(rawValue: $0.subtype)?.exam == store.task.exam }) { question in - Button { if let task = WritingTask(rawValue: question.subtype) { store.select(task) }; store.question = question.prompt; store.questionLabel = question.title; store.questionImage = question.image; dismiss() } label: { - Card(padding: 18) { VStack(alignment: .leading, spacing: 8) { Text(question.title).font(.system(size: 15, weight: .semibold)); Text(question.prompt).font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(3) } } - }.buttonStyle(.plain) + VStack(spacing: 10) { + ForEach(visible) { item in row(item, attempts: index.attempts(item.task, item.prompt)) } + if visible.isEmpty { + EmptyState(symbol: "books.vertical", title: source == .saved ? "把好题留在这里" : "没有符合条件的题目", + detail: source == .saved ? "在写作页导入或识别题目,再在下方命名保存。" : "换一个题型,或关闭“只看未练习”。") } - if questions.filter({ WritingTask(rawValue: $0.subtype)?.exam == store.task.exam }).isEmpty { EmptyState(symbol: "books.vertical", title: "把好题留在这里", detail: "在写作页输入或识别题目,再为它命名保存。") } } } + if source == .saved { saveBar } if let error { Text(error).foregroundStyle(WB.amber).font(.system(size: 12)) } - }.padding(28).frame(width: 670, height: 540).background(WB.canvas) + }.padding(28).frame(width: 760, height: 640).background(WB.canvas) + } + private func row(_ item: Item, attempts: [EssaySession]) -> some View { + Button { use(item) } label: { + Card(padding: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(item.title).font(.system(size: 14, weight: .semibold)) + if let year = item.year { Text(String(year)).font(.system(size: 11)).foregroundStyle(WB.blue) } + if !item.label.isEmpty { Text(item.label).font(.system(size: 11)).foregroundStyle(WB.secondary).padding(.horizontal, 6).padding(.vertical, 2).background(WB.tint, in: Capsule()) } + Spacer() + if let latest = attempts.last { + Text("已练 \(attempts.count) 次 · 最近 \(latest.finalScore.scoreText) / \(Int(item.task.maxScore))").font(.system(size: 11)).foregroundStyle(WB.green) + } else { Text("未练习").font(.system(size: 11)).foregroundStyle(WB.secondary) } + } + Text(item.prompt).font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(3).frame(maxWidth: .infinity, alignment: .leading) + } + } + }.buttonStyle(.plain) + .contextMenu { if let saved = item.saved { Button("从我的题库删除", role: .destructive) { delete(saved) } } } + } + private var saveBar: some View { + HStack(spacing: 8) { + TextField("题目名称,例如:2024 英语一 · 邀请信", text: $title).textFieldStyle(.roundedBorder) + TextField("年份", text: $year).textFieldStyle(.roundedBorder).frame(width: 64) + TextField("标签", text: $newLabel).textFieldStyle(.roundedBorder).frame(width: 100) + Button("保存当前题目") { save() }.buttonStyle(QuietButtonStyle()) + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.question.isEmpty) + }.help("保存写作页当前的\(store.task.title)题目") + } + private func use(_ item: Item) { + if store.useQuestion(item.prompt, title: item.title, task: item.task, image: item.image) { dismiss() } } private func save() { - let question = SavedQuestion(title: title, task: store.task, prompt: store.question, image: store.questionImage) - context.insert(question) - do { try context.save(); title = "" } catch { context.delete(question); self.error = error.localizedDescription } + do { + let details = try EssayFolderDetails(title: title, year: year, label: newLabel) + let question = SavedQuestion(title: details.title, task: store.task, prompt: store.question, image: store.questionImage, year: details.year, label: details.label) + context.insert(question) + do { try context.save(); title = ""; year = ""; newLabel = ""; error = nil; task = store.task } + catch { context.delete(question); throw error } + } catch { self.error = error.localizedDescription } + } + private func delete(_ question: SavedQuestion) { + context.delete(question) + do { try context.save() } catch { context.rollback(); self.error = error.localizedDescription } } } diff --git a/WriteBench/Features/Writing/WritingStore.swift b/WriteBench/Features/Writing/WritingStore.swift index f0d0439..91cc2d2 100644 --- a/WriteBench/Features/Writing/WritingStore.swift +++ b/WriteBench/Features/Writing/WritingStore.swift @@ -28,15 +28,21 @@ enum WritingStage { case preparation, answering } private var lastTick = Date() private var lastPersist = Date() private var rewriteSessionID: UUID? + /// Increments each time a running answer crosses the exam time limit. + private(set) var timeUpCount = 0 var words: Int { WordCounter.count(essay) } - var timerText: String { let t = Int(elapsed); return String(format: "%02d:%02d", t / 60, t % 60) } + var timerText: String { Self.clock(elapsed) } + /// The exam's suggested time, when the exam time limit setting is on. + var timeLimit: TimeInterval? { UserDefaults.standard.bool(forKey: "examTimeLimit") ? TimeInterval(task.suggestedMinutes * 60) : nil } + static func clock(_ seconds: TimeInterval) -> String { let t = Int(max(0, seconds)); return String(format: "%02d:%02d", t / 60, t % 60) } func attach(_ context: ModelContext) { guard self.context == nil else { return }; self.context = context; restore() } func tick() { - let now = Date() + let now = Date(), before = elapsed if timerRunning { elapsed += max(0, now.timeIntervalSince(lastTick)) } lastTick = now + if timerRunning, let limit = timeLimit, before < limit, elapsed >= limit { timeUpCount += 1 } if timerRunning && now.timeIntervalSince(lastPersist) >= 15 { persistDraft() } } @discardableResult func startAnswering() -> Bool { @@ -138,18 +144,18 @@ enum WritingStage { case preparation, answering } codex = CodexJudgeService(executable: connection.executable, model: configuration.codexModel, reasoning: configuration.codexReasoning) } catch { if Task.isCancelled || error is CancellationError { throw CancellationError() } - let judges = Judge.allCases.filter { configuration.provider(for: $0) == .codex }.map(\.title).joined(separator: "、") - throw JudgeExecutionError(judge: Judge.allCases.first { configuration.provider(for: $0) == .codex } ?? .c, + let judges = configuration.judges.filter { configuration.provider(for: $0) == .codex }.map(\.title).joined(separator: "、") + throw JudgeExecutionError(judge: configuration.judges.first { configuration.provider(for: $0) == .codex } ?? .c, detail: "\(judges) · ChatGPT via Codex\n\(error.localizedDescription)\n尚未发起评卷,请在设置中检查连接。") } } return ProviderRouter(configuration: configuration, deepSeek: selectedDeepSeek, codex: codex) } } - func submit(service: any EssayGradingService, isDemo: Bool, onComplete: @escaping (EssaySession) -> Void = { _ in }) { - launchSubmission(configuration: nil, isDemo: isDemo, onComplete: onComplete) { service } + func submit(service: any EssayGradingService, isDemo: Bool, judges: [Judge] = Judge.allCases, onComplete: @escaping (EssaySession) -> Void = { _ in }) { + launchSubmission(configuration: nil, judges: judges, isDemo: isDemo, onComplete: onComplete) { service } } - private func launchSubmission(configuration: GradingConfiguration?, isDemo: Bool, onComplete: @escaping (EssaySession) -> Void, + private func launchSubmission(configuration: GradingConfiguration?, judges: [Judge]? = nil, isDemo: Bool, onComplete: @escaping (EssaySession) -> Void, makeService: @escaping @Sendable () async throws -> any EssayGradingService) { guard !isGrading else { return } guard stage == .answering else { error = "请先点击开始答题。"; return } @@ -159,7 +165,7 @@ enum WritingStage { case preparation, answering } do { let submission = GradingSubmission(input: GradingInput(task: task, question: question, essay: essay, rubric: try RubricLoader.load(task)), duration: elapsed, inputMode: inputMode, questionImage: questionImage, sourceImages: sourceImages, parentSessionID: rewriteSessionID) - let job = BackgroundGradingJob(submission: submission, configuration: configuration, connecting: configuration?.requiresCodex == true) + let job = BackgroundGradingJob(submission: submission, configuration: configuration, connecting: configuration?.requiresCodex == true, judges: judges) gradingJob = job timerRunning = false; stage = .preparation gradingTask = Task { @@ -170,7 +176,7 @@ enum WritingStage { case preparation, answering } let service = try await makeService() try Task.checkCancellation() job.phase = .reviewing - let report = try await GradingCoordinator(service: service).grade(submission.input, isDemo: isDemo) { event in + let report = try await GradingCoordinator(service: service).grade(submission.input, isDemo: isDemo, judges: job.activeJudges) { event in await MainActor.run { job.receive(event) } } try Task.checkCancellation() @@ -181,6 +187,7 @@ enum WritingStage { case preparation, answering } context.insert(session) do { try context.save() } catch { context.delete(session); throw error } job.session = session; job.finish(.completed) + clearSubmittedAnswer(submission) onComplete(session) } catch { if Task.isCancelled || error is CancellationError { job.finish(.cancelled) } @@ -192,6 +199,31 @@ enum WritingStage { case preparation, answering } } } catch { self.error = error.localizedDescription } } + /// After a saved review, an untouched submitted draft starts fresh; the answer lives on in History. + private func clearSubmittedAnswer(_ submission: GradingSubmission) { + guard stage == .preparation, task == submission.input.task, essay == submission.input.essay, question == submission.input.question else { return } + saveTask?.cancel() + rewriteSessionID = nil + essay = ""; elapsed = 0; inputMode = .typed; sourceImages = [] + persistDraft() + } + /// Discards the current answer and timer for this task; the question stays. + func startOver() { + guard stage == .preparation, !essay.isEmpty || elapsed > 0 else { return } + saveTask?.cancel() + rewriteSessionID = nil + essay = ""; elapsed = 0; inputMode = .typed; sourceImages = [] + persistDraft() + } + /// Loads a bank question into the current draft, like a text import. + @discardableResult func useQuestion(_ prompt: String, title: String, task next: WritingTask, image: Data? = nil) -> Bool { + guard stage == .preparation else { return false } + select(next) + guard next == task else { return false } + saveTask?.cancel() + question = prompt; questionLabel = title; questionImage = image; rewriteSessionID = nil + return persistDraft() + } func cancelGrading() { guard let job = gradingJob, job.phase.isRunning else { return } job.phase = .cancelling diff --git a/WriteBench/Features/Writing/WritingView.swift b/WriteBench/Features/Writing/WritingView.swift index 544b35c..9687df2 100644 --- a/WriteBench/Features/Writing/WritingView.swift +++ b/WriteBench/Features/Writing/WritingView.swift @@ -1,7 +1,11 @@ import SwiftUI +import SwiftData import AppKit struct WritingView: View { + @AppStorage("autoSubmitAtLimit") private var autoSubmitAtLimit = false + @AppStorage("examTimeLimit") private var examTimeLimit = false + @Query(filter: #Predicate { !$0.isDemo }) private var sessions: [EssaySession] @Bindable var store: WritingStore @State private var ocrImport: OCRImport? @State private var isRecognizing = false @@ -12,12 +16,16 @@ struct WritingView: View { var body: some View { Group { if store.isInSession { - ImmersiveWritingView(store: store, isRecognizing: isRecognizing, onSubmit: submit, onImport: { importImages(.essay) }, onCancelOCR: { ocrTask?.cancel() }) + ImmersiveWritingView(store: store, isRecognizing: isRecognizing, onSubmit: { submit() }, onQuickSubmit: { submit(.quick) }, onImport: { importImages(.essay) }, onCancelOCR: { ocrTask?.cancel() }) } else { preparation } } .onReceive(clock) { _ in store.tick() } + .onChange(of: store.timeUpCount) { _, _ in + NSSound(named: "Glass")?.play() + if autoSubmitAtLimit, store.isInSession, !isRecognizing, !store.essay.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { submit() } + } .sheet(item: $ocrImport) { imported in OCRConfirmationView(imported: imported, initialQuestion: store.question, allowEssayImport: store.isInSession) { purpose, question, essay, images in store.question = question @@ -52,9 +60,13 @@ struct WritingView: View { HStack(alignment: .center, spacing: 24) { VStack(alignment: .leading, spacing: 7) { Text(store.essay.isEmpty ? "开始后进入沉浸式答题" : "已有草稿,开始后继续作答").font(.system(size: 14, weight: .medium)) - Text("题目、答题区与计时。手写稿可在答题页导入。").font(.system(size: 12)).foregroundStyle(WB.secondary) + Text(examTimeLimit ? "考试限时 \(store.task.suggestedMinutes) 分钟\(autoSubmitAtLimit ? ",到点自动交卷" : ",到点提醒")。手写稿可在答题页导入。" : "题目、答题区与计时。手写稿可在答题页导入。").font(.system(size: 12)).foregroundStyle(WB.secondary) } Spacer() + if !store.essay.isEmpty || store.elapsed > 0 { + Button("清空重来") { store.startOver() }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary) + .help("清空本题型当前的作答和计时,题目保留").accessibilityIdentifier("startOver") + } Button { editingQuestion = false; store.startAnswering() } label: { HStack(spacing: 12) { Text("开始答题"); Image(systemName: "arrow.right") } } .buttonStyle(PrimaryButtonStyle()).disabled(store.question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isRecognizing) .keyboardShortcut(.return, modifiers: .command).accessibilityIdentifier("startAnswering") @@ -78,7 +90,9 @@ struct WritingView: View { }.buttonStyle(.plain) } Spacer() - Button { store.showLibrary = true } label: { Label("我的题库", systemImage: "books.vertical") }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary) + Button { randomQuestion() } label: { Label("随机抽题", systemImage: "dice") }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary) + .help("从内置题目中抽一道还没练过的\(store.task.title)").accessibilityIdentifier("randomQuestion") + Button { store.showLibrary = true } label: { Label("题库", systemImage: "books.vertical") }.buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary) } } private var questionCard: some View { @@ -107,8 +121,14 @@ struct WritingView: View { } }.padding(28).background(.white, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(WB.line.opacity(0.8))) } - private func submit() { - store.submitConfigured(configuration: .load()) + private func randomQuestion() { + let index = PracticeIndex(sessions), current = QuestionBank.key(store.question) + let candidates = QuestionBank.questions(for: store.task).filter { QuestionBank.key($0.prompt) != current } + guard let pick = candidates.filter({ index.attempts($0.task, $0.prompt).isEmpty }).randomElement() ?? candidates.randomElement() else { return } + store.useQuestion(pick.prompt, title: pick.title, task: pick.task) + } + private func submit(_ mode: GradingMode = .full) { + store.submitConfigured(configuration: GradingConfiguration.load().with(mode)) } private func importImages(_ purpose: OCRPurpose) { guard !isRecognizing, !store.isGrading else { return } diff --git a/WriteBench/Models/BackgroundGradingJob.swift b/WriteBench/Models/BackgroundGradingJob.swift index 2b313ee..c4bcded 100644 --- a/WriteBench/Models/BackgroundGradingJob.swift +++ b/WriteBench/Models/BackgroundGradingJob.swift @@ -43,15 +43,21 @@ enum GradingProgressEvent: Sendable { let startedAt = Date() var finishedAt: Date? var phase: GradingPhase - var judges = Dictionary(uniqueKeysWithValues: Judge.allCases.map { ($0, JudgeProgress.waiting) }) + /// Judges taking part in this submission: three for a full review, one for a quick review. + let activeJudges: [Judge] + var judges: [Judge: JudgeProgress] var previews: [Judge: String] = [:] var results: [Judge: ReviewerResult] = [:] var detail: String? var session: EssaySession? var completedCount: Int { judges.values.filter { $0 == .completed }.count } + var total: Int { activeJudges.count } + var isQuick: Bool { activeJudges.count == 1 } - init(submission: GradingSubmission, configuration: GradingConfiguration?, connecting: Bool) { + init(submission: GradingSubmission, configuration: GradingConfiguration?, connecting: Bool, judges: [Judge]? = nil) { self.submission = submission; self.configuration = configuration + activeJudges = judges ?? configuration?.judges ?? Judge.allCases + self.judges = Dictionary(uniqueKeysWithValues: activeJudges.map { ($0, JudgeProgress.waiting) }) phase = connecting ? .connecting : .reviewing } func receive(_ event: GradingProgressEvent) { @@ -67,7 +73,7 @@ enum GradingProgressEvent: Sendable { } func finish(_ phase: GradingPhase, detail: String? = nil) { self.phase = phase; self.detail = detail; finishedAt = Date() - for judge in Judge.allCases where judges[judge] == .waiting || judges[judge] == .reviewing { judges[judge] = .cancelled } + for judge in activeJudges where judges[judge] == .waiting || judges[judge] == .reviewing { judges[judge] = .cancelled } } func elapsedText(at date: Date) -> String { let seconds = max(0, Int((finishedAt ?? date).timeIntervalSince(startedAt))) diff --git a/WriteBench/Models/GradingModels.swift b/WriteBench/Models/GradingModels.swift index a9ec64e..b3d257e 100644 --- a/WriteBench/Models/GradingModels.swift +++ b/WriteBench/Models/GradingModels.swift @@ -20,6 +20,11 @@ struct Correction: Codable, Hashable, Identifiable, Sendable { var explanation: String var id: String { "\(category.rawValue)|\(original)|\(corrected)" } } +struct ExpressionSuggestion: Codable, Hashable, Sendable { + var phrase: String + var meaning: String + var example: String +} struct JudgeResponse: Codable, Sendable { var score: Double // Diagnostic dimensions use a common 0–10 scale; overall score uses the exam scale. @@ -35,8 +40,9 @@ struct JudgeResponse: Codable, Sendable { var strengths: [String] = [] var weaknesses: [String] = [] var improvements: [String] = [] + var expressions: [ExpressionSuggestion] = [] private enum CodingKeys: String, CodingKey { - case score, taskCompletion, language, coherence, register, majorErrors, minorErrors, summary, corrections, improvedVersion, strengths, weaknesses, improvements + case score, taskCompletion, language, coherence, register, majorErrors, minorErrors, summary, corrections, improvedVersion, strengths, weaknesses, improvements, expressions } } extension JudgeResponse { @@ -63,6 +69,17 @@ extension JudgeResponse { strengths = try c.decodeIfPresent([String].self, forKey: .strengths) ?? [] weaknesses = try c.decodeIfPresent([String].self, forKey: .weaknesses) ?? [] improvements = try c.decodeIfPresent([String].self, forKey: .improvements) ?? [] + expressions = try c.decodeIfPresent([ExpressionSuggestion].self, forKey: .expressions) ?? [] + } +} +struct TokenUsage: Codable, Hashable, Sendable { + var input: Int + var cachedInput: Int + var output: Int + var reasoning: Int? + static func + (lhs: Self, rhs: Self) -> Self { + Self(input: lhs.input + rhs.input, cachedInput: lhs.cachedInput + rhs.cachedInput, output: lhs.output + rhs.output, + reasoning: lhs.reasoning.map { $0 + (rhs.reasoning ?? 0) } ?? rhs.reasoning) } } struct ReviewerResult: Codable, Identifiable, Sendable { @@ -72,11 +89,19 @@ struct ReviewerResult: Codable, Identifiable, Sendable { var timestamp: Date var provider: GradingProvider? = nil var reasoningEffort: String? = nil + var usage: TokenUsage? = nil + /// Seconds from request to validated result. + var duration: Double? = nil var id: String { judge.id } } enum Confidence: String, Codable, Sendable { - case high = "High", medium = "Medium", low = "Low" - var title: String { "\(rawValue) confidence" } + case high = "High", medium = "Medium", low = "Low", single = "Single" + var title: String { self == .single ? "快速单评" : "\(rawValue) confidence" } + static func label(_ raw: String) -> String { raw == Confidence.single.rawValue ? "单评" : raw } +} +/// Full review is the three-judge median. Quick review asks one judge, for drafts. +enum GradingMode: String, Codable, Sendable { + case full, quick } struct GradingReport: Codable, Sendable { var reviewers: [ReviewerResult] @@ -87,13 +112,29 @@ struct GradingReport: Codable, Sendable { var promptVersion: String var isDemo: Bool var timestamp: Date + /// nil for reports saved before quick review existed; those are full reviews. + var mode: GradingMode? = nil + var duration: Double? = nil + var gradingMode: GradingMode { mode ?? .full } + var usage: TokenUsage? { + let values = reviewers.compactMap(\.usage) + return values.isEmpty ? nil : values.dropFirst().reduce(values[0], +) + } var corrections: [Correction] { var seen = Set() return reviewers.flatMap(\.response.corrections).filter { seen.insert("\($0.category.rawValue)|\($0.original.lowercased().trimmingCharacters(in: .whitespacesAndNewlines))").inserted }.sorted { $0.severity == .major && $1.severity != .major } } - var improvedVersion: String { reviewers.first(where: { $0.judge == .b })?.response.improvedVersion ?? "" } + private var revisionSource: ReviewerResult? { reviewers.first(where: { $0.judge == .b }) ?? reviewers.first } + var improvedVersion: String { revisionSource?.response.improvedVersion ?? "" } + /// Expressions come from the reviewer whose improved version is shown, so each one appears in context. + var expressions: [ExpressionSuggestion] { + var seen = Set() + return (revisionSource?.response.expressions ?? []).filter { + !$0.phrase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && seen.insert($0.phrase.lowercased()).inserted + } + } var conclusion: String { reviewers.min { abs($0.response.score - finalScore) < abs($1.response.score - finalScore) }?.response.summary ?? "" } var strengths: [String] { uniqueFeedback(reviewers.flatMap(\.response.strengths)) } var weaknesses: [String] { uniqueFeedback(reviewers.flatMap { $0.response.weaknesses.isEmpty ? $0.response.majorErrors : $0.response.weaknesses }) } diff --git a/WriteBench/Models/QuestionBank.swift b/WriteBench/Models/QuestionBank.swift new file mode 100644 index 0000000..b6eb71a --- /dev/null +++ b/WriteBench/Models/QuestionBank.swift @@ -0,0 +1,77 @@ +import Foundation + +struct BankQuestion: Identifiable, Hashable, Sendable { + let id: String + let task: WritingTask + let title: String + let prompt: String +} + +/// Built-in practice prompts. All are original; none reproduces a past exam paper. +enum QuestionBank { + static func questions(for task: WritingTask) -> [BankQuestion] { + let sample = BankQuestion(id: "\(task.rawValue)-0", task: task, title: task == .kaoyanSmall ? "原创练习 · 邀请信" : "原创练习", prompt: task.sampleQuestion) + return [sample] + extra(task).enumerated().map { BankQuestion(id: "\(task.rawValue)-\($0.offset + 1)", task: task, title: $0.element.0, prompt: $0.element.1) } + } + static var all: [BankQuestion] { WritingTask.allCases.flatMap(questions(for:)) } + + /// Question text normalized the same way History groups attempts. + static func key(_ prompt: String) -> String { + prompt.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func extra(_ task: WritingTask) -> [(String, String)] { + switch task { + case .kaoyanSmall: [ + ("道歉信 · 错过会面", "You missed a meeting with Professor Smith, who had agreed to discuss your research proposal with you. Write an email to apologise. You should\n1. explain why you missed the meeting,\n2. express your regret, and\n3. suggest another time to meet.\nWrite about 100 words. Do not use your own name. Use “Li Ming” instead."), + ("推荐信 · 推荐一本书", "Your friend Jack, an international student, wants to improve his understanding of Chinese society and has asked you to recommend a book. Write him an email to\n1. recommend a book,\n2. briefly introduce its content, and\n3. explain why it would help him.\nWrite about 100 words. Do not use your own name. Use “Li Ming” instead."), + ("通知 · 招募志愿者", "The university library is recruiting student volunteers to help with a new reading-promotion programme. Write a notice to\n1. briefly describe the programme,\n2. state the requirements for volunteers, and\n3. explain how to apply.\nWrite about 100 words. Do not sign your own name. Use “University Library” instead."), + ("投诉信 · 网购延误", "You ordered a desk lamp online three weeks ago, but it has not arrived and the customer service line does not respond. Write a letter to the company to\n1. describe the problem,\n2. explain how it has affected you, and\n3. state what you expect them to do.\nWrite about 100 words. Do not use your own name. Use “Li Ming” instead."), + ("询问信 · 暑期项目", "You have learned that Professor Brown is running a summer research programme on urban environments. Write an email to\n1. introduce yourself briefly,\n2. express your interest in the programme, and\n3. ask for details about how to apply.\nWrite about 100 words. Do not use your own name. Use “Li Ming” instead.") + ] + case .kaoyanLarge: [ + ("图画 · 屏幕前的家庭晚餐", "A drawing shows a family of four sitting at a dinner table. Each member is looking down at a phone; the food in the middle of the table has gone cold. On the wall a clock reads 8 p.m. The caption reads “Together, alone”.\nWrite an essay of 160–200 words in which you should\n1) describe the drawing briefly,\n2) explain its intended meaning, and\n3) give your comments."), + ("图画 · 种树的祖孙", "A drawing shows an old man and a young boy planting a small tree. The old man is holding the young tree steady while the boy fills the hole with soil. Behind them stands a tall, leafy tree. The caption reads “Passing it on”.\nWrite an essay of 160–200 words in which you should\n1) describe the drawing briefly,\n2) explain its intended meaning, and\n3) give your comments."), + ("图画 · 证书垒成的台阶", "A drawing shows a student standing on a tall pile of books labelled “certificates” to reach a door labelled “career”. The student is reaching for the door, but the handle, labelled “real ability”, is far out of reach.\nWrite an essay of 160–200 words in which you should\n1) describe the drawing briefly,\n2) explain its intended meaning, and\n3) give your comments."), + ("图画 · 照亮夜路的书灯", "A drawing shows a person walking along a dark forest path at night. They are holding up an open book that gives off light like a lamp, illuminating the path ahead. The caption reads “Light the way”.\nWrite an essay of 160–200 words in which you should\n1) describe the drawing briefly,\n2) explain its intended meaning, and\n3) give your comments."), + ("图画 · 两条起跑线", "A drawing shows two runners on a track. One stands at the start line and keeps looking back at the crowd. The other is already running, eyes on the road ahead. A sign beside the track reads “The race is your own”.\nWrite an essay of 160–200 words in which you should\n1) describe the drawing briefly,\n2) explain its intended meaning, and\n3) give your comments.") + ] + case .cet6Writing: [ + ("终身学习", "For this part, you are allowed 30 minutes to write an essay on the importance of lifelong learning. You can start your essay with the sentence “Learning is a treasure that will follow its owner everywhere.” You should write at least 150 words but no more than 200 words."), + ("线上与线下学习", "For this part, you are allowed 30 minutes to write an essay on how college students can strike a balance between online and offline learning. You should write at least 150 words but no more than 200 words."), + ("失败的价值", "For this part, you are allowed 30 minutes to write an essay on the value of failure. You can start your essay with the sentence “Failure is not the opposite of success, but part of it.” You should write at least 150 words but no more than 200 words."), + ("与专业相关的兼职", "For this part, you are allowed 30 minutes to write an essay on whether college students should take part-time jobs related to their majors. You should state your view and support it with reasons. You should write at least 150 words but no more than 200 words."), + ("耐心与目标", "For this part, you are allowed 30 minutes to write an essay on the role of patience in achieving one’s goals. You should give examples to illustrate your point. You should write at least 150 words but no more than 200 words.") + ] + case .ieltsTask1: [ + ("折线图 · 家庭能源", "The line graph below shows the average annual energy use per household in one country, by source, from 1990 to 2020 (in units of 1,000 kWh). Summarise the information by selecting and reporting the main features, and make comparisons where relevant.\n\nSource — 1990 / 2000 / 2010 / 2020\nGas: 9.0 / 10.5 / 9.5 / 7.5\nElectricity: 4.0 / 4.5 / 5.5 / 6.5\nOil: 3.5 / 2.5 / 1.5 / 0.5\nSolar: 0 / 0.2 / 1.0 / 2.5\n\nWrite at least 150 words."), + ("柱状图 · 图书馆设施", "The bar chart below shows the percentage of university students who used four library facilities at least once a week in 2010 and 2020. Summarise the information by selecting and reporting the main features, and make comparisons where relevant.\n\nFacility — 2010 → 2020\nBook loans: 68% → 35%\nQuiet study rooms: 45% → 62%\nComputers: 52% → 20%\nGroup study rooms: 15% → 48%\n\nWrite at least 150 words."), + ("流程图 · 再生纸", "The diagram below shows how recycled paper is produced. Summarise the information by selecting and reporting the main features.\n\nStages in order:\n1. Used paper is collected from homes and offices.\n2. It is sorted by type and grade at a recycling centre.\n3. Paper is mixed with water and chemicals and pulped.\n4. The pulp is screened to remove staples, plastic and glue.\n5. Ink is removed in a de-inking tank.\n6. The clean pulp is pressed and dried on heated rollers.\n7. The finished paper is rolled, cut and delivered to manufacturers.\n\nWrite at least 150 words."), + ("地图 · 小镇中心变化", "The maps below show the centre of a small town in 2005 and today. Summarise the information by selecting and reporting the main features, and make comparisons where relevant.\n\n2005: A main road runs east–west through the centre. North of the road: a car park and a row of small shops. South of the road: a bus station and a large park with a pond.\nToday: The main road is pedestrianised; traffic uses a new ring road. The car park has become a shopping centre. The shops remain. The bus station has moved to the ring road and its site is now apartments. The park is smaller, and a café has been built beside the pond.\n\nWrite at least 150 words."), + ("饼图 · 家庭开支", "The pie charts below show how the average household in one country spent its income in 1980 and 2020. Summarise the information by selecting and reporting the main features, and make comparisons where relevant.\n\nCategory — 1980 / 2020\nFood: 35% / 18%\nHousing: 20% / 32%\nTransport: 12% / 14%\nLeisure: 8% / 15%\nEducation: 5% / 9%\nOther: 20% / 12%\n\nWrite at least 150 words.") + ] + case .ieltsTask2: [ + ("问题解决 · 工作时长", "In many countries, people are working longer hours than in the past. What are the causes of this? What can be done to address the problem?\n\nGive reasons for your answer and include any relevant examples from your own knowledge or experience. Write at least 250 words."), + ("同意与否 · 公共交通", "Some people think governments should spend money on public transport rather than on building new roads. To what extent do you agree or disagree?\n\nGive reasons for your answer and include any relevant examples from your own knowledge or experience. Write at least 250 words."), + ("利弊 · 网购与实体店", "Online shopping is gradually replacing shopping in high-street stores. Is this a positive or negative development?\n\nGive reasons for your answer and include any relevant examples from your own knowledge or experience. Write at least 250 words."), + ("双边讨论 · 入学年龄", "Some people believe children should begin formal education at a very early age. Others think they should not start school until they are at least seven years old. Discuss both views and give your own opinion.\n\nGive reasons for your answer and include any relevant examples from your own knowledge or experience. Write at least 250 words."), + ("同意与否 · 旅游与文化", "International tourism brings economic benefits, but it also damages local cultures and environments. To what extent do you agree or disagree?\n\nGive reasons for your answer and include any relevant examples from your own knowledge or experience. Write at least 250 words.") + ] + case .kaoyanTranslation: [ + ("科学方法", "将下列五个英语句子译成通顺、准确的汉语。请保留编号。原创练习,每句按 2 分练习尺度评阅。\n\n1. Science advances not because scientists are always right, but because they are willing to discover where they are wrong.\n2. A theory that cannot be tested, however elegant it may seem, offers little more than an interesting story.\n3. What distinguishes a careful researcher is less the ability to collect data than the patience to question what the data actually show.\n4. Even the most reliable instruments are only as useful as the questions that guide their use.\n5. It was only after decades of failed experiments that the importance of the overlooked variable finally became clear."), + ("城市与记忆", "将下列五个英语句子译成通顺、准确的汉语。请保留编号。原创练习,每句按 2 分练习尺度评阅。\n\n1. Old buildings matter to a city not merely as architecture, but as places where shared memories are stored.\n2. When a neighbourhood is redeveloped too quickly, residents may lose the sense of belonging that took generations to build.\n3. Planners who listen to local voices are more likely to create spaces that people actually want to use.\n4. A street that looks untidy to visitors may be precisely what gives residents their feeling of home.\n5. Preserving the past need not mean resisting change; it can mean choosing carefully what deserves to be carried forward."), + ("技术与工作", "将下列五个英语句子译成通顺、准确的汉语。请保留编号。原创练习,每句按 2 分练习尺度评阅。\n\n1. Automation rarely eliminates work altogether; more often, it changes which tasks people are paid to perform.\n2. Workers who can combine technical knowledge with sound judgment are likely to remain in demand.\n3. The danger lies not in machines becoming more capable, but in institutions failing to help people adapt.\n4. Few predictions about the future of work have proved accurate, which is itself a reason for humility.\n5. Whether new technology widens or narrows inequality depends largely on the choices societies make about education and training.") + ] + case .kaoyan2Translation: [ + ("选择职业", "请将下列英语段落完整译成准确、通顺的汉语。原创英语二翻译练习,采用 15 分练习尺度。\n\nChoosing a career is often presented as a single decision made at the end of university, but for most people it is a gradual process. Early jobs teach us not only practical skills but also what kind of work environment suits us. Some discover that they enjoy working with people; others realise they prefer solving problems on their own. These discoveries rarely come from careful planning alone. They come from trying things, noticing how we feel, and being honest about what we learn. It is therefore worth treating the first few years of work as a period of exploration rather than a test that must be passed perfectly. Changing direction is not a sign of failure. In many cases, it shows that a person has paid attention to experience and is willing to act on it. A career built in this way may look less tidy on paper, but it is more likely to be satisfying."), + ("博物馆的变化", "请将下列英语段落完整译成准确、通顺的汉语。原创英语二翻译练习,采用 15 分练习尺度。\n\nMuseums were once seen mainly as places for storing and displaying valuable objects. Visitors walked quietly past glass cases, read short labels and left. Today, many museums are trying to become more welcoming. They offer interactive exhibitions, evening events and activities designed for families. Some invite local residents to help decide what stories should be told. Digital tools allow people who cannot travel to explore collections online. These changes have brought in new audiences, especially young people who might otherwise never have visited. However, some critics worry that museums risk becoming places of entertainment rather than learning. The challenge is to find a balance: to make collections accessible and engaging without losing the careful research and thoughtful presentation that give museums their value."), + ("旅行的意义", "请将下列英语段落完整译成准确、通顺的汉语。原创英语二翻译练习,采用 15 分练习尺度。\n\nTravel is often promoted as a way to escape daily life, but its greater value may lie in what it teaches us when we return. In an unfamiliar place, we notice details that we normally ignore: how people greet each other, how streets are organised, what is considered polite. These observations remind us that our own habits are only one way of living among many. Of course, a short trip does not make anyone an expert on another culture. Travellers who visit only famous sights and take the same photographs as everyone else may learn very little. The difference lies in attitude. Those who travel with curiosity and humility, who ask questions and listen to the answers, tend to come home with a wider view of the world and a clearer view of themselves.") + ] + case .cet6Translation: [ + ("高铁", "请将下面的汉语段落译成英语。原创翻译练习。\n\n过去十多年来,中国的高速铁路发展迅速,已经连接起全国大部分主要城市。高铁不仅缩短了城市之间的旅行时间,也改变了人们的生活方式。许多人选择在一座城市工作、在另一座城市生活,周末往返变得十分方便。高铁的发展还带动了沿线地区的经济,促进了旅游业和商业的繁荣。与此同时,中国在高铁建设和运营方面积累的经验,也为其他国家提供了有益的参考。"), + ("中秋节", "请将下面的汉语段落译成英语。原创翻译练习。\n\n中秋节在农历八月十五,是中国重要的传统节日之一。这一天的月亮被认为是一年中最圆、最亮的,象征着团圆。按照传统,家人会聚在一起赏月、吃月饼,分享生活中的喜悦。在外工作或学习的人,即使无法回家,也常常通过电话或视频向家人表达思念。如今,月饼的口味越来越多样,但中秋节所承载的家庭观念和美好祝愿始终没有改变。"), + ("志愿服务", "请将下面的汉语段落译成英语。原创翻译练习。\n\n近年来,越来越多的中国年轻人参与志愿服务。他们走进社区、学校和乡村,为老人提供陪伴,为儿童辅导功课,或者参与环境保护活动。大型体育赛事和文化活动中,也随处可见志愿者的身影。参与志愿服务不仅帮助了他人,也让年轻人增长了见识,提高了沟通和组织能力。许多志愿者表示,在付出的过程中,他们更加理解了社会,也找到了自身的价值。") + ] + } + } +} diff --git a/WriteBench/Persistence/EssaySession.swift b/WriteBench/Persistence/EssaySession.swift index 60be580..1531309 100644 --- a/WriteBench/Persistence/EssaySession.swift +++ b/WriteBench/Persistence/EssaySession.swift @@ -63,6 +63,24 @@ import SwiftData var subtype: String var prompt: String var date: Date + // Defaults allow lightweight migration of questions saved before 1.5. + var year: Int? = nil + var label: String = "" @Attribute(.externalStorage) var image: Data? - init(title: String, task: WritingTask, prompt: String, image: Data? = nil) { id = UUID(); self.title = title; subtype = task.rawValue; self.prompt = prompt; date = Date(); self.image = image } + init(title: String, task: WritingTask, prompt: String, image: Data? = nil, year: Int? = nil, label: String = "") { + id = UUID(); self.title = title; subtype = task.rawValue; self.prompt = prompt; date = Date(); self.image = image; self.year = year; self.label = label + } +} + +/// Attempts per question, so the bank can show what has been practised. +@MainActor struct PracticeIndex { + private var byQuestion: [String: [EssaySession]] = [:] + init(_ sessions: [EssaySession]) { + for session in sessions where !session.isDemo { + byQuestion[Self.key(session.subtype, session.question), default: []].append(session) + } + for key in byQuestion.keys { byQuestion[key]?.sort { $0.date < $1.date } } + } + func attempts(_ task: WritingTask, _ prompt: String) -> [EssaySession] { byQuestion[Self.key(task.rawValue, prompt)] ?? [] } + private static func key(_ subtype: String, _ prompt: String) -> String { subtype + "\u{1}" + QuestionBank.key(prompt) } } diff --git a/WriteBench/Persistence/ReviewCard.swift b/WriteBench/Persistence/ReviewCard.swift new file mode 100644 index 0000000..91bf7fe --- /dev/null +++ b/WriteBench/Persistence/ReviewCard.swift @@ -0,0 +1,117 @@ +import Foundation +import SwiftData +import CryptoKit + +/// A spaced-repetition card: either a correction from a review or a reusable expression. +@Model final class ReviewCard { + @Attribute(.unique) var key: String + var kind: String + var sessionID: UUID? + var subtype: String + /// Mistake: the student's original span. Expression: the Chinese meaning. + var prompt: String + /// Mistake: the corrected span. Expression: the phrase. + var answer: String + var explanation: String + /// Mistake: the sentence it came from. Expression: an example sentence. + var context: String + var category: String + var severity: String + var box: Int + var dueDate: Date + var reviewCount: Int + var lapseCount: Int + var mastered: Bool + /// Hidden by the user; kept so the sync does not recreate it. + var archived: Bool + var createdAt: Date + var lastReviewedAt: Date? + + init(key: String, kind: ReviewCardKind, sessionID: UUID?, subtype: String, prompt: String, answer: String, explanation: String, + context: String, category: String = "", severity: String = "", createdAt: Date = Date()) { + self.key = key; self.kind = kind.rawValue; self.sessionID = sessionID; self.subtype = subtype + self.prompt = prompt; self.answer = answer; self.explanation = explanation; self.context = context + self.category = category; self.severity = severity + box = 0; dueDate = createdAt; reviewCount = 0; lapseCount = 0; mastered = false; archived = false + self.createdAt = createdAt + } + var cardKind: ReviewCardKind { ReviewCardKind(rawValue: kind) ?? .mistake } + var task: WritingTask? { WritingTask(rawValue: subtype) } + var isDue: Bool { !mastered && !archived && dueDate <= Date() } +} + +enum ReviewCardKind: String, Codable, Sendable { case mistake, expression } + +enum ReviewGrade { case again, good } + +enum SpacedRepetition { + /// Days until the next review after each successful recall. Recalling a card after the longest interval masters it. + static let intervals = [1, 2, 4, 8, 16] + static func apply(_ grade: ReviewGrade, to card: ReviewCard, now: Date = Date()) { + card.reviewCount += 1; card.lastReviewedAt = now + switch grade { + case .again: + card.lapseCount += 1; card.box = 0 + card.dueDate = now.addingTimeInterval(10 * 60) + case .good: + if card.box >= intervals.count { card.mastered = true; card.dueDate = now; return } + card.box += 1 + card.dueDate = Calendar.current.date(byAdding: .day, value: intervals[card.box - 1], to: now) ?? now + } + } + static func setMastered(_ card: ReviewCard, _ mastered: Bool, now: Date = Date()) { + card.mastered = mastered + if !mastered { card.box = 0; card.dueDate = now } + } +} + +@MainActor enum ReviewCardSync { + static func mistakeKey(session: UUID, correction: Correction) -> String { digest("mistake|\(session.uuidString)|\(correction.id)") } + static func expressionKey(phrase: String) -> String { digest("expression|" + phrase.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) } + private static func digest(_ value: String) -> String { SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() } + + /// Creates cards for corrections and expressions in saved reviews that do not have one yet. Returns the number added. + @discardableResult static func sync(_ context: ModelContext) throws -> Int { + let existing = Set(try context.fetch(FetchDescriptor()).map(\.key)) + var added = 0, seen = existing + for session in try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.date)])) where !session.isDemo { + guard let report = session.report else { continue } + for correction in report.corrections { + let key = mistakeKey(session: session.id, correction: correction) + guard seen.insert(key).inserted else { continue } + let sentence = CorrectionMatcher.range(of: correction.original, in: session.originalEssay) + .map { CorrectionMatcher.sentence(containing: $0, in: session.originalEssay) } ?? correction.original + context.insert(ReviewCard(key: key, kind: .mistake, sessionID: session.id, subtype: session.subtype, prompt: correction.original, + answer: correction.corrected, explanation: correction.explanation, context: sentence, + category: correction.category.rawValue, severity: correction.severity.rawValue, createdAt: session.date)) + added += 1 + } + for expression in report.expressions { + let key = expressionKey(phrase: expression.phrase) + guard seen.insert(key).inserted else { continue } + context.insert(ReviewCard(key: key, kind: .expression, sessionID: session.id, subtype: session.subtype, prompt: expression.meaning, + answer: expression.phrase, explanation: "", context: expression.example, createdAt: session.date)) + added += 1 + } + } + if added > 0 { try context.save() } + return added + } + @discardableResult static func addExpression(phrase: String, meaning: String, example: String, task: WritingTask, in context: ModelContext) throws -> ReviewCard { + let phrase = phrase.trimmingCharacters(in: .whitespacesAndNewlines), meaning = meaning.trimmingCharacters(in: .whitespacesAndNewlines) + guard !phrase.isEmpty, !meaning.isEmpty else { throw CardError.empty } + let key = expressionKey(phrase: phrase) + if let found = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.key == key })).first { + found.archived = false; try context.save(); return found + } + let card = ReviewCard(key: key, kind: .expression, sessionID: nil, subtype: task.rawValue, prompt: meaning, answer: phrase, explanation: "", + context: example.trimmingCharacters(in: .whitespacesAndNewlines)) + context.insert(card) + do { try context.save() } catch { context.delete(card); throw error } + return card + } + enum CardError: LocalizedError { + case empty + var errorDescription: String? { "请填写表达和中文释义。" } + } +} diff --git a/WriteBench/Services/Backup/BackupService.swift b/WriteBench/Services/Backup/BackupService.swift new file mode 100644 index 0000000..7f11e1d --- /dev/null +++ b/WriteBench/Services/Backup/BackupService.swift @@ -0,0 +1,153 @@ +import Foundation +import SwiftData + +/// A portable JSON copy of everything WriteBench stores locally. API keys and Codex credentials are never included. +struct BackupArchive: Codable { + static let formatName = "WriteBench backup" + var format = BackupArchive.formatName + var version = 1 + var exportedAt: Date + var appVersion: String + var sessions: [SessionRecord] + var drafts: [DraftRecord] + var questions: [QuestionRecord] + var folders: [FolderRecord] + var cards: [CardRecord] + + struct SessionRecord: Codable { + var id: UUID, date: Date, exam: String, subtype: String, question: String, originalEssay: String, correctedEssay: String, finalRewrite: String + var writingDuration: Double, wordCount: Int, inputMode: String, reviewerResults: Data, finalScore: Double, confidence: String + var detectedMistakes: Data, rubricVersion: String, graderPromptVersion: String, modelName: String, timestamp: Date, isDemo: Bool + var parentSessionID: UUID?, questionImage: Data?, sourceImages: Data? + } + struct DraftRecord: Codable { + var subtype: String, question: String, questionLabel: String, essay: String, elapsed: Double, inputMode: String, updatedAt: Date + var rewriteSessionID: UUID?, questionImage: Data?, sourceImages: Data? + } + struct QuestionRecord: Codable { + var id: UUID, title: String, subtype: String, prompt: String, date: Date, year: Int?, label: String, image: Data? + } + struct FolderRecord: Codable { + var key: String, title: String, questionYear: Int?, label: String, updatedAt: Date + } + struct CardRecord: Codable { + var key: String, kind: String, sessionID: UUID?, subtype: String, prompt: String, answer: String, explanation: String, context: String + var category: String, severity: String, box: Int, dueDate: Date, reviewCount: Int, lapseCount: Int, mastered: Bool, archived: Bool + var createdAt: Date, lastReviewedAt: Date? + } +} + +struct BackupSummary: Equatable { + var added = 0 + var skipped = 0 + var text: String { skipped == 0 ? "已恢复 \(added) 条记录。" : "已恢复 \(added) 条记录,跳过 \(skipped) 条本机已有或无法读取的记录。" } +} + +@MainActor enum BackupService { + static var suggestedFileName: String { "WriteBench-备份-\(Date().formatted(.iso8601.year().month().day())).json" } + + static func export(from context: ModelContext) throws -> Data { + let archive = BackupArchive( + exportedAt: Date(), + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "", + sessions: try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.date)])).map { + .init(id: $0.id, date: $0.date, exam: $0.exam, subtype: $0.subtype, question: $0.question, originalEssay: $0.originalEssay, + correctedEssay: $0.correctedEssay, finalRewrite: $0.finalRewrite, writingDuration: $0.writingDuration, wordCount: $0.wordCount, + inputMode: $0.inputMode, reviewerResults: $0.reviewerResults, finalScore: $0.finalScore, confidence: $0.confidence, + detectedMistakes: $0.detectedMistakes, rubricVersion: $0.rubricVersion, graderPromptVersion: $0.graderPromptVersion, + modelName: $0.modelName, timestamp: $0.timestamp, isDemo: $0.isDemo, parentSessionID: $0.parentSessionID, + questionImage: $0.questionImage, sourceImages: $0.sourceImages) + }, + drafts: try context.fetch(FetchDescriptor()).map { + .init(subtype: $0.subtype, question: $0.question, questionLabel: $0.questionLabel, essay: $0.essay, elapsed: $0.elapsed, + inputMode: $0.inputMode, updatedAt: $0.updatedAt, rewriteSessionID: $0.rewriteSessionID, questionImage: $0.questionImage, sourceImages: $0.sourceImages) + }, + questions: try context.fetch(FetchDescriptor()).map { + .init(id: $0.id, title: $0.title, subtype: $0.subtype, prompt: $0.prompt, date: $0.date, year: $0.year, label: $0.label, image: $0.image) + }, + folders: try context.fetch(FetchDescriptor()).map { + .init(key: $0.key, title: $0.title, questionYear: $0.questionYear, label: $0.label, updatedAt: $0.updatedAt) + }, + cards: try context.fetch(FetchDescriptor()).map { + .init(key: $0.key, kind: $0.kind, sessionID: $0.sessionID, subtype: $0.subtype, prompt: $0.prompt, answer: $0.answer, + explanation: $0.explanation, context: $0.context, category: $0.category, severity: $0.severity, box: $0.box, dueDate: $0.dueDate, + reviewCount: $0.reviewCount, lapseCount: $0.lapseCount, mastered: $0.mastered, archived: $0.archived, + createdAt: $0.createdAt, lastReviewedAt: $0.lastReviewedAt) + }) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(archive) + } + + /// Adds records this Mac does not have. Existing records are never overwritten; an empty draft may be filled. + static func restore(_ data: Data, into context: ModelContext) throws -> BackupSummary { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let archive: BackupArchive + do { archive = try decoder.decode(BackupArchive.self, from: data) } catch { throw BackupError.unreadable } + guard archive.format == BackupArchive.formatName else { throw BackupError.unreadable } + guard archive.version <= 1 else { throw BackupError.newerVersion } + var summary = BackupSummary() + + let sessionIDs = Set(try context.fetch(FetchDescriptor()).map(\.id)) + for record in archive.sessions { + guard !sessionIDs.contains(record.id), let task = WritingTask(rawValue: record.subtype), + let report = try? JSONDecoder().decode(GradingReport.self, from: record.reviewerResults) else { summary.skipped += 1; continue } + let session = try EssaySession(task: task, question: record.question, essay: record.originalEssay, duration: record.writingDuration, + inputMode: InputMode(rawValue: record.inputMode) ?? .typed, report: report, parentSessionID: record.parentSessionID) + session.id = record.id; session.date = record.date; session.exam = record.exam; session.correctedEssay = record.correctedEssay + session.finalRewrite = record.finalRewrite; session.wordCount = record.wordCount; session.reviewerResults = record.reviewerResults + session.finalScore = record.finalScore; session.confidence = record.confidence; session.detectedMistakes = record.detectedMistakes + session.rubricVersion = record.rubricVersion; session.graderPromptVersion = record.graderPromptVersion; session.modelName = record.modelName + session.timestamp = record.timestamp; session.isDemo = record.isDemo; session.questionImage = record.questionImage; session.sourceImages = record.sourceImages + context.insert(session); summary.added += 1 + } + let drafts = Dictionary(try context.fetch(FetchDescriptor()).map { ($0.subtype, $0) }, uniquingKeysWith: { first, _ in first }) + for record in archive.drafts { + guard let task = WritingTask(rawValue: record.subtype), !record.essay.isEmpty else { continue } + if let existing = drafts[record.subtype], !existing.essay.isEmpty { summary.skipped += 1; continue } + let draft = drafts[record.subtype] ?? WritingDraft(task: task) + if drafts[record.subtype] == nil { context.insert(draft) } + draft.question = record.question; draft.questionLabel = record.questionLabel; draft.essay = record.essay; draft.elapsed = record.elapsed + draft.inputMode = record.inputMode; draft.updatedAt = record.updatedAt; draft.rewriteSessionID = record.rewriteSessionID + draft.questionImage = record.questionImage; draft.sourceImages = record.sourceImages + summary.added += 1 + } + let questionIDs = Set(try context.fetch(FetchDescriptor()).map(\.id)) + for record in archive.questions { + guard !questionIDs.contains(record.id), let task = WritingTask(rawValue: record.subtype) else { summary.skipped += 1; continue } + let question = SavedQuestion(title: record.title, task: task, prompt: record.prompt, image: record.image, year: record.year, label: record.label) + question.id = record.id; question.date = record.date + context.insert(question); summary.added += 1 + } + let folderKeys = Set(try context.fetch(FetchDescriptor()).map(\.key)) + for record in archive.folders { + guard !folderKeys.contains(record.key) else { summary.skipped += 1; continue } + let folder = EssayFolderMetadata(key: record.key, title: record.title, questionYear: record.questionYear, label: record.label) + folder.updatedAt = record.updatedAt + context.insert(folder); summary.added += 1 + } + let cardKeys = Set(try context.fetch(FetchDescriptor()).map(\.key)) + for record in archive.cards { + guard !cardKeys.contains(record.key) else { summary.skipped += 1; continue } + let card = ReviewCard(key: record.key, kind: ReviewCardKind(rawValue: record.kind) ?? .mistake, sessionID: record.sessionID, subtype: record.subtype, + prompt: record.prompt, answer: record.answer, explanation: record.explanation, context: record.context, + category: record.category, severity: record.severity, createdAt: record.createdAt) + card.box = record.box; card.dueDate = record.dueDate; card.reviewCount = record.reviewCount; card.lapseCount = record.lapseCount + card.mastered = record.mastered; card.archived = record.archived; card.lastReviewedAt = record.lastReviewedAt + context.insert(card); summary.added += 1 + } + do { try context.save() } catch { context.rollback(); throw error } + return summary + } + enum BackupError: LocalizedError { + case unreadable, newerVersion + var errorDescription: String? { + switch self { + case .unreadable: "无法读取这个文件。请选择 WriteBench 导出的备份 JSON。" + case .newerVersion: "这个备份来自更新版本的 WriteBench,请先更新应用。" + } + } + } +} diff --git a/WriteBench/Services/Codex/CodexJudgeService.swift b/WriteBench/Services/Codex/CodexJudgeService.swift index 8a26833..51438b2 100644 --- a/WriteBench/Services/Codex/CodexJudgeService.swift +++ b/WriteBench/Services/Codex/CodexJudgeService.swift @@ -78,10 +78,25 @@ struct CodexJudgeService: EssayGradingService { let result = try await runner.run(ProcessRequest(executable: executable, arguments: arguments(directory: directory), directory: directory, input: Data(prompt.utf8), timeout: 600)) guard result.status == 0 else { throw CodexError.from(result) } let url = directory.appendingPathComponent("response.json") - guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size < 1_000_000, let data = try? Data(contentsOf: url), let response = try? JudgeResponse.decodeProviderOutput(data) else { throw CodexError.malformed } + guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, size < 1_000_000, let data = try? Data(contentsOf: url), var response = try? JudgeResponse.decodeProviderOutput(data) else { throw CodexError.malformed } + response.corrections = CorrectionMatcher.anchored(response.corrections, in: input.essay) try ScoreAggregator.validate(response, task: input.task) - guard response.corrections.allSatisfy({ input.essay.contains($0.original) }) else { throw GradingError.invalidResponse("Codex 修改建议引用了原文中不存在的文字") } - return ReviewerResult(judge: judge, response: response, model: model.isEmpty ? "Codex automatic" : model, timestamp: Date(), provider: .codex, reasoningEffort: reasoning) + return ReviewerResult(judge: judge, response: response, model: model.isEmpty ? "Codex automatic" : model, timestamp: Date(), provider: .codex, reasoningEffort: reasoning, + usage: CodexUsage.parse(result.stdout)) + } +} +/// `codex exec --json` reports token usage on its turn-completion event. Usage is informational; absence is not an error. +enum CodexUsage { + static func parse(_ stdout: Data) -> TokenUsage? { + var found: TokenUsage? + for line in String(decoding: stdout, as: UTF8.self).split(whereSeparator: \.isNewline) { + guard line.contains("\"usage\""), let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any], + let usage = (object["usage"] ?? (object["msg"] as? [String: Any])?["usage"]) as? [String: Any] else { continue } + func int(_ key: String) -> Int? { (usage[key] as? NSNumber)?.intValue } + guard let input = int("input_tokens"), let output = int("output_tokens") else { continue } + found = TokenUsage(input: input, cachedInput: int("cached_input_tokens") ?? 0, output: output, reasoning: int("reasoning_output_tokens")) + } + return found } } enum JudgeResponseSchema { @@ -94,9 +109,12 @@ enum JudgeResponseSchema { "properties": ["original": string, "corrected": string, "explanation": string, "category": ["type": "string", "enum": MistakeCategory.allCases.map(\.rawValue)], "severity": ["type": "string", "enum": ["major", "minor"]]]] + let expression: [String: Any] = ["type": "object", "additionalProperties": false, "required": ["example", "meaning", "phrase"], + "properties": ["phrase": string, "meaning": string, "example": string]] let properties: [String: Any] = ["score": number, "taskCompletion": number, "language": number, "coherence": number, "register": number, "majorErrors": strings, "minorErrors": strings, "summary": string, "strengths": strings, "weaknesses": strings, "improvements": strings, - "corrections": ["type": "array", "items": correction], "improvedVersion": string] + "corrections": ["type": "array", "items": correction], "improvedVersion": string, + "expressions": ["type": "array", "items": expression]] return try JSONSerialization.data(withJSONObject: ["type": "object", "additionalProperties": false, "required": properties.keys.sorted(), "properties": properties], options: [.prettyPrinted, .sortedKeys]) } } diff --git a/WriteBench/Services/DeepSeek/DeepSeekClient.swift b/WriteBench/Services/DeepSeek/DeepSeekClient.swift index bb1d63a..6e2aff5 100644 --- a/WriteBench/Services/DeepSeek/DeepSeekClient.swift +++ b/WriteBench/Services/DeepSeek/DeepSeekClient.swift @@ -51,7 +51,7 @@ struct DeepSeekClient: StreamingEssayGradingService { try await streaming.stream(for: request) { event in try await accumulator.consume(event, onPreview: onPreview) } try Task.checkCancellation() let finished = try await accumulator.completed() - return try decodedResult(finished.content, model: finished.model, input: input, judge: judge) + return try decodedResult(finished.content, model: finished.model, usage: finished.usage, input: input, judge: judge) } let (data, response) = try await transport.data(for: request) try Task.checkCancellation() @@ -61,15 +61,15 @@ struct DeepSeekClient: StreamingEssayGradingService { do { completion = try JSONDecoder().decode(ChatCompletion.self, from: data) } catch { throw GradingError.invalidResponse("无法读取 API 响应") } guard let choice = completion.choices.first, choice.finish_reason == "stop", let content = choice.message.content, !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw GradingError.invalidResponse("评审输出为空或被截断") } - return try decodedResult(content, model: completion.model, input: input, judge: judge) + return try decodedResult(content, model: completion.model, usage: completion.usage?.tokenUsage, input: input, judge: judge) } - private func decodedResult(_ content: String, model: String, input: GradingInput, judge: Judge) throws -> ReviewerResult { - let result: JudgeResponse + private func decodedResult(_ content: String, model: String, usage: TokenUsage?, input: GradingInput, judge: Judge) throws -> ReviewerResult { + var result: JudgeResponse do { result = try JudgeResponse.decodeProviderOutput(Data(content.utf8)) } catch { throw GradingError.invalidResponse("JSON 字段缺失或类型不符") } + result.corrections = CorrectionMatcher.anchored(result.corrections, in: input.essay) try ScoreAggregator.validate(result, task: input.task) - guard result.corrections.allSatisfy({ input.essay.contains($0.original) }) else { throw GradingError.invalidResponse("修改建议引用了原文中不存在的文字") } - return ReviewerResult(judge: judge, response: result, model: model, timestamp: Date(), provider: .deepSeek, reasoningEffort: "max") + return ReviewerResult(judge: judge, response: result, model: model, timestamp: Date(), provider: .deepSeek, reasoningEffort: "max", usage: usage) } func testConnection() async throws -> [String] { var request = URLRequest(url: URL(string: "https://api.deepseek.com/models")!) @@ -88,12 +88,22 @@ struct DeepSeekClient: StreamingEssayGradingService { let reasoning_effort = "max" let stream: Bool let thinking = Thinking(type: "enabled") + var stream_options: StreamOptions? { stream ? StreamOptions(include_usage: true) : nil } struct Format: Encodable { let type: String } struct Thinking: Encodable { let type: String } + struct StreamOptions: Encodable { let include_usage: Bool } + private enum CodingKeys: String, CodingKey { case model, messages, response_format, max_tokens, reasoning_effort, stream, thinking, stream_options } + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(model, forKey: .model); try c.encode(messages, forKey: .messages); try c.encode(response_format, forKey: .response_format) + try c.encode(max_tokens, forKey: .max_tokens); try c.encode(reasoning_effort, forKey: .reasoning_effort); try c.encode(stream, forKey: .stream) + try c.encode(thinking, forKey: .thinking); try c.encodeIfPresent(stream_options, forKey: .stream_options) + } } private struct ChatCompletion: Decodable { let model: String let choices: [Choice] + let usage: DeepSeekUsage? struct Choice: Decodable { let finish_reason: String?; let message: Content } struct Content: Decodable { let content: String? } } @@ -120,8 +130,9 @@ extension GraderPrompt { "score": 0.0, "taskCompletion": 0.0, "language": 0.0, "coherence": 0.0, "register": 0.0, "majorErrors": ["scoring-relevant issue"], "minorErrors": ["smaller issue"], "corrections": [{"original": "EXACT nonempty substring from the student essay", "corrected": "replacement text in the target language", "category": "Grammar", "severity": "major", "explanation": "reason"}], - "improvedVersion": "a complete improved answer in the target language"} - Valid categories are: \(MistakeCategory.allCases.map(\.rawValue).joined(separator: ", ")). Severity must be major or minor. Arrays can be empty. Prioritize up to 12 exam-relevant corrections. Avoid nitpicking acceptable usage. Missing task content belongs in majorErrors, not an invented original correction span. Each original MUST be an exact substring of the supplied essay. Do not penalize suspected OCR errors without evidence; input has been user-confirmed. Return a complete JSON object, without markdown fences. + "improvedVersion": "a complete improved answer in the target language", + "expressions": [{"phrase": "reusable expression in the target language", "meaning": "简体中文释义", "example": "one sentence using it in this topic"}]} + Valid categories are: \(MistakeCategory.allCases.map(\.rawValue).joined(separator: ", ")). Severity must be major or minor. Arrays can be empty. expressions lists 0–5 reusable, exam-appropriate expressions worth memorizing from your improvedVersion; prefer collocations and sentence frames over single common words, and never list phrases the student already used correctly. Prioritize up to 12 exam-relevant corrections. Avoid nitpicking acceptable usage. Missing task content belongs in majorErrors, not an invented original correction span. Each original MUST be an exact substring of the supplied essay. Do not penalize suspected OCR errors without evidence; input has been user-confirmed. Return a complete JSON object, without markdown fences. """ } static func user(_ input: GradingInput) throws -> String { diff --git a/WriteBench/Services/DeepSeek/DeepSeekStreaming.swift b/WriteBench/Services/DeepSeek/DeepSeekStreaming.swift index 4e476c2..1c840d4 100644 --- a/WriteBench/Services/DeepSeek/DeepSeekStreaming.swift +++ b/WriteBench/Services/DeepSeek/DeepSeekStreaming.swift @@ -5,6 +5,18 @@ protocol StreamingHTTPTransport: HTTPTransport { func stream(for request: URLRequest, onEvent: @escaping @Sendable (String) async throws -> Bool) async throws } +/// OpenAI-compatible usage, including DeepSeek's prompt cache split. Every field is optional on the wire. +struct DeepSeekUsage: Decodable, Sendable { + let prompt_tokens: Int? + let completion_tokens: Int? + let prompt_cache_hit_tokens: Int? + let completion_tokens_details: Details? + struct Details: Decodable, Sendable { let reasoning_tokens: Int? } + var tokenUsage: TokenUsage { + TokenUsage(input: prompt_tokens ?? 0, cachedInput: prompt_cache_hit_tokens ?? 0, output: completion_tokens ?? 0, reasoning: completion_tokens_details?.reasoning_tokens) + } +} + /// SSE framing is independent of network chunk boundaries and preserves UTF-8 and multi-line data. struct ServerSentEvents { private var line = Data() @@ -37,6 +49,7 @@ actor DeepSeekStreamAccumulator { private var content = "" private var model: String? private var finishReason: String? + private var usage: TokenUsage? private var done = false private var lastPreview = "" private var lastEmittedAt = Date.distantPast @@ -48,6 +61,7 @@ actor DeepSeekStreamAccumulator { do { chunk = try JSONDecoder().decode(Chunk.self, from: Data(event.utf8)) } catch { throw GradingError.invalidResponse("流式消息格式无效") } if let name = chunk.model { model = name } + if let reported = chunk.usage { usage = reported.tokenUsage } guard chunk.choices.count <= 1 else { throw GradingError.invalidResponse("返回了多份评阅") } if let choice = chunk.choices.first { guard choice.index == 0 else { throw GradingError.invalidResponse("流式评阅序号无效") } @@ -68,15 +82,16 @@ actor DeepSeekStreamAccumulator { } return true } - func completed() throws -> (content: String, model: String) { + func completed() throws -> (content: String, model: String, usage: TokenUsage?) { guard done, finishReason == "stop", let model, !model.isEmpty, !content.isEmpty else { throw GradingError.invalidResponse("流式评阅中断或输出被截断") } - return (content, model) + return (content, model, usage) } private struct Chunk: Decodable { let model: String? let choices: [Choice] + let usage: DeepSeekUsage? struct Choice: Decodable { let index: Int let delta: Delta diff --git a/WriteBench/Services/Grading/CorrectionMatcher.swift b/WriteBench/Services/Grading/CorrectionMatcher.swift new file mode 100644 index 0000000..69e7900 --- /dev/null +++ b/WriteBench/Services/Grading/CorrectionMatcher.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Locates reviewer-quoted spans in the student's answer despite quote, dash, width and whitespace drift. +/// A correction that cannot be located is dropped instead of invalidating an otherwise complete review. +enum CorrectionMatcher { + static func anchored(_ corrections: [Correction], in essay: String) -> [Correction] { + corrections.compactMap { correction in + guard !correction.corrected.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !correction.explanation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let range = range(of: correction.original, in: essay) else { return nil } + var located = correction + located.original = String(essay[range]) + return located + } + } + + static func range(of fragment: String, in essay: String) -> Range? { + let needle = fragment.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return nil } + if let exact = essay.range(of: needle) { return exact } + let haystack = normalized(essay), target = normalized(needle).map(\.character) + guard !target.isEmpty, target.count <= haystack.count else { return nil } + for caseInsensitive in [false, true] { + let pattern = caseInsensitive ? target.map { Character($0.lowercased()) } : target + outer: for start in 0...(haystack.count - pattern.count) { + for offset in pattern.indices { + let candidate = haystack[start + offset].character + guard (caseInsensitive ? Character(candidate.lowercased()) : candidate) == pattern[offset] else { continue outer } + } + return haystack[start].range.lowerBound.. [(character: Character, range: Range)] { + var output: [(character: Character, range: Range)] = [] + var index = text.startIndex + while index < text.endIndex { + let next = text.index(after: index) + let character = text[index] + if character.isWhitespace { + if let last = output.last, last.character == " " { + output[output.count - 1].range = last.range.lowerBound.. Character { + switch character { + case "‘", "’", "‚", "‛", "′", "'", "`": "'" + case "“", "”", "„", "‟", "″", """: "\"" + case "–", "—", "‒", "−", "-": "-" + case ",": "," + case "。": "." + case ";": ";" + case ":": ":" + case "!": "!" + case "?": "?" + case "(": "(" + case ")": ")" + default: character + } + } + + /// True when two answers differ only by case, quotes, dashes, width or spacing. + static func equivalent(_ attempt: String, _ reference: String) -> Bool { + let reference = reference.trimmingCharacters(in: .whitespacesAndNewlines) + guard let range = range(of: attempt, in: reference) else { return false } + return range.lowerBound == reference.startIndex && range.upperBound == reference.endIndex + } + + /// The sentence that contains a span, for drills that need context. + static func sentence(containing range: Range, in text: String) -> String { + let terminators: Set = [".", "!", "?", "。", "!", "?", "\n"] + var start = range.lowerBound + while start > text.startIndex { + let previous = text.index(before: start) + if terminators.contains(text[previous]) { break } + start = previous + } + var end = range.upperBound + while end < text.endIndex, text[end] != "\n" { + let character = text[end] + end = text.index(after: end) + if terminators.contains(character) { break } + } + return String(text[start.. GradingProvider { switch judge { case .a: a; case .b: b; case .c: c } } - var requiresDeepSeek: Bool { Judge.allCases.contains { provider(for: $0) == .deepSeek } } - var requiresCodex: Bool { Judge.allCases.contains { provider(for: $0) == .codex } } + var judges: [Judge] { mode == .quick ? [quickJudge] : Judge.allCases } + var requiresDeepSeek: Bool { judges.contains { provider(for: $0) == .deepSeek } } + var requiresCodex: Bool { judges.contains { provider(for: $0) == .codex } } + func with(_ mode: GradingMode) -> Self { var copy = self; copy.mode = mode; return copy } @MainActor static func load(_ defaults: UserDefaults = .standard) -> Self { Self(a: GradingProvider(rawValue: defaults.string(forKey: "judgeProviderA") ?? "") ?? .deepSeek, b: GradingProvider(rawValue: defaults.string(forKey: "judgeProviderB") ?? "") ?? .deepSeek, @@ -29,7 +33,8 @@ struct GradingConfiguration: Sendable { deepSeekModel: defaults.string(forKey: "deepSeekModel") ?? DeepSeekClient.defaultModel, codexModel: defaults.string(forKey: "codexModel") ?? CodexJudgeService.defaultModel, codexReasoning: defaults.string(forKey: "codexReasoning") ?? "max", - codexPath: defaults.string(forKey: "codexExecutablePath") ?? "") + codexPath: defaults.string(forKey: "codexExecutablePath") ?? "", + quickJudge: Judge(rawValue: defaults.string(forKey: "quickJudge") ?? "") ?? .b) } } struct ProviderRouter: StreamingEssayGradingService { diff --git a/WriteBench/Services/Grading/GradingService.swift b/WriteBench/Services/Grading/GradingService.swift index 0176c5c..4f2dbfa 100644 --- a/WriteBench/Services/Grading/GradingService.swift +++ b/WriteBench/Services/Grading/GradingService.swift @@ -28,18 +28,22 @@ enum GradingError: LocalizedError { case .missingKey: "尚未配置 DeepSeek API Key。请前往设置填入 API Key 后再评卷。" case .missingRubric: "找不到本题型的评分标准,请重新安装完整的应用。" case .http(let status): status == 401 ? "API Key 验证失败,请在 Settings 中更新。" : status == 402 ? "DeepSeek 余额不足,请检查账户。" : status == 429 ? "DeepSeek 请求过于频繁,请稍后重试。" : "DeepSeek 请求失败(HTTP \(status)),请稍后重试。" - case .incomplete: "三位评审未全部完成。请重试;不会使用部分评分计算总分。" + case .incomplete: "评审未全部完成。请重试;不会使用部分评分计算总分。" } } } enum ScoreAggregator { - static func aggregate(_ results: [ReviewerResult], task: WritingTask, isDemo: Bool) throws -> GradingReport { - guard results.count == 3, Set(results.map(\.judge)) == Set(Judge.allCases) else { throw GradingError.incomplete } + /// Three judges give the median; a single judge is a quick review with no agreement measure. + static func aggregate(_ results: [ReviewerResult], task: WritingTask, isDemo: Bool, judges: [Judge] = Judge.allCases) throws -> GradingReport { + guard judges.count == 1 || judges.count == 3, results.count == judges.count, Set(judges).count == judges.count, + Set(results.map(\.judge)) == Set(judges) else { throw GradingError.incomplete } for result in results { try validate(result.response, task: task) } let scores = results.map(\.response.score).sorted() - let spread = scores[2] - scores[0] - return GradingReport(reviewers: results.sorted { $0.judge.rawValue < $1.judge.rawValue }, finalScore: scores[1], spread: spread, confidence: spread <= 1 ? .high : spread <= 2 ? .medium : .low, rubricVersion: RubricLoader.version, promptVersion: GraderPrompt.version, isDemo: isDemo, timestamp: Date()) + let spread = scores[scores.count - 1] - scores[0] + let mode: GradingMode = judges.count == 1 ? .quick : .full + let confidence: Confidence = mode == .quick ? .single : spread <= 1 ? .high : spread <= 2 ? .medium : .low + return GradingReport(reviewers: results.sorted { $0.judge.rawValue < $1.judge.rawValue }, finalScore: scores[scores.count / 2], spread: spread, confidence: confidence, rubricVersion: RubricLoader.version, promptVersion: GraderPrompt.version, isDemo: isDemo, timestamp: Date(), mode: mode) } static func validate(_ response: JudgeResponse, task: WritingTask) throws { guard response.score.isFinite, (0...task.maxScore).contains(response.score) else { throw GradingError.invalidResponse("分数超出题型范围") } @@ -54,21 +58,24 @@ struct GradingCoordinator: Sendable { let service: any EssayGradingService // Reserved for explicit opt-in arbitration. v1 never makes a hidden fourth paid call. var chiefExaminer: (any ChiefExaminerService)? = nil - func grade(_ input: GradingInput, isDemo: Bool, onProgress: @escaping @Sendable (GradingProgressEvent) async -> Void = { _ in }) async throws -> GradingReport { + func grade(_ input: GradingInput, isDemo: Bool, judges: [Judge] = Judge.allCases, onProgress: @escaping @Sendable (GradingProgressEvent) async -> Void = { _ in }) async throws -> GradingReport { + let started = Date() let results = try await withThrowingTaskGroup(of: ReviewerResult.self) { group in defer { group.cancelAll() } - for judge in Judge.allCases { + for judge in judges { group.addTask { try Task.checkCancellation() await onProgress(.started(judge)) + let judgeStarted = Date() do { - let result: ReviewerResult + var result: ReviewerResult if let streaming = service as? any StreamingEssayGradingService { result = try await streaming.grade(input, judge: judge) { text in await onProgress(.preview(judge, text)) } } else { result = try await service.grade(input, judge: judge) } try Task.checkCancellation() guard result.judge == judge else { throw GradingError.invalidResponse("评审身份不匹配") } try ScoreAggregator.validate(result.response, task: input.task) + result.duration = Date().timeIntervalSince(judgeStarted) await onProgress(.completed(result)) return result } catch { @@ -84,7 +91,9 @@ struct GradingCoordinator: Sendable { return collected } try Task.checkCancellation() - return try ScoreAggregator.aggregate(results, task: input.task, isDemo: isDemo) + var report = try ScoreAggregator.aggregate(results, task: input.task, isDemo: isDemo, judges: judges) + report.duration = Date().timeIntervalSince(started) + return report } } @@ -95,4 +104,4 @@ enum RubricLoader { return try String(contentsOf: url, encoding: .utf8) } } -enum GraderPrompt { static let version = "1.2.0" } +enum GraderPrompt { static let version = "1.3.0" } diff --git a/WriteBench/Services/Grading/UsageCost.swift b/WriteBench/Services/Grading/UsageCost.swift new file mode 100644 index 0000000..db7df1c --- /dev/null +++ b/WriteBench/Services/Grading/UsageCost.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Estimated DeepSeek spend from user-entered prices. Codex runs on the ChatGPT plan, so it reports tokens only. +enum UsageCost { + static let inputKey = "deepSeekPriceInput", cachedKey = "deepSeekPriceCachedInput", outputKey = "deepSeekPriceOutput" + + struct Prices: Sendable { + var input: Double + var cached: Double + var output: Double + var isSet: Bool { input > 0 || cached > 0 || output > 0 } + static func load(_ defaults: UserDefaults = .standard) -> Self { + Self(input: defaults.double(forKey: inputKey), cached: defaults.double(forKey: cachedKey), output: defaults.double(forKey: outputKey)) + } + } + /// Yuan for one DeepSeek usage record, or nil when prices are not set. + static func cost(_ usage: TokenUsage, prices: Prices = .load()) -> Double? { + guard prices.isSet else { return nil } + let cached = min(usage.cachedInput, usage.input) + let input: Double = Double(usage.input - cached) * prices.input + let hits: Double = Double(cached) * prices.cached + let output: Double = Double(usage.output) * prices.output + return (input + hits + output) / 1_000_000 + } + static func cost(_ report: GradingReport, prices: Prices = .load()) -> Double? { + let deepSeek = report.reviewers.filter { $0.provider == .deepSeek }.compactMap(\.usage) + guard prices.isSet, !deepSeek.isEmpty else { return nil } + return deepSeek.compactMap { cost($0, prices: prices) }.reduce(0, +) + } + static func tokens(_ count: Int) -> String { + count >= 1_000_000 ? String(format: "%.2fM", Double(count) / 1_000_000) : count >= 1_000 ? String(format: "%.1fk", Double(count) / 1_000) : "\(count)" + } + static func yuan(_ value: Double) -> String { value < 0.01 ? "< ¥0.01" : String(format: "¥%.2f", value) } + static func summary(_ usage: TokenUsage) -> String { + var parts = ["输入 \(tokens(usage.input))"] + if usage.cachedInput > 0 { parts[0] += "(缓存 \(tokens(usage.cachedInput)))" } + parts.append("输出 \(tokens(usage.output))") + if let reasoning = usage.reasoning, reasoning > 0 { parts.append("其中思考 \(tokens(reasoning))") } + return parts.joined(separator: " · ") + } + static func duration(_ seconds: Double) -> String { + let total = Int(seconds.rounded()) + return total >= 60 ? "\(total / 60) 分 \(total % 60) 秒" : "\(total) 秒" + } +} diff --git a/WriteBenchTests/PracticeLoopTests.swift b/WriteBenchTests/PracticeLoopTests.swift new file mode 100644 index 0000000..bdeda47 --- /dev/null +++ b/WriteBenchTests/PracticeLoopTests.swift @@ -0,0 +1,232 @@ +import Foundation +import SwiftData +import Testing +@testable import WriteBench + +private let essay = "Dear Alex, I’m writing to invite you to our lecture. I look forward to hear from you. Yours, Li Ming." +private func judgeResponse(_ score: Double = 8, corrections: [Correction] = [], expressions: [ExpressionSuggestion] = []) -> JudgeResponse { + JudgeResponse(score: score, taskCompletion: 8, language: 7, coherence: 8, register: 8, majorErrors: [], minorErrors: [], summary: "Clear invitation.", + corrections: corrections, improvedVersion: essay, strengths: ["Clear purpose."], weaknesses: ["Verb form."], improvements: ["Check verbs."], + expressions: expressions) +} +private func report(_ scores: [Double] = [8, 7.5, 8], corrections: [Correction] = [], expressions: [ExpressionSuggestion] = []) throws -> GradingReport { + try ScoreAggregator.aggregate(zip(Judge.allCases, scores).map { + ReviewerResult(judge: $0.0, response: judgeResponse($0.1, corrections: corrections, expressions: expressions), model: "test", timestamp: Date()) + }, task: .kaoyanSmall, isDemo: false) +} +@MainActor private func container() throws -> ModelContainer { + try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, ReviewCard.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true)) +} +private let verbCorrection = Correction(original: "look forward to hear", corrected: "look forward to hearing", category: .grammar, severity: .major, explanation: "to 是介词。") + +// MARK: Correction anchoring + +@Test func correctionSpansTolerateQuoteSpacingAndCaseDrift() throws { + #expect(CorrectionMatcher.range(of: "I'm writing", in: essay).map { String(essay[$0]) } == "I’m writing") + #expect(CorrectionMatcher.range(of: "to our lecture", in: essay).map { String(essay[$0]) } == "to our lecture") + #expect(CorrectionMatcher.range(of: "LOOK FORWARD", in: essay).map { String(essay[$0]) } == "look forward") + #expect(CorrectionMatcher.range(of: ",", in: "你好,世界").map { String("你好,世界"[$0]) } == ",") + #expect(CorrectionMatcher.range(of: "not in the essay", in: essay) == nil) + let anchored = CorrectionMatcher.anchored([ + Correction(original: "I'm writing", corrected: "I am writing", category: .register, severity: .minor, explanation: "Formal."), + Correction(original: "a sentence the model invented", corrected: "x", category: .grammar, severity: .major, explanation: "Invented."), + Correction(original: "Li Ming", corrected: "Li Ming", category: .grammar, severity: .minor, explanation: " ") + ], in: essay) + #expect(anchored.map(\.original) == ["I’m writing"]) + #expect(CorrectionMatcher.equivalent("Look forward to HEARING ", "look forward to hearing")) + #expect(!CorrectionMatcher.equivalent("look forward", "look forward to hearing")) + let sentence = try #require(CorrectionMatcher.range(of: "hear from", in: essay)) + #expect(CorrectionMatcher.sentence(containing: sentence, in: essay) == "I look forward to hear from you.") +} + +private actor OneShotStream: StreamingHTTPTransport { + let events: [String] + init(_ events: [String]) { self.events = events } + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { throw URLError(.unsupportedURL) } + func stream(for request: URLRequest, onEvent: @escaping @Sendable (String) async throws -> Bool) async throws { + for event in events { if try await !onEvent(event) { return } } + } +} +@Test func deepSeekKeepsAReviewWhoseCorrectionQuotesDriftAndRecordsUsage() async throws { + var response = judgeResponse(corrections: [verbCorrection, Correction(original: "Im writing", corrected: "I am writing", category: .spelling, severity: .minor, explanation: "Not in essay.")]) + response.corrections[0].original = "look forward to hear" + let content = String(decoding: try JSONEncoder().encode(response), as: UTF8.self) + let chunk = try JSONSerialization.data(withJSONObject: ["model": "fixture", "choices": [["index": 0, "delta": ["content": content], "finish_reason": "stop"]]]) + let usage = #"{"model":"fixture","choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":800,"prompt_cache_hit_tokens":200,"completion_tokens_details":{"reasoning_tokens":500}}}"# + let client = DeepSeekClient(apiKey: "fixture", model: "fixture", transport: OneShotStream([String(decoding: chunk, as: UTF8.self), usage, "[DONE]"])) + let input = GradingInput(task: .kaoyanSmall, question: "Invite Alex.", essay: essay, rubric: "Test") + let result = try await client.grade(input, judge: .b) { _ in } + #expect(result.response.corrections.map(\.original) == ["look forward to hear"]) + #expect(result.usage == TokenUsage(input: 1200, cachedInput: 200, output: 800, reasoning: 500)) + let recorded = try #require(result.usage) + let cost = try #require(UsageCost.cost(recorded, prices: .init(input: 4, cached: 1, output: 16))) + let expected: Double = (1000.0 * 4 + 200.0 * 1 + 800.0 * 16) / 1_000_000 + #expect(abs(cost - expected) < 1e-9) + #expect(UsageCost.cost(recorded, prices: .init(input: 0, cached: 0, output: 0)) == nil) +} +@Test func codexUsageIsReadFromTheTurnCompletionEvent() { + let stdout = """ + {"type":"thread.started","thread_id":"x"} + {"type":"turn.completed","usage":{"input_tokens":5000,"cached_input_tokens":1000,"output_tokens":700,"reasoning_output_tokens":300}} + """ + #expect(CodexUsage.parse(Data(stdout.utf8)) == TokenUsage(input: 5000, cachedInput: 1000, output: 700, reasoning: 300)) + #expect(CodexUsage.parse(Data("not json".utf8)) == nil) +} +@Test func codexSchemaRequiresExpressions() throws { + let schema = try #require(JSONSerialization.jsonObject(with: JudgeResponseSchema.data()) as? [String: Any]) + #expect((schema["required"] as? [String])?.contains("expressions") == true) +} + +// MARK: Quick review + +private actor RecordingGrader: EssayGradingService { + var judges: [Judge] = [] + func grade(_ input: GradingInput, judge: Judge) async throws -> ReviewerResult { + judges.append(judge) + return ReviewerResult(judge: judge, response: judgeResponse(6.5), model: "test", timestamp: Date()) + } +} +@Test func quickReviewAsksOnlyTheChosenJudge() async throws { + let grader = RecordingGrader() + let input = GradingInput(task: .kaoyanSmall, question: "Invite Alex.", essay: essay, rubric: "Test") + let quick = try await GradingCoordinator(service: grader).grade(input, isDemo: false, judges: [.b]) + #expect(await grader.judges == [.b]) + #expect(quick.gradingMode == .quick && quick.confidence == .single && quick.finalScore == 6.5 && quick.spread == 0) + #expect(quick.improvedVersion == essay && quick.duration != nil && quick.reviewers[0].duration != nil) + #expect(throws: (any Error).self) { try ScoreAggregator.aggregate(quick.reviewers, task: .kaoyanSmall, isDemo: false) } + var configuration = GradingConfiguration(); configuration.quickJudge = .c + #expect(configuration.with(.quick).judges == [.c] && configuration.with(.quick).requiresCodex && !configuration.with(.quick).requiresDeepSeek) + #expect(configuration.judges == Judge.allCases) +} +@Test func reportsSavedBeforeQuickReviewDecodeAsFullReviews() throws { + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(try report())) as? [String: Any]) + json.removeValue(forKey: "mode"); json.removeValue(forKey: "duration") + let legacy = try JSONDecoder().decode(GradingReport.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(legacy.gradingMode == .full && legacy.expressions.isEmpty && legacy.usage == nil) +} +@Test @MainActor func quickSubmissionTracksOneJudgeAndClearsTheSubmittedDraft() async throws { + let context = ModelContext(try container()), store = WritingStore() + store.attach(context); store.question = "Invite Alex."; store.essay = essay; store.elapsed = 300 + #expect(store.startAnswering()) + store.submit(service: RecordingGrader(), isDemo: false, judges: [.a]) + #expect(store.gradingJob?.total == 1 && store.gradingJob?.isQuick == true) + await store.gradingTask?.value + let saved = try #require(store.gradingJob?.session) + #expect(saved.report?.gradingMode == .quick && saved.confidence == "Single") + #expect(store.essay.isEmpty && store.elapsed == 0 && store.question == "Invite Alex.") +} + +// MARK: Exam time limit + +@Test @MainActor func crossingTheExamLimitSignalsOnce() async throws { + let defaults = UserDefaults.standard, previous = defaults.object(forKey: "examTimeLimit") + defaults.set(true, forKey: "examTimeLimit") + defer { if let previous { defaults.set(previous, forKey: "examTimeLimit") } else { defaults.removeObject(forKey: "examTimeLimit") } } + let store = WritingStore(); store.attach(ModelContext(try container())); store.essay = "Draft" + #expect(store.timeLimit == TimeInterval(900)) + #expect(store.startAnswering()) + store.elapsed = 15 * 60 - 0.001 + try await Task.sleep(for: .milliseconds(20)); store.tick() + #expect(store.timeUpCount == 1) + try await Task.sleep(for: .milliseconds(20)); store.tick() + #expect(store.timeUpCount == 1) + store.leaveAnswering(); store.startOver() + #expect(store.essay.isEmpty && store.elapsed == 0) +} + +// MARK: Revisions and bank + +@Test func revisionDiffCountsWordsAndHandlesChinese() { + let english = RevisionDiff.compare("I look forward to hear from you.", "I really look forward to hearing from you.") + #expect(english.inserted == 2 && english.removed == 1) + #expect(String(english.text.characters) == "I really look forward to hear hearing from you.") + #expect(String(RevisionDiff.compare("in the hall on Friday at noon", "in the hall at noon").text.characters) == "in the hall on Friday at noon") + let chinese = RevisionDiff.compare("学习很重要", "学习非常重要") + #expect(chinese.inserted == 2 && chinese.removed == 1) +} +@Test @MainActor func baselinePrefersRecordedParentThenEarlierAttempt() throws { + let context = ModelContext(try container()) + let first = try EssaySession(task: .kaoyanSmall, question: "Q", essay: "One", duration: 1, inputMode: .typed, report: report()) + let second = try EssaySession(task: .kaoyanSmall, question: "Q ", essay: "Two", duration: 1, inputMode: .typed, report: report()) + let third = try EssaySession(task: .kaoyanSmall, question: "Q", essay: "Three", duration: 1, inputMode: .typed, report: report(), parentSessionID: first.id) + first.date = Date(timeIntervalSince1970: 1); second.date = Date(timeIntervalSince1970: 2); third.date = Date(timeIntervalSince1970: 3) + for session in [first, second, third] { context.insert(session) } + let all = [first, second, third] + #expect(RevisionBaseline.find(for: third, among: all)?.session.id == first.id) + #expect(RevisionBaseline.find(for: third, among: all)?.label == "第 1 稿") + #expect(RevisionBaseline.find(for: second, among: all)?.session.id == first.id) + #expect(RevisionBaseline.find(for: first, among: all) == nil) +} +@Test @MainActor func questionBankIsOriginalUniqueAndTracksPractice() throws { + for task in WritingTask.allCases { #expect(QuestionBank.questions(for: task).count >= 4) } + let ids = QuestionBank.all.map(\.id), prompts = QuestionBank.all.map(\.prompt) + #expect(Set(ids).count == ids.count && Set(prompts).count == prompts.count) + #expect(QuestionBank.questions(for: .kaoyanSmall)[0].prompt == WritingTask.kaoyanSmall.sampleQuestion) + let pick = QuestionBank.questions(for: .ieltsTask2)[2] + let session = try EssaySession(task: .ieltsTask2, question: pick.prompt + "\n", essay: essay, duration: 1, inputMode: .typed, report: report([7, 7, 7])) + let index = PracticeIndex([session]) + #expect(index.attempts(.ieltsTask2, pick.prompt).count == 1 && index.attempts(.ieltsTask1, pick.prompt).isEmpty) + let store = WritingStore(); store.attach(ModelContext(try container())); store.essay = "keep" + #expect(store.useQuestion(pick.prompt, title: pick.title, task: .ieltsTask2)) + #expect(store.task == .ieltsTask2 && store.question == pick.prompt && store.questionLabel == pick.title) +} + +// MARK: Spaced review + +@Test @MainActor func reviewCardsSyncOnceAndProgressToMastery() throws { + let context = ModelContext(try container()) + let expression = ExpressionSuggestion(phrase: "look forward to hearing from you", meaning: "期待你的回复", example: "I look forward to hearing from you soon.") + let session = try EssaySession(task: .kaoyanSmall, question: "Q", essay: essay, duration: 1, inputMode: .typed, report: report(corrections: [verbCorrection], expressions: [expression])) + context.insert(session); try context.save() + #expect(try ReviewCardSync.sync(context) == 2) + #expect(try ReviewCardSync.sync(context) == 0) + let cards = try context.fetch(FetchDescriptor()) + let mistake = try #require(cards.first { $0.cardKind == .mistake }) + #expect(mistake.context == "I look forward to hear from you." && mistake.answer == "look forward to hearing" && mistake.isDue) + let now = Date() + SpacedRepetition.apply(.again, to: mistake, now: now) + #expect(mistake.box == 0 && mistake.lapseCount == 1 && mistake.dueDate > now) + for (step, days) in SpacedRepetition.intervals.enumerated() { + SpacedRepetition.apply(.good, to: mistake, now: now) + #expect(mistake.box == step + 1 && !mistake.mastered) + #expect(Calendar.current.dateComponents([.day], from: now, to: mistake.dueDate).day == days) + } + SpacedRepetition.apply(.good, to: mistake, now: now) + #expect(mistake.mastered && !mistake.isDue) + SpacedRepetition.setMastered(mistake, false, now: now) + #expect(mistake.box == 0 && !mistake.mastered) + let manual = try ReviewCardSync.addExpression(phrase: " Look forward to hearing from you ", meaning: "x", example: "", task: .kaoyanSmall, in: context) + #expect(manual.key == ReviewCardSync.expressionKey(phrase: expression.phrase)) + #expect(throws: (any Error).self) { try ReviewCardSync.addExpression(phrase: "", meaning: "x", example: "", task: .kaoyanSmall, in: context) } +} + +// MARK: Backup + +@Test @MainActor func backupRoundTripsEverythingAndRestoreNeverOverwrites() throws { + let source = ModelContext(try container()) + let session = try EssaySession(task: .kaoyanSmall, question: "Q", essay: essay, duration: 90, inputMode: .handwritten, report: report(corrections: [verbCorrection]), + questionImage: Data([1, 2]), sourceImages: [Data([3])]) + session.finalRewrite = "Rewrite" + source.insert(session) + source.insert(SavedQuestion(title: "Saved", task: .cet6Writing, prompt: "P", year: 2024, label: "真题")) + source.insert(EssayFolderMetadata(key: "folder", title: "Folder", questionYear: 2023, label: "L")) + let draft = WritingDraft(task: .ieltsTask2); draft.essay = "Unfinished"; source.insert(draft) + try source.save(); try ReviewCardSync.sync(source) + let card = try #require(try source.fetch(FetchDescriptor()).first); card.box = 3; try source.save() + let data = try BackupService.export(from: source) + #expect(!String(decoding: data, as: UTF8.self).contains("fixture-key")) + + let target = ModelContext(try container()) + let summary = try BackupService.restore(data, into: target) + #expect(summary.added == 5 && summary.skipped == 0) + let restored = try #require(try target.fetch(FetchDescriptor()).first) + #expect(restored.id == session.id && abs(restored.date.timeIntervalSince(session.date)) < 1) + #expect(restored.finalRewrite == "Rewrite" && restored.questionImage == Data([1, 2]) && restored.inputMode == "handwritten" && restored.report?.corrections.count == 1) + #expect(try target.fetch(FetchDescriptor()).first?.label == "真题") + #expect(try target.fetch(FetchDescriptor()).first?.box == 3) + #expect(try target.fetch(FetchDescriptor()).first?.essay == "Unfinished") + let again = try BackupService.restore(data, into: target) + #expect(again.added == 0 && again.skipped == 5) + #expect(throws: (any Error).self) { try BackupService.restore(Data("{}".utf8), into: target) } +} diff --git a/WriteBenchTests/WriteBenchTests.swift b/WriteBenchTests/WriteBenchTests.swift index 1967ef2..9bb3d27 100644 --- a/WriteBenchTests/WriteBenchTests.swift +++ b/WriteBenchTests/WriteBenchTests.swift @@ -122,11 +122,13 @@ private actor FixtureTransport: HTTPTransport { await #expect(throws: (any Error).self) { try await client.grade(input(), judge: .a) } } } -@Test func rejectsInventedCorrectionSpans() async throws { - let correction = Correction(original: "This never occurred in the essay", corrected: "A replacement", category: .grammar, severity: .major, explanation: "Test") - let content = String(decoding: try JSONEncoder().encode(response(corrections: [correction])), as: UTF8.self) +@Test func dropsInventedCorrectionSpansButKeepsTheReview() async throws { + let invented = Correction(original: "This never occurred in the essay", corrected: "A replacement", category: .grammar, severity: .major, explanation: "Test") + let real = Correction(original: "look forward to hear", corrected: "look forward to hearing", category: .grammar, severity: .major, explanation: "Gerund.") + let content = String(decoding: try JSONEncoder().encode(response(corrections: [invented, real])), as: UTF8.self) let client = DeepSeekClient(apiKey: "test", model: "test", transport: try FixtureTransport(content: content)) - await #expect(throws: (any Error).self) { try await client.grade(input(), judge: .b) } + let result = try await client.grade(input(), judge: .b) + #expect(result.response.corrections.map(\.original) == ["look forward to hear"]) } @Test @MainActor func swiftDataRoundTripAndRewrite() throws { let container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) @@ -311,9 +313,11 @@ private struct UnavailableGrader: EssayGradingService { #expect(WordCounter.count(store.essay) == 0) #expect(store.startAnswering()) var saved: EssaySession? + let submitted = store.essay store.submit(service: RecordingGrader(), isDemo: true) { saved = $0 } let grading = try #require(store.gradingTask); await grading.value - #expect(saved?.originalEssay == store.essay) + #expect(saved?.originalEssay == submitted) + #expect(store.essay.isEmpty) // The graded draft starts fresh; the answer lives in History. #expect(saved != nil) #expect(try context.fetchCount(FetchDescriptor()) == 1) } diff --git a/project.yml b/project.yml index 63ad853..75d4c3a 100644 --- a/project.yml +++ b/project.yml @@ -11,8 +11,8 @@ settings: CODE_SIGN_IDENTITY: "-" ENABLE_HARDENED_RUNTIME: YES GENERATE_INFOPLIST_FILE: YES - MARKETING_VERSION: "1.4.0" - CURRENT_PROJECT_VERSION: "7" + MARKETING_VERSION: "1.5.0" + CURRENT_PROJECT_VERSION: "8" targets: WriteBench: type: application