Files
Video-Tree-TRM5/core/evolution/evolve.py
T

1501 lines
52 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.
"""进化引擎辅助函数与验证逻辑。
验证(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 roundingmax(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_clipasync
# =========================================================================
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)
# 失败模式 1JSON 解析失败
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)
# 失败模式 20 条 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 / AttributeErrorAPI 错误向上传播。
参数:
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. rewritemode="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", [])
# 分支 Alapse-only(无 defect edit、仅有 lapse 提醒)
if not edits and pack.lapse_notes:
return _build_lapse_only_attempt(original_content, pack.lapse_notes)
# 分支 Brewrite 模式(有 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}
# 分支 Cpatch 模式(默认)
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,
)