feat: add historical news AI backfill and visible task queue
Development CI / check-and-build (push) Successful in 2m44s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Failing after 7s

Freeze date-range previews and model settings, track per-news progress and downstream targets, and share completion-driven scheduling with realtime analysis.

Prevent overlapping retries for the same news, retain batch queue associations, and refresh selected details with session-safe pagination.
This commit is contained in:
eezd committed 2026-11-01 14:47:54 +08:00
1 parent cbd2618a20
commit b45981ec30
25 files changed
+1339 -39

No files matched your search

+4 -1
View File
@@ -15,9 +15,12 @@ export const createNewsSkillTemplatesApi = (refreshExisting = false) => request<
export const listNewsEventsApi = (page = 1) => request<{ items: Risk.NewsEvent[], total: number }>({ url: "/risk/events", method: "get", params: { page } })
export const getNewsEventApi = (id: number) => request<Risk.EventDetail>({ url: `/risk/events/${id}`, method: "get" })
export const previewReprocessApi = (data: Risk.ReprocessRange) => request<Risk.ReprocessPreview>({ url: "/risk/reprocess/preview", method: "post", data })
export const startReprocessApi = (data: Risk.ReprocessRange) => request<{ id: number }>({ url: "/risk/reprocess", method: "post", data })
export const startReprocessApi = (data: { preview_id: string }) => request<{ id: number }>({ url: "/risk/reprocess", method: "post", data })
export const listReprocessApi = () => request<Risk.ReprocessJob[]>({ url: "/risk/reprocess", method: "get" })
export const cancelReprocessApi = (id: number) => request<null>({ url: `/risk/reprocess/${id}/cancel`, method: "post" })
export const retryReprocessApi = (id: number) => request<null>({ url: `/risk/reprocess/${id}/retry`, method: "post" })
export const saveRiskConcurrencyApi = (max_concurrency: number) => request<{ max_concurrency: number }>({ url: "/risk/settings/concurrency", method: "patch", data: { max_concurrency } })
export const listRiskQueueApi = (params: { state?: string, stage?: string, origin?: string, job_id?: number, page?: number }) => request<Risk.Queue>({ url: "/risk/queue", method: "get", params })
export const getReprocessDetailApi = (id: number, page = 1) => request<Risk.ReprocessJob & { items: { id: number, news_id: number, title: string, url: string, mode: string, state: string, error: string | null }[] }>({ url: `/risk/reprocess/${id}`, method: "get", params: { page } })
+14 -3
View File
@@ -118,6 +118,17 @@ export interface EventDetail extends NewsEvent {
packages: RiskEvent[]
versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"] }[]
}
export interface ReprocessRange { start: string, end: string }
export interface ReprocessPreview { articles: number, packages: number, events: number }
export interface ReprocessJob { id: number, state: string, total: number, completed: number, failures: { stage: "grouping" | "analysis" | "events" | "outlook", id: number, error: string }[] }
export interface ReprocessRange { start: string, end: string, modes: ("missing" | "review")[] }
export interface ReprocessPreview { articles: number, packages: number, events: number, total: number, missing: number, review: number, preview_id: string, models: Record<string, { model: string, reasoning_effort: string | null }> }
export interface ReprocessJob { request?: { start: string, end: string, modes: string[] }, counts?: Record<string, number>, stages?: Record<string, Record<string, number>>, id: number, state: string, total: number, completed: number, failures: { stage: "grouping" | "analysis" | "events" | "outlook", id: number, error: string }[] }
export interface QueueItem { key: string, stage: string, object_id: number, title: string, job_id: number | null, origin: string, state: string, phase: string, model: string | null, reasoning_effort: string | null, started_at: string | null, updated_at: string | null, finished_at: string | null, url: string | null, round: number | null, tool: string | null, error: string | null }
export interface Queue {
items: QueueItem[]
total: number
as_of: string
worker_alive: boolean
enabled: boolean
limit: number
concurrency: { running: number, stages: Record<string, number> } | null
}
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import type * as Risk from "@@/apis/risk/type"
import { formatTime } from "@@/utils/text"
import { listRiskQueueApi } from "@/common/apis/risk"
import { useUserStore } from "@/pinia/stores/user"
const emit = defineEmits<{ package: [id: number], event: [id: number] }>()
const user = useUserStore()
const result = ref<Risk.Queue | null>(null)
const expanded = ref(false)
const state = ref("running")
const stage = ref("")
const origin = ref("")
const jobId = ref<number | undefined>()
const page = ref(1)
const error = ref(false)
let version = 0
let busy = false
let alive = true
let timer: ReturnType<typeof setInterval> | undefined
const labels: Record<string, string> = { grouping: "新闻分包", analysis: "包研判", events: "事件时间线", outlook: "阅读编排", backfill: "历史新闻", running: "运行中", queued: "排队", waiting: "等待条件", failed: "失败", done: "已完成", unknown: "状态未知" }
async function load() {
if (busy) return
busy = true
const seq = ++version
const session = user.sessionVersion
const query = { state: state.value || undefined, stage: stage.value || undefined, origin: origin.value || undefined, job_id: jobId.value || undefined, page: page.value }
const sameQuery = () => query.state === (state.value || undefined) && query.stage === (stage.value || undefined) && query.origin === (origin.value || undefined) && query.job_id === (jobId.value || undefined) && query.page === page.value
try {
const data = await listRiskQueueApi(query)
if (!sameQuery()) return
if (alive && seq === version && session === user.sessionVersion) {
result.value = data
error.value = false
}
} catch {
if (alive && seq === version && session === user.sessionVersion && sameQuery()) error.value = true
} finally {
if (seq === version) busy = false
}
}
watch([state, stage, origin, jobId], () => {
page.value = 1
void load()
})
watch(page, () => void load())
watch(() => user.sessionVersion, () => {
version++
busy = false
result.value = null
})
function visible() {
if (!document.hidden) void load()
}
onMounted(() => {
void load()
timer = setInterval(visible, 3000)
document.addEventListener("visibilitychange", visible)
})
onBeforeUnmount(() => {
alive = false
version++
clearInterval(timer)
document.removeEventListener("visibilitychange", visible)
})
</script>
<template>
<el-card shadow="never">
<template #header>
<b>AI 任务队列</b><el-button class="ml-3" link @click="expanded = !expanded">
{{ expanded ? '收起详情' : '展开完整队列' }}
</el-button>
</template>
<el-alert v-if="error" title="队列读取失败,保留上次状态" type="warning" :closable="false" />
<el-alert v-if="result && !result.worker_alive" title="Worker 心跳已过期,运行状态未知;保留数据库任务进度" type="warning" :closable="false" />
<p v-if="result">
{{ result.enabled ? 'AI 已启用' : 'AI 已暂停' }} · 并发上限 {{ result.limit }} · 当前执行 {{ result.concurrency?.running ?? '未知' }} 项
</p>
<div class="flex flex-wrap gap-2">
<el-select v-model="state" class="!w-32">
<el-option v-for="s in ['running', 'queued', 'waiting', 'failed', 'done']" :key="s" :label="labels[s]" :value="s" />
</el-select>
<el-select v-if="expanded" v-model="stage" clearable placeholder="全部阶段" class="!w-36">
<el-option v-for="s in ['grouping', 'analysis', 'events', 'outlook', 'backfill']" :key="s" :label="labels[s]" :value="s" />
</el-select>
<el-select v-if="expanded" v-model="origin" clearable placeholder="全部来源" class="!w-36">
<el-option label="实时" value="realtime" /><el-option label="回补" value="history" />
</el-select>
<el-input-number v-if="expanded" v-model="jobId" :min="1" :precision="0" placeholder="批次编号" controls-position="right" />
<el-button @click="load">
刷新
</el-button>
</div>
<p v-if="result">
{{ labels[state] }} {{ result.total }} 项 · 更新 {{ formatTime(result.as_of) }}
</p>
<div v-for="task in result?.items.slice(0, expanded ? 20 : 5)" :key="task.key" class="my-3 break-words border-b pb-2">
<b>{{ labels[task.stage] }} · {{ task.title }}</b>
<a v-if="task.url && /^https?:\/\//i.test(task.url)" :href="task.url" target="_blank" rel="noopener noreferrer" class="ml-2 text-blue-600">新闻原文</a>
<el-button v-if="task.stage === 'analysis'" link @click="emit('package', task.object_id)">
查看包
</el-button>
<el-button v-if="task.stage === 'events'" link @click="emit('event', task.object_id)">
查看事件
</el-button>
<p>{{ labels[task.state] }} · {{ task.phase }} {{ task.round ? `第${task.round}轮` : '' }} {{ task.tool ?? '' }} · {{ task.model ?? '待选定模型' }} {{ task.reasoning_effort ?? '' }}</p>
<p v-if="task.updated_at">
最近步骤 {{ formatTime(task.updated_at) }}
</p>
<p>开始 {{ formatTime(task.started_at) }} · {{ task.started_at ? `${Math.max(0, Math.floor((Date.parse(task.finished_at || result!.as_of) - Date.parse(task.started_at)) / 1000))}秒` : '等待领取' }} · {{ task.job_id ? `回补 #${task.job_id}` : '实时' }}</p>
<p v-if="task.error" class="text-red-600">
{{ task.error }}
</p>
</div>
<el-empty v-if="result && !result.items.length" description="当前筛选下没有任务" :image-size="40" />
<el-pagination v-if="expanded" v-model:current-page="page" :total="result?.total ?? 0" :page-size="20" layout="prev, pager, next" />
</el-card>
</template>
+80 -12
View File
@@ -14,8 +14,33 @@ const opened = ref(false)
const detailError = ref(false)
const detailId = ref(0)
const range = ref<[Date, Date] | null>(null)
const modes = ref<("missing" | "review")[]>(["missing"])
const preview = ref<Risk.ReprocessPreview | null>(null)
const jobs = ref<Risk.ReprocessJob[]>([])
const batch = ref<Awaited<ReturnType<typeof api.getReprocessDetailApi>> | null>(null)
const batchId = ref(0)
const batchPage = ref(1)
const batchLoading = ref(false)
const batchError = ref(false)
let batchSequence = 0
async function openBatch(id: number, page = 1) {
if (batchId.value !== id) batch.value = null
batchId.value = id
batchPage.value = page
batchLoading.value = true
const version = user.sessionVersion
const seq = ++batchSequence
try {
const data = await api.getReprocessDetailApi(id, page)
if (!active || seq !== batchSequence || version !== user.sessionVersion) return
batch.value = data
batchError.value = false
} catch {
if (active && seq === batchSequence && version === user.sessionVersion) batchError.value = true
} finally {
if (active && seq === batchSequence && version === user.sessionVersion) batchLoading.value = false
}
}
const busy = ref(false)
const error = ref(false)
let sequence = 0
@@ -32,6 +57,7 @@ async function load() {
total.value = events.total
jobs.value = runs
error.value = false
if (batchId.value && !batchLoading.value) await openBatch(batchId.value, batchPage.value)
} catch {
if (active && seq === sequence && version === user.sessionVersion) error.value = true
}
@@ -53,12 +79,12 @@ async function open(id: number) {
async function inspect() {
if (!range.value || busy.value) return
const version = user.sessionVersion
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString() }
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString(), modes: [...modes.value] }
const op = ++operation
busy.value = true
try {
const result = await api.previewReprocessApi(data)
if (active && version === user.sessionVersion && range.value && data.start === range.value[0].toISOString() && data.end === range.value[1].toISOString()) preview.value = result
if (active && version === user.sessionVersion && range.value && data.start === range.value[0].toISOString() && data.end === range.value[1].toISOString() && data.modes.join() === modes.value.join()) preview.value = result
} finally {
if (active && op === operation && version === user.sessionVersion) busy.value = false
}
@@ -66,13 +92,15 @@ async function inspect() {
async function start() {
if (!range.value || !preview.value || busy.value) return
const version = user.sessionVersion
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString() }
await ElMessageBox.confirm(`重整理 ${preview.value.articles} 篇新闻将产生模型费用,关联包和事件也会更新。是否启动?`, "重整理")
const data = { start: range.value[0].toISOString(), end: range.value[1].toISOString(), modes: [...modes.value] }
const previewId = preview.value.preview_id
await ElMessageBox.confirm(`回补 ${preview.value.articles} 篇新闻将产生模型费用,关联包和事件也会更新。是否启动?`, "AI 历史回补")
if (!active || version !== user.sessionVersion || busy.value) return
const op = ++operation
busy.value = true
try {
await api.startReprocessApi(data)
if (!preview.value || preview.value.preview_id !== previewId || data.modes.join() !== modes.value.join() || !range.value || data.start !== range.value[0].toISOString() || data.end !== range.value[1].toISOString()) return
await api.startReprocessApi({ preview_id: previewId })
if (active && version === user.sessionVersion) {
preview.value = null
await load()
@@ -86,11 +114,12 @@ async function action(id: number, retry: boolean) {
await (retry ? api.retryReprocessApi(id) : api.cancelReprocessApi(id))
if (active && version === user.sessionVersion) await load()
}
watch(range, () => {
watch([range, modes], () => {
preview.value = null
})
watch(page, load)
watch(() => user.sessionVersion, () => {
batchSequence++
sequence++
detailSequence++
operation++
@@ -98,6 +127,10 @@ watch(() => user.sessionVersion, () => {
detailError.value = false
items.value = []
jobs.value = []
batch.value = null
batchId.value = 0
batchLoading.value = false
batchError.value = false
detail.value = null
preview.value = null
opened.value = false
@@ -114,6 +147,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
active = false
batchSequence++
sequence++
detailSequence++
clearInterval(timer)
@@ -144,20 +178,43 @@ defineExpose({ open })
</div>
<el-pagination v-model:current-page="page" :total="total" :page-size="20" layout="prev, pager, next" />
<details class="mt-4">
<summary>按时间范围重整理新闻</summary>
<summary>AI 历史回补</summary>
<p>按新闻发布时间选择范围,使用现存最新修订。不会还原历史时点;共享包和事件可能同时更新。AI 暂停时任务等待。</p>
<el-checkbox-group v-model="modes">
<el-checkbox value="missing">
补齐未完成研判
</el-checkbox>
<el-checkbox value="review">
已研判重新核对(重新分包及核验)
</el-checkbox>
</el-checkbox-group>
<el-date-picker v-model="range" type="datetimerange" class="!max-w-full" start-placeholder="起始时间" end-placeholder="结束时间" />
<el-button :loading="busy" @click="inspect">
<el-button :loading="busy" :disabled="!range || !modes.length" @click="inspect">
预览范围
</el-button>
<template v-if="preview">
<p>{{ preview.articles }} 篇新闻 · {{ preview.packages }} 个包 · {{ preview.events }} 个事件</p>
<el-button :loading="busy" @click="start">
启动重整理
<p>范围共 {{ preview.total }} 篇 · 未完成 {{ preview.missing }} · 已研判 {{ preview.review }} · 本次 {{ preview.articles }} 篇新闻 · {{ preview.packages }} 个包 · {{ preview.events }} 个事件</p>
<p v-for="(model, stage) in preview.models" :key="stage">
{{ ({ grouping: '分包', analysis: '研判', outlook: '阅读编排' })[stage] }}:{{ model.model }} · 思考强度 {{ model.reasoning_effort ?? '模型默认' }}
</p>
<el-button :loading="busy" :disabled="!preview.articles" @click="start">
启动回补
</el-button>
</template>
<div v-for="job in jobs" :key="job.id" class="my-2">
#{{ job.id }} · {{ job.state }} · {{ job.completed }}/{{ job.total }}
#{{ job.id }} · {{ job.state }} · 新闻处理 {{ job.completed }}/{{ job.total }}
<p v-if="job.request">
{{ formatTime(job.request.start) }} 至 {{ formatTime(job.request.end) }} · {{ job.request.modes.map(m => m === 'missing' ? '补齐未完成' : '重新核对').join('、') }}
</p>
<p v-if="job.counts">
成功 {{ job.counts.done ?? 0 }} · 处理中 {{ job.counts.processing ?? 0 }} · 待处理 {{ job.counts.pending ?? 0 }} · 失败 {{ job.counts.failed ?? 0 }} · 跳过 {{ job.counts.skipped ?? 0 }}
</p>
<p v-for="(counts, stage) in job.stages" :key="stage">
{{ stage === 'analysis' ? '包研判' : '事件时间线' }}:待处理 {{ counts.pending ?? 0 }} · 运行 {{ counts.processing ?? 0 }} · 完成 {{ counts.done ?? 0 }} · 失败 {{ counts.failed ?? 0 }}
</p>
<el-button link @click="openBatch(job.id)">
查看新闻明细
</el-button>
<el-button v-if="['pending', 'running'].includes(job.state)" link @click="action(job.id, false)">
取消
</el-button>
@@ -168,6 +225,17 @@ defineExpose({ open })
{{ ({ grouping: "新闻初筛", analysis: "包研判", events: "事件时间线", outlook: "阅读编排" })[failure.stage] }} #{{ failure.id }}:{{ failure.error }}
</p>
</div>
<div v-if="batchId">
<h4>回补 #{{ batchId }} 新闻明细</h4>
<el-alert v-if="batchError" title="新闻明细读取失败,保留上次进度,可刷新重试" type="warning" :closable="false" />
<el-button :loading="batchLoading" @click="openBatch(batchId, batchPage)">
刷新明细
</el-button>
<p v-for="item in batch?.items" :key="item.id">
{{ item.title }}(新闻 #{{ item.news_id }})· {{ item.mode === 'missing' ? '补齐' : item.mode === 'review' ? '重新核对' : '旧批次' }} · {{ item.state }} {{ item.error }}
</p>
<el-pagination v-if="batch" :current-page="batchPage" :total="batch.total" :page-size="20" layout="prev, pager, next" @current-change="openBatch(batchId, $event)" />
</div>
</details>
</el-card>
<el-drawer v-model="opened" title="事件发展时间线" size="min(760px, 100vw)">
+2
View File
@@ -9,6 +9,7 @@ import * as api from "@/common/apis/risk"
import { listProviderModelsApi, listProvidersApi } from "@/common/apis/settings"
import { useUserStore } from "@/pinia/stores/user"
import AiQueue from "./components/AiQueue.vue"
import EventResearch from "./components/EventResearch.vue"
const eventResearch = ref<InstanceType<typeof EventResearch> | null>(null)
@@ -257,6 +258,7 @@ onBeforeUnmount(() => {
<template>
<div class="mx-auto max-w-7xl flex flex-col gap-4">
<AiQueue @package="openEvent" @event="openReading({ kind: 'event', id: $event, revision: 0, priority: 0, reason: '' })" />
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1">
<h1 class="m-0 text-xl font-semibold">