From 1048905095dc39a534754e10011e868ccd43a25a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 4 Nov 2026 13:35:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=85=E5=A1=AB=E5=85=A5=E5=8F=A3=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E5=80=BC=E6=97=B6=E5=81=9C=E5=9C=A8=20needs=5Finput?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8A=8A=E8=AF=95=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=88=A4=E6=88=90=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 草稿图建出来后,start/end 这类必填入口往往没有缺省值,系统不会替你编日期去跑。现在记成 needs_input:入口字段做成可填表单(日期预填七天前/今天,可改),点「试运行」按这些值跑 一次;缺值仍回 422,不会发请求。试运行只跑取数与口径卡,参数按发布同一套规则校验。 --- backend/app/api/ledger.py | 58 +++++++- backend/tests/test_ledger_docdraft_api.py | 47 ++++++- frontend/src/api/caliberLedger.ts | 6 + frontend/src/lib/termsLedger.ts | 8 +- frontend/src/pages/ledger/CaliberDrafts.tsx | 143 ++++++++++++++++++-- frontend/src/typesLedger.ts | 11 +- 6 files changed, 256 insertions(+), 17 deletions(-) diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 96debf77..a8ff99a5 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -40,7 +40,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.base import SessionLocal, get_session -from app.db.models import CaliberDraft, Chunk, DataSource, Document, Run, Workflow +from app.db.models import CaliberDraft, Chunk, DataSource, Document, Run, Workflow, WorkflowVersion from app.db.models_process import ActionRecord logger = logging.getLogger(__name__) @@ -522,9 +522,13 @@ async def _save_trial(draft_id: str, trial: dict[str, str]) -> None: async def _run_trial(*, draft_id: str, workflow_id: str, graph: dict[str, Any], values: dict[str, Any], - started_by: str | None) -> dict[str, str]: + started_by: str | None, + entry_fields: list[dict[str, Any]] | tuple[dict[str, Any], ...] = ()) -> dict[str, str]: """后台试跑草稿的取数与口径卡,返回 payload["trial"] 的形状(run_id / status / error)。 + 必填入口没有值时不跑:试跑是为了「看数对不对」,拿系统编的日期跑出来的数没有意义。这时状态记 needs_input, + 页面上让人把入口字段填了再点试运行(POST 同一个路径)。 + 跑的是**探针图**(取数 + 口径卡,`derive.caliber_probe`),不是整张草稿图:草稿图里带着报告撰写节点, 试跑只关心数对不对,不该为了一次试跑多花一次模型调用、也不该因为没有可用模型就判这份口径不行。 @@ -534,6 +538,12 @@ async def _run_trial(*, draft_id: str, workflow_id: str, graph: dict[str, Any], from app.engine.runner import run_manager from app.ledger.derive import NotDerivable, caliber_probe, load_pinned + missing = [str(field.get("name")) for field in entry_fields + if field.get("required") and values.get(str(field.get("name"))) in (None, "")] + if missing: + return {"run_id": "", "status": "needs_input", + "error": f"缺少必填输入:{'、'.join(missing)}"} + card_id = next((n.get("id") for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("type") == "metrics"), None) if not card_id: @@ -927,7 +937,7 @@ async def _extract_in_background(draft_id: str) -> None: try: trial = await _run_trial(draft_id=draft_id, workflow_id=workflow.id, graph=graph, values=values, - started_by=draft.created_by) + started_by=draft.created_by, entry_fields=_merged_fields(blocks)) except Exception as e: # noqa: BLE001 - 试运行出错也要写进 payload,不能让界面停在「试运行中」 logger.exception("草稿 %s 的试运行出错", draft_id) trial = {"run_id": "", "status": "failed", "error": f"试运行出错:{e}"} @@ -1029,6 +1039,9 @@ async def get_draft_trial(draft_id: str, session: AsyncSession = Depends(get_ses trial = payload.get("trial") if isinstance(payload.get("trial"), dict) else {} run_id = trial.get("run_id") if not isinstance(run_id, str) or not run_id: + if trial.get("status") == "needs_input": + return {"run_id": "", "status": "needs_input", "metrics": [], + "error": str(trial.get("error") or "")} raise HTTPException(404, "这份草稿还没有试运行") run = await session.get(Run, run_id) if run is None: @@ -1039,6 +1052,45 @@ async def get_draft_trial(draft_id: str, session: AsyncSession = Depends(get_ses return {"run_id": run.id, "status": status, "metrics": metrics, "error": str(run.error or "")} +class DraftTrialIn(BaseModel): + inputs: dict[str, Any] = Field(default_factory=dict) + + +@router.post("/drafts/{draft_id}/trial") +async def run_draft_trial(draft_id: str, payload: DraftTrialIn, + session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + """按填好的入口字段试跑一次(builder 及以上)。 + + 自动试跑缺必填输入时停在 needs_input(不拿系统编的日期跑,那样跑出来的数没有意义),人在这里补上再跑。 + 参数按入口字段校验,和正式运行同一套(apps/forms);跑的仍是探针图:只取数与口径卡,不写报告、不进统计。 + """ + from app.api.deps import current_principal, ensure_role + from app.apps.forms import input_schema, validate_form + + ensure_role("builder") + draft = await _draft_or_404(session, draft_id) + body = draft.payload if isinstance(draft.payload, dict) else {} + fields = [field for field in body.get("entry_fields") or [] if isinstance(field, dict)] + workflow_id = draft.workflow_id or body.get("workflow_id") + if not workflow_id: + raise HTTPException(409, "这份草稿还没有取数图,不能试运行") + version = int(body.get("version") or 1) + row = (await session.execute(select(WorkflowVersion).where( + WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.version == version))).scalar_one_or_none() + if row is None or not isinstance(row.graph, dict): + raise HTTPException(409, "找不到这份草稿的取数图,可能已被删除") + values: dict[str, Any] = {str(field.get("name")): field.get("default") for field in fields + if field.get("default") not in (None, "")} + values.update({str(key): value for key, value 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])) + trial = await _run_trial(draft_id=draft.id, workflow_id=str(workflow_id), graph=row.graph, + values=values, started_by=current_principal().username, entry_fields=fields) + await _save_trial(draft.id, trial) + run_id = str(trial.get("run_id") or "") + return {**trial, "metrics": await _trial_metrics(run_id) if run_id else []} + + def _check_metrics(metrics: Any) -> list[dict[str, Any]]: """PATCH 换指标时只做结构校验(不重跑引用核对:引用是模型交的,人改过的段落没法再逐字对一遍)。""" if not isinstance(metrics, list): diff --git a/backend/tests/test_ledger_docdraft_api.py b/backend/tests/test_ledger_docdraft_api.py index d2b715d0..80113427 100644 --- a/backend/tests/test_ledger_docdraft_api.py +++ b/backend/tests/test_ledger_docdraft_api.py @@ -96,7 +96,7 @@ async def fake_trial(monkeypatch): """试跑换成写一行 succeeded 的运行:不启动引擎、不查库,接口和 payload 的形状照真的一样。""" made: list[str] = [] - async def start(*, draft_id, workflow_id, graph, values, started_by): + async def start(*, draft_id, workflow_id, graph, values, started_by, entry_fields=()): run_id = f"run-draft-{uuid.uuid4().hex[:10]}" async with SessionLocal() as session: session.add(Run(id=run_id, workflow_id=workflow_id, status="succeeded", graph=graph, @@ -691,3 +691,48 @@ def test_questions_asked_again_next_round_are_deduped(): "报废模架数需要跨表,关联键未确认"] merged = api_ledger._merge_questions(first, again) assert merged == [first[0], "报废模架数需要跨表,关联键未确认"] + + +async def test_a_trial_without_required_inputs_waits_for_them_instead_of_failing(): + """必填入口没有值时不拿系统编的日期去跑:停在 needs_input,等人在页面上填。""" + fields = [{"name": "start", "type": "string", "required": True}] + out = await REAL_RUN_TRIAL(draft_id="d", workflow_id="w", graph={"nodes": []}, values={}, + started_by=None, entry_fields=fields) + assert out["status"] == "needs_input" and "start" in out["error"] + + +async def test_running_the_trial_with_inputs_typed_on_the_page(client, documents, datasource, monkeypatch): + """自动试跑缺必填输入,人填了再跑:参数按入口字段校验,缺值 422,填了才发起。""" + doc = await documents("dd-inputs") + source_id = await datasource() + reply = json.loads(plan_reply(f"{doc}-2")) + reply["entry_fields"] = [{"name": "start", "type": "string", "required": True, "default": "", + "description": "开始日期"}] + reply["sql"] = ("SELECT COUNT(*) * 1.0 AS refund_rate FROM orders WHERE status = 'paid' " + "AND paid_at >= '{{ input.start }}'") + fake_model(monkeypatch, json.dumps(reply, ensure_ascii=False)) + + async def waiting(*, draft_id, workflow_id, graph, values, started_by, entry_fields=()): + return {"run_id": "", "status": "needs_input", "error": "缺少必填输入:start"} + + monkeypatch.setattr(api_ledger, "_run_trial", waiting) + body = await extract(client, doc, source_id) + made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() + assert made["trial"]["status"] == "needs_input" + trial = (await client.get(f"/api/ledger/drafts/{body['draft_id']}/trial")).json() + assert trial["status"] == "needs_input" and trial["metrics"] == [] + + missing = await client.post(f"/api/ledger/drafts/{body['draft_id']}/trial", json={"inputs": {}}) + assert missing.status_code == 422 + + seen: dict[str, object] = {} + + async def started(*, draft_id, workflow_id, graph, values, started_by, entry_fields=()): + seen.clear() + seen.update(values) + return {"run_id": "", "status": "running", "error": ""} + + monkeypatch.setattr(api_ledger, "_run_trial", started) + res = await client.post(f"/api/ledger/drafts/{body['draft_id']}/trial", json={"inputs": {"start": "2026-01-01"}}) + assert res.status_code == 200, res.text + assert seen["start"] == "2026-01-01" diff --git a/frontend/src/api/caliberLedger.ts b/frontend/src/api/caliberLedger.ts index 4e9a6432..85c3643e 100644 --- a/frontend/src/api/caliberLedger.ts +++ b/frontend/src/api/caliberLedger.ts @@ -58,6 +58,12 @@ export const caliberLedger = { draft: (draftId: string, opts?: RequestOptions) => get(draftAt(draftId), opts), /** 起草后自动发起的那次只读试运行:状态和每个指标的值,running 时轮询到终态 */ draftTrial: (draftId: string, opts?: RequestOptions) => get(`${draftAt(draftId)}/trial`, opts), + /** + * 按填好的入口字段再试运行一次(POST 同一个路径):参数按入口字段校验,缺必填回 422; + * 返回 {run_id, status, metrics, error},status 是 running / succeeded / failed / needs_input + */ + runDraftTrial: (draftId: string, inputs: Record) => + post(`${draftAt(draftId)}/trial`, { inputs }), /** 选文档发起起草:documents 是要用的知识库文档 id,guidance 是给模型看的附加要求 */ extractDraft: (body: { documents: string[]; collection?: string; datasource_id?: string; guidance?: string }) => post<{ draft_id: string; status: string }>('/ledger/drafts/extract', body), diff --git a/frontend/src/lib/termsLedger.ts b/frontend/src/lib/termsLedger.ts index c55b71ab..1ba980b3 100644 --- a/frontend/src/lib/termsLedger.ts +++ b/frontend/src/lib/termsLedger.ts @@ -293,6 +293,11 @@ export const LEDGER_UI = { trialNoMetrics: '这次试运行没有产出指标值', trialUnavailable: '暂时无法读取试运行状态', trialRun: '看这次运行', + /** 必填入口字段没有值:状态停在 needs_input,不拿系统编的值去跑 */ + trialNeedsInput: '等补入口字段', + trialNeedsInputHint: '填好入口字段再试运行', + trialRunAction: '试运行', + trialRunStarting: '正在发起…', // ---- 详情 ---- documentsUsed: (n: number) => `${formatNumber(n)} 份文档`, @@ -342,8 +347,9 @@ export const LEDGER_UI = { // ---- 入口字段与发布 ---- entryFields: '入口字段', - entryFieldsHint: '发布后按这些字段收参数;缺省值可以不填', + entryFieldsHint: '试运行和发布后都按这些字段取值;带缺省值的可以不改', entryFieldNone: '这份草稿的图没有入口字段', + entryPrefilled: '已预填,可改', required: '必填', optional: '可选', defaultValue: (value: string) => `缺省 ${value}`, diff --git a/frontend/src/pages/ledger/CaliberDrafts.tsx b/frontend/src/pages/ledger/CaliberDrafts.tsx index 773be7a9..8df94b12 100644 --- a/frontend/src/pages/ledger/CaliberDrafts.tsx +++ b/frontend/src/pages/ledger/CaliberDrafts.tsx @@ -3,9 +3,10 @@ * * 主路径一次到位:选文档 → 模型读文档、对照数据目录产出定义 + 取数 + 入口字段 + 指标表达式 → 服务端校验后落成草稿图 * → 后台自动只读试运行 → 这里展示「定义 + 数 + 原文引用」→ 人去画布审改并发布。发布才需要人签字。 - * - 发起:选知识库文档,可选数据源(只写进适用范围)和一段起草要求,页面轮询详情直到状态离开 drafting(最多十分钟, - * 指标多时服务端会分几轮续跑,进度写在 payload.progress); - * - 试运行:草稿顶部显示这次自动试运行的状态和每个指标的值,running 时每两秒取一次,最多等一分钟;失败照实写原因; + * - 发起:选知识库文档和**必选的数据源**(取数 SQL 只对照它的表和列写),可加一段起草要求;页面轮询详情直到状态离开 + * drafting(最多十五分钟,指标多时服务端会分几轮续跑,进度写在 payload.progress); + * - 试运行:草稿顶部显示这次自动试运行的状态和每个指标的值,running 时每两秒取一次,最多等一分钟;必填入口没有值时停在 + * needs_input,在入口字段里填了再点「试运行」;失败照实写原因; * - 审读:指标默认收成一行(名字、单位、值和未决问题数),展开看单位、粒度、指标表达式、定义和逐字核对过的引用原文 * (第 N 段),逐条的未决问题可以勾掉;被丢弃的条目单独列出原因;入口字段列出名字、类型、必填和缺省值; * - 发布:主按钮去画布审改并发布。「接上一次运行」只是辅助入口:已经有答案时让它认这份定义。 @@ -15,7 +16,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' -import { ChevronLeft, FileText, PenLine, RefreshCw, ScrollText } from 'lucide-react' +import { ChevronLeft, FileText, PenLine, Play, RefreshCw, ScrollText } from 'lucide-react' import clsx from 'clsx' import { api } from '../../api/client' import { EmptyState, ErrorState, Notice, PageHeader, Skeleton, Spinner, promptDialog, toast } from '../../components/ui' @@ -23,8 +24,8 @@ import { formatDateTime, formatTime } from '../../lib/format' import { LEDGER_UI } from '../../lib/termsLedger' import type { DataSourceName, KbDocument, Run } from '../../types' import type { - CaliberDraft, CaliberDraftStatus, CaliberDraftSummary, DraftAlignment, DraftCitation, DraftMetric, DraftTrialMetric, - DraftTrialOut, + CaliberDraft, CaliberDraftStatus, CaliberDraftSummary, DraftAlignment, DraftCitation, DraftEntryField, DraftMetric, + DraftTrialMetric, DraftTrialOut, } from '../../typesLedger' const D = LEDGER_UI.drafts @@ -108,6 +109,69 @@ function defaultText(value: unknown): string { return text ?? String(value) } +/** 入口字段预填的日期:YYYY-MM-DD,按本地日期算 */ +function dayText(offsetDays: number): string { + const day = new Date() + day.setDate(day.getDate() + offsetDays) + const pad = (n: number) => String(n).padStart(2, '0') + return `${day.getFullYear()}-${pad(day.getMonth() + 1)}-${pad(day.getDate())}` +} + +/** + * 名字或说明里带「开始 / start」的日期字段预填七天前,带「结束 / end」的预填今天: + * 这两个值基本总是要填的,给个能改的范围比留空省一步。其余必填字段留空,让人自己写。 + */ +function datePrefill(field: DraftEntryField): string | null { + if ((field.type || 'string') !== 'string') return null + const hint = `${field.name} ${field.description ?? ''}`.toLowerCase() + if (hint.includes('开始') || /(^|[^a-z])start([^a-z]|$)/.test(hint)) return dayText(-7) + if (hint.includes('结束') || /(^|[^a-z])end([^a-z]|$)/.test(hint)) return dayText(0) + return null +} + +/** 入口字段的初值:有缺省值就用缺省值,日期字段按上面的规则预填,其余留空 */ +function entryInitialValues(fields: DraftEntryField[], prefilled: Record): Record { + const values: Record = {} + for (const field of fields) { + const hasDefault = field.default !== undefined && field.default !== null && field.default !== '' + if (hasDefault) { values[field.name] = defaultText(field.default); continue } + const guess = datePrefill(field) + values[field.name] = guess ?? '' + if (guess) prefilled[field.name] = true + } + return values +} + +/** 必填字段是不是都填了:布尔量总有值,数字要能读成数 */ +function entryReady(fields: DraftEntryField[], values: Record): boolean { + return fields.every((field) => { + if (!field.required) return true + const type = field.type || 'string' + if (type === 'boolean') return true + const text = (values[field.name] ?? '').trim() + if (!text) return false + return type === 'number' || type === 'integer' ? Number.isFinite(Number(text)) : true + }) +} + +/** 表单值换成接口要的 inputs:空字符串不传(服务端会用缺省值),数字转成数,布尔转成开关 */ +function entryInputs(fields: DraftEntryField[], values: Record): Record { + const inputs: Record = {} + for (const field of fields) { + const type = field.type || 'string' + const text = values[field.name] ?? '' + if (type === 'boolean') { inputs[field.name] = text === 'true'; continue } + if (!text.trim()) continue + if (type === 'number' || type === 'integer') { + const num = Number(text) + if (Number.isFinite(num)) inputs[field.name] = num + continue + } + inputs[field.name] = text + } + return inputs +} + export function CaliberDrafts() { const [drafts, setDrafts] = useState(null) const [listError, setListError] = useState(null) @@ -142,6 +206,11 @@ export function CaliberDrafts() { const [trialTimedOut, setTrialTimedOut] = useState(false) const [trialError, setTrialError] = useState(null) + /** 试运行的表单值:入口字段可改,必填没填满时按钮禁用;prefilled 记哪些是页面替人预填的 */ + const [entryValues, setEntryValues] = useState>({}) + const [prefilled, setPrefilled] = useState>({}) + const [trialStarting, setTrialStarting] = useState(false) + /** 引用所在段落的正文和段号:按文档懒取,取过的记在这里 */ const [chunks, setChunks] = useState>({}) const asked = useRef(new Set()) @@ -203,6 +272,9 @@ export function CaliberDrafts() { const draft = await api.caliberLedger.draft(id) if (seq !== openSeq.current) return setDetail(draft) + const marks: Record = {} + setEntryValues(entryInitialValues(draft.entry_fields ?? [], marks)) + setPrefilled(marks) if (draft.status === 'drafting') { setPollTimedOut(false); setPollingId(id) } } catch (e) { if (seq !== openSeq.current) return @@ -441,13 +513,33 @@ export function CaliberDrafts() { ) } + /** 按填好的入口字段再试运行一次:自动试跑缺必填输入时停在 needs_input,人在这里补上 */ + const runTrial = async (draft: CaliberDraft) => { + const fields = draft.entry_fields ?? [] + if (!entryReady(fields, entryValues)) return + setTrialStarting(true) + setTrialError(null) + try { + const out = await api.caliberLedger.runDraftTrial(draft.id, entryInputs(fields, entryValues)) + setTrial(out) + setTrialTimedOut(false) + if (out.status === 'running' && out.run_id) setTrialPollId(draft.id) + } catch (e) { + setTrialError(e) + } finally { + setTrialStarting(false) + } + } + /** 自动试运行:状态、每个指标的值(名字和值成对)、失败原因;没有试运行就整块不画 */ const renderTrial = (draft: CaliberDraft) => { const status = trial?.status ?? draft.trial?.status if (!status) return null const rows = trial?.metrics ?? [] - const color = status === 'succeeded' ? 'var(--ok)' : status === 'failed' ? 'var(--err)' : 'var(--accent)' - const label = status === 'succeeded' ? D.trialSucceeded : status === 'failed' ? D.trialFailed : D.trialRunning + const color = status === 'succeeded' ? 'var(--ok)' : status === 'failed' ? 'var(--err)' + : status === 'needs_input' ? 'var(--warn)' : 'var(--accent)' + const label = status === 'succeeded' ? D.trialSucceeded : status === 'failed' ? D.trialFailed + : status === 'needs_input' ? D.trialNeedsInput : D.trialRunning const unitOf = (id: string) => (draft.metrics ?? []).find((m) => m.id === id)?.unit return (
@@ -472,6 +564,11 @@ export function CaliberDrafts() { {D.trialFailed}:{trial?.error || draft.trial?.error || D.loadFailed}
)} + {status === 'needs_input' && ( +

+ {D.trialNeedsInputHint}:{trial?.error || draft.trial?.error || ''} +

+ )} {status === 'succeeded' && (rows.length > 0 ? (
    {rows.map((row) => { @@ -499,6 +596,7 @@ export function CaliberDrafts() { const fields = draft.entry_fields const notes = draft.entry_notes ?? [] if (!fields) return null + const canTrial = !!draft.workflow_id return (

    {D.entryFields}

    @@ -508,12 +606,26 @@ export function CaliberDrafts() { {fields.map((field) => { const hasDefault = field.default !== undefined && field.default !== null && field.default !== '' return ( -
  • +
  • {field.name} {field.type || 'string'} {field.required ? D.required : D.optional} {hasDefault && {D.defaultValue(defaultText(field.default))}} {field.description && {field.description}} + {canTrial && (field.type === 'boolean' ? ( + setEntryValues((cur) => ({ + ...cur, [field.name]: e.target.checked ? 'true' : 'false', + }))} /> + ) : ( + setEntryValues((cur) => ({ ...cur, [field.name]: e.target.value }))} /> + ))}
  • ) })} @@ -524,6 +636,19 @@ export function CaliberDrafts() { {notes.map((note, i) =>
  • {note}
  • )}
)} + {canTrial && ( +
+ + {Object.keys(prefilled).length > 0 && ( + {D.entryPrefilled} + )} +
+ )} ) } diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index f5cd4d78..d23b0e39 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -498,10 +498,15 @@ export interface DraftEntryField { description?: string } +/** + * 试运行的状态:还会缺必填入口字段(needs_input),这时不跑,等人填了再发起;needs_input 没有 run_id。 + */ +export type DraftTrialStatus = 'running' | 'succeeded' | 'failed' | 'needs_input' + /** 起草后自动发起的那次只读试运行(draft.payload.trial) */ export interface DraftTrial { run_id: string - status: 'running' | 'succeeded' | 'failed' + status: DraftTrialStatus error?: string } @@ -513,10 +518,10 @@ export interface DraftTrialMetric { rendered?: string } -/** GET /api/ledger/drafts/{id}/trial:这次自动试运行的状态和每个指标的值 */ +/** GET /api/ledger/drafts/{id}/trial:这次自动试运行的状态和每个指标的值;POST 同一个路径按填好的入口字段再发起一次 */ export interface DraftTrialOut { run_id: string - status: 'running' | 'succeeded' | 'failed' + status: DraftTrialStatus metrics: DraftTrialMetric[] error?: string | null }