415 lines
17 KiB
Python
Raw 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 -*-
"""分层诊断运行器与诊断产物头校验。
确定性层支持 regex、handler 与 density;语义层由外部 detector/人工产出 finding,
再通过当前正文、active 规则、版本与 layer 绑定门。所有路径只产发现,不修改正文。
"""
from __future__ import annotations
import hashlib
import re
from collections import defaultdict
from .carriers import carrier_at, carrier_ranges, carve_out_applies, scope_allows
from .schemas import validate
class ArtifactIncomplete(ValueError):
"""诊断产物头或外部发现不满足合同。"""
def text_hash(text: str) -> str:
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def context_window(text: str, start: int, end: int, width: int = 12) -> str:
"""span 前后各 width 字符,换行转空格。"""
lo, hi = max(0, start - width), min(len(text), end + width)
return text[lo:hi].replace("\n", " ")
def _decision(rule: dict, carrier: str) -> tuple[str, str, str]:
"""给出保守的诊断建议;命中不自动等于修改命令。"""
if carve_out_applies(rule, carrier):
return "ask", "carve_out_candidate", "medium"
disposition = rule["default_disposition"]
if disposition == "blocking" and rule["layer"] == "mechanical":
return "repair", "none", "high"
if disposition == "advisory":
return "ask", "unknown", "low"
return "ask", "pending_arbitration", "high"
def _finding(rule: dict, *, finding_id: str, text: str, spans: list[str], start: int, end: int,
carrier: str, evidence: str, width: int) -> dict:
decision, possible_function, confidence = _decision(rule, carrier)
carve_outs = list(rule.get("carve_out", [])) if carve_out_applies(rule, carrier) else []
return {
"id": finding_id,
"text_hash": text_hash(text),
"rule_id": rule["id"],
"rule_version": rule["version"],
"spans": spans,
"context_window": context_window(text, start, end, width),
"layer": rule["layer"],
"evidence": evidence,
"possible_function": possible_function,
"confidence": confidence,
"decision_proposal": decision,
"carrier": carrier,
"default_disposition": rule["default_disposition"],
"carve_out_candidates": carve_outs,
}
def _regex_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
rows = []
pattern = re.compile(rule["trigger"]["pattern"], re.MULTILINE)
for match in pattern.finditer(text):
carrier = carrier_at(text, match.start(), match.end(), ranges)
if not scope_allows(rule["carrier_scope"], carrier):
continue
rows.append(_finding(
rule,
finding_id="",
text=text,
spans=[match.group(0)],
start=match.start(),
end=match.end(),
carrier=carrier,
evidence=f"确定性正则命中:{rule['trigger']['pattern']}",
width=width,
))
return rows
def _sentence_rows(text: str) -> list[tuple[int, int, str]]:
pattern = re.compile(r"[^。!?!?\n]+[。!?!?]?", re.MULTILINE)
return [
(match.start(), match.end(), match.group(0).strip())
for match in pattern.finditer(text)
if match.group(0).strip()
]
def _short_sentence_runs(text: str, rule: dict, ranges, width: int) -> list[dict]:
trigger = rule["trigger"]
max_chars = int(trigger.get("max_chars", 8))
min_run = int(trigger.get("min_run", 4))
rows = _sentence_rows(text)
findings = []
run: list[tuple[int, int, str, str]] = []
def flush() -> None:
if len(run) < min_run:
run.clear()
return
start, end = run[0][0], run[-1][1]
carrier = run[0][3]
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in run],
start=start,
end=end,
carrier=carrier,
evidence=f"handler=short_sentence_run,连续 {len(run)} 句不超过 {max_chars} 字",
width=width,
))
run.clear()
for start, end, sentence in rows:
carrier = carrier_at(text, start, end, ranges)
length = len(re.sub(r"[\s。!?!?]", "", sentence))
if length <= max_chars and scope_allows(rule["carrier_scope"], carrier):
if run and run[-1][3] != carrier:
flush()
run.append((start, end, sentence, carrier))
else:
flush()
flush()
return findings
def _repeated_sentence_starts(text: str, rule: dict, ranges, width: int) -> list[dict]:
trigger = rule["trigger"]
min_run = int(trigger.get("min_run", 3))
max_chars = int(trigger.get("max_chars", 4))
grouped: dict[str, list[tuple[int, int, str, str]]] = defaultdict(list)
for start, end, sentence in _sentence_rows(text):
carrier = carrier_at(text, start, end, ranges)
if not scope_allows(rule["carrier_scope"], carrier):
continue
normalized = re.sub(r"^[\s「『“]+", "", sentence)
prefix = normalized[:max_chars]
if prefix:
grouped[prefix].append((start, end, sentence, carrier))
findings = []
for prefix, rows in sorted(grouped.items()):
if len(rows) < min_run:
continue
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in rows],
start=rows[0][0],
end=rows[-1][1],
carrier=rows[0][3] if len({item[3] for item in rows}) == 1 else "mixed",
evidence=f"handler=repeated_sentence_start,句首「{prefix}」重复 {len(rows)} 次",
width=width,
))
return findings
# 摄像头式动作清单:分句切分后,短小且命中动作词、又无因果/心理连接的分句成串出现才算异常。
_CLAUSE_SPLIT_PATTERN = re.compile(r"[^,,。!?!?;;::\n]+")
_ACTION_CONNECTIVE_PATTERN = re.compile(
r"因为|所以|于是|结果|为了|忽然|突然|竟然|不料|心中|心里|觉得|感到|想起|暗想"
)
def _camera_action_lists(text: str, rule: dict, ranges, width: int) -> list[dict]:
"""连续短动作分句清单(P-11):只报无选择、无因果、无情绪变化的逐帧罗列。
动作词表放在规则 trigger.pattern 里,handler 只做结构判定;
战斗动作链是否保留交给 carve_out 与人工复核,不在这里裁决。
"""
trigger = rule["trigger"]
max_chars = int(trigger.get("max_chars", 6))
min_run = int(trigger.get("min_run", 4))
pattern = trigger.get("pattern")
if not isinstance(pattern, str) or not pattern.strip():
raise ArtifactIncomplete(f"规则 {rule['id']}: camera_action_list 缺动作词表 pattern")
verb = re.compile(pattern)
findings: list[dict] = []
run: list[tuple[int, int, str, str]] = []
def flush() -> None:
if len(run) >= min_run:
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in run],
start=run[0][0],
end=run[-1][1],
carrier=run[0][3] if len({item[3] for item in run}) == 1 else "mixed",
evidence=f"handler=camera_action_list,连续 {len(run)} 个短动作分句无因果连接",
width=width,
))
run.clear()
for match in _CLAUSE_SPLIT_PATTERN.finditer(text):
clause = re.sub(r"\s", "", match.group(0))
carrier = carrier_at(text, match.start(), match.end(), ranges)
qualifies = (
bool(clause)
and len(clause) <= max_chars
and verb.search(clause) is not None
and _ACTION_CONNECTIVE_PATTERN.search(clause) is None
and scope_allows(rule["carrier_scope"], carrier)
)
if qualifies:
if run and run[-1][3] != carrier:
flush()
run.append((match.start(), match.end(), clause, carrier))
else:
flush()
flush()
return findings
def _uniform_paragraph_lengths(text: str, rule: dict, ranges, width: int) -> list[dict]:
"""段长过度均匀(P-05 先行子集):连续段落长度贴近均值才提示。
只量化段长分布;目录要求最终以作品/场景基线定阈值,tolerance 是冷启动
默认值,属于可审配置而非固定长短句比例目标。段尾与句法整齐暂不量化。
"""
trigger = rule["trigger"]
min_run = int(trigger.get("min_run", 4))
tolerance = int(trigger.get("tolerance", 15)) / 100
paragraphs: list[tuple[int, int, str, int, str, bool]] = []
for match in re.finditer(r"[^\n]+", text):
body = re.sub(r"\s", "", match.group(0))
carrier = carrier_at(text, match.start(), match.end(), ranges)
allowed = bool(body) and scope_allows(rule["carrier_scope"], carrier)
paragraphs.append((match.start(), match.end(), body, len(body), carrier, allowed))
findings: list[dict] = []
index = 0
total = len(paragraphs)
while index < total:
if not paragraphs[index][5]:
index += 1
continue
end = index
while end + 1 < total and paragraphs[end + 1][5]:
window = paragraphs[index:end + 2]
mean = sum(item[3] for item in window) / len(window)
if all(abs(item[3] - mean) <= mean * tolerance for item in window):
end += 1
else:
break
count = end - index + 1
if count >= min_run:
window = paragraphs[index:end + 1]
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[item[2] for item in window],
start=window[0][0],
end=window[-1][1],
carrier=window[0][4] if len({item[4] for item in window}) == 1 else "mixed",
evidence=(
f"handler=uniform_paragraph_length,连续 {count} 段段长贴近均值"
f"(容差 {int(tolerance * 100)}%)"
),
width=width,
))
index = end + 1
else:
index += 1
return findings
def _handler_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
handler = rule["trigger"]["handler"]
if handler == "short_sentence_run":
return _short_sentence_runs(text, rule, ranges, width)
if handler == "repeated_sentence_start":
return _repeated_sentence_starts(text, rule, ranges, width)
if handler == "camera_action_list":
return _camera_action_lists(text, rule, ranges, width)
if handler == "uniform_paragraph_length":
return _uniform_paragraph_lengths(text, rule, ranges, width)
raise ArtifactIncomplete(f"规则 {rule['id']} 使用未知 handler: {handler}")
def _density_findings(text: str, rule: dict, ranges, width: int) -> list[dict]:
trigger = rule["trigger"]
window_chars = int(trigger["window_chars"])
min_hits = int(trigger["min_hits"])
matches = []
for match in re.finditer(trigger["pattern"], text, flags=re.MULTILINE):
carrier = carrier_at(text, match.start(), match.end(), ranges)
if scope_allows(rule["carrier_scope"], carrier):
matches.append((match.start(), match.end(), match.group(0), carrier))
findings = []
consumed_until = -1
for index, item in enumerate(matches):
if item[0] < consumed_until:
continue
window_end = item[0] + window_chars
cluster = [candidate for candidate in matches[index:] if candidate[0] < window_end]
if len(cluster) < min_hits:
continue
start, end = cluster[0][0], cluster[-1][1]
carrier = cluster[0][3] if len({entry[3] for entry in cluster}) == 1 else "mixed"
findings.append(_finding(
rule,
finding_id="",
text=text,
spans=[entry[2] for entry in cluster],
start=start,
end=end,
carrier=carrier,
evidence=f"density 窗口 {window_chars} 字内命中 {len(cluster)} 次(阈值 {min_hits})",
width=width,
))
consumed_until = end
return findings
def run_deterministic_rules(text: str, rules: list, rule_library_version: str, mode: str,
window_width: int = 12) -> dict:
"""执行 active 的 regex/handler/density 规则,返回完整诊断产物。"""
if not isinstance(text, str) or not text:
raise ArtifactIncomplete("诊断文本不能为空")
if mode not in {"Audit", "Patch"}:
raise ArtifactIncomplete(f"诊断 mode 非法: {mode}")
if isinstance(window_width, bool) or not isinstance(window_width, int) or window_width < 0:
raise ArtifactIncomplete("诊断 context window 必须是非负整数")
ranges = carrier_ranges(text)
findings = []
for rule in rules:
if rule["status"] != "active":
continue
trigger_type = rule["trigger"]["type"]
if trigger_type == "regex":
rows = _regex_findings(text, rule, ranges, window_width)
elif trigger_type == "handler":
rows = _handler_findings(text, rule, ranges, window_width)
elif trigger_type == "density":
rows = _density_findings(text, rule, ranges, window_width)
else:
continue
for row in rows:
row["id"] = f"f{len(findings) + 1}"
findings.append(row)
return {
"text_hash": text_hash(text),
"rule_library_version": rule_library_version,
"mode": mode,
"findings": findings,
"executed_layers": sorted({finding["layer"] for finding in findings}),
}
def run_regex_rules(text: str, rules: list, rule_library_version: str, mode: str,
window_width: int = 12) -> dict:
"""兼容旧调用名;现在会执行全部确定性触发器。"""
return run_deterministic_rules(text, rules, rule_library_version, mode, window_width)
def merge_model_findings(artifact: dict, external: list, *, rules: dict | None = None,
text: str | None = None):
"""注入 model_judgment finding,并绑定当前正文与 active 规则。"""
base = len(artifact["findings"])
for index, item in enumerate(external):
validate(item, "finding")
if item["text_hash"] != artifact["text_hash"]:
raise ArtifactIncomplete(f"外部发现 {item.get('id')} 的 text_hash 与诊断文本不一致")
finding = dict(item)
if rules is not None:
rule = rules.get(finding["rule_id"])
if rule is None or rule.get("status") != "active":
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 未绑定 active 规则")
if rule["trigger"]["type"] != "model_judgment":
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 不能冒充确定性规则结果")
if finding["rule_version"] != rule["version"] or finding["layer"] != rule["layer"]:
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 的规则版本或 layer 不一致")
if text is not None:
missing = [span for span in finding["spans"] if span not in text]
if missing:
raise ArtifactIncomplete(f"外部发现 {finding.get('id')} 的 span 不在当前正文")
if "carrier" not in finding:
first = finding["spans"][0]
start = text.find(first)
finding["carrier"] = carrier_at(text, start, start + len(first)) if start >= 0 else "unknown"
finding["id"] = finding.get("id") or f"f{base + index + 1}"
artifact["findings"].append(finding)
return artifact
def validate_artifact(artifact: dict):
"""产物头完整性门禁:缺项即视为诊断未发生。"""
for key in ("text_hash", "rule_library_version", "mode", "findings"):
if key not in artifact or artifact[key] in (None, ""):
raise ArtifactIncomplete(f"诊断产物缺 {key},视为诊断未发生")
if artifact["mode"] not in {"Audit", "Patch"}:
raise ArtifactIncomplete(f"诊断产物 mode 非法: {artifact['mode']}")
if not isinstance(artifact["findings"], list):
raise ArtifactIncomplete("诊断产物 findings 必须是数组")
for finding in artifact["findings"]:
validate(finding, "finding")
return True
__all__ = [
"ArtifactIncomplete", "text_hash", "context_window", "run_deterministic_rules",
"run_regex_rules", "merge_model_findings", "validate_artifact",
]