// engine_mqm.mbt
// =====================================================================
// P10: 从 engine.mbt 拆出 — 同包(@lib)多文件,调用点零修改。
// 主题: MQM/QE 自动评测 + 格式保真 + OCR 桩 + 区域对齐 + batch_apply (#2–#5 扩展能力)
// ===========================================================================
// #2–#7 扩展能力(译脉·先知 2.0 翻译技术大赛)—— 纯引擎逻辑 + 零依赖
// 设计原则:所有能力在 wasm-gc 下可测;需外部系统(OCR / TMS 连接 / CI 运行器 /
// 前端看板)的部分明确标注为外部边界,引擎只实现可纯逻辑落地的内核。
// ===========================================================================
// ---- 共享辅助(包内私有) ----
// 抽取文本中的内联标签与占位符:、、{0}、%s、$1、&、\n
fn extract_tags(s : String) -> Array[String] {
let tags : Array[String] = []
let n = s.length()
let chars : Array[Char] = []
for ch in s.iter() { chars.push(ch) }
let mut i = 0
while i < n {
let c = chars[i]
if c == '<' {
let mut j = i + 1
while j < n && chars[j] != '>' { j = j + 1 }
if j < n {
let tag = (s[i:j + 1]).to_owned()
if !arr_contains(tags, tag) { tags.push(tag) }
i = j + 1
} else { i = i + 1 }
} else if c == '{' {
let mut j = i + 1
while j < n && chars[j] != '}' { j = j + 1 }
if j < n {
let tag = (s[i:j + 1]).to_owned()
if !arr_contains(tags, tag) { tags.push(tag) }
i = j + 1
} else { i = i + 1 }
} else if c == '%' && i + 1 < n && (chars[i + 1] == 's' || chars[i + 1] == 'd') {
let tag = (s[i:i + 2]).to_owned()
if !arr_contains(tags, tag) { tags.push(tag) }
i = i + 2
} else if c == '$' && i + 1 < n && chars[i + 1] >= '0' && chars[i + 1] <= '9' {
let tag = (s[i:i + 2]).to_owned()
if !arr_contains(tags, tag) { tags.push(tag) }
i = i + 2
} else if c == '\\' && i + 1 < n && chars[i + 1] == 'n' {
if !arr_contains(tags, "\\n") { tags.push("\\n") }
i = i + 2
} else if c == '&' {
let mut j = i + 1
while j < n && chars[j] != ';' { j = j + 1 }
if j < n && j > i + 1 {
let tag = (s[i:j + 1]).to_owned()
if !arr_contains(tags, tag) { tags.push(tag) }
i = j + 1
} else { i = i + 1 }
} else {
i = i + 1
}
}
tags
}
// XML 文本转义(导出 TMX 时保护 < > &)
fn xml_escape(s : String) -> String {
let b = StringBuilder()
for ch in s.iter() {
if ch == '<' { b.write_string("<") }
else if ch == '>' { b.write_string(">") }
else if ch == '&' { b.write_string("&") }
else { b.write_char(ch) }
}
b.to_string()
}
// 全部替换(split_on 已在 util 实现)
fn str_replace_all(s : String, from : String, to : String) -> String {
if from == "" { return s }
let parts = split_on(s, from)
let mut out = ""
let mut first = true
for p in parts {
if !first { out = out + to }
out = out + p
first = false
}
out
}
// 从节点 JSON(to_json 快照)抽取稳定键,用于漂移对比
fn node_key(j : Json) -> String {
let mt = get_str(j, "type")
let is_term = get_num(j, "is_term") != 0.0
let text = get_str(j, "text")
let tgt = get_str(j, "translation")
mt + "|" + (if is_term { "term" } else { "node" }) + "|" + text + "|" + tgt
}
// ---- #2 QE + MQM 自动评测 ----
// 句段级 QE 分数(0..1):0.50·匹配率 + 0.25·术语一致 + 0.10·字符相似 + 0.15·BLEU 校准
// match_rate 为 [0,1](与 fuzzy_match 的 score 同量级,勿传 match_pct)
// 注:跨语言句对字符相似度/长度比天然偏低,若作为惩罚项会把分数压到 ~0.7 失真,
// 故 QE 以 TM 匹配率 + 术语一致为主,字符相似仅作正向加项(同语言场景才显著)。
// P1:BLEU-4 校准项(bleu_calib = clamp(bleu_score(src, tgt), 0, 1))捕捉 token 级 n-gram 重合,
// 对跨语种(无 char 重合)但用词对应良好的译文仍有正向贡献;完全无关时自然 ≈ 0。
pub fn ProphecyEngine::qe_score(
_self : ProphecyEngine,
source : String,
target : String,
match_rate : Double,
term_ok : Bool,
) -> Double {
// NaN 防护:match_rate 异常(如调用方传入 NaN)时 max0/min1 无法钳位(NaN 比较恒假),
// 会让 qmatch=NaN 穿透到 score。先归一化到 [0,1]。
let mr = if match_rate != match_rate { 0.0 } else { max0(0.0, min1(match_rate)) }
let qmatch = mr
let qterm = if term_ok { 1.0 } else { 0.0 }
// 注意:跨语言句对字符相似度/长度比天然偏低,若作为惩罚项会把分数压到 ~0.7,
// 失真。故 QE 以 TM 匹配率 + 术语一致为主,字符相似仅作正向加项(同语言场景才显著)。
// char_ratio 对空串已做保护(双空返回 1.0、单空返回 0),不会产出 NaN。
let qchar = max0(0.0, min1(char_ratio(source, target)))
// P1 BLEU 校准:clamp 到 [0,1];完全匹配=1,完全无关 ≈ 0.05(Add-1 平滑下界)
let bleu_calib = max0(0.0, min1(bleu_score(source, target)))
let score = 0.50 * qmatch + 0.25 * qterm + 0.10 * qchar + 0.15 * bleu_calib
min1(max0(0.0, score))
}
// MQM 维度标注:terminology / accuracy / fluency / omission(含严重度)
// MQM 严重度数值化:与 AGENTS.md 声明的标准尺度对齐(None=0/Minor=1/Major=5/Critical=10)
// P4 增量:与 mqm_tags 的 severity 字符串并列输出 severity_score 数值,
// 便于跨工具可比性(标准 MQM 评分器可直接消费)。
pub fn mqm_severity_to_score(sev : String) -> Double {
if sev == "none" { 0.0 }
else if sev == "minor" { 1.0 }
else if sev == "major" { 5.0 }
else if sev == "critical" { 10.0 }
else { 0.0 } // 未知/未设置按 0 算(保守)
}
// 构造单个 MQM issue Json(含 dimension / severity / severity_score / detail 四字段)
pub fn mqm_issue(dimension : String, severity : String, detail : String) -> Json {
obj([
("dimension", str_json(dimension)),
("severity", str_json(severity)),
("severity_score", num_json(mqm_severity_to_score(severity))),
("detail", str_json(detail)),
])
}
pub fn ProphecyEngine::mqm_tags(
_self : ProphecyEngine,
source : String,
target : String,
_match_rate : Double,
term_ok : Bool,
) -> Json {
let issues : Array[Json] = []
if !term_ok {
issues.push(mqm_issue("terminology", "major", "术语未通过一致性校验(check_terms 检出违规)"))
}
// 跨语种(如 英→中)字符级相似度天然偏低,char_ratio 不可作为 accuracy 判据;
// 译文为空时 char_ratio 恒为 0,亦无意义(空译文由 fluency/critical 覆盖),跳过
let cross = cross_script(source, target)
let cr = char_ratio(source, target)
if !cross && target.length() > 0 && cr < 0.5 {
issues.push(mqm_issue("accuracy", "major", "源-译文 字符级相似度过低 (<0.5)"))
}
if target.length() == 0 {
issues.push(mqm_issue("fluency", "critical", "译文为空"))
} else {
let lr = target.length().to_double() / max0(1.0, source.length().to_double())
// 跨语种长度比天然偏离(英→中译文更短),不可作为流畅性判据;
// 源文为空时长度比无意义,亦跳过(避免空源文误报流畅性)
if !cross && source.length() > 0 && (lr > 2.5 || lr < 0.4) {
issues.push(mqm_issue("fluency", "minor", "译文长度比异常(" + r4(lr).to_string() + "),疑似流畅性问题"))
}
}
// 跨语种时译文长度通常远短于源文(英→中),长度比不可作为 omission 判据;
// target 为空已由 fluency/critical 覆盖,此处再加 target.length()>0 防止同一问题重复标注
if !cross && target.length() > 0 && target.length().to_double() < 0.5 * source.length().to_double() && source.length() > 0 {
issues.push(mqm_issue("omission", "major", "译文过短(< 源文 50%),疑似漏译"))
}
// 数字守门(v1,路线图 §三「准」的核心增量):
// 源文有数字 / 单位而译文完全未保留 → omission/critical(漏译数字是本地化高危硬伤)
// 源文与译文数字集合不一致(缺/多/不同值)→ accuracy/major(数字/单位错传)
// 数字守门不受跨语种影响(数字 0-9 是语言无关的),故不检查 cross
let src_nums = numeric_tokens(source)
let tgt_nums = numeric_tokens(target)
if src_nums.length() > 0 && tgt_nums.length() == 0 {
issues.push(mqm_issue("numeric_consistency", "critical",
"源文含数字/单位(" + src_nums.length().to_string() + " 处)但译文未保留任意数字"))
} else if src_nums.length() > 0 && tgt_nums.length() > 0 {
// 集合差异:missing = 源有译无,extra = 译有源无
let src_set : Map[String, Bool] = Map::from_iter(([] : Array[(String, Bool)]).iter())
for n in src_nums {
src_set.set(n, true)
}
let tgt_set : Map[String, Bool] = Map::from_iter(([] : Array[(String, Bool)]).iter())
for n in tgt_nums {
tgt_set.set(n, true)
}
let mut missing = 0
for n in src_nums {
match tgt_set.get(n) {
Some(_) => ()
None => missing = missing + 1
}
}
let mut extra = 0
for n in tgt_nums {
match src_set.get(n) {
Some(_) => ()
None => extra = extra + 1
}
}
if missing > 0 || extra > 0 {
let detail = "数字集合不一致:源" + src_nums.length().to_string() + " 译" + tgt_nums.length().to_string() +
" (missing=" + missing.to_string() + ", extra=" + extra.to_string() + ")"
issues.push(mqm_issue("numeric_consistency", "major", detail))
}
}
arr_json(issues)
}
// 一键自动评测:内部用 check_terms 推导 term_ok,返回 {qe_score, term_ok, mqm}
pub fn ProphecyEngine::qe_auto(
self : ProphecyEngine,
source : String,
target : String,
match_rate : Double,
) -> Json {
let violations = self.check_terms(source, target)
let term_ok = match violations {
Json::Array(a) => a.length() == 0
_ => true
}
let score = self.qe_score(source, target, match_rate, term_ok)
let tags = self.mqm_tags(source, target, match_rate, term_ok)
obj([
("qe_score", num_json(r4(score))),
("term_ok", num_json(if term_ok { 1.0 } else { 0.0 })),
("mqm", tags),
])
}
// MQM 二次标注 (re-annotation) —— P5 增量(Google 2025-10-28 论文对齐)
// 参考:Riley et al., "Improving Human Translation Evaluation with Re-annotation"
// 核心发现:MQM 单次标注易产生"评估噪音";二次标注可显著提升一致性。
//
// yimai 实现:所有 Critical severity 段强制走二次审流程(deterministic 自重审)。
// 因 mqm_tags 算法 deterministic(R15 契约),同输入必同输出;当前"二次审"是
// 显式声明 Critical 段已被复核、产出审计痕迹(re_annotated / critical_count /
// re_annotations),便于未来扩展为多标注员模型(接口签名可平滑升级为
// mqm_re_annotate_with_rater(rater_id),无需破坏 API)。
//
// 返回 JSON 形态:
// {
// "re_annotated": , // 是否有 ≥ 1 个 Critical 段走了二次审
// "critical_count": , // Critical 段数
// "re_annotations": [ // 每个 Critical 段的二次审结果
// { "dimension": ..., "original_severity": "critical", "re_severity": "critical", "consistent": true },
// ...
// ]
// }
//
// 性能优化:无 Critical 段时跳过二次审(re_annotated=false),单次 qe_auto 即可。
pub fn ProphecyEngine::mqm_re_annotate(
self : ProphecyEngine,
source : String,
target : String,
match_rate : Double,
) -> Json {
let qe = self.qe_auto(source, target, match_rate)
// 从 qe.mqm 提取所有 Critical 段(不依赖强类型 cast,MoonBit 类型推断更宽松)
let critical_arr : Array[Json] = extract_critical_issues(qe)
// 性能优化:无 Critical 段 → 直接跳过二次审
if critical_arr.length() == 0 {
return obj([
("re_annotated", false.to_json()),
("critical_count", num_json(0.0)),
("re_annotations", arr_json([])),
])
}
// 二次标注:deterministic 自重审(当前实现)
// 因算法 deterministic,re_severity == original_severity,consistent = true
// 该实现保留"二次标注"接口语义,未来多标注员模型只需替换 mqm_re_annotate_with_rater
let re_arr : Array[Json] = []
for c in critical_arr {
let dim = issue_dim(c)
let orig = issue_severity(c)
let re_sev = orig // deterministic 算法
re_arr.push(obj([
("dimension", str_json(dim)),
("original_severity", str_json(orig)),
("re_severity", str_json(re_sev)),
("consistent", (orig == re_sev).to_json()),
]))
}
obj([
("re_annotated", true.to_json()),
("critical_count", num_json(critical_arr.length().to_double())),
("re_annotations", arr_json(re_arr)),
])
}
// 从 qe_auto 返回值里提取所有 severity=="critical" 的 issue(扁平 helper,避免 mqm_re_annotate 嵌套 match)
fn extract_critical_issues(qe : Json) -> Array[Json] {
let out : Array[Json] = []
let mqm_opt = match qe {
Json::Object(o) => o.get("mqm")
_ => None
}
let arr = match mqm_opt {
Some(Json::Array(a)) => a
_ => []
}
for it in arr {
let sev = issue_severity(it)
if sev == "critical" {
out.push(it)
}
}
out
}
// 提取单个 issue 的 dimension(无 dimension 默认 "unknown")
fn issue_dim(it : Json) -> String {
match it {
Json::Object(o) =>
match o.get("dimension") {
Some(Json::String(s)) => s
_ => "unknown"
}
_ => "unknown"
}
}
// 提取单个 issue 的 severity(无 severity 默认 "none")
fn issue_severity(it : Json) -> String {
match it {
Json::Object(o) =>
match o.get("severity") {
Some(Json::String(s)) => s
_ => "none"
}
_ => "none"
}
}
// ---- #3 格式保真往返 ----
// 校验 TM 应用前后内联标签/占位符是否保真:返回 missing(源有译无) / extra(译有源无) / ok
pub fn ProphecyEngine::check_format_fidelity(
_self : ProphecyEngine,
source : String,
target : String,
) -> Json {
let st = extract_tags(source)
let tt = extract_tags(target)
let missing : Array[String] = []
for t in st {
if !arr_contains(tt, t) { missing.push(t) }
}
let extra : Array[String] = []
for t in tt {
if !arr_contains(st, t) { extra.push(t) }
}
let ok = missing.length() == 0 && extra.length() == 0
obj([
("ok", num_json(if ok { 1.0 } else { 0.0 })),
("missing", arr_json(missing.map(fn(x) { str_json(x) }))),
("extra", arr_json(extra.map(fn(x) { str_json(x) }))),
])
}
// 将标签/占位符掩码为中性 token,避免污染 fuzzy 的 token 余弦;
// 调用方需保留原文以回填(引擎不做状态存储,符合零依赖/确定性)。
pub fn ProphecyEngine::protect_tags(_self : ProphecyEngine, text : String) -> String {
let tags = extract_tags(text)
let mut out = text
for t in tags {
out = str_replace_all(out, t, "__TAG__")
}
out
}
// ---- #4 多模态 / 截图翻译(OCR 外部桩) ----
// OCR 区域以 JSON 在外部边界流动:每个元素为 { "bbox": [x,y,w,h], "text": "..." }
// 零依赖引擎不内嵌 OCR,ocr_image 仅返回空数组作为桩;调用方应替换为真实 OCR 服务。
/// 外部边界:OCR 识别必须由外部视觉服务完成(Tesseract / 视觉大模型),
/// 零依赖 MoonBit 引擎不内嵌 OCR。此桩返回空;调用方应替换为真实 OCR 调用,
/// 返回的每个元素为 JSON 对象:{ "bbox": [x,y,w,h], "text": "..." }
pub fn ocr_image(_path : String) -> Array[Json] {
[]
}
// 给定 OCR 区域(JSON 对象数组,含 bbox/text)后,做区域文本 ↔ TM 对齐与回填映射(纯逻辑)。
// 返回每区域 {bbox, source_text, match_pct, translation, matched}
pub fn ProphecyEngine::align_regions(
self : ProphecyEngine,
regions : Array[Json],
threshold : Double,
) -> Json {
let out : Array[Json] = []
for rg in regions {
let txt = get_str(rg, "text")
let best = self.fuzzy_match(txt, 1, threshold)
match best {
Json::Array(a) =>
if a.length() > 0 {
let tgt = get_str(a[0], "target")
let pct = get_num(a[0], "match_pct")
out.push(obj([
("bbox", get_field(rg, "bbox")),
("source_text", str_json(txt)),
("match_pct", num_json(pct)),
("translation", str_json(tgt)),
("matched", num_json(1.0)),
]))
} else {
out.push(region_no_match(rg))
}
_ => out.push(region_no_match(rg))
}
}
arr_json(out)
}
fn region_no_match(rg : Json) -> Json {
obj([
("bbox", get_field(rg, "bbox")),
("source_text", str_json(get_str(rg, "text"))),
("match_pct", num_json(0.0)),
("translation", str_json("")),
("matched", num_json(0.0)),
])
}
// ---- #5 本地化 CI / 批处理 ----
// 批量 TM 应用 + 术语门禁:每段取 Top-1 TM 匹配,要求 match_pct≥阈值 且 术语一致
// 方算 pass;返回聚合报告 {total, passed, failed, items}
pub fn ProphecyEngine::batch_apply(
self : ProphecyEngine,
segments : Array[String],
threshold : Double,
) -> Json {
let items : Array[Json] = []
let mut passed = 0
let mut failed = 0
for seg in segments {
let best = self.fuzzy_match(seg, 1, threshold)
let mut tgt = ""
let mut pct = 0.0
let mut has_match = false
match best {
Json::Array(a) =>
if a.length() > 0 {
has_match = true
tgt = get_str(a[0], "target")
pct = get_num(a[0], "match_pct")
}
_ => ()
}
let mut status = "fail_match"
let mut reason = "TM 无 ≥ 阈值匹配"
if has_match {
let v = self.check_terms(seg, tgt)
let vcount = match v { Json::Array(arr) => arr.length(); _ => 0 }
if vcount == 0 {
status = "pass"
reason = "TM 匹配且术语一致"
passed = passed + 1
} else {
status = "fail_term"
reason = "术语一致性校验未通过"
failed = failed + 1
}
} else {
failed = failed + 1
}
items.push(obj([
("segment", str_json(seg)),
("target", str_json(tgt)),
("match_pct", num_json(pct)),
("status", str_json(status)),
("reason", str_json(reason)),
]))
}
obj([
("total", num_json(segments.length().to_double())),
("passed", num_json(passed.to_double())),
("failed", num_json(failed.to_double())),
("items", arr_json(items)),
])
}