Add configurable news research backfill and coverage reporting
Allow selecting a historical start time and checking unfinished news before enabling analysis. Report enrolled work outside the selected range and use the active server boundary while retaining paused drafts. Cover date validation, queue carryover, task completion and cross-tab responses.
This commit is contained in:
1 parent
80a1aa389c
commit
c90691d9a0
9 files changed
+451
-10
No files matched your search
@@ -3,7 +3,8 @@ import { request } from "@/http/axios"
|
||||
|
||||
export const getSettingsApi = () => request<Research.Settings>({ url: "/risk/settings", method: "get" })
|
||||
export const saveSettingsApi = (data: Research.Config) => request<null>({ url: "/risk/settings", method: "put", data })
|
||||
export const enableApi = () => request<null>({ url: "/risk/enable", method: "post" })
|
||||
export const enableApi = (start_at: string) => request<Research.Coverage>({ url: "/risk/enable", method: "post", data: { start_at } })
|
||||
export const getCoverageApi = (start_at: string) => request<Research.Coverage>({ url: "/risk/coverage", method: "get", params: { start_at } })
|
||||
export const pauseApi = () => request<null>({ url: "/risk/pause", method: "post" })
|
||||
export const createSkillsApi = () => request<{ index: number, research: number }>({ url: "/risk/skill-templates", method: "post" })
|
||||
export const listEventsApi = (q: string, page: number) => request<{ items: Research.Event[], total: number }>({ url: "/risk/events", method: "get", params: { q, page } })
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface ModelConfig { provider_id: number | null, model: string, reason
|
||||
export interface Config { index: ModelConfig, research: ModelConfig, concurrency: number }
|
||||
export interface BatchProgress { id: number, phase: string, cutoff_at: string, packages_total: number, packages_done: number, packages_failed: number, packages_deferred: number, events_total: number, events_done: number, events_failed: number, events_deferred: number }
|
||||
export interface Settings { batch?: BatchProgress | null, config: Partial<Config>, enabled: boolean, start_at: string }
|
||||
export interface Coverage { start_at: string, checked_at: string, total: number, unprocessed: number, missing: number, stale: number, pending: number, processing: number, failed: number, completed: number, outside: { unprocessed: number, stale: number, pending: number, processing: number, failed: number } }
|
||||
export interface Package { id: number, news_id: number, title: string, url: string, state: string, error: string | null, keywords: Keyword[], events: { id: number, title: string, reason: string, quote: string }[] }
|
||||
export interface Point { time_precision: "minute" | "day" | "month" | "unknown", planned_precision: "minute" | "day" | "month" | "unknown", package_ids: number[], time_text: string, description: string, occurred_at: string | null, planned_at: string | null, reported_at: string | null, period_text: string, evidence_refs: { article_id: number, quote: string }[] }
|
||||
export interface Result { title: string, summary: string, facts: string, reasoning: string, uncertainty: string, opinions: string, counter_evidence: string, watchpoints: string, asset_impacts: string, importance_stars: number, risk_stars: number, confidence: string, verification: string, timeline_points: Point[] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { BatchProgress, Config, Event, EventDetail, Package, PackageDetail, VersionDetail, Vocabulary } from "@@/apis/news-research/type"
|
||||
import type { BatchProgress, Config, Coverage, Event, EventDetail, Package, PackageDetail, VersionDetail, Vocabulary } from "@@/apis/news-research/type"
|
||||
import type { Provider } from "@@/apis/settings/type"
|
||||
import type { SkillNode } from "@@/apis/skills/type"
|
||||
import * as api from "@@/apis/news-research"
|
||||
@@ -88,6 +88,18 @@ const opened = ref(false)
|
||||
const packageOpened = ref(false)
|
||||
const settingsOpened = ref(false)
|
||||
const enabled = ref(false)
|
||||
const startAt = ref<Date | null>(null)
|
||||
const savedStartAt = ref<Date | null>(null)
|
||||
const selectedStartAt = computed({
|
||||
get: () => enabled.value ? savedStartAt.value : startAt.value,
|
||||
set: (value: Date | null) => {
|
||||
startAt.value = value
|
||||
}
|
||||
})
|
||||
const coverage = ref<Coverage | null>(null)
|
||||
const coverageLoading = ref(false)
|
||||
const coverageError = ref(false)
|
||||
let coverageSequence = 0
|
||||
const batch = ref<BatchProgress | null>(null)
|
||||
const batchPhases: Record<string, string> = { assigning: "索引与归属中", researching: "集中研究中", done: "已完成", partial: "部分失败或延期" }
|
||||
const loading = ref(false)
|
||||
@@ -122,17 +134,46 @@ async function load() {
|
||||
const [settings, rows] = await Promise.all([api.getSettingsApi(), tab.value === "events" ? api.listEventsApi(q.value, page.value) : api.listPackagesApi(q.value, page.value)])
|
||||
if (!valid() || seq !== sequence) return
|
||||
enabled.value = settings.enabled
|
||||
if (!startAt.value || startAt.value.getTime() === savedStartAt.value?.getTime()) startAt.value = new Date(settings.start_at)
|
||||
savedStartAt.value = new Date(settings.start_at)
|
||||
batch.value = settings.batch ?? null
|
||||
if (tab.value === "events") events.value = rows.items as Event[]
|
||||
else packages.value = rows.items as Package[]
|
||||
total.value = rows.total
|
||||
error.value = false
|
||||
await checkCoverage()
|
||||
} catch {
|
||||
if (valid() && seq === sequence) error.value = true
|
||||
} finally {
|
||||
if (valid() && seq === sequence) loading.value = false
|
||||
}
|
||||
}
|
||||
async function checkCoverage() {
|
||||
if (!valid() || !selectedStartAt.value || !Number.isFinite(selectedStartAt.value.getTime())) return
|
||||
const start = selectedStartAt.value.toISOString()
|
||||
const seq = ++coverageSequence
|
||||
coverageLoading.value = true
|
||||
coverageError.value = false
|
||||
coverage.value = null
|
||||
try {
|
||||
const result = await api.getCoverageApi(start)
|
||||
if (valid() && seq === coverageSequence && selectedStartAt.value?.toISOString() === start) coverage.value = result
|
||||
} catch {
|
||||
if (valid() && seq === coverageSequence) coverageError.value = true
|
||||
} finally {
|
||||
if (valid() && seq === coverageSequence) coverageLoading.value = false
|
||||
}
|
||||
}
|
||||
async function enable() {
|
||||
if (!valid()) return
|
||||
if (!startAt.value || !Number.isFinite(startAt.value.getTime()) || startAt.value.getTime() > Date.now()) {
|
||||
ElMessage.error("请选择不晚于当前时间的回补起始时间")
|
||||
return
|
||||
}
|
||||
const start = startAt.value.toISOString()
|
||||
const result = await api.enableApi(start)
|
||||
if (valid() && selectedStartAt.value?.toISOString() === start) coverage.value = result
|
||||
}
|
||||
async function openEvent(id: number) {
|
||||
eventId = id
|
||||
detailSequence++
|
||||
@@ -264,6 +305,12 @@ watch(opened, (value) => {
|
||||
watch(packageOpened, (value) => {
|
||||
if (!value) packageSequence++
|
||||
})
|
||||
watch(selectedStartAt, () => {
|
||||
coverageSequence++
|
||||
coverage.value = null
|
||||
coverageLoading.value = false
|
||||
coverageError.value = false
|
||||
}, { flush: "sync" })
|
||||
watch(() => user.sessionVersion, () => {
|
||||
sequence++
|
||||
versionSequence++
|
||||
@@ -279,6 +326,8 @@ watch(() => user.sessionVersion, () => {
|
||||
opened.value = false
|
||||
packageOpened.value = false
|
||||
settingsOpened.value = false
|
||||
startAt.value = null
|
||||
savedStartAt.value = null
|
||||
})
|
||||
watch([tab, q], () => {
|
||||
page.value = 1
|
||||
@@ -298,6 +347,7 @@ onBeforeUnmount(() => {
|
||||
sequence++
|
||||
detailSequence++
|
||||
packageSequence++
|
||||
coverageSequence++
|
||||
clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
@@ -314,7 +364,7 @@ onBeforeUnmount(() => {
|
||||
<el-button @click="settings">
|
||||
模型与 Skills
|
||||
</el-button>
|
||||
<el-button :loading="busy" @click="action(enabled ? api.pauseApi : api.enableApi)">
|
||||
<el-button :loading="busy" :disabled="!enabled && (loading || error || !startAt)" @click="action(enabled ? api.pauseApi : enable)">
|
||||
{{ enabled ? '暂停新任务' : '启用两阶段处理' }}
|
||||
</el-button>
|
||||
<el-button @click="openVocabulary">
|
||||
@@ -327,6 +377,40 @@ onBeforeUnmount(() => {
|
||||
<p class="mb-4 text-sm text-slate-500">
|
||||
新闻包保存原文和关键词;事件汇集相关报道,持续更新判断与时间线。
|
||||
</p>
|
||||
<div class="mb-4 rounded border border-slate-200 p-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label for="research-start-at">回补起始时间</label>
|
||||
<el-date-picker
|
||||
id="research-start-at"
|
||||
v-model="selectedStartAt"
|
||||
type="datetime"
|
||||
:clearable="false"
|
||||
:disabled="enabled || busy"
|
||||
:disabled-date="(date: Date) => date.getTime() > Date.now()"
|
||||
placeholder="选择回补起始时间"
|
||||
/>
|
||||
<span class="text-sm text-slate-500">按浏览器本地时区</span>
|
||||
<el-button :loading="coverageLoading" :disabled="!startAt || busy" @click="checkCoverage">
|
||||
核查遗漏
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
启用时保存起点,自动核查并补齐该时间以来已采集的新闻;先完成关键词与归属,再集中研究事件。调整起点不会取消已有队列。
|
||||
</p>
|
||||
<p v-if="coverage" class="mt-2 text-sm">
|
||||
范围内 {{ coverage.total }} 条,全部阶段已完成 {{ coverage.completed }} 条,未完成 {{ coverage.unprocessed }} 条:
|
||||
未入队 {{ coverage.missing }},原文更新需重判 {{ coverage.stale }},待处理 {{ coverage.pending }},处理中 {{ coverage.processing }},失败 {{ coverage.failed }}。
|
||||
失败项需手动重试,当前批次以外的遗漏排入下一批。
|
||||
</p>
|
||||
<p v-if="coverage && coverage.outside.unprocessed" class="mt-2 text-sm text-amber-700">
|
||||
范围外另有已入队但未完成的新闻 {{ coverage.outside.unprocessed }} 条:
|
||||
原文更新需重判 {{ coverage.outside.stale }},待处理 {{ coverage.outside.pending }},处理中 {{ coverage.outside.processing }},失败 {{ coverage.outside.failed }}。
|
||||
已有任务继续按批次处理,失败项需手动重试。
|
||||
</p>
|
||||
<p v-if="coverageError" class="mt-2 text-sm text-red-600">
|
||||
核查失败,请重试。
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="batch" class="mb-4 text-sm text-slate-500">
|
||||
批次 #{{ batch.id }} · {{ batchPhases[batch.phase] ?? batch.phase }} ·
|
||||
新闻归属 {{ batch.packages_done }}/{{ batch.packages_total }},失败 {{ batch.packages_failed }},延期 {{ batch.packages_deferred }} ·
|
||||
|
||||
@@ -485,7 +485,7 @@ test("新闻列表只更新可见条目的匹配版本评级,保留滚动与
|
||||
|
||||
function researchPage(api: Record<string, unknown> = {}, user = vue.reactive({ sessionVersion: 1 }), extra: Record<string, unknown> = {}, dependencies: Record<string, unknown> = {}) {
|
||||
const load = moduleLoader({
|
||||
"@@/apis/news-research": { getSettingsApi: async () => ({ config: {}, enabled: false }), listEventsApi: async () => ({ items: [], total: 0 }), listPackagesApi: async () => ({ items: [], total: 0 }), ...api },
|
||||
"@@/apis/news-research": { getSettingsApi: async () => ({ config: {}, enabled: false, start_at: "2026-10-31T16:00:00Z" }), getCoverageApi: async (start_at: string) => ({ start_at, total: 0 }), listEventsApi: async () => ({ items: [], total: 0 }), listPackagesApi: async () => ({ items: [], total: 0 }), ...api },
|
||||
"@@/apis/skills": { listSkillTreeApi: async () => [] },
|
||||
"@@/apis/settings": { listProvidersApi: async () => [], listProviderModelsApi: async () => [] },
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
@@ -494,6 +494,136 @@ function researchPage(api: Record<string, unknown> = {}, user = vue.reactive({ s
|
||||
return load("@/pages/home/index.vue").default.setup({}, { expose() {} })
|
||||
}
|
||||
|
||||
test("回补时间按时区提交,刷新保留未提交的起点,核查不启用 AI", async () => {
|
||||
const checked: string[] = []
|
||||
const enabled: string[] = []
|
||||
const page = researchPage({
|
||||
getCoverageApi: async (start_at: string) => {
|
||||
checked.push(start_at)
|
||||
return { start_at, total: 10, missing: 4 }
|
||||
},
|
||||
enableApi: async (start_at: string) => {
|
||||
enabled.push(start_at)
|
||||
return { start_at, total: 10 }
|
||||
}
|
||||
})
|
||||
await page.load()
|
||||
assert.equal(page.startAt.value.toISOString(), "2026-10-31T16:00:00.000Z")
|
||||
page.startAt.value = new Date("2026-10-01T00:00:00+08:00")
|
||||
await page.load()
|
||||
assert.equal(page.startAt.value.toISOString(), "2026-09-30T16:00:00.000Z")
|
||||
assert.equal(checked.at(-1), "2026-09-30T16:00:00.000Z")
|
||||
assert.equal(enabled.length, 0)
|
||||
assert.equal(page.coverage.value.missing, 4)
|
||||
await page.enable()
|
||||
assert.deepEqual(enabled, ["2026-09-30T16:00:00.000Z"])
|
||||
})
|
||||
|
||||
test("其他标签页启用后按实际起点核查,暂停后恢复草稿并丢弃旧范围响应", async () => {
|
||||
let server = { config: {}, enabled: false, start_at: "2026-10-31T16:00:00Z" }
|
||||
const pending = Promise.withResolvers()
|
||||
const checked: string[] = []
|
||||
let delayDraft = true
|
||||
const draft = "2026-10-01T00:00:00.000Z"
|
||||
const actual = "2026-11-05T00:00:00.000Z"
|
||||
const page = researchPage({
|
||||
getSettingsApi: async () => ({ ...server }),
|
||||
getCoverageApi: async (start_at: string) => {
|
||||
checked.push(start_at)
|
||||
if (start_at === draft && delayDraft) {
|
||||
delayDraft = false
|
||||
return pending.promise
|
||||
}
|
||||
return { start_at, total: 5 }
|
||||
}
|
||||
})
|
||||
await page.load()
|
||||
page.selectedStartAt.value = new Date(draft)
|
||||
const old = page.checkCoverage()
|
||||
server = { ...server, enabled: true, start_at: actual }
|
||||
await page.load()
|
||||
assert.equal(page.enabled.value, true)
|
||||
assert.equal(page.selectedStartAt.value.toISOString(), actual)
|
||||
assert.equal(page.coverage.value.start_at, actual)
|
||||
assert.equal(page.startAt.value.toISOString(), draft)
|
||||
pending.resolve({ start_at: draft, total: 999 })
|
||||
await old
|
||||
assert.equal(page.coverage.value.start_at, actual)
|
||||
assert.equal(page.coverage.value.total, 5)
|
||||
server.enabled = false
|
||||
await page.load()
|
||||
assert.equal(page.selectedStartAt.value.toISOString(), draft)
|
||||
assert.equal(page.coverage.value.start_at, draft)
|
||||
assert.equal(page.savedStartAt.value.toISOString(), actual)
|
||||
assert.deepEqual(checked, ["2026-10-31T16:00:00.000Z", draft, actual, draft])
|
||||
})
|
||||
|
||||
test("回补核查丢弃旧时间、旧会话及卸载后的结果,失败可重试", async () => {
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
let unmount = () => {}
|
||||
const page = researchPage({ getCoverageApi: () => {
|
||||
const p = Promise.withResolvers()
|
||||
pending.push(p)
|
||||
return p.promise
|
||||
} }, user, { onBeforeUnmount: (fn: () => void) => unmount = fn })
|
||||
page.startAt.value = new Date("2026-10-01T00:00:00Z")
|
||||
const old = page.checkCoverage()
|
||||
page.startAt.value = new Date("2026-10-02T00:00:00Z")
|
||||
const current = page.checkCoverage()
|
||||
pending[1].resolve({ total: 2 })
|
||||
await current
|
||||
pending[0].resolve({ total: 1 })
|
||||
await old
|
||||
assert.equal(page.coverage.value.total, 2)
|
||||
const failed = page.checkCoverage()
|
||||
pending[2].reject(new Error("核查失败"))
|
||||
await failed
|
||||
assert.equal(page.coverageError.value, true)
|
||||
const retry = page.checkCoverage()
|
||||
pending[3].resolve({ total: 3 })
|
||||
await retry
|
||||
assert.equal(page.coverage.value.total, 3)
|
||||
assert.equal(page.coverageError.value, false)
|
||||
const inactive = page.checkCoverage()
|
||||
unmount()
|
||||
pending[4].resolve({ total: 4 })
|
||||
await inactive
|
||||
assert.equal(page.coverage.value, null)
|
||||
await page.checkCoverage()
|
||||
assert.equal(pending.length, 5)
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
assert.equal(page.coverage.value, null)
|
||||
assert.equal(page.startAt.value, null)
|
||||
})
|
||||
|
||||
test("切换会话后,旧回补核查不能恢复统计结果", async () => {
|
||||
const pending = Promise.withResolvers()
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const page = researchPage({ getCoverageApi: () => pending.promise }, user)
|
||||
page.startAt.value = new Date("2026-10-01T00:00:00Z")
|
||||
const old = page.checkCoverage()
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
pending.resolve({ total: 999 })
|
||||
await old
|
||||
assert.equal(page.coverage.value, null)
|
||||
assert.equal(page.startAt.value, null)
|
||||
})
|
||||
|
||||
test("回补时间无效或晚于当前时间时禁止启用", async () => {
|
||||
let calls = 0
|
||||
const errors: string[] = []
|
||||
const page = researchPage({ enableApi: async () => calls++ }, undefined, { ElMessage: { error: (message: string) => errors.push(message) } })
|
||||
for (const value of [null, new Date("invalid"), new Date(Date.now() + 86400000)]) {
|
||||
page.startAt.value = value
|
||||
await page.enable()
|
||||
}
|
||||
assert.equal(calls, 0)
|
||||
assert.equal(errors.length, 3)
|
||||
})
|
||||
|
||||
test("两阶段首页保留新查询结果,旧会话与卸载后的响应不写回", async () => {
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
|
||||
Reference in new issue
Block a user