From 57f2746c59d9998185acbe235e50a733220dcdcf Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 10:46:08 +0000 Subject: [PATCH 1/4] Open the regulations citation frontend task card Why: U7 frontend rendering of structured regulation citations needs a task record. Evidence: Card only. Impact: None on code. Refs: Agent-System-Dev docs/superpowers/specs/2026-10-18-regulations-citation-contract.md Co-authored-by: Cursor --- .task/feature-regulations-kb-citations.md | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .task/feature-regulations-kb-citations.md diff --git a/.task/feature-regulations-kb-citations.md b/.task/feature-regulations-kb-citations.md new file mode 100644 index 0000000..7d91a0c --- /dev/null +++ b/.task/feature-regulations-kb-citations.md @@ -0,0 +1,40 @@ +# 法规结构化引用前端呈现(U7) 任务卡 + +任务主记录:本文件随 PR 描述维护(PR 链接见下方“结果与验证”)。 +状态:进行中;owner:weichen。 + +## 基线与授权 + +- 权威仓:weichen/Agent-System-Frontend;root_target:main;immediate_target:main +- branch_kind:standalone;initiative_id:无(跨仓大任务 Agent-System-Dev `feature/regulations-kb` 的 U7 前端子交付,Dev PR #614 登记) +- R0(initial_main_sha):0459567(origin/main);工作区:/home/weichen/projects/Agent-System-Frontend-wt-feature-regulations-kb-citations(worktree);分支:feature/regulations-kb-citations +- 父任务信息(仅子任务填写):无 +- accepting_requirements:false +- 需求分流:verdict=JOIN(由 Dev 大任务承接);selected_initiative=Agent-System-Dev feature/regulations-kb;理由=计划 U7 前端范围 +- 授权与交付终点:owner 以 /goal 授权按法规知识库计划全部实施;终点为本 PR 合入 main,再由 RDM/Dev 组合钉住。 +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区) + +## 目标与非目标 + +目标:按 Dev 规格 `docs/superpowers/specs/2026-10-18-regulations-citation-contract.md` 第 5、6 节,在审计工作区新增“法规核验”树(`rdm.inspect.tree kind=regs`)与批/引用详情;驾驶舱阅读器把正文中的 `[cit-…]` 渲染为编号引用标记并可展开元数据与原文片段;未核验、无效与待复核状态清晰区分。 + +非目标:不改 RDM 写路径与命令;不改设计令牌;不做部署。 + +## Agent 实施收据 + +| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---| +| frontend-u7-regulation-citations | src/、test/ | implementation | cursor→antigravity→pi | 待运行 | — | — | — | 否 | /tmp/regkb-u7fe-receipt.json | + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 | +|---|---|---|---|---| +| 启动 | 0459567 | 0459567 | 2026-10-18 | 一致 | +| 验收前 | 待填 | 待填 | 待填 | 待填 | + +## 结果与验证(完成后填写) + +- 结果:NOT_RUN +- 验证:NOT_RUN +- 影响与剩余项:NOT_RUN From 6a3f2447f0e77829c6b9671a32c3a42e37aa39c5 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sun, 18 Oct 2026 10:53:59 +0000 Subject: [PATCH 2/4] agent-exec transport 4cf12d612fc8a946cd63c630834d3beb/35d799203497 --- src/components/CockpitReader.tsx | 20 ++- src/components/RdmAuditWorkspace.tsx | 192 +++++++++++++++++++++- src/components/RegCitation.tsx | 183 +++++++++++++++++++++ src/reg-citations.css | 85 ++++++++++ src/regCitations.ts | 235 +++++++++++++++++++++++++++ test/reg-citations.test.ts | 199 +++++++++++++++++++++++ 6 files changed, 906 insertions(+), 8 deletions(-) create mode 100644 src/components/RegCitation.tsx create mode 100644 src/reg-citations.css create mode 100644 src/regCitations.ts create mode 100644 test/reg-citations.test.ts diff --git a/src/components/CockpitReader.tsx b/src/components/CockpitReader.tsx index 0326236..158b848 100644 --- a/src/components/CockpitReader.tsx +++ b/src/components/CockpitReader.tsx @@ -12,6 +12,8 @@ import { type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode, type CockpitVersionSummary, type Density, type PanelTabId, } from '../documentCockpit' +import type { CitationView } from '../regCitations' +import { CitedBody } from './RegCitation' export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string } @@ -19,6 +21,8 @@ interface NodeDetailShape { meta: CockpitMeta node: CockpitTreeNode body?: CockpitContentBlock[] + /** U7:章节正文 [cit-…] 标记的引用视图(契约 §3)。 */ + citations?: CitationView[] | null children: CockpitTreeNode[] sources: { items: any[] } history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string } @@ -118,7 +122,7 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: { {isSection && detail && ( <> - {renderBody(detail.body, detail.meta)} + {renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)} )} @@ -133,9 +137,13 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: { ? 内容受限({sec.blocks[0].reason || '权限'}) : sec.contentState === 'empty' && 空} - {sec.blocks.map((b, i) => b.kind === 'text' - ?

{b.text}

- :

内容受限——标题与状态可见,正文不展示

)} + {sec.blocks[0]?.kind === 'text' && ( + b.kind === 'text').map(b => b.text || '')} + citations={sectionDetails[sec.ref.id]?.citations} scope={`doc-${sec.ref.id}`} /> + )} + {sec.blocks.map((b, i) => b.kind !== 'text' && ( +

内容受限——标题与状态可见,正文不展示

+ ))} ))} @@ -145,14 +153,14 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: { ) } -function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) { +function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta, citations: CitationView[] | null | undefined, scope: string) { if (!body || body.length === 0) return
暂无正文
if (body[0].kind === 'restricted') { return
内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示
} return ( <> - {body.map((b, i) =>

{b.text}

)} + b.text || '')} citations={citations} scope={scope} /> {meta.truncated &&
详情已截断:{meta.truncationReasons?.join(';')}
} ) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index a3b2e88..a0551e0 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -7,6 +7,12 @@ import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' import { store, useStore } from '../store' import { exportNeedsDraft } from '../workspaces' +import { + CITATION_CHIP_TONE, citationStateLabel, citationStyle, legStateLabel, REG_TREE_KIND_LABELS, + regsTreeParams, sealStateLabel, + type RegBatchDetail, type RegCitationDetail, +} from '../regCitations' +import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation' // ---- 类型(U3 ListShell/详情的精简前端契约) ---- @@ -275,20 +281,202 @@ function ExportSection({ pid }: { pid: string }) { // ---- 资料与文档 ---- function MaterialsTab({ pid }: { pid: string }) { - const [kind, setKind] = useState<'notes' | 'docs'>('docs') + const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs') return (
+
- + {kind === 'regs' ? : }
) } +// ---- 法规核验树(U7:rdm.inspect.tree kind=regs;根层证据批,parent= 列引用) ---- + +function RegTreeNode({ item, selected, onSelect }: { item: TreeItem; selected: boolean; onSelect: () => void }) { + return ( + + ) +} + +function RegsTreeList({ pid }: { pid: string }) { + const [shell, setShell] = useState | null>(null) + const [err, setErr] = useState('') + const [children, setChildren] = useState>>({}) + const [selected, setSelected] = useState(null) + const [detail, setDetail] = useState(null) + const [detailErr, setDetailErr] = useState('') + // genRef:项目切换代际(树与子页);selRef:选中代际(快速切换时丢弃旧详情响应)。 + const genRef = useRef(0) + const selRef = useRef(0) + + useEffect(() => { + const gen = ++genRef.current + selRef.current++ + setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({}) + api.rdmCommand>('rdm.inspect.tree', regsTreeParams(pid)) + .then(s => { if (gen === genRef.current) setShell({ ...s, items: s.items || [] }) }) + .catch(e => { if (gen === genRef.current) setErr(e.message) }) + }, [pid]) + + const loadMore = () => { + if (!shell?.nextCursor) return + const gen = genRef.current + api.rdmCommand>('rdm.inspect.tree', regsTreeParams(pid, { cursor: shell.nextCursor })) + .then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...(s.items || [])] } : s) }) + .catch(e => { if (gen === genRef.current) setErr(e.message) }) + } + + const loadChildren = (batchId: string, cursor?: string) => { + const gen = genRef.current + api.rdmCommand>('rdm.inspect.tree', regsTreeParams(pid, { parent: batchId, cursor })) + .then(s => { + if (gen !== genRef.current) return + setChildren(prev => { + const items = cursor ? [...(prev[batchId]?.items || []), ...(s.items || [])] : (s.items || []) + return { ...prev, [batchId]: { ...s, items } } + }) + }) + .catch(e => { if (gen === genRef.current) setErr(e.message) }) + } + + const toggleBatch = (item: TreeItem) => { + if (children[item.id]) { + setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next }) + return + } + loadChildren(item.id) + } + + const selectNode = (item: TreeItem) => { + setSelected(item); setDetail(null); setDetailErr('') + const gen = genRef.current + const sel = ++selRef.current + const current = () => gen === genRef.current && sel === selRef.current + api.rdmCommand('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id }) + .then(d => { if (current()) setDetail(d) }) + .catch(e => { if (current()) setDetailErr(e.message) }) + } + + if (err) return + if (!shell) return
加载中…
+ if (shell.items.length === 0) return 暂无法规核验证据批 + + return ( +
+
+ {shell.items.map(item => { + const sub = children[item.id] + return ( +
+
+ + selectNode(item)} /> +
+ {sub && ( +
+ {sub.items.length === 0 &&
无引用
} + {sub.items.map(c => ( + selectNode(c)} /> + ))} + {sub.hasMore && ( + + )} + {sub.truncated &&
已截断(深度或数量上限)
} +
+ )} +
+ ) + })} + {shell.hasMore && } + {shell.truncated &&
已截断(深度或数量上限)
} +
+ {selected && ( +
+ 核验详情 + {detailErr ? + : !detail ?
加载中…
+ : selected.kind === 'reg-batch' + ? + : } +
+ )} +
+ ) +} + +function RegBatchView({ item, detail }: { item: TreeItem; detail: RegBatchDetail }) { + const reasons = detail.stateReasons || [] + const citations = detail.citations || [] + const resolutions = detail.resolutions || [] + return ( +
+
{detail.title || item.title || item.id}
+
reg-batch:{item.id}
+
状态:
+ {reasons.length > 0 && ( +
+ 状态原因 +
    {reasons.map((r, i) =>
  • {r}
  • )}
+
+ )} + {detail.legStates && ( +
+ 来源腿 +
+
知识库
{legStateLabel(detail.legStates.kb, detail.legStates.kbReason)}
+
网络
{legStateLabel(detail.legStates.web, detail.legStates.webReason)}
+
+
+ )} + {detail.seal && ( +
+ 封存 +
+
状态
{sealStateLabel(detail.seal.state)}
+
密钥
{detail.seal.keyId || '—'}
+
清单哈希
{detail.seal.manifestSha256 || '—'}
+
+
+ )} +
+ 引用({citations.length}) + {citations.length === 0 &&
该批没有引用
} + {citations.map(c => )} +
+ {resolutions.length > 0 && ( +
+ 人工裁决({resolutions.length}) + {resolutions.map((r, i) => ( +
+ {r.decision} · {r.actor} · {new Date(r.at).toLocaleString('zh-CN')}{r.note ? ` · ${r.note}` : ''} +
+ ))} +
+ )} +
+ ) +} + +function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitationDetail }) { + return ( +
+
{detail.title || item.title || item.id}
+ +
+ ) +} + function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { const [shell, setShell] = useState | null>(null) const [err, setErr] = useState('') diff --git a/src/components/RegCitation.tsx b/src/components/RegCitation.tsx new file mode 100644 index 0000000..76977d1 --- /dev/null +++ b/src/components/RegCitation.tsx @@ -0,0 +1,183 @@ +// U7 法规结构化引用的共享呈现:编号标记、引用卡片、引用清单(审计工作区与驾驶舱阅读器共用)。 +// 正文与原文片段一律作为文本节点渲染,不经 HTML。非 verified 状态用未核验样式,invalid 用无效样式; +// 折叠只用 @ui Collapsible;颜色/字号/圆角只消费 @ui/tokens.css(见 reg-citations.css)。 +import { Fragment, useState, type ReactNode } from 'react' +import { Chip, Collapsible } from '@ui' +import { + CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle, + disclosureLabel, excerptView, parseCitedBlocks, safeWebHref, staleLabel, + type CitationView, +} from '../regCitations' +import '../reg-citations.css' + +/** 状态徽标:核验状态 + 过期「待复核」。 */ +export function CitationStateChips({ c }: { c: { state?: string; stale?: boolean; staleReason?: string } }) { + const stale = staleLabel(c) + return ( + + {citationStateLabel(c.state)} + {stale && {stale}} + + ) +} + +function citationHeading(c: CitationView): string { + const parts = [c.docNo, c.clauseNo].filter(Boolean) + return parts.length > 0 ? parts.join(' ') : (c.title || c.id) +} + +function formatTime(at: string | undefined): string { + if (!at) return '—' + const d = new Date(at) + return Number.isNaN(d.getTime()) ? at : d.toLocaleString('zh-CN') +} + +function Excerpt({ id, label, text, redaction }: { id: string; label: string; text?: string; redaction?: string }) { + if (!text) return
{label}未展示:{redaction}
+ return ( + {label}}> +
{text}
+
+ ) +} + +/** 引用卡片:元数据 + 可展开原文片段(受披露约束);缺失或 invalid 的 ID 只给无效说明。 */ +export function CitationCard({ id, citation, scope }: { id: string; citation?: CitationView; scope: string }) { + const style = citationStyle(citation) + if (!citation || style === 'invalid') { + return ( +
+
+ 无效引用 + {id} +
+
+ {citation ? '引用 ID 无法解析为证据批' : '本节引用清单中没有该引用'}——不得作为法律依据 +
+
+ ) + } + const web = citation.web + const href = safeWebHref(web?.finalUrl || web?.url) + const fields: [string, ReactNode][] = [] + const add = (label: string, v: ReactNode) => { if (v !== undefined && v !== null && v !== '') fields.push([label, v]) } + add('文号', citation.docNo) + add('标题', citation.title) + add('版本', citation.editionLabel) + add('效力状态', citation.legalStatus) + add('条款', citation.clauseNo) + add('章节路径', citation.headingPath?.length ? citation.headingPath.join(' › ') : undefined) + add('语料修订', citation.corpusRevision && {citation.corpusRevision}) + add('原文披露', citation.disclosure && disclosureLabel(citation.disclosure)) + if (web) { + add('网络来源', href + ? {web.finalUrl || web.url} + : web.finalUrl || web.url) + add('官方来源', web.official ? '是' : '否') + add('抓取时间', formatTime(web.fetchedAt)) + } + add('证据批', citation.batchId && {citation.batchId}) + add('引用 ID', {citation.id}) + const ex = excerptView(citation) + return ( +
+
+ {citationHeading(citation)} + +
+ {style !== 'valid' &&
未核验引用——不得作为有效法律依据
} + {citation.claim &&
主张:{citation.claim}
} +
+ {fields.map(([label, v]) => ( +
{label}
{v}
+ ))} +
+ + {web?.excerpt && ( + + )} +
+ ) +} + +/** 引用清单一行:摘要触发 + 折叠卡片。 */ +export function CitationListItem({ id, n, citation, scope }: { id: string; n?: number; citation?: CitationView; scope: string }) { + const style = citationStyle(citation) + return ( +
+ + {n !== undefined && [{n}]} + {citation && style !== 'invalid' ? citationHeading(citation) : id} + {citation && style !== 'invalid' && citation.title && citation.title !== citationHeading(citation) && ( + {citation.title} + )} + {citation && style !== 'invalid' + ? + : 无效引用} + + }> + + +
+ ) +} + +/** 带引用的正文:[cit-…] 渲染为编号标记,点击在段落下展开引用卡片;正文下附引用清单。 */ +export function CitedBody({ texts, citations, scope, paraClassName = 'cockpit-reader-para' }: { + texts: string[] + citations?: CitationView[] | null + scope: string + paraClassName?: string +}) { + const [open, setOpen] = useState(null) + const { blocks, order } = parseCitedBlocks(texts) + const index = new Map((citations || []).map(c => [c.id, c])) + const list = citationListOrder(order, citations) + return ( + <> + {blocks.map((segs, bi) => { + const ids = [...new Set(segs.flatMap(s => (s.kind === 'cite' ? [s.id] : [])))] + if (ids.length === 0) { + return

{segs.map(s => (s.kind === 'text' ? s.text : '')).join('')}

+ } + return ( +
+

+ {segs.map((s, si) => { + if (s.kind === 'text') return {s.text} + const c = index.get(s.id) + const key = `${bi}:${s.id}` + const isOpen = open === key + return ( + + ) + })} +

+ {ids.map(id => ( + + + + ))} +
+ ) + })} + {list.length > 0 && ( +
+
引用({list.length})
+ {list.map((id, i) => ( + + ))} +
+ )} + + ) +} diff --git a/src/reg-citations.css b/src/reg-citations.css new file mode 100644 index 0000000..29d1ba4 --- /dev/null +++ b/src/reg-citations.css @@ -0,0 +1,85 @@ +/* U7 法规结构化引用:编号标记、引用卡片、引用清单与审计法规树。 + 颜色、圆角、字号只消费 @ui/tokens.css;折叠动效由 @ui Collapsible(mk-col)承担。 + 有效依据(verified)用实线成功色;未核验用虚线警告色;无效用虚线错误色加删除线。 */ + +.regcite-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; } +.regcite-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); color: var(--text-dim); word-break: break-all; } + +.regcite-mark { + display: inline; + margin: 0 2px; + padding: 0 4px; + border: 1px solid var(--border); + border-radius: var(--radius-xs); + background: none; + font: inherit; + font-size: var(--fs-ui-sm); + line-height: 1.4; + vertical-align: baseline; + cursor: pointer; +} +.regcite-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.regcite-mark.regcite-valid { color: var(--ok-text); border-color: var(--ok-border); background: var(--ok-soft); } +.regcite-mark.regcite-unverified { color: var(--warn-text); border-style: dashed; border-color: var(--warn); background: var(--warn-soft); } +.regcite-mark.regcite-invalid { color: var(--err-text); border-style: dashed; border-color: var(--err); background: var(--err-soft); text-decoration: line-through; } +.regcite-mark-open { outline: 1px solid var(--accent); outline-offset: 1px; } + +.regcite-block { margin: 0; } + +.regcite-card { + margin: 6px 0; + padding: 8px 12px; + border: 1px solid var(--border-soft); + border-left-width: 3px; + border-radius: var(--radius); + background: var(--bg-raised); + font-size: var(--fs-ui-sm); + color: var(--text); +} +.regcite-card-valid { border-left-color: var(--ok); } +.regcite-card-unverified { border-left-color: var(--warn); border-left-style: dashed; } +.regcite-card-invalid { border-left-color: var(--err); border-left-style: dashed; } +.regcite-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.regcite-card-title { font-weight: 600; color: var(--text); } +.regcite-note { margin-top: 4px; color: var(--text-dim); } +.regcite-claim { margin-top: 4px; color: var(--text); } +.regcite-fields { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 10px; margin: 6px 0 0; } +.regcite-fields dt { color: var(--text-dim); } +.regcite-fields dd { margin: 0; color: var(--text); word-break: break-word; } +.regcite-link { color: var(--accent-text); word-break: break-all; } +.regcite-toggle { font-size: var(--fs-ui-sm); color: var(--accent-text); } +.regcite-excerpt { + margin: 4px 0 0; + padding: 6px 10px; + border-left: 2px solid var(--border-strong); + border-radius: var(--radius-xs); + background: var(--bg-inset); + white-space: pre-wrap; + color: var(--text); +} +.regcite-redaction { margin-top: 6px; color: var(--text-dim); } + +.regcite-list { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px; } +.regcite-list-head { font-size: var(--fs-ui-sm); color: var(--text-dim); padding-bottom: 4px; } +.regcite-item-head { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; } +.regcite-item-num { font-family: var(--mono); color: var(--text-dim); } +.regcite-item-title { font-weight: 550; } +.regcite-item-sub { color: var(--text-dim); } +.regcite-item-invalid .regcite-item-title { color: var(--err-text); text-decoration: line-through; } + +.regcite-reasons { margin: 0; padding-left: 18px; color: var(--text-dim); } +.regcite-tree-row { display: flex; align-items: center; gap: 2px; } +.regcite-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; } +.regcite-tree-caret { + flex: none; + width: 22px; + height: 26px; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--text-dim); + font-size: var(--fs-ui-sm); + cursor: pointer; +} +.regcite-tree-caret:hover { background: var(--bg-hover); color: var(--text); } diff --git a/src/regCitations.ts b/src/regCitations.ts new file mode 100644 index 0000000..cbc107d --- /dev/null +++ b/src/regCitations.ts @@ -0,0 +1,235 @@ +// U7 法规结构化引用纯逻辑层(契约:Dev 仓 docs/superpowers/specs +// 2026-10-18-regulations-citation-contract.md §1/§3/§5/§6)。只放可测判定: +// 正文 [cit-…] 标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数。 +// 引用是 RDM 证据批的派生视图;除 verified 外一律不得呈现为有效法律依据。 + +// ---- 契约类型 ---- + +interface CitationWeb { + url: string + finalUrl?: string + captureId?: string + contentSha256?: string + official?: boolean + fetchedAt?: string + excerpt?: string + excerptStart?: number + excerptEnd?: number +} + +export interface CitationView { + id: string + batchId?: string + claim?: string + state: string // verified|single_source|degraded|edition_unresolved|conflict|unverified|not_applicable|invalid + stale?: boolean + staleReason?: string + regulationId?: string + editionId?: string + nodeId?: string + docNo?: string + title?: string + editionLabel?: string + legalStatus?: string + clauseNo?: string + headingPath?: string[] + corpusRevision?: string + excerpt?: string + excerptStart?: number + excerptEnd?: number + nodeSha256?: string + disclosure?: string // full|excerpt|metadata_only|withheld + redactionReason?: string + web?: CitationWeb + manifestSha256?: string +} + +interface RegLegStates { kb: string; web: string; kbReason?: string; webReason?: string } +interface RegSealView { state: string; keyId?: string; manifestSha256?: string } +interface RegResolution { actor: string; decision: string; note?: string; at: string; batchManifestSha256?: string } + +/** rdm.inspect.object kind=reg-batch(契约 §5)。 */ +export interface RegBatchDetail { + title: string + state: string + stateReasons?: string[] | null + legStates?: RegLegStates + seal?: RegSealView + citations?: CitationView[] | null + resolutions?: RegResolution[] | null +} + +/** rdm.inspect.object kind=reg-citation(契约 §5)。 */ +export interface RegCitationDetail { title: string; citation: CitationView } + +// ---- 正文标记:[cit-<12 位小写十六进制>] ---- + +const MARKER = /\[(cit-[0-9a-f]{12})\]/g + +type CitedSegment = + | { kind: 'text'; text: string } + | { kind: 'cite'; id: string; n: number } + +/** 解析多段正文:按首次出现编号(跨段连续),重复标记复用编号;不合规的近似标记原样保留为文本。 */ +export function parseCitedBlocks(texts: string[]): { blocks: CitedSegment[][]; order: string[] } { + const order: string[] = [] + const numbers = new Map() + const blocks = texts.map(text => { + const out: CitedSegment[] = [] + let last = 0 + for (const m of text.matchAll(MARKER)) { + const at = m.index ?? 0 + if (at > last) out.push({ kind: 'text', text: text.slice(last, at) }) + const id = m[1] + let n = numbers.get(id) + if (n === undefined) { + order.push(id) + n = order.length + numbers.set(id, n) + } + out.push({ kind: 'cite', id, n }) + last = at + m[0].length + } + if (last < text.length) out.push({ kind: 'text', text: text.slice(last) }) + return out + }) + return { blocks, order } +} + +/** 引用清单顺序:正文编号在前,正文未出现的引用按返回顺序续编。 */ +export function citationListOrder(order: string[], citations: CitationView[] | null | undefined): string[] { + const out = [...order] + const seen = new Set(order) + for (const c of citations || []) { + if (!seen.has(c.id)) { seen.add(c.id); out.push(c.id) } + } + return out +} + +// ---- 核验状态 ---- + +const STATE_LABELS: Record = { + verified: '已核验', + single_source: '单一来源', + degraded: '降级', + edition_unresolved: '版本未定', + conflict: '冲突', + unverified: '未核验', + not_applicable: '不适用', + invalid: '无效引用', +} + +/** 状态文案;未知状态原样显示,不冒充已知状态。 */ +export function citationStateLabel(state: string | undefined): string { + if (!state) return STATE_LABELS.unverified + return STATE_LABELS[state] ?? state +} + +type CitationStyle = 'valid' | 'unverified' | 'invalid' + +/** 样式档:仅 verified 且未过期才是有效依据;ID 缺失或 invalid 为无效;其余一律未核验档。 */ +export function citationStyle(c: { state?: string; stale?: boolean } | undefined): CitationStyle { + if (!c || c.state === 'invalid') return 'invalid' + if (c.state === 'verified' && !c.stale) return 'valid' + return 'unverified' +} + +export const CITATION_CHIP_TONE: Record = { + valid: 'ok', + unverified: 'warn', + invalid: 'danger', +} + +const STALE_REASONS: Record = { + node_text_changed: '条款文本已变更', + legal_status_changed: '效力状态已变更', + edition_superseded: '版本已被替代', +} + +/** 过期提示:stale 显示「待复核」并附原因;未过期返回 null。 */ +export function staleLabel(c: { stale?: boolean; staleReason?: string }): string | null { + if (!c.stale) return null + if (!c.staleReason) return '待复核' + return `待复核(${STALE_REASONS[c.staleReason] ?? c.staleReason})` +} + +/** 标记文字:有效依据只显示编号;其余附状态文案,不靠颜色单独区分。 */ +export function citationMarkText(n: number, c: CitationView | undefined): string { + const style = citationStyle(c) + if (style === 'valid') return `${n}` + if (style === 'invalid') return `${n}·${STATE_LABELS.invalid}` + return `${n}·${c?.stale && c.state === 'verified' ? '待复核' : citationStateLabel(c?.state)}` +} + +// ---- 原文披露 ---- + +const REDACTION_LABELS: Record = { + withheld_by_rights: '受版权限制', + metadata_only: '仅元数据', + no_source_text_permission: '无原文查看权限', + source_withdrawn: '来源已撤回', +} + +/** 原文省略原因;未知原因原样显示。 */ +export function redactionLabel(reason: string | undefined): string { + if (!reason) return '未提供原文' + return REDACTION_LABELS[reason] ?? reason +} + +/** 原文片段呈现:有片段给片段;省略时给中文原因,不留空白。 */ +export function excerptView(c: Pick): { excerpt: string } | { redaction: string } { + if (c.excerpt) return { excerpt: c.excerpt } + return { redaction: redactionLabel(c.redactionReason) } +} + +const DISCLOSURE_LABELS: Record = { + full: '全文', + excerpt: '片段', + metadata_only: '仅元数据', + withheld: '已扣留', +} + +export function disclosureLabel(d: string | undefined): string { + if (!d) return '—' + return DISCLOSURE_LABELS[d] ?? d +} + +const LEG_LABELS: Record = { + ok: '正常', + empty: '无结果', + unavailable: '不可用', + degraded: '降级', + error: '出错', + rejected: '已拒绝', +} + +export function legStateLabel(state: string | undefined, reason?: string): string { + const label = state ? (LEG_LABELS[state] ?? state) : '—' + return reason ? `${label}(${reason})` : label +} + +export function sealStateLabel(state: string | undefined): string { + if (state === 'sealed') return '已封存' + if (state === 'unsealed') return '未封存' + return state || '—' +} + +/** 只有 http(s) 地址才可作为外链;其余地址按文本显示。 */ +export function safeWebHref(url: string | undefined): string | null { + if (!url) return null + return /^https?:\/\//i.test(url) ? url : null +} + +// ---- 审计读模型请求(rdm.inspect.tree kind=regs;契约 §5) ---- + +/** 根层列证据批;parent= 列该批的引用。cursor 沿用既有键集分页。 */ +export function regsTreeParams(projectId: string, opts: { parent?: string; cursor?: string } = {}): Record { + const params: Record = { projectId, kind: 'regs', cursor: opts.cursor ?? '' } + if (opts.parent) params.parent = opts.parent + return params +} + +export const REG_TREE_KIND_LABELS: Record = { + 'reg-batch': '证据批', + 'reg-citation': '引用', +} diff --git a/test/reg-citations.test.ts b/test/reg-citations.test.ts new file mode 100644 index 0000000..4ab0638 --- /dev/null +++ b/test/reg-citations.test.ts @@ -0,0 +1,199 @@ +// U7 法规结构化引用:标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数, +// 以及正文渲染不经 HTML(契约:Dev 仓 specs/2026-10-18-regulations-citation-contract.md)。 +import { describe, expect, it } from 'vitest' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { + CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle, + excerptView, parseCitedBlocks, redactionLabel, regsTreeParams, safeWebHref, staleLabel, + type CitationView, +} from '../src/regCitations' +import { CitationCard, CitedBody } from '../src/components/RegCitation' + +const A = 'cit-0123456789ab' +const B = 'cit-abcdef012345' +const C = 'cit-ffffffffffff' + +const cite = (id: string, over: Partial = {}): CitationView => ({ + id, batchId: 'rb-1', state: 'verified', docNo: 'GB 9706.1-2020', clauseNo: '4.2', + title: '医用电气设备 第1部分', editionLabel: '2020 版', legalStatus: '现行', corpusRevision: 'rev-7', + headingPath: ['4 通用要求', '4.2 风险管理'], excerpt: '应实施风险管理过程。', disclosure: 'excerpt', ...over, +}) + +describe('parseCitedBlocks:标记解析与编号', () => { + it('多个标记按首次出现编号,文本原样切分', () => { + const { blocks, order } = parseCitedBlocks([`甲[${A}]乙[${B}]丙`]) + expect(order).toEqual([A, B]) + expect(blocks[0]).toEqual([ + { kind: 'text', text: '甲' }, + { kind: 'cite', id: A, n: 1 }, + { kind: 'text', text: '乙' }, + { kind: 'cite', id: B, n: 2 }, + { kind: 'text', text: '丙' }, + ]) + }) + + it('重复标记复用编号,编号跨段连续', () => { + const { blocks, order } = parseCitedBlocks([`[${B}]开头[${A}]`, `再引[${B}]与[${C}][${A}]`]) + expect(order).toEqual([B, A, C]) + const nums = blocks.map(b => b.flatMap(s => (s.kind === 'cite' ? [`${s.id}:${s.n}`] : []))) + expect(nums).toEqual([[`${B}:1`, `${A}:2`], [`${B}:1`, `${C}:3`, `${A}:2`]]) + }) + + it('未知 ID 也按出现顺序编号(渲染时判为无效引用)', () => { + const { order } = parseCitedBlocks([`[${C}]`]) + expect(order).toEqual([C]) + }) + + it('没有标记的文本原样返回单个文本片段;空文本没有片段', () => { + const { blocks, order } = parseCitedBlocks(['普通正文 [slc-1] 不变', '']) + expect(order).toEqual([]) + expect(blocks).toEqual([[{ kind: 'text', text: '普通正文 [slc-1] 不变' }], []]) + }) + + it('形似标记但 ID 不合规的文本不解析', () => { + const texts = [ + '[cit-0123456789a]', // 11 位 + '[cit-0123456789abc]', // 13 位 + '[cit-0123456789AB]', // 大写 + '[cit-0123456789ag]', // 非十六进制 + 'cit-0123456789ab', // 无方括号 + '[cit 0123456789ab]', + '[CIT-0123456789ab]', + ] + const { blocks, order } = parseCitedBlocks(texts) + expect(order).toEqual([]) + blocks.forEach((b, i) => expect(b).toEqual([{ kind: 'text', text: texts[i] }])) + }) + + it('嵌套方括号只取合规的内层标记', () => { + const { blocks } = parseCitedBlocks([`[[${A}]]`]) + expect(blocks[0]).toEqual([{ kind: 'text', text: '[' }, { kind: 'cite', id: A, n: 1 }, { kind: 'text', text: ']' }]) + }) + + it('引用清单:正文编号在前,未出现在正文的引用续编', () => { + expect(citationListOrder([B, C], [cite(A), cite(B)])).toEqual([B, C, A]) + expect(citationListOrder([], null)).toEqual([]) + }) +}) + +describe('核验状态文案与样式', () => { + it('八种状态的中文文案;未知状态原样显示', () => { + expect(['verified', 'single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'invalid'] + .map(citationStateLabel)) + .toEqual(['已核验', '单一来源', '降级', '版本未定', '冲突', '未核验', '不适用', '无效引用']) + expect(citationStateLabel('mystery')).toBe('mystery') + expect(citationStateLabel(undefined)).toBe('未核验') + }) + + it('仅 verified 为有效样式;invalid 与缺失 ID 为无效样式;其余为未核验样式', () => { + expect(citationStyle({ state: 'verified' })).toBe('valid') + for (const s of ['single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'mystery']) { + expect(citationStyle({ state: s })).toBe('unverified') + } + expect(citationStyle({ state: 'invalid' })).toBe('invalid') + expect(citationStyle(undefined)).toBe('invalid') + expect(CITATION_CHIP_TONE).toEqual({ valid: 'ok', unverified: 'warn', invalid: 'danger' }) + }) + + it('stale 的 verified 不再呈现为有效依据,显示「待复核」及原因', () => { + expect(citationStyle({ state: 'verified', stale: true })).toBe('unverified') + expect(staleLabel({ stale: false })).toBeNull() + expect(staleLabel({ stale: true })).toBe('待复核') + expect(staleLabel({ stale: true, staleReason: 'node_text_changed' })).toBe('待复核(条款文本已变更)') + expect(staleLabel({ stale: true, staleReason: 'legal_status_changed' })).toBe('待复核(效力状态已变更)') + expect(staleLabel({ stale: true, staleReason: 'edition_superseded' })).toBe('待复核(版本已被替代)') + expect(staleLabel({ stale: true, staleReason: 'other_reason' })).toBe('待复核(other_reason)') + }) + + it('标记文字:有效只给编号,其余附状态文案', () => { + expect(citationMarkText(1, cite(A))).toBe('1') + expect(citationMarkText(2, cite(A, { state: 'single_source' }))).toBe('2·单一来源') + expect(citationMarkText(3, cite(A, { stale: true }))).toBe('3·待复核') + expect(citationMarkText(4, cite(A, { state: 'invalid' }))).toBe('4·无效引用') + expect(citationMarkText(5, undefined)).toBe('5·无效引用') + }) +}) + +describe('原文省略原因', () => { + it('已知原因中文化;未知原因原样;缺省给通用说明', () => { + expect(redactionLabel('withheld_by_rights')).toBe('受版权限制') + expect(redactionLabel('metadata_only')).toBe('仅元数据') + expect(redactionLabel('no_source_text_permission')).toBe('无原文查看权限') + expect(redactionLabel('source_withdrawn')).toBe('来源已撤回') + expect(redactionLabel('court_order')).toBe('court_order') + expect(redactionLabel(undefined)).toBe('未提供原文') + }) + + it('有片段给片段;省略时给原因', () => { + expect(excerptView({ excerpt: '原文' })).toEqual({ excerpt: '原文' }) + expect(excerptView({ redactionReason: 'withheld_by_rights' })).toEqual({ redaction: '受版权限制' }) + }) + + it('只有 http(s) 地址可作外链', () => { + expect(safeWebHref('https://www.nmpa.gov.cn/x')).toBe('https://www.nmpa.gov.cn/x') + expect(safeWebHref('javascript:alert(1)')).toBeNull() + expect(safeWebHref(undefined)).toBeNull() + }) +}) + +describe('regsTreeParams:rdm.inspect.tree kind=regs 请求参数', () => { + it('根层:kind=regs、空游标、无 parent', () => { + expect(regsTreeParams('p1')).toEqual({ projectId: 'p1', kind: 'regs', cursor: '' }) + }) + it('分页沿用游标', () => { + expect(regsTreeParams('p1', { cursor: 'c2' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c2' }) + }) + it('展开证据批:parent=', () => { + expect(regsTreeParams('p1', { parent: 'rb-1' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: '', parent: 'rb-1' }) + expect(regsTreeParams('p1', { parent: 'rb-1', cursor: 'c3' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c3', parent: 'rb-1' }) + }) +}) + +describe('引用呈现', () => { + it('正文标记编号渲染,未知与 invalid 显示无效引用,正文不作 HTML', () => { + const html = renderToStaticMarkup(createElement(CitedBody, { + texts: [`粗甲[${A}]乙[${B}]丙[${C}]`, `再引[${A}]`], + citations: [cite(A), cite(B, { state: 'invalid' })], + scope: 't', + })) + expect(html).toContain('<b>粗</b>甲') + expect(html).not.toContain('') + expect(html).toContain('regcite-mark regcite-valid') + expect(html).toContain('[1]') + expect(html).toContain('[2·无效引用]') + expect(html).toContain('[3·无效引用]') + expect(html.match(/regcite-mark regcite-valid/g)).toHaveLength(2) + expect(html).toContain('引用(3)') + expect(html).not.toContain(' { + const html = renderToStaticMarkup(createElement(CitedBody, { texts: ['普通 [cit-xyz] 正文'], citations: undefined, scope: 't' })) + expect(html).toBe('

普通 [cit-xyz] 正文

') + }) + + it('引用卡片:省略原文时显示中文原因;非 verified 标注未核验', () => { + const html = renderToStaticMarkup(createElement(CitationCard, { + id: A, scope: 't', + citation: cite(A, { state: 'conflict', excerpt: undefined, redactionReason: 'no_source_text_permission', stale: true, staleReason: 'edition_superseded' }), + })) + expect(html).toContain('regcite-card-unverified') + expect(html).toContain('冲突') + expect(html).toContain('待复核(版本已被替代)') + expect(html).toContain('未核验引用') + expect(html).toContain('原文片段未展示:无原文查看权限') + expect(html).toContain('GB 9706.1-2020') + expect(html).toContain('4 通用要求 › 4.2 风险管理') + }) + + it('引用卡片:verified 为有效样式,原文片段在折叠区内;缺失 ID 为无效卡片', () => { + const ok = renderToStaticMarkup(createElement(CitationCard, { id: A, scope: 't', citation: cite(A) })) + expect(ok).toContain('regcite-card-valid') + expect(ok).toContain('mk-col') + expect(ok).not.toContain('未核验引用') + const missing = renderToStaticMarkup(createElement(CitationCard, { id: C, scope: 't' })) + expect(missing).toContain('regcite-card-invalid') + expect(missing).toContain('无效引用') + }) +}) From a9e2c3fa256725cb7e896c27a71f2a4e5a3dffcb Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 10:58:55 +0000 Subject: [PATCH 3/4] Record regulation citation frontend results in the task card Why: Task card is the PR description. Evidence: vitest, knip, tsc and vite build passed in the composed layout (SELF); CI pending. Impact: Card only. Refs: Agent-System-Dev PR #614 Co-authored-by: Cursor --- .task/feature-regulations-kb-citations.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.task/feature-regulations-kb-citations.md b/.task/feature-regulations-kb-citations.md index 7d91a0c..032f9a3 100644 --- a/.task/feature-regulations-kb-citations.md +++ b/.task/feature-regulations-kb-citations.md @@ -24,17 +24,18 @@ | 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | |---|---|---|---|---|---|---|---|---|---| -| frontend-u7-regulation-citations | src/、test/ | implementation | cursor→antigravity→pi | 待运行 | — | — | — | 否 | /tmp/regkb-u7fe-receipt.json | +| frontend-u7-regulation-citations | src/、test/ | implementation | cursor→antigravity→pi | 1(35d799203497) | cursor / not-observable(新进程,fresh_session=true) | success | 6a3f244(apply 快进,run 4cf12d61) | 否 | /tmp/regkb-u7fe-receipt.json | ## 同步记录 | 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 | |---|---|---|---|---| | 启动 | 0459567 | 0459567 | 2026-10-18 | 一致 | -| 验收前 | 待填 | 待填 | 待填 | 待填 | +| 验收前 | 0459567(origin/main) | 6a3f244 | 2026-10-18 | 一致,无需同步 | ## 结果与验证(完成后填写) -- 结果:NOT_RUN -- 验证:NOT_RUN -- 影响与剩余项:NOT_RUN +- 结果:新增 `src/regCitations.ts`(契约类型、`[cit-…]` 标记解析与编号、状态/过期/省略原因文案、样式档、审计树请求参数)与 `src/components/RegCitation.tsx`(编号标记、引用卡片、引用清单,折叠用 @ui Collapsible,正文与片段仅作文本渲染);审计工作区新增“法规核验”树(证据批→引用)与批/引用详情(状态、原因、腿状态、封存、引用元数据、受披露约束的片段);驾驶舱阅读器正文渲染引用标记与引用清单。只有 verified 且未过期显示为有效依据,其余状态与无效引用使用明确的未核验/无效样式。样式只用令牌(`src/reg-citations.css`)。 +- 验证:host 在临时 Dev 组合布局(modules/ui c918747)重跑 `bash /tmp/regkb-fe-verify.sh all`(SELF):vitest 37 文件 345 用例通过(含 design-guard 与新增 reg-citations 测试),knip 未用导出 50 ≤ 51,`tsc -b` 与 `vite build` 通过,日志 /tmp/regkb-u7fe-verify.log;`git diff --check` 通过。权威结论以 PR HEAD CI 与 Dev 组合 `make test` 为准。 +- Review:host 独立审阅实施 diff(未复用实施会话):未使用 dangerouslySetInnerHTML 与 `
`,外链仅 http(s),字段名与 RDM U6 JSON 一致;无阻塞项。 +- 影响与剩余项:数据由 RDM U7(`rdm.inspect.tree kind=regs`、`rdm.inspect.object`、`rdm.cockpit.node` citations)提供;RDM 未提供时界面为空态,不影响既有标签页。Dev 组合 pin 在 U8 推进。 From 7050c91334629e38934d45964479ebe7fdfb6f05 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 10:59:01 +0000 Subject: [PATCH 4/4] Close the regulation citation frontend task card before merge Why: Cards close and archive in the merging PR. Evidence: Status, PR link and archive move only. Impact: Card only. Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Frontend/pulls/109 Co-authored-by: Cursor --- .task/{ => archive}/feature-regulations-kb-citations.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .task/{ => archive}/feature-regulations-kb-citations.md (100%) diff --git a/.task/feature-regulations-kb-citations.md b/.task/archive/feature-regulations-kb-citations.md similarity index 100% rename from .task/feature-regulations-kb-citations.md rename to .task/archive/feature-regulations-kb-citations.md