From 20bef14520c6cf94e67c4d27fbef24ae6248713b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 14:07:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=9B=AE=E5=BD=95=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E6=8F=90=E6=A1=88=E6=97=B6=E4=BE=9D=E6=8D=AE=E9=9A=8F?= =?UTF-8?q?=E9=A1=B9=E4=BF=9D=E5=AD=98=EF=BC=9A=E8=A1=A8=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E3=80=81=E5=8A=A9=E6=89=8B=E6=B5=81=E9=87=8C=E3=80=8C=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E6=8F=90=E6=A1=88=E7=BB=8F=20/catalog/{table?= =?UTF-8?q?}/patch=20=E4=BF=9D=E5=AD=98=EF=BC=8C=E5=86=99=E4=BA=86?= =?UTF-8?q?=E4=BE=9D=E6=8D=AE=E7=9A=84=E9=A1=B9=E5=A4=87=E6=B3=A8=E8=AE=B0?= =?UTF-8?q?=E6=88=90=E3=80=8C=E4=BE=9D=E6=8D=AE=EF=BC=9A=E2=80=A6=E2=80=A6?= =?UTF-8?q?=E3=80=8D=EF=BC=88=E8=B6=85=E5=87=BA=E7=9B=AE=E5=BD=95=E6=96=87?= =?UTF-8?q?=E5=AD=97=E9=A1=B9=E7=9A=84=E4=B8=8A=E9=99=90=20500=20=E5=AD=97?= =?UTF-8?q?=E6=88=AA=E6=96=AD=EF=BC=89=EF=BC=8C=E6=B2=A1=E5=86=99=E4=BE=9D?= =?UTF-8?q?=E6=8D=AE=E7=9A=84=E7=85=A7=E6=97=A7=EF=BC=9B=E5=80=BC=E4=B8=8D?= =?UTF-8?q?=E5=8F=98=E3=80=81=E4=BF=9D=E5=AD=98=E5=8D=B3=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E7=9A=84=E9=A1=B9=E5=92=8C=E6=B2=BF=E7=94=A8=E4=BA=86=E6=B5=8B?= =?UTF-8?q?=E9=87=8F=E5=A4=87=E6=B3=A8=E7=9A=84=E5=85=B3=E8=81=94=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=EF=BC=8C=E4=BE=9D=E6=8D=AE=E5=86=99=E5=9C=A8=E5=8E=9F?= =?UTF-8?q?=E6=9D=A5=E7=9A=84=E5=A4=87=E6=B3=A8=E5=89=8D=E9=9D=A2=EF=BC=88?= =?UTF-8?q?=E5=85=B3=E8=81=94=E5=85=B3=E7=B3=BB=E7=9A=84=E5=89=96=E6=9E=90?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E5=86=8D=E5=89=96=E6=9E=90=E6=97=B6=E6=95=B4?= =?UTF-8?q?=E6=AE=B5=E6=8D=A2=E6=8E=89=EF=BC=8C=E6=94=BE=E5=9C=A8=E5=89=8D?= =?UTF-8?q?=E9=9D=A2=E6=89=8D=E7=95=99=E5=BE=97=E4=BD=8F=EF=BC=89=E3=80=82?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E9=87=8C=E4=BA=BA=E5=B7=A5=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E7=9A=84=E9=A1=B9=E6=9C=89=E5=A4=87=E6=B3=A8=E6=97=B6?= =?UTF-8?q?=E4=B9=9F=E7=9C=8B=E5=BE=97=E5=88=B0=EF=BC=9A=E8=A1=A8=E7=BA=A7?= =?UTF-8?q?=E7=9A=84=E9=A1=B9=E5=86=99=E5=9C=A8=E5=80=BC=E4=B8=8B=E9=9D=A2?= =?UTF-8?q?=EF=BC=88=E5=90=8C=E6=9C=89=E6=95=88=E8=AE=B0=E5=BD=95=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E8=AF=B4=E6=98=8E=EF=BC=89=EF=BC=8C=E5=88=97?= =?UTF-8?q?=E7=9A=84=E4=B8=80=E6=A0=BC=E3=80=81=E5=85=B3=E8=81=94=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E6=82=AC=E5=81=9C=E7=9C=8B=EF=BC=9Bcheck-catalog-loop?= =?UTF-8?q?=20=E7=9A=84=E5=81=87=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=85=A7?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E8=AE=B0=E4=B8=8B=E4=BE=9D=E6=8D=AE?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E4=BF=9D=E5=AD=98=E5=90=8E=E7=9C=8B=E5=BE=97?= =?UTF-8?q?=E5=88=B0=E4=BE=9D=E6=8D=AE=E3=80=81=E4=BA=BA=E5=B7=A5=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E9=A1=B9=E7=9A=84=E8=AF=B4=E6=98=8E=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/catalog.py | 3 +- backend/app/data/catalog.py | 35 ++++++++++++-- backend/app/engine/dual_check_facts.py | 3 +- backend/tests/test_catalog_patch.py | 37 +++++++++++++++ frontend/src/pages/catalog/ColumnTable.tsx | 5 +- frontend/src/pages/catalog/TableDetail.tsx | 18 +++++-- frontend/src/pages/catalog/parts.tsx | 25 +++++++++- scripts/check-catalog-loop.mjs | 55 ++++++++++++++++++++-- 8 files changed, 164 insertions(+), 17 deletions(-) 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) })