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]}
+
{CT.itemUpdated(formatTime(target.updated_at))}
)} {target.note &&{target.note}
} + {/* 确认时的依据:使用者确认时说的话,和上面机器写的备注分开(驳回的项只在这里看得到当初确认时的依据) */} + {target.reason?.trim() && ( ++ {UT.fieldReason}:{target.reason.trim()} +
+ )}