"""进化引擎辅助函数与验证逻辑。 验证(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, Literal from loguru import logger from core.evolution.patch import ( APPENDIX_END, APPENDIX_START, append_to_appendix, apply_patch_with_report, extract_appendix_notes, momentum_region_bounds, replace_appendix_notes, ) from core.evolution.types import EvolutionRecord if TYPE_CHECKING: from collections.abc import Awaitable, Callable from core.evolution.protocols import PromptStore, SkillStore from core.evolution.types import ( EvolvePrompts, RejectedEdit, SkillCasePack, SystemCasePack, ToolCasePack, ) 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} 被修改: {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) # ========================================================================= # I. 进化循环内部类型 # ========================================================================= @dataclass class _PatchEvolutionAttempt: """单次补丁式进化尝试的中间结果。 属性: evolved_content: 改写后内容。 validation: 校验结果。 suggestions: LLM 输出的改动建议列表。 edits: LLM 输出的补丁列表。 apply_report: 补丁逐条应用状态。 clip_info: 超预算裁剪信息。 """ evolved_content: str validation: ValidationResult suggestions: list[dict[str, Any]] = field(default_factory=list) edits: list[dict[str, Any]] = field(default_factory=list) apply_report: list[dict[str, Any]] = field(default_factory=list) clip_info: dict[str, Any] = field(default_factory=lambda: {"triggered": False, "clipped": 0}) # ========================================================================= # J. 进化循环辅助函数 # ========================================================================= def _count_applied_reports(reports: list[dict[str, Any]]) -> int: """统计补丁报告中成功应用的条数。 以 ``status`` 前缀 ``"applied"`` 为判据,涵盖 applied_append / applied_replace / applied_rewrite 等所有成功状态。 参数: reports: apply_patch_with_report 或合成报告的列表。 返回: 成功应用的条数。 """ return sum(1 for r in reports if r["status"].startswith("applied")) def _with_report_source(reports: list[dict[str, Any]], source: str) -> list[dict[str, Any]]: """给补丁报告补上来源字段(extract / verify 标注)。 参数: reports: 原始补丁报告列表。 source: 来源标签("extract" / "verify")。 返回: 每条追加 ``"source"`` 字段的新列表。 """ return [{**r, "source": source} for r in reports] def _append_retry_messages( messages: list[dict[str, Any]], raw_content: str, feedback: str, ) -> None: """向对话中追加一次失败后的重试反馈(assistant + user)。 参数: messages: 当前对话消息列表(原地修改)。 raw_content: 上一轮 LLM 原始输出。 feedback: 给 LLM 的纠正反馈。 """ messages.append({"role": "assistant", "content": raw_content}) messages.append({"role": "user", "content": feedback}) # ========================================================================= # K. 补丁进化循环 # ========================================================================= async def _run_patch_evolution_loop( *, llm: LLMProvider, messages: list[dict[str, Any]], attempts: list[dict[str, Any]], target_file: str, target_type: str, original_content: str, source_version: str, log_target: str, attempt_builder: Callable[[dict[str, Any]], Awaitable[_PatchEvolutionAttempt]], ) -> EvolutionRecord: """执行带补丁应用与 no-op 重试的两轮进化循环。 恰好 2 次尝试(range(2)),三种失败模式各有对应重试提示: 1. JSON 解析失败 → "你的输出不是合法 JSON,请重新输出" 2. 0 条 applied 补丁 → "你的 edit 的 target 都没在原文中匹配到…" 3. 校验失败 → 具体校验错误文本 首轮失败追加重试提示继续第二轮;第二轮失败直接 reject。 校验通过立即返回 accepted。 参数: llm: LLM 调用端口。 messages: 对话消息列表(原地修改,追加重试上下文)。 attempts: 尝试摘要列表(原地追加)。 target_file: 目标文件名。 target_type: 目标类型(skill / system / tool)。 original_content: 改写前原文。 source_version: 改写前版本号。 log_target: 日志标签。 attempt_builder: 异步构建尝试的回调,接收 parsed JSON dict。 返回: EvolutionRecord 实例。 """ for attempt_idx in range(2): response = await llm.chat(messages) raw_content = response.content attempts.append({"attempt": attempt_idx + 1, "raw_length": len(raw_content)}) parsed = _parse_llm_json(raw_content) # 失败模式 1:JSON 解析失败 if parsed is None: logger.warning("{} 进化 LLM 响应 JSON 解析失败: {}", target_type, log_target) if attempt_idx == 0: _append_retry_messages( messages, raw_content, "你的输出不是合法 JSON,请重新输出。", ) continue return EvolutionRecord( target_file=target_file, target_type=target_type, original_content=original_content, evolved_content=original_content, reason="LLM 响应 JSON 解析失败", status="rejected", source_version=source_version, attempts=attempts, validation_errors=["JSON 解析失败"], ) attempt = await attempt_builder(parsed) # 失败模式 2:0 条 applied 补丁 if _count_applied_reports(attempt.apply_report) == 0: if attempt_idx == 0: _append_retry_messages( messages, raw_content, "你的 edit 的 target 都没在原文中匹配到,请逐字摘抄原文锚点后重输。", ) continue return EvolutionRecord( target_file=target_file, target_type=target_type, original_content=original_content, evolved_content=original_content, reason="补丁无有效改动(target 全未匹配)", status="rejected", source_version=source_version, suggestions=attempt.suggestions, attempts=attempts, edits=attempt.edits, apply_report=attempt.apply_report, clip_info=attempt.clip_info, ) # 成功:校验通过 if attempt.validation.passed: return EvolutionRecord( target_file=target_file, target_type=target_type, original_content=original_content, evolved_content=attempt.evolved_content, reason="验证通过", status="accepted", source_version=source_version, suggestions=attempt.suggestions, attempts=attempts, edits=attempt.edits, apply_report=attempt.apply_report, clip_info=attempt.clip_info, ) # 失败模式 3:校验失败 error_feedback = "\n".join(attempt.validation.errors) if attempt_idx == 0: _append_retry_messages( messages, raw_content, f"验证失败,请修正后重新输出:\n{error_feedback}", ) continue return EvolutionRecord( target_file=target_file, target_type=target_type, original_content=original_content, evolved_content=original_content, reason="验证失败(重试后仍未通过)", status="rejected", source_version=source_version, suggestions=attempt.suggestions, attempts=attempts, validation_errors=attempt.validation.errors, edits=attempt.edits, apply_report=attempt.apply_report, clip_info=attempt.clip_info, ) # 兜底(正常流程不可达) return EvolutionRecord( target_file=target_file, target_type=target_type, original_content=original_content, evolved_content=original_content, reason="未知错误", status="rejected", source_version=source_version, attempts=attempts, ) # ========================================================================= # L. Lapse-only 尝试构建 # ========================================================================= def _build_lapse_only_attempt( original_content: str, lapse_notes: list[str], ) -> _PatchEvolutionAttempt: """构造「仅 appendix 更新」尝试:无 defect edit,只把 lapse 提醒落进受保护区。 LLM 没给 defect edit 但有 lapse 提醒时,正常补丁路径会因 0 条 applied 报告被 ``_run_patch_evolution_loop`` 判为 no-op 而丢弃。这里给出一条 ``applied_append`` 合成报告(前缀 ``applied`` 使 ``_count_applied_reports > 0``),令该记录走 accepted 路径、appendix 真正落盘。 参数: original_content: 改写前全文。 lapse_notes: 待落 appendix 的 LAPSE 提醒。 返回: 含 appendix 更新内容与合成 apply_report 的 _PatchEvolutionAttempt。 """ evolved_content = append_to_appendix(original_content, lapse_notes) apply_report = [ { "op": "append", "target": "", "content_preview": "appendix LAPSE 提醒", "status": "applied_append", "index": 1, } ] return _PatchEvolutionAttempt( evolved_content=evolved_content, validation=validate_skill(original_content, evolved_content), suggestions=[], edits=[], apply_report=apply_report, clip_info={"triggered": False, "clipped": 0}, ) # ========================================================================= # M. Appendix consolidation # ========================================================================= _CONSOLIDATE_SYSTEM = ( "你在压缩一个 agent skill 的「执行提醒 appendix」。每条提醒都重申一条 skill 已有" "规则、是 agent 没遵循的点。你的任务是周期性压缩:去重、合并近义、精简措辞,但" "保留每条的可执行性。禁止发明新规则;禁止写入任何具体题目/选项/实体名等案例事实。" "只返回 JSON。" ) async def consolidate_appendix(llm: LLMProvider, notes: list[str]) -> list[str]: """LLM 压缩 appendix notes(去重/合并/精简),失败永不丢内容。 四关守卫(对标 TRM4 consolidate_appendix): G1. clean 后 <2 条直接短路返回(无需压缩,不调 LLM)。 G2. 只接受「非空且 len(compacted) <= len(clean)」的压缩结果。 G3. 任何异常(解析/空/网络)→ 返回 clean(绝不丢 appendix)。 G4. 在调用方 _append_lapse_with_consolidation 中: len(compacted) >= len(notes) → 拒绝等长压缩。 参数: llm: LLM 调用端口。 notes: 待压缩的 appendix 提醒列表。 返回: 压缩后的提醒列表;任何守卫未通过时返回 clean 后的原 notes。 """ # G1:clean 后不足 2 条,直接返回 clean = [str(n).strip() for n in (notes or []) if str(n).strip()] if len(clean) < 2: return clean numbered = "\n".join(f"{i}. {n}" for i, n in enumerate(clean, 1)) user = ( f"## 当前执行提醒(共 {len(clean)} 条)\n{numbered}\n\n" "压缩为更短的列表,不丢失可执行信息;合并重复与近义;保持每条简短具体可复用。" '只返回 JSON:{ "appendix_notes": ["压缩后提醒1", "压缩后提醒2"] }' ) try: response = await llm.chat( [ {"role": "system", "content": _CONSOLIDATE_SYSTEM}, {"role": "user", "content": user}, ] ) parsed = _parse_llm_json(response.content) compacted = [ str(n).strip() for n in (parsed or {}).get("appendix_notes", []) if str(n).strip() ] # G2:非空且确实压缩了 if compacted and len(compacted) <= len(clean): return compacted except Exception as exc: # noqa: BLE001 # G3:任何失败降级为保留原 notes。设计授权的优雅降级(非 P5 违规): # consolidation 是纯优化,失败不应中断 evolve 或丢 appendix;记 warning 非静默。 logger.warning("appendix consolidation 失败,保留原 notes:{}", exc) return clean async def _append_lapse_with_consolidation( text: str, lapse_notes: list[str], llm: LLMProvider, consolidate_threshold: int, ) -> str: """把 lapse 提醒追加进 appendix,超阈值时触发 LLM consolidation。 回写侧二确认——即便 consolidate_appendix 守卫已保证 <=,这里再校验「确实 变短」才 replace,避免等长压缩带来无意义改写抖动(守卫 G4)。 参数: text: 待追加的 skill 全文(正文已改完)。 lapse_notes: 本轮待落 appendix 的 LAPSE 提醒。 llm: LLM 调用端口,供 consolidation 使用。 consolidate_threshold: appendix note 条数 >= 此值时触发压缩。 返回: 追加(必要时压缩)后的 skill 全文。 """ after = append_to_appendix(text, lapse_notes) notes = extract_appendix_notes(after) if len(notes) >= consolidate_threshold: compacted = await consolidate_appendix(llm, notes) # G4:压缩结果必须严格变短才替换 if len(compacted) < len(notes): after = replace_appendix_notes(after, compacted) return after # ========================================================================= # N. 整篇重写 # ========================================================================= _REWRITE_SYSTEM = ( "你负责根据改动建议整篇重写 Agent Skill 文件。保留 frontmatter(---...---)中的 " "name / description / task_type 不变。保持文件精简,重写后长度不得超过原文。" '只返回 JSON:{ "rewritten": "重写后的完整文件内容" }' ) async def rewrite_from_suggestions( llm: LLMProvider, original: str, suggestions: list[dict[str, Any]], ) -> str: """从抽象 suggestion 整篇重写 Skill;校验失败回退原文(skill 不变)。 硬约束由系统提示下达 + 本函数校验双重保证。三条拒绝条件任一触发 即返回原文(保守不改): 1. 解析失败(JSON / rewritten 字段缺失或非字符串) 2. 重写后长度 > 原文 3. validate_skill 校验不过 仅捕获 ValueError / KeyError / TypeError / AttributeError;API 错误向上传播。 参数: llm: LLM 调用端口。 original: 改写前 Skill 文件全文。 suggestions: 抽象改动建议列表。 返回: 校验通过的重写全文;任一守卫未通过时返回 original。 """ sugg_text = "\n".join(f"- {s.get('change', '')}" for s in (suggestions or [])) user_msg = f"## 当前 Skill 文件\n\n{original}\n\n## 改动建议\n\n{sugg_text or '(无)'}" try: response = await llm.chat( [ {"role": "system", "content": _REWRITE_SYSTEM}, {"role": "user", "content": user_msg}, ] ) parsed = _parse_llm_json(response.content) rewritten = (parsed or {}).get("rewritten") if not isinstance(rewritten, str) or not rewritten.strip(): raise ValueError("rewrite 未返回非空 rewritten 字符串") except (ValueError, KeyError, TypeError, AttributeError): logger.warning("rewrite 解析失败,回退原文") return original # 拒绝条件 2:不许变长 if len(rewritten) > len(original): logger.warning("rewrite 变长({}->{}),回退原文", len(original), len(rewritten)) return original # 拒绝条件 3:冻结区/格式校验 if not validate_skill(original, rewritten).passed: logger.warning("rewrite 校验未过(冻结区/格式),回退原文") return original return rewritten # ========================================================================= # O. 单目标进化函数 # ========================================================================= async def evolve_single_skill( llm: LLMProvider, pack: SkillCasePack, skill_store: SkillStore, prompts: EvolvePrompts, source_version: str, edit_budget: int, consolidate_threshold: int, *, skill_update_mode: Literal["patch", "rewrite"] = "patch", rejected: list[RejectedEdit] | None = None, ) -> EvolutionRecord: """进化单个 Skill 文件。 三分支构建: A. lapse-only:无 defect edit + 有 lapse_notes → 仅 appendix 更新。 B. rewrite:mode="rewrite" + 有 edit → 整篇重写;失败回退 A 或 no-op。 C. patch(默认):rank_and_clip → apply_patch_with_report。 分支 B/C 完成后,若有 lapse_notes,追加 appendix(超阈值 consolidation)。 用户消息结构(按顺序): 1. (可选)黑名单 2. 当前 Skill 文件原文 3. 聚合统计 JSON 4. 失败案例 5. 成功案例 参数: llm: LLM 调用端口。 pack: 该题型的案例包。 skill_store: 版本化技能读取端口。 prompts: 进化模板束。 source_version: 改写前版本号。 edit_budget: per-target 编辑预算上限。 consolidate_threshold: appendix note 条数 >= 此值触发 consolidation。 skill_update_mode: 正文更新模式,"patch"(局部 edit)/ "rewrite"(整篇重写)。 rejected: 已验证无效的历史改法列表。 返回: EvolutionRecord 实例。 """ target_file = pack.target_file original_content = skill_store.read_skill(target_file) # 构建用户消息 stats_json = json.dumps(pack.stats, ensure_ascii=False, indent=2) user_msg = ( f"## 当前 Skill 文件\n\n{original_content}\n\n" f"## 聚合统计\n\n```json\n{stats_json}\n```\n\n" f"## 失败案例\n\n{_format_case_samples(pack.failure_cases)}\n\n" f"## 成功案例\n\n{_format_case_samples(pack.success_cases)}" ) rejected = rejected or [] if rejected: user_msg = ( "## 已验证无效的改法(黑名单,勿重复)\n\n" + _format_rejected_edits(rejected) + "\n\n" + user_msg ) messages: list[dict[str, Any]] = [ {"role": "system", "content": prompts.evolve_skill}, {"role": "user", "content": user_msg}, ] attempts: list[dict[str, Any]] = [] async def _build_attempt(parsed: dict[str, Any]) -> _PatchEvolutionAttempt: suggestions = parsed.get("suggestions", []) edits = parsed.get("edits", []) # 分支 A:lapse-only(无 defect edit、仅有 lapse 提醒) if not edits and pack.lapse_notes: return _build_lapse_only_attempt(original_content, pack.lapse_notes) # 分支 B:rewrite 模式(有 defect edits 时整篇重写) if skill_update_mode == "rewrite" and edits: rewritten = await rewrite_from_suggestions(llm, original_content, suggestions) if rewritten == original_content: # 重写校验失败/变长/解析失败 → 正文无改动 if pack.lapse_notes: return _build_lapse_only_attempt(original_content, pack.lapse_notes) return _PatchEvolutionAttempt( evolved_content=original_content, validation=validate_skill(original_content, original_content), suggestions=suggestions, edits=[], apply_report=[], clip_info={"triggered": False, "clipped": 0}, ) evolved_content = rewritten apply_report = [ { "op": "rewrite", "target": "", "content_preview": "整篇重写", "status": "applied_rewrite", "index": 1, } ] clip_info: dict[str, Any] = {"triggered": False, "clipped": 0} # 分支 C:patch 模式(默认) else: edits, clip_info = await rank_and_clip( llm, original_content, edits, edit_budget, "skill", rank_prompt=prompts.evolve_rank, ) evolved_content, apply_report = apply_patch_with_report( original_content, edits, protected_spans=_skill_protected_spans(original_content), ) # 分支 B/C 完成后:lapse 提醒追加 + consolidation if pack.lapse_notes: evolved_content = await _append_lapse_with_consolidation( evolved_content, pack.lapse_notes, llm, consolidate_threshold, ) return _PatchEvolutionAttempt( evolved_content=evolved_content, validation=validate_skill(original_content, evolved_content), suggestions=suggestions, edits=edits, apply_report=apply_report, clip_info=clip_info, ) return await _run_patch_evolution_loop( llm=llm, messages=messages, attempts=attempts, target_file=target_file, target_type="skill", original_content=original_content, source_version=source_version, log_target=target_file, attempt_builder=_build_attempt, ) async def evolve_system_prompt( llm: LLMProvider, pack: SystemCasePack, prompt_store: PromptStore, prompts: EvolvePrompts, source_version: str, edit_budget: int, ) -> EvolutionRecord: """进化 System Prompt。 无 lapse notes、无 appendix consolidation、无 rewrite 模式。 使用 system protected spans 保护冻结区。 用户消息中统计标题为「D5 行为模式统计」。 参数: llm: LLM 调用端口。 pack: 跨题型行为模式案例包。 prompt_store: 版本化提示词读取端口。 prompts: 进化模板束。 source_version: 改写前版本号。 edit_budget: per-target 编辑预算上限。 返回: EvolutionRecord 实例。 """ target_file = "system.md" original_content = prompt_store.read_prompt(target_file) stats_json = json.dumps(pack.stats, ensure_ascii=False, indent=2) user_msg = ( f"## 当前 System Prompt\n\n{original_content}\n\n" f"## D5 行为模式统计\n\n```json\n{stats_json}\n```\n\n" f"## 失败案例\n\n{_format_case_samples(pack.failure_cases)}\n\n" f"## 成功案例\n\n{_format_case_samples(pack.success_cases)}" ) messages: list[dict[str, Any]] = [ {"role": "system", "content": prompts.evolve_system}, {"role": "user", "content": user_msg}, ] attempts: list[dict[str, Any]] = [] async def _build_attempt(parsed: dict[str, Any]) -> _PatchEvolutionAttempt: suggestions = parsed.get("suggestions", []) edits = parsed.get("edits", []) edits, clip_info = await rank_and_clip( llm, original_content, edits, edit_budget, "system", rank_prompt=prompts.evolve_rank, ) evolved_content, apply_report = apply_patch_with_report( original_content, edits, protected_spans=_system_protected_spans(original_content), ) return _PatchEvolutionAttempt( evolved_content=evolved_content, validation=validate_system(original_content, evolved_content), suggestions=suggestions, edits=edits, apply_report=apply_report, clip_info=clip_info, ) return await _run_patch_evolution_loop( llm=llm, messages=messages, attempts=attempts, target_file=target_file, target_type="system", original_content=original_content, source_version=source_version, log_target=target_file, attempt_builder=_build_attempt, ) async def evolve_single_tool( llm: LLMProvider, pack: ToolCasePack, prompt_store: PromptStore, prompts: EvolvePrompts, source_version: str, edit_budget: int, ) -> EvolutionRecord: """进化单个工具的 extract + verify prompt。 extract 与 verify 的 edits 合并到 SHARED 预算池(打 ``_src`` 标签), 整体 rank_and_clip 到 edit_budget 后按 ``_src`` 拆回各自文件应用。 ``evolved_content`` 以 ``json.dumps({"extract": ..., "verify": ...})`` 存储。 ``target_file`` 固定为 ``{tool_name}_extract.md``。 apply_report 每条带 ``"source"`` 注解("extract" / "verify")。 参数: llm: LLM 调用端口。 pack: 该工具的案例包。 prompt_store: 版本化提示词读取端口。 prompts: 进化模板束。 source_version: 改写前版本号。 edit_budget: per-target 编辑预算上限(extract + verify 共享)。 返回: EvolutionRecord 实例。 """ tool_name = pack.tool_name target_file = f"{tool_name}_extract.md" orig_extract = prompt_store.read_prompt(f"{tool_name}_extract.md") orig_verify = prompt_store.read_prompt(f"{tool_name}_verify.md") original_combined = json.dumps( {"extract": orig_extract, "verify": orig_verify}, ensure_ascii=False, ) stats_json = json.dumps(pack.stats, ensure_ascii=False, indent=2) user_msg = ( f"## 当前 extract prompt\n\n{orig_extract}\n\n" f"## 当前 verify prompt\n\n{orig_verify}\n\n" f"## 工具质量统计\n\n```json\n{stats_json}\n```\n\n" f"## 失败 span 案例\n\n{_format_spans(pack.failure_spans)}\n\n" f"## 成功 span 案例\n\n{_format_spans(pack.success_spans)}" ) messages: list[dict[str, Any]] = [ {"role": "system", "content": prompts.evolve_tool}, {"role": "user", "content": user_msg}, ] attempts: list[dict[str, Any]] = [] async def _build_attempt(parsed: dict[str, Any]) -> _PatchEvolutionAttempt: suggestions = parsed.get("suggestions", []) edits_extract = parsed.get("edits_extract", []) edits_verify = parsed.get("edits_verify", []) # 合并打来源标记,对单 tool target 整体裁到 edit_budget pool: list[dict[str, Any]] = [{**e, "_src": "extract"} for e in edits_extract] + [ {**e, "_src": "verify"} for e in edits_verify ] pool, clip_info = await rank_and_clip( llm, original_combined, pool, edit_budget, "tool", rank_prompt=prompts.evolve_rank, ) # 按 _src 拆回,并剥离 _src 字段 extract_kept = [ {k: v for k, v in e.items() if k != "_src"} for e in pool if e["_src"] == "extract" ] verify_kept = [ {k: v for k, v in e.items() if k != "_src"} for e in pool if e["_src"] == "verify" ] # 分别应用,使用各自的 tool protected spans evolved_extract, extract_report = apply_patch_with_report( orig_extract, extract_kept, protected_spans=_tool_protected_spans(orig_extract), ) evolved_verify, verify_report = apply_patch_with_report( orig_verify, verify_kept, protected_spans=_tool_protected_spans(orig_verify), ) # 报告注解来源 apply_report = _with_report_source(extract_report, "extract") + _with_report_source( verify_report, "verify" ) evolved_combined = json.dumps( {"extract": evolved_extract, "verify": evolved_verify}, ensure_ascii=False, ) validation = validate_tool(orig_extract, evolved_extract, orig_verify, evolved_verify) return _PatchEvolutionAttempt( evolved_content=evolved_combined, validation=validation, suggestions=suggestions, edits=extract_kept + verify_kept, apply_report=apply_report, clip_info=clip_info, ) return await _run_patch_evolution_loop( llm=llm, messages=messages, attempts=attempts, target_file=target_file, target_type="tool", original_content=original_combined, source_version=source_version, log_target=tool_name, attempt_builder=_build_attempt, )