Skip to content

Commit 386c936

Browse files
committed
feat(examples): add eval-optimize-loop closed-loop optimization pipeline
Implement 6-phase automatic optimization closed loop for tRPC-Agent: evaluation -> failure attribution -> prompt optimization -> regression verification -> acceptance gate -> audit trail. - Phase 1: Baseline evaluation with fake/real dual-mode support - Phase 2: 6-category failure attribution with 4-layer rule chain - Phase 3: attribution-driven prompt optimization (FakeOptimizer) - Phase 4: Candidate validation with delta matrix - Phase 5: Configurable acceptance gate (5 rules + overfit detection) - Phase 6: Audit trail with JSON + Markdown dual-format reports Includes 99 unit tests covering all phases and full pipeline integration. Fake mode enables complete pipeline execution without API keys. Related: Issue #6 (犀牛鸟 2026)
1 parent 8080800 commit 386c936

23 files changed

Lines changed: 3584 additions & 0 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
output/
2+
__pycache__/
3+
.pytest_cache/
4+
*.pyc
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
{
2+
"_description": "Evaluation + Optimization 自动回归闭环配置",
3+
"pipeline": {
4+
"name": "PlateAgent Eval-Optimize Loop",
5+
"version": "1.0.0",
6+
"max_iterations": 5,
7+
"random_seed": 42
8+
},
9+
"gate": {
10+
"rules": {
11+
"total_score_improvement": {
12+
"enabled": true,
13+
"threshold": 0.03,
14+
"description": "验证集总分提升 ≥ 3%"
15+
},
16+
"no_new_hard_fail": {
17+
"enabled": true,
18+
"max_new_fails": 0,
19+
"description": "不允许新增 hard fail"
20+
},
21+
"critical_case_no_regress": {
22+
"enabled": true,
23+
"critical_case_ids": [],
24+
"description": "关键 case 不退步"
25+
},
26+
"cost_within_budget": {
27+
"enabled": true,
28+
"max_cost_ratio": 1.2,
29+
"description": "成本不超过 baseline 的 120%"
30+
},
31+
"overfit_detection": {
32+
"enabled": true,
33+
"description": "训练集提升 + 验证集退化 → 拒绝候选"
34+
}
35+
},
36+
"acceptance_strategy": "all_must_pass",
37+
"description": "all_must_pass: 所有启用的规则都通过才接受; majority: 多数通过即可"
38+
},
39+
"attribution": {
40+
"categories": [
41+
"final_answer_mismatch",
42+
"tool_call_error",
43+
"param_error",
44+
"llm_rubric_fail",
45+
"knowledge_recall_insufficient",
46+
"format_invalid"
47+
],
48+
"rules": {
49+
"final_answer_mismatch": {
50+
"trigger": "predicted != ground_truth",
51+
"priority": 1
52+
},
53+
"tool_call_error": {
54+
"trigger": "tool execution failed or timeout",
55+
"priority": 2
56+
},
57+
"param_error": {
58+
"trigger": "tool parameter invalid",
59+
"priority": 3
60+
},
61+
"llm_rubric_fail": {
62+
"trigger": "LLM Judge score below threshold",
63+
"threshold": 0.6,
64+
"priority": 4
65+
},
66+
"knowledge_recall_insufficient": {
67+
"trigger": "blacklist miss or confusion char not recalled",
68+
"priority": 5
69+
},
70+
"format_invalid": {
71+
"trigger": "output does not match expected JSON schema",
72+
"priority": 6
73+
}
74+
}
75+
},
76+
"optimizer": {
77+
"target_prompts": ["system_prompt", "skill_prompt"],
78+
"strategy": "failure_driven",
79+
"description": "根据归因结果,优先优化失败率最高的类别对应的 prompt 片段"
80+
},
81+
"output": {
82+
"dir": "output",
83+
"formats": ["json", "markdown"],
84+
"retain_audit_trail": true,
85+
"max_audit_entries": 50
86+
}
87+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"_description": "???",
3+
"version": "1.0.0",
4+
"cases": [
5+
{
6+
"case_id": "train_001",
7+
"image": "plate_001.jpg",
8+
"ground_truth": "\u4eacA12345",
9+
"conditions": {
10+
"type": "clear"
11+
},
12+
"expected_behavior": "should_pass",
13+
"description": "????"
14+
},
15+
{
16+
"case_id": "train_002",
17+
"image": "plate_028.jpg",
18+
"ground_truth": "\u4eacA12345",
19+
"conditions": {
20+
"type": "noise",
21+
"noise_level": 0.15
22+
},
23+
"expected_behavior": "may_fail",
24+
"description": "????"
25+
},
26+
{
27+
"case_id": "train_003",
28+
"image": "plate_012.jpg",
29+
"ground_truth": "\u82cfA88U88",
30+
"conditions": {
31+
"type": "blur",
32+
"blur_kernel": 5
33+
},
34+
"expected_behavior": "may_fail",
35+
"description": "????"
36+
}
37+
],
38+
"stats": {
39+
"total": 3,
40+
"should_pass": 1,
41+
"may_fail": 2
42+
}
43+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"_description": "???",
3+
"version": "1.0.0",
4+
"cases": [
5+
{
6+
"case_id": "val_001",
7+
"image": "plate_005.jpg",
8+
"ground_truth": "\u7ca4B54321",
9+
"conditions": {
10+
"type": "clear"
11+
},
12+
"expected_behavior": "should_pass",
13+
"critical": true,
14+
"description": "??case"
15+
},
16+
{
17+
"case_id": "val_002",
18+
"image": "plate_029.jpg",
19+
"ground_truth": "\u82cfD13579",
20+
"conditions": {
21+
"type": "noise",
22+
"noise_level": 0.2
23+
},
24+
"expected_behavior": "should_fail_baseline",
25+
"critical": false,
26+
"description": "??+???"
27+
},
28+
{
29+
"case_id": "val_003",
30+
"image": "plate_018.jpg",
31+
"ground_truth": "\u6d59C36912",
32+
"conditions": {
33+
"type": "blur",
34+
"blur_kernel": 7
35+
},
36+
"expected_behavior": "should_fail_baseline",
37+
"critical": false,
38+
"description": "????"
39+
}
40+
],
41+
"stats": {
42+
"total": 3,
43+
"should_pass": 1,
44+
"should_fail_baseline": 2,
45+
"critical": 1
46+
}
47+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Fake 模块公共导出"""
2+
from .fake_model import FakeLLM, FakeLLMResponse
3+
from .fake_judge import FakeJudge, JudgeResult, JudgeScore
4+
5+
__all__ = [
6+
"FakeLLM",
7+
"FakeLLMResponse",
8+
"FakeJudge",
9+
"JudgeResult",
10+
"JudgeScore",
11+
]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Fake Judge — 无 LLM API 调用下模拟评测打分。
2+
3+
基于规则引擎(非 LLM)对预测结果和 ground truth 进行对比评分,
4+
输出与 LLMJudge 相同的数据结构,保证 pipeline 可无缝切换。
5+
6+
三维评分均基于字符匹配率推导,模拟真实 LLM Judge 行为:
7+
识别差 → 黑名单召回和回复质量也会相应下降。
8+
"""
9+
10+
from dataclasses import dataclass
11+
12+
13+
@dataclass
14+
class JudgeScore:
15+
"""模拟的三维评分"""
16+
recognition_quality: float # 0.0-1.0
17+
blacklist_quality: float # 0.0-1.0
18+
response_quality: float # 0.0-1.0
19+
20+
@property
21+
def overall(self) -> float:
22+
return (self.recognition_quality + self.blacklist_quality + self.response_quality) / 3.0
23+
24+
@property
25+
def passed(self) -> bool:
26+
return self.overall >= 0.6
27+
28+
29+
@dataclass
30+
class JudgeResult:
31+
"""模拟的评测结果"""
32+
case_id: str
33+
ground_truth: str
34+
predicted: str
35+
score: JudgeScore
36+
passed: bool
37+
failure_reason: str = ""
38+
39+
40+
class FakeJudge:
41+
"""基于规则的假 Judge。
42+
43+
评分逻辑(完全确定性,无 LLM 依赖):
44+
- recognition_quality: 字符匹配率(0.0-1.0)
45+
- blacklist_quality: 基于识别质量推导(识别差→黑名单召回也差)
46+
- response_quality: 基于识别质量推导(识别差→回复质量也差)
47+
48+
使用方式:
49+
judge = FakeJudge()
50+
result = judge.evaluate("val_001", "京A12345", "京A12345")
51+
"""
52+
53+
def evaluate(
54+
self,
55+
case_id: str,
56+
ground_truth: str,
57+
predicted: str,
58+
) -> JudgeResult:
59+
"""对单条 case 进行评测。
60+
61+
Args:
62+
case_id: case 标识
63+
ground_truth: 标注真值
64+
predicted: Agent 预测结果
65+
66+
Returns:
67+
JudgeResult: 包含三维评分和 pass/fail 判断
68+
"""
69+
recognition = self._char_match_score(ground_truth, predicted)
70+
# 黑名单和回复质量随识别质量缩放(模拟真实场景)
71+
blacklist = max(0.1, recognition * 0.9)
72+
response = max(0.2, recognition * 1.05)
73+
74+
score = JudgeScore(
75+
recognition_quality=recognition,
76+
blacklist_quality=blacklist,
77+
response_quality=response,
78+
)
79+
80+
passed = score.passed
81+
reason = ""
82+
if not passed:
83+
if recognition < 0.8:
84+
reason = f"final_answer_mismatch: char_match={recognition:.2f}"
85+
elif blacklist < 0.6:
86+
reason = "knowledge_recall_insufficient: blacklist miss"
87+
else:
88+
reason = f"llm_rubric_fail: overall={score.overall:.2f}"
89+
90+
return JudgeResult(
91+
case_id=case_id,
92+
ground_truth=ground_truth,
93+
predicted=predicted,
94+
score=score,
95+
passed=passed,
96+
failure_reason=reason,
97+
)
98+
99+
@staticmethod
100+
def _char_match_score(a: str, b: str) -> float:
101+
"""字符级匹配得分。
102+
103+
完全匹配 → 1.0,逐字符比较取平均。
104+
"""
105+
if not a or not b:
106+
return 0.0
107+
if a == b:
108+
return 1.0
109+
matches = sum(1 for ca, cb in zip(a, b) if ca == cb)
110+
return matches / max(len(a), len(b))
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Fake LLM — 无 API Key 模式下模拟 LLM 响应。
2+
3+
设计思路:
4+
- 基于 case_id 匹配预设的响应映射表
5+
- 支持多种场景:通过、失败、工具调用错误等
6+
- 不产生任何网络请求,所有数据来自配置文件
7+
"""
8+
9+
from dataclasses import dataclass, field
10+
from typing import Optional
11+
12+
13+
@dataclass
14+
class FakeLLMResponse:
15+
"""模拟的 LLM 单次响应"""
16+
content: str
17+
tool_calls: list[dict] = field(default_factory=list)
18+
finish_reason: str = "stop"
19+
20+
21+
class FakeLLM:
22+
"""无依赖的假 LLM,用于 pipeline 快速验证。
23+
24+
使用方式:
25+
fake = FakeLLM(scenarios={"plate_001": "京A12345"})
26+
response = await fake.generate("识别 plate_001")
27+
"""
28+
29+
def __init__(self, scenarios: Optional[dict[str, str]] = None):
30+
"""
31+
Args:
32+
scenarios: {case_id: predicted_result} 映射。
33+
不传则使用内置默认值。
34+
"""
35+
self.scenarios = scenarios or self._default_scenarios()
36+
self.call_count = 0
37+
self.call_history: list[dict] = []
38+
39+
@staticmethod
40+
def _default_scenarios() -> dict[str, str]:
41+
"""内置默认场景 — 覆盖 6 个样例 case"""
42+
return {
43+
"train_001": "京A12345", # 清晰 → 通过
44+
"train_002": "京A12345", # 噪声 → 黑名单应命中
45+
"train_003": "苏A88U88", # 模糊 → 可能识别错误
46+
"val_001": "粤B54321", # 关键 case → 应通过
47+
"val_002": "苏D13579", # 噪声+黑名单 → 基线失败
48+
"val_003": "浙C36912", # 严重模糊 → 过拟合风险
49+
}
50+
51+
async def generate(self, prompt: str) -> FakeLLMResponse:
52+
"""模拟一次 LLM 调用。
53+
54+
从 prompt 中提取 case_id,返回对应的预设结果。
55+
若未匹配到 case_id,返回 "UNKNOWN"。
56+
"""
57+
self.call_count += 1
58+
case_id = self._extract_case_id(prompt)
59+
result = self.scenarios.get(case_id, "UNKNOWN")
60+
61+
response = FakeLLMResponse(content=result)
62+
self.call_history.append({
63+
"call": self.call_count,
64+
"case_id": case_id,
65+
"result": result,
66+
"prompt_snippet": prompt[:200],
67+
})
68+
return response
69+
70+
def _extract_case_id(self, prompt: str) -> str:
71+
"""从 prompt 中提取 case_id。"""
72+
for cid in self.scenarios:
73+
if cid in prompt:
74+
return cid
75+
return "unknown"
76+
77+
def reset(self):
78+
"""重置调用计数和历史。"""
79+
self.call_count = 0
80+
self.call_history.clear()

0 commit comments

Comments
 (0)