@zhuan-ai/zhuanspec 2.16.6 → 2.17.0

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.
@@ -70,6 +70,41 @@ function buildCorrectionSignal(content) {
70
70
  }
71
71
  return { kind: 'none', matchedKeywords: [], isRequirementChange: false };
72
72
  }
73
+ /**
74
+ * 对话经验解决信号检测。
75
+ *
76
+ * 设计意图:
77
+ * - 只检测「问题已解决/找到根因」语义,不检测「问题存在」(那是 correctionSignal 的职责)。
78
+ * - 关键词刻意保守(要求语气有确定性),减少误报——宁可漏报,不要在用户只是描述问题时就触发。
79
+ * - 触发后不立即弹窗,只打标;Stop hook 会在会话结束时统一判断是否注入精炼提示。
80
+ */
81
+ const RESOLUTION_KEYWORDS = [
82
+ // 中文——明确表达"已解决/已找到"
83
+ '解决了', '已解决', '找到了', '找到原因', '找到根因',
84
+ '原因是', '根因是', '根本原因', '问题出在', '问题在于',
85
+ '定位到了', '定位到问题', '排查出来了', '查清楚了',
86
+ '原来是', '原来问题', '确认是', '确认原因',
87
+ // 英文
88
+ 'fixed', 'resolved', 'found the cause', 'found the root cause',
89
+ 'root cause is', 'the issue was', 'the problem was',
90
+ 'figured it out', 'tracked it down',
91
+ ];
92
+ const RESOLUTION_CONTEXT_MAX_LEN = 300;
93
+ export function buildResolutionSignal(content) {
94
+ const lower = content.toLowerCase();
95
+ const matched = [];
96
+ for (const kw of RESOLUTION_KEYWORDS) {
97
+ if (lower.includes(kw.toLowerCase()))
98
+ matched.push(kw);
99
+ if (matched.length >= 4)
100
+ break;
101
+ }
102
+ return {
103
+ detected: matched.length > 0,
104
+ matchedKeywords: matched,
105
+ context: matched.length > 0 ? content.slice(0, RESOLUTION_CONTEXT_MAX_LEN) : '',
106
+ };
107
+ }
73
108
  /**
74
109
  * Detect trigger type from user input content
75
110
  */
@@ -230,6 +265,7 @@ export async function userInputHook(content, responseTimeMs) {
230
265
  const progressPath = path.join(metricsDir, 'progress.json');
231
266
  // Create input record
232
267
  const correctionSignal = buildCorrectionSignal(content);
268
+ const resolutionSignal = buildResolutionSignal(content);
233
269
  const inputRecord = {
234
270
  inputId: `input-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
235
271
  timestamp: getBeijingTime(),
@@ -240,6 +276,8 @@ export async function userInputHook(content, responseTimeMs) {
240
276
  affectedFiles: extractAffectedFiles(content),
241
277
  responseTimeMs,
242
278
  correctionSignal,
279
+ // 只有检测到解决信号时才写入,避免 user_inputs.json 膨胀
280
+ ...(resolutionSignal.detected ? { resolutionSignal } : {}),
243
281
  };
244
282
  // Load existing user inputs or create new
245
283
  let userInputs;
package/dist/core/init.js CHANGED
@@ -1494,6 +1494,16 @@ export class InitCommand {
1494
1494
  },
1495
1495
  ],
1496
1496
  },
1497
+ {
1498
+ matcher: 'Read|Grep|Glob',
1499
+ hooks: [
1500
+ {
1501
+ type: 'command',
1502
+ command: 'zhuanspec-hook knowledge-usage --json',
1503
+ async: true,
1504
+ },
1505
+ ],
1506
+ },
1497
1507
  ],
1498
1508
  Stop: [
1499
1509
  {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Knowledge Funnel(沉淀漏斗指标)
3
+ *
4
+ * 量化「纠偏信号 → 追问 → 落库 → 被引用」四节点漏斗各跳转化率,
5
+ * 用于回答"知识沉淀机制是否健康、哪一跳流失最多"。
6
+ *
7
+ * 数据来源(跨 change 扫描 active + archive):
8
+ * - 纠偏信号数:每个 change 的 metrics/user_inputs.json 中 correctionSignal.kind==='hard'
9
+ * && postBaseline===true 的条数(与 collect-knowledge 的口径一致)。
10
+ * - 追问数:每个 change 的 metrics/progress.json 中 askedPitfallSaved===true 的 change 数。
11
+ * - 落库数:knowledge 三分区现有条目总数(由调用方传入,避免重复扫描)。
12
+ * - 被引用数:knowledge/.metrics/usage.json 的 totalRefs。
13
+ * - ProjectWiki 被引用数:knowledge/.metrics/usage.json 的 projectWikiTotalRefs。
14
+ *
15
+ * 全程容错:任一文件读失败/JSON 损坏均跳过,绝不抛错。
16
+ */
17
+ /** 纠偏信号判定口径(与 collect-knowledge.ts readCorrectionStats 一致)。 */
18
+ export declare function countHardCorrections(inputs: Array<Record<string, unknown>>): number;
19
+ /** 漏斗统计结果 */
20
+ export interface FunnelStats {
21
+ /** 纠偏信号数(hard) */
22
+ correctionSignals: number;
23
+ /** 追问数(askedPitfallSaved===true 的 change 数) */
24
+ askedPitfalls: number;
25
+ /** 落库数(= knowledge 三分区条目总数) */
26
+ archivedEntries: number;
27
+ /** 被引用数(usage.json totalRefs) */
28
+ referencedRefs: number;
29
+ /** ProjectWiki 被引用数(usage.json projectWikiTotalRefs) */
30
+ projectWikiReferencedRefs: number;
31
+ /** ProjectWiki 被引用数按 service 聚合 */
32
+ projectWikiReferencedRefsByService: Record<string, number>;
33
+ /** 五跳转化率(0~1,分母为 0 记 0) */
34
+ conversions: {
35
+ signalToAsked: number;
36
+ askedToArchived: number;
37
+ archivedToReferenced: number;
38
+ signalToArchived: number;
39
+ signalToReferenced: number;
40
+ };
41
+ /** 扫描到的 change 数(含 archive),用于自检 */
42
+ scannedChanges: number;
43
+ }
44
+ /**
45
+ * 顶层聚合:拼装 FunnelStats。
46
+ * @param archivedEntries 落库数(由调用方传入 stats.total,避免重复 scanKnowledgeEntries)
47
+ */
48
+ export declare function computeFunnelStats(zhuanspecDir: string, knowledgeDir: string, archivedEntries: number): Promise<FunnelStats>;
49
+ //# sourceMappingURL=knowledge-funnel.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Knowledge Funnel(沉淀漏斗指标)
3
+ *
4
+ * 量化「纠偏信号 → 追问 → 落库 → 被引用」四节点漏斗各跳转化率,
5
+ * 用于回答"知识沉淀机制是否健康、哪一跳流失最多"。
6
+ *
7
+ * 数据来源(跨 change 扫描 active + archive):
8
+ * - 纠偏信号数:每个 change 的 metrics/user_inputs.json 中 correctionSignal.kind==='hard'
9
+ * && postBaseline===true 的条数(与 collect-knowledge 的口径一致)。
10
+ * - 追问数:每个 change 的 metrics/progress.json 中 askedPitfallSaved===true 的 change 数。
11
+ * - 落库数:knowledge 三分区现有条目总数(由调用方传入,避免重复扫描)。
12
+ * - 被引用数:knowledge/.metrics/usage.json 的 totalRefs。
13
+ * - ProjectWiki 被引用数:knowledge/.metrics/usage.json 的 projectWikiTotalRefs。
14
+ *
15
+ * 全程容错:任一文件读失败/JSON 损坏均跳过,绝不抛错。
16
+ */
17
+ import path from 'path';
18
+ import { promises as fs } from 'fs';
19
+ import { FileSystemUtils } from '../../utils/file-system.js';
20
+ import { readUsageStats } from '../hooks/knowledge-usage.js';
21
+ /** 纠偏信号判定口径(与 collect-knowledge.ts readCorrectionStats 一致)。 */
22
+ export function countHardCorrections(inputs) {
23
+ let hard = 0;
24
+ for (const entry of inputs) {
25
+ if (!entry || typeof entry !== 'object')
26
+ continue;
27
+ const sig = entry['correctionSignal'];
28
+ const postBaseline = entry['postBaseline'] === true;
29
+ if (sig && sig.kind === 'hard' && postBaseline)
30
+ hard += 1;
31
+ }
32
+ return hard;
33
+ }
34
+ /** 安全读取并解析 JSON,失败返回 null。 */
35
+ async function readJsonSafe(filePath) {
36
+ try {
37
+ if (!(await FileSystemUtils.fileExists(filePath)))
38
+ return null;
39
+ return JSON.parse(await FileSystemUtils.readFile(filePath));
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** 累加单个 change 目录的纠偏信号数与追问数。 */
46
+ async function accumulateChange(metricsDir, acc) {
47
+ const userInputs = await readJsonSafe(path.join(metricsDir, 'user_inputs.json'));
48
+ if (userInputs && Array.isArray(userInputs.inputs)) {
49
+ acc.correctionSignals += countHardCorrections(userInputs.inputs);
50
+ }
51
+ const progress = await readJsonSafe(path.join(metricsDir, 'progress.json'));
52
+ if (progress && progress.askedPitfallSaved === true) {
53
+ acc.askedPitfalls += 1;
54
+ }
55
+ acc.scannedChanges += 1;
56
+ }
57
+ /** 列出目录下的子目录名(容错,失败返回空)。 */
58
+ async function listDirs(dir) {
59
+ try {
60
+ const entries = await fs.readdir(dir, { withFileTypes: true });
61
+ return entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name);
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ /** 扫描所有 change(active + archive)聚合纠偏信号数与追问数。 */
68
+ async function scanChangeMetrics(zhuanspecDir) {
69
+ const acc = { correctionSignals: 0, askedPitfalls: 0, scannedChanges: 0 };
70
+ const changesDir = path.join(zhuanspecDir, 'changes');
71
+ const archiveDir = path.join(changesDir, 'archive');
72
+ // active changes(排除 archive 目录本身)
73
+ for (const id of await listDirs(changesDir)) {
74
+ if (id === 'archive')
75
+ continue;
76
+ await accumulateChange(path.join(changesDir, id, 'metrics'), acc);
77
+ }
78
+ // archived changes
79
+ for (const id of await listDirs(archiveDir)) {
80
+ await accumulateChange(path.join(archiveDir, id, 'metrics'), acc);
81
+ }
82
+ return acc;
83
+ }
84
+ /** 安全转化率:分母为 0 记 0,结果保留 4 位小数。 */
85
+ function rate(numerator, denominator) {
86
+ if (denominator <= 0)
87
+ return 0;
88
+ return Math.round((numerator / denominator) * 10000) / 10000;
89
+ }
90
+ function getProjectWikiServiceName(projectWikiRelPath) {
91
+ const segments = projectWikiRelPath.split(/[\\/]+/).filter(Boolean);
92
+ const wikiIndex = segments.indexOf('.project-wiki');
93
+ if (wikiIndex <= 0)
94
+ return null;
95
+ return segments[wikiIndex - 1] || null;
96
+ }
97
+ function aggregateProjectWikiRefsByService(byProjectWikiFile) {
98
+ const byService = {};
99
+ for (const [file, counter] of Object.entries(byProjectWikiFile)) {
100
+ const service = getProjectWikiServiceName(file);
101
+ if (!service)
102
+ continue;
103
+ byService[service] = (byService[service] ?? 0) + (counter.refCount ?? 0);
104
+ }
105
+ return Object.fromEntries(Object.entries(byService).sort(([a], [b]) => a.localeCompare(b)));
106
+ }
107
+ /**
108
+ * 顶层聚合:拼装 FunnelStats。
109
+ * @param archivedEntries 落库数(由调用方传入 stats.total,避免重复 scanKnowledgeEntries)
110
+ */
111
+ export async function computeFunnelStats(zhuanspecDir, knowledgeDir, archivedEntries) {
112
+ const changeScan = await scanChangeMetrics(zhuanspecDir);
113
+ const usage = await readUsageStats(knowledgeDir);
114
+ const referencedRefs = usage.totalRefs;
115
+ const projectWikiReferencedRefs = usage.projectWikiTotalRefs;
116
+ const projectWikiReferencedRefsByService = aggregateProjectWikiRefsByService(usage.byProjectWikiFile);
117
+ const { correctionSignals, askedPitfalls, scannedChanges } = changeScan;
118
+ return {
119
+ correctionSignals,
120
+ askedPitfalls,
121
+ archivedEntries,
122
+ referencedRefs,
123
+ projectWikiReferencedRefs,
124
+ projectWikiReferencedRefsByService,
125
+ conversions: {
126
+ signalToAsked: rate(askedPitfalls, correctionSignals),
127
+ askedToArchived: rate(archivedEntries, askedPitfalls),
128
+ archivedToReferenced: rate(referencedRefs, archivedEntries),
129
+ signalToArchived: rate(archivedEntries, correctionSignals),
130
+ signalToReferenced: rate(referencedRefs, correctionSignals),
131
+ },
132
+ scannedChanges,
133
+ };
134
+ }
135
+ //# sourceMappingURL=knowledge-funnel.js.map