corethink 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- corethink/__init__.py +17 -0
- corethink/__main__.py +3 -0
- corethink/_version.py +3 -0
- corethink/account.py +470 -0
- corethink/cli.py +1058 -0
- corethink/client.py +253 -0
- corethink/commands/__init__.py +0 -0
- corethink/commands/admin.py +703 -0
- corethink/commands/api.py +59 -0
- corethink/commands/archive.py +433 -0
- corethink/commands/auth.py +375 -0
- corethink/commands/billing.py +314 -0
- corethink/commands/capture.py +564 -0
- corethink/commands/connection.py +1266 -0
- corethink/commands/endpoint.py +1124 -0
- corethink/commands/env.py +1966 -0
- corethink/commands/hub.py +269 -0
- corethink/commands/library.py +347 -0
- corethink/commands/models.py +258 -0
- corethink/commands/train.py +2655 -0
- corethink/commands/workspace.py +622 -0
- corethink/config.py +181 -0
- corethink/context.py +46 -0
- corethink/env_template.py +117 -0
- corethink/environment_http_store.py +96 -0
- corethink/experiment_cli.py +2187 -0
- corethink/hosted_profiles.py +44 -0
- corethink/hosted_rl.py +688 -0
- corethink/hosted_rl_archive.py +74 -0
- corethink/hosted_rl_attribution.py +44 -0
- corethink/hosted_rl_billing.py +103 -0
- corethink/hosted_rl_state.py +182 -0
- corethink/jobs_cli.py +839 -0
- corethink/main.py +204 -0
- corethink/output.py +201 -0
- corethink/py.typed +0 -0
- corethink/rl_cli.py +997 -0
- corethink/rl_inspect.py +521 -0
- corethink/rl_playground.py +129 -0
- corethink/rl_reassessment.py +214 -0
- corethink/rl_settings.py +43 -0
- corethink/sdk.py +331 -0
- corethink/settings.py +162 -0
- corethink-1.0.0.dist-info/METADATA +120 -0
- corethink-1.0.0.dist-info/RECORD +48 -0
- corethink-1.0.0.dist-info/WHEEL +5 -0
- corethink-1.0.0.dist-info/entry_points.txt +2 -0
- corethink-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""`corethink capture`: an environment made from live traffic (companion/harnessCapture.ts).
|
|
2
|
+
|
|
3
|
+
Start a capture, point your app at `<program>@capture` (a strong model answers meanwhile), label what it caught (good, fix,
|
|
4
|
+
skip; a judge can suggest), then build the dataset: the labelled conversations become the environment's tasks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from corethink import output as ui
|
|
17
|
+
from corethink.client import ApiError
|
|
18
|
+
from corethink.commands.env import (
|
|
19
|
+
BASE,
|
|
20
|
+
_check_verdict,
|
|
21
|
+
_clip,
|
|
22
|
+
_plural,
|
|
23
|
+
_q,
|
|
24
|
+
find,
|
|
25
|
+
list_environments,
|
|
26
|
+
print_check,
|
|
27
|
+
)
|
|
28
|
+
from corethink.context import client
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
help="Make an environment from live traffic: capture, label, build its tasks.",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
ENV_ARGUMENT = typer.Argument(..., help="The capture's environment: its ID, the start of it, or its name.")
|
|
36
|
+
CONVERSATION_ID = re.compile(r"^[0-9a-f]{24}$")
|
|
37
|
+
FILTERS = ("all", "unlabelled", "good", "fix", "skip", "suggested")
|
|
38
|
+
DEFAULTS = {"days": 2, "budget": 25.0, "teacher": "anthropic/claude-sonnet-5.5"}
|
|
39
|
+
MAX_BUDGET_USD = 100
|
|
40
|
+
PAGE = 50
|
|
41
|
+
JUDGE_AT_ONCE = 25
|
|
42
|
+
|
|
43
|
+
STATE = {
|
|
44
|
+
"running": ("Capturing", "cyan"),
|
|
45
|
+
"stopped": ("Stopped", "dim"),
|
|
46
|
+
"ended": ("Ended", "dim"),
|
|
47
|
+
"limit": ("At its spending limit", "yellow"),
|
|
48
|
+
"none": ("Not started", "dim"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _env_id(given: str) -> str:
|
|
53
|
+
return str(find(list_environments(), given)["id"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _status(env_id: str) -> dict[str, Any]:
|
|
57
|
+
return client().get(f"{BASE}/usecases/{_q(env_id)}/capture") or {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _gateway(env_id: str) -> str | None:
|
|
61
|
+
try:
|
|
62
|
+
return (client().get(f"{BASE}/usecases/{_q(env_id)}/endpoint") or {}).get("gateway")
|
|
63
|
+
except ApiError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _snippet(program: str, gateway: str | None) -> str:
|
|
68
|
+
return f"""import os
|
|
69
|
+
from openai import OpenAI
|
|
70
|
+
|
|
71
|
+
client = OpenAI(base_url="{gateway or "<gateway>"}/v1", api_key=os.environ["CORETHINK_API_KEY"])
|
|
72
|
+
client.chat.completions.create(
|
|
73
|
+
model="{program}@capture",
|
|
74
|
+
messages=messages, # with your system prompt, as your app sends it today
|
|
75
|
+
tools=tools, # your tools; your code runs the calls, as with any model
|
|
76
|
+
user=conversation_id,
|
|
77
|
+
)"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command("teachers")
|
|
81
|
+
def teachers() -> None:
|
|
82
|
+
"""The models that can answer your traffic while it is captured."""
|
|
83
|
+
rows = (client().get(f"{BASE}/usecases:capture") or {}).get("teachers", [])
|
|
84
|
+
|
|
85
|
+
def human() -> None:
|
|
86
|
+
ui.table(rows, [("Model", "id"), ("Name", "label"), ("Note", "note")], empty="No models offered.")
|
|
87
|
+
|
|
88
|
+
ui.emit(rows, human)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command("start")
|
|
92
|
+
def start(
|
|
93
|
+
name: str = typer.Option(..., "--name", help="The environment's name."),
|
|
94
|
+
description: str = typer.Option("", "--description", help="What it is for."),
|
|
95
|
+
days: int = typer.Option(DEFAULTS["days"], "--days", min=1, max=7, help="How long to capture (1 to 7 days)."),
|
|
96
|
+
budget: float = typer.Option(
|
|
97
|
+
DEFAULTS["budget"],
|
|
98
|
+
"--budget",
|
|
99
|
+
min=1,
|
|
100
|
+
max=MAX_BUDGET_USD,
|
|
101
|
+
help="Spending limit in USD (held from your credit).",
|
|
102
|
+
),
|
|
103
|
+
teacher: str = typer.Option(
|
|
104
|
+
DEFAULTS["teacher"],
|
|
105
|
+
"--teacher",
|
|
106
|
+
help="The model that answers meanwhile (`capture teachers`).",
|
|
107
|
+
),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Start capturing live traffic into a new environment.
|
|
110
|
+
|
|
111
|
+
Example: corethink capture start --name "Support chat" --days 3 --budget 40
|
|
112
|
+
"""
|
|
113
|
+
started = client().post(
|
|
114
|
+
f"{BASE}/usecases:capture",
|
|
115
|
+
{
|
|
116
|
+
"name": name,
|
|
117
|
+
"description": description,
|
|
118
|
+
"days": days,
|
|
119
|
+
"budgetUsd": budget,
|
|
120
|
+
"teacher": teacher,
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
meta = started.get("meta") or {}
|
|
124
|
+
program = started.get("program") or ""
|
|
125
|
+
gateway = _gateway(str(meta.get("id"))) if meta.get("id") else None
|
|
126
|
+
|
|
127
|
+
def human() -> None:
|
|
128
|
+
plan = started.get("plan") or {}
|
|
129
|
+
ui.success(
|
|
130
|
+
f"Capturing into {meta.get('name')} ({meta.get('id')}) until {ui.when(plan.get('until'))}, up to {ui.usd(plan.get('budget_usd'))}."
|
|
131
|
+
)
|
|
132
|
+
ui.out.print(f"Point your app at model [bold]{program}@capture[/bold]:")
|
|
133
|
+
ui.out.print(_snippet(program, gateway), markup=False)
|
|
134
|
+
ui.info(
|
|
135
|
+
"It needs an API key of this environment's endpoint (see `corethink endpoint --help`, or its page). "
|
|
136
|
+
f"Then: corethink capture status {meta.get('id')}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
ui.emit({**started, "gateway": gateway, "model": f"{program}@capture"}, human)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("status")
|
|
143
|
+
def status(env: str = ENV_ARGUMENT) -> None:
|
|
144
|
+
"""Is a capture running, what it caught and spent, and how to point an app at it."""
|
|
145
|
+
env_id = _env_id(env)
|
|
146
|
+
found = _status(env_id)
|
|
147
|
+
|
|
148
|
+
def human() -> None:
|
|
149
|
+
plan = found.get("plan") or {}
|
|
150
|
+
label, style = STATE.get(str(found.get("state")), (str(found.get("state")), ""))
|
|
151
|
+
labelled = found.get("labelled") or {}
|
|
152
|
+
usable = int(labelled.get("good") or 0) + int(labelled.get("fix") or 0)
|
|
153
|
+
ui.fields(
|
|
154
|
+
[
|
|
155
|
+
("State", Text(label, style=style)),
|
|
156
|
+
("Answered by", plan.get("teacher_model")),
|
|
157
|
+
("Until", ui.when(plan.get("until")) if plan.get("until") else None),
|
|
158
|
+
(
|
|
159
|
+
"Conversations",
|
|
160
|
+
f"{found.get('conversations', 0)} ({found.get('requests', 0)} requests)",
|
|
161
|
+
),
|
|
162
|
+
(
|
|
163
|
+
"Spent",
|
|
164
|
+
f"{ui.usd(found.get('spentUsd'))} of {ui.usd(plan.get('budget_usd'))}"
|
|
165
|
+
if plan
|
|
166
|
+
else ui.usd(found.get("spentUsd")),
|
|
167
|
+
),
|
|
168
|
+
("Last request", ui.ago(found.get("lastAt")) if found.get("lastAt") else None),
|
|
169
|
+
(
|
|
170
|
+
"Labelled",
|
|
171
|
+
f"{usable} usable ({labelled.get('good', 0)} right, {labelled.get('fix', 0)} fixed), "
|
|
172
|
+
f"{labelled.get('skip', 0)} skipped, {labelled.get('suggested', 0)} reviewed by the judge",
|
|
173
|
+
),
|
|
174
|
+
("Model", f"{found.get('program')}@capture" if found.get("program") else None),
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
ui.info("A dataset needs at least 20 conversations for the eval set and 10 to train on.")
|
|
178
|
+
|
|
179
|
+
ui.emit(found, human)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command("change")
|
|
183
|
+
def change(
|
|
184
|
+
env: str = ENV_ARGUMENT,
|
|
185
|
+
stop: bool = typer.Option(False, "--stop", help="Stop capturing now."),
|
|
186
|
+
extend_days: int = typer.Option(
|
|
187
|
+
None,
|
|
188
|
+
"--extend-days",
|
|
189
|
+
min=1,
|
|
190
|
+
max=7,
|
|
191
|
+
help="Capture this many more days (resumes a stopped one).",
|
|
192
|
+
),
|
|
193
|
+
budget: float = typer.Option(None, "--budget", min=1, max=MAX_BUDGET_USD, help="A new spending limit in USD."),
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Stop a capture, extend (or resume) it, or change its spending limit.
|
|
196
|
+
|
|
197
|
+
Example: corethink capture change support-chat --extend-days 1 --budget 50
|
|
198
|
+
"""
|
|
199
|
+
body: dict[str, Any] = {}
|
|
200
|
+
if stop:
|
|
201
|
+
body["stop"] = True
|
|
202
|
+
if extend_days:
|
|
203
|
+
body["extendDays"] = extend_days
|
|
204
|
+
if budget is not None:
|
|
205
|
+
body["budgetUsd"] = budget
|
|
206
|
+
if not body:
|
|
207
|
+
ui.error("Give --stop, --extend-days or --budget.")
|
|
208
|
+
raise typer.Exit(2)
|
|
209
|
+
if stop and extend_days:
|
|
210
|
+
ui.error("Give --stop or --extend-days, not both.")
|
|
211
|
+
raise typer.Exit(2)
|
|
212
|
+
env_id = _env_id(env)
|
|
213
|
+
_apply(env_id, body)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _apply(env_id: str, body: dict[str, Any]) -> None:
|
|
217
|
+
plan = client().post(f"{BASE}/usecases/{_q(env_id)}/capture", body)
|
|
218
|
+
|
|
219
|
+
def human() -> None:
|
|
220
|
+
if plan.get("stopped"):
|
|
221
|
+
ui.success("Capture stopped.")
|
|
222
|
+
else:
|
|
223
|
+
ui.success(f"Capturing until {ui.when(plan.get('until'))}, up to {ui.usd(plan.get('budget_usd'))}.")
|
|
224
|
+
|
|
225
|
+
ui.emit(plan, human)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command("stop")
|
|
229
|
+
def stop_capture(env: str = ENV_ARGUMENT) -> None:
|
|
230
|
+
"""Stop capturing (`capture resume` starts it again)."""
|
|
231
|
+
_apply(_env_id(env), {"stop": True})
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command("resume")
|
|
235
|
+
def resume(
|
|
236
|
+
env: str = ENV_ARGUMENT,
|
|
237
|
+
days: int = typer.Option(1, "--days", min=1, max=7, help="Capture this many more days."),
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Capture again, for one more day or --days more."""
|
|
240
|
+
_apply(_env_id(env), {"extendDays": days})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---- conversations ---------------------------------------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _page(env_id: str, offset: int, limit: int, filter_: str) -> dict[str, Any]:
|
|
247
|
+
return (
|
|
248
|
+
client().get(
|
|
249
|
+
f"{BASE}/usecases/{_q(env_id)}/conversations",
|
|
250
|
+
offset=offset,
|
|
251
|
+
limit=limit,
|
|
252
|
+
filter=filter_,
|
|
253
|
+
)
|
|
254
|
+
or {}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _conversation_id(env_id: str, given: str) -> str:
|
|
259
|
+
"""A conversation's ID from all of it or its start (searched page by page)."""
|
|
260
|
+
given = given.strip().lower()
|
|
261
|
+
if CONVERSATION_ID.match(given):
|
|
262
|
+
return given
|
|
263
|
+
matches: list[str] = []
|
|
264
|
+
offset = 0
|
|
265
|
+
while True:
|
|
266
|
+
page = _page(env_id, offset, PAGE, "all")
|
|
267
|
+
items = page.get("items") or []
|
|
268
|
+
matches += [str(item["id"]) for item in items if str(item.get("id", "")).startswith(given)]
|
|
269
|
+
offset += len(items)
|
|
270
|
+
if not items or offset >= int(page.get("total") or 0) or len(matches) > 1:
|
|
271
|
+
break
|
|
272
|
+
if len(matches) == 1:
|
|
273
|
+
return matches[0]
|
|
274
|
+
ui.error(
|
|
275
|
+
f"No conversation starts with {given!r}."
|
|
276
|
+
if not matches
|
|
277
|
+
else f"More than one conversation starts with {given!r}."
|
|
278
|
+
)
|
|
279
|
+
raise typer.Exit(5 if not matches else 2)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _verdict(label: dict[str, Any] | None) -> Text | str:
|
|
283
|
+
if not label:
|
|
284
|
+
return "—"
|
|
285
|
+
verdict = label.get("verdict")
|
|
286
|
+
if verdict:
|
|
287
|
+
return Text(verdict, style={"good": "green", "fix": "yellow", "skip": "dim"}.get(verdict, ""))
|
|
288
|
+
suggestion = label.get("suggestion") or {}
|
|
289
|
+
if suggestion:
|
|
290
|
+
return Text(f"judge: {suggestion.get('verdict')}", style="cyan")
|
|
291
|
+
return "—"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.command("conversations")
|
|
295
|
+
def conversations(
|
|
296
|
+
env: str = ENV_ARGUMENT,
|
|
297
|
+
filter_: str = typer.Option("all", "--filter", help="all, unlabelled, good, fix, skip or suggested."),
|
|
298
|
+
limit: int = typer.Option(20, "--limit", min=1, max=PAGE, help="How many (at most 50)."),
|
|
299
|
+
offset: int = typer.Option(0, "--offset", min=0, help="Skip this many."),
|
|
300
|
+
) -> None:
|
|
301
|
+
"""The conversations a capture caught, newest first, with their labels.
|
|
302
|
+
|
|
303
|
+
Example: corethink capture conversations support-chat --filter unlabelled
|
|
304
|
+
"""
|
|
305
|
+
if filter_ not in FILTERS:
|
|
306
|
+
ui.error(f"--filter is one of {', '.join(FILTERS)}.")
|
|
307
|
+
raise typer.Exit(2)
|
|
308
|
+
env_id = _env_id(env)
|
|
309
|
+
page = _page(env_id, offset, limit, filter_)
|
|
310
|
+
items = page.get("items") or []
|
|
311
|
+
|
|
312
|
+
def human() -> None:
|
|
313
|
+
ui.table(
|
|
314
|
+
items,
|
|
315
|
+
[
|
|
316
|
+
("ID", lambda c: ui.short_id(c.get("id"), 10)),
|
|
317
|
+
("When", lambda c: ui.when(c.get("at"))),
|
|
318
|
+
("User", lambda c: _clip(c.get("user"), 50)),
|
|
319
|
+
("Answer", lambda c: _clip(c.get("answer"), 40)),
|
|
320
|
+
("Calls", lambda c: len(c.get("calls") or [])),
|
|
321
|
+
("Label", lambda c: _verdict(c.get("label"))),
|
|
322
|
+
],
|
|
323
|
+
empty="No conversations here yet." if filter_ == "all" else f"No {filter_} conversations.",
|
|
324
|
+
)
|
|
325
|
+
if items:
|
|
326
|
+
ui.info(f"{offset + 1}–{offset + len(items)} of {page.get('total', len(items))}.")
|
|
327
|
+
|
|
328
|
+
ui.emit(page, human)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _content(value: Any) -> str:
|
|
332
|
+
if value is None:
|
|
333
|
+
return ""
|
|
334
|
+
if isinstance(value, str):
|
|
335
|
+
return value
|
|
336
|
+
if isinstance(value, list):
|
|
337
|
+
return "\n".join(part.get("text", "") if isinstance(part, dict) else str(part) for part in value)
|
|
338
|
+
return json.dumps(value, ensure_ascii=False)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.command("show")
|
|
342
|
+
def show(
|
|
343
|
+
env: str = ENV_ARGUMENT,
|
|
344
|
+
conversation: str = typer.Argument(..., help="The conversation's ID (or its start)."),
|
|
345
|
+
) -> None:
|
|
346
|
+
"""One captured conversation: every message and tool call, its label and the judge's suggestion."""
|
|
347
|
+
env_id = _env_id(env)
|
|
348
|
+
conv = _conversation_id(env_id, conversation)
|
|
349
|
+
detail = client().get(f"{BASE}/usecases/{_q(env_id)}/conversations/{_q(conv)}")
|
|
350
|
+
|
|
351
|
+
def human() -> None:
|
|
352
|
+
ui.fields(
|
|
353
|
+
[
|
|
354
|
+
("Conversation", detail.get("id")),
|
|
355
|
+
("When", ui.when(detail.get("at"))),
|
|
356
|
+
("Answered by", detail.get("teacher")),
|
|
357
|
+
("Requests", detail.get("requests")),
|
|
358
|
+
("Cost", ui.usd(detail.get("costUsd"), 4)),
|
|
359
|
+
]
|
|
360
|
+
)
|
|
361
|
+
if detail.get("system"):
|
|
362
|
+
ui.out.print(Text("system", style="bold dim"))
|
|
363
|
+
ui.out.print(_clip(detail["system"], 400), markup=False)
|
|
364
|
+
for message in detail.get("messages") or []:
|
|
365
|
+
role = str(message.get("role"))
|
|
366
|
+
ui.out.print(
|
|
367
|
+
Text(
|
|
368
|
+
role,
|
|
369
|
+
style="bold " + {"user": "cyan", "assistant": "green", "tool": "magenta"}.get(role, "dim"),
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
text = _content(message.get("content"))
|
|
373
|
+
if text:
|
|
374
|
+
ui.out.print(text, markup=False)
|
|
375
|
+
for call in message.get("tool_calls") or []:
|
|
376
|
+
function = call.get("function") or {}
|
|
377
|
+
arguments = function.get("arguments")
|
|
378
|
+
ui.out.print(
|
|
379
|
+
f"→ {function.get('name')}({arguments if isinstance(arguments, str) else json.dumps(arguments, ensure_ascii=False)})",
|
|
380
|
+
markup=False,
|
|
381
|
+
)
|
|
382
|
+
label = detail.get("label") or {}
|
|
383
|
+
if label.get("verdict"):
|
|
384
|
+
ui.fields(
|
|
385
|
+
[
|
|
386
|
+
("Label", label.get("verdict")),
|
|
387
|
+
(
|
|
388
|
+
"Expected",
|
|
389
|
+
json.dumps(label["expected"], ensure_ascii=False) if "expected" in label else None,
|
|
390
|
+
),
|
|
391
|
+
("Goals", "; ".join(label.get("goals") or []) or None),
|
|
392
|
+
("Split", label.get("split")),
|
|
393
|
+
("Note", label.get("note")),
|
|
394
|
+
("By", label.get("by")),
|
|
395
|
+
],
|
|
396
|
+
title="Label",
|
|
397
|
+
)
|
|
398
|
+
suggestion = label.get("suggestion") or {}
|
|
399
|
+
if suggestion:
|
|
400
|
+
ui.fields(
|
|
401
|
+
[
|
|
402
|
+
("Verdict", suggestion.get("verdict")),
|
|
403
|
+
("Score", ui.score(suggestion.get("score"))),
|
|
404
|
+
("Why", suggestion.get("reason")),
|
|
405
|
+
(
|
|
406
|
+
"Expected",
|
|
407
|
+
json.dumps(suggestion.get("expected"), ensure_ascii=False)
|
|
408
|
+
if suggestion.get("expected") is not None
|
|
409
|
+
else None,
|
|
410
|
+
),
|
|
411
|
+
("Goals", "; ".join(suggestion.get("goals") or []) or None),
|
|
412
|
+
],
|
|
413
|
+
title="The judge suggests",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
ui.emit(detail, human)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _parse_expected(text: str) -> Any:
|
|
420
|
+
"""A JSON value when it reads as one, else the text itself."""
|
|
421
|
+
try:
|
|
422
|
+
return json.loads(text)
|
|
423
|
+
except ValueError:
|
|
424
|
+
return text
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@app.command("label")
|
|
428
|
+
def label(
|
|
429
|
+
env: str = ENV_ARGUMENT,
|
|
430
|
+
conversation: str = typer.Argument(..., help="The conversation's ID (or its start)."),
|
|
431
|
+
verdict: str = typer.Option(
|
|
432
|
+
None, "--verdict", help="good (the answer is right), fix (give the right one) or skip."
|
|
433
|
+
),
|
|
434
|
+
expected: str = typer.Option(None, "--expected", help="The right final answer (JSON or text), for fix."),
|
|
435
|
+
goal: list[str] = typer.Option(None, "--goal", help="What a right answer does (repeatable, at most 10)."),
|
|
436
|
+
split: str = typer.Option("auto", "--split", help="train, test or auto."),
|
|
437
|
+
note: str = typer.Option(None, "--note", help="A note kept with the label."),
|
|
438
|
+
accept: bool = typer.Option(False, "--accept", help="Take the judge's suggestion (its verdict, answer and goals)."),
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Label a captured conversation: good, fix (with the right answer or goals) or skip.
|
|
441
|
+
|
|
442
|
+
Example: corethink capture label support-chat 6f1c --verdict fix --expected '{"decision": "deny"}'
|
|
443
|
+
"""
|
|
444
|
+
if split not in ("train", "test", "auto"):
|
|
445
|
+
ui.error("--split is train, test or auto.")
|
|
446
|
+
raise typer.Exit(2)
|
|
447
|
+
env_id = _env_id(env)
|
|
448
|
+
conv = _conversation_id(env_id, conversation)
|
|
449
|
+
body: dict[str, Any] = {"split": split}
|
|
450
|
+
if accept:
|
|
451
|
+
detail = client().get(f"{BASE}/usecases/{_q(env_id)}/conversations/{_q(conv)}")
|
|
452
|
+
suggestion = (detail.get("label") or {}).get("suggestion")
|
|
453
|
+
if not suggestion:
|
|
454
|
+
ui.error(
|
|
455
|
+
"The judge has not reviewed this conversation: `corethink capture judge` first, or give --verdict."
|
|
456
|
+
)
|
|
457
|
+
raise typer.Exit(2)
|
|
458
|
+
body["verdict"] = suggestion.get("verdict")
|
|
459
|
+
if suggestion.get("verdict") == "fix":
|
|
460
|
+
if suggestion.get("expected") is not None:
|
|
461
|
+
body["expected"] = suggestion["expected"]
|
|
462
|
+
if suggestion.get("goals"):
|
|
463
|
+
body["goals"] = suggestion["goals"]
|
|
464
|
+
if verdict is not None:
|
|
465
|
+
if verdict not in ("good", "fix", "skip"):
|
|
466
|
+
ui.error("--verdict is good, fix or skip.")
|
|
467
|
+
raise typer.Exit(2)
|
|
468
|
+
body["verdict"] = verdict
|
|
469
|
+
if "verdict" not in body:
|
|
470
|
+
ui.error("Give --verdict (good, fix or skip), or --accept the judge's suggestion.")
|
|
471
|
+
raise typer.Exit(2)
|
|
472
|
+
if expected is not None:
|
|
473
|
+
body["expected"] = _parse_expected(expected)
|
|
474
|
+
if goal:
|
|
475
|
+
body["goals"] = list(goal)
|
|
476
|
+
if note:
|
|
477
|
+
body["note"] = note
|
|
478
|
+
if body["verdict"] == "fix" and "expected" not in body and not body.get("goals"):
|
|
479
|
+
ui.error("A fix needs the right answer (--expected) or at least one --goal.")
|
|
480
|
+
raise typer.Exit(2)
|
|
481
|
+
saved = client().put(f"{BASE}/usecases/{_q(env_id)}/conversations/{_q(conv)}/label", body)
|
|
482
|
+
ui.emit(saved, lambda: ui.success(f"Labelled {conv[:10]} {saved.get('verdict', body['verdict'])}."))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@app.command("judge")
|
|
486
|
+
def judge(
|
|
487
|
+
env: str = ENV_ARGUMENT,
|
|
488
|
+
conversation: list[str] = typer.Argument(
|
|
489
|
+
None,
|
|
490
|
+
help="Conversations to review (IDs); default: the next unlabelled ones the judge has not seen.",
|
|
491
|
+
),
|
|
492
|
+
count: int = typer.Option(
|
|
493
|
+
JUDGE_AT_ONCE, "--count", min=1, max=JUDGE_AT_ONCE, help="How many by default (at most 25)."
|
|
494
|
+
),
|
|
495
|
+
) -> None:
|
|
496
|
+
"""Ask the judge to review conversations and suggest labels (about $0.10 each, from your credit).
|
|
497
|
+
|
|
498
|
+
Example: corethink capture judge support-chat
|
|
499
|
+
"""
|
|
500
|
+
env_id = _env_id(env)
|
|
501
|
+
if conversation:
|
|
502
|
+
ids = [_conversation_id(env_id, given) for given in conversation][:JUDGE_AT_ONCE]
|
|
503
|
+
else:
|
|
504
|
+
ids = []
|
|
505
|
+
offset = 0
|
|
506
|
+
while len(ids) < count:
|
|
507
|
+
page = _page(env_id, offset, PAGE, "unlabelled")
|
|
508
|
+
items = page.get("items") or []
|
|
509
|
+
# only what nobody has decided and the judge has not seen: reviewing twice costs twice
|
|
510
|
+
ids += [item["id"] for item in items if not (item.get("label") or {}).get("suggestion")]
|
|
511
|
+
offset += len(items)
|
|
512
|
+
if not items or offset >= int(page.get("total") or 0):
|
|
513
|
+
break
|
|
514
|
+
ids = ids[:count]
|
|
515
|
+
if not ids:
|
|
516
|
+
ui.info("Nothing to review: every conversation is labelled or reviewed.")
|
|
517
|
+
ui.emit({"judged": 0, "failed": 0, "costUsd": 0}, lambda: None)
|
|
518
|
+
return
|
|
519
|
+
ui.info(f"Reviewing {_plural(len(ids), 'conversation')}…")
|
|
520
|
+
result = client().post(f"{BASE}/usecases/{_q(env_id)}/conversations:judge", {"conversations": ids})
|
|
521
|
+
|
|
522
|
+
def human() -> None:
|
|
523
|
+
failed = f", {result.get('failed')} failed" if result.get("failed") else ""
|
|
524
|
+
ui.success(f"Reviewed {result.get('judged', 0)}{failed} for {ui.usd(result.get('costUsd'))}.")
|
|
525
|
+
ui.info(f"See the suggestions: corethink capture conversations {env_id} --filter suggested")
|
|
526
|
+
|
|
527
|
+
ui.emit(result, human)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@app.command("dataset")
|
|
531
|
+
def dataset(
|
|
532
|
+
env: str = ENV_ARGUMENT,
|
|
533
|
+
rubric: str = typer.Option(None, "--rubric", help="How answers are judged (for goals)."),
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Build the environment's tasks from the labelled conversations; checked like an upload.
|
|
536
|
+
|
|
537
|
+
Example: corethink capture dataset support-chat
|
|
538
|
+
"""
|
|
539
|
+
env_id = _env_id(env)
|
|
540
|
+
built = client().post(f"{BASE}/usecases/{_q(env_id)}/dataset", {"rubric": rubric} if rubric else {})
|
|
541
|
+
check = built.get("check") or {}
|
|
542
|
+
counts = built.get("counts") or {}
|
|
543
|
+
|
|
544
|
+
def human() -> None:
|
|
545
|
+
ui.fields(
|
|
546
|
+
[
|
|
547
|
+
("Tasks", counts.get("scenarios")),
|
|
548
|
+
("Train / test", f"{counts.get('train', 0)} / {counts.get('test', 0)}"),
|
|
549
|
+
("Traces", counts.get("traces")),
|
|
550
|
+
("Duplicates left out", counts.get("duplicates") or None),
|
|
551
|
+
]
|
|
552
|
+
)
|
|
553
|
+
print_check(check)
|
|
554
|
+
_check_verdict(check, "The dataset")
|
|
555
|
+
if check.get("ok") and built.get("meta"):
|
|
556
|
+
ui.info(f"Next: corethink env show {env_id}")
|
|
557
|
+
|
|
558
|
+
ui.emit(built, human)
|
|
559
|
+
if not check.get("ok"):
|
|
560
|
+
raise typer.Exit(1)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def register(root: typer.Typer) -> None:
|
|
564
|
+
root.add_typer(app, name="capture", rich_help_panel="Build")
|