asa / scripts /lib /chat_eval /report.py
defenestrate's picture
ASA Space: squashed history (instructions and settings live in the private pack)
526950e
Raw History Blame Contribute Delete
16 kB
"""Unified weekly markdown report assembled from analytics functions."""
from __future__ import annotations
from datetime import datetime, timezone
import json as _json
from pathlib import Path
from scripts.lib.chat_eval.analytics import (
coverage,
doc_utilization,
misconception_frequency,
voice_failures,
timing_density,
query_frequency,
score_summary,
cost_summary,
cache_summary,
cache_recommendation,
topics_by_session,
chat_sessions,
)
def build_report(
path: Path,
*,
since: str | None = None,
session_core_path: Path | None = None,
catalog_path: Path | None = None,
title: str = "Chat Feedback Report",
heading_level: int = 1,
) -> str:
"""Concatenate every analytics view into one markdown document."""
parts: list[str] = []
parts.append(f"{'#' * heading_level} {title}")
parts.append("")
parts.append(f"Source log: `{path}`")
if since:
parts.append(f"Since: `{since}`")
parts.append("")
# 1. Summary
parts.append("## Summary")
summary = score_summary(path, since=since)
if summary:
parts.append("Mean score per dimension across judged turns:")
parts.append("")
parts.append("| Dimension | Mean |")
parts.append("|---|---|")
for dim, mean in summary.items():
parts.append(f"| {dim} | {mean} |")
else:
parts.append("(No judged turns yet β€” run `python -m scripts.lib.chat_eval.cli judge`.)")
parts.append("")
# 1.5 Cost
parts.append("## Cost")
cost = cost_summary(path, since=since)
if cost["n_turns"] > 0:
parts.append(f"Total spend (chat tutor only, est): **${cost['total_cost_usd']:.2f}** across {cost['n_turns']} turns.")
parts.append("")
parts.append(f"Mean per turn: **${cost['mean_cost_usd']:.3f}**. Most expensive turn: ${cost['max_cost_usd']:.3f} (`{cost['max_turn_id']}`).")
parts.append("")
if cost["by_intent"]:
parts.append("By intent:")
parts.append("")
parts.append("| Intent | n | Total | Mean |")
parts.append("|---|---|---|---|")
for intent, stats in sorted(cost["by_intent"].items(), key=lambda kv: -kv[1]["total"]):
parts.append(f"| {intent} | {stats['n']} | ${stats['total']:.3f} | ${stats['mean']:.3f} |")
parts.append("")
parts.append("_Judge + planner + embedder costs not included; estimated via `len(text)//4` tokens (Β±20%)._")
else:
parts.append("(No turns logged β€” no cost to report.)")
parts.append("")
# 1.6 Caching (cache hit rate + savings + cache strategy candidates)
parts.append("## Caching")
cache = cache_summary(path, since=since)
if cache["n_turns_with_data"] > 0:
parts.append(
f"Cache hit rate: **{cache['cache_hit_rate']*100:.1f}%** across "
f"{cache['n_turns_with_data']} turns with usage data."
)
parts.append("")
parts.append(
f"- Uncached input: **{cache['total_uncached_input']:,}** tokens "
f"(full rate)"
)
parts.append(
f"- Cache writes: **{cache['total_cache_writes']:,}** tokens "
f"(1.25x premium)"
)
parts.append(
f"- Cache reads: **{cache['total_cache_reads']:,}** tokens "
f"(0.10x discount)"
)
parts.append(
f"- Estimated savings vs no-cache: "
f"**${cache['estimated_savings_usd']:.2f}**"
)
parts.append("")
else:
parts.append("(No cache-usage data yet β€” chat sessions before "
"the caching commit lack `actual_input_tokens`. New "
"sessions will populate this section.)")
parts.append("")
# 1.7 Permanent-cache recommendation: which retrieved docs are worth
# pinning into a 1-hour cached prefix? Ranked by projected savings,
# accounting for the write premium.
parts.append("### Permanent cache: which docs to pin")
rec = cache_recommendation(path, since=since, ttl="1h", top_n=15)
if rec["recommended"]:
parts.append(
f"If you put these {len(rec['recommended'])} docs into a 1-hour "
f"cached prefix (write at 2x input, read at 0.10x), projected "
f"savings across the logged window: "
f"**${rec['total_recommended_savings_usd']:.4f}**."
)
parts.append("")
parts.append("| doc_id | kind | topic | retrieved | body tok | savings |")
parts.append("|---|---|---|---|---|---|")
for r in rec["recommended"]:
parts.append(
f"| `{r['doc_id']}` | {r['kind']} | {r['topic']} | "
f"{r['retrieval_count']} | {r['body_tokens']:,} | "
f"${r['savings_per_term_usd']:.4f} |"
)
parts.append("")
parts.append(
f"_Break-even at N={rec['recommended'][0]['break_even_at']} "
f"retrievals for 1-hour TTL. Docs retrieved fewer times don't "
f"pay back the write premium._"
)
elif rec["no_data_warning"]:
parts.append(f"({rec['no_data_warning']})")
else:
parts.append(
"(No docs were retrieved 2+ times in the logged window β€” "
"permanent caching has no docs to recommend yet.)"
)
parts.append("")
# 1.8 Session core (if file exists)
parts.append("### Session core")
core_meta_path = session_core_path or (
path.parent / "session_core.json"
)
if Path(core_meta_path).is_file():
try:
meta = _json.loads(Path(core_meta_path).read_text(encoding="utf-8"))
except (OSError, _json.JSONDecodeError):
meta = None
if meta:
ts = meta.get("build_timestamp", "")
try:
built = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=timezone.utc)
age_days = (datetime.now(timezone.utc) - built).days
except ValueError:
age_days = -1
n = len(meta.get("doc_ids", []))
est = meta.get("concurrent_sessions_estimate", "?")
proj = meta.get("projected_savings_usd", 0.0)
parts.append(
f"Built: `{ts}` ({age_days} days ago). "
f"Docs: **{n}**. "
f"Concurrent-sessions estimate: **{est}**. "
f"Projected savings/window: **${proj:.4f}**."
)
if age_days >= 14:
parts.append("")
parts.append(
"_Session core is **stale** (>= 14 days old). "
"Re-run `python -m scripts.build_session_core ...`._"
)
else:
parts.append("(session_core.json present but unreadable.)")
else:
parts.append("(No session_core.json β€” run `build_session_core` to "
"generate one.)")
parts.append("")
# 2. Concept coverage
parts.append("## Concept coverage")
cov = coverage(path, since=since)
if cov:
top = sorted(cov.items(), key=lambda kv: -kv[1])[:15]
parts.append("Top canonical terms by retrieval frequency:")
parts.append("")
parts.append("| Term | Retrievals |")
parts.append("|---|---|")
for term, count in top:
parts.append(f"| `{term}` | {count} |")
else:
parts.append("(No retrievals logged.)")
parts.append("")
# 3. Doc utilization
parts.append("## Doc utilization")
util = doc_utilization(path, since=since)
if util:
# Sort by lowest util_pct first (the failures)
ranked = sorted(util.items(), key=lambda kv: (kv[1]["util_pct"], -kv[1]["retrieved_count"]))[:20]
parts.append("Lowest-utilization docs (retrieved often, used rarely):")
parts.append("")
parts.append("| doc_id | retrieved | used | util % |")
parts.append("|---|---|---|---|")
for doc_id, stats in ranked:
parts.append(f"| `{doc_id}` | {stats['retrieved_count']} | {stats['used_count']} | {stats['util_pct']} |")
else:
parts.append("(No retrievals logged.)")
parts.append("")
# 4. Misconceptions
parts.append("## Misconceptions")
mc = misconception_frequency(path, since=since)
if mc:
parts.append("`count` = total flags. `in_query` = student exhibited the "
"misconception. `handled` = response addressed it. "
"`missed` = student exhibited it AND response didn't address "
"it (the actionable failure mode).")
parts.append("")
parts.append("| id | count | in_query | handled | missed |")
parts.append("|---|---|---|---|---|")
# Sort by missed desc, then count desc
for mid, stats in sorted(mc.items(),
key=lambda kv: (-kv[1]["missed"], -kv[1]["count"])):
parts.append(
f"| `{mid}` | {stats['count']} | {stats['in_query']} | "
f"{stats['handled']} | {stats['missed']} |"
)
else:
parts.append("(No misconceptions detected, or no judged turns.)")
parts.append("")
# 5. Voice failures
parts.append("## Voice failures")
vf = voice_failures(path, since=since)
if vf:
parts.append(f"({len(vf)} turns scored <= 2 on voice.)")
parts.append("")
for v in vf[:10]:
parts.append(f"### {v['turn_id']} (voice {v['voice_score']})")
parts.append(f"- **Query:** {v['user_message']}")
parts.append(f"- **Response excerpt:** {v['response'][:200]}{'...' if len(v['response']) > 200 else ''}")
parts.append(f"- **Diagnosis:** {v['diagnosis']}")
parts.append("")
else:
parts.append("(No voice failures detected.)")
parts.append("")
# 6. Timing patterns
parts.append("## Timing patterns")
timing = timing_density(path, since=since)
if timing:
parts.append("Turn count by weekday:")
parts.append("")
parts.append("| Weekday | Total |")
parts.append("|---|---|")
for wd in ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]:
total = timing.get(wd, {}).get("total", 0)
parts.append(f"| {wd} | {total} |")
else:
parts.append("(No turns logged.)")
parts.append("")
# 7. Topics by class session
parts.append("## Topics by class session")
catalog_p = catalog_path or (path.parent / "catalog.jsonl")
tbs = topics_by_session(path, catalog_path=catalog_p, since=since)
sessions = tbs.get("sessions", {})
unmapped = tbs.get("unmapped_turns", 0)
if sessions:
parts.append(
"Each turn is associated with the lecture session(s) its "
"retrieved docs trace to via `lecture_problem` doc_ids. "
"Top topics for a date are taken from the broader retrieval "
"pack (syntopicon entries surfaced alongside that lecture's "
"problems). One turn can land under multiple sessions if it "
"pulled from several lectures."
)
parts.append("")
parts.append("| Lecture date | Turns | Top topics | Intent breakdown | Example query |")
parts.append("|---|---|---|---|---|")
# Sort by date descending so most recent lecture appears first
for date in sorted(sessions.keys(), reverse=True):
stats = sessions[date]
top_topics = ", ".join(
f"{t} ({n})" for t, n in
sorted(stats["topics"].items(), key=lambda kv: -kv[1])[:3]
) or "β€”"
intents_breakdown = ", ".join(
f"{i} {n}" for i, n in
sorted(stats["intents"].items(), key=lambda kv: -kv[1])
)
sample = stats["sample_query"].replace("|", "\\|")
parts.append(
f"| {date} | {stats['turn_count']} | {top_topics} | "
f"{intents_breakdown} | {sample} |"
)
if unmapped:
parts.append("")
parts.append(
f"_{unmapped} turn(s) had no retrieval that linked to a "
"dated lecture session β€” typically pure-syntopicon hits without "
"lecture references, or generic slide retrieval._"
)
elif unmapped:
parts.append(
f"(No turns mapped to lecture sessions. {unmapped} turn(s) had "
"retrieval that didn't link to any dated lecture.)"
)
else:
parts.append("(No turns logged.)")
parts.append("")
# 7.5 Chat sessions (one row per conversation)
parts.append("## Chat sessions")
chats = chat_sessions(path, since=since)
if chats:
parts.append(
"One row per student chat. The opener is the first message β€” "
"usually what the student walked in with. Topics are aggregated "
"syntopicon hits across the whole chat."
)
parts.append("")
parts.append("| Started | Turns | Opener | Top topics | Intents | Final message |")
parts.append("|---|---|---|---|---|---|")
for chat in chats:
opener = chat["opener"][:80].replace("|", "\\|")
last = chat["last_user_message"][:60].replace("|", "\\|")
top_topics = ", ".join(
t for t, _ in
sorted(chat["topics"].items(), key=lambda kv: -kv[1])[:3]
) or "β€”"
intents = ", ".join(
f"{i} {n}" for i, n in
sorted(chat["intents"].items(), key=lambda kv: -kv[1])
)
parts.append(
f"| {chat['started_at']} | {chat['turn_count']} | "
f"{opener} | {top_topics} | {intents} | {last} |"
)
else:
parts.append("(No chat sessions in this period.)")
parts.append("")
# 8. Suggested actions
parts.append("## Suggested actions")
suggestions: list[str] = []
if summary and summary.get("voice", 5) < 3:
suggestions.append("- **Voice scores are low.** Review prompt fragments for AI-tutor tells.")
if summary and summary.get("substance", 5) < 3:
suggestions.append("- **Substance scores are low.** Tighten Socratic-default; engage on `explain` intents.")
if util:
unused = [d for d, s in util.items() if s["retrieved_count"] >= 3 and s["util_pct"] < 20]
if unused:
suggestions.append(f"- **{len(unused)} docs are retrieved often but rarely used.** Consider tuning quotas or pruning the catalog: {unused[:5]}")
if mc:
missed = [(mid, s) for mid, s in mc.items() if s["missed"] > 0]
if missed:
suggestions.append(
f"- **{len(missed)} misconception(s) appeared in student "
f"queries and were NOT addressed in the response.** "
f"Faculty may want to tighten the prompt's displacement "
f"language: {[m[0] for m in missed[:5]]}"
)
if Path(core_meta_path).is_file():
try:
meta = _json.loads(Path(core_meta_path).read_text(encoding="utf-8"))
ts = meta.get("build_timestamp", "")
built = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(
tzinfo=timezone.utc)
age_days = (datetime.now(timezone.utc) - built).days
if age_days >= 14:
suggestions.append(
f"- **Session core is stale ({age_days} days old).** "
"Re-run the nightly builder."
)
except (OSError, ValueError, _json.JSONDecodeError):
pass
if not suggestions:
suggestions.append("- (No actionable patterns detected in this period.)")
parts.extend(suggestions)
parts.append("")
return "\n".join(parts)