diff --git a/backend/app/api/ledger.py b/backend/app/api/ledger.py index 1b9c1ad8..00ae98e2 100644 --- a/backend/app/api/ledger.py +++ b/backend/app/api/ledger.py @@ -518,11 +518,13 @@ async def _extract_in_background(draft_id: str) -> None: } except docdraft.DraftUnavailable as e: draft.error = str(e) or "AI 起草不可用" + draft.status = "failed" await session.commit() return except Exception as e: # noqa: BLE001 - 后台出错要写回这一行,不然界面永远停在「起草中」 logger.exception("从文档起草口径出错(草稿 %s)", draft_id) draft.error = f"起草出错:{e}" + draft.status = "failed" await session.commit() return draft.payload = payload @@ -575,7 +577,7 @@ async def draft_extract(payload: DraftExtractIn, background: BackgroundTasks, @router.get("/drafts") async def list_drafts(status: str | None = Query(default=None, description="按状态筛:drafting / questions_open / " - "path_pending / attached / rejected"), + "path_pending / attached / rejected / failed"), session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """口径草稿列表,最近改过的在前。只给计数和状态,正文用 GET /api/ledger/drafts/{id} 看。""" from app.api.deps import ensure_role @@ -658,7 +660,10 @@ async def patch_draft(draft_id: str, payload: DraftPatchIn, session: AsyncSessio if not (isinstance(q, str) and q.strip() in drop)] if payload.metrics is not None or payload.resolved is not None: draft.payload = payload_data - if draft.status in ("drafting", "questions_open", "path_pending"): + metrics = payload_data.get("metrics") + # 起草失败的草稿补上真指标后要能往前走;没有指标时不重算,免得空草稿被当成「待接数据路径」 + if isinstance(metrics, list) and metrics and draft.status in ("drafting", "questions_open", + "path_pending", "failed"): draft.status = _status_for(payload_data) await session.commit() return await _draft_view(session, draft) diff --git a/backend/app/ledger/docdraft.py b/backend/app/ledger/docdraft.py index 2ea27988..8e688865 100644 --- a/backend/app/ledger/docdraft.py +++ b/backend/app/ledger/docdraft.py @@ -22,7 +22,8 @@ from collections.abc import Iterable, Mapping from typing import Any #: 草稿的状态。path_pending 不是合法图,attached 之后取数路径才来自一次真实运行。 -STATUSES = ("drafting", "questions_open", "path_pending", "attached", "rejected") +#: failed 是起草这一步没跑成(没有可用模型、模型回复读不懂),和 rejected(人判它不是口径)不是一回事。 +STATUSES = ("drafting", "questions_open", "path_pending", "attached", "rejected", "failed") #: 一次发给模型的总字数上限(按 chunk 边界截断,见 extract) MAX_INPUT_CHARS = 40000 diff --git a/backend/tests/test_ledger_docdraft_api.py b/backend/tests/test_ledger_docdraft_api.py index c9b1db6f..c203c532 100644 --- a/backend/tests/test_ledger_docdraft_api.py +++ b/backend/tests/test_ledger_docdraft_api.py @@ -220,7 +220,7 @@ async def test_a_model_that_is_unavailable_leaves_the_draft_with_an_error(client monkeypatch.setattr(docdraft, "extract", unavailable) body = (await client.post("/api/ledger/drafts/extract", json={"documents": [doc]})).json() made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() - assert made["status"] == "drafting" and made["error"] == "还没有接入可用的起草模型" + assert made["status"] == "failed" and made["error"] == "还没有接入可用的起草模型" assert made["metric_count"] == 0 and not made.get("metrics") diff --git a/frontend/src/lib/termsLedger.ts b/frontend/src/lib/termsLedger.ts index dbfd0ac0..25a5ae1c 100644 --- a/frontend/src/lib/termsLedger.ts +++ b/frontend/src/lib/termsLedger.ts @@ -230,13 +230,14 @@ export const LEDGER_UI = { subtitle: '文档里写的是业务说法,这里整理成定义草稿;取数路径接上一次真实运行', back: '返回口径目录', refresh: '刷新', - /** 列表按状态筛:全部加四种落点,起草中的只出现在全部里 */ + /** 列表按状态筛:全部加五种落点,起草中的只出现在全部里 */ filter: { all: '全部', questions_open: '待确认问题', path_pending: '待接数据路径', attached: '已接上', rejected: '已驳回', + failed: '起草失败', } as Record, status: { drafting: '起草中', @@ -244,6 +245,7 @@ export const LEDGER_UI = { path_pending: '待接数据路径', attached: '已接上', rejected: '已驳回', + failed: '起草失败', } as Record, filterLabel: '按状态筛', summary: (metrics: number, questions: number) => `${formatNumber(metrics)} 个指标 · ${ diff --git a/frontend/src/pages/ledger/CaliberDrafts.tsx b/frontend/src/pages/ledger/CaliberDrafts.tsx index ca08a511..d70a93a0 100644 --- a/frontend/src/pages/ledger/CaliberDrafts.tsx +++ b/frontend/src/pages/ledger/CaliberDrafts.tsx @@ -28,12 +28,12 @@ const D = LEDGER_UI.drafts /** 列表的状态筛:全部加四种落点,起草中的只出现在全部里 */ type Filter = '' | Exclude -const FILTERS: Filter[] = ['', 'questions_open', 'path_pending', 'attached', 'rejected'] +const FILTERS: Filter[] = ['', 'questions_open', 'path_pending', 'attached', 'rejected', 'failed'] -/** 状态标签:起草中 / 待确认问题 / 待接数据路径 / 已接上 / 已驳回 */ +/** 状态标签:起草中 / 待确认问题 / 待接数据路径 / 已接上 / 已驳回 / 起草失败 */ function StatusPill({ status }: { status: CaliberDraftStatus }) { const color = status === 'attached' ? 'var(--ok)' : status === 'rejected' ? 'var(--text-faint)' - : status === 'drafting' ? 'var(--accent)' : 'var(--warn)' + : status === 'drafting' ? 'var(--accent)' : status === 'failed' ? 'var(--err)' : 'var(--warn)' return ( diff --git a/frontend/src/typesLedger.ts b/frontend/src/typesLedger.ts index 57f1d173..a938ce46 100644 --- a/frontend/src/typesLedger.ts +++ b/frontend/src/typesLedger.ts @@ -444,7 +444,7 @@ export interface CaliberTrialOut { * 草稿的状态(app/ledger/docdraft.STATUSES):起草中 → 有待确认的问题 / 待接数据路径 → 已接上; * 另有已驳回。path_pending 不是合法图,取数路径只能来自一次真实运行。 */ -export type CaliberDraftStatus = 'drafting' | 'questions_open' | 'path_pending' | 'attached' | 'rejected' +export type CaliberDraftStatus = 'drafting' | 'questions_open' | 'path_pending' | 'attached' | 'rejected' | 'failed' /** 一条引用:逐字原文、原文所在段落(chunk)、span 是原文里的 [start, end) */ export interface DraftCitation {