agent-coach-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_coach_cli-0.1.0.dist-info/METADATA +242 -0
- agent_coach_cli-0.1.0.dist-info/RECORD +19 -0
- agent_coach_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_coach_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_coach_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_coach_cli-0.1.0.dist-info/top_level.txt +1 -0
- agentcoach/__init__.py +1 -0
- agentcoach/__main__.py +3 -0
- agentcoach/cli.py +254 -0
- agentcoach/collect.py +336 -0
- agentcoach/heuristics.py +134 -0
- agentcoach/i18n.py +88 -0
- agentcoach/judge.py +328 -0
- agentcoach/paths.py +24 -0
- agentcoach/redact.py +52 -0
- agentcoach/report.py +559 -0
- agentcoach/rubric/default.json +561 -0
- agentcoach/rubric.py +46 -0
- agentcoach/store.py +102 -0
agentcoach/report.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""Self-contained HTML report: one screen, four tabs, no CDN, opens from disk.
|
|
2
|
+
|
|
3
|
+
Python only serialises the data; the page builds the views (overview, coaching,
|
|
4
|
+
conversations, trend) with a small vanilla-JS app so nothing has to scroll the
|
|
5
|
+
whole page. Each pane scrolls on its own, and on phones the list/detail panes
|
|
6
|
+
stack with a back button.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize(runs: list[dict], rubric: dict) -> None:
|
|
15
|
+
"""Runs scored with an older rubric lack newer dimensions; show them as 'no data'."""
|
|
16
|
+
for r in runs:
|
|
17
|
+
dims = r.setdefault("dimensions", {})
|
|
18
|
+
for d in rubric["dimensions"]:
|
|
19
|
+
dims.setdefault(d["id"], {"score": None, "n": 0, "missed": 0})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _payload(runs: list[dict], rubric: dict) -> dict:
|
|
23
|
+
slim_runs = [{k: r.get(k) for k in ("created", "overall", "dimensions", "judge", "session_count", "sources")}
|
|
24
|
+
| {"gap_share": (r.get("misses") or {}).get("gap_share"),
|
|
25
|
+
"gaps_per_100_turns": (r.get("misses") or {}).get("gaps_per_100_turns")}
|
|
26
|
+
| {"rubric_version": r.get("rubric", {}).get("version", 1)} for r in runs]
|
|
27
|
+
cur = runs[-1]
|
|
28
|
+
return {
|
|
29
|
+
"rubric": {
|
|
30
|
+
"title": rubric["title"], "version": rubric.get("version", 1),
|
|
31
|
+
"sources": rubric["sources"],
|
|
32
|
+
"dimensions": [{k: d.get(k) for k in ("id", "name", "short", "summary", "guide", "example")}
|
|
33
|
+
for d in rubric["dimensions"]],
|
|
34
|
+
},
|
|
35
|
+
"runs": slim_runs,
|
|
36
|
+
"current": {k: cur.get(k) for k in ("created", "overall", "judge", "session_count", "sources", "sessions", "misses")},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render(runs: list[dict], rubric: dict) -> str:
|
|
41
|
+
_normalize(runs, rubric)
|
|
42
|
+
lang = rubric.get("lang", "ko")
|
|
43
|
+
data = json.dumps(_payload(runs, rubric), ensure_ascii=False).replace("</", "<\\/")
|
|
44
|
+
html = TEMPLATE.replace("{{html_lang}}", lang)
|
|
45
|
+
html = re.sub(r"\{\{(\w+)\}\}", lambda m: UI[m.group(1)].get(lang) or UI[m.group(1)]["en"], html)
|
|
46
|
+
return html.replace("/*__DATA__*/null", data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
UI = {
|
|
50
|
+
'you_wrote_m': {'ko': '<b>실제로 쓴 지시 · turn ${m.turn}</b>', 'en': '<b>What you wrote · turn ${m.turn}</b>'},
|
|
51
|
+
'title': {'ko': '<title>AI 코치 리포트</title>', 'en': '<title>agent-coach report</title>'},
|
|
52
|
+
'overall': {'ko': '<span class="muted small">종합</span>', 'en': '<span class="muted small">Overall</span>'},
|
|
53
|
+
'theme_btn': {'ko': 'title="밝게/어둡게"', 'en': 'title="Light / dark"'},
|
|
54
|
+
'tab_overview': {'ko': 'data-tab="overview">개요<', 'en': 'data-tab="overview">Overview<'},
|
|
55
|
+
'tab_coach': {'ko': 'data-tab="coach">항목별 코칭<', 'en': 'data-tab="coach">Coaching<'},
|
|
56
|
+
'tab_misses': {'ko': 'data-tab="misses">놓친 응답<', 'en': 'data-tab="misses">Missed responses<'},
|
|
57
|
+
'tab_sessions': {'ko': 'data-tab="sessions">대화별 결과<', 'en': 'data-tab="sessions">Conversations<'},
|
|
58
|
+
'tab_trend': {'ko': 'data-tab="trend">회차별 변화<', 'en': 'data-tab="trend">Progress<'},
|
|
59
|
+
'focus_h': {'ko': '<h2>이번 주기에 집중할 것</h2>', 'en': '<h2>Focus on this cycle</h2>'},
|
|
60
|
+
'radar_h': {'ko': '<h2>항목별 점수</h2>', 'en': '<h2>Scores by habit</h2>'},
|
|
61
|
+
'weak_h': {'ko': '<h2>약한 순서 <span class="muted small">눌러서 코칭 보기</span></h2>', 'en': '<h2>Weakest first <span class="muted small">click for coaching</span></h2>'},
|
|
62
|
+
'dims_h': {'ko': '<h2>항목</h2>', 'en': '<h2>Habits</h2>'},
|
|
63
|
+
'search_ph': {'ko': 'placeholder="대화 검색"', 'en': 'placeholder="Search conversations"'},
|
|
64
|
+
'pill': {'ko': '["빠뜨림", "부분", "잘함"]', 'en': '["Missed", "Partial", "Good"]'},
|
|
65
|
+
'meta': {'ko': '`${day(D.current.created)} 채점 · ${(D.current.sources || []).map(s => SRC[s] || s).join(", ")} · 대화 ${D.current.session_count}개 · ${D.current.judge} · 채점표 v${D.rubric.version}`', 'en': '`Graded ${day(D.current.created)} · ${(D.current.sources || []).map(s => SRC[s] || s).join(", ")} · ${D.current.session_count} conversations · ${D.current.judge} · rubric v${D.rubric.version}`'},
|
|
66
|
+
'pts1': {'ko': '<span class="muted">${sc(d.id)}점</span>', 'en': '<span class="muted">${sc(d.id)}</span>'},
|
|
67
|
+
'best': {'ko': '`가장 잘하는 것: <b>${esc(best.name)}</b> (${sc(best.id)}점)`', 'en': '`Strongest: <b>${esc(best.name)}</b> (${sc(best.id)})`'},
|
|
68
|
+
'tile1': {'ko': '<span>채점한 대화</span>', 'en': '<span>conversations graded</span>'},
|
|
69
|
+
'tile2': {'ko': '<span>고쳐 쓰기 예시</span>', 'en': '<span>rewrites of your prompts</span>'},
|
|
70
|
+
'tile3': {'ko': '<span>놓친 응답 중 내 지시 빈틈</span>', 'en': '<span>missed responses that were prompt gaps</span>'},
|
|
71
|
+
'about': {'ko': '점수는 해당 항목이 필요했던 대화만 평균 낸 값입니다.', 'en': 'Each score averages only the conversations where that habit mattered.'},
|
|
72
|
+
'nconv': {'ko': '${n.n ? n.n + "개 대화" : ""}', 'en': '${n.n ? n.n + " convos" : ""}'},
|
|
73
|
+
'radar_aria': {'ko': 'aria-label="항목별 점수 레이더 차트"', 'en': 'aria-label="Radar chart of scores by habit"'},
|
|
74
|
+
'pts2': {'ko': '${esc(d.name)}: ${v}점</title>', 'en': '${esc(d.name)}: ${v}</title>'},
|
|
75
|
+
'legend_cur': {'ko': '></i>이번 ${day(cur.created)}', 'en': '></i>This run ${day(cur.created)}'},
|
|
76
|
+
'legend_prev': {'ko': '></i>지난번 ${day(prev.created)}', 'en': '></i>Previous ${day(prev.created)}'},
|
|
77
|
+
'back_dims': {'ko': '← 항목 목록', 'en': '← Habits'},
|
|
78
|
+
'dim_counts': {'ko': '해당 대화 ${v.n || 0}개 · 빠뜨림 ${v.missed || 0}개', 'en': '${v.n || 0} relevant conversations · missed in ${v.missed || 0}'},
|
|
79
|
+
'rewrites_h': {'ko': '내 대화에서 고쳐 쓰기 <span', 'en': 'Rewrites from your conversations <span'},
|
|
80
|
+
'you_wrote': {'ko': '<b>실제로 쓴 지시 · turn ${c.turn}</b>', 'en': '<b>What you wrote · turn ${c.turn}</b>'},
|
|
81
|
+
'better': {'ko': '<b>이렇게 썼다면</b>', 'en': '<b>Better</b>'},
|
|
82
|
+
'guide_ex_h': {'ko': '가이드 예시</h3>', 'en': 'Example from the guide</h3>'},
|
|
83
|
+
'instead': {'ko': '<b>대신에</b>', 'en': '<b>Instead of</b>'},
|
|
84
|
+
'try': {'ko': '<b>이렇게</b>', 'en': '<b>Try</b>'},
|
|
85
|
+
'guide_n': {'ko': '공식 가이드 근거 ${d.guide.length}개', 'en': 'Official guidance (${d.guide.length})'},
|
|
86
|
+
'missed_n': {'ko': '빠뜨린 대화 ${ms.length}개</summary><table><tr><th>대화</th><th>근거</th></tr>', 'en': 'Conversations that missed it (${ms.length})</summary><table><tr><th>Conversation</th><th>Why</th></tr>'},
|
|
87
|
+
'all_dims': {'ko': '<option value="">모든 항목</option>', 'en': '<option value="">All habits</option>'},
|
|
88
|
+
'missed_opt': {'ko': '${esc(d.short)} 빠뜨린 대화</option>', 'en': 'Missed: ${esc(d.short)}</option>'},
|
|
89
|
+
'turns1': {'ko': '· ${s.turns}턴</div>\n <div class="marks"', 'en': '· ${s.turns} turns</div>\n <div class="marks"'},
|
|
90
|
+
'no_match': {'ko': '조건에 맞는 대화가 없습니다', 'en': 'No matching conversations'},
|
|
91
|
+
'no_sess': {'ko': '채점한 대화가 없습니다.', 'en': 'No conversations graded.'},
|
|
92
|
+
'back_sess': {'ko': '← 대화 목록', 'en': '← Conversations'},
|
|
93
|
+
'turns2': {'ko': '${day(s.ended)} · ${s.turns}턴</div>`;', 'en': '${day(s.ended)} · ${s.turns} turns</div>`;'},
|
|
94
|
+
'sess_rewrites': {'ko': '<h3>이 대화의 고쳐 쓰기</h3>', 'en': '<h3>Rewrites in this conversation</h3>'},
|
|
95
|
+
'sess_misses': {'ko': '원하는 응답을 못 받은 곳 <span class="chip">', 'en': 'Where you didn\'t get what you wanted <span class="chip">'},
|
|
96
|
+
'verdicts': {'ko': '항목별 판정</h3>', 'en': 'Verdict per habit</h3>'},
|
|
97
|
+
'na': {'ko': '"해당 없음"', 'en': '"Not applicable"'},
|
|
98
|
+
'causes': {'ko': '{prompt_gap: ["내 지시의 빈틈", "c-gap"], agent_error: ["에이전트 잘못", "c-agent"],\n changed_mind: ["마음이 바뀜", "c-mind"], external: ["외부 요인", "c-ext"], unclassified: ["미분류", "c-ext"]}', 'en': '{prompt_gap: ["Gap in your prompt", "c-gap"], agent_error: ["Agent error", "c-agent"],\n changed_mind: ["Changed your mind", "c-mind"], external: ["External limit", "c-ext"], unclassified: ["Unclassified", "c-ext"]}'},
|
|
99
|
+
'signals': {'ko': '{correction: "바로잡음", repeat: "같은 말 반복", redo: "다시/되돌리기", mishap: "사고·손해", ask_back: "되물음", frustration: "답답함"}', 'en': '{correction: "Corrected", repeat: "Repeated yourself", redo: "Redo / undo", mishap: "Mishap", ask_back: "Agent asked back", frustration: "Frustration"}'},
|
|
100
|
+
'miss_h': {'ko': '<h2>원하는 응답을 못 받은 곳 <span class="muted small">${tot}곳</span></h2>', 'en': '<h2>Missed responses <span class="muted small">${tot}</span></h2>'},
|
|
101
|
+
'miss_note': {'ko': '짧은 지시라도 맥락으로 통했으면 문제 삼지 않습니다. 고쳐 쓰기는 <b>내 지시의 빈틈</b>에만 붙습니다.', 'en': 'Short prompts are fine when context carried the intent. Rewrites are given only for <b>gaps in your prompt</b>.'},
|
|
102
|
+
'rule_cands': {'ko': '`규칙 후보 ${themes.length}`', 'en': '`Rule candidates ${themes.length}`'},
|
|
103
|
+
'repeat_n': {'ko': '${m.refs.length}번 반복 · ${esc(m.where)}', 'en': 'repeated ${m.refs.length}× · ${esc(m.where)}'},
|
|
104
|
+
'none_here': {'ko': '해당하는 곳이 없습니다', 'en': 'Nothing here'},
|
|
105
|
+
'one_piece': {'ko': '<b>이 한 조각만 더했다면</b>', 'en': '<b>With the one missing piece</b>'},
|
|
106
|
+
'prompt_turn': {'ko': '<b>지시 · turn ${m.turn}</b>', 'en': '<b>Prompt · turn ${m.turn}</b>'},
|
|
107
|
+
'what_happened': {'ko': '무슨 일이 있었나</span>', 'en': 'What happened</span>'},
|
|
108
|
+
'why_lbl': {'ko': '${m.cause === "prompt_gap" ? "빠진 것" : "내 지시 문제가 아닌 이유"}', 'en': '${m.cause === "prompt_gap" ? "Missing piece" : "Why it wasn\'t your prompt"}'},
|
|
109
|
+
'no_misses': {'ko': '놓친 응답이 없습니다. LLM 채점(--judge claude/codex)으로 돌려야 원인까지 나뉩니다.', 'en': 'No missed responses. Causes are only classified with an LLM judge (--judge claude/codex).'},
|
|
110
|
+
'back': {'ko': '← 목록</button>', 'en': '← List</button>'},
|
|
111
|
+
'theme_meta': {'ko': '대화 ${new Set(m.refs.map(r => r.session)).size}곳에서 ${m.refs.length}번 빠짐 · 추천 위치: ${esc(m.where)}', 'en': 'Missing ${m.refs.length}× across ${new Set(m.refs.map(r => r.session)).size} conversations · put it in: ${esc(m.where)}'},
|
|
112
|
+
'theme_rule': {'ko': '<b>규칙 파일에 이 한 줄을 넣어 두면 매번 말하지 않아도 됩니다</b>', 'en': "<b>Add this line to a rules file and you won't have to say it again</b>"},
|
|
113
|
+
'theme_items': {'ko': '빠졌던 지시</h3>', 'en': 'Prompts that missed it</h3>'},
|
|
114
|
+
'theme_badge': {'ko': '<b>반복되는 빈틈: ${esc(t.title)}</b>', 'en': '<b>Recurring gap: ${esc(t.title)}</b>'},
|
|
115
|
+
'first_run': {'ko': '"아직 첫 회차입니다. 1~2주 뒤 agent-coach run 을 다시 돌리면 그 사이 대화만 채점해 변화가 쌓입니다."', 'en': '"This is your first run. Run agent-coach run again in a week or two; only the new conversations are graded and your progress builds up here."'},
|
|
116
|
+
'versions': {'ko': '`회차마다 채점표 버전이 다릅니다(v${versions.join(", v")}). 새 항목은 이전 회차에 점수가 없습니다.` : `${runs.length}회차 기록`', 'en': '`Runs used different rubric versions (v${versions.join(", v")}). New habits have no score in earlier runs.` : `${runs.length} runs`'},
|
|
117
|
+
'no_record': {'ko': '\'<div class="muted small">기록 없음</div>\'', 'en': '\'<div class="muted small">No data</div>\''},
|
|
118
|
+
'spark_aria': {'ko': 'aria-label="회차별 점수"', 'en': 'aria-label="Score per run"'},
|
|
119
|
+
'pts3': {'ko': '${x[0]}: ${x[1]}점</title>', 'en': '${x[0]}: ${x[1]}</title>'},
|
|
120
|
+
's_overall': {'ko': '[["종합", null,', 'en': '[["Overall", null,'},
|
|
121
|
+
's_gap': {'ko': '["놓친 응답 중 내 빈틈 비율(낮을수록 좋음)", null,', 'en': '["Share of misses that were prompt gaps (lower is better)", null,'},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
TEMPLATE = r"""<!doctype html>
|
|
126
|
+
<html lang="{{html_lang}}"><head><meta charset="utf-8">
|
|
127
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
128
|
+
{{title}}
|
|
129
|
+
<style>
|
|
130
|
+
:root{color-scheme:light;--bg:#f4f3ef;--surface:#fcfcfb;--surface2:#f0eee9;--line:#e2e0d9;--grid:#dcd9d2;
|
|
131
|
+
--text:#0b0b0b;--text2:#52514e;--muted:#7a7873;--accent:#2a78d6;--accent-soft:rgba(42,120,214,.13);
|
|
132
|
+
--prev:#9a978f;--good-bg:#e5f2e8;--good:#1f6b33;--bad-bg:#fae8e5;--bad:#9b2c1f;--warn:#8a5a00;--warn-bg:#fbf0d9}
|
|
133
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--bg:#111110;--surface:#1a1a19;
|
|
134
|
+
--surface2:#232321;--line:#2e2d2a;--grid:#3a3936;--text:#f0efec;--text2:#c3c2b7;--muted:#8f8d86;--accent:#3987e5;
|
|
135
|
+
--accent-soft:rgba(57,135,229,.2);--prev:#77756e;--good-bg:#16301e;--good:#8fd6a3;--bad-bg:#3a1c18;--bad:#f1a397;--warn:#f0c46a;--warn-bg:#352a12}}
|
|
136
|
+
:root[data-theme="dark"]{color-scheme:dark;--bg:#111110;--surface:#1a1a19;--surface2:#232321;--line:#2e2d2a;--grid:#3a3936;
|
|
137
|
+
--text:#f0efec;--text2:#c3c2b7;--muted:#8f8d86;--accent:#3987e5;--accent-soft:rgba(57,135,229,.2);--prev:#77756e;
|
|
138
|
+
--good-bg:#16301e;--good:#8fd6a3;--bad-bg:#3a1c18;--bad:#f1a397;--warn:#f0c46a;--warn-bg:#352a12}
|
|
139
|
+
*{box-sizing:border-box}
|
|
140
|
+
html,body{height:100%;margin:0}
|
|
141
|
+
body{background:var(--bg);color:var(--text);font:14px/1.55 "Pretendard","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;
|
|
142
|
+
display:flex;flex-direction:column;overflow:hidden}
|
|
143
|
+
button{font:inherit;color:inherit}
|
|
144
|
+
a{color:var(--accent)}
|
|
145
|
+
.muted{color:var(--muted)}.small{font-size:12px}
|
|
146
|
+
/* top bar */
|
|
147
|
+
header{display:flex;align-items:center;gap:16px;padding:10px 20px;border-bottom:1px solid var(--line);background:var(--surface);flex-wrap:wrap}
|
|
148
|
+
.brand{font-weight:700;font-size:15px;white-space:nowrap}
|
|
149
|
+
.kpi{display:flex;align-items:baseline;gap:6px}
|
|
150
|
+
.kpi b{font-size:22px;letter-spacing:-.02em}
|
|
151
|
+
.meta{color:var(--muted);font-size:12px;flex:1;min-width:160px}
|
|
152
|
+
.iconbtn{border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:5px 10px;cursor:pointer}
|
|
153
|
+
nav.tabs{display:flex;gap:2px;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--line);overflow-x:auto}
|
|
154
|
+
nav.tabs button{border:0;background:none;padding:10px 14px;cursor:pointer;color:var(--text2);border-bottom:2px solid transparent;white-space:nowrap}
|
|
155
|
+
nav.tabs button[aria-selected="true"]{color:var(--text);border-bottom-color:var(--accent);font-weight:600}
|
|
156
|
+
main{flex:1;min-height:0;padding:16px 20px}
|
|
157
|
+
.view{height:100%;display:none}
|
|
158
|
+
.view.on{display:grid}
|
|
159
|
+
.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;min-height:0}
|
|
160
|
+
.scroll{overflow:auto}
|
|
161
|
+
h2{font-size:15px;margin:0 0 10px}h3{font-size:14px;margin:0}
|
|
162
|
+
/* overview */
|
|
163
|
+
#v-overview{grid-template-columns:minmax(240px,1fr) minmax(300px,1.3fr) minmax(260px,1.1fr);gap:16px}
|
|
164
|
+
.big{font-size:56px;font-weight:700;line-height:1;letter-spacing:-.03em}
|
|
165
|
+
.delta{display:inline-block;font-size:12px;font-weight:600;padding:2px 8px;border-radius:99px;margin-left:6px;vertical-align:middle}
|
|
166
|
+
.delta.up{background:var(--good-bg);color:var(--good)}.delta.down{background:var(--bad-bg);color:var(--bad)}.delta.flat{background:var(--surface2);color:var(--text2)}
|
|
167
|
+
.focus{margin:8px 0 0;padding-left:18px}.focus li{margin:8px 0}
|
|
168
|
+
.focus button{all:unset;cursor:pointer;font-weight:600;color:var(--text)}.focus button:hover{color:var(--accent)}
|
|
169
|
+
.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:14px 0}
|
|
170
|
+
.tiles button{all:unset;cursor:pointer;background:var(--surface2);border-radius:10px;padding:10px;text-align:left}
|
|
171
|
+
.tiles button:hover{outline:1px solid var(--line)}
|
|
172
|
+
.tiles b{display:block;font-size:22px;line-height:1.2}.tiles span{font-size:12px;color:var(--text2)}
|
|
173
|
+
.radar{width:100%;height:100%;max-height:440px;display:block}
|
|
174
|
+
.grid{fill:none;stroke:var(--grid);stroke-width:1}
|
|
175
|
+
.cur{fill:var(--accent-soft);stroke:var(--accent);stroke-width:2;stroke-linejoin:round}
|
|
176
|
+
.prev{fill:none;stroke:var(--prev);stroke-width:2;stroke-dasharray:5 4}
|
|
177
|
+
.dot{fill:var(--accent);stroke:var(--surface);stroke-width:2;cursor:pointer}
|
|
178
|
+
.axis{font-size:11px;fill:var(--text2);font-weight:600;cursor:pointer}.axis tspan{font-weight:400;fill:var(--muted)}
|
|
179
|
+
.legend{display:flex;gap:14px;font-size:12px;color:var(--text2);justify-content:center}
|
|
180
|
+
.legend i{display:inline-block;width:14px;height:3px;border-radius:2px;vertical-align:middle;margin-right:6px}
|
|
181
|
+
.barlist{list-style:none;margin:0;padding:0}
|
|
182
|
+
.barlist li{display:grid;grid-template-columns:1fr 34px;gap:2px 10px;align-items:center;padding:7px 8px;border-radius:8px;cursor:pointer}
|
|
183
|
+
.barlist li:hover,.barlist li[aria-current="true"]{background:var(--surface2)}
|
|
184
|
+
.barlist .nm{font-size:13px}.barlist .sc{text-align:right;font-weight:600;font-variant-numeric:tabular-nums}
|
|
185
|
+
.bar{grid-column:1/-1;height:5px;background:var(--surface2);border-radius:99px;overflow:hidden}
|
|
186
|
+
.bar span{display:block;height:100%;background:var(--accent);border-radius:99px}
|
|
187
|
+
.barlist li[aria-current="true"] .bar{background:var(--bg)}
|
|
188
|
+
.na{color:var(--muted);font-weight:400}
|
|
189
|
+
/* master-detail */
|
|
190
|
+
.md{grid-template-columns:minmax(240px,320px) 1fr;gap:16px}
|
|
191
|
+
.md>.card{display:flex;flex-direction:column}
|
|
192
|
+
.list{flex:1;min-height:0}
|
|
193
|
+
.detail{overflow:auto}
|
|
194
|
+
.back{display:none}
|
|
195
|
+
.chip{display:inline-block;font-size:11px;padding:1px 8px;border-radius:99px;background:var(--surface2);color:var(--text2);margin-right:4px}
|
|
196
|
+
.guide{font-size:13px;color:var(--text2);border-left:3px solid var(--line);padding:2px 0 2px 10px;margin:8px 0}
|
|
197
|
+
.ex{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
|
|
198
|
+
.ex>div{border-radius:10px;padding:10px 12px;white-space:pre-wrap;word-break:break-word}
|
|
199
|
+
.ex .before{background:var(--bad-bg)}.ex .after{background:var(--good-bg)}
|
|
200
|
+
.ex b{display:block;font-size:11px;margin-bottom:4px;color:var(--text2)}
|
|
201
|
+
.why{font-size:13px;color:var(--text2);margin-top:6px}
|
|
202
|
+
.sess{font-size:12px;color:var(--muted);margin-top:4px}
|
|
203
|
+
.coach{padding:12px 0;border-top:1px solid var(--line)}.coach:first-of-type{border-top:0}
|
|
204
|
+
details{margin-top:12px}summary{cursor:pointer;color:var(--text2)}
|
|
205
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
206
|
+
th,td{text-align:left;padding:7px 6px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--text2);font-weight:600}
|
|
207
|
+
.slist{list-style:none;margin:0;padding:0}
|
|
208
|
+
.slist li{padding:9px 8px;border-radius:8px;cursor:pointer;border-bottom:1px solid var(--line)}
|
|
209
|
+
.slist li:hover,.slist li[aria-current="true"]{background:var(--surface2)}
|
|
210
|
+
.marks{font-family:ui-monospace,Consolas,monospace;font-size:12px;letter-spacing:2px;color:var(--text2)}
|
|
211
|
+
.toolbar{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}
|
|
212
|
+
.toolbar input,.toolbar select{flex:1;min-width:120px;border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:6px 10px;font:inherit;color:inherit}
|
|
213
|
+
.score-row{display:grid;grid-template-columns:110px 44px 1fr;gap:8px;padding:8px 0;border-bottom:1px solid var(--line);align-items:start}
|
|
214
|
+
.pill{font-size:12px;font-weight:600;text-align:center;border-radius:6px;padding:1px 0}
|
|
215
|
+
.p2{background:var(--good-bg);color:var(--good)}.p1{background:var(--warn-bg);color:var(--warn)}.p0{background:var(--bad-bg);color:var(--bad)}.pn{background:var(--surface2);color:var(--muted)}
|
|
216
|
+
/* misses */
|
|
217
|
+
.stack{display:flex;height:10px;border-radius:99px;overflow:hidden;background:var(--surface2);margin:8px 0 6px;gap:2px}
|
|
218
|
+
.stack span{display:block;height:100%}
|
|
219
|
+
.c-gap{background:var(--accent)}.c-agent{background:var(--prev)}.c-mind{background:var(--grid)}.c-ext{background:var(--line)}
|
|
220
|
+
.keys{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--text2)}
|
|
221
|
+
.keys i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px;vertical-align:baseline}
|
|
222
|
+
.seg{display:flex;gap:4px;flex-wrap:wrap;margin:10px 0}
|
|
223
|
+
.seg button{border:1px solid var(--line);background:var(--surface);border-radius:99px;padding:3px 10px;cursor:pointer;font-size:12px}
|
|
224
|
+
.seg button[aria-pressed="true"]{background:var(--text);color:var(--surface);border-color:var(--text)}
|
|
225
|
+
.theme{background:var(--warn-bg)}
|
|
226
|
+
.lbl{font-size:11px;font-weight:600;color:var(--text2)}
|
|
227
|
+
/* trend */
|
|
228
|
+
#v-trend{grid-template-rows:auto 1fr;gap:16px}
|
|
229
|
+
.trends{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;align-content:start}
|
|
230
|
+
.trends .card{padding:12px}
|
|
231
|
+
.spark{width:100%;height:56px;display:block}
|
|
232
|
+
.trend{fill:none;stroke:var(--accent);stroke-width:2;stroke-linejoin:round;stroke-linecap:round}
|
|
233
|
+
.note{font-size:12px;color:var(--muted);margin:0}
|
|
234
|
+
/* responsive */
|
|
235
|
+
@media (max-width:1100px){#v-overview{grid-template-columns:1fr 1fr}#v-overview .c-dims{grid-column:1/-1}}
|
|
236
|
+
@media (max-width:760px){
|
|
237
|
+
body{overflow:auto;height:auto}
|
|
238
|
+
header{padding:10px 16px;gap:10px}
|
|
239
|
+
main{padding:12px 16px}
|
|
240
|
+
.view.on{display:block}
|
|
241
|
+
.view>.card,.view>.trends{margin-bottom:12px}
|
|
242
|
+
#v-overview .c-radar{min-height:340px}
|
|
243
|
+
.md>.card{max-height:none}
|
|
244
|
+
.md.showing .c-list{display:none}
|
|
245
|
+
.md:not(.showing) .c-detail{display:none}
|
|
246
|
+
.back{display:inline-block;margin-bottom:10px}
|
|
247
|
+
.ex{grid-template-columns:1fr}
|
|
248
|
+
.score-row{grid-template-columns:96px 40px 1fr}
|
|
249
|
+
}
|
|
250
|
+
</style></head>
|
|
251
|
+
<body>
|
|
252
|
+
<header>
|
|
253
|
+
<div class="brand">agent-coach</div>
|
|
254
|
+
<div class="kpi">{{overall}}<b id="kpi-score">–</b><span class="muted small">/100</span><span id="kpi-delta"></span></div>
|
|
255
|
+
<div class="meta" id="meta"></div>
|
|
256
|
+
<button class="iconbtn" id="theme" {{theme_btn}}>◐</button>
|
|
257
|
+
</header>
|
|
258
|
+
<nav class="tabs" role="tablist">
|
|
259
|
+
<button role="tab" {{tab_overview}}/button>
|
|
260
|
+
<button role="tab" {{tab_coach}}/button>
|
|
261
|
+
<button role="tab" {{tab_misses}}/button>
|
|
262
|
+
<button role="tab" {{tab_sessions}}/button>
|
|
263
|
+
<button role="tab" {{tab_trend}}/button>
|
|
264
|
+
</nav>
|
|
265
|
+
<main>
|
|
266
|
+
<section class="view" id="v-overview">
|
|
267
|
+
<div class="card scroll">
|
|
268
|
+
{{focus_h}}
|
|
269
|
+
<ol class="focus" id="focus"></ol>
|
|
270
|
+
<p class="small muted" id="best"></p>
|
|
271
|
+
<div class="tiles" id="tiles"></div>
|
|
272
|
+
<p class="small muted" id="about"></p>
|
|
273
|
+
</div>
|
|
274
|
+
<div class="card c-radar" style="display:flex;flex-direction:column">
|
|
275
|
+
{{radar_h}}
|
|
276
|
+
<div style="flex:1;min-height:0" id="radar"></div>
|
|
277
|
+
<div class="legend" id="legend"></div>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="card scroll c-dims">
|
|
280
|
+
{{weak_h}}
|
|
281
|
+
<ul class="barlist" id="ov-dims"></ul>
|
|
282
|
+
</div>
|
|
283
|
+
</section>
|
|
284
|
+
|
|
285
|
+
<section class="view md" id="v-coach">
|
|
286
|
+
<div class="card c-list">{{dims_h}}<ul class="barlist list scroll" id="coach-list"></ul></div>
|
|
287
|
+
<div class="card c-detail detail" id="coach-detail"></div>
|
|
288
|
+
</section>
|
|
289
|
+
|
|
290
|
+
<section class="view md" id="v-misses">
|
|
291
|
+
<div class="card c-list">
|
|
292
|
+
<div id="miss-sum"></div>
|
|
293
|
+
<div class="seg" id="miss-filter"></div>
|
|
294
|
+
<ul class="slist list scroll" id="miss-list"></ul>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="card c-detail detail" id="miss-detail"></div>
|
|
297
|
+
</section>
|
|
298
|
+
|
|
299
|
+
<section class="view md" id="v-sessions">
|
|
300
|
+
<div class="card c-list">
|
|
301
|
+
<div class="toolbar"><input id="q" type="search" {{search_ph}}><select id="filter"></select></div>
|
|
302
|
+
<ul class="slist list scroll" id="sess-list"></ul>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="card c-detail detail" id="sess-detail"></div>
|
|
305
|
+
</section>
|
|
306
|
+
|
|
307
|
+
<section class="view" id="v-trend">
|
|
308
|
+
<p class="note" id="trend-note"></p>
|
|
309
|
+
<div class="trends scroll" id="trends"></div>
|
|
310
|
+
</section>
|
|
311
|
+
</main>
|
|
312
|
+
<script id="data" type="application/json">/*__DATA__*/null</script>
|
|
313
|
+
<script>
|
|
314
|
+
(() => {
|
|
315
|
+
const D = JSON.parse(document.getElementById("data").textContent);
|
|
316
|
+
const dims = D.rubric.dimensions, runs = D.runs, cur = runs[runs.length - 1], prev = runs.length > 1 ? runs[runs.length - 2] : null;
|
|
317
|
+
const sessions = D.current.sessions || [];
|
|
318
|
+
const SRC = {claude: "Claude Code", codex: "Codex"};
|
|
319
|
+
const $ = s => document.querySelector(s);
|
|
320
|
+
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
|
321
|
+
const day = s => (s || "").slice(0, 10);
|
|
322
|
+
const sc = id => cur.dimensions[id] ? cur.dimensions[id].score : null;
|
|
323
|
+
const delta = (a, b) => (a == null || b == null) ? "" :
|
|
324
|
+
`<span class="delta ${a > b ? "up" : a < b ? "down" : "flat"}">${a > b ? "+" : ""}${a - b}</span>`;
|
|
325
|
+
const pill = v => `<span class="pill ${v == null ? "pn" : "p" + v}">${v == null ? "–" : {{pill}}[v]}</span>`;
|
|
326
|
+
const store = {get(k){try{return localStorage.getItem(k)}catch(e){return null}}, set(k,v){try{localStorage.setItem(k,v)}catch(e){}}};
|
|
327
|
+
|
|
328
|
+
/* ---------- header ---------- */
|
|
329
|
+
$("#kpi-score").textContent = cur.overall ?? "–";
|
|
330
|
+
$("#kpi-delta").innerHTML = prev && prev.rubric_version === cur.rubric_version ? delta(cur.overall, prev.overall) : "";
|
|
331
|
+
$("#meta").textContent = {{meta}};
|
|
332
|
+
const theme = store.get("ac-theme"); if (theme) document.documentElement.dataset.theme = theme;
|
|
333
|
+
$("#theme").onclick = () => {
|
|
334
|
+
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
|
|
335
|
+
document.documentElement.dataset.theme = dark ? "light" : "dark"; store.set("ac-theme", document.documentElement.dataset.theme);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/* ---------- routing ---------- */
|
|
339
|
+
const views = ["overview", "coach", "misses", "sessions", "trend"];
|
|
340
|
+
function go(tab, arg, push = true) {
|
|
341
|
+
if (!views.includes(tab)) tab = "overview";
|
|
342
|
+
document.querySelectorAll("nav.tabs button").forEach(b => b.setAttribute("aria-selected", b.dataset.tab === tab));
|
|
343
|
+
document.querySelectorAll(".view").forEach(v => v.classList.toggle("on", v.id === "v-" + tab));
|
|
344
|
+
if (tab === "coach") showDim(arg || state.dim, !!arg);
|
|
345
|
+
if (tab === "sessions") showSession(arg || state.sess, !!arg);
|
|
346
|
+
if (tab === "misses") showMiss(arg || state.miss, !!arg);
|
|
347
|
+
if (push) history.replaceState(null, "", "#" + tab + (arg ? "/" + arg : ""));
|
|
348
|
+
}
|
|
349
|
+
document.querySelectorAll("nav.tabs button").forEach(b => b.onclick = () => {
|
|
350
|
+
document.querySelectorAll(".md").forEach(m => m.classList.remove("showing")); go(b.dataset.tab);
|
|
351
|
+
});
|
|
352
|
+
const state = {dim: null, sess: null, miss: null, cause: "prompt_gap"};
|
|
353
|
+
|
|
354
|
+
/* ---------- overview ---------- */
|
|
355
|
+
const ranked = dims.filter(d => sc(d.id) != null).sort((a, b) => sc(a.id) - sc(b.id));
|
|
356
|
+
const unscored = dims.filter(d => sc(d.id) == null);
|
|
357
|
+
$("#focus").innerHTML = ranked.slice(0, 3).map(d =>
|
|
358
|
+
`<li><button data-dim="${d.id}">${esc(d.name)}</button> {{pts1}}<div class="small muted">${esc(d.summary)}</div></li>`).join("");
|
|
359
|
+
const best = ranked[ranked.length - 1];
|
|
360
|
+
$("#best").innerHTML = best ? {{best}} : "";
|
|
361
|
+
const nCoach = sessions.reduce((a, s) => a + (s.coaching || []).length, 0);
|
|
362
|
+
const MS = D.current.misses || {total: 0, by_cause: {}, themes: []};
|
|
363
|
+
const nGap = (MS.by_cause || {}).prompt_gap || 0;
|
|
364
|
+
$("#tiles").innerHTML = `<button data-go="sessions"><b>${sessions.length}</b>{{tile1}}</button>
|
|
365
|
+
<button data-go="coach"><b>${nCoach}</b>{{tile2}}</button>
|
|
366
|
+
<button data-go="misses"><b>${nGap}<small class="muted" style="font-size:13px"> / ${MS.total}</small></b>{{tile3}}</button>`;
|
|
367
|
+
document.querySelectorAll("[data-go]").forEach(b => b.onclick = () => go(b.dataset.go));
|
|
368
|
+
$("#about").innerHTML =`${esc(D.rubric.title)}. {{about}}`;
|
|
369
|
+
const barItem = d => {
|
|
370
|
+
const v = sc(d.id), n = cur.dimensions[d.id] || {};
|
|
371
|
+
return `<li data-dim="${d.id}"><span class="nm">${esc(d.name)} <span class="small muted">{{nconv}}</span></span>
|
|
372
|
+
<span class="sc">${v == null ? '<span class="na">–</span>' : v}</span><span class="bar"><span style="width:${v || 0}%"></span></span></li>`;
|
|
373
|
+
};
|
|
374
|
+
$("#ov-dims").innerHTML = ranked.concat(unscored).map(barItem).join("");
|
|
375
|
+
document.addEventListener("click", e => {
|
|
376
|
+
const el = e.target.closest("[data-dim]");
|
|
377
|
+
if (el && !el.closest("#coach-list")) go("coach", el.dataset.dim);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
function radar() {
|
|
381
|
+
const n = dims.length, W = 420, H = 400, cx = W / 2, cy = H / 2 + 4, r = 140;
|
|
382
|
+
const pt = (i, v) => { const a = -Math.PI / 2 + 2 * Math.PI * i / n; return [cx + r * v / 100 * Math.cos(a), cy + r * v / 100 * Math.sin(a)]; };
|
|
383
|
+
let s = `<svg viewBox="0 0 ${W} ${H}" class="radar" role="img" {{radar_aria}}>`;
|
|
384
|
+
for (const ring of [25, 50, 75, 100]) s += `<polygon class="grid" points="${dims.map((_, i) => pt(i, ring).join(",")).join(" ")}"/>`;
|
|
385
|
+
dims.forEach((d, i) => {
|
|
386
|
+
const [x, y] = pt(i, 100), [lx, ly] = pt(i, 121), v = sc(d.id);
|
|
387
|
+
const anchor = Math.abs(lx - cx) < 8 ? "middle" : lx > cx ? "start" : "end";
|
|
388
|
+
s += `<line class="grid" x1="${cx}" y1="${cy}" x2="${x}" y2="${y}"/>`;
|
|
389
|
+
s += `<text class="axis" data-dim="${d.id}" x="${lx}" y="${ly}" text-anchor="${anchor}">${esc(d.short)}<tspan x="${lx}" dy="1.2em">${v ?? "–"}</tspan></text>`;
|
|
390
|
+
});
|
|
391
|
+
const poly = (run, cls) => `<polygon class="${cls}" points="${dims.map((d, i) => pt(i, (run.dimensions[d.id] || {}).score || 0).join(",")).join(" ")}"/>`;
|
|
392
|
+
if (prev) s += poly(prev, "prev");
|
|
393
|
+
s += poly(cur, "cur");
|
|
394
|
+
dims.forEach((d, i) => { const v = sc(d.id); if (v == null) return; const [x, y] = pt(i, v);
|
|
395
|
+
s += `<circle class="dot" data-dim="${d.id}" cx="${x}" cy="${y}" r="5"><title>{{pts2}}</circle>`; });
|
|
396
|
+
$("#radar").innerHTML = s + "</svg>";
|
|
397
|
+
$("#legend").innerHTML = `<span><i style="background:var(--accent)"{{legend_cur}}</span>` +
|
|
398
|
+
(prev ? `<span><i style="background:var(--prev)"{{legend_prev}}</span>` : "");
|
|
399
|
+
}
|
|
400
|
+
radar();
|
|
401
|
+
|
|
402
|
+
/* ---------- coaching ---------- */
|
|
403
|
+
const coachBy = {}, missedBy = {};
|
|
404
|
+
for (const s of sessions) {
|
|
405
|
+
for (const c of s.coaching || []) (coachBy[c.dimension] ||= []).push({...c, s});
|
|
406
|
+
for (const [id, v] of Object.entries(s.dimensions || {})) if (v.score === 0) (missedBy[id] ||= []).push({...v, s});
|
|
407
|
+
}
|
|
408
|
+
$("#coach-list").innerHTML = ranked.concat(unscored).map(barItem).join("");
|
|
409
|
+
$("#coach-list").addEventListener("click", e => { const li = e.target.closest("li[data-dim]"); if (li) go("coach", li.dataset.dim); });
|
|
410
|
+
const sessLine = s => `${esc(SRC[s.source] || s.source)} · ${esc(s.project)} · ${day(s.ended)} · ${esc(s.task_summary)}`;
|
|
411
|
+
function showDim(id, open) {
|
|
412
|
+
id = id || (ranked[0] || dims[0]).id; state.dim = id;
|
|
413
|
+
const d = dims.find(x => x.id === id); if (!d) return;
|
|
414
|
+
document.querySelectorAll("#coach-list li").forEach(li => li.setAttribute("aria-current", li.dataset.dim === id));
|
|
415
|
+
const v = cur.dimensions[id] || {}, pv = prev && prev.rubric_version === cur.rubric_version ? (prev.dimensions[id] || {}).score : null;
|
|
416
|
+
const ex = coachBy[id] || [], ms = missedBy[id] || [];
|
|
417
|
+
let h = `<button class="iconbtn back" data-back>{{back_dims}}</button>
|
|
418
|
+
<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;flex-wrap:wrap">
|
|
419
|
+
<div><h2 style="font-size:18px;margin:0">${esc(d.name)}</h2><div class="muted">${esc(d.summary)}</div></div>
|
|
420
|
+
<div><span class="big" style="font-size:40px">${v.score ?? "–"}</span>${delta(v.score, pv)}</div></div>
|
|
421
|
+
<div class="small muted" style="margin:6px 0 4px">{{dim_counts}}</div>`;
|
|
422
|
+
if (ex.length) {
|
|
423
|
+
h += `<h3 style="margin-top:14px">{{rewrites_h}} class="chip">${ex.length}</span></h3>`;
|
|
424
|
+
h += ex.map(c => `<div class="coach"><div class="ex"><div class="before">{{you_wrote}}${esc(c.original)}</div>
|
|
425
|
+
<div class="after">{{better}}${esc(c.better)}</div></div><div class="why">${esc(c.why)}</div>
|
|
426
|
+
<div class="sess"><a href="#sessions/${esc(c.s.id)}" data-sess="${esc(c.s.id)}">${sessLine(c.s)}</a></div></div>`).join("");
|
|
427
|
+
} else if (d.example) {
|
|
428
|
+
h += `<h3 style="margin-top:14px">{{guide_ex_h}}<div class="ex"><div class="before">{{instead}}${esc(d.example.before)}</div><div class="after">{{try}}${esc(d.example.after)}</div></div>`;
|
|
429
|
+
}
|
|
430
|
+
h += `<details ${ex.length ? "" : "open"}><summary>{{guide_n}}</summary>` + d.guide.map(g => {
|
|
431
|
+
const src = D.rubric.sources[g.source] || {}; return `<div class="guide">${esc(g.quote)}<br><a href="${esc(src.url)}" target="_blank" rel="noopener">${esc(src.title)} · ${esc(g.section)}</a></div>`;
|
|
432
|
+
}).join("") + `</details>`;
|
|
433
|
+
if (ms.length) h += `<details><summary>{{missed_n}}` +
|
|
434
|
+
ms.map(m => `<tr><td><a href="#sessions/${esc(m.s.id)}" data-sess="${esc(m.s.id)}">${esc(m.s.task_summary)}</a><div class="sess">${day(m.s.ended)} · ${esc(m.s.project)}</div></td><td>${esc(m.note)}${m.excerpt ? `<div class="sess">“${esc(m.excerpt)}”</div>` : ""}</td></tr>`).join("") + `</table></details>`;
|
|
435
|
+
$("#coach-detail").innerHTML = h; $("#coach-detail").scrollTop = 0;
|
|
436
|
+
if (open) $("#v-coach").classList.add("showing");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/* ---------- sessions ---------- */
|
|
440
|
+
$("#filter").innerHTML = `{{all_dims}}` + dims.map(d => `<option value="${d.id}">{{missed_opt}}`).join("");
|
|
441
|
+
const markOf = v => v == null ? "·" : ["✗", "△", "○"][v];
|
|
442
|
+
function listSessions() {
|
|
443
|
+
const q = $("#q").value.trim().toLowerCase(), f = $("#filter").value;
|
|
444
|
+
const rows = sessions.filter(s => (!q || (s.task_summary + " " + s.project).toLowerCase().includes(q)) &&
|
|
445
|
+
(!f || (s.dimensions[f] || {}).score === 0));
|
|
446
|
+
$("#sess-list").innerHTML = rows.map(s => `<li data-id="${esc(s.id)}"><div>${esc(s.task_summary)}</div>
|
|
447
|
+
<div class="sess">${day(s.ended)} · ${esc(SRC[s.source] || s.source)} {{turns1}} title="${dims.map(d => d.short).join(" ")}">${dims.map(d => markOf((s.dimensions[d.id] || {}).score)).join("")}</div></li>`).join("")
|
|
448
|
+
|| `<li class="muted">{{no_match}}</li>`;
|
|
449
|
+
document.querySelectorAll("#sess-list li[data-id]").forEach(li => li.setAttribute("aria-current", li.dataset.id === state.sess));
|
|
450
|
+
}
|
|
451
|
+
$("#q").oninput = listSessions; $("#filter").onchange = listSessions;
|
|
452
|
+
$("#sess-list").addEventListener("click", e => { const li = e.target.closest("li[data-id]"); if (li) go("sessions", li.dataset.id); });
|
|
453
|
+
document.addEventListener("click", e => { const a = e.target.closest("[data-sess]"); if (a) { e.preventDefault(); go("sessions", a.dataset.sess); } });
|
|
454
|
+
function showSession(id, open) {
|
|
455
|
+
listSessions();
|
|
456
|
+
const s = sessions.find(x => x.id === id) || sessions[0]; if (!s) { $("#sess-detail").innerHTML = '<p class="muted">{{no_sess}}</p>'; return; }
|
|
457
|
+
state.sess = s.id;
|
|
458
|
+
document.querySelectorAll("#sess-list li[data-id]").forEach(li => li.setAttribute("aria-current", li.dataset.id === s.id));
|
|
459
|
+
let h = `<button class="iconbtn back" data-back>{{back_sess}}</button>
|
|
460
|
+
<h2 style="font-size:17px;margin:0">${esc(s.task_summary)}</h2>
|
|
461
|
+
<div class="sess" style="margin-bottom:10px">${esc(SRC[s.source] || s.source)} · ${esc(s.project)} · ${day(s.started)} ~ {{turns2}}
|
|
462
|
+
if ((s.coaching || []).length) h += `{{sess_rewrites}}` + s.coaching.map(c => {
|
|
463
|
+
const d = dims.find(x => x.id === c.dimension) || {}; return `<div class="coach"><span class="chip">${esc(d.name || c.dimension)}</span>
|
|
464
|
+
<div class="ex"><div class="before">{{you_wrote}}${esc(c.original)}</div><div class="after">{{better}}${esc(c.better)}</div></div><div class="why">${esc(c.why)}</div></div>`; }).join("");
|
|
465
|
+
const sm = misses.filter(m => m.s.id === s.id);
|
|
466
|
+
if (sm.length) h += `<h3 style="margin-top:14px">{{sess_misses}}${sm.length}</span></h3>` + sm.map(m => missBlock(m, false)).join("");
|
|
467
|
+
h += `<h3 style="margin-top:14px">{{verdicts}}` + dims.map(d => { const v = s.dimensions[d.id] || {};
|
|
468
|
+
return `<div class="score-row"><a href="#coach/${d.id}" data-dim="${d.id}">${esc(d.name)}</a>${pill(v.score)}<div>${esc(v.note || (v.score == null ? {{na}} : ""))}${v.excerpt ? `<div class="sess">turn ${v.evidence_turn}: “${esc(v.excerpt)}”</div>` : ""}</div></div>`; }).join("");
|
|
469
|
+
$("#sess-detail").innerHTML = h; $("#sess-detail").scrollTop = 0;
|
|
470
|
+
if (open) $("#v-sessions").classList.add("showing");
|
|
471
|
+
}
|
|
472
|
+
document.addEventListener("click", e => { if (e.target.closest("[data-back]")) document.querySelectorAll(".md").forEach(m => m.classList.remove("showing")); });
|
|
473
|
+
|
|
474
|
+
/* ---------- misses ---------- */
|
|
475
|
+
const CAUSE = {{causes}};
|
|
476
|
+
const SIGNAL = {{signals}};
|
|
477
|
+
const misses = [];
|
|
478
|
+
sessions.forEach(s => (s.misses || []).forEach((m, i) => misses.push({...m, s, key: s.id + ":" + i})));
|
|
479
|
+
const themes = (MS.themes || []).map((t, i) => ({...t, key: "theme:" + i}));
|
|
480
|
+
(function summary() {
|
|
481
|
+
const bc = MS.by_cause || {}, tot = MS.total || 0;
|
|
482
|
+
const order = ["prompt_gap", "agent_error", "changed_mind", "external", "unclassified"].filter(k => bc[k]);
|
|
483
|
+
$("#miss-sum").innerHTML = `{{miss_h}}
|
|
484
|
+
<div class="stack">${order.map(k => `<span class="${CAUSE[k][1]}" style="width:${bc[k] / tot * 100}%" title="${CAUSE[k][0]} ${bc[k]}"></span>`).join("")}</div>
|
|
485
|
+
<div class="keys">${order.map(k => `<span><i class="${CAUSE[k][1]}"></i>${CAUSE[k][0]} ${bc[k]}</span>`).join("")}</div>
|
|
486
|
+
<p class="small muted" style="margin:8px 0 0">{{miss_note}}</p>`;
|
|
487
|
+
const opts = [["themes", {{rule_cands}}], ...order.map(k => [k, `${CAUSE[k][0]} ${bc[k]}`])];
|
|
488
|
+
if (!themes.length) opts.shift();
|
|
489
|
+
if (!opts.find(o => o[0] === state.cause)) state.cause = opts.length ? opts[0][0] : "prompt_gap";
|
|
490
|
+
$("#miss-filter").innerHTML = opts.map(([k, l]) => `<button data-cause="${k}" aria-pressed="${k === state.cause}">${esc(l)}</button>`).join("");
|
|
491
|
+
$("#miss-filter").onclick = e => { const b = e.target.closest("[data-cause]"); if (!b) return; state.cause = b.dataset.cause;
|
|
492
|
+
document.querySelectorAll("#miss-filter button").forEach(x => x.setAttribute("aria-pressed", x === b)); state.miss = null; showMiss(null, false); };
|
|
493
|
+
})();
|
|
494
|
+
function listMisses() {
|
|
495
|
+
const items = state.cause === "themes" ? themes : misses.filter(m => m.cause === state.cause);
|
|
496
|
+
$("#miss-list").innerHTML = items.map(m => m.key.startsWith("theme:")
|
|
497
|
+
? `<li data-miss="${m.key}"><div><b>${esc(m.title)}</b></div><div class="sess">{{repeat_n}}</div></li>`
|
|
498
|
+
: `<li data-miss="${esc(m.key)}"><div>${esc(m.original)}</div><div class="sess">${esc(SIGNAL[m.signal] || m.signal)} · ${day(m.s.ended)} · ${esc(m.s.project)}</div></li>`).join("")
|
|
499
|
+
|| `<li class="muted">{{none_here}}</li>`;
|
|
500
|
+
return items;
|
|
501
|
+
}
|
|
502
|
+
$("#miss-list").addEventListener("click", e => { const li = e.target.closest("li[data-miss]"); if (li) go("misses", li.dataset.miss); });
|
|
503
|
+
function missBlock(m, withSession = true) {
|
|
504
|
+
const c = CAUSE[m.cause] || CAUSE.unclassified;
|
|
505
|
+
let h = `<div class="coach"><span class="chip"><i class="${c[1]}" style="display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px"></i>${c[0]}</span><span class="chip">${esc(SIGNAL[m.signal] || m.signal)}</span>`;
|
|
506
|
+
if (m.better) h += `<div class="ex"><div class="before">{{you_wrote_m}}${esc(m.original)}</div><div class="after">{{one_piece}}${esc(m.better)}</div></div>`;
|
|
507
|
+
else h += `<div class="ex" style="grid-template-columns:1fr"><div class="before" style="background:var(--surface2)">{{prompt_turn}}${esc(m.original)}</div></div>`;
|
|
508
|
+
h += `<div class="why"><span class="lbl">{{what_happened}} ${esc(m.what_went_wrong)}</div>`;
|
|
509
|
+
if (m.why) h += `<div class="why"><span class="lbl">{{why_lbl}}</span> ${esc(m.why)}</div>`;
|
|
510
|
+
if (withSession) h += `<div class="sess"><a href="#sessions/${esc(m.s.id)}" data-sess="${esc(m.s.id)}">${sessLine(m.s)}</a></div>`;
|
|
511
|
+
return h + `</div>`;
|
|
512
|
+
}
|
|
513
|
+
function showMiss(key, open) {
|
|
514
|
+
const items = listMisses();
|
|
515
|
+
const m = items.find(x => x.key === key) || items[0];
|
|
516
|
+
if (!m) { $("#miss-detail").innerHTML = '<p class="muted">{{no_misses}}</p>'; return; }
|
|
517
|
+
state.miss = m.key;
|
|
518
|
+
document.querySelectorAll("#miss-list li[data-miss]").forEach(li => li.setAttribute("aria-current", li.dataset.miss === m.key));
|
|
519
|
+
let h = `<button class="iconbtn back" data-back>{{back}}`;
|
|
520
|
+
if (m.key.startsWith("theme:")) {
|
|
521
|
+
h += `<h2 style="font-size:17px;margin:0">${esc(m.title)}</h2><div class="sess">{{theme_meta}}</div>
|
|
522
|
+
<div class="ex" style="grid-template-columns:1fr"><div class="after">{{theme_rule}}${esc(m.rule)}</div></div>
|
|
523
|
+
<h3 style="margin-top:14px">{{theme_items}}` +
|
|
524
|
+
m.refs.map(r => misses.find(x => x.s.id === r.session && x.turn === r.turn)).filter(Boolean).map(x => missBlock(x)).join("");
|
|
525
|
+
} else {
|
|
526
|
+
h += `<h2 style="font-size:16px;margin:0 0 4px">${esc(m.s.task_summary)}</h2>` + missBlock(m);
|
|
527
|
+
const t = themes.find(t => t.refs.some(r => r.session === m.s.id && r.turn === m.turn));
|
|
528
|
+
if (t) h += `<div class="ex" style="grid-template-columns:1fr"><div class="theme">{{theme_badge}}${esc(t.rule)}</div></div>`;
|
|
529
|
+
}
|
|
530
|
+
$("#miss-detail").innerHTML = h; $("#miss-detail").scrollTop = 0;
|
|
531
|
+
if (open) $("#v-misses").classList.add("showing");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* ---------- trend ---------- */
|
|
535
|
+
const versions = [...new Set(runs.map(r => r.rubric_version))];
|
|
536
|
+
$("#trend-note").textContent = runs.length < 2
|
|
537
|
+
? {{first_run}}
|
|
538
|
+
: versions.length > 1 ? {{versions}};
|
|
539
|
+
function spark(pts) {
|
|
540
|
+
const p = pts.filter(x => x[1] != null); if (!p.length) return {{no_record}};
|
|
541
|
+
const W = 200, H = 56, pad = 8, xs = p.map((_, i) => pad + (W - 2 * pad) * (p.length > 1 ? i / (p.length - 1) : .5)), ys = p.map(x => H - pad - (H - 2 * pad) * x[1] / 100);
|
|
542
|
+
let s = `<svg viewBox="0 0 ${W} ${H}" class="spark" role="img" {{spark_aria}}><line class="grid" x1="${pad}" y1="${H - pad}" x2="${W - pad}" y2="${H - pad}"/>`;
|
|
543
|
+
if (p.length > 1) s += `<polyline class="trend" points="${xs.map((x, i) => x + "," + ys[i]).join(" ")}"/>`;
|
|
544
|
+
p.forEach((x, i) => s += `<circle class="dot" cx="${xs[i]}" cy="${ys[i]}" r="4"><title>{{pts3}}</circle>`);
|
|
545
|
+
return s + "</svg>";
|
|
546
|
+
}
|
|
547
|
+
const series = {{s_overall}} runs.map(r => [day(r.created), r.overall])],
|
|
548
|
+
{{s_gap}} runs.map(r => [day(r.created), r.gap_share])]].concat(dims.map(d => [d.name, d.id, runs.map(r => [day(r.created), (r.dimensions[d.id] || {}).score])]));
|
|
549
|
+
$("#trends").innerHTML = series.map(([name, id, pts]) => { const last = [...pts].reverse().find(x => x[1] != null);
|
|
550
|
+
return `<div class="card"${id ? ` data-dim="${id}" style="cursor:pointer"` : ""}><div style="display:flex;justify-content:space-between"><h3>${esc(name)}</h3><span class="muted">${last ? last[1] : "–"}</span></div>${spark(pts)}</div>`; }).join("");
|
|
551
|
+
|
|
552
|
+
/* ---------- start ---------- */
|
|
553
|
+
const [tab, arg] = (location.hash.slice(1) || "overview").split("/");
|
|
554
|
+
go(tab, arg, false);
|
|
555
|
+
if (matchMedia("(max-width:760px)").matches && !arg) document.querySelectorAll(".md").forEach(m => m.classList.remove("showing"));
|
|
556
|
+
})();
|
|
557
|
+
</script>
|
|
558
|
+
</body></html>
|
|
559
|
+
"""
|