From 34db1bcf6726ea496393da6f0a1a3863f3ee0de0 Mon Sep 17 00:00:00 2001 From: Functionhx <172989722+Functionhx@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:00:19 +0800 Subject: [PATCH 1/2] feat(macos): background streaming reviews and revision folders --- Documentation/Providers.md | 14 +- Documentation/Validation.md | 11 ++ Documentation/macOS-guide.md | 14 +- README.md | 12 +- RELEASE-NOTES.md | 11 ++ WriteBench.xcodeproj/project.pbxproj | 44 ++++- WriteBench/App/AppLifecycle.swift | 19 +++ WriteBench/App/WorkspaceView.swift | 9 +- WriteBench/App/WriteBenchApp.swift | 18 +- .../Features/History/EssayHistoryGroup.swift | 47 ++++++ WriteBench/Features/History/HistoryView.swift | 114 +++++++++++-- .../Review/BackgroundGradingView.swift | 103 ++++++++++++ .../Features/Review/ReviewTextExporter.swift | 33 ++++ WriteBench/Features/Review/ReviewView.swift | 35 ++++ .../Features/Settings/SettingsView.swift | 10 +- .../Writing/ImmersiveWritingView.swift | 15 +- .../Features/Writing/WritingStore.swift | 93 +++++++---- WriteBench/Features/Writing/WritingView.swift | 3 +- WriteBench/Models/BackgroundGradingJob.swift | 76 +++++++++ WriteBench/Models/GradingModels.swift | 40 +++++ WriteBench/Persistence/EssaySession.swift | 5 +- .../Services/Codex/CodexJudgeService.swift | 5 +- .../Services/DeepSeek/DeepSeekClient.swift | 45 +++-- .../DeepSeek/DeepSeekCredentials.swift | 4 +- .../Services/DeepSeek/DeepSeekStreaming.swift | 152 +++++++++++++++++ .../Services/Grading/GradingProvider.swift | 9 +- .../Services/Grading/GradingService.swift | 49 +++++- WriteBenchTests/BackgroundGradingTests.swift | 156 ++++++++++++++++++ WriteBenchTests/HistoryReviewTests.swift | 98 +++++++++++ WriteBenchTests/ProviderTests.swift | 8 +- WriteBenchTests/StreamingTests.swift | 78 +++++++++ WriteBenchTests/WriteBenchTests.swift | 15 +- project.yml | 4 +- 33 files changed, 1229 insertions(+), 120 deletions(-) create mode 100644 WriteBench/App/AppLifecycle.swift create mode 100644 WriteBench/Features/History/EssayHistoryGroup.swift create mode 100644 WriteBench/Features/Review/BackgroundGradingView.swift create mode 100644 WriteBench/Features/Review/ReviewTextExporter.swift create mode 100644 WriteBench/Models/BackgroundGradingJob.swift create mode 100644 WriteBench/Services/DeepSeek/DeepSeekStreaming.swift create mode 100644 WriteBenchTests/BackgroundGradingTests.swift create mode 100644 WriteBenchTests/HistoryReviewTests.swift create mode 100644 WriteBenchTests/StreamingTests.swift diff --git a/Documentation/Providers.md b/Documentation/Providers.md index 8fd09d7..90888f8 100644 --- a/Documentation/Providers.md +++ b/Documentation/Providers.md @@ -1,4 +1,4 @@ -# AI grading providers — 1.3 +# AI grading providers — 1.4 Defaults verified on 2026-09-13: @@ -10,6 +10,18 @@ Defaults verified on 2026-09-13: Each role has its own provider selector. A submission freezes the selected configuration, sends identical original evidence to three independent requests, validates every response and aggregates the median locally. An error identifies the judge and provider. There is no fallback to another provider, partial aggregate, or mock result. Test fixtures are compiled only into WriteBenchTests. Legacy demo reviews remain stored but are hidden from History and excluded from analytics. +## Background lifecycle and structured streaming + +`WritingStore` is owned by the application, independently of the current page and immersive editor. Hand-in snapshots the question, essay, duration, images, configuration and optional source-review ID. One grading job may run at a time; another draft can be edited while it runs. Completion, cancellation or failure never restores over that newer draft. Closing the window keeps the app running; explicit quit cancels and awaits the job, including its Codex subprocess. + +`GradingCoordinator` uses a throwing task group and reports actual reviewer completions. The progress bar is completed judges / 3, not an estimated token or time percentage. A judge failure cancels remaining requests. Results are persisted only after all three structured responses pass validation. The user explicitly opens the finished review; it never steals focus. + +`StreamingEssayGradingService` adds provisional preview events without changing the provider-independent result contract. DeepSeek uses URLSession async bytes and official SSE (`stream: true`). The bounded byte framer supports UTF-8, CRLF and SSE data lines; finalization requires `[DONE]` and `finish_reason: stop`. A small JSON string tokenizer previews only the root `summary` field, including incomplete strings and escaped Unicode. Full JSON decoding, required-field validation and exact correction-span checks still gate every score. Reasoning content is neither decoded nor displayed. This follows the [official DeepSeek streaming format](https://api-docs.deepseek.com/api/create-chat-completion/). + +Codex continues to decode its final schema-constrained output file; no token-by-token Codex preview is claimed. Prompt version 1.2 requires `strengths`, `weaknesses` and `improvements` arrays alongside the existing fields. Old saved reviews decode these missing arrays as empty. The overview uses the median-score reviewer's conclusion and locally deduplicates feedback; it makes no extra summarization call. + +History groups records at display time by task, normalized exact prompt and question-image digest. It does not mutate old records or infer their ancestry. The optional SwiftData `parentSessionID` records only an explicit rewrite source, captured when submitted. Original essays and previous scores remain immutable. Different diagrams with identical instruction text stay separate. + ## Direct key entry A user pastes a DeepSeek key and clicks **使用此 Key**. It works immediately from process memory, without accessing an old Keychain item. **在这台 Mac 上记住 Key** is optional and defaults off. No key is stored in UserDefaults, SwiftData, source, logs or a release package. Submission reads memory only. Explicit persistence and opt-in startup restoration run off the UI thread using a new data-protection Keychain item, with authentication UI disallowed. Legacy development items are never queried. An inaccessible item leaves the user able to enter the API key again. A failed optional save leaves the in-memory key usable and reports that it could not be remembered. diff --git a/Documentation/Validation.md b/Documentation/Validation.md index 1a954e7..b6d185d 100644 --- a/Documentation/Validation.md +++ b/Documentation/Validation.md @@ -1,3 +1,14 @@ +# 1.4.0 validation · 2026-09-13 + +- 47 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. +- SSE fixtures cover UTF-8 split across byte boundaries, CRLF, comments, multiline data, escaped/incomplete JSON summaries, first readable previews, required final `[DONE]`, malformed/truncated completion rejection, and backward-compatible saved feedback. Production streaming uses URLSession async bytes; no paid live DeepSeek streaming call was made for this release. +- History tests cover chronological ordering, old same-question grouping without invented ancestry, branching from an earlier draft, search retaining all versions, exam/prompt/image isolation, demo exclusion, and revision parent persistence across a disk-store reopen. Review export includes scores, comments and corrections while leaving both full essay bodies out; old saved reports still copy correctly. +- A separate fixture executable built the original v1.3.1 SwiftData schema and wrote two synthetic reviews (4.5 and 7.5). An isolated copy of the new app successfully opened and migrated that store, displayed one question folder and reopened both complete legacy reviews. No user database was used for this upgrade check. +- Native interactive checks in isolated app copies: hand-in returns to preparation with a 0/3 status bar; Settings remains accessible during grading; word count defaults off and becomes visible in immersion when enabled; a completed review is opened explicitly. The history folder and its two child rows were visually inspected. Clicking the new review Copy showed Copied; the existing improved-essay Copy remains separate. +- Real-time partial-preview timing and cancellation are covered by gated service tests. Codex still displays its final structured feedback on completion, rather than token streaming. An end-to-end live mixed-provider paid review is not claimed. +- The hosted test app uses an in-memory container and does not restore credentials. Credential preference tests use a temporary defaults suite. QA copies have separate identities and synthetic data; the user's running app and current writing session were left untouched. +- macOS version 1.4.0 (7), Universal arm64 + x86_64. The only SwiftData schema addition is optional `EssaySession.parentSessionID`; grouping is computed without rewriting old records. Windows and Android remain 0.1.0. + # 1.3.1 validation · 2026-09-13 - 33 Swift tests pass. New coverage includes Chinese/English UTF-8 (with/without BOM), UTF-16 LE/BE, paragraph normalization, rejected empty/binary/oversized input, a real text-file read, per-task SwiftData persistence, answer/image/time preservation, and detachment from a historical rewrite even when the new question text is identical. diff --git a/Documentation/macOS-guide.md b/Documentation/macOS-guide.md index 8c1fb02..a73a3d6 100644 --- a/Documentation/macOS-guide.md +++ b/Documentation/macOS-guide.md @@ -1,4 +1,4 @@ -# WriteBench 1.3 +# WriteBench 1.4 A real native macOS exam-writing workstation, built with Swift 6, SwiftUI, AppKit, SwiftData, Vision and Swift Charts. No web wrapper, external runtime or third-party app dependencies. @@ -17,11 +17,11 @@ Double-click **WriteBench.app** in this folder, or open **WriteBench.xcodeproj** 1. Choose 考研英语 (英语一小作文/大作文、英语一/二翻译), CET-6 writing/translation, or IELTS Academic Task 1 / Task 2. 2. Use the supplied **original practice question**, edit/paste your own, or import an image. The pencil beside the question toggles its plain-text editor. 真题库 saves your own labelled question sources; bundled exercises are not presented as past papers. 3. Click **开始答题** (or **⌘Return**) to enter the only answering workspace: native full-screen immersion. Preparation has no essay editor or grading button. The sidebar, exam tabs and decorative cards disappear. The question stays on the left and your answer on the right. -4. The timer starts when you start answering. Kaoyan and CET-6 use a ruled answer area with **no live word count**; IELTS retains a small word count. This is a practice writing surface, not a claim of exact official answer-card dimensions. Native undo/redo and copy/paste remain available through standard shortcuts, without a formatting toolbar. **保存并离开** saves the draft and pauses its timer; continuing requires **开始答题** again. Switching away from the app during an active session does not stop the exam timer. Leaving macOS full screen through the system controls still leaves you in the same minimal answering workspace. -5. Click **交卷** or press **⌘Return** while answering. Three independent graders run concurrently. Complete reviews are saved before opening, and word count is available after submission. A failed or cancelled grade returns to the same immersive answer with the draft intact. There is no non-immersive submission route. -6. In History / Review, **开始重写** or **继续重写** enters the same immersive workspace. Rewrites automatically save back to the source review, including after closing/reopening the app. The review page itself has no alternate editable essay field. Each completed regrading is its own history record. +4. The timer starts when you start answering. Kaoyan and CET-6 use a ruled answer area. All tasks default to **no live word count**. Enable **答题时显示词数** in Settings if wanted; Chinese translations show characters. This is a practice writing surface, not a claim of exact official answer-card dimensions. Native undo/redo and copy/paste remain available through standard shortcuts, without a formatting toolbar. **保存并离开** saves the draft and pauses its timer; continuing requires **开始答题** again. Switching away from the app during an active session does not stop the exam timer. Leaving macOS full screen through the system controls still leaves you in the same minimal answering workspace. +5. Click **交卷** or press **⌘Return** while answering. The app immediately returns to preparation while three independent graders run in the background. The status strip shows actual completed reviewers, elapsed time and submitted word count. Switch pages, edit another draft or minimize the window; use **查看进度** for streamed DeepSeek comments. Codex feedback arrives when its structured result completes. Open the final review yourself when ready. Failure or cancellation never overwrites the current draft or produces a partial total. Quitting the app interrupts unfinished grading. There is no non-immersive submission route. +6. In History / Review, **开始重写** or **继续重写** enters the same immersive workspace. Rewrites automatically save back to the source review, including after closing/reopening the app. The review page itself has no alternate editable essay field. Each completed regrading retains its own review inside the same question folder. New rewrites record their source version, including branches from an older draft. Old same-question records are grouped without inventing parent links. The review header **Copy** copies the assessment; the existing improved-essay **Copy** still copies only that essay. -Drafts are kept separately for all five task types, with debounced local saves and periodic timer saves. History reopens complete reviews, including the original question, essay and imported source images. Search and exam filtering are available in History. +Drafts are kept separately for all eight task types, with debounced local saves and periodic timer saves. Expand a question folder in History to reopen complete reviews, including the original question, essay and imported source images. Search and exam filtering are available in History. ## Handwritten essays and OCR @@ -41,7 +41,7 @@ For **ChatGPT · via Codex**, install the official CLI and run `codex login` onc The app has no automatic provider fallback. If any judge fails, its name and provider are shown and no total score is saved. Missing DeepSeek keys preserve the draft and offer **前往设置** or **继续作答**. Codex-only configurations do not require a DeepSeek key. -See [provider architecture and validation](Documentation/Providers.md). Real Codex/MAX structured grading has been validated using a synthetic essay; DeepSeek model-list connection has been validated. This native direct-distribution build is not App Sandboxed because it launches the independently installed CLI. No system security setting is changed. Existing local essays are preserved. +See [provider architecture and validation](Providers.md). Real Codex/MAX structured grading has been validated using a synthetic essay; DeepSeek model-list connection has been validated. This native direct-distribution build is not App Sandboxed because it launches the independently installed CLI. No system security setting is changed. Existing local essays are preserved. ## Scoring and statistics @@ -75,7 +75,7 @@ WriteBench/ DeepSeek/ URLSession, in-memory credentials, optional Keychain Codex/ Official CLI discovery, subprocess, JSON Schema Grading/ Provider protocol, orchestration, validation, median - Rubrics/ Five bundled, versioned Markdown resources + Rubrics/ Eight bundled, versioned Markdown resources WriteBenchTests/ Domain, concurrency, transport, persistence and real OCR tests ``` diff --git a/README.md b/README.md index c83818b..6a6ad5b 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ WriteBench 把练习收敛为一条清晰的路径:选题、作答、评阅、 | 专注写作 | 认真评阅 | 留下进步 | | :--- | :--- | :--- | -| 原生全屏作答,自动保存草稿 | 三位评审独立阅读同一份原文 | 完整历史与重写记录 | -| 考研、六级使用横线答题区,不显示实时字数 | 原题要求、语言、组织与语域分别反馈 | 错误归类、趋势与练习统计 | +| 原生全屏作答,自动保存草稿 | 三位评审独立阅读同一份原文 | 同题目录与逐稿修改路径 | +| 考研、六级使用横线答题区,默认关闭实时词数 | 原题要求、语言、组织与语域分别反馈 | 错误归类、趋势与练习统计 | | 手写稿识别后先校对,再提交 | 本机取中位数,显示评审分歧 | 所有原稿保留在自己的设备上 | ### 支持的考试 @@ -35,7 +35,7 @@ WriteBench 把练习收敛为一条清晰的路径:选题、作答、评阅、 | **CET-6 六级** | 写作 · 汉译英 | 各 15 分练习尺度 | | **IELTS 雅思 Academic** | Task 1 · Task 2 | 单项任务 Band 9 | -翻译练习重点检查译义、完整性、逻辑关系和目标语言表达;英语一与英语二使用独立 rubric。考研、六级作答期间不显示计词器。 +翻译练习重点检查译义、完整性、逻辑关系和目标语言表达;英语一与英语二使用独立 rubric。macOS 各题型默认不显示实时词数;可在 Settings 开启,交卷后始终显示提交词数。 练习分数用于反馈与自查;内置 rubric 是版本化的实践摘要,不是官方阅卷系统。CET-6 不虚构总分换算,IELTS 不把单篇任务分数当作完整 Writing 成绩。 @@ -57,7 +57,9 @@ macOS 当前是本地 ad-hoc 签名版本,尚未经过 Apple Developer ID 公 2. 使用 Codex 评审时,先安装[官方 Codex CLI](https://learn.chatgpt.com/docs/codex-cli),在终端运行 `codex login`。已登录的用户直接点击 **Check Connection**,无需再走浏览器。 3. 选择考试与题型。macOS 点击题目卡片的 **导入文字**,粘贴完整题目,或选择 `.txt` / `.md` 文件(UTF-8 / UTF-16),编辑确认后自动保存;也可以导入题目图片。 4. 点击 **开始答题**,在沉浸式界面完成作文,然后 **交卷**。 -5. 阅读三位评审的分数与修改建议,点击 **开始重写** 完成下一稿。 +5. 交卷后评阅在后台继续,进度条显示实际完成的评审人数。可切换页面或最小化窗口;点击 **查看进度** 阅读 DeepSeek 实时评语,完成后自行打开结果。退出应用会中断未完成评阅。 +6. 阅读结论、给分、优点、不足和下一稿建议。评阅顶部 **Copy** 复制评审结果,改进作文旁的 **Copy** 单独复制作文。 +7. 在 History 展开题目目录,查看每一稿的时间与分数;点击 **开始重写** 继续修改,新稿会记录基于哪一稿。同题旧记录自动归组,全部原文与评分保留。 **没有 Key 就提示配置,不会给出假评分。** 演示评分代码只存在于测试目标;任何评审失败都会说明是哪一位,保留草稿,不自动改用另一个服务。 @@ -134,7 +136,7 @@ platforms/android/ Android Studio 项目与手机界面 scripts/ 构建、图标生成与显式联调脚本 ``` -macOS 自动化测试覆盖 29 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。完整混合三评需用户填入有效 Key 后使用。 +macOS 自动化测试覆盖 47 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。
更多文档 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 4e2a17c..bf1dff9 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,14 @@ +# 1.4.0 · 后台评阅与逐稿修改路径 + +- 交卷后立即回到准备页,三位评审在后台独立工作。顶部显示真实的完成进度(0/3 → 3/3)、等待时间、取消和结果入口,完成时不强制弹出评阅。 +- DeepSeek 实时显示可读评语;Codex 完成后显示结构化反馈。三份完整结果全部校验通过后,才在本机汇总总分并保存。不会把评语片段或未完成的评分当作结论。 +- 评阅增加结论、写得好的地方、不足和下一稿建议。顶部 Copy 单独复制评审结果,已有作文 Copy 保持原样。 +- History 改为题目目录:同题的多次提交收在一起,展开查看各稿时间、分数与完整评阅。从“重写”提交的新稿记录来源,支持从早期稿件分支修改;旧记录自动归组而不臆测修改关系。 +- Settings 新增实时词数开关,所有题型默认关闭;交卷后显示提交词数,中文翻译显示字符数。 +- 交卷内容和来源稿在提交时固定,后台完成或取消不会覆盖另一份正在写的草稿。缺少 Key 继续提示设置,失败说明具体评审,不降级、不切换服务、不生成假分数。 + +macOS 15+,Apple silicon + Intel。可切换页面、最小化或关闭窗口等待;退出应用会取消未完成评阅。Windows / Android 仍为 0.1.0 预览版,本次功能面向 macOS。 + # 1.3.1 · macOS 文字题目导入 - 题目卡片新增「导入文字」,支持直接粘贴、编辑和命名,再确认填入。 diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj index dfd6125..c0f8eff 100644 --- a/WriteBench.xcodeproj/project.pbxproj +++ b/WriteBench.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 0108E61BCDE2D4568F4D5C8B /* ProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3584B2D03E15D42CF5A4B1 /* ProviderTests.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 */; }; 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F2FF0974E091C6E391D13 /* QuestionTextReader.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 */; }; @@ -16,7 +18,10 @@ 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 */; }; 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 */; }; 4B7B1CDF7FADBE73B96E26DA /* kaoyan_english1_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = AD3CEB6EC11C595CA1EA79D6 /* kaoyan_english1_translation.md */; }; 51899521D61CB025A5559EC5 /* LocalProcessRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 100F5B726342F502CA8C4207 /* LocalProcessRunner.swift */; }; 5317F309ED9004BD4665C0DC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 37444757CFEC8F67705A2D6C /* Assets.xcassets */; }; @@ -27,13 +32,16 @@ 611D1CBBEE2017C06DCD6D8D /* GradingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 634F608AF0DF72D3DE51E438 /* GradingService.swift */; }; 6C1F7BEC5994E87DFA1CEF27 /* DeepSeekClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A399A503863E6199B340A50C /* DeepSeekClient.swift */; }; 76165A1D695B6E1E39EE5906 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70A5D3F3824C11B7B262C308 /* HistoryView.swift */; }; + 7A7FFA1F4A7D393FDC0F3AA0 /* EssayHistoryGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E52B4F1877F33DAEDBED32 /* EssayHistoryGroup.swift */; }; 7ADD51F051853BB04DC26A4D /* WindowImmersionBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */; }; 7E4B43903D948A9E26E11E8D /* BrandArt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA993471F8CFF92BD9B2956 /* BrandArt.swift */; }; 86CD1B5AEC144D4283491FC5 /* PlainTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD6C23EF9D33F68C7816C642 /* PlainTextEditor.swift */; }; + 892F119876541FA3E8699E90 /* BackgroundGradingJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31DECD6B9F0B3A54C27F4535 /* BackgroundGradingJob.swift */; }; 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF0EC53CAA982107C3AA320 /* SettingsView.swift */; }; 938D3916C068089FACA81D61 /* MistakesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4391A8E13E5C7A35D0A1467A /* MistakesView.swift */; }; 96EB80336D8E7D70E3839817 /* kaoyan_english1_small.md in Resources */ = {isa = PBXBuildFile; fileRef = 6A7B637179FC6E271F31DBF4 /* kaoyan_english1_small.md */; }; A104DD7F66FF512A80BA9717 /* QuestionImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */; }; + A840C857A17F4257AD96C9AF /* ReviewTextExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A81F1C48B2FAE8914576B /* ReviewTextExporter.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 */; }; @@ -48,6 +56,7 @@ 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 */; }; FBEB290B54B4DA0E050F77E1 /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 781A9E69AB1A93F99198A60E /* StatisticsView.swift */; }; /* End PBXBuildFile section */ @@ -63,9 +72,12 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 018639A9F1452A7617A5A0A2 /* AppLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifecycle.swift; sourceTree = ""; }; + 029A81F1C48B2FAE8914576B /* ReviewTextExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewTextExporter.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; 1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextQuestionImportView.swift; sourceTree = ""; }; @@ -74,10 +86,14 @@ 27300883AE5AE3DD46C36924 /* CodexJudgeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexJudgeService.swift; sourceTree = ""; }; 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionImportTests.swift; sourceTree = ""; }; 296A6193086826B2B071503F /* MockGradingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGradingService.swift; sourceTree = ""; }; + 306B86B1159828782A062144 /* StreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingTests.swift; sourceTree = ""; }; + 31DECD6B9F0B3A54C27F4535 /* BackgroundGradingJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingJob.swift; sourceTree = ""; }; + 34A07798AF0255AB8EAFF76A /* BackgroundGradingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingTests.swift; sourceTree = ""; }; 37444757CFEC8F67705A2D6C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3A3647AF5325DFD1CE43543B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 405A85F2A07E72B5AD277D96 /* ielts_task1.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ielts_task1.md; sourceTree = ""; }; 4391A8E13E5C7A35D0A1467A /* MistakesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MistakesView.swift; sourceTree = ""; }; + 4BCC37FAB6E0DDC543B0D48B /* DeepSeekStreaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekStreaming.swift; sourceTree = ""; }; 51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowImmersionBridge.swift; sourceTree = ""; }; 56792E2C1501DBCE7B80739B /* kaoyan_english2_translation.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = kaoyan_english2_translation.md; sourceTree = ""; }; 5AEB65E44D0601AEC6053865 /* Exam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Exam.swift; sourceTree = ""; }; @@ -89,6 +105,7 @@ 70A5D3F3824C11B7B262C308 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 781A9E69AB1A93F99198A60E /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.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 = ""; }; 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 = ""; }; @@ -105,6 +122,7 @@ CFD296324E8F63CD0B525458 /* GradingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingProvider.swift; sourceTree = ""; }; D46587A10C9A9B126523FD04 /* DeepSeekCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekCredentials.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 = ""; }; 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 = ""; }; @@ -123,6 +141,8 @@ 1E299B61878E0D3292AC4569 /* Review */ = { isa = PBXGroup; children = ( + DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */, + 029A81F1C48B2FAE8914576B /* ReviewTextExporter.swift */, 0F9DB3748FBE72E213C6EB37 /* ReviewView.swift */, ); path = Review; @@ -153,6 +173,7 @@ 59584BB59D280C5141746843 /* Models */ = { isa = PBXGroup; children = ( + 31DECD6B9F0B3A54C27F4535 /* BackgroundGradingJob.swift */, 5AEB65E44D0601AEC6053865 /* Exam.swift */, DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */, ); @@ -180,6 +201,7 @@ 785B6BC1BFFDA84F214B4163 /* App */ = { isa = PBXGroup; children = ( + 018639A9F1452A7617A5A0A2 /* AppLifecycle.swift */, 9238EAE831BF69E5F210D72A /* WorkspaceView.swift */, E94CDCC1049F7BD4C287AF84 /* WriteBench.entitlements */, 2322E689ACEC8741C4B1C432 /* WriteBenchApp.swift */, @@ -198,9 +220,12 @@ 8E4D664CF8F8C451767E12E6 /* WriteBenchTests */ = { isa = PBXGroup; children = ( + 34A07798AF0255AB8EAFF76A /* BackgroundGradingTests.swift */, + 879136039903990A8065C3BD /* HistoryReviewTests.swift */, 296A6193086826B2B071503F /* MockGradingService.swift */, BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */, 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */, + 306B86B1159828782A062144 /* StreamingTests.swift */, 679F1D25813DBB4821B089EF /* WriteBenchTests.swift */, ); path = WriteBenchTests; @@ -240,6 +265,7 @@ A90CF1CE3C57C22A6291790B /* History */ = { isa = PBXGroup; children = ( + 11E52B4F1877F33DAEDBED32 /* EssayHistoryGroup.swift */, 70A5D3F3824C11B7B262C308 /* HistoryView.swift */, ); path = History; @@ -335,6 +361,7 @@ children = ( A399A503863E6199B340A50C /* DeepSeekClient.swift */, D46587A10C9A9B126523FD04 /* DeepSeekCredentials.swift */, + 4BCC37FAB6E0DDC543B0D48B /* DeepSeekStreaming.swift */, 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */, ); path = DeepSeek; @@ -440,10 +467,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 143462C249149F8D7065D80D /* AppLifecycle.swift in Sources */, + 892F119876541FA3E8699E90 /* BackgroundGradingJob.swift in Sources */, + 473B2ECB957707EDB2BF41AA /* BackgroundGradingView.swift in Sources */, 7E4B43903D948A9E26E11E8D /* BrandArt.swift in Sources */, DEF094BE47996A62C68AB985 /* CodexJudgeService.swift in Sources */, 6C1F7BEC5994E87DFA1CEF27 /* DeepSeekClient.swift in Sources */, F59451A7E3C70F80EF599140 /* DeepSeekCredentials.swift in Sources */, + 448090E99953CC5C5F9C2F40 /* DeepSeekStreaming.swift in Sources */, + 7A7FFA1F4A7D393FDC0F3AA0 /* EssayHistoryGroup.swift in Sources */, E7928726F27B1B2EB5341AEB /* EssaySession.swift in Sources */, 27C8DB78E1B529A54B818ABD /* Exam.swift in Sources */, C66E0F5339DADB4AE603BD5D /* GradingModels.swift in Sources */, @@ -460,6 +492,7 @@ ECFAF47EFB1BFE1187B43C7D /* PracticeRail.swift in Sources */, 3FF816DF3394076A7EAFCA8E /* QuestionLibraryView.swift in Sources */, 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */, + A840C857A17F4257AD96C9AF /* ReviewTextExporter.swift in Sources */, 55C7907B2F577F01BC57A08B /* ReviewView.swift in Sources */, 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */, FBEB290B54B4DA0E050F77E1 /* StatisticsView.swift in Sources */, @@ -477,9 +510,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 380960FEBBC3C2CDC16C3147 /* BackgroundGradingTests.swift in Sources */, + F2DBAF31C0FA351AB0D126DB /* HistoryReviewTests.swift in Sources */, 32367F4CF29D3A467EFA453D /* MockGradingService.swift in Sources */, 0108E61BCDE2D4568F4D5C8B /* ProviderTests.swift in Sources */, A104DD7F66FF512A80BA9717 /* QuestionImportTests.swift in Sources */, + 14D92D7829215C6748E48951 /* StreamingTests.swift in Sources */, CB760C24CAE63253B9400E19 /* WriteBenchTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -547,7 +583,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; @@ -562,7 +598,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -643,7 +679,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_HARDENED_RUNTIME = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -664,7 +700,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.4.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/WriteBench/App/AppLifecycle.swift b/WriteBench/App/AppLifecycle.swift new file mode 100644 index 0000000..2d9cff0 --- /dev/null +++ b/WriteBench/App/AppLifecycle.swift @@ -0,0 +1,19 @@ +import AppKit + +@MainActor final class AppLifecycle: NSObject, NSApplicationDelegate { + weak var writingStore: WritingStore? + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let store = writingStore else { return .terminateNow } + store.tick(); store.persistDraft() + guard store.isGrading, let task = store.gradingTask else { return .terminateNow } + store.cancelGrading() + Task { + await task.value + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater + } +} diff --git a/WriteBench/App/WorkspaceView.swift b/WriteBench/App/WorkspaceView.swift index 695a517..2b1185b 100644 --- a/WriteBench/App/WorkspaceView.swift +++ b/WriteBench/App/WorkspaceView.swift @@ -12,15 +12,18 @@ struct WorkspaceView: View { @Environment(\.scenePhase) private var scenePhase @State private var review: EssaySession? @State private var destination: Destination = .write - @State private var store = WritingStore() + @Bindable var store: WritingStore var body: some View { GeometryReader { geometry in HStack(spacing: 0) { if !store.isInSession { sidebar.frame(width: 200) } VStack(spacing: 0) { if !store.isInSession { header } + if let job = store.gradingJob { + BackgroundGradingView(job: job, onCancel: store.cancelGrading, onDismiss: store.dismissGradingStatus) { review = $0 } + } if destination == .write { - WritingView(store: store, onReview: { review = $0 }).frame(maxWidth: .infinity) + WritingView(store: store).frame(maxWidth: .infinity) } else { switch destination { @@ -39,7 +42,7 @@ struct WorkspaceView: View { .task { store.attach(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) { store.beginRewrite($0); destination = .write; review = nil } } + .sheet(item: $review) { session in ReviewView(session: session) { if !store.isInSession || store.leaveAnswering() { store.beginRewrite($0); destination = .write; review = nil } } } .alert("未配置 DeepSeek API Key", isPresented: $store.needsAPIKey) { Button("继续作答", role: .cancel) { } Button("前往设置") { if store.leaveAnswering() { destination = .settings } } diff --git a/WriteBench/App/WriteBenchApp.swift b/WriteBench/App/WriteBenchApp.swift index 766e7ea..861d180 100644 --- a/WriteBench/App/WriteBenchApp.swift +++ b/WriteBench/App/WriteBenchApp.swift @@ -2,10 +2,26 @@ import SwiftUI import SwiftData @main struct WriteBenchApp: App { + @NSApplicationDelegateAdaptor(AppLifecycle.self) private var lifecycle + @State private var writingStore = WritingStore() private let container: ModelContainer? private let storageError: String? + private static var isTestHost: Bool { + #if DEBUG + NSClassFromString("XCTestCase") != nil || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + #else + false + #endif + } init() { 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, + configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + storageError = nil + return + } // Keep the previous sandbox database and external image storage together on upgrade. let fm = FileManager.default let legacy = fm.homeDirectoryForCurrentUser.appendingPathComponent("Library/Containers/com.chen.WriteBench/Data/Library/Application Support/default.store") @@ -20,7 +36,7 @@ import SwiftData } var body: some Scene { Window("WriteBench", id: "workspace") { - if let container { WorkspaceView().modelContainer(container).preferredColorScheme(.light).task { await DeepSeekCredentials.restoreRememberedKey() } } + if let container { WorkspaceView(store: writingStore).modelContainer(container).preferredColorScheme(.light).task { lifecycle.writingStore = writingStore; if !Self.isTestHost { await DeepSeekCredentials.restoreRememberedKey() } } } else { VStack(spacing: 18) { Text("WriteBench could not open local storage").font(.title2); Text(storageError ?? "Unknown storage error").textSelection(.enabled); Text("Your files have not been reset. Restart the app or check available disk space.").foregroundStyle(.secondary) }.padding(40).frame(width: 600, height: 300) } } .windowStyle(.hiddenTitleBar).windowToolbarStyle(.unified).defaultSize(width: 1440, height: 900) diff --git a/WriteBench/Features/History/EssayHistoryGroup.swift b/WriteBench/Features/History/EssayHistoryGroup.swift new file mode 100644 index 0000000..e32b258 --- /dev/null +++ b/WriteBench/Features/History/EssayHistoryGroup.swift @@ -0,0 +1,47 @@ +import Foundation +import CryptoKit + +/// A non-destructive presentation of saved attempts. Legacy records need no rewrite. +@MainActor struct EssayHistoryGroup: Identifiable { + struct Key: Hashable { + let subtype: String + let question: String + let imageDigest: String? + } + let id: Key + let versions: [EssaySession] + var first: EssaySession { versions[0] } + var latest: EssaySession { versions[versions.count - 1] } + var questionTitle: String { first.question.split(whereSeparator: \.isNewline).joined(separator: " ") } + + private init(key: Key, versions: [EssaySession]) { + id = key + self.versions = versions.sorted { + $0.date == $1.date ? $0.id.uuidString < $1.id.uuidString : $0.date < $1.date + } + } + static func make(from sessions: [EssaySession]) -> [Self] { + let grouped = Dictionary(grouping: sessions.filter { !$0.isDemo }) { session in + Key(subtype: session.subtype, + question: session.question.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n").trimmingCharacters(in: .whitespacesAndNewlines), + imageDigest: session.questionImage.map { SHA256.hash(data: $0).map { String(format: "%02x", $0) }.joined() }) + } + return grouped.map { Self(key: $0.key, versions: $0.value) }.sorted { + $0.latest.date == $1.latest.date ? $0.latest.id.uuidString < $1.latest.id.uuidString : $0.latest.date > $1.latest.date + } + } + func matches(search: String, exam: Exam?) -> Bool { + guard exam == nil || latest.exam == exam?.rawValue else { return false } + let query = search.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty || versions.contains { + [$0.question, $0.originalEssay, $0.finalRewrite].contains { $0.localizedCaseInsensitiveContains(query) } + } + } + func parentNumber(of session: EssaySession) -> Int? { + guard let parentID = session.parentSessionID, + parentID != session.id, + let index = versions.firstIndex(where: { $0.id == parentID }) else { return nil } + return index + 1 + } +} diff --git a/WriteBench/Features/History/HistoryView.swift b/WriteBench/Features/History/HistoryView.swift index 93ec5e7..aa8332f 100644 --- a/WriteBench/Features/History/HistoryView.swift +++ b/WriteBench/Features/History/HistoryView.swift @@ -6,37 +6,117 @@ struct HistoryView: View { var onOpen: (EssaySession) -> Void @State private var search = "" @State private var exam: Exam? - private var filtered: [EssaySession] { sessions.filter { (exam == nil || $0.exam == exam?.rawValue) && (search.isEmpty || $0.question.localizedCaseInsensitiveContains(search) || $0.originalEssay.localizedCaseInsensitiveContains(search)) } } + @State private var expanded: Set = [] + private var groups: [EssayHistoryGroup] { + EssayHistoryGroup.make(from: sessions).filter { $0.matches(search: search, exam: exam) } + } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { - SectionHeading(title: "Your writing, revisited.", subtitle: "History · Every essay, every review, every rewrite.") + SectionHeading(title: "Your writing, revisited.", subtitle: "每道题一个目录,留住每一稿的进步。") HStack { Image(systemName: "magnifyingglass").foregroundStyle(WB.secondary) TextField("Search questions and essays", text: $search).textFieldStyle(.plain) Spacer() - Picker("Exam", selection: $exam) { Text("All exams").tag(Optional.none); ForEach(Exam.allCases) { Text($0.title).tag(Optional($0)) } }.frame(width: 190) - }.padding(14).background(.white, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(WB.line)) - if filtered.isEmpty { Card { EmptyState(symbol: "clock.arrow.circlepath", title: sessions.isEmpty ? "Your next essay starts a story." : "No matching essays", detail: sessions.isEmpty ? "Submit your first essay to keep its score, feedback and rewrite here." : "Try another search or exam filter.") } } - else { + Picker("Exam", selection: $exam) { + Text("All exams").tag(Optional.none) + ForEach(Exam.allCases) { Text($0.title).tag(Optional($0)) } + }.frame(width: 190) + }.padding(14).background(.white, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(WB.line)) + if groups.isEmpty { + Card { + EmptyState(symbol: "folder", title: sessions.isEmpty ? "Your next essay starts a story." : "No matching essays", + detail: sessions.isEmpty ? "交卷后,同题作答与重写会保存在一个目录里。" : "Try another search or exam filter.") + } + } else { Card(padding: 0) { VStack(spacing: 0) { - HStack { Text("DATE").frame(width: 130, alignment: .leading); Text("EXAM / QUESTION").frame(maxWidth: .infinity, alignment: .leading); Text("SCORE").frame(width: 90); Text("CONFIDENCE").frame(width: 110) }.font(.system(size: 10, weight: .semibold)).tracking(1).foregroundStyle(WB.secondary).padding(20) - ForEach(filtered) { session in - Button { onOpen(session) } label: { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 5) { Text(session.date.formatted(date: .abbreviated, time: .omitted)); Text(session.date.formatted(date: .omitted, time: .shortened)).font(.system(size: 11)).foregroundStyle(WB.secondary) }.frame(width: 118, alignment: .leading) - VStack(alignment: .leading, spacing: 7) { HStack { Text(session.task.fullTitle).fontWeight(.medium); if session.isDemo { Text("DEMO").font(.system(size: 9, weight: .bold)).foregroundStyle(WB.blue).padding(4).background(WB.tint, in: RoundedRectangle(cornerRadius: 4)) } }; Text(session.question.replacingOccurrences(of: "\n", with: " ")).font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(1) }.frame(maxWidth: .infinity, alignment: .leading) - Text("\(session.finalScore.scoreText) / \(Int(session.task.maxScore))").font(.system(size: 14, weight: .semibold)).foregroundStyle(WB.blue).frame(width: 90) - Text(session.confidence).font(.system(size: 12)).foregroundStyle(session.confidence == "Low" ? WB.amber : WB.green).frame(width: 96) - Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(WB.secondary) - }.font(.system(size: 13)).padding(20).contentShape(Rectangle()).overlay(alignment: .top) { Rectangle().fill(WB.line.opacity(0.55)).frame(height: 1) } - }.buttonStyle(.plain) + HStack { + Text("题目 / 修改记录").frame(maxWidth: .infinity, alignment: .leading) + Text("最近得分").frame(width: 120) + Text("置信度").frame(width: 90) + }.font(.system(size: 11, weight: .semibold)).foregroundStyle(WB.secondary).padding(20) + ForEach(groups) { group in + folder(group) + if expanded.contains(group.id) { versions(group) } } } } + Text("同一题型、题目及题图的记录自动归组。点击“重写”提交的新稿会记录来源,旧记录按交卷时间排列。") + .font(.system(size: 11)).foregroundStyle(WB.secondary) } }.padding(32) } } + private func folder(_ group: EssayHistoryGroup) -> some View { + let isExpanded = expanded.contains(group.id) + return Button { + withAnimation(.easeInOut(duration: 0.18)) { + if isExpanded { expanded.remove(group.id) } else { expanded.insert(group.id) } + } + } label: { + HStack(spacing: 14) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)).foregroundStyle(WB.secondary).frame(width: 10) + Image(systemName: isExpanded ? "folder.fill" : "folder") + .font(.system(size: 23, weight: .light)).foregroundStyle(WB.blue).frame(width: 30) + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 10) { + Text(group.latest.task.fullTitle).font(.system(size: 14, weight: .medium)) + Text("\(group.versions.count) 稿").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + Text(group.questionTitle.isEmpty ? "图片题目" : group.questionTitle) + .font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(1) + Text("最近提交 · \(group.latest.date.formatted(date: .abbreviated, time: .shortened))") + .font(.system(size: 10)).foregroundStyle(WB.secondary) + }.frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 5) { + Text("\(group.latest.finalScore.scoreText) / \(Int(group.latest.task.maxScore))") + .font(.system(size: 15, weight: .semibold)).foregroundStyle(WB.blue) + if group.versions.count > 1 { + Text("\(group.first.finalScore.scoreText) → \(group.latest.finalScore.scoreText)") + .font(.system(size: 11)).foregroundStyle(WB.secondary) + } + }.frame(width: 120) + confidence(group.latest).frame(width: 90) + }.padding(20).contentShape(Rectangle()) + .background(isExpanded ? WB.tint.opacity(0.35) : .white) + .overlay(alignment: .top) { WB.line.opacity(0.55).frame(height: 1) } + }.buttonStyle(.plain) + .accessibilityLabel("\(group.latest.task.fullTitle),\(group.versions.count) 稿,\(isExpanded ? "收起" : "展开")修改记录") + .help(isExpanded ? "收起修改记录" : "展开全部版本") + } + private func versions(_ group: EssayHistoryGroup) -> some View { + VStack(spacing: 0) { + ForEach(Array(group.versions.enumerated()), id: \.element.id) { index, session in + Button { onOpen(session) } label: { + HStack(spacing: 14) { + Text(String(format: "%02d", index + 1)).font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(WB.blue).frame(width: 32, height: 32) + .background(WB.tint, in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("第 \(index + 1) 稿").font(.system(size: 13, weight: .medium)) + if let parent = group.parentNumber(of: session) { + Label("基于第 \(parent) 稿", systemImage: "arrow.turn.down.right") + .font(.system(size: 11)).foregroundStyle(WB.secondary) + } + } + 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) + 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, 20) + .contentShape(Rectangle()).overlay(alignment: .top) { WB.line.opacity(0.4).frame(height: 1).padding(.leading, 64) } + }.buttonStyle(.plain).help("打开第 \(index + 1) 稿的完整评阅") + } + } + } + private func confidence(_ session: EssaySession) -> some View { + Text(session.confidence).font(.system(size: 12)).foregroundStyle(session.confidence == "Low" ? WB.amber : WB.green) + } } diff --git a/WriteBench/Features/Review/BackgroundGradingView.swift b/WriteBench/Features/Review/BackgroundGradingView.swift new file mode 100644 index 0000000..9d54e29 --- /dev/null +++ b/WriteBench/Features/Review/BackgroundGradingView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct BackgroundGradingView: View { + @Bindable var job: BackgroundGradingJob + var onCancel: () -> Void + var onDismiss: () -> Void + var onReview: (EssaySession) -> Void + @State private var showingDetails = false + @State private var pendingReview: EssaySession? + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { clock in + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 12) { + if job.phase.isRunning { ProgressView().controlSize(.small) } + else { Image(systemName: job.phase == .completed ? "checkmark.circle.fill" : "info.circle").foregroundStyle(job.phase == .completed ? WB.green : WB.amber) } + VStack(alignment: .leading, spacing: 4) { + Text(job.phase.title).font(.system(size: 13, weight: .semibold)) + 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) + 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) + } else { + if let session = job.session { Button("查看结果") { onReview(session) }.buttonStyle(QuietButtonStyle()).foregroundStyle(WB.blue).accessibilityIdentifier("openBackgroundReview") } + IconButton(symbol: "xmark", help: "收起评阅状态", action: onDismiss) + } + } + ProgressView(value: Double(job.completedCount), total: 3).tint(WB.blue) + .accessibilityLabel("已完成 \(job.completedCount) 位评审,共 3 位") + }.padding(.horizontal, 32).padding(.vertical, 12).background(WB.tint.opacity(0.65)) + } + .sheet(isPresented: $showingDetails, onDismiss: { + if let session = pendingReview { pendingReview = nil; onReview(session) } + }) { + GradingProgressView(job: job, onCancel: onCancel) { session in pendingReview = session; showingDetails = false } + } + } +} + +struct GradingProgressView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var job: BackgroundGradingJob + var onCancel: () -> Void + var onReview: (EssaySession) -> Void + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 7) { + Text(job.phase.title).font(.system(size: 22, weight: .semibold)) + Text("\(job.submission.input.task.fullTitle) · 已交卷 \(job.submission.countText)").font(.system(size: 12)).foregroundStyle(WB.secondary) + } + Spacer() + IconButton(symbol: "xmark", help: "关闭进度窗口") { dismiss() } + }.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 + Card(padding: 20) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(judge.title).font(.system(size: 14, weight: .semibold)) + Text(job.configuration?.provider(for: judge).title ?? judge.role).font(.system(size: 11)).foregroundStyle(WB.secondary) + Spacer() + Text(job.judges[judge]?.rawValue ?? "等待").font(.system(size: 11)).foregroundStyle(job.judges[judge] == .completed ? WB.green : WB.secondary) + if let result = job.results[judge] { Text("\(result.response.score.scoreText) / \(Int(job.submission.input.task.maxScore))").font(.system(size: 13, weight: .semibold)).foregroundStyle(WB.blue) } + } + Text(job.previews[judge].flatMap { $0.isEmpty ? nil : $0 } ?? placeholder(for: judge)) + .font(.system(size: 14)).lineSpacing(5).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + } + } + } + if let detail = job.detail { Label(detail, systemImage: "exclamationmark.circle").font(.system(size: 12)).foregroundStyle(WB.amber).textSelection(.enabled) } + DisclosureGroup("本次提交的题目与作答") { + VStack(alignment: .leading, spacing: 16) { Text(job.submission.input.question).foregroundStyle(WB.secondary); Text(job.submission.input.essay) } + .font(.system(size: 13)).lineSpacing(5).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding(.top, 12) + }.font(.system(size: 12)) + }.padding(.horizontal, 24).padding(.bottom, 24) + } + HStack { + Text("可切换页面或最小化窗口;退出应用会中断未完成评阅。").font(.system(size: 11)).foregroundStyle(WB.secondary) + Spacer() + if job.phase.isRunning { + Button("取消评阅", action: onCancel).buttonStyle(QuietButtonStyle()).disabled(job.phase == .cancelling) + Button("后台继续") { dismiss() }.buttonStyle(PrimaryButtonStyle()).keyboardShortcut(.cancelAction) + } else if let session = job.session { + Button("查看结果") { onReview(session) }.buttonStyle(PrimaryButtonStyle()) + } else { Button("返回") { dismiss() }.buttonStyle(QuietButtonStyle()).keyboardShortcut(.cancelAction) } + }.padding(24).background(.white) + }.frame(width: 760, height: 670).background(WB.canvas).foregroundStyle(WB.ink) + } + private func placeholder(for judge: Judge) -> String { + switch job.judges[judge] { + case .cancelled: "该评审已停止。" + case .failed: "该评审未返回完整评阅。" + case .waiting: "正在检查连接,尚未发起评阅。" + default: job.configuration?.provider(for: judge) == .codex ? "Codex 正在独立评阅,完成后显示评语。" : "等待评审返回评语。MAX 思考可能需要数分钟。" + } + } +} diff --git a/WriteBench/Features/Review/ReviewTextExporter.swift b/WriteBench/Features/Review/ReviewTextExporter.swift new file mode 100644 index 0000000..b1d5bc0 --- /dev/null +++ b/WriteBench/Features/Review/ReviewTextExporter.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Copies the assessment; essay text has its own existing Copy action. +@MainActor enum ReviewTextExporter { + static func text(for session: EssaySession) -> String? { + 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)", + "评阅结论(中位分评审)\n\(report.conclusion)", + feedback("写得好的地方", report.strengths), + feedback("不足的地方", report.weaknesses), + feedback("下一稿怎么改", report.improvements), + "评分维度(诊断分 / 10)\n\(session.task.isTranslation ? "译义与完整性" : "任务完成度"):\(report.dimension(\.taskCompletion).scoreText)\n语言:\(report.dimension(\.language).scoreText)\n连贯性:\(report.dimension(\.coherence).scoreText)\n语域:\(report.dimension(\.register).scoreText)" + ] + let reviewers = report.reviewers.map { result in + var lines = ["\(result.judge.title) · \(result.judge.role) · \(result.response.score.scoreText) / \(Int(session.task.maxScore))", + "\(result.provider?.title ?? result.model) · \(result.model)", result.response.summary] + if !result.response.majorErrors.isEmpty { lines.append(feedback("主要问题", result.response.majorErrors)) } + 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")) + 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)" + } + sections.append("逐句修改\n" + (corrections.isEmpty ? "未标注逐句修改。" : corrections.joined(separator: "\n\n"))) + return sections.joined(separator: "\n\n") + } + private static func feedback(_ title: String, _ items: [String]) -> String { + title + "\n" + (items.isEmpty ? "本次评阅未单列此项。" : items.map { "• " + $0 }.joined(separator: "\n")) + } +} diff --git a/WriteBench/Features/Review/ReviewView.swift b/WriteBench/Features/Review/ReviewView.swift index 408bec2..ddc5c80 100644 --- a/WriteBench/Features/Review/ReviewView.swift +++ b/WriteBench/Features/Review/ReviewView.swift @@ -5,12 +5,26 @@ struct ReviewView: View { @Environment(\.dismiss) private var dismiss @Bindable var session: EssaySession var onRewrite: (EssaySession) -> Void + @State private var didCopyReview = false + @State private var copyFeedbackTask: Task? var body: some View { VStack(spacing: 0) { HStack { Label(session.task.isTranslation ? "Translation review" : "Writing review", systemImage: "checkmark.seal").font(.system(size: 16, weight: .semibold)).labelStyle(BlueIconLabelStyle()) Spacer() Text(session.task.fullTitle).foregroundStyle(WB.secondary) + Button { + guard let text = ReviewTextExporter.text(for: session) else { return } + NSPasteboard.general.clearContents() + didCopyReview = NSPasteboard.general.setString(text, forType: .string) + copyFeedbackTask?.cancel() + copyFeedbackTask = Task { + do { try await Task.sleep(for: .seconds(2)); didCopyReview = false } catch { } + } + } label: { + Label(didCopyReview ? "Copied" : "Copy", systemImage: didCopyReview ? "checkmark" : "doc.on.doc") + }.buttonStyle(QuietButtonStyle()).disabled(session.report == nil) + .help("复制评分、评语和修改建议").accessibilityLabel("复制评审结果") IconButton(symbol: "xmark", help: "Close review") { dismiss() } }.padding(22).background(.white) ScrollView { @@ -20,6 +34,20 @@ 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) + Card { + VStack(alignment: .leading, spacing: 16) { + Text("评阅结论").font(.system(size: 18, weight: .semibold)) + Text(report.conclusion).font(.system(size: 15)).lineSpacing(6).textSelection(.enabled) + Text("采用中位分评审的结论;下方保留三位评审的独立意见。").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + } + Card { + VStack(alignment: .leading, spacing: 22) { + feedback("写得好的地方", symbol: "checkmark.circle", color: WB.green, items: report.strengths, empty: "这份评阅未单列优点,可结合评审意见查看。") + feedback("不足的地方", symbol: "exclamationmark.circle", color: WB.amber, items: report.weaknesses, empty: "评审未单列主要不足,请结合评分维度查看。") + feedback("下一稿怎么改", symbol: "pencil.line", color: WB.blue, items: report.improvements, empty: "请参考下方逐句修改与改进版本。") + } + } HStack(spacing: 14) { ForEach(report.reviewers) { reviewer in Card(padding: 18) { @@ -128,6 +156,13 @@ struct ReviewView: View { Text(value.scoreText).font(.system(size: 13, weight: .medium)).monospacedDigit().frame(width: 35, alignment: .trailing) } } + private func feedback(_ title: String, symbol: String, color: Color, items: [String], empty: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: symbol).font(.system(size: 15, weight: .semibold)).foregroundStyle(color) + if items.isEmpty { Text(empty).font(.system(size: 12)).foregroundStyle(WB.secondary) } + ForEach(items, id: \.self) { Text("• " + $0).font(.system(size: 14)).lineSpacing(4).textSelection(.enabled) } + } + } } struct CorrectionRow: View { let correction: Correction diff --git a/WriteBench/Features/Settings/SettingsView.swift b/WriteBench/Features/Settings/SettingsView.swift index 292ed72..1505e11 100644 --- a/WriteBench/Features/Settings/SettingsView.swift +++ b/WriteBench/Features/Settings/SettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI struct SettingsView: View { + @AppStorage("showLiveWordCount") private var showLiveWordCount = false @AppStorage("deepSeekModel") private var model = DeepSeekClient.defaultModel @AppStorage("judgeProviderA") private var providerA = GradingProvider.deepSeek @AppStorage("judgeProviderB") private var providerB = GradingProvider.deepSeek @@ -21,6 +22,13 @@ struct SettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { SectionHeading(title: "A workspace of your own.", subtitle: "Settings · AI providers and local storage.") + Card { + VStack(alignment: .leading, spacing: 12) { + 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) + } + } providerCard codexCard Card { @@ -52,7 +60,7 @@ struct SettingsView: View { settingsNote("Exam scales", "英语一:小作文 / 10,大作文 / 20;CET-6 写作原始分 / 15;IELTS 单项任务 band / 9。") } } - HStack(spacing: 10) { BrandMark(size: 24); Text("WriteBench 1.3").font(.system(size: 12, weight: .medium)); Text("Made for a more deliberate writing practice.").font(.system(size: 11)).foregroundStyle(WB.secondary) }.padding(.top, 4) + 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 } } diff --git a/WriteBench/Features/Writing/ImmersiveWritingView.swift b/WriteBench/Features/Writing/ImmersiveWritingView.swift index a32727f..58736e0 100644 --- a/WriteBench/Features/Writing/ImmersiveWritingView.swift +++ b/WriteBench/Features/Writing/ImmersiveWritingView.swift @@ -2,6 +2,7 @@ import SwiftUI /// The only workspace that can edit or submit an answer. struct ImmersiveWritingView: View { + @AppStorage("showLiveWordCount") private var showLiveWordCount = false @Bindable var store: WritingStore var isRecognizing: Bool var onSubmit: () -> Void @@ -11,7 +12,7 @@ struct ImmersiveWritingView: View { VStack(spacing: 0) { HStack(spacing: 24) { Button { store.leaveAnswering() } label: { Label("保存并离开", systemImage: "chevron.left") } - .buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary).disabled(store.isGrading || isRecognizing).accessibilityIdentifier("leaveAnswering") + .buttonStyle(.plain).font(.system(size: 12)).foregroundStyle(WB.secondary).disabled(isRecognizing).accessibilityIdentifier("leaveAnswering") Spacer() Text(store.task.fullTitle).font(.system(size: 13, weight: .medium)).foregroundStyle(WB.secondary) Spacer() @@ -25,13 +26,13 @@ struct ImmersiveWritingView: View { }.padding(.horizontal, 24).padding(.vertical, 28) }.background(.white).foregroundStyle(WB.ink) .overlay { - if store.isGrading || isRecognizing { + if isRecognizing { Color.white.opacity(0.96).ignoresSafeArea() VStack(spacing: 20) { ProgressView().controlSize(.regular) - Text(store.isGrading ? "正在评阅" : "正在识别手写稿").font(.system(size: 20, weight: .medium)) - Text(store.isGrading ? "三位评审正在独立评阅你的作答。" : "识别后请逐页校对,再确认评分。").font(.system(size: 13)).foregroundStyle(WB.secondary) - Button("取消") { if store.isGrading { store.gradingTask?.cancel() } else { onCancelOCR() } }.buttonStyle(QuietButtonStyle()) + Text("正在识别手写稿").font(.system(size: 20, weight: .medium)) + Text("识别后请逐页校对,再确认评分。").font(.system(size: 13)).foregroundStyle(WB.secondary) + Button("取消") { onCancelOCR() }.buttonStyle(QuietButtonStyle()) } } } @@ -52,9 +53,9 @@ struct ImmersiveWritingView: View { HStack { Text(store.task.exam == .ielts ? "Answer" : "答题区").font(.system(size: 12, weight: .medium)).foregroundStyle(WB.secondary) Spacer() - if store.showsLiveWordCount { Text("\(store.words) words").font(.system(size: 12)).monospacedDigit().foregroundStyle(WB.secondary).accessibilityIdentifier("liveWordCount") } + if showLiveWordCount { Text(store.task.targetLanguage == "Simplified Chinese" ? "\(store.essay.count) 字符" : "\(store.words) words").font(.system(size: 12)).monospacedDigit().foregroundStyle(WB.secondary).accessibilityIdentifier("liveWordCount") } } - PlainTextEditor(text: $store.essay, fontSize: store.task.exam == .ielts ? 18 : 20, editable: !store.isGrading, identifier: "essayEditor", ruled: store.task.exam != .ielts, requestFocus: true) + PlainTextEditor(text: $store.essay, fontSize: store.task.exam == .ielts ? 18 : 20, editable: true, identifier: "essayEditor", ruled: store.task.exam != .ielts, requestFocus: true) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(Rectangle().stroke(Color.black.opacity(0.12), lineWidth: 0.75)) HStack { diff --git a/WriteBench/Features/Writing/WritingStore.swift b/WriteBench/Features/Writing/WritingStore.swift index 99103bf..f0d0439 100644 --- a/WriteBench/Features/Writing/WritingStore.swift +++ b/WriteBench/Features/Writing/WritingStore.swift @@ -2,7 +2,7 @@ import SwiftUI import SwiftData import Observation -enum WritingStage { case preparation, answering, grading } +enum WritingStage { case preparation, answering } @MainActor @Observable final class WritingStore { var task: WritingTask = .kaoyanSmall @@ -11,8 +11,8 @@ enum WritingStage { case preparation, answering, grading } var essay = "" private(set) var stage: WritingStage = .preparation var isInSession: Bool { stage != .preparation } - var isGrading: Bool { stage == .grading } - var showsLiveWordCount: Bool { task.exam == .ielts } + var isGrading: Bool { gradingJob?.phase.isRunning == true } + private(set) var gradingJob: BackgroundGradingJob? var inputMode: InputMode = .typed var elapsed: TimeInterval = 0 var timerRunning = false @@ -114,8 +114,8 @@ enum WritingStage { case preparation, answering, grading } } else { question = task.sampleQuestion; questionLabel = task == .kaoyanSmall ? "原创练习 · 邀请信" : "原创练习"; essay = ""; elapsed = 0; inputMode = .typed; questionImage = nil; sourceImages = []; rewriteSessionID = nil } } catch { self.error = error.localizedDescription } } - func submitConfigured(configuration: GradingConfiguration, loadKey: @MainActor () throws -> String = DeepSeekCredentials.load, onComplete: @escaping (EssaySession) -> Void) { - guard stage == .answering else { return } + func submitConfigured(configuration: GradingConfiguration, loadKey: @MainActor () throws -> String = DeepSeekCredentials.load, onComplete: @escaping (EssaySession) -> Void = { _ in }) { + guard stage == .answering, !isGrading else { return } needsAPIKey = false guard persistDraft() else { return } var deepSeek: DeepSeekClient? @@ -129,55 +129,78 @@ enum WritingStage { case preparation, answering, grading } } catch GradingError.missingKey { needsAPIKey = true; return } catch { self.error = error.localizedDescription; return } let selectedDeepSeek = deepSeek - if !configuration.requiresCodex { - submit(service: ProviderRouter(configuration: configuration, deepSeek: selectedDeepSeek, codex: nil), isDemo: false, onComplete: onComplete) - return - } - tick(); timerRunning = false; stage = .grading - gradingTask = Task { - do { - let connection = try await CodexJudgeService.checkConnection(customPath: configuration.codexPath) - try Task.checkCancellation() - let codex = CodexJudgeService(executable: connection.executable, model: configuration.codexModel, reasoning: configuration.codexReasoning) - stage = .answering - submit(service: ProviderRouter(configuration: configuration, deepSeek: selectedDeepSeek, codex: codex), isDemo: false, onComplete: onComplete) - if stage == .answering { lastTick = Date(); timerRunning = true; gradingTask = nil } - } catch { - stage = .answering; lastTick = Date(); timerRunning = true; gradingTask = nil - if !(error is CancellationError) { + launchSubmission(configuration: configuration, isDemo: false, onComplete: onComplete) { + var codex: CodexJudgeService? + if configuration.requiresCodex { + do { + let connection = try await CodexJudgeService.checkConnection(customPath: configuration.codexPath) + try Task.checkCancellation() + 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: "、") - self.error = "\(judges) · ChatGPT via Codex\n\(error.localizedDescription)\n本次尚未发起评卷。请保存并离开,前往设置检查连接。" + throw JudgeExecutionError(judge: Judge.allCases.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) { + func submit(service: any EssayGradingService, isDemo: Bool, onComplete: @escaping (EssaySession) -> Void = { _ in }) { + launchSubmission(configuration: nil, isDemo: isDemo, onComplete: onComplete) { service } + } + private func launchSubmission(configuration: GradingConfiguration?, isDemo: Bool, onComplete: @escaping (EssaySession) -> Void, + makeService: @escaping @Sendable () async throws -> any EssayGradingService) { + guard !isGrading else { return } guard stage == .answering else { error = "请先点击开始答题。"; return } guard let context else { return } guard !question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !essay.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { error = "请先填写题目与作答内容。"; return } tick(); guard persistDraft() else { return } do { - let input = GradingInput(task: task, question: question, essay: essay, rubric: try RubricLoader.load(task)) - let duration = elapsed, mode = inputMode, image = questionImage, images = sourceImages - timerRunning = false; stage = .grading + 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) + gradingJob = job + timerRunning = false; stage = .preparation gradingTask = Task { - defer { - if stage == .grading { stage = .answering; lastTick = Date(); timerRunning = true } - gradingTask = nil - } + let activity = ProcessInfo.processInfo.beginActivity(options: .userInitiatedAllowingIdleSystemSleep, reason: "WriteBench 正在评阅已提交的作答") + defer { ProcessInfo.processInfo.endActivity(activity) } + defer { if gradingJob?.id == job.id { gradingTask = nil } } do { - let report = try await GradingCoordinator(service: service).grade(input, isDemo: isDemo) + let service = try await makeService() try Task.checkCancellation() - let session = try EssaySession(task: input.task, question: input.question, essay: input.essay, duration: duration, inputMode: mode, report: report, questionImage: image, sourceImages: images) + job.phase = .reviewing + let report = try await GradingCoordinator(service: service).grade(submission.input, isDemo: isDemo) { event in + await MainActor.run { job.receive(event) } + } + try Task.checkCancellation() + job.phase = .saving + let session = try EssaySession(task: submission.input.task, question: submission.input.question, essay: submission.input.essay, + duration: submission.duration, inputMode: submission.inputMode, report: report, questionImage: submission.questionImage, + sourceImages: submission.sourceImages, parentSessionID: submission.parentSessionID) context.insert(session) do { try context.save() } catch { context.delete(session); throw error } - stage = .preparation + job.session = session; job.finish(.completed) onComplete(session) - } catch is CancellationError { } - catch { self.error = error.localizedDescription } + } catch { + if Task.isCancelled || error is CancellationError { job.finish(.cancelled) } + else { + if let failure = error as? JudgeExecutionError { job.judges[failure.judge] = .failed } + job.finish(.failed, detail: error.localizedDescription) + } + } } } catch { self.error = error.localizedDescription } } + func cancelGrading() { + guard let job = gradingJob, job.phase.isRunning else { return } + job.phase = .cancelling + gradingTask?.cancel() + } + func dismissGradingStatus() { + guard !isGrading else { return } + gradingJob = nil + } func beginRewrite(_ session: EssaySession) { guard stage == .preparation else { return } select(session.task) diff --git a/WriteBench/Features/Writing/WritingView.swift b/WriteBench/Features/Writing/WritingView.swift index 7704b92..544b35c 100644 --- a/WriteBench/Features/Writing/WritingView.swift +++ b/WriteBench/Features/Writing/WritingView.swift @@ -3,7 +3,6 @@ import AppKit struct WritingView: View { @Bindable var store: WritingStore - var onReview: (EssaySession) -> Void @State private var ocrImport: OCRImport? @State private var isRecognizing = false @State private var ocrTask: Task? @@ -109,7 +108,7 @@ 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(), onComplete: onReview) + store.submitConfigured(configuration: .load()) } private func importImages(_ purpose: OCRPurpose) { guard !isRecognizing, !store.isGrading else { return } diff --git a/WriteBench/Models/BackgroundGradingJob.swift b/WriteBench/Models/BackgroundGradingJob.swift new file mode 100644 index 0000000..2b313ee --- /dev/null +++ b/WriteBench/Models/BackgroundGradingJob.swift @@ -0,0 +1,76 @@ +import Foundation +import Observation + +struct GradingSubmission: Sendable { + let input: GradingInput + let duration: TimeInterval + let inputMode: InputMode + let questionImage: Data? + let sourceImages: [Data] + var parentSessionID: UUID? = nil + var countText: String { + input.task.targetLanguage == "Simplified Chinese" ? "\(input.essay.count) 字符" : "\(WordCounter.count(input.essay)) words" + } +} + +enum GradingPhase { + case connecting, reviewing, saving, cancelling, completed, failed, cancelled + var isRunning: Bool { [.connecting, .reviewing, .saving, .cancelling].contains(self) } + var title: String { + switch self { + case .connecting: "正在检查评审连接" + case .reviewing: "正在后台评阅" + case .saving: "正在保存评阅" + case .cancelling: "正在取消评阅" + case .completed: "评阅完成" + case .failed: "评阅未完成" + case .cancelled: "已取消评阅" + } + } +} +enum JudgeProgress: String { case waiting = "等待连接", reviewing = "正在评阅", completed = "已完成", failed = "失败", cancelled = "已取消" } +enum GradingProgressEvent: Sendable { + case started(Judge) + case preview(Judge, String) + case completed(ReviewerResult) + case failed(Judge, String) +} + +@MainActor @Observable final class BackgroundGradingJob: Identifiable { + let id = UUID() + let submission: GradingSubmission + let configuration: GradingConfiguration? + let startedAt = Date() + var finishedAt: Date? + var phase: GradingPhase + var judges = Dictionary(uniqueKeysWithValues: Judge.allCases.map { ($0, JudgeProgress.waiting) }) + var previews: [Judge: String] = [:] + var results: [Judge: ReviewerResult] = [:] + var detail: String? + var session: EssaySession? + var completedCount: Int { judges.values.filter { $0 == .completed }.count } + + init(submission: GradingSubmission, configuration: GradingConfiguration?, connecting: Bool) { + self.submission = submission; self.configuration = configuration + phase = connecting ? .connecting : .reviewing + } + func receive(_ event: GradingProgressEvent) { + guard phase == .reviewing else { return } + switch event { + case .started(let judge): judges[judge] = .reviewing + case .preview(let judge, let text): previews[judge] = text + case .completed(let result): + results[result.judge] = result; previews[result.judge] = result.response.summary + judges[result.judge] = .completed + case .failed(let judge, let message): judges[judge] = .failed; detail = message + } + } + 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 } + } + func elapsedText(at date: Date) -> String { + let seconds = max(0, Int((finishedAt ?? date).timeIntervalSince(startedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } +} diff --git a/WriteBench/Models/GradingModels.swift b/WriteBench/Models/GradingModels.swift index 959e8d5..a9ec64e 100644 --- a/WriteBench/Models/GradingModels.swift +++ b/WriteBench/Models/GradingModels.swift @@ -32,6 +32,38 @@ struct JudgeResponse: Codable, Sendable { var summary: String var corrections: [Correction] var improvedVersion: String + var strengths: [String] = [] + var weaknesses: [String] = [] + var improvements: [String] = [] + private enum CodingKeys: String, CodingKey { + case score, taskCompletion, language, coherence, register, majorErrors, minorErrors, summary, corrections, improvedVersion, strengths, weaknesses, improvements + } +} +extension JudgeResponse { + static func decodeProviderOutput(_ data: Data) throws -> JudgeResponse { + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + ["strengths", "weaknesses", "improvements"].allSatisfy({ object[$0] is [String] }) else { + throw GradingError.invalidResponse("缺少优点、不足或改进建议") + } + return try JSONDecoder().decode(JudgeResponse.self, from: data) + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + score = try c.decode(Double.self, forKey: .score) + taskCompletion = try c.decode(Double.self, forKey: .taskCompletion) + language = try c.decode(Double.self, forKey: .language) + coherence = try c.decode(Double.self, forKey: .coherence) + register = try c.decode(Double.self, forKey: .register) + majorErrors = try c.decode([String].self, forKey: .majorErrors) + minorErrors = try c.decode([String].self, forKey: .minorErrors) + summary = try c.decode(String.self, forKey: .summary) + corrections = try c.decode([Correction].self, forKey: .corrections) + improvedVersion = try c.decode(String.self, forKey: .improvedVersion) + // Existing saved reports predate these fields and remain readable. + strengths = try c.decodeIfPresent([String].self, forKey: .strengths) ?? [] + weaknesses = try c.decodeIfPresent([String].self, forKey: .weaknesses) ?? [] + improvements = try c.decodeIfPresent([String].self, forKey: .improvements) ?? [] + } } struct ReviewerResult: Codable, Identifiable, Sendable { var judge: Judge @@ -62,6 +94,14 @@ struct GradingReport: Codable, Sendable { }.sorted { $0.severity == .major && $1.severity != .major } } var improvedVersion: String { reviewers.first(where: { $0.judge == .b })?.response.improvedVersion ?? "" } + 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 }) } + var improvements: [String] { uniqueFeedback(reviewers.flatMap(\.response.improvements)) } + private func uniqueFeedback(_ items: [String]) -> [String] { + var seen = Set() + return items.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted } + } func dimension(_ keyPath: KeyPath) -> Double { let values = reviewers.map { $0.response[keyPath: keyPath] }.sorted() return values.isEmpty ? 0 : values[values.count / 2] diff --git a/WriteBench/Persistence/EssaySession.swift b/WriteBench/Persistence/EssaySession.swift index aa84cfa..60be580 100644 --- a/WriteBench/Persistence/EssaySession.swift +++ b/WriteBench/Persistence/EssaySession.swift @@ -22,11 +22,13 @@ import SwiftData var modelName: String var timestamp: Date var isDemo: Bool + // Optional for lightweight migration of reviews saved before revision tracking. + var parentSessionID: UUID? = nil @Attribute(.externalStorage) var questionImage: Data? @Attribute(.externalStorage) var sourceImages: Data? var task: WritingTask { WritingTask(rawValue: subtype) ?? .kaoyanSmall } var report: GradingReport? { try? JSONDecoder().decode(GradingReport.self, from: reviewerResults) } - init(task: WritingTask, question: String, essay: String, duration: Double, inputMode: InputMode, report: GradingReport, questionImage: Data? = nil, sourceImages: [Data] = []) throws { + init(task: WritingTask, question: String, essay: String, duration: Double, inputMode: InputMode, report: GradingReport, questionImage: Data? = nil, sourceImages: [Data] = [], parentSessionID: UUID? = nil) throws { id = UUID(); date = Date(); exam = task.exam.rawValue; subtype = task.rawValue self.question = question; originalEssay = essay; correctedEssay = report.improvedVersion; finalRewrite = "" writingDuration = duration; wordCount = WordCounter.count(essay); self.inputMode = inputMode.rawValue @@ -34,6 +36,7 @@ import SwiftData detectedMistakes = try JSONEncoder().encode(report.corrections); rubricVersion = report.rubricVersion; graderPromptVersion = report.promptVersion modelName = Array(Set(report.reviewers.map(\.model))).sorted().joined(separator: ", ") timestamp = report.timestamp; isDemo = report.isDemo; self.questionImage = questionImage + self.parentSessionID = parentSessionID self.sourceImages = sourceImages.isEmpty ? nil : try JSONEncoder().encode(sourceImages) } } diff --git a/WriteBench/Services/Codex/CodexJudgeService.swift b/WriteBench/Services/Codex/CodexJudgeService.swift index 511230d..8a26833 100644 --- a/WriteBench/Services/Codex/CodexJudgeService.swift +++ b/WriteBench/Services/Codex/CodexJudgeService.swift @@ -78,7 +78,7 @@ 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? JSONDecoder().decode(JudgeResponse.self, from: data) else { throw CodexError.malformed } + 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 } 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) @@ -95,7 +95,8 @@ enum JudgeResponseSchema { "category": ["type": "string", "enum": MistakeCategory.allCases.map(\.rawValue)], "severity": ["type": "string", "enum": ["major", "minor"]]]] let properties: [String: Any] = ["score": number, "taskCompletion": number, "language": number, "coherence": number, "register": number, - "majorErrors": strings, "minorErrors": strings, "summary": string, "corrections": ["type": "array", "items": correction], "improvedVersion": string] + "majorErrors": strings, "minorErrors": strings, "summary": string, "strengths": strings, "weaknesses": strings, "improvements": strings, + "corrections": ["type": "array", "items": correction], "improvedVersion": string] 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 73b6cfa..bb1d63a 100644 --- a/WriteBench/Services/DeepSeek/DeepSeekClient.swift +++ b/WriteBench/Services/DeepSeek/DeepSeekClient.swift @@ -3,7 +3,7 @@ import Foundation protocol HTTPTransport: Sendable { func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) } -struct URLSessionTransport: HTTPTransport { +struct URLSessionTransport: StreamingHTTPTransport { private let session: URLSession init() { let config = URLSessionConfiguration.ephemeral @@ -17,21 +17,42 @@ struct URLSessionTransport: HTTPTransport { guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } return (data, http) } + func stream(for request: URLRequest, onEvent: @escaping @Sendable (String) async throws -> Bool) async throws { + let (bytes, response) = try await session.bytes(for: request) + defer { bytes.task.cancel() } + guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } + guard (200...299).contains(http.statusCode) else { throw GradingError.http(http.statusCode) } + guard http.mimeType == "text/event-stream" else { throw GradingError.invalidResponse("服务未返回流式评阅") } + var events = ServerSentEvents(), total = 0 + for try await byte in bytes { + try Task.checkCancellation() + total += 1 + guard total <= 33_554_432 else { throw GradingError.invalidResponse("流式响应过大") } + if let event = try events.append(byte), try await !onEvent(event) { return } + } + } } -struct DeepSeekClient: EssayGradingService { +struct DeepSeekClient: StreamingEssayGradingService { static let defaultModel = "deepseek-v4-pro" let apiKey: String let model: String var transport: any HTTPTransport = URLSessionTransport() - func grade(_ input: GradingInput, judge: Judge) async throws -> ReviewerResult { + func grade(_ input: GradingInput, judge: Judge, onPreview: @escaping @Sendable (String) async -> Void) async throws -> ReviewerResult { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw GradingError.missingKey } var request = URLRequest(url: URL(string: "https://api.deepseek.com/chat/completions")!) request.httpMethod = "POST" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - let payload = ChatRequest(model: model, messages: [Message(role: "system", content: GraderPrompt.system(judge: judge, input: input)), Message(role: "user", content: try GraderPrompt.user(input))]) + let payload = ChatRequest(model: model, messages: [Message(role: "system", content: GraderPrompt.system(judge: judge, input: input)), Message(role: "user", content: try GraderPrompt.user(input))], stream: transport is any StreamingHTTPTransport) request.httpBody = try JSONEncoder().encode(payload) + if let streaming = transport as? any StreamingHTTPTransport { + let accumulator = DeepSeekStreamAccumulator() + 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) + } let (data, response) = try await transport.data(for: request) try Task.checkCancellation() guard (200...299).contains(response.statusCode) else { throw GradingError.http(response.statusCode) } @@ -40,12 +61,15 @@ struct DeepSeekClient: EssayGradingService { 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) + } + private func decodedResult(_ content: String, model: String, input: GradingInput, judge: Judge) throws -> ReviewerResult { let result: JudgeResponse - do { result = try JSONDecoder().decode(JudgeResponse.self, from: Data(content.utf8)) } + do { result = try JudgeResponse.decodeProviderOutput(Data(content.utf8)) } catch { throw GradingError.invalidResponse("JSON 字段缺失或类型不符") } 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: completion.model, timestamp: Date(), provider: .deepSeek, reasoningEffort: "max") + return ReviewerResult(judge: judge, response: result, model: model, timestamp: Date(), provider: .deepSeek, reasoningEffort: "max") } func testConnection() async throws -> [String] { var request = URLRequest(url: URL(string: "https://api.deepseek.com/models")!) @@ -62,7 +86,7 @@ struct DeepSeekClient: EssayGradingService { let response_format = Format(type: "json_object") let max_tokens = 131072 let reasoning_effort = "max" - let stream = false + let stream: Bool let thinking = Thinking(type: "enabled") struct Format: Encodable { let type: String } struct Thinking: Encodable { let type: String } @@ -91,9 +115,10 @@ extension GraderPrompt { \(input.rubric) Overall score is on 0–\(input.task.maxScore), in increments of 0.5. Diagnostic taskCompletion, language, coherence, register are 0–10; these diagnostics are not a replacement for the exam rubric. Never conflate the two scales. Give concise, specific explanations in Simplified Chinese. Keep corrected text and improvedVersion in \(input.task.targetLanguage). Original spans must be copied verbatim from the student answer. \(input.task.isTranslation ? "Assess translation fidelity against the source, completeness, logical relationships and natural target-language expression. Do not require essay arguments or penalize valid alternative translations. Distinguish omissions, additions and mistranslations. improvedVersion must be a complete faithful translation, not an essay." : "Preserve the student's meaning.") Do not invent prompt facts or data missing from a diagram. If essential information is missing, clearly explain the limitation in summary and taskCompletion. - Return JSON only with exactly this schema; every field is required: - {"score": 0.0, "taskCompletion": 0.0, "language": 0.0, "coherence": 0.0, "register": 0.0, - "majorErrors": ["scoring-relevant issue"], "minorErrors": ["smaller issue"], "summary": "specific examiner feedback", + Return JSON only with exactly this schema; every field is required. Write summary FIRST so it can be previewed while the rest streams. Give a concise verdict in summary; do not expose private reasoning. strengths identifies 1–3 specific things done well, weaknesses identifies 1–3 scoring-relevant problems, and improvements gives 1–3 concrete next-rewrite actions. Do not invent praise; arrays may be empty when there is no supported observation. + {"summary": "specific examiner verdict", "strengths": ["what works, with evidence"], "weaknesses": ["what loses marks, with evidence"], "improvements": ["specific next-rewrite action"], + "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. diff --git a/WriteBench/Services/DeepSeek/DeepSeekCredentials.swift b/WriteBench/Services/DeepSeek/DeepSeekCredentials.swift index ce48bb6..36bc7bc 100644 --- a/WriteBench/Services/DeepSeek/DeepSeekCredentials.swift +++ b/WriteBench/Services/DeepSeek/DeepSeekCredentials.swift @@ -5,12 +5,12 @@ import Foundation private static var sessionKey: String? private static let rememberPreference = "rememberDeepSeekKeyV2" static var hasSessionKey: Bool { sessionKey != nil } - static func use(_ value: String, remember: Bool) throws { + static func use(_ value: String, remember: Bool, defaults: UserDefaults = .standard) throws { let key = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { throw GradingError.missingKey } sessionKey = key // The caller performs optional persistence off the UI thread. - if !remember { UserDefaults.standard.set(false, forKey: rememberPreference) } + if !remember { defaults.set(false, forKey: rememberPreference) } } static func remember(_ value: String) async throws { let key = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/WriteBench/Services/DeepSeek/DeepSeekStreaming.swift b/WriteBench/Services/DeepSeek/DeepSeekStreaming.swift new file mode 100644 index 0000000..4e476c2 --- /dev/null +++ b/WriteBench/Services/DeepSeek/DeepSeekStreaming.swift @@ -0,0 +1,152 @@ +import Foundation + +protocol StreamingHTTPTransport: HTTPTransport { + /// Return false from onEvent to close the response immediately after [DONE]. + func stream(for request: URLRequest, onEvent: @escaping @Sendable (String) async throws -> Bool) async throws +} + +/// SSE framing is independent of network chunk boundaries and preserves UTF-8 and multi-line data. +struct ServerSentEvents { + private var line = Data() + private var fields: [String] = [] + private var eventBytes = 0 + mutating func append(_ byte: UInt8) throws -> String? { + guard line.count < 1_048_576 else { throw GradingError.invalidResponse("流式消息过大") } + guard byte == 10 else { line.append(byte); return nil } + if line.last == 13 { line.removeLast() } + guard let value = String(data: line, encoding: .utf8) else { throw GradingError.invalidResponse("流式消息编码无效") } + line.removeAll(keepingCapacity: true) + if value.isEmpty { + guard !fields.isEmpty else { return nil } + let event = fields.joined(separator: "\n") + fields.removeAll(keepingCapacity: true); eventBytes = 0 + return event + } + if value.hasPrefix("data:") { + var data = value.dropFirst(5) + if data.first == " " { data = data.dropFirst() } + eventBytes += data.utf8.count + guard eventBytes <= 1_048_576 else { throw GradingError.invalidResponse("流式消息过大") } + fields.append(String(data)) + } + return nil + } +} + +actor DeepSeekStreamAccumulator { + private var content = "" + private var model: String? + private var finishReason: String? + private var done = false + private var lastPreview = "" + private var lastEmittedAt = Date.distantPast + + func consume(_ event: String, onPreview: @Sendable (String) async -> Void) async throws -> Bool { + try Task.checkCancellation() + if event == "[DONE]" { done = true; return false } + let chunk: Chunk + do { chunk = try JSONDecoder().decode(Chunk.self, from: Data(event.utf8)) } + catch { throw GradingError.invalidResponse("流式消息格式无效") } + if let name = chunk.model { model = name } + guard chunk.choices.count <= 1 else { throw GradingError.invalidResponse("返回了多份评阅") } + if let choice = chunk.choices.first { + guard choice.index == 0 else { throw GradingError.invalidResponse("流式评阅序号无效") } + if let delta = choice.delta.content { + content += delta + guard content.utf8.count <= 2_097_152 else { throw GradingError.invalidResponse("评阅内容过长") } + } + if let reason = choice.finish_reason { finishReason = reason } + // reasoning_content is deliberately not decoded, retained, or displayed. + if !content.isEmpty, lastPreview.isEmpty || Date().timeIntervalSince(lastEmittedAt) >= 0.12 || finishReason != nil { + lastEmittedAt = Date() + let preview = JSONSummaryPreview.extract(from: content) + if !preview.isEmpty, preview != lastPreview { + lastPreview = preview + await onPreview(preview) + } + } + } + return true + } + func completed() throws -> (content: String, model: String) { + guard done, finishReason == "stop", let model, !model.isEmpty, !content.isEmpty else { + throw GradingError.invalidResponse("流式评阅中断或输出被截断") + } + return (content, model) + } + private struct Chunk: Decodable { + let model: String? + let choices: [Choice] + struct Choice: Decodable { + let index: Int + let delta: Delta + let finish_reason: String? + } + struct Delta: Decodable { let content: String? } + } +} + +/// Preview only a root JSON summary string. Incomplete previews never enter the grading decoder. +/// A small string tokenizer handles split escapes and surrogate pairs without regex or prose parsing. +enum JSONSummaryPreview { + static func extract(from text: String) -> String { + let scalars = Array(text.unicodeScalars) + var index = 0, depth = 0 + while index < scalars.count { + let scalar = scalars[index] + if scalar == "\"" { + guard let token = string(in: scalars, at: index) else { return "" } + index = token.next + if depth == 1, token.complete, token.value == "summary" { + while index < scalars.count, CharacterSet.whitespacesAndNewlines.contains(scalars[index]) { index += 1 } + guard index < scalars.count, scalars[index] == ":" else { continue } + index += 1 + while index < scalars.count, CharacterSet.whitespacesAndNewlines.contains(scalars[index]) { index += 1 } + guard index < scalars.count, scalars[index] == "\"", let value = string(in: scalars, at: index) else { return "" } + return String(value.value.prefix(4_000)) + } + if !token.complete { return "" } + } else { + if scalar == "{" || scalar == "[" { depth += 1 } + if scalar == "}" || scalar == "]" { depth -= 1 } + index += 1 + } + } + return "" + } + private static func string(in s: [Unicode.Scalar], at start: Int) -> (value: String, next: Int, complete: Bool)? { + var value = "", i = start + 1 + while i < s.count { + if s[i] == "\"" { return (value, i + 1, true) } + if s[i] == "\\" { + i += 1 + guard i < s.count else { break } + switch s[i] { + case "\"", "\\", "/": value.unicodeScalars.append(s[i]); i += 1 + case "n": value += "\n"; i += 1 + case "r": value += "\r"; i += 1 + case "t": value += "\t"; i += 1 + case "b": value += "\u{08}"; i += 1 + case "f": value += "\u{0C}"; i += 1 + case "u": + guard i + 4 < s.count else { return (value, s.count, false) } + guard let first = UInt32(String(String.UnicodeScalarView(s[(i + 1)...(i + 4)])), radix: 16) else { return nil } + i += 5 + var code = first + if (0xD800...0xDBFF).contains(first) { + guard i + 5 < s.count else { return (value, s.count, false) } + guard s[i] == "\\", s[i + 1] == "u", let second = UInt32(String(String.UnicodeScalarView(s[(i + 2)...(i + 5)])), radix: 16), (0xDC00...0xDFFF).contains(second) else { return nil } + code = 0x10000 + ((first - 0xD800) << 10) + second - 0xDC00; i += 6 + } + guard let decoded = Unicode.Scalar(code) else { return nil } + value.unicodeScalars.append(decoded) + default: return nil + } + } else { + guard s[i].value >= 32 else { return nil } + value.unicodeScalars.append(s[i]); i += 1 + } + } + return (value, i, false) + } +} diff --git a/WriteBench/Services/Grading/GradingProvider.swift b/WriteBench/Services/Grading/GradingProvider.swift index 0051f59..0137401 100644 --- a/WriteBench/Services/Grading/GradingProvider.swift +++ b/WriteBench/Services/Grading/GradingProvider.swift @@ -32,15 +32,18 @@ struct GradingConfiguration: Sendable { codexPath: defaults.string(forKey: "codexExecutablePath") ?? "") } } -struct ProviderRouter: EssayGradingService { +struct ProviderRouter: StreamingEssayGradingService { let configuration: GradingConfiguration let deepSeek: (any EssayGradingService)? let codex: (any EssayGradingService)? - func grade(_ input: GradingInput, judge: Judge) async throws -> ReviewerResult { + func grade(_ input: GradingInput, judge: Judge, onPreview: @escaping @Sendable (String) async -> Void) async throws -> ReviewerResult { let provider = configuration.provider(for: judge) do { guard let service = provider == .deepSeek ? deepSeek : codex else { throw GradingError.incomplete } - var result = try await service.grade(input, judge: judge) + var result: ReviewerResult + if let streaming = service as? any StreamingEssayGradingService { + result = try await streaming.grade(input, judge: judge, onPreview: onPreview) + } else { result = try await service.grade(input, judge: judge) } result.provider = provider return result } catch is CancellationError { throw CancellationError() } diff --git a/WriteBench/Services/Grading/GradingService.swift b/WriteBench/Services/Grading/GradingService.swift index 2ffae2d..0176c5c 100644 --- a/WriteBench/Services/Grading/GradingService.swift +++ b/WriteBench/Services/Grading/GradingService.swift @@ -3,6 +3,19 @@ import Foundation protocol EssayGradingService: Sendable { func grade(_ input: GradingInput, judge: Judge) async throws -> ReviewerResult } +protocol StreamingEssayGradingService: EssayGradingService { + func grade(_ input: GradingInput, judge: Judge, onPreview: @escaping @Sendable (String) async -> Void) async throws -> ReviewerResult +} +extension StreamingEssayGradingService { + func grade(_ input: GradingInput, judge: Judge) async throws -> ReviewerResult { + try await grade(input, judge: judge, onPreview: { _ in }) + } +} +struct JudgeExecutionError: LocalizedError { + let judge: Judge + let detail: String + var errorDescription: String? { "\(judge.title)\n\(detail)" } +} protocol ChiefExaminerService: Sendable { func arbitrate(_ input: GradingInput, reviewers: [ReviewerResult]) async throws -> JudgeResponse } @@ -41,11 +54,35 @@ 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) async throws -> GradingReport { - async let a = service.grade(input, judge: .a) - async let b = service.grade(input, judge: .b) - async let c = service.grade(input, judge: .c) - let results = try await [a, b, c] + func grade(_ input: GradingInput, isDemo: Bool, onProgress: @escaping @Sendable (GradingProgressEvent) async -> Void = { _ in }) async throws -> GradingReport { + let results = try await withThrowingTaskGroup(of: ReviewerResult.self) { group in + defer { group.cancelAll() } + for judge in Judge.allCases { + group.addTask { + try Task.checkCancellation() + await onProgress(.started(judge)) + do { + let 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) + await onProgress(.completed(result)) + return result + } catch { + if Task.isCancelled || error is CancellationError { throw CancellationError() } + let failure = JudgeExecutionError(judge: judge, detail: error.localizedDescription) + await onProgress(.failed(judge, failure.localizedDescription)) + throw failure + } + } + } + var collected: [ReviewerResult] = [] + for try await result in group { collected.append(result) } + return collected + } try Task.checkCancellation() return try ScoreAggregator.aggregate(results, task: input.task, isDemo: isDemo) } @@ -58,4 +95,4 @@ enum RubricLoader { return try String(contentsOf: url, encoding: .utf8) } } -enum GraderPrompt { static let version = "1.1.0" } +enum GraderPrompt { static let version = "1.2.0" } diff --git a/WriteBenchTests/BackgroundGradingTests.swift b/WriteBenchTests/BackgroundGradingTests.swift new file mode 100644 index 0000000..ce0d1b2 --- /dev/null +++ b/WriteBenchTests/BackgroundGradingTests.swift @@ -0,0 +1,156 @@ +import Foundation +import SwiftData +import Testing +@testable import WriteBench + +private func backgroundResponse(_ essay: String) -> JudgeResponse { + JudgeResponse(score: 8, taskCompletion: 8, language: 8, coherence: 8, register: 8, majorErrors: [], minorErrors: [], + summary: "任务完成,注意时态一致。", corrections: [], improvedVersion: essay, + strengths: ["交代了活动主题。"], weaknesses: ["时态不一致。"], improvements: ["逐句核对动词时态。"]) +} +private enum BackgroundTestError: Error { case timeout, reviewerFailed } +private actor GatedGrader: StreamingEssayGradingService { + var inputs: [Judge: GradingInput] = [:] + var waiting: [Judge: CheckedContinuation] = [:] + var cancellations: Set = [] + func grade(_ input: GradingInput, judge: Judge, onPreview: @escaping @Sendable (String) async -> Void) async throws -> ReviewerResult { + inputs[judge] = input + await onPreview("已收到题目,正在核对任务要求。") + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { continuation.resume(throwing: CancellationError()) } + else { waiting[judge] = continuation } + } + } onCancel: { Task { await self.cancel(judge) } } + return ReviewerResult(judge: judge, response: backgroundResponse(input.essay), model: "test-only-gated", timestamp: Date()) + } + func finish(_ judge: Judge, fail: Bool = false) { + let continuation = waiting.removeValue(forKey: judge) + if fail { continuation?.resume(throwing: BackgroundTestError.reviewerFailed) } + else { continuation?.resume() } + } + private func cancel(_ judge: Judge) { cancellations.insert(judge); waiting.removeValue(forKey: judge)?.resume(throwing: CancellationError()) } +} +@MainActor private func eventually(_ condition: @MainActor () async -> Bool) async throws { + for _ in 0..<250 { + if await condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + throw BackgroundTestError.timeout +} +@MainActor private func backgroundStore() throws -> (WritingStore, ModelContext) { + let container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let context = ModelContext(container), store = WritingStore() + store.attach(context); store.question = "Invite Alex to a lecture."; store.essay = "Dear Alex, Please join the lecture. Yours, Li Ming." + store.elapsed = 125; store.inputMode = .handwritten; store.sourceImages = [Data([1, 2, 3])] + store.questionImage = Data([4, 5, 6]); store.startAnswering() + return (store, context) +} + +@Test @MainActor func backgroundProgressUsesRealCompletionsAndOriginalSnapshot() async throws { + let (store, context) = try backgroundStore(), grader = GatedGrader() + let original = store.essay + var completions = 0 + store.submit(service: grader, isDemo: true) { _ in completions += 1 } + let running = try #require(store.gradingTask), job = try #require(store.gradingJob) + defer { running.cancel() } + #expect(store.stage == .preparation && store.isGrading && !store.timerRunning) + store.select(.cet6Translation); store.question = "另一道题目"; store.essay = "A new answer."; store.elapsed = 0 + #expect(store.startAnswering()) + try await eventually { await grader.waiting.count == 3 } + #expect(job.completedCount == 0) + #expect(job.previews.count == 3) + #expect(await grader.inputs.values.allSatisfy { $0.essay == original && $0.task == .kaoyanSmall }) + // A second submission must not replace or duplicate the running paid request. + store.submitConfigured(configuration: .init(), loadKey: { Issue.record("Busy submission must not access credentials"); return "" }) + #expect(store.gradingJob?.id == job.id) + await grader.finish(.b) + try await eventually { job.completedCount == 1 } + #expect(job.judges[.b] == .completed && job.judges[.a] == .reviewing) + #expect(job.session == nil && completions == 0) + await grader.finish(.a); await grader.finish(.c); await running.value + #expect(job.phase == .completed && job.completedCount == 3) + #expect(completions == 1 && !store.isGrading) + #expect(store.stage == .answering && store.timerRunning && store.essay == "A new answer.") + let saved = try #require(context.fetch(FetchDescriptor()).first) + #expect(saved.task == .kaoyanSmall && saved.originalEssay == original) + #expect(saved.question == "Invite Alex to a lecture.") + #expect(saved.inputMode == "handwritten" && saved.writingDuration >= 125) + #expect(saved.questionImage == Data([4, 5, 6])) + #expect(saved.report?.strengths == ["交代了活动主题。"]) + #expect(job.submission.countText == "\(WordCounter.count(original)) words") + #expect(try context.fetchCount(FetchDescriptor()) == 1) +} + +@Test @MainActor func cancellingBackgroundReviewKeepsCurrentDraftAndDoesNotSavePartialScores() async throws { + let (store, context) = try backgroundStore(), grader = GatedGrader() + store.submit(service: grader, isDemo: true) { _ in Issue.record("Cancelled review must not complete") } + let running = try #require(store.gradingTask), job = try #require(store.gradingJob) + defer { running.cancel() } + try await eventually { await grader.waiting.count == 3 } + await grader.finish(.a); try await eventually { job.completedCount == 1 } + #expect(store.startAnswering()); store.essay = "My continuing draft." + store.cancelGrading() + #expect(job.phase == .cancelling) + await running.value + #expect(job.phase == .cancelled && !store.isGrading && job.session == nil) + #expect(store.stage == .answering && store.timerRunning && store.essay == "My continuing draft.") + #expect(await grader.waiting.isEmpty) + #expect(await grader.cancellations.contains(.b)) + #expect(try context.fetchCount(FetchDescriptor()) == 0) +} + +@Test @MainActor func backgroundFailureNamesJudgeAndCancelsRemainingRequests() async throws { + let (store, context) = try backgroundStore(), grader = GatedGrader() + store.submit(service: grader, isDemo: true) + let running = try #require(store.gradingTask), job = try #require(store.gradingJob) + defer { running.cancel() } + try await eventually { await grader.waiting.count == 3 } + await grader.finish(.c, fail: true); await running.value + #expect(job.phase == .failed && job.judges[.c] == .failed) + #expect(job.detail?.contains("Judge C") == true) + #expect(job.completedCount == 0 && job.session == nil) + #expect(await grader.waiting.isEmpty) + #expect(store.stage == .preparation && !store.timerRunning) + #expect(job.submission.input.essay == store.essay) + #expect(try context.fetchCount(FetchDescriptor()) == 0) +} + +@Test @MainActor func codexConnectionFailureDoesNotRestoreOrOverwriteAnotherWritingSession() async throws { + let (store, context) = try backgroundStore() + let configuration = GradingConfiguration(a: .codex, b: .codex, c: .codex, codexPath: "/writebench-test-no-such-codex") + store.submitConfigured(configuration: configuration) + let running = try #require(store.gradingTask), job = try #require(store.gradingJob) + #expect(job.phase == .connecting && !store.isInSession) + store.select(.ieltsTask2); store.essay = "An unrelated draft."; store.startAnswering() + await running.value + #expect(job.phase == .failed && job.detail?.contains("Codex") == true) + #expect(store.task == .ieltsTask2 && store.essay == "An unrelated draft.") + #expect(store.stage == .answering && store.timerRunning) + #expect(try context.fetchCount(FetchDescriptor()) == 0) +} + +@Test @MainActor func backgroundRewriteSavesTheSubmittedParentWhenAnotherRewriteStarts() async throws { + let (store, context) = try backgroundStore(), grader = GatedGrader() + #expect(store.leaveAnswering()) + let report = try ScoreAggregator.aggregate(Judge.allCases.map { + ReviewerResult(judge: $0, response: backgroundResponse(store.essay), model: "test", timestamp: Date()) + }, task: .kaoyanSmall, isDemo: false) + let source = try EssaySession(task: .kaoyanSmall, question: store.question, essay: store.essay, duration: 10, inputMode: .typed, report: report) + let other = try EssaySession(task: .kaoyanSmall, question: store.question, essay: "Another original answer.", duration: 20, inputMode: .typed, report: report) + context.insert(source); context.insert(other); try context.save() + store.beginRewrite(source); store.essay = "The submitted revision." + store.submit(service: grader, isDemo: false) + let running = try #require(store.gradingTask), job = try #require(store.gradingJob) + defer { running.cancel() } + store.beginRewrite(other); store.essay = "A different rewrite in progress." + try await eventually { await grader.waiting.count == 3 } + for judge in Judge.allCases { await grader.finish(judge) } + await running.value + let saved = try #require(job.session) + #expect(saved.parentSessionID == source.id) + #expect(saved.originalEssay == "The submitted revision.") + #expect(store.essay == "A different rewrite in progress." && store.isInSession) + #expect(source.originalEssay != saved.originalEssay) + #expect(EssayHistoryGroup.make(from: try context.fetch(FetchDescriptor())).count == 1) +} diff --git a/WriteBenchTests/HistoryReviewTests.swift b/WriteBenchTests/HistoryReviewTests.swift new file mode 100644 index 0000000..31e29fa --- /dev/null +++ b/WriteBenchTests/HistoryReviewTests.swift @@ -0,0 +1,98 @@ +import Foundation +import SwiftData +import Testing +@testable import WriteBench + +@MainActor private func historySession(question: String = "Invite Alex.\nInclude the time.", task: WritingTask = .kaoyanSmall, + score: Double = 8, date: TimeInterval = 100, parent: UUID? = nil) throws -> EssaySession { + let response = JudgeResponse(score: score, taskCompletion: 8, language: 7, coherence: 8, register: 9, + majorErrors: ["Check the verb form."], minorErrors: [], summary: "Clear purpose, but revise the verb form.", + corrections: [Correction(original: "look forward to hear", corrected: "look forward to hearing", category: .grammar, + severity: .minor, explanation: "Use a gerund after to here.")], improvedVersion: "SEPARATE IMPROVED ESSAY BODY", + strengths: ["Clear purpose."], weaknesses: ["Incorrect verb form."], improvements: ["Check gerunds after prepositions."]) + let reviewers = Judge.allCases.map { ReviewerResult(judge: $0, response: response, model: "test-fixture", timestamp: Date(), provider: .deepSeek) } + let report = try ScoreAggregator.aggregate(reviewers, task: task, isDemo: false) + let session = try EssaySession(task: task, question: question, essay: "SEPARATE ORIGINAL ESSAY BODY", duration: 100, + inputMode: .typed, report: report, parentSessionID: parent) + session.date = Date(timeIntervalSince1970: date) + return session +} + +@Test @MainActor func historyGroupsOldAttemptsWithoutInventingAncestry() throws { + let first = try historySession(score: 4.5), second = try historySession(question: "\nInvite Alex.\r\nInclude the time.\n", score: 7.5, date: 200) + let branch = try historySession(score: 8, date: 300, parent: first.id) + let groups = EssayHistoryGroup.make(from: [branch, second, first]) + let group = try #require(groups.first) + #expect(groups.count == 1) + #expect(group.versions.map(\.id) == [first.id, second.id, branch.id]) + #expect(group.first.finalScore == 4.5 && group.latest.finalScore == 8) + #expect(group.parentNumber(of: second) == nil) + #expect(group.parentNumber(of: branch) == 1) // A branch must not claim to follow the latest version. + #expect(first.parentSessionID == nil && second.parentSessionID == nil) + #expect(first.originalEssay == "SEPARATE ORIGINAL ESSAY BODY") + first.originalEssay = "An earlier draft containing a unique search term." + #expect(group.matches(search: "UNIQUE SEARCH", exam: .kaoyan)) + #expect(group.versions.count == 3) // Matching an older draft keeps its full context. + #expect(!group.matches(search: "missing query", exam: nil)) + #expect(!group.matches(search: "", exam: .cet6)) +} + +@Test @MainActor func historyKeepsDistinctTasksPromptsAndQuestionImagesSeparate() throws { + let original = try historySession() + let anotherTask = try historySession(task: .cet6Writing, date: 400) + let anotherPrompt = try historySession(question: "Invite Sam.\nInclude the time.", date: 300) + let imageA = try historySession(date: 200), imageB = try historySession(date: 210) + imageA.questionImage = Data([1]); imageB.questionImage = Data([2]) + let demo = try historySession(date: 500); demo.isDemo = true + let groups = EssayHistoryGroup.make(from: [original, anotherTask, anotherPrompt, imageA, imageB, demo]) + #expect(groups.count == 5) + #expect(groups.first?.latest.id == anotherTask.id) + #expect(groups.allSatisfy { $0.versions.count == 1 }) + let reordered = EssayHistoryGroup.make(from: [imageB, anotherPrompt, anotherTask, imageA, original]) + #expect(reordered.map(\.id) == groups.map(\.id)) +} + +@Test @MainActor func revisionParentsSurviveADiskStoreReopen() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("writebench-history-test-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("history.store") + let sourceID: UUID, revisionID: UUID + do { + let container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, configurations: ModelConfiguration(url: url)) + let context = ModelContext(container), source = try historySession() + let revision = try historySession(date: 200, parent: source.id) + sourceID = source.id; revisionID = revision.id + context.insert(source); context.insert(revision); try context.save() + } + let reopened = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, configurations: ModelConfiguration(url: url)) + let sessions = try ModelContext(reopened).fetch(FetchDescriptor()) + #expect(sessions.first(where: { $0.id == sourceID })?.parentSessionID == nil) + #expect(sessions.first(where: { $0.id == revisionID })?.parentSessionID == sourceID) + #expect(EssayHistoryGroup.make(from: sessions).count == 1) +} + +@Test @MainActor func reviewCopyContainsAssessmentAndLeavesEssayCopySeparate() throws { + let session = try historySession(score: 7.5) + let text = try #require(ReviewTextExporter.text(for: session)) + for required in ["7.5 / 10", "置信度:High", "Clear purpose.", "Incorrect verb form.", "Check gerunds", "Judge A", "Judge B", "Judge C", "look forward to hearing", "Use a gerund", "DeepSeek"] { + #expect(text.contains(required)) + } + #expect(!text.contains(session.originalEssay)) + #expect(!text.contains(session.correctedEssay)) + #expect(session.correctedEssay == "SEPARATE IMPROVED ESSAY BODY") + var json = try #require(try JSONSerialization.jsonObject(with: session.reviewerResults) as? [String: Any]) + var reviewers = try #require(json["reviewers"] as? [[String: Any]]) + for index in reviewers.indices { + var response = try #require(reviewers[index]["response"] as? [String: Any]) + for key in ["strengths", "weaknesses", "improvements"] { response.removeValue(forKey: key) } + reviewers[index]["response"] = response + } + json["reviewers"] = reviewers + session.reviewerResults = try JSONSerialization.data(withJSONObject: json) + let legacy = try #require(ReviewTextExporter.text(for: session)) + #expect(legacy.contains("本次评阅未单列此项。")) + #expect(legacy.contains("Check the verb form.")) + session.reviewerResults = Data("invalid".utf8) + #expect(ReviewTextExporter.text(for: session) == nil) +} diff --git a/WriteBenchTests/ProviderTests.swift b/WriteBenchTests/ProviderTests.swift index 77957be..5ab31cf 100644 --- a/WriteBenchTests/ProviderTests.swift +++ b/WriteBenchTests/ProviderTests.swift @@ -102,11 +102,13 @@ private actor CodexFixtureRunner: ProcessRunning { } @Test @MainActor func pastedKeyWorksWithoutKeychainOrPersistentPreferences() throws { - defer { DeepSeekCredentials.clearSession() } - try DeepSeekCredentials.use(" local-test-only-placeholder ", remember: false) + let suite = "writebench-credentials-test-\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { DeepSeekCredentials.clearSession(); defaults.removePersistentDomain(forName: suite) } + try DeepSeekCredentials.use(" local-test-only-placeholder ", remember: false, defaults: defaults) #expect(DeepSeekCredentials.hasSessionKey) #expect(try DeepSeekCredentials.load() == "local-test-only-placeholder") - #expect(UserDefaults.standard.string(forKey: "deepSeekAPIKey") == nil) + #expect(defaults.string(forKey: "deepSeekAPIKey") == nil) DeepSeekCredentials.clearSession() #expect(!DeepSeekCredentials.hasSessionKey) #expect(throws: GradingError.self) { try DeepSeekCredentials.load() } diff --git a/WriteBenchTests/StreamingTests.swift b/WriteBenchTests/StreamingTests.swift new file mode 100644 index 0000000..d95fb2b --- /dev/null +++ b/WriteBenchTests/StreamingTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import WriteBench + +@Test func sseFramesUTF8AcrossBytesCommentsAndMultilineEvents() throws { + let source = ": keep-alive\r\nid: 1\r\ndata: {\"text\":\r\ndata: \"中文\"}\r\n\r\ndata: [DONE]\n\n" + var parser = ServerSentEvents(), events: [String] = [] + for byte in source.utf8 { if let event = try parser.append(byte) { events.append(event) } } + #expect(events == ["{\"text\":\n\"中文\"}", "[DONE]"]) +} +@Test func streamedSummaryTokenizerHandlesPartialEscapesAndIgnoresNestedKeys() { + #expect(JSONSummaryPreview.extract(from: #"{"summary":"任务已完成"#) == "任务已完成") + #expect(JSONSummaryPreview.extract(from: #"{"summary":"Good\n\"work\" \u4e2d\u6587"#) == "Good\n\"work\" 中文") + #expect(JSONSummaryPreview.extract(from: #"{"summary":"Keep \uD83D"#) == "Keep ") + #expect(JSONSummaryPreview.extract(from: #"{"summary":"Keep \uD83D\uDC4D"#) == "Keep 👍") + #expect(JSONSummaryPreview.extract(from: #"{"summary":"Keep \u4e"#) == "Keep ") + #expect(JSONSummaryPreview.extract(from: #"{"corrections":[{"summary":"ignored"}],"summary":"Actual verdict"}"#) == "Actual verdict") + #expect(JSONSummaryPreview.extract(from: #"{"corrections":[{"summary":"ignored"}]}"#).isEmpty) +} +private actor PreviewRecorder { + var values: [String] = [] + func append(_ value: String) { values.append(value) } +} +private func chunk(_ content: String? = nil, reasoning: String? = nil, finish: String? = nil) throws -> String { + var delta: [String: String] = [:] + if let content { delta["content"] = content } + if let reasoning { delta["reasoning_content"] = reasoning } + let json: [String: Any] = ["model": "stream-fixture", "choices": [["index": 0, "delta": delta, "finish_reason": finish as Any? ?? NSNull()]]] + return String(decoding: try JSONSerialization.data(withJSONObject: json), as: UTF8.self) +} +private actor StreamFixture: StreamingHTTPTransport { + let events: [String] + var requests: [URLRequest] = [] + 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 { + requests.append(request) + for event in events { + try Task.checkCancellation() + if try await !onEvent(event) { return } + } + } +} +private let streamedInput = GradingInput(task: .kaoyanSmall, question: "Invite Alex.", essay: "Dear Alex, please come. Yours, Li Ming.", rubric: "Test") +private let responseTail = #", but check tense.","score":8,"taskCompletion":8,"language":8,"coherence":8,"register":8,"majorErrors":[],"minorErrors":[],"corrections":[],"improvedVersion":"Dear Alex, please come. Yours, Li Ming.","strengths":["Clear invitation."],"weaknesses":["Check tense."],"improvements":["Review each verb."]}"# + +@Test func deepSeekStreamsARealPreviewBeforeDecodingCompleteStructuredFeedback() async throws { + let prefix = #"{"summary":"Task completed"# + let transport = try StreamFixture(events: [chunk(reasoning: "PRIVATE_REASONING_SHOULD_NOT_APPEAR"), chunk(prefix), chunk(responseTail, finish: "stop"), "[DONE]"]) + let previews = PreviewRecorder() + let result = try await DeepSeekClient(apiKey: "fixture-key", model: "fixture", transport: transport).grade(streamedInput, judge: .b) { await previews.append($0) } + #expect(await previews.values.first == "Task completed") + #expect(await previews.values.last == "Task completed, but check tense.") + #expect(await previews.values.allSatisfy { !$0.contains("PRIVATE_REASONING") }) + #expect(result.response.score == 8 && result.response.strengths == ["Clear invitation."]) + #expect(result.response.improvements == ["Review each verb."]) + let request = try #require(await transport.requests.first) + let data = try #require(request.httpBody) + let body = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(body["stream"] as? Bool == true) + #expect(body["reasoning_effort"] as? String == "max") +} +@Test func interruptedTruncatedOrMalformedStreamsNeverProduceAScore() async throws { + let content = #"{"summary":"Task completed"# + responseTail + let cases = try [[chunk(content, finish: "stop")], [chunk(content, finish: "length"), "[DONE]"], [chunk("{", finish: "stop"), "[DONE]"], ["not JSON", "[DONE]"]] + for events in cases { + let client = DeepSeekClient(apiKey: "fixture-key", model: "fixture", transport: StreamFixture(events: events)) + await #expect(throws: (any Error).self) { try await client.grade(streamedInput, judge: .a) } + } +} +@Test func historicalFeedbackDecodesButNewProviderOutputRequiresAllFeedbackSections() throws { + let complete = #"{"summary":"Task completed"# + responseTail + var json = try #require(JSONSerialization.jsonObject(with: Data(complete.utf8)) as? [String: Any]) + for field in ["strengths", "weaknesses", "improvements"] { json.removeValue(forKey: field) } + let legacy = try JSONSerialization.data(withJSONObject: json) + #expect(try JSONDecoder().decode(JudgeResponse.self, from: legacy).strengths.isEmpty) + #expect(throws: (any Error).self) { try JudgeResponse.decodeProviderOutput(legacy) } +} diff --git a/WriteBenchTests/WriteBenchTests.swift b/WriteBenchTests/WriteBenchTests.swift index 63d5006..1967ef2 100644 --- a/WriteBenchTests/WriteBenchTests.swift +++ b/WriteBenchTests/WriteBenchTests.swift @@ -185,7 +185,6 @@ private actor FixtureTransport: HTTPTransport { #expect(store.error != nil) #expect(store.startAnswering()) #expect(store.isInSession && store.timerRunning) - #expect(!store.showsLiveWordCount) store.select(.cet6Writing) #expect(store.task == .kaoyanSmall) // An active session cannot switch exams. #expect(store.leaveAnswering()) @@ -195,8 +194,8 @@ private actor FixtureTransport: HTTPTransport { #expect(restored.stage == .preparation) #expect(restored.startAnswering()) #expect(restored.leaveAnswering()) - restored.select(.cet6Writing); #expect(!restored.showsLiveWordCount) - restored.select(.ieltsTask2); #expect(restored.showsLiveWordCount) + restored.select(.cet6Writing); #expect(restored.task == .cet6Writing) + restored.select(.ieltsTask2); #expect(restored.task == .ieltsTask2) } @Test @MainActor func handInSavesThenReturnsToPreparation() async throws { let container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) @@ -205,7 +204,7 @@ private actor FixtureTransport: HTTPTransport { #expect(store.startAnswering()) var savedID: UUID? store.submit(service: RecordingGrader(), isDemo: true) { savedID = $0.id } - #expect(store.stage == .grading && !store.timerRunning) + #expect(store.stage == .preparation && store.isGrading && !store.timerRunning) #expect(!store.leaveAnswering()) let grading = try #require(store.gradingTask); await grading.value #expect(savedID != nil) @@ -215,15 +214,16 @@ private actor FixtureTransport: HTTPTransport { private struct UnavailableGrader: EssayGradingService { func grade(_ input: GradingInput, judge: Judge) async throws -> ReviewerResult { throw GradingError.http(503) } } -@Test @MainActor func failedGradingReturnsToImmersionWithoutLosingAnswer() async throws { +@Test @MainActor func failedGradingRetainsDraftAfterReturningToPreparation() async throws { let container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) let context = ModelContext(container) let store = WritingStore(); store.attach(context); store.essay = input().essay; store.startAnswering() store.submit(service: UnavailableGrader(), isDemo: false) { _ in Issue.record("A failed grade must not complete") } let grading = try #require(store.gradingTask); await grading.value - #expect(store.stage == .answering && store.timerRunning) + #expect(store.stage == .preparation && !store.timerRunning) #expect(store.essay == input().essay) - #expect(store.error != nil) + #expect(store.gradingJob?.phase == .failed) + #expect(store.gradingJob?.detail != nil) #expect(try context.fetchCount(FetchDescriptor()) == 0) } @Test @MainActor func rewriteUsesImmersionAndUpdatesOriginalHistory() throws { @@ -310,7 +310,6 @@ private struct UnavailableGrader: EssayGradingService { store.essay = "学习一项新技能通常始于一种不适感:我们知道自己想达到什么目标,却还不能做得很好。" #expect(WordCounter.count(store.essay) == 0) #expect(store.startAnswering()) - #expect(!store.showsLiveWordCount) var saved: EssaySession? store.submit(service: RecordingGrader(), isDemo: true) { saved = $0 } let grading = try #require(store.gradingTask); await grading.value diff --git a/project.yml b/project.yml index fa4e79b..63ad853 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.3.1" - CURRENT_PROJECT_VERSION: "6" + MARKETING_VERSION: "1.4.0" + CURRENT_PROJECT_VERSION: "7" targets: WriteBench: type: application From a18d662e4ae882a56e1ac6b76478ce719306e325 Mon Sep 17 00:00:00 2001 From: Functionhx <172989722+Functionhx@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:18:04 +0800 Subject: [PATCH 2/2] feat(macos): review outline and customizable exam folders --- Documentation/Providers.md | 2 +- Documentation/Validation.md | 8 +- Documentation/macOS-guide.md | 4 +- README.md | 6 +- RELEASE-NOTES.md | 2 + WriteBench.xcodeproj/project.pbxproj | 16 ++ WriteBench/App/WriteBenchApp.swift | 4 +- WriteBench/Components/ExamTaskBadge.swift | 26 +++ .../Features/History/EssayHistoryGroup.swift | 11 +- .../History/HistoryFolderEditor.swift | 57 +++++ WriteBench/Features/History/HistoryView.swift | 117 ++++++---- .../Features/Review/ReviewOutline.swift | 61 +++++ WriteBench/Features/Review/ReviewView.swift | 214 ++++++++++-------- .../Persistence/EssayFolderMetadata.swift | 52 +++++ WriteBenchTests/HistoryReviewTests.swift | 34 +++ 15 files changed, 472 insertions(+), 142 deletions(-) create mode 100644 WriteBench/Components/ExamTaskBadge.swift create mode 100644 WriteBench/Features/History/HistoryFolderEditor.swift create mode 100644 WriteBench/Features/Review/ReviewOutline.swift create mode 100644 WriteBench/Persistence/EssayFolderMetadata.swift diff --git a/Documentation/Providers.md b/Documentation/Providers.md index 90888f8..1fb31ec 100644 --- a/Documentation/Providers.md +++ b/Documentation/Providers.md @@ -20,7 +20,7 @@ Each role has its own provider selector. A submission freezes the selected confi Codex continues to decode its final schema-constrained output file; no token-by-token Codex preview is claimed. Prompt version 1.2 requires `strengths`, `weaknesses` and `improvements` arrays alongside the existing fields. Old saved reviews decode these missing arrays as empty. The overview uses the median-score reviewer's conclusion and locally deduplicates feedback; it makes no extra summarization call. -History groups records at display time by task, normalized exact prompt and question-image digest. It does not mutate old records or infer their ancestry. The optional SwiftData `parentSessionID` records only an explicit rewrite source, captured when submitted. Original essays and previous scores remain immutable. Different diagrams with identical instruction text stay separate. +History groups records at display time by task, normalized exact prompt and question-image digest. It does not mutate old records or infer their ancestry. The optional SwiftData `parentSessionID` records only an explicit rewrite source, captured when submitted. Original essays and previous scores remain immutable. Different diagrams with identical instruction text stay separate. Optional folder title, question year and custom label live in a separate `EssayFolderMetadata` model keyed by the stable group digest, so future attempts inherit the same organization without changing essay evidence. ## Direct key entry diff --git a/Documentation/Validation.md b/Documentation/Validation.md index b6d185d..af4656f 100644 --- a/Documentation/Validation.md +++ b/Documentation/Validation.md @@ -1,13 +1,13 @@ # 1.4.0 validation · 2026-09-13 -- 47 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. +- 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. - SSE fixtures cover UTF-8 split across byte boundaries, CRLF, comments, multiline data, escaped/incomplete JSON summaries, first readable previews, required final `[DONE]`, malformed/truncated completion rejection, and backward-compatible saved feedback. Production streaming uses URLSession async bytes; no paid live DeepSeek streaming call was made for this release. -- History tests cover chronological ordering, old same-question grouping without invented ancestry, branching from an earlier draft, search retaining all versions, exam/prompt/image isolation, demo exclusion, and revision parent persistence across a disk-store reopen. Review export includes scores, comments and corrections while leaving both full essay bodies out; old saved reports still copy correctly. +- History tests cover chronological ordering, old same-question grouping without invented ancestry, branching from an earlier draft, search retaining all versions, exam/prompt/image isolation, demo exclusion, revision parent persistence across a disk-store reopen, optional year validation, folder metadata persistence/filtering and inheritance by future versions. Review export includes scores, comments and corrections while leaving both full essay bodies out; old saved reports still copy correctly. - A separate fixture executable built the original v1.3.1 SwiftData schema and wrote two synthetic reviews (4.5 and 7.5). An isolated copy of the new app successfully opened and migrated that store, displayed one question folder and reopened both complete legacy reviews. No user database was used for this upgrade check. -- Native interactive checks in isolated app copies: hand-in returns to preparation with a 0/3 status bar; Settings remains accessible during grading; word count defaults off and becomes visible in immersion when enabled; a completed review is opened explicitly. The history folder and its two child rows were visually inspected. Clicking the new review Copy showed Copied; the existing improved-essay Copy remains separate. +- Native interactive checks in isolated app copies: hand-in returns to preparation with a 0/3 status bar; Settings remains accessible during grading; word count defaults off and becomes visible in immersion when enabled; a completed review is opened explicitly. The history folder and its two child rows were visually inspected. Clicking the new review Copy showed Copied; the existing improved-essay Copy remains separate. The question-info menu saved a custom title, 2024 year and label in the isolated store, exposed year/label filters, and kept exam/task badges visible. The left review outline was checked with actual section jumps. - Real-time partial-preview timing and cancellation are covered by gated service tests. Codex still displays its final structured feedback on completion, rather than token streaming. An end-to-end live mixed-provider paid review is not claimed. - The hosted test app uses an in-memory container and does not restore credentials. Credential preference tests use a temporary defaults suite. QA copies have separate identities and synthetic data; the user's running app and current writing session were left untouched. -- macOS version 1.4.0 (7), Universal arm64 + x86_64. The only SwiftData schema addition is optional `EssaySession.parentSessionID`; grouping is computed without rewriting old records. Windows and Android remain 0.1.0. +- macOS version 1.4.0 (7), Universal arm64 + x86_64. SwiftData adds optional `EssaySession.parentSessionID` and the separate `EssayFolderMetadata` model; grouping is computed without rewriting old records. Windows and Android remain 0.1.0. # 1.3.1 validation · 2026-09-13 diff --git a/Documentation/macOS-guide.md b/Documentation/macOS-guide.md index a73a3d6..c9749b2 100644 --- a/Documentation/macOS-guide.md +++ b/Documentation/macOS-guide.md @@ -19,9 +19,9 @@ Double-click **WriteBench.app** in this folder, or open **WriteBench.xcodeproj** 3. Click **开始答题** (or **⌘Return**) to enter the only answering workspace: native full-screen immersion. Preparation has no essay editor or grading button. The sidebar, exam tabs and decorative cards disappear. The question stays on the left and your answer on the right. 4. The timer starts when you start answering. Kaoyan and CET-6 use a ruled answer area. All tasks default to **no live word count**. Enable **答题时显示词数** in Settings if wanted; Chinese translations show characters. This is a practice writing surface, not a claim of exact official answer-card dimensions. Native undo/redo and copy/paste remain available through standard shortcuts, without a formatting toolbar. **保存并离开** saves the draft and pauses its timer; continuing requires **开始答题** again. Switching away from the app during an active session does not stop the exam timer. Leaving macOS full screen through the system controls still leaves you in the same minimal answering workspace. 5. Click **交卷** or press **⌘Return** while answering. The app immediately returns to preparation while three independent graders run in the background. The status strip shows actual completed reviewers, elapsed time and submitted word count. Switch pages, edit another draft or minimize the window; use **查看进度** for streamed DeepSeek comments. Codex feedback arrives when its structured result completes. Open the final review yourself when ready. Failure or cancellation never overwrites the current draft or produces a partial total. Quitting the app interrupts unfinished grading. There is no non-immersive submission route. -6. In History / Review, **开始重写** or **继续重写** enters the same immersive workspace. Rewrites automatically save back to the source review, including after closing/reopening the app. The review page itself has no alternate editable essay field. Each completed regrading retains its own review inside the same question folder. New rewrites record their source version, including branches from an older draft. Old same-question records are grouped without inventing parent links. The review header **Copy** copies the assessment; the existing improved-essay **Copy** still copies only that essay. +6. In History / Review, **开始重写** or **继续重写** enters the same immersive workspace. Rewrites automatically save back to the source review, including after closing/reopening the app. The review page itself has no alternate editable essay field. Each completed regrading retains its own review inside the same question folder. New rewrites record their source version, including branches from an older draft. Old same-question records are grouped without inventing parent links. The narrow left outline jumps to conclusions, feedback, examiner comments, corrections, the improved essay and rewrite. The review header **Copy** copies the assessment; the existing improved-essay **Copy** still copies only that essay. -Drafts are kept separately for all eight task types, with debounced local saves and periodic timer saves. Expand a question folder in History to reopen complete reviews, including the original question, essay and imported source images. Search and exam filtering are available in History. +Drafts are kept separately for all eight task types, with debounced local saves and periodic timer saves. Expand a question folder in History to reopen complete reviews, including the original question, essay and imported source images. Use the folder’s **… → 编辑题目信息** menu to set an optional name, question year and custom label. Year and label filters appear when those fields are used, alongside search and exam filtering. Exam and task badges always remain visible, even after renaming a folder. ## Handwritten essays and OCR diff --git a/README.md b/README.md index 6a6ad5b..6457ce7 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ macOS 当前是本地 ad-hoc 签名版本,尚未经过 Apple Developer ID 公 3. 选择考试与题型。macOS 点击题目卡片的 **导入文字**,粘贴完整题目,或选择 `.txt` / `.md` 文件(UTF-8 / UTF-16),编辑确认后自动保存;也可以导入题目图片。 4. 点击 **开始答题**,在沉浸式界面完成作文,然后 **交卷**。 5. 交卷后评阅在后台继续,进度条显示实际完成的评审人数。可切换页面或最小化窗口;点击 **查看进度** 阅读 DeepSeek 实时评语,完成后自行打开结果。退出应用会中断未完成评阅。 -6. 阅读结论、给分、优点、不足和下一稿建议。评阅顶部 **Copy** 复制评审结果,改进作文旁的 **Copy** 单独复制作文。 -7. 在 History 展开题目目录,查看每一稿的时间与分数;点击 **开始重写** 继续修改,新稿会记录基于哪一稿。同题旧记录自动归组,全部原文与评分保留。 +6. 阅读结论、给分、优点、不足和下一稿建议。评阅左侧小目录可快速跳到结论、逐句修改和重写。顶部 **Copy** 复制评审结果,改进作文旁的 **Copy** 单独复制作文。 +7. 在 History 展开题目目录,查看每一稿的时间与分数;点击 **开始重写** 继续修改,新稿会记录基于哪一稿。同题旧记录自动归组,全部原文与评分保留。目录右侧 **… → 编辑题目信息** 可填写自定义名称、题目年份和标签,并按年份 / 标签筛选;考试及题型始终突出标注。 **没有 Key 就提示配置,不会给出假评分。** 演示评分代码只存在于测试目标;任何评审失败都会说明是哪一位,保留草稿,不自动改用另一个服务。 @@ -136,7 +136,7 @@ platforms/android/ Android Studio 项目与手机界面 scripts/ 构建、图标生成与显式联调脚本 ``` -macOS 自动化测试覆盖 47 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。 +macOS 自动化测试覆盖 49 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。
更多文档 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index bf1dff9..3b37670 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -3,6 +3,8 @@ - 交卷后立即回到准备页,三位评审在后台独立工作。顶部显示真实的完成进度(0/3 → 3/3)、等待时间、取消和结果入口,完成时不强制弹出评阅。 - DeepSeek 实时显示可读评语;Codex 完成后显示结构化反馈。三份完整结果全部校验通过后,才在本机汇总总分并保存。不会把评语片段或未完成的评分当作结论。 - 评阅增加结论、写得好的地方、不足和下一稿建议。顶部 Copy 单独复制评审结果,已有作文 Copy 保持原样。 +- 评阅左侧新增六项简短目录,点击跳到总分与结论、优点与不足、评审意见、逐句修改、改进作文或重写。 +- History 目录支持自定义名称、题目年份和标签,可按年份 / 标签筛选。考研 / 六级 / 雅思及各题型以独立标记突出显示,自定义名称不会遮住考试信息。 - History 改为题目目录:同题的多次提交收在一起,展开查看各稿时间、分数与完整评阅。从“重写”提交的新稿记录来源,支持从早期稿件分支修改;旧记录自动归组而不臆测修改关系。 - Settings 新增实时词数开关,所有题型默认关闭;交卷后显示提交词数,中文翻译显示字符数。 - 交卷内容和来源稿在提交时固定,后台完成或取消不会覆盖另一份正在写的草稿。缺少 Key 继续提示设置,失败说明具体评审,不降级、不切换服务、不生成假分数。 diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj index c0f8eff..a1b736e 100644 --- a/WriteBench.xcodeproj/project.pbxproj +++ b/WriteBench.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 143462C249149F8D7065D80D /* AppLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018639A9F1452A7617A5A0A2 /* AppLifecycle.swift */; }; 14D92D7829215C6748E48951 /* StreamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306B86B1159828782A062144 /* StreamingTests.swift */; }; 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */; }; + 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 */; }; 2B8AA947E573B4AD052F6F95 /* OCRService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F892E2F452C5999A8C08043 /* OCRService.swift */; }; @@ -40,14 +41,17 @@ 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF0EC53CAA982107C3AA320 /* SettingsView.swift */; }; 938D3916C068089FACA81D61 /* MistakesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4391A8E13E5C7A35D0A1467A /* MistakesView.swift */; }; 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 */; }; A840C857A17F4257AD96C9AF /* ReviewTextExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A81F1C48B2FAE8914576B /* ReviewTextExporter.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 */; }; CCB636BAB11DE1B7DE6C1CA2 /* ImmersiveWritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B600C2CBBB1B477ABB65C42 /* ImmersiveWritingView.swift */; }; + CF9CA154E89BD40306ABC7D2 /* EssayFolderMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9C06884EE52989CB4491F /* EssayFolderMetadata.swift */; }; D3F29CFA674B891722D5B739 /* cet6_writing.md in Resources */ = {isa = PBXBuildFile; fileRef = 650B4D62F506EDDC71D6396A /* cet6_writing.md */; }; D4A1396AF9FFF2FF6C40C24D /* kaoyan_english2_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = 56792E2C1501DBCE7B80739B /* kaoyan_english2_translation.md */; }; + DB3DEEA68D82D9F8BECF108E /* ReviewOutline.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B9132C06548AC9B8B44070 /* ReviewOutline.swift */; }; DB6452447E988C02C326B0BE /* WriteBenchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2322E689ACEC8741C4B1C432 /* WriteBenchApp.swift */; }; DB8D1A55F0E264D96E7E1D8D /* TextQuestionImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */; }; DCC333A3FAF19BC2ECC79BA0 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */; }; @@ -92,6 +96,7 @@ 37444757CFEC8F67705A2D6C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3A3647AF5325DFD1CE43543B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 405A85F2A07E72B5AD277D96 /* ielts_task1.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ielts_task1.md; sourceTree = ""; }; + 419DE9ABE9E26F14128B9B1E /* HistoryFolderEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryFolderEditor.swift; sourceTree = ""; }; 4391A8E13E5C7A35D0A1467A /* MistakesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MistakesView.swift; sourceTree = ""; }; 4BCC37FAB6E0DDC543B0D48B /* DeepSeekStreaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekStreaming.swift; sourceTree = ""; }; 51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowImmersionBridge.swift; sourceTree = ""; }; @@ -106,6 +111,7 @@ 781A9E69AB1A93F99198A60E /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.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 = ""; }; + 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 = ""; }; @@ -121,12 +127,14 @@ CD6C23EF9D33F68C7816C642 /* PlainTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainTextEditor.swift; sourceTree = ""; }; 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 = ""; }; DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingModels.swift; sourceTree = ""; }; DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundGradingView.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 = ""; }; F54C82E0AE1ACA592616CDDB /* OCRConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRConfirmationView.swift; sourceTree = ""; }; + F7B9132C06548AC9B8B44070 /* ReviewOutline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewOutline.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -142,6 +150,7 @@ isa = PBXGroup; children = ( DA8E73D997ED19A98227B24C /* BackgroundGradingView.swift */, + F7B9132C06548AC9B8B44070 /* ReviewOutline.swift */, 029A81F1C48B2FAE8914576B /* ReviewTextExporter.swift */, 0F9DB3748FBE72E213C6EB37 /* ReviewView.swift */, ); @@ -257,6 +266,7 @@ A25C75961D1DDB246F9560BE /* Persistence */ = { isa = PBXGroup; children = ( + 89F9C06884EE52989CB4491F /* EssayFolderMetadata.swift */, 0A8874F23C092B83269A6E56 /* EssaySession.swift */, ); path = Persistence; @@ -266,6 +276,7 @@ isa = PBXGroup; children = ( 11E52B4F1877F33DAEDBED32 /* EssayHistoryGroup.swift */, + 419DE9ABE9E26F14128B9B1E /* HistoryFolderEditor.swift */, 70A5D3F3824C11B7B262C308 /* HistoryView.swift */, ); path = History; @@ -337,6 +348,7 @@ isa = PBXGroup; children = ( 9AA993471F8CFF92BD9B2956 /* BrandArt.swift */, + D78510A02F0F827272F84695 /* ExamTaskBadge.swift */, CD6C23EF9D33F68C7816C642 /* PlainTextEditor.swift */, 51A76CC13C7CABACEC80CBAE /* WindowImmersionBridge.swift */, ); @@ -475,12 +487,15 @@ 6C1F7BEC5994E87DFA1CEF27 /* DeepSeekClient.swift in Sources */, F59451A7E3C70F80EF599140 /* DeepSeekCredentials.swift in Sources */, 448090E99953CC5C5F9C2F40 /* DeepSeekStreaming.swift in Sources */, + CF9CA154E89BD40306ABC7D2 /* EssayFolderMetadata.swift in Sources */, 7A7FFA1F4A7D393FDC0F3AA0 /* EssayHistoryGroup.swift in Sources */, E7928726F27B1B2EB5341AEB /* EssaySession.swift in Sources */, 27C8DB78E1B529A54B818ABD /* Exam.swift in Sources */, + 1E1C317A4096B9830C2A3786 /* ExamTaskBadge.swift in Sources */, C66E0F5339DADB4AE603BD5D /* GradingModels.swift in Sources */, C7DF6658F1F355665EEC175A /* GradingProvider.swift in Sources */, 611D1CBBEE2017C06DCD6D8D /* GradingService.swift in Sources */, + 9EA1454709EC8802D43C7CA4 /* HistoryFolderEditor.swift in Sources */, 76165A1D695B6E1E39EE5906 /* HistoryView.swift in Sources */, CCB636BAB11DE1B7DE6C1CA2 /* ImmersiveWritingView.swift in Sources */, DCC333A3FAF19BC2ECC79BA0 /* KeychainService.swift in Sources */, @@ -492,6 +507,7 @@ ECFAF47EFB1BFE1187B43C7D /* PracticeRail.swift in Sources */, 3FF816DF3394076A7EAFCA8E /* QuestionLibraryView.swift in Sources */, 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */, + DB3DEEA68D82D9F8BECF108E /* ReviewOutline.swift in Sources */, A840C857A17F4257AD96C9AF /* ReviewTextExporter.swift in Sources */, 55C7907B2F577F01BC57A08B /* ReviewView.swift in Sources */, 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */, diff --git a/WriteBench/App/WriteBenchApp.swift b/WriteBench/App/WriteBenchApp.swift index 861d180..45b1be2 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, + container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.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, configurations: ModelConfiguration(url: storeURL)) + container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, EssayFolderMetadata.self, configurations: ModelConfiguration(url: storeURL)) storageError = nil if UserDefaults.standard.string(forKey: "deepSeekModel") == "deepseek-v4-flash" { UserDefaults.standard.set(DeepSeekClient.defaultModel, forKey: "deepSeekModel") } } diff --git a/WriteBench/Components/ExamTaskBadge.swift b/WriteBench/Components/ExamTaskBadge.swift new file mode 100644 index 0000000..ca9b5f6 --- /dev/null +++ b/WriteBench/Components/ExamTaskBadge.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// Exam and task stay visible even when a question folder has a custom title. +struct ExamTaskBadge: View { + let task: WritingTask + private var subtype: String { + switch task { + case .kaoyanSmall: "英语一 · 小作文" + case .kaoyanLarge: "英语一 · 大作文" + case .cet6Writing: "写作" + case .ieltsTask1: "Task 1 · 小作文" + case .ieltsTask2: "Task 2 · 大作文" + default: task.title + } + } + var body: some View { + HStack(spacing: 8) { + Label(task.exam.title, systemImage: task.exam.symbol) + .font(.system(size: 12, weight: .semibold)).foregroundStyle(WB.ink) + Text(subtype).font(.system(size: 11, weight: .medium)).foregroundStyle(WB.blue) + .padding(.horizontal, 8).padding(.vertical, 5) + .background(WB.tint, in: RoundedRectangle(cornerRadius: 6)) + }.lineLimit(1).fixedSize(horizontal: true, vertical: false) + .accessibilityElement(children: .combine) + } +} diff --git a/WriteBench/Features/History/EssayHistoryGroup.swift b/WriteBench/Features/History/EssayHistoryGroup.swift index e32b258..9e83ce8 100644 --- a/WriteBench/Features/History/EssayHistoryGroup.swift +++ b/WriteBench/Features/History/EssayHistoryGroup.swift @@ -7,6 +7,11 @@ import CryptoKit let subtype: String let question: String let imageDigest: String? + var storageKey: String { + let parts = [subtype, question, imageDigest ?? ""] + let value = parts.map { "\($0.utf8.count):" + $0 }.joined() + return SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } } let id: Key let versions: [EssaySession] @@ -31,10 +36,12 @@ import CryptoKit $0.latest.date == $1.latest.date ? $0.latest.id.uuidString < $1.latest.id.uuidString : $0.latest.date > $1.latest.date } } - func matches(search: String, exam: Exam?) -> Bool { + func matches(search: String, exam: Exam?, year: Int? = nil, label: String? = nil, metadata: EssayFolderMetadata? = nil) -> Bool { guard exam == nil || latest.exam == exam?.rawValue else { return false } + guard year == nil || metadata?.questionYear == year, + label == nil || metadata?.label == label else { return false } let query = search.trimmingCharacters(in: .whitespacesAndNewlines) - return query.isEmpty || versions.contains { + return query.isEmpty || [metadata?.title ?? "", metadata?.label ?? ""].contains { $0.localizedCaseInsensitiveContains(query) } || versions.contains { [$0.question, $0.originalEssay, $0.finalRewrite].contains { $0.localizedCaseInsensitiveContains(query) } } } diff --git a/WriteBench/Features/History/HistoryFolderEditor.swift b/WriteBench/Features/History/HistoryFolderEditor.swift new file mode 100644 index 0000000..85ccf55 --- /dev/null +++ b/WriteBench/Features/History/HistoryFolderEditor.swift @@ -0,0 +1,57 @@ +import SwiftUI +import SwiftData + +struct HistoryFolderEditor: View { + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + let group: EssayHistoryGroup + let metadata: EssayFolderMetadata? + @State private var title: String + @State private var year: String + @State private var label: String + @State private var error: String? + init(group: EssayHistoryGroup, metadata: EssayFolderMetadata?) { + self.group = group; self.metadata = metadata + _title = State(initialValue: metadata?.title ?? "") + _year = State(initialValue: metadata?.questionYear.map(String.init) ?? "") + _label = State(initialValue: metadata?.label ?? "") + } + var body: some View { + VStack(alignment: .leading, spacing: 24) { + HStack { + Text("题目信息").font(.system(size: 21, weight: .semibold)) + Spacer() + IconButton(symbol: "xmark", help: "取消编辑") { dismiss() } + } + Text("\(group.latest.task.fullTitle) · \(group.versions.count) 稿") + .font(.system(size: 12)).foregroundStyle(WB.secondary) + VStack(alignment: .leading, spacing: 17) { + field("自定义名称", placeholder: "例如:邀请信练习", value: $title) + field("题目年份", placeholder: "例如:2024(可留空)", value: $year) + field("自定义标签", placeholder: "例如:真题、模拟题或自己的分类", value: $label) + } + Text("名称留空时使用题型名称。年份指题目年份,与交卷日期分开;本目录下的所有稿件共用这些信息。") + .font(.system(size: 11)).foregroundStyle(WB.secondary).lineSpacing(3) + if let error { Label(error, systemImage: "exclamationmark.circle").font(.system(size: 12)).foregroundStyle(WB.amber) } + HStack { + Spacer() + Button("取消") { dismiss() }.buttonStyle(QuietButtonStyle()).keyboardShortcut(.cancelAction) + Button("保存") { + do { + try EssayFolderDetails(title: title, year: year, label: label) + .save(key: group.id.storageKey, existing: metadata, in: context) + dismiss() + } catch { self.error = error.localizedDescription } + }.buttonStyle(PrimaryButtonStyle()).keyboardShortcut(.defaultAction) + } + }.padding(28).frame(width: 440).background(WB.canvas).foregroundStyle(WB.ink) + } + private func field(_ name: String, placeholder: String, value: Binding) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(name).font(.system(size: 12, weight: .medium)) + TextField(placeholder, text: value).textFieldStyle(.plain).font(.system(size: 13)) + .padding(11).background(.white, in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke(WB.line)).accessibilityLabel(name) + } + } +} diff --git a/WriteBench/Features/History/HistoryView.swift b/WriteBench/Features/History/HistoryView.swift index aa8332f..3db5a86 100644 --- a/WriteBench/Features/History/HistoryView.swift +++ b/WriteBench/Features/History/HistoryView.swift @@ -3,12 +3,21 @@ import SwiftData struct HistoryView: View { @Query(filter: #Predicate { !$0.isDemo }, sort: \EssaySession.date, order: .reverse) private var sessions: [EssaySession] + @Query private var folderMetadata: [EssayFolderMetadata] var onOpen: (EssaySession) -> Void @State private var search = "" @State private var exam: Exam? + @State private var year: Int? + @State private var label: String? + @State private var editingGroup: EssayHistoryGroup? + private var metadataByKey: [String: EssayFolderMetadata] { Dictionary(uniqueKeysWithValues: folderMetadata.map { ($0.key, $0) }) } + private var years: [Int] { Array(Set(folderMetadata.compactMap(\.questionYear))).sorted(by: >) } + private var labels: [String] { Array(Set(folderMetadata.map(\.label).filter { !$0.isEmpty })).sorted() } @State private var expanded: Set = [] private var groups: [EssayHistoryGroup] { - EssayHistoryGroup.make(from: sessions).filter { $0.matches(search: search, exam: exam) } + EssayHistoryGroup.make(from: sessions).filter { + $0.matches(search: search, exam: exam, year: year, label: label, metadata: metadataByKey[$0.id.storageKey]) + } } var body: some View { ScrollView { @@ -18,10 +27,22 @@ struct HistoryView: View { Image(systemName: "magnifyingglass").foregroundStyle(WB.secondary) TextField("Search questions and essays", text: $search).textFieldStyle(.plain) Spacer() + if !years.isEmpty { + Picker("年份", selection: $year) { + Text("全部年份").tag(Optional.none) + ForEach(years, id: \.self) { Text(String($0)).tag(Optional($0)) } + }.labelsHidden().frame(width: 110) + } + if !labels.isEmpty { + Picker("标签", selection: $label) { + Text("全部标签").tag(Optional.none) + ForEach(labels, id: \.self) { Text($0).tag(Optional($0)) } + }.labelsHidden().frame(width: 120) + } Picker("Exam", selection: $exam) { Text("All exams").tag(Optional.none) ForEach(Exam.allCases) { Text($0.title).tag(Optional($0)) } - }.frame(width: 190) + }.frame(width: 160) }.padding(14).background(.white, in: RoundedRectangle(cornerRadius: 12)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(WB.line)) if groups.isEmpty { @@ -36,6 +57,7 @@ struct HistoryView: View { Text("题目 / 修改记录").frame(maxWidth: .infinity, alignment: .leading) Text("最近得分").frame(width: 120) Text("置信度").frame(width: 90) + Color.clear.frame(width: 24, height: 1) }.font(.system(size: 11, weight: .semibold)).foregroundStyle(WB.secondary).padding(20) ForEach(groups) { group in folder(group) @@ -48,44 +70,63 @@ struct HistoryView: View { } }.padding(32) } + .sheet(item: $editingGroup) { group in + HistoryFolderEditor(group: group, metadata: metadataByKey[group.id.storageKey]) + } + .onChange(of: years) { _, available in if let year, !available.contains(year) { self.year = nil } } + .onChange(of: labels) { _, available in if let label, !available.contains(label) { self.label = nil } } } private func folder(_ group: EssayHistoryGroup) -> some View { let isExpanded = expanded.contains(group.id) - return Button { - withAnimation(.easeInOut(duration: 0.18)) { - if isExpanded { expanded.remove(group.id) } else { expanded.insert(group.id) } - } - } label: { - HStack(spacing: 14) { - Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 10, weight: .semibold)).foregroundStyle(WB.secondary).frame(width: 10) - Image(systemName: isExpanded ? "folder.fill" : "folder") - .font(.system(size: 23, weight: .light)).foregroundStyle(WB.blue).frame(width: 30) - VStack(alignment: .leading, spacing: 7) { - HStack(spacing: 10) { - Text(group.latest.task.fullTitle).font(.system(size: 14, weight: .medium)) - Text("\(group.versions.count) 稿").font(.system(size: 11)).foregroundStyle(WB.secondary) - } - Text(group.questionTitle.isEmpty ? "图片题目" : group.questionTitle) - .font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(1) - Text("最近提交 · \(group.latest.date.formatted(date: .abbreviated, time: .shortened))") - .font(.system(size: 10)).foregroundStyle(WB.secondary) - }.frame(maxWidth: .infinity, alignment: .leading) - VStack(spacing: 5) { - Text("\(group.latest.finalScore.scoreText) / \(Int(group.latest.task.maxScore))") - .font(.system(size: 15, weight: .semibold)).foregroundStyle(WB.blue) - if group.versions.count > 1 { - Text("\(group.first.finalScore.scoreText) → \(group.latest.finalScore.scoreText)") - .font(.system(size: 11)).foregroundStyle(WB.secondary) - } - }.frame(width: 120) - confidence(group.latest).frame(width: 90) - }.padding(20).contentShape(Rectangle()) - .background(isExpanded ? WB.tint.opacity(0.35) : .white) - .overlay(alignment: .top) { WB.line.opacity(0.55).frame(height: 1) } - }.buttonStyle(.plain) - .accessibilityLabel("\(group.latest.task.fullTitle),\(group.versions.count) 稿,\(isExpanded ? "收起" : "展开")修改记录") - .help(isExpanded ? "收起修改记录" : "展开全部版本") + let metadata = metadataByKey[group.id.storageKey] + let customTitle = metadata?.title ?? "" + return HStack(spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + if isExpanded { expanded.remove(group.id) } else { expanded.insert(group.id) } + } + } label: { + HStack(spacing: 14) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)).foregroundStyle(WB.secondary).frame(width: 10) + Image(systemName: isExpanded ? "folder.fill" : "folder") + .font(.system(size: 23, weight: .light)).foregroundStyle(WB.blue).frame(width: 30) + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 10) { + ExamTaskBadge(task: group.latest.task) + Text("\(group.versions.count) 稿").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + if !customTitle.isEmpty { + Text(customTitle).font(.system(size: 14, weight: .medium)).lineLimit(1) + } + Text(group.questionTitle.isEmpty ? "图片题目" : group.questionTitle) + .font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(1) + HStack(spacing: 8) { + if let year = metadata?.questionYear { Text(String(year)).foregroundStyle(WB.blue) } + if let label = metadata?.label, !label.isEmpty { Text(label).lineLimit(1) } + Text("最近提交 · \(group.latest.date.formatted(date: .abbreviated, time: .shortened))").lineLimit(1) + }.font(.system(size: 10)).foregroundStyle(WB.secondary) + }.frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 5) { + Text("\(group.latest.finalScore.scoreText) / \(Int(group.latest.task.maxScore))") + .font(.system(size: 15, weight: .semibold)).foregroundStyle(WB.blue) + if group.versions.count > 1 { + Text("\(group.first.finalScore.scoreText) → \(group.latest.finalScore.scoreText)") + .font(.system(size: 11)).foregroundStyle(WB.secondary) + } + }.frame(width: 120) + confidence(group.latest).frame(width: 90) + }.padding(.vertical, 20).padding(.leading, 20).padding(.trailing, 4).contentShape(Rectangle()) + }.buttonStyle(.plain) + .accessibilityLabel("\(group.latest.task.fullTitle),\(group.versions.count) 稿,\(isExpanded ? "收起" : "展开")修改记录") + .help(isExpanded ? "收起修改记录" : "展开全部版本") + Menu { + Button("编辑题目信息…") { editingGroup = group } + } label: { Image(systemName: "ellipsis").foregroundStyle(WB.secondary) } + .menuStyle(.borderlessButton).menuIndicator(.hidden).frame(width: 24).padding(.trailing, 16) + .help("自定义名称、年份和标签").accessibilityLabel("编辑题目信息") + }.background(isExpanded ? WB.tint.opacity(0.35) : .white) + .overlay(alignment: .top) { WB.line.opacity(0.55).frame(height: 1) } } private func versions(_ group: EssayHistoryGroup) -> some View { VStack(spacing: 0) { @@ -110,7 +151,7 @@ struct HistoryView: View { .font(.system(size: 13, weight: .medium)).foregroundStyle(WB.blue).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, 20) + }.padding(.vertical, 15).padding(.leading, 64).padding(.trailing, 60) .contentShape(Rectangle()).overlay(alignment: .top) { WB.line.opacity(0.4).frame(height: 1).padding(.leading, 64) } }.buttonStyle(.plain).help("打开第 \(index + 1) 稿的完整评阅") } diff --git a/WriteBench/Features/Review/ReviewOutline.swift b/WriteBench/Features/Review/ReviewOutline.swift new file mode 100644 index 0000000..ae826f7 --- /dev/null +++ b/WriteBench/Features/Review/ReviewOutline.swift @@ -0,0 +1,61 @@ +import SwiftUI + +enum ReviewSection: String, CaseIterable, Identifiable { + case overview, feedback, examiners, corrections, improved, rewrite + var id: String { rawValue } + func title(isTranslation: Bool) -> String { + switch self { + case .overview: "总分与结论" + case .feedback: "优点与不足" + case .examiners: "评审意见" + case .corrections: "逐句修改" + case .improved: isTranslation ? "参考改译" : "改进作文" + case .rewrite: "重写" + } + } +} + +struct ReviewOutline: View { + let selection: ReviewSection + let isTranslation: Bool + 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 + Button { onSelect(section) } label: { + Text(section.title(isTranslation: isTranslation)) + .font(.system(size: 12, weight: selection == section ? .medium : .regular)) + .foregroundStyle(selection == section ? WB.blue : WB.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 10) + .background(selection == section ? WB.tint : .clear, in: RoundedRectangle(cornerRadius: 9)) + .contentShape(RoundedRectangle(cornerRadius: 9)) + }.buttonStyle(NavigationButtonStyle()) + .accessibilityIdentifier("reviewJump_\(section.rawValue)") + .accessibilityAddTraits(selection == section ? .isSelected : []) + } + Spacer(minLength: 0) + }.padding(.horizontal, 12).padding(.top, 30).frame(width: 144) + .background(.white.opacity(0.6)) + .overlay(alignment: .trailing) { WB.line.opacity(0.55).frame(width: 1) } + } +} + +struct ReviewSectionPositions: PreferenceKey { + static let defaultValue: [ReviewSection: CGFloat] = [:] + static func reduce(value: inout [ReviewSection: CGFloat], nextValue: () -> [ReviewSection: CGFloat]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} +extension View { + func reviewAnchor(_ section: ReviewSection) -> some View { + id(section).background { + GeometryReader { geometry in + Color.clear.preference(key: ReviewSectionPositions.self, + value: [section: geometry.frame(in: .named("reviewReadingArea")).minY]) + } + } + } +} diff --git a/WriteBench/Features/Review/ReviewView.swift b/WriteBench/Features/Review/ReviewView.swift index ddc5c80..8cbb1be 100644 --- a/WriteBench/Features/Review/ReviewView.swift +++ b/WriteBench/Features/Review/ReviewView.swift @@ -3,8 +3,12 @@ import SwiftData struct ReviewView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Bindable var session: EssaySession var onRewrite: (EssaySession) -> Void + @State private var readingSection: ReviewSection = .overview + @State private var navigationTarget: ReviewSection? + @State private var atEnd = false @State private var didCopyReview = false @State private var copyFeedbackTask: Task? var body: some View { @@ -12,7 +16,7 @@ struct ReviewView: View { HStack { Label(session.task.isTranslation ? "Translation review" : "Writing review", systemImage: "checkmark.seal").font(.system(size: 16, weight: .semibold)).labelStyle(BlueIconLabelStyle()) Spacer() - Text(session.task.fullTitle).foregroundStyle(WB.secondary) + ExamTaskBadge(task: session.task) Button { guard let text = ReviewTextExporter.text(for: session) else { return } NSPasteboard.general.clearContents() @@ -27,107 +31,137 @@ struct ReviewView: View { .help("复制评分、评语和修改建议").accessibilityLabel("复制评审结果") IconButton(symbol: "xmark", help: "Close review") { dismiss() } }.padding(22).background(.white) - ScrollView { - VStack(alignment: .leading, spacing: 22) { - if let report = session.report { - if report.isDemo { - 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) - Card { - VStack(alignment: .leading, spacing: 16) { - Text("评阅结论").font(.system(size: 18, weight: .semibold)) - Text(report.conclusion).font(.system(size: 15)).lineSpacing(6).textSelection(.enabled) - Text("采用中位分评审的结论;下方保留三位评审的独立意见。").font(.system(size: 11)).foregroundStyle(WB.secondary) + ScrollViewReader { proxy in + HStack(spacing: 0) { + if session.report != nil { + ReviewOutline(selection: readingSection, isTranslation: session.task.isTranslation) { section in + navigationTarget = section + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.22)) { + readingSection = section + proxy.scrollTo(section, anchor: .top) } } - Card { - VStack(alignment: .leading, spacing: 22) { - feedback("写得好的地方", symbol: "checkmark.circle", color: WB.green, items: report.strengths, empty: "这份评阅未单列优点,可结合评审意见查看。") - feedback("不足的地方", symbol: "exclamationmark.circle", color: WB.amber, items: report.weaknesses, empty: "评审未单列主要不足,请结合评分维度查看。") - feedback("下一稿怎么改", symbol: "pencil.line", color: WB.blue, items: report.improvements, empty: "请参考下方逐句修改与改进版本。") - } + } + ScrollView { reportContent } + .coordinateSpace(name: "reviewReadingArea") + .onPreferenceChange(ReviewSectionPositions.self) { positions in + guard !atEnd, navigationTarget == nil else { return } + readingSection = ReviewSection.allCases.last { (positions[$0] ?? .infinity) <= 32 } ?? .overview } - HStack(spacing: 14) { - ForEach(report.reviewers) { reviewer in - Card(padding: 18) { - VStack(alignment: .leading, spacing: 8) { - HStack { Text(reviewer.judge.title).font(.system(size: 13, weight: .semibold)); Spacer(); Image(systemName: "checkmark.circle.fill").foregroundStyle(WB.green) } - Text(reviewer.response.score.scoreText).font(.system(size: 29, weight: .semibold, design: .rounded)) - 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) - } - } - } + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentSize.height > geometry.containerSize.height && + geometry.contentOffset.y + geometry.containerSize.height >= geometry.contentSize.height - 2 + } action: { _, value in + atEnd = value + if value, navigationTarget == nil { readingSection = .rewrite } } - Card { - VStack(alignment: .leading, spacing: 20) { - HStack { Text("At a glance").font(.system(size: 17, weight: .semibold)); Spacer(); Text("Diagnostic scale · / 10").font(.system(size: 11)).foregroundStyle(WB.secondary) } - dimension(session.task.isTranslation ? "Meaning & completeness" : "Task Completion", value: report.dimension(\.taskCompletion)) - dimension("Language", value: report.dimension(\.language)) - dimension("Coherence", value: report.dimension(\.coherence)) - dimension("Register", value: report.dimension(\.register)) - if session.task.exam == .ielts { Text("These are practice diagnostics. The examiner’s overall band also considers lexical resource and grammatical range; this is a single-task estimate.").font(.system(size: 11)).foregroundStyle(WB.secondary) } - } + .onScrollPhaseChange { _, phase in + if phase == .interacting || phase == .tracking { navigationTarget = nil } } - Card { - VStack(alignment: .leading, spacing: 18) { - Text("Examiner comments").font(.system(size: 18, weight: .semibold)) - ForEach(report.reviewers) { reviewer in - VStack(alignment: .leading, spacing: 8) { - Text("\(reviewer.judge.title) · \(reviewer.judge.role)").font(.system(size: 12, weight: .semibold)).foregroundStyle(WB.blue) - Text(reviewer.response.summary).font(.system(size: 14)).lineSpacing(5).textSelection(.enabled) - ForEach(reviewer.response.majorErrors, id: \.self) { Text("• " + $0).font(.system(size: 13)).foregroundStyle(WB.amber) } - ForEach(reviewer.response.minorErrors, id: \.self) { Text("• " + $0).font(.system(size: 12)).foregroundStyle(WB.secondary) } - } - } - } - } - 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 { Text(report.isDemo ? "演示只包含少量本地示例规则。真实逐句修改请使用 DeepSeek 评分。" : "评审未标注逐句修改。请结合上方评语检查任务完成情况。").font(.system(size: 14)).foregroundStyle(WB.secondary) } - ForEach(report.corrections) { correction in CorrectionRow(correction: correction) } - } - } - Card { - VStack(alignment: .leading, spacing: 16) { - 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) + } + } + }.frame(minWidth: 940, idealWidth: 1080, minHeight: 620, idealHeight: 840).background(WB.canvas).foregroundStyle(WB.ink) + .onDisappear { copyFeedbackTask?.cancel() } + } + private var reportContent: some View { + VStack(alignment: .leading, spacing: 22) { + if let report = session.report { + if report.isDemo { + 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) + Card { + VStack(alignment: .leading, spacing: 16) { + Text("评阅结论").font(.system(size: 18, weight: .semibold)) + Text(report.conclusion).font(.system(size: 15)).lineSpacing(6).textSelection(.enabled) + Text("采用中位分评审的结论;下方保留三位评审的独立意见。").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + } + Card { + VStack(alignment: .leading, spacing: 22) { + feedback("写得好的地方", symbol: "checkmark.circle", color: WB.green, items: report.strengths, empty: "这份评阅未单列优点,可结合评审意见查看。") + feedback("不足的地方", symbol: "exclamationmark.circle", color: WB.amber, items: report.weaknesses, empty: "评审未单列主要不足,请结合评分维度查看。") + feedback("下一稿怎么改", symbol: "pencil.line", color: WB.blue, items: report.improvements, empty: "请参考下方逐句修改与改进版本。") + } + }.reviewAnchor(.feedback) + HStack(spacing: 14) { + ForEach(report.reviewers) { reviewer in + Card(padding: 18) { + VStack(alignment: .leading, spacing: 8) { + HStack { Text(reviewer.judge.title).font(.system(size: 13, weight: .semibold)); Spacer(); Image(systemName: "checkmark.circle.fill").foregroundStyle(WB.green) } + Text(reviewer.response.score.scoreText).font(.system(size: 29, weight: .semibold, design: .rounded)) + 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) } } - Card { - DisclosureGroup("Original question & essay") { - VStack(alignment: .leading, spacing: 20) { - Text(session.question).foregroundStyle(WB.secondary) - if let data = session.questionImage, let image = NSImage(data: data) { Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 260) } - Text(session.originalEssay) - if let data = session.sourceImages, let pages = try? JSONDecoder().decode([Data].self, from: data) { - ForEach(pages.indices, id: \.self) { index in - if let image = NSImage(data: pages[index]) { Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 350).accessibilityLabel("Handwritten page \(index + 1)") } - } - } - }.font(.system(size: 14)).lineSpacing(5).textSelection(.enabled).padding(.top, 16) + } + } + Card { + VStack(alignment: .leading, spacing: 20) { + HStack { Text("At a glance").font(.system(size: 17, weight: .semibold)); Spacer(); Text("Diagnostic scale · / 10").font(.system(size: 11)).foregroundStyle(WB.secondary) } + dimension(session.task.isTranslation ? "Meaning & completeness" : "Task Completion", value: report.dimension(\.taskCompletion)) + dimension("Language", value: report.dimension(\.language)) + dimension("Coherence", value: report.dimension(\.coherence)) + dimension("Register", value: report.dimension(\.register)) + if session.task.exam == .ielts { Text("These are practice diagnostics. The examiner’s overall band also considers lexical resource and grammatical range; this is a single-task estimate.").font(.system(size: 11)).foregroundStyle(WB.secondary) } + } + } + Card { + VStack(alignment: .leading, spacing: 18) { + Text("Examiner comments").font(.system(size: 18, weight: .semibold)) + ForEach(report.reviewers) { reviewer in + VStack(alignment: .leading, spacing: 8) { + Text("\(reviewer.judge.title) · \(reviewer.judge.role)").font(.system(size: 12, weight: .semibold)).foregroundStyle(WB.blue) + Text(reviewer.response.summary).font(.system(size: 14)).lineSpacing(5).textSelection(.enabled) + ForEach(reviewer.response.majorErrors, id: \.self) { Text("• " + $0).font(.system(size: 13)).foregroundStyle(WB.amber) } + ForEach(reviewer.response.minorErrors, id: \.self) { Text("• " + $0).font(.system(size: 12)).foregroundStyle(WB.secondary) } } } - Card { - VStack(alignment: .leading, spacing: 16) { - Text("Rewrite").font(.system(size: 18, weight: .semibold)) - Text("把反馈写进下一稿。重写将在沉浸式答题页进行,草稿自动保存。").font(.system(size: 13)).foregroundStyle(WB.secondary) - if !session.finalRewrite.isEmpty { Text(session.finalRewrite).font(.system(size: 14)).lineSpacing(5).textSelection(.enabled) } - HStack { Spacer(); Button(session.finalRewrite.isEmpty ? "开始重写" : "继续重写") { onRewrite(session) }.buttonStyle(PrimaryButtonStyle()).accessibilityIdentifier("startRewrite") } + } + }.reviewAnchor(.examiners) + 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 { Text(report.isDemo ? "演示只包含少量本地示例规则。真实逐句修改请使用 DeepSeek 评分。" : "评审未标注逐句修改。请结合上方评语检查任务完成情况。").font(.system(size: 14)).foregroundStyle(WB.secondary) } + ForEach(report.corrections) { correction in CorrectionRow(correction: correction) } + } + }.reviewAnchor(.corrections) + Card { + VStack(alignment: .leading, spacing: 16) { + 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) + } + }.reviewAnchor(.improved) + Card { + DisclosureGroup("Original question & essay") { + VStack(alignment: .leading, spacing: 20) { + Text(session.question).foregroundStyle(WB.secondary) + if let data = session.questionImage, let image = NSImage(data: data) { Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 260) } + Text(session.originalEssay) + if let data = session.sourceImages, let pages = try? JSONDecoder().decode([Data].self, from: data) { + ForEach(pages.indices, id: \.self) { index in + if let image = NSImage(data: pages[index]) { Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 350).accessibilityLabel("Handwritten page \(index + 1)") } + } } - } - 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) - } 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) } + }.font(.system(size: 14)).lineSpacing(5).textSelection(.enabled).padding(.top, 16) + } + } + Card { + VStack(alignment: .leading, spacing: 16) { + Text("Rewrite").font(.system(size: 18, weight: .semibold)) + Text("把反馈写进下一稿。重写将在沉浸式答题页进行,草稿自动保存。").font(.system(size: 13)).foregroundStyle(WB.secondary) + if !session.finalRewrite.isEmpty { Text(session.finalRewrite).font(.system(size: 14)).lineSpacing(5).textSelection(.enabled) } + HStack { Spacer(); Button(session.finalRewrite.isEmpty ? "开始重写" : "继续重写") { onRewrite(session) }.buttonStyle(PrimaryButtonStyle()).accessibilityIdentifier("startRewrite") } } - }.padding(28) + }.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) + } 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) } } - }.frame(minWidth: 820, idealWidth: 960, minHeight: 620, idealHeight: 840).background(WB.canvas).foregroundStyle(WB.ink) + }.padding(28) } private func scoreCard(_ report: GradingReport) -> some View { Card(padding: 28) { diff --git a/WriteBench/Persistence/EssayFolderMetadata.swift b/WriteBench/Persistence/EssayFolderMetadata.swift new file mode 100644 index 0000000..62ea158 --- /dev/null +++ b/WriteBench/Persistence/EssayFolderMetadata.swift @@ -0,0 +1,52 @@ +import Foundation +import SwiftData + +/// Question-level organization, shared by every past and future attempt in the folder. +@Model final class EssayFolderMetadata { + @Attribute(.unique) var key: String + var title: String + var questionYear: Int? + var label: String + var updatedAt: Date + init(key: String, title: String = "", questionYear: Int? = nil, label: String = "") { + self.key = key; self.title = title; self.questionYear = questionYear; self.label = label; updatedAt = Date() + } +} + +struct EssayFolderDetails { + var title: String + var year: Int? + var label: String + init(title: String, year: String, label: String) throws { + self.title = title.trimmingCharacters(in: .whitespacesAndNewlines) + self.label = label.trimmingCharacters(in: .whitespacesAndNewlines) + let yearText = year.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.title.count <= 100, self.label.count <= 32 else { throw DetailsError.tooLong } + if yearText.isEmpty { self.year = nil } + else { + guard yearText.count == 4, yearText.allSatisfy(\.isASCII), let value = Int(yearText), (1000...2999).contains(value) else { throw DetailsError.year } + self.year = value + } + } + private enum DetailsError: LocalizedError { + case year, tooLong + var errorDescription: String? { + switch self { + case .year: "请填写四位年份,例如 2024;不需要时可留空。" + case .tooLong: "名称请控制在 100 字以内,标签在 32 字以内。" + } + } + } + @MainActor func save(key: String, existing: EssayFolderMetadata?, in context: ModelContext) throws { + let item = existing ?? EssayFolderMetadata(key: key) + let previous = (item.title, item.questionYear, item.label, item.updatedAt) + if existing == nil { context.insert(item) } + item.title = title; item.questionYear = year; item.label = label; item.updatedAt = Date() + do { try context.save() } + catch { + if existing == nil { context.delete(item) } + else { (item.title, item.questionYear, item.label, item.updatedAt) = previous } + throw error + } + } +} diff --git a/WriteBenchTests/HistoryReviewTests.swift b/WriteBenchTests/HistoryReviewTests.swift index 31e29fa..221616b 100644 --- a/WriteBenchTests/HistoryReviewTests.swift +++ b/WriteBenchTests/HistoryReviewTests.swift @@ -96,3 +96,37 @@ import Testing session.reviewerResults = Data("invalid".utf8) #expect(ReviewTextExporter.text(for: session) == nil) } + +@Test func folderDetailsAcceptCustomLabelsAndOptionalQuestionYears() throws { + let details = try EssayFolderDetails(title: " Invitation practice ", year: "2024", label: "真题") + #expect(details.title == "Invitation practice" && details.year == 2024 && details.label == "真题") + #expect(try EssayFolderDetails(title: "", year: " \n", label: "").year == nil) + for year in ["24", "2024年", "2024.0", "2024", "3000", "-100"] { + #expect(throws: (any Error).self) { try EssayFolderDetails(title: "", year: year, label: "") } + } + #expect(throws: (any Error).self) { try EssayFolderDetails(title: String(repeating: "a", count: 101), year: "", label: "") } +} + +@Test @MainActor func folderMetadataPersistsAndAppliesToFutureVersionsWithoutChangingEssays() throws { + let container = try ModelContainer(for: EssaySession.self, EssayFolderMetadata.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let context = ModelContext(container), first = try historySession(score: 4.5) + context.insert(first); try context.save() + let oldGroup = try #require(EssayHistoryGroup.make(from: [first]).first) + try EssayFolderDetails(title: "Invitation practice", year: "2024", label: "真题").save(key: oldGroup.id.storageKey, existing: nil, in: context) + let revision = try historySession(score: 7.5, date: 200, parent: first.id) + context.insert(revision); try context.save() + let reloadedContext = ModelContext(container) + let items = try reloadedContext.fetch(FetchDescriptor()) + let metadata = try #require(items.first) + #expect(items.count == 1 && metadata.questionYear == 2024) + let group = try #require(EssayHistoryGroup.make(from: reloadedContext.fetch(FetchDescriptor())).first) + #expect(group.id.storageKey == oldGroup.id.storageKey && group.versions.count == 2) + #expect(group.matches(search: "invitation practice", exam: .kaoyan, year: 2024, label: "真题", metadata: metadata)) + #expect(!group.matches(search: "", exam: nil, year: 2023, metadata: metadata)) + #expect(!group.matches(search: "", exam: nil, label: "模拟题", metadata: metadata)) + try EssayFolderDetails(title: "", year: "", label: "自选").save(key: group.id.storageKey, existing: metadata, in: reloadedContext) + #expect(try reloadedContext.fetchCount(FetchDescriptor()) == 1) + #expect(metadata.title.isEmpty && metadata.questionYear == nil && metadata.label == "自选") + #expect(first.question == revision.question && first.originalEssay == "SEPARATE ORIGINAL ESSAY BODY") + #expect(first.finalScore == 4.5 && revision.finalScore == 7.5) +}