From a8fdf8d2d8452e54f4b5ae19a565abf4d44c38b2 Mon Sep 17 00:00:00 2001 From: Functionhx <172989722+Functionhx@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:51:09 -0700 Subject: [PATCH] =?UTF-8?q?macOS=201.7=EF=BC=9A=E8=8B=B1=E4=B8=80=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E9=80=90=E5=8F=A5=E5=BE=97=E5=88=86=E4=B8=8E=E8=B8=A9?= =?UTF-8?q?=E7=82=B9=E7=BB=99=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 英一翻译评分标准按大纲规则与采分点做法重写:每句 2 分、3–4 个意群逐点给分 - 评审返回逐句得分与采分点,总分与各句之和一致;评阅页新增逐句得分卡片 - 与上一稿对比显示逐句分差 Co-Authored-By: Claude Opus 5.5 --- Documentation/Validation.md | 5 ++ README.md | 2 +- RELEASE-NOTES.md | 6 +++ WriteBench.xcodeproj/project.pbxproj | 8 +-- WriteBench/Components/QuestionText.swift | 10 ++++ .../Features/Review/ReviewOutline.swift | 3 +- .../Features/Review/ReviewTextExporter.swift | 6 +++ WriteBench/Features/Review/ReviewView.swift | 49 ++++++++++++++++++- .../Features/Review/RevisionViews.swift | 9 ++++ WriteBench/Models/GradingModels.swift | 38 +++++++++++++- WriteBench/Rubrics/cet6_translation.md | 2 +- WriteBench/Rubrics/cet6_writing.md | 2 +- WriteBench/Rubrics/ielts_task1.md | 2 +- WriteBench/Rubrics/ielts_task2.md | 2 +- WriteBench/Rubrics/kaoyan_english1_large.md | 2 +- WriteBench/Rubrics/kaoyan_english1_small.md | 2 +- .../Rubrics/kaoyan_english1_translation.md | 15 ++++-- WriteBench/Rubrics/kaoyan_english2_large.md | 2 +- WriteBench/Rubrics/kaoyan_english2_small.md | 2 +- .../Rubrics/kaoyan_english2_translation.md | 2 +- .../Services/Codex/CodexJudgeService.swift | 8 ++- .../Services/DeepSeek/DeepSeekClient.swift | 5 +- .../Services/Grading/GradingService.swift | 12 ++++- WriteBenchTests/PracticeLoopTests.swift | 45 +++++++++++++++++ project.yml | 4 +- 25 files changed, 214 insertions(+), 29 deletions(-) diff --git a/Documentation/Validation.md b/Documentation/Validation.md index 3e9fa6a..1995ec1 100644 --- a/Documentation/Validation.md +++ b/Documentation/Validation.md @@ -1,3 +1,8 @@ +# 1.7.0 validation · 2026-10-02 + +- 69 Swift tests pass. New coverage: English I translation totals reconcile to the sum of segment marks (allowing a single 0.5 typo deduction), out-of-range segments are dropped, segments are cleared for other tasks; per-sentence marks come from the median reviewer with every judge's mark; legacy reports without segments decode; prompts require segments only for English I translation and the Codex schema includes them; numbered underlined segments are extracted from the question. +- The review page was rendered offscreen with a synthetic three-judge English I translation report to check the per-sentence card. The rubric follows the published syllabus rules (0.5 cap for distorted meaning, multiple renderings, cumulative typo deduction) and the 采分点 practice of 3–4 meaning groups per sentence. No paid live grading was run. + # 1.6.2 validation · 2026-10-02 - 66 Swift tests pass. New coverage: the custom caret replaces the system indicator, stays within one text line on Latin and blank lines, moves with the selection and hides for a range selection; answer-sheet numbering for the six kaoyan tasks. diff --git a/README.md b/README.md index 9ce0191..0795515 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ platforms/android/ Android Studio 项目与手机界面 scripts/ 构建、图标生成与显式联调脚本 ``` -macOS 自动化测试覆盖 66 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。 +macOS 自动化测试覆盖 69 个案例,Android 有 5 个领域测试与 2 个实际设备服务测试;Windows 通过评分、持久化、字段校验及实际 OCR 自检。GPT-6 Astra/MAX 已通过实际 Swift 子进程完成样例评卷;DeepSeek 已验证官方模型接口连接。后台、流式输出及版本路径已用隔离测试验证;本次未执行完整付费三评,需用户填入有效 Key 后使用。
更多文档 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 4014ae8..edc0fa8 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,9 @@ +# 1.7.0 · 英一翻译逐句得分与踩点给分 + +- 英一翻译按大纲规则逐句评分:每句 2 分,拆成 3–4 个意群作为采分点,逐点给分;译文与原意明显不符的句子最多 0.5 分;同一处给出多种译法、其中有错的按错误给分;错别字整题累计满 3 个扣 0.5 分。准确(信)第一、通顺(达)第二,不要求“雅”。 +- 评阅页新增“逐句得分”:(46)–(50) 每句的得分、三位评审各自的给分、英文原句、评语,以及每个采分点得到或丢掉了多少分。总分等于各句之和(扣除错别字后)。与上一稿对比也显示每一句的分差。 +- 英二翻译仍按整篇评分(官方为整篇分档)。评审提示词 1.4.0,评分标准 2026.10-v1。 + # 1.6.2 · 考研答题卡式答题页 - 考研六个题型的答题页仿照答题卡 2:左上角标明部分与节(如“第四部分:写作 A 节”),右上角是“英语(一)/(二)答题卡 2”。答题区是黑色矩形边框,配深色细横线,题号印在首行左侧(英一翻译 46–50,英一写作 51 / 52,英二翻译 46,英二写作 47 / 48),四角有定位方块,下方注明“超出黑色矩形边框限定区域的答案无效”。六级、雅思保持原样。 diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj index 5aaa51d..77775dc 100644 --- a/WriteBench.xcodeproj/project.pbxproj +++ b/WriteBench.xcodeproj/project.pbxproj @@ -659,7 +659,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; @@ -674,7 +674,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.6.2; + MARKETING_VERSION = 1.7.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -755,7 +755,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_HARDENED_RUNTIME = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -776,7 +776,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.6.2; + MARKETING_VERSION = 1.7.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/WriteBench/Components/QuestionText.swift b/WriteBench/Components/QuestionText.swift index 7dc13a3..4ea7a88 100644 --- a/WriteBench/Components/QuestionText.swift +++ b/WriteBench/Components/QuestionText.swift @@ -9,6 +9,16 @@ enum QuestionText { text.replacingOccurrences(of: open, with: "").replacingOccurrences(of: close, with: "") } + /// Numbered underlined segments, e.g. "(46) …" → ["46": "…"]. + static func underlinedSegments(_ text: String) -> [String: String] { + guard let regex = try? NSRegularExpression(pattern: #"\((\d+)\)\s*(.*?)"#, options: [.dotMatchesLineSeparators]) else { return [:] } + var result: [String: String] = [:] + for match in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) { + if let number = Range(match.range(at: 1), in: text), let body = Range(match.range(at: 2), in: text) { result[String(text[number])] = String(text[body]) } + } + return result + } + static func attributed(_ text: String) -> AttributedString { var result = AttributedString() var rest = Substring(text) diff --git a/WriteBench/Features/Review/ReviewOutline.swift b/WriteBench/Features/Review/ReviewOutline.swift index 7b313c3..609564d 100644 --- a/WriteBench/Features/Review/ReviewOutline.swift +++ b/WriteBench/Features/Review/ReviewOutline.swift @@ -1,12 +1,13 @@ import SwiftUI enum ReviewSection: String, CaseIterable, Identifiable { - case overview, progress, feedback, examiners, corrections, improved, rewrite + case overview, progress, segments, feedback, examiners, corrections, improved, rewrite var id: String { rawValue } func title(isTranslation: Bool) -> String { switch self { case .overview: "总分与结论" case .progress: "与上一稿对比" + case .segments: "逐句得分" case .feedback: "优点与不足" case .examiners: "评审意见" case .corrections: "逐句修改" diff --git a/WriteBench/Features/Review/ReviewTextExporter.swift b/WriteBench/Features/Review/ReviewTextExporter.swift index 81d041c..f84f329 100644 --- a/WriteBench/Features/Review/ReviewTextExporter.swift +++ b/WriteBench/Features/Review/ReviewTextExporter.swift @@ -13,6 +13,12 @@ import Foundation 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)" ] + if !report.segmentScores.isEmpty { + sections.append("逐句得分(中位分评审)\n" + report.segmentScores.map { item in + (["(\(item.segment.number)) \(item.segment.score.scoreText) / \(item.segment.maxScore.scoreText) \(item.segment.comment)"] + + item.segment.points.map { " · \($0.source):\($0.earned.scoreText) / \($0.max.scoreText)\($0.note.isEmpty ? "" : "," + $0.note)" }).joined(separator: "\n") + }.joined(separator: "\n")) + } 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] diff --git a/WriteBench/Features/Review/ReviewView.swift b/WriteBench/Features/Review/ReviewView.swift index d297fec..97b23b3 100644 --- a/WriteBench/Features/Review/ReviewView.swift +++ b/WriteBench/Features/Review/ReviewView.swift @@ -70,7 +70,10 @@ struct ReviewView: View { baseline = RevisionBaseline.find(for: session, among: candidates) } } - private var sections: [ReviewSection] { ReviewSection.allCases.filter { $0 != .progress || baseline != nil } } + private var sections: [ReviewSection] { + let hasSegments = !(session.report?.segmentScores.isEmpty ?? true) + return ReviewSection.allCases.filter { ($0 != .progress || baseline != nil) && ($0 != .segments || hasSegments) } + } private var reportContent: some View { VStack(alignment: .leading, spacing: 22) { if let report = session.report { @@ -79,6 +82,7 @@ struct ReviewView: View { } scoreCard(report).reviewAnchor(.overview) if let baseline { RevisionComparisonCard(session: session, baseline: baseline.session, baselineNumber: baseline.label).reviewAnchor(.progress) } + if !report.segmentScores.isEmpty { segmentCard(report).reviewAnchor(.segments) } Card { VStack(alignment: .leading, spacing: 16) { Text("评阅结论").font(.system(size: 18, weight: .semibold)) @@ -192,7 +196,7 @@ struct ReviewView: View { Card(padding: 28) { HStack(alignment: .center, spacing: 28) { VStack(alignment: .leading, spacing: 8) { - Text("\(report.isDemo ? "DEMO " : "")WRITING SCORE").font(.system(size: 11, weight: .semibold)).tracking(1.6).foregroundStyle(WB.secondary) + Text("\(report.isDemo ? "DEMO " : "")\(session.task.isTranslation ? "TRANSLATION" : "WRITING") SCORE").font(.system(size: 11, weight: .semibold)).tracking(1.6).foregroundStyle(WB.secondary) HStack(alignment: .firstTextBaseline, spacing: 8) { Text(report.finalScore.scoreText).font(.system(size: 62, weight: .semibold, design: .rounded)).foregroundStyle(WB.blue) Text("/ \(Int(session.task.maxScore))").font(.system(size: 24)).foregroundStyle(WB.secondary) @@ -212,6 +216,47 @@ struct ReviewView: View { } } } + private func segmentCard(_ report: GradingReport) -> some View { + let sources = QuestionText.underlinedSegments(session.question) + return Card { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("逐句得分").font(.system(size: 18, weight: .semibold)) + Spacer() + Text("踩点给分 · 每句 2 分").font(.system(size: 11)).foregroundStyle(WB.secondary) + } + ForEach(report.segmentScores, id: \.segment.number) { item in + let segment = item.segment + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("(\(segment.number))").font(.system(size: 14, weight: .semibold)) + Text("\(segment.score.scoreText) / \(segment.maxScore.scoreText)").font(.system(size: 20, weight: .semibold, design: .rounded)) + .foregroundStyle(segment.score >= segment.maxScore ? WB.green : segment.score <= segment.maxScore / 4 ? WB.amber : WB.blue) + Spacer() + Text(item.byJudge.map { "\($0.judge.rawValue.uppercased()) \($0.score.scoreText)" }.joined(separator: " · ")) + .font(.system(size: 11)).monospacedDigit().foregroundStyle(WB.secondary).help("三位评审对这一句的给分") + } + if let source = sources[segment.number] { Text(source).font(.system(size: 13)).foregroundStyle(WB.secondary).lineSpacing(4).textSelection(.enabled) } + if !segment.comment.isEmpty { Text(segment.comment).font(.system(size: 14)).lineSpacing(4).textSelection(.enabled) } + ForEach(Array(segment.points.enumerated()), id: \.offset) { _, point in + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: point.earned >= point.max ? "checkmark.circle.fill" : point.earned > 0 ? "circle.lefthalf.filled" : "xmark.circle") + .foregroundStyle(point.earned >= point.max ? WB.green : point.earned > 0 ? WB.blue : WB.amber) + VStack(alignment: .leading, spacing: 3) { + Text(point.source).font(.system(size: 13, weight: .medium)) + if !point.note.isEmpty { Text(point.note).font(.system(size: 12)).foregroundStyle(WB.secondary) } + }.textSelection(.enabled) + Spacer() + Text("\(point.earned.scoreText) / \(point.max.scoreText)").font(.system(size: 12)).monospacedDigit().foregroundStyle(WB.secondary) + } + } + }.padding(14).frame(maxWidth: .infinity, alignment: .leading).background(WB.canvas, in: RoundedRectangle(cornerRadius: 12)) + } + Text("逐句分与采分点来自总分居中的那位评审,各句之和即总分;错别字整题累计满 3 个扣 0.5 分。右侧为三位评审各自的给分。") + .font(.system(size: 11)).foregroundStyle(WB.secondary).lineSpacing(3) + } + } + } private func usageLine(_ report: GradingReport) -> String { var parts: [String] = [] if let duration = report.duration { parts.append("评阅用时 \(UsageCost.duration(duration))") } diff --git a/WriteBench/Features/Review/RevisionViews.swift b/WriteBench/Features/Review/RevisionViews.swift index 4cd9e7e..9d3f9e6 100644 --- a/WriteBench/Features/Review/RevisionViews.swift +++ b/WriteBench/Features/Review/RevisionViews.swift @@ -133,6 +133,15 @@ struct RevisionComparisonCard: View { change("主要问题", Double(previous.corrections.filter { $0.severity == .major }.count), Double(current.corrections.filter { $0.severity == .major }.count), lowerIsBetter: true, integer: true) } } + if let current, let previous, !current.segmentScores.isEmpty, !previous.segmentScores.isEmpty { + HStack(spacing: 12) { + ForEach(current.segmentScores, id: \.segment.number) { item in + if let old = previous.segmentScores.first(where: { $0.segment.number == item.segment.number }) { + change("(\(item.segment.number))", old.segment.score, item.segment.score) + } + } + } + } let diff = RevisionDiff.compare(baseline.originalEssay, session.originalEssay) DisclosureGroup(isExpanded: $showDiff) { Text(diff.text).font(.system(size: 14)).lineSpacing(6).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding(.top, 12) diff --git a/WriteBench/Models/GradingModels.swift b/WriteBench/Models/GradingModels.swift index b3d257e..ede88ed 100644 --- a/WriteBench/Models/GradingModels.swift +++ b/WriteBench/Models/GradingModels.swift @@ -25,6 +25,31 @@ struct ExpressionSuggestion: Codable, Hashable, Sendable { var meaning: String var example: String } +/// One 采分点 (meaning group) inside a numbered translation segment. +struct ScoringPoint: Codable, Hashable, Sendable { + var source: String + var earned: Double + var max: Double + var note: String +} +/// Per-segment marks for English I translation, where each numbered underlined sentence is scored on its own. +struct SegmentScore: Codable, Hashable, Sendable { + var number: String + var score: Double + var maxScore: Double + var comment: String + var points: [ScoringPoint] = [] + private enum CodingKeys: String, CodingKey { case number, score, maxScore, comment, points } + init(number: String, score: Double, maxScore: Double, comment: String, points: [ScoringPoint] = []) { + self.number = number; self.score = score; self.maxScore = maxScore; self.comment = comment; self.points = points + } + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + number = try c.decode(String.self, forKey: .number); score = try c.decode(Double.self, forKey: .score) + maxScore = try c.decode(Double.self, forKey: .maxScore); comment = try c.decode(String.self, forKey: .comment) + points = try c.decodeIfPresent([ScoringPoint].self, forKey: .points) ?? [] + } +} struct JudgeResponse: Codable, Sendable { var score: Double // Diagnostic dimensions use a common 0–10 scale; overall score uses the exam scale. @@ -41,8 +66,9 @@ struct JudgeResponse: Codable, Sendable { var weaknesses: [String] = [] var improvements: [String] = [] var expressions: [ExpressionSuggestion] = [] + var segments: [SegmentScore] = [] private enum CodingKeys: String, CodingKey { - case score, taskCompletion, language, coherence, register, majorErrors, minorErrors, summary, corrections, improvedVersion, strengths, weaknesses, improvements, expressions + case score, taskCompletion, language, coherence, register, majorErrors, minorErrors, summary, corrections, improvedVersion, strengths, weaknesses, improvements, expressions, segments } } extension JudgeResponse { @@ -70,6 +96,7 @@ extension JudgeResponse { weaknesses = try c.decodeIfPresent([String].self, forKey: .weaknesses) ?? [] improvements = try c.decodeIfPresent([String].self, forKey: .improvements) ?? [] expressions = try c.decodeIfPresent([ExpressionSuggestion].self, forKey: .expressions) ?? [] + segments = try c.decodeIfPresent([SegmentScore].self, forKey: .segments) ?? [] } } struct TokenUsage: Codable, Hashable, Sendable { @@ -126,6 +153,15 @@ struct GradingReport: Codable, Sendable { seen.insert("\($0.category.rawValue)|\($0.original.lowercased().trimmingCharacters(in: .whitespacesAndNewlines))").inserted }.sorted { $0.severity == .major && $1.severity != .major } } + /// The reviewer whose overall score is the median: its per-segment marks add up to the reported total. + var medianReviewer: ReviewerResult? { reviewers.min { abs($0.response.score - finalScore) < abs($1.response.score - finalScore) } } + /// Per-sentence marks from the median reviewer, with every reviewer's mark for the same sentence. + var segmentScores: [(segment: SegmentScore, byJudge: [(judge: Judge, score: Double)])] { + guard let source = medianReviewer, !source.response.segments.isEmpty else { return [] } + return source.response.segments.map { segment in + (segment, reviewers.compactMap { reviewer in reviewer.response.segments.first { $0.number == segment.number }.map { (reviewer.judge, $0.score) } }) + } + } private var revisionSource: ReviewerResult? { reviewers.first(where: { $0.judge == .b }) ?? reviewers.first } var improvedVersion: String { revisionSource?.response.improvedVersion ?? "" } /// Expressions come from the reviewer whose improved version is shown, so each one appears in context. diff --git a/WriteBench/Rubrics/cet6_translation.md b/WriteBench/Rubrics/cet6_translation.md index b106060..45b6d16 100644 --- a/WriteBench/Rubrics/cet6_translation.md +++ b/WriteBench/Rubrics/cet6_translation.md @@ -1,5 +1,5 @@ # CET-6 · 汉译英翻译练习 -Version: 2026.09-v1. App-owned practice rubric, not an official examiner manual. +Version: 2026.10-v1. App-owned practice rubric, not an official examiner manual. Score on a 0–15 practice raw translation scale. This is not a claim of conversion to the CET report score. Translate the source Chinese paragraph into English. Prioritize accurate and complete meaning, appropriate vocabulary and collocation, grammatical correctness, coherent expression and suitable register. Accept natural paraphrases and valid alternative translations. Do not require a writing-task argument, introduction or conclusion. Do not impose an English word limit on the answer. diff --git a/WriteBench/Rubrics/cet6_writing.md b/WriteBench/Rubrics/cet6_writing.md index d1c863f..f063c9b 100644 --- a/WriteBench/Rubrics/cet6_writing.md +++ b/WriteBench/Rubrics/cet6_writing.md @@ -1,5 +1,5 @@ # CET-6 writing -Version: 2026.09-v1 +Version: 2026.10-v1 Scale: practice raw writing score 0–15, half-point estimates. Do not convert to the CET scaled report or a 710-point total. Typical task: 30 minutes, 150–200 words; obey the actual prompt when it specifies otherwise. App-owned practice rubric. Assess relevance and explicit requirements, logical development and support, coherent organization, vocabulary appropriacy, grammatical range and accuracy. Avoid automatic per-word deductions invented without a supplied official scoring rule. Anchors: 13–15 clear relevant well-developed response with good organization and few errors; 10–12 mostly clear with adequate support and some errors; 7–9 conveys the basic idea but development/language are uneven; 4–6 weak content or errors that often impede meaning; 1–3 very little effective relevant writing; 0 blank or wholly unrelated. diff --git a/WriteBench/Rubrics/ielts_task1.md b/WriteBench/Rubrics/ielts_task1.md index d1eb575..9e163d9 100644 --- a/WriteBench/Rubrics/ielts_task1.md +++ b/WriteBench/Rubrics/ielts_task1.md @@ -1,5 +1,5 @@ # IELTS Academic Writing — Task 1 -Version: 2026.09-v1 +Version: 2026.10-v1 Scale: single-task band estimate 0–9, half-band estimates. Minimum 150 words, suggested 20 minutes. This is a practice estimate; never label it an overall IELTS Writing band. Assess four equally weighted criteria: task achievement; coherence/cohesion; lexical resource; grammatical range/accuracy. Form the single-task score from these four assessments, rounded to the nearest 0.5. The app's language/register diagnostics are separate summaries, not the official criterion names. Task achievement: accurate key features, an overview and relevant comparisons; no invented trends or numbers. For incomplete chart descriptions in the prompt, explicitly state limits. Coherence: logical grouping, progression, purposeful links. Lexical resource: range, precision, appropriate collocations. Grammar: range and control, effect of errors on meaning. Register should be neutral or academic. diff --git a/WriteBench/Rubrics/ielts_task2.md b/WriteBench/Rubrics/ielts_task2.md index 0031dbf..868518b 100644 --- a/WriteBench/Rubrics/ielts_task2.md +++ b/WriteBench/Rubrics/ielts_task2.md @@ -1,5 +1,5 @@ # IELTS Writing — Task 2 -Version: 2026.09-v1 +Version: 2026.10-v1 Scale: single-task band estimate 0–9, half-band estimates. Minimum 250 words, suggested 40 minutes. Do not present it as the overall IELTS Writing band; overall Writing requires both tasks, with Task 2 weighted twice Task 1. Assess four equally weighted criteria: task response; coherence/cohesion; lexical resource; grammatical range/accuracy. Form the task score from these four assessments, rounded to nearest 0.5. The app's diagnostic language/register meters are separate summaries. Task response: address all question parts, clear sustained position, relevant developed support, avoid overgeneralization. Coherence: progression and purposeful paragraphs without mechanical overlinking. Lexical resource: range, precision, appropriate style and collocation. Grammar: appropriate structures, control, error impact. Formal or neutral essay register. diff --git a/WriteBench/Rubrics/kaoyan_english1_large.md b/WriteBench/Rubrics/kaoyan_english1_large.md index 0789ac9..123c192 100644 --- a/WriteBench/Rubrics/kaoyan_english1_large.md +++ b/WriteBench/Rubrics/kaoyan_english1_large.md @@ -1,5 +1,5 @@ # Kaoyan English I — large essay -Version: 2026.09-v1 +Version: 2026.10-v1 Scale: 0–20, half-point estimates. Typical length: 160–200 words. App-owned practice rubric, not official examiner guidance. Assess accurate description of supplied visual information, interpretation of its central message, relevant commentary with development, coherent paragraphs, language range and accuracy. Never invent unseen details from an image; rely on the confirmed written question and flag insufficient information. Anchors: 17–20 fulfills the task with clear, developed interpretation and accurate varied language; 13–16 generally complete, coherent and sufficiently developed despite errors; 9–12 basic relevant response with weak development and noticeable errors; 5–8 significant omissions or language problems; 1–4 minimal relevant communication; 0 blank or wholly unrelated. diff --git a/WriteBench/Rubrics/kaoyan_english1_small.md b/WriteBench/Rubrics/kaoyan_english1_small.md index 2c2359f..d0fba7b 100644 --- a/WriteBench/Rubrics/kaoyan_english1_small.md +++ b/WriteBench/Rubrics/kaoyan_english1_small.md @@ -1,5 +1,5 @@ # Kaoyan English I — small essay -Version: 2026.09-v1 +Version: 2026.10-v1 Scale: 0–10, half-point estimates. Around 100 words. This is an app-owned practice rubric, not official examiner guidance. Priorities: all explicit bullet points and communicative purpose; correct letter/notice format; recipient-appropriate tone; clear order; accurate, natural language. Evaluate underlength through missing content, not an invented fixed penalty. Follow the prompt's signature requirements. Anchors: 9–10 completes all requirements clearly with appropriate format and very few language errors; 7–8 mostly complete and coherent with occasional errors; 5–6 basic purpose achieved but uneven coverage or frequent errors; 3–4 incomplete purpose and meaning often obstructed; 1–2 very limited relevant communication; 0 blank or wholly unrelated. diff --git a/WriteBench/Rubrics/kaoyan_english1_translation.md b/WriteBench/Rubrics/kaoyan_english1_translation.md index 7fad4d3..914ee9e 100644 --- a/WriteBench/Rubrics/kaoyan_english1_translation.md +++ b/WriteBench/Rubrics/kaoyan_english1_translation.md @@ -1,8 +1,13 @@ -# 考研英语一 · 英译汉翻译练习 -Version: 2026.09-v1. App-owned practice rubric, not an official examiner manual. +# 考研英语一 · 英译汉(划线句翻译) +Version: 2026.10-v1. Practice rubric following the published Kaoyan English I translation rules and the common 踩点给分 marking practice; not an official marking manual. -Practice scale: 0–10. When the prompt contains five numbered source sentences, evaluate each on 0–2 and sum; otherwise assess the supplied translation task proportionally on the 10-point practice scale and state the scope. Do not pretend a short custom fragment is a complete exam paper. +Task: translate only the five numbered underlined segments (usually (46)–(50)) into Chinese; the rest of the passage is context. Each segment is worth 2 points; the question is worth 10. -Prioritize faithful meaning, correct logical relationships, completeness of important information, accurate handling of long sentence structure, and natural readable Chinese. Accept different correct expressions. Do not require literal word order. Meaning reversals, omitted main clauses, invented content and mistranslated referents are major issues. Punctuation or stylistic preferences alone are minor. Never judge Chinese answers using English word-count or essay-organization requirements. +Mark each segment by scoring points (踩点给分): +1. Split the source segment into 3–4 meaning groups (意群) at its natural divisions: punctuation, subordinate clauses (that / which / who / when / although …), coordinated clauses (and / but / or …), prepositional, participial and infinitive phrases, and a long subject. Give each group a share of the 2 points (normally 0.5 each; a key main-clause group may carry 1) so the shares sum to exactly 2. +2. Award a group's share when its meaning is rendered accurately and completely; give half the share for a partially correct rendering; give 0 for an omitted, mistranslated or reversed group. Segment scores use 0.5 steps (0, 0.5, 1, 1.5, 2). +3. Official caps: if the segment's translation clearly departs from the original meaning, the segment scores at most 0.5. If the student offers two or more renderings of the same item and any one is wrong, mark it as wrong. +4. Typos (错别字) are counted cumulatively across the whole question: three or more deduct 0.5 from the total; fewer deduct nothing. Report any typo deduction in minorErrors. +5. Accuracy (信) comes first: main/subordinate structure, referents, logical relations, tense/voice and the contextual sense of words (including familiar words used in unfamiliar senses). Fluency (达) comes second: Chinese word order (attributives before the noun; time, place and manner adverbials before the verb), natural collocation, no translationese that obstructs meaning. Elegance (雅) is not required; do not deduct for plain but accurate Chinese. Accept any correct alternative rendering. -Diagnostic fields use 0–10: taskCompletion = meaning accuracy and completeness; language = Chinese expression; coherence = preservation of logical connections; register = appropriate tone. corrections.original must be exact Chinese spans from the student's answer; corrected and improvedVersion must be Chinese. Report source omissions in majorErrors without inventing original correction spans. Give a full reference translation and explain that valid alternatives may exist. +The total score is the sum of the five segment scores minus any typo deduction. Diagnostic fields use 0–10: taskCompletion = accuracy and completeness; language = Chinese expression; coherence = preservation of logical relations; register = appropriate tone. corrections.original must be exact Chinese spans from the student's answer; corrected and improvedVersion must be Chinese. improvedVersion is a numbered reference translation of the five segments only. Report missing segments or groups in majorErrors. diff --git a/WriteBench/Rubrics/kaoyan_english2_large.md b/WriteBench/Rubrics/kaoyan_english2_large.md index 2c83d56..1b20345 100644 --- a/WriteBench/Rubrics/kaoyan_english2_large.md +++ b/WriteBench/Rubrics/kaoyan_english2_large.md @@ -1,5 +1,5 @@ # Kaoyan English II — large essay -Version: 2026.09-v1 +Version: 2026.10-v1 Scale: 0–15, half-point estimates. Typical length: about 150 words. App-owned practice rubric, not official examiner guidance. English II large essays are usually based on a chart or table. Assess: accurate description of the key data and its overall trend or comparison, using the figures actually given; a reasonable interpretation of causes or meaning; relevant comments; coherent paragraphs; language range and accuracy. Selecting the main features matters more than listing every number. Misreading or inventing data is a task-completion error. Rely only on the figure transcription in the question; never invent unseen values. Anchors: 13–15 complete, accurate data description with developed, relevant comment and varied accurate language; 10–12 generally complete and coherent despite some errors or thin development; 7–9 basic relevant response with weak data use or noticeable errors; 4–6 significant omissions, misread data or language problems; 1–3 minimal relevant communication; 0 blank or wholly unrelated. diff --git a/WriteBench/Rubrics/kaoyan_english2_small.md b/WriteBench/Rubrics/kaoyan_english2_small.md index 601fa1c..10b2b08 100644 --- a/WriteBench/Rubrics/kaoyan_english2_small.md +++ b/WriteBench/Rubrics/kaoyan_english2_small.md @@ -1,5 +1,5 @@ # Kaoyan English II — small essay -Version: 2026.09-v1 +Version: 2026.10-v1 Scale: 0–10, half-point estimates. Around 100 words. This is an app-owned practice rubric, not official examiner guidance. Priorities: every bullet the prompt asks for and the communicative purpose; correct email/letter/notice format; tone suited to the recipient; clear order; accurate, natural language. English II small essays are practical and everyday; reward clear, appropriate communication over ornate wording. Evaluate underlength through missing content, not an invented fixed penalty. Follow the prompt's signature and address requirements. Anchors: 9–10 covers all requirements clearly with appropriate format and very few language errors; 7–8 mostly complete and coherent with occasional errors; 5–6 basic purpose achieved but uneven coverage or frequent errors; 3–4 incomplete purpose and meaning often obstructed; 1–2 very limited relevant communication; 0 blank or wholly unrelated. diff --git a/WriteBench/Rubrics/kaoyan_english2_translation.md b/WriteBench/Rubrics/kaoyan_english2_translation.md index 65d77fb..a7cca28 100644 --- a/WriteBench/Rubrics/kaoyan_english2_translation.md +++ b/WriteBench/Rubrics/kaoyan_english2_translation.md @@ -1,5 +1,5 @@ # 考研英语二 · 英译汉段落翻译练习 -Version: 2026.09-v1. App-owned practice rubric, not an official examiner manual. +Version: 2026.10-v1. App-owned practice rubric, not an official examiner manual. Use a 0–15 practice scale for a complete English-to-Chinese passage translation. Judge the whole passage; do not apply English I's five-sentence, 2-point-per-sentence scheme. For a custom short fragment, state the limited practice scope rather than representing it as a complete official exam task. diff --git a/WriteBench/Services/Codex/CodexJudgeService.swift b/WriteBench/Services/Codex/CodexJudgeService.swift index 51438b2..d1334d4 100644 --- a/WriteBench/Services/Codex/CodexJudgeService.swift +++ b/WriteBench/Services/Codex/CodexJudgeService.swift @@ -80,6 +80,7 @@ struct CodexJudgeService: EssayGradingService { 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), var response = try? JudgeResponse.decodeProviderOutput(data) else { throw CodexError.malformed } response.corrections = CorrectionMatcher.anchored(response.corrections, in: input.essay) + ScoreAggregator.reconcileSegments(&response, task: input.task) try ScoreAggregator.validate(response, task: input.task) return ReviewerResult(judge: judge, response: response, model: model.isEmpty ? "Codex automatic" : model, timestamp: Date(), provider: .codex, reasoningEffort: reasoning, usage: CodexUsage.parse(result.stdout)) @@ -111,10 +112,15 @@ enum JudgeResponseSchema { "severity": ["type": "string", "enum": ["major", "minor"]]]] let expression: [String: Any] = ["type": "object", "additionalProperties": false, "required": ["example", "meaning", "phrase"], "properties": ["phrase": string, "meaning": string, "example": string]] + let point: [String: Any] = ["type": "object", "additionalProperties": false, "required": ["earned", "max", "note", "source"], + "properties": ["source": string, "earned": number, "max": number, "note": string]] + let segment: [String: Any] = ["type": "object", "additionalProperties": false, "required": ["comment", "maxScore", "number", "points", "score"], + "properties": ["number": string, "score": number, "maxScore": number, "comment": string, "points": ["type": "array", "items": point]]] let properties: [String: Any] = ["score": number, "taskCompletion": number, "language": number, "coherence": number, "register": number, "majorErrors": strings, "minorErrors": strings, "summary": string, "strengths": strings, "weaknesses": strings, "improvements": strings, "corrections": ["type": "array", "items": correction], "improvedVersion": string, - "expressions": ["type": "array", "items": expression]] + "expressions": ["type": "array", "items": expression], + "segments": ["type": "array", "items": segment]] 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 22b538e..711ae24 100644 --- a/WriteBench/Services/DeepSeek/DeepSeekClient.swift +++ b/WriteBench/Services/DeepSeek/DeepSeekClient.swift @@ -68,6 +68,7 @@ struct DeepSeekClient: StreamingEssayGradingService { do { result = try JudgeResponse.decodeProviderOutput(Data(content.utf8)) } catch { throw GradingError.invalidResponse("JSON 字段缺失或类型不符") } result.corrections = CorrectionMatcher.anchored(result.corrections, in: input.essay) + ScoreAggregator.reconcileSegments(&result, task: input.task) try ScoreAggregator.validate(result, task: input.task) return ReviewerResult(judge: judge, response: result, model: model, timestamp: Date(), provider: .deepSeek, reasoningEffort: "max", usage: usage) } @@ -131,7 +132,9 @@ extension GraderPrompt { "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", - "expressions": [{"phrase": "reusable expression in the target language", "meaning": "简体中文释义", "example": "one sentence using it in this topic"}]} + "expressions": [{"phrase": "reusable expression in the target language", "meaning": "简体中文释义", "example": "one sentence using it in this topic"}], + "segments": [{"number": "46", "score": 1.5, "maxScore": 2, "comment": "简体中文:这一句得分与失分的原因", "points": [{"source": "the English meaning group", "earned": 0.5, "max": 0.5, "note": "简体中文:译对了什么或错在哪里"}]}]} + \(input.task == .kaoyanTranslation ? "segments is REQUIRED: one entry per numbered underlined segment, in order, numbered as in the question (e.g. 46–50). Split each segment into its 3–4 meaning groups as points whose max values sum to exactly maxScore (2). score uses 0.5 steps. The overall score must equal the sum of segment scores, minus 0.5 only when the whole answer has three or more typos." : "segments must be an empty array for this task.") Valid categories are: \(MistakeCategory.allCases.map(\.rawValue).joined(separator: ", ")). Severity must be major or minor. Arrays can be empty. expressions lists 0–5 reusable, exam-appropriate expressions worth memorizing from your improvedVersion; prefer collocations and sentence frames over single common words, and never list phrases the student already used correctly. Prioritize up to 12 exam-relevant corrections. Avoid nitpicking acceptable usage. Missing task content belongs in majorErrors, not an invented original correction span. Each original MUST be an exact substring of the supplied essay. Do not penalize suspected OCR errors without evidence; input has been user-confirmed. Return a complete JSON object, without markdown fences. """ } diff --git a/WriteBench/Services/Grading/GradingService.swift b/WriteBench/Services/Grading/GradingService.swift index da54ba6..c417a52 100644 --- a/WriteBench/Services/Grading/GradingService.swift +++ b/WriteBench/Services/Grading/GradingService.swift @@ -45,6 +45,14 @@ enum ScoreAggregator { let confidence: Confidence = mode == .quick ? .single : spread <= 1 ? .high : spread <= 2 ? .medium : .low return GradingReport(reviewers: results.sorted { $0.judge.rawValue < $1.judge.rawValue }, finalScore: scores[scores.count / 2], spread: spread, confidence: confidence, rubricVersion: RubricLoader.version, promptVersion: GraderPrompt.version, isDemo: isDemo, timestamp: Date(), mode: mode) } + /// English I translation totals are the sum of the five segment marks, less at most 0.5 for typos. + static func reconcileSegments(_ response: inout JudgeResponse, task: WritingTask) { + guard task == .kaoyanTranslation else { response.segments = []; return } + response.segments = response.segments.filter { $0.maxScore > 0 && $0.score.isFinite && (0...$0.maxScore).contains($0.score) } + guard !response.segments.isEmpty else { return } + let sum = response.segments.reduce(0) { $0 + $1.score } + if !(sum - 0.5...sum).contains(response.score) { response.score = min(task.maxScore, sum) } + } static func validate(_ response: JudgeResponse, task: WritingTask) throws { guard response.score.isFinite, (0...task.maxScore).contains(response.score) else { throw GradingError.invalidResponse("分数超出题型范围") } let values = [response.taskCompletion, response.language, response.coherence, response.register] @@ -98,10 +106,10 @@ struct GradingCoordinator: Sendable { } enum RubricLoader { - static let version = "2026.09-v1" + static let version = "2026.10-v1" static func load(_ task: WritingTask) throws -> String { guard let url = Bundle.main.url(forResource: task.rubricFile, withExtension: "md") else { throw GradingError.missingRubric } return try String(contentsOf: url, encoding: .utf8) } } -enum GraderPrompt { static let version = "1.3.2" } +enum GraderPrompt { static let version = "1.4.0" } diff --git a/WriteBenchTests/PracticeLoopTests.swift b/WriteBenchTests/PracticeLoopTests.swift index 9a12449..793bdd4 100644 --- a/WriteBenchTests/PracticeLoopTests.swift +++ b/WriteBenchTests/PracticeLoopTests.swift @@ -286,3 +286,48 @@ private actor RecordingGrader: EssayGradingService { #expect(view.caretFrame() == nil) #expect(WritingTask.kaoyan2Small.answerSheet?.number == "47." && WritingTask.kaoyanTranslation.answerSheet?.number == "46–50" && WritingTask.cet6Writing.answerSheet == nil) } + +// MARK: Per-sentence translation scores + +private func segments(_ scores: [Double]) -> [SegmentScore] { + zip(46..., scores).map { SegmentScore(number: String($0.0), score: $0.1, maxScore: 2, comment: "第 \($0.0) 句", + points: [ScoringPoint(source: "main clause", earned: min($0.1, 1), max: 1, note: ""), ScoringPoint(source: "modifier", earned: max(0, $0.1 - 1), max: 1, note: "")]) } +} +@Test func englishOneTranslationTotalsFollowTheSentenceMarks() { + var response = judgeResponse(6, corrections: []) + response.segments = segments([2, 1.5, 1, 2, 1]) + ScoreAggregator.reconcileSegments(&response, task: .kaoyanTranslation) + #expect(response.score == 7.5) + response.score = 7 + ScoreAggregator.reconcileSegments(&response, task: .kaoyanTranslation) + #expect(response.score == 7) // A 0.5 typo deduction is allowed. + response.segments.append(SegmentScore(number: "51", score: 3, maxScore: 2, comment: "out of range")) + ScoreAggregator.reconcileSegments(&response, task: .kaoyanTranslation) + #expect(response.segments.count == 5) + ScoreAggregator.reconcileSegments(&response, task: .kaoyan2Translation) + #expect(response.segments.isEmpty) +} +@Test func sentenceMarksComeFromTheMedianReviewer() throws { + let marks = [[2, 2, 1.5, 2, 1.5], [1.5, 1, 1, 1.5, 1], [2, 1.5, 1.5, 1.5, 1]] + let reviewers = zip(Judge.allCases, marks).map { judge, marks -> ReviewerResult in + var response = judgeResponse(marks.reduce(0, +)); response.segments = segments(marks) + return ReviewerResult(judge: judge, response: response, model: "test", timestamp: Date()) + } + let report = try ScoreAggregator.aggregate(reviewers, task: .kaoyanTranslation, isDemo: false) + #expect(report.finalScore == 7.5 && report.medianReviewer?.judge == .c) + #expect(report.segmentScores.map(\.segment.score) == [2, 1.5, 1.5, 1.5, 1]) + #expect(report.segmentScores[1].byJudge.map(\.score) == [2, 1, 1.5]) + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(judgeResponse())) as? [String: Any]) + json.removeValue(forKey: "segments") + #expect(try JSONDecoder().decode(JudgeResponse.self, from: JSONSerialization.data(withJSONObject: json)).segments.isEmpty) +} +@Test func promptsAndSchemaAskForSentenceMarksOnlyForEnglishOneTranslation() throws { + let question = "Translate the underlined segments. (46) We don't have to learn it. It is innate. (47) It never leaves us." + #expect(QuestionText.underlinedSegments(question) == ["46": "We don't have to learn it.", "47": "It never leaves us."]) + let translation = GraderPrompt.system(judge: .a, input: GradingInput(task: .kaoyanTranslation, question: question, essay: "x", rubric: try RubricLoader.load(.kaoyanTranslation))) + #expect(translation.contains("segments is REQUIRED") && translation.contains("踩点给分")) + let essay = GraderPrompt.system(judge: .a, input: GradingInput(task: .kaoyanSmall, question: "Q", essay: "x", rubric: "r")) + #expect(essay.contains("segments must be an empty array")) + let schema = try #require(JSONSerialization.jsonObject(with: JudgeResponseSchema.data()) as? [String: Any]) + #expect((schema["required"] as? [String])?.contains("segments") == true) +} diff --git a/project.yml b/project.yml index 7cef971..4462320 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.6.2" - CURRENT_PROJECT_VERSION: "11" + MARKETING_VERSION: "1.7.0" + CURRENT_PROJECT_VERSION: "12" targets: WriteBench: type: application