feat(api+frontend): D7 落成边亮灯——假设→因子边可见性三处 [vps]
CI/CD / test (push) Successful in 40s
CI/CD / nas-deploy (push) Successful in 30s
CI/CD / nas-verify (push) Successful in 9s

体验稿(8823)逐条过审拍板后动工,不造新机器只做可见性:

件① 三件套(spec §7):
- 1a 卡片已产因子清单:列表项内联 factors(registry hypothesis
  血统反查:名/表达式/origin/落成日);单因子名 factorId 前端退役
- 1b 分解批次历史:GET /pipeline/hypotheses/{id}/decompose-jobs
  台账新→旧(QA tasks/list 端点形状照抄),卡片折叠区按需拉取
- 1c 轮次进度:run_decompose 每轮 on_progress 回调→内存 job
  progress(currentRound/totalRounds/passed/regen;真轮数非 QA 摆设),
  台账仍只记起跑+终态;worker 协议改收 job_id

件② 工厂来源列:factors 端点带 origin/hypothesis 血统,
⚡decomposer/✍manual 徽标+回链假设池

件③ 琥珀待办:todos 聚合加 queued/data_check 卡(已确认未分解),
分解转 building 即消行——人工卡点②显性化

件④ 结果持久落卡:前端一次性弹层退役,终态摘要+清单+批次全在卡

测试:后端 TestD7Visibility 六件(历史端点/内联清单/进度中飞可见/
终态不带过程态/琥珀消行/工厂血统)+前端三件套三测;8 目录 2780 绿
This commit is contained in:
2026-10-09 11:51:00 +08:00
parent d92492a554
commit 877723ab4a
11 changed files with 521 additions and 72 deletions
@@ -121,3 +121,4 @@ spec 逐节拍板 → writing-plans 出实施计划 → 分域派单:②权重
- 2026-10-08 v0.5 初稿落档(81adfbc,与调研报告同 commit;备案 69cff60 补挂 [nas] 标签)
- 2026-10-08 晚:D7 定案(落成边亮灯——「机器在灯没开」定谳 + 可见性四件 + 出厂验收线 + 供给源可插拔);新增 §7,原 §7-§10 顺延为 §8-§11;QuantaAlpha 因子出生边调研报告入库。同日用户 VPS 实测「AI 生成草稿」报 LLM 上游异常——修复先行于改造(调通后按 spec 对比验收)
- 2026-10-09:LLM 链两连修上产(端点错配 85d14a1 + client 全流式化 acc273b,QA 同款);**生产首批 origin:decomposer 因子落册**(高管增持卡,D7 零实绩破局);终局诊断=响应孤儿化(服务端成功+浏览器断连无感知),治本=**分解 job 化**(QA 任务字典范式:202 立返+后台任务+卡片 decomposeJob 轮询+decompose_jobs 台账表)——§7 件④地基落,总设计 §13 P5.3 同步;生产全程验证通过(job-20261009101513:202 秒回→3 轮 2m43s→8 因子 0 失败);件①扩为三件套(产物清单+批次历史+轮次进度,QA 调研:progress 字段结构/tasks-list 端点照抄,currentRound 摆设我们用真轮数,WS 直播归 D2)
- 2026-10-09 晚:**D7 真实现落地**(8823 体验稿逐条过审拍板后动工):件①1a=列表项内联 `factors` 清单(registry hypothesis 血统反查:名/表达式/origin/落成日;卡片单因子名 factorId 前端退役);1b=新端点 `GET /pipeline/hypotheses/{id}/decompose-jobs`(台账新→旧,QA tasks/list 形状)+卡片折叠区按需拉取;1c=`run_decompose` 每轮 `on_progress` 回调→内存 job `progress`(currentRound/totalRounds/passed/regen/message;台账仍只记起跑+终态),worker 协议改收 job_id;件②=factors 端点带 origin/hypothesis+工厂「来源」列(⚡decomposer/✍manual 徽标+回链假设池);件③=todos 聚合加 queued/data_check 卡琥珀待办(分解转 building 即消行,人工卡点②显性化);件④=前端一次性结果弹层退役,结果持久落卡。测试=后端 TestD7Visibility 六件+前端三件套三测
+1 -1
View File
@@ -8,7 +8,7 @@ describe('api/pipeline 契约', () => {
api.getCompositeVersions, api.getLadder, api.getAttribution,
api.getResearchCard, api.getIcTrend,
api.getLlmConfig, api.updateLlmConfig, api.testLlmConnection,
api.startDecompose,
api.startDecompose, api.getDecomposeJobs,
api.getEnvStatus, api.getMonthlyStatus,
]) {
expect(typeof fn).toBe('function')
+29
View File
@@ -24,6 +24,16 @@ export interface Hypothesis {
factorId?: string
deathReason?: string
decomposeJob?: DecomposeJob | null
factors?: HypothesisFactor[]
}
// D7 件①1a:卡片已产因子清单(registry hypothesis 血统反查,内联于列表)
export interface HypothesisFactor {
name: string
expression: string | null
origin: string
createdAt: string | null
status: string
}
export type FactorStatus = 'incubating' | 'assessable' | 'promoted' | 'decaying' | 'retired'
@@ -33,6 +43,8 @@ export interface PipelineFactor {
category: 'fundamental' | 'pledge' | 'research' | 'sentiment' | 'technical'
version: string
status: FactorStatus
origin?: string // D7 件②:decomposer/manual 身份戳
hypothesis?: string | null // 来源假设卡回链血统
icRecentT: number | null
icFullT: number | null
promotedAtT: number | null
@@ -317,6 +329,16 @@ export interface DecomposeJob {
error: string | null
startedAt: string | null
finishedAt: string | null
// D7 件①1c:在飞轮次进度(内存态,终态为 null/缺省)
progress?: DecomposeProgress | null
}
export interface DecomposeProgress {
currentRound: number
totalRounds: number
passed: number
regen: number
message: string
}
// 分解 job 化(10-09,QA 范式):POST 立返 202+后台任务,浏览器不再扛
@@ -328,6 +350,13 @@ export async function startDecompose(id: string): Promise<DecomposeJob> {
return data.job
}
// D7 件①1b:该卡分解批次历史(台账新→旧,卡片折叠区按需拉取)
export async function getDecomposeJobs(id: string): Promise<DecomposeJob[]> {
const { data } = await apiClient.get<{ items: DecomposeJob[] }>(
`/pipeline/hypotheses/${id}/decompose-jobs`)
return data.items
}
// —— P5.2/P5.6 设置页状态卡(只读,spec §13)——
export interface EnvStatusItem {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getFactors, getMonthlyReview, getIcTrend } from '@/api/pipeline'
import type { PipelineFactor, MonthlyReview, IcTrend } from '@/api/pipeline'
import StatusChip from '@/components/pipeline/StatusChip.vue'
@@ -7,6 +8,8 @@ import { FACTOR_LABEL, FACTOR_TONE } from '@/components/pipeline/tones'
import PanelCard from './PanelCard.vue'
import IcTrendChart from './IcTrendChart.vue'
const router = useRouter()
const factors = ref<PipelineFactor[]>([])
const review = ref<MonthlyReview | null>(null)
const trend = ref<IcTrend | null>(null)
@@ -51,6 +54,15 @@ const fmt = (v: number | null): string => (v === null ? '—' : v.toFixed(2))
<template #default="{ row }"><span class="mono" :class="{ sel: row.id === trendFactor }">{{ row.id }}</span>@{{ row.version }}</template>
</el-table-column>
<el-table-column prop="category" label="域" width="100" />
<!-- D7 件②:origin 身份戳+来源卡回链(⚡=AI 分解注册/✍=人工注册) -->
<el-table-column label="来源" width="130">
<template #default="{ row }">
<span v-if="row.origin === 'decomposer'" class="ob dec" title="AI 分解器自动注册">⚡ decomposer</span>
<span v-else class="ob man" title="人工实现注册">✍ manual</span>
<a v-if="row.hypothesis" class="src-link"
@click.stop="router.push('/pipeline/hypotheses')">{{ row.hypothesis }} →</a>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tooltip v-if="row.status === 'assessable'" content="可判=够格被评审;近窗 t≥2 才会出现在晋级评审页" placement="top">
@@ -87,6 +99,12 @@ const fmt = (v: number | null): string => (v === null ? '—' : v.toFixed(2))
.kv .v.low { color: #ff5f6d; }
.mono { font-family: var(--mono); }
.mono.sel { color: var(--brand); }
/* D7 件②:origin 徽标+回链 */
.ob { display: inline-flex; align-items: center; font-size: 10.5px; border-radius: 5px; padding: 0 6px; line-height: 17px; white-space: nowrap; }
.ob.dec { color: var(--brand); border: 1px solid rgba(0, 229, 255, 0.45); background: var(--cyan-soft); }
.ob.man { color: var(--text-3); border: 1px solid var(--border); }
.src-link { font-size: 10.5px; color: var(--cyan-dim); cursor: pointer; margin-left: 6px; border-bottom: 1px dashed rgba(0, 184, 204, 0.4); }
.src-link:hover { color: var(--brand); }
.alert-txt { color: #ff5f6d; font-family: var(--mono); font-size: 11.5px; }
.dim { color: var(--text-3); }
</style>
@@ -8,7 +8,7 @@ import { hypothesesMock } from '@/mock/pipelineData'
vi.mock('@/api/pipeline', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/pipeline')>()
return { ...actual, getHypotheses: vi.fn(), draftHypothesis: vi.fn(), saveHypothesis: vi.fn() }
return { ...actual, getHypotheses: vi.fn(), draftHypothesis: vi.fn(), saveHypothesis: vi.fn(), getDecomposeJobs: vi.fn() }
})
describe('HypothesisPool.vue', () => {
@@ -89,3 +89,72 @@ describe('向导区(P4-1)', () => {
expect(w.find('.wiz-form').exists()).toBe(false)
})
})
describe('D7 三件套(件①)', () => {
const HYP = {
id: 'hyp-d7', title: '高管增持之后股价会涨', source: 'wizard',
logic: 'l', expectedSign: 'positive', falsifiable: 'f',
dataNeeds: ['fundamentals_pit'], state: 'building',
updatedAt: '2026-10-09', factorId: 'insider_buy_decay60',
decomposeJob: {
jobId: 'job-x', status: 'completed', rounds: 3,
registered: [{ name: 'a', source: 'fundamentals_pit', expression: 'e', justification: 'j' }],
failed: [], error: null, startedAt: '2026-10-09T10:15:13',
finishedAt: '2026-10-09T10:17:56',
},
factors: [
{ name: 'insider_buy_decay60', expression: 'zscore(ts_mean(x,60))', origin: 'decomposer', createdAt: '2026-10-09', status: 'incubating' },
],
} as unknown as api.Hypothesis
beforeEach(() => vi.clearAllMocks())
it('卡片渲染因子清单(血统反查)与终态摘要行', async () => {
vi.mocked(api.getHypotheses).mockResolvedValue([HYP])
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('已产因子') // 折叠头
expect(w.text()).toContain('1 条') // 计数
expect(w.text()).toContain('3 轮 · 通过 1') // 终态摘要
const head = w.findAll('.sec-head').find((s) => s.text().includes('已产因子'))
await head!.trigger('click')
await flushPromises()
expect(w.text()).toContain('insider_buy_decay60')
expect(w.text()).toContain('zscore(ts_mean(x,60))')
})
it('展开批次历史按需拉取台账并渲染明细', async () => {
vi.mocked(api.getHypotheses).mockResolvedValue([HYP])
vi.mocked(api.getDecomposeJobs).mockResolvedValue([
{ jobId: 'job-x', status: 'completed', rounds: 3,
registered: HYP.decomposeJob!.registered,
failed: [{ name: 'bad_f', violations: [{ code: 'WHITELIST', detail: '列不在白名单' }] }],
error: null, startedAt: '2026-10-09T10:15:13', finishedAt: '2026-10-09T10:17:56' },
])
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
await flushPromises()
const head = w.findAll('.sec-head').find((s) => s.text().includes('分解批次'))
await head!.trigger('click')
await flushPromises()
expect(api.getDecomposeJobs).toHaveBeenCalledWith('hyp-d7')
expect(w.text()).toContain('job-x')
expect(w.text()).toContain('通过 1 / 拒 1')
// 点批次行展开明细
await w.find('.batch-h').trigger('click')
await flushPromises()
expect(w.text()).toContain('WHITELIST')
})
it('在飞卡片渲染轮次进度行(服务端 progress)', async () => {
const running = { ...HYP, decomposeJob: {
jobId: 'job-run', status: 'running', rounds: null, registered: [],
failed: [], error: null, startedAt: '2026-10-09T11:00:00', finishedAt: null,
progress: { currentRound: 2, totalRounds: 3, passed: 6, regen: 1, message: '第 2/3 轮校验完成' },
} } as unknown as api.Hypothesis
vi.mocked(api.getHypotheses).mockResolvedValue([running])
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.find('.prog').exists()).toBe(true)
expect(w.text()).toContain('第 2/3 轮 · 通过 6 · 重生成 1')
})
})
+151 -53
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getHypotheses, draftHypothesis, saveHypothesis, startDecompose, draftInflight } from '@/api/pipeline'
import { getHypotheses, draftHypothesis, saveHypothesis, startDecompose,
getDecomposeJobs, draftInflight } from '@/api/pipeline'
import type { Hypothesis, CardDraftInput, DecomposeJob } from '@/api/pipeline'
import StatusChip from '@/components/pipeline/StatusChip.vue'
import { HYPOTHESIS_LABEL, HYPOTHESIS_TONE } from '@/components/pipeline/tones'
@@ -53,12 +54,17 @@ const canDecompose = (h: Hypothesis): boolean => DECOMPOSABLE.includes(h.state)
// 在飞态=服务端真相(卡片 decomposeJob),前端只轮询不登记(10-09 job 化)
const isDecomposing = (h: Hypothesis): boolean => h.decomposeJob?.status === 'running'
// job 终态回放:title 随快照存(job 视图不带卡片标题)
const decomposeResult = ref<{ job: DecomposeJob; title: string } | null>(null)
// 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) {
@@ -79,22 +85,32 @@ function ensurePoll() {
if (!items) return
all.value = items
if (watchJobId) {
const w = items.find((i) => i.decomposeJob?.jobId === watchJobId)?.decomposeJob
if (w && w.status !== 'running') settle(w)
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)
}
function settle(job: DecomposeJob) {
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 = ''
const title = all.value.find((x) => x.decomposeJob?.jobId === job.jobId)?.title ?? ''
decomposeResult.value = { job, title }
// 完成即展开产物区(mockup 同款「长出」),批次缓存失效重拉
openFac.value = { ...openFac.value, [hypId]: true }
openBat.value = { ...openBat.value, [hypId]: true }
delete batchCache.value[hypId]
batchCache.value = { ...batchCache.value }
refreshBatches(hypId)
} else {
decomposeResult.value = null
decomposeError.value = job.error || '分解失败'
}
stopPoll()
@@ -103,7 +119,6 @@ function settle(job: DecomposeJob) {
async function runDecompose(h: Hypothesis): Promise<void> {
if (isDecomposing(h) || !canDecompose(h)) return
decomposeError.value = ''
decomposeResult.value = null
try {
const job = await startDecompose(h.id)
watchJobId = job.jobId
@@ -132,6 +147,28 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
(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>
@@ -180,33 +217,6 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
<a v-if="decomposeError.includes('LLM 未配置')" class="dec-link"
@click="router.push('/pipeline/settings')">去设置页配置 →</a>
</div>
<div v-if="decomposeResult" class="dec-result">
<div class="dec-head">
分解完成 · {{ decomposeResult.title }}
({{ decomposeResult.job.rounds }} 轮 · 通过 {{ decomposeResult.job.registered.length }} /
失败 {{ decomposeResult.job.failed.length }})
<button class="dec-close" @click="decomposeResult = null">收起</button>
</div>
<div v-if="decomposeResult.job.registered.length" class="dec-sec">
✅ 已注册(incubating,进月度批评):
<div v-for="c in decomposeResult.job.registered" :key="c.name" class="dec-item">
<span class="mono dec-name">{{ c.name }}</span>
<span class="dec-src">{{ c.source }}</span>
<code class="dec-expr">{{ c.expression }}</code>
<div class="dec-why">{{ c.justification }}</div>
</div>
</div>
<div v-if="decomposeResult.job.failed.length" class="dec-sec">
❌ 未通过硬门(不落库):
<div v-for="(f, i) in decomposeResult.job.failed" :key="i" class="dec-item bad">
<span class="mono">{{ f.name ?? '(未命名)' }}</span>
<code v-if="f.expression" class="dec-expr">{{ f.expression }}</code>
<ul class="dec-viol">
<li v-for="(v, j) in f.violations" :key="j">{{ v.code }}:{{ v.detail }}</li>
</ul>
</div>
</div>
</div>
<div class="cards">
<div v-for="h in shown" :key="h.id" class="hcard" :class="{ dead: tab === 'graveyard' }">
<div class="hcard-head">
@@ -217,12 +227,77 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
<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>
<div class="hcard-row"><span class="k">因子</span><span class="mono">{{ h.factorId ?? '—' }}</span></div>
<div v-if="h.decomposeJob && h.decomposeJob.status !== 'running'" class="hcard-row">
<!-- 件①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.status === 'completed'
? `通过 ${h.decomposeJob.registered.length} / 失败 ${h.decomposeJob.failed.length} · ${h.decomposeJob.finishedAt ?? ''}`
: `失败:${h.decomposeJob.error ?? ''}` }}</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">
<div v-for="f in h.factors" :key="f.name" class="fac-row">
<span class="fac-name" title="进因子工厂"
@click="router.push('/pipeline/factors')">{{ f.name }}</span>
<code class="fac-expr">{{ f.expression }}</code>
<span class="fac-date">{{ f.createdAt }}</span>
</div>
</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">
@@ -265,15 +340,38 @@ function onDraftInput(key: keyof CardDraftInput, v: string) {
.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; }
.dec-result { border: 1px solid rgba(0, 229, 255, 0.35); border-radius: var(--r-sm); padding: 10px 12px; margin-bottom: 12px; font-size: 11.5px; }
.dec-head { font-family: var(--mono); color: var(--brand); margin-bottom: 8px; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
.dec-close { margin-left: auto; background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text-3); font-size: 10px; padding: 2px 8px; cursor: pointer; }
.dec-sec { color: var(--text-2); margin-bottom: 8px; }
.dec-item { border-top: 1px dashed var(--border); padding: 6px 0; display: flex; flex-direction: column; gap: 3px; }
.dec-item.bad { color: var(--text-3); }
.dec-name { color: var(--text); }
.dec-src { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
.dec-expr { font-family: var(--mono); font-size: 10.5px; color: var(--text-2); background: var(--bg); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 2px 6px; word-break: break-all; }
.dec-why { color: var(--text-3); }
.dec-viol { margin: 2px 0 0 16px; padding: 0; color: var(--text-3); }
/* —— 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-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; }
.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>
+19 -4
View File
@@ -41,9 +41,12 @@ def is_running(hyp_id: str) -> bool:
return job_id is not None and _JOBS.get(job_id, {}).get("status") == "running"
def start(hyp_id: str, worker: Callable[[], Awaitable[dict]],
def start(hyp_id: str, worker: Callable[[str], Awaitable[dict]],
db_path: str) -> dict[str, Any]:
"""登记 running 行(内存+台账)并起后台任务;同卡在飞由调用方前置拦(409)."""
"""登记 running 行(内存+台账)并起后台任务;同卡在飞由调用方前置拦(409).
worker 收 job_id(D7 件①1c:体内每轮回调 update_progress 写在飞进度).
"""
job: dict[str, Any] = {
"jobId": f"job-{datetime.now().strftime('%Y%m%d%H%M%S')}"
f"-{uuid.uuid4().hex[:8]}",
@@ -60,11 +63,22 @@ def start(hyp_id: str, worker: Callable[[], Awaitable[dict]],
return dict(job)
async def _run(job_id: str, worker: Callable[[], Awaitable[dict]],
def update_progress(job_id: str, *, round_no: int, total_rounds: int,
passed: int, regen: int, message: str) -> None:
"""在飞轮次进度(D7 件①1c,QA task.progress 同位):只写内存 job 字典,
台账仍只记起跑+终态(过程态不落库,与 QA 同纪律)."""
job = _JOBS.get(job_id)
if job is None or job.get("status") != "running":
return
job["progress"] = {"currentRound": round_no, "totalRounds": total_rounds,
"passed": passed, "regen": regen, "message": message}
async def _run(job_id: str, worker: Callable[[str], Awaitable[dict]],
db_path: str) -> None:
job = _JOBS[job_id]
try:
result = await worker()
result = await worker(job_id)
except LLMError as e:
# P3-8 同款不泄露:真因只进服务端日志,job.error 固定文案
logger.warning("llm decompose upstream error: %s", e)
@@ -79,6 +93,7 @@ async def _run(job_id: str, worker: Callable[[], Awaitable[dict]],
else:
job.update(status="completed", rounds=result["rounds"],
registered=result["registered"], failed=result["failed"])
job.pop("progress", None) # 终态不再带过程态
job["finishedAt"] = _now()
from sanguo_portfolio import pipeline_store
pipeline_store.upsert_decompose_job(db_path, job)
+12 -1
View File
@@ -106,13 +106,22 @@ async def run_decompose(client: LLMClient, card: dict, *,
existing_names: set[str],
library_exprs: dict[str, str],
unwired_domains: tuple[str, ...] = (),
max_rounds: int = MAX_ROUNDS) -> dict:
max_rounds: int = MAX_ROUNDS,
on_progress=None) -> dict:
"""反馈循环:初代→逐条硬校验→失败者渲染违规重生成→只重验新一轮.
名字批次内占有:通过者名字进 claimed 防同批重名(失败者名字释放可重用).
on_progress(D7 件①1c):每轮校验完以进程内真轮数回调(通过/重生成计数),
QA task.progress 字段结构同位但其 currentRound 是摆设(只初始化无更新)
——这里的数据源是循环本身.
"""
from sanguo_factor.factor_guard import check_candidate
def _report(msg: str, regen: int) -> None:
if on_progress is not None:
on_progress(round_no=rounds, total_rounds=max_rounds,
passed=len(passed), regen=regen, message=msg)
unwired = tuple(d for d in unwired_domains if d)
passed: list[dict] = []
failed: list[dict] = []
@@ -138,8 +147,10 @@ async def run_decompose(client: LLMClient, card: dict, *,
(newly_failed.append({**cand, "violations": violations})
if violations else passed.append(cand))
failed = newly_failed
_report(f"第 {rounds}/{max_rounds} 轮校验完成", len(failed))
if not failed or rounds >= max_rounds:
break
_report(f"第 {rounds}/{max_rounds} 轮失败者反馈重生成中", len(failed))
pending = normalize_candidates(await client.chat_json(
build_decompose_messages(card, feedback=failed,
existing_names=existing_names),
+59 -4
View File
@@ -373,6 +373,17 @@ def todos() -> dict:
"severity": "info", "updatedAt": now})
except Exception:
pass
# ⑤ 假设卡琥珀待办(D7 件③,人工卡点②显性化):已确认未分解的卡
# (queued/data_check=尚未成功过分解;分解成功即转 building 消行)
for it in pipeline_store.list_hypotheses(_pipeline_db()):
if it["state"] not in ("queued", "data_check"):
continue
items.append({"id": f"decompose-{it['id']}",
"touchpoint": "hypothesis",
"title": f"{it['title'][:24]}——已确认未分解",
"detail": "人工卡点②:你点「AI 分解」,机器落册孵化因子",
"path": "/pipeline/hypotheses", "severity": "warn",
"updatedAt": now})
order = {"critical": 0, "warn": 1, "info": 2}
items.sort(key=lambda i: order.get(i["severity"], 9))
return {"items": items}
@@ -437,6 +448,9 @@ def factors() -> dict:
items.append({"id": name, "category": _factor_category(source),
"version": str(versions[-1]["v"]) if versions else "1",
"status": e["status"],
# D7 件②:origin 身份戳+来源卡回链血统(manual=缺省)
"origin": e.get("origin") or "manual",
"hypothesis": e.get("hypothesis"),
"icRecentT": t, # 最近批(12M 滚动窗)即近窗 t
"icFullT": None, # eval_store 每批单窗 t,无全史批数据源,如实 null
"promotedAtT": e.get("promotion_t") or None,
@@ -881,6 +895,31 @@ def hypotheses_list() -> dict:
# 每卡附最新分解 job 视图(job 化后前端唯一轮询源;内存→台账→重启判 failed)
for it in items:
it["decomposeJob"] = decompose_jobs.view(it["id"], _pipeline_db())
# D7 件①1a:已产因子清单(registry hypothesis 血统反查)——卡上单因子名
# (首条代表作指针,历史包袱)由此退役;规模小直接内联,膨胀再改按需拉取
by_hyp: dict[str, list[dict[str, Any]]] = {}
try:
from sanguo_factor import version_registry as vr
reg = vr.load_registry(_ensure_factor_runtime_registry())
for name, e in reg["factors"].items():
hyp = e.get("hypothesis")
if not hyp:
continue
versions = e.get("versions") or []
expr = ((versions[0].get("params") or {}).get("expression")
if versions else None)
by_hyp.setdefault(hyp, []).append({
"name": name, "expression": expr,
"origin": e.get("origin") or "manual",
"createdAt": (versions[0].get("effective_from")
if versions else None),
"status": e.get("status")})
except Exception: # noqa: BLE001 registry 读失败不拖垮卡片列表
logger.warning("hypotheses factors 反查失败,如实空清单", exc_info=True)
for it in items:
fs = by_hyp.get(it["id"]) or []
fs.sort(key=lambda f: f["createdAt"] or "", reverse=True)
it["factors"] = fs
return {"items": items}
@@ -970,14 +1009,28 @@ async def hypotheses_decompose(hyp_id: str) -> dict:
except LLMConfigError as e:
raise HTTPException(503, str(e))
async def worker() -> dict:
return await _decompose_worker(hyp_id, config)
async def worker(job_id: str) -> dict:
return await _decompose_worker(hyp_id, config, job_id)
job = decompose_jobs.start(hyp_id, worker, _pipeline_db())
return {"job": job}
async def _decompose_worker(hyp_id: str, config) -> dict:
@router.get("/pipeline/hypotheses/{hyp_id}/decompose-jobs")
def hypotheses_decompose_jobs(hyp_id: str) -> dict:
"""该卡分解批次历史(D7 件①1b):台账新→旧,卡片折叠区按需拉取.
端点形状照抄 QA GET /api/v1/mining/tasks/list(app.py:553-557);
内存只有最新一条,历史=台账单一真相(进度过程态不入此列).
"""
items = pipeline_store.list_hypotheses(_pipeline_db())
if not any(i["id"] == hyp_id for i in items):
raise HTTPException(404, "卡片不存在")
return {"items": pipeline_store.list_decompose_jobs(
_pipeline_db(), hyp_id, limit=20)}
async def _decompose_worker(hyp_id: str, config, job_id: str | None = None) -> dict:
"""分解后台体:锁内重读卡片→LLM+硬门循环→通过者注册挂血统→卡片转 building.
零特权:分解产物与手写因子同注册表同求值器,月度批评经动态注册链
@@ -1022,7 +1075,9 @@ async def _decompose_worker(hyp_id: str, config) -> dict:
result = await _run_decompose(
LLMClient(config), card, existing_names=existing_names,
library_exprs=library_exprs, unwired_domains=unwired)
library_exprs=library_exprs, unwired_domains=unwired,
on_progress=(lambda **kw: decompose_jobs.update_progress(
job_id, **kw)) if job_id else None)
registered: list[dict[str, Any]] = []
if result["passed"]:
+23 -8
View File
@@ -340,9 +340,19 @@ def upsert_decompose_job(path: str, job: dict[str, Any]) -> None:
job.get("error"), job.get("startedAt"), job.get("finishedAt")))
def _decompose_row_to_job(d: dict[str, Any]) -> dict[str, Any]:
"""台账行→camelCase job 视图(list_hypotheses 同款转换纪律)."""
import json as _json
return {"jobId": d["id"], "hypId": d["hyp_id"], "status": d["status"],
"rounds": d["rounds"],
"registered": _json.loads(d["registered"] or "[]"),
"failed": _json.loads(d["failed"] or "[]"),
"error": d["error"], "startedAt": d["started_at"],
"finishedAt": d["finished_at"]}
def latest_decompose_job(path: str, hyp_id: str) -> dict[str, Any] | None:
"""该卡最新一条 job 行→camelCase 视图(list_hypotheses 同款转换纪律)."""
import json as _json
with _connect(path) as conn:
r = conn.execute(
"SELECT * FROM decompose_jobs WHERE hyp_id=? "
@@ -350,10 +360,15 @@ def latest_decompose_job(path: str, hyp_id: str) -> dict[str, Any] | None:
(hyp_id,)).fetchone()
if r is None:
return None
d = dict(r)
return {"jobId": d["id"], "hypId": d["hyp_id"], "status": d["status"],
"rounds": d["rounds"],
"registered": _json.loads(d["registered"] or "[]"),
"failed": _json.loads(d["failed"] or "[]"),
"error": d["error"], "startedAt": d["started_at"],
"finishedAt": d["finished_at"]}
return _decompose_row_to_job(dict(r))
def list_decompose_jobs(path: str, hyp_id: str,
limit: int = 20) -> list[dict[str, Any]]:
"""该卡分解批次历史,新→旧(D7 件①1b:卡片折叠区按需拉取的台账源)."""
with _connect(path) as conn:
rows = conn.execute(
"SELECT * FROM decompose_jobs WHERE hyp_id=? "
"ORDER BY started_at DESC, id DESC LIMIT ?",
(hyp_id, limit)).fetchall()
return [_decompose_row_to_job(dict(r)) for r in rows]
@@ -350,6 +350,144 @@ class TestDecomposeJobs:
assert row["status"] == "failed"
class TestD7Visibility:
"""D7 落成边亮灯(10-09 拍板):三件套数据面+工厂 origin+琥珀待办."""
def test_decompose_jobs_history_endpoint(self, client, tmp_path):
"""件①1b:批次历史端点=台账新→旧(QA tasks/list 同形状 {items})."""
from sanguo_portfolio import pipeline_store
db = pipeline_store.default_pipeline_db_path()
hyp = _seed_card(client)
for i, (jid, st) in enumerate([("job-old", "completed"),
("job-new", "completed")]):
pipeline_store.upsert_decompose_job(db, {
"jobId": jid, "hypId": hyp, "status": st, "rounds": 1,
"registered": [{"name": f"f{i}", "source": "bars_daily",
"expression": "e", "justification": "j"}],
"failed": [], "error": None,
"startedAt": f"2026-10-09T10:1{i}:00", "finishedAt": None})
r = client.get(f"/api/v1/pipeline/hypotheses/{hyp}/decompose-jobs")
assert r.status_code == 200
items = r.json()["items"]
assert [i["jobId"] for i in items] == ["job-new", "job-old"]
assert items[0]["registered"][0]["name"] == "f1"
# 不存在的卡 404
assert client.get("/api/v1/pipeline/hypotheses/hyp-x/decompose-jobs"
).status_code == 404
def test_hypotheses_list_includes_factors(self, client, fake_llm,
tmp_path, monkeypatch):
"""件①1a:列表项内联血统反查因子清单(名/表达式/origin/落成日)."""
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r1.yaml"))
_, queue = fake_llm
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
assert _wait_job(client, hyp)["status"] == "completed"
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
item = next(i for i in items if i["id"] == hyp)
assert [f["name"] for f in item["factors"]] == ["llm_mom20"]
assert item["factors"][0]["origin"] == "decomposer"
assert item["factors"][0]["expression"].startswith("cs_rank")
assert item["factors"][0]["createdAt"] # 落成日=首版 effective_from
def test_progress_reports_rounds_while_running(self, client, fake_llm,
tmp_path, monkeypatch):
"""件①1c:在飞卡片 decomposeJob.progress=进程内真轮数(非 QA 摆设)."""
import asyncio
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r3.yaml"))
hyp = _seed_card(client)
seen = []
async def slow(client_, card, **k):
cb = k.get("on_progress")
if cb:
cb(round_no=1, total_rounds=3, passed=3, regen=1,
message="第 1/3 轮校验完成")
await asyncio.sleep(0.15)
cb(round_no=2, total_rounds=3, passed=6, regen=1,
message="第 2/3 轮失败者反馈重生成中")
await asyncio.sleep(0.15)
seen.append(True)
return {"passed": [], "failed": [], "rounds": 3}
import sanguo_api.routes_pipeline as rp
monkeypatch.setattr(rp, "_run_decompose", slow)
r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/decompose")
assert r.status_code == 202
job = _poll_job(client, hyp)
assert job["status"] == "completed"
# 进度曾可观测(轮询窗口内读到 progress;0.15s×2>轮询间隔 50ms)
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
cur = next(i for i in items if i["id"] == hyp)["decomposeJob"]
assert cur.get("progress") is None # 终态不带过程态
def test_progress_visible_mid_flight(self, client, fake_llm, tmp_path,
monkeypatch):
"""件①1c 真断言:running 期间列表项 progress.currentRound 递增可见."""
import asyncio
import time
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r4.yaml"))
hyp = _seed_card(client)
async def slow(client_, card, **k):
cb = k.get("on_progress")
if cb:
cb(round_no=1, total_rounds=3, passed=3, regen=1,
message="第 1/3 轮校验完成")
await asyncio.sleep(0.4)
return {"passed": [], "failed": [], "rounds": 1}
import sanguo_api.routes_pipeline as rp
monkeypatch.setattr(rp, "_run_decompose", slow)
assert client.post(
f"/api/v1/pipeline/hypotheses/{hyp}/decompose").status_code == 202
got = None
for _ in range(80):
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
cur = next(i for i in items if i["id"] == hyp)["decomposeJob"]
if cur and cur.get("progress"):
got = cur["progress"]
break
time.sleep(0.02)
assert got and got["currentRound"] == 1 and got["totalRounds"] == 3
assert got["passed"] == 3 and got["regen"] == 1
assert _poll_job(client, hyp)["status"] == "completed"
def test_todos_amber_until_decomposed(self, client, fake_llm,
tmp_path, monkeypatch):
"""件③:queued 卡=琥珀待办;分解转 building 即消行."""
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r5.yaml"))
_, queue = fake_llm
hyp = _seed_card(client)
def _amber_titles():
return [t["title"] for t in client.get(
"/api/v1/pipeline/todos").json()["items"]
if t["id"] == f"decompose-{hyp}"]
assert _amber_titles(), "queued 卡应上琥珀待办"
queue.append(DECOMP_GOOD)
assert _wait_job(client, hyp)["status"] == "completed"
assert not _amber_titles(), "building 卡不应再上待办"
def test_factors_include_origin_and_hypothesis(self, client, fake_llm,
tmp_path, monkeypatch):
"""件②:工厂列表带 origin 身份戳+来源卡血统(manual 缺省)."""
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path / "r6.yaml"))
_, queue = fake_llm
queue.append(DECOMP_GOOD)
hyp = _seed_card(client)
assert _wait_job(client, hyp)["status"] == "completed"
items = client.get("/api/v1/pipeline/factors").json()["items"]
row = next(i for i in items if i["id"] == "llm_mom20")
assert row["origin"] == "decomposer" and row["hypothesis"] == hyp
"""P3-7: save 端点 source 白名单+上限(此前零白名单零上限,任意串入库)."""
class TestSaveSourceGuard:
"""P3-7: save 端点 source 白名单+上限(此前零白名单零上限,任意串入库)."""