headroom_3 / headroom /learn /analyzer.py
chopratejas's picture
fix(learn): pass explicit model in tests to avoid API key requirement
a6eefcc
Raw History Blame
14.3 kB
"""Session analysis via LLM β€” replaces all regex/heuristic analysis.
Pipeline: Scanner (events) β†’ Digest Builder β†’ LLM β†’ Recommendations
No regex patterns, no static lookback windows, no hardcoded heuristics.
A single LLM call understands the full conversation context and produces
structured recommendations for CLAUDE.md / MEMORY.md.
Supports any LLM provider via LiteLLM: Anthropic, OpenAI, Google, Bedrock,
Ollama, and 100+ others. Auto-detects the best available model from env vars.
"""
from __future__ import annotations
import json
import logging
import os
from .models import (
AnalysisResult,
ProjectInfo,
Recommendation,
RecommendationTarget,
SessionData,
SessionEvent,
ToolCall,
)
logger = logging.getLogger(__name__)
# Default models by provider (checked in order)
_MODEL_DEFAULTS: list[tuple[str, str]] = [
("ANTHROPIC_API_KEY", "claude-sonnet-4-6"),
("OPENAI_API_KEY", "gpt-4o"),
("GEMINI_API_KEY", "gemini/gemini-2.0-flash"),
]
_MAX_DIGEST_TOKENS = 80_000 # Budget for the digest (leave room for prompt + output)
def _detect_default_model() -> str:
"""Pick the best available model based on which API keys are set."""
for env_var, model in _MODEL_DEFAULTS:
if os.environ.get(env_var):
return model
raise RuntimeError(
"No LLM API key found. headroom learn needs one of:\n"
" export ANTHROPIC_API_KEY=sk-ant-... β†’ uses claude-sonnet-4-6\n"
" export OPENAI_API_KEY=sk-... β†’ uses gpt-4o\n"
" export GEMINI_API_KEY=... β†’ uses gemini-2.0-flash\n"
"Or specify a model directly: headroom learn --model <litellm-model-name>"
)
class SessionAnalyzer:
"""Analyzes session data via LLM to produce actionable recommendations.
Uses LiteLLM for provider-agnostic access to 100+ models.
Auto-detects the best available model from environment API keys.
"""
def __init__(self, model: str | None = None):
self.model = model
def analyze(self, project: ProjectInfo, sessions: list[SessionData]) -> AnalysisResult:
"""Analyze sessions and produce recommendations via LLM."""
all_calls = [tc for s in sessions for tc in s.tool_calls]
failed_calls = [tc for tc in all_calls if tc.is_error]
result = AnalysisResult(
project=project,
total_sessions=len(sessions),
total_calls=len(all_calls),
total_failures=len(failed_calls),
)
if not failed_calls and not any(s.events for s in sessions):
return result
# Build compact digest of all sessions
digest = _build_digest(project, sessions)
# Resolve model (auto-detect if not specified)
model = self.model or _detect_default_model()
# Call LLM for analysis
try:
raw = _call_llm(digest, model)
result.recommendations = _parse_llm_response(raw)
except Exception as e:
logger.warning("LLM analysis failed: %s", e)
# Return result with stats but no recommendations
return result
# =============================================================================
# Digest Builder β€” compact text representation of session events
# =============================================================================
def _build_digest(project: ProjectInfo, sessions: list[SessionData]) -> str:
"""Build a token-efficient text digest of all session events.
The digest includes:
- Project context
- Per-session summaries with condensed event streams
- Error outputs (truncated), success indicators, user messages
"""
lines: list[str] = []
# Project header
lines.append(f"Project: {project.name} ({project.project_path})")
total_calls = sum(len(s.tool_calls) for s in sessions)
total_failures = sum(s.failure_count for s in sessions)
total_tokens_in = sum(s.total_input_tokens for s in sessions)
total_tokens_out = sum(s.total_output_tokens for s in sessions)
lines.append(
f"Total: {len(sessions)} sessions, {total_calls} tool calls, "
f"{total_failures} failures ({total_failures / total_calls:.1%})"
if total_calls
else f"Total: {len(sessions)} sessions, 0 tool calls"
)
if total_tokens_in:
lines.append(f"Tokens used: {total_tokens_in:,} in / {total_tokens_out:,} out")
lines.append("")
# Budget tracking β€” stop adding events when we approach the limit
# Rough estimate: 4 chars per token
char_budget = _MAX_DIGEST_TOKENS * 4
chars_used = sum(len(ln) for ln in lines)
for session in sessions:
if chars_used > char_budget:
lines.append(
f"... (remaining {len(sessions) - sessions.index(session)} sessions truncated)"
)
break
session_header = (
f"=== Session {session.session_id[:12]} "
f"({len(session.tool_calls)} calls, {session.failure_count} failures"
)
if session.total_input_tokens:
session_header += f", {session.total_input_tokens:,} input tokens"
session_header += ") ==="
lines.append(session_header)
chars_used += len(session_header)
# Use events if available (richer context), fall back to tool_calls
if session.events:
for event in session.events:
if chars_used > char_budget:
lines.append(" ... (remaining events truncated)")
break
event_line = _format_event(event)
if event_line:
lines.append(event_line)
chars_used += len(event_line)
else:
for tc in session.tool_calls:
if chars_used > char_budget:
lines.append(" ... (remaining calls truncated)")
break
tc_line = _format_tool_call(tc)
lines.append(tc_line)
chars_used += len(tc_line)
lines.append("")
return "\n".join(lines)
def _format_event(event: SessionEvent) -> str | None:
"""Format a single event into a compact digest line."""
if event.type == "tool_call" and event.tool_call:
return _format_tool_call(event.tool_call)
if event.type == "user_message" and event.text.strip():
text = event.text.strip()[:300]
return f' [{event.msg_index}] USER: "{text}"'
if event.type == "interruption":
return f" [{event.msg_index}] INTERRUPTED: {event.text[:150]}"
if event.type == "agent_summary":
return (
f" [{event.msg_index}] SUBAGENT: {event.agent_tool_count} tool calls, "
f"{event.agent_tokens:,} tokens, {event.agent_duration_ms / 1000:.1f}s "
f'β€” prompt: "{event.agent_prompt[:100]}"'
)
return None
def _format_tool_call(tc: ToolCall) -> str:
"""Format a single tool call into a compact digest line."""
status = "ERROR" if tc.is_error else "OK"
error_cat = f"({tc.error_category.value})" if tc.is_error else ""
# Input summary
input_str = tc.input_summary[:120]
if tc.is_error:
# Include truncated error output for failures
output_preview = tc.output[:200].replace("\n", " ").strip()
return f" [{tc.msg_index}] {tc.name}: {input_str} β†’ {status}{error_cat}: {output_preview}"
else:
# Just indicate success with size
size = f"({tc.output_bytes} bytes)" if tc.output_bytes > 0 else ""
return f" [{tc.msg_index}] {tc.name}: {input_str} β†’ {status} {size}"
# =============================================================================
# LLM Call β€” Sonnet 4.6 with structured output
# =============================================================================
_SYSTEM_PROMPT = """\
You are an expert at analyzing coding agent sessions to extract actionable patterns.
You will receive a digest of tool call sessions from a coding agent (Claude Code, Codex, etc.).
Your job is to identify patterns that, if documented, would PREVENT TOKEN WASTE in future sessions.
Focus on:
1. **Environment rules** β€” what runtime commands work vs fail (e.g., "use uv run python, not python3")
2. **File structure facts** β€” known large files, correct paths, search scopes
3. **User preferences** β€” things the user corrected, rejected, or explicitly requested
4. **Failure patterns** β€” repeated failures that could be prevented with upfront knowledge
5. **Workflow rules** β€” subagent guidance, command execution preferences
6. **Token waste hotspots** β€” patterns that waste the most tokens (re-reads, wrong paths, retries)
Rules:
- Only include patterns with CLEAR evidence from the data (2+ occurrences or explicit user direction)
- Every recommendation must be specific and actionable (not "be careful" but "use X instead of Y")
- Estimate tokens saved per recommendation (how many tokens would be saved per session if this rule existed)
- Separate stable project facts (CONTEXT_FILE) from evolving preferences (MEMORY_FILE)
- CONTEXT_FILE rules go in CLAUDE.md/AGENTS.md β€” they are project-level, stable facts
- MEMORY_FILE rules go in MEMORY.md β€” they are session-level, evolving preferences
- Keep recommendations concise β€” each should be 1-3 lines of markdown
- Do NOT produce tautological rules (e.g., "use python3 not python3")
- Do NOT produce rules about things that only happened once (transient errors)
Return ONLY valid JSON matching this schema β€” no other text:
{
"context_file_rules": [
{
"section": "string β€” section heading (e.g., 'Environment', 'File Paths', 'Commands')",
"content": "string β€” markdown content, 1-3 bullet points",
"estimated_tokens_saved": "integer β€” tokens saved per session if rule existed",
"evidence_count": "integer β€” number of occurrences supporting this rule"
}
],
"memory_file_rules": [
{
"section": "string β€” section heading",
"content": "string β€” markdown content, 1-3 bullet points",
"estimated_tokens_saved": "integer",
"evidence_count": "integer"
}
]
}
"""
def _call_llm(digest: str, model: str) -> dict:
"""Call LLM with the session digest and return parsed JSON.
Uses LiteLLM for provider-agnostic access. The model string determines
the provider: "claude-*" β†’ Anthropic, "gpt-*" β†’ OpenAI, "gemini/*" β†’ Google, etc.
"""
import litellm
# Suppress LiteLLM's verbose logging
litellm.suppress_debug_info = True
# For Anthropic models, bypass ANTHROPIC_BASE_URL which may point to
# the user's local headroom proxy
api_base = None
if model.startswith("claude"):
api_base = "https://api.anthropic.com"
response = litellm.completion(
model=model,
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{
"role": "user",
"content": (
"Analyze these coding agent sessions and return JSON recommendations:\n\n"
+ digest
),
},
],
max_tokens=4096,
api_base=api_base,
)
# Extract text from response
text = response.choices[0].message.content or ""
# Parse JSON β€” handle both raw JSON and ```json fenced blocks
text = text.strip()
if text.startswith("```"):
lines = text.split("\n")
lines = [ln for ln in lines[1:] if not ln.strip().startswith("```")]
text = "\n".join(lines)
result: dict = json.loads(text)
return result
# =============================================================================
# Response Parser β€” LLM JSON β†’ Recommendation list
# =============================================================================
def _parse_llm_response(raw: dict) -> list[Recommendation]:
"""Convert LLM structured output into Recommendation objects."""
recommendations: list[Recommendation] = []
for rule in raw.get("context_file_rules", []):
if not isinstance(rule, dict):
continue
section = rule.get("section", "").strip()
content = rule.get("content", "").strip()
if not section or not content:
continue
recommendations.append(
Recommendation(
target=RecommendationTarget.CONTEXT_FILE,
section=section,
content=content,
confidence=0.9,
evidence_count=_safe_int(rule.get("evidence_count", 1)),
estimated_tokens_saved=_safe_int(rule.get("estimated_tokens_saved", 0)),
)
)
for rule in raw.get("memory_file_rules", []):
if not isinstance(rule, dict):
continue
section = rule.get("section", "").strip()
content = rule.get("content", "").strip()
if not section or not content:
continue
recommendations.append(
Recommendation(
target=RecommendationTarget.MEMORY_FILE,
section=section,
content=content,
confidence=0.7,
evidence_count=_safe_int(rule.get("evidence_count", 1)),
estimated_tokens_saved=_safe_int(rule.get("estimated_tokens_saved", 0)),
)
)
# Sort by estimated token savings
recommendations.sort(key=lambda r: r.estimated_tokens_saved, reverse=True)
return recommendations
def _safe_int(val: object) -> int:
"""Safely convert a value to int."""
if isinstance(val, int):
return val
if isinstance(val, (float, str)):
try:
return int(val)
except (ValueError, TypeError):
return 0
return 0
# =============================================================================
# Legacy compatibility alias
# =============================================================================
class FailureAnalyzer:
"""Legacy alias for SessionAnalyzer β€” used by existing CLI code."""
def __init__(self) -> None:
self._analyzer = SessionAnalyzer()
def analyze(self, project: ProjectInfo, sessions: list[SessionData]) -> AnalysisResult:
return self._analyzer.analyze(project, sessions)