applied-cli 0.6.9__tar.gz → 0.6.11__tar.gz

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.
Files changed (74) hide show
  1. {applied_cli-0.6.9 → applied_cli-0.6.11}/PKG-INFO +25 -1
  2. {applied_cli-0.6.9 → applied_cli-0.6.11}/README.md +24 -0
  3. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/__init__.py +1 -1
  4. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/catalog.py +5 -1
  5. applied_cli-0.6.11/applied_cli/v2/evals.py +567 -0
  6. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli.egg-info/PKG-INFO +25 -1
  7. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli.egg-info/SOURCES.txt +2 -0
  8. {applied_cli-0.6.9 → applied_cli-0.6.11}/pyproject.toml +1 -1
  9. applied_cli-0.6.11/tests/test_v2_evals.py +163 -0
  10. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/agent_scoped_flows.py +0 -0
  11. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/auth.py +0 -0
  12. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/cli.py +0 -0
  13. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/client.py +0 -0
  14. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/conversation_lookup.py +0 -0
  15. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/conversations.py +0 -0
  16. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/credentials.py +0 -0
  17. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/flow_helpers.py +0 -0
  18. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/formatters.py +0 -0
  19. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/mcp.py +0 -0
  20. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/recovery.py +0 -0
  21. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/toolkit.py +0 -0
  22. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/tools.py +0 -0
  23. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/__init__.py +0 -0
  24. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/agents.py +0 -0
  25. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/articles.py +0 -0
  26. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/connectors.py +0 -0
  27. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/content.py +0 -0
  28. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/conversations.py +0 -0
  29. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/domains.py +0 -0
  30. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/flows.py +0 -0
  31. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/knowledge.py +0 -0
  32. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/manifest.py +0 -0
  33. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/products.py +0 -0
  34. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/scenarios.py +0 -0
  35. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/taxonomy.py +0 -0
  36. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli/v2/tickets.py +0 -0
  37. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli.egg-info/dependency_links.txt +0 -0
  38. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli.egg-info/entry_points.txt +0 -0
  39. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli.egg-info/requires.txt +0 -0
  40. {applied_cli-0.6.9 → applied_cli-0.6.11}/applied_cli.egg-info/top_level.txt +0 -0
  41. {applied_cli-0.6.9 → applied_cli-0.6.11}/setup.cfg +0 -0
  42. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_agent_scoped_flows.py +0 -0
  43. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_audit_tools.py +0 -0
  44. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_auth_context.py +0 -0
  45. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_benchmark_clone.py +0 -0
  46. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_benchmark_delete_guardrail.py +0 -0
  47. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_benchmark_list_with_results.py +0 -0
  48. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_benchmark_results.py +0 -0
  49. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_benchmark_scenario_tools.py +0 -0
  50. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_cli.py +0 -0
  51. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_cli_v2.py +0 -0
  52. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_client.py +0 -0
  53. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_client_v2.py +0 -0
  54. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_conversation_tools.py +0 -0
  55. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_flow_tools.py +0 -0
  56. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_knowledge_content_tools.py +0 -0
  57. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_recovery.py +0 -0
  58. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_scenario_bulk_cancel.py +0 -0
  59. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_scenario_bulk_run_contact.py +0 -0
  60. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_scenario_bulk_run_wait.py +0 -0
  61. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_scenario_create_bulk.py +0 -0
  62. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_toolkit_contract.py +0 -0
  63. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_agents.py +0 -0
  64. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_articles.py +0 -0
  65. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_catalog_and_mcp.py +0 -0
  66. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_connectors.py +0 -0
  67. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_content.py +0 -0
  68. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_conversations.py +0 -0
  69. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_flows.py +0 -0
  70. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_knowledge.py +0 -0
  71. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_products.py +0 -0
  72. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_scenarios.py +0 -0
  73. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_taxonomy.py +0 -0
  74. {applied_cli-0.6.9 → applied_cli-0.6.11}/tests/test_v2_tickets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.6.9
3
+ Version: 0.6.11
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -82,6 +82,8 @@ Raw analytics SQL is not available through the public CLI surface.
82
82
 
83
83
  ## Benchmarks & Scenarios
84
84
 
85
+ > **LEGACY surface.** Benchmarks and scenarios are the older regression-suite system. The current evaluation system is the **/evals** surface (feedback + simulations + test data), driven by the `simulation_test_*`, `test_customer_*`, and `simulation_run_*` tools (MCP / `/v1/simulation-tests/`, `/v1/test-customers/`, `/v1/simulation-runs/`). The `feedback` field on `scenario_update` / `scenario_run_update` is a legacy free-text note, NOT the new Judgment+Comment feedback surface. Use the /evals tools for new evaluation work; the tools below are for maintaining existing benchmark suites.
86
+
85
87
  A **benchmark** is a named regression suite; a **scenario** is one test conversation
86
88
  (built from a real `input_conversation_id`) that can belong to one or more benchmarks.
87
89
  The typical loop is: build a suite → run it → review the pass rate → fix → re-run.
@@ -97,6 +99,11 @@ applied benchmark-create --agent-id <agent_id> --name "Cancel Regression"
97
99
  applied scenario-create --input-conversation-id <conversation_id> --name "<name>" \
98
100
  --benchmark-id <benchmark_id>
99
101
 
102
+ # Build a suite fast from several real conversations at once. Each scenario is
103
+ # named from its conversation's title (or "<prefix> N" with --name-prefix).
104
+ applied scenario-create-bulk --conversation-ids <id1>,<id2>,<id3> \
105
+ --benchmark-id <benchmark_id>
106
+
100
107
  # Port a suite to another agent (e.g. email -> chat). Cross-agent recreates the
101
108
  # scenarios under the destination agent; same-agent just tags them in.
102
109
  # Dry-run by default; add --apply to write.
@@ -116,6 +123,9 @@ applied scenario-bulk-cancel <job_id> --apply
116
123
  # Review pass/fail health (pass_status reflects the latest run per scenario)
117
124
  applied benchmark-results <benchmark_id> --format json
118
125
 
126
+ # Portfolio go/no-go: pass rates across all of an agent's benchmarks at a glance
127
+ applied benchmarks --agent-id <agent_id> --with-results --format json
128
+
119
129
  # Rate scenarios as you evaluate
120
130
  applied scenario-update <scenario_id> --pass-status pass --feedback "<note>"
121
131
 
@@ -167,8 +177,22 @@ conversations = await tools.conversation_query(
167
177
  | `benchmark_clone` | Copy all scenarios from one benchmark into another |
168
178
  | `benchmark_delete` | Delete a benchmark (guards against wiping scenarios) |
169
179
  | `benchmark_results` | Pass/fail/unrated tally and pass rate for a benchmark |
180
+ | `benchmark_list` | List benchmarks (with per-benchmark pass rates via `with_results`) |
181
+ | `scenario_create_bulk` | Build scenarios from several conversations at once |
170
182
  | `scenario_bulk_run` | Run scenarios (contact override + wait-to-completion) |
171
183
  | `scenario_bulk_cancel` | Cancel a stuck bulk run's queued/running scenario runs |
184
+ | `review_run` | Grade real conversations with the AI Reviewer (pass/fail) |
185
+ | `review_status` | Verdict pairs + the Reviewer's reason for a set of conversations |
186
+ | `review_set_verdict` | Record the human verdict with a teaching reason |
187
+ | `review_teach` | Teach the Reviewer a rule directly |
188
+ | `review_make_test` | Turn a flagged conversation into a proposed regression test |
189
+ | `review_proposals_list` | The suite change-inbox (proposed adds/edits/retires/merges) |
190
+ | `review_proposals_decide` | Accept or dismiss a proposed test |
191
+
192
+ The full `review_*` namespace also includes `review_get`, `review_clear_verdict`,
193
+ `review_add_note`, `review_trust`, and `review_lessons` — the outer review loop:
194
+ grade real traffic, confirm/overrule with a reason that teaches the Reviewer, and
195
+ promote failures into the simulation suite as tests.
172
196
 
173
197
  ## Examples
174
198
 
@@ -56,6 +56,8 @@ Raw analytics SQL is not available through the public CLI surface.
56
56
 
57
57
  ## Benchmarks & Scenarios
58
58
 
59
+ > **LEGACY surface.** Benchmarks and scenarios are the older regression-suite system. The current evaluation system is the **/evals** surface (feedback + simulations + test data), driven by the `simulation_test_*`, `test_customer_*`, and `simulation_run_*` tools (MCP / `/v1/simulation-tests/`, `/v1/test-customers/`, `/v1/simulation-runs/`). The `feedback` field on `scenario_update` / `scenario_run_update` is a legacy free-text note, NOT the new Judgment+Comment feedback surface. Use the /evals tools for new evaluation work; the tools below are for maintaining existing benchmark suites.
60
+
59
61
  A **benchmark** is a named regression suite; a **scenario** is one test conversation
60
62
  (built from a real `input_conversation_id`) that can belong to one or more benchmarks.
61
63
  The typical loop is: build a suite → run it → review the pass rate → fix → re-run.
@@ -71,6 +73,11 @@ applied benchmark-create --agent-id <agent_id> --name "Cancel Regression"
71
73
  applied scenario-create --input-conversation-id <conversation_id> --name "<name>" \
72
74
  --benchmark-id <benchmark_id>
73
75
 
76
+ # Build a suite fast from several real conversations at once. Each scenario is
77
+ # named from its conversation's title (or "<prefix> N" with --name-prefix).
78
+ applied scenario-create-bulk --conversation-ids <id1>,<id2>,<id3> \
79
+ --benchmark-id <benchmark_id>
80
+
74
81
  # Port a suite to another agent (e.g. email -> chat). Cross-agent recreates the
75
82
  # scenarios under the destination agent; same-agent just tags them in.
76
83
  # Dry-run by default; add --apply to write.
@@ -90,6 +97,9 @@ applied scenario-bulk-cancel <job_id> --apply
90
97
  # Review pass/fail health (pass_status reflects the latest run per scenario)
91
98
  applied benchmark-results <benchmark_id> --format json
92
99
 
100
+ # Portfolio go/no-go: pass rates across all of an agent's benchmarks at a glance
101
+ applied benchmarks --agent-id <agent_id> --with-results --format json
102
+
93
103
  # Rate scenarios as you evaluate
94
104
  applied scenario-update <scenario_id> --pass-status pass --feedback "<note>"
95
105
 
@@ -141,8 +151,22 @@ conversations = await tools.conversation_query(
141
151
  | `benchmark_clone` | Copy all scenarios from one benchmark into another |
142
152
  | `benchmark_delete` | Delete a benchmark (guards against wiping scenarios) |
143
153
  | `benchmark_results` | Pass/fail/unrated tally and pass rate for a benchmark |
154
+ | `benchmark_list` | List benchmarks (with per-benchmark pass rates via `with_results`) |
155
+ | `scenario_create_bulk` | Build scenarios from several conversations at once |
144
156
  | `scenario_bulk_run` | Run scenarios (contact override + wait-to-completion) |
145
157
  | `scenario_bulk_cancel` | Cancel a stuck bulk run's queued/running scenario runs |
158
+ | `review_run` | Grade real conversations with the AI Reviewer (pass/fail) |
159
+ | `review_status` | Verdict pairs + the Reviewer's reason for a set of conversations |
160
+ | `review_set_verdict` | Record the human verdict with a teaching reason |
161
+ | `review_teach` | Teach the Reviewer a rule directly |
162
+ | `review_make_test` | Turn a flagged conversation into a proposed regression test |
163
+ | `review_proposals_list` | The suite change-inbox (proposed adds/edits/retires/merges) |
164
+ | `review_proposals_decide` | Accept or dismiss a proposed test |
165
+
166
+ The full `review_*` namespace also includes `review_get`, `review_clear_verdict`,
167
+ `review_add_note`, `review_trust`, and `review_lessons` — the outer review loop:
168
+ grade real traffic, confirm/overrule with a reason that teaches the Reviewer, and
169
+ promote failures into the simulation suite as tests.
146
170
 
147
171
  ## Examples
148
172
 
@@ -6,7 +6,7 @@ from applied_cli.client import AppliedClient
6
6
  from applied_cli.formatters import to_csv, to_json
7
7
  from applied_cli.toolkit import ToolResult, ToolSpec
8
8
 
9
- __version__ = "0.6.0"
9
+ __version__ = "0.6.11"
10
10
 
11
11
  __all__ = [
12
12
  "AppliedClient",
@@ -18,6 +18,7 @@ from applied_cli.v2.connectors import connector_specs
18
18
  from applied_cli.v2.content import content_specs
19
19
  from applied_cli.v2.conversations import conversation_specs
20
20
  from applied_cli.v2.domains import LEGACY_TOOL_RENAMES
21
+ from applied_cli.v2.evals import evals_specs
21
22
  from applied_cli.v2.flows import flow_specs
22
23
  from applied_cli.v2.knowledge import knowledge_specs
23
24
  from applied_cli.v2.products import product_specs
@@ -99,7 +100,9 @@ def _input_model_for(func: Any, v2_name: str) -> type[BaseModel]:
99
100
  }:
100
101
  continue
101
102
  annotation = _safe_annotation(parameter.annotation)
102
- default = ... if parameter.default is inspect.Parameter.empty else parameter.default
103
+ default = (
104
+ ... if parameter.default is inspect.Parameter.empty else parameter.default
105
+ )
103
106
  fields[name] = (annotation, default)
104
107
 
105
108
  if not fields:
@@ -225,6 +228,7 @@ def get_tool_catalog(include_debug: bool = False) -> ToolCatalog:
225
228
  *connector_specs(),
226
229
  *content_specs(),
227
230
  *conversation_specs(),
231
+ *evals_specs(),
228
232
  *flow_specs(),
229
233
  *knowledge_specs(),
230
234
  *product_specs(),
@@ -0,0 +1,567 @@
1
+ """Native structured v2 tools for the evals / review loop.
2
+
3
+ The review loop over real traffic: the AI Reviewer grades a conversation, a human
4
+ confirms or overrules with a teaching reason, the reason folds back into the
5
+ Reviewer's criteria, and a flagged conversation becomes a regression test in one
6
+ step. These tools expose that loop to an agent — grade conversations, record
7
+ verdicts, teach the Reviewer, and promote a bad conversation into the simulation
8
+ suite as a proposed test (with an auto-generated test customer).
9
+
10
+ All endpoints are shop-scoped and merchant-authenticated. Discover conversations
11
+ to review with `conversations_search` / `conversations_find`, then feed their ids
12
+ here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Literal
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from applied_cli.client import AppliedAPIError, AppliedClient
22
+ from applied_cli.toolkit import ToolError, ToolResult, ToolSpec
23
+
24
+
25
+ class StrictInput(BaseModel):
26
+ model_config = ConfigDict(extra="forbid")
27
+
28
+
29
+ # ── inputs ──────────────────────────────────────────────────────────────────
30
+
31
+
32
+ class ReviewRunInput(StrictInput):
33
+ conversation_ids: list[str] = Field(
34
+ description="Conversation ids to grade (max 50 per call).",
35
+ )
36
+ force: bool = Field(
37
+ default=False,
38
+ description=(
39
+ "Re-grade even if an AI verdict already exists. Off by default so "
40
+ "already-reviewed conversations are skipped."
41
+ ),
42
+ )
43
+
44
+
45
+ class ReviewStatusInput(StrictInput):
46
+ conversation_ids: list[str] = Field(
47
+ description="Conversation ids to fetch verdict pairs + narration for.",
48
+ )
49
+
50
+
51
+ class ReviewGetInput(StrictInput):
52
+ conversation_id: str
53
+
54
+
55
+ class ReviewSetVerdictInput(StrictInput):
56
+ conversation_id: str
57
+ verdict: Literal["pass", "fail"]
58
+ reason: str | None = Field(
59
+ default=None,
60
+ description=(
61
+ "Why — the teaching signal. The Reviewer folds recent reasons into "
62
+ "its criteria automatically, so a clear reason improves future grading."
63
+ ),
64
+ )
65
+ pinned: bool | None = Field(
66
+ default=None,
67
+ description="Pin this reason as a durable lesson that never rotates out.",
68
+ )
69
+
70
+
71
+ class ReviewClearVerdictInput(StrictInput):
72
+ conversation_id: str
73
+
74
+
75
+ class ReviewAddNoteInput(StrictInput):
76
+ conversation_id: str
77
+ kind: Literal["correction", "good"] = "correction"
78
+ observed: str | None = Field(
79
+ default=None, description="What the bot did wrong (corrections only)."
80
+ )
81
+ desired: str | None = Field(
82
+ default=None,
83
+ description="What it should have done, or why a good turn is worth reusing.",
84
+ )
85
+ message_id: str | None = Field(
86
+ default=None, description="The turn this note anchors to (optional)."
87
+ )
88
+
89
+
90
+ class ReviewTeachInput(StrictInput):
91
+ text: str = Field(
92
+ description=(
93
+ "A rule to teach the Reviewer directly. Lands pinned and applies to "
94
+ "every review from the next one on."
95
+ ),
96
+ )
97
+
98
+
99
+ class EmptyInput(StrictInput):
100
+ pass
101
+
102
+
103
+ class ReviewMakeTestInput(StrictInput):
104
+ conversation_id: str = Field(
105
+ description="The conversation to turn into a proposed regression test.",
106
+ )
107
+
108
+
109
+ class ProposalsListInput(StrictInput):
110
+ status: Literal["proposed", "accepted", "dismissed"] = "proposed"
111
+
112
+
113
+ class ProposalsDecideInput(StrictInput):
114
+ proposal_id: str
115
+ decision: Literal["accepted", "dismissed"]
116
+ customer_state_ref: str | None = Field(
117
+ default=None,
118
+ description=(
119
+ "When accepting an add/merge that has no fixture yet, the test "
120
+ "customer (CustomerState) id to bind the test to."
121
+ ),
122
+ )
123
+ reason: str | None = Field(
124
+ default=None,
125
+ description="When dismissing, why — a negative training signal for miners.",
126
+ )
127
+
128
+
129
+ # ── helpers ─────────────────────────────────────────────────────────────────
130
+
131
+
132
+ def _api_error_result(exc: AppliedAPIError) -> ToolResult[Any]:
133
+ return ToolResult(
134
+ ok=False,
135
+ error=ToolError(
136
+ code=f"api.{exc.status_code}",
137
+ message=str(exc),
138
+ status_code=exc.status_code,
139
+ retryable=exc.status_code in {408, 429, 500, 502, 503, 504},
140
+ recovery_hint=exc.suggestion,
141
+ ),
142
+ )
143
+
144
+
145
+ # ── handlers ────────────────────────────────────────────────────────────────
146
+
147
+
148
+ async def review_run_handler(
149
+ client: AppliedClient, params: ReviewRunInput
150
+ ) -> ToolResult[Any]:
151
+ ids = params.conversation_ids[:50]
152
+ if not ids:
153
+ return ToolResult(
154
+ ok=False,
155
+ error=ToolError(
156
+ code="input.invalid",
157
+ message="Provide at least one conversation id.",
158
+ retryable=False,
159
+ ),
160
+ )
161
+ try:
162
+ data = await client.request(
163
+ "POST",
164
+ "/v1/evals/review/",
165
+ body={"ids": ids, "force": params.force},
166
+ )
167
+ except AppliedAPIError as exc:
168
+ return _api_error_result(exc)
169
+ return ToolResult(
170
+ data=data,
171
+ summary=f"Ran the AI Reviewer over {len(ids)} conversation(s).",
172
+ next_actions=[
173
+ "Use review_status to read the verdicts and the Reviewer's reasons.",
174
+ "Use review_set_verdict to confirm or overrule a verdict.",
175
+ ],
176
+ )
177
+
178
+
179
+ async def review_status_handler(
180
+ client: AppliedClient, params: ReviewStatusInput
181
+ ) -> ToolResult[Any]:
182
+ ids = params.conversation_ids[:200]
183
+ try:
184
+ data = await client.request(
185
+ "GET", "/v1/evals/status/", params={"ids": ",".join(ids)}
186
+ )
187
+ except AppliedAPIError as exc:
188
+ return _api_error_result(exc)
189
+ rows = data.get("rows", []) if isinstance(data, dict) else []
190
+ return ToolResult(
191
+ data=data,
192
+ summary=f"Verdict pairs for {len(rows)} of {len(ids)} conversation(s).",
193
+ next_actions=[
194
+ "Use review_get for the full review pair + notes on one conversation.",
195
+ "Use review_run with the unreviewed ids to grade them.",
196
+ ],
197
+ )
198
+
199
+
200
+ async def review_get_handler(
201
+ client: AppliedClient, params: ReviewGetInput
202
+ ) -> ToolResult[Any]:
203
+ try:
204
+ data = await client.request("GET", f"/v1/evals/{params.conversation_id}/")
205
+ except AppliedAPIError as exc:
206
+ return _api_error_result(exc)
207
+ return ToolResult(
208
+ data=data,
209
+ summary=f"Review pair + notes for conversation {params.conversation_id}.",
210
+ next_actions=[
211
+ "Use review_set_verdict to record the human verdict.",
212
+ "Use review_make_test to turn a flagged conversation into a test.",
213
+ ],
214
+ )
215
+
216
+
217
+ async def review_set_verdict_handler(
218
+ client: AppliedClient, params: ReviewSetVerdictInput
219
+ ) -> ToolResult[Any]:
220
+ body: dict[str, Any] = {"verdict": params.verdict}
221
+ if params.reason is not None:
222
+ body["reason"] = params.reason
223
+ if params.pinned is not None:
224
+ body["pinned"] = params.pinned
225
+ try:
226
+ data = await client.request(
227
+ "POST", f"/v1/evals/{params.conversation_id}/review/", body=body
228
+ )
229
+ except AppliedAPIError as exc:
230
+ return _api_error_result(exc)
231
+ return ToolResult(
232
+ data=data,
233
+ summary=f"Recorded a '{params.verdict}' verdict.",
234
+ next_actions=(
235
+ [
236
+ "Use review_make_test to add this as a regression test.",
237
+ "Use review_add_note to flag the specific failing turn.",
238
+ ]
239
+ if params.verdict == "fail"
240
+ else []
241
+ ),
242
+ )
243
+
244
+
245
+ async def review_clear_verdict_handler(
246
+ client: AppliedClient, params: ReviewClearVerdictInput
247
+ ) -> ToolResult[Any]:
248
+ try:
249
+ await client.request("DELETE", f"/v1/evals/{params.conversation_id}/review/")
250
+ except AppliedAPIError as exc:
251
+ return _api_error_result(exc)
252
+ return ToolResult(
253
+ data={"conversation_id": params.conversation_id, "cleared": True},
254
+ summary="Withdrew the human verdict.",
255
+ )
256
+
257
+
258
+ async def review_add_note_handler(
259
+ client: AppliedClient, params: ReviewAddNoteInput
260
+ ) -> ToolResult[Any]:
261
+ if not (params.observed or params.desired):
262
+ return ToolResult(
263
+ ok=False,
264
+ error=ToolError(
265
+ code="input.invalid",
266
+ message="A note needs observed and/or desired text.",
267
+ retryable=False,
268
+ ),
269
+ )
270
+ body: dict[str, Any] = {"kind": params.kind}
271
+ if params.observed is not None:
272
+ body["observed"] = params.observed
273
+ if params.desired is not None:
274
+ body["desired"] = params.desired
275
+ if params.message_id is not None:
276
+ body["message_id"] = params.message_id
277
+ try:
278
+ data = await client.request(
279
+ "POST", f"/v1/evals/{params.conversation_id}/notes/", body=body
280
+ )
281
+ except AppliedAPIError as exc:
282
+ return _api_error_result(exc)
283
+ return ToolResult(data=data, summary=f"Added a '{params.kind}' note.")
284
+
285
+
286
+ async def review_trust_handler(
287
+ client: AppliedClient, params: EmptyInput
288
+ ) -> ToolResult[Any]:
289
+ try:
290
+ data = await client.request("GET", "/v1/evals/trust/")
291
+ except AppliedAPIError as exc:
292
+ return _api_error_result(exc)
293
+ pairs = data.get("total_pairs") if isinstance(data, dict) else None
294
+ return ToolResult(
295
+ data=data,
296
+ summary=(
297
+ f"Reviewer↔human calibration over {pairs} judged pair(s)."
298
+ if pairs is not None
299
+ else "Reviewer↔human calibration by topic."
300
+ ),
301
+ )
302
+
303
+
304
+ async def review_lessons_handler(
305
+ client: AppliedClient, params: EmptyInput
306
+ ) -> ToolResult[Any]:
307
+ try:
308
+ data = await client.request("GET", "/v1/evals/lessons/")
309
+ except AppliedAPIError as exc:
310
+ return _api_error_result(exc)
311
+ lessons = data.get("lessons", []) if isinstance(data, dict) else []
312
+ return ToolResult(
313
+ data=data,
314
+ summary=f"The Reviewer has learned {len(lessons)} lesson(s).",
315
+ next_actions=["Use review_teach to add a rule directly."],
316
+ )
317
+
318
+
319
+ async def review_teach_handler(
320
+ client: AppliedClient, params: ReviewTeachInput
321
+ ) -> ToolResult[Any]:
322
+ try:
323
+ data = await client.request(
324
+ "POST", "/v1/evals/lessons/", body={"text": params.text}
325
+ )
326
+ except AppliedAPIError as exc:
327
+ return _api_error_result(exc)
328
+ return ToolResult(
329
+ data=data,
330
+ summary="Taught the Reviewer a new rule (pinned).",
331
+ )
332
+
333
+
334
+ async def review_make_test_handler(
335
+ client: AppliedClient, params: ReviewMakeTestInput
336
+ ) -> ToolResult[Any]:
337
+ try:
338
+ data = await client.request(
339
+ "POST",
340
+ "/v1/simulation-test-proposals/from-conversation/",
341
+ body={"conversation_id": params.conversation_id},
342
+ )
343
+ except AppliedAPIError as exc:
344
+ return _api_error_result(exc)
345
+ return ToolResult(
346
+ data=data,
347
+ summary="Proposed a regression test (with an auto-built test customer).",
348
+ next_actions=[
349
+ "Use review_proposals_list to see the change inbox.",
350
+ "Use review_proposals_decide to accept it into the suite.",
351
+ ],
352
+ )
353
+
354
+
355
+ async def review_proposals_list_handler(
356
+ client: AppliedClient, params: ProposalsListInput
357
+ ) -> ToolResult[Any]:
358
+ try:
359
+ data = await client.request(
360
+ "GET",
361
+ "/v1/simulation-test-proposals/",
362
+ params={"status": params.status},
363
+ )
364
+ except AppliedAPIError as exc:
365
+ return _api_error_result(exc)
366
+ items = data.get("items", []) if isinstance(data, dict) else []
367
+ return ToolResult(
368
+ data=data,
369
+ summary=f"{len(items)} {params.status} test proposal(s).",
370
+ next_actions=[
371
+ "Use review_proposals_decide to accept or dismiss a proposal.",
372
+ ],
373
+ )
374
+
375
+
376
+ async def review_proposals_decide_handler(
377
+ client: AppliedClient, params: ProposalsDecideInput
378
+ ) -> ToolResult[Any]:
379
+ body: dict[str, Any] = {"status": params.decision}
380
+ if params.customer_state_ref is not None:
381
+ body["customer_state_ref"] = params.customer_state_ref
382
+ if params.reason is not None:
383
+ body["reason"] = params.reason
384
+ try:
385
+ data = await client.request(
386
+ "PATCH",
387
+ f"/v1/simulation-test-proposals/{params.proposal_id}/",
388
+ body=body,
389
+ )
390
+ except AppliedAPIError as exc:
391
+ return _api_error_result(exc)
392
+ return ToolResult(
393
+ data=data,
394
+ summary=f"Marked proposal {params.proposal_id} {params.decision}.",
395
+ next_actions=(
396
+ ["Use scenarios_bulk_run to run the newly added test."]
397
+ if params.decision == "accepted"
398
+ else []
399
+ ),
400
+ )
401
+
402
+
403
+ # ── specs ───────────────────────────────────────────────────────────────────
404
+
405
+
406
+ def evals_specs() -> list[ToolSpec]:
407
+ return [
408
+ ToolSpec(
409
+ name="review_run",
410
+ namespace="review",
411
+ description=(
412
+ "Run the AI Reviewer over one or more real conversations. It grades "
413
+ "each pass/fail against learned criteria without ever reading the "
414
+ "human verdict. Skips already-reviewed conversations unless force=true."
415
+ ),
416
+ input_model=ReviewRunInput,
417
+ output_model=None,
418
+ handler=review_run_handler,
419
+ read_write_mode="write",
420
+ tags=["evals", "native"],
421
+ ),
422
+ ToolSpec(
423
+ name="review_status",
424
+ namespace="review",
425
+ description=(
426
+ "Fetch the verdict pair (AI + human) and the Reviewer's one-line "
427
+ "reason for a set of conversations, plus shop-wide review counts."
428
+ ),
429
+ input_model=ReviewStatusInput,
430
+ output_model=None,
431
+ handler=review_status_handler,
432
+ read_write_mode="read",
433
+ tags=["evals", "native"],
434
+ ),
435
+ ToolSpec(
436
+ name="review_get",
437
+ namespace="review",
438
+ description=(
439
+ "Get the full review of one conversation: the AI verdict + reason, "
440
+ "the human verdict, and every turn-level note."
441
+ ),
442
+ input_model=ReviewGetInput,
443
+ output_model=None,
444
+ handler=review_get_handler,
445
+ read_write_mode="read",
446
+ tags=["evals", "native"],
447
+ ),
448
+ ToolSpec(
449
+ name="review_set_verdict",
450
+ namespace="review",
451
+ description=(
452
+ "Record the human verdict on a conversation (pass/fail) with an "
453
+ "optional teaching reason. The reason folds into the Reviewer's "
454
+ "criteria automatically. Upserts — call again to change it."
455
+ ),
456
+ input_model=ReviewSetVerdictInput,
457
+ output_model=None,
458
+ handler=review_set_verdict_handler,
459
+ read_write_mode="write",
460
+ tags=["evals", "native"],
461
+ ),
462
+ ToolSpec(
463
+ name="review_clear_verdict",
464
+ namespace="review",
465
+ description="Withdraw the human verdict on a conversation (undo).",
466
+ input_model=ReviewClearVerdictInput,
467
+ output_model=None,
468
+ handler=review_clear_verdict_handler,
469
+ read_write_mode="write",
470
+ tags=["evals", "native"],
471
+ ),
472
+ ToolSpec(
473
+ name="review_add_note",
474
+ namespace="review",
475
+ description=(
476
+ "Attach a turn-level note to a conversation: a correction (what the "
477
+ "bot did wrong / what it should have done) or a good example."
478
+ ),
479
+ input_model=ReviewAddNoteInput,
480
+ output_model=None,
481
+ handler=review_add_note_handler,
482
+ read_write_mode="write",
483
+ tags=["evals", "native"],
484
+ ),
485
+ ToolSpec(
486
+ name="review_trust",
487
+ namespace="review",
488
+ description=(
489
+ "Per-topic Reviewer↔human calibration: agreement, and false passes "
490
+ "(the Reviewer passed what a human flagged) — the direction to watch."
491
+ ),
492
+ input_model=EmptyInput,
493
+ output_model=None,
494
+ handler=review_trust_handler,
495
+ read_write_mode="read",
496
+ tags=["evals", "native"],
497
+ ),
498
+ ToolSpec(
499
+ name="review_lessons",
500
+ namespace="review",
501
+ description=(
502
+ "What the Reviewer has learned — the capped set of human signals "
503
+ "(verdict reasons, turn notes, taught rules, merged lessons) its "
504
+ "criteria compile from, with provenance."
505
+ ),
506
+ input_model=EmptyInput,
507
+ output_model=None,
508
+ handler=review_lessons_handler,
509
+ read_write_mode="read",
510
+ tags=["evals", "native"],
511
+ ),
512
+ ToolSpec(
513
+ name="review_teach",
514
+ namespace="review",
515
+ description=(
516
+ "Teach the Reviewer a rule directly. Lands pinned and applies to "
517
+ "every review from the next one on."
518
+ ),
519
+ input_model=ReviewTeachInput,
520
+ output_model=None,
521
+ handler=review_teach_handler,
522
+ read_write_mode="write",
523
+ tags=["evals", "native"],
524
+ ),
525
+ ToolSpec(
526
+ name="review_make_test",
527
+ namespace="review",
528
+ description=(
529
+ "Turn a reviewed conversation into a proposed regression test — "
530
+ "drafts both the test and a fictionalized test customer, and lands "
531
+ "it in the suite's change inbox. The one path from a bad "
532
+ "conversation to a suite candidate."
533
+ ),
534
+ input_model=ReviewMakeTestInput,
535
+ output_model=None,
536
+ handler=review_make_test_handler,
537
+ read_write_mode="write",
538
+ tags=["evals", "native"],
539
+ ),
540
+ ToolSpec(
541
+ name="review_proposals_list",
542
+ namespace="review",
543
+ description=(
544
+ "List test proposals in the change inbox (proposed / accepted / "
545
+ "dismissed) — adds, edits, retires, and merges awaiting a decision."
546
+ ),
547
+ input_model=ProposalsListInput,
548
+ output_model=None,
549
+ handler=review_proposals_list_handler,
550
+ read_write_mode="read",
551
+ tags=["evals", "native"],
552
+ ),
553
+ ToolSpec(
554
+ name="review_proposals_decide",
555
+ namespace="review",
556
+ description=(
557
+ "Accept or dismiss a test proposal. Accepting an add/merge without "
558
+ "a fixture needs a customer_state_ref; dismissing takes an optional "
559
+ "reason used as a negative training signal."
560
+ ),
561
+ input_model=ProposalsDecideInput,
562
+ output_model=None,
563
+ handler=review_proposals_decide_handler,
564
+ read_write_mode="write",
565
+ tags=["evals", "native"],
566
+ ),
567
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.6.9
3
+ Version: 0.6.11
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -82,6 +82,8 @@ Raw analytics SQL is not available through the public CLI surface.
82
82
 
83
83
  ## Benchmarks & Scenarios
84
84
 
85
+ > **LEGACY surface.** Benchmarks and scenarios are the older regression-suite system. The current evaluation system is the **/evals** surface (feedback + simulations + test data), driven by the `simulation_test_*`, `test_customer_*`, and `simulation_run_*` tools (MCP / `/v1/simulation-tests/`, `/v1/test-customers/`, `/v1/simulation-runs/`). The `feedback` field on `scenario_update` / `scenario_run_update` is a legacy free-text note, NOT the new Judgment+Comment feedback surface. Use the /evals tools for new evaluation work; the tools below are for maintaining existing benchmark suites.
86
+
85
87
  A **benchmark** is a named regression suite; a **scenario** is one test conversation
86
88
  (built from a real `input_conversation_id`) that can belong to one or more benchmarks.
87
89
  The typical loop is: build a suite → run it → review the pass rate → fix → re-run.
@@ -97,6 +99,11 @@ applied benchmark-create --agent-id <agent_id> --name "Cancel Regression"
97
99
  applied scenario-create --input-conversation-id <conversation_id> --name "<name>" \
98
100
  --benchmark-id <benchmark_id>
99
101
 
102
+ # Build a suite fast from several real conversations at once. Each scenario is
103
+ # named from its conversation's title (or "<prefix> N" with --name-prefix).
104
+ applied scenario-create-bulk --conversation-ids <id1>,<id2>,<id3> \
105
+ --benchmark-id <benchmark_id>
106
+
100
107
  # Port a suite to another agent (e.g. email -> chat). Cross-agent recreates the
101
108
  # scenarios under the destination agent; same-agent just tags them in.
102
109
  # Dry-run by default; add --apply to write.
@@ -116,6 +123,9 @@ applied scenario-bulk-cancel <job_id> --apply
116
123
  # Review pass/fail health (pass_status reflects the latest run per scenario)
117
124
  applied benchmark-results <benchmark_id> --format json
118
125
 
126
+ # Portfolio go/no-go: pass rates across all of an agent's benchmarks at a glance
127
+ applied benchmarks --agent-id <agent_id> --with-results --format json
128
+
119
129
  # Rate scenarios as you evaluate
120
130
  applied scenario-update <scenario_id> --pass-status pass --feedback "<note>"
121
131
 
@@ -167,8 +177,22 @@ conversations = await tools.conversation_query(
167
177
  | `benchmark_clone` | Copy all scenarios from one benchmark into another |
168
178
  | `benchmark_delete` | Delete a benchmark (guards against wiping scenarios) |
169
179
  | `benchmark_results` | Pass/fail/unrated tally and pass rate for a benchmark |
180
+ | `benchmark_list` | List benchmarks (with per-benchmark pass rates via `with_results`) |
181
+ | `scenario_create_bulk` | Build scenarios from several conversations at once |
170
182
  | `scenario_bulk_run` | Run scenarios (contact override + wait-to-completion) |
171
183
  | `scenario_bulk_cancel` | Cancel a stuck bulk run's queued/running scenario runs |
184
+ | `review_run` | Grade real conversations with the AI Reviewer (pass/fail) |
185
+ | `review_status` | Verdict pairs + the Reviewer's reason for a set of conversations |
186
+ | `review_set_verdict` | Record the human verdict with a teaching reason |
187
+ | `review_teach` | Teach the Reviewer a rule directly |
188
+ | `review_make_test` | Turn a flagged conversation into a proposed regression test |
189
+ | `review_proposals_list` | The suite change-inbox (proposed adds/edits/retires/merges) |
190
+ | `review_proposals_decide` | Accept or dismiss a proposed test |
191
+
192
+ The full `review_*` namespace also includes `review_get`, `review_clear_verdict`,
193
+ `review_add_note`, `review_trust`, and `review_lessons` — the outer review loop:
194
+ grade real traffic, confirm/overrule with a reason that teaches the Reviewer, and
195
+ promote failures into the simulation suite as tests.
172
196
 
173
197
  ## Examples
174
198
 
@@ -28,6 +28,7 @@ applied_cli/v2/connectors.py
28
28
  applied_cli/v2/content.py
29
29
  applied_cli/v2/conversations.py
30
30
  applied_cli/v2/domains.py
31
+ applied_cli/v2/evals.py
31
32
  applied_cli/v2/flows.py
32
33
  applied_cli/v2/knowledge.py
33
34
  applied_cli/v2/manifest.py
@@ -62,6 +63,7 @@ tests/test_v2_catalog_and_mcp.py
62
63
  tests/test_v2_connectors.py
63
64
  tests/test_v2_content.py
64
65
  tests/test_v2_conversations.py
66
+ tests/test_v2_evals.py
65
67
  tests/test_v2_flows.py
66
68
  tests/test_v2_knowledge.py
67
69
  tests/test_v2_products.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "applied-cli"
3
- version = "0.6.9"
3
+ version = "0.6.11"
4
4
  description = "CLI and shared client library for Applied Labs AI support agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,163 @@
1
+ import pytest
2
+
3
+ from applied_cli.v2.catalog import get_tool_catalog
4
+
5
+
6
+ class FakeEvalsClient:
7
+ """Records the raw request(method, path, params, body) each tool makes and
8
+ returns a canned response, so we assert the exact wire shape."""
9
+
10
+ def __init__(self, response=None):
11
+ self.access_mode = "full_access"
12
+ self.shop_id = "shop-1"
13
+ self.calls: list[dict] = []
14
+ self._response = response if response is not None else {}
15
+
16
+ async def request(self, method, path, params=None, body=None, shop_id=None):
17
+ self.calls.append(
18
+ {"method": method, "path": path, "params": params, "body": body}
19
+ )
20
+ return self._response
21
+
22
+
23
+ async def _run(tool_name, client, **kwargs):
24
+ spec = get_tool_catalog()[tool_name]
25
+ return await spec.run(client, kwargs)
26
+
27
+
28
+ def test_all_review_tools_registered():
29
+ catalog = get_tool_catalog()
30
+ review = {s.name for s in catalog if s.namespace == "review"}
31
+ assert review == {
32
+ "review_run",
33
+ "review_status",
34
+ "review_get",
35
+ "review_set_verdict",
36
+ "review_clear_verdict",
37
+ "review_add_note",
38
+ "review_trust",
39
+ "review_lessons",
40
+ "review_teach",
41
+ "review_make_test",
42
+ "review_proposals_list",
43
+ "review_proposals_decide",
44
+ }
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_review_run_posts_ids_and_force():
49
+ client = FakeEvalsClient({"graded": 2, "flagged": 1})
50
+ result = await _run("review_run", client, conversation_ids=["a", "b"], force=True)
51
+ assert result.ok
52
+ assert client.calls[0] == {
53
+ "method": "POST",
54
+ "path": "/v1/evals/review/",
55
+ "params": None,
56
+ "body": {"ids": ["a", "b"], "force": True},
57
+ }
58
+ assert result.data == {"graded": 2, "flagged": 1}
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_review_run_rejects_empty_ids_without_calling_api():
63
+ client = FakeEvalsClient()
64
+ result = await _run("review_run", client, conversation_ids=[])
65
+ assert not result.ok
66
+ assert result.error.code == "input.invalid"
67
+ assert client.calls == []
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_review_status_joins_ids_into_query():
72
+ client = FakeEvalsClient({"rows": [{"conversation_id": "a"}], "counts": {}})
73
+ await _run("review_status", client, conversation_ids=["a", "b", "c"])
74
+ assert client.calls[0]["method"] == "GET"
75
+ assert client.calls[0]["path"] == "/v1/evals/status/"
76
+ assert client.calls[0]["params"] == {"ids": "a,b,c"}
77
+
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_review_set_verdict_omits_unset_optionals():
81
+ client = FakeEvalsClient({"verdict": "fail"})
82
+ await _run(
83
+ "review_set_verdict",
84
+ client,
85
+ conversation_id="conv-1",
86
+ verdict="fail",
87
+ reason="Promised a refund it never issued.",
88
+ )
89
+ call = client.calls[0]
90
+ assert call["path"] == "/v1/evals/conv-1/review/"
91
+ assert call["body"] == {
92
+ "verdict": "fail",
93
+ "reason": "Promised a refund it never issued.",
94
+ }
95
+ assert "pinned" not in call["body"]
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_review_clear_verdict_deletes():
100
+ client = FakeEvalsClient(None)
101
+ result = await _run("review_clear_verdict", client, conversation_id="conv-1")
102
+ assert result.ok
103
+ assert client.calls[0]["method"] == "DELETE"
104
+ assert client.calls[0]["path"] == "/v1/evals/conv-1/review/"
105
+
106
+
107
+ @pytest.mark.asyncio
108
+ async def test_review_add_note_requires_text():
109
+ client = FakeEvalsClient()
110
+ result = await _run(
111
+ "review_add_note", client, conversation_id="conv-1", kind="correction"
112
+ )
113
+ assert not result.ok
114
+ assert result.error.code == "input.invalid"
115
+ assert client.calls == []
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_review_teach_posts_rule_text():
120
+ client = FakeEvalsClient({"id": "note-1"})
121
+ await _run("review_teach", client, text="Always verify the subscription first.")
122
+ assert client.calls[0] == {
123
+ "method": "POST",
124
+ "path": "/v1/evals/lessons/",
125
+ "params": None,
126
+ "body": {"text": "Always verify the subscription first."},
127
+ }
128
+
129
+
130
+ @pytest.mark.asyncio
131
+ async def test_review_make_test_hits_from_conversation():
132
+ client = FakeEvalsClient({"id": "prop-1", "kind": "add"})
133
+ await _run("review_make_test", client, conversation_id="conv-9")
134
+ assert client.calls[0]["path"] == (
135
+ "/v1/simulation-test-proposals/from-conversation/"
136
+ )
137
+ assert client.calls[0]["body"] == {"conversation_id": "conv-9"}
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_review_proposals_decide_accept_with_customer():
142
+ client = FakeEvalsClient({"status": "accepted"})
143
+ await _run(
144
+ "review_proposals_decide",
145
+ client,
146
+ proposal_id="prop-1",
147
+ decision="accepted",
148
+ customer_state_ref="cust-1",
149
+ )
150
+ call = client.calls[0]
151
+ assert call["method"] == "PATCH"
152
+ assert call["path"] == "/v1/simulation-test-proposals/prop-1/"
153
+ assert call["body"] == {"status": "accepted", "customer_state_ref": "cust-1"}
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_unknown_field_rejected_before_handler():
158
+ client = FakeEvalsClient()
159
+ spec = get_tool_catalog()["review_trust"]
160
+ result = await spec.run(client, {"bogus": 1})
161
+ assert not result.ok
162
+ assert result.error.code == "input.invalid"
163
+ assert client.calls == []
File without changes