diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py
index bd453ee1..29e857ff 100644
--- a/backend/app/api/catalog.py
+++ b/backend/app/api/catalog.py
@@ -680,7 +680,8 @@ async def preview_catalog_patch(source_id: str, table: str, payload: CatalogPatc
async def save_catalog_patch(source_id: str, table: str, payload: CatalogPatchIn,
x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
- """保存一条目录修改提案:改动过的项记为人工填写、已确认。版本不符 409;有一项不合法 422,整条不写。"""
+ """保存一条目录修改提案:改动过的项记为人工填写、已确认,写了依据的项依据进备注(「依据:……」,规则见
+ catalog.apply_patch)。版本不符 409;有一项不合法 422,整条不写。"""
_, source = await _resolved(session, source_id)
key, _ = await _table_key(session, source, table)
try:
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index 91fc61c3..7a62840f 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1100,12 +1100,33 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
return PatchPlan(out, problems)
+def _reason_note(reason: str | None) -> str | None:
+ """提案的依据 → 保存时这一项的备注「依据:……」(同模型起草的依据,FACT_REASON_NOTE)。超出目录文字项的上限
+ (_TEXT_MAX)时截断、末尾写省略号。没有依据为 None。"""
+ text = (reason or "").strip()
+ if not text:
+ return None
+ note = FACT_REASON_NOTE.format(reason=text)
+ return note if len(note) <= _TEXT_MAX else note[:_TEXT_MAX - 1] + "…"
+
+
+def _join_notes(*parts: Any) -> str | None:
+ """几段备注连成一段:各段去掉句末的句号,用句号隔开(同起草里「依据:……。剖析核对:……」的写法)。都空为 None。"""
+ texts = [p.strip().rstrip("。") for p in parts if isinstance(p, str) and p.strip()]
+ return "。".join(texts) or None
+
+
def apply_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
tables: Mapping[str, Any] | None = None, at: str | None = None) -> dict[str, Any]:
"""把一条提案并进目录,返回新目录(不改入参)。有一项不合法就整条不收,抛 CatalogInvalid。
值有变化的项记为人工填写、已确认(和人工编辑同一条规矩:改动出自人的确认,不是模型起草);值没变的只把
状态改成已确认、来源不变(等同单项确认);已经确认过的原样不动。
+
+ 提案里写了依据(reason)的项,依据写进这一项的备注「依据:……」(_reason_note):保存的修改附上使用者的原话,保存之后
+ 看得到当初为什么这么改。改了值的项备注就是这一句(原来的备注说的是旧值);值不变、保存即确认的项,和沿用了测量备注的
+ 关联关系,依据写在原来的备注前面——原来的备注说的还是这个值;关联关系的测量备注以「数据剖析:」开头,再剖析时整段
+ 换成新的(catalog_profile._patch_confirmed),依据放在前面才不会被一起换掉。没写依据的照旧。
"""
plan = plan_patch(notes, changes, table=table, tables=tables)
if plan.problems:
@@ -1117,20 +1138,26 @@ def apply_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str,
if change.state == "same":
continue
prev = slots.get(key)
+ said = _reason_note(change.reason)
if change.state == "confirm" and prev is not None:
- slots[key] = {**prev, "status": "confirmed", "updated_at": at}
+ item = {**prev, "status": "confirmed", "updated_at": at}
+ if said:
+ item["note"] = _join_notes(said, prev.get("note"))
+ slots[key] = item
continue
if key[0] == "r":
rel = {"id": key[1], **change.after, "source": "human", "status": "confirmed", "updated_at": at}
- if prev is not None and prev.get("note"):
- rel["note"] = prev["note"] # 覆盖率沿用了,说明它怎么测的那段备注也留着
+ # 覆盖率沿用了,说明它怎么测的那段备注也留着
+ kept = prev.get("note") if prev is not None else None
+ if note := (_join_notes(said, kept) if said else kept):
+ rel["note"] = note
slots[key] = rel
else:
# 码值是补充、不删已有的码:原来标了「已列全」的,补充之后仍然列全。提案自己不能标「已列全」——
# 用户在对话里说一个码值的意思,不等于确认这一列只有这些取值
done = key[0] == "c" and key[2] == "codes" and prev is not None and prev.get("status") != "rejected" \
and codes_complete(prev)
- slots[key] = make_item(change.after, "human", "confirmed", at=at, complete=done)
+ slots[key] = make_item(change.after, "human", "confirmed", note=said, at=at, complete=done)
out = _assemble(slots)
if problems := validate_notes(out):
raise CatalogInvalid(problems)
diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py
index da1d3d4e..2cb44a83 100644
--- a/backend/app/engine/dual_check_facts.py
+++ b/backend/app/engine/dual_check_facts.py
@@ -50,7 +50,8 @@ is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不
原因,只有审批停下期间目录被人改了、数据源换了版本时才会和最后写的不一样:真写按写入时的目录、表结构和导入时认作
日期的列判断。组合太多的(JOINT_LIMIT)那一项不预告,卡上有条件地说。
-catalog.save_patch 不收备注,这里用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,只多把备注放进项里。
+catalog.save_patch 只把提案的依据写成「依据:……」的备注,这里的备注是出处(哪次运行、哪个节点),所以用它的同一套原料
+(read_entry、apply_patch、write_entry 的乐观锁)写,再把这一项换成带出处备注的人工确认项。
"""
from __future__ import annotations
diff --git a/backend/tests/test_catalog_patch.py b/backend/tests/test_catalog_patch.py
index 5520313b..b8b5ee39 100644
--- a/backend/tests/test_catalog_patch.py
+++ b/backend/tests/test_catalog_patch.py
@@ -169,6 +169,41 @@ async def test_apply_patch_records_human_confirmed(scenic_db):
catalog.apply_patch(proposed, [{"path": "kind", "value": "nope"}], table="visits", tables=tables)
+_PARKS = catalog.relation_id("visits", ["park_id"], "parks", ["id"])
+
+
+async def test_saving_keeps_the_reason_as_the_note(scenic_db):
+ """保存的修改在依据里附上使用者的原话(表助手、助手流里的提案都经这里保存):有依据的项 note 写「依据:……」,超出目录
+ 文字项的上限截断;没有依据的照旧。值不变、保存即确认的项,和沿用了测量备注的关联关系,依据写在原来的备注前面——原来的
+ 备注说的还是这个值;关联关系的测量备注以「数据剖析:」开头,再剖析时会整段换掉,依据放在前面才留得住。"""
+ tables = (await _schema(scenic_db))["tables"]
+ proposed = scenic_notes.notes("proposed")["visits"]
+ proposed["valid_filter"]["note"] = "依据:码值 0 是作废。剖析核对:排除 3.2% 的行(共 1,000 行)"
+ rel = next(r for r in proposed["relations"] if r["id"] == _PARKS)
+ rel.update(source="profile", coverage=0.97, note="数据剖析:抽样 1,000 行,覆盖率 97%。")
+ out = catalog.apply_patch(proposed, [
+ {"path": "columns.visitor_count.measure", "value": "stock", "reason": "使用者说:在园人数是存量"},
+ {"path": "columns.visitor_count.unit", "value": "人"}, # 没有依据:照旧不写备注
+ {"path": "valid_filter", "value": "status = 1", "reason": "使用者说:只算有效的"}, # 值不变:保存即确认
+ {"path": f"relations.{_PARKS}", "value": {"columns": ["park_id"], "to_table": "parks", "to_columns": ["id"],
+ "cardinality": "one_to_one"}, "reason": "使用者说:一个景区一条"},
+ {"path": "grain", "value": "一张门票的一次入园", "reason": "长" * 600},
+ ], table="visits", tables=tables, at="2026-10-14T00:00:00+00:00")
+ measure = out["columns"]["visitor_count"]["measure"]
+ assert measure["note"] == "依据:使用者说:在园人数是存量"
+ assert (measure["value"], measure["source"], measure["status"]) == ("stock", "human", "confirmed")
+ assert "note" not in out["columns"]["visitor_count"]["unit"]
+ valid = out["valid_filter"]
+ assert valid["note"] == "依据:使用者说:只算有效的。依据:码值 0 是作废。剖析核对:排除 3.2% 的行(共 1,000 行)"
+ assert (valid["source"], valid["status"]) == ("llm", "confirmed")
+ saved = next(r for r in out["relations"] if r["id"] == _PARKS)
+ assert saved["note"] == "依据:使用者说:一个景区一条。数据剖析:抽样 1,000 行,覆盖率 97%"
+ assert (saved["cardinality"], saved["coverage"], saved["source"]) == ("one_to_one", 0.97, "human")
+ grain = out["grain"]["note"]
+ assert grain.startswith("依据:长") and grain.endswith("…") and len(grain) == 500
+ assert catalog.validate_notes(out) == []
+
+
# --------------------------------------------------------------------------
# 协议
# --------------------------------------------------------------------------
@@ -337,6 +372,8 @@ async def test_preview_and_save(scenic_source):
assert detail["version"] == version + 1 and detail["updated_by"] == "王敏"
codes = detail["notes"]["columns"]["status"]["codes"]
assert codes["value"]["9"] == "作废" and (codes["source"], codes["status"]) == ("human", "confirmed")
+ # 依据随项保存:保存之后看得到当初为什么这么改
+ assert codes["note"] == "依据:作废单"
# 拿旧版本再存:409,什么都不写
r = await client.post(f"{base}/patch", json={"changes": changes, "if_version": version})
diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx
index 56e64f27..bbbe8e0c 100644
--- a/frontend/src/pages/catalog/ColumnTable.tsx
+++ b/frontend/src/pages/catalog/ColumnTable.tsx
@@ -9,7 +9,7 @@ import { IconButton } from '../../components/ui'
import {
CATALOG_COLUMN_FIELD_LABEL, CATALOG_MEASURE_HINT, CATALOG_MEASURE_LABEL, CATALOG_TEXT as CT, CODES_TEXT as KT,
} from '../../lib/terms'
-import { FOCUS_RING, FOCUS_STYLE, ItemMark } from './parts'
+import { FOCUS_RING, FOCUS_STYLE, ItemMark, NoteHint, confirmedNote } from './parts'
import type { ReviewTarget } from './parts'
import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model'
import type { ColumnDraft, ColumnField } from './model'
@@ -136,6 +136,8 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
const problem = problems[`c:${name}:${f}`]
const changed = editing && ((draft?.[f] ?? '') !== (initial?.[f] ?? '') || (f === 'codes' && completeOf(draft) !== completeOf(initial)))
const focused = focusField === f
+ // 人工确认的项的备注(保存时的依据……):悬停看
+ const note = editing ? '' : confirmedNote(it)
return (
// 窄框下没填的项不占行(编辑时照常列出来好填)
{CT.columnHead[f]}
{editing ? input : view}
+ {note && }
{it && !changed && (
boole
// 表级各项
// ---------------------------------------------------------------------------
-/** 值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因) */
+/**
+ * 推断时也在值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因)。
+ * 别的项人工确认过、带着备注(保存提案时附的依据……)时也写
+ */
const NOTED_FIELDS = new Set(['valid_filter', 'dedup'])
function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, onReview, focused = false }: {
@@ -766,7 +769,8 @@ function TableFieldRow({ field, item, edit, busy, problems, columns, onChange, o
}
}
const unknown = edit && field === 'keys' ? unknownKeys(edit.form.table.keys ?? '', columns) : []
- const note = !edit && item && item.status !== 'rejected' && NOTED_FIELDS.has(field) ? item.note?.trim() ?? '' : ''
+ const note = edit || !item || item.status === 'rejected' ? ''
+ : NOTED_FIELDS.has(field) ? item.note?.trim() ?? '' : confirmedNote(item)
return (
—}
|
- onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
+
+ onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
+ {/* 人工确认的关系的备注(保存时的依据、沿用的测量说明):悬停看 */}
+ {confirmedNote(r) && }
+
|
)
diff --git a/frontend/src/pages/catalog/parts.tsx b/frontend/src/pages/catalog/parts.tsx
index d4518d02..34be8d01 100644
--- a/frontend/src/pages/catalog/parts.tsx
+++ b/frontend/src/pages/catalog/parts.tsx
@@ -1,14 +1,14 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
import { createPortal } from 'react-dom'
-import { Check, CircleCheck, CircleDashed, CircleX, RotateCcw, ShieldCheck, Trash2, X } from 'lucide-react'
+import { Check, CircleCheck, CircleDashed, CircleX, Info, RotateCcw, ShieldCheck, Trash2, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import clsx from 'clsx'
import type { CatalogCounts, CatalogReviewAction, CatalogSource, CatalogStatus } from '../../types'
import { Notice } from '../../components/ui'
import { formatDateTime, formatNumber, formatTime } from '../../lib/format'
import {
- CATALOG_SOURCE_LABEL, CATALOG_STATUS_HINT, CATALOG_STATUS_LABEL, CATALOG_TEXT as CT,
+ CATALOG_SOURCE_LABEL, CATALOG_STATUS_HINT, CATALOG_STATUS_LABEL, CATALOG_TEXT as CT, CATALOG_UI_TEXT as UT,
} from '../../lib/terms'
import { STATUSES, initialStatus } from './model'
@@ -294,6 +294,27 @@ function ActionButton({ icon, children, disabled, onClick, action, title }: {
)
}
+/**
+ * 人工确认的项带的备注:保存提案时附的依据(「依据:使用者说……」)、确认时留着的数据剖析说明。确认过的项是 SQL 检查和
+ * 助手的依据,为什么这么定要看得到,不能只藏在状态弹层里:表级的项写在值下面(TableFieldRow,同有效记录条件的说明),
+ * 列的一格、关联关系悬停看(NoteHint)。没有备注、不是已确认的为空串
+ */
+export const confirmedNote = (item: { status: CatalogStatus; note?: string } | null | undefined): string =>
+ (item && item.status === 'confirmed' ? item.note?.trim() ?? '' : '')
+
+/**
+ * 列的一格、关联关系上的备注:只占一个小图标,悬停看全文;读屏念「说明:……」。attr 是给检查脚本定位的 data- 属性名
+ */
+export function NoteHint({ note, attr }: { note: string; attr: string }) {
+ return (
+
+
+ {UT.fieldNote}:{note}
+
+ )
+}
+
/** 从审阅队列打开的那一项(表级项、列的一格、一条关系):一圈强调色的框,淡淡的底 */
export const FOCUS_RING = 'rounded-md ring-2 ring-inset ring-[var(--accent)]'
export const FOCUS_STYLE = { background: 'color-mix(in srgb, var(--accent) 7%, transparent)' }
diff --git a/scripts/check-catalog-loop.mjs b/scripts/check-catalog-loop.mjs
index 909e2d9e..e16c7534 100644
--- a/scripts/check-catalog-loop.mjs
+++ b/scripts/check-catalog-loop.mjs
@@ -293,13 +293,17 @@ function applyReview(notes, path, action) {
else it.status = action === 'confirm' ? 'confirmed' : action === 'reject' ? 'rejected' : INIT[it.source] ?? 'proposed'
return assemble(map)
}
-/** 保存提案(catalog.apply_patch 的简化):码值是补充,其余整项替换;改动的项记为人工填写、已确认 */
+/**
+ * 保存提案(catalog.apply_patch 的简化):码值是补充(驳回的码值不算),其余整项替换;改动的项记为人工填写、已确认,
+ * 写了依据的项依据进备注「依据:……」
+ */
function applyPatch(notes, changes) {
const map = slots(clone(notes ?? {}))
for (const c of changes) {
const prev = map.get(c.path)
- const value = c.path.endsWith('.codes') && prev ? { ...prev.value, ...c.value } : c.value
- map.set(c.path, { value, source: 'human', status: 'confirmed', updated_at: new Date().toISOString() })
+ const live = prev && prev.status !== 'rejected' ? prev : null
+ const value = c.path.endsWith('.codes') && live ? { ...live.value, ...c.value } : c.value
+ map.set(c.path, { value, source: 'human', status: 'confirmed', updated_at: new Date().toISOString(), ...(c.reason ? { note: `依据:${c.reason}` } : {}) })
}
return assemble(map)
}
@@ -840,6 +844,13 @@ await section('表助手 · 入口、开场、提案与待保存的修改', asyn
await textOf(panel(page).locator('[data-assistant-saved]')))
check('……表详情换上最新的目录(第 4 版),有效记录条件是新的值', await until(async () => (await version(page)) === 4) && v0 === 2
&& (await textOf(page.locator('[data-field="valid_filter"]'))).includes('status NOT IN (8, 9)'))
+ // 保存的修改在依据里附上原话(服务端记成这一项的备注「依据:……」):表级的项写在值下面,列的一格悬停看得到
+ check('……保存时的依据看得到:有效记录条件的值下面写「依据:用户说……」',
+ (await textOf(page.locator('[data-field="valid_filter"] [data-field-note]'))).includes('依据:用户说「统计时退票和作废的都要排除」'),
+ await textOf(page.locator('[data-field="valid_filter"]')))
+ const savedNote = page.locator('[data-column="status"] [data-cell="codes"] [data-cell-note]')
+ check('……码值那一格悬停写保存时的依据', ((await savedNote.getAttribute('title').catch(() => '')) ?? '').includes('依据:用户说「8 是退票」'),
+ String(await savedNote.getAttribute('title').catch(() => null)))
check('……卡片上存了的项标「已保存」,不能再加回', (await changeState(page, 'valid_filter')) === 'saved'
&& await panel(page).locator('[data-assistant-change="valid_filter"] [data-assistant-change-toggle]').count() === 0)
await cleanCopy(page, '表助手')
@@ -1390,6 +1401,44 @@ await section('导出导入 · 导出文件、导入先预览再写入', async (
await close()
})
+await section('表详情 · 人工确认的项的说明', async () => {
+ // 人工确认的项带着备注(保存提案时的依据、数据剖析的说明):表级的项写在值下面(同有效记录条件的说明),列的一格、
+ // 关联关系悬停看得到。推断的项照旧只有有效记录条件、去重规则在值下面写说明
+ const state = freshState()
+ const visits = state.tables[S1].find((t) => t.name === 'visits')
+ const LABEL_NOTE = '依据:使用者说:这张表一次刷闸记一行'
+ const COL_NOTE = '依据:使用者说:入园人数按人计'
+ const REL_NOTE = '依据:使用者说:每条入园记录对应一个景区。数据剖析:抽样 2,000 行,覆盖率 98%'
+ visits.notes.label = item('入园记录', 'human', undefined, { note: LABEL_NOTE })
+ visits.notes.grain = item('一张门票的一次入园', 'llm', 'proposed', { note: '依据:表名和票号列' })
+ visits.notes.columns.visitor_count.unit = item('人', 'human', undefined, { note: COL_NOTE })
+ visits.notes.columns.visitor_count.label = item('入园人数', 'llm', 'proposed', { note: '依据:列名' })
+ visits.notes.relations = [{ id: 'r_park', columns: ['park_id'], to_table: 'parks', to_columns: ['id'], cardinality: 'many_to_one', coverage: 0.98,
+ source: 'human', status: 'confirmed', updated_at: T0, note: REL_NOTE }]
+ const { page, errors, close } = await open(`/data/catalog/${S1}/visits`, { handlers: loopHandlers(state) })
+ await until(async () => (await page.locator('[data-catalog-detail="visits"][data-version]').count()) > 0)
+ await shot(page, 'catalog-confirmed-notes')
+ check('人工确认的表级项:值下面写备注(中文名)', (await textOf(page.locator('[data-field="label"] [data-field-note]'))).includes(LABEL_NOTE),
+ await textOf(page.locator('[data-field="label"]')))
+ check('……推断的表级项照旧不在值下面写(粒度)', await page.locator('[data-field="grain"] [data-field-note]').count() === 0)
+ const cellNote = page.locator('[data-column="visitor_count"] [data-cell="unit"] [data-cell-note]')
+ check('人工确认的列级项:那一格悬停写备注', ((await cellNote.getAttribute('title').catch(() => '')) ?? '').includes(COL_NOTE),
+ String(await cellNote.getAttribute('title').catch(() => null)))
+ check('……读屏也念得到', (await cellNote.innerText().catch(() => '')).includes(COL_NOTE))
+ check('……推断的列级项不加(中文名)', await page.locator('[data-column="visitor_count"] [data-cell="label"] [data-cell-note]').count() === 0)
+ const relNote = page.locator('[data-relation="r_park"] [data-relation-note]')
+ if (SHOTS) {
+ await page.locator('[data-column="visitor_count"]').scrollIntoViewIfNeeded()
+ await shot(page, 'catalog-confirmed-notes-columns')
+ await page.locator('[data-relation="r_park"]').scrollIntoViewIfNeeded()
+ await shot(page, 'catalog-confirmed-notes-relations')
+ }
+ check('人工确认的关联关系:悬停写备注', ((await relNote.getAttribute('title').catch(() => '')) ?? '').includes(REL_NOTE),
+ String(await relNote.getAttribute('title').catch(() => null)))
+ check('没有运行时报错', errors.length === 0, errors[0] ?? '')
+ await close()
+})
+
await section('防错事实的提议 · 表详情里的说明', async () => {
const state = freshState()
const { page, errors, close } = await open(`/data/catalog/${S1}/visits`, { handlers: loopHandlers(state) })