fix(ai): correct evidence snippet time unit and strip per-message times

Evidence messages carry millisecond timestamps but the preprocessing pipeline
renders times assuming seconds (multiplies by 1000), producing absurd years
(e.g. 56150) inside semantic snippets. Convert ms back to seconds before
rendering. Also strip the per-line time prefix from semantic snippets so the
evidence block shows a single group time instead of a time on every message,
and drop the redundant time span in the group header. Adds regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-06-21 16:18:39 +08:00
1 parent a23bb1b937
commit d58168a029
5 files changed
+59 -26

No files matched your search

@@ -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()
})
@@ -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)) {
@@ -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', '***') }))
@@ -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<string, unknown>, 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,
})
+2 -23
View File
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
import type { ChatEvidencePayload, ChatEvidenceGroup, EvidenceStatus } from '@openchatlab/core'
import type { ChatEvidencePayload, EvidenceStatus } from '@openchatlab/core'
import { useLayoutStore } from '@/stores/layout'
const props = defineProps<{
@@ -43,20 +42,6 @@ const statusDotClass: Record<EvidenceStatus, string> = {
excluded: 'bg-gray-400',
}
function formatGroupTimeRange(group: ChatEvidenceGroup): string | null {
let startTs = group.timeRange?.startTs
let endTs = group.timeRange?.endTs
if (startTs == null || endTs == null) {
const timestamps = group.sources.map((source) => source.timestamp).filter((ts) => Number.isFinite(ts))
if (timestamps.length === 0) return null
startTs = Math.min(...timestamps)
endTs = Math.max(...timestamps)
}
const start = dayjs(startTs).format('YYYY-MM-DD')
const end = dayjs(endTs).format('YYYY-MM-DD')
return start === end ? start : `${start} ~ ${end}`
}
function viewSource(messageId: number): void {
layoutStore.openChatRecordDrawer({ scrollToMessageId: messageId })
}
@@ -143,13 +128,7 @@ function viewSource(messageId: number): void {
<span class="rounded px-1.5 py-0.5 text-[11px] font-medium" :class="statusBadgeClass[group.status]">
{{ t(`ai.chat.evidence.group.${group.status}`) }}
</span>
<span class="truncate text-xs font-medium text-gray-700 dark:text-gray-200">{{ group.title }}</span>
<span
v-if="formatGroupTimeRange(group)"
class="ml-auto shrink-0 text-[11px] text-gray-400 dark:text-gray-500"
>
{{ formatGroupTimeRange(group) }}
</span>
<span class="ml-auto shrink-0 text-[11px] text-gray-400 dark:text-gray-500">{{ group.title }}</span>
</div>
<div v-if="group.reason" class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">{{ group.reason }}</div>