add-coder 0.3.34 → 0.3.37
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/README.en.md +101 -42
- package/README.md +63 -18
- package/dist/index.js +24 -6
- package/package.json +2 -2
- package/templates/.add-coder-src-hash.json +94 -39
- package/templates/adapters/claude/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/claude/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/claude/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/claude/hooks/session-start.mjs +65 -1
- package/templates/adapters/codex/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/codex/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/codex/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/codex/hooks/session-start.mjs +65 -1
- package/templates/adapters/qoder/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/qoder/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/qoder/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/qoder/hooks/session-start.mjs +67 -1
- package/templates/adapters/trae/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/trae/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/trae/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/trae/hooks/session-start.mjs +65 -1
- package/templates/adapters/vscode/hooks/doc-format-guard.mjs +172 -84
- package/templates/adapters/vscode/hooks/post-tool-use.mjs +59 -1
- package/templates/adapters/vscode/hooks/prompt-submit.mjs +72 -0
- package/templates/adapters/vscode/hooks/session-start.mjs +65 -1
- package/templates/core/governance/doc-format-guard.ts +29 -112
- package/templates/core/governance/post-tool-router.ts +33 -1
- package/templates/core/governance/prompt-router.ts +47 -0
- package/templates/core/governance/session-start-guard.ts +48 -1
- package/templates/core/prisma/add.prisma +203 -0
- package/templates/core/scripts/db-ensure.sh +92 -2
- package/templates/core/scripts/mcp-server/shared/db-types.ts +119 -0
- package/templates/core/scripts/mcp-server/shared/hitl-create-policy.ts +27 -0
- package/templates/core/scripts/mcp-server/shared/hitl-proposal-content.ts +110 -0
- package/templates/core/scripts/mcp-server/shared/hitl-widget-instance.ts +85 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/batch-fit.ts +250 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/feedback-stats.ts +101 -0
- package/templates/core/scripts/mcp-server/shared/memory/calibration/unit-state.ts +224 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/conflicts.ts +59 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/dedup.ts +45 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/errors.ts +33 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/handoff-digest.ts +92 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/metric-candidate.ts +79 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/scope.ts +94 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/secrets.ts +50 -0
- package/templates/core/scripts/mcp-server/shared/memory/domain/state-machine.ts +90 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/index.ts +117 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/local-onnx.ts +105 -0
- package/templates/core/scripts/mcp-server/shared/memory/embedding/openai-compatible.ts +87 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/consolidation.ts +226 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/evidence-collector.ts +153 -0
- package/templates/core/scripts/mcp-server/shared/memory/jobs/snapshot.ts +114 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-recall.ts +134 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/gate-writer.ts +217 -0
- package/templates/core/scripts/mcp-server/shared/memory/metrics/stage-words.ts +69 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/context-builder.ts +89 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/pg.ts +139 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite-fts5.sql +29 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fts/sqlite.ts +106 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/fusion.ts +43 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/pipeline.ts +285 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/query-terms.ts +31 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/recall-writer.ts +87 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/reranker.ts +116 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/types.ts +52 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/pgvector.ts +143 -0
- package/templates/core/scripts/mcp-server/shared/memory/retrieval/vector/sqlite-vec.ts +118 -0
- package/templates/core/scripts/mcp-server/shared/memory/switches.ts +39 -0
- package/templates/core/scripts/mcp-server/shared/review-files.ts +22 -0
- package/templates/core/scripts/mcp-server/shared/runtime-freshness.ts +235 -0
- package/templates/core/scripts/mcp-server/tools/gateway/check_dps.ts +37 -0
- package/templates/core/scripts/mcp-server/tools/gateway/check_rahs.ts +40 -1
- package/templates/core/scripts/mcp-server/tools/hitl.ts +108 -42
- package/templates/core/scripts/mcp-server/tools/index.ts +7 -1
- package/templates/core/scripts/mcp-server/tools/memory-compat.ts +258 -0
- package/templates/core/scripts/mcp-server/tools/memory.ts +654 -0
- package/templates/core/scripts/mcp-server/tools/plan.ts +8 -3
- package/templates/core/scripts/mcp-server/tools/review.ts +10 -7
- package/templates/core/scripts/mcp-server.ts +36 -0
- package/templates/core/templates/checklist-template.md +13 -0
- package/templates/core/templates/review-implementation-template.md +24 -0
- package/templates/core/templates/review-template.md +16 -0
- package/templates/core/validation/index.ts +136 -0
- package/templates/core/validation/policy.ts +91 -0
- package/templates/core/validation/registry.ts +61 -0
- package/templates/core/validation/schema-validator.ts +277 -0
- package/templates/core/validation/validators/add-route.ts +32 -0
- package/templates/core/validation/validators/checklist.ts +48 -0
- package/templates/core/validation/validators/handoff.ts +46 -0
- package/templates/core/validation/validators/hitl.ts +22 -0
- package/templates/core/validation/validators/index.ts +52 -0
- package/templates/core/validation/validators/plan.ts +20 -0
- package/templates/core/validation/validators/report.ts +16 -0
- package/templates/core/validation/validators/review.ts +30 -0
- package/templates/core/validation/validators/spec.ts +25 -0
- package/templates/core/validation/validators/tasks.ts +40 -0
- package/templates/core/validation/validators/types.ts +32 -0
- package/templates/core/vocabulary/add-governance-vocabulary.md +18 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 冷启动批量拟合(Plan rank-calibration Task 2.1 / Spec §2 §BatchFit)
|
|
3
|
+
*
|
|
4
|
+
* 三工具分工里"**从无到有**"的那一件:给定一批带 ground truth 的样本 → 输出一个**点估计**权重。
|
|
5
|
+
* 不做在线跟踪(那是 Kalman),不做频谱诊断(那是 FFT)。
|
|
6
|
+
*
|
|
7
|
+
* 两个关键约束:
|
|
8
|
+
* 1. **train/test 切分**:标注集派生的样本必须按 query 维度切分,拟合只用 train,
|
|
9
|
+
* 验收只在 test —— 用验收集拟合会虚高 MRR(Spec §2 [2026-09-13 修订]);
|
|
10
|
+
* 2. **目标函数可注入**:本模块默认用内置代理目标(按通道位次加权的命中率),
|
|
11
|
+
* 真实校准由 `rank-calibrate.ts` 注入"跑 recallPipeline + 算 MRR"的目标函数。
|
|
12
|
+
* 这样模块保持纯函数可测,而线上用真指标而非代理指标。
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto"
|
|
15
|
+
import type { FeedbackChannel } from "./feedback-stats.js"
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_MIN_SAMPLES = 5
|
|
18
|
+
|
|
19
|
+
/** 现行排序常数(无快照时的回落值 = 改造前行为,必须保持一致) */
|
|
20
|
+
export const DEFAULT_WEIGHTS_V3 = {
|
|
21
|
+
vectorWeight: 0.3,
|
|
22
|
+
boostScale: 1,
|
|
23
|
+
rrfK: 10,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export interface FitWeights {
|
|
27
|
+
vectorWeight: number
|
|
28
|
+
boostScale: number
|
|
29
|
+
rrfK: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 一条样本:query 归属 + ground truth + 各通道位次 */
|
|
33
|
+
export interface FitSample {
|
|
34
|
+
queryId: string
|
|
35
|
+
memoryId: string
|
|
36
|
+
relevant: boolean
|
|
37
|
+
/** 该条在各通道中的位次(未命中该通道则不出现) */
|
|
38
|
+
ranks: Partial<Record<FeedbackChannel, number>>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FitSplit {
|
|
42
|
+
splitId: string
|
|
43
|
+
trainQueries: string[]
|
|
44
|
+
testQueries: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WeightFitResult {
|
|
48
|
+
weights: FitWeights
|
|
49
|
+
/** 1 − 目标函数最优值(目标函数应返回 0..1 的归一化收益) */
|
|
50
|
+
residual: number
|
|
51
|
+
samples: number
|
|
52
|
+
trainSamples: number
|
|
53
|
+
testSamples: number
|
|
54
|
+
method: "default" | "grid"
|
|
55
|
+
splitId: string
|
|
56
|
+
/** 最优解落在搜索空间边界(提示空间可能过窄) */
|
|
57
|
+
boundaryHit: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SearchSpace {
|
|
61
|
+
vectorWeight: number[]
|
|
62
|
+
boostScale: number[]
|
|
63
|
+
rrfK: number[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const DEFAULT_SEARCH_SPACE: SearchSpace = {
|
|
67
|
+
vectorWeight: [0, 0.1, 0.2, 0.3, 0.5, 0.8],
|
|
68
|
+
boostScale: [0.5, 1, 2],
|
|
69
|
+
rrfK: [5, 10, 20, 60],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 目标函数:返回 0..1 的归一化收益(越大越好),由调用方决定是代理指标还是真实 MRR */
|
|
73
|
+
export type FitObjective = (weights: FitWeights, samples: readonly FitSample[]) => number
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 切分分组 = **Plan 业务闭包**(架构事实,非算法聚类)。
|
|
77
|
+
* 见 Spec §2.2:单元素自身迭代(单元内)与跨单元流转(跨单元)是两条不同数据流,
|
|
78
|
+
* 用临时聚类代替 Plan 闭包会让"防泄漏"退化成口径游戏。
|
|
79
|
+
*/
|
|
80
|
+
export interface QueryGroup {
|
|
81
|
+
/** = planKeyword(业务闭包标识) */
|
|
82
|
+
groupId: string
|
|
83
|
+
queryIds: string[]
|
|
84
|
+
/** 单元状态:未收敛单元的样本默认不进训练集(Spec §2.2) */
|
|
85
|
+
unitState?: "closed" | "in-flight"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface GroupFold {
|
|
89
|
+
testGroupId: string
|
|
90
|
+
trainGroups: string[]
|
|
91
|
+
testQueries: string[]
|
|
92
|
+
trainQueries: string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface GroupedSplit {
|
|
96
|
+
mode: "leave-one-group-out" | "single-holdout"
|
|
97
|
+
folds: GroupFold[]
|
|
98
|
+
splitId: string
|
|
99
|
+
/** 簇数 < 3 → 结论不稳(明示,不隐瞒) */
|
|
100
|
+
lowGroupCount: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hashId(input: string): string {
|
|
104
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 8)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 按 query 维度确定性切分(同输入 → 同 split,可复现)。
|
|
109
|
+
* 切分单位是 **query** 而非样本:同一 query 的样本不能跨集,否则信息泄漏。
|
|
110
|
+
*/
|
|
111
|
+
export function splitByQuery(queryIds: readonly string[], opts: { testRatio?: number } = {}): FitSplit {
|
|
112
|
+
const testRatio = opts.testRatio ?? 0.4
|
|
113
|
+
const unique = [...new Set(queryIds)]
|
|
114
|
+
// 以 queryId 哈希排序 → 与传入顺序无关,可复现
|
|
115
|
+
const ordered = [...unique].sort((a, b) => hashId(a).localeCompare(hashId(b)))
|
|
116
|
+
const testCount = Math.max(1, Math.round(ordered.length * testRatio))
|
|
117
|
+
const testQueries = ordered.slice(0, Math.min(testCount, Math.max(0, ordered.length - 1)))
|
|
118
|
+
const testSet = new Set(testQueries)
|
|
119
|
+
const trainQueries = ordered.filter((q) => !testSet.has(q))
|
|
120
|
+
return {
|
|
121
|
+
splitId: hashId(`split|${ordered.join(",")}|${testRatio}`),
|
|
122
|
+
trainQueries,
|
|
123
|
+
testQueries,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 按**业务闭包**切分(Spec §2.1/§2.2)。
|
|
129
|
+
*
|
|
130
|
+
* - 默认 `leave-one-group-out`:逐簇留出作 test、其余作 train(每折的 test query 一条都不在 train 中)
|
|
131
|
+
* - `single-holdout`:按簇哈希确定性挑 test 簇(用于簇数不足时的退化路径)
|
|
132
|
+
* - 未收敛单元(`unitState:"in-flight"`)参与分组但**默认不进训练集**,避免把"进行中的偏差"学进权重
|
|
133
|
+
*/
|
|
134
|
+
export function splitByGroup(
|
|
135
|
+
groups: readonly QueryGroup[],
|
|
136
|
+
opts: { mode?: "leave-one-group-out" | "single-holdout"; testRatio?: number; includeInFlight?: boolean } = {},
|
|
137
|
+
): GroupedSplit {
|
|
138
|
+
const usable = groups.filter((g) => opts.includeInFlight === true || (g.unitState ?? "closed") === "closed")
|
|
139
|
+
const all = usable.length > 0 ? usable : groups
|
|
140
|
+
const mode = opts.mode ?? "leave-one-group-out"
|
|
141
|
+
const splitId = hashId(`groupsplit|${mode}|${all.map((g) => g.groupId).sort().join(",")}`)
|
|
142
|
+
const lowGroupCount = all.length < 3
|
|
143
|
+
|
|
144
|
+
if (mode === "single-holdout") {
|
|
145
|
+
const testRatio = opts.testRatio ?? 0.4
|
|
146
|
+
const ordered = [...all].sort((a, b) => hashId(a.groupId).localeCompare(hashId(b.groupId)))
|
|
147
|
+
const testCount = Math.max(1, Math.min(ordered.length - 1, Math.round(ordered.length * testRatio)))
|
|
148
|
+
const testGroups = ordered.slice(0, Math.max(0, testCount))
|
|
149
|
+
const trainGroups = ordered.slice(testGroups.length)
|
|
150
|
+
return {
|
|
151
|
+
mode,
|
|
152
|
+
splitId,
|
|
153
|
+
lowGroupCount,
|
|
154
|
+
folds: [
|
|
155
|
+
{
|
|
156
|
+
testGroupId: testGroups.map((g) => g.groupId).join("+"),
|
|
157
|
+
trainGroups: trainGroups.map((g) => g.groupId),
|
|
158
|
+
testQueries: testGroups.flatMap((g) => g.queryIds),
|
|
159
|
+
trainQueries: trainGroups.flatMap((g) => g.queryIds),
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
mode,
|
|
167
|
+
splitId,
|
|
168
|
+
lowGroupCount,
|
|
169
|
+
folds: all.map((test) => ({
|
|
170
|
+
testGroupId: test.groupId,
|
|
171
|
+
trainGroups: all.filter((g) => g.groupId !== test.groupId).map((g) => g.groupId),
|
|
172
|
+
testQueries: [...test.queryIds],
|
|
173
|
+
trainQueries: all.filter((g) => g.groupId !== test.groupId).flatMap((g) => g.queryIds),
|
|
174
|
+
})),
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 内置代理目标:按通道位次加权命中率(AA 于 recall 的粗略替代,仅用于无注入场景与测试) */
|
|
179
|
+
export function surrogateObjective(weights: FitWeights, samples: readonly FitSample[]): number {
|
|
180
|
+
if (samples.length === 0) return 0
|
|
181
|
+
let good = 0
|
|
182
|
+
let total = 0
|
|
183
|
+
for (const s of samples) {
|
|
184
|
+
const lexical = s.ranks.lexical != null ? (1 - weights.vectorWeight) / (weights.rrfK + s.ranks.lexical) : 0
|
|
185
|
+
const vector = s.ranks.vector != null ? weights.vectorWeight / (weights.rrfK + s.ranks.vector) : 0
|
|
186
|
+
const score = lexical + vector
|
|
187
|
+
total += 1
|
|
188
|
+
if (s.relevant && score > 0) good += 1
|
|
189
|
+
else if (!s.relevant && score === 0) good += 1
|
|
190
|
+
}
|
|
191
|
+
return total === 0 ? 0 : Number((good / total).toFixed(6))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function fitWeights(
|
|
195
|
+
samples: readonly FitSample[],
|
|
196
|
+
split: FitSplit,
|
|
197
|
+
opts: { minSamples?: number; space?: SearchSpace; objective?: FitObjective } = {},
|
|
198
|
+
): WeightFitResult {
|
|
199
|
+
const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES
|
|
200
|
+
const trainSet = new Set(split.trainQueries)
|
|
201
|
+
const testSet = new Set(split.testQueries)
|
|
202
|
+
const train = samples.filter((s) => trainSet.has(s.queryId))
|
|
203
|
+
const test = samples.filter((s) => testSet.has(s.queryId))
|
|
204
|
+
|
|
205
|
+
const base = {
|
|
206
|
+
samples: samples.length,
|
|
207
|
+
trainSamples: train.length,
|
|
208
|
+
testSamples: test.length,
|
|
209
|
+
splitId: split.splitId,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 冷启动门控:样本不足 → 回落现行常数,且**不产出"拟合结果"**
|
|
213
|
+
if (train.length < minSamples) {
|
|
214
|
+
return { ...base, weights: { ...DEFAULT_WEIGHTS_V3 }, residual: 1, method: "default", boundaryHit: false }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const space = opts.space ?? DEFAULT_SEARCH_SPACE
|
|
218
|
+
const objective = opts.objective ?? surrogateObjective
|
|
219
|
+
let best: FitWeights = { ...DEFAULT_WEIGHTS_V3 }
|
|
220
|
+
let bestScore = -Infinity
|
|
221
|
+
let hit = false
|
|
222
|
+
|
|
223
|
+
for (const vectorWeight of space.vectorWeight) {
|
|
224
|
+
for (const boostScale of space.boostScale) {
|
|
225
|
+
for (const rrfK of space.rrfK) {
|
|
226
|
+
const weights: FitWeights = { vectorWeight, boostScale, rrfK }
|
|
227
|
+
const score = objective(weights, train)
|
|
228
|
+
if (score > bestScore) {
|
|
229
|
+
bestScore = score
|
|
230
|
+
best = weights
|
|
231
|
+
hit =
|
|
232
|
+
vectorWeight === space.vectorWeight[0] ||
|
|
233
|
+
vectorWeight === space.vectorWeight[space.vectorWeight.length - 1] ||
|
|
234
|
+
boostScale === space.boostScale[0] ||
|
|
235
|
+
boostScale === space.boostScale[space.boostScale.length - 1] ||
|
|
236
|
+
rrfK === space.rrfK[0] ||
|
|
237
|
+
rrfK === space.rrfK[space.rrfK.length - 1]
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
...base,
|
|
245
|
+
weights: best,
|
|
246
|
+
residual: Number((1 - Math.max(0, Math.min(1, bestScore))).toFixed(6)),
|
|
247
|
+
method: "grid",
|
|
248
|
+
boundaryHit: hit,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 反馈统计(Plan rank-calibration Task 1.1 / Spec §1 §FeedbackStats)
|
|
3
|
+
*
|
|
4
|
+
* 职责边界(**这是三工具分工的第一环**):只做**聚合**,不做拟合、不做权重建议。
|
|
5
|
+
* 通道归属由调用方提供(从 Recall 审计的 scoreBreakdown/fusedChannels + 各通道位次推导),
|
|
6
|
+
* 本模块不猜测来源 —— 猜错通道会让后续拟合学到错的信号。
|
|
7
|
+
*
|
|
8
|
+
* 统计口径(Spec §1):
|
|
9
|
+
* - 有用 = USED | USEFUL;无用 = IRRELEVANT | OUTDATED | CONTRADICTED | HARMFUL;
|
|
10
|
+
* - **UNKNOWN 及其他未知取值不计入分母**(避免用未知样本稀释/污染有用率);
|
|
11
|
+
* - 样本数不足 → status="insufficient"(**此时不得据此改权重**);
|
|
12
|
+
* - 无样本的通道返回 total=0 / usefulRate=0,不用默认值伪装。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type FeedbackChannel = "lexical" | "vector"
|
|
16
|
+
|
|
17
|
+
export const USEFUL_OUTCOMES = ["USED", "USEFUL"] as const
|
|
18
|
+
export const NOT_USEFUL_OUTCOMES = ["IRRELEVANT", "OUTDATED", "CONTRADICTED", "HARMFUL"] as const
|
|
19
|
+
export const DEFAULT_MIN_SAMPLES = 5
|
|
20
|
+
|
|
21
|
+
export interface FeedbackSample {
|
|
22
|
+
memoryId: string
|
|
23
|
+
/** 该条在最终排序中的位次(null = 未入选) */
|
|
24
|
+
rank: number | null
|
|
25
|
+
outcome: string
|
|
26
|
+
/** 检出该条的通道集合(调用方从召回审计推导,可多通道) */
|
|
27
|
+
channels: readonly FeedbackChannel[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChannelStat {
|
|
31
|
+
useful: number
|
|
32
|
+
total: number
|
|
33
|
+
usefulRate: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ChannelOutcomeStats {
|
|
37
|
+
/** 可分类样本数(分母:排除 UNKNOWN 等未分类 outcome) */
|
|
38
|
+
samples: number
|
|
39
|
+
status: "ok" | "insufficient"
|
|
40
|
+
byChannel: Record<FeedbackChannel, ChannelStat>
|
|
41
|
+
byRankBucket: { rank: 1 | 2 | 3 | 4 | 5; useful: number; total: number; usefulRate: number }[]
|
|
42
|
+
/** 未分类 outcome 的数量(可审计:有多少样本被排除在分母外) */
|
|
43
|
+
unclassified: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classify(outcome: string): "useful" | "not-useful" | "unclassified" {
|
|
47
|
+
if ((USEFUL_OUTCOMES as readonly string[]).includes(outcome)) return "useful"
|
|
48
|
+
if ((NOT_USEFUL_OUTCOMES as readonly string[]).includes(outcome)) return "not-useful"
|
|
49
|
+
return "unclassified"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rate(useful: number, total: number): number {
|
|
53
|
+
return total === 0 ? 0 : Number((useful / total).toFixed(4))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function aggregateFeedback(
|
|
57
|
+
samples: readonly FeedbackSample[],
|
|
58
|
+
opts: { minSamples?: number } = {},
|
|
59
|
+
): ChannelOutcomeStats {
|
|
60
|
+
const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES
|
|
61
|
+
|
|
62
|
+
const byChannel: Record<FeedbackChannel, ChannelStat> = {
|
|
63
|
+
lexical: { useful: 0, total: 0, usefulRate: 0 },
|
|
64
|
+
vector: { useful: 0, total: 0, usefulRate: 0 },
|
|
65
|
+
}
|
|
66
|
+
const buckets = [1, 2, 3, 4, 5].map((rank) => ({ rank: rank as 1 | 2 | 3 | 4 | 5, useful: 0, total: 0 }))
|
|
67
|
+
|
|
68
|
+
let classified = 0
|
|
69
|
+
let unclassified = 0
|
|
70
|
+
|
|
71
|
+
for (const sample of samples) {
|
|
72
|
+
const kind = classify(sample.outcome)
|
|
73
|
+
if (kind === "unclassified") {
|
|
74
|
+
unclassified++
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
classified++
|
|
78
|
+
const useful = kind === "useful"
|
|
79
|
+
for (const channel of new Set(sample.channels)) {
|
|
80
|
+
if (!byChannel[channel]) continue
|
|
81
|
+
byChannel[channel].total++
|
|
82
|
+
if (useful) byChannel[channel].useful++
|
|
83
|
+
}
|
|
84
|
+
if (sample.rank != null && sample.rank >= 1 && sample.rank <= 5) {
|
|
85
|
+
const bucket = buckets[sample.rank - 1]
|
|
86
|
+
bucket.total++
|
|
87
|
+
if (useful) bucket.useful++
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
samples: classified,
|
|
93
|
+
status: classified < minSamples ? "insufficient" : "ok",
|
|
94
|
+
byChannel: {
|
|
95
|
+
lexical: { ...byChannel.lexical, usefulRate: rate(byChannel.lexical.useful, byChannel.lexical.total) },
|
|
96
|
+
vector: { ...byChannel.vector, usefulRate: rate(byChannel.vector.useful, byChannel.vector.total) },
|
|
97
|
+
},
|
|
98
|
+
byRankBucket: buckets.map((b) => ({ ...b, usefulRate: rate(b.useful, b.total) })),
|
|
99
|
+
unclassified,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 单元状态解析(Plan rank-calibration Task 2.1.9 / Spec §2.2 + §2.3)
|
|
3
|
+
*
|
|
4
|
+
* 架构约束:单元(Plan 业务闭包)的状态是**既有事实**,必须读出来——不能手填、不能另建状态表。
|
|
5
|
+
* closed = add-route 全部产出项 [x] 且存在 ROUND_CLOSED devlog
|
|
6
|
+
* in-flight = add-route 存在但仍有 [ ],或缺少 ROUND_CLOSED(并发协议下的飞行中单元)
|
|
7
|
+
* unknown = 连 add-route 都没有(§2.3 视为缺制品,样本不可作训练依据)
|
|
8
|
+
*
|
|
9
|
+
* 同时产出**单元引用表**(refs):报告按单元分层时直接引用这些制品,不复制内容(§2.3)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { validate as coreValidate, type ValidationIssue } from "../../../../../validation/index.js"
|
|
13
|
+
|
|
14
|
+
export interface UnitRefs {
|
|
15
|
+
planPath?: string
|
|
16
|
+
addRoutePath?: string
|
|
17
|
+
handoffPath?: string
|
|
18
|
+
specsDir?: string
|
|
19
|
+
planRecordId?: string
|
|
20
|
+
roundClosedAuditId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UnitState {
|
|
24
|
+
planKeyword: string
|
|
25
|
+
state: "closed" | "in-flight" | "unknown"
|
|
26
|
+
/**
|
|
27
|
+
* 诊断量(**不参与 closed 判定**):add-route 勾选与事实的差距。
|
|
28
|
+
* 勾选是过程进度,不是收敛证据——用它判 closed 会把"过程"当"结果"。
|
|
29
|
+
*/
|
|
30
|
+
documentationLag: { openSteps: number; totalSteps: number; lagging: boolean }
|
|
31
|
+
/** 封口四要素(Spec §2.2:四项合取) */
|
|
32
|
+
evidence: {
|
|
33
|
+
roundClosed: boolean
|
|
34
|
+
handoff: boolean
|
|
35
|
+
acceptance: boolean
|
|
36
|
+
planStatus: boolean
|
|
37
|
+
}
|
|
38
|
+
/** handoff 合规校验明细(来自 core 校验层;存在 ≠ 合规,Spec §2.2) */
|
|
39
|
+
handoffValidation?: { type: "handoff.single" | "handoff.multi"; ok: boolean; issues: ValidationIssue[] }
|
|
40
|
+
/** 缺失的既有制品(§2.3:缺证据的单元不可作为训练依据) */
|
|
41
|
+
missingArtifacts: string[]
|
|
42
|
+
refs: UnitRefs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UnitStateDeps {
|
|
46
|
+
projectRoot: string
|
|
47
|
+
magicDir: string
|
|
48
|
+
/** 读文件内容(不存在返回 null);测试注入 */
|
|
49
|
+
readFile?: (absPath: string) => string | null
|
|
50
|
+
/** 列 plans 目录下的文件名(不存在返回 []);测试注入 */
|
|
51
|
+
listDir?: (absDir: string) => string[]
|
|
52
|
+
/**
|
|
53
|
+
* 列 plans 目录下的**相对路径**(含日期子目录,如 `2026-09/12/x.md`);生产路径必须用这个 ——
|
|
54
|
+
* ADD 约定制品按 `plans/{YYYY-MM}/{DD}/` 分层,扁平列目录会漏掉全部真实 Plan(2026-09-13 真实单元自检暴露)。
|
|
55
|
+
*/
|
|
56
|
+
listFiles?: (absDir: string) => string[]
|
|
57
|
+
/** 是否存在(specs 目录判断);测试注入 */
|
|
58
|
+
exists?: (absPath: string) => boolean
|
|
59
|
+
/** 查 ROUND_CLOSED devlog(测试注入) */
|
|
60
|
+
findRoundClosed?: (planKeyword: string) => Promise<{ id: string } | null>
|
|
61
|
+
/** 查 PlanRecord(测试注入) */
|
|
62
|
+
findPlanRecord?: (planKeyword: string) => Promise<{
|
|
63
|
+
id: string
|
|
64
|
+
lifecycle?: string
|
|
65
|
+
doneTasks?: number
|
|
66
|
+
totalTasks?: number
|
|
67
|
+
} | null>
|
|
68
|
+
/**
|
|
69
|
+
* handoff 合规校验(默认 = core 校验层,schema 真源)。
|
|
70
|
+
* 注入点仅供测试;生产路径必须走 core,不得再自解析模板(Spec §2.2/§2.4)。
|
|
71
|
+
*/
|
|
72
|
+
validateHandoff?: (input: {
|
|
73
|
+
type: "handoff.single" | "handoff.multi"
|
|
74
|
+
path: string
|
|
75
|
+
expectRounds: number
|
|
76
|
+
}) => { ok: boolean; issues: ValidationIssue[] }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 轮次数推导(既有制品读取,不新建状态)。
|
|
81
|
+
*
|
|
82
|
+
* 语义:取各制品中 `轮次 N` 的最大编号;**编号从 0 起 → +1,从 1 起 → 不变**。
|
|
83
|
+
* 例:`轮次 0..3` → 4 轮;`轮次 1` → 1 轮(单轮)。
|
|
84
|
+
* 输入建议按真源优先级传入:[planContent, tasksContent, addRouteContent]
|
|
85
|
+
* (tasks.md 的「轮次拓扑」是最稳定的轮次真源;add-route 的轮次常写在代码块内)。
|
|
86
|
+
*/
|
|
87
|
+
export function planRounds(...contents: readonly string[]): number {
|
|
88
|
+
const nums: number[] = []
|
|
89
|
+
for (const content of contents) {
|
|
90
|
+
for (const m of content.matchAll(/轮次\s*(\d+)/g)) nums.push(Number(m[1]))
|
|
91
|
+
}
|
|
92
|
+
if (nums.length === 0) return 1
|
|
93
|
+
const max = Math.max(...nums)
|
|
94
|
+
const min = Math.min(...nums)
|
|
95
|
+
return min === 0 ? max + 1 : max
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 统计 add-route 产出项勾选情况(只认 `- [ ]` / `- [x]`) */
|
|
99
|
+
export function countSteps(content: string): { open: number; total: number } {
|
|
100
|
+
let open = 0
|
|
101
|
+
let total = 0
|
|
102
|
+
for (const line of content.split("\n")) {
|
|
103
|
+
if (/^- \[ \]/.test(line)) {
|
|
104
|
+
open++
|
|
105
|
+
total++
|
|
106
|
+
} else if (/^- \[[xX]\]/.test(line)) {
|
|
107
|
+
total++
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { open, total }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function resolveUnitState(
|
|
114
|
+
planKeyword: string,
|
|
115
|
+
deps: UnitStateDeps,
|
|
116
|
+
): Promise<UnitState> {
|
|
117
|
+
const plansDir = `${deps.projectRoot}/${deps.magicDir}/plans`
|
|
118
|
+
const specsDirRoot = `${deps.projectRoot}/${deps.magicDir}/specs`
|
|
119
|
+
const listDir = deps.listFiles ?? deps.listDir ?? (() => [])
|
|
120
|
+
const readFile = deps.readFile ?? (() => null)
|
|
121
|
+
const exists = deps.exists ?? (() => false)
|
|
122
|
+
|
|
123
|
+
const has = (name: string, must: string[]): boolean =>
|
|
124
|
+
must.every((m) => name.toLowerCase().includes(m.toLowerCase()))
|
|
125
|
+
|
|
126
|
+
const files = listDir(plansDir)
|
|
127
|
+
// ★ 匹配基准是 plan **base** 名(去掉 -plan-vN):兄弟制品不带 plan 段——
|
|
128
|
+
// Plan=`{base}-plan-v1.md`、add-route=`{base}-add-route-v1.md`、handoff=`{base}-handoff-v1.md`。
|
|
129
|
+
// 用完整 planKeyword 去匹配会漏掉后两者(2026-09-13 真实单元自检暴露)。
|
|
130
|
+
const base = planKeyword.replace(/-plan-v\d+$/i, "")
|
|
131
|
+
const planRel = files.find((f) => has(f, [base, "-plan-v"]) && !f.includes(".hitl"))
|
|
132
|
+
const addRouteRel = files.find((f) => has(f, [base, "add-route"]))
|
|
133
|
+
const handoffRel = files.find((f) => has(f, [base, "handoff"]))
|
|
134
|
+
|
|
135
|
+
const refs: UnitRefs = {}
|
|
136
|
+
const missingArtifacts: string[] = []
|
|
137
|
+
|
|
138
|
+
if (planRel) refs.planPath = `${deps.magicDir}/plans/${planRel}`
|
|
139
|
+
else missingArtifacts.push("plan")
|
|
140
|
+
if (addRouteRel) refs.addRoutePath = `${deps.magicDir}/plans/${addRouteRel}`
|
|
141
|
+
else missingArtifacts.push("add-route")
|
|
142
|
+
if (handoffRel) refs.handoffPath = `${deps.magicDir}/plans/${handoffRel}`
|
|
143
|
+
else missingArtifacts.push("handoff")
|
|
144
|
+
|
|
145
|
+
if (exists(`${specsDirRoot}/${base}`)) refs.specsDir = `${deps.magicDir}/specs/${base}`
|
|
146
|
+
else missingArtifacts.push("specs")
|
|
147
|
+
|
|
148
|
+
const [roundClosed, planRecord] = await Promise.all([
|
|
149
|
+
deps.findRoundClosed ? deps.findRoundClosed(planKeyword) : Promise.resolve(null),
|
|
150
|
+
deps.findPlanRecord ? deps.findPlanRecord(planKeyword) : Promise.resolve(null),
|
|
151
|
+
])
|
|
152
|
+
if (roundClosed) refs.roundClosedAuditId = roundClosed.id
|
|
153
|
+
else missingArtifacts.push("round-closed")
|
|
154
|
+
if (planRecord) refs.planRecordId = planRecord.id
|
|
155
|
+
else missingArtifacts.push("plan-record")
|
|
156
|
+
|
|
157
|
+
const addRouteContent = addRouteRel ? (readFile(`${plansDir}/${addRouteRel}`) ?? "") : ""
|
|
158
|
+
const { open, total } = countSteps(addRouteContent)
|
|
159
|
+
|
|
160
|
+
// 验收证据:checklist 存在且 [T] 项无未勾选(缺证据的单元不可作训练依据)
|
|
161
|
+
const checklistAbs = refs.specsDir ? `${deps.projectRoot}/${refs.specsDir}/checklist.md` : ""
|
|
162
|
+
const checklistContent = checklistAbs ? (readFile(checklistAbs) ?? "") : ""
|
|
163
|
+
const unverifiedT = (checklistContent.match(/^- \[ \] \[T\]/gm) ?? []).length
|
|
164
|
+
const verifiedT = (checklistContent.match(/^- \[[xX]\] \[T\]/gm) ?? []).length
|
|
165
|
+
const acceptance = unverifiedT + verifiedT > 0 && unverifiedT === 0
|
|
166
|
+
|
|
167
|
+
// PlanRecord 状态:存在且进度收敛(done === total 且 total > 0)
|
|
168
|
+
const planStatus =
|
|
169
|
+
!!planRecord && (planRecord.totalTasks ?? 0) > 0 && planRecord.doneTasks === planRecord.totalTasks
|
|
170
|
+
|
|
171
|
+
const evidence = {
|
|
172
|
+
roundClosed: !!roundClosed,
|
|
173
|
+
handoff: false, // 由下方 handoff 合规校验填充(存在 ≠ 合规)
|
|
174
|
+
acceptance,
|
|
175
|
+
planStatus,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 轮次真源:Plan 正文 + tasks.md(若有)+ add-route,取最大编号推导
|
|
179
|
+
const specBaseFromRefs = refs.specsDir ? `${deps.projectRoot}/${refs.specsDir}` : ""
|
|
180
|
+
const tasksContent = specBaseFromRefs ? (readFile(`${specBaseFromRefs}/tasks.md`) ?? "") : ""
|
|
181
|
+
const planContentForRounds = planRel ? (readFile(`${plansDir}/${planRel}`) ?? "") : ""
|
|
182
|
+
|
|
183
|
+
// handoff 合规校验:走 core 校验层(schema 真源);缺文件即不合规
|
|
184
|
+
let handoffValidation: UnitState["handoffValidation"]
|
|
185
|
+
if (handoffRel) {
|
|
186
|
+
const rounds = planRounds(planContentForRounds, tasksContent, addRouteContent)
|
|
187
|
+
const type = rounds > 1 ? "handoff.multi" : "handoff.single"
|
|
188
|
+
const absPath = `${plansDir}/${handoffRel}`
|
|
189
|
+
const runner =
|
|
190
|
+
deps.validateHandoff ??
|
|
191
|
+
((input: { type: "handoff.single" | "handoff.multi"; path: string; expectRounds: number }) => {
|
|
192
|
+
// core 校验层读 schema 真源;本模块不自解析模板(Spec §2.2/§2.4)
|
|
193
|
+
return coreValidate({
|
|
194
|
+
type: input.type,
|
|
195
|
+
path: input.path,
|
|
196
|
+
hook: "manual",
|
|
197
|
+
expectRounds: input.expectRounds,
|
|
198
|
+
projectRoot: deps.projectRoot,
|
|
199
|
+
magicDir: deps.magicDir,
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
const result = runner({ type, path: absPath, expectRounds: rounds })
|
|
203
|
+
handoffValidation = { type, ok: result.ok, issues: result.issues }
|
|
204
|
+
evidence.handoff = result.ok
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let state: UnitState["state"]
|
|
208
|
+
if (!addRouteRel && !planRel) state = "unknown"
|
|
209
|
+
else if (evidence.roundClosed && evidence.handoff && evidence.acceptance && evidence.planStatus) {
|
|
210
|
+
state = "closed"
|
|
211
|
+
} else {
|
|
212
|
+
state = "in-flight"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
planKeyword,
|
|
217
|
+
state,
|
|
218
|
+
documentationLag: { openSteps: open, totalSteps: total, lagging: total > 0 && open > 0 },
|
|
219
|
+
evidence,
|
|
220
|
+
...(handoffValidation ? { handoffValidation } : {}),
|
|
221
|
+
missingArtifacts,
|
|
222
|
+
refs,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 冲突检测(Plan §3 治理队列 / §7.2 同级冲突不得静默覆盖)
|
|
3
|
+
*
|
|
4
|
+
* 首版为保守启发式(无 embedding 依赖):同 scope 且高文本相似的
|
|
5
|
+
* CONSTRAINT/DECISION 互为疑似冲突,必须进入 review,由人裁决。
|
|
6
|
+
*/
|
|
7
|
+
import { charBigrams, jaccardSimilarity } from "./dedup.js"
|
|
8
|
+
|
|
9
|
+
export interface Conflict {
|
|
10
|
+
memoryId: string
|
|
11
|
+
topic: string
|
|
12
|
+
similarity: number
|
|
13
|
+
reason: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ConflictCandidate {
|
|
17
|
+
id?: string
|
|
18
|
+
kind: string
|
|
19
|
+
topic: string
|
|
20
|
+
content: string
|
|
21
|
+
scopeType: string
|
|
22
|
+
scopeValue: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ConflictOptions {
|
|
26
|
+
/** 相似度阈值(字符二元组 Jaccard),默认 0.6 */
|
|
27
|
+
threshold?: number
|
|
28
|
+
/** 参与冲突判定的 kind 集合 */
|
|
29
|
+
kinds?: readonly string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CONFLICT_KINDS = ["CONSTRAINT", "DECISION", "CONVENTION"] as const
|
|
33
|
+
|
|
34
|
+
export function detectConflicts(
|
|
35
|
+
candidate: ConflictCandidate,
|
|
36
|
+
actives: ConflictCandidate[],
|
|
37
|
+
opts: ConflictOptions = {},
|
|
38
|
+
): Conflict[] {
|
|
39
|
+
const threshold = opts.threshold ?? 0.6
|
|
40
|
+
const kinds = opts.kinds ?? DEFAULT_CONFLICT_KINDS
|
|
41
|
+
if (!kinds.includes(candidate.kind)) return []
|
|
42
|
+
const candVec = charBigrams(candidate.topic + " " + candidate.content)
|
|
43
|
+
const out: Conflict[] = []
|
|
44
|
+
for (const m of actives) {
|
|
45
|
+
if (m.id && m.id === candidate.id) continue
|
|
46
|
+
if (!kinds.includes(m.kind)) continue
|
|
47
|
+
if (m.scopeType !== candidate.scopeType || m.scopeValue !== candidate.scopeValue) continue
|
|
48
|
+
const sim = jaccardSimilarity(candVec, charBigrams(m.topic + " " + m.content))
|
|
49
|
+
if (sim >= threshold) {
|
|
50
|
+
out.push({
|
|
51
|
+
memoryId: m.id ?? "",
|
|
52
|
+
topic: m.topic,
|
|
53
|
+
similarity: Math.round(sim * 1000) / 1000,
|
|
54
|
+
reason: `同 scope(${m.scopeType}:${m.scopeValue}) 存在高相似 ${m.kind},疑似冲突,需人工裁决`,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out.sort((a, b) => b.similarity - a.similarity)
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 去重与内容规范化(Plan §4.4)
|
|
3
|
+
* contentHash = sha256(normalize(text));幂等键 = repositoryRef + contentHash + scopeType + scopeValue
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "crypto"
|
|
6
|
+
|
|
7
|
+
/** 规范化:NFKC(全半角折叠)→ 小写 → 折叠全部空白为单空格 → trim */
|
|
8
|
+
export function normalizeContent(s: string): string {
|
|
9
|
+
return s.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function contentHash(s: string): string {
|
|
13
|
+
return createHash("sha256").update(normalizeContent(s), "utf8").digest("hex")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 幂等键(与 AddMemory 的 @@unique 约束一一对应) */
|
|
17
|
+
export function memoryIdempotencyKey(input: {
|
|
18
|
+
repositoryRef: string
|
|
19
|
+
content: string
|
|
20
|
+
scopeType: string
|
|
21
|
+
scopeValue: string
|
|
22
|
+
}): string {
|
|
23
|
+
return [
|
|
24
|
+
input.repositoryRef,
|
|
25
|
+
contentHash(input.content),
|
|
26
|
+
input.scopeType,
|
|
27
|
+
input.scopeValue,
|
|
28
|
+
].join("|")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 字符二元组集合(冲突检测 / 近重复合并的轻量相似度基础,无需 embedding) */
|
|
32
|
+
export function charBigrams(s: string): Set<string> {
|
|
33
|
+
const n = normalizeContent(s)
|
|
34
|
+
const out = new Set<string>()
|
|
35
|
+
for (let i = 0; i < n.length - 1; i++) out.add(n.slice(i, i + 2))
|
|
36
|
+
if (n.length === 1) out.add(n)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
41
|
+
if (a.size === 0 && b.size === 0) return 1
|
|
42
|
+
let inter = 0
|
|
43
|
+
for (const x of a) if (b.has(x)) inter++
|
|
44
|
+
return inter / (a.size + b.size - inter || 1)
|
|
45
|
+
}
|