From 25e444ca3fdf42945df95fc10d668757c763af97 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 3 Oct 2026 12:14:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9B=AE=E5=BD=95=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=204A=20=E6=9C=AF=E8=AF=AD=EF=BC=9A=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E4=B8=8A=E7=95=8C=E9=9D=A2=E7=9A=84=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E6=8A=A5=E9=94=99=E6=94=B9=E7=94=A8=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E5=8F=AB=E6=B3=95=EF=BC=8C=E8=A1=A8=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=86=99=E6=98=8E=E7=BB=86=E8=A1=A8=E3=80=81=E5=BA=A6?= =?UTF-8?q?=E9=87=8F=E7=B1=BB=E5=9E=8B=E5=86=99=E5=8F=AF=E7=B4=AF=E5=8A=A0?= =?UTF-8?q?=E3=80=81=E5=85=B3=E8=81=94=E5=85=B3=E7=B3=BB=E4=B8=A4=E7=AB=AF?= =?UTF-8?q?=E5=86=99=E6=9C=AC=E8=A1=A8=E5=AD=97=E6=AE=B5=E5=92=8C=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E8=A1=A8=EF=BC=8C=E7=BB=99=E6=A8=A1=E5=9E=8B=E7=9C=8B?= =?UTF-8?q?=E7=9A=84=E8=AF=B4=E6=B3=95=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/data/catalog.py | 40 ++++++++++------ backend/tests/test_catalog_impact.py | 4 +- backend/tests/test_catalog_patch.py | 3 +- backend/tests/test_catalog_terms.py | 69 ++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_catalog_terms.py diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py index 5af60d7a..53585abe 100644 --- a/backend/app/data/catalog.py +++ b/backend/app/data/catalog.py @@ -157,13 +157,26 @@ def _codes_ok(value: Any) -> bool: and all(isinstance(k, str) and isinstance(v, str) and v.strip() for k, v in value.items())) +#: 表类型、度量类型在界面上的叫法(和 frontend/src/lib/terms.ts 的 CATALOG_KIND_LABEL / CATALOG_MEASURE_LABEL 一致)。 +#: 校验报错会原样上界面(接口 422 的 detail),按它写:以前写「事实表」「流量」,界面上却是「明细表」「可累加」。 +#: 给模型看的渲染另用 KIND_LABEL / MEASURE_LABEL,保留「事实表」「流量,可跨期加总」——模型认得这套说法 +UI_KIND_LABEL = {"fact": "明细表", "dimension": "维度表", "snapshot": "快照表", "log": "日志表", "config": "配置表"} +UI_MEASURE_LABEL = {"flow": "可累加", "stock": "存量", "ratio": "比率", "identifier": "标识", "status": "状态", + "attribute": "属性"} + + +def _one_of(labels: Iterable[str]) -> str: + names = list(labels) + return f"应为{'、'.join(names[:-1])}或{names[-1]}之一" + + #: 每个字段的值怎么查、不合规时怎么说 _TABLE_VALUE_RULES: dict[str, tuple[Any, str]] = { "label": (_text_ok, "应为不超过 500 字的文字"), "description": (lambda v: _text_ok(v, _LONG_TEXT_MAX), "应为不超过 2000 字的文字"), "grain": (_text_ok, "应为不超过 500 字的文字"), "keys": (_str_list_ok, "应为列名的列表"), - "kind": (lambda v: v in TABLE_KINDS, "应为事实表、维度表、快照表、日志表或配置表之一"), + "kind": (lambda v: v in TABLE_KINDS, _one_of(UI_KIND_LABEL[k] for k in TABLE_KINDS)), "business_date": (_business_date_ok, "应写明日期列,规则和时区写成文字"), "valid_filter": (_text_ok, "应为不超过 500 字的条件"), "dedup": (_text_ok, "应为不超过 500 字的文字"), @@ -172,7 +185,7 @@ _COLUMN_VALUE_RULES: dict[str, tuple[Any, str]] = { "label": (_text_ok, "应为不超过 500 字的文字"), "meaning": (_text_ok, "应为不超过 500 字的文字"), "unit": (lambda v: _text_ok(v, 50), "应为不超过 50 字的文字"), - "measure": (lambda v: v in COLUMN_MEASURES, "应为流量、存量、比率、标识、状态或属性之一"), + "measure": (lambda v: v in COLUMN_MEASURES, _one_of(UI_MEASURE_LABEL[k] for k in COLUMN_MEASURES)), "codes": (_codes_ok, "应为码值到含义的对照,含义写成文字"), } @@ -201,7 +214,7 @@ def _relation_problems(rel: Any, seen: set[str]) -> list[str]: if not isinstance(rel, dict): return ["关联关系应为对象"] rid = rel.get("id") - # 带编号时后面空一格再接说明:「关联关系 r1a2 的被指向列…」 + # 带编号时后面空一格再接说明:「关联关系 r1a2 的目标字段…」。两端的叫法和界面一致:本表字段 → 目标表的目标字段 where = f"关联关系 {rid} " if isinstance(rid, str) and rid else "关联关系" problems: list[str] = [] if not (isinstance(rid, str) and rid.strip()) or "." in str(rid): @@ -214,13 +227,13 @@ def _relation_problems(rel: Any, seen: set[str]) -> list[str]: problems.append(f"{where}含有不认识的字段「{'、'.join(extra)}」") cols, to_cols = rel.get("columns"), rel.get("to_columns") if not _str_list_ok(cols): - problems.append(f"{where}的本表列应为列名的列表") + problems.append(f"{where}的本表字段应为列名的列表") if not _text_ok(rel.get("to_table"), 255): - problems.append(f"{where}缺少被指向的表") + problems.append(f"{where}缺少目标表") if not _str_list_ok(to_cols): - problems.append(f"{where}的被指向列应为列名的列表") + problems.append(f"{where}的目标字段应为列名的列表") elif _str_list_ok(cols) and len(cols) != len(to_cols): - problems.append(f"{where}两端的列数不一致") + problems.append(f"{where}两端的字段数不一致") if rel.get("cardinality") not in (*CARDINALITIES, None): problems.append(f"{where}的基数「{rel.get('cardinality')}」不在可选值内") coverage = rel.get("coverage") @@ -1921,10 +1934,11 @@ __all__ = [ "DraftReport", "INFERRED_MARK", "ITEM_SOURCES", "ITEM_STATUSES", "JoinEdge", "KIND_LABEL", "MEASURE_LABEL", "MergeStats", "NEW_RELATION_PATH", "PATCH_MAX_CHANGES", "PatchChange", "PatchPlan", "QUERY_SNAPSHOT_KIND", "REVIEW_ACTIONS", "TABLE_FIELDS", "TABLE_FIELD_LABEL", "TABLE_KINDS", "TableDraft", "TableDraftResult", - "apply_human_edit", "apply_patch", "describe_slot", "draft_catalog", "draft_structure", "draft_with_model", - "fk_relations", "frozen_catalog", "infer_name_relations", "initial_status", "join_paths", "make_item", - "merge_notes", "now_iso", "parse_path", "plan_patch", "query_snapshot_meta", "read_catalog", "read_entry", - "relation_graph", "relation_id", "render_table_index", "render_table_notes", "resolve_draft_model", - "resolve_table_name", "review_entry", "review_item", "same_notes", "save_human_edit", "save_patch", - "status_counts", "system_notes_source", "table_usage", "validate_notes", "visible_notes", "write_entry", + "UI_KIND_LABEL", "UI_MEASURE_LABEL", "apply_human_edit", "apply_patch", "describe_slot", "draft_catalog", + "draft_structure", "draft_with_model", "fk_relations", "frozen_catalog", "infer_name_relations", + "initial_status", "join_paths", "make_item", "merge_notes", "now_iso", "parse_path", "plan_patch", + "query_snapshot_meta", "read_catalog", "read_entry", "relation_graph", "relation_id", "render_table_index", + "render_table_notes", "resolve_draft_model", "resolve_table_name", "review_entry", "review_item", "same_notes", + "save_human_edit", "save_patch", "status_counts", "system_notes_source", "table_usage", "validate_notes", + "visible_notes", "write_entry", ] diff --git a/backend/tests/test_catalog_impact.py b/backend/tests/test_catalog_impact.py index 7e07e2af..dd3476d8 100644 --- a/backend/tests/test_catalog_impact.py +++ b/backend/tests/test_catalog_impact.py @@ -77,8 +77,8 @@ async def world(scenic_db): created.append(wf.id) return wf - direct = await template("入园日报", [_graph(_query("q", src, "SELECT COUNT(*) FROM main.VISITS v WHERE v.status = 1", - "查询入园人数"))], published=1) + counted = _query("q", src, "SELECT COUNT(*) FROM main.VISITS v WHERE v.status = 1", "查询入园人数") + direct = await template("入园日报", [_graph(counted)], published=1) agent = await template("入园分析", [_graph(_node("ask", "agent", "分析入园", prompt="分析", tools=[f"db_query__{src}", f"db_schema__{src}"]))], published=1, level="governed") diff --git a/backend/tests/test_catalog_patch.py b/backend/tests/test_catalog_patch.py index 37de087f..939ea0f5 100644 --- a/backend/tests/test_catalog_patch.py +++ b/backend/tests/test_catalog_patch.py @@ -61,7 +61,8 @@ async def test_plan_patch_reports_before_and_after(scenic_db): assert (measure.path, measure.before, measure.before_status, measure.after) == ( "columns.visitor_count.measure", "flow", "confirmed", "stock") assert measure.state == "change" and measure.reason == "在园人数是存量,不能跨天相加" - assert (kind.path, kind.before, kind.before_status, kind.after, kind.state) == ("kind", None, None, "fact", "change") + assert (kind.path, kind.before, kind.before_status, kind.after, kind.state) == ( + "kind", None, None, "fact", "change") async def test_codes_are_supplemented_not_replaced(scenic_db): diff --git a/backend/tests/test_catalog_terms.py b/backend/tests/test_catalog_terms.py new file mode 100644 index 00000000..f940a66c --- /dev/null +++ b/backend/tests/test_catalog_terms.py @@ -0,0 +1,69 @@ +"""服务端上界面的数据目录说法和界面一致(阶段 4A 术语对齐)。 + +界面以 frontend/src/lib/terms.ts 的 CATALOG_TERMS 一组为准:表类型的 fact 写「明细表」,度量类型的 flow 写「可累加」, +关联关系两端写「本表字段 → 目标表的目标字段」。校验报错(CatalogInvalid,接口回 422 时原样上界面)以前写的是 +「事实表」「流量」「被指向的表」,和界面对不上。给模型看的提示词(render_table_notes、起草提示)照旧,不在这里管。 +""" +from __future__ import annotations + +import re +from pathlib import Path + +from app.data import catalog +from app.data.catalog import make_item + +TERMS = Path(__file__).resolve().parents[2] / "frontend" / "src" / "lib" / "terms.ts" + + +def _labels(name: str) -> list[str]: + """terms.ts 里一张对照表的中文值,按书写顺序。""" + text = TERMS.read_text(encoding="utf-8") + block = re.search(rf"export const {name}\b[^=]*=\s*\{{(.*?)\n\}}", text, re.S) + assert block, f"terms.ts 里没有 {name}" + return re.findall(r":\s*'([^']+)'", block.group(1)) + + +def _choices(labels: list[str]) -> str: + return f"应为{'、'.join(labels[:-1])}或{labels[-1]}之一" + + +def test_kind_and_measure_hints_follow_the_ui_labels(): + kinds = _labels("CATALOG_KIND_LABEL") + measures = _labels("CATALOG_MEASURE_LABEL") + assert kinds[0] == "明细表" and measures[0] == "可累加" + [kind] = catalog.validate_notes({"kind": make_item("event", "human")}) + assert kind == f"表的表类型{_choices(kinds)}" + [measure] = catalog.validate_notes({"columns": {"amount": {"measure": make_item("sum", "human")}}}) + assert measure == f"列 amount 的度量类型{_choices(measures)}" + for problem in (kind, measure): + assert "事实表" not in problem and "流量" not in problem + + +def test_relation_problems_use_the_ui_words(): + rel = {"id": "r1", "columns": ["a", "b"], "to_table": "", "to_columns": ["id"], "cardinality": None, + "coverage": None, "source": "human", "status": "confirmed"} + problems = catalog.validate_notes({"relations": [rel]}) + assert "关联关系 r1 缺少目标表" in problems + rel = {**rel, "to_table": "parks", "to_columns": "id"} + assert "关联关系 r1 的目标字段应为列名的列表" in catalog.validate_notes({"relations": [rel]}) + rel = {**rel, "columns": "a", "to_columns": ["id"]} + assert "关联关系 r1 的本表字段应为列名的列表" in catalog.validate_notes({"relations": [rel]}) + rel = {**rel, "columns": ["a", "b"]} + assert "关联关系 r1 两端的字段数不一致" in catalog.validate_notes({"relations": [rel]}) + text = ";".join(catalog.validate_notes({"relations": [{**rel, "to_table": ""}]})) + assert "被指向" not in text and "本表列" not in text + + +async def test_api_422_uses_the_ui_labels(): + """接口把校验报错原样交给界面:整份提交和提案保存都是这一句。""" + from app.api.catalog import _invalid + + try: + catalog.apply_human_edit({}, {"kind": {"value": "event"}}, table="visits") + except catalog.CatalogInvalid as e: + detail = _invalid(e).detail + assert "明细表" in detail and "事实表" not in detail + # 给模型看的渲染照旧:模型认得「事实表」「流量,可跨期加总」 + text = catalog.render_table_notes({"kind": make_item("fact", "human"), + "columns": {"n": {"measure": make_item("flow", "human")}}}) + assert "表类型:事实表" in text and "流量,可跨期加总" in text