mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-12 14:30:57 +01:00
The transcript analyzer behind /compile-candidate lived only in the working tree. Untracked meant unlinted — pre-commit only inspects files handed to it — so nothing had ever checked it, and it had been abandoned mid-refactor with every parse raising AttributeError: '_Accumulator' object has no attribute 'first_timestamp'. The accumulator had been moved onto the grouped records dataclasses, but three increment sites and parse_session still read flat attributes that no longer existed. parse_session now hands over acc.meta and acc.counts directly; only subagent_count comes from elsewhere, since no transcript line carries it. Two modules broke the 500-line limit, so they were split along seams that were already there. signatures.py takes the text-signature helpers — traces.py was importing them from parse.py, making parse an accidental hub, and now neither imports the other. stats.py takes the pure number and time helpers. parse.py 521 -> 458, detectors.py 559 -> 467. The structural lint findings were refactored, not configured away. _counter_candidates took eight arguments; the five naming which counter field to mine and at what thresholds became a frozen _CounterSpec, so both call sites are one line. _extract_skill carried in_span and a line budget as loop variables across thirteen branches; a _SpanBuffer owns that state now, leaving the walk at four. record() took twelve flat kwargs and now takes one override per record block, with session_meta() so a partial meta override keeps the standard timestamp — a bare SessionMeta(slug=...) would have nulled started_at and quietly changed the per-week arithmetic. All 36 call sites rewritten by an AST pass. tests/factories.py moved into conftest.py, where every other package here keeps shared builders and where name-tests-test does not object. 965 tests pass at 100% statement and branch coverage, every hook is green, and no suppression was added anywhere. `candidates` and `traces skill-finish` both run against the real corpus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
468 lines
15 KiB
Python
468 lines
15 KiB
Python
"""Pure heuristics turning session records into ranked automation candidates.
|
|
|
|
No LLM anywhere: the compile-worthiness score comes from turn composition and
|
|
cross-invocation command similarity, savings from measured token usage.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import hashlib
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.session_autopsy import stats
|
|
|
|
if TYPE_CHECKING:
|
|
from datetime import datetime
|
|
|
|
from python_pkg.session_autopsy.records import SessionRecord, SkillInvocation
|
|
|
|
MIN_SKILL_INVOCATIONS = 3
|
|
NGRAM_SIZES = range(3, 7)
|
|
NGRAM_MIN_OCCURRENCES = 8
|
|
NGRAM_MIN_SESSIONS = 4
|
|
NGRAM_MIN_DISTINCT_RATIO = 0.5
|
|
ERROR_MIN_OCCURRENCES = 10
|
|
ERROR_MIN_SESSIONS = 3
|
|
PROMPT_MIN_OCCURRENCES = 4
|
|
PROMPT_MIN_WORDS = 3
|
|
MAX_EVIDENCE_SESSIONS = 10
|
|
TREND_TOP_SESSIONS = 3
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _CounterSpec:
|
|
"""Which signature-counter field to mine, and the thresholds to mine it at."""
|
|
|
|
field_name: str
|
|
"""Attribute on ``record.obs`` holding the ``sig -> count`` map."""
|
|
kind: str
|
|
"""Candidate kind tag."""
|
|
min_occurrences: int
|
|
"""Total-count threshold."""
|
|
min_sessions: int
|
|
"""Distinct-session threshold (ORed with the occurrence threshold)."""
|
|
action: str
|
|
"""Suggested action text."""
|
|
|
|
|
|
_ERROR_SPEC = _CounterSpec(
|
|
field_name="error_signatures",
|
|
kind="err",
|
|
min_occurrences=ERROR_MIN_OCCURRENCES,
|
|
min_sessions=ERROR_MIN_SESSIONS,
|
|
action="fix once: eliminate the failure's root cause",
|
|
)
|
|
_PROMPT_SPEC = _CounterSpec(
|
|
field_name="typed_prompt_signatures",
|
|
kind="prompt",
|
|
min_occurrences=PROMPT_MIN_OCCURRENCES,
|
|
min_sessions=1,
|
|
action="promote to slash command or script",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Candidate:
|
|
"""One ranked automation opportunity."""
|
|
|
|
id: str
|
|
kind: str
|
|
title: str
|
|
sig: str
|
|
sessions: int
|
|
occurrences: int
|
|
avg_tokens: int
|
|
per_week: float
|
|
est_weekly_savings: int
|
|
score: float
|
|
action: str
|
|
session_ids: list[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
"""Serialize for ``candidates --json``.
|
|
|
|
Returns:
|
|
A JSON-compatible dict of all fields.
|
|
"""
|
|
return {
|
|
"id": self.id,
|
|
"kind": self.kind,
|
|
"title": self.title,
|
|
"sig": self.sig,
|
|
"sessions": self.sessions,
|
|
"occurrences": self.occurrences,
|
|
"avg_tokens": self.avg_tokens,
|
|
"per_week": round(self.per_week, 2),
|
|
"est_weekly_savings": self.est_weekly_savings,
|
|
"score": round(self.score, 3),
|
|
"action": self.action,
|
|
"session_ids": self.session_ids,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class TrendInfo:
|
|
"""Weekly cache-read-per-turn averages and their direction."""
|
|
|
|
weekly: list[tuple[str, int]] = field(default_factory=list)
|
|
slope_per_week: float = 0.0
|
|
top_sessions: list[tuple[str, int]] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class AnalysisResult:
|
|
"""Everything the report renders: ranked candidates and the trend."""
|
|
|
|
candidates: list[Candidate] = field(default_factory=list)
|
|
trend: TrendInfo = field(default_factory=TrendInfo)
|
|
|
|
|
|
def analyze(records: list[SessionRecord], now: datetime) -> AnalysisResult:
|
|
"""Run every detector and rank the merged candidates.
|
|
|
|
Args:
|
|
records: All stored session records.
|
|
now: Current UTC time (injected for determinism in tests).
|
|
|
|
Returns:
|
|
Ranked candidates and the context trend.
|
|
"""
|
|
per_turn_avg = stats.per_turn_avg(records)
|
|
candidates = [
|
|
*_skill_candidates(records, now),
|
|
*_ngram_candidates(records, now, per_turn_avg),
|
|
*_error_candidates(records, now, per_turn_avg),
|
|
*_prompt_candidates(records, now, per_turn_avg),
|
|
]
|
|
candidates.sort(key=lambda cand: (-cand.est_weekly_savings, cand.id))
|
|
return AnalysisResult(candidates=candidates, trend=_trend(records))
|
|
|
|
|
|
def _hex_id(text: str) -> str:
|
|
"""Derive the stable 8-hex candidate id suffix for a signature.
|
|
|
|
Args:
|
|
text: The normalized signature.
|
|
|
|
Returns:
|
|
First 8 hex chars of its SHA-256.
|
|
"""
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
|
|
|
|
|
|
def _skill_candidates(records: list[SessionRecord], now: datetime) -> list[Candidate]:
|
|
"""Rank every skill with enough invocations by measured spend.
|
|
|
|
No compile-vs-keep verdict is attempted here: real-corpus probing showed no
|
|
cheap scalar separates mechanical skills from judgment-heavy ones (a giant
|
|
Write turn hides among mechanical read turns). The score is published as
|
|
evidence; the verdict belongs to /compile-candidate reading real traces.
|
|
|
|
Args:
|
|
records: All session records.
|
|
now: Current UTC time.
|
|
|
|
Returns:
|
|
One candidate per skill with at least MIN_SKILL_INVOCATIONS uses.
|
|
"""
|
|
by_skill: dict[str, list[tuple[SessionRecord, SkillInvocation]]] = {}
|
|
for record in records:
|
|
for invocation in record.obs.skill_invocations:
|
|
by_skill.setdefault(invocation.name, []).append((record, invocation))
|
|
return [
|
|
_score_skill(name, pairs, now)
|
|
for name, pairs in sorted(by_skill.items())
|
|
if len(pairs) >= MIN_SKILL_INVOCATIONS
|
|
]
|
|
|
|
|
|
def _score_skill(
|
|
name: str,
|
|
pairs: list[tuple[SessionRecord, SkillInvocation]],
|
|
now: datetime,
|
|
) -> Candidate:
|
|
"""Build the candidate for one skill from all its invocations.
|
|
|
|
Args:
|
|
name: Skill name.
|
|
pairs: (session record, invocation) for every invocation.
|
|
now: Current UTC time.
|
|
|
|
Returns:
|
|
The scored candidate (score = determinism * similarity).
|
|
"""
|
|
determinism = stats.mean(
|
|
[
|
|
invocation.tool_only_turns / total
|
|
for _, invocation in pairs
|
|
if (total := invocation.tool_only_turns + invocation.text_turns) > 0
|
|
],
|
|
)
|
|
similarity = stats.mean_pairwise_similarity(
|
|
[invocation.bash_sig for _, invocation in pairs]
|
|
)
|
|
score = determinism * similarity
|
|
avg_tokens = int(
|
|
stats.mean(
|
|
[float(inv.tokens_output + inv.tokens_cache_write) for _, inv in pairs]
|
|
)
|
|
)
|
|
per_week = stats.per_week(
|
|
[stats.parse_timestamp(record.meta.started_at) for record, _ in pairs], now
|
|
)
|
|
session_ids = _recent_session_ids([record for record, _ in pairs])
|
|
action = f"review for compilation: /compile-candidate skill-{name}"
|
|
return Candidate(
|
|
id=f"skill-{name}",
|
|
kind="skill",
|
|
title=f"Skill '{name}' ({len(pairs)} invocations)",
|
|
sig=name,
|
|
sessions=len({record.session_id for record, _ in pairs}),
|
|
occurrences=len(pairs),
|
|
avg_tokens=avg_tokens,
|
|
per_week=per_week,
|
|
est_weekly_savings=int(avg_tokens * per_week),
|
|
score=score,
|
|
action=action,
|
|
session_ids=session_ids,
|
|
)
|
|
|
|
|
|
def _recent_session_ids(records: list[SessionRecord]) -> list[str]:
|
|
"""Most recent contributing session ids for the traces subcommand.
|
|
|
|
Args:
|
|
records: Contributing session records (may repeat).
|
|
|
|
Returns:
|
|
Up to :data:`MAX_EVIDENCE_SESSIONS` unique ids, newest first.
|
|
"""
|
|
unique: dict[str, str] = {}
|
|
for record in records:
|
|
unique[record.session_id] = record.meta.started_at or ""
|
|
ranked = sorted(unique.items(), key=lambda item: item[1], reverse=True)
|
|
return [session_id for session_id, _ in ranked[:MAX_EVIDENCE_SESSIONS]]
|
|
|
|
|
|
def _counter_candidates(
|
|
records: list[SessionRecord],
|
|
now: datetime,
|
|
per_turn_avg: int,
|
|
spec: _CounterSpec,
|
|
) -> list[Candidate]:
|
|
"""Shared aggregation for signature-counter fields (errors, prompts).
|
|
|
|
Args:
|
|
records: All session records.
|
|
now: Current UTC time.
|
|
per_turn_avg: Corpus-wide tokens per assistant turn.
|
|
spec: Which counter field to mine, and at what thresholds.
|
|
|
|
Returns:
|
|
Candidates for every signature crossing either threshold.
|
|
"""
|
|
totals: dict[str, int] = {}
|
|
contributors: dict[str, list[SessionRecord]] = {}
|
|
for record in records:
|
|
counter: dict[str, int] = getattr(record.obs, spec.field_name)
|
|
for sig, count in counter.items():
|
|
totals[sig] = totals.get(sig, 0) + count
|
|
contributors.setdefault(sig, []).append(record)
|
|
candidates = []
|
|
for sig, total in sorted(totals.items()):
|
|
sessions = contributors[sig]
|
|
if total < spec.min_occurrences and len(sessions) < spec.min_sessions:
|
|
continue
|
|
timestamps = [
|
|
stats.parse_timestamp(record.meta.started_at) for record in sessions
|
|
]
|
|
per_week = stats.per_week(timestamps, now) * (total / len(sessions))
|
|
candidates.append(
|
|
Candidate(
|
|
id=f"{spec.kind}-{_hex_id(sig)}",
|
|
kind=spec.kind,
|
|
title=sig[:70],
|
|
sig=sig,
|
|
sessions=len({record.session_id for record in sessions}),
|
|
occurrences=total,
|
|
avg_tokens=per_turn_avg,
|
|
per_week=per_week,
|
|
est_weekly_savings=int(per_turn_avg * per_week),
|
|
score=1.0,
|
|
action=spec.action,
|
|
session_ids=_recent_session_ids(sessions),
|
|
),
|
|
)
|
|
return candidates
|
|
|
|
|
|
def _error_candidates(
|
|
records: list[SessionRecord], now: datetime, per_turn_avg: int
|
|
) -> list[Candidate]:
|
|
"""Repeated error signatures worth a fix-once script.
|
|
|
|
Args:
|
|
records: All session records.
|
|
now: Current UTC time.
|
|
per_turn_avg: Corpus-wide tokens per assistant turn (crude cost proxy).
|
|
|
|
Returns:
|
|
Fix-once candidates.
|
|
"""
|
|
return _counter_candidates(records, now, per_turn_avg, _ERROR_SPEC)
|
|
|
|
|
|
def _prompt_candidates(
|
|
records: list[SessionRecord], now: datetime, per_turn_avg: int
|
|
) -> list[Candidate]:
|
|
"""Repeated typed prompts worth promoting to a command or script.
|
|
|
|
Args:
|
|
records: All session records.
|
|
now: Current UTC time.
|
|
per_turn_avg: Corpus-wide tokens per assistant turn.
|
|
|
|
Returns:
|
|
Prompt-promotion candidates (short generic prompts filtered out).
|
|
"""
|
|
raw = _counter_candidates(records, now, per_turn_avg, _PROMPT_SPEC)
|
|
return [
|
|
cand
|
|
for cand in raw
|
|
if len(cand.sig.split()) >= PROMPT_MIN_WORDS
|
|
and cand.occurrences >= PROMPT_MIN_OCCURRENCES
|
|
]
|
|
|
|
|
|
def _ngram_candidates(
|
|
records: list[SessionRecord], now: datetime, per_turn_avg: int
|
|
) -> list[Candidate]:
|
|
"""Repeated cross-session Bash command sequences outside skill spans.
|
|
|
|
Args:
|
|
records: All session records.
|
|
now: Current UTC time.
|
|
per_turn_avg: Corpus-wide tokens per assistant turn.
|
|
|
|
Returns:
|
|
Sequence candidates, longest surviving grams only.
|
|
"""
|
|
counts: dict[tuple[str, ...], int] = {}
|
|
contributors: dict[tuple[str, ...], list[SessionRecord]] = {}
|
|
for record in records:
|
|
seen_here: set[tuple[str, ...]] = set()
|
|
for sequence in record.obs.bash_sequences:
|
|
for size in NGRAM_SIZES:
|
|
for start in range(len(sequence) - size + 1):
|
|
gram = tuple(sequence[start : start + size])
|
|
counts[gram] = counts.get(gram, 0) + 1
|
|
if gram not in seen_here:
|
|
seen_here.add(gram)
|
|
contributors.setdefault(gram, []).append(record)
|
|
surviving = {
|
|
gram: count
|
|
for gram, count in counts.items()
|
|
if count >= NGRAM_MIN_OCCURRENCES
|
|
and len(contributors[gram]) >= NGRAM_MIN_SESSIONS
|
|
and len(set(gram)) / len(gram) >= NGRAM_MIN_DISTINCT_RATIO
|
|
}
|
|
top = _drop_subgrams(surviving)
|
|
candidates = []
|
|
for gram in sorted(top):
|
|
sessions = contributors[gram]
|
|
joined = " → ".join(gram)
|
|
occurrences = counts[gram]
|
|
per_week = stats.per_week(
|
|
[stats.parse_timestamp(record.meta.started_at) for record in sessions], now
|
|
)
|
|
candidates.append(
|
|
Candidate(
|
|
id=f"ngram-{_hex_id(joined)}",
|
|
kind="ngram",
|
|
title=joined[:70],
|
|
sig=joined,
|
|
sessions=len({record.session_id for record in sessions}),
|
|
occurrences=occurrences,
|
|
avg_tokens=per_turn_avg * len(gram),
|
|
per_week=per_week,
|
|
est_weekly_savings=int(per_turn_avg * len(gram) * per_week),
|
|
score=1.0,
|
|
action="script this sequence",
|
|
session_ids=_recent_session_ids(sessions),
|
|
),
|
|
)
|
|
return candidates
|
|
|
|
|
|
def _drop_subgrams(surviving: dict[tuple[str, ...], int]) -> set[tuple[str, ...]]:
|
|
"""Remove grams contained in a longer surviving gram.
|
|
|
|
Args:
|
|
surviving: Grams that crossed the frequency thresholds.
|
|
|
|
Returns:
|
|
The maximal grams only.
|
|
"""
|
|
grams = sorted(surviving, key=len, reverse=True)
|
|
kept: set[tuple[str, ...]] = set()
|
|
for gram in grams:
|
|
if not any(_contains(longer, gram) for longer in kept):
|
|
kept.add(gram)
|
|
return kept
|
|
|
|
|
|
def _contains(longer: tuple[str, ...], shorter: tuple[str, ...]) -> bool:
|
|
"""Whether ``shorter`` appears contiguously inside ``longer``.
|
|
|
|
Args:
|
|
longer: The candidate container gram.
|
|
shorter: The gram to look for.
|
|
|
|
Returns:
|
|
True when contained.
|
|
"""
|
|
if len(shorter) >= len(longer):
|
|
return False
|
|
return any(
|
|
longer[i : i + len(shorter)] == shorter
|
|
for i in range(len(longer) - len(shorter) + 1)
|
|
)
|
|
|
|
|
|
def _trend(records: list[SessionRecord]) -> TrendInfo:
|
|
"""Weekly cache-read-per-turn trend plus the heaviest sessions.
|
|
|
|
Args:
|
|
records: All session records.
|
|
|
|
Returns:
|
|
The populated trend info (empty when timestamps are missing).
|
|
"""
|
|
weekly_turns: dict[str, int] = {}
|
|
weekly_reads: dict[str, int] = {}
|
|
heaviest: list[tuple[str, int]] = []
|
|
for record in records:
|
|
stamp = stats.parse_timestamp(record.meta.started_at)
|
|
if stamp is None or record.counts.assistant_msgs == 0:
|
|
continue
|
|
week = f"{stamp.isocalendar().year}-W{stamp.isocalendar().week:02d}"
|
|
weekly_turns[week] = weekly_turns.get(week, 0) + record.counts.assistant_msgs
|
|
weekly_reads[week] = weekly_reads.get(week, 0) + record.tokens.cache_read
|
|
heaviest.append(
|
|
(
|
|
record.meta.slug or record.session_id,
|
|
record.tokens.cache_read // record.counts.assistant_msgs,
|
|
)
|
|
)
|
|
weekly = [
|
|
(week, weekly_reads[week] // weekly_turns[week])
|
|
for week in sorted(weekly_turns)
|
|
]
|
|
heaviest.sort(key=lambda item: -item[1])
|
|
return TrendInfo(
|
|
weekly=weekly,
|
|
slope_per_week=stats.slope([float(avg) for _, avg in weekly]),
|
|
top_sessions=heaviest[:TREND_TOP_SESSIONS],
|
|
)
|