applied-cli 0.6.10__tar.gz → 0.6.12__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.10 → applied_cli-0.6.12}/PKG-INFO +15 -1
  2. {applied_cli-0.6.10 → applied_cli-0.6.12}/README.md +14 -0
  3. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/__init__.py +1 -1
  4. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/client.py +20 -1
  5. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/tools.py +3 -0
  6. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/catalog.py +5 -1
  7. applied_cli-0.6.12/applied_cli/v2/evals.py +608 -0
  8. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/PKG-INFO +15 -1
  9. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/SOURCES.txt +2 -0
  10. {applied_cli-0.6.10 → applied_cli-0.6.12}/pyproject.toml +1 -1
  11. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_client.py +29 -0
  12. applied_cli-0.6.12/tests/test_v2_evals.py +187 -0
  13. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/agent_scoped_flows.py +0 -0
  14. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/auth.py +0 -0
  15. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/cli.py +0 -0
  16. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/conversation_lookup.py +0 -0
  17. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/conversations.py +0 -0
  18. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/credentials.py +0 -0
  19. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/flow_helpers.py +0 -0
  20. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/formatters.py +0 -0
  21. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/mcp.py +0 -0
  22. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/recovery.py +0 -0
  23. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/toolkit.py +0 -0
  24. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/__init__.py +0 -0
  25. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/agents.py +0 -0
  26. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/articles.py +0 -0
  27. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/connectors.py +0 -0
  28. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/content.py +0 -0
  29. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/conversations.py +0 -0
  30. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/domains.py +0 -0
  31. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/flows.py +0 -0
  32. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/knowledge.py +0 -0
  33. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/manifest.py +0 -0
  34. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/products.py +0 -0
  35. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/scenarios.py +0 -0
  36. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/taxonomy.py +0 -0
  37. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/tickets.py +0 -0
  38. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/dependency_links.txt +0 -0
  39. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/entry_points.txt +0 -0
  40. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/requires.txt +0 -0
  41. {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/top_level.txt +0 -0
  42. {applied_cli-0.6.10 → applied_cli-0.6.12}/setup.cfg +0 -0
  43. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_agent_scoped_flows.py +0 -0
  44. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_audit_tools.py +0 -0
  45. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_auth_context.py +0 -0
  46. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_clone.py +0 -0
  47. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_delete_guardrail.py +0 -0
  48. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_list_with_results.py +0 -0
  49. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_results.py +0 -0
  50. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_scenario_tools.py +0 -0
  51. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_cli.py +0 -0
  52. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_cli_v2.py +0 -0
  53. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_client_v2.py +0 -0
  54. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_conversation_tools.py +0 -0
  55. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_flow_tools.py +0 -0
  56. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_knowledge_content_tools.py +0 -0
  57. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_recovery.py +0 -0
  58. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_bulk_cancel.py +0 -0
  59. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_bulk_run_contact.py +0 -0
  60. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_bulk_run_wait.py +0 -0
  61. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_create_bulk.py +0 -0
  62. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_toolkit_contract.py +0 -0
  63. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_agents.py +0 -0
  64. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_articles.py +0 -0
  65. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_catalog_and_mcp.py +0 -0
  66. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_connectors.py +0 -0
  67. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_content.py +0 -0
  68. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_conversations.py +0 -0
  69. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_flows.py +0 -0
  70. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_knowledge.py +0 -0
  71. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_products.py +0 -0
  72. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_scenarios.py +0 -0
  73. {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_taxonomy.py +0 -0
  74. {applied_cli-0.6.10 → applied_cli-0.6.12}/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.10
3
+ Version: 0.6.12
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.
@@ -179,6 +181,18 @@ conversations = await tools.conversation_query(
179
181
  | `scenario_create_bulk` | Build scenarios from several conversations at once |
180
182
  | `scenario_bulk_run` | Run scenarios (contact override + wait-to-completion) |
181
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.
182
196
 
183
197
  ## Examples
184
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.
@@ -153,6 +155,18 @@ conversations = await tools.conversation_query(
153
155
  | `scenario_create_bulk` | Build scenarios from several conversations at once |
154
156
  | `scenario_bulk_run` | Run scenarios (contact override + wait-to-completion) |
155
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.
156
170
 
157
171
  ## Examples
158
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.12"
10
10
 
11
11
  __all__ = [
12
12
  "AppliedClient",
@@ -210,6 +210,21 @@ def _filter_has_explicit_lookup(field: str) -> bool:
210
210
  return field.endswith(_EXPLICIT_FILTER_SUFFIXES)
211
211
 
212
212
 
213
+ # Filters the server declares ONLY in scalar form (method filters, e.g. the
214
+ # evals review-state filters on ConversationFilterset). Rewriting these to
215
+ # `field__in` silently drops them — django-filter ignores unknown params —
216
+ # so they must pass through verbatim.
217
+ _SCALAR_ONLY_FILTER_FIELDS = frozenset(
218
+ {
219
+ "verdict",
220
+ "reviewed",
221
+ "needs_attention",
222
+ "exclude_audited",
223
+ "only_unaudited",
224
+ }
225
+ )
226
+
227
+
213
228
  def _serialize_filter_value(value: Any) -> str:
214
229
  if isinstance(value, (dict, list)):
215
230
  return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
@@ -260,7 +275,11 @@ def _apply_query_filter(params: dict[str, Any], field: str, cond: Any) -> None:
260
275
  )
261
276
  return
262
277
 
263
- key = field if explicit_lookup else f"{field}__in"
278
+ key = (
279
+ field
280
+ if explicit_lookup or field in _SCALAR_ONLY_FILTER_FIELDS
281
+ else f"{field}__in"
282
+ )
264
283
  params[key] = _serialize_filter_value(cond)
265
284
 
266
285
 
@@ -1186,6 +1186,9 @@ async def conversation_query(
1186
1186
  {"sublabel_id": "<intent-uuid>"} — filter by intent
1187
1187
  {"flags__in": "<flag-uuid>"} — has specific flag
1188
1188
  {"resolution": {"nin": ["escalated"]}} — exclude escalated
1189
+ {"verdict": "ai_flagged"} — AI Reviewer flagged (also: pass, fail,
1190
+ disagree, agree, none)
1191
+ {"reviewed": False} — no human review verdict yet
1189
1192
  search: Full-text search across conversation content
1190
1193
  fields: Fields to return, e.g. ["id", "title", "contact.email", "score", "comment"]
1191
1194
  When provided, fields override view-specific projections.
@@ -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,608 @@
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. Enumerate the review
11
+ queue directly with `review_status(segment="inbox")` (or "disagree"), or discover
12
+ conversations with `conversations_search` / `conversations_find` and feed their
13
+ ids here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Literal
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+ from applied_cli.client import AppliedAPIError, AppliedClient
23
+ from applied_cli.toolkit import ToolError, ToolResult, ToolSpec
24
+
25
+
26
+ class StrictInput(BaseModel):
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+
30
+ # ── inputs ──────────────────────────────────────────────────────────────────
31
+
32
+
33
+ class ReviewRunInput(StrictInput):
34
+ conversation_ids: list[str] = Field(
35
+ description="Conversation ids to grade (max 50 per call).",
36
+ )
37
+ force: bool = Field(
38
+ default=False,
39
+ description=(
40
+ "Re-grade even if an AI verdict already exists. Off by default so "
41
+ "already-reviewed conversations are skipped."
42
+ ),
43
+ )
44
+
45
+
46
+ class ReviewStatusInput(StrictInput):
47
+ conversation_ids: list[str] | None = Field(
48
+ default=None,
49
+ description=(
50
+ "Conversation ids to fetch verdict pairs + narration for. Omit and "
51
+ "pass segment to enumerate a queue segment instead."
52
+ ),
53
+ )
54
+ segment: Literal["inbox", "disagree"] | None = Field(
55
+ default=None,
56
+ description=(
57
+ "Enumerate a queue segment server-side, newest judgment first: "
58
+ "'inbox' (AI-flagged, no human verdict yet) or 'disagree' (human "
59
+ "and AI verdicts differ). Ignored when conversation_ids is set."
60
+ ),
61
+ )
62
+ limit: int | None = Field(
63
+ default=None,
64
+ description="Max segment rows (server default 50, max 200).",
65
+ )
66
+
67
+
68
+ class ReviewGetInput(StrictInput):
69
+ conversation_id: str
70
+
71
+
72
+ class ReviewSetVerdictInput(StrictInput):
73
+ conversation_id: str
74
+ verdict: Literal["pass", "fail"]
75
+ reason: str | None = Field(
76
+ default=None,
77
+ description=(
78
+ "Why — the teaching signal. The Reviewer folds recent reasons into "
79
+ "its criteria automatically, so a clear reason improves future grading."
80
+ ),
81
+ )
82
+ pinned: bool | None = Field(
83
+ default=None,
84
+ description="Pin this reason as a durable lesson that never rotates out.",
85
+ )
86
+
87
+
88
+ class ReviewClearVerdictInput(StrictInput):
89
+ conversation_id: str
90
+
91
+
92
+ class ReviewAddNoteInput(StrictInput):
93
+ conversation_id: str
94
+ kind: Literal["correction", "good"] = "correction"
95
+ observed: str | None = Field(
96
+ default=None, description="What the bot did wrong (corrections only)."
97
+ )
98
+ desired: str | None = Field(
99
+ default=None,
100
+ description="What it should have done, or why a good turn is worth reusing.",
101
+ )
102
+ message_id: str | None = Field(
103
+ default=None, description="The turn this note anchors to (optional)."
104
+ )
105
+
106
+
107
+ class ReviewTeachInput(StrictInput):
108
+ text: str = Field(
109
+ description=(
110
+ "A rule to teach the Reviewer directly. Lands pinned and applies to "
111
+ "every review from the next one on."
112
+ ),
113
+ )
114
+
115
+
116
+ class EmptyInput(StrictInput):
117
+ pass
118
+
119
+
120
+ class ReviewMakeTestInput(StrictInput):
121
+ conversation_id: str = Field(
122
+ description="The conversation to turn into a proposed regression test.",
123
+ )
124
+
125
+
126
+ class ProposalsListInput(StrictInput):
127
+ status: Literal["proposed", "accepted", "dismissed"] = "proposed"
128
+
129
+
130
+ class ProposalsDecideInput(StrictInput):
131
+ proposal_id: str
132
+ decision: Literal["accepted", "dismissed"]
133
+ customer_state_ref: str | None = Field(
134
+ default=None,
135
+ description=(
136
+ "When accepting an add/merge that has no fixture yet, the test "
137
+ "customer (CustomerState) id to bind the test to."
138
+ ),
139
+ )
140
+ reason: str | None = Field(
141
+ default=None,
142
+ description="When dismissing, why — a negative training signal for miners.",
143
+ )
144
+
145
+
146
+ # ── helpers ─────────────────────────────────────────────────────────────────
147
+
148
+
149
+ def _api_error_result(exc: AppliedAPIError) -> ToolResult[Any]:
150
+ return ToolResult(
151
+ ok=False,
152
+ error=ToolError(
153
+ code=f"api.{exc.status_code}",
154
+ message=str(exc),
155
+ status_code=exc.status_code,
156
+ retryable=exc.status_code in {408, 429, 500, 502, 503, 504},
157
+ recovery_hint=exc.suggestion,
158
+ ),
159
+ )
160
+
161
+
162
+ # ── handlers ────────────────────────────────────────────────────────────────
163
+
164
+
165
+ async def review_run_handler(
166
+ client: AppliedClient, params: ReviewRunInput
167
+ ) -> ToolResult[Any]:
168
+ ids = params.conversation_ids[:50]
169
+ if not ids:
170
+ return ToolResult(
171
+ ok=False,
172
+ error=ToolError(
173
+ code="input.invalid",
174
+ message="Provide at least one conversation id.",
175
+ retryable=False,
176
+ ),
177
+ )
178
+ try:
179
+ data = await client.request(
180
+ "POST",
181
+ "/v1/evals/review/",
182
+ body={"ids": ids, "force": params.force},
183
+ )
184
+ except AppliedAPIError as exc:
185
+ return _api_error_result(exc)
186
+ return ToolResult(
187
+ data=data,
188
+ summary=f"Ran the AI Reviewer over {len(ids)} conversation(s).",
189
+ next_actions=[
190
+ "Use review_status to read the verdicts and the Reviewer's reasons.",
191
+ "Use review_set_verdict to confirm or overrule a verdict.",
192
+ ],
193
+ )
194
+
195
+
196
+ async def review_status_handler(
197
+ client: AppliedClient, params: ReviewStatusInput
198
+ ) -> ToolResult[Any]:
199
+ ids = (params.conversation_ids or [])[:200]
200
+ if not ids and not params.segment:
201
+ return ToolResult(
202
+ ok=False,
203
+ error=ToolError(
204
+ code="input.invalid",
205
+ message=(
206
+ "Provide conversation_ids, or segment='inbox'/'disagree' "
207
+ "to enumerate a queue segment."
208
+ ),
209
+ retryable=False,
210
+ ),
211
+ )
212
+ query: dict[str, Any] = {}
213
+ if ids:
214
+ query["ids"] = ",".join(ids)
215
+ else:
216
+ query["segment"] = params.segment
217
+ if params.limit is not None:
218
+ query["limit"] = params.limit
219
+ try:
220
+ data = await client.request("GET", "/v1/evals/status/", params=query)
221
+ except AppliedAPIError as exc:
222
+ return _api_error_result(exc)
223
+ rows = data.get("rows", []) if isinstance(data, dict) else []
224
+ summary = (
225
+ f"{len(rows)} row(s) in the '{params.segment}' segment."
226
+ if not ids
227
+ else f"Verdict pairs for {len(rows)} of {len(ids)} conversation(s)."
228
+ )
229
+ return ToolResult(
230
+ data=data,
231
+ summary=summary,
232
+ next_actions=[
233
+ "Use review_get for the full review pair + notes on one conversation.",
234
+ "Use review_set_verdict to confirm or overrule a verdict.",
235
+ ],
236
+ )
237
+
238
+
239
+ async def review_get_handler(
240
+ client: AppliedClient, params: ReviewGetInput
241
+ ) -> ToolResult[Any]:
242
+ try:
243
+ data = await client.request("GET", f"/v1/evals/{params.conversation_id}/")
244
+ except AppliedAPIError as exc:
245
+ return _api_error_result(exc)
246
+ return ToolResult(
247
+ data=data,
248
+ summary=f"Review pair + notes for conversation {params.conversation_id}.",
249
+ next_actions=[
250
+ "Use review_set_verdict to record the human verdict.",
251
+ "Use review_make_test to turn a flagged conversation into a test.",
252
+ ],
253
+ )
254
+
255
+
256
+ async def review_set_verdict_handler(
257
+ client: AppliedClient, params: ReviewSetVerdictInput
258
+ ) -> ToolResult[Any]:
259
+ body: dict[str, Any] = {"verdict": params.verdict}
260
+ if params.reason is not None:
261
+ body["reason"] = params.reason
262
+ if params.pinned is not None:
263
+ body["pinned"] = params.pinned
264
+ try:
265
+ data = await client.request(
266
+ "POST", f"/v1/evals/{params.conversation_id}/review/", body=body
267
+ )
268
+ except AppliedAPIError as exc:
269
+ return _api_error_result(exc)
270
+ return ToolResult(
271
+ data=data,
272
+ summary=f"Recorded a '{params.verdict}' verdict.",
273
+ next_actions=(
274
+ [
275
+ "Use review_make_test to add this as a regression test.",
276
+ "Use review_add_note to flag the specific failing turn.",
277
+ ]
278
+ if params.verdict == "fail"
279
+ else []
280
+ ),
281
+ )
282
+
283
+
284
+ async def review_clear_verdict_handler(
285
+ client: AppliedClient, params: ReviewClearVerdictInput
286
+ ) -> ToolResult[Any]:
287
+ try:
288
+ await client.request("DELETE", f"/v1/evals/{params.conversation_id}/review/")
289
+ except AppliedAPIError as exc:
290
+ return _api_error_result(exc)
291
+ return ToolResult(
292
+ data={"conversation_id": params.conversation_id, "cleared": True},
293
+ summary="Withdrew the human verdict.",
294
+ )
295
+
296
+
297
+ async def review_add_note_handler(
298
+ client: AppliedClient, params: ReviewAddNoteInput
299
+ ) -> ToolResult[Any]:
300
+ if not (params.observed or params.desired):
301
+ return ToolResult(
302
+ ok=False,
303
+ error=ToolError(
304
+ code="input.invalid",
305
+ message="A note needs observed and/or desired text.",
306
+ retryable=False,
307
+ ),
308
+ )
309
+ body: dict[str, Any] = {"kind": params.kind}
310
+ if params.observed is not None:
311
+ body["observed"] = params.observed
312
+ if params.desired is not None:
313
+ body["desired"] = params.desired
314
+ if params.message_id is not None:
315
+ body["message_id"] = params.message_id
316
+ try:
317
+ data = await client.request(
318
+ "POST", f"/v1/evals/{params.conversation_id}/notes/", body=body
319
+ )
320
+ except AppliedAPIError as exc:
321
+ return _api_error_result(exc)
322
+ return ToolResult(data=data, summary=f"Added a '{params.kind}' note.")
323
+
324
+
325
+ async def review_trust_handler(
326
+ client: AppliedClient, params: EmptyInput
327
+ ) -> ToolResult[Any]:
328
+ try:
329
+ data = await client.request("GET", "/v1/evals/trust/")
330
+ except AppliedAPIError as exc:
331
+ return _api_error_result(exc)
332
+ pairs = data.get("total_pairs") if isinstance(data, dict) else None
333
+ return ToolResult(
334
+ data=data,
335
+ summary=(
336
+ f"Reviewer↔human calibration over {pairs} judged pair(s)."
337
+ if pairs is not None
338
+ else "Reviewer↔human calibration by topic."
339
+ ),
340
+ )
341
+
342
+
343
+ async def review_lessons_handler(
344
+ client: AppliedClient, params: EmptyInput
345
+ ) -> ToolResult[Any]:
346
+ try:
347
+ data = await client.request("GET", "/v1/evals/lessons/")
348
+ except AppliedAPIError as exc:
349
+ return _api_error_result(exc)
350
+ lessons = data.get("lessons", []) if isinstance(data, dict) else []
351
+ return ToolResult(
352
+ data=data,
353
+ summary=f"The Reviewer has learned {len(lessons)} lesson(s).",
354
+ next_actions=["Use review_teach to add a rule directly."],
355
+ )
356
+
357
+
358
+ async def review_teach_handler(
359
+ client: AppliedClient, params: ReviewTeachInput
360
+ ) -> ToolResult[Any]:
361
+ try:
362
+ data = await client.request(
363
+ "POST", "/v1/evals/lessons/", body={"text": params.text}
364
+ )
365
+ except AppliedAPIError as exc:
366
+ return _api_error_result(exc)
367
+ return ToolResult(
368
+ data=data,
369
+ summary="Taught the Reviewer a new rule (pinned).",
370
+ )
371
+
372
+
373
+ async def review_make_test_handler(
374
+ client: AppliedClient, params: ReviewMakeTestInput
375
+ ) -> ToolResult[Any]:
376
+ try:
377
+ data = await client.request(
378
+ "POST",
379
+ "/v1/simulation-test-proposals/from-conversation/",
380
+ body={"conversation_id": params.conversation_id},
381
+ )
382
+ except AppliedAPIError as exc:
383
+ return _api_error_result(exc)
384
+ return ToolResult(
385
+ data=data,
386
+ summary="Proposed a regression test (with an auto-built test customer).",
387
+ next_actions=[
388
+ "Use review_proposals_list to see the change inbox.",
389
+ "Use review_proposals_decide to accept it into the suite.",
390
+ ],
391
+ )
392
+
393
+
394
+ async def review_proposals_list_handler(
395
+ client: AppliedClient, params: ProposalsListInput
396
+ ) -> ToolResult[Any]:
397
+ try:
398
+ data = await client.request(
399
+ "GET",
400
+ "/v1/simulation-test-proposals/",
401
+ params={"status": params.status},
402
+ )
403
+ except AppliedAPIError as exc:
404
+ return _api_error_result(exc)
405
+ items = data.get("items", []) if isinstance(data, dict) else []
406
+ return ToolResult(
407
+ data=data,
408
+ summary=f"{len(items)} {params.status} test proposal(s).",
409
+ next_actions=[
410
+ "Use review_proposals_decide to accept or dismiss a proposal.",
411
+ ],
412
+ )
413
+
414
+
415
+ async def review_proposals_decide_handler(
416
+ client: AppliedClient, params: ProposalsDecideInput
417
+ ) -> ToolResult[Any]:
418
+ body: dict[str, Any] = {"status": params.decision}
419
+ if params.customer_state_ref is not None:
420
+ body["customer_state_ref"] = params.customer_state_ref
421
+ if params.reason is not None:
422
+ body["reason"] = params.reason
423
+ try:
424
+ data = await client.request(
425
+ "PATCH",
426
+ f"/v1/simulation-test-proposals/{params.proposal_id}/",
427
+ body=body,
428
+ )
429
+ except AppliedAPIError as exc:
430
+ return _api_error_result(exc)
431
+ return ToolResult(
432
+ data=data,
433
+ summary=f"Marked proposal {params.proposal_id} {params.decision}.",
434
+ next_actions=(
435
+ ["Use scenarios_bulk_run to run the newly added test."]
436
+ if params.decision == "accepted"
437
+ else []
438
+ ),
439
+ )
440
+
441
+
442
+ # ── specs ───────────────────────────────────────────────────────────────────
443
+
444
+
445
+ def evals_specs() -> list[ToolSpec]:
446
+ return [
447
+ ToolSpec(
448
+ name="review_run",
449
+ namespace="review",
450
+ description=(
451
+ "Run the AI Reviewer over one or more real conversations. It grades "
452
+ "each pass/fail against learned criteria without ever reading the "
453
+ "human verdict. Skips already-reviewed conversations unless force=true."
454
+ ),
455
+ input_model=ReviewRunInput,
456
+ output_model=None,
457
+ handler=review_run_handler,
458
+ read_write_mode="write",
459
+ tags=["evals", "native"],
460
+ ),
461
+ ToolSpec(
462
+ name="review_status",
463
+ namespace="review",
464
+ description=(
465
+ "Fetch the verdict pair (AI + human) and the Reviewer's one-line "
466
+ "reason for a set of conversations, plus shop-wide review counts. "
467
+ "Pass segment='inbox' (AI-flagged, unreviewed) or 'disagree' "
468
+ "instead of ids to enumerate the review queue directly."
469
+ ),
470
+ input_model=ReviewStatusInput,
471
+ output_model=None,
472
+ handler=review_status_handler,
473
+ read_write_mode="read",
474
+ tags=["evals", "native"],
475
+ ),
476
+ ToolSpec(
477
+ name="review_get",
478
+ namespace="review",
479
+ description=(
480
+ "Get the full review of one conversation: the AI verdict + reason, "
481
+ "the human verdict, and every turn-level note."
482
+ ),
483
+ input_model=ReviewGetInput,
484
+ output_model=None,
485
+ handler=review_get_handler,
486
+ read_write_mode="read",
487
+ tags=["evals", "native"],
488
+ ),
489
+ ToolSpec(
490
+ name="review_set_verdict",
491
+ namespace="review",
492
+ description=(
493
+ "Record the human verdict on a conversation (pass/fail) with an "
494
+ "optional teaching reason. The reason folds into the Reviewer's "
495
+ "criteria automatically. Upserts — call again to change it."
496
+ ),
497
+ input_model=ReviewSetVerdictInput,
498
+ output_model=None,
499
+ handler=review_set_verdict_handler,
500
+ read_write_mode="write",
501
+ tags=["evals", "native"],
502
+ ),
503
+ ToolSpec(
504
+ name="review_clear_verdict",
505
+ namespace="review",
506
+ description="Withdraw the human verdict on a conversation (undo).",
507
+ input_model=ReviewClearVerdictInput,
508
+ output_model=None,
509
+ handler=review_clear_verdict_handler,
510
+ read_write_mode="write",
511
+ tags=["evals", "native"],
512
+ ),
513
+ ToolSpec(
514
+ name="review_add_note",
515
+ namespace="review",
516
+ description=(
517
+ "Attach a turn-level note to a conversation: a correction (what the "
518
+ "bot did wrong / what it should have done) or a good example."
519
+ ),
520
+ input_model=ReviewAddNoteInput,
521
+ output_model=None,
522
+ handler=review_add_note_handler,
523
+ read_write_mode="write",
524
+ tags=["evals", "native"],
525
+ ),
526
+ ToolSpec(
527
+ name="review_trust",
528
+ namespace="review",
529
+ description=(
530
+ "Per-topic Reviewer↔human calibration: agreement, and false passes "
531
+ "(the Reviewer passed what a human flagged) — the direction to watch."
532
+ ),
533
+ input_model=EmptyInput,
534
+ output_model=None,
535
+ handler=review_trust_handler,
536
+ read_write_mode="read",
537
+ tags=["evals", "native"],
538
+ ),
539
+ ToolSpec(
540
+ name="review_lessons",
541
+ namespace="review",
542
+ description=(
543
+ "What the Reviewer has learned — the capped set of human signals "
544
+ "(verdict reasons, turn notes, taught rules, merged lessons) its "
545
+ "criteria compile from, with provenance."
546
+ ),
547
+ input_model=EmptyInput,
548
+ output_model=None,
549
+ handler=review_lessons_handler,
550
+ read_write_mode="read",
551
+ tags=["evals", "native"],
552
+ ),
553
+ ToolSpec(
554
+ name="review_teach",
555
+ namespace="review",
556
+ description=(
557
+ "Teach the Reviewer a rule directly. Lands pinned and applies to "
558
+ "every review from the next one on."
559
+ ),
560
+ input_model=ReviewTeachInput,
561
+ output_model=None,
562
+ handler=review_teach_handler,
563
+ read_write_mode="write",
564
+ tags=["evals", "native"],
565
+ ),
566
+ ToolSpec(
567
+ name="review_make_test",
568
+ namespace="review",
569
+ description=(
570
+ "Turn a reviewed conversation into a proposed regression test — "
571
+ "drafts both the test and a fictionalized test customer, and lands "
572
+ "it in the suite's change inbox. The one path from a bad "
573
+ "conversation to a suite candidate."
574
+ ),
575
+ input_model=ReviewMakeTestInput,
576
+ output_model=None,
577
+ handler=review_make_test_handler,
578
+ read_write_mode="write",
579
+ tags=["evals", "native"],
580
+ ),
581
+ ToolSpec(
582
+ name="review_proposals_list",
583
+ namespace="review",
584
+ description=(
585
+ "List test proposals in the change inbox (proposed / accepted / "
586
+ "dismissed) — adds, edits, retires, and merges awaiting a decision."
587
+ ),
588
+ input_model=ProposalsListInput,
589
+ output_model=None,
590
+ handler=review_proposals_list_handler,
591
+ read_write_mode="read",
592
+ tags=["evals", "native"],
593
+ ),
594
+ ToolSpec(
595
+ name="review_proposals_decide",
596
+ namespace="review",
597
+ description=(
598
+ "Accept or dismiss a test proposal. Accepting an add/merge without "
599
+ "a fixture needs a customer_state_ref; dismissing takes an optional "
600
+ "reason used as a negative training signal."
601
+ ),
602
+ input_model=ProposalsDecideInput,
603
+ output_model=None,
604
+ handler=review_proposals_decide_handler,
605
+ read_write_mode="write",
606
+ tags=["evals", "native"],
607
+ ),
608
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.6.10
3
+ Version: 0.6.12
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.
@@ -179,6 +181,18 @@ conversations = await tools.conversation_query(
179
181
  | `scenario_create_bulk` | Build scenarios from several conversations at once |
180
182
  | `scenario_bulk_run` | Run scenarios (contact override + wait-to-completion) |
181
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.
182
196
 
183
197
  ## Examples
184
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.10"
3
+ version = "0.6.12"
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"
@@ -136,6 +136,35 @@ async def test_query_conversations_serializes_metric_event_filter_payload(monkey
136
136
  )
137
137
 
138
138
 
139
+ @pytest.mark.asyncio
140
+ async def test_query_conversations_passes_scalar_only_filters_verbatim(monkeypatch):
141
+ """Method filters like the evals review-state filters exist server-side only
142
+ in scalar form — rewriting them to `field__in` silently drops them."""
143
+ client = AppliedClient(token="test-token")
144
+ seen = {}
145
+
146
+ async def fake_request(method, path, params=None, body=None, shop_id=None):
147
+ seen["params"] = params
148
+ return []
149
+
150
+ monkeypatch.setattr(client, "_request", fake_request)
151
+
152
+ await client.query_conversations(
153
+ filters={
154
+ "verdict": "ai_flagged",
155
+ "reviewed": False,
156
+ "resolution": "escalated",
157
+ }
158
+ )
159
+
160
+ assert seen["params"]["verdict"] == "ai_flagged"
161
+ assert seen["params"]["reviewed"] == "false"
162
+ assert "verdict__in" not in seen["params"]
163
+ assert "reviewed__in" not in seen["params"]
164
+ # Ordinary scalars keep the historical `__in` rewrite.
165
+ assert seen["params"]["resolution__in"] == "escalated"
166
+
167
+
139
168
  @pytest.mark.asyncio
140
169
  async def test_analytics_report_posts_payload(monkeypatch):
141
170
  client = AppliedClient(token="test-token")
@@ -0,0 +1,187 @@
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_status_enumerates_segment_without_ids():
81
+ client = FakeEvalsClient({"rows": [{"conversation_id": "a"}], "counts": {}})
82
+ result = await _run("review_status", client, segment="inbox", limit=25)
83
+ assert result.ok
84
+ assert client.calls[0]["params"] == {"segment": "inbox", "limit": 25}
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_review_status_ids_win_over_segment():
89
+ client = FakeEvalsClient({"rows": [], "counts": {}})
90
+ await _run("review_status", client, conversation_ids=["a"], segment="inbox")
91
+ assert client.calls[0]["params"] == {"ids": "a"}
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_review_status_requires_ids_or_segment():
96
+ client = FakeEvalsClient()
97
+ result = await _run("review_status", client)
98
+ assert not result.ok
99
+ assert result.error.code == "input.invalid"
100
+ assert client.calls == []
101
+
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_review_set_verdict_omits_unset_optionals():
105
+ client = FakeEvalsClient({"verdict": "fail"})
106
+ await _run(
107
+ "review_set_verdict",
108
+ client,
109
+ conversation_id="conv-1",
110
+ verdict="fail",
111
+ reason="Promised a refund it never issued.",
112
+ )
113
+ call = client.calls[0]
114
+ assert call["path"] == "/v1/evals/conv-1/review/"
115
+ assert call["body"] == {
116
+ "verdict": "fail",
117
+ "reason": "Promised a refund it never issued.",
118
+ }
119
+ assert "pinned" not in call["body"]
120
+
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_review_clear_verdict_deletes():
124
+ client = FakeEvalsClient(None)
125
+ result = await _run("review_clear_verdict", client, conversation_id="conv-1")
126
+ assert result.ok
127
+ assert client.calls[0]["method"] == "DELETE"
128
+ assert client.calls[0]["path"] == "/v1/evals/conv-1/review/"
129
+
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_review_add_note_requires_text():
133
+ client = FakeEvalsClient()
134
+ result = await _run(
135
+ "review_add_note", client, conversation_id="conv-1", kind="correction"
136
+ )
137
+ assert not result.ok
138
+ assert result.error.code == "input.invalid"
139
+ assert client.calls == []
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_review_teach_posts_rule_text():
144
+ client = FakeEvalsClient({"id": "note-1"})
145
+ await _run("review_teach", client, text="Always verify the subscription first.")
146
+ assert client.calls[0] == {
147
+ "method": "POST",
148
+ "path": "/v1/evals/lessons/",
149
+ "params": None,
150
+ "body": {"text": "Always verify the subscription first."},
151
+ }
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_review_make_test_hits_from_conversation():
156
+ client = FakeEvalsClient({"id": "prop-1", "kind": "add"})
157
+ await _run("review_make_test", client, conversation_id="conv-9")
158
+ assert client.calls[0]["path"] == (
159
+ "/v1/simulation-test-proposals/from-conversation/"
160
+ )
161
+ assert client.calls[0]["body"] == {"conversation_id": "conv-9"}
162
+
163
+
164
+ @pytest.mark.asyncio
165
+ async def test_review_proposals_decide_accept_with_customer():
166
+ client = FakeEvalsClient({"status": "accepted"})
167
+ await _run(
168
+ "review_proposals_decide",
169
+ client,
170
+ proposal_id="prop-1",
171
+ decision="accepted",
172
+ customer_state_ref="cust-1",
173
+ )
174
+ call = client.calls[0]
175
+ assert call["method"] == "PATCH"
176
+ assert call["path"] == "/v1/simulation-test-proposals/prop-1/"
177
+ assert call["body"] == {"status": "accepted", "customer_state_ref": "cust-1"}
178
+
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_unknown_field_rejected_before_handler():
182
+ client = FakeEvalsClient()
183
+ spec = get_tool_catalog()["review_trust"]
184
+ result = await spec.run(client, {"bogus": 1})
185
+ assert not result.ok
186
+ assert result.error.code == "input.invalid"
187
+ assert client.calls == []
File without changes