Files
sanguo_vnpy_v2/frontend/src/views/pipeline/HypothesisPool.vue
T

427 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getHypotheses, getGraveyardFactors, draftHypothesis, saveHypothesis, startDecompose,
getDecomposeJobs, draftInflight } from '@/api/pipeline'
import type { Hypothesis, CardDraftInput, DecomposeJob, GraveyardFactor } from '@/api/pipeline'
import StatusChip from '@/components/pipeline/StatusChip.vue'
import { HYPOTHESIS_LABEL, HYPOTHESIS_TONE } from '@/components/pipeline/tones'
import PanelCard from './PanelCard.vue'
const all = ref<Hypothesis[]>([])
const tab = ref<'pool' | 'graveyard'>('pool')
// 因子墓园(10-09 可见性补):判死因子不上工厂板/详情 404,唯一视图在此
const deadFactors = ref<GraveyardFactor[]>([])
onMounted(async () => {
// 后端未接真(P4 排期):失败退化空池,不裸抛 unhandled rejection
all.value = await getHypotheses().catch(() => [])
deadFactors.value = await getGraveyardFactors().catch(() => [])
ensurePoll() // 挂载即查:服务端在飞分解(切页/刷新回来)续接轮询
})
onUnmounted(stopPoll)
const pool = computed(() => all.value.filter((h) => h.state !== 'graveyard'))
const graveyard = computed(() => all.value.filter((h) => h.state === 'graveyard'))
const shown = computed(() => (tab.value === 'pool' ? pool.value : graveyard.value))
const sentence = ref('')
const draft = ref<CardDraftInput | null>(null)
const busy = ref<'idle' | 'drafting' | 'saving'>('idle')
const error = ref('')
async function genDraft() {
if (!sentence.value.trim() || busy.value !== 'idle' || draftInflight.value) return
busy.value = 'drafting'; error.value = ''
try {
draft.value = await draftHypothesis(sentence.value.trim())
} catch (e: any) {
error.value = e?.response?.data?.detail ?? e?.message ?? '生成失败(LLM 未配置或返回异常)'
} finally { busy.value = 'idle' }
}
async function confirmSave() {
if (!draft.value || busy.value !== 'idle') return
busy.value = 'saving'; error.value = ''
try {
const item = await saveHypothesis(draft.value, sentence.value.trim())
all.value = [item, ...all.value.filter((h) => h.id !== item.id)]
draft.value = null; sentence.value = ''
} catch (e: any) {
error.value = e?.response?.data?.detail ?? '落卡失败'
} finally { busy.value = 'idle' }
}
const router = useRouter()
const DECOMPOSABLE: Hypothesis['state'][] = ['queued', 'data_check', 'building']
const canDecompose = (h: Hypothesis): boolean => DECOMPOSABLE.includes(h.state)
// 决议O:部分死=卡留活跃池+角标☠n/N(n=死因子数,0 不显示)
const deadFactorCount = (h: Hypothesis): number =>
(h.factors || []).filter((f) => f.status === 'graveyard').length
// 在飞态=服务端真相(卡片 decomposeJob),前端只轮询不登记(10-09 job 化)
const isDecomposing = (h: Hypothesis): boolean => h.decomposeJob?.status === 'running'
// job 终态回放:完成后结果持久落卡(件④,取消一次性弹层)
const decomposeError = ref('')
let pollTimer: ReturnType<typeof setInterval> | null = null
let watchJobId: string | null = null
// —— D7 件①三件套的折叠/缓存态 ——
const openFac = ref<Record<string, boolean>>({}) // 1a 因子清单
const openBat = ref<Record<string, boolean>>({}) // 1b 批次历史
const openBatch = ref<Record<string, boolean>>({}) // 单批明细
const batchCache = ref<Record<string, DecomposeJob[]>>({})
function patchJob(hypId: string, job: DecomposeJob) {
const idx = all.value.findIndex((x) => x.id === hypId)
if (idx >= 0) {
all.value = [...all.value.slice(0, idx),
{ ...all.value[idx], decomposeJob: job },
...all.value.slice(idx + 1)]
}
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
function ensurePoll() {
if (pollTimer) return
pollTimer = setInterval(async () => {
const items = await getHypotheses().catch(() => null)
if (!items) return
all.value = items
if (watchJobId) {
const hit = items.find((i) => i.decomposeJob?.jobId === watchJobId)
if (hit?.decomposeJob && hit.decomposeJob.status !== 'running') {
settle(hit.id, hit.decomposeJob)
}
} else if (!items.some((i) => i.decomposeJob?.status === 'running')) {
stopPoll() // 无本页发起的 watch 且全站无在飞 → 停表
}
}, 4000)
}
async function refreshBatches(hypId: string): Promise<void> {
const rows = await getDecomposeJobs(hypId).catch(() => null)
if (rows) batchCache.value = { ...batchCache.value, [hypId]: rows }
}
function settle(hypId: string, job: DecomposeJob) {
watchJobId = null
if (job.status === 'completed') {
decomposeError.value = ''
// 完成即展开产物区(mockup 同款「长出」),批次缓存失效重拉
openFac.value = { ...openFac.value, [hypId]: true }
openBat.value = { ...openBat.value, [hypId]: true }
delete batchCache.value[hypId]
batchCache.value = { ...batchCache.value }
refreshBatches(hypId)
} else {
decomposeError.value = job.error || '分解失败'
}
stopPoll()
}
async function runDecompose(h: Hypothesis): Promise<void> {
if (isDecomposing(h) || !canDecompose(h)) return
decomposeError.value = ''
try {
const job = await startDecompose(h.id)
watchJobId = job.jobId
patchJob(h.id, job)
ensurePoll()
} catch (e: any) {
const status = e?.response?.status
const detail = e?.response?.data?.detail ?? e?.message ?? ''
if (status === 503) {
decomposeError.value = `LLM 未配置——${detail}`
} else if (status === 409) {
// 409 双义:「在飞(后台任务)」原文直出 /「状态不可分解」带前缀
decomposeError.value = detail.includes('进行中')
? detail : `当前状态不可分解——${detail}`
} else {
decomposeError.value = detail || '分解启动失败'
}
}
}
function onDraftInput(key: keyof CardDraftInput, v: string) {
if (!draft.value) return
if (key === 'dataNeeds') {
draft.value.dataNeeds = v.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
} else {
(draft.value as any)[key] = v
}
}
// —— 折叠交互 ——
function toggleFac(h: Hypothesis): void {
openFac.value = { ...openFac.value, [h.id]: !openFac.value[h.id] }
}
async function toggleBat(h: Hypothesis): Promise<void> {
const open = !openBat.value[h.id]
openBat.value = { ...openBat.value, [h.id]: open }
if (open && !batchCache.value[h.id]) await refreshBatches(h.id)
}
function toggleBatch(jobId: string): void {
openBatch.value = { ...openBatch.value, [jobId]: !openBatch.value[jobId] }
}
function fmtTime(iso: string | null): string {
return iso ? iso.slice(5, 16).replace('T', ' ') : '—'
}
function progText(h: Hypothesis): string {
const p = h.decomposeJob?.progress
if (!p) return '生成候选中(≤8 条)…'
return `第 ${p.currentRound}/${p.totalRounds} 轮 · 通过 ${p.passed} · 重生成 ${p.regen}`
}
</script>
<template>
<div class="pl-page">
<PanelCard title="假设池" :sub="`输入即持久工件 · 池内 ${pool.length} 张 · 墓园 ${graveyard.length} 张(负结果留档防重复造轮)`">
<template #action>
<div class="tabs">
<button class="tab-btn" :class="{ on: tab === 'pool' }" @click="tab = 'pool'">活跃池</button>
<button class="tab-btn" :class="{ on: tab === 'graveyard' }" @click="tab = 'graveyard'">墓园</button>
</div>
</template>
<!-- 向导区(决议 M:一句话→五字段草稿→人工确认落卡;LLM 不落库) -->
<div class="wizard">
<div class="wiz-row">
<input v-model="sentence" class="wiz-input" maxlength="500"
placeholder="一句话描述投资假设,例:高管增持之后股价会涨" />
<button class="wiz-btn" :disabled="busy !== 'idle' || draftInflight || !sentence.trim()"
@click="genDraft">{{ busy === 'drafting' || draftInflight ? '生成中…' : 'AI 生成草稿' }}</button>
</div>
<div v-if="error" class="wiz-error">{{ error }}</div>
<div v-if="draft" class="wiz-form">
<label>假设陈述 <input class="wiz-input" :value="draft.title"
@input="onDraftInput('title', ($event.target as HTMLInputElement).value)" /></label>
<label>依据(条件句式) <input class="wiz-input" :value="draft.logic"
@input="onDraftInput('logic', ($event.target as HTMLInputElement).value)" /></label>
<label>预期方向
<select class="wiz-input" :value="draft.expectedSign"
@change="onDraftInput('expectedSign', ($event.target as HTMLSelectElement).value)">
<option value="positive">positive(正向)</option>
<option value="negative">negative(反向)</option>
</select>
</label>
<label>可证伪声明 <input class="wiz-input" :value="draft.falsifiable"
@input="onDraftInput('falsifiable', ($event.target as HTMLInputElement).value)" /></label>
<label>关联数据域(逗号分隔) <input class="wiz-input" :value="draft.dataNeeds.join(',')"
@input="onDraftInput('dataNeeds', ($event.target as HTMLInputElement).value)" /></label>
<div class="wiz-row">
<button class="wiz-btn primary" :disabled="busy !== 'idle'"
@click="confirmSave">{{ busy === 'saving' ? '落卡中…' : '确认落卡' }}</button>
<button class="wiz-btn" @click="draft = null">放弃草稿</button>
</div>
</div>
</div>
<div v-if="decomposeError" class="dec-error">
{{ decomposeError }}
<a v-if="decomposeError.includes('LLM 未配置')" class="dec-link"
@click="router.push('/pipeline/settings')">去设置页配置 →</a>
</div>
<div class="cards">
<div v-for="h in shown" :key="h.id" class="hcard" :class="{ dead: tab === 'graveyard' }">
<div class="hcard-head">
<span class="hcard-title">{{ h.title }}</span>
<StatusChip :label="HYPOTHESIS_LABEL[h.state]" :tone="HYPOTHESIS_TONE[h.state]" />
<span v-if="deadFactorCount(h)" class="skull-badge"
:title="`卡下 ${deadFactorCount(h)}/${h.factors!.length} 因子已判死(卡留活跃池)`">
☠{{ deadFactorCount(h) }}/{{ h.factors!.length }}
</span>
</div>
<div class="hcard-row"><span class="k">来源</span><span>{{ h.source }}</span></div>
<div class="hcard-row"><span class="k">逻辑</span><span>{{ h.logic }}</span></div>
<div class="hcard-row"><span class="k">证伪</span><span>{{ h.falsifiable || '—' }}</span></div>
<div class="hcard-row"><span class="k">数据</span><span>{{ h.dataNeeds.length ? h.dataNeeds.join(' · ') : '—' }}</span></div>
<!-- 件①1c:在飞轮次进度(服务端内存态,4s 轮询) -->
<div v-if="isDecomposing(h)" class="prog">
<span class="spin"></span><span class="ptxt">{{ progText(h) }}</span>
<span class="pnote">后台执行 · 可离开本页</span>
</div>
<!-- 终态摘要行(件④:持久,刷新不丢) -->
<div v-else-if="h.decomposeJob && h.decomposeJob.status === 'completed'" class="hcard-row">
<span class="k">分解</span>
<span>{{ h.decomposeJob.rounds }} 轮 · 通过 {{ h.decomposeJob.registered.length }} /
拒 {{ h.decomposeJob.failed.length }} · {{ fmtTime(h.decomposeJob.finishedAt) }}</span>
</div>
<div v-else-if="h.decomposeJob && h.decomposeJob.status === 'failed'" class="hcard-row">
<span class="k">分解</span>
<span class="bad">失败:{{ h.decomposeJob.error ?? '' }}</span>
</div>
<!-- 件①1a:已产因子清单(registry 血统反查;单因子名 factorId 已退役) -->
<div v-if="h.factors?.length" class="hsec">
<div class="sec-head" @click="toggleFac(h)">
<span class="arr">{{ openFac[h.id] ? '▾' : '▸' }}</span>
<b>已产因子</b>{{ h.factors.length }} 条
<span class="hint">点名进工厂 · 血统=registry 反查</span>
</div>
<div v-if="openFac[h.id]" class="sec-body">
<template v-for="(f, i) in h.factors" :key="f.name">
<!-- 批次分割线:批次切换处插行;首条无批次而清单非空=早期段 -->
<div v-if="f.batch && (i === 0 || h.factors[i - 1].batch?.jobId !== f.batch.jobId)"
class="fac-batch">
── 第 {{ f.batch.startedAt.slice(5, 16).replace('T', ' ') }} 批 · {{ f.batch.jobId }} ──
</div>
<div v-else-if="!f.batch && (i === 0 || h.factors[i - 1].batch)" class="fac-batch early">
── 早期(无批次记录) ──
</div>
<div class="fac-row">
<span v-if="f.status === 'graveyard'" class="fac-name dead"
:title="`☠ ${f.cause ?? '未注死因'}`">☠ {{ f.name }}</span>
<span v-else class="fac-name" title="看出生档案"
@click="router.push(`/pipeline/factors/${f.name}`)">{{ f.name }}</span>
<code class="fac-expr">{{ f.expression }}</code>
<span class="fac-date">{{ f.createdAt }}</span>
</div>
</template>
</div>
</div>
<!-- 件①1b:分解批次历史(decompose_jobs 台账,按需拉取) -->
<div v-if="h.factors?.length || (h.decomposeJob && h.state !== 'queued')" class="hsec">
<div class="sec-head" @click="toggleBat(h)">
<span class="arr">{{ openBat[h.id] ? '▾' : '▸' }}</span>
<b>分解批次</b>{{ batchCache[h.id]?.length ?? '…' }} 批
<span class="hint">台账新→旧 · 点批次看明细</span>
</div>
<div v-if="openBat[h.id]" class="sec-body">
<div v-if="!batchCache[h.id]?.length" class="hint">(无批次记录)</div>
<div v-for="job in batchCache[h.id]" :key="job.jobId" class="batch">
<div class="batch-h" @click="toggleBatch(job.jobId)">
<b class="bt">{{ fmtTime(job.startedAt) }}</b>
<span :class="job.status === 'completed' ? 'okn' : 'badn'">
{{ job.status === 'completed' ? '完成' : '失败' }}</span>
<span>通过 {{ job.registered.length }} / 拒 {{ job.failed.length }}</span>
<span class="dim">{{ job.rounds ?? '—' }} 轮</span>
<span class="bt jid">{{ job.jobId }}</span>
<span class="arr">{{ openBatch[job.jobId] ? '▴' : '▾' }}</span>
</div>
<div v-if="openBatch[job.jobId]" class="batch-b">
<div v-if="job.error" class="bad">{{ job.error }}</div>
<div v-if="job.registered.length" class="bmem">
✅ 注册(incubating):
<span v-for="c in job.registered" :key="c.name" class="bm"
:title="c.expression">{{ c.name }}</span>
</div>
<div v-if="job.failed.length" class="bmem">
❌ 未过硬门(不落库):
<span v-for="(f, i) in job.failed" :key="i" class="bm bad-bm"
:title="f.violations?.map((v) => `${v.code}:${v.detail}`).join(';')">
{{ f.name ?? '(未命名)' }}</span>
</div>
<div v-if="job.failed.length" class="bviol">
<div v-for="(f, i) in job.failed" :key="i">
{{ f.name ?? '(未命名)' }}:<template v-for="(v, j) in f.violations" :key="j">{{ v.code }}:{{ v.detail }} </template>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="h.deathReason" class="hcard-death">☠ {{ h.deathReason }}</div>
<div v-if="canDecompose(h) || isDecomposing(h)" class="hcard-ops">
<button class="wiz-btn" :disabled="isDecomposing(h)"
@click="runDecompose(h)">
{{ isDecomposing(h) ? '分解中(后台执行,可离开本页)…' : '⚡ AI 分解' }}
</button>
</div>
<div class="hcard-foot">{{ h.updatedAt }}</div>
</div>
</div>
<!-- 因子墓园(判死因子档案:registry graveyard 条目,死因可检索防重复造轮) -->
<div v-if="tab === 'graveyard'" class="gfac">
<div class="gfac-head">
☠ 判死因子 {{ deadFactors.length }} 条
<span class="hint">注册表档案 · 不上工厂板 · 不可复活</span>
</div>
<div v-if="!deadFactors.length" class="hint">(暂无判死因子——工厂详情页「送墓园」与考不过的夜班批会进这里)</div>
<div v-for="f in deadFactors" :key="f.name" class="gfac-row">
<span class="fac-name mono">{{ f.name }}</span>
<span class="dim">{{ f.hypothesis || '无假设卡(存量/早期)' }}</span>
<span class="gfac-cause">{{ f.cause }}</span>
</div>
</div>
</PanelCard>
</div>
</template>
<style scoped>
.pl-page { max-width: 1080px; margin: 0 auto; }
.tabs { display: flex; gap: 6px; }
.tab-btn { background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-3); font-family: var(--mono); font-size: 11px; padding: 3px 12px; cursor: pointer; transition: all 0.15s var(--ease); }
.tab-btn.on { color: var(--brand); border-color: rgba(0, 229, 255, 0.5); background: var(--cyan-soft); }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
.hcard { border: 1px solid var(--border); border-radius: var(--r-sm); padding: 10px 12px; display: flex; flex-direction: column; gap: 5px; }
.hcard.dead { opacity: 0.75; }
.hcard-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.hcard-title { font-size: 13px; color: var(--text); font-weight: 600; }
.hcard-row { display: flex; gap: 8px; font-size: 11.5px; }
.hcard-row .k { color: var(--text-3); font-family: var(--mono); flex-shrink: 0; width: 34px; }
.hcard-death { font-size: 11px; color: #ff5f6d; border-top: 1px dashed var(--border); padding-top: 5px; }
/* 因子墓园区(判死因子档案) */
.gfac { margin-top: 10px; border-top: 1px dashed var(--border); padding-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.gfac-head { font-size: 12px; color: var(--text-2); font-weight: 600; display: flex; gap: 8px; align-items: baseline; }
.gfac-row { display: flex; gap: 10px; font-size: 11.5px; align-items: baseline; flex-wrap: wrap; }
.gfac-row .fac-name { color: var(--text-3); text-decoration: line-through; text-decoration-color: rgba(255, 95, 109, 0.5); }
.gfac-cause { color: #ff8a94; flex: 1; min-width: 200px; }
.hint { color: var(--text-3); font-size: 11px; }
.hcard-foot { font-size: 10px; color: var(--text-3); font-family: var(--mono); text-align: right; }
.mono { font-family: var(--mono); }
.wizard { border: 1px dashed var(--border); border-radius: var(--r-sm); padding: 10px 12px; margin-bottom: 12px; display: flex; flex-direction: column; gap: 8px; }
.wiz-row { display: flex; gap: 8px; }
.wiz-input { flex: 1; background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); font-size: 12px; padding: 6px 8px; }
.wiz-btn { background: var(--cyan-soft); border: 1px solid rgba(0, 229, 255, 0.5); border-radius: var(--r-sm); color: var(--brand); font-size: 12px; padding: 6px 14px; cursor: pointer; }
.wiz-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.wiz-btn.primary { background: rgba(0, 229, 255, 0.18); }
.wiz-error { font-size: 11px; color: #ff5f6d; }
.wiz-form { display: flex; flex-direction: column; gap: 6px; }
.wiz-form label { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--text-3); flex-shrink: 0; }
.hcard-ops { display: flex; justify-content: flex-end; }
.dec-error { font-size: 11.5px; color: #ff5f6d; margin-bottom: 10px; }
.dec-link { color: var(--brand); cursor: pointer; margin-left: 8px; text-decoration: underline; }
/* —— D7 件①三件套 —— */
.prog { border: 1px solid rgba(0, 229, 255, 0.35); background: var(--cyan-soft); border-radius: var(--r-sm); padding: 6px 10px; font-size: 11.5px; display: flex; align-items: center; gap: 8px; }
.spin { width: 11px; height: 11px; border: 2px solid rgba(0, 229, 255, 0.5); border-top-color: transparent; border-radius: 50%; animation: d7spin 1s linear infinite; flex-shrink: 0; }
@keyframes d7spin { to { transform: rotate(360deg); } }
.prog .ptxt { color: var(--brand); font-family: var(--mono); }
.prog .pnote { margin-left: auto; color: var(--text-3); font-size: 10.5px; }
.hsec { border-top: 1px dashed var(--border); padding-top: 5px; }
.sec-head { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 11.5px; color: var(--text-2); user-select: none; flex-wrap: wrap; }
.sec-head:hover { color: var(--text); }
.sec-head b { color: var(--text); }
.sec-head .arr { font-size: 10px; color: var(--text-3); }
.sec-head .hint { color: var(--text-3); font-size: 10.5px; }
.sec-body { padding: 6px 0 2px; }
.fac-row { display: flex; align-items: baseline; gap: 8px; padding: 2.5px 0; font-size: 11px; border-bottom: 1px solid var(--border); }
.fac-row:last-child { border-bottom: none; }
.fac-name { font-family: var(--mono); color: var(--brand); cursor: pointer; white-space: nowrap; }
.fac-name:hover { text-decoration: underline; }
.fac-name.dead { text-decoration: line-through; color: var(--text-3); cursor: default; }
.skull-badge { font-family: var(--mono); font-size: 10px; color: #ff5f6d;
border: 1px solid rgba(255, 95, 109, 0.4); border-radius: var(--r-sm);
padding: 0 5px; line-height: 16px; flex-shrink: 0; }
.fac-expr { flex: 1; min-width: 0; font-family: var(--mono); font-size: 10px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fac-date { color: var(--text-3); font-size: 10px; white-space: nowrap; }
.fac-batch { font-family: var(--mono); font-size: 9.5px; color: var(--cyan-dim); border-top: 1px dashed rgba(0, 184, 204, 0.35); margin-top: 4px; padding-top: 3px; }
.fac-batch:first-child { border-top: none; margin-top: 0; padding-top: 0; }
.fac-batch.early { color: var(--text-3); border-top-color: var(--border); }
.batch { border: 1px solid var(--border); border-radius: var(--r-sm); margin-bottom: 5px; }
.batch-h { display: flex; align-items: center; gap: 7px; padding: 5px 8px; cursor: pointer; font-size: 11px; flex-wrap: wrap; }
.batch-h:hover { background: var(--cyan-soft); }
.batch-h .bt { font-family: var(--mono); color: var(--text-2); font-size: 10.5px; }
.batch-h .jid { margin-left: auto; color: var(--text-3); font-size: 9.5px; }
.batch-h .arr { color: var(--text-3); font-size: 10px; }
.batch-b { border-top: 1px solid var(--border); padding: 6px 8px; font-size: 11px; display: flex; flex-direction: column; gap: 4px; }
.bmem { display: flex; flex-wrap: wrap; gap: 4px; align-items: baseline; color: var(--text-3); }
.bm { font-family: var(--mono); font-size: 10px; color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.3); border-radius: 4px; padding: 0 6px; line-height: 16px; }
.bm.bad-bm { color: var(--text-3); border-color: var(--border); text-decoration: line-through; }
.bviol { color: var(--text-3); font-size: 10.5px; }
.okn { color: #2ee68a; }
.badn { color: #ff5f6d; }
.bad { color: #ff5f6d; }
.dim { color: var(--text-3); }
</style>