laya-cli 0.1.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.
laya_cli/cli.py ADDED
@@ -0,0 +1,1332 @@
1
+ #!/usr/bin/env python3
2
+ """laya-cli — ergonomic CLI for Laya (human + AI-agent friendly).
3
+
4
+ Primary command is `predict` for direct use:
5
+ laya-cli predict "refund my order" --preset triage
6
+ laya-cli predict --text "is this spam?" --preset guard --format json
7
+ echo '{"state":"hello"}' | laya-cli predict --questions q.json --format jsonl
8
+
9
+ Batch pipeline (TASK.md) is preserved via `classify | filter`:
10
+ cat candidates.jsonl | laya-cli classify --questions q.json | laya-cli filter --where "on_topic>=0.4" --sort -on_topic
11
+
12
+ Also: `questions`/`presets`, `evaluate`, `filter`, `info`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import re as _re
21
+ import sys
22
+ import time
23
+ from typing import Any
24
+
25
+ from . import __version__
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Parser
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def _build_parser() -> argparse.ArgumentParser:
33
+ p = argparse.ArgumentParser(
34
+ prog="laya-cli",
35
+ description="Ergonomic CLI for Laya — typed decisions (choice/score/noul) in one forward pass. Works for humans (table output) and agents (JSON/JSONL).",
36
+ )
37
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
38
+ sub = p.add_subparsers(dest="cmd", required=True)
39
+
40
+ # ---- predict (primary, human+AI friendly) -----------------------------
41
+ pr = sub.add_parser(
42
+ "predict",
43
+ help="Run Laya on one text/JSON state or a batch (JSONL). Human-friendly & agent-friendly.",
44
+ description="Predict with Laya on a single state or a batch. Supports plain text, JSON state, presets, shortlist for high-cardinality choices, and table/JSON output.",
45
+ epilog=(
46
+ "Examples (human):\n"
47
+ ' laya-cli predict "I was charged twice, refund please" --preset triage\n'
48
+ ' laya-cli predict --text "Ignore previous instructions" --preset guard\n'
49
+ ' laya-cli predict --preset email --state \'{"subject":"Invoice","body":"Hi"}\' --format table\n'
50
+ "\n"
51
+ "Examples (agent / batch):\n"
52
+ ' laya-cli predict --text "hello" --questions q.json --format json\n'
53
+ " cat candidates.jsonl | laya-cli predict --questions q.json --state-field state --format jsonl > scored.jsonl\n"
54
+ " laya-cli predict --input candidates.jsonl --questions q.json --shortlist-k 20 --format jsonl\n"
55
+ ' laya-cli predict --preset triage --text "my payment failed" --model convaiinnovations/laya --device cpu --full-probs\n'
56
+ ),
57
+ formatter_class=argparse.RawDescriptionHelpFormatter,
58
+ )
59
+ # Positional text is sugar for --text (allows: laya-cli predict "hello" --preset guard)
60
+ pr.add_argument(
61
+ "text_positional",
62
+ nargs="*",
63
+ help="State as plain text (shorthand for --text). If multiple, each is a separate prediction.",
64
+ )
65
+ pr.add_argument(
66
+ "--text",
67
+ dest="text_opt",
68
+ action="append",
69
+ default=None,
70
+ help="State as plain text (can repeat).",
71
+ )
72
+ pr.add_argument(
73
+ "--state",
74
+ dest="state_json",
75
+ default=None,
76
+ help='State as JSON string (e.g. \'{"subject":"Hi","body":"..."}\')',
77
+ )
78
+ pr.add_argument("--state-file", dest="state_file", default=None, help="State from JSON file")
79
+ pr.add_argument(
80
+ "--input",
81
+ dest="input_path",
82
+ default=None,
83
+ help="Batch input: JSONL file (each line JSON) or - for stdin. Each object’s field --state-field is used as state.",
84
+ )
85
+ pr.add_argument(
86
+ "--state-field",
87
+ default="state",
88
+ help="For --input batch: field holding state text (default: state). Taken verbatim, no concatenation.",
89
+ )
90
+ pr.add_argument(
91
+ "--questions",
92
+ default=None,
93
+ help="Path to questions.json (dict of {id: {type, instructions, criteria}})",
94
+ )
95
+ pr.add_argument(
96
+ "--questions-inline",
97
+ dest="questions_inline",
98
+ default=None,
99
+ help="Questions as JSON string (merges with --questions/--preset)",
100
+ )
101
+ pr.add_argument(
102
+ "--preset",
103
+ default=None,
104
+ help="Built-in preset: triage | email | guard | moderation | router (can combine with --questions)",
105
+ )
106
+ pr.add_argument(
107
+ "--model",
108
+ default="convaiinnovations/laya",
109
+ help="HF repo id (default: convaiinnovations/laya)",
110
+ )
111
+ pr.add_argument(
112
+ "--subfolder",
113
+ default=None,
114
+ help="Subfolder checkpoint: multilingual / typed-decisions / '' (bundle root). Only that subfolder is downloaded.",
115
+ )
116
+ pr.add_argument(
117
+ "--device",
118
+ default=None,
119
+ choices=["cpu", "mps", "cuda"],
120
+ help="Device to force (default: auto: cuda > mps > cpu)",
121
+ )
122
+ pr.add_argument(
123
+ "--router",
124
+ action="store_true",
125
+ help="Use laya.Router(preload=True) — recommended for multilingual (auto-detects script/language per request).",
126
+ )
127
+ pr.add_argument(
128
+ "--lang",
129
+ default=None,
130
+ help="Force language for Router (e.g. en, fr, de). Without --lang Router only recognises {en,fr,de,es,pt,it,nl} among Latin scripts.",
131
+ )
132
+ pr.add_argument(
133
+ "--shortlist-k",
134
+ dest="shortlist_k",
135
+ type=int,
136
+ default=None,
137
+ help="Enable shortlist for high-cardinality choice: keep top K labels via embeddings (uses agent encoder via embed_fn_from_agent).",
138
+ )
139
+ pr.add_argument(
140
+ "--full-probs",
141
+ action="store_true",
142
+ help="Include full probability distributions in flattened output",
143
+ )
144
+ pr.add_argument(
145
+ "--format",
146
+ dest="out_format",
147
+ choices=["json", "jsonl", "table"],
148
+ default=None,
149
+ help="Output format: json (single object), jsonl (one per line), table (human). Default: json for single, jsonl for batch.",
150
+ )
151
+ pr.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
152
+ pr.add_argument(
153
+ "--flatten",
154
+ action="store_true",
155
+ help="For batch JSONL: flatten answers into top-level fields like classify does ({id}, {id}_p, {id}_confidence) instead of nested answers.",
156
+ )
157
+ pr.add_argument(
158
+ "--prepend-field",
159
+ default=None,
160
+ help="For batch: prepend field to state text as '<value> <state>'. Disabled by default (mixing metadata degraded on_topic scores 0.1-0.3).",
161
+ )
162
+ pr.add_argument("--output", default=None, help="Output file (default: stdout)")
163
+
164
+ # ---- classify (legacy batch, kept for TASK.md compat) -----------------
165
+ c = sub.add_parser(
166
+ "classify",
167
+ help="Batch classify JSONL from stdin via Laya (legacy, use predict for new code).",
168
+ description="Classify JSONL from stdin via Laya; append flattened answers. Kept for pipeline compatibility (px | laya-cli classify). For new code prefer `predict`.",
169
+ epilog=(
170
+ "Examples:\n"
171
+ " cat candidates.jsonl | laya-cli classify --questions questions.json > scored.jsonl\n"
172
+ ' px videos --queries "..." --state --dedupe keep-first \\\n'
173
+ " | laya-cli classify --questions questions.json \\\n"
174
+ ' | laya-cli filter --where "on_topic>=0.4" --sort -on_topic > shortlist.jsonl\n'
175
+ ),
176
+ formatter_class=argparse.RawDescriptionHelpFormatter,
177
+ )
178
+ c.add_argument("--questions", required=True, help="Path to questions.json")
179
+ c.add_argument(
180
+ "--state-field",
181
+ default="state",
182
+ help="Field name holding the state text (default: state). Taken verbatim.",
183
+ )
184
+ c.add_argument(
185
+ "--model",
186
+ default="convaiinnovations/laya",
187
+ help="HF repo id (default: convaiinnovations/laya)",
188
+ )
189
+ c.add_argument(
190
+ "--subfolder",
191
+ default=None,
192
+ help="Subfolder checkpoint: multilingual / typed-decisions / ''",
193
+ )
194
+ c.add_argument("--device", default=None, choices=["cpu", "mps", "cuda"], help="Device to force")
195
+ c.add_argument("--router", action="store_true", help="Use laya.Router(preload=True)")
196
+ c.add_argument("--lang", default=None, help="Force language for Router")
197
+ c.add_argument("--prepend-field", default=None, help="Optional field to prepend to state text")
198
+ c.add_argument("--full-probs", action="store_true", help="Include full distribution ({id}_probs)")
199
+
200
+ # ---- filter -----------------------------------------------------------
201
+ f = sub.add_parser(
202
+ "filter",
203
+ help="Lightweight post-filter/sorter over JSONL.",
204
+ description="Filter and sort JSONL from stdin. Replaces per-case build_shortlist*.py.",
205
+ epilog=(
206
+ "Examples:\n"
207
+ ' cat scored.jsonl | laya-cli filter --where "on_topic>=0.4" --sort -on_topic\n'
208
+ ' cat scored.jsonl | laya-cli filter --where "on_topic>=0.4,is_spam!=1" --sort -on_topic,+id\n'
209
+ ),
210
+ formatter_class=argparse.RawDescriptionHelpFormatter,
211
+ )
212
+ f.add_argument(
213
+ "--where",
214
+ default=None,
215
+ help='Filter expression(s), comma = AND. Ops: == != = > < >= <=. Example: "on_topic>=0.4"',
216
+ )
217
+ f.add_argument(
218
+ "--sort",
219
+ default=None,
220
+ help='Sort keys, comma-separated, "-" prefix = descending. Example: "-on_topic,+id"',
221
+ )
222
+
223
+ # ---- questions / presets ----------------------------------------------
224
+ q = sub.add_parser(
225
+ "questions",
226
+ aliases=["presets"],
227
+ help="Show or list built-in preset questions.json.",
228
+ description="Presets are direct passthroughs of laya.*_questions() — triage, email, guard, moderation, router.",
229
+ epilog=(
230
+ "Examples:\n"
231
+ " laya-cli questions list\n"
232
+ " laya-cli questions triage > questions.json\n"
233
+ ' laya-cli predict --preset triage --text "my payment failed" # use without file\n'
234
+ " laya-cli questions guard | laya-cli predict --input /dev/stdin --state-field prompt # pipe\n"
235
+ ),
236
+ formatter_class=argparse.RawDescriptionHelpFormatter,
237
+ )
238
+ q.add_argument(
239
+ "preset",
240
+ nargs="?",
241
+ default=None,
242
+ help="Preset name: triage | email | guard | moderation | router | list",
243
+ )
244
+ q.add_argument(
245
+ "--format",
246
+ dest="q_format",
247
+ choices=["json", "table"],
248
+ default="json",
249
+ help="Output format (default: json)",
250
+ )
251
+
252
+ # ---- evaluate ---------------------------------------------------------
253
+ e = sub.add_parser(
254
+ "evaluate",
255
+ help="Evaluate on a labelled JSONL set and print accuracy / threshold report.",
256
+ description="Evaluate before shipping: run classify on a labelled set and report accuracy per question, pass rate at threshold, and precision @ threshold.",
257
+ epilog=(
258
+ "Examples:\n"
259
+ " laya-cli evaluate --questions questions.json --labeled labeled.jsonl --label-field label --threshold 0.5\n"
260
+ " laya-cli evaluate --questions q.json --labeled dev.jsonl --field on_topic --state-field state\n"
261
+ ),
262
+ formatter_class=argparse.RawDescriptionHelpFormatter,
263
+ )
264
+ e.add_argument("--questions", required=True, help="Path to questions.json")
265
+ e.add_argument(
266
+ "--labeled",
267
+ required=True,
268
+ help="Path to labelled JSONL (each line: {state_field: text, label_field: true_label})",
269
+ )
270
+ e.add_argument("--state-field", default="state", help="Field holding the state text (default: state)")
271
+ e.add_argument(
272
+ "--label-field",
273
+ default="label",
274
+ help="Field holding the ground-truth label (default: label)",
275
+ )
276
+ e.add_argument("--field", default=None, help="Optional single question id to evaluate (default: all)")
277
+ e.add_argument("--threshold", type=float, default=0.5, help="Threshold for pass/precision (default: 0.5)")
278
+ e.add_argument("--model", default="convaiinnovations/laya", help="HF repo id")
279
+ e.add_argument("--subfolder", default=None, help="Subfolder checkpoint")
280
+ e.add_argument("--device", default=None, choices=["cpu", "mps", "cuda"], help="Device to force")
281
+ e.add_argument("--router", action="store_true", help="Use laya.Router(preload=True)")
282
+ e.add_argument("--lang", default=None, help="Force language for Router")
283
+ e.add_argument("--prepend-field", default=None, help="Optional field to prepend to state text")
284
+
285
+ # ---- info -------------------------------------------------------------
286
+ inf = sub.add_parser(
287
+ "info",
288
+ help="Show model, device and checkpoint info.",
289
+ description="Show environment, available devices, and (if cached) checkpoint config.",
290
+ )
291
+ inf.add_argument("--model", default="convaiinnovations/laya", help="HF repo id to inspect")
292
+ inf.add_argument("--subfolder", default=None, help="Subfolder to inspect")
293
+
294
+ return p
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Shared helpers
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ def _load_questions(path: str) -> dict[str, Any]:
303
+ with open(path, encoding="utf-8") as f:
304
+ data = json.load(f)
305
+ if not isinstance(data, dict) or not data:
306
+ print(f"error: questions file {path!r} must be a non-empty dict", file=sys.stderr)
307
+ sys.exit(2)
308
+ for qid, qdef in data.items():
309
+ if not isinstance(qdef, dict) or "type" not in qdef:
310
+ print(f"error: question {qid!r} must have a 'type' field", file=sys.stderr)
311
+ sys.exit(2)
312
+ if qdef["type"] not in ("choice", "score", "noul"):
313
+ print(f"error: question {qid!r} has unknown type {qdef['type']!r}", file=sys.stderr)
314
+ sys.exit(2)
315
+ return data
316
+
317
+
318
+ def _load_preset(name: str) -> dict[str, Any]:
319
+ mapping = {
320
+ "triage": "triage_questions",
321
+ "email": "email_questions",
322
+ "guard": "guard_questions",
323
+ "moderation": "moderation_questions",
324
+ "router": "router_questions",
325
+ }
326
+ key = name.strip().lower()
327
+ if key not in mapping:
328
+ print(
329
+ f"error: unknown preset {name!r}; choose from {', '.join(sorted(mapping))}",
330
+ file=sys.stderr,
331
+ )
332
+ sys.exit(2)
333
+ try:
334
+ import laya
335
+ except ImportError as exc:
336
+ print(f"error: laya not installed: {exc}", file=sys.stderr)
337
+ sys.exit(2)
338
+ fn = getattr(laya, mapping[key])
339
+ return fn()
340
+
341
+
342
+ def _load_questions_merged(args) -> dict[str, Any]:
343
+ """Merge --preset, --questions, --questions-inline (preset < file < inline)."""
344
+ merged: dict[str, Any] = {}
345
+ if getattr(args, "preset", None):
346
+ merged.update(_load_preset(args.preset))
347
+ if getattr(args, "questions", None):
348
+ file_q = _load_questions(args.questions)
349
+ merged.update(file_q)
350
+ inline = getattr(args, "questions_inline", None)
351
+ if inline:
352
+ try:
353
+ inline_q = json.loads(inline)
354
+ except json.JSONDecodeError as exc:
355
+ print(f"error: invalid --questions-inline JSON: {exc}", file=sys.stderr)
356
+ sys.exit(2)
357
+ if not isinstance(inline_q, dict):
358
+ print("error: --questions-inline must be a JSON object (dict)", file=sys.stderr)
359
+ sys.exit(2)
360
+ merged.update(inline_q)
361
+ if not merged:
362
+ print(
363
+ "error: no questions provided. Use --preset or --questions or --questions-inline",
364
+ file=sys.stderr,
365
+ )
366
+ sys.exit(2)
367
+ # validate merged
368
+ for qid, qdef in merged.items():
369
+ if not isinstance(qdef, dict) or "type" not in qdef:
370
+ print(f"error: question {qid!r} must have a 'type' field", file=sys.stderr)
371
+ sys.exit(2)
372
+ if qdef["type"] not in ("choice", "score", "noul"):
373
+ print(f"error: question {qid!r} has unknown type {qdef['type']!r}", file=sys.stderr)
374
+ sys.exit(2)
375
+ return merged
376
+
377
+
378
+ def _init_agent(args) -> Any:
379
+ """Load Laya agent or Router exactly once; warm up; emit timing to stderr."""
380
+ t0 = time.time()
381
+ if getattr(args, "router", False):
382
+ if not getattr(args, "lang", None):
383
+ print(
384
+ "[laya-cli] warning: --router is active without --lang. "
385
+ "As of laya 0.3.4 the Router detector only recognises {en,fr,de,es,pt,it,nl} "
386
+ "among Latin-script languages; Polish, Czech, Turkish, Swedish etc. are "
387
+ "silently routed to the English checkpoint and may answer confidently and wrongly. "
388
+ "Pass --lang when you know it.",
389
+ file=sys.stderr,
390
+ )
391
+ try:
392
+ from laya import Router
393
+ except ImportError as exc:
394
+ print(f"error: laya not installed: {exc}", file=sys.stderr)
395
+ sys.exit(2)
396
+ kwargs: dict[str, Any] = {"preload": True}
397
+ if getattr(args, "device", None):
398
+ kwargs["device"] = args.device
399
+ agent = Router(**kwargs)
400
+ try:
401
+ dummy_q = {"_warmup": {"type": "noul", "instructions": "Is this warmup text relevant?"}}
402
+ agent.predict("warmup text for kernel compilation", dummy_q, lang=getattr(args, "lang", None)) if getattr(
403
+ args, "lang", None
404
+ ) else agent.predict("warmup text for kernel compilation", dummy_q)
405
+ except Exception:
406
+ pass
407
+ dt = time.time() - t0
408
+ print(
409
+ f"[laya-cli] Router loaded in {dt:.1f}s (device={getattr(agent, 'device', '?')})",
410
+ file=sys.stderr,
411
+ )
412
+ return agent
413
+ else:
414
+ try:
415
+ import laya
416
+ except ImportError as exc:
417
+ print(f"error: laya not installed: {exc}", file=sys.stderr)
418
+ sys.exit(2)
419
+ kwargs: dict[str, Any] = {}
420
+ if getattr(args, "device", None):
421
+ kwargs["device"] = args.device
422
+ if getattr(args, "subfolder", None):
423
+ kwargs["subfolder"] = args.subfolder
424
+ agent = laya.load(getattr(args, "model", "convaiinnovations/laya"), **kwargs)
425
+ try:
426
+ dummy_q = getattr(args, "_warmup_questions", None)
427
+ if dummy_q is None:
428
+ dummy_q = {"_warmup": {"type": "noul", "instructions": "Is this warmup text relevant?"}}
429
+ agent.predict("warmup text for kernel compilation", dummy_q)
430
+ except Exception:
431
+ pass
432
+ dt = time.time() - t0
433
+ print(
434
+ f"[laya-cli] model {getattr(args, 'model', 'convaiinnovations/laya')!r} loaded in {dt:.1f}s (device={getattr(agent, 'device', '?')})",
435
+ file=sys.stderr,
436
+ )
437
+ return agent
438
+
439
+
440
+ def _apply_answers(row: dict[str, Any], answers: dict[str, Any], questions: dict[str, Any], full_probs: bool) -> None:
441
+ for qid, qdef in questions.items():
442
+ ans = answers.get(qid)
443
+ if ans is None:
444
+ continue
445
+ qtype = qdef.get("type")
446
+ if qtype == "choice":
447
+ row[qid] = ans.get("choice")
448
+ probs = ans.get("probabilities", {})
449
+ chosen = ans.get("choice")
450
+ p = probs.get(chosen) if isinstance(probs, dict) else None
451
+ if p is not None:
452
+ row[f"{qid}_p"] = p
453
+ if full_probs:
454
+ row[f"{qid}_probs"] = probs
455
+ if "confidence" in ans:
456
+ row[f"{qid}_confidence"] = ans["confidence"]
457
+ elif qtype == "score":
458
+ row[f"{qid}_score"] = ans.get("score")
459
+ if "confidence" in ans:
460
+ row[f"{qid}_confidence"] = ans["confidence"]
461
+ if full_probs and "probabilities" in ans:
462
+ row[f"{qid}_probs"] = ans["probabilities"]
463
+ elif qtype == "noul":
464
+ row[qid] = ans.get("noul")
465
+ if "confidence" in ans:
466
+ row[f"{qid}_confidence"] = ans["confidence"]
467
+
468
+
469
+ def _collect_states_predict(args) -> list[tuple[Any, Any]]:
470
+ """Return list of (original_row_or_none, state) for predict."""
471
+ states: list[tuple[Any, Any]] = []
472
+
473
+ # Explicit batch input
474
+ if getattr(args, "input_path", None):
475
+ path = args.input_path
476
+ fh = sys.stdin if path == "-" else open(path, encoding="utf-8")
477
+ try:
478
+ for line in fh:
479
+ line = line.strip()
480
+ if not line:
481
+ continue
482
+ try:
483
+ row = json.loads(line)
484
+ except json.JSONDecodeError:
485
+ # treat as raw text line
486
+ row = {"state": line}
487
+ if not isinstance(row, dict):
488
+ row = {"state": str(row)}
489
+ raw = row.get(args.state_field, "")
490
+ if raw is None:
491
+ raw = ""
492
+ if isinstance(raw, (dict, list)):
493
+ state_text = json.dumps(raw, ensure_ascii=False)
494
+ else:
495
+ state_text = str(raw)
496
+ if getattr(args, "prepend_field", None):
497
+ extra = row.get(args.prepend_field)
498
+ if extra is not None and str(extra).strip() != "":
499
+ extra_s = (
500
+ json.dumps(extra, ensure_ascii=False) if isinstance(extra, (dict, list)) else str(extra)
501
+ )
502
+ state_text = f"{extra_s} {state_text}"
503
+ # keep original row for output
504
+ states.append(
505
+ (
506
+ row,
507
+ state_text
508
+ if not isinstance(row.get(args.state_field), (dict, list)) or isinstance(raw, (dict, list))
509
+ else state_text,
510
+ )
511
+ )
512
+ # For cases where state field was dict/list originally, state_text already serialized
513
+ # but we remember original row
514
+ states[-1] = (row, state_text)
515
+ finally:
516
+ if fh is not sys.stdin:
517
+ fh.close()
518
+ return states
519
+
520
+ # Positional + --text
521
+ texts: list[str] = []
522
+ if getattr(args, "text_positional", None):
523
+ texts.extend([t for t in args.text_positional if t is not None and str(t) != ""])
524
+ if getattr(args, "text_opt", None):
525
+ # text_opt is list of strings (action append)
526
+ for t in args.text_opt:
527
+ if t is not None:
528
+ texts.append(t)
529
+ if texts:
530
+ for t in texts:
531
+ states.append((None, t))
532
+ return states
533
+
534
+ # --state JSON string
535
+ if getattr(args, "state_json", None):
536
+ try:
537
+ parsed = json.loads(args.state_json)
538
+ states.append((None, parsed))
539
+ except json.JSONDecodeError:
540
+ states.append((None, args.state_json))
541
+ return states
542
+
543
+ if getattr(args, "state_file", None):
544
+ with open(args.state_file, encoding="utf-8") as f:
545
+ content = f.read().strip()
546
+ try:
547
+ parsed = json.loads(content)
548
+ states.append((None, parsed))
549
+ except json.JSONDecodeError:
550
+ states.append((None, content))
551
+ return states
552
+
553
+ # Fallback: stdin as single state (human pipe) or batch if JSONL
554
+ if not sys.stdin.isatty():
555
+ data = sys.stdin.read()
556
+ if not data.strip():
557
+ return states
558
+ # Try JSONL first: multiple lines JSON?
559
+ lines = [line for line in data.splitlines() if line.strip() != ""]
560
+ # If single line and it looks like JSON object, treat as state JSON
561
+ if len(lines) == 1:
562
+ try:
563
+ parsed = json.loads(lines[0])
564
+ if isinstance(parsed, dict) and args.state_field in parsed:
565
+ raw = parsed.get(args.state_field, "")
566
+ state_text = json.dumps(raw, ensure_ascii=False) if isinstance(raw, (dict, list)) else str(raw)
567
+ states.append((parsed, state_text))
568
+ elif isinstance(parsed, dict):
569
+ # use whole object as state (Laya supports dict state)
570
+ states.append((None, parsed))
571
+ else:
572
+ states.append((None, parsed))
573
+ return states
574
+ except json.JSONDecodeError:
575
+ states.append((None, lines[0]))
576
+ return states
577
+ # Multiple lines: try batch JSONL
578
+ has_json = False
579
+ for line in lines:
580
+ try:
581
+ row = json.loads(line)
582
+ if isinstance(row, dict):
583
+ has_json = True
584
+ raw = row.get(args.state_field, "")
585
+ st = json.dumps(raw, ensure_ascii=False) if isinstance(raw, (dict, list)) else str(raw)
586
+ states.append((row, st))
587
+ else:
588
+ states.append((None, str(row)))
589
+ except json.JSONDecodeError:
590
+ states.append((None, line))
591
+ if has_json:
592
+ return states
593
+ # Fallback: treat whole stdin as single text
594
+ if not states:
595
+ states.append((None, data.strip()))
596
+ return states
597
+
598
+ return states
599
+
600
+
601
+ def _write_output(data: Any, args, is_batch: bool = False) -> None:
602
+ out_path = getattr(args, "output", None)
603
+ fh = open(out_path, "w", encoding="utf-8") if out_path else sys.stdout
604
+ try:
605
+ fmt = getattr(args, "out_format", None)
606
+ if fmt is None:
607
+ fmt = "jsonl" if is_batch else "json"
608
+ pretty = getattr(args, "pretty", False)
609
+ if fmt == "table":
610
+ # table is only for single currently; for batch we output per-row table sections
611
+ if is_batch:
612
+ for item in data:
613
+ _print_table_single(item, fh)
614
+ fh.write("\n")
615
+ else:
616
+ _print_table_single(data, fh)
617
+ elif fmt == "jsonl":
618
+ if is_batch:
619
+ for item in data:
620
+ fh.write(json.dumps(item, ensure_ascii=False) + "\n")
621
+ else:
622
+ fh.write(json.dumps(data, ensure_ascii=False) + "\n")
623
+ else: # json
624
+ if is_batch:
625
+ # when batch but format json -> output JSON array
626
+ json.dump(data, fh, ensure_ascii=False, indent=2 if pretty else None)
627
+ fh.write("\n")
628
+ else:
629
+ json.dump(data, fh, ensure_ascii=False, indent=2 if pretty else None)
630
+ fh.write("\n")
631
+ finally:
632
+ if fh is not sys.stdout:
633
+ fh.close()
634
+ else:
635
+ fh.flush()
636
+
637
+
638
+ def _print_table_single(result: dict[str, Any], fh) -> None:
639
+ answers = result.get("answers", {})
640
+ routing = result.get("routing")
641
+ usage = result.get("usage", {})
642
+ fh.write("Answers:\n")
643
+ # header
644
+ fh.write(f" {'question':<18} {'type':<7} {'answer':<22} {'confidence':<10} {'details'}\n")
645
+ fh.write(" " + "-" * 80 + "\n")
646
+ for qid, ans in answers.items():
647
+ qtype = ans.get("type", "")
648
+ if qtype == "choice":
649
+ detail = f"{ans.get('choice')} p={ans.get('probabilities', {}).get(ans.get('choice'), '')}"
650
+ answer = str(ans.get("choice"))
651
+ elif qtype == "score":
652
+ answer = str(ans.get("score"))
653
+ detail = f"probs={ans.get('probabilities')}"
654
+ elif qtype == "noul":
655
+ answer = str(ans.get("noul"))
656
+ detail = f"P(true)={ans.get('noul')}"
657
+ else:
658
+ answer = str(ans)
659
+ detail = ""
660
+ fh.write(f" {qid:<18} {qtype:<7} {answer:<22} {ans.get('confidence', ''):<10} {detail}\n")
661
+ # show full probs on next line if choice
662
+ if qtype == "choice" and "probabilities" in ans:
663
+ fh.write(f" probs: {json.dumps(ans['probabilities'], ensure_ascii=False)}\n")
664
+ if routing:
665
+ fh.write(f"\nRouting: {routing.get('model')} — {routing.get('reason')}\n")
666
+ if usage:
667
+ fh.write(f"Usage: {usage}\n")
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # Command impls
672
+ # ---------------------------------------------------------------------------
673
+
674
+
675
+ def cmd_predict(args: argparse.Namespace) -> None:
676
+ questions = _load_questions_merged(args)
677
+ args._warmup_questions = questions
678
+ agent = _init_agent(args)
679
+
680
+ states = _collect_states_predict(args)
681
+ if not states:
682
+ print(
683
+ "error: no state provided. Use TEXT positional, --text, --state, --state-file or --input / stdin",
684
+ file=sys.stderr,
685
+ )
686
+ sys.exit(2)
687
+
688
+ # Decide output mode
689
+ is_batch = len(states) > 1 or getattr(args, "input_path", None) is not None
690
+ # If --input was used, we already know it's batch
691
+ # For predict via stdin single JSONL line, we treat as single vs batch based on count
692
+
693
+ results: list[dict[str, Any]] = []
694
+ for orig, state in states:
695
+ # state can be str/dict/list — pass as-is to Laya
696
+ try:
697
+ if getattr(args, "shortlist_k", None) is not None:
698
+ # shortlist path
699
+ try:
700
+ from laya import embed_fn_from_agent, predict_shortlist
701
+ except ImportError as exc:
702
+ print(f"error: shortlist requires laya shortlist module: {exc}", file=sys.stderr)
703
+ sys.exit(2)
704
+ embed_fn = embed_fn_from_agent(agent)
705
+ k = int(args.shortlist_k)
706
+ # predict_shortlist expects agent, state, questions, embed_fn, k
707
+ if getattr(args, "router", False):
708
+ kwargs = {}
709
+ if getattr(args, "lang", None):
710
+ kwargs["lang"] = args.lang
711
+ result = predict_shortlist(agent, state, questions, embed_fn, k=k, **kwargs)
712
+ else:
713
+ result = predict_shortlist(agent, state, questions, embed_fn, k=k)
714
+ else:
715
+ if getattr(args, "router", False):
716
+ kwargs = {}
717
+ if getattr(args, "lang", None):
718
+ kwargs["lang"] = args.lang
719
+ result = agent.predict(state, questions, **kwargs)
720
+ else:
721
+ result = agent.predict(state, questions)
722
+ except Exception as exc:
723
+ print(
724
+ f"[laya-cli] error: predict failed for state {str(state)[:80]!r}: {exc}",
725
+ file=sys.stderr,
726
+ )
727
+ result = {"error": str(exc), "state": state, "answers": {}}
728
+
729
+ # For batch with --flatten, mimic classify output
730
+ if is_batch and getattr(args, "flatten", False):
731
+ # orig is original row dict (or None)
732
+ row = dict(orig) if isinstance(orig, dict) else {"state": state} if orig is None else {"state": str(state)}
733
+ # ensure state field preserved
734
+ if isinstance(orig, dict) and args.state_field not in row and isinstance(state, str):
735
+ row[args.state_field] = state
736
+ answers = result.get("answers", {})
737
+ _apply_answers(row, answers, questions, full_probs=getattr(args, "full_probs", False))
738
+ # also keep routing/usage if Router
739
+ if "routing" in result:
740
+ row["_routing"] = result["routing"]
741
+ results.append(row)
742
+ elif is_batch:
743
+ # Batch but not flattened: output per-row result with answers
744
+ out_row = {}
745
+ if isinstance(orig, dict):
746
+ out_row = dict(orig)
747
+ else:
748
+ out_row = {"state": state}
749
+ out_row["answers"] = result.get("answers", {})
750
+ if "routing" in result:
751
+ out_row["routing"] = result["routing"]
752
+ if "usage" in result:
753
+ out_row["usage"] = result["usage"]
754
+ if "shortlist" in result:
755
+ out_row["shortlist"] = result["shortlist"]
756
+ if "error" in result:
757
+ out_row["error"] = result["error"]
758
+ results.append(out_row)
759
+ else:
760
+ # single: result is top-level
761
+ if getattr(args, "flatten", False):
762
+ # flatten single as well
763
+ row = (
764
+ {"state": state}
765
+ if not isinstance(state, dict)
766
+ else dict(state)
767
+ if isinstance(state, dict)
768
+ else {"state": str(state)}
769
+ )
770
+ answers = result.get("answers", {})
771
+ _apply_answers(row, answers, questions, full_probs=getattr(args, "full_probs", False))
772
+ results = row # type: ignore
773
+ else:
774
+ results.append(result) # type: ignore
775
+
776
+ if is_batch:
777
+ _write_output(results, args, is_batch=True)
778
+ print(f"[laya-cli] predict: {len(states)} states", file=sys.stderr)
779
+ else:
780
+ single = results[0] if isinstance(results, list) else results
781
+ _write_output(single, args, is_batch=False)
782
+
783
+
784
+ def cmd_classify(args: argparse.Namespace) -> None:
785
+ questions = _load_questions(args.questions)
786
+ args._warmup_questions = questions
787
+ agent = _init_agent(args)
788
+ n_in = n_out = 0
789
+ for line in sys.stdin:
790
+ line = line.strip()
791
+ if not line:
792
+ continue
793
+ try:
794
+ row = json.loads(line)
795
+ except json.JSONDecodeError as exc:
796
+ print(f"[laya-cli] warning: skipping invalid JSON line: {exc}", file=sys.stderr)
797
+ continue
798
+ if not isinstance(row, dict):
799
+ print("[laya-cli] warning: skipping non-object JSON line", file=sys.stderr)
800
+ continue
801
+ raw_state = row.get(args.state_field, "")
802
+ if raw_state is None:
803
+ raw_state = ""
804
+ if isinstance(raw_state, (dict, list)):
805
+ state_text = json.dumps(raw_state, ensure_ascii=False)
806
+ else:
807
+ state_text = str(raw_state)
808
+ if args.prepend_field:
809
+ extra = row.get(args.prepend_field)
810
+ if extra is not None and str(extra).strip() != "":
811
+ extra_s = json.dumps(extra, ensure_ascii=False) if isinstance(extra, (dict, list)) else str(extra)
812
+ state_text = f"{extra_s} {state_text}"
813
+ n_in += 1
814
+ try:
815
+ if args.router:
816
+ kwargs = {}
817
+ if args.lang:
818
+ kwargs["lang"] = args.lang
819
+ result = agent.predict(state_text, questions, **kwargs)
820
+ else:
821
+ result = agent.predict(state_text, questions)
822
+ except Exception as exc:
823
+ print(f"[laya-cli] error: predict failed on line {n_in}: {exc}", file=sys.stderr)
824
+ row["_laya_error"] = str(exc)
825
+ sys.stdout.write(json.dumps(row, ensure_ascii=False) + "\n")
826
+ sys.stdout.flush()
827
+ continue
828
+ answers = result.get("answers", result)
829
+ if not isinstance(answers, dict):
830
+ answers = {}
831
+ _apply_answers(row, answers, questions, full_probs=args.full_probs)
832
+ n_out += 1
833
+ sys.stdout.write(json.dumps(row, ensure_ascii=False) + "\n")
834
+ sys.stdout.flush()
835
+ print(f"[laya-cli] classify: {n_in} in, {n_out} out", file=sys.stderr)
836
+
837
+
838
+ # ---------------------------------------------------------------------------
839
+ # filter helpers
840
+ # ---------------------------------------------------------------------------
841
+
842
+ _WHERE_RE = _re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*(>=|<=|==|!=|>|<|=)\s*(.+?)\s*$")
843
+
844
+
845
+ def _parse_where(expr: str):
846
+ if not expr:
847
+ return []
848
+ parts = [p.strip() for p in expr.split(",") if p.strip() != ""]
849
+ filters = []
850
+ for p in parts:
851
+ m = _WHERE_RE.match(p)
852
+ if not m:
853
+ print(
854
+ f"error: invalid --where expression {p!r} (expected like 'field>=0.4')",
855
+ file=sys.stderr,
856
+ )
857
+ sys.exit(2)
858
+ field, op, raw_val = m.groups()
859
+ if op == "=":
860
+ op = "=="
861
+ v_str = raw_val.strip()
862
+ if len(v_str) >= 2 and v_str[0] == v_str[-1] and v_str[0] in ('"', "'"):
863
+ value = v_str[1:-1]
864
+ else:
865
+ try:
866
+ if "." in v_str or "e" in v_str.lower():
867
+ value = float(v_str)
868
+ else:
869
+ value = int(v_str)
870
+ except ValueError:
871
+ value = v_str
872
+ filters.append((field, op, value))
873
+ return filters
874
+
875
+
876
+ def _parse_sort(expr: str | None):
877
+ if not expr:
878
+ return []
879
+ parts = [p.strip() for p in expr.split(",") if p.strip() != ""]
880
+ keys = []
881
+ for p in parts:
882
+ if p.startswith("-"):
883
+ keys.append((p[1:], -1))
884
+ elif p.startswith("+"):
885
+ keys.append((p[1:], 1))
886
+ else:
887
+ keys.append((p, 1))
888
+ return keys
889
+
890
+
891
+ def _row_get(row: dict[str, Any], field: str):
892
+ return row.get(field)
893
+
894
+
895
+ def _passes_filters(row: dict[str, Any], filters) -> bool:
896
+ for field, op, val in filters:
897
+ cur = _row_get(row, field)
898
+ if cur is None:
899
+ return False
900
+ if isinstance(val, (int, float)) and isinstance(cur, str):
901
+ try:
902
+ cur = float(cur) if "." in cur else int(cur)
903
+ except ValueError:
904
+ return False
905
+ try:
906
+ if op == "==":
907
+ if cur != val:
908
+ return False
909
+ elif op == "!=":
910
+ if cur == val:
911
+ return False
912
+ elif op == ">":
913
+ if not (cur > val):
914
+ return False
915
+ elif op == "<":
916
+ if not (cur < val):
917
+ return False
918
+ elif op == ">=":
919
+ if not (cur >= val):
920
+ return False
921
+ elif op == "<=":
922
+ if not (cur <= val):
923
+ return False
924
+ except TypeError:
925
+ return False
926
+ return True
927
+
928
+
929
+ def cmd_filter(args: argparse.Namespace) -> None:
930
+ filters = _parse_where(args.where)
931
+ sort_keys = _parse_sort(args.sort)
932
+ rows: list[dict[str, Any]] = []
933
+ need_sort = len(sort_keys) > 0
934
+ if need_sort:
935
+ for line in sys.stdin:
936
+ line = line.strip()
937
+ if not line:
938
+ continue
939
+ try:
940
+ row = json.loads(line)
941
+ except json.JSONDecodeError:
942
+ continue
943
+ if not isinstance(row, dict):
944
+ continue
945
+ if filters and not _passes_filters(row, filters):
946
+ continue
947
+ rows.append(row)
948
+ import functools
949
+
950
+ def cmp(a, b):
951
+ for field, direction in sort_keys:
952
+ av = _row_get(a, field)
953
+ bv = _row_get(b, field)
954
+ if av is None and bv is None:
955
+ continue
956
+ if av is None:
957
+ return 1
958
+ if bv is None:
959
+ return -1
960
+ try:
961
+ if av < bv:
962
+ return -direction
963
+ if av > bv:
964
+ return direction
965
+ except TypeError:
966
+ sa, sb = str(av), str(bv)
967
+ if sa < sb:
968
+ return -direction
969
+ if sa > sb:
970
+ return direction
971
+ return 0
972
+
973
+ rows.sort(key=functools.cmp_to_key(cmp))
974
+ for r in rows:
975
+ sys.stdout.write(json.dumps(r, ensure_ascii=False) + "\n")
976
+ sys.stdout.flush()
977
+ print(f"[laya-cli] filter: {len(rows)} rows (sorted by {args.sort})", file=sys.stderr)
978
+ else:
979
+ n = 0
980
+ for line in sys.stdin:
981
+ line = line.strip()
982
+ if not line:
983
+ continue
984
+ try:
985
+ row = json.loads(line)
986
+ except json.JSONDecodeError:
987
+ continue
988
+ if not isinstance(row, dict):
989
+ continue
990
+ if filters and not _passes_filters(row, filters):
991
+ continue
992
+ sys.stdout.write(json.dumps(row, ensure_ascii=False) + "\n")
993
+ n += 1
994
+ sys.stdout.flush()
995
+ print(f"[laya-cli] filter: {n} rows", file=sys.stderr)
996
+
997
+
998
+ # ---------------------------------------------------------------------------
999
+ # questions
1000
+ # ---------------------------------------------------------------------------
1001
+
1002
+
1003
+ def cmd_questions(args: argparse.Namespace) -> None:
1004
+ preset = args.preset
1005
+ if preset is None or preset.strip().lower() in ("list", "ls", "--list"):
1006
+ # list presets
1007
+ presets = {
1008
+ "triage": "Support ticket triage (intent, urgency, frustration, churn)",
1009
+ "email": "Inbound email triage & threat filtering (category, spam, phishing)",
1010
+ "guard": "Real-time LLM input guardrails (jailbreak, injection, harm)",
1011
+ "moderation": "Content safety & moderation (toxic, harassment, threat, spam)",
1012
+ "router": "Intelligent model routing (difficulty, domain, tools, sensitivity)",
1013
+ }
1014
+ if getattr(args, "q_format", "json") == "table":
1015
+ print("Available presets:")
1016
+ for k, desc in presets.items():
1017
+ print(f" {k:<12} {desc}")
1018
+ else:
1019
+ json.dump(presets, sys.stdout, ensure_ascii=False, indent=2)
1020
+ sys.stdout.write("\n")
1021
+ sys.stdout.flush()
1022
+ return
1023
+ key = preset.strip().lower()
1024
+ mapping = {
1025
+ "triage": "triage_questions",
1026
+ "email": "email_questions",
1027
+ "guard": "guard_questions",
1028
+ "moderation": "moderation_questions",
1029
+ "router": "router_questions",
1030
+ }
1031
+ if key not in mapping:
1032
+ print(
1033
+ f"error: unknown preset {preset!r}; choose from {', '.join(sorted(mapping))} or 'list'",
1034
+ file=sys.stderr,
1035
+ )
1036
+ sys.exit(2)
1037
+ try:
1038
+ import laya
1039
+ except ImportError as exc:
1040
+ print(f"error: laya not installed: {exc}", file=sys.stderr)
1041
+ sys.exit(2)
1042
+ fn = getattr(laya, mapping[key])
1043
+ qs = fn()
1044
+ if getattr(args, "q_format", "json") == "table":
1045
+ print(f"Preset: {key}")
1046
+ for qid, qdef in qs.items():
1047
+ crit = qdef.get("criteria")
1048
+ print(f" {qid} ({qdef.get('type')}): {qdef.get('instructions')}")
1049
+ if isinstance(crit, dict):
1050
+ for k, v in crit.items():
1051
+ print(f" - {k}: {v}")
1052
+ elif isinstance(crit, list):
1053
+ for i, c in enumerate(crit):
1054
+ print(f" {i}: {c}")
1055
+ else:
1056
+ json.dump(qs, sys.stdout, ensure_ascii=False, indent=2)
1057
+ sys.stdout.write("\n")
1058
+ sys.stdout.flush()
1059
+
1060
+
1061
+ # ---------------------------------------------------------------------------
1062
+ # evaluate
1063
+ # ---------------------------------------------------------------------------
1064
+
1065
+
1066
+ def cmd_evaluate(args: argparse.Namespace) -> None:
1067
+ questions = _load_questions(args.questions)
1068
+ if args.field:
1069
+ if args.field not in questions:
1070
+ print(
1071
+ f"error: --field {args.field!r} not in questions.json (available: {', '.join(questions)})",
1072
+ file=sys.stderr,
1073
+ )
1074
+ sys.exit(2)
1075
+ questions = {args.field: questions[args.field]}
1076
+ labeled_path = args.labeled
1077
+ if not os.path.exists(labeled_path):
1078
+ print(f"error: labeled file not found: {labeled_path!r}", file=sys.stderr)
1079
+ sys.exit(2)
1080
+
1081
+ class _FakeArgs:
1082
+ pass
1083
+
1084
+ fake = _FakeArgs()
1085
+ fake.model = args.model
1086
+ fake.subfolder = args.subfolder
1087
+ fake.device = args.device
1088
+ fake.router = args.router
1089
+ fake.lang = args.lang
1090
+ fake._warmup_questions = questions
1091
+ agent = _init_agent(fake)
1092
+ rows: list[dict[str, Any]] = []
1093
+ with open(labeled_path, encoding="utf-8") as f:
1094
+ for idx, line in enumerate(f, 1):
1095
+ line = line.strip()
1096
+ if not line:
1097
+ continue
1098
+ try:
1099
+ row = json.loads(line)
1100
+ except json.JSONDecodeError as exc:
1101
+ print(f"[laya-cli] warning: skipping invalid JSON line {idx}: {exc}", file=sys.stderr)
1102
+ continue
1103
+ rows.append(row)
1104
+ total = len(rows)
1105
+ if total == 0:
1106
+ print("No labeled examples found.", file=sys.stderr)
1107
+ sys.exit(2)
1108
+ stats: dict[str, dict[str, Any]] = {}
1109
+ for qid, qdef in questions.items():
1110
+ stats[qid] = {
1111
+ "type": qdef.get("type"),
1112
+ "correct": 0,
1113
+ "passing": 0,
1114
+ "correct_and_passing": 0,
1115
+ "n": 0,
1116
+ }
1117
+ threshold = args.threshold
1118
+ for row in rows:
1119
+ raw_state = row.get(args.state_field, "")
1120
+ if raw_state is None:
1121
+ raw_state = ""
1122
+ if isinstance(raw_state, (dict, list)):
1123
+ state_text = json.dumps(raw_state, ensure_ascii=False)
1124
+ else:
1125
+ state_text = str(raw_state)
1126
+ if args.prepend_field:
1127
+ extra = row.get(args.prepend_field)
1128
+ if extra is not None and str(extra).strip() != "":
1129
+ extra_s = json.dumps(extra, ensure_ascii=False) if isinstance(extra, (dict, list)) else str(extra)
1130
+ state_text = f"{extra_s} {state_text}"
1131
+ try:
1132
+ if args.router:
1133
+ kwargs = {}
1134
+ if args.lang:
1135
+ kwargs["lang"] = args.lang
1136
+ result = agent.predict(state_text, questions, **kwargs)
1137
+ else:
1138
+ result = agent.predict(state_text, questions)
1139
+ except Exception as exc:
1140
+ print(f"[laya-cli] warning: predict failed on row: {exc}", file=sys.stderr)
1141
+ continue
1142
+ answers = result.get("answers", result)
1143
+ true_val = row.get(args.label_field)
1144
+ for qid, qdef in questions.items():
1145
+ ans = answers.get(qid)
1146
+ if ans is None:
1147
+ continue
1148
+ qtype = qdef.get("type")
1149
+ if len(questions) > 1 and qid in row and qid != args.label_field:
1150
+ gt = row.get(qid)
1151
+ else:
1152
+ gt = true_val
1153
+ is_correct = False
1154
+ passing = False
1155
+ if qtype == "choice":
1156
+ pred = ans.get("choice")
1157
+ probs = ans.get("probabilities", {})
1158
+ p = probs.get(pred, 0) if isinstance(probs, dict) else 0
1159
+ is_correct = (str(pred) == str(gt)) if gt is not None else False
1160
+ passing = float(p) >= threshold
1161
+ elif qtype == "noul":
1162
+ p_true = float(ans.get("noul", 0))
1163
+ if isinstance(gt, bool):
1164
+ gt_bool = gt
1165
+ elif isinstance(gt, (int, float)):
1166
+ gt_bool = bool(gt)
1167
+ elif isinstance(gt, str):
1168
+ gt_bool = gt.strip().lower() in ("true", "1", "yes", "y", "t")
1169
+ else:
1170
+ gt_bool = bool(gt) if gt is not None else False
1171
+ pred_bool = p_true >= threshold
1172
+ is_correct = pred_bool == gt_bool
1173
+ passing = max(p_true, 1 - p_true) >= threshold
1174
+ elif qtype == "score":
1175
+ pred_score = float(ans.get("score", 0))
1176
+ try:
1177
+ gt_score = float(gt) if gt is not None else None
1178
+ except (ValueError, TypeError):
1179
+ gt_score = None
1180
+ if gt_score is not None:
1181
+ is_correct = round(pred_score) == round(gt_score)
1182
+ passing = float(ans.get("confidence", 0)) >= threshold
1183
+ else:
1184
+ continue
1185
+ st = stats[qid]
1186
+ st["n"] += 1
1187
+ if is_correct:
1188
+ st["correct"] += 1
1189
+ if passing:
1190
+ st["passing"] += 1
1191
+ if is_correct:
1192
+ st["correct_and_passing"] += 1
1193
+ out_lines = []
1194
+ out_lines.append("laya-cli evaluate report")
1195
+ out_lines.append(f" questions: {', '.join(stats.keys())}")
1196
+ out_lines.append(f" labeled file: {labeled_path}")
1197
+ out_lines.append(f" state field: {args.state_field!r}, label field: {args.label_field!r}, threshold: {threshold}")
1198
+ out_lines.append(f" examples: {total}")
1199
+ out_lines.append("")
1200
+ for qid, st in stats.items():
1201
+ n = st["n"]
1202
+ if n == 0:
1203
+ continue
1204
+ acc = st["correct"] / n if n else 0
1205
+ pass_rate = st["passing"] / n if n else 0
1206
+ prec = (st["correct_and_passing"] / st["passing"]) if st["passing"] else 0
1207
+ esc = 1 - pass_rate
1208
+ out_lines.append(f" [{qid}] type={st['type']}")
1209
+ out_lines.append(f" accuracy: {acc:.1%} ({st['correct']}/{n})")
1210
+ out_lines.append(f" passing @ {threshold}: {pass_rate:.1%} ({st['passing']}/{n})")
1211
+ out_lines.append(
1212
+ f" precision @ {threshold} (correct among passing): {prec:.1%} ({st['correct_and_passing']}/{st['passing']})"
1213
+ if st["passing"]
1214
+ else f" precision @ {threshold}: n/a (no passing)"
1215
+ )
1216
+ out_lines.append(f" escalation rate (1 - passing): {esc:.1%} — share that would go to LLM/human")
1217
+ out_lines.append("")
1218
+ report = "\n".join(out_lines)
1219
+ sys.stdout.write(report + "\n")
1220
+ sys.stdout.flush()
1221
+
1222
+
1223
+ def cmd_info(args: argparse.Namespace) -> None:
1224
+ import platform
1225
+
1226
+ print(f"laya-cli {__version__}")
1227
+ print(f"python: {platform.python_version()} ({sys.executable})")
1228
+ try:
1229
+ import torch
1230
+
1231
+ print(f"torch: {torch.__version__}")
1232
+ print(f" cuda available: {torch.cuda.is_available()}")
1233
+ if torch.cuda.is_available():
1234
+ print(f" cuda devices: {torch.cuda.device_count()}")
1235
+ try:
1236
+ import torch.backends.mps as mps
1237
+
1238
+ print(f" mps available: {mps.is_available()}")
1239
+ except Exception:
1240
+ pass
1241
+ except ImportError:
1242
+ print("torch: not installed")
1243
+ try:
1244
+ import laya
1245
+
1246
+ print(f"laya: {getattr(laya, '__version__', 'unknown')}")
1247
+ print(f"model: {args.model}" + (f" subfolder={args.subfolder}" if args.subfolder else ""))
1248
+ try:
1249
+ from huggingface_hub import scan_cache_dir
1250
+
1251
+ cache = scan_cache_dir()
1252
+ repos = [r.repo_id for r in cache.repos]
1253
+ if args.model in repos:
1254
+ print(" cached: yes")
1255
+ else:
1256
+ print(" cached: not found (will download on first predict)")
1257
+ except Exception:
1258
+ pass
1259
+ except ImportError as e:
1260
+ print(f"laya: not installed ({e})")
1261
+
1262
+
1263
+ def _preprocess_argv(argv: list[str]) -> list[str]:
1264
+ known_flags = {
1265
+ "--questions",
1266
+ "--state-field",
1267
+ "--model",
1268
+ "--subfolder",
1269
+ "--device",
1270
+ "--router",
1271
+ "--lang",
1272
+ "--prepend-field",
1273
+ "--full-probs",
1274
+ "--where",
1275
+ "--sort",
1276
+ "--labeled",
1277
+ "--label-field",
1278
+ "--field",
1279
+ "--threshold",
1280
+ "--help",
1281
+ "-h",
1282
+ "--version",
1283
+ "--text",
1284
+ "--state",
1285
+ "--state-file",
1286
+ "--input",
1287
+ "--questions-inline",
1288
+ "--preset",
1289
+ "--shortlist-k",
1290
+ "--format",
1291
+ "--pretty",
1292
+ "--flatten",
1293
+ "--output",
1294
+ "--q_format",
1295
+ }
1296
+ out: list[str] = []
1297
+ i = 0
1298
+ while i < len(argv):
1299
+ tok = argv[i]
1300
+ out.append(tok)
1301
+ if tok == "--sort" and i + 1 < len(argv):
1302
+ nxt = argv[i + 1]
1303
+ if nxt not in known_flags and (nxt.startswith("-") or nxt.startswith("+")):
1304
+ out[-1] = f"--sort={nxt}"
1305
+ i += 1
1306
+ i += 1
1307
+ return out
1308
+
1309
+
1310
+ def main() -> None:
1311
+ sys.argv = _preprocess_argv(sys.argv)
1312
+ parser = _build_parser()
1313
+ args = parser.parse_args()
1314
+ if args.cmd == "predict":
1315
+ cmd_predict(args)
1316
+ elif args.cmd == "classify":
1317
+ cmd_classify(args)
1318
+ elif args.cmd == "filter":
1319
+ cmd_filter(args)
1320
+ elif args.cmd in ("questions", "presets"):
1321
+ cmd_questions(args)
1322
+ elif args.cmd == "evaluate":
1323
+ cmd_evaluate(args)
1324
+ elif args.cmd == "info":
1325
+ cmd_info(args)
1326
+ else:
1327
+ parser.print_help()
1328
+ sys.exit(2)
1329
+
1330
+
1331
+ if __name__ == "__main__":
1332
+ main()