保存目录修改提案时依据随项保存:表助手、助手流里「建议更新数据目录」的提案经 /catalog/{table}/patch 保存,写了依据的项备注记成「依据:……」(超出目录文字项的上限 500 字截断),没写依据的照旧;值不变、保存即确认的项和沿用了测量备注的关联关系,依据写在原来的备注前面(关联关系的剖析备注再剖析时整段换掉,放在前面才留得住)。表详情里人工确认的项有备注时也看得到:表级的项写在值下面(同有效记录条件的说明),列的一格、关联关系悬停看;check-catalog-loop 的假服务端照服务端记下依据,补保存后看得到依据、人工确认项的说明两处检查

This commit is contained in:
ThearchyHelios
2026-10-14 14:07:14 +08:00
parent fc25c6367c
commit 20bef14520
8 changed files with 164 additions and 17 deletions
+2 -1
View File
@@ -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:
+31 -4
View File
@@ -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)
+2 -1
View File
@@ -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
+37
View File
@@ -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})
+4 -1
View File
@@ -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 (
// 窄框下没填的项不占行(编辑时照常列出来好填)
<td className={clsx('px-2 py-1.5 align-top', NARROW.cell, !it && !editing && '@max-[40rem]:hidden', focused && FOCUS_RING)}
@@ -144,6 +146,7 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble
<span className={NARROW.label}>{CT.columnHead[f]}</span>
<div className="flex min-w-0 flex-1 items-start gap-1.5">
<div className="min-w-0 flex-1">{editing ? input : view}</div>
{note && <NoteHint note={note} attr="data-cell-note" />}
{it && !changed && (
<span className="pt-0.5">
<ItemMark compact disabled={busy || editing} disabledHint={editing ? CT.reviewWhileEditing : undefined}
+13 -5
View File
@@ -18,7 +18,7 @@ import {
CATALOG_USAGE_TEXT, CODES_TEXT as KT,
} from '../../lib/terms'
import { CatalogImpactList } from '../../components/CatalogImpact'
import { CountBadges, FOCUS_RING, FOCUS_STYLE, ItemMark, StatusChip, StatusLegend, SystemNotesNotice } from './parts'
import { CountBadges, FOCUS_RING, FOCUS_STYLE, ItemMark, NoteHint, StatusChip, StatusLegend, SystemNotesNotice, confirmedNote } from './parts'
import type { ReviewTarget } from './parts'
import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable'
import type { ColumnModel } from './ColumnTable'
@@ -695,7 +695,10 @@ function blankCodes(notes: CatalogDetail['notes'], pick: (path: string) => boole
// 表级各项
// ---------------------------------------------------------------------------
/** 值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因) */
/**
* 推断时也在值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因)。
* 别的项人工确认过、带着备注(保存提案时附的依据……)时也写
*/
const NOTED_FIELDS = new Set<TableField>(['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 (
<div className={clsx('grid gap-x-4 gap-y-1 px-3 py-2.5 sm:grid-cols-[7.5rem_minmax(0,1fr)]', focused && FOCUS_RING)}
@@ -918,8 +922,12 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview, focusPath =
: <span className="text-faint">—</span>}
</td>
<td className={clsx(cell, '@max-[40rem]:order-first @max-[40rem]:pb-1')} data-relation-status="">
<ItemMark target={{ path: relationPath(r.id), where, source: r.source, status: r.status, note: r.note, updated_at: r.updated_at }}
disabled={busy} onReview={(a) => onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
<span className="inline-flex items-start gap-1.5">
<ItemMark target={{ path: relationPath(r.id), where, source: r.source, status: r.status, note: r.note, updated_at: r.updated_at }}
disabled={busy} onReview={(a) => onReview({ path: relationPath(r.id), where, source: r.source, status: r.status }, a)} />
{/* 人工确认的关系的备注(保存时的依据、沿用的测量说明):悬停看 */}
{confirmedNote(r) && <NoteHint note={confirmedNote(r)} attr="data-relation-note" />}
</span>
</td>
</tr>
)
+23 -2
View File
@@ -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 (
<span className="inline-flex shrink-0 cursor-help items-center pt-[3px] text-faint hover:text-dim" title={`${UT.fieldNote}:${note}`}
{...{ [attr]: '' }}>
<Info size={11} aria-hidden />
<span className="sr-only">{UT.fieldNote}:{note}</span>
</span>
)
}
/** 从审阅队列打开的那一项(表级项、列的一格、一条关系):一圈强调色的框,淡淡的底 */
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)' }
+52 -3
View File
@@ -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) })