Files
AgentLab/backend/tests/test_plain_layer.py
T
ThearchyHelios 277982ead3 普通用户建应用(后端):
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表
- 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号
- 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period)
- 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
2026-10-15 12:09:29 +08:00

152 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""白话层(docs/design/normal-user-apps.md N10,app/plain.py)和反问收尾的服务端部分(N11、G6)。
- 覆盖:代码里出现的问题编号(工作流发布门禁 contract.* / governed.* / action.* / process.*、子工作流和口径卡固定的版本、
应用检查 app.*、蓝图 bp.*、模板 tpl.*、个人应用 personal.*、发布单 plan.*)都要有白话。B1、B3、B4 新增的编号由各包
自己补;这里扫的是代码里实际出现的全部编号,报出没有白话的那几个;
- 谁来改(who):要管理员的编号 → admin;自动修复给的是 choice → you,auto / assist → assistant;应用检查只有人能处理的
(APP_HUMAN_ONLY)→ you,其余 → assistant。白话里登记的那一档和这条规则一致(一种说法,P5);
- 文案:白话里不写「钉住」、不裸写配置键名(check-copy 同一套规则在 scripts/check-copy.mjs 里扫);
- 反问收尾:只问不改的一轮记进历史时,下一轮的上下文写「助手反问了什么」,不写「没有结果」;只问了一句的那一轮记的
空图不算「上一轮的图」。
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from app.db import models # noqa: F401 - 建表要先登记全部模型(conftest 按 Base.metadata 建表)
from app.plain import ADMIN_CODES, PLAIN, PLAIN_PREFIX, plain_of, who_of, with_plain
APP = Path(__file__).resolve().parent.parent / "app"
#: 白话层要覆盖的编号前缀
PREFIXES = ("contract", "governed", "action", "process", "app", "bp", "tpl", "personal", "plan")
_CODE = r"((?:%s)\.[a-z_]+)" % "|".join(PREFIXES)
#: 问题编号在代码里出现的几种写法:code="x"、"code": "x"、out.error("x"、add("x"、flag 的元组写法、
#: 异常类的默认编号、模块级常量(PERSONAL_OFF = "personal.off")
PATTERNS = [
re.compile(r'\bcode\s*=\s*"%s"(?!\s*,\s*")' % _CODE),
re.compile(r'"code"\s*:\s*"%s"' % _CODE),
re.compile(r'\.(?:error|warning|info)\(\s*"%s"' % _CODE),
re.compile(r'\badd\(\s*"%s"' % _CODE),
re.compile(r'\bcode\s*=\s*"[^"]*",\s*"%s"' % _CODE),
re.compile(r'\bcode\s*:\s*str\s*=\s*"%s"' % _CODE),
re.compile(r'^[A-Z][A-Z0-9_]*\s*(?::\s*\w+\s*)?=\s*"%s"' % _CODE, re.M),
re.compile(r'Refused\([^)]*?"%s"\s*\)' % _CODE),
]
#: 不是问题编号的同名写法:运行事件的种类(core/events.py 的 action.sent 一类)
SKIP_FILES = {APP / "core" / "events.py"}
def codes_in_code() -> dict[str, str]:
"""代码里出现的问题编号 → 第一次出现在哪个文件。"""
out: dict[str, str] = {}
for path in sorted(APP.rglob("*.py")):
if path in SKIP_FILES:
continue
text = path.read_text(encoding="utf-8")
for pattern in PATTERNS:
for code in pattern.findall(text):
out.setdefault(code, str(path.relative_to(APP)))
return out
def test_the_scan_finds_codes_in_every_family():
found = codes_in_code()
for code in ("contract.required_missing", "contract.report_from_invalid", "governed.metric_untraced",
"action.prod_missing", "process.human_in_subgraph", "app.text_number", "app.acl_empty",
"app.polish_refused", "plan.graph_changed", "plan.preflight", "bp.refused"):
assert code in found, code
# 事件种类不是问题编号
assert "action.sent" not in found
def test_every_problem_code_in_the_code_has_a_plain_wording():
missing = {code: where for code, where in codes_in_code().items() if plain_of(code) is None}
assert not missing, "这些问题编号还没有白话(app/plain.py):" + ";".join(
f"{code}({where})" for code, where in sorted(missing.items()))
def test_subgraph_and_caliber_version_codes_have_plain_wording():
from app.apps.versions import CALIBER_RETIRED, CALIBER_UNPUBLISHED, SUBGRAPH_RETIRED, SUBGRAPH_UNPUBLISHED
for code in (SUBGRAPH_UNPUBLISHED, SUBGRAPH_RETIRED, CALIBER_UNPUBLISHED, CALIBER_RETIRED):
assert plain_of(code) is not None, code
#: 工作流发布门禁、应用检查、发布单的白话(包 B2 写的):一句话、为什么要紧、怎么办三样都要有
FULL = ("contract.", "governed.", "action.", "process.", "app.", "plan.", "subgraph_version_", "caliber_version_")
def test_every_plain_wording_is_complete_and_follows_the_copy_rules():
keys = ("report_from", "metrics_from", "caliber_from", "on_timeout", "assignee", "upgrade_policy",
"evidence_role", "output_schema", "cite_fields")
for code, plain in [*PLAIN.items(), *PLAIN_PREFIX.items()]:
assert plain.title and plain.action, code
assert plain.why or not code.startswith(FULL), code
assert plain.who in ("you", "assistant", "admin"), code
text = plain.title + plain.why + plain.action
assert "钉" not in text and "跑" not in text, code
assert not any(k in text for k in keys), code
# 中文句子用全角标点
assert not re.search(r"[一-鿿][,;:?!]|[,;:?!][一-鿿]", text), code
def test_who_follows_one_rule():
from app.api.copilot_gen import APP_HUMAN_ONLY
# 应用检查(apps/lint.py 报的)在白话里登记的那一档:只有人能处理的「你来定」,其余「助手可以修」(助手的自查修它们)
lint = {code for code, where in codes_in_code().items() if where == "apps/lint.py"}
for code in lint:
assert PLAIN[code].who == ("you" if code in APP_HUMAN_ONLY else "assistant"), code
assert who_of({"code": "app.slot_unbound"}) == "you"
assert who_of({"code": "app.text_number"}) == "assistant"
# 自动修复给的种类:要人选的你来定,能自动修、能交给助手的助手可以修
assert who_of({"code": "contract.required_missing", "fix_kind": "choice"}) == "you"
assert who_of({"code": "governed.subgraph_unpinned", "fix_kind": "auto"}) == "assistant"
assert who_of({"code": "governed.supervisor", "fix_kind": "assist"}) == "assistant"
# 要管理员的:端点、能力登记、受管级和降级的发布
assert {"action.prod_missing", "action.reconcile_unplanned", "plan.admin_required", "plan.downgrade_admin"} \
<= ADMIN_CODES
assert who_of({"code": "action.prod_missing", "fix_kind": "assist"}) == "admin"
# 没登记白话的:照旧显示技术说法(不补 plain)
issue = {"code": "no.such_code", "message": "x"}
assert with_plain(issue) is issue
out = with_plain({"code": "subgraph_version_retired", "message": "x"})
assert out["plain"]["title"] and out["plain"]["who"] == "you"
@pytest.fixture
async def conversation():
from app.db.base import SessionLocal
from app.db.models import Conversation, ConversationTurn
async with SessionLocal() as session:
conv = Conversation(title="反问")
session.add(conv)
await session.flush()
session.add(ConversationTurn(conversation_id=conv.id, seq=1, question="订单金额", answer="", status="done",
graph={"nodes": [], "edges": []},
meta={"asked": {"question": "按下单时间还是付款时间算?",
"options": ["下单时间", "付款时间"]}}))
# 问数据页的写法:meta.asked 只记 true,问题写在这一轮的答案里
session.add(ConversationTurn(conversation_id=conv.id, seq=2, question="付款时间", status="done",
answer="要不要去掉已退款的订单?", meta={"asked": True}))
await session.commit()
return conv.id
async def test_history_says_what_the_assistant_asked(conversation):
from app.api.copilot import _history_section, _previous_graph
from app.db.base import SessionLocal
async with SessionLocal() as session:
text = await _history_section(session, conversation)
previous = await _previous_graph(session, conversation)
assert "助手反问" in text and "按下单时间还是付款时间算?" in text and "下单时间、付款时间" in text
assert "(助手反问,等用户回答:要不要去掉已退款的订单?)" in text
assert "(没有结果)" not in text
# 只问了一句的那一轮记的是空图:不算「上一轮的图」
assert previous is None and "上一轮用的工作流" not in text