From 68af29a180ad604121dcde74206663b1ef11fbd2 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 3 Nov 2026 19:43:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=A3=E5=BE=84=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E4=B8=8E=E8=AF=95=E8=B7=91=EF=BC=9A=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E8=A1=A8=E5=8D=95=E3=80=81=E4=BE=9D=E8=B5=96=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=92=8C=E5=8F=AA=E8=AF=BB=E8=AF=95=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/ledger.py | 69 ++++++- backend/tests/test_ledger_trial.py | 55 ++++++ frontend/src/App.tsx | 2 + frontend/src/api/caliberLedger.ts | 11 +- frontend/src/components/CommandPalette.tsx | 6 +- frontend/src/pages/ledger/CaliberCatalog.tsx | 198 +++++++++++++++++++ frontend/src/typesLedger.ts | 52 +++++ 7 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_ledger_trial.py create mode 100644 frontend/src/pages/ledger/CaliberCatalog.tsx diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 7cba10fe..fecb6a8d 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -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 diff --git a/backend/tests/test_ledger_trial.py b/backend/tests/test_ledger_trial.py new file mode 100644 index 00000000..51559ea7 --- /dev/null +++ b/backend/tests/test_ledger_trial.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59bcda87..193bde74 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* 应用:固定路径(inbox)写在 /apps/:appId 前面,读的人一眼看出它不是应用 id */} } /> {/* 要在 /apps/:appId 之前:create 不是应用 id */} diff --git a/frontend/src/api/caliberLedger.ts b/frontend/src/api/caliberLedger.ts index aa78ac75..e8ea1f39 100644 --- a/frontend/src/api/caliberLedger.ts +++ b/frontend/src/api/caliberLedger.ts @@ -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) => + post( + `/ledger/calibers/${encodeURIComponent(workflowId)}/${version}/${encodeURIComponent(nodeId)}/trial`, + { inputs }, + ), } diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index c412b0c7..aa5b783c 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -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 模型 署名 主题 偏好' }, ] diff --git a/frontend/src/pages/ledger/CaliberCatalog.tsx b/frontend/src/pages/ledger/CaliberCatalog.tsx new file mode 100644 index 00000000..a932dc47 --- /dev/null +++ b/frontend/src/pages/ledger/CaliberCatalog.tsx @@ -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 { + const out: Record = {} + 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(null) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [open, setOpen] = useState(null) + const [values, setValues] = useState>>({}) + const [trials, setTrials] = useState>({}) + const [busy, setBusy] = useState(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 ( +
+ } title="口径" subtitle="已发布口径的入口、指标、依赖与试跑" + actions={} /> +
+
+ + {error ? + : !items ? + : !shown.length ? } title="还没有可用的口径" + body={query ? '换个关键词试试' : '在工作流里发布一份带口径卡的版本后,这里就会出现'} /> + : shown.map((d) => { + const key = keyOf(d) + const expanded = open === key + const result = trials[key] + return ( +
+ + {expanded && ( +
+
+
+

指标

+
    + {d.metrics.map((m) => ( +
  • + {m.name ?? m.id} + {m.unit || '—'} +
  • + ))} +
+
+
+

依赖

+

+ 数据源:{d.dependencies.sources.join('、') || '—'}
+ 表:{d.dependencies.tables.join('、') || '—'}
+ {d.compose_reason ? `不能组合:${d.compose_reason}` : '可以按这一版组合'} +

+ {d.issuance.reasons.length > 0 && ( +

+ 出具:{d.issuance.reasons.join(';')} +

+ )} +
+
+
+

入口字段

+ {d.entry_schema.properties && Object.keys(d.entry_schema.properties).length > 0 ? ( + setValues((all) => ({ ...all, [key]: v }))} + idPrefix={`caliber-${key}`} /> + ) :

这份口径不需要参数。

} +
+
+ + 打开工作流 + 试跑是只读探索运行,不是正式出具 +
+ {result && ( +
+ {result.status === 'succeeded' ? ( +
    + {result.metrics.map((m) => ( +
  • + {m.name ?? m.id} + {m.rendered ?? String(m.value ?? '—')}{m.unit ? ` ${m.unit}` : ''} +
  • + ))} +
+ ) : ( + + {result.status === 'running' ? '还在运行:' : '试跑没有成功:'} + {result.error || result.status} + {' '}看运行 + + )} +
+ )} +
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index ca0ab914..82f2c431 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -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 | null + schema_versions?: Record | 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 | null + error?: string | null + metrics: CaliberTrialMetric[] +}