必填入口没有值时停在 needs_input,不再把试运行判成失败

草稿图建出来后,start/end 这类必填入口往往没有缺省值,系统不会替你编日期去跑。现在记成
needs_input:入口字段做成可填表单(日期预填七天前/今天,可改),点「试运行」按这些值跑
一次;缺值仍回 422,不会发请求。试运行只跑取数与口径卡,参数按发布同一套规则校验。
This commit is contained in:
ThearchyHelios
2026-11-04 13:35:21 +08:00
parent 62c8702478
commit 1048905095
6 changed files with 256 additions and 17 deletions
+55 -3
View File
@@ -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):
+46 -1
View File
@@ -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"
+6
View File
@@ -58,6 +58,12 @@ export const caliberLedger = {
draft: (draftId: string, opts?: RequestOptions) => get<CaliberDraft>(draftAt(draftId), opts),
/** 起草后自动发起的那次只读试运行:状态和每个指标的值,running 时轮询到终态 */
draftTrial: (draftId: string, opts?: RequestOptions) => get<DraftTrialOut>(`${draftAt(draftId)}/trial`, opts),
/**
* 按填好的入口字段再试运行一次(POST 同一个路径):参数按入口字段校验,缺必填回 422;
* 返回 {run_id, status, metrics, error},status 是 running / succeeded / failed / needs_input
*/
runDraftTrial: (draftId: string, inputs: Record<string, unknown>) =>
post<DraftTrialOut>(`${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),
+7 -1
View File
@@ -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}`,
+134 -9
View File
@@ -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<string, boolean>): Record<string, string> {
const values: Record<string, string> = {}
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<string, string>): 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<string, string>): Record<string, unknown> {
const inputs: Record<string, unknown> = {}
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<CaliberDraftSummary[] | null>(null)
const [listError, setListError] = useState<unknown>(null)
@@ -142,6 +206,11 @@ export function CaliberDrafts() {
const [trialTimedOut, setTrialTimedOut] = useState(false)
const [trialError, setTrialError] = useState<unknown>(null)
/** 试运行的表单值:入口字段可改,必填没填满时按钮禁用;prefilled 记哪些是页面替人预填的 */
const [entryValues, setEntryValues] = useState<Record<string, string>>({})
const [prefilled, setPrefilled] = useState<Record<string, boolean>>({})
const [trialStarting, setTrialStarting] = useState(false)
/** 引用所在段落的正文和段号:按文档懒取,取过的记在这里 */
const [chunks, setChunks] = useState<Record<string, { ordinal: number; content: string }>>({})
const asked = useRef(new Set<string>())
@@ -203,6 +272,9 @@ export function CaliberDrafts() {
const draft = await api.caliberLedger.draft(id)
if (seq !== openSeq.current) return
setDetail(draft)
const marks: Record<string, boolean> = {}
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 (
<div className="rounded-md border bg-elev px-3 py-2" data-draft-trial={status}>
@@ -472,6 +564,11 @@ export function CaliberDrafts() {
<Notice tone="err">{D.trialFailed}:{trial?.error || draft.trial?.error || D.loadFailed}</Notice>
</div>
)}
{status === 'needs_input' && (
<p className="mt-1 text-2xs text-dim" data-draft-trial-needs-input="">
{D.trialNeedsInputHint}:{trial?.error || draft.trial?.error || ''}
</p>
)}
{status === 'succeeded' && (rows.length > 0 ? (
<ul className="mt-1 grid gap-x-4 gap-y-0.5 text-2xs sm:grid-cols-[1fr_auto]">
{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 (
<div data-draft-entry-fields="">
<h3 className="mb-1 text-2xs font-semibold text-dim">{D.entryFields}</h3>
@@ -508,12 +606,26 @@ export function CaliberDrafts() {
{fields.map((field) => {
const hasDefault = field.default !== undefined && field.default !== null && field.default !== ''
return (
<li key={field.name} className="flex flex-wrap items-baseline gap-x-2" data-draft-entry-field={field.name}>
<li key={field.name} className="flex flex-wrap items-center gap-x-2 gap-y-1"
data-draft-entry-field={field.name}>
<code className="font-medium text-dim">{field.name}</code>
<span className="text-faint">{field.type || 'string'}</span>
<span className="text-faint">{field.required ? D.required : D.optional}</span>
{hasDefault && <span className="text-faint">{D.defaultValue(defaultText(field.default))}</span>}
{field.description && <span className="text-dim [overflow-wrap:anywhere]">{field.description}</span>}
{canTrial && (field.type === 'boolean' ? (
<input type="checkbox" className="accent-[var(--accent)]" disabled={trialStarting}
data-draft-entry-input={field.name}
checked={(entryValues[field.name] ?? '') === 'true'}
onChange={(e) => setEntryValues((cur) => ({
...cur, [field.name]: e.target.checked ? 'true' : 'false',
}))} />
) : (
<input className="field w-36 text-2xs" value={entryValues[field.name] ?? ''}
disabled={trialStarting} data-draft-entry-input={field.name}
placeholder={prefilled[field.name] ? D.entryPrefilled : ''}
onChange={(e) => setEntryValues((cur) => ({ ...cur, [field.name]: e.target.value }))} />
))}
</li>
)
})}
@@ -524,6 +636,19 @@ export function CaliberDrafts() {
{notes.map((note, i) => <li key={`${i}:${note}`}>{note}</li>)}
</ul>
)}
{canTrial && (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<button type="button" className="btn btn-xs btn-primary"
disabled={trialStarting || !entryReady(fields, entryValues)}
onClick={() => void runTrial(draft)} data-draft-trial-run-button="">
{trialStarting ? <Spinner size={11} /> : <Play size={11} aria-hidden />}
{trialStarting ? D.trialRunStarting : D.trialRunAction}
</button>
{Object.keys(prefilled).length > 0 && (
<span className="text-[10px] text-faint">{D.entryPrefilled}</span>
)}
</div>
)}
</div>
)
}
+8 -3
View File
@@ -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
}