Files
2026-10-05 18:24:57 +08:00

850 lines
39 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""数值层:让数字相关的结论由代码决定,而不是由判定模型去猜。只用标准库。
判定模型(Laya / Jev)读的是文本,不做算术。所以检测方案(recipe)里和数字有关的东西
都在这里用确定性的代码处理:
1. 测量规格 每个测量值的单位、小数位、限值、档位、临界带(guard)
2. 硬规则 用测量值写成的条件(all / any / not + 比较),结果是 是/否 或一个等级
3. 文字化 把「82.3」写成「82.3 %(要求 ≥ 70:满足,高出 12.3)」再交给模型,
模型只需要读懂结论,不需要自己比较大小
4. 裁决 题目可以绑定硬规则:规则为准,模型的答案用来交叉核对,冲突或临界时转人工
5. 自检 在每个阈值两侧自动生成边界用例,用来实测模型对数字的判断力
启动器和视觉服务都会导入这个文件。
"""
import math
import random
OPS = (">=", ">", "<=", "<", "==", "!=", "in", "between")
NUMERIC_OPS = (">=", ">", "<=", "<", "between")
SYMBOL = {">=": "≥", ">": ">", "<=": "≤", "<": "<", "==": "=", "!=": "≠"}
TEXT = {
"zh": {
"subject": "检测对象", "measures": "测量结果", "context": "补充信息", "quality": "图像质量", "notes": "说明",
"higher": "高出 %s", "lower": "低 %s", "equal": "正好相等", "edge": "临界", "band": "档位:%s", "conf": "置信度 %s",
"between": "%s ~ %s", "in": "属于 %s", "and": " 且 ", "or": " 或 ", "not": "不满足(%s)",
"sep": ";", "comma": ",", "colon": ":", "lp": "(", "rp": ")", "else": "其余情况",
"standard": "判定标准", "missing": "没有测到", "yes": "是", "no": "否",
"r_conflict": "规则与模型的答案不一致,以规则为准", "r_marginal": "测量值落在临界带内",
"r_low_conf": "模型置信度低于阈值", "r_missing": "缺少测量值:%s", "r_quality": "图像质量不合格:%s",
"r_no_model": "模型没有返回这道题的答案", "r_rule_only": "模型不可用,仅按规则裁决",
"q_blur": "画面模糊(清晰度 %s,要求 ≥ %s)", "q_dark": "画面过暗(亮度 %s,要求 ≥ %s)",
"q_bright": "画面过亮(亮度 %s,要求 ≤ %s)", "q_ok": "合格",
},
"en": {
"subject": "Subject", "measures": "Measurements", "context": "Context", "quality": "Image quality", "notes": "Notes",
"higher": "%s above", "lower": "%s below", "equal": "exactly equal", "edge": "borderline", "band": "band: %s", "conf": "confidence %s",
"between": "%s to %s", "in": "one of %s", "and": " and ", "or": " or ", "not": "not (%s)",
"sep": "; ", "comma": ", ", "colon": ": ", "lp": " (", "rp": ")", "else": "otherwise",
"standard": "Standard", "missing": "not measured", "yes": "yes", "no": "no",
"r_conflict": "the rule and the model disagree; the rule decides", "r_marginal": "a measurement is inside the guard band",
"r_low_conf": "model confidence is below the threshold", "r_missing": "missing measurement: %s",
"r_quality": "image quality check failed: %s", "r_no_model": "the model returned no answer for this question",
"r_rule_only": "the model is unavailable; decided by the rule alone",
"q_blur": "blurry (sharpness %s, required ≥ %s)", "q_dark": "too dark (brightness %s, required ≥ %s)",
"q_bright": "too bright (brightness %s, required ≤ %s)", "q_ok": "ok",
},
}
def tx(lang, key, *args):
text = TEXT["en" if lang == "en" else "zh"][key]
return text % args if args else text
# ----------------------------------------------------------------- 数字格式
def is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def fmt(value, decimals=2):
"""按规格的小数位显示测量值。"""
if not is_number(value):
return str(value)
decimals = max(0, min(6, int(decimals)))
return "%.*f" % (decimals, round(float(value), decimals))
def fmt_short(value):
"""阈值、差值用的短格式:最多 4 位有效小数,去掉多余的 0。"""
if not is_number(value):
return str(value)
text = "%.4f" % float(value)
text = text.rstrip("0").rstrip(".")
return "0" if text in ("-0", "") else text
def spec_of(recipe, name):
spec = (recipe.get("measurements") or {}).get(name)
return spec if isinstance(spec, dict) else {}
def label_of(recipe, name):
return str(spec_of(recipe, name).get("label") or name)
def unit_of(recipe, name):
return str(spec_of(recipe, name).get("unit") or "")
def with_unit(text, unit):
return ("%s %s" % (text, unit)) if unit else text
def normalize(recipe, values):
"""把测量值四舍五入到规格里的小数位。规则和文字说明都用这个值,保证「显示的数」和「拿来比较的数」是同一个。"""
out = {}
for name, value in (values or {}).items():
if is_number(value) and not str(name).startswith("_"):
decimals = max(0, min(6, int(spec_of(recipe, name).get("decimals", 2))))
value = round(float(value), decimals)
out[name] = value
return out
# ----------------------------------------------------------------- 条件
def leaves(node):
"""条件树里所有的比较(叶子)。"""
out = []
if not isinstance(node, dict):
return out
if "all" in node or "any" in node:
for kid in node.get("all") or node.get("any") or []:
out.extend(leaves(kid))
elif "not" in node:
out.extend(leaves(node["not"]))
elif "m" in node:
out.append(node)
return out
def guard_of(recipe, name, threshold):
"""临界带的半宽:测量值离阈值不超过它,就认为这次比较不可靠。"""
spec = spec_of(recipe, name)
guard = 0.0
if is_number(spec.get("guard")):
guard = max(guard, abs(float(spec["guard"])))
if is_number(spec.get("guard_pct")) and is_number(threshold):
guard = max(guard, abs(float(threshold)) * abs(float(spec["guard_pct"])) / 100.0)
return guard
def compare(recipe, leaf, values):
"""算一次比较。返回 dict:ok(True / False / None=缺值)、edge(是否在临界带内)、margin(离阈值的距离)。"""
name, op, target = leaf.get("m"), leaf.get("op", ">="), leaf.get("value")
value = values.get(name)
out = {"m": name, "op": op, "value": target, "actual": value, "ok": None, "edge": False, "margin": None}
if value is None:
return out
if op in ("==", "!=", "in"):
if op == "in":
pool = target if isinstance(target, (list, tuple)) else [target]
ok = any(_same(value, item) for item in pool)
else:
ok = _same(value, target)
if op == "!=":
ok = not ok
out["ok"] = ok
return out
if not is_number(value):
return out
value = float(value)
if op == "between":
if not (isinstance(target, (list, tuple)) and len(target) == 2 and all(is_number(x) for x in target)):
return out
lo, hi = float(min(target)), float(max(target))
out["ok"] = lo <= value <= hi
distance = min(abs(value - lo), abs(value - hi))
out["margin"] = distance
guard = max(guard_of(recipe, name, lo), guard_of(recipe, name, hi))
out["edge"] = guard > 0 and distance <= guard
return out
if not is_number(target):
return out
target = float(target)
out["ok"] = {">=": value >= target, ">": value > target, "<=": value <= target, "<": value < target}[op]
out["margin"] = abs(value - target)
guard = guard_of(recipe, name, target)
out["edge"] = guard > 0 and abs(value - target) <= guard
return out
def _same(a, b):
if is_number(a) and is_number(b):
return float(a) == float(b)
return str(a).strip().lower() == str(b).strip().lower()
def evaluate(recipe, node, values, trace=None):
"""算一棵条件树。返回 (lo, hi, missing):
lo / hi 是把临界带内的比较分别当成「不成立 / 成立」时整棵树的结果。lo == hi 说明结论稳定,
不相等说明有测量值卡在临界带里,换一次测量结论就可能翻转。
"""
if not isinstance(node, dict) or not node:
return True, True, []
if "all" in node or "any" in node:
is_all = "all" in node
parts = [evaluate(recipe, kid, values, trace) for kid in (node.get("all") if is_all else node.get("any")) or []]
missing = [m for p in parts for m in p[2]]
if is_all:
return all(p[0] for p in parts), all(p[1] for p in parts), missing
return any(p[0] for p in parts), any(p[1] for p in parts), missing
if "not" in node:
lo, hi, missing = evaluate(recipe, node["not"], values, trace)
return (not hi), (not lo), missing
result = compare(recipe, node, values)
if trace is not None:
trace.append(result)
if result["ok"] is None:
return False, False, [result["m"]]
if result["edge"]:
return False, True, []
return result["ok"], result["ok"], []
def point(recipe, node, values):
"""不考虑临界带,按测量值本身算出的结果。"""
if not isinstance(node, dict) or not node:
return True
if "all" in node:
return all(point(recipe, kid, values) for kid in node["all"] or [])
if "any" in node:
return any(point(recipe, kid, values) for kid in node["any"] or [])
if "not" in node:
return not point(recipe, node["not"], values)
return bool(compare(recipe, node, values)["ok"])
def run_rule(recipe, rule_id, values, lang="zh"):
"""算一条硬规则。返回 {id, label, type, result, marginal, missing, checks, text}。"""
rule = (recipe.get("rules") or {}).get(rule_id) or {}
kind = "grade" if "cases" in rule else "bool"
out = {"id": rule_id, "label": str(rule.get("label") or rule_id), "type": kind, "result": None,
"marginal": False, "missing": [], "checks": []}
trace = []
if kind == "bool":
lo, hi, missing = evaluate(recipe, rule.get("when") or {}, values, trace)
out["missing"] = sorted(set(missing))
if not out["missing"]:
out["result"] = point(recipe, rule.get("when") or {}, values)
out["marginal"] = lo != hi
else:
chosen, marginal, missing_all = None, False, []
for case in rule.get("cases") or []:
when = case.get("when")
if not when: # 没有条件的一档是兜底
if chosen is None:
chosen = case.get("value")
break
lo, hi, missing = evaluate(recipe, when, values, trace)
missing_all.extend(missing)
if lo != hi:
marginal = True
if chosen is None and point(recipe, when, values):
chosen = case.get("value")
break
out["missing"] = sorted(set(missing_all))
if not out["missing"]:
out["result"] = chosen
out["marginal"] = marginal
seen = set()
for item in trace: # 同一个比较在多档里出现时只留一条
key = (item["m"], item["op"], str(item["value"]))
if key not in seen:
seen.add(key)
out["checks"].append(item)
out["text"] = tx(lang, "sep").join(check_text(recipe, c, lang) for c in out["checks"])
return out
# ----------------------------------------------------------------- 文字化
def threshold_text(recipe, name, op, target):
unit = unit_of(recipe, name)
if op == "between" and isinstance(target, (list, tuple)) and len(target) == 2:
return with_unit("%s ~ %s" % (fmt_short(target[0]), fmt_short(target[1])), unit)
if op == "in":
pool = target if isinstance(target, (list, tuple)) else [target]
return "∈ {%s}" % ", ".join(str(x) for x in pool)
return "%s %s" % (SYMBOL.get(op, op), with_unit(fmt_short(target) if is_number(target) else str(target), unit))
def check_text(recipe, check, lang="zh"):
"""一次比较的完整说法,用在裁决说明里:成熟色占比 82.3 %(≥ 70 %:是)。"""
name = check["m"]
spec = spec_of(recipe, name)
actual = check["actual"]
shown = tx(lang, "missing") if actual is None else with_unit(fmt(actual, spec.get("decimals", 2)), unit_of(recipe, name))
verdict = tx(lang, "missing") if check["ok"] is None else tx(lang, "yes" if check["ok"] else "no")
text = "%s %s%s%s%s%s" % (label_of(recipe, name), shown, tx(lang, "lp"),
threshold_text(recipe, name, check["op"], check["value"]), tx(lang, "colon"), verdict)
if check["edge"]:
text += tx(lang, "comma") + tx(lang, "edge")
return text + tx(lang, "rp")
def comparison_phrase(recipe, name, op, target, value, lang="zh"):
"""测量值旁边的括号内容:≥ 70 %:是,高出 12.3。
写法和题目里的判定标准逐字对应(同样的符号、同样的阈值),后面直接给出这一条成立与否,
再补一句测量值比阈值高多少或低多少。模型只需要把标准里的每一条对上「是 / 否」。
"""
check = compare(recipe, {"m": name, "op": op, "value": target}, {name: value})
head = threshold_text(recipe, name, op, target) + tx(lang, "colon")
if check["ok"] is None:
return head + tx(lang, "missing")
parts = [tx(lang, "yes" if check["ok"] else "no")]
if is_number(value) and is_number(target) and op in (">=", ">", "<=", "<"):
gap = round(float(value) - float(target), 6)
if gap == 0:
parts.append(tx(lang, "equal"))
else:
parts.append(tx(lang, "higher" if gap > 0 else "lower", fmt_short(abs(gap))))
if check["edge"]:
parts.append(tx(lang, "edge"))
return head + tx(lang, "comma").join(parts)
def band_of(recipe, name, value):
"""测量值落在哪一档。bands 从低到高写,每档的 max 是不含的上界,最后一档不写 max。"""
bands = spec_of(recipe, name).get("bands")
if not isinstance(bands, list) or not is_number(value):
return None
for band in bands:
if not isinstance(band, dict):
continue
if not is_number(band.get("max")) or float(value) < float(band["max"]):
return str(band.get("label") or "")
return None
def thresholds_for(recipe, name):
"""这个测量值需要对照的所有阈值:规格里的 limit,加上规则里用到的。"""
found, seen = [], set()
def add(op, target):
key = (op, str(target))
if key not in seen:
seen.add(key)
found.append((op, target))
limit = spec_of(recipe, name).get("limit")
if isinstance(limit, dict):
if is_number(limit.get("min")) and is_number(limit.get("max")):
add("between", [limit["min"], limit["max"]])
elif is_number(limit.get("min")):
add(">=", limit["min"])
elif is_number(limit.get("max")):
add("<=", limit["max"])
for rule in (recipe.get("rules") or {}).values():
if not isinstance(rule, dict):
continue
nodes = [rule.get("when")] + [case.get("when") for case in rule.get("cases") or [] if isinstance(case, dict)]
for node in nodes:
for leaf in leaves(node):
if leaf.get("m") == name and leaf.get("op", ">=") in OPS:
add(leaf.get("op", ">="), leaf.get("value"))
return found
def describe(recipe, name, value, lang="zh", level="compared", detail=None):
"""一个测量值的文字说法。level="raw" 只有数值,"compared" 带上和每个阈值的比较结论。"""
spec = spec_of(recipe, name)
if value is None:
return tx(lang, "missing")
if not is_number(value):
text = str(value)
if isinstance(detail, dict) and is_number(detail.get("prob")):
text += tx(lang, "lp") + tx(lang, "conf", fmt(detail["prob"], 2)) + tx(lang, "rp")
return text
text = with_unit(fmt(value, spec.get("decimals", 2)), unit_of(recipe, name))
if level == "raw":
return text
notes = []
band = band_of(recipe, name, value)
if band:
notes.append(tx(lang, "band", band))
for op, target in thresholds_for(recipe, name):
if op in ("==", "!=", "in"):
continue
notes.append(comparison_phrase(recipe, name, op, target, value, lang))
if notes:
text += tx(lang, "lp") + tx(lang, "sep").join(notes) + tx(lang, "rp")
return text
def condition_text(recipe, node, lang="zh"):
"""把条件树写成一句话,用来放进题目和页面:成熟色占比 ≥ 70 % 且 病斑占比 ≤ 3 %。"""
if not isinstance(node, dict) or not node:
return ""
if "all" in node or "any" in node:
is_all = "all" in node
parts = [condition_text(recipe, kid, lang) for kid in (node.get("all") if is_all else node.get("any")) or []]
parts = [p for p in parts if p]
joined = tx(lang, "and" if is_all else "or").join(parts)
return joined
if "not" in node:
return tx(lang, "not", condition_text(recipe, node["not"], lang))
name, op = node.get("m"), node.get("op", ">=")
return "%s %s" % (label_of(recipe, name), threshold_text(recipe, name, op, node.get("value")))
def rule_text(recipe, rule_id, lang="zh"):
rule = (recipe.get("rules") or {}).get(rule_id) or {}
if "cases" not in rule:
return condition_text(recipe, rule.get("when") or {}, lang)
parts = []
for case in rule.get("cases") or []:
when = case.get("when")
parts.append("%s%s%s" % (case.get("value"), tx(lang, "colon"), condition_text(recipe, when, lang) if when else tx(lang, "else")))
return tx(lang, "sep").join(parts)
def build_state(recipe, values, lang="zh", level="compared", context=None, details=None, quality=None):
"""组装交给判定模型的素材(state)。"""
state = {}
if recipe.get("subject"):
state[tx(lang, "subject")] = str(recipe["subject"])
block = {}
specs = recipe.get("measurements") or {}
names = [n for n in specs if n in values] + [n for n in values if n not in specs]
in_rules = set(leaf.get("m") for rule in (recipe.get("rules") or {}).values() if isinstance(rule, dict)
for node in [rule.get("when")] + [c.get("when") for c in rule.get("cases") or [] if isinstance(c, dict)]
for leaf in leaves(node))
for name in names:
if name.startswith("_") or spec_of(recipe, name).get("hidden"):
continue
if values.get(name) is None and name not in in_rules:
continue # 没有值、规则也用不到的项(例如识别模型还没训练)不写进素材
block[label_of(recipe, name)] = describe(recipe, name, values.get(name), lang, level, (details or {}).get(name))
state[tx(lang, "measures")] = block
if quality and not quality.get("ok", True):
state[tx(lang, "quality")] = tx(lang, "sep").join(quality.get("problems") or [])
if context:
state[tx(lang, "context")] = context
return state
def bound_questions(recipe, lang="zh", append_standard=True):
"""返回发给模型的题目:绑定了硬规则的题目,在题干后面附上判定标准。"""
questions = {}
bindings = recipe.get("bindings") or {}
for qid, spec in (recipe.get("questions") or {}).items():
if not isinstance(spec, dict):
continue
spec = dict(spec)
bind = bindings.get(qid) or {}
rule_id = bind.get("rule")
if append_standard and rule_id and rule_id in (recipe.get("rules") or {}) and bind.get("show_standard", True):
standard = rule_text(recipe, rule_id, lang)
if standard:
spec["instructions"] = "%s\n%s%s%s" % (str(spec.get("instructions") or "").rstrip(), tx(lang, "standard"),
tx(lang, "colon"), standard)
questions[qid] = spec
return questions
# ----------------------------------------------------------------- 图像质量
def check_quality(recipe, values, lang="zh"):
"""按方案里的 quality 设置检查清晰度和亮度。不合格的图不应该拿来下结论。"""
cfg = recipe.get("quality") or {}
problems = []
sharp, bright = values.get("_sharpness"), values.get("_brightness")
if is_number(cfg.get("min_sharpness")) and is_number(sharp) and sharp < cfg["min_sharpness"]:
problems.append(tx(lang, "q_blur", fmt(sharp, 1), fmt_short(cfg["min_sharpness"])))
limits = cfg.get("brightness")
if isinstance(limits, (list, tuple)) and len(limits) == 2 and is_number(bright):
if is_number(limits[0]) and bright < limits[0]:
problems.append(tx(lang, "q_dark", fmt(bright, 1), fmt_short(limits[0])))
if is_number(limits[1]) and bright > limits[1]:
problems.append(tx(lang, "q_bright", fmt(bright, 1), fmt_short(limits[1])))
return {"ok": not problems, "problems": problems, "sharpness": sharp, "brightness": bright}
# ----------------------------------------------------------------- 裁决
def question_type(spec):
kind = (spec or {}).get("type")
return kind if kind in ("noul", "choice", "score") else "noul"
def laya_value(answer, kind):
"""从模型的一道题答案里取出结论和置信度。"""
if not isinstance(answer, dict):
return None, None
conf = answer.get("answer_confidence")
if kind == "choice" and "choice" in answer:
if conf is None and isinstance(answer.get("probabilities"), dict):
probs = [p for p in answer["probabilities"].values() if is_number(p)]
conf = max(probs) if probs else None
return answer.get("choice"), conf
if kind == "score" and "score" in answer:
value = int(round(float(answer["score"]))) if is_number(answer.get("score")) else None
if conf is None and isinstance(answer.get("probabilities"), dict):
probs = [p for p in answer["probabilities"].values() if is_number(p)]
conf = max(probs) if probs else None
return value, conf
if "noul" in answer and is_number(answer.get("noul")):
p = float(answer["noul"])
return p >= 0.5, (conf if conf is not None else max(p, 1 - p))
return None, conf
def rule_value(result, kind):
"""把规则的结果换成和题目类型对应的值,便于和模型的答案比较。"""
if result is None:
return None
if kind == "noul":
return bool(result) if isinstance(result, bool) else str(result).strip().lower() in ("true", "yes", "1", "是")
if kind == "score":
try:
return int(result)
except (TypeError, ValueError):
return None
return str(result)
def same_answer(a, b, kind):
if a is None or b is None:
return None
if kind == "choice":
return str(a) == str(b)
return a == b
def show_value(value, kind, spec, lang="zh"):
if value is None:
return "-"
if kind == "noul":
text = tx(lang, "yes" if value else "no")
return text[:1].upper() + text[1:]
if kind == "score":
levels = (spec or {}).get("criteria")
if isinstance(levels, list) and 0 <= int(value) < len(levels):
return "%s · %s" % (value, levels[int(value)])
return str(value)
return str(value)
def decide(recipe, values, answers=None, lang="zh", min_confidence=None, quality=None, model_error=None):
"""逐题给出最终结论。
answers 是模型返回的 answers(可以是 None:模型不可用时只按规则裁决)。
每道题的结论来源:
绑定了规则且 mode = enforce(默认) 规则为准,模型的答案只用来交叉核对
绑定了规则且 mode = advise 模型为准,规则的结果作为参照
没有绑定规则 模型为准
冲突、临界、低置信度、缺测量值、图像质量不合格,都会把 review 置为 True(建议人工复核)。
"""
answers = answers if isinstance(answers, dict) else {}
bindings = recipe.get("bindings") or {}
rules_cache = {}
verdicts = {}
for qid, spec in (recipe.get("questions") or {}).items():
if not isinstance(spec, dict):
continue
kind = question_type(spec)
bind = bindings.get(qid) or {}
rule_id = bind.get("rule") if bind.get("rule") in (recipe.get("rules") or {}) else None
mode = "advise" if bind.get("mode") == "advise" else "enforce"
item = {"type": kind, "instructions": str(spec.get("instructions") or ""), "final": None, "final_text": "-",
"source": "none", "mode": mode if rule_id else "model", "rule": None, "model": None,
"agree": None, "review": False, "reasons": []}
rule_result = None
if rule_id:
if rule_id not in rules_cache:
rules_cache[rule_id] = run_rule(recipe, rule_id, values, lang)
rule = rules_cache[rule_id]
rule_result = rule_value(rule["result"], kind)
item["rule"] = {"id": rule_id, "label": rule["label"], "result": rule_result,
"result_text": show_value(rule_result, kind, spec, lang), "marginal": rule["marginal"],
"missing": rule["missing"], "checks": rule["checks"], "text": rule["text"],
"standard": rule_text(recipe, rule_id, lang)}
if rule["missing"]:
item["review"] = True
item["reasons"].append(tx(lang, "r_missing", ", ".join(label_of(recipe, m) for m in rule["missing"])))
if rule["marginal"]:
item["review"] = True
item["reasons"].append(tx(lang, "r_marginal"))
model_result, conf = laya_value(answers.get(qid), kind)
if qid in answers:
item["model"] = {"result": model_result, "result_text": show_value(model_result, kind, spec, lang),
"confidence": conf, "answer": answers.get(qid)}
if is_number(min_confidence) and is_number(conf) and conf < float(min_confidence):
item["model"]["low_confidence"] = True
if not rule_id or mode == "advise": # 结论来自模型时,低置信度才需要复核
item["review"] = True
item["reasons"].append(tx(lang, "r_low_conf"))
if rule_id and model_result is not None and rule_result is not None:
item["agree"] = same_answer(rule_result, model_result, kind)
if item["agree"] is False:
item["review"] = True
item["reasons"].append(tx(lang, "r_conflict"))
if rule_id and mode == "enforce" and rule_result is not None:
item["final"], item["source"] = rule_result, "rule"
elif model_result is not None:
item["final"], item["source"] = model_result, "model"
elif rule_result is not None: # advise 模式下模型没给答案:退回规则
item["final"], item["source"] = rule_result, "rule"
if model_result is None:
if rule_id and rule_result is not None:
if model_error:
item["reasons"].append(tx(lang, "r_rule_only"))
else:
item["review"] = True
item["reasons"].append(tx(lang, "r_no_model"))
item["final_text"] = show_value(item["final"], kind, spec, lang)
verdicts[qid] = item
reasons = []
if quality and not quality.get("ok", True):
reasons.append(tx(lang, "r_quality", tx(lang, "sep").join(quality.get("problems") or [])))
for item in verdicts.values():
for reason in item["reasons"]:
if reason not in reasons:
reasons.append(reason)
review = bool(quality and not quality.get("ok", True)) or any(v["review"] for v in verdicts.values())
return {"verdicts": verdicts, "review": review, "reasons": reasons}
# ----------------------------------------------------------------- 检查方案
def validate(recipe):
"""检查方案里数值层相关的部分,返回问题列表(空 = 没问题)。"""
problems = []
if not isinstance(recipe, dict):
return ["recipe must be an object"]
specs = recipe.get("measurements") if isinstance(recipe.get("measurements"), dict) else {}
rules = recipe.get("rules") if isinstance(recipe.get("rules"), dict) else {}
questions = recipe.get("questions") if isinstance(recipe.get("questions"), dict) else {}
for rule_id, rule in rules.items():
if not isinstance(rule, dict):
problems.append("rule '%s' must be an object" % rule_id)
continue
nodes = [rule.get("when")] + [c.get("when") for c in rule.get("cases") or [] if isinstance(c, dict)]
if "cases" not in rule and not rule.get("when"):
problems.append("rule '%s' needs 'when' or 'cases'" % rule_id)
for node in nodes:
for leaf in leaves(node):
if leaf.get("m") not in specs:
problems.append("rule '%s' uses unknown measurement '%s'" % (rule_id, leaf.get("m")))
if leaf.get("op", ">=") not in OPS:
problems.append("rule '%s' uses unknown operator '%s'" % (rule_id, leaf.get("op")))
for qid, bind in (recipe.get("bindings") or {}).items():
if qid not in questions:
problems.append("binding '%s' has no matching question" % qid)
continue
rule_id = (bind or {}).get("rule")
if rule_id not in rules:
problems.append("binding '%s' points to unknown rule '%s'" % (qid, rule_id))
continue
kind = question_type(questions[qid])
rule = rules[rule_id]
if kind == "noul" and "cases" in rule:
problems.append("question '%s' is yes/no but rule '%s' returns a grade" % (qid, rule_id))
if kind != "noul" and "cases" not in rule:
problems.append("question '%s' is %s but rule '%s' returns yes/no" % (qid, kind, rule_id))
if kind == "choice" and "cases" in rule:
criteria = questions[qid].get("criteria")
labels = set(criteria) if isinstance(criteria, (dict, list)) else set()
for case in rule.get("cases") or []:
if labels and str(case.get("value")) not in set(str(x) for x in labels):
problems.append("rule '%s' returns '%s', which is not an option of question '%s'" % (rule_id, case.get("value"), qid))
return problems
# ----------------------------------------------------------------- 边界用例(数字判断力自检)
OFFSETS = (-0.30, -0.10, -0.03, -0.005, 0.005, 0.03, 0.10, 0.30)
def _span(recipe, name):
"""测量值的取值范围,用来把生成的数值限制在合理区间。"""
spec = spec_of(recipe, name)
rng = spec.get("range")
if isinstance(rng, (list, tuple)) and len(rng) == 2 and all(is_number(x) for x in rng):
return float(rng[0]), float(rng[1])
return (0.0, 100.0) if spec.get("unit") == "%" else (0.0, float("inf"))
def _clamp(recipe, name, value):
lo, hi = _span(recipe, name)
value = max(lo, min(hi, value))
decimals = int(spec_of(recipe, name).get("decimals", 2))
return round(value, decimals) if decimals > 0 else float(int(round(value)))
def _near(recipe, name, target, offset):
"""阈值旁边的一个数:偏离 offset(相对值),阈值是 0 时按取值范围偏移。"""
target = float(target)
lo, hi = _span(recipe, name)
scale = abs(target) if target else (hi - lo if math.isfinite(hi - lo) and hi > lo else 1.0) * 0.1
step = 10 ** (-int(spec_of(recipe, name).get("decimals", 2)))
delta = offset * scale
if abs(delta) < step: # 至少错开一个最小刻度,否则四舍五入后和阈值相等
delta = step if offset > 0 else -step
return _clamp(recipe, name, target + delta)
def _typical(recipe, name, good=True):
"""一个明显合格(或明显不合格)的取值,作为其它测量值的背景。"""
spec = spec_of(recipe, name)
if is_number(spec.get("typical")) and good:
return _clamp(recipe, name, float(spec["typical"]))
pairs = [(op, t) for op, t in thresholds_for(recipe, name) if op in (">=", ">", "<=", "<") and is_number(t)]
if not pairs:
lo, hi = _span(recipe, name)
return _clamp(recipe, name, lo if not math.isfinite(hi) else (lo + hi) / 2)
uppers = [float(t) for op, t in pairs if op in ("<=", "<")]
lowers = [float(t) for op, t in pairs if op in (">=", ">")]
if good:
if uppers and not lowers:
return _clamp(recipe, name, min(uppers) * 0.4)
if lowers and not uppers:
return _near(recipe, name, max(lowers), 0.3)
return _clamp(recipe, name, (max(lowers) + min(uppers)) / 2)
if uppers:
return _near(recipe, name, max(uppers), 0.5)
return _clamp(recipe, name, min(lowers) * 0.4)
def probe_cases(recipe, limit=48, seed=7):
"""为方案里绑定了硬规则的题目生成边界用例。
每个用例是一组测量值,正确答案由 run_rule 算出,所以用例一定和规则一致。
生成方式:每次让一个测量值落在某个阈值的两侧(±0.5%、±3%、±10%、±30%),其它测量值取明显合格的值;
再补一些随机组合。离阈值最近的相对距离记在 distance 里,用来区分「贴近边界」和「远离边界」的表现。
"""
bindings = recipe.get("bindings") or {}
questions = recipe.get("questions") or {}
rules = recipe.get("rules") or {}
bound = [(qid, bindings[qid].get("rule")) for qid in questions
if isinstance(bindings.get(qid), dict) and bindings[qid].get("rule") in rules]
if not bound:
return []
used = []
for _, rule_id in bound:
rule = rules[rule_id]
nodes = [rule.get("when")] + [c.get("when") for c in rule.get("cases") or [] if isinstance(c, dict)]
for node in nodes:
for leaf in leaves(node):
if leaf.get("op", ">=") in NUMERIC_OPS and leaf.get("m") in (recipe.get("measurements") or {}):
used.append(leaf)
names = []
for leaf in used:
if leaf["m"] not in names:
names.append(leaf["m"])
if not names:
return []
base_good = {n: _typical(recipe, n, True) for n in names}
base_bad = {n: _typical(recipe, n, False) for n in names}
rng = random.Random(seed)
vectors, seen = [], set()
def push(vec):
key = tuple(vec[n] for n in names)
if key not in seen:
seen.add(key)
vectors.append(dict(vec))
for leaf in used:
targets = leaf["value"] if leaf.get("op") == "between" else [leaf["value"]]
for target in targets if isinstance(targets, (list, tuple)) else [targets]:
if not is_number(target):
continue
for offset in OFFSETS:
vec = dict(base_good)
vec[leaf["m"]] = _near(recipe, leaf["m"], target, offset)
push(vec)
push(base_good)
push(base_bad)
thresholds = {n: [float(t) for op, t in thresholds_for(recipe, n) if is_number(t)] or [1.0] for n in names}
for _ in range(limit * 4):
if len(vectors) >= max(limit, 8):
break
vec = {}
for n in names:
target = rng.choice(thresholds[n])
vec[n] = _near(recipe, n, target, rng.choice(OFFSETS + (-0.6, 0.6)))
push(vec)
if len(vectors) > limit: # 太多时均匀抽取,保持各个阈值都有用例
step = len(vectors) / float(limit)
vectors = [vectors[int(i * step)] for i in range(limit)]
cases = []
for vec in vectors:
gold, marginal = {}, False
for qid, rule_id in bound:
result = run_rule(recipe, rule_id, vec)
gold[qid] = rule_value(result["result"], question_type(questions[qid]))
marginal = marginal or result["marginal"]
distance = None
for leaf in used:
targets = leaf["value"] if isinstance(leaf["value"], (list, tuple)) else [leaf["value"]]
for target in targets:
if is_number(target) and is_number(vec.get(leaf["m"])):
lo, hi = _span(recipe, leaf["m"])
scale = abs(float(target)) or ((hi - lo) * 0.1 if math.isfinite(hi - lo) and hi > lo else 1.0)
d = abs(vec[leaf["m"]] - float(target)) / scale
distance = d if distance is None else min(distance, d)
cases.append({"values": vec, "gold": gold, "distance": round(distance, 4) if distance is not None else None,
"marginal": marginal})
return cases
def score_probe(recipe, cases, answers_list):
"""对照用例的正确答案给模型打分。answers_list 和 cases 一一对应,每项是模型返回的 answers。"""
questions = recipe.get("questions") or {}
report = {}
for index, case in enumerate(cases):
answers = answers_list[index] if index < len(answers_list) and isinstance(answers_list[index], dict) else {}
for qid, gold in case["gold"].items():
kind = question_type(questions.get(qid))
got, conf = laya_value(answers.get(qid), kind)
row = report.setdefault(qid, {"n": 0, "correct": 0, "near_n": 0, "near_correct": 0, "far_n": 0,
"far_correct": 0, "conf_sum": 0.0, "conf_n": 0, "misses": []})
if gold is None:
continue
ok = same_answer(gold, got, kind) is True
near = case["distance"] is not None and case["distance"] <= 0.05
row["n"] += 1
row["correct"] += int(ok)
row["near_n" if near else "far_n"] += 1
row["near_correct" if near else "far_correct"] += int(ok)
if is_number(conf):
row["conf_sum"] += float(conf)
row["conf_n"] += 1
if not ok and len(row["misses"]) < 6:
row["misses"].append({"values": case["values"], "expected": gold, "got": got, "confidence": conf,
"distance": case["distance"]})
for row in report.values():
row["accuracy"] = round(row["correct"] / row["n"], 4) if row["n"] else None
row["near_accuracy"] = round(row["near_correct"] / row["near_n"], 4) if row["near_n"] else None
row["far_accuracy"] = round(row["far_correct"] / row["far_n"], 4) if row["far_n"] else None
row["mean_confidence"] = round(row.pop("conf_sum") / row["conf_n"], 4) if row["conf_n"] else None
row.pop("conf_n", None)
return report
def finetune_records(recipe, cases, lang="zh", level="compared"):
"""把用例写成 {state, questions, gold} 记录,对应上游微调脚本读取的 JSONL 结构。"""
questions = bound_questions(recipe, lang)
records = []
for case in cases:
gold = {qid: value for qid, value in case["gold"].items() if value is not None}
if not gold:
continue
records.append({"state": build_state(recipe, case["values"], lang, level),
"questions": {qid: questions[qid] for qid in gold if qid in questions}, "gold": gold})
return records