@morningljn/mnemo 0.1.2
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/LICENSE +21 -0
- package/README.md +156 -0
- package/README_zh.md +156 -0
- package/banner.png +0 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.js +138 -0
- package/dist/init.js.map +1 -0
- package/dist/retriever.d.ts +70 -0
- package/dist/retriever.js +689 -0
- package/dist/retriever.js.map +1 -0
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +62 -0
- package/dist/schema.js.map +1 -0
- package/dist/security.d.ts +15 -0
- package/dist/security.js +116 -0
- package/dist/security.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +150 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +122 -0
- package/dist/store.js +696 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +72 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/init.ts +157 -0
- package/src/retriever.ts +806 -0
- package/src/schema.ts +61 -0
- package/src/security.ts +132 -0
- package/src/server.ts +172 -0
- package/src/store.ts +805 -0
- package/src/types.ts +81 -0
- package/tests/retriever.test.ts +55 -0
- package/tests/security.test.ts +30 -0
- package/tests/store.test.ts +104 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +10 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite 事实存储层。
|
|
3
|
+
* 移植自 Ocean CLI MemoryStore,使用 better-sqlite3 同步 API。
|
|
4
|
+
*
|
|
5
|
+
* 职责:
|
|
6
|
+
* - CRUD 操作(facts + entities + fact_entities)
|
|
7
|
+
* - 实体自动提取(正则模式)
|
|
8
|
+
* - 信任评分反馈(非对称调整)
|
|
9
|
+
* - 去重(content UNIQUE 约束)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import Database from 'better-sqlite3'
|
|
13
|
+
import type { Statement } from 'better-sqlite3'
|
|
14
|
+
import { mkdirSync } from 'node:fs'
|
|
15
|
+
import { dirname } from 'node:path'
|
|
16
|
+
import { SCHEMA } from './schema.js'
|
|
17
|
+
import type { Fact, FactCategory } from './types.js'
|
|
18
|
+
|
|
19
|
+
// 信任评分常量
|
|
20
|
+
const HELPFUL_DELTA = 0.05
|
|
21
|
+
const UNHELPFUL_DELTA = -0.10
|
|
22
|
+
const TRUST_MIN = 0.0
|
|
23
|
+
const TRUST_MAX = 1.0
|
|
24
|
+
|
|
25
|
+
// 信任衰减配置:每个 category 的宽限期和衰减速率
|
|
26
|
+
const DECAY_CONFIG: Record<string, { graceDays: number; decayPerWeek: number }> = {
|
|
27
|
+
identity: { graceDays: 60, decayPerWeek: 0.02 }, // 身份信息稳定
|
|
28
|
+
coding_style: { graceDays: 30, decayPerWeek: 0.03 }, // 编码习惯渐变
|
|
29
|
+
tool_pref: { graceDays: 30, decayPerWeek: 0.03 }, // 工具偏好渐变
|
|
30
|
+
workflow: { graceDays: 45, decayPerWeek: 0.02 }, // 工作流较稳定
|
|
31
|
+
project: { graceDays: 30, decayPerWeek: 0.05 }, // 项目知识:开发中自动续命,停工后30天开始衰减
|
|
32
|
+
general: { graceDays: 30, decayPerWeek: 0.03 }, // 通用中等
|
|
33
|
+
}
|
|
34
|
+
const DEFAULT_DECAY = DECAY_CONFIG.general
|
|
35
|
+
const MIN_SURVIVAL_TRUST = 0.1
|
|
36
|
+
|
|
37
|
+
// 实体提取正则
|
|
38
|
+
const RE_CAPITALIZED = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g
|
|
39
|
+
const RE_DOUBLE_QUOTE = /"([^"]+)"/g
|
|
40
|
+
const RE_SINGLE_QUOTE = /'([^']+)'/g
|
|
41
|
+
const RE_AKA = /(\w+(?:\s+\w+)*)\s+(?:aka|also known as)\s+(\w+(?:\s+\w+)*)/gi
|
|
42
|
+
// 中文实体提取正则
|
|
43
|
+
const RE_CN_QUOTED = /[「」""'']([^「」""'']{2,20})[「」""'']?/g
|
|
44
|
+
const RE_CN_BOOK = /《([^》]+)》/g
|
|
45
|
+
// 中文停用词
|
|
46
|
+
const CN_STOP_WORDS = new Set([
|
|
47
|
+
'这个', '那个', '什么', '怎么', '为什么', '可以', '应该', '需要',
|
|
48
|
+
'使用', '进行', '通过', '关于', '对于', '根据', '以及', '或者',
|
|
49
|
+
'但是', '因为', '所以', '如果', '虽然', '已经', '正在', '没有',
|
|
50
|
+
'不是', '一个', '一种', '一些', '我们', '他们', '自己', '这些',
|
|
51
|
+
'那些', '可能', '能够', '就是', '还是', '只要', '只有', '然后',
|
|
52
|
+
'所以', '因为', '但是', '而且', '或者', '以及', '如果', '虽然',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
function clampTrust(value: number): number {
|
|
56
|
+
return Math.max(TRUST_MIN, Math.min(TRUST_MAX, value))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** facts 表行类型 */
|
|
60
|
+
interface FactRow {
|
|
61
|
+
fact_id: number
|
|
62
|
+
content: string
|
|
63
|
+
category: string
|
|
64
|
+
tags: string
|
|
65
|
+
keywords: string
|
|
66
|
+
trust_score: number
|
|
67
|
+
retrieval_count: number
|
|
68
|
+
helpful_count: number
|
|
69
|
+
created_at: string
|
|
70
|
+
updated_at: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** entities 表行类型 */
|
|
74
|
+
interface EntityRow {
|
|
75
|
+
entity_id: number
|
|
76
|
+
name: string
|
|
77
|
+
entity_type: string
|
|
78
|
+
aliases: string
|
|
79
|
+
created_at: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class MemoryStore {
|
|
83
|
+
private db: Database.Database
|
|
84
|
+
|
|
85
|
+
// 预编译语句
|
|
86
|
+
private stmtInsertFact!: Statement
|
|
87
|
+
private stmtFindFactByContent!: Statement
|
|
88
|
+
private stmtFindEntityByName!: Statement
|
|
89
|
+
private stmtFindEntityByAlias!: Statement
|
|
90
|
+
private stmtInsertEntity!: Statement
|
|
91
|
+
private stmtInsertFactEntity!: Statement
|
|
92
|
+
private stmtDeleteFactEntities!: Statement
|
|
93
|
+
private stmtGetEntitiesForFact!: Statement
|
|
94
|
+
|
|
95
|
+
constructor(dbPath: string, private defaultTrust = 0.5) {
|
|
96
|
+
mkdirSync(dirname(dbPath), { recursive: true })
|
|
97
|
+
this.db = new Database(dbPath)
|
|
98
|
+
this.db.pragma('journal_mode = WAL')
|
|
99
|
+
this.db.pragma('foreign_keys = ON')
|
|
100
|
+
this.initSchema()
|
|
101
|
+
this.prepareStatements()
|
|
102
|
+
this.cleanOrphanEntities()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private initSchema(): void {
|
|
106
|
+
this.db.exec(SCHEMA)
|
|
107
|
+
// 增量迁移:为已有数据库添加新列/新表
|
|
108
|
+
this.migrateSchema()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 增量迁移:添加新列(已存在则跳过) */
|
|
112
|
+
private migrateSchema(): void {
|
|
113
|
+
// keywords 列(v2)
|
|
114
|
+
try {
|
|
115
|
+
this.db.exec('ALTER TABLE facts ADD COLUMN keywords TEXT DEFAULT \'[]\'')
|
|
116
|
+
} catch { /* 列已存在 */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private prepareStatements(): void {
|
|
120
|
+
this.stmtInsertFact = this.db.prepare(
|
|
121
|
+
'INSERT INTO facts (content, category, tags, keywords, trust_score) VALUES (?, ?, ?, ?, ?)'
|
|
122
|
+
)
|
|
123
|
+
this.stmtFindFactByContent = this.db.prepare(
|
|
124
|
+
'SELECT fact_id FROM facts WHERE content = ?'
|
|
125
|
+
)
|
|
126
|
+
this.stmtFindEntityByName = this.db.prepare(
|
|
127
|
+
'SELECT entity_id FROM entities WHERE name = ?'
|
|
128
|
+
)
|
|
129
|
+
this.stmtFindEntityByAlias = this.db.prepare(
|
|
130
|
+
"SELECT entity_id FROM entities WHERE ',' || aliases || ',' LIKE '%,' || ? || ',%'"
|
|
131
|
+
)
|
|
132
|
+
this.stmtInsertEntity = this.db.prepare(
|
|
133
|
+
'INSERT INTO entities (name) VALUES (?)'
|
|
134
|
+
)
|
|
135
|
+
this.stmtInsertFactEntity = this.db.prepare(
|
|
136
|
+
'INSERT OR IGNORE INTO fact_entities (fact_id, entity_id) VALUES (?, ?)'
|
|
137
|
+
)
|
|
138
|
+
this.stmtDeleteFactEntities = this.db.prepare(
|
|
139
|
+
'DELETE FROM fact_entities WHERE fact_id = ?'
|
|
140
|
+
)
|
|
141
|
+
this.stmtGetEntitiesForFact = this.db.prepare(
|
|
142
|
+
`SELECT e.name FROM entities e
|
|
143
|
+
JOIN fact_entities fe ON fe.entity_id = e.entity_id
|
|
144
|
+
WHERE fe.fact_id = ?`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ------------------------------------------------------------------
|
|
149
|
+
// Public API
|
|
150
|
+
// ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/** 添加事实,返回 fact_id。精确重复返回已有 ID。 */
|
|
153
|
+
addFact(content: string, category: FactCategory = 'general', tags = ''): number {
|
|
154
|
+
const trimmed = content.trim()
|
|
155
|
+
if (!trimmed) throw new Error('content must not be empty')
|
|
156
|
+
|
|
157
|
+
// 将中文 bigram 追加到 tags,让 FTS5 能索引中文词组
|
|
158
|
+
const enhancedTags = this.enhanceTagsForChinese(trimmed, tags)
|
|
159
|
+
|
|
160
|
+
const insertFacts = this.db.transaction(() => {
|
|
161
|
+
try {
|
|
162
|
+
const info = this.stmtInsertFact.run(trimmed, category, enhancedTags, '[]', this.defaultTrust)
|
|
163
|
+
const factId = Number(info.lastInsertRowid)
|
|
164
|
+
|
|
165
|
+
// 实体提取和关联(只从内容中提取,不从 tags 提取)
|
|
166
|
+
const entities = this.extractEntities(trimmed)
|
|
167
|
+
for (const name of entities) {
|
|
168
|
+
const entityId = this.resolveEntity(name)
|
|
169
|
+
this.stmtInsertFactEntity.run(factId, entityId)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return factId
|
|
173
|
+
} catch (err: unknown) {
|
|
174
|
+
// UNIQUE 冲突 — 返回已有 ID
|
|
175
|
+
if (err instanceof Error && err.message?.includes('UNIQUE')) {
|
|
176
|
+
const row = this.stmtFindFactByContent.get(trimmed) as { fact_id: number } | null
|
|
177
|
+
return row ? row.fact_id : -1
|
|
178
|
+
}
|
|
179
|
+
throw err
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return insertFacts()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 三层递进式去重。
|
|
188
|
+
*
|
|
189
|
+
* 层 1: 实体重叠 + 编辑距离(英文/有明确实体时效果最好)
|
|
190
|
+
* 层 2: Jaccard bigram 相似度(中文回退,bigram 分词不依赖实体提取)
|
|
191
|
+
* 层 3: Containment 包含率(捕捉"旧事实是新事实子集"的场景)
|
|
192
|
+
*
|
|
193
|
+
* 每层找到匹配即返回,不再尝试下一层。
|
|
194
|
+
*/
|
|
195
|
+
findSimilarFact(content: string, category?: FactCategory): Fact | null {
|
|
196
|
+
const newEntities = this.extractEntities(content).map(e => e.toLowerCase())
|
|
197
|
+
const existing = this.listFacts(category, 0.0, 50)
|
|
198
|
+
|
|
199
|
+
// -- 层 1: 实体重叠 + 编辑距离(保留原逻辑,不短路) --
|
|
200
|
+
if (newEntities.length > 0) {
|
|
201
|
+
const entitySet = new Set(newEntities)
|
|
202
|
+
let bestMatch: Fact | null = null
|
|
203
|
+
let bestScore = 0
|
|
204
|
+
|
|
205
|
+
for (const fact of existing) {
|
|
206
|
+
const factEntityNames = (this.stmtGetEntitiesForFact.all(fact.factId) as { name: string }[])
|
|
207
|
+
.map(r => r.name.toLowerCase())
|
|
208
|
+
if (factEntityNames.length === 0) continue
|
|
209
|
+
|
|
210
|
+
const factEntitySet = new Set(factEntityNames)
|
|
211
|
+
|
|
212
|
+
let overlap = 0
|
|
213
|
+
for (const e of entitySet) { if (factEntitySet.has(e)) overlap++ }
|
|
214
|
+
const newInOld = overlap / entitySet.size
|
|
215
|
+
const oldInNew = overlap / factEntitySet.size
|
|
216
|
+
const entityScore = Math.min(newInOld, oldInNew)
|
|
217
|
+
|
|
218
|
+
if (entityScore < 0.5) continue
|
|
219
|
+
|
|
220
|
+
const editSim = this.normalizedEditDistance(content, fact.content)
|
|
221
|
+
if (editSim >= 0.5 && editSim > bestScore) {
|
|
222
|
+
bestMatch = fact
|
|
223
|
+
bestScore = editSim
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (bestMatch) return bestMatch
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// -- 层 2: Jaccard bigram 相似度(中文回退) --
|
|
230
|
+
const tokens = this.tokenizeForDedup(content)
|
|
231
|
+
if (tokens.size >= 3) {
|
|
232
|
+
let bestMatch: Fact | null = null
|
|
233
|
+
let bestScore = 0
|
|
234
|
+
for (const fact of existing) {
|
|
235
|
+
const factTokens = this.tokenizeForDedup(fact.content)
|
|
236
|
+
const sim = this.jaccardSimilarity(tokens, factTokens)
|
|
237
|
+
if (sim >= 0.45 && sim > bestScore) {
|
|
238
|
+
bestMatch = fact
|
|
239
|
+
bestScore = sim
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (bestMatch) return bestMatch
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// -- 层 3: Containment 包含率(子集检测) --
|
|
246
|
+
if (tokens.size >= 3) {
|
|
247
|
+
for (const fact of existing) {
|
|
248
|
+
const factTokens = this.tokenizeForDedup(fact.content)
|
|
249
|
+
if (this.containmentScore(tokens, factTokens) >= 0.8) {
|
|
250
|
+
return fact
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 归一化编辑距离(Levenshtein),返回 0~1 的相似度 */
|
|
259
|
+
private normalizedEditDistance(a: string, b: string): number {
|
|
260
|
+
if (a === b) return 1
|
|
261
|
+
const la = a.length, lb = b.length
|
|
262
|
+
if (la === 0 || lb === 0) return 0
|
|
263
|
+
// 限制长度差过大的情况:长度差超过3倍直接判不相似
|
|
264
|
+
const maxLen = Math.max(la, lb)
|
|
265
|
+
const minLen = Math.min(la, lb)
|
|
266
|
+
if (minLen * 3 < maxLen) return 0
|
|
267
|
+
|
|
268
|
+
// 一维 DP 求编辑距离
|
|
269
|
+
const prev = new Uint16Array(minLen + 1)
|
|
270
|
+
const curr = new Uint16Array(minLen + 1)
|
|
271
|
+
for (let j = 0; j <= minLen; j++) prev[j] = j
|
|
272
|
+
|
|
273
|
+
for (let i = 1; i <= maxLen; i++) {
|
|
274
|
+
curr[0] = i
|
|
275
|
+
const ca = i <= la ? a[i - 1] : ''
|
|
276
|
+
for (let j = 1; j <= minLen; j++) {
|
|
277
|
+
const cb = j <= lb ? b[j - 1] : ''
|
|
278
|
+
const cost = ca === cb ? 0 : 1
|
|
279
|
+
curr[j] = Math.min(
|
|
280
|
+
prev[j] + 1, // 删除
|
|
281
|
+
curr[j - 1] + 1, // 插入
|
|
282
|
+
prev[j - 1] + cost // 替换
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
prev.set(curr)
|
|
286
|
+
}
|
|
287
|
+
return 1 - prev[minLen] / maxLen
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 部分更新事实 */
|
|
291
|
+
updateFact(
|
|
292
|
+
factId: number,
|
|
293
|
+
updates: {
|
|
294
|
+
content?: string
|
|
295
|
+
tags?: string
|
|
296
|
+
category?: FactCategory
|
|
297
|
+
trustDelta?: number
|
|
298
|
+
},
|
|
299
|
+
): boolean {
|
|
300
|
+
const row = this.db.prepare('SELECT fact_id, trust_score FROM facts WHERE fact_id = ?')
|
|
301
|
+
.get(factId) as (Pick<FactRow, 'fact_id' | 'trust_score'>) | null
|
|
302
|
+
if (!row) return false
|
|
303
|
+
|
|
304
|
+
const assignments: string[] = ["updated_at = datetime('now', 'localtime')"]
|
|
305
|
+
const params: unknown[] = []
|
|
306
|
+
|
|
307
|
+
if (updates.content !== undefined) {
|
|
308
|
+
assignments.push('content = ?')
|
|
309
|
+
params.push(updates.content.trim())
|
|
310
|
+
}
|
|
311
|
+
if (updates.tags !== undefined) {
|
|
312
|
+
assignments.push('tags = ?')
|
|
313
|
+
params.push(updates.tags)
|
|
314
|
+
}
|
|
315
|
+
if (updates.category !== undefined) {
|
|
316
|
+
assignments.push('category = ?')
|
|
317
|
+
params.push(updates.category)
|
|
318
|
+
}
|
|
319
|
+
if (updates.trustDelta !== undefined) {
|
|
320
|
+
const newTrust = clampTrust(row.trust_score + updates.trustDelta)
|
|
321
|
+
assignments.push('trust_score = ?')
|
|
322
|
+
params.push(newTrust)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
params.push(factId)
|
|
326
|
+
this.db.prepare(`UPDATE facts SET ${assignments.join(', ')} WHERE fact_id = ?`).run(...params)
|
|
327
|
+
|
|
328
|
+
// 内容变更时重新提取实体
|
|
329
|
+
if (updates.content !== undefined) {
|
|
330
|
+
this.stmtDeleteFactEntities.run(factId)
|
|
331
|
+
const entities = this.extractEntities(updates.content)
|
|
332
|
+
for (const name of entities) {
|
|
333
|
+
const entityId = this.resolveEntity(name)
|
|
334
|
+
this.stmtInsertFactEntity.run(factId, entityId)
|
|
335
|
+
}
|
|
336
|
+
// 清理因内容变更而产生的孤立实体
|
|
337
|
+
this.cleanOrphanEntities()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return true
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 删除事实 */
|
|
344
|
+
removeFact(factId: number): boolean {
|
|
345
|
+
const row = this.db.prepare('SELECT fact_id FROM facts WHERE fact_id = ?').get(factId)
|
|
346
|
+
if (!row) return false
|
|
347
|
+
this.stmtDeleteFactEntities.run(factId)
|
|
348
|
+
this.db.prepare('DELETE FROM facts WHERE fact_id = ?').run(factId)
|
|
349
|
+
this.cleanOrphanEntities()
|
|
350
|
+
return true
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 浏览事实(按信任评分排序) */
|
|
354
|
+
listFacts(category?: FactCategory, minTrust = 0.0, limit = 50): Fact[] {
|
|
355
|
+
const params: unknown[] = [minTrust]
|
|
356
|
+
let categoryClause = ''
|
|
357
|
+
if (category) {
|
|
358
|
+
categoryClause = 'AND category = ?'
|
|
359
|
+
params.push(category)
|
|
360
|
+
}
|
|
361
|
+
params.push(limit)
|
|
362
|
+
|
|
363
|
+
const sql = `
|
|
364
|
+
SELECT fact_id, content, category, tags, keywords, trust_score,
|
|
365
|
+
retrieval_count, helpful_count, created_at, updated_at
|
|
366
|
+
FROM facts
|
|
367
|
+
WHERE trust_score >= ?
|
|
368
|
+
${categoryClause}
|
|
369
|
+
ORDER BY trust_score DESC
|
|
370
|
+
LIMIT ?
|
|
371
|
+
`
|
|
372
|
+
|
|
373
|
+
const rows = this.db.prepare(sql).all(...params) as FactRow[]
|
|
374
|
+
return rows.map(r => this.rowToFact(r))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 记录反馈,调整信任评分 */
|
|
378
|
+
recordFeedback(factId: number, helpful: boolean): { oldTrust: number; newTrust: number; helpfulCount: number } {
|
|
379
|
+
const row = this.db.prepare(
|
|
380
|
+
'SELECT fact_id, trust_score, helpful_count FROM facts WHERE fact_id = ?'
|
|
381
|
+
).get(factId) as (Pick<FactRow, 'fact_id' | 'trust_score' | 'helpful_count'>) | null
|
|
382
|
+
if (!row) throw new Error(`fact_id ${factId} not found`)
|
|
383
|
+
|
|
384
|
+
const oldTrust = row.trust_score
|
|
385
|
+
const delta = helpful ? HELPFUL_DELTA : UNHELPFUL_DELTA
|
|
386
|
+
const newTrust = clampTrust(oldTrust + delta)
|
|
387
|
+
const helpfulIncrement = helpful ? 1 : 0
|
|
388
|
+
|
|
389
|
+
this.db.prepare(`
|
|
390
|
+
UPDATE facts
|
|
391
|
+
SET trust_score = ?, helpful_count = helpful_count + ?, updated_at = datetime('now', 'localtime')
|
|
392
|
+
WHERE fact_id = ?
|
|
393
|
+
`).run(newTrust, helpfulIncrement, factId)
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
oldTrust,
|
|
397
|
+
newTrust,
|
|
398
|
+
helpfulCount: row.helpful_count + helpfulIncrement,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 按实体名查询关联事实 */
|
|
403
|
+
getFactsByEntity(entityName: string, category?: FactCategory, limit = 10): Fact[] {
|
|
404
|
+
const params: unknown[] = [entityName]
|
|
405
|
+
let categoryClause = ''
|
|
406
|
+
if (category) {
|
|
407
|
+
categoryClause = 'AND f.category = ?'
|
|
408
|
+
params.push(category)
|
|
409
|
+
}
|
|
410
|
+
params.push(limit)
|
|
411
|
+
|
|
412
|
+
const sql = `
|
|
413
|
+
SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.trust_score,
|
|
414
|
+
f.retrieval_count, f.helpful_count, f.created_at, f.updated_at
|
|
415
|
+
FROM facts f
|
|
416
|
+
JOIN fact_entities fe ON f.fact_id = fe.fact_id
|
|
417
|
+
JOIN entities e ON fe.entity_id = e.entity_id
|
|
418
|
+
WHERE e.name LIKE ?
|
|
419
|
+
${categoryClause}
|
|
420
|
+
ORDER BY f.trust_score DESC
|
|
421
|
+
LIMIT ?
|
|
422
|
+
`
|
|
423
|
+
|
|
424
|
+
const rows = this.db.prepare(sql).all(...params) as FactRow[]
|
|
425
|
+
return rows.map(r => this.rowToFact(r))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 按多实体 AND 查询(同时关联多个实体的事实) */
|
|
429
|
+
getFactsByEntities(entities: string[], category?: FactCategory, limit = 10): Fact[] {
|
|
430
|
+
if (entities.length === 0) return []
|
|
431
|
+
|
|
432
|
+
// 构建 INTERSECT 查询
|
|
433
|
+
const intersects = entities.map(() =>
|
|
434
|
+
`SELECT fe.fact_id FROM fact_entities fe
|
|
435
|
+
JOIN entities e ON fe.entity_id = e.entity_id
|
|
436
|
+
WHERE e.name LIKE ?`
|
|
437
|
+
).join(' INTERSECT ')
|
|
438
|
+
|
|
439
|
+
const params: unknown[] = [...entities]
|
|
440
|
+
let categoryClause = ''
|
|
441
|
+
if (category) {
|
|
442
|
+
categoryClause = 'AND f.category = ?'
|
|
443
|
+
params.push(category)
|
|
444
|
+
}
|
|
445
|
+
params.push(limit)
|
|
446
|
+
|
|
447
|
+
const sql = `
|
|
448
|
+
SELECT f.fact_id, f.content, f.category, f.tags, f.keywords, f.trust_score,
|
|
449
|
+
f.retrieval_count, f.helpful_count, f.created_at, f.updated_at
|
|
450
|
+
FROM facts f
|
|
451
|
+
WHERE f.fact_id IN (${intersects})
|
|
452
|
+
${categoryClause}
|
|
453
|
+
ORDER BY f.trust_score DESC
|
|
454
|
+
LIMIT ?
|
|
455
|
+
`
|
|
456
|
+
|
|
457
|
+
const rows = this.db.prepare(sql).all(...params) as FactRow[]
|
|
458
|
+
return rows.map(r => this.rowToFact(r))
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 获取事实关联的实体名列表 */
|
|
462
|
+
getEntitiesForFact(factId: number): string[] {
|
|
463
|
+
const rows = this.stmtGetEntitiesForFact.all(factId) as Pick<EntityRow, 'name'>[]
|
|
464
|
+
return rows.map(r => r.name)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 信任衰减:按 category 宽限期 + 衰减率处理过期事实。
|
|
469
|
+
* 超过宽限期的事实,trust_score 每周衰减 decayPerWeek。
|
|
470
|
+
* trust_score < MIN_SURVIVAL_TRUST 的自动删除。
|
|
471
|
+
* @returns { decayed: number, removed: number }
|
|
472
|
+
*/
|
|
473
|
+
decayTrustScores(): { decayed: number; removed: number } {
|
|
474
|
+
const now = Date.now()
|
|
475
|
+
const rows = this.db.prepare(
|
|
476
|
+
'SELECT fact_id, category, trust_score, updated_at FROM facts'
|
|
477
|
+
).all() as Array<{ fact_id: number; category: string; trust_score: number; updated_at: string }>
|
|
478
|
+
|
|
479
|
+
let decayed = 0
|
|
480
|
+
let removed = 0
|
|
481
|
+
|
|
482
|
+
for (const row of rows) {
|
|
483
|
+
const config = DECAY_CONFIG[row.category] ?? DEFAULT_DECAY
|
|
484
|
+
const updatedDate = new Date(row.updated_at + 'Z')
|
|
485
|
+
const ageDays = (now - updatedDate.getTime()) / 86_400_000
|
|
486
|
+
|
|
487
|
+
if (ageDays <= config.graceDays) continue
|
|
488
|
+
|
|
489
|
+
const decayWeeks = (ageDays - config.graceDays) / 7
|
|
490
|
+
const newTrust = clampTrust(row.trust_score - config.decayPerWeek * decayWeeks)
|
|
491
|
+
|
|
492
|
+
if (newTrust <= MIN_SURVIVAL_TRUST) {
|
|
493
|
+
this.removeFact(row.fact_id)
|
|
494
|
+
removed++
|
|
495
|
+
} else if (newTrust < row.trust_score) {
|
|
496
|
+
this.db.prepare('UPDATE facts SET trust_score = ? WHERE fact_id = ?').run(newTrust, row.fact_id)
|
|
497
|
+
decayed++
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { decayed, removed }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 矛盾降权:新事实添加后,查找同 category 中共享实体但内容冲突的旧事实,降低其 trust。
|
|
506
|
+
* 用归一化编辑距离判断冲突:同实体但编辑距离低 = 矛盾/需求变更。
|
|
507
|
+
* @returns 被降权的事实数量
|
|
508
|
+
*/
|
|
509
|
+
demoteContradictingFacts(newFactId: number, newContent: string, category: FactCategory): number {
|
|
510
|
+
const entities = this.getEntitiesForFact(newFactId)
|
|
511
|
+
if (entities.length === 0) return 0
|
|
512
|
+
|
|
513
|
+
const entityPlaceholders = entities.map(() => '?').join(',')
|
|
514
|
+
const rows = this.db.prepare(`
|
|
515
|
+
SELECT DISTINCT f.fact_id, f.content
|
|
516
|
+
FROM facts f
|
|
517
|
+
JOIN fact_entities fe ON f.fact_id = fe.fact_id
|
|
518
|
+
JOIN entities e ON fe.entity_id = e.entity_id
|
|
519
|
+
WHERE e.name IN (${entityPlaceholders})
|
|
520
|
+
AND f.category = ?
|
|
521
|
+
AND f.fact_id != ?
|
|
522
|
+
`).all(...entities, category, newFactId) as Array<{ fact_id: number; content: string }>
|
|
523
|
+
|
|
524
|
+
if (rows.length === 0) return 0
|
|
525
|
+
|
|
526
|
+
let demoted = 0
|
|
527
|
+
for (const row of rows) {
|
|
528
|
+
const editSim = this.normalizedEditDistance(newContent, row.content)
|
|
529
|
+
// 同实体 + 编辑距离低 = 矛盾信号(如"用Express"改成"用Fastify")
|
|
530
|
+
// 编辑距离 0.2~0.5 是矛盾区间;<0.2 完全不同;≥0.5 太相似(已在 findSimilarFact 合并)
|
|
531
|
+
if (editSim >= 0.2 && editSim < 0.5) {
|
|
532
|
+
this.db.prepare(
|
|
533
|
+
'UPDATE facts SET trust_score = MAX(0, trust_score - 0.10) WHERE fact_id = ?'
|
|
534
|
+
).run(row.fact_id)
|
|
535
|
+
demoted++
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return demoted
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 启动时矛盾审计:扫描所有事实对,找到内容冲突的,降权较旧的。
|
|
543
|
+
*
|
|
544
|
+
* 双层扫描:
|
|
545
|
+
* 1. 实体 JOIN(已有逻辑):捕捉有明确实体重叠的矛盾
|
|
546
|
+
* 2. Jaccard 回退(新增):捕捉中文事实等实体提取为空的矛盾
|
|
547
|
+
*
|
|
548
|
+
* 每次 session 启动必然执行,不依赖写入触发。
|
|
549
|
+
* @returns { audited: number, demoted: number }
|
|
550
|
+
*/
|
|
551
|
+
auditContradictions(): { audited: number; demoted: number } {
|
|
552
|
+
let demoted = 0
|
|
553
|
+
const alreadyDemoted = new Set<number>()
|
|
554
|
+
|
|
555
|
+
// -- 层 1: 实体 JOIN 矛盾检测(原有逻辑) --
|
|
556
|
+
const rows = this.db.prepare(`
|
|
557
|
+
SELECT f1.fact_id as id1, f1.content as c1, f1.updated_at as t1,
|
|
558
|
+
f2.fact_id as id2, f2.content as c2, f2.updated_at as t2
|
|
559
|
+
FROM facts f1
|
|
560
|
+
JOIN fact_entities fe1 ON f1.fact_id = fe1.fact_id
|
|
561
|
+
JOIN entities e ON fe1.entity_id = e.entity_id
|
|
562
|
+
JOIN fact_entities fe2 ON e.entity_id = fe2.entity_id
|
|
563
|
+
JOIN facts f2 ON fe2.fact_id = f2.fact_id
|
|
564
|
+
WHERE f1.fact_id < f2.fact_id
|
|
565
|
+
AND f1.category = f2.category
|
|
566
|
+
AND f1.trust_score >= 0.2
|
|
567
|
+
AND f2.trust_score >= 0.2
|
|
568
|
+
GROUP BY f1.fact_id, f2.fact_id
|
|
569
|
+
`).all() as Array<{ id1: number; c1: string; t1: string; id2: number; c2: string; t2: string }>
|
|
570
|
+
|
|
571
|
+
for (const row of rows) {
|
|
572
|
+
const editSim = this.normalizedEditDistance(row.c1, row.c2)
|
|
573
|
+
if (editSim >= 0.2 && editSim < 0.5) {
|
|
574
|
+
const older = row.t1 < row.t2 ? row.id1 : row.id2
|
|
575
|
+
if (!alreadyDemoted.has(older)) {
|
|
576
|
+
this.db.prepare(
|
|
577
|
+
'UPDATE facts SET trust_score = MAX(0, trust_score - 0.10) WHERE fact_id = ?'
|
|
578
|
+
).run(older)
|
|
579
|
+
alreadyDemoted.add(older)
|
|
580
|
+
demoted++
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// -- 层 2: Jaccard 纯文本矛盾扫描(中文回退,O(n²),事实数 ≤ 200 时执行) --
|
|
586
|
+
const allFacts = this.listFacts(undefined, 0.2, 200)
|
|
587
|
+
if (allFacts.length <= 200 && allFacts.length >= 2) {
|
|
588
|
+
for (let i = 0; i < allFacts.length; i++) {
|
|
589
|
+
const a = allFacts[i]
|
|
590
|
+
const tokensA = this.tokenizeForDedup(a.content)
|
|
591
|
+
if (tokensA.size < 3) continue
|
|
592
|
+
|
|
593
|
+
for (let j = i + 1; j < allFacts.length; j++) {
|
|
594
|
+
const b = allFacts[j]
|
|
595
|
+
// 跳过不同 category(矛盾通常在同一分类内)
|
|
596
|
+
if (a.category !== b.category) continue
|
|
597
|
+
|
|
598
|
+
const tokensB = this.tokenizeForDedup(b.content)
|
|
599
|
+
if (tokensB.size < 3) continue
|
|
600
|
+
|
|
601
|
+
const sim = this.jaccardSimilarity(tokensA, tokensB)
|
|
602
|
+
// Jaccard 0.45~0.7 视为同主题但表述有差异(矛盾区间)
|
|
603
|
+
// < 0.45 不相关;≥ 0.7 太相似(应在 findSimilarFact 中合并)
|
|
604
|
+
if (sim >= 0.45 && sim < 0.7) {
|
|
605
|
+
const older = a.updatedAt < b.updatedAt ? a.factId : b.factId
|
|
606
|
+
if (!alreadyDemoted.has(older)) {
|
|
607
|
+
this.db.prepare(
|
|
608
|
+
'UPDATE facts SET trust_score = MAX(0, trust_score - 0.10) WHERE fact_id = ?'
|
|
609
|
+
).run(older)
|
|
610
|
+
alreadyDemoted.add(older)
|
|
611
|
+
demoted++
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return { audited: rows.length, demoted }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 项目活跃续命:重置所有 project category 事实的 updated_at。
|
|
623
|
+
* 用户在本项目启动 ocean = 项目正在开发,project 事实不应衰减。
|
|
624
|
+
* 项目完成后不再启动 = 自然衰减 + 自动清理。
|
|
625
|
+
*/
|
|
626
|
+
refreshProjectFacts(): void {
|
|
627
|
+
this.db.prepare(
|
|
628
|
+
`UPDATE facts SET updated_at = datetime('now', 'localtime') WHERE category = 'project'`
|
|
629
|
+
).run()
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** 获取事实总数 */
|
|
633
|
+
getTotalCount(): number {
|
|
634
|
+
const row = this.db.prepare('SELECT COUNT(*) as count FROM facts').get() as { count: number }
|
|
635
|
+
return row.count
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** 获取数据库连接(供 FactRetriever 直接使用) */
|
|
639
|
+
get connection(): Database.Database {
|
|
640
|
+
return this.db
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** 去重用分词:英文空格分词 + 中文 bigram */
|
|
644
|
+
private tokenizeForDedup(text: string): Set<string> {
|
|
645
|
+
const tokens = new Set<string>()
|
|
646
|
+
// 英文 token
|
|
647
|
+
for (const word of text.toLowerCase().split(/\s+/)) {
|
|
648
|
+
const cleaned = word.replace(/[.,;:!?"'(){}[\]#@<>]/g, '')
|
|
649
|
+
if (cleaned && cleaned.length > 1) tokens.add(cleaned)
|
|
650
|
+
}
|
|
651
|
+
// 中文 bigram
|
|
652
|
+
const cnChars = text.match(/[\u4e00-\u9fff]+/g) ?? []
|
|
653
|
+
for (const segment of cnChars) {
|
|
654
|
+
for (let i = 0; i < segment.length - 1; i++) {
|
|
655
|
+
tokens.add(segment.slice(i, i + 2))
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return tokens
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Jaccard 相似度 */
|
|
662
|
+
private jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
663
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
664
|
+
let intersection = 0
|
|
665
|
+
for (const item of a) {
|
|
666
|
+
if (b.has(item)) intersection++
|
|
667
|
+
}
|
|
668
|
+
const unionSize = a.size + b.size - intersection
|
|
669
|
+
return unionSize > 0 ? intersection / unionSize : 0
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** 包含率:短文本的 token 在长文本中出现的比例(双向取 max) */
|
|
673
|
+
private containmentScore(a: Set<string>, b: Set<string>): number {
|
|
674
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
675
|
+
// a 包含在 b 中的比例
|
|
676
|
+
let aInB = 0
|
|
677
|
+
for (const item of a) if (b.has(item)) aInB++
|
|
678
|
+
const scoreA = aInB / a.size
|
|
679
|
+
// b 包含在 a 中的比例
|
|
680
|
+
let bInA = 0
|
|
681
|
+
for (const item of b) if (a.has(item)) bInA++
|
|
682
|
+
const scoreB = bInA / b.size
|
|
683
|
+
return Math.max(scoreA, scoreB)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** 将中文 bigram 追加到 tags,让 FTS5 能检索中文词组 */
|
|
687
|
+
private enhanceTagsForChinese(content: string, tags: string): string {
|
|
688
|
+
const bigrams: string[] = []
|
|
689
|
+
const cnChars = content.match(/[\u4e00-\u9fff]+/g) ?? []
|
|
690
|
+
for (const segment of cnChars) {
|
|
691
|
+
for (let i = 0; i < segment.length - 1; i++) {
|
|
692
|
+
const bg = segment.slice(i, i + 2)
|
|
693
|
+
if (!CN_STOP_WORDS.has(bg)) {
|
|
694
|
+
bigrams.push(bg)
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (bigrams.length === 0) return tags
|
|
699
|
+
const existingTags = tags ? tags + ',' : ''
|
|
700
|
+
return existingTags + bigrams.join(',')
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** 实体类型分类 */
|
|
704
|
+
private classifyEntity(name: string): string {
|
|
705
|
+
// 含英文 → technology
|
|
706
|
+
if (/[A-Za-z]/.test(name)) return 'technology'
|
|
707
|
+
// 中文 3-4 字常见人名模式 → person(2字太容易误判)
|
|
708
|
+
if (/^[\u4e00-\u9fff]{3,4}$/.test(name)) return 'person'
|
|
709
|
+
// 中文 5+ 字 → topic
|
|
710
|
+
if (/^[\u4e00-\u9fff·]{5,}$/.test(name)) return 'topic'
|
|
711
|
+
// 2字中文 → topic(如"模型"、"架构"等技术术语)
|
|
712
|
+
if (/^[\u4e00-\u9fff]{2}$/.test(name)) return 'topic'
|
|
713
|
+
return 'unknown'
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** 清理没有关联任何事实的孤立实体 */
|
|
717
|
+
private cleanOrphanEntities(): void {
|
|
718
|
+
this.db.prepare(`
|
|
719
|
+
DELETE FROM entities WHERE entity_id NOT IN (
|
|
720
|
+
SELECT DISTINCT entity_id FROM fact_entities
|
|
721
|
+
)
|
|
722
|
+
`).run()
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
close(): void {
|
|
726
|
+
this.db.close()
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ------------------------------------------------------------------
|
|
730
|
+
// 内部方法
|
|
731
|
+
// ------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
private resolveEntity(name: string): number {
|
|
734
|
+
// 精确名称匹配
|
|
735
|
+
const byName = this.stmtFindEntityByName.get(name) as Pick<EntityRow, 'entity_id'> | null
|
|
736
|
+
if (byName) return byName.entity_id
|
|
737
|
+
|
|
738
|
+
// 别名匹配
|
|
739
|
+
const byAlias = this.stmtFindEntityByAlias.get(name) as Pick<EntityRow, 'entity_id'> | null
|
|
740
|
+
if (byAlias) return byAlias.entity_id
|
|
741
|
+
|
|
742
|
+
// 创建新实体,自动填充 entity_type
|
|
743
|
+
const entityType = this.classifyEntity(name)
|
|
744
|
+
const info = this.db.prepare(
|
|
745
|
+
'INSERT INTO entities (name, entity_type) VALUES (?, ?)'
|
|
746
|
+
).run(name, entityType)
|
|
747
|
+
return Number(info.lastInsertRowid)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private extractEntities(text: string): string[] {
|
|
751
|
+
const seen = new Set<string>()
|
|
752
|
+
const result: string[] = []
|
|
753
|
+
|
|
754
|
+
const add = (name: string): void => {
|
|
755
|
+
const stripped = name.trim()
|
|
756
|
+
// 实体验证:拒绝包含句子级标点的碎片
|
|
757
|
+
const CN_PUNCT = /[、,。!?;:,…—()《》【】""''「」\n\r\t]/
|
|
758
|
+
if (stripped
|
|
759
|
+
&& stripped.length >= 2 && stripped.length <= 30
|
|
760
|
+
&& !seen.has(stripped.toLowerCase())
|
|
761
|
+
&& !CN_PUNCT.test(stripped)) {
|
|
762
|
+
seen.add(stripped.toLowerCase())
|
|
763
|
+
result.push(stripped)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// 英文实体
|
|
768
|
+
for (const m of text.matchAll(RE_CAPITALIZED)) add(m[1])
|
|
769
|
+
for (const m of text.matchAll(RE_DOUBLE_QUOTE)) add(m[1])
|
|
770
|
+
for (const m of text.matchAll(RE_SINGLE_QUOTE)) add(m[1])
|
|
771
|
+
for (const m of text.matchAll(RE_AKA)) { add(m[1]); add(m[2]) }
|
|
772
|
+
|
|
773
|
+
// 中文实体:引号包裹
|
|
774
|
+
for (const m of text.matchAll(RE_CN_QUOTED)) add(m[1])
|
|
775
|
+
// 中文实体:书名号
|
|
776
|
+
for (const m of text.matchAll(RE_CN_BOOK)) add(m[1])
|
|
777
|
+
|
|
778
|
+
// 中文实体:常见声明模式("我叫XXX"、"项目叫XXX"、"名字是XXX")
|
|
779
|
+
const CN_NAME_DECL = /(?:我叫|叫|名字是|名称是|名叫|叫做)([^\s,,。!?;:]{2,10})/gi
|
|
780
|
+
for (const m of text.matchAll(CN_NAME_DECL)) {
|
|
781
|
+
const v = m[1].trim()
|
|
782
|
+
if (v.length >= 2 && v.length <= 8) add(v)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// 注意:bigram 分词只用于 FTS5 tags 索引(enhanceTagsForChinese),
|
|
786
|
+
// 不作为实体存储。实体提取只取引号/书名号/声明模式中的明确实体。
|
|
787
|
+
|
|
788
|
+
return result
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private rowToFact(row: FactRow): Fact {
|
|
792
|
+
return {
|
|
793
|
+
factId: row.fact_id,
|
|
794
|
+
content: row.content,
|
|
795
|
+
category: row.category as FactCategory,
|
|
796
|
+
tags: row.tags,
|
|
797
|
+
keywords: row.keywords,
|
|
798
|
+
trustScore: row.trust_score,
|
|
799
|
+
retrievalCount: row.retrieval_count,
|
|
800
|
+
helpfulCount: row.helpful_count,
|
|
801
|
+
createdAt: row.created_at,
|
|
802
|
+
updatedAt: row.updated_at,
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|