diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 55946277..3892954a 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -2266,7 +2266,9 @@ APP_SYSTEM_PROMPT = """\ 硬性约定: - 只能用下面组件目录里的组件,props 按目录里写的字段和类型写,不要编造组件和属性 - 绑定(bind)只取钉住的版本里已有的值:绑定的节点、指标、报告都要在钉住的版本里存在,拿不准就先 look 那一版; - 文字组件里不要写数字,也不要拼算式——数字只来自绑定,界面层不做任何计算 + 文字组件里不要写数据的数字,也不要拼算式——数字只来自绑定,界面层不做任何计算。说明流程规则(审批的金额线、时限) + 时写的数要和钉住的版本里节点配置写的一致;用户要写的规则在钉住的版本里没有,不要照写,在 done 的说明里告诉用户 + 流程并不按这条执行、要先改工作流 - 可见性表达式(visible)只能用 run、input、metric、instance、task、user、view 这几个根名字,只能比较、取值、in 和 and / or / not,不能写算术 - 只改呈现:不改钉住的版本(pins)、入口(entries)、视图(views)、岗位(slots),不改表单的数据约束(字段、类型、 diff --git a/backend/app/apps/lint.py b/backend/app/apps/lint.py index 124771c2..ad01137d 100644 --- a/backend/app/apps/lint.py +++ b/backend/app/apps/lint.py @@ -25,6 +25,8 @@ RUN_FIELDS = ("status", "tier", "finished_at") USER_FIELDS = ("username", "display_name") _DIGIT = re.compile(r"\d") +#: 文字里的一个数:千分位的逗号算在数里(「5,000」就是 5000) +_NUMBER = re.compile(r"\d[\d,,]*(?:\.\d+)?") _REPORT_TEXT = re.compile(r"^\s*\{\{\s*nodes\.([A-Za-z0-9_-]+)\.text\s*\}\}\s*$") _PLACEHOLDER = re.compile(r"\{([A-Za-z0-9_㐀-鿿]+)\}") @@ -245,6 +247,7 @@ def _lint_components(spec: dict[str, Any], pins: dict[str, Any], views: dict[str from app.apps.catalog import BY_NAME + constants = _graph_numbers(pins) for comp, _, at in components(spec): ctype = comp.get("type") entry = BY_NAME.get(str(ctype)) @@ -257,20 +260,59 @@ def _lint_components(spec: dict[str, Any], pins: dict[str, Any], views: dict[str f"{at}.props{('.' + where) if where else ''}") if comp.get("children") and not entry["children"]: out.error("app.props_invalid", f"组件「{comp.get('id')}」({entry['label']})不能放子组件", f"{at}.children") - _lint_text(comp, entry, at, level, out) + _lint_text(comp, entry, at, level, out, constants) _lint_bind(spec, comp, entry, pins, views, at, out) _lint_visible(comp, views, at, out) _lint_refs(spec, comp, at, out) -def _lint_text(comp: dict[str, Any], entry: dict[str, Any], at: str, level: str, out: _Issues) -> None: - """静态文字里有数字:叙述层无算术、数字只来自证据。受管级报错,已发布级警告。""" +def _numbers(text: str) -> list[str]: + return [m.replace(",", "").replace(",", "").rstrip(".") for m in _NUMBER.findall(text)] + + +def _graph_numbers(pins: dict[str, Any]) -> set[str]: + """固定的版本里节点配置写着的数:条件里的金额线、时限、会签人数、表单的上下限这些流程规则。只看 config,不看标题。""" + found: set[str] = set() + + def walk(value: Any) -> None: + if isinstance(value, bool): + return + if isinstance(value, (int, float)): + found.update(_numbers(f"{value:g}" if isinstance(value, float) else str(value))) + elif isinstance(value, str): + found.update(_numbers(value)) + elif isinstance(value, dict): + for v in value.values(): + walk(v) + elif isinstance(value, list): + for v in value: + walk(v) + + for state in pins.values(): + if getattr(state, "row", None) is None: + continue + for node in node_map(state.graph).values(): + walk(node.get("config")) + return found + + +def _lint_text(comp: dict[str, Any], entry: dict[str, Any], at: str, level: str, out: _Issues, + constants: set[str]) -> None: + """静态文字里有数字:叙述层无算术、数字只来自证据。受管级报错,已发布级警告。 + + 数据要绑定指标或单元格;流程规则(审批的金额线、时限、会签人数)的出处是固定版本里的节点配置——文字里的每个数都在 + 那里写着才算有出处。规则只写在说明里、流程并不照着执行的,照样报:那句话是假的。""" props = comp.get("props") or {} text = props.get("markdown") if comp.get("type") == "text" else props.get("text") if comp.get("type") == "notice" else None - if isinstance(text, str) and _DIGIT.search(text): - add = out.error if level == "governed" else out.warning - add("app.text_number", f"组件「{comp.get('id')}」({entry['label']})的静态文字里写了数字:数字没有出处," - "请改为绑定指标或单元格", f"{at}.props") + if not isinstance(text, str) or not _DIGIT.search(text): + return + loose = [n for n in dict.fromkeys(_numbers(text)) if n not in constants] + if not loose: + return + add = out.error if level == "governed" else out.warning + add("app.text_number", f"组件「{comp.get('id')}」({entry['label']})的静态文字里写了数字「{'、'.join(loose[:5])}」," + "没有出处:数据请改为绑定指标或单元格;流程规则(如审批的金额线、时限)要先写进工作流,文字里的数和工作流里的一致" + "才算有出处", f"{at}.props") def _lint_bind(spec: dict[str, Any], comp: dict[str, Any], entry: dict[str, Any], pins: dict[str, Any], diff --git a/backend/tests/test_app_spec_tools.py b/backend/tests/test_app_spec_tools.py index e8155c22..87f53814 100644 --- a/backend/tests/test_app_spec_tools.py +++ b/backend/tests/test_app_spec_tools.py @@ -374,3 +374,19 @@ class _Grant: ]) def test_app_roles(principal, grants, want): assert role_of(principal, _App(), grants) == want + + +async def test_text_numbers_from_the_pinned_process_have_a_source(): + """流程规则的数(金额线、时限)出处是固定版本里的节点配置:对得上就不报;流程里没有的规则写在说明里照样报——那句话是假的。""" + graph = approval() + graph["nodes"][1]["data"]["config"].update(due="PT48H", condition="input.amount > 5000") + wf, info = await make_workflow(graph) + text = lambda md: base_spec(wf, info, pages=[{"id": "home", "title": "首页", "body": [ # noqa: E731 + {"id": "t", "type": "text", "props": {"markdown": md}}]}]) + async with SessionLocal() as session: + ok = await lint_app(session, text("金额超过 5,000 元由财务审批,48 小时内处理"), {}) + bad = await lint_app(session, text("金额超过 8000 元需要总监会签,48 小时内处理"), {}) + assert "app.text_number" not in codes(ok) + issue = next(i for i in bad if i["code"] == "app.text_number") + # 只列对不上的那个数,48 小时在节点配置里写着 + assert issue["level"] == "error" and "「8000」" in issue["message"] and "48" not in issue["message"]