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.
- {applied_cli-0.6.10 → applied_cli-0.6.12}/PKG-INFO +15 -1
- {applied_cli-0.6.10 → applied_cli-0.6.12}/README.md +14 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/__init__.py +1 -1
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/client.py +20 -1
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/tools.py +3 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/catalog.py +5 -1
- applied_cli-0.6.12/applied_cli/v2/evals.py +608 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/PKG-INFO +15 -1
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/SOURCES.txt +2 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/pyproject.toml +1 -1
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_client.py +29 -0
- applied_cli-0.6.12/tests/test_v2_evals.py +187 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/agent_scoped_flows.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/auth.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/cli.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/conversation_lookup.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/conversations.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/credentials.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/flow_helpers.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/formatters.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/mcp.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/recovery.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/toolkit.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/__init__.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/agents.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/articles.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/connectors.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/content.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/conversations.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/domains.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/flows.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/knowledge.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/manifest.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/products.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/scenarios.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/taxonomy.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli/v2/tickets.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/dependency_links.txt +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/entry_points.txt +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/requires.txt +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/applied_cli.egg-info/top_level.txt +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/setup.cfg +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_agent_scoped_flows.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_audit_tools.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_auth_context.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_clone.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_delete_guardrail.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_list_with_results.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_results.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_benchmark_scenario_tools.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_cli.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_cli_v2.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_client_v2.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_conversation_tools.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_flow_tools.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_knowledge_content_tools.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_recovery.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_bulk_cancel.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_bulk_run_contact.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_bulk_run_wait.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_scenario_create_bulk.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_toolkit_contract.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_agents.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_articles.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_catalog_and_mcp.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_connectors.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_content.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_conversations.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_flows.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_knowledge.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_products.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_scenarios.py +0 -0
- {applied_cli-0.6.10 → applied_cli-0.6.12}/tests/test_v2_taxonomy.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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 =
|
|
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 =
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|