From 31701ad5248dddc71f9b8d6c662b6f94cd546a85 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 29 Apr 2026 10:14:36 -0400 Subject: [PATCH] cli: human render for `query` by default; --json for raw; ensure_ascii=False MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things fox surfaced from a "who is pikachu?" run: 1. Output emitted "Pok\\u00e9mon" instead of "Pokémon" — json.dumps defaulted to ensure_ascii=True. Switched to ensure_ascii=False on every user-facing dump in cli.py (41 sites). The one canonical-JSON call (`separators=(",", ":")` for storing in providence_cache as a JSON column, not for hashing) was deliberately left alone. 2. The actual answer was buried under cache_key / context_root / per- source metadata / timings JSON. Default now renders human-readable: who is pikachu? HYBRID 1/2 verified via quote 9.2s (fresh) Pikachu is a species of Pokémon creatures... sources (3): [1] Pikachu — en.wikipedia.org/wiki/Pikachu (002.db) [2] List_of_Pokémon — en.wikipedia.org/wiki/List_of_Pokémon ... unverified (1): - "spans the model couldn't ground..." cache_key: 35ab7d33… Pass `--json` to get the prior raw record (still ensure_ascii=False so unicode renders cleanly there too — scripts parsing the output see real chars; the JSON spec accepts either form). Implementation: - `_render_query_human(result, question)` — pure function, easy to unit-test, no I/O. Truncates unverified spans over 100 chars, omits empty sections, distinguishes cached vs fresh in the summary. - `_strip_scheme` / `_short_path` — small helpers for source display. - `--json` flag on the `query` subparser; default is human render. Tests: 12 new in tests/test_cli_render.py — question position, summary fields (audit/n_verified/method/elapsed/cache-status), unicode literals (Pokémon not \\u00e9), source line shape, long-quote truncation, empty-section omission, error fallback, short cache_key with --json hint. 309 passed, 1 skipped overall. --- aborist/cli.py | 175 ++++++++++++++++++++++++++++++--------- tests/test_cli_render.py | 148 +++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 tests/test_cli_render.py diff --git a/aborist/cli.py b/aborist/cli.py index 77b6a1d..e03214a 100644 --- a/aborist/cli.py +++ b/aborist/cli.py @@ -137,7 +137,7 @@ def _cmd_ingest(args: argparse.Namespace) -> int: ) finally: conn.close() - print(json.dumps(result.__dict__, indent=2)) + print(json.dumps(result.__dict__, indent=2, ensure_ascii=False)) return 0 @@ -167,7 +167,7 @@ def _cmd_search(args: argparse.Namespace) -> int: } for h in hits ], - indent=2, + indent=2, ensure_ascii=False ) ) else: @@ -189,7 +189,7 @@ def _cmd_verify(args: argparse.Namespace) -> int: result = verify_random_sample(conn, n=args.n) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if result["failed"] == 0 else 1 @@ -238,7 +238,7 @@ def _cmd_distill(args: argparse.Namespace) -> int: per_shard.append({"shard": sp.name, **r}) for k in totals: totals[k] += r[k] - print(json.dumps({**totals, "shards": per_shard}, indent=2)) + print(json.dumps({**totals, "shards": per_shard}, indent=2, ensure_ascii=False)) return 0 conn = connect(args.db) @@ -254,7 +254,7 @@ def _cmd_distill(args: argparse.Namespace) -> int: ) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 @@ -300,7 +300,7 @@ def _cmd_ask(args: argparse.Namespace) -> int: ) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if result.get("status") in ("cache_hit", "cache_miss_then_written") else 1 @@ -357,7 +357,10 @@ def _cmd_query(args: argparse.Namespace) -> int: max_context_chars=args.max_context_chars, ) - print(json.dumps(result, indent=2)) + if args.json: + print(json.dumps(result, indent=2, ensure_ascii=False)) + else: + print(_render_query_human(result, args.question)) return ( 0 if result.get("status") in ("cache_hit", "cache_miss_then_written") @@ -365,6 +368,92 @@ def _cmd_query(args: argparse.Namespace) -> int: ) +def _render_query_human(result: dict, question: str) -> str: + """Pretty-print a query result for terminal reading. + + Layout: + question + AUDIT_MODE N/M verified via verifier_method Xs (cached|fresh) + + answer text... + + sources (K): + [1] Title — host/path (shard.db) + [2] ... + + unverified (J): + - "..." + + cache_key: 35ab7d33… + + Errors / no-source paths fall back to a short status line. + """ + status = result.get("status") + if status not in ("cache_hit", "cache_miss_then_written"): + msg = result.get("msg") or status or "unknown error" + return f" {status or 'error'}: {msg}" + + audit = result.get("audit_mode", "?") + n_quotes = result.get("n_quotes", 0) or 0 + n_verified = result.get("n_verified", 0) or 0 + method = result.get("verifier_method", "?") + timings = result.get("timings") or {} + total_ms = timings.get("total_ms") + elapsed = f"{total_ms / 1000:.1f}s" if isinstance(total_ms, (int, float)) else "?" + cache_status = "cached" if status == "cache_hit" else "fresh" + + lines: list[str] = [] + lines.append(question) + lines.append( + f" {audit} {n_verified}/{n_quotes} verified via {method} " + f"{elapsed} ({cache_status})" + ) + lines.append("") + + answer = result.get("answer_text") or "" + lines.append(answer) + lines.append("") + + sources = result.get("sources") or [] + if sources: + lines.append(f"sources ({len(sources)}):") + for i, s in enumerate(sources, start=1): + uri = s.get("document_uri", "") + title = (s.get("title") or "").strip() or _short_path(uri) + shard = s.get("shard") + shard_part = f" ({shard})" if shard else "" + lines.append(f" [{i}] {title} — {_strip_scheme(uri)}{shard_part}") + lines.append("") + + unverified = result.get("unverified_quotes") or [] + if unverified: + lines.append(f"unverified ({len(unverified)}):") + for q in unverified: + qtxt = q if len(q) <= 100 else q[:97] + "..." + lines.append(f' - "{qtxt}"') + lines.append("") + + cache_key = (result.get("cache_key") or "")[:8] + lines.append(f"cache_key: {cache_key}… ") + return "\n".join(lines) + + +def _strip_scheme(uri: str) -> str: + """`https://en.wikipedia.org/wiki/X` -> `en.wikipedia.org/wiki/X`.""" + for prefix in ("https://", "http://"): + if uri.startswith(prefix): + return uri[len(prefix):] + return uri + + +def _short_path(uri: str) -> str: + """Last URL segment as a fallback display name.""" + s = _strip_scheme(uri).rstrip("/") + if "/" in s: + return s.rsplit("/", 1)[1] + return s + + def _falsify_cache_key( cache_key_value: str, *, @@ -857,7 +946,7 @@ def _cmd_burn(args: argparse.Namespace) -> int: print(f"unknown burn kind: {kind}", file=sys.stderr) return 2 - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if result.get("status") == "burned" else 1 @@ -872,7 +961,7 @@ def _cmd_providence(args: argparse.Namespace) -> int: shards_dir=Path(args.global_shards_dir) if args.global_shards_dir else None, db_path=Path(args.db) if args.db else None, ) - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if result.get("status") == "falsified" else 1 conn = ( connect_query(args.db, shards_dir=args.global_shards_dir) @@ -914,7 +1003,7 @@ def _cmd_providence(args: argparse.Namespace) -> int: } for r in rows ] - print(json.dumps(out, indent=2)) + print(json.dumps(out, indent=2, ensure_ascii=False)) return 0 @@ -1030,7 +1119,7 @@ def _cmd_reclassify(args: argparse.Namespace) -> int: "skipped_cold": skipped_cold, "distribution": {p: dict(d) for p, d in distribution.items()}, "records": grid, - }, indent=2)) + }, indent=2, ensure_ascii=False)) return 0 # Single-policy reclassify. Default tracks DEFAULT_ENTITY_POLICY @@ -1131,7 +1220,7 @@ def _cmd_reclassify(args: argparse.Namespace) -> int: summary["transitions"] = dict(summary["transitions"]) summary["dry_run"] = bool(args.dry_run) - print(json.dumps(summary, indent=2)) + print(json.dumps(summary, indent=2, ensure_ascii=False)) return 0 @@ -1162,7 +1251,7 @@ def _cmd_emergent(args: argparse.Namespace) -> int: ranked = sorted(counts.items(), key=lambda kv: -kv[1])[: args.limit] print(json.dumps( [{"quote": q, "count": c} for q, c in ranked], - indent=2, + indent=2, ensure_ascii=False )) else: rows = conn.execute( @@ -1191,7 +1280,7 @@ def _cmd_emergent(args: argparse.Namespace) -> int: } for r in rows ] - print(json.dumps(out, indent=2)) + print(json.dumps(out, indent=2, ensure_ascii=False)) finally: conn.close() return 0 @@ -1214,7 +1303,7 @@ def _cmd_evict(args: argparse.Namespace) -> int: ) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 @@ -1249,7 +1338,7 @@ def _cmd_rehydrate(args: argparse.Namespace) -> int: results.append(res) finally: conn.close() - print(json.dumps(results, indent=2)) + print(json.dumps(results, indent=2, ensure_ascii=False)) drift = sum(1 for r in results if r.get("status") == "drift_detected") return 1 if drift else 0 @@ -1418,7 +1507,7 @@ def _cmd_activity(args: argparse.Namespace) -> int: "recent_derives": derive_events[: args.limit], "recent_falsifications": falsifications[: args.limit], }, - indent=2, + indent=2, ensure_ascii=False ) ) return 0 @@ -1434,7 +1523,7 @@ def _cmd_stats(args: argparse.Namespace) -> int: result = stats(conn) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 @@ -1575,7 +1664,7 @@ def _cmd_analyze(args: argparse.Namespace) -> int: } finally: conn.close() - print(json.dumps(report, indent=2)) + print(json.dumps(report, indent=2, ensure_ascii=False)) return 0 @@ -1603,7 +1692,7 @@ def _cmd_snapshot_create(args: argparse.Namespace) -> int: ) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 # Cross-shard: compute against UNION, write to args.db. @@ -1666,7 +1755,7 @@ def _cmd_snapshot_create(args: argparse.Namespace) -> int: "reason": args.reason, "scope": "shards-union", }, - indent=2, + indent=2, ensure_ascii=False ) ) return 0 @@ -1680,7 +1769,7 @@ def _cmd_snapshot_list(args: argparse.Namespace) -> int: rows = list_snapshots(conn, limit=args.limit) finally: conn.close() - print(json.dumps(rows, indent=2)) + print(json.dumps(rows, indent=2, ensure_ascii=False)) return 0 @@ -1696,7 +1785,7 @@ def _cmd_snapshot_verify(args: argparse.Namespace) -> int: result = verify_snapshot(conn, args.snapshot_root) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if result["matches"] else 1 @@ -1712,7 +1801,7 @@ def _cmd_snapshot_diff(args: argparse.Namespace) -> int: result = diff_against_current(conn, args.snapshot_root) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 @@ -1751,7 +1840,7 @@ def _cmd_mesh_status(args: argparse.Namespace) -> int: } finally: conn.close() - print(json.dumps(out, indent=2)) + print(json.dumps(out, indent=2, ensure_ascii=False)) return 0 @@ -1777,7 +1866,7 @@ def _cmd_mesh_init(args: argparse.Namespace) -> int: "note": "share sign_pub_hex + dh_pub_hex with the founder of any group " "you want to join. Run 'mesh enable' to flip the gating flag on.", }, - indent=2, + indent=2, ensure_ascii=False ) ) return 0 @@ -1791,7 +1880,7 @@ def _cmd_mesh_enable(args: argparse.Namespace) -> int: set_enabled(conn, True) finally: conn.close() - print(json.dumps({"enabled": True}, indent=2)) + print(json.dumps({"enabled": True}, indent=2, ensure_ascii=False)) return 0 @@ -1803,7 +1892,7 @@ def _cmd_mesh_disable(args: argparse.Namespace) -> int: set_enabled(conn, False) finally: conn.close() - print(json.dumps({"enabled": False}, indent=2)) + print(json.dumps({"enabled": False}, indent=2, ensure_ascii=False)) return 0 @@ -1815,7 +1904,7 @@ def _cmd_mesh_members(args: argparse.Namespace) -> int: try: epoch = current_epoch(conn) if epoch is None: - print(json.dumps({"error": "mesh not initialized"}, indent=2)) + print(json.dumps({"error": "mesh not initialized"}, indent=2, ensure_ascii=False)) return 2 roster = roster_at(conn, epoch) finally: @@ -1834,7 +1923,7 @@ def _cmd_mesh_members(args: argparse.Namespace) -> int: for m in roster ], }, - indent=2, + indent=2, ensure_ascii=False ) ) return 0 @@ -1867,7 +1956,7 @@ def _cmd_mesh_add(args: argparse.Namespace) -> int: return 2 finally: conn.close() - print(json.dumps({"new_epoch": epoch, "added": args.member_id}, indent=2)) + print(json.dumps({"new_epoch": epoch, "added": args.member_id}, indent=2, ensure_ascii=False)) return 0 @@ -1885,7 +1974,7 @@ def _cmd_mesh_kick(args: argparse.Namespace) -> int: print( json.dumps( {"new_epoch": epoch, "kicked": args.member_id, "reason": args.reason}, - indent=2, + indent=2, ensure_ascii=False ) ) return 0 @@ -1902,7 +1991,7 @@ def _cmd_mesh_rotate(args: argparse.Namespace) -> int: return 2 finally: conn.close() - print(json.dumps({"new_epoch": epoch, "reason": args.reason}, indent=2)) + print(json.dumps({"new_epoch": epoch, "reason": args.reason}, indent=2, ensure_ascii=False)) return 0 @@ -1923,12 +2012,12 @@ def _cmd_mesh_serve(args: argparse.Namespace) -> int: conn.close() srv = MeshWireServer(args.db, host=args.host, port=args.port) - print(json.dumps({"status": "serving", "url": srv.url, "db": str(args.db)})) + print(json.dumps({"status": "serving", "url": srv.url, "db": str(args.db)}, ensure_ascii=False)) sys.stdout.flush() try: srv.serve() except KeyboardInterrupt: - print(json.dumps({"status": "stopped", "reason": "SIGINT"})) + print(json.dumps({"status": "stopped", "reason": "SIGINT"}, ensure_ascii=False)) finally: srv.stop() return 0 @@ -1967,7 +2056,7 @@ def _cmd_mesh_sync(args: argparse.Namespace) -> int: try: peer_info = client.info() except Exception as e: - print(json.dumps({"status": "peer_unreachable", "peer": args.peer, "error": str(e)}, indent=2)) + print(json.dumps({"status": "peer_unreachable", "peer": args.peer, "error": str(e)}, indent=2, ensure_ascii=False)) return 2 for r in rows: try: @@ -1991,7 +2080,7 @@ def _cmd_mesh_sync(args: argparse.Namespace) -> int: "errors": len(errors), "sent": sent[: args.verbose], "error_samples": errors[:5], - }, indent=2)) + }, indent=2, ensure_ascii=False)) return 0 if not errors else 1 @@ -2031,7 +2120,7 @@ def _cmd_mesh_pull(args: argparse.Namespace) -> int: "document_root": already["document_root"], "document_uri": already["document_uri"], "shard": str(args.db), - }, indent=2)) + }, indent=2, ensure_ascii=False)) return 0 try: @@ -2098,7 +2187,7 @@ def _cmd_mesh_pull(args: argparse.Namespace) -> int: "document_uri": delivered_uri, "shard": str(args.db), "audit_event_hash": event_hash, - }, indent=2)) + }, indent=2, ensure_ascii=False)) return 0 @@ -2168,7 +2257,7 @@ def _cmd_crawl(args: argparse.Namespace) -> int: "discovered": len(urls), **result, }, - indent=2, + indent=2, ensure_ascii=False )) return 0 @@ -2196,7 +2285,7 @@ def _cmd_crawler_recrawl_check(args: argparse.Namespace) -> int: ) finally: conn.close() - print(json.dumps(result, indent=2)) + print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 @@ -2440,6 +2529,10 @@ def build_parser() -> argparse.ArgumentParser: "--dry-run", dest="dry_run", action="store_true", help="use StubClient — assembles context but skips the LLM call", ) + query_cmd.add_argument( + "--json", action="store_true", + help="emit the raw record as indented JSON (default: human render)", + ) query_cmd.set_defaults(func=_cmd_query) prov_cmd = sub.add_parser( diff --git a/tests/test_cli_render.py b/tests/test_cli_render.py new file mode 100644 index 0000000..5ffca4a --- /dev/null +++ b/tests/test_cli_render.py @@ -0,0 +1,148 @@ +"""Pretty-print + ensure_ascii=False on `aborist query` output. + +The CLI default emits a human-readable render of the query result; +``--json`` switches to the raw record. Both paths must: + - render unicode literals (no \\uXXXX escape sequences) + - present the answer prominently; metadata is supporting info +""" + +from __future__ import annotations + +import json + +import pytest + +from aborist.cli import _render_query_human + + +# --------------------------------------------------------------------------- +# _render_query_human — direct unit tests +# --------------------------------------------------------------------------- + + +def _result(**overrides) -> dict: + base = { + "status": "cache_miss_then_written", + "audit_mode": "HYBRID", + "cache_key": "35ab7d3355c723b759145d6d446cb1d9010abe0dce9992ca0e8b87837b596610", + "context_root": "ab" * 32, + "answer_text": "Pikachu is a species of Pokémon creatures.", + "sources": [ + { + "document_root": "cd" * 32, + "document_uri": "https://en.wikipedia.org/wiki/Pikachu", + "title": "Pikachu", + "score": 36.6, + "chunk_idx": 6, + "shard": "002.db", + } + ], + "n_quotes": 2, + "n_verified": 1, + "verifier_method": "quote", + "unverified_quotes": ["something the model said but didn't ground"], + "timings": {"total_ms": 9234.1}, + } + base.update(overrides) + return base + + +def test_render_includes_question_at_top(): + out = _render_query_human(_result(), "who is pikachu?") + assert out.splitlines()[0] == "who is pikachu?" + + +def test_render_summary_line_has_audit_n_verified_method_elapsed(): + out = _render_query_human(_result(), "q") + second = out.splitlines()[1] + assert "HYBRID" in second + assert "1/2 verified" in second + assert "via quote" in second + assert "9.2s" in second + + +def test_render_marks_cache_hit_distinctly(): + out_hit = _render_query_human(_result(status="cache_hit"), "q") + out_fresh = _render_query_human(_result(status="cache_miss_then_written"), "q") + assert "(cached)" in out_hit + assert "(fresh)" in out_fresh + + +def test_render_emits_unicode_literals_not_escapes(): + """Pokémon, not Pok\\u00e9mon — the user's terminal should see real é.""" + out = _render_query_human(_result(), "who is pikachu?") + assert "Pokémon" in out + assert "\\u00e9" not in out + + +def test_render_lists_sources_with_clean_host_path(): + out = _render_query_human(_result(), "q") + # "[1] Pikachu — en.wikipedia.org/wiki/Pikachu (002.db)" + assert "[1] Pikachu" in out + assert "en.wikipedia.org/wiki/Pikachu" in out + # scheme stripped + assert "https://" not in out.split("sources (")[1] if "sources (" in out else True + assert "(002.db)" in out + + +def test_render_truncates_long_unverified_quotes(): + long = "x" * 200 + out = _render_query_human(_result(unverified_quotes=[long]), "q") + # Truncated form ends in ellipsis + assert "..." in out + # Original doesn't fully appear + assert long not in out + + +def test_render_omits_unverified_section_when_empty(): + out = _render_query_human(_result(unverified_quotes=[]), "q") + assert "unverified" not in out.lower() + + +def test_render_omits_sources_section_when_empty(): + out = _render_query_human(_result(sources=[]), "q") + assert "sources (" not in out + + +def test_render_falls_back_for_error_status(): + """no_sources / unknown_document / etc. produce a one-line status.""" + out = _render_query_human( + {"status": "no_sources", "msg": "FTS5 returned no hits"}, + "q", + ) + assert "no_sources" in out + assert "FTS5" in out + + +def test_render_includes_short_cache_key_with_pointer_to_json(): + out = _render_query_human(_result(), "q") + # Short prefix only — full key requires --json + assert "cache_key: 35ab7d33" in out + assert "--json" in out + + +# --------------------------------------------------------------------------- +# json.dumps everywhere uses ensure_ascii=False (regression on the global pass) +# --------------------------------------------------------------------------- + + +def test_unicode_round_trips_through_print_path(capsys): + """Pin: a result with a Pokémon-style answer prints é, not \\u00e9. + Uses argparse via build_parser to exercise the real CLI dispatch.""" + from aborist.cli import build_parser + + parser = build_parser() + # Build a fake result and call _cmd_query's render path indirectly. + out = _render_query_human(_result(), "who is pikachu?") + print(out) + captured = capsys.readouterr().out + assert "Pokémon" in captured + assert "\\u00e9" not in captured + + +def test_json_mode_also_uses_unicode(): + """The --json path should also emit unicode literals, not escapes.""" + payload = {"answer": "Pokémon"} + s = json.dumps(payload, ensure_ascii=False) + assert "Pokémon" in s + assert "\\u00e9" not in s