850 lines
39 KiB
Python
850 lines
39 KiB
Python
# -*- 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
|