"""Command-line entry point for obsidian-sorter. Phase A commands (read-only — nothing here writes to the vault): scan report vault shape propose classify one note, print a proposal inbox classify every note in the inbox validate replay the classifier against known-good notes """ from __future__ import annotations import argparse import json import sys from dataclasses import replace from .classify import Classifier from .jev import CostTracker, JevClient, JevError from .models import Note from .tier2 import HybridClassifier, JevClassifier from .vault import Vault from .watch import Watcher from .writer import Writer def _load_vault(args) -> Vault: return Vault(args.vault) def _print_proposal(p) -> None: if p.method == "blocked": print(f" ⛔ BLOCKED — {p.source_rel}") for w in p.warnings: print(f" {w}") return print(f" Proposal for: {p.source_rel}") print(f" type : {p.note_type or '(none)'}") print(f" target folder : {p.target_folder or '(unknown)'}") print(f" toc : {p.toc_section or '(none)'}") print(f" template : {p.template or '(none)'}") print(f" confidence : {p.confidence:.2f} [{p.method}]") if p.reasons: print(f" why : {'; '.join(p.reasons)}") if p.alternatives: alts = ", ".join(f"{t}={s:.2f}" for t, s in p.alternatives) print(f" alternatives : {alts}") for w in p.warnings: print(f" ⚠ {w}") # --------------------------------------------------------------------- commands def cmd_scan(args) -> int: vault = _load_vault(args) notes = vault.scan() print(f"Vault : {vault.root}") print(f"Notes : {len(notes)}") print(f"Folders : {len(vault.folders())}") types: dict[str, int] = {} for n in notes: key = (n.note_type or "(none)").lower() types[key] = types.get(key, 0) + 1 print("Types :") for t, c in sorted(types.items(), key=lambda kv: -kv[1]): print(f" {t:20s} {c}") needing = [n for n in notes if n.needs_classification] print(f"\nNeeding classification (no trusted type): {len(needing)}") for n in needing[:20]: print(f" - {n.rel_path}") if len(needing) > 20: print(f" ... +{len(needing) - 20} more") inbox = vault.inbox_notes(args.inbox) print(f"\nInbox ({args.inbox}): {len(inbox)} note(s)") for n in inbox: print(f" - {n.filename}") return 0 def cmd_propose(args) -> int: vault = _load_vault(args) if args.jev: jev, tracker = _make_jev(args) clf = HybridClassifier(vault, jev=jev, threshold=args.threshold) else: clf = Classifier(vault) tracker = None from pathlib import Path path = Path(args.file).expanduser() if not path.is_absolute(): candidate = vault.root / args.file path = candidate if candidate.exists() else path note = vault.read_note(path) if note is None: print(f"error: cannot read note: {path}", file=sys.stderr) return 1 p = clf.classify(note) _print_proposal(p) if tracker is not None: s = tracker.summary() print(f" cost : ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens)") if args.json: print(json.dumps(p.as_dict(), indent=2)) return 0 def cmd_inbox(args) -> int: vault = _load_vault(args) if getattr(args, "jev", False): jev, tracker = _make_jev(args) clf = HybridClassifier(vault, jev=jev, threshold=args.threshold) else: clf = Classifier(vault) tracker = None notes = vault.inbox_notes(args.inbox) if not notes: print(f"Inbox is empty ({args.inbox}).") return 0 print(f"Found {len(notes)} note(s) in {args.inbox}:\n") proposals = [] for note in notes: p = clf.classify(note) proposals.append(p) _print_proposal(p) print() if tracker is not None: s = tracker.summary() print(f"cost: ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens, {s['calls']} calls)") if args.json: print(json.dumps([p.as_dict() for p in proposals], indent=2)) return 0 def cmd_validate(args) -> int: """ Replay the classifier against notes that already have a known-good type (established during the Phase 3 cleanup) and measure agreement. Two modes: --in-folder keep the note's real folder (folder evidence available) --as-inbox pretend the note lives in the inbox (no folder evidence) """ vault = _load_vault(args) clf = Classifier(vault) notes = vault.scan() ground_truth = [ n for n in notes if (n.note_type or "").strip().lower() in { "project", "area", "tool", "subject", "backup-system", "container", "machine", "website", "service", "network-component", "person", "storage", } ] correct = 0 total = 0 misses: list[tuple[str, str, str, float]] = [] low_conf = 0 for note in ground_truth: expected = (note.note_type or "").strip().lower() # Simulate an UNSORTED note: strip the known-good type/status so the # classifier must work from evidence rather than echoing it back. if args.in_folder: probe = replace(note, note_type=None, status=None) else: probe = replace( note, note_type=None, status=None, rel_path=f"{args.inbox}/{note.filename}", ) p = clf.classify(probe) if p.method in ("blocked", "none"): total += 1 misses.append((note.rel_path, expected, p.note_type or "(none)", p.confidence)) continue total += 1 if p.note_type == expected: correct += 1 else: misses.append((note.rel_path, expected, p.note_type, p.confidence)) if p.confidence < 0.45: low_conf += 1 acc = (correct / total * 100) if total else 0.0 mode = "IN-FOLDER (folder evidence available)" if args.in_folder else "AS-INBOX (no folder evidence)" print(f"=== Validation — {mode} ===") print(f"Notes tested : {total}") print(f"Correct : {correct}") print(f"Accuracy : {acc:.1f}%") print(f"Low-conf : {low_conf} (would escalate to JEV)") if misses: print(f"\nMisses ({len(misses)}):") for rel, exp, got, conf in sorted(misses, key=lambda m: -m[3]): print(f" expected={exp:16s} got={got:16s} conf={conf:.2f} {rel}") return 0 def _make_jev(args): """Build a JEV classifier, or None when unavailable.""" try: client = JevClient(timeout=args.timeout) except JevError as e: print(f"warning: JEV unavailable ({e})", file=sys.stderr) return None, None vault = Vault(args.vault) tracker = CostTracker(max_calls_per_run=args.max_calls) jev = JevClassifier(vault, client, tracker=tracker) return jev, tracker def cmd_benchmark(args) -> int: """Run the hybrid classifier against notes with known-good types. Reports accuracy and real token cost. JEV calls cost money — keep --limit sane. """ vault = _load_vault(args) jev, tracker = _make_jev(args) if jev is None: return 1 clf = HybridClassifier(vault, jev=jev, threshold=args.threshold) ground_truth = [ n for n in vault.scan() if (n.note_type or "").strip().lower() in { "project", "area", "tool", "subject", "backup-system", "container", "machine", "website", "service", "network-component", "person", "storage", } ] if not args.all: ground_truth = ground_truth[: args.limit] correct = 0 folder_correct = 0 misses: list[tuple[str, str, str, float, str]] = [] for i, note in enumerate(ground_truth, 1): expected = (note.note_type or "").strip().lower() # Simulate an unsorted inbox note. probe = replace( note, note_type=None, status=None, rel_path=f"{args.inbox}/{note.filename}", ) if tracker.budget_exceeded(): print(f"\nBudget cap reached ({args.max_calls} calls) — stopping.") break p = clf.classify(probe) if p.note_type == expected: correct += 1 else: misses.append((note.rel_path, expected, p.note_type, p.confidence, p.method)) if p.target_folder and note.folder and p.target_folder == note.folder: folder_correct += 1 if not args.quiet: mark = "✓" if p.note_type == expected else "✗" print(f" [{i:>3}/{len(ground_truth)}] {mark} {expected:14s} → {p.note_type:14s} " f"({p.confidence:.2f}, {p.method})") tested = correct + len(misses) acc = (correct / tested * 100) if tested else 0.0 facc = (folder_correct / tested * 100) if tested else 0.0 print(f"\n=== JEV Benchmark ===") print(f"Notes tested : {tested}") print(f"Type accuracy : {acc:.1f}% ({correct}/{tested})") print(f"Folder match : {facc:.1f}% ({folder_correct}/{tested})") s = tracker.summary() print(f"JEV calls : {s['calls']} ({s['errors']} errors)") print(f"Tokens : {s['input_tokens']} in / {s['output_tokens']} out") print(f"Cost : ${s['cost_usd']:.6f}") if tested: print(f"Cost / note : ${s['cost_usd'] / tested:.6f}") if misses: print(f"\nMisses ({len(misses)}):") for rel, exp, got, conf, method in misses: print(f" expected={exp:14s} got={got:14s} conf={conf:.2f} [{method}] {rel[:70]}") return 0 def _make_classifier(args, vault): """Rules-only or hybrid, depending on --jev. Returns (classifier, tracker).""" if getattr(args, "jev", False): jev, tracker = _make_jev(args) return HybridClassifier(vault, jev=jev, threshold=args.threshold), tracker return Classifier(vault), None def _confirm(prompt: str) -> bool: try: return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes") except EOFError: return False def cmd_sort(args) -> int: """Plan (and optionally apply) sorting of one note.""" vault = _load_vault(args) clf, tracker = _make_classifier(args, vault) writer = Writer(vault) from pathlib import Path path = Path(args.file).expanduser() if not path.is_absolute(): candidate = vault.root / args.file path = candidate if candidate.exists() else path note = vault.read_note(path) if note is None: print(f"error: cannot read note: {path}", file=sys.stderr) return 1 proposal = clf.classify(note) plan = writer.plan(note, proposal) print(plan.render()) if tracker is not None: s = tracker.summary() print(f"\n cost: ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens)") if not args.apply: print("\n(dry run — pass --apply to write)") return 0 if not plan.writable: print(f"\nrefusing to apply: {plan.blocked_reason}", file=sys.stderr) return 2 if not args.yes and not _confirm("\nApply these changes?"): print("aborted.") return 1 ok, log = writer.apply(plan, source_path=note.path) print() for line in log: print(f" {line}") return 0 if ok else 3 def cmd_sort_inbox(args) -> int: """Plan (and optionally apply) every note in the inbox.""" vault = _load_vault(args) clf, tracker = _make_classifier(args, vault) writer = Writer(vault) notes = vault.inbox_notes(args.inbox) if not notes: print(f"Inbox is empty ({args.inbox}).") return 0 plans = [] for note in notes: proposal = clf.classify(note) plan = writer.plan(note, proposal) plans.append((note, plan)) print(f"Found {len(plans)} note(s) in {args.inbox}:\n") for _note, plan in plans: print(plan.render()) print() if tracker is not None: s = tracker.summary() print(f" cost: ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens)\n") if not args.apply: print("(dry run — pass --apply to write)") return 0 writable = [(n, p) for n, p in plans if p.writable] blocked = [(n, p) for n, p in plans if not p.writable] for _n, p in blocked: print(f"skipping (blocked): {p.source_rel} — {p.blocked_reason}", file=sys.stderr) if not writable: return 2 if not args.yes and not _confirm(f"Apply {len(writable)} change(s)?"): print("aborted.") return 1 failures = 0 for note, plan in writable: ok, log = writer.apply(plan, source_path=note.path) print(f"\n{plan.source_rel}:") for line in log: print(f" {line}") if not ok: failures += 1 return 0 if failures == 0 else 3 def cmd_watch(args) -> int: """Watch the inbox: propose destinations, and file notes you approve.""" vault = _load_vault(args) jev, tracker = _make_jev(args) clf = HybridClassifier(vault, jev=jev, threshold=args.threshold) watcher = Watcher( vault, clf, inbox=args.inbox, interval=args.interval, dry_run=args.dry_run, ) if args.once: lines = watcher.run_once() if lines: for line in lines: print(line) else: print("nothing to do") if tracker is not None and tracker.calls: print(f"\ncost: ${tracker.summary()['cost_usd']:.6f}") return 0 mode = "DRY RUN" if args.dry_run else "live" print(f"watching '{args.inbox}' every {args.interval}s ({mode}) — Ctrl-C to stop") try: watcher.run_forever(max_iterations=args.iterations) except KeyboardInterrupt: print("\nstopped") return 0 # ------------------------------------------------------------------------ main def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="obsidian-sort", description="Classify and file Obsidian notes (Phase A: read-only).", ) p.add_argument("--vault", default="~/obsidian", help="vault root (default: ~/obsidian)") p.add_argument("--inbox", default="inbox", help="inbox folder name (default: inbox)") p.add_argument("--json", action="store_true", help="also emit JSON") sub = p.add_subparsers(dest="command", required=True) sub.add_parser("scan", help="report vault shape").set_defaults(func=cmd_scan) sp = sub.add_parser("propose", help="classify one note") sp.add_argument("file", help="path to a note (absolute or vault-relative)") sp.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure") sp.add_argument("--threshold", type=float, default=0.45, help="escalate to JEV below this rules confidence") sp.add_argument("--timeout", type=float, default=90.0) sp.add_argument("--max-calls", type=int, default=500) sp.set_defaults(func=cmd_propose) si = sub.add_parser("inbox", help="classify all inbox notes") si.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure") si.add_argument("--threshold", type=float, default=0.45) si.add_argument("--timeout", type=float, default=90.0) si.add_argument("--max-calls", type=int, default=500) si.set_defaults(func=cmd_inbox) sv = sub.add_parser("validate", help="replay classifier against known-good notes") sv.add_argument("--in-folder", action="store_true", help="use the note's real folder (default: simulate inbox)") sv.set_defaults(func=cmd_validate) sb = sub.add_parser("benchmark", help="run the hybrid classifier and measure accuracy + cost") sb.add_argument("--limit", type=int, default=25, help="how many notes to test (default 25)") sb.add_argument("--all", action="store_true", help="test every known-good note") sb.add_argument("--threshold", type=float, default=0.45, help="escalate to JEV below this rules confidence") sb.add_argument("--quiet", action="store_true", help="only print the summary") sb.add_argument("--timeout", type=float, default=90.0) sb.add_argument("--max-calls", type=int, default=500) sb.set_defaults(func=cmd_benchmark) # --- sort (Phase C: writes) ----------------------------------------- ss = sub.add_parser("sort", help="plan and optionally apply sorting for one note") ss.add_argument("file", help="path to a note (absolute or vault-relative)") ss.add_argument("--apply", action="store_true", help="write the changes (default: dry run)") ss.add_argument("--yes", action="store_true", help="skip the confirmation prompt") ss.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure") ss.add_argument("--threshold", type=float, default=0.45) ss.add_argument("--timeout", type=float, default=90.0) ss.add_argument("--max-calls", type=int, default=500) ss.set_defaults(func=cmd_sort) si = sub.add_parser("sort-inbox", help="plan and optionally apply sorting for the whole inbox") si.add_argument("--apply", action="store_true", help="write the changes (default: dry run)") si.add_argument("--yes", action="store_true", help="skip the confirmation prompt") si.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure") si.add_argument("--threshold", type=float, default=0.45) si.add_argument("--timeout", type=float, default=90.0) si.add_argument("--max-calls", type=int, default=500) si.set_defaults(func=cmd_sort_inbox) sw = sub.add_parser("watch", help="watch the inbox and maintain proposals") sw.add_argument("--once", action="store_true", help="single pass, then exit") sw.add_argument("--interval", type=float, default=5.0, help="seconds between passes") sw.add_argument("--iterations", type=int, default=None, help="stop after N passes") sw.add_argument("--dry-run", action="store_true", help="report but never write") sw.add_argument("--threshold", type=float, default=0.45) sw.add_argument("--timeout", type=float, default=90.0) sw.add_argument("--max-calls", type=int, default=500) sw.set_defaults(func=cmd_watch) return p def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) return args.func(args) if __name__ == "__main__": raise SystemExit(main())