# -*- 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", ]