"""进化引擎辅助函数与验证逻辑。 验证(validate_skill / validate_system / validate_tool)、受保护区构建、 编辑预算退火、rank-and-clip 裁剪、格式化工具等纯/准纯函数。 Task 8 将在此基础上添加进化入口(evolve_skill / evolve_system / evolve_tool)。 不依赖 app/ 或 adapters/(LLMProvider 通过 core.protocols 注入)。 """ from __future__ import annotations import json import re from dataclasses import asdict, dataclass, field from typing import TYPE_CHECKING, Any from loguru import logger from core.evolution.patch import ( APPENDIX_END, APPENDIX_START, momentum_region_bounds, ) if TYPE_CHECKING: from core.evolution.protocols import SkillStore from core.evolution.types import RejectedEdit from core.protocols import LLMProvider # ========================================================================= # 0. 局部类型 # ========================================================================= @dataclass class ValidationResult: """格式验证的结果。 属性: passed: 验证是否通过。 errors: 失败原因列表;passed=True 时为空。 """ passed: bool errors: list[str] = field(default_factory=list) # ========================================================================= # A. 内部辅助函数 # ========================================================================= def _parse_frontmatter(text: str) -> dict[str, str] | None: """解析 YAML frontmatter,失败返回 None。 参数: text: Markdown 文件全文。 返回: frontmatter 字典,无有效 frontmatter 时返回 None。 """ import yaml as _yaml match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) if not match: return None try: return _yaml.safe_load(match.group(1)) except _yaml.YAMLError: return None def _strip_appendix_region(text: str) -> str: """剥离 appendix 受保护区(含 marker),返回其余正文。 宽松语义:APPENDIX_START / APPENDIX_END 任一缺失即当作「无区」原样返回,不报错。 """ if APPENDIX_START in text and APPENDIX_END in text: head, rest = text.split(APPENDIX_START, 1) _, tail = rest.split(APPENDIX_END, 1) return head.rstrip() + tail return text def _strip_momentum_region(text: str) -> str: """剥离 momentum 受保护区(含 marker),返回其余正文。 严格语义:委托 momentum_region_bounds 做配对检测, marker 损坏/不配对时 raise ValueError。 异常: ValueError: momentum marker 损坏/不配对。 """ bounds = momentum_region_bounds(text) if bounds is None: return text start, end = bounds return text[:start].rstrip() + text[end:] def _strip_protected_regions(text: str) -> str: """剥离 appendix + momentum 两个受保护区,返回正文部分。 先 appendix(宽松),再 momentum(严格)——顺序有关: appendix 宽松剥离不会误杀 momentum marker。 异常: ValueError: momentum marker 损坏/不配对。 """ text = _strip_appendix_region(text) text = _strip_momentum_region(text) return text def _check_length(original: str, evolved: str) -> list[str]: """检查改写后长度是否在 [0.3x, 2.0x] 范围内。 仅比正文,剔除 appendix + momentum 两区。orig_len==0 时跳过。 参数: original: 改写前全文。 evolved: 改写后全文。 返回: 错误消息列表(空列表表示通过)。 """ errors: list[str] = [] orig_body = _strip_protected_regions(original) evol_body = _strip_protected_regions(evolved) orig_len = len(orig_body) if orig_len == 0: return errors ratio = len(evol_body) / orig_len evol_len = len(evol_body) if ratio > 2.0: errors.append( f"长度超限: {evol_len} 字符是原文 {orig_len} 的 {ratio:.1f} 倍 (上限 2.0)" ) if ratio < 0.3: errors.append( f"长度不足: {evol_len} 字符是原文 {orig_len} 的 {ratio:.1f} 倍 (下限 0.3)" ) return errors def _check_code_blocks(text: str) -> list[str]: """检查代码块是否闭合。 参数: text: 待检查的文本。 返回: 错误消息列表(空列表表示通过)。 """ count = text.count("```") if count % 2 != 0: return [f"Markdown 格式错误: 代码块未闭合 (``` 出现 {count} 次)"] return [] def _extract_section(text: str, heading: str) -> str | None: """提取 ## heading 到下一个 ## 之间的文本。 参数: text: Markdown 全文。 heading: 二级标题名。 返回: 该 section 的完整文本(含标题行),未找到时返回 None。 """ pattern = rf"(## {re.escape(heading)}.*?)(?=\n## |\Z)" match = re.search(pattern, text, re.DOTALL) return match.group(1).strip() if match else None # ========================================================================= # B. 受保护区构建 # ========================================================================= def _appendix_span(content: str) -> str: """返回 appendix 受保护区整段(含 marker);不存在返回空串。 参数: content: 文本全文。 返回: appendix 区的完整文本(含 marker),或空串。 """ if APPENDIX_START in content and APPENDIX_END in content: start = content.index(APPENDIX_START) end = content.index(APPENDIX_END) + len(APPENDIX_END) return content[start:end] return "" def _momentum_span(content: str) -> str: """返回 momentum 受保护区整段(含 marker);不存在返回空串。 委托 momentum_region_bounds 做配对检测: marker 损坏/不配对时由其 raise ValueError。 参数: content: 文本全文。 返回: momentum 区的完整文本(含 marker),或空串。 异常: ValueError: momentum marker 损坏/不配对。 """ bounds = momentum_region_bounds(content) if bounds is None: return "" start, end = bounds return content[start:end] def _skill_protected_spans(text: str) -> list[str]: """Skill 冻结块:frontmatter + appendix 区 + momentum 区(各项可选)。 参数: text: Skill 文件全文。 返回: 冻结文本块列表。 """ spans: list[str] = [] match = re.match(r"^---\n.*?\n---", text, re.DOTALL) if match: spans.append(match.group(0)) appendix = _appendix_span(text) if appendix: spans.append(appendix) momentum = _momentum_span(text) if momentum: spans.append(momentum) return spans def _system_protected_spans(text: str) -> list[str]: """System Prompt 冻结块:能力边界 / 输出格式 / 视频树结构 三段 + appendix 区。 参数: text: system.md 全文。 返回: 冻结文本块列表。 """ spans: list[str] = [ section for section in ( _extract_section(text, name) for name in ("能力边界", "输出格式", "视频树结构") ) if section ] appendix = _appendix_span(text) if appendix: spans.append(appendix) return spans def _tool_protected_spans(text: str) -> list[str]: """Tool Prompt 冻结块:输出格式段 + appendix 区。 参数: text: Tool Prompt 全文。 返回: 冻结文本块列表。 """ spans: list[str] = [] section = _extract_section(text, "输出格式") if section: spans.append(section) appendix = _appendix_span(text) if appendix: spans.append(appendix) return spans # ========================================================================= # C. 验证函数 # ========================================================================= def validate_skill(original: str, evolved: str) -> ValidationResult: """校验 Skill 改写结果。 检查项: frontmatter 三字段保留(name / description / task_type)、 长度比在 [0.3, 2.0]、代码块闭合。 参数: original: 改写前的 Skill 文件全文。 evolved: 改写后的 Skill 文件全文。 返回: ValidationResult 实例。 """ errors: list[str] = [] orig_fm = _parse_frontmatter(original) evol_fm = _parse_frontmatter(evolved) if orig_fm is None: errors.append("原文缺少有效 frontmatter") elif evol_fm is None: errors.append("改写后缺少有效 frontmatter") else: for key in ("name", "description", "task_type"): if orig_fm.get(key) != evol_fm.get(key): errors.append( f"frontmatter 字段 {key} 被修改: " f"{orig_fm.get(key)!r} → {evol_fm.get(key)!r}" ) errors.extend(_check_length(original, evolved)) errors.extend(_check_code_blocks(evolved)) return ValidationResult(passed=len(errors) == 0, errors=errors) def validate_system(original: str, evolved: str) -> ValidationResult: """校验 System Prompt 改写结果。 检查项: 三个冻结区值比较(能力边界 / 输出格式 / 视频树结构)、 长度比在 [0.3, 2.0]、代码块闭合。 参数: original: 改写前的 system.md 全文。 evolved: 改写后的 system.md 全文。 返回: ValidationResult 实例。 """ errors: list[str] = [] frozen_sections = ["能力边界", "输出格式", "视频树结构"] for section_name in frozen_sections: orig_section = _extract_section(original, section_name) if orig_section is None: continue evol_section = _extract_section(evolved, section_name) if evol_section is None: errors.append(f"冻结区 '## {section_name}' 在改写后缺失") elif orig_section != evol_section: errors.append(f"冻结区 '## {section_name}' 在改写后被修改") errors.extend(_check_length(original, evolved)) errors.extend(_check_code_blocks(evolved)) return ValidationResult(passed=len(errors) == 0, errors=errors) def validate_tool( orig_extract: str, evol_extract: str, orig_verify: str, evol_verify: str, ) -> ValidationResult: """校验 Tool Prompt 改写结果。 检查项: 输出格式 section 保留(per file)、长度比在 [0.3, 2.0]。 与 skill / system 不同,**不检查代码块闭合**。 参数: orig_extract: 改写前的 extract prompt。 evol_extract: 改写后的 extract prompt。 orig_verify: 改写前的 verify prompt。 evol_verify: 改写后的 verify prompt。 返回: ValidationResult 实例。 """ errors: list[str] = [] for label, orig, evol in [ ("extract", orig_extract, evol_extract), ("verify", orig_verify, evol_verify), ]: orig_fmt = _extract_section(orig, "输出格式") if orig_fmt is not None: evol_fmt = _extract_section(evol, "输出格式") if evol_fmt is None: errors.append(f"{label}: 冻结区 '## 输出格式' 在改写后缺失") elif orig_fmt != evol_fmt: errors.append(f"{label}: 冻结区 '## 输出格式' 在改写后被修改") errors.extend(_check_length(orig, evol)) return ValidationResult(passed=len(errors) == 0, errors=errors) # ========================================================================= # D. 纯数学 # ========================================================================= def edit_budget_at(global_step: int, total_steps: int, start: int, end: int) -> int: """按 global_step 线性退火的 per-target 编辑预算。 借鉴 SkillOpt LinearScheduler:在 [0, total_steps] 上把预算从 start 线性退火到 end。total_steps<=1 直接返回 start(避免单步取到最小值)。 round 用 Python banker's rounding,max(end, ...) 兜底硬下限。 参数: global_step: 当前全局步(0-indexed)。 total_steps: 退火地平线(step 数),即分母。 start: 退火起点(需 >= end)。 end: 退火终点(亦为硬下限)。 返回: 当步 per-target 最大 edit 条数。 异常: AssertionError: start < end。 """ assert start >= end, ( f"edit_budget_at 要求 start >= end,实际 start={start}, end={end}" ) if total_steps <= 1: return start t = min(global_step, total_steps) / total_steps return max(end, round(start + (end - start) * t)) # ========================================================================= # E. JSON 解析(进化版本,不同于 metrics 版) # ========================================================================= def _parse_llm_json(raw: str) -> dict | None: """从 LLM 响应中解析 JSON。 仅两种策略:(1) 提取 ```json 代码块;(2) 直接 json.loads。 不做 outermost braces 推断、不用 json_repair。失败返回 None。 参数: raw: LLM 原始输出文本。 返回: 解析后的字典;失败或结果非 dict 时返回 None。 """ text = raw.strip() # 策略 1:提取 ```json ... ``` 代码块 code_block = re.search(r"```json\s*\n(.*?)```", text, re.DOTALL) if code_block: text = code_block.group(1).strip() # 策略 2:直接解析 try: result = json.loads(text) if isinstance(result, dict): return result return None except (json.JSONDecodeError, ValueError): return None # ========================================================================= # F. rank_and_clip(async) # ========================================================================= def _select_top_edits( indices: list[Any], edits: list[dict[str, Any]], max_edits: int, ) -> list[dict[str, Any]]: """按 rank LLM 给出的优先级索引筛选 edits。 依次保留首个 max_edits 条合法、不重复、在范围内的索引对应 edit。 用 type(idx) is int(非 isinstance)以排除 bool。 参数: indices: rank LLM 返回的 0-based 优先级索引。 edits: 候选 edit 列表。 max_edits: 最多保留条数。 返回: 按优先级顺序保留的 edit 列表。 """ selected: list[dict[str, Any]] = [] seen: set[int] = set() for idx in indices: if type(idx) is int and 0 <= idx < len(edits) and idx not in seen: selected.append(edits[idx]) seen.add(idx) if len(selected) >= max_edits: break return selected async def _request_rank_indices( llm: LLMProvider, prompts: str, original: str, edits: list[dict[str, Any]], max_edits: int, label: str, ) -> list[int]: """调 rank LLM 取重要性降序的索引列表。 守 P5:响应无法解析或 selected_indices 非列表时直接 raise ValueError。 参数: llm: LLM 调用端口。 prompts: evolve_rank 模板内容。 original: 当前 prompt 全文(排序上下文)。 edits: 候选 edit 列表。 max_edits: 本轮预算上限。 label: 目标标签(仅用于报错信息)。 返回: rank LLM 返回的原始索引列表(尚未去重/越界过滤)。 异常: ValueError: 响应无 selected_indices,或其值非列表。 """ edits_desc = "\n".join( f"[{i}] op={e.get('op')} support_count={e.get('support_count', 0)} " f"target={str(e.get('target', ''))[:60]!r} " f"content={str(e.get('content', ''))[:60]!r}" for i, e in enumerate(edits) ) user_msg = ( f"## 当前文件\n\n{original}\n\n" f"## 候选 edits({len(edits)} 条,预算 {max_edits} 条)\n\n{edits_desc}\n\n" f"请选出最重要的 {max_edits} 条,返回其 0-based 索引(重要性降序)。" ) response = await llm.chat( [ {"role": "system", "content": prompts}, {"role": "user", "content": user_msg}, ] ) parsed = _parse_llm_json(response.content) if not parsed or "selected_indices" not in parsed: raise ValueError(f"{label} rank LLM 未返回 selected_indices,拒绝静默截断") indices = parsed["selected_indices"] if not isinstance(indices, list): raise ValueError(f"{label} rank LLM selected_indices 非列表") return indices async def rank_and_clip( llm: LLMProvider, original_content: str, edits: list[dict[str, Any]], max_edits: int, label: str, *, rank_prompt: str = "", ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """超预算时调 rank LLM 排序取 top-L;未超则原样返回。 三级降级:LLM rank → _select_top_edits → empty → fallback to edits[:max_edits]。 对 rank LLM 的输出波动一律优雅降级而非中止。 参数: llm: LLM 调用端口。 original_content: 当前 prompt 全文(排序上下文)。 edits: 候选 edit 列表。 max_edits: 本轮预算上限。 label: 目标标签(skill/system/tool,仅用于日志)。 rank_prompt: evolve_rank 模板内容。 返回: (裁剪后 edits, {"triggered": bool, "clipped": int})。 """ if len(edits) <= max_edits: return edits, {"triggered": False, "clipped": 0} try: indices = await _request_rank_indices( llm, rank_prompt, original_content, edits, max_edits, label ) except Exception as exc: logger.warning( "{} rank LLM 排序不可用({});退化为按原序取前 {} 条", label, exc, max_edits, ) indices = [] selected = _select_top_edits(indices, edits, max_edits) if not selected: selected = edits[:max_edits] logger.warning("{} rank 有效索引为 0;退化为按原序取前 {} 条", label, max_edits) elif len(selected) < max_edits: logger.warning( "{} rank 仅得 {} 条有效(<预算 {});按更保守的条数应用", label, len(selected), max_edits, ) logger.info("{} edits 超预算裁剪 {}->{}", label, len(edits), len(selected)) return selected, {"triggered": True, "clipped": len(edits) - len(selected)} # ========================================================================= # G. resolve_skill_file # ========================================================================= def resolve_skill_file(skill_store: SkillStore, task_type: str) -> str: """按运行时规则解析 task_type 对应的 skill 文件名。 转换规则:小写 + 空格替换为短横线 + .md 后缀。 若 store 中不存在匹配文件,退化到 default-strategy.md。 参数: skill_store: 版本化技能读取端口。 task_type: 题目任务类型(如 "Action Reasoning")。 返回: 匹配的 skill 文件名。 """ file_name = f"{task_type.lower().replace(' ', '-')}.md" available = skill_store.list_skill_files() if file_name in available: return file_name return "default-strategy.md" # ========================================================================= # H. 格式化辅助 # ========================================================================= def _format_case_samples(cases: list[Any]) -> str: """将 CaseSample 列表格式化为 LLM 可读文本。 对 trace 中的 tool_output 截断到 500 字符。 参数: cases: CaseSample 实例列表(也兼容 dict)。 返回: 格式化后的多行文本。 """ lines: list[str] = [] for case in cases: if not isinstance(case, dict): case = asdict(case) lines.append(f"### {case.get('question_id', 'unknown')}") lines.append(f"- question: {case.get('question', '')}") options = case.get("options", []) if options: lines.append(f"- options: {json.dumps(options, ensure_ascii=False)}") lines.append(f"- answer: {case.get('answer', '')}") lines.append(f"- prediction: {case.get('prediction', '')}") lines.append(f"- error_type: {case.get('error_type', '')}") lines.append(f"- selection_reason: {case.get('selection_reason', '')}") trace = case.get("trace", []) if trace: lines.append("- trace:") for step in trace: output_text = str(step.get("tool_output", "")) if len(output_text) > 500: output_text = output_text[:500] + "..." lines.append( f" - step {step.get('step', '?')}: " f"tool={step.get('tool_name', '')} " f"args={json.dumps(step.get('tool_args', {}), ensure_ascii=False)} " f"output={output_text}" ) lines.append("") return "\n".join(lines) def _format_spans(spans: list[dict[str, Any]]) -> str: """将工具 span 字典列表格式化为 LLM 可读文本。 对 tool_output 截断到 500 字符。 参数: spans: span 字典列表,每个包含 step / tool_name / tool_args 等字段。 返回: 格式化后的多行文本。 """ lines: list[str] = [] for span in spans: lines.append(f"### step {span.get('step', '?')}") lines.append(f"- tool_name: {span.get('tool_name', '')}") lines.append( f"- tool_args: {json.dumps(span.get('tool_args', {}), ensure_ascii=False)}" ) output_text = str(span.get("tool_output", "")) if len(output_text) > 500: output_text = output_text[:500] + "..." lines.append(f"- tool_output: {output_text}") lines.append( f"- extraction_completeness: {span.get('extraction_completeness', '')}" ) lines.append(f"- hallucination_rate: {span.get('hallucination_rate', '')}") missed = span.get("missed_info_tags", []) if missed: lines.append( f"- missed_info_tags: {json.dumps(missed, ensure_ascii=False)}" ) hall_tags = span.get("hallucination_tags", []) if hall_tags: lines.append( f"- hallucination_tags: {json.dumps(hall_tags, ensure_ascii=False)}" ) lines.append("") return "\n".join(lines) def _format_rejected_edits(rejected: list[RejectedEdit]) -> str: """将已验证无效的改法列表格式化为 LLM 可读文本。 gate 证据格式:W=... L=... E={:.2f} delta_hat={:+.3f}。 参数: rejected: RejectedEdit 实例列表。 返回: 格式化后的多行文本。 """ lines: list[str] = [] for edit in rejected: lines.append(f"### {edit.target_file} | delta {edit.delta:+.2f}") lines.append(f"- 已验证无效的改法: {edit.change_summary}") if edit.gate_e_value is not None: lines.append( f"- 已验证无效: W={edit.gate_w} L={edit.gate_l} " f"E={edit.gate_e_value:.2f} δ̂={edit.gate_delta_shrunk:+.3f}" ) lines.append("") return "\n".join(lines)