diff --git a/packages/node-runtime/src/semantic-index/service.test.ts b/packages/node-runtime/src/semantic-index/service.test.ts index 561be344..0589d394 100644 --- a/packages/node-runtime/src/semantic-index/service.test.ts +++ b/packages/node-runtime/src/semantic-index/service.test.ts @@ -14,6 +14,9 @@ import type { SessionRuntimeAdapter } from '../services/adapters' import type { LocalPipelineFactory } from './embedding/local' const SESSION_ID = 'sess1' +// 真实秒级基准时间(2023-11-14),用于让证据 snippet 渲染出真实年份, +// 暴露「毫秒被当作秒再 *1000」导致五位数年份(如 56150)的回归。 +const BASE_TS_SECONDS = 1_700_000_000 function setup(opts?: { embedDelayMs?: number }) { const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() @@ -28,7 +31,7 @@ function setup(opts?: { embedDelayMs?: number }) { `) const insert = db.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, 0, ?)') for (let i = 1; i <= 40; i++) { - insert.run(i, (i % 2) + 1, i * 60, `第${i}条关于项目排期和需求讨论的消息内容`) + insert.run(i, (i % 2) + 1, BASE_TS_SECONDS + i * 60, `第${i}条关于项目排期和需求讨论的消息内容`) } buildFtsIndex(db) @@ -343,6 +346,13 @@ test('searchForTool desensitizes + anonymizes via pipeline, never leaks raw', as } // 元数据不得夹带原始消息 assert.equal(JSON.stringify(result.sources).includes('rawMessages'), false) + + // 回归:snippet 时间口径正确——证据消息 ts 是毫秒,预处理管道按秒渲染(内部 *1000), + // 服务必须把毫秒转回秒,否则会渲染出五位数年份(如 2023 -> 56xxx)。 + const fiveDigitYear = /\b\d{5}\/\d{1,2}\/\d{1,2}/ + assert.equal(fiveDigitYear.test(result.text), false) + assert.ok(result.text.includes('2023/')) + service.close() db.close() }) diff --git a/packages/node-runtime/src/semantic-index/service.ts b/packages/node-runtime/src/semantic-index/service.ts index 8b235f43..155bb845 100644 --- a/packages/node-runtime/src/semantic-index/service.ts +++ b/packages/node-runtime/src/semantic-index/service.ts @@ -524,7 +524,8 @@ export class SemanticIndexService { senderName: m.senderName, senderPlatformId: m.senderPlatformId, content: m.content, - timestamp: m.ts, + // EvidenceMessage.ts 是毫秒;预处理管道按秒渲染时间(format.ts 内部 *1000),故此处回退为秒 + timestamp: Math.floor(m.ts / 1000), })) for (const m of block.messages) { if (m.senderId != null && !nameMap.has(m.senderId)) { diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.test.ts b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts index ff62621e..f640ef0d 100644 --- a/packages/tools/src/definitions/retrieve-chat-evidence.test.ts +++ b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts @@ -197,6 +197,35 @@ describe('retrieveChatEvidenceTool candidates & grouping', () => { assert.equal(source.timestamp, 1714521600 * 1000) }) + it('strips per-line time prefixes from semantic snippets (display shows one group time)', async () => { + const service = makeService({ + searchForTool: async () => + semanticResult({ + sources: [ + { + startMessageId: 100, + endMessageId: 110, + score: 0.9, + chunkIds: ['c1'], + // 预处理管道渲染格式:每行「时间 发送者: 内容」 + snippet: '2024/5/1 08:00:00 甲: 到了乐山\n2024/5/1 09:00:00 乙: 住了一晚', + startTime: '2024-05-01T00:00:00.000Z', + endTime: '2024-05-01T01:00:00.000Z', + }, + ], + }), + }) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'semantic' }, + makeContext({ semanticIndexService: service }) + ) + const snippet = getPayload(res.data).groups[0].sources[0].snippet + assert.equal(snippet.includes('2024/5/1'), false) + assert.equal(snippet.includes('08:00:00'), false) + assert.ok(snippet.includes('甲: 到了乐山')) + assert.ok(snippet.includes('乙: 住了一晚')) + }) + it('desensitizes keyword snippets and never persists raw secret content', async () => { const desensitize = (messages: RawMessage[]): RawMessage[] => messages.map((m) => ({ ...m, content: (m.content ?? '').replace('13800000000', '***') })) diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.ts b/packages/tools/src/definitions/retrieve-chat-evidence.ts index fd676d06..31fc2112 100644 --- a/packages/tools/src/definitions/retrieve-chat-evidence.ts +++ b/packages/tools/src/definitions/retrieve-chat-evidence.ts @@ -94,6 +94,20 @@ function truncateSnippet(text: string): string { return t.length > EVIDENCE_SNIPPET_MAX_CHARS ? `${t.slice(0, EVIDENCE_SNIPPET_MAX_CHARS)}…` : t } +/** + * 语义 snippet 由预处理管道渲染为多行「时间 发送者: 内容」,时间对证据块展示是噪声 + * (分组已给出总时间)。这里去掉每行行首的时间前缀,仅保留「发送者: 内容」。 + * 覆盖 zh-CN(`2025/3/3 07:25:04`)与 en-US(`3/3/2025, 7:25:04 AM`)两种 toLocaleString 输出。 + */ +const LINE_TIME_PREFIX = /^\s*\d{1,4}[/-]\d{1,2}[/-]\d{1,4},?\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s?[APap][Mm])?\s+/ +function stripLineTimestamps(text: string): string { + return text + .split('\n') + .map((line) => line.replace(LINE_TIME_PREFIX, '')) + .join('\n') + .trim() +} + function parseDateMs(s?: unknown): number | undefined { if (typeof s !== 'string' || !s.trim()) return undefined const d = new Date(s.trim().replace(' ', 'T')) @@ -367,7 +381,7 @@ async function handler(params: Record, context: ToolExecutionCo timestamp: startMs ?? 0, rangeStartMs: startMs ?? 0, rangeEndMs: endMs ?? startMs ?? 0, - snippet: truncateSnippet(s.snippet), + snippet: truncateSnippet(stripLineTimestamps(s.snippet)), sourceKind: 'semantic', score: s.score, }) diff --git a/src/components/AIChat/chat/EvidenceBlock.vue b/src/components/AIChat/chat/EvidenceBlock.vue index 841775f1..41557a13 100644 --- a/src/components/AIChat/chat/EvidenceBlock.vue +++ b/src/components/AIChat/chat/EvidenceBlock.vue @@ -1,8 +1,7 @@