Skip to content

Commit 843de99

Browse files
committed
Polish eval optimize loop SDK path
1 parent be2401d commit 843de99

15 files changed

Lines changed: 802 additions & 123 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
examples/*.log
77
examples/optimization/eval_optimize_loop/outputs/optimization_report.json
88
examples/optimization/eval_optimize_loop/outputs/optimization_report.md
9+
examples/optimization/eval_optimize_loop/outputs/runs/
910

1011
trpc-agent-py.egg-info
1112

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
11
# 设计说明
22

3-
本示例把闭环拆成加载、评测、归因、候选生成、门禁和报告六层。失败归因采用可审计规则表:JSON 解析和禁用格式命中归为 `format_violation`,最终答案错误归为 `final_response_mismatch`,工具名错误、参数错误、知识召回不足和超长分别进入独立类别;若用例声明期望归因,报告会计算 accuracy。接受门禁先要求 validation 提升,再拒绝 train 提升但 validation 不提升的过拟合候选,并检查受保护用例降分、新硬失败、单例最大降分和成本预算。防过拟合依赖训练集与验证集分离、protected case、delta_type 和逐例降分阈值,而不是只看平均分;每个拒绝理由都会进入审计报告,便于复盘,也便于评审核对策略。fake mode 用通用 expectation/tags/protected/simulated_outputs 生成输出,不依赖样例名,可稳定覆盖隐藏样本;sdk mode 则通过 `SDKBackend` 调用 `AgentOptimizer` 与 `TargetPrompt`,用于真实接入,两者共享门禁、归因和报告结构。报告写 JSON、Markdown 和 runs 审计目录,保存输入哈希、配置快照、候选 prompt、diff、case 结果和成本。默认不回写源 prompt,只有显式 `--update-source` 才允许,避免示例运行污染源码。
3+
## 失败归因
4+
评测先由 FakeJudge 产出结构化失败,再用规则归因:JSON、禁用格式归为 `format_violation`,精确答案错归为 `final_response_mismatch`,工具名、参数、知识召回、长度和 rubric 各有独立类别。每个失败都保留 reason 与 evidence,便于复核。若样例声明期望类别,报告会计算归因准确率。
5+
6+
## 接受门禁
7+
Gate 要求验证集提升达到阈值,并检查新硬失败、受保护样例退化、单例降分和累计成本。若训练分上涨但验证不涨,直接标记过拟合并拒绝。
8+
9+
## 防过拟合
10+
训练集只用于暴露问题,候选必须通过验证集和 protected case。过拟合候选即使修好训练格式,只要把验证集自然语言或受保护精确答案改坏,也会被拒绝。`delta_type` 标出 new_pass、new_fail、score_up、score_down,避免只看平均分掩盖局部退化。
11+
12+
## Fake 与 SDK
13+
Fake mode 由 expectation、tags、protected 和 simulated_outputs 驱动,不依赖样例 id,可无 API key 稳定复现。SDK mode 通过 `SDKBackend` 调用 `AgentOptimizer``TargetPrompt`,失败时给出明确错误,不回退到 fake。
14+
15+
## 审计与回写
16+
报告同时写 JSON、Markdown 和 `runs/<run_id>/`,保存输入哈希、配置快照、候选 prompt、diff、case 结果与成本,并给出可复现命令。默认不回写源 prompt,只有显式 `--update-source` 才允许,报告会记录该选择。

examples/optimization/eval_optimize_loop/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ python examples/optimization/eval_optimize_loop/run_pipeline.py \
6868

6969
`--sdk-call-agent` must point to an async callable compatible with
7070
`AgentOptimizer.optimize(call_agent=...)`. Configure any real model credentials
71-
needed by that callable. SDK mode never silently falls back to fake mode.
71+
needed by that callable. SDK mode never silently falls back to fake mode. When
72+
the SDK optimizer returns a best prompt but does not expose per-case validation
73+
results, this example still writes JSON/Markdown reports and audit artifacts
74+
with `gate_status: not_applicable`, records the SDK result summary, and selects
75+
`sdk_best` as the SDK optimizer's chosen prompt.
7276

7377
## Source Prompt Writes
7478

@@ -110,6 +114,11 @@ optional `simulated_outputs`; it does not depend on sample `case_id` names.
110114
- audit data: seed, duration, config hash, input hashes, candidate prompt hashes,
111115
cost, prompt diffs, and reproducibility command.
112116

117+
`gate.max_total_cost` is interpreted as the total evaluated run cost at the time
118+
each candidate is judged: baseline cost plus all candidates evaluated so far,
119+
including rejected candidates. This makes budget decisions deterministic and
120+
auditable when multiple candidates are considered.
121+
113122
`optimization_report.md` includes final decision, gate reasons, score table,
114123
per-case delta table, failure attribution summary, cost/audit details, prompt
115124
diffs, and the reproducibility command.
@@ -139,4 +148,6 @@ python -m pytest examples/optimization/eval_optimize_loop/tests
139148
The tests cover fake hidden-sample generalization, config validation, gate
140149
rejection paths, protected-case behavior, failure attribution, tool/knowledge
141150
judge paths, SDK adapter wiring through monkeypatching, deterministic report
142-
generation, and both CLI forms.
151+
generation, and both CLI forms. CI can run fake mode plus the monkeypatched SDK
152+
smoke tests without real API credentials; real SDK/model calls are opt-in local
153+
or integration runs.

examples/optimization/eval_optimize_loop/eval_loop/backends.py

Lines changed: 79 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
from pathlib import Path
99
from typing import Any
1010
from typing import Iterable
11-
from typing import Protocol
1211

12+
from .diffing import make_unified_diff
1313
from .evaluator import ExampleEvaluator
1414
from .fake_judge import FakeJudge
1515
from .fake_model import FakeModel
@@ -19,22 +19,6 @@
1919
from .schemas import EvalResult
2020

2121

22-
class EvalOptimizeBackend(Protocol):
23-
def evaluate(self, *, prompt_id: str, prompt: str, cases: Iterable[EvalCase], split: str) -> EvalResult:
24-
...
25-
26-
def optimize(
27-
self,
28-
*,
29-
baseline_prompt: str,
30-
train_path: str | Path,
31-
val_path: str | Path,
32-
optimizer_config_path: str | Path,
33-
output_dir: str | Path,
34-
) -> list[CandidatePrompt]:
35-
...
36-
37-
3822
@dataclass
3923
class FakeBackend:
4024
seed: int = 91
@@ -61,20 +45,43 @@ def optimize(
6145

6246
@dataclass
6347
class SDKBackend:
64-
"""Thin adapter around AgentOptimizer/TargetPrompt for real SDK runs."""
48+
"""Thin optimizer adapter around AgentOptimizer/TargetPrompt for SDK runs.
49+
50+
SDK mode relies on AgentOptimizer's internal evaluation loop. It does not
51+
implement the fake per-case ``evaluate`` API.
52+
"""
6553

6654
prompt_path: str | Path
6755
call_agent_path: str | None = None
6856
update_source: bool = False
57+
last_result_summary: dict[str, Any] | None = None
58+
last_artifact_dir: str | None = None
6959

70-
def evaluate(self, *, prompt_id: str, prompt: str, cases: Iterable[EvalCase], split: str) -> EvalResult:
71-
raise ValueError(
72-
"sdk mode evaluation expects SDK evalset files and is performed by AgentOptimizer/AgentEvaluator. "
73-
"Use --sdk-call-agent module:function and SDK-compatible train/val evalsets; fake EvalCase objects "
74-
"cannot be evaluated by the SDK adapter."
60+
def optimize(
61+
self,
62+
*,
63+
baseline_prompt: str,
64+
train_path: str | Path,
65+
val_path: str | Path,
66+
optimizer_config_path: str | Path,
67+
output_dir: str | Path,
68+
) -> list[CandidatePrompt]:
69+
if _has_running_loop():
70+
raise ValueError(
71+
"SDKBackend.optimize() cannot be called while an event loop is already running; "
72+
"use await SDKBackend.optimize_async(...) instead."
73+
)
74+
return asyncio.run(
75+
self.optimize_async(
76+
baseline_prompt=baseline_prompt,
77+
train_path=train_path,
78+
val_path=val_path,
79+
optimizer_config_path=optimizer_config_path,
80+
output_dir=output_dir,
81+
)
7582
)
7683

77-
def optimize(
84+
async def optimize_async(
7885
self,
7986
*,
8087
baseline_prompt: str,
@@ -91,35 +98,38 @@ def optimize(
9198
)
9299
call_agent = _load_call_agent(self.call_agent_path)
93100
try:
94-
from trpc_agent_sdk.evaluation import AgentEvaluator
95101
from trpc_agent_sdk.evaluation import AgentOptimizer
96102
from trpc_agent_sdk.evaluation import TargetPrompt
97103
except Exception as exc: # pragma: no cover - depends on optional SDK import health
98-
raise ValueError(f"sdk mode could not import AgentEvaluator/AgentOptimizer/TargetPrompt: {exc}") from exc
104+
raise ValueError(f"sdk mode could not import AgentOptimizer/TargetPrompt: {exc}") from exc
99105

100-
_ = AgentEvaluator
101106
target_prompt = TargetPrompt().add_path("system_prompt", str(self.prompt_path))
102-
result = asyncio.run(
103-
AgentOptimizer.optimize(
104-
config_path=str(optimizer_config_path),
105-
call_agent=call_agent,
106-
target_prompt=target_prompt,
107-
train_dataset_path=str(train_path),
108-
validation_dataset_path=str(val_path),
109-
output_dir=str(output_dir),
110-
update_source=self.update_source,
111-
verbose=0,
112-
)
107+
result = await AgentOptimizer.optimize(
108+
config_path=str(optimizer_config_path),
109+
call_agent=call_agent,
110+
target_prompt=target_prompt,
111+
train_dataset_path=str(train_path),
112+
validation_dataset_path=str(val_path),
113+
output_dir=str(output_dir),
114+
update_source=self.update_source,
115+
verbose=0,
113116
)
114117
best_prompt = getattr(result, "best_prompts", {}).get("system_prompt")
115118
if not best_prompt:
116119
raise ValueError("sdk mode completed but OptimizeResult.best_prompts['system_prompt'] was missing")
120+
self.last_result_summary = _summarize_sdk_result(result)
121+
self.last_artifact_dir = str(output_dir)
117122
return [
118123
CandidatePrompt(
119124
candidate_id="sdk_best",
120125
prompt=best_prompt,
121126
rationale="Best prompt returned by AgentOptimizer.optimize.",
122-
prompt_diff=_simple_diff(baseline_prompt, best_prompt),
127+
prompt_diff=make_unified_diff(
128+
baseline_prompt,
129+
best_prompt,
130+
before_name="baseline_system_prompt.txt",
131+
after_name="sdk_best/system_prompt.txt",
132+
),
123133
)
124134
]
125135

@@ -128,14 +138,39 @@ def _load_call_agent(path: str):
128138
if ":" not in path:
129139
raise ValueError("--sdk-call-agent must use module:function format")
130140
module_name, function_name = path.split(":", 1)
131-
module = importlib.import_module(module_name)
141+
try:
142+
module = importlib.import_module(module_name)
143+
except Exception as exc:
144+
raise ValueError(f"--sdk-call-agent target {path!r} could not import module {module_name!r}: {exc}") from exc
132145
call_agent = getattr(module, function_name, None)
133146
if call_agent is None:
134147
raise ValueError(f"--sdk-call-agent target {path!r} was not found")
135148
return call_agent
136149

137150

138-
def _simple_diff(before: str, after: str) -> str:
139-
if before == after:
140-
return "# no prompt changes"
141-
return "- baseline system_prompt\n+ optimized system_prompt"
151+
def _has_running_loop() -> bool:
152+
try:
153+
asyncio.get_running_loop()
154+
except RuntimeError:
155+
return False
156+
return True
157+
158+
159+
def _summarize_sdk_result(result: Any) -> dict[str, Any]:
160+
if hasattr(result, "model_dump"):
161+
payload = result.model_dump(mode="json")
162+
elif hasattr(result, "__dict__"):
163+
payload = dict(result.__dict__)
164+
else:
165+
payload = {"repr": repr(result)}
166+
return _safe_jsonable(payload)
167+
168+
169+
def _safe_jsonable(value: Any) -> Any:
170+
if isinstance(value, dict):
171+
return {str(key): _safe_jsonable(item) for key, item in value.items()}
172+
if isinstance(value, (list, tuple)):
173+
return [_safe_jsonable(item) for item in value]
174+
if isinstance(value, (str, int, float, bool)) or value is None:
175+
return value
176+
return repr(value)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Prompt diff helpers."""
2+
3+
from __future__ import annotations
4+
5+
import difflib
6+
7+
8+
def make_unified_diff(before: str, after: str, *, before_name: str, after_name: str) -> str:
9+
"""Return a stable unified diff for prompt text."""
10+
11+
diff = difflib.unified_diff(
12+
before.splitlines(),
13+
after.splitlines(),
14+
fromfile=before_name,
15+
tofile=after_name,
16+
lineterm="",
17+
)
18+
rendered = "\n".join(line.rstrip() for line in diff)
19+
return rendered or f"--- {before_name}\n+++ {after_name}\n# no prompt changes"

examples/optimization/eval_optimize_loop/eval_loop/optimizer.py

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,57 @@
22

33
from __future__ import annotations
44

5+
from .diffing import make_unified_diff
56
from .schemas import CandidatePrompt
67

78

89
class FakeOptimizer:
910
"""Produce the two candidates required by the example issue."""
1011

1112
def propose(self, baseline_prompt: str) -> list[CandidatePrompt]:
13+
overfit_prompt = (
14+
baseline_prompt.rstrip()
15+
+ "\n\n"
16+
+ "# Optimizer patch\n"
17+
+ "OPTIMIZER_MARKER: ALWAYS_OUTPUT_JSON\n"
18+
+ "Always force every final answer into JSON, even when the user asks for prose.\n"
19+
)
20+
safe_prompt = (
21+
baseline_prompt.rstrip()
22+
+ "\n\n"
23+
+ "# Optimizer patch\n"
24+
+ "OPTIMIZER_MARKER: STRICT_WHEN_REQUESTED\n"
25+
+ "Use strict JSON only when the user explicitly asks for JSON.\n"
26+
+ "Use exact answers only when the user explicitly asks for an exact answer.\n"
27+
+ "Otherwise answer naturally and honor rubric constraints.\n"
28+
)
1229
return [
1330
CandidatePrompt(
1431
candidate_id="candidate_001_overfit",
15-
prompt=(
16-
baseline_prompt.rstrip()
17-
+ "\n\n"
18-
+ "# Optimizer patch\n"
19-
+ "OPTIMIZER_MARKER: ALWAYS_OUTPUT_JSON\n"
20-
+ "Always force every final answer into JSON, even when the user asks for prose.\n"
21-
),
32+
prompt=overfit_prompt,
2233
rationale=(
2334
"The train failures are strict JSON/exact formatting failures, so this candidate "
2435
"over-corrects by forcing JSON globally."
2536
),
26-
prompt_diff=(
27-
"+ OPTIMIZER_MARKER: ALWAYS_OUTPUT_JSON\n"
28-
"+ Always force every final answer into JSON, even when the user asks for prose."
37+
prompt_diff=make_unified_diff(
38+
baseline_prompt,
39+
overfit_prompt,
40+
before_name="baseline_system_prompt.txt",
41+
after_name="candidate_001_overfit/system_prompt.txt",
2942
),
3043
),
3144
CandidatePrompt(
3245
candidate_id="candidate_002_safe",
33-
prompt=(
34-
baseline_prompt.rstrip()
35-
+ "\n\n"
36-
+ "# Optimizer patch\n"
37-
+ "OPTIMIZER_MARKER: STRICT_WHEN_REQUESTED\n"
38-
+ "Use strict JSON only when the user explicitly asks for JSON.\n"
39-
+ "Use exact answers only when the user explicitly asks for an exact answer.\n"
40-
+ "Otherwise answer naturally and honor rubric constraints.\n"
41-
),
46+
prompt=safe_prompt,
4247
rationale=(
4348
"This candidate fixes observed strict-format failures without changing "
4449
"natural-language behavior on validation cases."
4550
),
46-
prompt_diff=(
47-
"+ OPTIMIZER_MARKER: STRICT_WHEN_REQUESTED\n"
48-
"+ Use strict JSON only when explicitly requested.\n"
49-
"+ Preserve natural-language answers unless a strict format is requested."
51+
prompt_diff=make_unified_diff(
52+
baseline_prompt,
53+
safe_prompt,
54+
before_name="baseline_system_prompt.txt",
55+
after_name="candidate_002_safe/system_prompt.txt",
5056
),
5157
),
5258
]

examples/optimization/eval_optimize_loop/eval_loop/report.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ def render_markdown(report: OptimizationReport) -> str:
132132
"",
133133
])
134134
for decision in report.gate_decisions:
135-
verdict = "accepted" if decision.accepted else "rejected"
135+
verdict = (
136+
decision.gate_status
137+
if decision.gate_status != "applied"
138+
else ("accepted" if decision.accepted else "rejected")
139+
)
136140
lines.append(f"### {decision.candidate_id} ({verdict})")
137141
for reason in decision.reasons:
138142
lines.append(f"- {reason}")
@@ -148,7 +152,7 @@ def render_markdown(report: OptimizationReport) -> str:
148152
for record in report.candidates:
149153
candidate: CandidatePrompt = record["candidate"]
150154
gate = decision_by_id[candidate.candidate_id]
151-
verdict = "accept" if gate.accepted else "reject"
155+
verdict = gate.gate_status if gate.gate_status != "applied" else ("accept" if gate.accepted else "reject")
152156
lines.append(
153157
f"| {candidate.candidate_id} | {record['train_result'].score:.3f} | "
154158
f"{record['validation_result'].score:.3f} | {verdict} |"
@@ -218,7 +222,9 @@ def render_markdown(report: OptimizationReport) -> str:
218222
"## Reproducibility",
219223
"",
220224
"```bash",
221-
REPRODUCIBILITY_COMMAND,
225+
report.run.get("reproducibility_command")
226+
or report.audit.get("reproducibility_command")
227+
or REPRODUCIBILITY_COMMAND,
222228
"```",
223229
"",
224230
])

examples/optimization/eval_optimize_loop/eval_loop/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ class GateDecision:
120120
cumulative_cost: float
121121
total_run_cost: float
122122
cost: float
123+
gate_status: str = "applied"
124+
gate_not_applied_reason: str | None = None
123125

124126

125127
@dataclass(frozen=True)

0 commit comments

Comments
 (0)