@zhuan-ai/zhuanspec 2.17.0 → 2.17.1
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.
- package/dist/core/hooks/knowledge-usage.d.ts +29 -0
- package/dist/core/hooks/knowledge-usage.js +160 -11
- package/dist/core/init.js +1 -1
- package/dist/core/metrics/knowledge-funnel.d.ts +2 -0
- package/dist/core/metrics/knowledge-funnel.js +9 -0
- package/dist/core/templates/codex-hooks-template.js +1 -1
- package/package.json +1 -1
|
@@ -14,6 +14,7 @@ interface UsageCounter {
|
|
|
14
14
|
refCount: number;
|
|
15
15
|
lastRefAt: string;
|
|
16
16
|
}
|
|
17
|
+
type ProjectWikiRefSource = 'file' | 'llmwiki' | string;
|
|
17
18
|
/** usage.json 落点结构:zhuanspec/knowledge/.metrics/usage.json */
|
|
18
19
|
export interface KnowledgeUsageStats {
|
|
19
20
|
schemaVersion: 1;
|
|
@@ -26,6 +27,10 @@ export interface KnowledgeUsageStats {
|
|
|
26
27
|
byFile: Record<string, UsageCounter>;
|
|
27
28
|
/** 键 = 相对 ZhuanSpec 根的 ProjectWiki 路径,如 service/.project-wiki/project.md */
|
|
28
29
|
byProjectWikiFile: Record<string, UsageCounter>;
|
|
30
|
+
/** 键 = ProjectWiki 引用来源,如 file / llmwiki */
|
|
31
|
+
projectWikiRefsBySource: Record<string, UsageCounter>;
|
|
32
|
+
/** 键 = ProjectWiki 文件路径,值 = 该文件按来源拆分的引用计数 */
|
|
33
|
+
byProjectWikiFileSource: Record<string, Record<string, UsageCounter>>;
|
|
29
34
|
lastUpdatedAt: string;
|
|
30
35
|
}
|
|
31
36
|
/** 单次上报输入(清晰可复用的上报接口契约) */
|
|
@@ -35,6 +40,22 @@ export interface KnowledgeRefEvent {
|
|
|
35
40
|
/** 引用时间,缺省取 getBeijingTime() */
|
|
36
41
|
refAt?: string;
|
|
37
42
|
}
|
|
43
|
+
export interface ProjectWikiEffectiveRef {
|
|
44
|
+
/** llmwiki 返回的 ProjectWiki 文档路径,支持绝对路径或相对 ZhuanSpec 根路径 */
|
|
45
|
+
path: string;
|
|
46
|
+
/** llmwiki 召回分数,仅用于后续分析,不参与是否计数的判定 */
|
|
47
|
+
score?: number;
|
|
48
|
+
/** 调用方判定该文档为有效引用的简要原因 */
|
|
49
|
+
reason?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface ProjectWikiRefEvent {
|
|
52
|
+
source?: ProjectWikiRefSource;
|
|
53
|
+
query?: string;
|
|
54
|
+
project?: string;
|
|
55
|
+
effectiveRefs: ProjectWikiEffectiveRef[];
|
|
56
|
+
/** 引用时间,缺省取 getBeijingTime() */
|
|
57
|
+
refAt?: string;
|
|
58
|
+
}
|
|
38
59
|
interface KnowledgeUsageOptions {
|
|
39
60
|
json?: boolean;
|
|
40
61
|
file?: string;
|
|
@@ -43,6 +64,14 @@ interface KnowledgeUsageOptions {
|
|
|
43
64
|
export declare function getUsagePath(knowledgeDir: string): string;
|
|
44
65
|
/** 读取 usage.json,缺失/损坏均返回空结构(容错,不抛) */
|
|
45
66
|
export declare function readUsageStats(knowledgeDir: string): Promise<KnowledgeUsageStats>;
|
|
67
|
+
export declare function extractLlmwikiEffectiveRefsFromHookInput(stdinData: Record<string, unknown>): ProjectWikiEffectiveRef[];
|
|
68
|
+
/**
|
|
69
|
+
* 上报 llmwiki 召回后被实际使用的 ProjectWiki 文档。
|
|
70
|
+
* 只统计调用方传入的 effectiveRefs,不统计全部召回结果;同一次事件内同一路径去重。
|
|
71
|
+
*
|
|
72
|
+
* @returns 实际累加的唯一文档数
|
|
73
|
+
*/
|
|
74
|
+
export declare function reportProjectWikiRefs(knowledgeDir: string, event: ProjectWikiRefEvent): Promise<number>;
|
|
46
75
|
/**
|
|
47
76
|
* 清晰可复用的上报接口:累加一次引用。
|
|
48
77
|
* - filePath 未命中 knowledge/ 三类目录 → no-op 返回 false。
|
|
@@ -28,6 +28,8 @@ function emptyUsage() {
|
|
|
28
28
|
byId: {},
|
|
29
29
|
byFile: {},
|
|
30
30
|
byProjectWikiFile: {},
|
|
31
|
+
projectWikiRefsBySource: {},
|
|
32
|
+
byProjectWikiFileSource: {},
|
|
31
33
|
lastUpdatedAt: '',
|
|
32
34
|
};
|
|
33
35
|
}
|
|
@@ -48,6 +50,12 @@ export async function readUsageStats(knowledgeDir) {
|
|
|
48
50
|
byProjectWikiFile: parsed.byProjectWikiFile && typeof parsed.byProjectWikiFile === 'object'
|
|
49
51
|
? parsed.byProjectWikiFile
|
|
50
52
|
: {},
|
|
53
|
+
projectWikiRefsBySource: parsed.projectWikiRefsBySource && typeof parsed.projectWikiRefsBySource === 'object'
|
|
54
|
+
? parsed.projectWikiRefsBySource
|
|
55
|
+
: {},
|
|
56
|
+
byProjectWikiFileSource: parsed.byProjectWikiFileSource && typeof parsed.byProjectWikiFileSource === 'object'
|
|
57
|
+
? parsed.byProjectWikiFileSource
|
|
58
|
+
: {},
|
|
51
59
|
lastUpdatedAt: typeof parsed.lastUpdatedAt === 'string' ? parsed.lastUpdatedAt : '',
|
|
52
60
|
};
|
|
53
61
|
}
|
|
@@ -95,6 +103,143 @@ function matchProjectWikiFile(repoRoot, filePath) {
|
|
|
95
103
|
return null;
|
|
96
104
|
return segments.join('/');
|
|
97
105
|
}
|
|
106
|
+
function normalizeProjectWikiFile(repoRoot, filePath) {
|
|
107
|
+
if (!filePath)
|
|
108
|
+
return null;
|
|
109
|
+
if (path.isAbsolute(filePath)) {
|
|
110
|
+
return matchProjectWikiFile(repoRoot, filePath);
|
|
111
|
+
}
|
|
112
|
+
return matchProjectWikiFile(repoRoot, path.join(repoRoot, filePath));
|
|
113
|
+
}
|
|
114
|
+
function bump(counter, refAt) {
|
|
115
|
+
return {
|
|
116
|
+
refCount: (counter?.refCount ?? 0) + 1,
|
|
117
|
+
lastRefAt: refAt,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function bumpProjectWikiRef(usage, relPath, source, refAt) {
|
|
121
|
+
usage.byProjectWikiFile[relPath] = bump(usage.byProjectWikiFile[relPath], refAt);
|
|
122
|
+
usage.projectWikiRefsBySource[source] = bump(usage.projectWikiRefsBySource[source], refAt);
|
|
123
|
+
usage.byProjectWikiFileSource[relPath] = usage.byProjectWikiFileSource[relPath] || {};
|
|
124
|
+
usage.byProjectWikiFileSource[relPath][source] = bump(usage.byProjectWikiFileSource[relPath][source], refAt);
|
|
125
|
+
usage.projectWikiTotalRefs += 1;
|
|
126
|
+
}
|
|
127
|
+
function isObject(value) {
|
|
128
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
129
|
+
}
|
|
130
|
+
function toEffectiveRefs(value) {
|
|
131
|
+
if (!Array.isArray(value))
|
|
132
|
+
return [];
|
|
133
|
+
const refs = [];
|
|
134
|
+
for (const item of value) {
|
|
135
|
+
if (typeof item === 'string') {
|
|
136
|
+
refs.push({ path: item });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!isObject(item))
|
|
140
|
+
continue;
|
|
141
|
+
const refPath = typeof item.path === 'string'
|
|
142
|
+
? item.path
|
|
143
|
+
: typeof item.file_path === 'string'
|
|
144
|
+
? item.file_path
|
|
145
|
+
: '';
|
|
146
|
+
if (!refPath)
|
|
147
|
+
continue;
|
|
148
|
+
refs.push({
|
|
149
|
+
path: refPath,
|
|
150
|
+
score: typeof item.score === 'number' ? item.score : undefined,
|
|
151
|
+
reason: typeof item.reason === 'string' ? item.reason : undefined,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return refs;
|
|
155
|
+
}
|
|
156
|
+
function extractProjectWikiRefEvent(stdinData, toolInput) {
|
|
157
|
+
const candidates = [stdinData, toolInput].filter(Boolean);
|
|
158
|
+
for (const candidate of candidates) {
|
|
159
|
+
const refs = toEffectiveRefs(candidate.projectWikiEffectiveRefs
|
|
160
|
+
?? candidate.project_wiki_effective_refs
|
|
161
|
+
?? candidate.effectiveRefs
|
|
162
|
+
?? candidate.effective_refs);
|
|
163
|
+
if (refs.length === 0)
|
|
164
|
+
continue;
|
|
165
|
+
return {
|
|
166
|
+
source: typeof candidate.source === 'string' ? candidate.source : 'llmwiki',
|
|
167
|
+
query: typeof candidate.query === 'string' ? candidate.query : undefined,
|
|
168
|
+
project: typeof candidate.project === 'string' ? candidate.project : undefined,
|
|
169
|
+
effectiveRefs: refs,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function collectStringValues(value, output = []) {
|
|
175
|
+
if (typeof value === 'string') {
|
|
176
|
+
output.push(value);
|
|
177
|
+
return output;
|
|
178
|
+
}
|
|
179
|
+
if (Array.isArray(value)) {
|
|
180
|
+
for (const item of value)
|
|
181
|
+
collectStringValues(item, output);
|
|
182
|
+
return output;
|
|
183
|
+
}
|
|
184
|
+
if (isObject(value)) {
|
|
185
|
+
for (const item of Object.values(value))
|
|
186
|
+
collectStringValues(item, output);
|
|
187
|
+
}
|
|
188
|
+
return output;
|
|
189
|
+
}
|
|
190
|
+
function extractProjectWikiPaths(text) {
|
|
191
|
+
const matches = text.match(/[^\s"'`<>]*\.project-wiki\/[^\s"'`<>]*?\.md/g) || [];
|
|
192
|
+
return matches
|
|
193
|
+
.map((item) => item.replace(/[),.;:,。;:]+$/g, ''))
|
|
194
|
+
.filter(Boolean);
|
|
195
|
+
}
|
|
196
|
+
export function extractLlmwikiEffectiveRefsFromHookInput(stdinData) {
|
|
197
|
+
const toolInput = stdinData.tool_input;
|
|
198
|
+
const command = typeof toolInput?.command === 'string' ? toolInput.command : '';
|
|
199
|
+
if (!/\bllmwiki\b/.test(command))
|
|
200
|
+
return [];
|
|
201
|
+
const toolResult = stdinData.tool_result;
|
|
202
|
+
const searchText = [
|
|
203
|
+
command,
|
|
204
|
+
...collectStringValues(toolResult),
|
|
205
|
+
].join('\n');
|
|
206
|
+
return Array.from(new Set(extractProjectWikiPaths(searchText))).map((refPath) => ({
|
|
207
|
+
path: refPath,
|
|
208
|
+
reason: 'llmwiki query result referenced by Bash output',
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 上报 llmwiki 召回后被实际使用的 ProjectWiki 文档。
|
|
213
|
+
* 只统计调用方传入的 effectiveRefs,不统计全部召回结果;同一次事件内同一路径去重。
|
|
214
|
+
*
|
|
215
|
+
* @returns 实际累加的唯一文档数
|
|
216
|
+
*/
|
|
217
|
+
export async function reportProjectWikiRefs(knowledgeDir, event) {
|
|
218
|
+
const repoRoot = path.dirname(path.dirname(path.resolve(knowledgeDir)));
|
|
219
|
+
const source = event.source || 'llmwiki';
|
|
220
|
+
const refAt = event.refAt || getBeijingTime();
|
|
221
|
+
const uniqueRefs = new Set();
|
|
222
|
+
for (const ref of event.effectiveRefs || []) {
|
|
223
|
+
const relPath = normalizeProjectWikiFile(repoRoot, ref.path);
|
|
224
|
+
if (relPath)
|
|
225
|
+
uniqueRefs.add(relPath);
|
|
226
|
+
}
|
|
227
|
+
if (uniqueRefs.size === 0)
|
|
228
|
+
return 0;
|
|
229
|
+
try {
|
|
230
|
+
const usage = await readUsageStats(knowledgeDir);
|
|
231
|
+
for (const relPath of uniqueRefs) {
|
|
232
|
+
bumpProjectWikiRef(usage, relPath, source, refAt);
|
|
233
|
+
}
|
|
234
|
+
usage.lastUpdatedAt = refAt;
|
|
235
|
+
await FileSystemUtils.createDirectory(path.dirname(getUsagePath(knowledgeDir)));
|
|
236
|
+
await atomicWriteJson(getUsagePath(knowledgeDir), usage);
|
|
237
|
+
return uniqueRefs.size;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
98
243
|
/**
|
|
99
244
|
* 清晰可复用的上报接口:累加一次引用。
|
|
100
245
|
* - filePath 未命中 knowledge/ 三类目录 → no-op 返回 false。
|
|
@@ -106,7 +251,7 @@ function matchProjectWikiFile(repoRoot, filePath) {
|
|
|
106
251
|
export async function reportKnowledgeRef(knowledgeDir, event) {
|
|
107
252
|
const relPath = matchKnowledgeFile(knowledgeDir, event.filePath);
|
|
108
253
|
const repoRoot = path.dirname(path.dirname(path.resolve(knowledgeDir)));
|
|
109
|
-
const projectWikiRelPath =
|
|
254
|
+
const projectWikiRelPath = normalizeProjectWikiFile(repoRoot, event.filePath);
|
|
110
255
|
if (!relPath && !projectWikiRelPath)
|
|
111
256
|
return false;
|
|
112
257
|
const refAt = event.refAt || getBeijingTime();
|
|
@@ -124,19 +269,14 @@ export async function reportKnowledgeRef(knowledgeDir, event) {
|
|
|
124
269
|
}
|
|
125
270
|
try {
|
|
126
271
|
const usage = await readUsageStats(knowledgeDir);
|
|
127
|
-
const bump = (counter) => ({
|
|
128
|
-
refCount: (counter?.refCount ?? 0) + 1,
|
|
129
|
-
lastRefAt: refAt,
|
|
130
|
-
});
|
|
131
272
|
if (relPath) {
|
|
132
|
-
usage.byFile[relPath] = bump(usage.byFile[relPath]);
|
|
273
|
+
usage.byFile[relPath] = bump(usage.byFile[relPath], refAt);
|
|
133
274
|
if (id)
|
|
134
|
-
usage.byId[id] = bump(usage.byId[id]);
|
|
275
|
+
usage.byId[id] = bump(usage.byId[id], refAt);
|
|
135
276
|
usage.totalRefs += 1;
|
|
136
277
|
}
|
|
137
278
|
if (projectWikiRelPath) {
|
|
138
|
-
usage
|
|
139
|
-
usage.projectWikiTotalRefs += 1;
|
|
279
|
+
bumpProjectWikiRef(usage, projectWikiRelPath, 'file', refAt);
|
|
140
280
|
}
|
|
141
281
|
usage.lastUpdatedAt = refAt;
|
|
142
282
|
await FileSystemUtils.createDirectory(path.dirname(getUsagePath(knowledgeDir)));
|
|
@@ -173,11 +313,20 @@ export async function knowledgeUsageHook(options) {
|
|
|
173
313
|
}
|
|
174
314
|
const toolInput = stdinData.tool_input;
|
|
175
315
|
const filePath = toolInput?.file_path || toolInput?.path || options.file || '';
|
|
176
|
-
|
|
316
|
+
const projectWikiRefEvent = extractProjectWikiRefEvent(stdinData, toolInput) || {
|
|
317
|
+
source: 'llmwiki',
|
|
318
|
+
effectiveRefs: extractLlmwikiEffectiveRefsFromHookInput(stdinData),
|
|
319
|
+
};
|
|
320
|
+
if (filePath || projectWikiRefEvent.effectiveRefs.length > 0) {
|
|
177
321
|
try {
|
|
178
322
|
const repoRoot = resolveZhuanSpecRoot(process.cwd());
|
|
179
323
|
const knowledgeDir = path.join(repoRoot, 'zhuanspec', 'knowledge');
|
|
180
|
-
|
|
324
|
+
if (projectWikiRefEvent.effectiveRefs.length > 0) {
|
|
325
|
+
await reportProjectWikiRefs(knowledgeDir, projectWikiRefEvent);
|
|
326
|
+
}
|
|
327
|
+
if (filePath) {
|
|
328
|
+
await reportKnowledgeRef(knowledgeDir, { filePath });
|
|
329
|
+
}
|
|
181
330
|
}
|
|
182
331
|
catch {
|
|
183
332
|
// 旁路 hook 永不抛错
|
package/dist/core/init.js
CHANGED
|
@@ -30,6 +30,8 @@ export interface FunnelStats {
|
|
|
30
30
|
projectWikiReferencedRefs: number;
|
|
31
31
|
/** ProjectWiki 被引用数按 service 聚合 */
|
|
32
32
|
projectWikiReferencedRefsByService: Record<string, number>;
|
|
33
|
+
/** ProjectWiki 被引用数按来源聚合,如 file / llmwiki */
|
|
34
|
+
projectWikiReferencedRefsBySource: Record<string, number>;
|
|
33
35
|
/** 五跳转化率(0~1,分母为 0 记 0) */
|
|
34
36
|
conversions: {
|
|
35
37
|
signalToAsked: number;
|
|
@@ -104,6 +104,13 @@ function aggregateProjectWikiRefsByService(byProjectWikiFile) {
|
|
|
104
104
|
}
|
|
105
105
|
return Object.fromEntries(Object.entries(byService).sort(([a], [b]) => a.localeCompare(b)));
|
|
106
106
|
}
|
|
107
|
+
function aggregateCounters(counters) {
|
|
108
|
+
const result = {};
|
|
109
|
+
for (const [key, counter] of Object.entries(counters)) {
|
|
110
|
+
result[key] = counter.refCount ?? 0;
|
|
111
|
+
}
|
|
112
|
+
return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b)));
|
|
113
|
+
}
|
|
107
114
|
/**
|
|
108
115
|
* 顶层聚合:拼装 FunnelStats。
|
|
109
116
|
* @param archivedEntries 落库数(由调用方传入 stats.total,避免重复 scanKnowledgeEntries)
|
|
@@ -114,6 +121,7 @@ export async function computeFunnelStats(zhuanspecDir, knowledgeDir, archivedEnt
|
|
|
114
121
|
const referencedRefs = usage.totalRefs;
|
|
115
122
|
const projectWikiReferencedRefs = usage.projectWikiTotalRefs;
|
|
116
123
|
const projectWikiReferencedRefsByService = aggregateProjectWikiRefsByService(usage.byProjectWikiFile);
|
|
124
|
+
const projectWikiReferencedRefsBySource = aggregateCounters(usage.projectWikiRefsBySource);
|
|
117
125
|
const { correctionSignals, askedPitfalls, scannedChanges } = changeScan;
|
|
118
126
|
return {
|
|
119
127
|
correctionSignals,
|
|
@@ -122,6 +130,7 @@ export async function computeFunnelStats(zhuanspecDir, knowledgeDir, archivedEnt
|
|
|
122
130
|
referencedRefs,
|
|
123
131
|
projectWikiReferencedRefs,
|
|
124
132
|
projectWikiReferencedRefsByService,
|
|
133
|
+
projectWikiReferencedRefsBySource,
|
|
125
134
|
conversions: {
|
|
126
135
|
signalToAsked: rate(askedPitfalls, correctionSignals),
|
|
127
136
|
askedToArchived: rate(archivedEntries, askedPitfalls),
|