增加口径目录页与试跑:入口表单、依赖说明和只读试跑

This commit is contained in:
ThearchyHelios
2026-11-03 19:43:39 +08:00
parent aeaca4ce69
commit 68af29a180
7 changed files with 389 additions and 4 deletions
+68 -1
View File
@@ -22,6 +22,7 @@
"""
from __future__ import annotations
import asyncio
from typing import Any
from datetime import datetime
@@ -32,7 +33,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import SessionLocal, get_session
from app.db.models import Workflow
from app.db.models import Run, Workflow
from app.db.models_process import ActionRecord
router = APIRouter(prefix="/api/ledger", tags=["ledger"])
@@ -283,3 +284,69 @@ async def caliber_descriptor(workflow_id: str, version: int, node_id: str,
if made is None:
raise HTTPException(404, "没有这份口径:工作流不存在、这一版没发布过或已退役,或者节点不是源头口径卡")
return made
class TrialIn(BaseModel):
"""试跑只收入口字段的值;没写的按口径里的缺省值补。"""
inputs: dict[str, Any] = Field(default_factory=dict)
@router.post("/calibers/{workflow_id}/{version}/{node_id}/trial")
async def trial_caliber(workflow_id: str, version: int, node_id: str, payload: TrialIn,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""按固定版本试跑一份口径:派生只读小图(取数 + 口径卡),跑一次探索运行,返回指标值和运行 id。
试跑不是正式出具:来源记 trial,不进使用统计、不自动出具;参数按入口 Schema 校验,缺的补缺省值。不能派生的口径
(输入不是查库结果里的格、取数模板引用了运行时变量……)照实回 409。最多等 30 秒;还没跑完就回 running,前端按
run_id 去看运行。"""
from app.api.deps import current_principal
from app.apps.forms import input_schema, validate_form
from app.engine.runner import run_manager
from app.ledger.derive import NotDerivable, caliber_probe, load_pinned
try:
pinned = await load_pinned(session, workflow_id, int(version), node_id)
except NotDerivable as e:
raise HTTPException(409, e.message) from None
fields = [dict(f) for f in pinned.entry_fields]
known = {str(f.get("name")) for f in fields}
unknown = sorted(str(k) for k in payload.inputs if str(k) not in known)
if unknown:
raise HTTPException(422, f"不认识的入口字段:{'、'.join(unknown)}")
values = {str(f["name"]): f.get("default") for f in fields if f.get("default") not in (None, "")}
values.update({str(k): v for k, v in payload.inputs.items()})
if errors := validate_form(input_schema(fields), values):
raise HTTPException(422, ";".join(str(e.get("message")) for e in errors[:5]))
graph = caliber_probe(pinned).graph
principal = current_principal()
run = await run_manager.start(
graph=graph, input_payload=values, run_class="exploratory", origin_kind="trial",
origin={"kind": "caliber_trial", "workflow_id": workflow_id, "version": int(version),
"node_id": node_id, "card": pinned.node_id},
started_by=principal.username)
row = None
for _ in range(300):
async with SessionLocal() as poll:
row = await poll.get(Run, run.id)
if row is None or row.status in ("succeeded", "failed", "cancelled"):
break
await asyncio.sleep(0.1)
out: dict[str, Any] = {"run_id": run.id, "status": row.status if row is not None else "unknown",
"output": row.output if row is not None else None,
"error": row.error if row is not None else None, "metrics": []}
if row is not None and row.status == "succeeded":
from app.api.evidence import _sealed, _sealed_cards
sealed = await _sealed(run.id)
cards = {nid: card for nid, _, card in _sealed_cards(sealed)}
card = cards.get(pinned.node_id) or next(iter(cards.values()), None)
if card is not None:
out["metrics"] = [
{k: m.get(k) for k in ("id", "name", "unit", "value", "rendered", "decimals", "format")
if m.get(k) is not None}
for m in card.get("metrics") or [] if isinstance(m, dict) and m.get("id")
]
return out
+55
View File
@@ -0,0 +1,55 @@
"""口径试跑(POST /api/ledger/calibers/{wf}/{version}/{node}/trial):按固定版本派生只读图,跑一次探索运行。
试跑不是正式出具:来源记 trial;参数按入口 Schema 校验,缺的补缺省值;返回指标值和运行 id。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Run
from app.engine.runner import run_manager
from app.ledger import match as ledger_match
from app.main import app
from tests import ledger_samples as S
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
yield
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def test_trial_runs_the_pinned_caliber_and_returns_its_metrics(client):
wf = await S.publish()
res = await client.post(f"/api/ledger/calibers/{wf}/1/card/trial",
json={"inputs": {"start": "2026-09-01", "end": "2026-09-08"}})
assert res.status_code == 200, res.text
body = res.json()
assert body["status"] == "succeeded" and body["run_id"]
assert [m["id"] for m in body["metrics"]] == ["gmv"]
assert float(body["metrics"][0]["value"]) == 170.5
assert body["metrics"][0]["unit"] == "元"
async with SessionLocal() as session:
run = await session.get(Run, body["run_id"])
assert run is not None and run.origin_kind == "trial" and run.run_class == "exploratory"
assert run.origin["kind"] == "caliber_trial" and run.origin["workflow_id"] == wf
async def test_trial_refuses_missing_required_inputs_and_unknown_fields(client):
wf = await S.publish()
missing = await client.post(f"/api/ledger/calibers/{wf}/1/card/trial", json={"inputs": {}})
assert missing.status_code == 422 and "start" in missing.text
unknown = await client.post(f"/api/ledger/calibers/{wf}/1/card/trial",
json={"inputs": {"start": "2026-09-01", "end": "2026-09-08", "nope": 1}})
assert unknown.status_code == 422 and "nope" in unknown.text
+2
View File
@@ -21,6 +21,7 @@ import { SettingsPage } from './pages/SettingsPage'
import { SchedulesPage } from './pages/schedules/SchedulesPage'
// 评测(下一轮第 4 波,评测-D8):顶级导航,搭建者以上可见
import { EvalsPage } from './pages/evals/EvalsPage'
import { CaliberCatalog } from './pages/ledger/CaliberCatalog'
import { NotFound } from './pages/NotFound'
// 应用平台:使用面(FE-5)、搭建面的应用编辑(FE-6)
import { AppsHome } from './pages/apps/AppsHome'
@@ -194,6 +195,7 @@ function Shell() {
<Route path="/evals" element={<EvalsPage />} />
<Route path="/evals/:section" element={<EvalsPage />} />
<Route path="/evals/:section/:id" element={<EvalsPage />} />
<Route path="/ledger/calibers" element={<CaliberCatalog />} />
{/* 应用:固定路径(inbox)写在 /apps/:appId 前面,读的人一眼看出它不是应用 id */}
<Route path="/apps" element={<AppsHome />} />
{/* 要在 /apps/:appId 之前:create 不是应用 id */}
+10 -1
View File
@@ -12,7 +12,8 @@
* 请求函数从 ./http 导入,不从 ./client 导入(client.ts 要导入这个文件,反过来就成了循环)。
*/
import type {
CertifyQueue, FreezeOut, LedgerFeedbackOut, LedgerFeedbackVerdict, LedgerIssueOut, LedgerReconcileIn, LedgerReconcileOut, RunLedger,
CaliberDescriptor, CaliberTrialOut, CertifyQueue, FreezeOut, LedgerFeedbackOut, LedgerFeedbackVerdict, LedgerIssueOut,
LedgerReconcileIn, LedgerReconcileOut, RunLedger,
} from '../typesLedger'
import { get, post, type RequestOptions } from './http'
@@ -37,4 +38,12 @@ export const caliberLedger = {
...(opts.conversation_id ? { conversation_id: opts.conversation_id } : {}),
...(opts.turn_id ? { turn_id: opts.turn_id } : {}),
}),
/** 口径目录:每份已发布口径卡的入口 Schema、依赖、能否组合/出具(不返回 SQL、参数值) */
catalog: (opts?: RequestOptions) => get<{ calibers: CaliberDescriptor[] }>('/ledger/calibers', opts),
/** 按固定版本试跑:只读探索运行,返回指标值和 run_id;不是正式出具 */
trial: (workflowId: string, version: number, nodeId: string, inputs: Record<string, unknown>) =>
post<CaliberTrialOut>(
`/ledger/calibers/${encodeURIComponent(workflowId)}/${version}/${encodeURIComponent(nodeId)}/trial`,
{ inputs },
),
}
+4 -2
View File
@@ -5,8 +5,8 @@ import type { NavigateFunction } from 'react-router-dom'
import { create } from 'zustand'
import clsx from 'clsx'
import {
Bell, BellOff, BookOpen, CalendarClock, Database, FlaskConical, Gauge, History, Hourglass, Inbox, Keyboard, LayoutGrid,
MessageSquare, MessageSquarePlus, Monitor, Plus, Search, Settings, SunMoon, Workflow as WorkflowIcon, Wrench,
Bell, BellOff, BookOpen, CalendarClock, Database, FlaskConical, Gauge, History, Hourglass, Inbox, Keyboard, Layers,
LayoutGrid, MessageSquare, MessageSquarePlus, Monitor, Plus, Search, Settings, SunMoon, Workflow as WorkflowIcon, Wrench,
} from 'lucide-react'
import { ApiError, api } from '../api/client'
import { hasPendingApproval, useCatalog } from '../store/catalog'
@@ -66,6 +66,8 @@ export const PAGES: PageDef[] = [
{ to: '/schedules', label: SCHEDULE_TEXT.title, hint: SCHEDULE_TEXT.hint, icon: CalendarClock, shortcut: 'Alt+8', keywords: 'schedules schedule cron 定时 计划 周期 自动运行' },
// 评测(评测-D8):搭建者以上可见(使用者的页面表在 visiblePages 里收窄,用不着另判),同样排在设置前面;快捷键用最后一个空着的 ⌥0
{ to: '/evals', label: EVAL_TEXT.nav, hint: EVAL_TEXT.hint, icon: Gauge, shortcut: 'Alt+0', keywords: 'evals eval evaluation 评测 回归 题集 准确率 A/B 口径命中 重复度' },
// 口径目录:已发布口径的入口、依赖和试跑。后加的页,快捷键用没占用的 Alt+L
{ to: '/ledger/calibers', label: '口径', hint: '查看已发布口径的入口、依赖,并按参数试跑', icon: Layers, shortcut: 'Alt+L', keywords: 'caliber ledger 口径 指标 试跑 入口' },
{ to: '/settings', label: '设置', hint: '模型接入、偏好、运行环境', icon: Settings, shortcut: 'Alt+7', keywords: 'settings provider 模型 署名 主题 偏好' },
]
@@ -0,0 +1,198 @@
/**
* 口径目录(/ledger/calibers):已发布口径的入口、指标、依赖和试跑。
*
* 只读目录 + 参数表单 + 一次只读探索运行。口径的技术身份是「工作流@版本/口径卡」;目录给业务的人看的是名字、指标、
* 入口字段和能不能用。试跑按固定版本派生只读图(服务端 ledger/derive.caliber_probe),不是正式出具:来源记 trial,
* 不进使用统计。SQL 不在页面上出现——服务端 descriptor 本来就不返回。
*/
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronDown, Layers, Play, RefreshCw, Search } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../../api/client'
import { SchemaForm } from '../../components/SchemaForm'
import { EmptyState, ErrorState, Notice, PageHeader, Skeleton, Spinner, toast } from '../../components/ui'
import { LEDGER_TEXT } from '../../lib/terms'
import type { FormSchema } from '../../typesApp'
import type { CaliberDescriptor, CaliberTrialOut } from '../../typesLedger'
const keyOf = (d: CaliberDescriptor) => `${d.workflow_id}@${d.version}/${d.card.node_id}`
function defaultsOf(schema: FormSchema): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [name, field] of Object.entries(schema.properties ?? {})) {
if (field?.default !== undefined && field.default !== null && field.default !== '') out[name] = field.default
}
return out
}
export function CaliberCatalog() {
const [items, setItems] = useState<CaliberDescriptor[] | null>(null)
const [error, setError] = useState<unknown>(null)
const [query, setQuery] = useState('')
const [open, setOpen] = useState<string | null>(null)
const [values, setValues] = useState<Record<string, Record<string, unknown>>>({})
const [trials, setTrials] = useState<Record<string, CaliberTrialOut>>({})
const [busy, setBusy] = useState<string | null>(null)
const load = () => {
setError(null)
setItems(null)
api.caliberLedger.catalog().then(
(body) => setItems(body.calibers),
(e) => setError(e),
)
}
useEffect(load, [])
const shown = useMemo(() => {
const q = query.trim().toLowerCase()
if (!items || !q) return items ?? []
return items.filter((d) => [d.workflow_name, d.card.caliber, d.card.title,
...d.metrics.map((m) => m.name ?? m.id)].some((s) => String(s ?? '').toLowerCase().includes(q)))
}, [items, query])
const toggle = (d: CaliberDescriptor) => {
const key = keyOf(d)
if (open === key) { setOpen(null); return }
setOpen(key)
setValues((v) => (v[key] ? v : { ...v, [key]: defaultsOf(d.entry_schema) }))
}
const trial = async (d: CaliberDescriptor) => {
const key = keyOf(d)
setBusy(key)
try {
const out = await api.caliberLedger.trial(d.workflow_id, d.version, d.card.node_id, values[key] ?? {})
setTrials((t) => ({ ...t, [key]: out }))
if (out.status === 'succeeded') toast.ok('试跑完成')
else if (out.status === 'running') toast.warn('试跑还在运行,打开运行记录看结果')
else toast.error(out.error || '试跑没有成功')
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
return (
<div className="flex h-full flex-col" data-caliber-catalog="">
<PageHeader icon={<Layers size={14} />} title="口径" subtitle="已发布口径的入口、指标、依赖与试跑"
actions={<button type="button" className="btn btn-xs btn-ghost" onClick={load} title="重新加载">
<RefreshCw size={12} aria-hidden /> 刷新
</button>} />
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-3 p-4">
<label className="relative block">
<Search size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-faint" aria-hidden />
<input className="input w-full pl-8" value={query} onChange={(e) => setQuery(e.target.value)}
placeholder="按工作流、口径、指标名查找" aria-label="查找口径" />
</label>
{error ? <ErrorState error={error} onRetry={load} compact />
: !items ? <Skeleton rows={5} height={38} />
: !shown.length ? <EmptyState offline={false} icon={<Layers size={22} />} title="还没有可用的口径"
body={query ? '换个关键词试试' : '在工作流里发布一份带口径卡的版本后,这里就会出现'} />
: shown.map((d) => {
const key = keyOf(d)
const expanded = open === key
const result = trials[key]
return (
<section key={key} className="overflow-hidden rounded-lg border bg-panel" data-caliber={key}>
<button type="button" className="flex w-full items-start gap-3 px-3 py-2.5 text-left hover:bg-hover"
onClick={() => toggle(d)} aria-expanded={expanded} data-caliber-toggle="">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-sm font-medium">{d.workflow_name}</span>
<span className="text-2xs text-faint">v{d.version}</span>
{d.card.caliber && <span className="text-xs text-dim">· {d.card.caliber}</span>}
<span className="rounded border px-1.5 py-0.5 text-[10px] text-dim">
{(LEDGER_TEXT.level as Record<string, string>)[d.level] ?? d.level}
</span>
</div>
<div className="mt-0.5 text-2xs text-faint">
{d.metrics.map((m) => m.name ?? m.id).join('、')}
{d.entry_schema.properties && Object.keys(d.entry_schema.properties).length > 0
? ` · ${Object.keys(d.entry_schema.properties).length} 个入口字段` : ' · 没有入口字段'}
{d.dependencies.tables.length > 0 ? ` · 读 ${d.dependencies.tables.join('、')}` : ''}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-[10px] text-faint">
<span>{d.composable ? '可组合' : '不可组合'}</span>
<span>{d.issuance.auto_issue ? '可自动出具' : '只可一键出具'}</span>
<ChevronDown size={14} className={clsx('transition-transform', expanded && 'rotate-180')} aria-hidden />
</div>
</button>
{expanded && (
<div className="space-y-3 border-t px-3 py-3" data-caliber-detail="">
<div className="grid gap-3 md:grid-cols-2">
<div>
<h3 className="mb-1 text-2xs font-semibold text-dim">指标</h3>
<ul className="space-y-1 text-xs">
{d.metrics.map((m) => (
<li key={m.id} className="flex items-baseline justify-between gap-2 border-b border-hairline pb-1 last:border-0">
<span>{m.name ?? m.id}</span>
<span className="text-2xs text-faint">{m.unit || '—'}</span>
</li>
))}
</ul>
</div>
<div>
<h3 className="mb-1 text-2xs font-semibold text-dim">依赖</h3>
<p className="text-2xs leading-relaxed text-dim">
数据源:{d.dependencies.sources.join('、') || '—'}<br />
表:{d.dependencies.tables.join('、') || '—'}<br />
{d.compose_reason ? `不能组合:${d.compose_reason}` : '可以按这一版组合'}
</p>
{d.issuance.reasons.length > 0 && (
<p className="mt-1 text-2xs leading-relaxed text-faint">
出具:{d.issuance.reasons.join(';')}
</p>
)}
</div>
</div>
<div>
<h3 className="mb-1 text-2xs font-semibold text-dim">入口字段</h3>
{d.entry_schema.properties && Object.keys(d.entry_schema.properties).length > 0 ? (
<SchemaForm schema={d.entry_schema} value={values[key] ?? {}}
onChange={(v) => setValues((all) => ({ ...all, [key]: v }))}
idPrefix={`caliber-${key}`} />
) : <p className="text-2xs text-faint">这份口径不需要参数。</p>}
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn btn-primary btn-sm" disabled={busy === key}
onClick={() => void trial(d)} data-caliber-trial="">
{busy === key ? <Spinner size={12} /> : <Play size={12} aria-hidden />} 试跑
</button>
<Link to={`/studio/${d.workflow_id}`} className="btn btn-sm btn-ghost">打开工作流</Link>
<span className="text-2xs text-faint">试跑是只读探索运行,不是正式出具</span>
</div>
{result && (
<div className="rounded-md border bg-elev px-3 py-2 text-xs" data-caliber-trial-result={result.status}>
{result.status === 'succeeded' ? (
<ul className="space-y-1">
{result.metrics.map((m) => (
<li key={m.id} className="flex items-baseline justify-between gap-2">
<span>{m.name ?? m.id}</span>
<span className="tnum font-medium">{m.rendered ?? String(m.value ?? '—')}{m.unit ? ` ${m.unit}` : ''}</span>
</li>
))}
</ul>
) : (
<Notice tone={result.status === 'running' ? 'info' : 'err'}>
{result.status === 'running' ? '还在运行:' : '试跑没有成功:'}
{result.error || result.status}
{' '}<Link className="underline" to={`/runs/${result.run_id}`}>看运行</Link>
</Notice>
)}
</div>
)}
</div>
)}
</section>
)
})}
</div>
</div>
</div>
)
}
+52
View File
@@ -5,6 +5,7 @@
* 给,界面照着画。比对在封存之后做(不在封存范围内),GET /api/runs/{id}/ledger 在比对还没出结果时答 state=pending,
* 页面按退避再取(store/ledger.ts),不靠实时流:ledger.matched 在事件总线关闭之后才追加,打开着的页面收不到。
*/
import type { FormSchema } from './typesApp'
/** 绑定的一个参数:值是这次运行查到过的遮罩值时不给值(masked),也不能拿它代填出具 */
export interface LedgerBinding {
@@ -370,3 +371,54 @@ export interface FreezeOut {
metrics: string[]
sources: CertifyTurn[]
}
// ---------------------------------------------------------------- 口径目录 / 试跑
export interface CaliberMetric {
id: string
name?: string
unit?: string
decimals?: number
format?: string
}
/** GET /api/ledger/calibers:一份已发布口径卡的可调用描述(不返回 SQL、不返回参数值) */
export interface CaliberDescriptor {
workflow_id: string
workflow_name: string
version: number
level: string
graph_hash?: string | null
card: { node_id: string; title: string; caliber?: string | null; caliber_version?: string | null }
metrics: CaliberMetric[]
entry_schema: FormSchema
composable: boolean
compose_reason?: string | null
dependencies: {
sources: string[]
tables: string[]
catalog_versions?: Record<string, unknown> | null
schema_versions?: Record<string, unknown> | null
}
issuance: {
level: string
auto_issue: boolean
requires_independent_proof: boolean
side_effects?: string | null
reasons: string[]
}
}
export interface CaliberTrialMetric extends CaliberMetric {
value?: unknown
rendered?: string
}
/** POST /api/ledger/calibers/{wf}/{version}/{node}/trial:按固定版本试跑一次 */
export interface CaliberTrialOut {
run_id: string
status: string
output?: Record<string, unknown> | null
error?: string | null
metrics: CaliberTrialMetric[]
}