|
17 | 17 |
|
18 | 18 | # ── helpers ─────────────────────────────────────────────────────────────────── |
19 | 19 |
|
| 20 | +def _cmd_scan(args): |
| 21 | + from tracer.scanner import ( |
| 22 | + scan, format_scan, scan_html, load_scan_traces, |
| 23 | + MIN_SCAN_TRACES, SUGGESTED_SCAN_TRACES, |
| 24 | + ) |
| 25 | + from tracer.cli._ui import header, step, warn, info |
| 26 | + |
| 27 | + header("scan", f"traces={args.traces} target={args.target:.0%}") |
| 28 | + |
| 29 | + # Data-volume gate (checked before the embedding step). |
| 30 | + n_traces = len(load_scan_traces(args.traces)[0]) |
| 31 | + if n_traces < MIN_SCAN_TRACES and not args.force: |
| 32 | + warn(f"Only {n_traces:,} traces. tracer scan needs at least " |
| 33 | + f"{MIN_SCAN_TRACES:,} for a stable read, and ~{SUGGESTED_SCAN_TRACES:,} " |
| 34 | + f"is the sweet spot.") |
| 35 | + info("Collect more traces, or re-run with --force to scan anyway on thin " |
| 36 | + "data (results will be a best-effort floor, not a guarantee).") |
| 37 | + sys.exit(1) |
| 38 | + if args.force: |
| 39 | + warn("--force: bypassing the thin-data guard. Clustering will be coarsened to " |
| 40 | + "concentrate held-out evidence; reported bounds are a best-effort floor, " |
| 41 | + "not a guarantee.") |
| 42 | + if n_traces < MIN_SCAN_TRACES: |
| 43 | + info(f"{n_traces:,} traces is below the {MIN_SCAN_TRACES:,} floor; " |
| 44 | + f"~{SUGGESTED_SCAN_TRACES:,} is the sweet spot for a real verdict.") |
| 45 | + |
| 46 | + # Embeddings. Default: sentence-transformers locally (the model below). |
| 47 | + # --embeddings reuses a precomputed .npy; --embed-url calls your own HTTP |
| 48 | + # embedding service (any endpoint, e.g. OpenAI-compatible), no vendor lock-in. |
| 49 | + embeddings = None |
| 50 | + if args.embeddings: |
| 51 | + import numpy as _np |
| 52 | + embeddings = _np.load(args.embeddings) |
| 53 | + elif args.embed_url: |
| 54 | + from tracer.embeddings.embedder import Embedder |
| 55 | + headers = {} |
| 56 | + for h in (args.embed_header or []): |
| 57 | + k, _, v = h.partition(":") |
| 58 | + headers[k.strip()] = v.strip() |
| 59 | + texts, _ = load_scan_traces(args.traces) |
| 60 | + try: |
| 61 | + with step(f"Embedding {len(texts)} traces via {args.embed_url}"): |
| 62 | + embeddings = Embedder.from_endpoint( |
| 63 | + args.embed_url, headers=headers or None, |
| 64 | + input_key=args.embed_input_key, output_key=args.embed_output_key, |
| 65 | + batch_key=args.embed_batch_key, |
| 66 | + ).embed(texts) |
| 67 | + except Exception as e: |
| 68 | + warn(f"Embedding endpoint failed ({args.embed_url}): {e}") |
| 69 | + info("Check the URL, --embed-header auth, and the " |
| 70 | + "--embed-input-key/--embed-output-key for your endpoint's shape.") |
| 71 | + sys.exit(1) |
| 72 | + |
| 73 | + with step("Clustering + exact bounds"): |
| 74 | + result = scan( |
| 75 | + args.traces, |
| 76 | + target=args.target, |
| 77 | + embeddings=embeddings, |
| 78 | + model=args.embed_model, |
| 79 | + teacher_price_per_1k=args.teacher_price_per_1k, |
| 80 | + monthly_calls=args.monthly_calls, |
| 81 | + viz_layout=args.viz_layout, |
| 82 | + force=args.force, |
| 83 | + ) |
| 84 | + print(format_scan(result)) |
| 85 | + if args.html: |
| 86 | + import pathlib |
| 87 | + out = pathlib.Path(args.html) |
| 88 | + out.write_text(scan_html(result, source_name=pathlib.Path(args.traces).name), |
| 89 | + encoding="utf-8") |
| 90 | + print(f"\nHTML report written to {out}") |
| 91 | + if not args.no_open: |
| 92 | + import webbrowser |
| 93 | + webbrowser.open(out.resolve().as_uri()) |
| 94 | + |
| 95 | + |
20 | 96 | def _cmd_fit(args): |
21 | 97 | from tracer.api import fit |
22 | 98 | from tracer.config import FitConfig |
@@ -534,6 +610,50 @@ def main(): |
534 | 610 |
|
535 | 611 | sub.add_parser("demo", help="Zero-setup demo with synthetic data") |
536 | 612 |
|
| 613 | + p_scan = sub.add_parser( |
| 614 | + "scan", |
| 615 | + help="Day-one verdict: how much traffic is certifiably ML-shaped, with a 3D map") |
| 616 | + p_scan.add_argument("traces", help="Path to traces JSONL file") |
| 617 | + p_scan.add_argument("--target", type=float, default=0.90, |
| 618 | + help="Target label-agreement to certify against (default: 0.90)") |
| 619 | + p_scan.add_argument("--teacher-price-per-1k", type=float, default=None, |
| 620 | + help="Your teacher $ per 1k calls, to estimate savings") |
| 621 | + p_scan.add_argument("--monthly-calls", type=int, default=None, |
| 622 | + help="Monthly call volume, to project monthly savings") |
| 623 | + p_scan.add_argument("--html", default=None, |
| 624 | + help="Also write a self-contained HTML report to this path") |
| 625 | + p_scan.add_argument("--no-open", action="store_true", |
| 626 | + help="Don't open the HTML report in a browser") |
| 627 | + # Embeddings: local sentence-transformers by default; precomputed .npy or a |
| 628 | + # custom HTTP endpoint optional. |
| 629 | + p_scan.add_argument("--embed-model", default="all-MiniLM-L6-v2", |
| 630 | + help="Local sentence-transformers model for embeddings " |
| 631 | + "(default: all-MiniLM-L6-v2). Needs tracer-llm[embeddings].") |
| 632 | + p_scan.add_argument("--embeddings", default=None, |
| 633 | + help="Path to a precomputed embeddings .npy (skip embedding).") |
| 634 | + p_scan.add_argument("--embed-url", default=None, |
| 635 | + help="Use your own HTTP embedding endpoint instead of a local " |
| 636 | + "model. POSTs JSON and reads the embedding from the response.") |
| 637 | + p_scan.add_argument("--embed-header", action="append", default=None, metavar="K: V", |
| 638 | + help="Header for --embed-url (repeatable), e.g. " |
| 639 | + "--embed-header 'Authorization: Bearer ...'") |
| 640 | + p_scan.add_argument("--embed-input-key", default="input", |
| 641 | + help="Request JSON key for the text (default: input)") |
| 642 | + p_scan.add_argument("--embed-output-key", default="embedding", |
| 643 | + help="Response JSON key for the vector (default: embedding)") |
| 644 | + p_scan.add_argument("--embed-batch-key", default=None, |
| 645 | + help="If set, send all texts in one request under this key " |
| 646 | + "(for batch endpoints).") |
| 647 | + p_scan.add_argument("--viz-layout", default="pca", |
| 648 | + choices=["pca", "umap", "tsne", "auto"], |
| 649 | + help="3D layout for the HTML report. pca (default) is a dense " |
| 650 | + "connected cloud; umap/tsne separate many-class data into " |
| 651 | + "distinct clouds (umap needs umap-learn installed).") |
| 652 | + p_scan.add_argument("--force", action="store_true", |
| 653 | + help="Scan thin data anyway (< 1,000 traces). Coarsens the " |
| 654 | + "clustering to concentrate held-out evidence and reports a " |
| 655 | + "best-effort floor, not a guarantee. Warns loudly.") |
| 656 | + |
537 | 657 | p_fit = sub.add_parser("fit", help="Fit a routing policy from traces") |
538 | 658 | p_fit.add_argument("traces", help="Path to traces JSONL file") |
539 | 659 | p_fit.add_argument("--artifact-dir", default=".tracer", |
@@ -586,6 +706,7 @@ def main(): |
586 | 706 | args = parser.parse_args() |
587 | 707 |
|
588 | 708 | dispatch = { |
| 709 | + "scan": _cmd_scan, |
589 | 710 | "fit": _cmd_fit, |
590 | 711 | "report": _cmd_report, |
591 | 712 | "report-html": _cmd_report_html, |
|
0 commit comments