fix(insight): address annual summary review findings

This commit is contained in:
digua committed 2026-07-12 15:29:09 +08:00
1 parent 1713e0d577
commit b15f38c4a5
9 files changed
+94 -14

No files matched your search

@@ -204,6 +204,25 @@ test('counts an active private counterpart on each day and scopes name-match ide
db.close()
})
test('ignores legacy system messages when counting private direct-contact days', () => {
const db = createSession({ id: 'private-system', type: ChatType.PRIVATE })
db.raw.prepare('INSERT INTO member VALUES (?, ?, ?, ?, ?, ?)').run(3, 'system', '系统消息', null, '[]', null)
addMessage(db, {
id: 1,
senderId: 3,
ts: localTs(2024, 2, 1),
content: 'notification',
platformMessageId: 'system-1',
})
const facts = getAnnualSummarySessionFacts(db, 'private-system', range2024)
assert.equal(facts.kind, 'analyzed')
if (facts.kind !== 'analyzed') return
assert.deepEqual(facts.directContactKeysByDay, {})
db.close()
})
test('reports missing and unresolved owners instead of guessing', () => {
const missing = createSession({ id: 'missing', type: ChatType.PRIVATE, ownerId: null })
const unresolved = createSession({ id: 'unresolved', type: ChatType.GROUP, ownerId: 'unknown' })
+5 -3
View File
@@ -133,7 +133,7 @@ export function getAnnualSummarySessionFacts(
ownerMessagesByDay,
directContactKeysByDay:
meta.type === ChatType.PRIVATE && privateContact
? getPrivateDirectContactsByDay(db, sessionId, meta.platform, privateContact, range)
? getPrivateDirectContactsByDay(db, sessionId, meta.platform, owner.id, privateContact, range)
: getGroupDirectContactsByDay(db, sessionId, meta.platform, owner.id, contacts, range),
messageTypeCounts,
textLengthCounts,
@@ -237,6 +237,7 @@ function getPrivateDirectContactsByDay(
db: DatabaseAdapter,
sessionId: string,
platform: ChatPlatform,
ownerId: number,
contact: ContactMemberRef,
range: AnnualSummaryRange
): Record<string, string[]> {
@@ -244,10 +245,11 @@ function getPrivateDirectContactsByDay(
.prepare(
`SELECT DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date
FROM message msg
WHERE msg.ts >= ? AND msg.ts <= ? AND msg.type NOT IN (${SYSTEM_MESSAGE_TYPES.join(', ')})
WHERE msg.sender_id IN (?, ?) AND msg.ts >= ? AND msg.ts <= ?
AND msg.type NOT IN (${SYSTEM_MESSAGE_TYPES.join(', ')})
ORDER BY date`
)
.all(range.startTs, range.endTs) as Array<{ date: string }>
.all(ownerId, contact.id, range.startTs, range.endTs) as Array<{ date: string }>
const key = buildContactKey(
platform,
contact.platformId,
@@ -124,6 +124,29 @@ test('preserves failed state until explicit recompute', async (t) => {
assert.equal(env.service.getAnnualSummary({ mode: 'year', year: 2026 }).cache.status, 'fresh')
})
test('reports snapshot persistence failures without restarting the task', async (t) => {
let calls = 0
const env = createEnv(async ({ signature, range }) => {
calls++
return makeSnapshot(range, signature)
})
t.after(async () => {
await env.service.close()
fs.rmSync(env.dir, { recursive: true, force: true })
})
const insightDir = path.join(env.dir, 'insight')
fs.mkdirSync(insightDir)
fs.writeFileSync(path.join(insightDir, 'annual-summary'), 'not a directory')
env.service.getAnnualSummary({ mode: 'year', year: 2026 })
await flushTasks()
const failed = env.service.getAnnualSummary({ mode: 'year', year: 2026 })
assert.equal(failed.task.status, 'failed')
assert.ok(failed.task.lastError)
assert.equal(calls, 1)
})
test('discards a worker result when the DB signature changes during compute', async (t) => {
let finish: (() => void) | undefined
const env = createEnv(
@@ -150,16 +150,17 @@ class DefaultGlobalInsightService implements GlobalInsightService {
private handleTaskSuccess(id: string, inputSignature: string, snapshot: AnnualSummarySnapshot): void {
if (this.inFlight?.id !== id) return
this.inFlight = null
const latestSignature = buildAnnualSummarySignature(this.deps.adapter, snapshot.range)
const finishedAt = this.now()
if (inputSignature !== latestSignature || snapshot.signature !== latestSignature) {
this.inFlight = null
this.task = { ...this.task, status: 'superseded', finishedAt, currentSessionId: undefined }
appLogger.info('global-insight', 'annual summary worker result discarded because signature changed')
return
}
try {
writeAnnualSummarySnapshot(this.snapshotDir, snapshot)
this.inFlight = null
this.snapshots.set(toAnnualSummaryRangeKey(snapshot.range), snapshot)
this.task = {
...this.task,
@@ -31,9 +31,7 @@ const { sessions } = storeToRefs(sessionStore)
// 搜索关键词
const searchQuery = ref('')
const ownerIssueCount = computed(
() => sessions.value.filter((session) => session.ownerStatus !== 'resolved').length
)
const ownerIssueCount = computed(() => sessions.value.filter((session) => session.ownerStatus !== 'resolved').length)
// 过滤后的会话列表
const filteredSessions = computed(() => {
@@ -798,12 +796,18 @@ onMounted(() => {
<button
type="button"
class="flex w-28 min-w-0 items-center gap-1.5 text-left text-xs transition-colors hover:text-primary-600 dark:hover:text-primary-400"
:class="session.ownerStatus === 'resolved' ? 'text-gray-600 dark:text-gray-300' : 'text-amber-600 dark:text-amber-400'"
:class="
session.ownerStatus === 'resolved'
? 'text-gray-600 dark:text-gray-300'
: 'text-amber-600 dark:text-amber-400'
"
:title="session.ownerId || t('tools.batchManage.ownerStatus.missing')"
@click="openOwnerModal(session, $event)"
>
<UIcon
:name="session.ownerStatus === 'resolved' ? 'i-heroicons-user-circle' : 'i-heroicons-exclamation-circle'"
:name="
session.ownerStatus === 'resolved' ? 'i-heroicons-user-circle' : 'i-heroicons-exclamation-circle'
"
class="h-3.5 w-3.5 shrink-0"
/>
<span class="truncate">
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { nextTick, ref } from 'vue'
import { watchAnnualSummarySettingsClose } from './annual-summary-time-range'
test('refreshes annual summary after the settings modal closes', async () => {
const showSettings = ref(false)
let refreshCalls = 0
const stop = watchAnnualSummarySettingsClose(showSettings, () => refreshCalls++)
showSettings.value = true
await nextTick()
assert.equal(refreshCalls, 0)
showSettings.value = false
await nextTick()
assert.equal(refreshCalls, 1)
stop()
})
+10 -1
View File
@@ -1,4 +1,4 @@
import { computed, inject, provide, ref, type ComputedRef, type InjectionKey, type Ref } from 'vue'
import { computed, inject, provide, ref, watch, type ComputedRef, type InjectionKey, type Ref } from 'vue'
import type { TimeRangeValue, TimeSelectRangeSource, TimeSelectState } from '@/components/common/TimeSelect.vue'
interface AnnualSummaryTimeRangeContext {
@@ -52,3 +52,12 @@ export function useAnnualSummaryTimeRange(): AnnualSummaryTimeRangeContext {
if (!context) throw new Error('Annual summary time range context is unavailable')
return context
}
export function watchAnnualSummarySettingsClose(
showSettings: Ref<boolean>,
refresh: () => void
): ReturnType<typeof watch> {
return watch(showSettings, (visible, wasVisible) => {
if (wasVisible && !visible) refresh()
})
}
@@ -261,7 +261,7 @@ function percentage(value: number, total: number): number {
<h3 class="text-[11px] font-bold uppercase text-pink-600 dark:text-pink-400">
{{ t('insight.overviewCard.keyMetrics') }}
</h3>
<div class="mt-4 grid grid-cols-3 divide-x divide-gray-150 dark:divide-white/10">
<div class="mt-4 grid grid-cols-3 divide-x divide-gray-200 dark:divide-white/10">
<div v-for="item in highlights" :key="item.key" class="min-w-0 px-3 first:pl-0 last:pr-0">
<div class="truncate font-mono text-sm font-black tabular-nums text-gray-900 dark:text-white">
{{ item.value }}
+6 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useLayoutStore } from '@/stores/layout'
import type { AnnualSummaryFetchOptions } from '@/services/data/types'
@@ -7,11 +8,12 @@ import type { AnnualSummaryResponse } from '@openchatlab/shared-types'
import LoadingState from '@/components/UI/LoadingState.vue'
import { useDataService } from '@/services'
import { reportError } from '@/services/log-report'
import { useAnnualSummaryTimeRange } from '../annual-summary-time-range'
import { useAnnualSummaryTimeRange, watchAnnualSummarySettingsClose } from '../annual-summary-time-range'
import AnnualInsightBoard from './components/AnnualInsightBoard.vue'
const { t } = useI18n()
const layoutStore = useLayoutStore()
const { showSettings } = storeToRefs(layoutStore)
const currentYear = new Date().getFullYear()
const timeRange = useAnnualSummaryTimeRange()
const response = ref<AnnualSummaryResponse | null>(null)
@@ -59,6 +61,7 @@ watch(
},
{ immediate: true }
)
watchAnnualSummarySettingsClose(showSettings, () => void loadSummary(false))
onBeforeUnmount(clearPoll)
@@ -131,7 +134,7 @@ function openSessions(): void {
<div
v-if="errorMessage || response?.task.status === 'failed'"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-150 bg-red-50/50 px-4 py-2.5 text-xs text-red-800 backdrop-blur-sm dark:border-red-950/40 dark:bg-red-950/20 dark:text-red-300"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-200 bg-red-50/50 px-4 py-2.5 text-xs text-red-800 backdrop-blur-sm dark:border-red-950/40 dark:bg-red-950/20 dark:text-red-300"
>
<span>{{ t('insight.status.failed') }}</span>
<UButton size="xs" color="error" variant="soft" icon="i-heroicons-arrow-path" @click="loadSummary(true)">
@@ -155,7 +158,7 @@ function openSessions(): void {
<template v-else-if="response?.metrics && response.textLength">
<div
v-if="isZeroData && !hasNoAnalyzableOwner"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-150 bg-white p-4 text-xs text-gray-600 dark:border-zinc-850 dark:bg-zinc-900/50 dark:text-zinc-300"
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-600 dark:border-zinc-800 dark:bg-zinc-900/50 dark:text-zinc-300"
>
<span v-if="latestYearSuggestion">
{{ t('insight.status.noDataWithLatest', latestYearSuggestion) }}