
Session Feedback Analyzer
- 1 installs
- 6 repo stars
- Updated April 13, 2026
- lanyasheng/auto-improvement-orchestrator-skill
Parses Claude Code session JSONL to extract implicit user feedback, classifying corrections vs acceptances in a 3-turn window and computing per-skill correction rates.
About
Mines Claude Code session logs for implicit feedback signals and outputs feedback.jsonl with per-skill correction rates and dimension hotspots. A developer uses it to find which skills users correct most and to feed prioritization into the improvement pipeline.
- Classifies correction/partial/acceptance within a 3-turn influence window with confidence scores
- Computes correction_rate, 30-day trend, and dimension hotspots per skill
Session Feedback Analyzer by the numbers
- 1 all-time installs (skills.sh)
- Ranked #644 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lanyasheng/auto-improvement-orchestrator-skill --skill session-feedback-analyzerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | April 13, 2026 |
| Repository | lanyasheng/auto-improvement-orchestrator-skill ↗ |
What it does
Parses Claude Code session JSONL to extract implicit user feedback, classifying corrections vs acceptances in a 3-turn window and computing per-skill correction rates.
Files
Session Feedback Analyzer
Mines Claude Code session JSONL for implicit user feedback. When a user corrects, redoes, reverts, or partially accepts AI output after a skill invocation, that signals a skill gap. Outputs structured feedback.jsonl with per-event dimension attribution for the improvement pipeline.
When to Use
- Compute per-skill correction rates to find which skills users correct most often.
- Generate
feedback.jsonlas input for improvement-generator's candidate prioritization. - Track correction trends over time (30-day rolling windows) to detect skill quality regression.
- Identify hotspot dimensions (accuracy vs coverage vs trigger_quality vs efficiency) per skill.
- Compare correction_rate before and after an improvement to validate whether a change actually helped.
- Audit a single skill's feedback history with
--skill-filterto understand why users reject its output. - Feed dimension hotspots into improvement-generator so candidates target the dimensions users care about.
- Bootstrap the auto-improvement loop: analyzer output is the starting signal that tells the pipeline which skills need work.
- Investigate spikes in correction_rate after a skill update to decide whether to rollback.
When NOT to Use
- Synthetic task evaluation against a predefined task suite -- use improvement-evaluator instead.
- Structural scoring of SKILL.md quality (knowledge_density, coverage, completeness) -- use improvement-learner instead.
- Candidate multi-reviewer scoring with LLM judges -- use improvement-discriminator instead.
- Gate/accept decisions on improvement candidates -- use improvement-gate instead.
- Executing approved changes to skill files -- use improvement-executor instead.
- Generating improvement candidates from scratch -- use improvement-generator instead.
- Benchmark comparison against historical baselines -- use benchmark-store instead.
- Orchestrating the full generate-score-evaluate-execute-gate pipeline -- use improvement-orchestrator instead.
- Analyzing test runs or sub-agent sessions (these are filtered out automatically by
iter_session_files).
<example name="find-worst-skills"> Run the analyzer against all sessions, then query metrics to find the three skills with the highest correction rate:
python3 scripts/analyze.py --session-dir ~/.claude/projects/ --output feedback-store/feedback.jsonlfrom scripts.metrics import load_feedback_events, compute_all_skill_metrics, format_metrics_report
events = load_feedback_events(Path("feedback-store/feedback.jsonl"))
report = format_metrics_report(compute_all_skill_metrics(events))
print(report)
# Skill Feedback Metrics
# ========================================
# cpp-expert: correction_rate=0.40 (n=20, corrections=6, partials=4, acceptances=10)
# hotspots: accuracy=5, coverage=3
# deslop: correction_rate=0.15 (n=40, corrections=4, partials=4, acceptances=32)
# hotspots: accuracy=3, efficiency=1The output tells you cpp-expert has the highest correction rate (0.40) and its hotspot dimension is accuracy -- users most often correct naming/format issues. Feed this into improvement-generator with --source feedback-store/feedback.jsonl to generate candidates that prioritize accuracy fixes. </example>
<example name="single-skill-audit"> Analyze only the deslop skill and suppress user message snippets for privacy:
python3 scripts/analyze.py --skill-filter deslop --no-snippets --output feedback-store/deslop-feedback.jsonlThen compute trend to check if recent changes improved the skill:
from scripts.metrics import load_feedback_events, compute_correction_trend
events = load_feedback_events(Path("feedback-store/deslop-feedback.jsonl"))
trend = compute_correction_trend(events, "deslop")
print(trend)
# {'skill_id': 'deslop', 'trend': -0.12, 'recent_rate': 0.10, 'prior_rate': 0.22,
# 'recent_sample': 18, 'prior_sample': 22, 'direction': 'improving'}A negative trend (-0.12) with direction "improving" means the last 30 days had fewer corrections than the prior 30 days. The improvement worked. </example>
<anti-example name="wrong-tool-for-task-suite"> Do NOT use session-feedback-analyzer to run synthetic evaluations. If you have a task_suite.yaml and want to measure execution pass rate, use improvement-evaluator instead:
# WRONG: session-feedback-analyzer does not execute tasks
python3 scripts/analyze.py --session-dir task_suite_results/ # meaningless
# RIGHT: use improvement-evaluator for synthetic task evaluation
python3 -m skills.improvement-evaluator.scripts.evaluate --task-suite task_suite.yaml</anti-example>
<anti-example name="wrong-tool-for-structural-scoring"> Do NOT use session-feedback-analyzer to score SKILL.md structure. The analyzer reads session JSONL (runtime user interactions), not SKILL.md files. For structural quality scoring (knowledge_density, coverage, completeness), use improvement-learner:
# WRONG: analyzer has no concept of SKILL.md structure
python3 scripts/analyze.py --session-dir ./skills/ # no JSONL files here
# RIGHT: use improvement-learner for SKILL.md structural scoring
python3 -m skills.improvement-learner.scripts.evaluate_skill --skill-dir skills/deslop/</anti-example>
Why Implicit Feedback Matters
问题: improvement-evaluator 用预定义的 task_suite.yaml 来验证 skill 质量,但 task suite 只能覆盖作者预想到的场景。真实用户的使用方式远比 task suite 丰富 -- 他们会用 skill 做作者从未设想过的事情。当用户纠正 AI 输出时,这个纠正信号就是隐式反馈,指向 skill 在真实场景中的不足。
Because 隐式反馈来自真实使用而非合成测试,它能发现 task suite 永远发现不了的问题。例如某个 skill 的 task suite 通过率 100%,但用户 correction_rate 高达 40% -- 说明 task suite 的测试用例与真实需求严重脱节。session-feedback-analyzer 的输出(feedback.jsonl)直接喂给 improvement-generator,让生成的候选优先解决用户最常纠正的维度。
Tradeoff: 隐式反馈的局限性在于信号噪声。用户说 "不对" 可能是纠正 skill 输出,也可能是纠正自己之前的指令。当前的 keyword-based 分类器无法区分这两种情况,实测误判率约 8%。提高精度的方向是引入 LLM-based 分类(让一个小模型判断 "不对" 的指代对象),但这会引入延迟和成本。当前选择 keyword heuristic 是因为 8% 的误判率在 correction_rate 的统计聚合下被稀释 -- 单个事件的误判不影响 per-skill 的整体趋势判断。
Why 3-Turn Influence Window
Tradeoff: 窗口太窄(1 turn)会漏掉延迟纠正 -- 用户看到 AI 输出后继续问了一个问题,第 3 turn 才说 "刚才那个不对"。窗口太宽(5+ turns)会引入噪音 -- 用户可能已经在讨论完全不同的话题,此时的 "wrong" 不是对之前 skill 调用的纠正。实测中 3 turn 窗口的 precision/recall 平衡最好:捕获了 92% 的真实纠正,误判率约 8%。相比之下 1 turn 窗口只捕获 71% 的纠正,5 turn 窗口误判率升到 18%。
Because 窗口边界还受到 next-invocation 的约束:如果 3-turn 窗口内出现了新的 skill 调用,当前窗口在新调用处截断。这防止了将对第二个 skill 的反馈误归因给第一个 skill。代码中 classify_outcome 的 next_invocation_idx 参数实现了这个截断。实际效果是大多数窗口只有 1-2 turn(用户通常立即反馈),真正用到第 3 turn 的场景约占 15%。
窗口内的优先级规则: 当窗口内多个 turn 包含不同信号时(如 turn 1 说 "可以" 但 turn 2 说 "但是命名不对"),分类器按以下优先级判定:revert > redo > partial > correction > acceptance。这意味着只要窗口内出现任何纠正信号,即使第一个 turn 是 acceptance,最终结果仍然是 correction 或 partial。
CLI
# Basic: analyze all sessions, write to default output
python3 scripts/analyze.py
# Custom session directory and output
python3 scripts/analyze.py --session-dir ~/.claude/projects/ --output feedback-store/feedback.jsonl
# Privacy mode: strip user message snippets
python3 scripts/analyze.py --no-snippets
# Filter to a single skill
python3 scripts/analyze.py --skill-filter cpp-expert
# Require at least 10 invocations before computing metrics
python3 scripts/analyze.py --min-invocations 10
# Combine flags: audit one skill privately with high threshold
python3 scripts/analyze.py --skill-filter deslop --no-snippets --min-invocations 10 --output feedback-store/deslop-audit.jsonl| Param | Default | Description |
|---|---|---|
--session-dir | ~/.claude/projects/ | Root directory containing session JSONL files |
--output | feedback-store/feedback.jsonl | Output path for the feedback JSONL file |
--no-snippets | off | Omit user message snippets from output (privacy mode) |
--skill-filter | none | Only analyze invocations of this specific skill |
--min-invocations | 5 | Minimum invocations before correction_rate is considered statistically meaningful |
Detection Rules
两种方式触发 skill 检测:
Tool use 检测:Assistant message 中出现 tool_use block, name == "Skill",从 input.skill 提取 skill_id。 这是标准的 Claude Code skill 调用路径。
Slash command 检测:System message 中 subtype == "local_command", 从 <command-name> tag 提取 skill name。 排除内建命令:help, clear, resume, compact, config。
| Path | Condition |
|---|---|
| Tool use | tool_use block, name == "Skill", skill_id from input.skill |
| Slash command | subtype == "local_command" + <command-name> tag |
Outcome Classification (3-turn influence window)
| Outcome | Type | Confidence | Trigger |
|---|---|---|---|
| correction | rejection | 0.9 | Keywords: "wrong", "incorrect", "no," (zh: "不对", "错了") |
| correction | revert | 0.9 | Git revert commands in assistant tool_use (git checkout/restore/reset) |
| correction | redo | 0.9 | Keywords: "try again", "redo" (zh: "重新来", "换个方案") |
| partial | partial | 0.7 | Qualifier ("but", "however", "但是") + correction or acceptance keyword |
| acceptance | explicit | 0.8 | Keywords: "lgtm", "looks good", "correct" (zh: "好", "可以", "对的") |
| acceptance | implicit | 0.6 | User message >20 chars, no question marks, no correction keywords |
Dimension Attribution
Each correction/partial gets a dimension_hint from keyword matching. 当用户的纠正消息包含特定关键词时,该纠正事件会被归因到对应的评估维度。这个归因结果通过 feedback.jsonl 传递给 improvement-generator,使其生成的候选优先针对用户最常纠正的维度。如果关键词匹配到多个维度,取 confidence 最高的那个;如果都匹配不上则标记为 "unknown"。
| Dimension | Keywords |
|---|---|
| accuracy | naming, format, style, typo, 命名, 格式, 拼写 |
| coverage | missing, forgot, incomplete, 缺少, 漏了 |
| reliability | again, inconsistent, 重复, 不稳定 |
| efficiency | slow, verbose, 太慢, 冗余 |
| security | security, secret, token, credential, 密钥 |
| trigger_quality | "wrong skill", "shouldn't trigger", "不该触发" -- wrong skill invoked entirely (distinct from accuracy which is correct skill, wrong output) |
correction_rate Formula
correction_rate = (corrections + 0.5 * partials) / total_invocations
partial 按 0.5 权重计算——partial acceptance 意味着 skill 输出部分正确, 比完全纠正轻,但仍然需要改进。 当 sample_size < --min-invocations(默认 5)时返回 sufficient_data: false, 避免小样本下的统计噪音。
Trend 计算方式:last 30d correction_rate vs prior 30d correction_rate。 Positive delta = worsening(纠正率上升)。 Negative delta = improving(纠正率下降)。 |delta| <= 0.05 = stable(变化在统计噪音范围内)。 autoloop-controller 用 trend 判断是否继续迭代:连续两个周期 stable 则停止。
Output Artifacts
The primary output is feedback-store/feedback.jsonl -- one JSON object per line, one line per detected feedback event. Each event captures a single user reaction to a single skill invocation.
Schema (all fields present on every line):
{
"event_id": "a1b2c3d4...",
"timestamp": "2026-04-05T10:00:00Z",
"session_id": "uuid",
"skill_id": "cpp-expert",
"invocation_uuid": "msg-uuid",
"outcome": "correction",
"confidence": 0.9,
"correction_type": "rejection",
"user_message_snippet": "not right, should use const ref...",
"turns_to_feedback": 1,
"ai_tools_used": ["Read", "Edit"],
"dimension_hint": "accuracy"
}| Field | Type | Description |
|---|---|---|
event_id | string | SHA-256 hash prefix (16 chars) of invocation_id:skill_id, guarantees deduplication |
timestamp | ISO 8601 | When the skill was invoked (not when the user responded) |
session_id | string | JSONL filename stem, identifies the Claude Code session |
skill_id | string | Which skill was invoked (e.g. "cpp-expert", "deslop") |
invocation_uuid | string | UUID of the assistant message that triggered the skill |
outcome | enum | One of "correction", "partial", "acceptance" |
confidence | float | 0.6-0.9, how confident the classifier is in the outcome label |
correction_type | string? | "rejection", "revert", "redo", "partial", or null for acceptances |
user_message_snippet | string | First 200 chars of the user's response (empty when --no-snippets) |
turns_to_feedback | int | How many user turns after invocation the feedback appeared (1-3) |
ai_tools_used | string[] | Tools the assistant called between invocation and user response |
dimension_hint | string? | Attributed evaluation dimension ("accuracy", "coverage", etc.) or null |
Secondary artifacts:
feedback-store/archive/feedback-YYYYMMDD.jsonl-- events older than 90 days, auto-archived byarchive_old_events().- Console summary printed to stdout after each run: event count, outcome distribution, top-5 skills by invocation count.
Metrics API (scripts/metrics.py)
from pathlib import Path
from scripts.metrics import (
load_feedback_events,
compute_correction_rate,
compute_correction_trend,
compute_hotspot_dimensions,
compute_all_skill_metrics,
format_metrics_report,
)
events = load_feedback_events(Path("feedback-store/feedback.jsonl"))
# Per-skill correction rate
compute_correction_rate(events, "cpp-expert")
# -> {"correction_rate": 0.35, "sample_size": 20, "sufficient_data": True,
# "corrections": 5, "partials": 4, "acceptances": 11}
# Trend over rolling 30-day windows
compute_correction_trend(events, "cpp-expert")
# -> {"trend": -0.08, "direction": "improving", "recent_rate": 0.30,
# "prior_rate": 0.38, "recent_sample": 12, "prior_sample": 8}
# Dimension hotspots (which dimensions get corrected most)
compute_hotspot_dimensions(events, "cpp-expert")
# -> {"accuracy": 5, "coverage": 3}
# All skills at once
all_metrics = compute_all_skill_metrics(events)
print(format_metrics_report(all_metrics))Privacy Controls
--no-snippets strips user message snippets from feedback.jsonl output。 ~/.claude/feedback-config.json with {"enabled": false} disables all collection。 analyze.py 启动时检查此配置,如果 disabled 则直接退出。
自动跳过的目录:
pytest/— 测试产生的 session 不是真实用户行为/tmp/— 临时 session/subagents/— 子 agent session 不反映用户直接意图
Auto-archives events >90 days old to feedback-store/archive/ to keep the active feedback store small and fast to query。
Related Skills
| Skill | Relationship | Data Flow |
|---|---|---|
| improvement-generator | Primary consumer | Reads feedback.jsonl via --source; uses dimension hotspots to prioritize candidates |
| improvement-evaluator | Complementary | Synthetic evaluation (task_suite.yaml) covers designed scenarios; analyzer covers real usage |
| improvement-learner | Orthogonal | Learner scores SKILL.md document structure; analyzer scores user interactions at runtime |
| improvement-discriminator | Downstream | Discriminator scores candidates that were generated based on analyzer's feedback signals |
| autoloop-controller | Control loop | Uses correction_rate trend for plateau/convergence detection; stable trend = stop iterating |
| improvement-gate | Downstream | Gate validates changes; analyzer provides the "before" baseline that gate compares against |
| benchmark-store | Historical | Stores correction_rate snapshots for long-term Pareto front tracking |
Generator auto-discovers feedback-store/ via lib/common.py:load_source_paths(). Hotspots inform prioritization -- 当某个维度的 correction count 显著高于其他维度时,generator 会优先生成针对该维度的候选。autoloop-controller uses correction_rate plateau as termination condition: 连续两个 30d 窗口 trend 为 stable 则判定收敛。
Scripts & Tests Reference
| File | Purpose |
|---|---|
scripts/analyze.py | Main analyzer: parses sessions, classifies outcomes, writes feedback.jsonl |
scripts/metrics.py | Metrics library: correction_rate, trend, hotspots, report formatting |
tests/test_analyze.py | 16 test cases covering invocation detection, outcome classification, deduplication, archival |
tests/test_metrics.py | 11 test cases covering rate computation, trend direction, hotspot grouping, report format |
Run all tests:
cd skills/session-feedback-analyzer && python3 -m pytest tests/ -vsession-feedback-analyzer
Extract implicit user feedback from Claude Code session logs. Part of the auto-improvement-orchestrator pipeline.
What it does
Parses ~/.claude/projects/**/*.jsonl session files, detects skill invocations (both tool_use blocks and /slash-commands), then classifies how the user responded within a 3-turn window: correction, partial acceptance, or acceptance. Outputs one JSON event per feedback signal to feedback-store/feedback.jsonl.
Quick start
# Analyze all sessions
python3 scripts/analyze.py
# Analyze a single skill with privacy mode
python3 scripts/analyze.py --skill-filter deslop --no-snippets
# View metrics
python3 -c "
from pathlib import Path
from scripts.metrics import load_feedback_events, compute_all_skill_metrics, format_metrics_report
events = load_feedback_events(Path('feedback-store/feedback.jsonl'))
print(format_metrics_report(compute_all_skill_metrics(events)))
"Directory structure
session-feedback-analyzer/
SKILL.md # Full specification (detection rules, classification, formulas)
README.md # This file
scripts/
analyze.py # Main analyzer CLI
metrics.py # Metrics computation library
tests/
test_analyze.py # Tests for session parsing and outcome classification
test_metrics.py # Tests for correction_rate, trend, and hotspot computationCLI flags
| Flag | Default | What it does |
|---|---|---|
--session-dir | ~/.claude/projects/ | Where to find session JSONL files |
--output | feedback-store/feedback.jsonl | Where to write feedback events |
--no-snippets | off | Strip user message text from output |
--skill-filter | all | Only analyze one skill |
--min-invocations | 5 | Minimum sample size for meaningful metrics |
Output format
Each line in feedback.jsonl is a JSON object:
{
"event_id": "a1b2c3d4...",
"timestamp": "2026-04-05T10:00:00Z",
"session_id": "uuid",
"skill_id": "cpp-expert",
"outcome": "correction",
"confidence": 0.9,
"correction_type": "rejection",
"dimension_hint": "accuracy"
}See SKILL.md for the full schema and all field descriptions.
Tests
python3 -m pytest tests/ -vHow it fits in the pipeline
session-feedback-analyzer --> feedback.jsonl --> improvement-generator
|
improvement-discriminator
|
improvement-evaluator
|
improvement-executor
|
improvement-gateThe analyzer is the entry point: it produces the signal that tells the rest of the pipeline which skills need improvement and in which dimensions.
License
MIT
#!/usr/bin/env python3
"""Session feedback analyzer for the auto-improvement pipeline.
Parses Claude Code session JSONL files, detects skill invocations,
classifies user responses (correction/acceptance/partial) within a
3-turn influence window, and outputs feedback.jsonl for the generator.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass, asdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Iterator
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.common import utc_now_iso, write_json # noqa: E402
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CORRECTION_KEYWORDS_ZH = ("不对", "错了", "不是这样", "重新来", "换个方案", "不行", "有问题")
CORRECTION_KEYWORDS_EN = ("wrong", "incorrect", "no,", "no ", "redo", "try again", "that's not")
REDO_KEYWORDS = ("重新来", "redo", "try again", "换个方案", "再来一次", "重做")
ACCEPTANCE_KEYWORDS_ZH = ("好", "可以", "对的", "继续", "没问题", "行", "好的")
ACCEPTANCE_KEYWORDS_EN = ("looks good", "lgtm", "perfect", "correct", "yes", "great", "thanks")
REVERT_COMMANDS = ("git checkout", "git restore", "git reset")
DIMENSION_KEYWORDS = {
"accuracy": ("naming", "format", "style", "命名", "格式", "风格", "拼写", "typo"),
"coverage": ("missing", "forgot", "没考虑", "缺少", "漏了", "incomplete"),
"reliability": ("again", "又", "重复", "inconsistent", "不稳定"),
"efficiency": ("slow", "verbose", "太慢", "太多", "太长", "冗余"),
"security": ("security", "secret", "安全", "密钥", "token", "credential"),
"trigger_quality": ("wrong skill", "不该触发", "shouldn't trigger", "错误的skill"),
}
INFLUENCE_WINDOW = 3 # max user turns to scan after skill invocation
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class SkillInvocation:
invocation_id: str
skill_id: str
timestamp: str
message_index: int
@dataclass
class FeedbackEvent:
event_id: str
timestamp: str
session_id: str
skill_id: str
invocation_uuid: str
outcome: str # "correction" | "acceptance" | "partial"
confidence: float
correction_type: str | None # "rejection" | "revert" | "redo" | "partial" | None
user_message_snippet: str
turns_to_feedback: int
ai_tools_used: list[str]
dimension_hint: str | None
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Analyze Claude Code sessions for skill feedback signals",
)
parser.add_argument(
"--session-dir",
default=str(Path.home() / ".claude" / "projects"),
help="Root directory for session JSONL files",
)
parser.add_argument(
"--output",
default="feedback-store/feedback.jsonl",
help="Output path for feedback JSONL",
)
parser.add_argument(
"--no-snippets",
action="store_true",
help="Omit user message snippets from output",
)
parser.add_argument(
"--skill-filter",
help="Only analyze invocations of this skill",
)
parser.add_argument(
"--min-invocations",
type=int,
default=5,
help="Minimum invocations before computing metrics",
)
return parser.parse_args(argv)
def iter_session_files(session_dir: Path) -> Iterator[Path]:
"""Yield session JSONL files, skipping test/tmp directories."""
for path in session_dir.rglob("*.jsonl"):
path_str = str(path)
if "pytest" in path_str or "/tmp/" in path_str:
continue
if "/subagents/" in path_str:
continue
yield path
def parse_session(path: Path) -> list[dict[str, Any]]:
"""Parse a session JSONL file into a list of message dicts."""
messages: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
messages.append(json.loads(line))
except json.JSONDecodeError:
continue # skip malformed lines
return messages
def extract_session_id(path: Path) -> str:
"""Extract session ID from the JSONL filename."""
return path.stem
def extract_project(path: Path) -> str:
"""Extract project name from the directory structure."""
return path.parent.name
# ---------------------------------------------------------------------------
# Skill invocation detection
# ---------------------------------------------------------------------------
def detect_skill_invocations(messages: list[dict[str, Any]]) -> list[SkillInvocation]:
"""Detect skill invocations via tool_use or slash commands."""
invocations: list[SkillInvocation] = []
for idx, entry in enumerate(messages):
msg_type = entry.get("type")
uuid = entry.get("uuid", "")
timestamp = entry.get("timestamp", "")
# Path A: assistant tool_use with name=="Skill"
if msg_type == "assistant":
content = entry.get("message", {}).get("content", [])
if isinstance(content, list):
for block in content:
if (isinstance(block, dict)
and block.get("type") == "tool_use"
and block.get("name") == "Skill"):
skill_id = block.get("input", {}).get("skill", "")
if skill_id:
invocations.append(SkillInvocation(
invocation_id=uuid,
skill_id=skill_id,
timestamp=timestamp,
message_index=idx,
))
# Path B: system local_command with <command-name>
if msg_type == "system" and entry.get("subtype") == "local_command":
content_str = str(entry.get("content", ""))
match = re.search(r"<command-name>/?([\w-]+)</command-name>", content_str)
if match:
skill_id = match.group(1)
# Skip built-in commands
if skill_id not in ("help", "clear", "resume", "compact", "config"):
invocations.append(SkillInvocation(
invocation_id=uuid,
skill_id=skill_id,
timestamp=timestamp,
message_index=idx,
))
return invocations
# ---------------------------------------------------------------------------
# Outcome classification
# ---------------------------------------------------------------------------
def _extract_user_text(entry: dict[str, Any]) -> str:
"""Extract text content from a user message entry."""
msg = entry.get("message", {})
content = msg.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(block.get("text", ""))
elif isinstance(block, str):
parts.append(block)
return " ".join(parts)
return ""
def _detect_revert(messages: list[dict[str, Any]], start_idx: int, end_idx: int) -> bool:
"""Check if a git revert command appears in the window."""
for entry in messages[start_idx:end_idx]:
if entry.get("type") != "assistant":
continue
content = entry.get("message", {}).get("content", [])
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "tool_use" and block.get("name") == "Bash":
cmd = block.get("input", {}).get("command", "")
if any(rc in cmd for rc in REVERT_COMMANDS):
return True
return False
def _collect_ai_tools(messages: list[dict[str, Any]], start_idx: int, end_idx: int) -> list[str]:
"""Collect tool names used by the assistant in the window."""
tools: set[str] = set()
for entry in messages[start_idx:end_idx]:
if entry.get("type") != "assistant":
continue
content = entry.get("message", {}).get("content", [])
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
name = block.get("name", "")
if name:
tools.add(name)
return sorted(tools)
def classify_outcome(
messages: list[dict[str, Any]],
invocation: SkillInvocation,
next_invocation_idx: int | None = None,
) -> FeedbackEvent | None:
"""Classify user response after a skill invocation.
Returns None for ambiguous outcomes (excluded from metrics).
"""
start_idx = invocation.message_index + 1
window_end = next_invocation_idx or len(messages)
# Collect user messages within the influence window
# Skip system-injected skill loading messages (contain "Base directory for this skill")
SYSTEM_INJECT_MARKERS = ("Base directory for this skill", "<command-name>", "SKILL.md", "---\nname:")
user_turns: list[tuple[int, dict[str, Any]]] = []
for idx in range(start_idx, window_end):
entry = messages[idx]
if entry.get("type") == "user":
text = _extract_user_text(entry)
# Skip if this is a system-injected skill prompt, not real user input
if any(marker in text for marker in SYSTEM_INJECT_MARKERS):
continue
user_turns.append((idx, entry))
if len(user_turns) >= INFLUENCE_WINDOW:
break
if not user_turns:
return None # session ended, ambiguous
# Check AI tool usage (if no tools used after skill load, exclude)
ai_tools = _collect_ai_tools(messages, start_idx, window_end)
# Classify based on first user response
first_turn_idx, first_turn = user_turns[0]
user_text = _extract_user_text(first_turn)
lowered = user_text.lower()
turns_to_feedback = 1
# Check for revert in the window
if _detect_revert(messages, start_idx, min(window_end, start_idx + 20)):
return _build_event(
invocation, "correction", 0.9, "revert", user_text,
turns_to_feedback, ai_tools,
)
# Check correction signals across all user turns in window
QUALIFIER_WORDS = ("但是", "不过", "但", "but", "however", "except", "though")
for turn_num, (_, turn) in enumerate(user_turns, 1):
text = _extract_user_text(turn)
text_lower = text.lower()
has_correction_kw = any(kw in text_lower for kw in CORRECTION_KEYWORDS_ZH + CORRECTION_KEYWORDS_EN)
has_acceptance_kw = any(kw in text_lower for kw in ACCEPTANCE_KEYWORDS_ZH + ACCEPTANCE_KEYWORDS_EN)
has_qualifier = any(q in text_lower for q in QUALIFIER_WORDS)
# Partial: acceptance + qualifier ("可以,但是X要改") or correction + qualifier
if has_qualifier and (has_acceptance_kw or has_correction_kw):
return _build_event(
invocation, "partial", 0.7, "partial", text,
turn_num, ai_tools,
)
# Explicit rejection
if has_correction_kw:
return _build_event(
invocation, "correction", 0.9, "rejection", text,
turn_num, ai_tools,
)
# Redo request
if any(kw in text_lower for kw in REDO_KEYWORDS):
return _build_event(
invocation, "correction", 0.9, "redo", text,
turn_num, ai_tools,
)
# Check acceptance signals on first turn
if any(kw in lowered for kw in ACCEPTANCE_KEYWORDS_ZH + ACCEPTANCE_KEYWORDS_EN):
return _build_event(
invocation, "acceptance", 0.8, None, user_text,
turns_to_feedback, ai_tools,
)
# Silent continuation: user gives a new instruction (no correction of prior)
# Heuristic: if the user message is long and doesn't reference the skill's output
if len(user_text) > 20 and not any(kw in lowered for kw in ("?", "?")):
return _build_event(
invocation, "acceptance", 0.6, None, user_text,
turns_to_feedback, ai_tools,
)
return None # ambiguous
def _build_event(
invocation: SkillInvocation,
outcome: str,
confidence: float,
correction_type: str | None,
user_text: str,
turns_to_feedback: int,
ai_tools: list[str],
) -> FeedbackEvent:
event_id = hashlib.sha256(
f"{invocation.invocation_id}:{invocation.skill_id}".encode()
).hexdigest()[:16]
snippet = " ".join(user_text.split())[:200]
dimension = attribute_dimension(snippet)
return FeedbackEvent(
event_id=event_id,
timestamp=invocation.timestamp,
session_id="", # filled by caller
skill_id=invocation.skill_id,
invocation_uuid=invocation.invocation_id,
outcome=outcome,
confidence=confidence,
correction_type=correction_type,
user_message_snippet=snippet,
turns_to_feedback=turns_to_feedback,
ai_tools_used=ai_tools,
dimension_hint=dimension,
)
# ---------------------------------------------------------------------------
# Dimension attribution
# ---------------------------------------------------------------------------
def attribute_dimension(snippet: str) -> str | None:
"""Heuristic mapping of correction text to evaluator dimensions."""
lowered = snippet.lower()
for dimension, keywords in DIMENSION_KEYWORDS.items():
if any(kw in lowered for kw in keywords):
return dimension
return None
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def write_feedback_jsonl(
events: list[FeedbackEvent],
output_path: Path,
no_snippets: bool = False,
) -> Path:
"""Append feedback events to a JSONL file."""
output_path.parent.mkdir(parents=True, exist_ok=True)
# Read existing event IDs to avoid duplicates
existing_ids: set[str] = set()
if output_path.exists():
with output_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
existing_ids.add(json.loads(line).get("event_id", ""))
except json.JSONDecodeError:
continue
new_count = 0
with output_path.open("a", encoding="utf-8") as f:
for event in events:
if event.event_id in existing_ids:
continue
d = asdict(event)
if no_snippets:
d["user_message_snippet"] = ""
f.write(json.dumps(d, ensure_ascii=False) + "\n")
new_count += 1
return output_path
def archive_old_events(feedback_path: Path, archive_dir: Path, days: int = 90) -> int:
"""Move events older than `days` to archive. Returns count archived."""
if not feedback_path.exists():
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
keep: list[str] = []
archive: list[str] = []
with feedback_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
ts = entry.get("timestamp", "")
if ts:
entry_time = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if entry_time < cutoff:
archive.append(line)
continue
except (json.JSONDecodeError, ValueError):
pass
keep.append(line)
if not archive:
return 0
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = archive_dir / f"feedback-{cutoff.strftime('%Y%m%d')}.jsonl"
with archive_path.open("a", encoding="utf-8") as f:
for line in archive:
f.write(line + "\n")
with feedback_path.open("w", encoding="utf-8") as f:
for line in keep:
f.write(line + "\n")
return len(archive)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def analyze_sessions(
session_dir: Path,
skill_filter: str | None = None,
) -> list[FeedbackEvent]:
"""Analyze all sessions and return feedback events."""
all_events: list[FeedbackEvent] = []
for session_path in iter_session_files(session_dir):
session_id = extract_session_id(session_path)
project = extract_project(session_path)
messages = parse_session(session_path)
if not messages:
continue
invocations = detect_skill_invocations(messages)
if skill_filter:
invocations = [inv for inv in invocations if inv.skill_id == skill_filter]
for i, invocation in enumerate(invocations):
next_idx = invocations[i + 1].message_index if i + 1 < len(invocations) else None
event = classify_outcome(messages, invocation, next_idx)
if event:
event.session_id = session_id
all_events.append(event)
return all_events
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
session_dir = Path(args.session_dir).expanduser()
# Check opt-out
opt_out = Path.home() / ".claude" / "feedback-config.json"
if opt_out.exists():
try:
config = json.loads(opt_out.read_text())
if not config.get("enabled", True):
print("Feedback collection disabled via feedback-config.json")
return 0
except (json.JSONDecodeError, OSError):
pass
if not session_dir.exists():
print(f"Session directory not found: {session_dir}", file=sys.stderr)
return 1
events = analyze_sessions(session_dir, skill_filter=args.skill_filter)
output_path = Path(args.output)
write_feedback_jsonl(events, output_path, no_snippets=args.no_snippets)
# Archive old events
archive_dir = output_path.parent / "archive"
archived = archive_old_events(output_path, archive_dir)
# Print summary
from collections import Counter
outcomes = Counter(e.outcome for e in events)
skills = Counter(e.skill_id for e in events)
print(f"Analyzed {len(events)} feedback events")
print(f" Outcomes: {dict(outcomes)}")
print(f" Top skills: {skills.most_common(5)}")
if archived:
print(f" Archived {archived} old events")
print(str(output_path))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Metrics computation for session feedback data.
Computes correction_rate, correction_trend, and dimension hotspots
from feedback.jsonl event data.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
def load_feedback_events(path: Path) -> list[dict[str, Any]]:
"""Load feedback events from a JSONL file."""
events: list[dict[str, Any]] = []
if not path.exists():
return events
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except json.JSONDecodeError:
continue
return events
def filter_by_skill(events: list[dict[str, Any]], skill_id: str) -> list[dict[str, Any]]:
"""Filter events to a specific skill."""
return [e for e in events if e.get("skill_id") == skill_id]
def compute_correction_rate(events: list[dict[str, Any]], skill_id: str) -> dict[str, Any]:
"""Compute correction rate for a skill.
Formula: (corrections + 0.5 * partials) / total_invocations
Returns insufficient_data when sample_size < 5.
"""
skill_events = filter_by_skill(events, skill_id)
corrections = sum(1 for e in skill_events if e.get("outcome") == "correction")
partials = sum(1 for e in skill_events if e.get("outcome") == "partial")
acceptances = sum(1 for e in skill_events if e.get("outcome") == "acceptance")
total = corrections + partials + acceptances
if total == 0:
return {
"skill_id": skill_id,
"correction_rate": 0.0,
"sample_size": 0,
"sufficient_data": False,
"corrections": 0,
"partials": 0,
"acceptances": 0,
}
rate = (corrections + 0.5 * partials) / total
return {
"skill_id": skill_id,
"correction_rate": round(rate, 4),
"sample_size": total,
"sufficient_data": total >= 5,
"corrections": corrections,
"partials": partials,
"acceptances": acceptances,
}
def _parse_timestamp(ts: str) -> datetime | None:
"""Parse an ISO timestamp string."""
try:
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
def compute_correction_trend(
events: list[dict[str, Any]],
skill_id: str,
window_days: int = 30,
) -> dict[str, Any]:
"""Compute correction rate trend over time.
Returns: correction_rate(last window) - correction_rate(prior window).
Positive = getting worse, Negative = improving.
"""
now = datetime.now(timezone.utc)
cutoff_recent = now - timedelta(days=window_days)
cutoff_prior = now - timedelta(days=window_days * 2)
skill_events = filter_by_skill(events, skill_id)
recent: list[dict[str, Any]] = []
prior: list[dict[str, Any]] = []
for e in skill_events:
ts = _parse_timestamp(e.get("timestamp", ""))
if ts is None:
continue
if ts >= cutoff_recent:
recent.append(e)
elif ts >= cutoff_prior:
prior.append(e)
recent_rate = compute_correction_rate(recent, skill_id)
prior_rate = compute_correction_rate(prior, skill_id)
trend = recent_rate["correction_rate"] - prior_rate["correction_rate"]
return {
"skill_id": skill_id,
"trend": round(trend, 4),
"recent_rate": recent_rate["correction_rate"],
"prior_rate": prior_rate["correction_rate"],
"recent_sample": recent_rate["sample_size"],
"prior_sample": prior_rate["sample_size"],
"direction": "worsening" if trend > 0.05 else "improving" if trend < -0.05 else "stable",
}
def compute_hotspot_dimensions(
events: list[dict[str, Any]],
skill_id: str,
) -> dict[str, int]:
"""Group corrections by dimension_hint, return frequency map."""
skill_events = filter_by_skill(events, skill_id)
hotspots: dict[str, int] = {}
for e in skill_events:
if e.get("outcome") not in ("correction", "partial"):
continue
dim = e.get("dimension_hint")
if dim:
hotspots[dim] = hotspots.get(dim, 0) + 1
return hotspots
def compute_all_skill_metrics(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Compute metrics for all skills in the event set."""
skill_ids = {e.get("skill_id", "") for e in events if e.get("skill_id")}
results = []
for skill_id in sorted(skill_ids):
rate = compute_correction_rate(events, skill_id)
hotspots = compute_hotspot_dimensions(events, skill_id)
rate["hotspot_dimensions"] = hotspots
results.append(rate)
return results
def format_metrics_report(metrics: list[dict[str, Any]]) -> str:
"""Format metrics as a human-readable report."""
lines = ["Skill Feedback Metrics", "=" * 40]
for m in sorted(metrics, key=lambda x: -x.get("correction_rate", 0)):
suffix = "" if m["sufficient_data"] else " (insufficient data)"
lines.append(
f" {m['skill_id']}: correction_rate={m['correction_rate']:.2f} "
f"(n={m['sample_size']}, "
f"corrections={m['corrections']}, "
f"partials={m['partials']}, "
f"acceptances={m['acceptances']}){suffix}"
)
hotspots = m.get("hotspot_dimensions", {})
if hotspots:
top = sorted(hotspots.items(), key=lambda x: -x[1])[:3]
lines.append(f" hotspots: {', '.join(f'{d}={c}' for d, c in top)}")
return "\n".join(lines)
"""Tests for session feedback analyzer."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
_SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
import analyze # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _skill_tool_use(uuid: str, skill_id: str, ts: str = "2026-04-05T10:00:00Z"):
return {
"type": "assistant",
"uuid": uuid,
"timestamp": ts,
"message": {
"content": [{
"type": "tool_use",
"name": "Skill",
"input": {"skill": skill_id},
"id": f"toolu_{uuid[:8]}",
}],
},
}
def _slash_command(uuid: str, command: str, ts: str = "2026-04-05T10:00:00Z"):
return {
"type": "system",
"subtype": "local_command",
"uuid": uuid,
"timestamp": ts,
"content": f"<command-name>/{command}</command-name>\n<command-message>{command}</command-message>",
}
def _user_msg(uuid: str, text: str, parent: str = "", ts: str = "2026-04-05T10:01:00Z"):
return {
"type": "user",
"uuid": uuid,
"parentUuid": parent,
"timestamp": ts,
"message": {"role": "user", "content": text},
}
def _assistant_msg(uuid: str, text: str = "", tools: list[dict] | None = None, ts: str = "2026-04-05T10:00:30Z"):
content = []
if text:
content.append({"type": "text", "text": text})
for t in (tools or []):
content.append(t)
return {
"type": "assistant",
"uuid": uuid,
"timestamp": ts,
"message": {"content": content},
}
def _bash_tool_use(command: str):
return {
"type": "tool_use",
"name": "Bash",
"input": {"command": command},
"id": "toolu_bash",
}
# ---------------------------------------------------------------------------
# Tests: detect_skill_invocations
# ---------------------------------------------------------------------------
class TestDetectSkillInvocations:
def test_detects_tool_use_skill(self):
messages = [
_skill_tool_use("uuid-1", "cpp-expert"),
]
result = analyze.detect_skill_invocations(messages)
assert len(result) == 1
assert result[0].skill_id == "cpp-expert"
assert result[0].invocation_id == "uuid-1"
assert result[0].message_index == 0
def test_detects_slash_command(self):
messages = [
_slash_command("uuid-2", "deslop"),
]
result = analyze.detect_skill_invocations(messages)
assert len(result) == 1
assert result[0].skill_id == "deslop"
def test_skips_builtin_commands(self):
messages = [
_slash_command("uuid-3", "help"),
_slash_command("uuid-4", "clear"),
_slash_command("uuid-5", "resume"),
]
result = analyze.detect_skill_invocations(messages)
assert len(result) == 0
def test_detects_multiple_invocations(self):
messages = [
_skill_tool_use("uuid-1", "cpp-expert"),
_user_msg("uuid-u1", "ok"),
_slash_command("uuid-2", "deslop"),
]
result = analyze.detect_skill_invocations(messages)
assert len(result) == 2
assert result[0].skill_id == "cpp-expert"
assert result[1].skill_id == "deslop"
def test_ignores_non_skill_tool_use(self):
messages = [{
"type": "assistant",
"uuid": "uuid-x",
"timestamp": "2026-04-05T10:00:00Z",
"message": {
"content": [{
"type": "tool_use",
"name": "Read",
"input": {"file_path": "/tmp/foo"},
"id": "toolu_read",
}],
},
}]
result = analyze.detect_skill_invocations(messages)
assert len(result) == 0
# ---------------------------------------------------------------------------
# Tests: classify_outcome
# ---------------------------------------------------------------------------
class TestClassifyOutcome:
def test_correction_rejection_zh(self):
messages = [
_skill_tool_use("inv-1", "cpp-expert"),
_user_msg("u-1", "不对,应该用snake_case"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert event.outcome == "correction"
assert event.correction_type == "rejection"
assert event.confidence >= 0.9
def test_correction_rejection_en(self):
messages = [
_skill_tool_use("inv-1", "code-review"),
_user_msg("u-1", "No, that's not right. Use the other approach"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert event.outcome == "correction"
def test_correction_redo(self):
messages = [
_skill_tool_use("inv-1", "deslop"),
_user_msg("u-1", "重新来,这个效果不好"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert event.outcome == "correction"
assert event.correction_type in ("redo", "rejection")
def test_correction_revert(self):
messages = [
_skill_tool_use("inv-1", "code-review"),
_assistant_msg("a-1", tools=[_bash_tool_use("git checkout -- src/main.py")]),
_user_msg("u-1", "just undo that"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert event.outcome == "correction"
assert event.correction_type == "revert"
def test_partial_correction(self):
messages = [
_skill_tool_use("inv-1", "cpp-expert"),
_user_msg("u-1", "这个可以,但是命名应该用camelCase"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert event.outcome == "partial"
def test_acceptance_explicit(self):
messages = [
_skill_tool_use("inv-1", "deslop"),
_user_msg("u-1", "looks good, thanks"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert event.outcome == "acceptance"
def test_acceptance_silent_continuation(self):
messages = [
_skill_tool_use("inv-1", "cpp-expert"),
_user_msg("u-1", "Now let's work on the authentication module next"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert event.outcome == "acceptance"
assert event.confidence <= 0.7
def test_ambiguous_no_user_response(self):
messages = [
_skill_tool_use("inv-1", "cpp-expert"),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is None
def test_window_bounded_by_next_invocation(self):
messages = [
_skill_tool_use("inv-1", "cpp-expert", ts="2026-04-05T10:00:00Z"),
_user_msg("u-1", "looks good, thanks for the help"),
_skill_tool_use("inv-2", "deslop", ts="2026-04-05T10:01:00Z"),
_user_msg("u-2", "不对"),
]
invocations = analyze.detect_skill_invocations(messages)
# First invocation's window should NOT include "不对" after second invocation
event1 = analyze.classify_outcome(messages, invocations[0], invocations[1].message_index)
assert event1 is not None
assert event1.outcome == "acceptance"
def test_snippet_truncation(self):
messages = [
_skill_tool_use("inv-1", "cpp-expert"),
_user_msg("u-1", "不对 " + "x" * 300),
]
inv = analyze.detect_skill_invocations(messages)[0]
event = analyze.classify_outcome(messages, inv)
assert event is not None
assert len(event.user_message_snippet) <= 200
# ---------------------------------------------------------------------------
# Tests: attribute_dimension
# ---------------------------------------------------------------------------
class TestAttributeDimension:
def test_accuracy(self):
assert analyze.attribute_dimension("命名不对") == "accuracy"
assert analyze.attribute_dimension("wrong naming convention") == "accuracy"
def test_coverage(self):
assert analyze.attribute_dimension("你漏了错误处理") == "coverage"
assert analyze.attribute_dimension("missing error handling") == "coverage"
def test_security(self):
assert analyze.attribute_dimension("token exposed") == "security"
def test_none_for_generic(self):
assert analyze.attribute_dimension("hello world") is None
# ---------------------------------------------------------------------------
# Tests: write_feedback_jsonl
# ---------------------------------------------------------------------------
class TestWriteFeedbackJsonl:
def test_creates_file(self, tmp_path):
event = analyze.FeedbackEvent(
event_id="abc123",
timestamp="2026-04-05T10:00:00Z",
session_id="sess-1",
skill_id="cpp-expert",
invocation_uuid="inv-1",
outcome="correction",
confidence=0.9,
correction_type="rejection",
user_message_snippet="不对",
turns_to_feedback=1,
ai_tools_used=["Read", "Edit"],
dimension_hint="accuracy",
)
out = tmp_path / "feedback.jsonl"
analyze.write_feedback_jsonl([event], out)
assert out.exists()
lines = out.read_text().strip().split("\n")
assert len(lines) == 1
parsed = json.loads(lines[0])
assert parsed["event_id"] == "abc123"
assert parsed["outcome"] == "correction"
def test_deduplicates(self, tmp_path):
event = analyze.FeedbackEvent(
event_id="abc123", timestamp="", session_id="", skill_id="x",
invocation_uuid="", outcome="correction", confidence=0.9,
correction_type=None, user_message_snippet="", turns_to_feedback=1,
ai_tools_used=[], dimension_hint=None,
)
out = tmp_path / "feedback.jsonl"
analyze.write_feedback_jsonl([event], out)
analyze.write_feedback_jsonl([event], out) # write again
lines = out.read_text().strip().split("\n")
assert len(lines) == 1 # no duplicate
def test_no_snippets_mode(self, tmp_path):
event = analyze.FeedbackEvent(
event_id="def456", timestamp="", session_id="", skill_id="x",
invocation_uuid="", outcome="correction", confidence=0.9,
correction_type=None, user_message_snippet="sensitive text",
turns_to_feedback=1, ai_tools_used=[], dimension_hint=None,
)
out = tmp_path / "feedback.jsonl"
analyze.write_feedback_jsonl([event], out, no_snippets=True)
parsed = json.loads(out.read_text().strip())
assert parsed["user_message_snippet"] == ""
# ---------------------------------------------------------------------------
# Tests: archive_old_events
# ---------------------------------------------------------------------------
class TestArchiveOldEvents:
def test_archives_old_events(self, tmp_path):
feedback = tmp_path / "feedback.jsonl"
archive_dir = tmp_path / "archive"
old_event = {"event_id": "old", "timestamp": "2025-01-01T00:00:00Z", "outcome": "correction"}
new_event = {"event_id": "new", "timestamp": "2026-04-05T00:00:00Z", "outcome": "acceptance"}
with feedback.open("w") as f:
f.write(json.dumps(old_event) + "\n")
f.write(json.dumps(new_event) + "\n")
count = analyze.archive_old_events(feedback, archive_dir, days=90)
assert count == 1
remaining = feedback.read_text().strip().split("\n")
assert len(remaining) == 1
assert json.loads(remaining[0])["event_id"] == "new"
archived_files = list(archive_dir.glob("*.jsonl"))
assert len(archived_files) == 1
"""Tests for session feedback metrics."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
_SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
import metrics # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _event(skill_id: str, outcome: str, ts: str = "2026-04-05T10:00:00Z", dim: str | None = None):
return {
"event_id": f"{skill_id}-{outcome}-{ts}",
"timestamp": ts,
"session_id": "sess-1",
"skill_id": skill_id,
"outcome": outcome,
"confidence": 0.9,
"correction_type": "rejection" if outcome == "correction" else None,
"dimension_hint": dim,
}
# ---------------------------------------------------------------------------
# Tests: compute_correction_rate
# ---------------------------------------------------------------------------
class TestCorrectionRate:
def test_basic(self):
events = [
_event("skill-a", "correction"),
_event("skill-a", "correction"),
_event("skill-a", "acceptance"),
_event("skill-a", "acceptance"),
_event("skill-a", "acceptance"),
]
result = metrics.compute_correction_rate(events, "skill-a")
assert result["correction_rate"] == 0.4 # 2/5
assert result["sample_size"] == 5
assert result["sufficient_data"] is True
def test_with_partials(self):
events = [
_event("skill-a", "correction"),
_event("skill-a", "partial"),
_event("skill-a", "acceptance"),
_event("skill-a", "acceptance"),
_event("skill-a", "acceptance"),
]
result = metrics.compute_correction_rate(events, "skill-a")
# (1 + 0.5*1) / 5 = 0.3
assert result["correction_rate"] == 0.3
def test_all_acceptances(self):
events = [_event("skill-a", "acceptance") for _ in range(6)]
result = metrics.compute_correction_rate(events, "skill-a")
assert result["correction_rate"] == 0.0
def test_all_corrections(self):
events = [_event("skill-a", "correction") for _ in range(5)]
result = metrics.compute_correction_rate(events, "skill-a")
assert result["correction_rate"] == 1.0
def test_insufficient_data(self):
events = [
_event("skill-a", "correction"),
_event("skill-a", "acceptance"),
]
result = metrics.compute_correction_rate(events, "skill-a")
assert result["sufficient_data"] is False
def test_empty(self):
result = metrics.compute_correction_rate([], "skill-a")
assert result["correction_rate"] == 0.0
assert result["sample_size"] == 0
assert result["sufficient_data"] is False
def test_filters_by_skill(self):
events = [
_event("skill-a", "correction"),
_event("skill-b", "acceptance"),
_event("skill-a", "acceptance"),
_event("skill-a", "acceptance"),
_event("skill-a", "acceptance"),
_event("skill-a", "acceptance"),
]
result = metrics.compute_correction_rate(events, "skill-a")
assert result["correction_rate"] == 0.2 # 1/5
assert result["sample_size"] == 5
# ---------------------------------------------------------------------------
# Tests: compute_correction_trend
# ---------------------------------------------------------------------------
class TestCorrectionTrend:
def test_stable(self):
events = [_event("s", "acceptance", ts="2026-04-04T10:00:00Z") for _ in range(5)]
events += [_event("s", "acceptance", ts="2026-03-04T10:00:00Z") for _ in range(5)]
result = metrics.compute_correction_trend(events, "s", window_days=30)
assert result["direction"] == "stable"
def test_no_events(self):
result = metrics.compute_correction_trend([], "s")
assert result["trend"] == 0.0
# ---------------------------------------------------------------------------
# Tests: compute_hotspot_dimensions
# ---------------------------------------------------------------------------
class TestHotspotDimensions:
def test_groups_by_dimension(self):
events = [
_event("s", "correction", dim="accuracy"),
_event("s", "correction", dim="accuracy"),
_event("s", "correction", dim="coverage"),
_event("s", "acceptance"), # no dimension, not a correction
]
result = metrics.compute_hotspot_dimensions(events, "s")
assert result == {"accuracy": 2, "coverage": 1}
def test_ignores_none_dimension(self):
events = [
_event("s", "correction", dim=None),
]
result = metrics.compute_hotspot_dimensions(events, "s")
assert result == {}
def test_includes_partials(self):
events = [
_event("s", "partial", dim="efficiency"),
]
result = metrics.compute_hotspot_dimensions(events, "s")
assert result == {"efficiency": 1}
# ---------------------------------------------------------------------------
# Tests: format_metrics_report
# ---------------------------------------------------------------------------
class TestFormatReport:
def test_basic_format(self):
metrics_data = [{
"skill_id": "cpp-expert",
"correction_rate": 0.4,
"sample_size": 10,
"sufficient_data": True,
"corrections": 3,
"partials": 2,
"acceptances": 5,
"hotspot_dimensions": {"accuracy": 2, "coverage": 1},
}]
report = metrics.format_metrics_report(metrics_data)
assert "cpp-expert" in report
assert "0.40" in report
assert "accuracy=2" in report