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.
Files changed (48) hide show
  1. corethink/__init__.py +17 -0
  2. corethink/__main__.py +3 -0
  3. corethink/_version.py +3 -0
  4. corethink/account.py +470 -0
  5. corethink/cli.py +1058 -0
  6. corethink/client.py +253 -0
  7. corethink/commands/__init__.py +0 -0
  8. corethink/commands/admin.py +703 -0
  9. corethink/commands/api.py +59 -0
  10. corethink/commands/archive.py +433 -0
  11. corethink/commands/auth.py +375 -0
  12. corethink/commands/billing.py +314 -0
  13. corethink/commands/capture.py +564 -0
  14. corethink/commands/connection.py +1266 -0
  15. corethink/commands/endpoint.py +1124 -0
  16. corethink/commands/env.py +1966 -0
  17. corethink/commands/hub.py +269 -0
  18. corethink/commands/library.py +347 -0
  19. corethink/commands/models.py +258 -0
  20. corethink/commands/train.py +2655 -0
  21. corethink/commands/workspace.py +622 -0
  22. corethink/config.py +181 -0
  23. corethink/context.py +46 -0
  24. corethink/env_template.py +117 -0
  25. corethink/environment_http_store.py +96 -0
  26. corethink/experiment_cli.py +2187 -0
  27. corethink/hosted_profiles.py +44 -0
  28. corethink/hosted_rl.py +688 -0
  29. corethink/hosted_rl_archive.py +74 -0
  30. corethink/hosted_rl_attribution.py +44 -0
  31. corethink/hosted_rl_billing.py +103 -0
  32. corethink/hosted_rl_state.py +182 -0
  33. corethink/jobs_cli.py +839 -0
  34. corethink/main.py +204 -0
  35. corethink/output.py +201 -0
  36. corethink/py.typed +0 -0
  37. corethink/rl_cli.py +997 -0
  38. corethink/rl_inspect.py +521 -0
  39. corethink/rl_playground.py +129 -0
  40. corethink/rl_reassessment.py +214 -0
  41. corethink/rl_settings.py +43 -0
  42. corethink/sdk.py +331 -0
  43. corethink/settings.py +162 -0
  44. corethink-1.0.0.dist-info/METADATA +120 -0
  45. corethink-1.0.0.dist-info/RECORD +48 -0
  46. corethink-1.0.0.dist-info/WHEEL +5 -0
  47. corethink-1.0.0.dist-info/entry_points.txt +2 -0
  48. 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")