ablation-workbench 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ # Changelog
2
+
3
+ <!-- version list -->
4
+
5
+ ## v0.2.0 (2026-10-28)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Preserve legacy review anchors with fixed baseline
10
+ ([#2](https://github.com/yiminspace/ablation/pull/2),
11
+ [`3b76cd0`](https://github.com/yiminspace/ablation/commit/3b76cd09a53277fb384136ac88bc51e38b20326f))
12
+
13
+ - Resolve exact external logs for replay evidence
14
+ ([#1](https://github.com/yiminspace/ablation/pull/1),
15
+ [`149fccf`](https://github.com/yiminspace/ablation/commit/149fccfa5d4444ee16771bd2c64f8134e12bc2be))
16
+
17
+ - 保留旧版评论并校验导入分组和长 diff ([#3](https://github.com/yiminspace/ablation/pull/3),
18
+ [`dcdf8c9`](https://github.com/yiminspace/ablation/commit/dcdf8c969f96186e17ce96a617f6bb42927a8dea))
19
+
20
+ - 等待外部日志入库后自动关联测试记录 ([#5](https://github.com/yiminspace/ablation/pull/5),
21
+ [`d76308e`](https://github.com/yiminspace/ablation/commit/d76308e431d61a8e8dba81fdbaa2a12466856e80))
22
+
23
+ ### Documentation
24
+
25
+ - Require replay log association before handoff
26
+ ([#8](https://github.com/yiminspace/ablation/pull/8),
27
+ [`1f839ba`](https://github.com/yiminspace/ablation/commit/1f839ba70a61fbd190a3612778897fdcdc80ede2))
28
+
29
+ - 对齐 ablation 中英文 skill 指引 ([#6](https://github.com/yiminspace/ablation/pull/6),
30
+ [`a771914`](https://github.com/yiminspace/ablation/commit/a771914a49c1ec3cc62b03e73df2033cf0b1e8ba))
31
+
32
+ - 补齐中文版 skill 元数据和对应结构 ([#7](https://github.com/yiminspace/ablation/pull/7),
33
+ [`31bf4d9`](https://github.com/yiminspace/ablation/commit/31bf4d9ed9cc7bd5859cf2df9e3afe29a007182f))
34
+
35
+ ### Features
36
+
37
+ - Add complete replay history and detail drawers
38
+ ([#9](https://github.com/yiminspace/ablation/pull/9),
39
+ [`c6b849e`](https://github.com/yiminspace/ablation/commit/c6b849e5ab28bc62c0836d4dcedeb4ee00d1535a))
40
+
41
+ - Simplify original request reviews and source links
42
+ ([#2](https://github.com/yiminspace/ablation/pull/2),
43
+ [`3b76cd0`](https://github.com/yiminspace/ablation/commit/3b76cd09a53277fb384136ac88bc51e38b20326f))
44
+
45
+ - Simplify request review and link rationale to source
46
+ ([#2](https://github.com/yiminspace/ablation/pull/2),
47
+ [`3b76cd0`](https://github.com/yiminspace/ablation/commit/3b76cd09a53277fb384136ac88bc51e38b20326f))
48
+
49
+ - 在实验标题下实时展示测试日志 ([#4](https://github.com/yiminspace/ablation/pull/4),
50
+ [`8905d67`](https://github.com/yiminspace/ablation/commit/8905d67e8ace99f023985c15605e3ea30e3e1b31))
51
+
52
+ - 对齐 Quarry 的自动发布与版本更新流程 ([#10](https://github.com/yiminspace/ablation/pull/10),
53
+ [`52949cd`](https://github.com/yiminspace/ablation/commit/52949cd16c67051e1adb57a20066eaed6b5042a6))
54
+
55
+ - 按需求分组审阅提示词改动并保留原文 diff ([#3](https://github.com/yiminspace/ablation/pull/3),
56
+ [`dcdf8c9`](https://github.com/yiminspace/ablation/commit/dcdf8c969f96186e17ce96a617f6bb42927a8dea))
57
+
58
+
59
+ ## Unreleased
60
+
61
+ - Align releases and update discovery with Quarry: automatically version main commits, publish validated tagged packages to PyPI, and show periodic GUI update notifications with explicit upgrade commands and release notes.
62
+
63
+ - Group related prompt edits by requirement, with rationale, expected effects and GitHub source links; keep original line diffs and comment anchors available in both views.
64
+
65
+ - Show only candidate prompt changes in the default diff, with decoded text, line and word differences, collapsed context, without duplicate summaries or replay status; show modification rationale directly below the diff. Keep input history and scenario changes in the full request evidence.
66
+
67
+ - Add optional platform-neutral replay tracing headers, response ID/link extraction, and independently authenticated log lookups.
68
+ - Save external evidence with each run and display log links in the GUI without treating association failure as model failure.
69
+
70
+
71
+ ## 0.1.0 — development preview
72
+
73
+ Initial open-source preview, distributed through GitHub Releases. This preview did not publish to PyPI or enable registry updates.
74
+
75
+ - Import request files, JSON logs, and HTTP captures into portable experiments, with configurable source endpoints and field mappings.
76
+ - Preserve source evidence, normalization notes, candidates, synthetic scenarios, model outputs, and assertion results.
77
+ - Review prompt, input, request, response, and check differences in a local GitHub-style interface.
78
+ - Focus the initial view on prompt changes and rationale, with detailed evidence and comparison settings available on demand.
79
+ - Submit anchored comments, copy a portable handoff, or dispatch a configured local agent.
80
+ - Keep explanations in the current review and retain candidate history for requested changes.
81
+ - Preserve historical failure counts and let follow-up questions reference previously reviewed responses after another replay.
82
+ - Reject credential-bearing request patches and source URL queries, and validate imported experiment bundles before saving.
83
+ - Record oversized replay results as execution errors and terminate remaining Agent child processes on cancellation.
84
+ - Provide cached update discovery, explicit package-manager upgrades, and offline release notes.
85
+
86
+ Replays call a configured model endpoint. Returned tool calls are recorded but never executed. Agent execution is tested with local stubs; no real-model behavior or desktop-chat wakeup is claimed.
@@ -0,0 +1,3 @@
1
+ """Local prompt experiment workbench."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import sys
9
+ import time
10
+ from urllib.parse import urlparse
11
+
12
+ from . import __version__
13
+ from .engine import Workbench
14
+
15
+
16
+ def default_workspace():
17
+ return Path(os.environ.get("ABLATION_WORKSPACE", Path.home() / ".local/share/ablation"))
18
+
19
+
20
+ def read_json(path):
21
+ return json.loads(sys.stdin.read() if path == "-" else Path(path).expanduser().read_text(encoding="utf-8"))
22
+
23
+
24
+ def emit(value):
25
+ print(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False))
26
+
27
+
28
+ def config_path(workspace):
29
+ return Path(workspace) / "config.json"
30
+
31
+
32
+ def read_config(workspace):
33
+ path = config_path(workspace)
34
+ return json.loads(path.read_text()) if path.exists() else {"schema_version": 1, "profiles": {}}
35
+
36
+
37
+ def save_config(workspace, data):
38
+ from .runner import workspace_lock
39
+ path = config_path(workspace)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ with workspace_lock(Path(workspace)):
42
+ tmp = path.with_suffix(".tmp")
43
+ tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
44
+ tmp.chmod(0o600)
45
+ tmp.replace(path)
46
+
47
+
48
+ def configure_profile(args, workspace):
49
+ profile = {}
50
+ for key in ("base_url", "source_base_url", "source_path", "source_request_pointer", "source_response_pointer", "api_key_env", "token_env", "source_user_env", "source_password_env"):
51
+ value = getattr(args, key, None)
52
+ if value is None:
53
+ continue
54
+ if key.endswith("_env") and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value):
55
+ raise ValueError(f"{key} must be an environment variable name, not a secret value")
56
+ if key.endswith("url"):
57
+ url = urlparse(value)
58
+ if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password or url.query or url.fragment:
59
+ raise ValueError(f"{key} must be an HTTP(S) URL without credentials, query or fragment")
60
+ profile[key] = value
61
+ if args.source_fields_json is not None:
62
+ profile["source_fields"] = json.loads(args.source_fields_json)
63
+ if args.observers_json is not None:
64
+ profile["observers"] = read_json(args.observers_json)
65
+ from .observation import validate_observers
66
+ validate_observers(profile.get("observers", []))
67
+ config = read_config(workspace)
68
+ config.setdefault("profiles", {}).setdefault(args.name, {}).update(profile)
69
+ from .sources import validate_source_profile
70
+ validate_source_profile(config["profiles"][args.name])
71
+ validate_observers(config["profiles"][args.name].get("observers", []))
72
+ save_config(workspace, config)
73
+ return {"profile": args.name, "config": str(config_path(workspace)), "settings": config["profiles"][args.name]}
74
+
75
+
76
+ def parser():
77
+ p = argparse.ArgumentParser(prog="ablation", description="Local prompt experiments, replay, diffs and review.")
78
+ p.add_argument("--version", action="version", version=__version__)
79
+ p.add_argument("--workspace", type=Path, default=default_workspace())
80
+ sub = p.add_subparsers(dest="command", required=True)
81
+ sub.add_parser("init", help="Create a local workspace with credential references only")
82
+ imp = sub.add_parser("import", help="Import request, log or capture JSON from a file or configured source")
83
+ imp.add_argument("source", help="JSON path, '-' for stdin, same-origin JSON URL, or configured source reference")
84
+ imp.add_argument("--format", choices=["request", "log", "capture"], default="request")
85
+ imp.add_argument("--ref", action="store_true", help="Treat source as an identifier expanded through the profile's source_path")
86
+ imp.add_argument("--profile", default="")
87
+ imp.add_argument("--title", default="")
88
+ imp.add_argument("--response", help="Optional original response JSON")
89
+ sub.add_parser("list")
90
+ show = sub.add_parser("show"); show.add_argument("experiment")
91
+ demo = sub.add_parser("demo", help="Create a synthetic walkthrough, with no model requests")
92
+ demo.add_argument("--open", action="store_true")
93
+ gui = sub.add_parser("gui", help="Serve the local review workspace")
94
+ gui.add_argument("experiment", nargs="?")
95
+ gui.add_argument("--port", type=int, default=0)
96
+ gui.add_argument("--no-open", action="store_true")
97
+ candidate = sub.add_parser("candidate", help="Create an immutable candidate")
98
+ candidate.add_argument("experiment"); candidate.add_argument("--label", required=True)
99
+ candidate.add_argument("--patch", required=True, help="JSON Patch file or '-' for stdin")
100
+ rationale = candidate.add_mutually_exclusive_group(required=True)
101
+ rationale.add_argument("--rationale", help="Why the change helps, then verified source location and impact; supports GitHub Markdown links")
102
+ rationale.add_argument("--rationale-file", type=Path, help="UTF-8 file containing the same explanation, including newlines and source links")
103
+ candidate.add_argument("--parent", default="baseline")
104
+ groups = sub.add_parser("change-groups", help="Set review annotations without changing requests or replay evidence")
105
+ groups.add_argument("experiment"); groups.add_argument("--candidate", required=True)
106
+ groups.add_argument("--file", required=True, help="JSON requirement groups with Chinese reasons and GitHub source links")
107
+ scenario = sub.add_parser("scenario", help="Create an immutable test input and explicit checks")
108
+ scenario.add_argument("experiment"); scenario.add_argument("--label", required=True)
109
+ scenario.add_argument("--patch", required=True); scenario.add_argument("--description", required=True)
110
+ scenario.add_argument("--origin", choices=["synthetic", "source", "real", "derived"], default="synthetic")
111
+ scenario.add_argument("--checks", help="JSON assertions file")
112
+ run = sub.add_parser("run", help="Replay explicit candidate/scenario pairs; returned tools are not executed")
113
+ run.add_argument("experiment"); run.add_argument("--candidate", action="append", dest="candidates", required=True)
114
+ run.add_argument("--scenario", action="append", dest="scenarios", default=None)
115
+ run.add_argument("--repeats", type=int, default=1); run.add_argument("--profile", required=True)
116
+ diff = sub.add_parser("diff")
117
+ diff.add_argument("experiment"); diff.add_argument("--base", default="baseline")
118
+ diff.add_argument("--candidate", default="baseline"); diff.add_argument("--scenario", default="original")
119
+ diff.add_argument("--artifact", choices=["changes", "prompt", "input", "request", "response", "checks", "source"], default="request")
120
+ review = sub.add_parser("review"); rs = review.add_subparsers(dest="review_action", required=True)
121
+ submit = rs.add_parser("submit"); submit.add_argument("experiment"); submit.add_argument("--file", required=True)
122
+ text = rs.add_parser("text"); text.add_argument("experiment"); text.add_argument("review")
123
+ reply = rs.add_parser("reply"); reply.add_argument("experiment"); reply.add_argument("review")
124
+ reply.add_argument("--text"); reply.add_argument("--file"); reply.add_argument("--comment-index", type=int)
125
+ reply.add_argument("--author", default="agent")
126
+ dispatch = rs.add_parser("dispatch", help="Run configured Agent and wait for completion")
127
+ dispatch.add_argument("experiment"); dispatch.add_argument("review")
128
+ export = sub.add_parser("export"); export.add_argument("experiment"); export.add_argument("--out", required=True)
129
+ bundle = sub.add_parser("import-bundle"); bundle.add_argument("path")
130
+ config = sub.add_parser("config"); cs = config.add_subparsers(dest="config_action", required=True)
131
+ cs.add_parser("show")
132
+ profile = cs.add_parser("profile"); profile.add_argument("name")
133
+ for flag in ("base-url", "source-base-url", "source-path", "source-request-pointer", "source-response-pointer", "api-key-env", "token-env", "source-user-env", "source-password-env"):
134
+ profile.add_argument("--" + flag)
135
+ profile.add_argument("--observers-json", help="JSON file containing optional tracking headers and external evidence observers")
136
+ profile.add_argument("--source-fields-json", help="JSON object mapping top-level request field names to source JSON Pointers")
137
+ agent = sub.add_parser("agent"); ats = agent.add_subparsers(dest="agent_action", required=True)
138
+ ac = ats.add_parser("configure"); ac.add_argument("--backend", choices=["codex", "command"], default="codex")
139
+ ac.add_argument("--cwd", type=Path); ac.add_argument("--command-json", help="JSON argv array for custom command; reads task from stdin")
140
+ ac.add_argument("--timeout", type=int, default=600)
141
+ status = ats.add_parser("status"); status.add_argument("job", nargs="?")
142
+ cancel = ats.add_parser("cancel"); cancel.add_argument("job")
143
+ check = sub.add_parser("update-check"); check.add_argument("--force", action="store_true")
144
+ upgrade = sub.add_parser("upgrade"); upgrade.add_argument("--yes", action="store_true", help="Execute the displayed upgrade plan")
145
+ sub.add_parser("changelog")
146
+ return p
147
+
148
+
149
+ def main(argv=None):
150
+ args = parser().parse_args(argv)
151
+ try:
152
+ workbench = Workbench(args.workspace)
153
+ exit_code = 0
154
+ cmd = args.command
155
+ if cmd == "init":
156
+ if not config_path(args.workspace).exists():
157
+ save_config(args.workspace, {"schema_version": 1, "profiles": {}})
158
+ result = {"workspace": str(args.workspace.resolve()), "config": str(config_path(args.workspace).resolve())}
159
+ elif cmd == "import":
160
+ source = {"kind": args.format, "profile": args.profile}
161
+ if args.ref:
162
+ source["ref"] = args.source
163
+ elif args.source.startswith(("http://", "https://")):
164
+ source["url"] = args.source
165
+ elif args.source == "-" or Path(args.source).expanduser().is_file():
166
+ source["data"] = read_json(args.source)
167
+ else:
168
+ from .sources import profile_for
169
+ if not args.profile:
170
+ raise ValueError("Source file was not found; choose --profile with source_path and --ref for an identifier")
171
+ _, configured = profile_for(workbench.store.config(), args.profile)
172
+ if "{ref}" not in configured.get("source_path", ""):
173
+ raise ValueError("Source references require profile.source_path containing {ref}")
174
+ if any(character in args.source for character in ("/", "\\", ":")) or args.source.startswith((".", "~")) or args.source.lower().endswith(".json"):
175
+ raise ValueError("Source may be a missing file path; use --ref to explicitly treat it as an identifier")
176
+ source["ref"] = args.source
177
+ if args.response:
178
+ source["response"] = read_json(args.response)
179
+ result = workbench.import_source(source, title=args.title)
180
+ elif cmd == "list": result = workbench.list_experiments()
181
+ elif cmd == "show": result = workbench.get(args.experiment)
182
+ elif cmd in ("demo", "gui"):
183
+ from .server import serve
184
+ if cmd == "demo":
185
+ from .demo import create_demo
186
+ result = create_demo(workbench)
187
+ if args.open:
188
+ serve(workbench, experiment_id=result["id"])
189
+ return 0
190
+ else:
191
+ serve(workbench, experiment_id=args.experiment, port=args.port, open_browser=not args.no_open)
192
+ return 0
193
+ elif cmd == "candidate":
194
+ result = workbench.add_candidate(args.experiment, label=args.label, patch=read_json(args.patch), rationale=args.rationale_file.read_text(encoding="utf-8") if args.rationale_file else args.rationale, parent=args.parent)
195
+ elif cmd == "change-groups":
196
+ result = workbench.set_change_groups(args.experiment, candidate=args.candidate, groups=read_json(args.file))
197
+ elif cmd == "scenario":
198
+ result = workbench.add_scenario(args.experiment, label=args.label, patch=read_json(args.patch), description=args.description, origin=args.origin, checks=read_json(args.checks) if args.checks else None)
199
+ elif cmd == "run":
200
+ previous_runs = len(workbench.get(args.experiment)["runs"])
201
+ result = workbench.run(args.experiment, candidates=args.candidates, scenarios=args.scenarios or ["original"], repeats=args.repeats, profile=args.profile)
202
+ produced = result["runs"][previous_runs:]
203
+ if any(run["status"] in ("error", "failed") for run in produced): exit_code = 4
204
+ elif any(run.get("passed") is False for run in produced): exit_code = 3
205
+ elif cmd == "diff":
206
+ result = workbench.compare(args.experiment, base=args.base, candidate=args.candidate, scenario=args.scenario, artifact=args.artifact)
207
+ elif cmd == "review":
208
+ if args.review_action == "submit": result = workbench.submit_review(args.experiment, read_json(args.file))
209
+ elif args.review_action == "text":
210
+ print(workbench.review_text(args.experiment, args.review)); return 0
211
+ elif args.review_action == "reply":
212
+ if bool(args.text) == bool(args.file): raise ValueError("Choose exactly one of --text or --file")
213
+ text = args.text if args.text else (sys.stdin.read() if args.file == "-" else Path(args.file).read_text())
214
+ result = workbench.reply(args.experiment, args.review, text=text, comment_index=args.comment_index, author=args.author)
215
+ else:
216
+ from .runner import AgentRunner
217
+ runner = AgentRunner(workbench); result = runner.start(args.experiment, args.review)
218
+ try:
219
+ while result["status"] in ("queued", "running", "cancelling"):
220
+ time.sleep(0.2); result = runner.status(result["id"])
221
+ except KeyboardInterrupt:
222
+ runner.cancel(result["id"])
223
+ deadline = time.monotonic() + 5
224
+ while time.monotonic() < deadline and runner.status(result["id"])["status"] in ("queued", "running", "cancelling"):
225
+ time.sleep(0.1)
226
+ raise
227
+ emit(result); return 0 if result["status"] == "completed" else 4
228
+ elif cmd == "export": result = {"path": str(workbench.export_bundle(args.experiment, args.out))}
229
+ elif cmd == "import-bundle": result = workbench.import_bundle(args.path)
230
+ elif cmd == "config": result = read_config(args.workspace) if args.config_action == "show" else configure_profile(args, args.workspace)
231
+ elif cmd == "agent":
232
+ from .runner import AgentRunner
233
+ if args.agent_action == "configure":
234
+ if not 1 <= args.timeout <= 7200: raise ValueError("timeout must be 1–7200 seconds")
235
+ agent = {"backend": args.backend, "timeout_seconds": args.timeout}
236
+ if args.cwd:
237
+ if not args.cwd.is_dir(): raise ValueError("Agent cwd must exist")
238
+ agent["cwd"] = str(args.cwd.resolve())
239
+ if args.backend == "command":
240
+ command = json.loads(args.command_json or "null")
241
+ if not isinstance(command, list) or not command or not all(isinstance(x, str) and x for x in command):
242
+ raise ValueError("Custom backend requires --command-json '[\"executable\", ...]'")
243
+ agent["command"] = command
244
+ config = read_config(args.workspace); config["agent"] = agent; save_config(args.workspace, config)
245
+ result = {"agent": agent}
246
+ else:
247
+ runner = AgentRunner(workbench)
248
+ result = runner.cancel(args.job) if args.agent_action == "cancel" else (runner.status(args.job) if args.job else runner.list_jobs())
249
+ elif cmd == "update-check":
250
+ from .updates import update_info
251
+ result = update_info(args.workspace, force=args.force)
252
+ elif cmd == "upgrade":
253
+ from .updates import perform_upgrade, upgrade_plan
254
+ result = perform_upgrade(args.workspace) if args.yes else upgrade_plan()
255
+ if result.get("status") == "failed": exit_code = 4
256
+ else:
257
+ from .updates import changelog
258
+ print(changelog()); return 0
259
+ emit(result)
260
+ return exit_code
261
+ except KeyboardInterrupt:
262
+ print("Cancelled. Completed experiment records are retained.", file=sys.stderr)
263
+ return 130
264
+ except (ValueError, FileNotFoundError, RuntimeError, OSError, KeyError) as exc:
265
+ print(json.dumps({"error": {"code": "operation_failed", "message": str(exc)}}, ensure_ascii=False), file=sys.stderr)
266
+ return 2
267
+
268
+
269
+ if __name__ == "__main__":
270
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ """Synthetic examples that require no accounts or model calls."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def create_demo(workbench):
6
+ request = {
7
+ "model": "example-model",
8
+ "instructions": "Report the result of the originally requested export format.",
9
+ "input": [
10
+ {"role": "user", "content": "Export the project as CSV."},
11
+ {"role": "assistant", "content": "CSV is unavailable. Would PDF work?"},
12
+ {"role": "user", "content": "Yes, PDF is fine."},
13
+ {"role": "user", "content": "Export report: format=PDF; file_created=true; verification=confirmed."},
14
+ ],
15
+ "tools": [{"type": "function", "name": "report_result", "description": "Report whether the requested task was completed.", "parameters": {"type": "object", "properties": {"status": {"type": "string", "enum": ["success", "failed", "unknown"]}, "summary": {"type": "string"}}, "required": ["status", "summary"], "additionalProperties": False}}],
16
+ }
17
+ exp = workbench.import_source({"kind": "request", "data": request}, title="导出结果判断 · 人工构造演示")
18
+ with workbench.store.lock(exp["id"]):
19
+ exp["source"].update(synthetic=True, fidelity="synthetic")
20
+ exp["source"]["notes"].append("全部为人工构造示例,没有引用真实用户日志,也没有预设模型运行结果。")
21
+ exp["scenarios"][0].update(label="用户同意 PDF,文件生成已确认", origin="synthetic", description="演示中的原始输入:用户从 CSV 改为接受 PDF,随后收到了文件生成成功的确认。没有调用模型。")
22
+ workbench.store.save(exp)
23
+ exp = workbench.add_candidate(exp["id"], label="旧候选:针对 PDF 的写法(已放弃)", patch=[{"op": "replace", "path": "/instructions", "value": "When a PDF export exists, call report_result with status success."}], rationale="这版把 PDF 和成功工具调用写死,可能只碰巧通过当前例子,因此保留为过拟合反例。")
24
+ exp = workbench.add_candidate(exp["id"], label="按用户确认后的任务和证据判断", patch=[{"op": "replace", "path": "/instructions", "value": "Evaluate completion against the task as resolved through authorized user choices. Apply each choice only within its stated scope, retain other requirements, and distinguish confirmed effects from unresolved outcomes."}], rationale="用户同意 PDF 后应据此判断完成情况,同时保留其他要求,并在缺少执行证据时说明结果尚未确认。")
25
+ exp = workbench.add_scenario(exp["id"], label="文件生成结果未确认", patch=[{"op": "replace", "path": "/input/3/content", "value": "Export report: format=PDF; file_created=unknown; verification=unavailable. No file result was received."}], description="人工构造边界场景:只把最后一条报告的 file_created 从 true 改为 unknown,verification 从 confirmed 改为 unavailable;用户请求和同意 PDF 的内容不变。它表示没有收到文件结果,不能据此说失败。原版和候选都需在此输入下运行,当前尚未验证。", origin="synthetic", checks=[{"kind": "tool_argument_equals", "name": "report_result", "pointer": "/status", "value": "unknown"}])
26
+ return exp