diff --git a/README.md b/README.md index d8fb29de..21f51124 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ flowchart LR | 模型起草 | 作为后台任务逐表进行,每张表记下状态和原因,写完一张就并入目录,宽表每写完一段就并入;关掉对话框不停,可以停止、只重试失败的表,清单上标出上次起草失败或只完成一部分的表。停止、服务重启时已经写进目录的段保留;服务重启时没跑完的任务标成中断,不会自己续跑,点重试重新起草这些表(已写进的不会丢)。不设总时长上限,靠止损和手动停止:最近 10 次调用有 7 次没写出来时后面的段不再拆小,同一张表接连 3 段都没写出来就跳过它剩下的段。保留模型的思考,输出额度 32768(服务方不收时退回 8192);每次调用最多 40 列,宽表先起草表级的项、再把列均分成几段起草,按表的先后排队,不插到窄表前面;额度用完或超时就拆小再问(超时的两半同时问,拆到每段 10 列仍接连超时就不再拆),累计两次后新开始的段减半(最低 10 列),每段连同它分开、拆小的各块最多调用 8 次,进度上看得到段被分成了几块,缺的表和列只补问缺的,被截断的回复先留下已经写完整的部分;最近 6 次调用有 4 次没写出来时提醒换模型。没写出来的原因写清是第几段、哪几列,「已拆小重试」只跟在真拆过的段后面。同步起草和后台任务互斥。重新起草时模型这次留空或没通过校验的项不删以前起草的;模型拿不准、留空的列(目录里还没有中文名和含义的)逐表记下,可以补充起草要求后重起草。库注释就是表名、列名本身或者是标识符写法的,不记进目录(不当中文名,也不当说明、含义)。数据源上存的「起草要求」和这次的补充说明一起交给模型,帮它理解业务、统一用词,不能推翻「只记数据事实」的规矩,交回的内容照样逐项校验。用哪个模型在「设置 → 偏好设置」的「起草模型」里选,空着跟随助手的模型;按配方导入表格的 AI 起草也用它 | | 防错事实的提议 | 模型起草时另提议「有效记录条件」(一段 SQL 条件)和「去重规则」,各带依据;开了剖析的源把目录里的码值一起交给模型(遮罩列不给)。条件按数据源方言用 sqlglot 解析,只能引用这张表的列,不能有子查询、聚合、窗口函数,只允许几个常用函数,不过就不收(逐表说明里写原因);开了剖析的源上经只读守卫数出排除比例,排除 0% 或 100% 的不收,收下的在说明里写「剖析核对:排除 x% 的行」。这些都记为推断,SQL 检查的错误级规则只认人确认过的,提议在审阅队列里排在前面 | | 审阅队列 | 只列推断的项,按「表被运行用到的次数 × 这一项多影响数字 × 来源」排序:有效记录条件、去重规则、业务日期、关联关系最前,业务主键、度量类型次之,中文名、含义、单位靠后,每项写明排在这里的理由;按类别和表筛选,多选批量确认、驳回或撤销审阅(逐表乐观锁,冲突逐条标出);看得到一张表被哪些运行用过,以及常用表、全部和影响数字的项各确认了多少 | -| 表助手 | 表详情页里和助手对话完善这一张表:它看得到表结构、目录现状(每项的来源和状态)、关联关系、剖析结果、起草要求和上次起草留空的列(遮罩列只给列名),按你的指示成批提出修改,每项带改前、改后和依据(照目录的校验:公式不收、列名要在表结构里),拿不准的会反过来问你;只在开了剖析的源上经只读守卫跑查询核实(每次请求最多 5 条,结果先遮)。提案攒在「待保存的修改」里,确认后一次保存(一次最多 200 项),记为人工确认。对话不存服务端,只改目录、不碰业务库 | +| 表助手 | 表详情页里和助手对话完善这一张表:它看得到表结构、目录现状(每项的来源和状态)、关联关系、剖析结果、起草要求和上次起草留空的列(遮罩列只给列名),按你的指示成批提出修改,每项带改前、改后和依据(照目录的校验:公式不收、列名要在表结构里),拿不准的会反过来问你;只在开了剖析的源上经只读守卫跑查询核实(每次请求最多 5 条,结果先遮)。提案攒在「待保存的修改」里,确认后一次保存(一次最多 200 项),记为人工确认,每项的依据另存为这一项「确认时的依据」(和备注分开,表详情里看得到;撤销审阅回到推断时去掉)。对话不存服务端,只改目录、不碰业务库 | | 导出与导入 | 导出成一个 JSON 文件(全部表的目录、各项状态和起草要求),在另一个实例或数据源上导入:先预览哪些表、列对得上,每一项会新增、更新还是保留,冲突和跳过的逐条列出;人确认、驳回过的项照带,目标里已经确认过而值不同的保留目标、记成冲突,文件里推断的项只填空缺,表结构里没有的列跳过并写原因;有起草在进行时不能导入。起草要求只在目标没有时写入 | | 数据剖析 | 默认关闭,按数据源开启。只读、经过守卫,单条查询和整次剖析都有上限,大表用统计信息估算行数,遮罩的列不取样。抽样核实推断出的关联,补上覆盖率和基数,站得住的升为已验证;状态列给出码值候选,含义等人填写 | | 对话里纠正 | 用户说「status=9 表示作废」,助手给出一条目录修改提案,列出改前、改后和理由,人确认后才保存;公式不收,留给口径卡 | diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py index 29e857ff..2d7da5b9 100644 --- a/backend/app/api/catalog.py +++ b/backend/app/api/catalog.py @@ -680,8 +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]: - """保存一条目录修改提案:改动过的项记为人工填写、已确认,写了依据的项依据进备注(「依据:……」,规则见 - catalog.apply_patch)。版本不符 409;有一项不合法 422,整条不写。""" + """保存一条目录修改提案:改动过的项记为人工填写、已确认,写了依据的项依据另存成这一项确认时的依据(reason,和备注 + 分开,规则见 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 3deec211..9c674cb4 100644 --- a/backend/app/data/catalog.py +++ b/backend/app/data/catalog.py @@ -7,7 +7,7 @@ **只记数据事实,不放计算公式。** 公式(指标怎么算)在口径卡里;这里只描述数据本身。所以 notes 的 字段是固定的一组,不认识的字段一律拒收(validate_notes)。 -notes 结构(每个「项」都是 {value, source, status},可选 note、updated_at):: +notes 结构(每个「项」都是 {value, source, status},可选 note、reason、updated_at):: {"label": 项(str), "description": 项(str), "grain": 项(str), "keys": 项(list[str]), "kind": 项("fact"|"dimension"|"snapshot"|"log"|"config"), @@ -18,7 +18,10 @@ notes 结构(每个「项」都是 {value, source, status},可选 note、upd "codes": 项(dict[码值, 含义];含义可为空串,表示待填写;项上可带 complete: true, 表示已列出全部取值)}}, "relations": [{"id", "columns", "to_table", "to_columns", "cardinality", "coverage", - "source", "status", "note"?, "cardinality_checked"?}]} + "source", "status", "note"?, "reason"?, "cardinality_checked"?}]} + +note 是机器写的说明(模型起草的依据、数据剖析的测量、双盲复算的出处……);reason 是保存修改提案时使用者确认的依据 +(界面上叫「确认时的依据」,最多 500 字),只由 apply_patch 写,两者分开存,规矩见 apply_patch。 关系上的 cardinality_checked: true 只由数据剖析写:基数是用数据核实的(子表一侧数过 COUNT 和 COUNT(DISTINCT), 或者有主键、唯一约束)。外键约束、命名推断给的基数是按表结构推的,子表一侧可能每个键恰好一行;SQL 检查只在 @@ -87,16 +90,18 @@ COLUMN_FIELD_LABEL = {"label": "中文名", "meaning": "含义", "unit": "单位 #: 各来源起草出来的项的初始状态:外键约束是数据库强制的,算有确证;人工填写即确认;其余都是推断 _INITIAL_STATUS = {"fk": "verified", "human": "confirmed"} -_ITEM_KEYS = frozenset({"value", "source", "status", "note", "updated_at"}) +_ITEM_KEYS = frozenset({"value", "source", "status", "note", "reason", "updated_at"}) #: 码值项另有一个「已列全」标记(complete: true):码值表列出了这一列的全部取值。只有人工明确确认、或者数据剖析 #: 没截断地看完了全表取值时才有;没有它的码值表只是「已知的一部分」,SQL 检查不能拿它判断某个值「不在码值表里」 _CODES_ITEM_KEYS = _ITEM_KEYS | {"complete"} _RELATION_KEYS = frozenset({"id", "columns", "to_table", "to_columns", "cardinality", "coverage", "source", - "status", "note", "updated_at", "cardinality_checked"}) + "status", "note", "reason", "updated_at", "cardinality_checked"}) _BUSINESS_DATE_KEYS = frozenset({"column", "rule", "timezone"}) #: 文本项的长度上限。目录是给模型和人读的短说明,长篇大论放口径卡或知识库 _TEXT_MAX = 500 _LONG_TEXT_MAX = 2000 +#: 确认时的依据(项上的 reason)的长度上限:同文本项 +REASON_MAX = _TEXT_MAX def now_iso() -> str: @@ -137,12 +142,15 @@ def unreviewed_status(item: Mapping[str, Any]) -> str: def make_item(value: Any, source: str, status: str | None = None, *, note: str | None = None, - at: str | None = None, complete: bool = False) -> dict[str, Any]: + at: str | None = None, complete: bool = False, reason: str | None = None) -> dict[str, Any]: """构造一个项。status 不给时按来源取初始状态(initial_status)。complete 只用于码值项(已列出全部取值), - 为假时不写这个键:没有标记和标记为否是一回事,写法统一,冻结进快照的哈希才稳定。""" + 为假时不写这个键:没有标记和标记为否是一回事,写法统一,冻结进快照的哈希才稳定。reason 是确认时的依据 + (apply_patch 写),没有时不写这个键。""" out: dict[str, Any] = {"value": value, "source": source, "status": status or initial_status(source)} if note: out["note"] = note + if reason: + out["reason"] = reason if at: out["updated_at"] = at if complete: @@ -150,6 +158,15 @@ def make_item(value: Any, source: str, status: str | None = None, *, note: str | return out +def clip_reason(reason: Any) -> str | None: + """提案里的依据 → 存进项里的 reason:去掉首尾空白,超过 REASON_MAX 字截成 REASON_MAX - 1 字加「…」。没有(空的、 + 不是文字)为 None。预览(plan_patch)和保存(apply_patch)用的是同一句。""" + text = reason.strip() if isinstance(reason, str) else "" + if not text: + return None + return text if len(text) <= REASON_MAX else text[:REASON_MAX - 1] + "…" + + def codes_complete(item: Any) -> bool: """码值项是不是标了「已列全」。""" return isinstance(item, Mapping) and item.get("complete") is True @@ -256,11 +273,20 @@ def _item_problems(where: str, item: Any, rule: tuple[Any, str], *, problems.append(f"{where}{hint}") if "note" in item and not isinstance(item["note"], str): problems.append(f"{where}的备注应为文字") + if "reason" in item and not _reason_ok(item["reason"]): + problems.append(f"{where}{_REASON_HINT}") if "updated_at" in item and not isinstance(item["updated_at"], str): problems.append(f"{where}的修改时间应为文字") return problems +def _reason_ok(value: Any) -> bool: + return isinstance(value, str) and len(value) <= REASON_MAX + + +_REASON_HINT = f"确认时的依据应为不超过 {REASON_MAX} 字的文字" + + def _relation_problems(rel: Any, seen: set[str]) -> list[str]: if not isinstance(rel, dict): return ["关联关系应为对象"] @@ -297,6 +323,8 @@ def _relation_problems(rel: Any, seen: set[str]) -> list[str]: problems.append(f"{where}的状态「{rel.get('status')}」不在可选值内") if "note" in rel and not isinstance(rel["note"], str): problems.append(f"{where}的备注应为文字") + if "reason" in rel and not _reason_ok(rel["reason"]): + problems.append(f"{where}{_REASON_HINT}") if "cardinality_checked" in rel and not isinstance(rel["cardinality_checked"], bool): problems.append(f"{where}的「基数已用数据核实」应为是或否") if "updated_at" in rel and not isinstance(rel["updated_at"], str): @@ -609,7 +637,7 @@ def merge_notes(existing: Mapping[str, Any] | None, draft: Mapping[str, Any] | N """把起草结果并入已有目录,返回 (新目录, 计数)。不改入参。 规则(逐个槽位): - - 已有项是 confirmed 或 rejected:不动。驳回的留着,同一项下次起草就不会再冒出来。 + - 已有项是 confirmed 或 rejected:不动(确认时的依据 reason 跟着留着)。驳回的留着,同一项下次起草就不会再冒出来。 - 起草结果里没有这一项: - 模型起草的项(llm)留着。模型这次留空、给的值没通过校验(_from_model 里就丢掉了),都不能把以前起草好的 删掉——实测整库重跑一次,模型这次没给的几百项全被删掉,有的表覆盖率从八成多掉到五成。只有这一轮完整起草过 @@ -698,7 +726,10 @@ def describe_slot(key: Slot) -> str: def review_item(notes: Mapping[str, Any] | None, path: str, action: str, *, at: str | None = None) -> dict[str, Any]: - """对一项做审阅,返回新目录(不改入参)。路径不对、项不存在、操作不认识都抛 CatalogPathError。""" + """对一项做审阅,返回新目录(不改入参)。路径不对、项不存在、操作不认识都抛 CatalogPathError。 + + 确认时的依据(reason):确认、驳回不带依据,原有的留着;撤销审阅回到推断,去掉——推断项上挂着「使用者说:……」, + 看起来像使用者确认过它。""" if action not in REVIEW_ACTIONS: raise CatalogPathError("不支持这种审阅操作,只能确认、驳回或撤销审阅") key = parse_path(path) @@ -711,6 +742,8 @@ def review_item(notes: Mapping[str, Any] | None, path: str, action: str, *, at: del slots[key] return _assemble(slots) item["status"] = {"confirm": "confirmed", "reject": "rejected"}.get(action) or unreviewed_status(item) + if action == "reset": + item.pop("reason", None) item["updated_at"] = at or now_iso() return _assemble(slots) @@ -755,6 +788,8 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str, 或数据剖析。码值的「已列全」标记跟着值走:只勾上或取消它也算改过(_edit_value)。 - 值没动的项:保持原来的来源和状态;提交里把状态改成确认、驳回或来源的初始状态的,照改(等同单项审阅); 改成别的状态的不认(不能把推断改成「有确证」)。 + - 确认时的依据(reason)只由保存修改提案写(apply_patch),提交里带的不作数:值改过、新填的项去掉(原来的依据说的 + 是旧值,界面原样带回来的也不认);值没动的留着库里的;状态改回来源的初始状态(等同撤销审阅)的去掉。 - 已有、但提交里没有的表级项、列级项:删掉。 - 已有、但提交里没有的关联关系:外键约束、命名推断、数据剖析、模型起草来的,转为驳回、保留编号;只有人工 新建的才真正删掉。删掉的推断关系下次起草会再提出来,关系图(助手挑表、SQL 检查)也会按表结构现推回来, @@ -785,8 +820,9 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str, for key, item in new.items(): prev = old.get(key) if prev is None or _edit_value(key, item) != _edit_value(key, prev): - # 「基数已用数据核实」只由数据剖析写:人工改过的关系记成人工确认,提交里带的这个标记不作数 - fresh = {k: v for k, v in item.items() if k not in ("updated_at", "cardinality_checked")} + # 「基数已用数据核实」只由数据剖析写:人工改过的关系记成人工确认,提交里带的这个标记不作数;确认时的依据只由 + # 保存修改提案写,说的是旧值,不留 + fresh = {k: v for k, v in item.items() if k not in ("updated_at", "cardinality_checked", "reason")} fresh.update(source="human", status="confirmed", updated_at=at) out[key] = fresh continue @@ -795,6 +831,8 @@ def apply_human_edit(existing: Mapping[str, Any] | None, submitted: Mapping[str, if status != prev.get("status") and status in ("confirmed", "rejected", unreviewed_status(prev)): kept["status"] = status kept["updated_at"] = at + if status not in ("confirmed", "rejected"): + kept.pop("reason", None) # 等同撤销审阅:回到推断,确认时的依据不留 if item.get("note") != prev.get("note"): if item.get("note"): kept["note"] = item["note"] @@ -863,7 +901,6 @@ NEW_RELATION_PATH = "relations.new" #: 一条提案最多几项(预览、保存都按它)。对话里的纠正通常是一两条事实;表助手(data/catalog_assistant.py)会逐列补中文名、 #: 含义,界面上跨几轮攒下的待保存修改一次保存,宽表上常常几十上百项,所以放到 200 PATCH_MAX_CHANGES = 200 -_REASON_MAX = 500 #: 看起来像计算公式的文字:「转化率=下单数/访问数」「= SUM(amount)」。公式进口径卡,目录只记数据事实。 #: 不认减号:「上线日期=2024-01-01」不是公式。有效记录条件本来就是 SQL 条件,不查它。 #: 对话提案(plan_patch)、人工整份提交(apply_human_edit,只查改动过的项)、模型起草(_from_model,丢掉这一项) @@ -882,6 +919,7 @@ class PatchChange: (同导入预览「目标里这一项已经驳回」):只是给人看的,算改后、算 state 都按没有这一项,保存时换成人工确认的新值; - after:保存后的值(码值是补充后的完整对照);value:要交回保存的原样取值(码值只有补充的那几个, 409 之后在最新的目录上重新补); + - reason:提案的依据,按保存时的样子截过(clip_reason),没有为空串; - state:change 值有变化;confirm 值相同、但还不是已确认(保存即确认);same 已经是这个值且已确认。 - note:服务端对这一项的说明,给人看;没有时为 None,as_dict 里也不出现。目前只有一种:新增的关联关系没写 基数、又推算不出来(_relation_after)。 @@ -1037,8 +1075,7 @@ def plan_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str, continue path = raw["path"].strip() value = copy.deepcopy(raw["value"]) - reason = raw.get("reason") - reason = reason.strip()[:_REASON_MAX] if isinstance(reason, str) else "" + reason = clip_reason(raw.get("reason")) or "" if path == NEW_RELATION_PATH: rel, problem = _patch_relation(table, value, None, tables, columns) if problem: @@ -1105,22 +1142,6 @@ 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。 @@ -1128,10 +1149,10 @@ def apply_patch(notes: Mapping[str, Any] | None, changes: Any, *, table: str, 值有变化的项记为人工填写、已确认(和人工编辑同一条规矩:改动出自人的确认,不是模型起草);值没变的只把 状态改成已确认、来源不变(等同单项确认);已经确认过的原样不动。 - 提案里写了依据(reason)的项,依据写进这一项的备注「依据:……」(_reason_note):保存的修改附上使用者的原话,保存之后 - 看得到当初为什么这么改。改了值的项备注就是这一句(原来的备注说的是旧值);值不变、保存即确认的项,和沿用了测量备注的 - 关联关系,依据写在原来的备注前面——原来的备注说的还是这个值;关联关系的测量备注以「数据剖析:」开头,再剖析时整段 - 换成新的(catalog_profile._patch_confirmed),依据放在前面才不会被一起换掉。没写依据的照旧。 + 提案里的依据(reason,clip_reason 截过)记进这一项的 reason(确认时的依据),和备注 note 分开:note 是起草的依据、 + 剖析的测量这类机器写的说明,照旧不拼依据——以前拼进 note,再剖析时测量的说法换不了新(catalog_profile._patch_confirmed + 只换剖析自己写的备注)、旧依据叠在新依据后面、撤销审阅后还挂在推断项上。依据每次保存整句换掉、不叠加:值改了的项 + (关联关系改了值也一样)是这一次的依据,没写就没有(原来的说的是旧值);值不变、保存即确认的也换成这一次的。 """ plan = plan_patch(notes, changes, table=table, tables=tables) if plan.problems: @@ -1143,26 +1164,25 @@ 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) + reason = change.reason or None if change.state == "confirm" and prev is not None: - item = {**prev, "status": "confirmed", "updated_at": at} - if said: - item["note"] = _join_notes(said, prev.get("note")) + item = {k: v for k, v in prev.items() if k != "reason"} + item.update(status="confirmed", updated_at=at, **({"reason": reason} if reason else {})) slots[key] = item continue if key[0] == "r": rel = {"id": key[1], **change.after, "source": "human", "status": "confirmed", "updated_at": at} - # 覆盖率沿用了,说明它怎么测的那段备注也留着 - kept = prev.get("note") if prev is not None else None - if note := (_join_notes(said, kept) if said else kept): - rel["note"] = note + if prev is not None and prev.get("note"): + rel["note"] = prev["note"] # 覆盖率沿用了,说明它怎么测的那段备注也留着 + if reason: + rel["reason"] = reason 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", note=said, at=at, complete=done) + slots[key] = make_item(change.after, "human", "confirmed", at=at, complete=done, reason=reason) out = _assemble(slots) if problems := validate_notes(out): raise CatalogInvalid(problems) @@ -2471,7 +2491,7 @@ __all__ = [ "MODEL_MIN_PART_COLUMNS", "MODEL_NOTICE_OFF", "MODEL_NOTICE_ON", "MODEL_NOTICE_WINDOW", "MODEL_PART_MAX_CALLS", "MODEL_RETRIES", "MODEL_RETRY_DELAYS_S", "MODEL_SHRINK_AFTER", "MODEL_SPLIT_DEPTH", "MODEL_SPLIT_MIN_COLUMNS", "MODEL_TABLE_GIVE_UP_AFTER", "MODEL_TIMEOUT_S", "MergeStats", "NEW_RELATION_PATH", - "PATCH_MAX_CHANGES", + "PATCH_MAX_CHANGES", "REASON_MAX", "clip_reason", "PROFILE_NOTE_PREFIX", "PROFILE_VERIFY_COVERAGE", "WRITE_CONFLICT_ERROR", "default_tables", "missing_table_error", "pick_tables", "write_draft", "PatchChange", "PatchPlan", "QUERY_SNAPSHOT_KIND", "REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL", diff --git a/backend/app/data/catalog_assistant.py b/backend/app/data/catalog_assistant.py index 6f1c378e..5d537079 100644 --- a/backend/app/data/catalog_assistant.py +++ b/backend/app/data/catalog_assistant.py @@ -3,10 +3,11 @@ **为什么要有它。** 模型起草只看表结构,缩写列名、状态码的意思常常拿不准而留空;审阅时人知道答案,却要一项一项填。表助手 把这张表的结构、目录现状(每项的来源和状态)、关联关系、剖析结果、起草要求、使用次数、上次起草留空的列交给模型,让它主动 问拿不准的地方;使用者说出一条业务事实,它整理成成批的修改提案(形状同助手流里的 catalog_patch),使用者在界面上逐项看过、 -一次保存(PATCH /catalog/{table}/patch,记为人工确认,每项的 reason 作为依据进这一项的备注)。 +一次保存(PATCH /catalog/{table}/patch,记为人工确认,每项的 reason 另存成这一项的确认时的依据,见 catalog.apply_patch)。 -**依据只引用这次对话。** 数据源的起草要求是以前写在数据源上的背景,背景里标明「不是这次对话里使用者说的话」;提示词要求 -reason 里的原话只引用这次对话里使用者的消息,依据来自起草要求的写「起草要求写着:……」。验收时模型把起草要求写成了 +**依据只引用这次对话。** 数据源的起草要求是以前写在数据源上的背景,背景里标明「不是这次对话里使用者说的话」;目录项上以前 +保存时记下的依据也一样,背景里写成「以前确认时的依据:……」,和备注分开。提示词要求 reason 里的原话只引用这次对话里使用者的 +消息,依据来自起草要求的写「起草要求写着:……」,来自以前的依据的写「以前确认时的依据写着:……」。验收时模型把起草要求写成了 「使用者说:……」,保存之后看起来像是使用者亲口确认过。 **只改目录、不改业务库。** 模型只有两个工具: @@ -20,8 +21,8 @@ reason 里的原话只引用这次对话里使用者的消息,依据来自起 取自遮罩列的结果列整列换成「已遮罩」,改了名字的也认;这一次请求里前面遮掉的值按值换),报错原话里的值也换。没开剖析时 提示词里写明只能根据表结构和目录推断。 -**遮罩列只给列名。** 表结构里遮罩的列只写列名和类型(不给注释),目录里这些列的码值、带着取值的备注一律不给(剖析的备注里 -有取值范围);界面上还没保存的修改里这些列的码值也不给。 +**遮罩列只给列名。** 表结构里遮罩的列只写列名和类型(不给注释),目录里这些列的码值、带着取值的备注和确认时的依据一律不给 +(剖析的备注里有取值范围,使用者的原话里也可能有);界面上还没保存的修改里这些列的码值也不给。 **模型。** 取模型的顺序同起草(data/draft_model.py:起草模型 → 助手的模型 → 默认接入),用不了时的说法也同起草 (先过 catalog.resolve_draft_model,接口回 409 catalog_draft_model_unavailable)。保留思考、温度 0,额度 @@ -128,8 +129,9 @@ ASSISTANT_SYSTEM = """你是 AgentLab 的「表助手」,和使用者一起完 标着「推断」的项是最值得问的。 - 使用者说出一条业务事实时,用 propose_changes 把它整理成修改提案,reason 里引用使用者的原话(例如「使用者说:9 是作废」)。\ reason 里引用使用者原话只能引用这次对话里使用者的消息;依据来自下面背景里的数据源起草要求时写「起草要求写着:……」,\ -不要写成「使用者说」。一次可以提多项。提案只是建议:使用者在界面上看过、保存之后才写进数据目录,记为人工确认,\ -reason 会作为这一项的依据一起保存。 +来自背景里目录项下面的「以前确认时的依据」时写「以前确认时的依据写着:……」(那是以前保存时记下的,不是这次对话里\ +使用者说的话),都不要写成「使用者说」。一次可以提多项。提案只是建议:使用者在界面上看过、保存之后才写进数据目录,\ +记为人工确认,reason 会作为这一项确认时的依据一起保存。 - 表名、列名照原样写,只能用下面表结构里有的列。 - 标着「已确认」的项由人工确认过,使用者没有明确要改就不要提修改;标着「已驳回」的项使用者驳回过,不要再提。 - 「界面上还没保存的修改」是使用者已经接受、还没保存的提案:不要重复提;要改其中一项就再提一次,写完整的新值。 @@ -181,6 +183,7 @@ ASSISTANT_PROMPT_LABELS = { "catalog_empty": "这张表还没有数据目录。", "item": "- {path}({label}):{value}〔{source},{status}〕", "item_note": " 备注:{text}", + "item_reason": " 以前确认时的依据:{text}", "catalog_more": "(还有 {n} 项没有列出:这张表的目录太长。要改的项照上面的路径写法提,核对时按当前目录算)", "relation": "{columns} → {to_table}.{to_columns}{extra}", "relation_extra": "({text})", @@ -246,7 +249,8 @@ PROPOSE_TOOL_PROMPT: dict[str, Any] = { "value": {"description": "新的值:文字、列名列表、码值对照或关联关系"}, "reason": {"type": "string", "description": "一句话依据:引用这次对话里使用者的原话(「使用者说:……」);" - "来自数据源起草要求的写「起草要求写着:……」"}, + "来自数据源起草要求的写「起草要求写着:……」,来自以前确认时的依据" + "的写「以前确认时的依据写着:……」"}, }, "required": ["path", "value", "reason"], }, @@ -372,8 +376,9 @@ def _touches_masked(key: catalog.Slot, item: Mapping[str, Any], masked: set[str] def _catalog_lines(notes: Mapping[str, Any] | None, masked: set[str]) -> list[str]: - """这张表的目录,每项一行:路径、字段、值、来源和状态,有备注的另起一行。遮罩列上的码值不给,说到遮罩列的项不给备注 - (剖析的备注里有取值范围)。""" + """这张表的目录,每项一行:路径、字段、值、来源和状态,有备注、有确认时的依据的各另起一行。依据是以前保存时记下的, + 写成「以前确认时的依据:……」,不让模型当成这次对话里使用者说的话。遮罩列上的码值不给,说到遮罩列的项不给备注和 + 依据(剖析的备注里有取值范围,使用者的原话里也可能有)。""" labels = ASSISTANT_PROMPT_LABELS lines: list[str] = [] # 表级的项、关联关系在前,列的项在后(稳定排序,各自照目录里的顺序);遮罩列上的码值不算 @@ -395,6 +400,9 @@ def _catalog_lines(notes: Mapping[str, Any] | None, masked: set[str]) -> list[st note = item.get("note") if isinstance(note, str) and note.strip() and not hidden: lines.append(labels["item_note"].format(text=note.strip())) + reason = item.get("reason") + if isinstance(reason, str) and reason.strip() and not hidden: + lines.append(labels["item_reason"].format(text=reason.strip())) return lines diff --git a/backend/app/data/catalog_profile.py b/backend/app/data/catalog_profile.py index f905945c..9d7fb51b 100644 --- a/backend/app/data/catalog_profile.py +++ b/backend/app/data/catalog_profile.py @@ -999,7 +999,8 @@ def _codes_finding(c: _CodesOutcome, value: dict[str, str]) -> dict[str, Any]: def _patch_confirmed(rel: dict[str, Any], o: _RelationOutcome, at: str) -> bool: """人工确认过的关系只补覆盖率和基数:覆盖率是测量值,照新的写;基数只在原来没有时补,不改人定的; - 备注只在原来为空、或者是剖析自己写的时候换成新的(只是日期变了不换)。状态、来源不动。返回改没改。""" + 备注只在原来为空、或者是剖析自己写的时候换成新的(只是日期变了不换)。状态、来源、确认时的依据(reason,保存提案时 + 另存,不在备注里)不动。返回改没改。""" changed = False if rel.get("coverage") != o.coverage: rel["coverage"] = o.coverage diff --git a/backend/app/data/catalog_transfer.py b/backend/app/data/catalog_transfer.py index ad2dd3c5..b0badbdf 100644 --- a/backend/app/data/catalog_transfer.py +++ b/backend/app/data/catalog_transfer.py @@ -12,10 +12,11 @@ tables: {表名: {notes, version}}},含全部状态的项,表结构里已经 只对目标数据源表结构里的表;对不上的列在 unmatched_tables。文件里两张表对上目标的同一张时,写法完全一样的那张优先, 另一张算对不上。列名同样按表结构的写法、再忽略大小写对;业务主键、业务日期、关联关系两端的列和指向的表都换成目标的写法, 关联关系的编号按目标的表名、列名重算(catalog.relation_id)。 -- 文件里已确认、已驳回的项照带:来源、状态、备注、修改时间都不变。目标那一格空着就新增;目标是推断、有确证的就更新; - 目标也是已确认、已驳回的,和文件一样就保留,不一样(值或状态不同)就保留目标的、记一条冲突。关联关系的「一样」看两端和 - 基数,覆盖率是测量值、不算。 -- 文件里推断、有确证的项只填目标的空缺,目标那一格有东西(哪怕是驳回)就保留目标的,不算冲突。 +- 文件里已确认、已驳回的项照带:来源、状态、备注、确认时的依据(reason)、修改时间都不变。目标那一格空着就新增;目标是 + 推断、有确证的就更新;目标也是已确认、已驳回的,和文件一样就保留,不一样(值或状态不同)就保留目标的、记一条冲突。关联 + 关系的「一样」看两端和基数,覆盖率是测量值、不算。 +- 文件里推断、有确证的项只填目标的空缺,目标那一格有东西(哪怕是驳回)就保留目标的,不算冲突。这样的项不带确认时的依据 + (文件被手改过才会有):推断项上挂着依据,看起来像使用者确认过它。 - 跳过并写原因:表结构里没有的列、业务主键和业务日期指着的列没有的、关联关系两端对不上的(指向的表、两端的列)、 目标数据源遮罩的列上的码值(码值就是这一列的取值,遮罩了就不该进目录)、不认识的字段、取值不合规的项(说法同 catalog.validate_notes)。 @@ -404,6 +405,8 @@ def plan_table(table_name: str, target: str, file_notes: Mapping[str, Any], targ continue seen.add(mapped) status = item.get("status") + if status not in _LOCKED: + item = {k: v for k, v in item.items() if k != "reason"} # 推断、有确证的项不带确认时的依据 have = existing.get(mapped) if have is None: plan.add += 1 diff --git a/backend/app/engine/dual_check_facts.py b/backend/app/engine/dual_check_facts.py index 2cb44a83..6b9e077f 100644 --- a/backend/app/engine/dual_check_facts.py +++ b/backend/app/engine/dual_check_facts.py @@ -50,8 +50,9 @@ is_test = 0、is_valid = 1、deleted_at IS NULL);只涉及一张表。拿不 原因,只有审批停下期间目录被人改了、数据源换了版本时才会和最后写的不一样:真写按写入时的目录、表结构和导入时认作 日期的列判断。组合太多的(JOINT_LIMIT)那一项不预告,卡上有条件地说。 -catalog.save_patch 只把提案的依据写成「依据:……」的备注,这里的备注是出处(哪次运行、哪个节点),所以用它的同一套原料 -(read_entry、apply_patch、write_entry 的乐观锁)写,再把这一项换成带出处备注的人工确认项。 +catalog.save_patch 把提案的依据另存成这一项确认时的依据(reason),这里要写的是备注里的出处(哪次运行、哪个节点), +所以用它的同一套原料(read_entry、apply_patch、write_entry 的乐观锁)写,再把这一项整个换成带出处备注的人工确认项: +以前确认时记下的依据说的是旧值,不留。 """ from __future__ import annotations @@ -1052,11 +1053,11 @@ async def _write(session: Any, row: DataSource, fact: Fact, *, run_id: str, node note = note_of(kept, run_id=run_id, node_title=node_title) at = catalog.now_iso() try: - merged = catalog.apply_patch(notes, [{"path": kept.item, "value": value, "reason": note}], + merged = catalog.apply_patch(notes, [{"path": kept.item, "value": value}], table=kept.table, tables=tables, at=at) except catalog.CatalogInvalid as e: return None, [*reasons, f"「{kept.table}」的{label}未写入数据目录:{e}"] - # 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处 + # 不论原来是谁提的:这一项现在出自人的选择,记为人工填写、已确认,备注写明出处(整项换掉:以前确认时的依据不留) merged[kept.item] = catalog.make_item(value, "human", "confirmed", note=note, at=at) try: await catalog.write_entry(session, row.id, kept.table, merged, if_version=version, actor=actor) diff --git a/backend/tests/test_catalog_assistant.py b/backend/tests/test_catalog_assistant.py index 6ec473fa..c5d0ede4 100644 --- a/backend/tests/test_catalog_assistant.py +++ b/backend/tests/test_catalog_assistant.py @@ -370,6 +370,30 @@ async def test_the_draft_guidance_is_not_what_the_user_said(client, tmp_path, mo assert "这次对话里使用者" in reason and "起草要求写着" in reason +async def test_reasons_saved_earlier_are_not_what_the_user_said(client, tmp_path, monkeypatch): + """目录项上以前保存时记下的依据(reason)不是这次对话里使用者说的话:背景里在这一项下面标成「以前确认时的依据:……」, + 和备注分开;系统提示词、propose_changes 的说明写清 reason 里引用的原话只能是这次对话里使用者的消息,来自以前的依据时写 + 「以前确认时的依据写着:……」。说到遮罩列的项不给依据(同备注:里面可能有取值)。""" + notes = {**ORDERS_NOTES, + "grain": make_item("每笔订单一行", "human", "confirmed", reason="使用者说:一笔订单一行"), + "columns": {**ORDERS_NOTES["columns"], + "buyer_phone": {"label": make_item("联系电话", "human", "confirmed", + reason=f"使用者说:{PHONES[1]} 是客服电话")}}} + sid = await _source(client, tmp_path, notes=notes) + model = Scripted([Turn(text=["好。"])]) + use(monkeypatch, model) + await _talk(client, sid, _ask("看看还缺什么")) + system = model.seen[0][0].content + assert "- grain(粒度):每笔订单一行〔人工填写,已确认〕\n 以前确认时的依据:使用者说:一笔订单一行" in system + # 备注照旧另起一行,不和依据混在一起 + assert " 备注:数据剖析:全表 30 行,2 个取值" in system and "备注:使用者说" not in system + assert PHONES[1] not in system and "客服电话" not in system + assert "只能引用这次对话里使用者的消息" in system and "以前确认时的依据写着:" in system + reason = (catalog_assistant.PROPOSE_TOOL_PROMPT["function"]["parameters"]["properties"]["changes"]["items"] + ["properties"]["reason"]["description"]) + assert "以前确认时的依据写着" in reason + + async def test_background_says_how_many_runs_used_the_table(client, tmp_path, monkeypatch): """使用次数的口径同审阅队列:被几次运行用到(同「用过这张表的运行」的 total),查询几次(同目录清单的「使用 N 次」)。""" from app.db.models import Artifact, Run diff --git a/backend/tests/test_catalog_patch.py b/backend/tests/test_catalog_patch.py index 500a9042..e9c1a49d 100644 --- a/backend/tests/test_catalog_patch.py +++ b/backend/tests/test_catalog_patch.py @@ -172,38 +172,6 @@ async def test_apply_patch_records_human_confirmed(scenic_db): _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) == [] - - async def test_rejected_items_show_their_value_before(scenic_db): """提案落在已驳回的项上:改前带上被驳回的原值、状态写已驳回(同导入预览「目标里这一项已经驳回」)。只改预览给出的 信息,保存的规矩不变:照旧换成人工确认的新值;码值不在驳回的码值上补;和驳回的值一样的也不算「值不变」,照样存成 @@ -402,8 +370,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"] == "依据:作废单" + # 依据随项另存(确认时的依据,规则见 test_catalog_reason.py),不拼进备注 + assert codes["reason"] == "作废单" and "note" not in codes # 拿旧版本再存:409,什么都不写 r = await client.post(f"{base}/patch", json={"changes": changes, "if_version": version}) diff --git a/backend/tests/test_catalog_reason.py b/backend/tests/test_catalog_reason.py new file mode 100644 index 00000000..44a926be --- /dev/null +++ b/backend/tests/test_catalog_reason.py @@ -0,0 +1,236 @@ +"""保存修改提案时的依据:目录项上单独的一个字段 reason(界面上叫「确认时的依据」),和备注 note 分开。 + +表助手、助手流里「建议更新数据目录」的提案经 /catalog/{table}/patch 保存,带了依据的项把依据记进这一项的 reason(最多 +500 字,超了截成 499 字加「…」);note 照旧是起草的依据、剖析的测量说明、双盲复算的出处这类机器写的话,不拼依据。 + +- 去留:值改了(关联关系改了值也一样)换成这次的依据,没有依据就去掉旧的;值不变、保存即确认的换成这次的(不叠加); + 单项审阅的确认、驳回留着,撤销审阅(回到推断)去掉;剖析、起草、导入以外的来源改写这一项(值变了)时去掉;剖析刷新 + 关联关系的测量只动 note。 +- 校验认它(文字、最多 500 字);起草、剖析的合并不碰人工确认的项,依据留着;导入时已确认、已驳回的项连依据一起带, + 推断、有确证的项不带。 + +以前把依据拼进 note(「依据:……。数据剖析:……」):再剖析时测量的说法换不了新,和覆盖率那一栏对不上;改了值的关联关系 +旧依据叠在新依据后面;撤销审阅回到推断,「依据:使用者说:……」还挂在推断项上,看起来像使用者确认过。 + +表名、列名、数据全部虚构。 +""" +from __future__ import annotations + +from app.data import catalog, catalog_profile, catalog_transfer +from app.data.catalog import make_item + +AT = "2026-10-14T08:00:00+00:00" +LATER = "2026-10-15T08:00:00+00:00" + +#: 提案核对用的表结构(只要列名和主键) +TABLES = { + "visits": {"qualified": "visits", "primary_key": ["id"], + "columns": [{"name": c} for c in ("id", "ticket_no", "park_id", "visitor_count", "status")]}, + "parks": {"qualified": "parks", "primary_key": ["id"], "columns": [{"name": "id"}, {"name": "name"}]}, +} +PARKS = catalog.relation_id("visits", ["park_id"], "parks", ["id"]) +PARK_ENDS = {"columns": ["park_id"], "to_table": "parks", "to_columns": ["id"]} +PROFILE_NOTE = "数据剖析:抽样 1,000 行,覆盖率 97%。" +FILTER_NOTE = "依据:码值 0 是作废。剖析核对:排除 3.2% 的行(共 1,000 行)" + + +def _rel(**extra) -> dict: + """数据剖析核实过的入园记录 → 景区:覆盖率 97%,备注是测量的说法。""" + return {"id": PARKS, **PARK_ENDS, "cardinality": "many_to_one", "coverage": 0.97, "source": "profile", + "status": "verified", "note": PROFILE_NOTE, **extra} + + +def _visits() -> dict: + return { + "grain": make_item("一张门票一行", "llm"), + "valid_filter": make_item("status = 1", "llm", note=FILTER_NOTE), + "columns": {"visitor_count": {"measure": make_item("flow", "llm")}}, + "relations": [_rel()], + } + + +def _patch(notes: dict, changes: list[dict], at: str = AT) -> dict: + return catalog.apply_patch(notes, changes, table="visits", tables=TABLES, at=at) + + +# -------------------------------------------------------------------------- +# 保存提案 +# -------------------------------------------------------------------------- + + +def test_saving_keeps_the_reason_apart_from_the_note(): + out = _patch(_visits(), [ + {"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": {**PARK_ENDS, "cardinality": "one_to_one"}, + "reason": "使用者说:一个景区一条"}, + {"path": "grain", "value": "一张门票的一次入园", "reason": "长" * 600}, + ]) + measure = out["columns"]["visitor_count"]["measure"] + assert measure["reason"] == "使用者说:在园人数是存量" and "note" not in measure + assert (measure["value"], measure["source"], measure["status"]) == ("stock", "human", "confirmed") + unit = out["columns"]["visitor_count"]["unit"] + assert "reason" not in unit and "note" not in unit + # 值不变的确认:来源、备注都不变,依据另记 + valid = out["valid_filter"] + assert valid["note"] == FILTER_NOTE and valid["reason"] == "使用者说:只算有效的" + assert (valid["source"], valid["status"]) == ("llm", "confirmed") + # 关联关系改了基数:覆盖率和说明它怎么测的备注照旧沿用,备注里不拼依据 + rel = out["relations"][0] + assert rel["note"] == PROFILE_NOTE and rel["reason"] == "使用者说:一个景区一条" + assert (rel["cardinality"], rel["coverage"], rel["source"], rel["status"]) == ( + "one_to_one", 0.97, "human", "confirmed") + # 超过 500 字:截成 499 字加「…」 + grain = out["grain"] + assert grain["reason"] == "长" * 499 + "…" and "note" not in grain + assert catalog.validate_notes(out) == [] + # 正好 500 字的不截;预览给出的依据和保存的是同一句 + exact = _patch(_visits(), [{"path": "grain", "value": "一行一张票", "reason": "短" * 500}]) + assert exact["grain"]["reason"] == "短" * 500 + plan = catalog.plan_patch(_visits(), [{"path": "grain", "value": "一行一张票", "reason": "长" * 600}], + table="visits", tables=TABLES) + assert plan.changes[0].reason == "长" * 499 + "…" + + +def test_profiling_again_refreshes_the_measurement_and_keeps_the_reason(): + """带依据保存的关联关系再剖析:覆盖率和备注里测量的说法一起换新(以前备注成了「依据:……。数据剖析:……」,剖析 + 认不出是自己写的,覆盖率变了、备注却停在保存那一刻),依据不动,人定的基数不改。""" + saved = _patch({"relations": [_rel()]}, [ + {"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "one_to_one"}, + "reason": "使用者说:一个景区一条"}, + ]) + outcome = catalog_profile._TableOutcome(relations=[catalog_profile._RelationOutcome( + rid=PARKS, target="parks.id", coverage=0.99, cardinality="many_to_one", holds=True, sample=1000, matched=990, + note="数据剖析:抽样 1,000 行,覆盖率 99%。")]) + merged, findings, stats = catalog_profile._apply(saved, outcome, at=LATER) + rel = merged["relations"][0] + assert rel["coverage"] == 0.99 and rel["note"] == "数据剖析:抽样 1,000 行,覆盖率 99%。" + assert rel["reason"] == "使用者说:一个景区一条" and rel["cardinality"] == "one_to_one" + assert (rel["source"], rel["status"], rel["updated_at"]) == ("human", "confirmed", LATER) + assert stats.updated == 1 and findings[0]["confirmed"] is True + + +def test_saving_again_replaces_the_reason_instead_of_piling_it_up(): + first = _patch(_visits(), [ + {"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "one_to_one"}, + "reason": "使用者说:一个景区一条"}, + {"path": "columns.visitor_count.measure", "value": "stock", "reason": "使用者说:在园人数是存量"}, + {"path": "valid_filter", "value": "status = 1", "reason": "使用者说:只算有效的"}, + ]) + again = _patch(first, [ + # 关联关系又改了值:换成这次的依据,旧的不叠在后面;备注照旧只是测量的说法 + {"path": f"relations.{PARKS}", "value": {**PARK_ENDS, "cardinality": "many_to_one"}, + "reason": "使用者说:一个景区有多条入园记录"}, + # 改了值、这次没写依据:旧的依据说的是旧值,去掉 + {"path": "columns.visitor_count.measure", "value": "ratio"}, + ]) + rel = again["relations"][0] + assert rel["reason"] == "使用者说:一个景区有多条入园记录" and rel["note"] == PROFILE_NOTE + assert "reason" not in again["columns"]["visitor_count"]["measure"] + # 撤销审阅回到推断:依据去掉;再一次保存即确认,只有这一次的依据,备注还是起草时的那句 + reset = catalog.review_item(again, "valid_filter", "reset", at=AT) + assert reset["valid_filter"]["status"] == "proposed" and "reason" not in reset["valid_filter"] + confirmed = _patch(reset, [{"path": "valid_filter", "value": "status = 1", "reason": "使用者说:确认无误"}]) + assert confirmed["valid_filter"]["reason"] == "使用者说:确认无误" + assert confirmed["valid_filter"]["note"] == FILTER_NOTE + # 推断项上不知怎么留着一句依据(手改过的数据):保存即确认时换成这一次的,不叠 + stray = {"grain": {**make_item("一张门票一行", "llm"), "reason": "旧的依据"}} + assert _patch(stray, [{"path": "grain", "value": "一张门票一行", "reason": "新的依据"}])["grain"]["reason"] == "新的依据" + assert "reason" not in _patch(stray, [{"path": "grain", "value": "一张门票一行"}])["grain"] + + +# -------------------------------------------------------------------------- +# 审阅、人工编辑 +# -------------------------------------------------------------------------- + + +def test_review_keeps_the_reason_until_the_review_is_undone(): + notes = {"grain": make_item("一张门票一行", "llm", "confirmed", reason="使用者说:一张票一行"), + "label": make_item("入园记录", "human", "confirmed", reason="使用者说:就叫入园记录")} + rejected = catalog.review_item(notes, "grain", "reject", at=AT) + assert rejected["grain"]["status"] == "rejected" and rejected["grain"]["reason"] == "使用者说:一张票一行" + again = catalog.review_item(rejected, "grain", "confirm", at=AT) + assert again["grain"]["status"] == "confirmed" and again["grain"]["reason"] == "使用者说:一张票一行" + reset = catalog.review_item(again, "grain", "reset", at=AT) + assert reset["grain"]["status"] == "proposed" and "reason" not in reset["grain"] + # 人工填写的项撤销就是删掉,依据一起没了 + assert "label" not in catalog.review_item(notes, "label", "reset", at=AT) + + +def test_editing_by_hand_drops_the_reason_only_when_the_value_changes(): + """整份提交(表详情里的编辑)不写依据:改了值的项去掉旧的依据(界面原样带回来的也不认),值没动的照旧留着库里的, + 状态改回推断等同撤销审阅、去掉;去掉的推断关系转为驳回,依据留着(同单项驳回)。""" + rel = {"id": PARKS, **PARK_ENDS, "cardinality": "many_to_one", "coverage": None, "source": "name", + "status": "confirmed", "reason": "使用者说:一个景区一条"} + existing = { + "label": make_item("入园记录", "human", "confirmed", reason="使用者说:就叫入园记录"), + "grain": make_item("一张门票一行", "llm", "confirmed", reason="使用者说:一张票一行"), + "kind": make_item("fact", "llm", "confirmed", reason="使用者说:是明细"), + "relations": [rel], + } + submitted = { + "label": {**existing["label"], "value": "入园流水"}, + "grain": {**existing["grain"], "reason": "界面上写不了依据"}, + "kind": {**existing["kind"], "status": "proposed"}, + "columns": {"status": {"codes": {"value": {"1": "有效"}, "reason": "新填的也不收"}}}, + } + out = catalog.apply_human_edit(existing, submitted, table="visits", at=AT) + assert out["label"]["value"] == "入园流水" and "reason" not in out["label"] + assert out["grain"]["reason"] == "使用者说:一张票一行" + assert out["kind"]["status"] == "proposed" and "reason" not in out["kind"] + codes = out["columns"]["status"]["codes"] + assert (codes["source"], codes["status"]) == ("human", "confirmed") and "reason" not in codes + [kept] = out["relations"] + assert kept["status"] == "rejected" and kept["reason"] == "使用者说:一个景区一条" + assert catalog.validate_notes(out) == [] + + +# -------------------------------------------------------------------------- +# 校验、合并、导入 +# -------------------------------------------------------------------------- + + +def test_the_reason_is_text_of_at_most_500_characters(): + ok = {"label": make_item("入园记录", "human", "confirmed", reason="短" * 500), + "columns": {"status": {"codes": make_item({"1": "有效"}, "human", "confirmed", complete=True, + reason="使用者说:1 是有效")}}, + "relations": [_rel(status="confirmed", reason="使用者说:一个景区一条")]} + assert catalog.validate_notes(ok) == [] + bad = catalog.validate_notes({"label": {**make_item("入园记录", "human", "confirmed"), "reason": "长" * 501}, + "grain": {**make_item("一行", "human", "confirmed"), "reason": 3}, + "relations": [_rel(reason=["使用者说"])]}) + assert len(bad) == 3 and all("确认时的依据" in p for p in bad), bad + + +def test_drafting_and_profiling_leave_the_reason_of_reviewed_items_alone(): + existing = {"label": make_item("入园记录", "llm", "confirmed", reason="使用者说:就叫入园记录"), + "grain": make_item("一张门票一行", "llm", "rejected", reason="使用者说:一张票一行"), + "columns": {"status": {"codes": make_item({"1": "有效"}, "profile", "confirmed", + reason="使用者说:1 是有效")}}} + draft = {"label": make_item("入园流水", "llm"), "grain": make_item("每次刷闸一行", "llm"), + "kind": make_item("fact", "llm")} + merged, _ = catalog.merge_notes(existing, draft, covered={"llm"}, at=AT, columns=["id", "status"]) + assert merged["label"] == existing["label"] and merged["grain"] == existing["grain"] + assert "reason" not in merged["kind"] + outcome = catalog_profile._TableOutcome(codes=[catalog_profile._CodesOutcome( + column="status", values=[("1", 900), ("0", 100)], rows=1000, note="数据剖析:全表 1,000 行,2 个取值。", + complete=True)]) + profiled, _, _ = catalog_profile._apply(merged, outcome, at=LATER) + assert profiled["columns"]["status"]["codes"] == existing["columns"]["status"]["codes"] + + +def test_importing_carries_the_reason_of_confirmed_and_rejected_items_only(): + cache = {"tables": {"visits": TABLES["visits"]}} + file_notes = { + "label": make_item("入园记录", "human", "confirmed", reason="使用者说:就叫入园记录"), + "grain": make_item("一张门票一行", "llm", "rejected", reason="使用者说:一张票一行"), + # 推断项上带着依据(文件被手改过):只填空缺,不带确认时的依据 + "description": {**make_item("每次刷闸记一行", "llm"), "reason": "手改的文件"}, + } + plan = catalog_transfer.plan_table("visits", "visits", file_notes, {}, cache, set(), "sqlite") + assert plan.skipped == [] and plan.add == 3 + writes = {key[1]: item for key, item in plan.writes.items()} + assert writes["label"]["reason"] == "使用者说:就叫入园记录" + assert writes["grain"]["reason"] == "使用者说:一张票一行" + assert writes["description"]["value"] == "每次刷闸记一行" and "reason" not in writes["description"] diff --git a/backend/tests/test_catalog_transfer.py b/backend/tests/test_catalog_transfer.py index b0556247..ba861de3 100644 --- a/backend/tests/test_catalog_transfer.py +++ b/backend/tests/test_catalog_transfer.py @@ -68,8 +68,9 @@ def _rel(to_table: str, columns: list[str], to_columns: list[str], *, status: st SOURCE_ORDERS = { "label": make_item("订单", "human", "confirmed", at="2026-10-01T08:00:00+00:00"), "grain": make_item("每笔订单一行", "llm"), - "valid_filter": make_item("status <> 9", "human", "confirmed"), - "dedup": make_item("同一订单号取最后一次", "llm", "rejected"), + # 确认、驳回过的项带着保存提案时记下的依据:导出、导入照带 + "valid_filter": make_item("status <> 9", "human", "confirmed", reason="使用者说:9 是作废,统计时排除"), + "dedup": make_item("同一订单号取最后一次", "llm", "rejected", reason="使用者说:同一订单号只有一行"), "kind": make_item("fact", "llm", "confirmed"), "columns": { "status": {"codes": make_item({"1": "已支付", "9": "作废"}, "human", "confirmed", complete=True), @@ -250,8 +251,9 @@ async def test_import_writes_by_the_rules_and_reports_versions(client): assert orders.updated_by == "沈岚" assert notes["label"]["value"] == "订单表" # 冲突:保留目标的 assert notes["grain"]["value"] == "一行一单" # 推断只填空缺 - assert notes["valid_filter"] == {**SOURCE_ORDERS["valid_filter"]} # 照带:来源、状态不变 + assert notes["valid_filter"] == {**SOURCE_ORDERS["valid_filter"]} # 照带:来源、状态、依据不变 assert notes["dedup"]["status"] == "rejected" and notes["dedup"]["source"] == "llm" + assert notes["dedup"]["reason"] == "使用者说:同一订单号只有一行" codes = notes["columns"]["STATUS"]["codes"] assert codes["value"] == {"1": "已支付", "9": "作废"} and codes["complete"] is True assert notes["columns"]["STATUS"]["label"]["value"] == "状态码" diff --git a/backend/tests/test_dual_check_facts.py b/backend/tests/test_dual_check_facts.py index 0e27e978..66fdf79b 100644 --- a/backend/tests/test_dual_check_facts.py +++ b/backend/tests/test_dual_check_facts.py @@ -631,6 +631,20 @@ async def test_a_rejected_item_is_not_brought_back_but_a_rejected_other_value_do assert (await entry_of(source_id)).notes["business_date"]["value"] == {"column": "order_date"} +async def test_writing_over_an_item_drops_the_reason_it_was_confirmed_with(shop): + """双盲复算的选择改写这一项(值变了):以前确认时记下的依据说的是旧值,不留;出处照旧写在备注里。""" + name, source_id = shop + await seed(source_id, {"business_date": catalog.make_item({"column": "paid_date"}, "llm", "rejected", + reason="使用者说:按支付日算")}) + out = await record_choices([candidate(BY_ORDER_DATE, RIGHT, "a", source=name)], run_id="run-7", + node_title="复核", actor=None) + assert out["written"] == [{"source": name, "table": "orders", "item": "business_date", + "value": {"column": "order_date"}}] + item = (await entry_of(source_id)).notes["business_date"] + assert item["value"] == {"column": "order_date"} and (item["source"], item["status"]) == ("human", "confirmed") + assert "reason" not in item and "run-7" in item["note"] + + async def test_contradictory_choices_in_one_decision_write_nothing(shop): """同一次裁决里,一个指标采纳了按下单日、另一个采纳了按支付日:一张表只有一个业务日期,写哪个都是替人拿主意, 都不写。""" diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 1f33dd6a..2d6d030b 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2974,6 +2974,8 @@ export const CATALOG_UI_TEXT = { emptyTables: (n: number) => `挑表时跳过 ${formatNumber(n)} 张空表`, // ---- 表详情里有效记录条件、去重规则的说明(模型起草时附的依据、数据剖析核对的排除比例) fieldNote: '说明', + // ---- 保存修改提案时记下的依据(目录项的 reason):和上面的说明分开写,表详情、状态弹层同一个叫法 + fieldReason: '确认时的依据', } // =========================================================================== diff --git a/frontend/src/pages/catalog/ColumnTable.tsx b/frontend/src/pages/catalog/ColumnTable.tsx index bbbe8e0c..12b043d3 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, NoteHint, confirmedNote } from './parts' +import { FOCUS_RING, FOCUS_STYLE, ItemMark, NoteHint, ReasonHint, confirmedNote, confirmedReason } from './parts' import type { ReviewTarget } from './parts' import { COLUMN_FIELDS, codesComplete, columnPath, completeOf } from './model' import type { ColumnDraft, ColumnField } from './model' @@ -136,7 +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 reason = editing ? '' : confirmedReason(it) const note = editing ? '' : confirmedNote(it) return ( // 窄框下没填的项不占行(编辑时照常列出来好填) @@ -146,11 +147,13 @@ const ColumnRow = memo(function ColumnRow({ col, editing, draft, initial, proble {CT.columnHead[f]}
{editing ? input : view}
+ {note && } {it && !changed && ( onReview({ path: columnPath(name, f), where, source: it.source, status: it.status }, a)} /> )} diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx index ddf3ce23..0f0882c4 100644 --- a/frontend/src/pages/catalog/TableDetail.tsx +++ b/frontend/src/pages/catalog/TableDetail.tsx @@ -18,7 +18,10 @@ import { CATALOG_USAGE_TEXT, CODES_TEXT as KT, } from '../../lib/terms' import { CatalogImpactList } from '../../components/CatalogImpact' -import { CountBadges, FOCUS_RING, FOCUS_STYLE, ItemMark, NoteHint, StatusChip, StatusLegend, SystemNotesNotice, confirmedNote } from './parts' +import { + CountBadges, FOCUS_RING, FOCUS_STYLE, ItemMark, NoteHint, REASON_ICON, ReasonHint, StatusChip, StatusLegend, SystemNotesNotice, confirmedNote, + confirmedReason, +} from './parts' import type { ReviewTarget } from './parts' import { ColumnTable, NARROW, Value, pendingCodes } from './ColumnTable' import type { ColumnModel } from './ColumnTable' @@ -697,7 +700,8 @@ function blankCodes(notes: CatalogDetail['notes'], pick: (path: string) => boole /** * 推断时也在值下面写说明的项:有效记录条件、去重规则(模型起草时附的依据、数据剖析核对的排除比例或没有核对的原因)。 - * 别的项人工确认过、带着备注(保存提案时附的依据……)时也写 + * 别的项人工确认过、带着备注(剖析的说明、双盲复算的出处……)时也写。确认时的依据(保存修改提案时记下的)另起一行写, + * 不和说明混在一起 */ const NOTED_FIELDS = new Set(['valid_filter', 'dedup']) @@ -771,6 +775,7 @@ 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() ?? '' : confirmedNote(item) + const reason = edit ? '' : confirmedReason(item) return (
{edit ? input : }
{item && !changed && ( - onReview({ path: tablePath(field), where, source: item.source, status: item.status }, a)} /> )} {changed && {CT.changedMark}}
+ {reason && ( +

+ + {UT.fieldReason}:{reason} +

+ )} {note && (

@@ -923,9 +935,11 @@ function RelationList({ relations, busy, targetOf, onOpen, onReview, focusPath = - 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 34be8d01..bf980661 100644 --- a/frontend/src/pages/catalog/parts.tsx +++ b/frontend/src/pages/catalog/parts.tsx @@ -1,7 +1,7 @@ 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, Info, RotateCcw, ShieldCheck, Trash2, X } from 'lucide-react' +import { Check, CircleCheck, CircleDashed, CircleX, Info, MessageSquareQuote, 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' @@ -99,12 +99,14 @@ export interface ReviewTarget { source: CatalogSource status: CatalogStatus note?: string + /** 确认时的依据(保存修改提案时记下的,和备注分开写) */ + reason?: string updated_at?: string } /** * 单项的来源和状态。触发按钮就是状态标识(compact 时只有图标,列表格里每格一个);点开是一个小弹层: - * 位置、来源、状态说明、更新时间,以及确认 / 驳回 / 恢复。弹层挂在 body 上、按触发按钮的位置固定定位, + * 位置、来源、状态说明、更新时间、备注和确认时的依据(各一块),以及确认 / 驳回 / 恢复。弹层挂在 body 上、按触发按钮的位置固定定位, * 不会被表格的横向滚动裁掉;滚动时跟着触发按钮走,滚出视野或改窗口大小时收起。键盘:Enter 打开,焦点落到第一个可点的操作, * ←→↑↓ 在操作之间移动,Tab 在弹层里循环,Esc 收起并把焦点还给触发按钮 */ @@ -260,6 +262,12 @@ export function ItemMark({ target, compact = false, disabled = false, disabledHi

{CT.itemUpdated(formatTime(target.updated_at))}

)} {target.note &&

{target.note}

} + {/* 确认时的依据:使用者确认时说的话,和上面机器写的备注分开(驳回的项只在这里看得到当初确认时的依据) */} + {target.reason?.trim() && ( +

+ {UT.fieldReason}:{target.reason.trim()} +

+ )}
} disabled={disabled || status === 'confirmed'} onClick={() => act('confirm')} action="confirm"> @@ -295,26 +303,48 @@ function ActionButton({ icon, children, disabled, onClick, action, title }: { } /** - * 人工确认的项带的备注:保存提案时附的依据(「依据:使用者说……」)、确认时留着的数据剖析说明。确认过的项是 SQL 检查和 - * 助手的依据,为什么这么定要看得到,不能只藏在状态弹层里:表级的项写在值下面(TableFieldRow,同有效记录条件的说明), - * 列的一格、关联关系悬停看(NoteHint)。没有备注、不是已确认的为空串 + * 人工确认的项带的备注:确认时留着的数据剖析说明、模型起草时附的依据、双盲复算写明的出处。确认过的项是 SQL 检查和助手的 + * 依据,为什么这么定要看得到,不能只藏在状态弹层里:表级的项写在值下面(TableFieldRow,同有效记录条件的说明),列的一格、 + * 关联关系悬停看(NoteHint)。没有备注、不是已确认的为空串 */ export const confirmedNote = (item: { status: CatalogStatus; note?: string } | null | undefined): string => (item && item.status === 'confirmed' ? item.note?.trim() ?? '' : '') /** - * 列的一格、关联关系上的备注:只占一个小图标,悬停看全文;读屏念「说明:……」。attr 是给检查脚本定位的 data- 属性名 + * 确认时的依据(目录项的 reason):保存修改提案时使用者确认的依据,服务端和备注分开存,这里也分开写——表级的项在值下面 + * 另起一行,列的一格、关联关系另一个悬停的图标(NoteHint,读屏念「确认时的依据:……」)。只在已确认的项上就近写;驳回的项 + * 值已划掉,当初确认时的依据只在状态弹层里看。没有为空串 */ -export function NoteHint({ note, attr }: { note: string; attr: string }) { +export const confirmedReason = (item: { status: CatalogStatus; reason?: string } | null | undefined): string => + (item && item.status === 'confirmed' ? item.reason?.trim() ?? '' : '') + +/** 确认时的依据用的图标:引号气泡(使用者的话),和备注的「i」分得开 */ +export const REASON_ICON: LucideIcon = MessageSquareQuote + +/** + * 列的一格、关联关系上的备注或确认时的依据:只占一个小图标,悬停看全文;读屏念「说明:……」(label 换成「确认时的依据」 + * 时念「确认时的依据:……」)。attr 是给检查脚本定位的 data- 属性名 + */ +export function NoteHint({ note, attr, label = UT.fieldNote, icon: Icon = Info }: { + note: string + attr: string + label?: string + icon?: LucideIcon +}) { return ( - - - {UT.fieldNote}:{note} + + {label}:{note} ) } +/** 列的一格、关联关系上确认时的依据(没有为 null) */ +export function ReasonHint({ reason, attr }: { reason: string; attr: string }) { + return reason ? : null +} + /** 从审阅队列打开的那一项(表级项、列的一格、一条关系):一圈强调色的框,淡淡的底 */ 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/frontend/src/types.ts b/frontend/src/types.ts index c1b8f6f8..926f2a13 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2712,7 +2712,13 @@ export interface CatalogItem { value: T source: CatalogSource status: CatalogStatus + /** 备注:机器写的说明(模型起草的依据、数据剖析的测量、双盲复算的出处……) */ note?: string + /** + * 确认时的依据:保存修改提案(表助手、助手流里「建议更新数据目录」)时使用者确认的依据,和备注分开存。值改了换成那一次的, + * 撤销审阅回到推断时去掉;只由服务端写,整份提交时带上也不作数 + */ + reason?: string updated_at?: string } @@ -2745,6 +2751,8 @@ export interface CatalogRelation { source: CatalogSource status: CatalogStatus note?: string + /** 确认时的依据(同 CatalogItem.reason) */ + reason?: string updated_at?: string /** * 基数是数据剖析用数据核实的(子表一侧数过是否唯一),只由剖析写。外键、命名推断的基数没有这个键。 diff --git a/scripts/check-catalog-loop.mjs b/scripts/check-catalog-loop.mjs index d9058d87..0968e622 100644 --- a/scripts/check-catalog-loop.mjs +++ b/scripts/check-catalog-loop.mjs @@ -285,17 +285,22 @@ const writeEntry = (t, notes) => { t.updated_by = ACTOR } const conflictDetail = (name) => ({ detail: `表「${name}」的数据目录刚被修改过,请重新载入后再提交` }) +/** 单项审阅(catalog.review_item):确认、驳回留着确认时的依据(reason),撤销审阅回到推断时去掉;人工填写的撤销即删除 */ function applyReview(notes, path, action) { const map = slots(clone(notes ?? {})) const it = map.get(path) if (!it) return null if (action === 'reset' && it.source === 'human') map.delete(path) - else it.status = action === 'confirm' ? 'confirmed' : action === 'reject' ? 'rejected' : INIT[it.source] ?? 'proposed' + else { + it.status = action === 'confirm' ? 'confirmed' : action === 'reject' ? 'rejected' : INIT[it.source] ?? 'proposed' + if (action === 'reset') delete it.reason + } return assemble(map) } /** - * 保存提案(catalog.apply_patch 的简化):码值是补充(驳回的码值不算),其余整项替换;改动的项记为人工填写、已确认, - * 写了依据的项依据进备注「依据:……」 + * 保存提案(catalog.apply_patch 的简化):码值是补充(驳回的码值不算),其余整项替换。值改了的项记为人工填写、已确认; + * 值不变的只改成已确认(来源、备注不变)。写了依据的项依据另存在 reason(确认时的依据),和备注分开,每次整句换掉: + * 这次没写的去掉旧的 */ function applyPatch(notes, changes) { const map = slots(clone(notes ?? {})) @@ -303,7 +308,15 @@ function applyPatch(notes, changes) { const prev = map.get(c.path) 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}` } : {}) }) + const reason = c.reason ? { reason: c.reason } : {} + const at = new Date().toISOString() + if (live && JSON.stringify(live.value) === JSON.stringify(value)) { + if (live.status === 'confirmed') continue + const { reason: _old, ...kept } = live + map.set(c.path, { ...kept, status: 'confirmed', updated_at: at, ...reason }) + continue + } + map.set(c.path, { value, source: 'human', status: 'confirmed', updated_at: at, ...reason }) } return assemble(map) } @@ -860,13 +873,16 @@ 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('依据:用户说「统计时退票和作废的都要排除」'), + // 保存的修改把依据另存成这一项确认时的依据(服务端的 reason,和备注分开):表级的项写在值下面,列的一格悬停看得到 + check('……确认时的依据看得到:有效记录条件的值下面写「确认时的依据:用户说……」', + (await textOf(page.locator('[data-field="valid_filter"] [data-field-reason]'))) === '确认时的依据:用户说「统计时退票和作废的都要排除」', 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 page.locator('[data-field="valid_filter"] [data-field-note]').count() === 0, await textOf(page.locator('[data-field="valid_filter"]'))) + const savedReason = page.locator('[data-column="status"] [data-cell="codes"] [data-cell-reason]') + check('……码值那一格悬停写确认时的依据', ((await savedReason.getAttribute('title').catch(() => '')) ?? '') === '确认时的依据:用户说「8 是退票」', + String(await savedReason.getAttribute('title').catch(() => null))) + check('……那一格没有另挂说明', await page.locator('[data-column="status"] [data-cell="codes"] [data-cell-note]').count() === 0) 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, '表助手') @@ -1482,40 +1498,77 @@ await section('导出导入 · 导入结果只数真正写进去的表', async ( await close() }) -await section('表详情 · 人工确认的项的说明', async () => { - // 人工确认的项带着备注(保存提案时的依据、数据剖析的说明):表级的项写在值下面(同有效记录条件的说明),列的一格、 - // 关联关系悬停看得到。推断的项照旧只有有效记录条件、去重规则在值下面写说明 +await section('表详情 · 确认时的依据和人工确认的项的说明', async () => { + // 保存修改提案时记下的依据(项上的 reason,界面上叫「确认时的依据」)和备注(note:起草的依据、剖析的测量、双盲复算的 + // 出处)分开写:表级的项写在值下面各一行,列的一格、关联关系各一个悬停的小图标,读屏念「确认时的依据:……」「说明:……」; + // 状态弹层里也写依据。只在已确认的项上就近写,驳回的只在弹层里写。推断的项照旧只有有效记录条件、去重规则在值下面写说明 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 }) + const LABEL_REASON = '使用者说:这张表一次刷闸记一行' + const FILTER_REASON = '使用者说:只算有效的' + const KEYS_REASON = '使用者说:票号不会重复' + const UNIT_REASON = '使用者说:入园人数按人计' + const DATE_NOTE = '双盲复算(运行 run-check-0003,节点「复核入园人数」):审核人在指标「入园人数」上采用了盲算的写法' + const CODES_NOTE = '数据剖析:整表 12,345 行,1 个取值' + const CODES_REASON = '使用者说:1 是已入园' + const REL_NOTE = '数据剖析:抽样 2,000 行,覆盖率 98%' + const REL_REASON = '使用者说:每条入园记录对应一个景区' + visits.notes.label = item('入园记录', 'human', undefined, { reason: LABEL_REASON }) + // 值不变、保存即确认的模型起草项:起草时的说明照旧,依据另写一行 + visits.notes.valid_filter = item('status NOT IN (9)', 'llm', 'confirmed', { note: FILTER_NOTE, reason: FILTER_REASON }) visits.notes.grain = item('一张门票的一次入园', 'llm', 'proposed', { note: '依据:表名和票号列' }) - visits.notes.columns.visitor_count.unit = item('人', 'human', undefined, { note: COL_NOTE }) + visits.notes.keys = item(['ticket_no'], 'llm', 'rejected', { reason: KEYS_REASON }) + visits.notes.columns.visitor_count.unit = item('人', 'human', undefined, { reason: UNIT_REASON }) visits.notes.columns.visitor_count.label = item('入园人数', 'llm', 'proposed', { note: '依据:列名' }) + // 双盲复算写进来的人工确认项:只有备注(出处),没有确认时的依据 + visits.notes.business_date = item({ column: 'visit_time' }, 'human', undefined, { note: DATE_NOTE }) + visits.notes.columns.status.codes = item({ 1: '已入园' }, 'profile', 'confirmed', { note: CODES_NOTE, reason: CODES_REASON }) 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 }] + source: 'human', status: 'confirmed', updated_at: T0, note: REL_NOTE, reason: REL_REASON }] 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), + const field = (f, part) => page.locator(`[data-field="${f}"] [data-field-${part}]`) + check('表级的项:值下面写确认时的依据(中文名)', (await textOf(field('label', 'reason'))) === `确认时的依据:${LABEL_REASON}`, 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]') + check('……没有备注的不另写说明', await field('label', 'note').count() === 0) + check('依据和说明分开写:有效记录条件下面一行依据、一行起草时的说明', (await textOf(field('valid_filter', 'reason'))) === `确认时的依据:${FILTER_REASON}` + && (await textOf(field('valid_filter', 'note'))) === `说明:${FILTER_NOTE}`, await textOf(page.locator('[data-field="valid_filter"]'))) + check('人工确认的项只有备注(双盲复算的出处):写说明,不写依据(业务日期)', (await textOf(field('business_date', 'note'))) === `说明:${DATE_NOTE}` + && await field('business_date', 'reason').count() === 0, await textOf(page.locator('[data-field="business_date"]'))) + check('……推断的表级项照旧不在值下面写(粒度)', await field('grain', 'note').count() === 0 && await field('grain', 'reason').count() === 0) + check('……驳回的项不在值下面写依据(业务主键)', await field('keys', 'reason').count() === 0, await textOf(page.locator('[data-field="keys"]'))) + const cell = (c, f, part) => page.locator(`[data-column="${c}"] [data-cell="${f}"] [data-cell-${part}]`) + const titleOf = async (loc) => (await loc.getAttribute('title').catch(() => null)) ?? '' + check('列的一格:悬停写确认时的依据(单位)', (await titleOf(cell('visitor_count', 'unit', 'reason'))) === `确认时的依据:${UNIT_REASON}`, + await titleOf(cell('visitor_count', 'unit', 'reason'))) + check('……读屏念「确认时的依据:……」', (await cell('visitor_count', 'unit', 'reason').innerText().catch(() => '')).includes(`确认时的依据:${UNIT_REASON}`)) + check('……没有备注的那一格不挂说明的图标', await cell('visitor_count', 'unit', 'note').count() === 0) + check('依据和说明各一个图标(码值:剖析的说明、确认时的依据)', (await titleOf(cell('status', 'codes', 'reason'))) === `确认时的依据:${CODES_REASON}` + && (await titleOf(cell('status', 'codes', 'note'))) === `说明:${CODES_NOTE}`) + check('……推断的列级项不加(中文名)', await cell('visitor_count', 'label', 'note').count() === 0 && await cell('visitor_count', 'label', 'reason').count() === 0) 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('关联关系:依据、测量的说明各悬停一个', (await titleOf(page.locator('[data-relation="r_park"] [data-relation-reason]'))) === `确认时的依据:${REL_REASON}` + && (await titleOf(page.locator('[data-relation="r_park"] [data-relation-note]'))) === `说明:${REL_NOTE}`) + // 状态弹层:依据单独一块,写明是确认时的依据;驳回的项也在这里看得到当初确认时的依据 + const popover = async (path) => { + await page.locator(`[data-item-mark="${path}"]`).first().click() + const box = page.locator(`[data-item-panel="${path}"]`) + await box.waitFor({ timeout: 2000 }).catch(() => {}) + const text = await textOf(box.locator('[data-item-reason]')) + await page.keyboard.press('Escape') + await box.waitFor({ state: 'detached', timeout: 2000 }).catch(() => {}) + return text + } + check('状态弹层里写确认时的依据(中文名)', (await popover('label')) === `确认时的依据:${LABEL_REASON}`) + check('……驳回的项只在弹层里写(业务主键)', (await popover('keys')) === `确认时的依据:${KEYS_REASON}`) + check('……没有依据的项弹层里不写(粒度)', (await popover('grain')) === '') + await cleanCopy(page, '确认时的依据') check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() }) @@ -1547,9 +1600,9 @@ await section('表助手 · 改前是已驳回的值', async () => { await panel(page).locator('[data-assistant-save]').click() check('保存:交回原样取值和依据', await until(async () => count(sent, /\/visits\/patch$/) === 1) && JSON.stringify(lastSent(sent, /\/visits\/patch$/)?.body?.changes?.[0]) === JSON.stringify({ path: 'columns.visitor_count.unit', value: '人', reason: '使用者说:入园人数按人计' })) - check('……存上以后那一格是人工确认的「人」,悬停写保存时的依据', await until(async () => (await version(page)) === 3) + check('……存上以后那一格是人工确认的「人」,悬停写确认时的依据', await until(async () => (await version(page)) === 3) && (await textOf(page.locator('[data-column="visitor_count"] [data-cell="unit"]'))).includes('人') - && ((await page.locator('[data-column="visitor_count"] [data-cell="unit"] [data-cell-note]').getAttribute('title').catch(() => '')) ?? '').includes('依据:使用者说:入园人数按人计')) + && ((await page.locator('[data-column="visitor_count"] [data-cell="unit"] [data-cell-reason]').getAttribute('title').catch(() => '')) ?? '') === '确认时的依据:使用者说:入园人数按人计') await cleanCopy(page, '改前是已驳回的值') check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close()