Files
2026-07-13 12:42:18 +08:00

318 lines
11 KiB
Python

"""Compact estimated context savings helpers.
The project intentionally labels these values as estimates: the helper uses a
conservative character-count approximation instead of model-specific tokenizers.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Iterable
CHARS_PER_TOKEN = 4
def estimate_tokens(value: Any) -> int:
"""Estimate token count with a conservative 4 chars/token approximation."""
if value is None:
return 0
if isinstance(value, str):
text = value
else:
text = json.dumps(
value,
default=str,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
)
if not text:
return 0
return max(1, (len(text) + CHARS_PER_TOKEN - 1) // CHARS_PER_TOKEN)
def estimate_file_tokens(repo_root: Path, files: Iterable[str]) -> int:
"""Estimate tokens for changed files using file sizes, not file contents."""
total = 0
root = repo_root.resolve()
for file_name in files:
path = Path(file_name)
full_path = path if path.is_absolute() else root / path
try:
if full_path.is_file():
total += max(
1,
(full_path.stat().st_size + CHARS_PER_TOKEN - 1)
// CHARS_PER_TOKEN,
)
except OSError:
continue
return total
def estimate_context_savings(
*,
original_context: Any | None = None,
returned_context: Any | None = None,
original_tokens: int | None = None,
returned_tokens: int | None = None,
) -> dict[str, int | bool] | None:
"""Return tiny savings metadata, or None when no baseline is available."""
baseline = (
original_tokens
if original_tokens is not None
else estimate_tokens(original_context)
)
returned = (
returned_tokens
if returned_tokens is not None
else estimate_tokens(returned_context)
)
if baseline <= 0:
return None
saved = max(0, baseline - returned)
percent = round((saved / baseline) * 100) if baseline else 0
return {
"estimated": True,
"saved_tokens": int(saved),
"saved_percent": int(percent),
}
def attach_context_savings(
result: dict[str, Any],
*,
original_context: Any | None = None,
original_tokens: int | None = None,
returned_context: Any | None = None,
returned_tokens: int | None = None,
) -> dict[str, Any]:
"""Attach compact ``context_savings`` metadata when it can be estimated."""
estimate = estimate_context_savings(
original_context=original_context,
returned_context=result if returned_context is None else returned_context,
original_tokens=original_tokens,
returned_tokens=returned_tokens,
)
if estimate is not None:
result["context_savings"] = estimate
return result
def format_context_savings(estimate: dict[str, Any] | None) -> str | None:
"""Format a one-line human summary for CLI output."""
if not estimate:
return None
saved = int(estimate.get("saved_tokens", 0))
percent = int(estimate.get("saved_percent", 0))
return f"Estimated context saved: ~{saved:,} tokens (~{percent}%)"
def _fmt_compact(n: int) -> str:
"""Compact integer formatting: 1234 -> '1.2k', 9876 -> '9.9k', 500 -> '500'."""
if n >= 10_000:
return f"{n // 1000:,}k"
if n >= 1000:
return f"{n / 1000:.1f}k"
return str(n)
def _breakdown_from_response(response: dict[str, Any]) -> dict[str, int]:
"""Pull a per-category token estimate from a detect-changes / review response.
Only fields that exist and have content are reported, so the breakdown
line stays meaningful instead of padding with zeros.
"""
# Friendly label -> response-dict key
fields = [
("Functions", "changed_functions"),
("Flows", "affected_flows"),
("Tests", "test_gaps"),
("Risk", "review_priorities"),
("Impact", "impacted_nodes"),
("Edges", "edges"),
("Source", "source_snippets"),
("Imports", "imports"),
]
out: dict[str, int] = {}
for label, key in fields:
value = response.get(key)
if not value:
continue
tokens = estimate_tokens(value)
if tokens > 0:
out[label] = tokens
return out
def verify_with_tiktoken(
repo_root: "Path | str",
changed_files: Iterable[str],
response: Any,
encoding_name: str = "cl100k_base",
) -> dict[str, int] | None:
"""Calibrate the chars/4 estimate against a real model tokenizer.
Returns ``{"verified_baseline": int, "verified_returned": int,
"verified_saved": int, "verified_percent": int}`` or ``None`` if
tiktoken is not installed. Reads every changed file's content (unlike
the stat-only ``estimate_file_tokens``) so the numbers reflect what
an agent would actually consume.
"""
try:
import tiktoken # type: ignore[import-untyped]
except ImportError:
return None
enc = tiktoken.get_encoding(encoding_name)
root = Path(repo_root).resolve()
naive_real = 0
for f in changed_files:
p = root / f
try:
if p.is_file():
naive_real += len(enc.encode(p.read_text(errors="replace")))
except OSError:
continue
if isinstance(response, str):
graph_real = len(enc.encode(response))
else:
text = json.dumps(
response, default=str, ensure_ascii=True,
separators=(",", ":"), sort_keys=True,
)
graph_real = len(enc.encode(text))
saved = max(0, naive_real - graph_real)
pct = round(saved * 100 / naive_real) if naive_real > 0 else 0
return {
"verified_baseline": naive_real,
"verified_returned": graph_real,
"verified_saved": saved,
"verified_percent": pct,
}
def format_context_savings_panel(
estimate: dict[str, Any] | None,
*,
original_tokens: int | None = None,
returned_tokens: int | None = None,
response: dict[str, Any] | None = None,
breakdown: dict[str, int] | None = None,
verified: dict[str, int] | None = None,
title: str = "Token Savings",
width: int = 64,
) -> str | None:
"""Format the savings estimate as a boxed multi-line CLI panel.
Example output (width=60)::
┌──────────────── Token Savings ────────────────┐
│ Full context would be: 12,932 tokens │
│ Graph context used: 773 tokens │
│ Saved: 12,159 tokens (~94%) │
│ Breakdown: Functions 580 · Tests 120 · ... │
└───────────────────────────────────────────────┘
All numbers are labelled as estimates upstream (``estimated: true`` in the
metadata dict) because the project uses a 4-chars-per-token approximation,
not model-specific tokenization.
Args:
estimate: The ``context_savings`` dict from a tool response.
original_tokens: Optional override for the naive baseline.
returned_tokens: Optional override for the graph response size.
response: When provided, breakdown is auto-derived from common keys
(``changed_functions``, ``affected_flows``, ``test_gaps``,
``review_priorities``, ``impacted_nodes``, ``edges``,
``source_snippets``, ``imports``).
breakdown: Explicit ``{label: tokens}`` map; takes precedence over
``response``-derived breakdown when both are provided.
title: Title centered in the top border.
width: Total panel width, capped at terminal width if larger.
Returns:
The panel as a single ``\\n``-joined string, or ``None`` when there
is nothing meaningful to display.
"""
if not estimate:
return None
saved = int(estimate.get("saved_tokens", 0))
percent = int(estimate.get("saved_percent", 0))
# Derive baseline + returned from saved+percent if not provided
if original_tokens is None:
if percent > 0:
original_tokens = int(round(saved * 100 / percent))
else:
original_tokens = saved
if returned_tokens is None:
returned_tokens = max(0, (original_tokens or 0) - saved)
if breakdown is None and response is not None:
breakdown = _breakdown_from_response(response)
# Top up the breakdown with an "Other" bucket so the parts sum to
# ``returned_tokens`` exactly. "Other" covers fields the breakdown
# doesn't enumerate (status, summary, risk_score, context_savings
# metadata, JSON envelope chars). Skip when there's no positive
# remainder — the breakdown already accounts for the whole response.
if breakdown and returned_tokens is not None:
labelled_sum = sum(breakdown.values())
remainder = returned_tokens - labelled_sum
if remainder > 0:
breakdown = dict(breakdown) # copy before mutating
breakdown["Other"] = remainder
# Lines that go inside the box (without borders)
inner_lines: list[str] = [
f"Full context would be: {original_tokens:>9,} tokens",
f"Graph context used: {returned_tokens:>9,} tokens",
f"Saved: {saved:>9,} tokens (~{percent}%)",
]
if verified:
vb = verified["verified_baseline"]
vr = verified["verified_returned"]
vs = verified["verified_saved"]
vp = verified["verified_percent"]
inner_lines.append(
f"Verified (tiktoken): {vs:>9,} tokens (~{vp}%) "
f"[{vb:,}{vr:,}]"
)
if breakdown:
parts = [f"{label} {_fmt_compact(tok)}" for label, tok in breakdown.items()]
bd_line = "Breakdown: " + " · ".join(parts)
inner_lines.append(bd_line)
# Compute final width: at least wide enough for the longest inner line + padding
content_width = max(len(s) for s in inner_lines)
inner_w = max(width - 2, content_width + 2) # +2 for one space pad each side
# Title bar
title_str = f" {title} "
dash_total = inner_w - len(title_str)
if dash_total < 4:
dash_total = 4
left_dash = dash_total // 2
right_dash = dash_total - left_dash
top = "┌" + "─" * left_dash + title_str + "─" * right_dash + "┐"
bottom = "└" + "─" * inner_w + "┘"
def _box_line(content: str) -> str:
pad = inner_w - 2 - len(content)
if pad < 0:
pad = 0
return f"│ {content}{' ' * pad} │"
lines = [top]
for s in inner_lines:
lines.append(_box_line(s))
lines.append(bottom)
return "\n".join(lines)