refactor(news): remove retired research workflow
This commit is contained in:
1 parent
f3894c3728
commit
d79a10c2e6
69 files changed
+460
-6713
No files matched your search
@@ -1,25 +0,0 @@
|
||||
import type * as Risk from "./type"
|
||||
import { request } from "@/http/axios"
|
||||
|
||||
export const getRiskSettingsApi = () => request<Risk.Settings>({ url: "/risk/settings", method: "get" })
|
||||
export const saveRiskSettingsApi = (data: Risk.Settings) => request<null>({ url: "/risk/settings", method: "put", data })
|
||||
export const enableRiskApi = (data: Risk.Settings) => request<null>({ url: "/risk/enable", method: "post", timeout: 650000, data })
|
||||
export const getRiskOverviewApi = (hours: number) => request<Risk.Overview>({ url: "/risk/overview", method: "get", params: { hours } })
|
||||
export const listRiskEventsApi = (params: Risk.EventQuery) => request<{ items: Risk.NewsEvent[], total: number }>({ url: "/risk/events", method: "get", params })
|
||||
export const getRiskPackageApi = (id: number) => request<{ corrections?: { id: number, result: unknown }[], grouping_versions?: { id: number, state: string, error: string | null }[], event: Risk.NewsPackage, history: Risk.Analysis[] }>({ url: `/risk/packages/${id}`, method: "get" })
|
||||
export const listRiskAnalysesApi = (params: { news_id?: number, revision_no?: number, failed?: boolean, stage?: string }, notify = true) => request<Risk.Analysis[]>({ url: "/risk/tasks", method: "get", params, notify })
|
||||
export const retryRiskAnalysisApi = (id: number, stage = "grouping") => request<null>({ url: `/risk/tasks/${id}/retry`, method: "post", params: { stage } })
|
||||
|
||||
export const listNewsPackagesApi = (newsId: number, revision: number) => request<Risk.NewsPackage[]>({ url: `/risk/news/${newsId}/packages`, method: "get", params: { revision_no: revision } })
|
||||
export const createNewsSkillTemplatesApi = (refreshExisting = false) => request<{ grouping_skill_id: number, analysis_skill_id: number, outlook_skill_id: number }>({ url: "/risk/skill-templates", method: "post", data: { refresh_existing: refreshExisting } })
|
||||
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: { preview_id: string, run_mode?: "shared" | "history_only" }) => 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 } })
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { Effort } from "../settings/type"
|
||||
|
||||
export interface RiskResult {
|
||||
summary: string
|
||||
reasoning: string
|
||||
uncertainty: string
|
||||
evidence?: string
|
||||
direction: string
|
||||
verification: string
|
||||
confidence: string
|
||||
risk_stars: number
|
||||
importance_stars: number
|
||||
facts?: string
|
||||
timeline?: string
|
||||
asset_impacts?: string
|
||||
opinions?: string
|
||||
counter_evidence?: string
|
||||
watchpoints?: string
|
||||
claims?: { statement: string, verdict: string, evidence_refs: { article_id: number, quote: string }[] }[]
|
||||
related_packages?: { id: number, relation: string, reason: string }[]
|
||||
evidence_refs?: { article_id: number, quote: string }[]
|
||||
}
|
||||
export interface NewsPackage {
|
||||
main_event_id: number | null
|
||||
replacement_ids: number[]
|
||||
fact: string
|
||||
tags: string[]
|
||||
content_kind: string
|
||||
article_count: number
|
||||
id: number
|
||||
title: string
|
||||
category: string
|
||||
entities: string[]
|
||||
state: string
|
||||
revision: number
|
||||
error: string | null
|
||||
published_at: string
|
||||
updated_at: string
|
||||
}
|
||||
export interface Settings {
|
||||
max_concurrency: number
|
||||
enabled: boolean
|
||||
provider_id: number | null
|
||||
model: string
|
||||
second_provider_id: number | null
|
||||
second_model: string
|
||||
third_provider_id: number | null
|
||||
third_model: string
|
||||
reasoning_effort: Effort | null
|
||||
second_reasoning_effort: Effort | null
|
||||
third_reasoning_effort: Effort | null
|
||||
grouping_skill_id: number | null
|
||||
analysis_skill_id: number | null
|
||||
outlook_skill_id: number | null
|
||||
start_at: string | null
|
||||
}
|
||||
export interface Analysis {
|
||||
id: number
|
||||
news_id: number
|
||||
state: string
|
||||
result: RiskResult | null
|
||||
input: { title: string, source: string, url: string, published_at: string, progress_at: string, revision_no: number }
|
||||
model: string | null
|
||||
error: string | null
|
||||
analyzed_at: string | null
|
||||
historical: boolean
|
||||
}
|
||||
export interface Reading { kind?: "package" | "event", id: number, revision: number, priority: number, reason: string }
|
||||
export interface Trend { summary: string, conditions: string, counter_evidence: string, watchpoints: string }
|
||||
export interface Briefing {
|
||||
reading: Reading[]
|
||||
expansion: Reading[]
|
||||
as_of: string
|
||||
synthesis: { summary: string, market: string, short_term: Trend, medium_term: Trend } | null
|
||||
inputs: { kind?: "package" | "event", id: number, revision: number, category: string, tags: string[], result: RiskResult & { title: string } }[]
|
||||
}
|
||||
export interface Overview {
|
||||
concurrency: { limit: number, running: number | null, queued: number, stages: Record<string, number>, updated_at: string | null }
|
||||
briefing: Briefing | null
|
||||
stage2: Record<string, number>
|
||||
stage3?: { state: string, pending: boolean, heartbeat: string | null }
|
||||
as_of: string
|
||||
counts: Record<string, number>
|
||||
coverage: { total: number, done: number, skipped: number, failed: number, pending: number, processing: number }
|
||||
scenario: { direction: string, summary: string, conditions: string, counter_factors: string, uncertainty: string, event_ids: number[] } | null
|
||||
snapshot_at: string | null
|
||||
quotes: { at: string, BTCUSDT?: { lastPrice: string, priceChangePercent: string }, ETHUSDT?: { lastPrice: string, priceChangePercent: string } } | null
|
||||
stale: boolean
|
||||
error: string | null
|
||||
heartbeat: string | null
|
||||
}
|
||||
export interface EventQuery { tag?: string, importance?: number, hours?: number, stars?: number, q?: string, category?: string, asset?: string, start?: string, end?: string, page?: number }
|
||||
|
||||
export interface TimelinePoint {
|
||||
planned_at?: string | null
|
||||
planned_precision?: string
|
||||
period_text?: string
|
||||
occurred_at: string | null
|
||||
time_precision: string
|
||||
time_text: string
|
||||
reported_at: string | null
|
||||
description: string
|
||||
verification: string
|
||||
package_id: number
|
||||
package_revision: number
|
||||
evidence_refs: { article_id: number, quote: string }[]
|
||||
}
|
||||
export interface NewsEvent {
|
||||
id: number
|
||||
title: string
|
||||
state: string
|
||||
error: string | null
|
||||
revision: number
|
||||
published_revision: number
|
||||
updated_at: string
|
||||
tags: string[]
|
||||
category: string
|
||||
package_count: number
|
||||
risk_stars: number
|
||||
importance_stars: number
|
||||
result: (RiskResult & { timeline_points: TimelinePoint[] }) | null
|
||||
}
|
||||
export interface EventDetail extends NewsEvent {
|
||||
packages: NewsPackage[]
|
||||
evidence: { id: number, version_id: number, data: unknown, fetched_at: string }[]
|
||||
versions: { id: number, revision: number, state: string, error: string | null, result: NewsEvent["result"], usage: unknown }[]
|
||||
}
|
||||
export interface ReprocessRange { selection?: "range" | "attempted", 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?: { selection?: "range" | "attempted", run_mode?: "shared" | "history_only", start: string | null, end: string | null, 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
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
<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', '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>
|
||||
@@ -1,276 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type * as Risk from "@@/apis/risk/type"
|
||||
import { formatTime } from "@@/utils/text"
|
||||
import * as api from "@/common/apis/risk"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
|
||||
const emit = defineEmits<{ package: [id: number] }>()
|
||||
const user = useUserStore()
|
||||
const detail = ref<Risk.EventDetail | null>(null)
|
||||
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 selection = ref<"range" | "attempted">("range")
|
||||
const runMode = ref<"shared" | "history_only">("shared")
|
||||
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
|
||||
let detailSequence = 0
|
||||
let operation = 0
|
||||
let active = true
|
||||
async function load() {
|
||||
const seq = ++sequence
|
||||
const version = user.sessionVersion
|
||||
try {
|
||||
const runs = await api.listReprocessApi()
|
||||
if (!active || seq !== sequence || version !== user.sessionVersion) return
|
||||
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
|
||||
}
|
||||
}
|
||||
async function open(id: number) {
|
||||
const seq = ++detailSequence
|
||||
const version = user.sessionVersion
|
||||
detail.value = null
|
||||
detailError.value = false
|
||||
detailId.value = id
|
||||
opened.value = true
|
||||
try {
|
||||
const result = await api.getNewsEventApi(id)
|
||||
if (active && opened.value && seq === detailSequence && version === user.sessionVersion) detail.value = result
|
||||
} catch {
|
||||
if (active && seq === detailSequence && version === user.sessionVersion) detailError.value = true
|
||||
}
|
||||
}
|
||||
async function inspect() {
|
||||
if ((selection.value === "range" && !range.value) || busy.value) return
|
||||
const version = user.sessionVersion
|
||||
const data = { selection: selection.value, start: range.value?.[0].toISOString(), end: range.value?.[1].toISOString(), modes: selection.value === "attempted" ? ["review" as const] : [...modes.value] }
|
||||
const op = ++operation
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.previewReprocessApi(data)
|
||||
if (active && version === user.sessionVersion && data.selection === selection.value && data.start === range.value?.[0].toISOString() && data.end === range.value?.[1].toISOString() && data.modes.join() === (selection.value === "attempted" ? "review" : modes.value.join())) preview.value = result
|
||||
} finally {
|
||||
if (active && op === operation && version === user.sessionVersion) busy.value = false
|
||||
}
|
||||
}
|
||||
async function start() {
|
||||
if ((selection.value === "range" && !range.value) || !preview.value || busy.value) return
|
||||
const version = user.sessionVersion
|
||||
const data = { selection: selection.value, start: range.value?.[0].toISOString(), end: range.value?.[1].toISOString(), modes: selection.value === "attempted" ? ["review" as const] : [...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 {
|
||||
if (!preview.value || preview.value.preview_id !== previewId || data.modes.join() !== (selection.value === "attempted" ? "review" : modes.value.join()) || data.selection !== selection.value || data.start !== range.value?.[0].toISOString() || data.end !== range.value?.[1].toISOString()) return
|
||||
await api.startReprocessApi({ preview_id: previewId, run_mode: runMode.value })
|
||||
if (active && version === user.sessionVersion) {
|
||||
preview.value = null
|
||||
await load()
|
||||
}
|
||||
} finally {
|
||||
if (active && op === operation && version === user.sessionVersion) busy.value = false
|
||||
}
|
||||
}
|
||||
async function action(id: number, retry: boolean) {
|
||||
const version = user.sessionVersion
|
||||
await (retry ? api.retryReprocessApi(id) : api.cancelReprocessApi(id))
|
||||
if (active && version === user.sessionVersion) await load()
|
||||
}
|
||||
watch([range, modes], () => {
|
||||
preview.value = null
|
||||
})
|
||||
watch(() => user.sessionVersion, () => {
|
||||
batchSequence++
|
||||
sequence++
|
||||
detailSequence++
|
||||
operation++
|
||||
busy.value = false
|
||||
detailError.value = false
|
||||
jobs.value = []
|
||||
batch.value = null
|
||||
batchId.value = 0
|
||||
batchLoading.value = false
|
||||
batchError.value = false
|
||||
detail.value = null
|
||||
preview.value = null
|
||||
opened.value = false
|
||||
})
|
||||
watch(opened, (value) => {
|
||||
if (!value) detailSequence++
|
||||
})
|
||||
let timer: ReturnType<typeof setInterval>
|
||||
onMounted(() => {
|
||||
void load()
|
||||
timer = setInterval(() => {
|
||||
if (!document.hidden) void load()
|
||||
}, 15000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
active = false
|
||||
batchSequence++
|
||||
sequence++
|
||||
detailSequence++
|
||||
clearInterval(timer)
|
||||
})
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
AI 历史回补
|
||||
</template>
|
||||
<el-alert v-if="error" title="事件读取失败,可重试" type="warning" :closable="false" />
|
||||
<el-button link @click="load">
|
||||
刷新
|
||||
</el-button>
|
||||
<details class="mt-4">
|
||||
<summary>AI 历史回补</summary>
|
||||
<p>按新闻发布时间选择范围,使用现存最新修订。不会还原历史时点;共享包和事件可能同时更新。AI 暂停时任务等待。</p>
|
||||
<el-select v-model="selection" @change="preview = null">
|
||||
<el-option label="按时间段选择" value="range" />
|
||||
<el-option label="全部已有AI处理记录(重新分包)" value="attempted" />
|
||||
</el-select>
|
||||
<el-select v-model="runMode" @change="preview = null">
|
||||
<el-option label="与实时任务共享调度" value="shared" />
|
||||
<el-option label="仅运行本批次,完成后暂停" value="history_only" />
|
||||
</el-select>
|
||||
<el-checkbox-group v-if="selection === 'range'" v-model="modes">
|
||||
<el-checkbox value="missing">
|
||||
补齐未完成研判
|
||||
</el-checkbox>
|
||||
<el-checkbox value="review">
|
||||
已研判重新核对(重新分包及核验)
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<p v-else>
|
||||
重新分包并核验所有曾尝试过 AI 处理的新闻,包括此前失败的新闻。
|
||||
</p>
|
||||
<el-date-picker v-if="selection === 'range'" v-model="range" type="datetimerange" class="!max-w-full" start-placeholder="起始时间" end-placeholder="结束时间" />
|
||||
<el-button :loading="busy" :disabled="(selection === 'range' && !range) || (selection === 'range' && !modes.length)" @click="inspect">
|
||||
预览范围
|
||||
</el-button>
|
||||
<template v-if="preview">
|
||||
<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 }}
|
||||
<p v-if="job.request">
|
||||
{{ job.request.selection === 'attempted' ? '全部已有AI处理记录' : `${formatTime(job.request.start)} 至 ${formatTime(job.request.end)}` }} · {{ job.request.run_mode === 'history_only' ? '仅历史,结束暂停' : '共享调度' }} · {{ 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>
|
||||
<el-button v-if="job.state === 'failed'" link @click="action(job.id, true)">
|
||||
重试失败项
|
||||
</el-button>
|
||||
<p v-for="failure in job.failures" :key="`${failure.stage}:${failure.id}`">
|
||||
{{ ({ 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)">
|
||||
<el-alert v-if="detailError" title="事件详情读取失败" type="warning" :closable="false" />
|
||||
<el-button v-if="detailError" @click="open(detailId)">
|
||||
重试
|
||||
</el-button>
|
||||
<template v-if="detail">
|
||||
<h2>{{ detail.title }}</h2><p>{{ detail.result?.summary }}</p>
|
||||
<el-alert v-if="detail.error" :title="detail.error" type="warning" :closable="false" />
|
||||
<div v-for="(point, index) in detail.result?.timeline_points" :key="index" class="my-4 border-l-2 pl-3">
|
||||
<b>{{ point.time_text || '发生时间未知' }}</b><span v-if="point.planned_at"> · 计划 {{ point.planned_at }}({{ point.planned_precision }})</span><span v-if="point.period_text"> · 统计期 {{ point.period_text }}</span> · {{ point.time_precision }} · {{ point.verification }}
|
||||
<p>{{ point.description }}</p><p>报道时间:{{ formatTime(point.reported_at) }}</p>
|
||||
<el-button link @click="emit('package', point.package_id)">
|
||||
包 #{{ point.package_id }} · v{{ point.package_revision }}
|
||||
</el-button>
|
||||
<blockquote v-for="(ref, n) in point.evidence_refs" :key="n">
|
||||
{{ ref.quote }}
|
||||
</blockquote>
|
||||
</div>
|
||||
<p>事实:{{ detail.result?.facts }}</p><p>市场影响:{{ detail.result?.asset_impacts }}</p><p>判断依据:{{ detail.result?.reasoning }}</p>
|
||||
<p>反证:{{ detail.result?.counter_evidence }}</p><p>不确定性:{{ detail.result?.uncertainty }}</p><p>意见:{{ detail.result?.opinions }}</p><p>关注:{{ detail.result?.watchpoints }}</p>
|
||||
<p>组成包</p>
|
||||
<div v-for="item in detail.packages" :key="item.id">
|
||||
<el-button link @click="emit('package', item.id)">
|
||||
{{ item.title }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p>核验记录</p>
|
||||
<details v-for="record in detail.evidence" :key="record.id">
|
||||
<summary>证据 #{{ record.id }} · {{ formatTime(record.fetched_at) }}</summary>
|
||||
<pre class="whitespace-pre-wrap break-all">{{ record.data }}</pre>
|
||||
</details>
|
||||
<p>版本历史</p>
|
||||
<details v-for="version in detail.versions" :key="version.id" class="my-2">
|
||||
<summary>v{{ version.revision }} · {{ version.state }} {{ version.error }}</summary>
|
||||
<p>{{ version.result?.summary }}</p><pre class="whitespace-pre-wrap break-all">{{ version.usage }}</pre>
|
||||
<p v-for="(point, index) in version.result?.timeline_points" :key="index">
|
||||
{{ point.time_text }} · {{ point.description }} · 包 #{{ point.package_id }} v{{ point.package_revision }}
|
||||
</p>
|
||||
<p>{{ version.result?.uncertainty }}</p>
|
||||
</details>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import * as vue from "vue"
|
||||
import { moduleLoader } from "./module-loader.ts"
|
||||
|
||||
test("event archives and fact packages use separate API contracts", async () => {
|
||||
const calls: { url: string }[] = []
|
||||
const load = moduleLoader({ "@/http/axios": { request: async (config: { url: string }) => calls.push(config) } })
|
||||
const api = load("@@/apis/risk")
|
||||
await api.listRiskEventsApi({ importance: 3, hours: 72 })
|
||||
await api.getNewsEventApi(9)
|
||||
await api.getRiskPackageApi(4)
|
||||
await api.listNewsPackagesApi(12, 2)
|
||||
assert.deepEqual(calls.map(c => c.url), ["/risk/events", "/risk/events/9", "/risk/packages/4", "/risk/news/12/packages"])
|
||||
})
|
||||
|
||||
test("switching event details discards previous event and session responses", async () => {
|
||||
const first = Promise.withResolvers<any>()
|
||||
const second = Promise.withResolvers<any>()
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const load = moduleLoader({
|
||||
"@/common/apis/risk": { getNewsEventApi: (id: number) => id === 1 ? first.promise : second.promise },
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@@/utils/text": { formatTime: (s: string) => s }
|
||||
}, { ...vue, onMounted() {}, onBeforeUnmount() {}, setInterval() {}, clearInterval() {} })
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {} })
|
||||
const a = page.open(1)
|
||||
const b = page.open(2)
|
||||
second.resolve({ id: 2, title: "Second event", packages: [{ id: 4, fact: "Original fact" }] })
|
||||
await b
|
||||
first.resolve({ id: 1, title: "Old event" })
|
||||
await a
|
||||
assert.equal(page.detail.value.id, 2)
|
||||
assert.equal(page.detail.value.packages[0].fact, "Original fact")
|
||||
const pending = page.open(1)
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
await pending
|
||||
assert.equal(page.detail.value, null)
|
||||
assert.equal(page.opened.value, false)
|
||||
})
|
||||
@@ -483,236 +483,6 @@ test("新闻列表只更新可见条目的匹配版本评级,保留滚动与
|
||||
assert.equal(queries.length, 5)
|
||||
})
|
||||
|
||||
test("事件详情忽略旧请求与旧会话,失败可重试", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": { getNewsEventApi: () => {
|
||||
const request = Promise.withResolvers()
|
||||
pending.push(request)
|
||||
return request.promise
|
||||
} },
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, globals)
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
const first = page.open(1)
|
||||
const second = page.open(2)
|
||||
pending[1].resolve({ id: 2 })
|
||||
await second
|
||||
pending[0].reject(new Error("Old failure"))
|
||||
await first
|
||||
assert.equal(page.detail.value.id, 2)
|
||||
assert.equal(page.detailError.value, false)
|
||||
const failed = page.open(3)
|
||||
pending[2].reject(new Error("Failed"))
|
||||
await failed
|
||||
assert.equal(page.detailError.value, true)
|
||||
const retry = page.open(3)
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
pending[3].resolve({ id: 3 })
|
||||
await retry
|
||||
assert.equal(page.detail.value, null)
|
||||
assert.equal(page.opened.value, false)
|
||||
})
|
||||
|
||||
test("重整理预览的旧会话完成不能解除新操作的忙碌状态", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": { previewReprocessApi: () => {
|
||||
const request = Promise.withResolvers()
|
||||
pending.push(request)
|
||||
return request.promise
|
||||
} },
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, globals)
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
page.range.value = [new Date("2026-11-01"), new Date("2026-11-02")]
|
||||
await vue.nextTick()
|
||||
const old = page.inspect()
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
const current = page.inspect()
|
||||
pending[0].resolve({ articles: 1 })
|
||||
await old
|
||||
assert.equal(page.busy.value, true)
|
||||
assert.equal(page.preview.value, null)
|
||||
pending[1].resolve({ articles: 2 })
|
||||
await current
|
||||
assert.equal(page.busy.value, false)
|
||||
assert.equal(page.preview.value.articles, 2)
|
||||
})
|
||||
|
||||
test("AI 队列隔离旧会话与旧筛选响应,轮询隐藏页面时暂停并在卸载后清理", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||
const queries: any[] = []
|
||||
let poll = () => {}
|
||||
let unmount = () => {}
|
||||
let cleared = false
|
||||
const doc = { hidden: true, addEventListener() {}, removeEventListener() {} }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": { listRiskQueueApi: (query: unknown) => {
|
||||
queries.push(query)
|
||||
const request = Promise.withResolvers()
|
||||
pending.push(request)
|
||||
return request.promise
|
||||
} },
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, { ...globals, document: doc, setInterval: (callback: () => void) => {
|
||||
poll = callback
|
||||
return 1
|
||||
}, clearInterval: () => {
|
||||
cleared = true
|
||||
}, onMounted: (callback: () => void) => callback(), onBeforeUnmount: (callback: () => void) => {
|
||||
unmount = callback
|
||||
} })
|
||||
const page = load("@/pages/home/components/AiQueue.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
assert.equal(queries.length, 1)
|
||||
poll()
|
||||
assert.equal(queries.length, 1)
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
const current = page.load()
|
||||
pending[1].resolve({ total: 7, items: [] })
|
||||
await current
|
||||
pending[0].resolve({ total: 99, items: [] })
|
||||
await setImmediate()
|
||||
assert.equal(page.result.value.total, 7)
|
||||
page.state.value = "queued"
|
||||
await vue.nextTick()
|
||||
assert.equal(queries[2].state, "queued")
|
||||
page.stage.value = "analysis"
|
||||
await vue.nextTick()
|
||||
pending[2].resolve({ total: 999, items: [] })
|
||||
await setImmediate()
|
||||
assert.equal(page.result.value.total, 7)
|
||||
doc.hidden = false
|
||||
poll()
|
||||
pending[3].resolve({ total: 4, items: [] })
|
||||
await setImmediate()
|
||||
assert.equal(queries[3].stage, "analysis")
|
||||
assert.equal(page.result.value.total, 4)
|
||||
const late = page.load()
|
||||
unmount()
|
||||
pending[4].resolve({ total: 100, items: [] })
|
||||
await late
|
||||
assert.equal(page.result.value.total, 4)
|
||||
assert.equal(cleared, true)
|
||||
})
|
||||
|
||||
test("回补确认绑定原预览,确认期间替换预览不能启动另一批次", async () => {
|
||||
const confirmation = Promise.withResolvers()
|
||||
const started: unknown[] = []
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 1 }) },
|
||||
"@/common/apis/risk": { startReprocessApi: async (body: unknown) => {
|
||||
started.push(body)
|
||||
} },
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, { ...globals, ElMessageBox: { confirm: () => confirmation.promise } })
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
page.range.value = [new Date("2026-10-01"), new Date("2026-11-01")]
|
||||
await vue.nextTick()
|
||||
page.preview.value = { preview_id: "accepted-preview", articles: 2 }
|
||||
const action = page.start()
|
||||
page.preview.value = { preview_id: "replacement-preview", articles: 10 }
|
||||
confirmation.resolve(true)
|
||||
await action
|
||||
assert.deepEqual(started, [])
|
||||
})
|
||||
|
||||
test("回补新闻明细随轮询和手动刷新更新,保留批次分页并支持失败重试", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
let state = "pending"
|
||||
let fail = false
|
||||
const requests: [number, number][] = []
|
||||
let poll = () => {}
|
||||
const doc = { hidden: false }
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": {
|
||||
listNewsEventsApi: async () => ({ items: [], total: 0 }),
|
||||
listReprocessApi: async () => [{ id: 7, state }],
|
||||
getReprocessDetailApi: async (id: number, page: number) => {
|
||||
requests.push([id, page])
|
||||
if (fail) throw new Error("Synthetic detail failure")
|
||||
return { id, state, total: 40, items: [{ id: 21, state }] }
|
||||
}
|
||||
},
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, { ...globals, document: doc, onMounted: (callback: () => void) => callback(), setInterval: (callback: () => void) => {
|
||||
poll = callback
|
||||
return 1
|
||||
} })
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
await setImmediate()
|
||||
await page.openBatch(7, 2)
|
||||
state = "done"
|
||||
poll()
|
||||
await setImmediate()
|
||||
assert.equal(page.batch.value.items[0].state, "done")
|
||||
assert.equal(page.batchPage.value, 2)
|
||||
assert.deepEqual(requests, [[7, 2], [7, 2]])
|
||||
doc.hidden = true
|
||||
poll()
|
||||
await setImmediate()
|
||||
assert.equal(requests.length, 2)
|
||||
fail = true
|
||||
await page.load()
|
||||
assert.equal(page.batchError.value, true)
|
||||
assert.equal(page.batch.value.items[0].state, "done")
|
||||
assert.equal(page.batchLoading.value, false)
|
||||
fail = false
|
||||
await page.openBatch(7, 2)
|
||||
assert.equal(page.batchError.value, false)
|
||||
})
|
||||
|
||||
test("回补明细刷新不抢占新选择,旧会话与旧请求不能覆盖当前批次", async () => {
|
||||
const user = vue.reactive({ sessionVersion: 1 })
|
||||
const requests: { id: number, page: number, promise: ReturnType<typeof Promise.withResolvers> }[] = []
|
||||
const load = moduleLoader({
|
||||
"@/pinia/stores/user": { useUserStore: () => user },
|
||||
"@/common/apis/risk": {
|
||||
listNewsEventsApi: async () => ({ items: [], total: 0 }),
|
||||
listReprocessApi: async () => [],
|
||||
getReprocessDetailApi: (id: number, page: number) => {
|
||||
const promise = Promise.withResolvers()
|
||||
requests.push({ id, page, promise })
|
||||
return promise.promise
|
||||
}
|
||||
},
|
||||
"@@/utils/text": { formatTime: (value: string) => value }
|
||||
}, globals)
|
||||
const page = load("@/pages/home/components/EventResearch.vue").default.setup({}, { expose() {}, emit() {} })
|
||||
const old = page.openBatch(7, 2)
|
||||
const selected = page.openBatch(8, 1)
|
||||
await page.load()
|
||||
assert.deepEqual(requests.map(r => [r.id, r.page]), [[7, 2], [8, 1]])
|
||||
requests[0].promise.resolve({ id: 7, items: [] })
|
||||
await old
|
||||
assert.equal(page.batchLoading.value, true)
|
||||
requests[1].promise.resolve({ id: 8, items: [] })
|
||||
await selected
|
||||
assert.equal(page.batch.value.id, 8)
|
||||
const late = page.openBatch(8, 2)
|
||||
user.sessionVersion++
|
||||
await vue.nextTick()
|
||||
const current = page.openBatch(9, 1)
|
||||
requests[2].promise.reject(new Error("Old session failure"))
|
||||
await late
|
||||
assert.equal(page.batchLoading.value, true)
|
||||
assert.equal(page.batchError.value, false)
|
||||
requests[3].promise.resolve({ id: 9, items: [] })
|
||||
await current
|
||||
assert.equal(page.batch.value.id, 9)
|
||||
assert.equal(page.batchId.value, 9)
|
||||
assert.equal(page.batchPage.value, 1)
|
||||
})
|
||||
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 },
|
||||
|
||||
Reference in new issue
Block a user