diff --git a/CHANGELOG.md b/CHANGELOG.md index a0705a34..a6f9be34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes +- Course-correction matching normalizes prompts and keywords to Unicode NFC, so composed and decomposed accents match. Stored prompt summaries and the 60-second correction window are unchanged. Fixes [#573](https://github.com/Tencent/teamai-cli/issues/573). - Course-correction detection matches keywords in space-separated scripts as whole words, so Spanish "segundo" no longer counts as `undo` (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). - MCP `requires` is resolved from `PATH` (including Windows `PATHEXT`), so `teamai mcp inject` no longer skips servers such as `uvx` on Windows ([#540](https://github.com/Tencent/teamai-cli/pull/540), for [#539](https://github.com/Tencent/teamai-cli/issues/539)). - The GitHub and CNB providers resolve their CLI to a launchable absolute path and start it through cross-spawn, so on Windows they no longer answer "installed" while every call fails silently ([#520](https://github.com/Tencent/teamai-cli/pull/520)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 6bed9187..2d66f296 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1254,6 +1254,8 @@ sharing: The prompt is checked when the `UserPromptSubmit` hook captures it, so a change to the team keywords applies to new prompts after the next `teamai pull`; sessions recorded earlier are not re-evaluated. +Matching normalizes both the prompt and keywords to Unicode NFC. For example, `réessaye` matches `re\u0301essaye`, where `\u0301` is a combining acute accent. Accents remain significant, so `reessaye` does not match. Normalization applies only to matching and does not change the stored prompt summary or the 60-second correction window. + Intervention data is automatically aggregated and reported to the team's `stats/.yaml` during `teamai pull`, and shown in the "Session Autonomy" leaderboard of `teamai digest`, with team averages and per-person intervention rate rankings — useful for verifying whether a skill/rule reduces intervention rates after rollout. Tools without a transcript (e.g. Cursor) degrade gracefully, tracking only `correction`. #### Conversation Volume & Token Usage diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2b143d37..c359b13d 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1221,6 +1221,8 @@ sharing: 匹配在 `UserPromptSubmit` hook 捕获 prompt 时完成,因此修改团队纠偏词后,下一次 `teamai pull` 之后的新 prompt 才会生效;之前记录的会话不会重新评估。 +匹配时,prompt 和纠偏词都会转换为 Unicode NFC 形式。例如,`réessaye` 可以匹配 `re\u0301essaye`,其中 `\u0301` 是组合尖音符。重音符号仍有区别,因此 `reessaye` 不匹配。规范化仅用于匹配,不会改变已存储的 prompt 摘要或 60 秒的纠偏时间窗口。 + 干预数据会随 `teamai pull` 自动聚合上报到团队 `stats/.yaml`,并在 `teamai digest` 的「会话自主性」榜单中给出团队均值与人均干预率排行,可用于验证某个 skill / rule 上线后干预率是否下降。无 transcript 的工具(如 Cursor)会优雅降级,只统计 `correction`。 #### 对话量与 Token 用量 diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index 62100c2a..19d762bd 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -163,6 +163,37 @@ describe('parseHookEvent', () => { } }); + it.each([ + ['NFC keyword and NFD prompt', 'r\u00e9essaye', 're\u0301essaye'], + ['NFD keyword and NFC prompt', 're\u0301essaye', 'r\u00e9essaye'], + ['NFD keyword and uppercase prompt', 're\u0301essaye', 'R\u00c9ESSAYE!'], + ['NFD prompt past the summary limit', 'r\u00e9essaye', `${'x '.repeat(150)}re\u0301essaye`], + ])('matches canonically equivalent text: %s', async (_label, keyword, prompt) => { + const event = await parseHookEvent( + JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }), + 'claude', + { correctionKeywords: [keyword] }, + ); + expect(event?.correction).toBe(true); + expect(event?.promptSummary).toBe(prompt.slice(0, 200)); + }); + + it.each([ + ['missing accent', 'reessaye'], + ['different accent', 're\u0300essaye'], + ['letter prefix', 'pre\u0301essaye'], + ['letter suffix', 're\u0301essayez'], + ['underscore prefix', 'test_re\u0301essaye'], + ['underscore suffix', 're\u0301essaye_it'], + ])('does not match a team keyword with a %s', async (_label, prompt) => { + const event = await parseHookEvent( + JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }), + 'claude', + { correctionKeywords: ['r\u00e9essaye'] }, + ); + expect(event?.correction).toBe(false); + }); + it('checks the full prompt, not only the 200-char summary', async () => { const prompt = `${'x '.repeat(150)}wrong`; const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }); @@ -734,6 +765,26 @@ describe('parseHookEvent interventions', () => { describe('rebuildSessions interventions', () => { const now = new Date().toISOString(); + it.each([ + ['NFC keyword and NFD prompt', 'r\u00e9essaye', 're\u0301essaye'], + ['NFD keyword and NFC prompt', 're\u0301essaye', 'r\u00e9essaye'], + ])('counts a Unicode correction within the time window: %s', async (_label, keyword, prompt) => { + const event = await parseHookEvent( + JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }), + 'claude', + { correctionKeywords: [keyword] }, + ); + if (!event) throw new Error('Expected a prompt-submit event'); + + for (const [gap, expected] of [[0, 1], [60_000, 1], [60_001, 0]]) { + const sessions = rebuildSessions([ + { type: 'stop', timestamp: now, sessionId: 's', tool: 'claude' }, + { ...event, timestamp: new Date(new Date(now).getTime() + gap).toISOString() }, + ]); + expect(sessions[0]?.interventions.correction, `gap ${gap}`).toBe(expected); + } + }); + it('defaults to zero interventions', () => { const sessions = rebuildSessions([ { type: 'session_start', timestamp: now, sessionId: 's1', tool: 'claude', cwd: '/p' }, @@ -742,6 +793,14 @@ describe('rebuildSessions interventions', () => { expect(sessions[0].interventionCount).toBe(0); }); + it('normalizes built-in keywords when a legacy event has no correction flag', () => { + const sessions = rebuildSessions([ + { type: 'stop', timestamp: now, sessionId: 's', tool: 'claude' }, + { type: 'prompt_submit', timestamp: now, sessionId: 's', tool: 'claude', promptSummary: '\u3061\u304b\u3099\u3046' }, + ]); + expect(sessions[0]?.interventions.correction).toBe(1); + }); + it('takes interrupt/toolReject from the latest stop snapshot (idempotent)', () => { const sessions = rebuildSessions([ { type: 'session_start', timestamp: now, sessionId: 's1', tool: 'claude', cwd: '/p' }, diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index 9370aa51..1e577269 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -824,7 +824,7 @@ function wordBoundaryPattern(keyword: string): RegExp { /** True when `lower` contains `keyword`, whole-word for spaced scripts, substring otherwise. */ function containsKeyword(lower: string, keyword: string): boolean { - const k = keyword.trim().toLowerCase(); + const k = keyword.trim().normalize('NFC').toLowerCase(); if (!k) return false; if (UNSPACED_SCRIPT_RE.test(k)) return lower.includes(k); return wordBoundaryPattern(k).test(lower); @@ -836,7 +836,7 @@ function containsKeyword(lower: string, keyword: string): boolean { */ function isCorrectionPrompt(text?: string, extraKeywords: readonly string[] = []): boolean { if (!text) return false; - const lower = text.toLowerCase(); + const lower = text.normalize('NFC').toLowerCase(); return [...CORRECTION_KEYWORDS, ...extraKeywords].some((k) => containsKeyword(lower, k)); }