236 lines
7.8 KiB
TypeScript
236 lines
7.8 KiB
TypeScript
// 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<string, number>()
|
||
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<string, string> = {
|
||
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<CitationStyle, 'ok' | 'warn' | 'danger'> = {
|
||
valid: 'ok',
|
||
unverified: 'warn',
|
||
invalid: 'danger',
|
||
}
|
||
|
||
const STALE_REASONS: Record<string, string> = {
|
||
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<string, string> = {
|
||
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<CitationView, 'excerpt' | 'redactionReason'>): { excerpt: string } | { redaction: string } {
|
||
if (c.excerpt) return { excerpt: c.excerpt }
|
||
return { redaction: redactionLabel(c.redactionReason) }
|
||
}
|
||
|
||
const DISCLOSURE_LABELS: Record<string, string> = {
|
||
full: '全文',
|
||
excerpt: '片段',
|
||
metadata_only: '仅元数据',
|
||
withheld: '已扣留',
|
||
}
|
||
|
||
export function disclosureLabel(d: string | undefined): string {
|
||
if (!d) return '—'
|
||
return DISCLOSURE_LABELS[d] ?? d
|
||
}
|
||
|
||
const LEG_LABELS: Record<string, string> = {
|
||
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=<batchId> 列该批的引用。cursor 沿用既有键集分页。 */
|
||
export function regsTreeParams(projectId: string, opts: { parent?: string; cursor?: string } = {}): Record<string, unknown> {
|
||
const params: Record<string, unknown> = { projectId, kind: 'regs', cursor: opts.cursor ?? '' }
|
||
if (opts.parent) params.parent = opts.parent
|
||
return params
|
||
}
|
||
|
||
export const REG_TREE_KIND_LABELS: Record<string, string> = {
|
||
'reg-batch': '证据批',
|
||
'reg-citation': '引用',
|
||
}
|