口径草稿页:看引用原文、清未决问题、接上一次真实运行

列表按状态筛,抽取完成后轮询到出结果;被丢弃的条目单独列出原因,未决问题逐条处理完才
能接数据路径。入口挂在口径目录页头,不动全局导航。
This commit is contained in:
ThearchyHelios
2026-11-03 22:59:47 +08:00
parent 7f66985dba
commit 8b80b59a15
6 changed files with 930 additions and 7 deletions
+3
View File
@@ -22,6 +22,7 @@ import { SchedulesPage } from './pages/schedules/SchedulesPage'
// 评测(下一轮第 4 波,评测-D8):顶级导航,搭建者以上可见
import { EvalsPage } from './pages/evals/EvalsPage'
import { CaliberCatalog } from './pages/ledger/CaliberCatalog'
import { CaliberDrafts } from './pages/ledger/CaliberDrafts'
import { NotFound } from './pages/NotFound'
// 应用平台:使用面(FE-5)、搭建面的应用编辑(FE-6)
import { AppsHome } from './pages/apps/AppsHome'
@@ -196,6 +197,8 @@ function Shell() {
<Route path="/evals/:section" element={<EvalsPage />} />
<Route path="/evals/:section/:id" element={<EvalsPage />} />
<Route path="/ledger/calibers" element={<CaliberCatalog />} />
{/* 从业务文档起草口径:草稿只到定义为止,取数路径接一次真实运行 */}
<Route path="/ledger/drafts" element={<CaliberDrafts />} />
{/* 应用:固定路径(inbox)写在 /apps/:appId 前面,读的人一眼看出它不是应用 id */}
<Route path="/apps" element={<AppsHome />} />
{/* 要在 /apps/:appId 之前:create 不是应用 id */}
+26 -3
View File
@@ -12,12 +12,13 @@
* 请求函数从 ./http 导入,不从 ./client 导入(client.ts 要导入这个文件,反过来就成了循环)。
*/
import type {
CaliberDescriptor, CaliberTrialOut, CertifyQueue, FreezeOut, LedgerFeedbackOut, LedgerFeedbackVerdict, LedgerIssueOut,
LedgerReconcileIn, LedgerReconcileOut, RunLedger,
CaliberDescriptor, CaliberDraft, CaliberDraftAttachOut, CaliberDraftSummary, CaliberTrialOut, CertifyQueue, DraftMetric,
FreezeOut, LedgerFeedbackOut, LedgerFeedbackVerdict, LedgerIssueOut, LedgerReconcileIn, LedgerReconcileOut, RunLedger,
} from '../typesLedger'
import { get, post, type RequestOptions } from './http'
import { get, patch, post, type RequestOptions } from './http'
const at = (runId: string) => `/ledger/answers/${encodeURIComponent(runId)}`
const draftAt = (draftId: string) => `/ledger/drafts/${encodeURIComponent(draftId)}`
export const caliberLedger = {
run: (runId: string, opts?: RequestOptions) => get<RunLedger>(`/runs/${encodeURIComponent(runId)}/ledger`, opts),
@@ -46,4 +47,26 @@ export const caliberLedger = {
`/ledger/calibers/${encodeURIComponent(workflowId)}/${version}/${encodeURIComponent(nodeId)}/trial`,
{ inputs },
),
/**
* 从业务文档起草口径(app/ledger/docdraft.py,搭建者):草稿只产出定义,不产出取数。发起后立刻返回 drafting,模型在后台跑,
* 结果用 draft() 反复取,直到状态离开 drafting
*/
drafts: (status?: string, opts?: RequestOptions) =>
get<{ drafts: CaliberDraftSummary[] }>(`/ledger/drafts${status ? `?status=${encodeURIComponent(status)}` : ''}`, opts),
/** 一份草稿的完整内容:payload 摊平在上层(metrics / dropped / open_questions / alignment) */
draft: (draftId: string, opts?: RequestOptions) => get<CaliberDraft>(draftAt(draftId), opts),
/** 选文档发起起草:documents 是要用的知识库文档 id,guidance 是给模型看的附加要求 */
extractDraft: (body: { documents: string[]; collection?: string; datasource_id?: string; guidance?: string }) =>
post<{ draft_id: string; status: string }>('/ledger/drafts/extract', body),
/**
* 改标题、整体换掉指标、或把答清的问题消掉(resolved 是问题原文)。服务端据此重算状态:还有问题 → questions_open,
* 否则 path_pending;已经接上或驳回的不改状态
*/
patchDraft: (draftId: string, body: { title?: string; metrics?: DraftMetric[]; resolved?: string[] }) =>
patch<CaliberDraft>(draftAt(draftId), body),
/** 接上一次真实运行补取数路径(app/ledger/docattach.py):由它建工作流和版本,定义来自草稿 */
attachDraft: (draftId: string, runId: string) => post<CaliberDraftAttachOut>(`${draftAt(draftId)}/attach`, { run_id: runId }),
/** 「不是口径」:置 rejected,写一条审计,以后不再出现在列表的待办里 */
dismissDraft: (draftId: string, note?: string) =>
post<{ ok: boolean; draft_id: string; status: string }>(`${draftAt(draftId)}/dismiss`, { note: note ?? null }),
}
+113
View File
@@ -223,6 +223,119 @@ export const LEDGER_UI = {
loadFailed: '无法读取可沉淀的问题',
},
// ---- 从业务文档起草口径(/ledger/drafts;app/ledger/docdraft.py、docattach.py) ----
drafts: {
entry: '从业务文档起草',
title: '从业务文档起草口径',
subtitle: '文档里写的是业务说法,这里整理成定义草稿;取数路径接上一次真实运行',
back: '返回口径目录',
refresh: '刷新',
/** 列表按状态筛:全部加四种落点,起草中的只出现在全部里 */
filter: {
all: '全部',
questions_open: '待确认问题',
path_pending: '待接数据路径',
attached: '已接上',
rejected: '已驳回',
} as Record<string, string>,
status: {
drafting: '起草中',
questions_open: '待确认问题',
path_pending: '待接数据路径',
attached: '已接上',
rejected: '已驳回',
} as Record<string, string>,
filterLabel: '按状态筛',
summary: (metrics: number, questions: number) => `${formatNumber(metrics)} 个指标 · ${
questions ? `${formatNumber(questions)} 个未决问题` : '没有未决问题'}`,
loadFailed: '无法读取口径草稿',
empty: '还没有口径草稿',
emptyBody: '选一份业务文档,把里面的指标整理成定义草稿',
emptyFiltered: '这个状态下没有草稿,换个状态看看',
// ---- 发起:选文档、可选数据源和起草要求 ----
start: '起草一份新口径',
documents: '文档',
documentsHint: '从知识库里选;模型只读这些文档的段落',
noDocuments: '知识库里还没有已完成的文档',
noDocumentsBody: '先在知识页上传业务文档,切块完成后再来起草',
documentMeta: (collection: string, chunks: number) => `${collection} · ${formatNumber(chunks)} 段`,
datasource: '数据源(可选)',
datasourceHint: '只写进适用范围,不读表结构',
datasourceNone: '不指定',
guidance: '起草要求(可选)',
guidancePlaceholder: '例如:只整理和月度结算有关的指标',
submit: '开始起草',
submitting: '正在发起…',
needDoc: '请至少选一份文档',
started: '已开始起草,完成后指标会出现在下面',
// ---- 抽取中 ----
drafting: '正在抽取指标…',
draftingHint: '模型在后台读文档、核对引用,通常不到一分钟;离开页面不影响结果',
draftingTimeout: '起草超过一分钟还没有结果:稍后刷新再看;失败原因会写在这份草稿上',
draftingFailed: '起草没有完成',
// ---- 详情 ----
documentsUsed: (n: number) => `${formatNumber(n)} 份文档`,
truncated: '文档太长,后面的段落没有参与起草:可以少选几份,或先拆分文档',
metrics: '指标',
noMetrics: '没有整理出指标',
noMetricsBody: '文档里没有能逐字核对上引用的指标定义,可以换一份文档或补充起草要求后重来',
synonyms: (list: string) => `又称:${list}`,
unit: '单位',
grain: '粒度',
timeField: '时间字段',
dimensions: '维度',
filters: '过滤',
numerator: '分子',
denominator: '分母',
scope: '范围',
exclusions: '不包含',
owner: '负责人',
effectiveFrom: '生效日期',
citations: '引用原文',
paragraph: (n: number) => `第 ${n} 段`,
citationMissing: '原文段落无法读取',
questions: '未决问题',
questionsOther: '跨指标的问题',
resolve: '回答并移除',
resolveHint: '在业务上问清之后勾选对应的问题,移除后状态会跟着变',
resolveNeed: '请先勾选已经问清的问题',
resolved: (n: number) => `已移除 ${formatNumber(n)} 个问题`,
dropped: '被丢弃的条目',
droppedHint: '只产出定义:带 SQL、表名痕迹,或引用无法逐字核对上的指标不留下',
alignment: '和已发布口径对齐',
alignmentNew: '新的',
alignmentDuplicate: '已有出处',
alignmentConflict: '定义不同',
model: (id: string) => `起草模型:${id}`,
createdBy: (who: string) => `起草人:${who}`,
// ---- 接上一次运行 ----
attach: '接上一次真实运行',
attachHint: '取数路径照抄这次运行里执行过的查询,文档只贡献定义;接上后产出可试运行的工作流版本,不发布',
runId: '运行 id',
runIdPlaceholder: '粘贴运行记录里的 id',
recentRuns: '最近成功的运行',
recentRunsNone: '没有可选的运行',
recentRunsPick: '选一次运行填入左边',
attachAction: '接上',
attaching: '正在接入…',
attachNeedRun: '请先填运行 id',
attached: (version: number) => `已接上,工作流产出版本 v${version}`,
attachWarnings: '这些指标没有对上已有口径卡,需要人决定新建还是忽略:',
openWorkflow: '打开工作流',
// ---- 不是口径 ----
dismiss: '不是口径',
dismissTitle: '这份草稿不是口径',
dismissNote: '理由(可选)',
dismissPlaceholder: '例如:一次性的核对,不需要固定下来',
dismissConfirm: '确认不是口径',
dismissed: '已驳回,这份草稿不再出现在待办里',
},
// ---- 设置:口径账本一组 ----
settings: {
title: '口径账本',
+10 -4
View File
@@ -7,12 +7,13 @@
*/
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronDown, Layers, Play, RefreshCw, Search } from 'lucide-react'
import { ChevronDown, FileText, 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 { LEDGER_UI } from '../../lib/termsLedger'
import type { FormSchema } from '../../typesApp'
import type { CaliberDescriptor, CaliberTrialOut } from '../../typesLedger'
@@ -78,9 +79,14 @@ export function CaliberCatalog() {
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>} />
actions={<>
<Link to="/ledger/drafts" className="btn btn-xs btn-ghost" data-caliber-drafts-link="">
<FileText size={12} aria-hidden /> {LEDGER_UI.drafts.entry}
</Link>
<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">
+674
View File
@@ -0,0 +1,674 @@
/**
* 从业务文档起草口径(/ledger/drafts;服务端 app/ledger/docdraft.py、docattach.py)。
*
* 业务文档写的是业务说法(名称、粒度、分子分母、过滤),不是库里的表和 SQL。这一页做三段:
* - 发起:选知识库文档,可选数据源(只写进适用范围)和一段起草要求。模型在后台读文档、核对引用,页面轮询详情直到状态离开
* drafting(最多一分钟),期间显示抽取中;
* - 审读:每条指标带单位、粒度、定义和逐字核对过的引用原文(第 N 段),逐条的未决问题可以勾掉;被丢弃的条目单独列出原因。
* 引用的段落正文按需从知识库取,省得一次读几十份文档的全部段落;
* - 接上:草稿只到定义为止,取数路径必须来自一次真实运行。填运行 id(也可以从最近成功的运行里选),attach 之后产出可试运行
* 的工作流版本,不发布;不是口径就驳回并记一条审计。
*
* 状态由服务端算(还有问题 → questions_open,否则 path_pending;attach 之后 attached),页面只按状态画。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronLeft, FileText, 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'
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,
} from '../../typesLedger'
const D = LEDGER_UI.drafts
/** 列表的状态筛:全部加四种落点,起草中的只出现在全部里 */
type Filter = '' | Exclude<CaliberDraftStatus, 'drafting'>
const FILTERS: Filter[] = ['', 'questions_open', 'path_pending', 'attached', 'rejected']
/** 状态标签:起草中 / 待确认问题 / 待接数据路径 / 已接上 / 已驳回 */
function StatusPill({ status }: { status: CaliberDraftStatus }) {
const color = status === 'attached' ? 'var(--ok)' : status === 'rejected' ? 'var(--text-faint)'
: status === 'drafting' ? 'var(--accent)' : 'var(--warn)'
return (
<span className="shrink-0 rounded border px-1.5 py-0.5 text-[10px]"
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, var(--border))` }}>
{D.status[status] ?? status}
</span>
)
}
/** span 是引用在段落原文里的 [start, end):切成「前 / 引用 / 后」三段高亮 */
function spanParts(content: string, span?: [number, number]): [string, string, string] | null {
if (!span) return null
const [start, end] = span
if (start < 0 || end > content.length || start >= end) return null
return [content.slice(0, start), content.slice(start, end), content.slice(end)]
}
function QuoteBody({ content, span, quote }: { content: string; span?: [number, number]; quote: string }) {
const parts = spanParts(content, span)
if (!content) return <>{quote}</>
if (!parts) return <>{content}</>
return (
<>
{parts[0]}
<mark className="rounded px-0.5 text-fg" style={{ background: 'color-mix(in srgb, var(--warn) 22%, transparent)' }}>
{parts[1]}
</mark>
{parts[2]}
</>
)
}
/** 草稿和已发布口径的对齐:新的、已有出处的、定义不同的 */
function AlignmentBlock({ alignment }: { alignment: DraftAlignment }) {
const added = alignment.new ?? []
const duplicate = alignment.duplicate ?? []
const conflict = alignment.conflict ?? []
if (!added.length && !duplicate.length && !conflict.length) return null
const row = (label: string, items: { name: string; reason?: string }[]) => items.length > 0 && (
<li className="flex flex-wrap items-baseline gap-x-1.5">
<span className="text-faint">{label}</span>
{items.map((item) => (
<span key={item.name}>
{item.name}
{item.reason ? `(${item.reason})` : ''}
</span>
))}
</li>
)
return (
<div data-draft-alignment="">
<h3 className="mb-1 text-2xs font-semibold text-dim">{D.alignment}</h3>
<ul className="space-y-1 text-2xs leading-relaxed">
{row(D.alignmentNew, added.map((name) => ({ name })))}
{row(D.alignmentDuplicate, duplicate)}
{row(D.alignmentConflict, conflict)}
</ul>
</div>
)
}
export function CaliberDrafts() {
const [drafts, setDrafts] = useState<CaliberDraftSummary[] | null>(null)
const [listError, setListError] = useState<unknown>(null)
const [filter, setFilter] = useState<Filter>('')
const [openId, setOpenId] = useState<string | null>(null)
const [detail, setDetail] = useState<CaliberDraft | null>(null)
const [detailError, setDetailError] = useState<unknown>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [docs, setDocs] = useState<KbDocument[] | null>(null)
const [docsError, setDocsError] = useState<unknown>(null)
const [sources, setSources] = useState<DataSourceName[]>([])
const [pickedDocs, setPickedDocs] = useState<string[]>([])
const [datasourceId, setDatasourceId] = useState('')
const [guidance, setGuidance] = useState('')
const [submitting, setSubmitting] = useState(false)
const [pickedQuestions, setPickedQuestions] = useState<string[]>([])
const [busy, setBusy] = useState<string | null>(null)
const [runId, setRunId] = useState('')
const [recentRuns, setRecentRuns] = useState<Run[] | null>(null)
const [attachOut, setAttachOut] = useState<{ workflow_id: string; version: number; warnings: string[] } | null>(null)
/** 起草中的草稿:每两秒取一次详情,状态离开 drafting 或超过一分钟就停 */
const [pollingId, setPollingId] = useState<string | null>(null)
const [pollTimedOut, setPollTimedOut] = useState(false)
/** 引用所在段落的正文和段号:按文档懒取,取过的记在这里 */
const [chunks, setChunks] = useState<Record<string, { ordinal: number; content: string }>>({})
const asked = useRef(new Set<string>())
/** 连续点开两份草稿时,晚到的那次详情不覆盖现在打开的那份 */
const openSeq = useRef(0)
const loadList = useCallback(() => {
setListError(null)
api.caliberLedger.drafts().then(
(body) => setDrafts(body.drafts),
(e) => { setDrafts(null); setListError(e) },
)
}, [])
useEffect(loadList, [loadList])
useEffect(() => {
api.kb.documents().then((rows) => { setDocs(rows); setDocsError(null) },
(e) => { setDocs([]); setDocsError(e) })
api.datasources.names().then(
(all) => setSources(all.filter((s) => s.enabled !== false)),
() => setSources([]),
)
}, [])
const ensureChunks = useCallback((documentId: string) => {
if (!documentId || asked.current.has(documentId)) return
asked.current.add(documentId)
api.kb.document(documentId).then(({ chunks: rows }) => {
setChunks((cur) => {
const next = { ...cur }
for (const row of rows) next[row.id] = { ordinal: row.ordinal, content: row.content }
return next
})
}, () => { asked.current.delete(documentId) })
}, [])
const open = async (id: string) => {
const seq = ++openSeq.current
setOpenId(id)
setDetail(null)
setDetailError(null)
setDetailLoading(true)
setPickedQuestions([])
setAttachOut(null)
setRunId('')
setRecentRuns(null)
setPollingId(null)
setPollTimedOut(false)
try {
const draft = await api.caliberLedger.draft(id)
if (seq !== openSeq.current) return
setDetail(draft)
if (draft.status === 'drafting') { setPollTimedOut(false); setPollingId(id) }
} catch (e) {
if (seq !== openSeq.current) return
setDetailError(e)
} finally {
if (seq === openSeq.current) setDetailLoading(false)
}
api.runs.list({ status: 'succeeded', limit: 20 }).then(
(runs) => { if (seq === openSeq.current) setRecentRuns(runs) },
() => { if (seq === openSeq.current) setRecentRuns([]) },
)
}
useEffect(() => {
if (!pollingId) return
const until = Date.now() + 60_000
let alive = true
const timer = setInterval(() => {
api.caliberLedger.draft(pollingId).then((draft) => {
if (!alive) return
setDetail((cur) => (cur && cur.id === draft.id ? draft : cur))
if (draft.status !== 'drafting') {
setPollingId(null)
setPollTimedOut(false)
loadList()
} else if (Date.now() > until) {
setPollingId(null)
setPollTimedOut(true)
loadList()
}
}, (e) => {
if (!alive) return
setPollingId(null)
setDetailError(e)
})
}, 2000)
return () => { alive = false; clearInterval(timer) }
}, [pollingId, loadList])
useEffect(() => {
for (const doc of detail?.documents ?? []) ensureChunks(doc.document_id)
}, [detail, ensureChunks])
const shown = useMemo(
() => (drafts ?? []).filter((d) => !filter || d.status === filter),
[drafts, filter],
)
const chunkDoc = useMemo(() => {
const map: Record<string, string> = {}
for (const doc of detail?.documents ?? []) for (const chunk of doc.chunk_ids) map[chunk] = doc.document_id
return map
}, [detail])
const paragraphOf = (citation: DraftCitation): number | null => {
const ordinal = chunks[citation.chunk]?.ordinal
if (typeof ordinal === 'number') return ordinal
const documentId = citation.document_id || chunkDoc[citation.chunk]
const doc = detail?.documents?.find((d) => d.document_id === documentId)
const at = doc?.chunk_ids.indexOf(citation.chunk) ?? -1
return at >= 0 ? at + 1 : null
}
const submit = async () => {
if (!pickedDocs.length) { toast.warn(D.needDoc); return }
setSubmitting(true)
try {
const out = await api.caliberLedger.extractDraft({
documents: pickedDocs,
...(datasourceId ? { datasource_id: datasourceId } : {}),
...(guidance.trim() ? { guidance: guidance.trim() } : {}),
})
toast.ok(D.started)
setPickedDocs([])
setGuidance('')
setDatasourceId('')
setFilter('')
loadList()
void open(out.draft_id)
} catch (e) {
toast.error(e)
} finally {
setSubmitting(false)
}
}
const resolve = async (draft: CaliberDraft) => {
if (!pickedQuestions.length) { toast.warn(D.resolveNeed); return }
setBusy(`resolve:${draft.id}`)
try {
const fresh = await api.caliberLedger.patchDraft(draft.id, { resolved: pickedQuestions })
setDetail(fresh)
toast.ok(D.resolved(pickedQuestions.length))
setPickedQuestions([])
loadList()
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
const attach = async (draft: CaliberDraft) => {
const id = runId.trim()
if (!id) { toast.warn(D.attachNeedRun); return }
setBusy(`attach:${draft.id}`)
try {
const out = await api.caliberLedger.attachDraft(draft.id, id)
setAttachOut(out)
setDetail(await api.caliberLedger.draft(draft.id))
toast.ok(D.attached(out.version))
loadList()
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
const dismiss = async (draft: CaliberDraft) => {
const note = await promptDialog({
title: D.dismissTitle, label: D.dismissNote, placeholder: D.dismissPlaceholder, confirmLabel: D.dismissConfirm,
})
if (note == null) return
setBusy(`dismiss:${draft.id}`)
try {
await api.caliberLedger.dismissDraft(draft.id, note.trim() || undefined)
setDetail(await api.caliberLedger.draft(draft.id))
toast.ok(D.dismissed)
loadList()
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
const toggleQuestion = (question: string, checked: boolean) => {
setPickedQuestions((cur) => (checked ? [...cur, question] : cur.filter((q) => q !== question)))
}
const questionList = (questions: string[] | undefined, prefix: string) => {
const list = (questions ?? []).filter((q) => q.trim())
if (!list.length) return null
return (
<ul className="mt-1 space-y-0.5">
{list.map((q, i) => (
<li key={`${prefix}:${i}`}>
<label className="flex items-start gap-1.5 text-2xs leading-relaxed text-dim">
<input type="checkbox" className="mt-0.5 accent-[var(--accent)]" checked={pickedQuestions.includes(q)}
onChange={(e) => toggleQuestion(q, e.target.checked)} />
<span className="min-w-0 [overflow-wrap:anywhere]">{q}</span>
</label>
</li>
))}
</ul>
)
}
const citationRow = (citation: DraftCitation, key: string, metricName: string) => {
const chunk = chunks[citation.chunk]
const ordinal = paragraphOf(citation)
return (
<li key={key}>
<details className="rounded border bg-elev px-2 py-1">
<summary className="cursor-pointer text-2xs leading-relaxed text-dim">
<span className="[overflow-wrap:anywhere]">「{citation.quote}」</span>
{ordinal != null && <span className="ml-1.5 text-faint">· {D.paragraph(ordinal)}</span>}
</summary>
<p className="mt-1 whitespace-pre-wrap text-2xs leading-relaxed text-faint">
{chunk
? <QuoteBody content={chunk.content} span={citation.span} quote={citation.quote} />
: D.citationMissing}
</p>
<p className="mt-0.5 text-[10px] text-faint">{metricName}</p>
</details>
</li>
)
}
const renderMetric = (metric: DraftMetric, index: number) => {
const definition = metric.definition ?? {}
const citations = definition.citations ?? []
const rows = [
[D.unit, metric.unit],
[D.grain, metric.grain],
[D.timeField, metric.time_field],
[D.numerator, metric.numerator],
[D.denominator, metric.denominator],
[D.scope, definition.scope],
[D.owner, definition.owner],
[D.effectiveFrom, definition.effective_from],
[D.dimensions, metric.dimensions?.join('、')],
[D.filters, metric.filters?.join(';')],
[D.exclusions, definition.exclusions?.join(';')],
].filter(([, value]) => typeof value === 'string' && value.trim()) as [string, string][]
return (
<li key={`${metric.name}:${index}`} className="rounded-md border bg-panel px-3 py-2" data-draft-metric={metric.name}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-xs font-medium">{metric.name}</span>
{!!metric.synonyms?.length && <span className="text-2xs text-faint">{D.synonyms(metric.synonyms.join('、'))}</span>}
</div>
{rows.length > 0 && (
<dl className="mt-1 grid gap-x-3 gap-y-0.5 text-2xs sm:grid-cols-[auto_1fr]">
{rows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-faint">{label}</dt>
<dd className="text-dim">{value}</dd>
</div>
))}
</dl>
)}
{definition.text && <p className="mt-1.5 text-2xs leading-relaxed text-dim">{definition.text}</p>}
{citations.length > 0 && (
<div className="mt-1.5">
<h4 className="mb-1 text-2xs font-semibold text-faint">{D.citations}</h4>
<ul className="space-y-1">{citations.map((c, i) => citationRow(c, `${index}:${i}`, metric.name))}</ul>
</div>
)}
{!!metric.open_questions?.length && (
<div className="mt-1.5" data-draft-questions="">
<h4 className="text-2xs font-semibold text-faint">{D.questions}</h4>
{questionList(metric.open_questions, `${index}`)}
</div>
)}
</li>
)
}
const renderDetail = (draft: CaliberDraft) => {
const drafting = draft.status === 'drafting'
const terminal = draft.status === 'attached' || draft.status === 'rejected'
const questions = draft.open_questions ?? []
const dropped = draft.dropped ?? []
const metrics = draft.metrics ?? []
return (
<div className="space-y-3 border-t px-3 py-3" data-draft-detail="">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-faint">
<StatusPill status={draft.status} />
{!!draft.documents?.length && <span>{D.documentsUsed(draft.documents.length)}</span>}
{draft.created_at && <span title={formatDateTime(draft.created_at)}>{formatTime(draft.created_at)}</span>}
{draft.created_by && <span>{D.createdBy(draft.created_by)}</span>}
{draft.model_id && <span title={draft.model_id}>{D.model(draft.model_id)}</span>}
{draft.workflow_id && draft.status === 'attached' && (
<Link to={`/studio/${draft.workflow_id}`} className="text-accent hover:underline" data-draft-workflow="">
{D.openWorkflow}{draft.workflow_name ? `:${draft.workflow_name}` : ''}
</Link>
)}
</div>
{draft.error && (
<Notice tone="err">
{drafting ? D.draftingFailed : D.loadFailed}:{draft.error}
</Notice>
)}
{drafting && pollingId === draft.id && (
<Notice tone="info">
<span className="inline-flex items-center gap-1.5"><Spinner size={12} /> {D.drafting}</span>
<span className="mt-0.5 block text-faint">{D.draftingHint}</span>
</Notice>
)}
{drafting && pollTimedOut && <Notice tone="warn">{D.draftingTimeout}</Notice>}
{draft.truncated && <Notice tone="warn">{D.truncated}</Notice>}
<div>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-2xs font-semibold text-dim">{D.metrics}</h3>
{pickedQuestions.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-2xs text-faint">{D.resolveHint}</span>
<button type="button" className="btn btn-xs" disabled={busy === `resolve:${draft.id}`}
onClick={() => void resolve(draft)} data-draft-resolve="">
{busy === `resolve:${draft.id}` ? <Spinner size={11} /> : null} {D.resolve}
</button>
</div>
)}
</div>
{metrics.length ? (
<ul className="space-y-2">{metrics.map(renderMetric)}</ul>
) : !drafting ? (
<EmptyState offline={false} icon={<ScrollText size={20} />} title={D.noMetrics} body={D.noMetricsBody} />
) : (
<Skeleton rows={3} height={16} />
)}
</div>
{questions.length > 0 && (
<div data-draft-questions-all="">
<h3 className="mb-1 text-2xs font-semibold text-dim">{D.questionsOther}</h3>
{questionList(questions, 'top')}
</div>
)}
{dropped.length > 0 && (
<div data-draft-dropped="">
<h3 className="text-2xs font-semibold text-dim">{D.dropped}</h3>
<p className="mb-1 text-2xs leading-relaxed text-faint">{D.droppedHint}</p>
<ul className="space-y-0.5 text-2xs leading-relaxed">
{dropped.map((item, i) => (
<li key={`${item.name}:${i}`} className="text-dim">
<span className="font-medium">{item.name}</span>
<span className="text-faint">:{item.reason}</span>
</li>
))}
</ul>
</div>
)}
{draft.alignment && <AlignmentBlock alignment={draft.alignment} />}
{!drafting && !terminal && (
<div className="rounded-md border bg-elev px-3 py-2" data-draft-attach="">
<h3 className="text-2xs font-semibold text-dim">{D.attach}</h3>
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{D.attachHint}</p>
<div className="mt-1.5 flex flex-wrap items-end gap-2">
<label className="min-w-40 flex-1 text-2xs text-faint">
<span className="mb-0.5 block">{D.runId}</span>
<input className="input w-full" value={runId} placeholder={D.runIdPlaceholder}
onChange={(e) => setRunId(e.target.value)} aria-label={D.runId} data-draft-run-id="" />
</label>
<label className="w-56 text-2xs text-faint">
<span className="mb-0.5 block">{D.recentRuns}</span>
<select className="field h-[30px] w-full" value="" aria-label={D.recentRuns}
onChange={(e) => { if (e.target.value) setRunId(e.target.value) }}>
<option value="">
{recentRuns == null ? D.recentRunsPick : recentRuns.length ? D.recentRunsPick : D.recentRunsNone}
</option>
{(recentRuns ?? []).map((run) => (
<option key={run.id} value={run.id}>
{run.workflow_name || run.id}{run.created_at ? ` · ${formatTime(run.created_at)}` : ''}
</option>
))}
</select>
</label>
<button type="button" className="btn btn-primary btn-sm" disabled={busy === `attach:${draft.id}`}
onClick={() => void attach(draft)} data-draft-attach-submit="">
{busy === `attach:${draft.id}` ? <Spinner size={12} /> : null} {busy === `attach:${draft.id}` ? D.attaching : D.attachAction}
</button>
</div>
</div>
)}
{attachOut && (
<div className="rounded-md border bg-elev px-3 py-2 text-2xs leading-relaxed text-dim" data-draft-attached="">
<p>{D.attached(attachOut.version)}</p>
<Link to={`/studio/${attachOut.workflow_id}`} className="text-accent hover:underline">
{D.openWorkflow}
</Link>
{attachOut.warnings.length > 0 && (
<div className="mt-1">
<span className="text-faint">{D.attachWarnings}</span>
<ul className="mt-0.5 list-disc pl-4">
{attachOut.warnings.map((w) => <li key={w}>{w}</li>)}
</ul>
</div>
)}
</div>
)}
{!terminal && (
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn btn-xs btn-ghost" disabled={!!busy}
onClick={() => void dismiss(draft)} data-draft-dismiss="">
{busy === `dismiss:${draft.id}` ? <Spinner size={11} /> : null} {D.dismiss}
</button>
</div>
)}
</div>
)
}
const pendingDocs = !docs ? <Skeleton rows={4} height={22} />
: docs.length === 0 ? <p className="py-2 text-2xs leading-relaxed text-faint">{D.noDocuments}:{D.noDocumentsBody}</p>
: (
<ul className="max-h-40 space-y-0.5 overflow-y-auto pr-1">
{docs.map((doc) => {
const ready = doc.status !== 'processing' && doc.status !== 'failed'
return (
<li key={doc.id}>
<label className={clsx('flex items-start gap-1.5 text-2xs leading-relaxed',
ready ? 'text-dim' : 'text-faint')}>
<input type="checkbox" className="mt-0.5 accent-[var(--accent)]" disabled={!ready}
checked={pickedDocs.includes(doc.id)}
onChange={(e) => setPickedDocs((cur) => (e.target.checked
? [...cur, doc.id] : cur.filter((id) => id !== doc.id)))} />
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">{doc.title || doc.id}</span>
<span className="shrink-0 text-faint">{D.documentMeta(doc.collection, doc.chunk_count)}</span>
</label>
</li>
)
})}
</ul>
)
return (
<div className="flex h-full flex-col" data-caliber-drafts="">
<PageHeader icon={<FileText size={14} />} title={D.title} subtitle={D.subtitle}
actions={<>
<Link to="/ledger/calibers" className="btn btn-xs btn-ghost">
<ChevronLeft size={12} aria-hidden /> {D.back}
</Link>
<button type="button" className="btn btn-xs btn-ghost" onClick={loadList} title={D.refresh}>
<RefreshCw size={12} aria-hidden /> {D.refresh}
</button>
</>} />
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-3 p-4">
<section className="rounded-lg border bg-panel px-3 py-2.5" data-draft-start="">
<h2 className="text-xs font-semibold">{D.start}</h2>
<div className="mt-2 grid gap-3 md:grid-cols-2">
<div>
<h3 className="text-2xs font-semibold text-dim">{D.documents}</h3>
<p className="mb-1 text-2xs text-faint">{D.documentsHint}</p>
{docsError ? <ErrorState error={docsError} compact /> : pendingDocs}
</div>
<div className="space-y-2">
<label className="block text-2xs text-faint">
<span className="mb-0.5 block font-semibold text-dim">{D.datasource}</span>
<select className="field w-full" value={datasourceId} onChange={(e) => setDatasourceId(e.target.value)}>
<option value="">{D.datasourceNone}</option>
{sources.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<span className="mt-0.5 block">{D.datasourceHint}</span>
</label>
<label className="block text-2xs text-faint">
<span className="mb-0.5 block font-semibold text-dim">{D.guidance}</span>
<textarea className="field min-h-[64px] w-full text-xs" rows={3} value={guidance}
placeholder={D.guidancePlaceholder} onChange={(e) => setGuidance(e.target.value)} />
</label>
<button type="button" className="btn btn-primary btn-sm" disabled={submitting}
onClick={() => void submit()} data-draft-submit="">
{submitting ? <Spinner size={12} /> : <FileText size={12} aria-hidden />} {submitting ? D.submitting : D.submit}
</button>
</div>
</div>
</section>
<div className="flex flex-wrap items-center gap-1" role="group" aria-label={D.filterLabel}>
{FILTERS.map((f) => (
<button key={f || 'all'} type="button"
className={clsx('btn btn-xs', filter === f ? 'btn-primary' : 'btn-ghost')}
aria-pressed={filter === f} onClick={() => setFilter(f)} data-draft-filter={f || 'all'}>
{f ? D.filter[f] : D.filter.all}
{drafts && <span className="tnum ml-1 text-[10px] opacity-70">
{f ? drafts.filter((d) => d.status === f).length : drafts.length}
</span>}
</button>
))}
</div>
{listError ? <ErrorState error={listError} onRetry={loadList} compact />
: !drafts ? <Skeleton rows={5} height={38} />
: !shown.length ? (
<EmptyState offline={false} icon={<FileText size={22} />} title={D.empty}
body={filter || drafts.length ? D.emptyFiltered : D.emptyBody} />
) : shown.map((draft) => {
const expanded = openId === draft.id
return (
<section key={draft.id} className="overflow-hidden rounded-lg border bg-panel" data-draft={draft.id}>
<button type="button" className="flex w-full items-start gap-3 px-3 py-2.5 text-left hover:bg-hover"
onClick={() => {
if (expanded) {
openSeq.current++
setOpenId(null)
setDetail(null)
setPollingId(null)
} else {
void open(draft.id)
}
}}
aria-expanded={expanded} data-draft-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 [overflow-wrap:anywhere]">{draft.title}</span>
<StatusPill status={draft.status} />
{draft.error && <span className="text-2xs text-[var(--err)]">{D.draftingFailed}</span>}
</div>
<div className="mt-0.5 text-2xs text-faint">
{D.summary(draft.metric_count, draft.question_count)}
{draft.created_at ? ` · ${formatTime(draft.created_at)}` : ''}
</div>
</div>
</button>
{expanded && (
detailLoading ? <div className="border-t px-3 py-3"><Skeleton rows={4} height={16} /></div>
: detailError ? <div className="border-t px-3 py-3"><ErrorState error={detailError} compact
onRetry={() => void open(draft.id)} /></div>
: detail && detail.id === draft.id ? renderDetail(detail) : null
)}
</section>
)
})}
</div>
</div>
</div>
)
}
+104
View File
@@ -437,3 +437,107 @@ export interface CaliberTrialOut {
error?: string | null
metrics: CaliberTrialMetric[]
}
// ---------------------------------------------------------------- 从业务文档起草口径
/**
* 草稿的状态(app/ledger/docdraft.STATUSES):起草中 → 有待确认的问题 / 待接数据路径 → 已接上;
* 另有已驳回。path_pending 不是合法图,取数路径只能来自一次真实运行。
*/
export type CaliberDraftStatus = 'drafting' | 'questions_open' | 'path_pending' | 'attached' | 'rejected'
/** 一条引用:逐字原文、原文所在段落(chunk)、span 是原文里的 [start, end) */
export interface DraftCitation {
chunk: string
document_id?: string
quote: string
span?: [number, number]
}
/** 一条指标的定义(docdraft.DEFINITION_FIELDS) */
export interface DraftDefinition {
text?: string
scope?: string
exclusions?: string[]
owner?: string
effective_from?: string
citations?: DraftCitation[]
}
/** 草稿里的一条指标:只有业务定义,不含 SQL、表名、列名 */
export interface DraftMetric {
name: string
synonyms?: string[]
unit?: string
/** 粒度,如「日」「订单行」 */
grain?: string
/** 业务时间字段的业务叫法 */
time_field?: string
filters?: string[]
numerator?: string
denominator?: string
dimensions?: string[]
definition?: DraftDefinition
open_questions?: string[]
}
/** 被丢弃的条目和原因(含 SQL 痕迹、引用核对不上) */
export interface DraftDropped {
name: string
reason: string
}
/** 草稿和已发布口径的对齐结果:新的、已有出处的、和现有口径定义不同的 */
export interface DraftAlignment {
new: string[]
duplicate: { name: string; workflow_id?: string; version?: number; node_id?: string }[]
conflict: { name: string; workflow_id?: string; version?: number; reason?: string }[]
}
/** 起草时用了哪份文档的哪些段落 */
export interface DraftDocument {
document_id: string
title: string
chunk_ids: string[]
}
/** GET /api/ledger/drafts 的一条:只有计数和状态,正文用 GET /api/ledger/drafts/{id} 看 */
export interface CaliberDraftSummary {
id: string
title: string
status: CaliberDraftStatus
metric_count: number
question_count: number
workflow_id: string | null
created_at: string | null
error: string | null
}
/** GET /api/ledger/drafts/{id}:payload 摊平在上层(documents / metrics / dropped / open_questions / alignment) */
export interface CaliberDraft extends CaliberDraftSummary {
documents?: DraftDocument[]
metrics?: DraftMetric[]
dropped?: DraftDropped[]
open_questions?: string[]
alignment?: DraftAlignment
/** 文档超过一次能发给模型的字数,后面的段落没有参与起草 */
truncated?: boolean
model_id?: string | null
collection?: string | null
datasource_id?: string | null
/** 起草时的文档 id 和附加要求 */
origin?: { documents?: string[]; guidance?: string }
created_by?: string | null
updated_at?: string | null
workflow_name?: string | null
}
/** POST /api/ledger/drafts/{id}/attach:接上一次真实运行后建出的工作流版本 */
export interface CaliberDraftAttachOut {
workflow_id: string
version: number
/** 并进了定义的指标(口径卡上的 metric) */
metrics: unknown[]
/** 对不上已有指标名的那些,由人决定新建还是忽略 */
warnings: string[]
}