fix(ranking): correct dynamic keyword analysis

Forward the selected keywords through the shared HTTP route without persistent caching, keep large templates out of the request URL, and reject empty keyword sets before building a regular expression. Ignore superseded analysis responses so rapid keyword changes cannot restore stale rankings or loading state.
This commit is contained in:
digua committed 2026-08-02 23:41:47 +08:00
1 parent 0f527a3c45
commit ec10d0dded
7 files changed
+115 -13

No files matched your search

@@ -7,7 +7,7 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import Database from 'better-sqlite3'
import { accumulateCoOccurrencePairs, getMentionAnalysis } from '../social'
import { accumulateCoOccurrencePairs, getLaughAnalysis, getMentionAnalysis } from '../social'
import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../../interfaces'
class Statement implements PreparedStatement {
@@ -88,6 +88,25 @@ function createMentionDatabase(): Adapter {
}
describe('social analysis', () => {
it('returns an empty keyword analysis when no usable keywords are provided', () => {
const database = createMentionDatabase()
try {
const expected = {
rankByRate: [],
rankByCount: [],
typeDistribution: [],
totalLaughs: 0,
totalMessages: 0,
groupLaughRate: 0,
}
assert.deepEqual(getLaughAnalysis(database), expected)
assert.deepEqual(getLaughAnalysis(database, undefined, ['', ' ']), expected)
} finally {
database.close()
}
})
it('tracks the latest timestamp for each co-occurrence pair', () => {
const pairs = accumulateCoOccurrencePairs([
{ senderId: 1, ts: 1704103200 },
+3 -1
View File
@@ -246,7 +246,9 @@ export function getLaughAnalysis(db: DatabaseAdapter, filter?: TimeFilter, keywo
totalMessages: 0,
groupLaughRate: 0,
}
const laughKeywords = keywords && keywords.length > 0 ? keywords : []
const laughKeywords = keywords?.map((keyword) => keyword.trim()).filter(Boolean) ?? []
if (laughKeywords.length === 0) return emptyResult
const patterns = laughKeywords.map(keywordToPattern)
const laughRegex = new RegExp(`(${patterns.join('|')})`, 'gi')
@@ -1,11 +1,12 @@
/**
* analytics 路由缓存接入集成测试。
* analytics 路由集成测试。
*
* 运行:node --import tsx --test packages/http-routes/src/routes/web/analytics.test.ts
*
* 验证目标(接入契约,不重复 core 的算法矩阵):
* 1. 同一查询二次请求命中磁盘缓存、不再访问数据库(命中后关闭 db 仍能成功返回)。
* 2. DB 文件状态变化(mtime/size)使缓存失效,触发重新计算。
* 3. 动态关键词按请求实时计算,不复用其他关键词的分析结果。
*/
import { describe, it, beforeEach, afterEach } from 'node:test'
@@ -60,9 +61,10 @@ class Adapter implements DatabaseAdapter {
const SESSION_ID = 'chat-1'
const MEMBER_ACTIVITY_URL = `/_web/sessions/${SESSION_ID}/stats/member-activity`
const JOURNEY_URL = `/_web/sessions/${SESSION_ID}/analytics/journey`
const KEYWORD_URL = `/_web/sessions/${SESSION_ID}/analytics/laugh`
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
describe('analytics routes caching', () => {
describe('analytics routes', () => {
let root: string
let dbFile: string
let raw: Database.Database
@@ -174,6 +176,23 @@ describe('analytics routes caching', () => {
assert.equal(diff.statusCode, 500)
})
it('computes keyword analysis from each request without reusing another keyword result', async () => {
const first = await app.inject({ method: 'POST', url: KEYWORD_URL, payload: { keywords: ['a'] } })
assert.equal(first.statusCode, 200)
assert.deepEqual(first.json().typeDistribution, [{ type: 'a', count: 1, percentage: 100 }])
const second = await app.inject({ method: 'POST', url: KEYWORD_URL, payload: { keywords: ['b'] } })
assert.equal(second.statusCode, 200)
assert.deepEqual(second.json().typeDistribution, [{ type: 'b', count: 1, percentage: 100 }])
const combined = await app.inject({ method: 'POST', url: KEYWORD_URL, payload: { keywords: ['a', 'b'] } })
assert.equal(combined.statusCode, 200)
assert.deepEqual(combined.json().typeDistribution, [
{ type: 'a', count: 1, percentage: 50 },
{ type: 'b', count: 1, percentage: 50 },
])
})
it('serves journey analytics and keys its cache by time range', async () => {
const first = await app.inject({ method: 'GET', url: `${JOURNEY_URL}?startTs=100&endTs=300` })
assert.equal(first.statusCode, 200)
@@ -39,6 +39,7 @@ import { parseTimeFilter } from './time-filter'
import { withAnalyticsCache, type AnalyticsCacheContext } from './analytics-cache'
type FilteredQuery = { startTs?: string; endTs?: string; memberId?: string }
type KeywordAnalysisBody = { keywords?: string[] }
type AnalyticsRouteContext = AnalyticsCacheContext & {
pathProvider: Pick<PathProvider, 'getCacheDir' | 'getSystemDir'>
@@ -157,12 +158,14 @@ export function registerAnalyticsRoutes(server: FastifyInstance, ctx: AnalyticsR
}
)
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
server.post<{ Params: { id: string }; Querystring: FilteredQuery; Body: KeywordAnalysisBody }>(
'/_web/sessions/:id/analytics/laugh',
async (request) => {
const id = request.params.id
const filter = parseTimeFilter(request.query)
return cached('laugh', id, { ...filter }, () => getLaughAnalysis(adapter.ensureReadonly(id), filter))
const keywords = Array.isArray(request.body?.keywords) ? request.body.keywords : []
// Keywords are edited interactively, so this endpoint intentionally bypasses the persistent analytics cache.
return getLaughAnalysis(adapter.ensureReadonly(id), filter, keywords)
}
)
@@ -161,6 +161,7 @@ const currentTemplateName = computed(() => {
// 分析结果
const analysis = ref<LaughAnalysis | null>(null)
const isLoading = ref(false)
let analysisRequestId = 0
// 模板弹窗(创建/编辑)
const showTemplateModal = ref(false)
@@ -217,8 +218,10 @@ function selectTemplate(template: KeywordTemplate) {
// 清空所有关键词
function clearAllKeywords() {
analysisRequestId++
currentKeywords.value = []
analysis.value = null
isLoading.value = false
selectedTemplateId.value = ''
}
@@ -306,21 +309,26 @@ function deleteTemplate(templateId: string) {
// 加载分析数据
async function loadAnalysis() {
const requestId = ++analysisRequestId
if (!props.sessionId || currentKeywords.value.length === 0) {
analysis.value = null
isLoading.value = false
return
}
isLoading.value = true
try {
analysis.value = await useDataService().getLaughAnalysis(props.sessionId, props.timeFilter, [
const result = await useDataService().getLaughAnalysis(props.sessionId, props.timeFilter, [
...currentKeywords.value,
])
if (requestId !== analysisRequestId) return
analysis.value = result
} catch (error) {
if (requestId !== analysisRequestId) return
console.error('Failed to load keyword ranking:', error)
analysis.value = null
} finally {
isLoading.value = false
if (requestId === analysisRequestId) isLoading.value = false
}
}
+11 -5
View File
@@ -61,12 +61,16 @@ import type {
} from './types'
import { get, post, del, put, patch, analyticsGet, analyticsPost } from '../utils/http'
function buildFilterParams(filter?: TimeFilter): string {
if (!filter) return ''
const params = new URLSearchParams()
function appendTimeFilterParams(params: URLSearchParams, filter?: TimeFilter): void {
if (!filter) return
if (filter.startTs) params.set('startTs', String(filter.startTs))
if (filter.endTs) params.set('endTs', String(filter.endTs))
if (filter.memberId) params.set('memberId', String(filter.memberId))
}
function buildFilterParams(filter?: TimeFilter): string {
const params = new URLSearchParams()
appendTimeFilterParams(params, filter)
const qs = params.toString()
return qs ? `?${qs}` : ''
}
@@ -404,8 +408,10 @@ export class FetchDataAdapter implements DataAdapter {
return analyticsGet(`/sessions/${sessionId}/analytics/cluster${qs ? `?${qs}` : ''}`)
}
getLaughAnalysis(sessionId: string, filter?: TimeFilter, _keywords?: string[]): Promise<LaughAnalysis> {
return analyticsGet(`/sessions/${sessionId}/analytics/laugh${buildFilterParams(filter)}`)
getLaughAnalysis(sessionId: string, filter?: TimeFilter, keywords?: string[]): Promise<LaughAnalysis> {
return analyticsPost(`/sessions/${sessionId}/analytics/laugh${buildFilterParams(filter)}`, {
keywords: keywords ?? [],
})
}
getRelationshipStats(
+45
View File
@@ -8,6 +8,7 @@ import {
analyticsGet,
abortAnalyticsRequests,
} from './http'
import { FetchDataAdapter } from '../data/fetch'
describe('http client', () => {
beforeEach(() => {
@@ -184,4 +185,48 @@ describe('http client', () => {
}
})
})
describe('FetchDataAdapter keyword analysis', () => {
it('sends time filters in the URL and keywords in the request body', async () => {
const originalFetch = globalThis.fetch
let requestedUrl = ''
let requestedInit: RequestInit | undefined
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
requestedUrl = String(input)
requestedInit = init
return Promise.resolve(
new Response(
JSON.stringify({
rankByRate: [],
rankByCount: [],
typeDistribution: [],
totalLaughs: 0,
totalMessages: 0,
groupLaughRate: 0,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
}) as typeof fetch
try {
const longKeyword = '长'.repeat(20_000)
await new FetchDataAdapter().getLaughAnalysis('chat-1', { startTs: 100, endTs: 200, memberId: 3 }, [
longKeyword,
'笑死',
])
const url = new URL(requestedUrl, 'http://localhost')
assert.equal(url.pathname, '/_web/sessions/chat-1/analytics/laugh')
assert.equal(url.searchParams.get('startTs'), '100')
assert.equal(url.searchParams.get('endTs'), '200')
assert.equal(url.searchParams.get('memberId'), '3')
assert.equal(url.searchParams.has('keywords'), false)
assert.equal(requestedInit?.method, 'POST')
assert.deepEqual(JSON.parse(String(requestedInit?.body)), { keywords: [longKeyword, '笑死'] })
} finally {
globalThis.fetch = originalFetch
}
})
})
})