Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Documentation/Validation.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 后使用。

<details>
<summary><strong>更多文档</strong></summary>
Expand Down
6 changes: 6 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -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),四角有定位方块,下方注明“超出黑色矩形边框限定区域的答案无效”。六级、雅思保持原样。
Expand Down
8 changes: 4 additions & 4 deletions WriteBench.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions WriteBench/Components/QuestionText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ enum QuestionText {
text.replacingOccurrences(of: open, with: "").replacingOccurrences(of: close, with: "")
}

/// Numbered underlined segments, e.g. "(46) <u>…</u>" → ["46": "…"].
static func underlinedSegments(_ text: String) -> [String: String] {
guard let regex = try? NSRegularExpression(pattern: #"\((\d+)\)\s*<u>(.*?)</u>"#, 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)
Expand Down
3 changes: 2 additions & 1 deletion WriteBench/Features/Review/ReviewOutline.swift
Original file line number Diff line number Diff line change
@@ -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: "逐句修改"
Expand Down
6 changes: 6 additions & 0 deletions WriteBench/Features/Review/ReviewTextExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
49 changes: 47 additions & 2 deletions WriteBench/Features/Review/ReviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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))") }
Expand Down
9 changes: 9 additions & 0 deletions WriteBench/Features/Review/RevisionViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 37 additions & 1 deletion WriteBench/Models/GradingModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion WriteBench/Rubrics/cet6_translation.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion WriteBench/Rubrics/cet6_writing.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading