cli: human render for query by default; --json for raw; ensure_ascii=False

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…   <run with --json for full record>

   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.
This commit is contained in:
russell@unturf.com 2026-04-29 10:14:36 -04:00
parent 6291f7c190
commit 31701ad524
No known key found for this signature in database
2 changed files with 282 additions and 41 deletions

View file

@ -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… <run with --json for full record>
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}… <run with --json for full record>")
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(

148
tests/test_cli_render.py Normal file
View file

@ -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