Skip to content

Commit 451deae

Browse files
committed
fix: tighten verified learning references
1 parent f8fec0c commit 451deae

6 files changed

Lines changed: 59 additions & 34 deletions

File tree

plugin/dashboard/app/dashboard/page.tsx

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Users,
1212
} from "lucide-react";
1313
import { LearningsBadge } from "@/components/common/learnings-badge";
14+
import { LearningApplicationBadge } from "@/components/common/learning-application-badge";
1415
import { HostBadge } from "@/components/common/host-badge";
1516
import { PageHeader } from "@/components/common/page-header";
1617
import { StatCard } from "@/components/common/stat-card";
@@ -96,7 +97,7 @@ export default function DashboardPage() {
9697
const approvedSharedSkills = (sharedSkills ?? []).filter(
9798
(p) => agentPlaybookStatusLabel(p) === "APPROVED",
9899
);
99-
const currentPreferences = (preferences ?? []).filter((p) => p.status == null);
100+
const currentPreferences = (preferences ?? []).filter((p) => p.status == null);
100101
const statsByRule = useMemo(() => {
101102
const map = new Map<string, PlaybookApplicationStat>();
102103
for (const s of topApplied ?? []) {
@@ -285,7 +286,7 @@ export default function DashboardPage() {
285286
<span className="text-[11px] text-muted-foreground">
286287
{learningScope(item.kind)}
287288
</span>
288-
<LearningCitationStatBadge stat={stat} />
289+
<LearningApplicationBadge stat={stat} />
289290
</div>
290291
</div>
291292
</div>
@@ -404,17 +405,3 @@ function learningIcon(kind: RecentLearningKind) {
404405
function learningKindLabel(kind: RecentLearningKind): string {
405406
return kind === "preference" ? "preference" : "skill";
406407
}
407-
408-
function LearningCitationStatBadge({
409-
stat,
410-
}: {
411-
stat: PlaybookApplicationStat | undefined;
412-
}) {
413-
if (!stat || stat.applied_count === 0) return null;
414-
const last = formatRelative(stat.last_applied_at);
415-
return (
416-
<Badge variant="secondary" className="h-5 text-[10px]">
417-
Referenced {stat.applied_count}×{stat.last_applied_at ? ` · ${last}` : ""}
418-
</Badge>
419-
);
420-
}

plugin/dashboard/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export interface SessionTurn {
9999
export interface SessionSummary {
100100
session_id: string;
101101
turn_count: number;
102+
/** Assistant turns with at least one verified reference. Legacy field name. */
102103
learning_interaction_count: number;
103104
/** Unique learning identities recorded in the session injection registry. */
104105
injected_learning_count: number;
@@ -116,6 +117,7 @@ export interface SessionSummary {
116117
export interface SessionDetail {
117118
session_id: string;
118119
turns: SessionTurn[];
120+
/** Assistant turns with at least one verified reference. Legacy field name. */
119121
learning_interaction_count: number;
120122
/** Unique learning identities recorded in the session injection registry. */
121123
injected_learning_count: number;

plugin/src/claude_smart/cs_cite.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,17 +282,19 @@ def parse_text_citations(text: str) -> list[str]:
282282
instruction requires the citation marker to be final.
283283
"""
284284
old_matches = [
285-
(m.start(), "ids", m.group("ids"))
285+
(m.start(), m.end(), "ids", m.group("ids"))
286286
for m in _TEXT_CITATION_LINE_RE.finditer(text or "")
287287
]
288288
new_matches = [
289-
(m.start(), "links", m.group("body"))
289+
(m.start(), m.end(), "links", m.group("body"))
290290
for m in _CITED_LINK_LINE_RE.finditer(text or "")
291291
]
292292
matches = old_matches + new_matches
293293
if not matches:
294294
return []
295-
_, kind, value = max(matches, key=lambda item: item[0])
295+
_, end, kind, value = max(matches, key=lambda item: item[0])
296+
if (text or "")[end:].strip():
297+
return []
296298
if kind == "ids":
297299
return _parse_id_tokens(value)
298300
return _parse_dashboard_link_tokens(value)

plugin/src/claude_smart/events/stop.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -287,21 +287,17 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
287287
if not cited_ids:
288288
return []
289289
registry = state.read_injected(session_id)
290-
registry_by_casefold = {
291-
key.casefold(): value for key, value in registry.items()
292-
}
293290
seen_citations: set[str] = set()
294291
seen_entries: set[str] = set()
295292
resolved: list[dict[str, Any]] = []
296293
for cid in cited_ids:
297-
normalized_cid = cid.casefold()
298-
if normalized_cid in seen_citations:
294+
if cid in seen_citations:
299295
continue
300-
seen_citations.add(normalized_cid)
301-
entry = _registry_entry_for_citation(registry, registry_by_casefold, cid)
296+
seen_citations.add(cid)
297+
entry = _registry_entry_for_citation(registry, cid)
302298
if not entry:
303299
continue
304-
canonical_id = _canonical_registry_entry_id(entry, normalized_cid)
300+
canonical_id = _canonical_registry_entry_id(entry, cid)
305301
if canonical_id in seen_entries:
306302
continue
307303
seen_entries.add(canonical_id)
@@ -333,16 +329,15 @@ def _canonical_registry_entry_id(entry: dict[str, Any], fallback: str) -> str:
333329
source_kind = "profile" if kind == "profile" else str(
334330
entry.get("source_kind") or ""
335331
)
336-
return f"{kind}:{source_kind}:{real_id}".casefold()
332+
return f"{kind}:{source_kind}:{real_id}"
337333
entry_id = entry.get("id")
338334
if isinstance(entry_id, str) and entry_id:
339-
return entry_id.casefold()
335+
return entry_id
340336
return fallback
341337

342338

343339
def _registry_entry_for_citation(
344340
registry: dict[str, dict[str, Any]],
345-
registry_by_casefold: dict[str, dict[str, Any]],
346341
citation: str,
347342
) -> dict[str, Any] | None:
348343
"""Resolve an exact rank id or a stable dashboard-route citation token.
@@ -353,10 +348,7 @@ def _registry_entry_for_citation(
353348
id (the compatibility path for entries without a real id).
354349
"""
355350
if not citation.startswith("route:"):
356-
entry = registry.get(citation)
357-
if entry is not None:
358-
return entry
359-
return registry_by_casefold.get(citation.casefold())
351+
return registry.get(citation)
360352
try:
361353
_, kind, source_kind, real_id = citation.split(":", 3)
362354
except ValueError:

tests/test_cs_cite.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,15 @@ def test_parse_text_citations_uses_last_marker_line() -> None:
168168
assert cs_cite.parse_text_citations(text) == ["p2-2222"]
169169

170170

171+
def test_parse_text_citations_requires_marker_to_be_final() -> None:
172+
text = (
173+
"✨ Learning referenced: "
174+
"[git safety](http://localhost:3001/rules/s1-1111)\n"
175+
"The answer continues after the marker."
176+
)
177+
assert cs_cite.parse_text_citations(text) == []
178+
179+
171180
def test_parse_text_citations_accepts_uppercase_and_cs_prefixes() -> None:
172181
text = "Done.\n\n✨ 2 claude-smart learnings applied [cs:CS:P1-AB12,Cs:S2-CD34]"
173182
assert cs_cite.parse_text_citations(text) == ["p1-ab12", "s2-cd34"]

tests/test_events.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,6 +1472,39 @@ def test_stop_deduplicates_learning_aliases(session_dir) -> None:
14721472
]
14731473

14741474

1475+
def test_stop_keeps_case_distinct_learning_ids(session_dir) -> None:
1476+
"""Stable real ids remain exact even when they differ only by case."""
1477+
state.append_injected(
1478+
"s1",
1479+
[
1480+
{
1481+
"id": "p1-abcd",
1482+
"kind": "profile",
1483+
"title": "uppercase",
1484+
"content": "uppercase",
1485+
"real_id": "AbCd",
1486+
"source_kind": "profile",
1487+
"ts": 0,
1488+
},
1489+
{
1490+
"id": "p2-abcd",
1491+
"kind": "profile",
1492+
"title": "lowercase",
1493+
"content": "lowercase",
1494+
"real_id": "abcd",
1495+
"source_kind": "profile",
1496+
"ts": 0,
1497+
},
1498+
],
1499+
)
1500+
1501+
cited = stop._resolve_cited_items(
1502+
"s1", ["route:profile:profile:AbCd", "route:profile:profile:abcd"]
1503+
)
1504+
1505+
assert [item["real_id"] for item in cited] == ["AbCd", "abcd"]
1506+
1507+
14751508
def test_stop_handles_missing_transcript_file(session_dir, monkeypatch) -> None:
14761509
"""``transcript_path`` points at a nonexistent file → no crash, empty record."""
14771510
monkeypatch.setattr(

0 commit comments

Comments
 (0)