diff --git a/packages/core/src/query/advanced/__tests__/social.test.ts b/packages/core/src/query/advanced/__tests__/social.test.ts index c80636f7..cfe65065 100644 --- a/packages/core/src/query/advanced/__tests__/social.test.ts +++ b/packages/core/src/query/advanced/__tests__/social.test.ts @@ -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 }, diff --git a/packages/core/src/query/advanced/social.ts b/packages/core/src/query/advanced/social.ts index b718228a..65f51947 100644 --- a/packages/core/src/query/advanced/social.ts +++ b/packages/core/src/query/advanced/social.ts @@ -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') diff --git a/packages/http-routes/src/routes/web/analytics.test.ts b/packages/http-routes/src/routes/web/analytics.test.ts index 302c3b81..3503eaff 100644 --- a/packages/http-routes/src/routes/web/analytics.test.ts +++ b/packages/http-routes/src/routes/web/analytics.test.ts @@ -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) diff --git a/packages/http-routes/src/routes/web/analytics.ts b/packages/http-routes/src/routes/web/analytics.ts index 62d50af8..d78c2452 100644 --- a/packages/http-routes/src/routes/web/analytics.ts +++ b/packages/http-routes/src/routes/web/analytics.ts @@ -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 @@ -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) } ) diff --git a/src/components/analysis/ranking/KeywordRankingTab.vue b/src/components/analysis/ranking/KeywordRankingTab.vue index 1258e562..3cc10593 100644 --- a/src/components/analysis/ranking/KeywordRankingTab.vue +++ b/src/components/analysis/ranking/KeywordRankingTab.vue @@ -161,6 +161,7 @@ const currentTemplateName = computed(() => { // 分析结果 const analysis = ref(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 } } diff --git a/src/services/data/fetch.ts b/src/services/data/fetch.ts index 8e3bb0f9..bb53fd36 100644 --- a/src/services/data/fetch.ts +++ b/src/services/data/fetch.ts @@ -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 { - return analyticsGet(`/sessions/${sessionId}/analytics/laugh${buildFilterParams(filter)}`) + getLaughAnalysis(sessionId: string, filter?: TimeFilter, keywords?: string[]): Promise { + return analyticsPost(`/sessions/${sessionId}/analytics/laugh${buildFilterParams(filter)}`, { + keywords: keywords ?? [], + }) } getRelationshipStats( diff --git a/src/services/utils/http.test.ts b/src/services/utils/http.test.ts index a129f0a6..c29b5833 100644 --- a/src/services/utils/http.test.ts +++ b/src/services/utils/http.test.ts @@ -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 + } + }) + }) })