diff --git a/README.md b/README.md index ce08b5ce..d31d54b9 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ flowchart LR 流式输出里看到的就是最终数字。裸数字、解析不了的引用当场判违规、交回去重写(`max_repairs`,默认 1 次), 仍不过关时按 `on_violation` 让节点失败(正式运行默认)或把违规标在报告里(探索运行默认)。 出具契约写了 `report_from` 就按引用核对:取回报告文档时复验哈希、独立重算一遍,全部带引用才判完整出具。 +纯指标看板(契约只写 `metrics_from`,上游没有报告撰写节点、成果里也没有模型写的话)按**指标出具**:没有叙述可核,核的是指标本身——必需指标齐不齐、每个指标的输入是否追到原始数据(查询快照的格、运行输入)、成果字段里的数是否都回指到指标;受管级要求每个指标都静态地追到原始数据。 **取数链路。** agent、工具节点、代码节点、知识检索取数时都记进证据台账,报告目录按台账编出 Q1… / K1…。 口径卡表达式可以写 `cell(nodes.pull, 0, 'gmv')`,出处精确到单元格。agent 开 `cite_fields`(配合 `output_schema`) @@ -355,13 +356,15 @@ cd backend && python -m app.auth reset-admin --username admin # 不给 --passw ``` **角色**:管理员 ⊇ 搭建者 ⊇ 使用者。应用另有三档授权:使用、查看全部实例、管理(授给使用者的「管理」按「查看全部 -实例」算)。 +实例」算)。搭建者绑定岗位、授权时从只读的人员名录(`GET /api/directory`:用户名、显示名、是否停用、组名)里选, +名录里没有的、停用了的名字不让添加;单点登录的人和组第一次登录后才进名录,没进名录的要确认后才添加。开了登录后, +发布、发起、数据导入的确认一律记登录的人,界面上不再有署名框。 | 能做的事 | 管理员 | 搭建者 | 使用者 | |---|---|---|---| | 问数据、助手 | ✓ | ✓ | 默认不能,管理员可在「设置 → 身份」开启 | | 编排、探索运行、以已发布级发布、数据源、数据目录、定时任务、知识库、自定义工具 | ✓ | ✓ | ✗ | -| 以受管级发布、版本退役 | ✓ | ✗ | ✗ | +| 以受管级发布、把受管工作流降为已发布、版本退役 | ✓ | ✗ | ✗ | | 记录页、运行的实时推送 | ✓ | ✓ | 只限自己的问数据运行 | | MCP 的 stdio、端点和凭据、能力治理、模型接入 | ✓ | 只读 | ✗ | | 应用:使用 / 查看全部实例 / 搭建发布 | ✓ | 能用能看;管理要创建者或有管理授权 | 按授权使用、查看 | @@ -387,7 +390,9 @@ cd backend && python -m app.auth reset-admin --username admin # 不给 --passw - **正式运行免审批**:声明为只读、审批设为免审批的能力,在正式运行里直接执行,带 MCP、自定义工具的 Agent 不再停在 半路等审批。可写数据源上的写语句不会因为一句声明而免审批。 - **写入能力只能放在「调用工具」节点里**:Agent、多 Agent 协作的工具清单里有写入能力时,发布检查各级都报错,运行时 - 也拒绝。 + 也拒绝。工具页的试运行、工具库执行碰到写入能力一律演练:不发送,交回将要发送的方法、地址、请求头名和请求体。 + 试运行没保存的草稿时,请求按草稿的配置拼,读写按已保存的治理和草稿的方法里更严的一个认。试运行和端点的测试连接按 HTTP 状态说成败:2xx 才算成功,能连上但回 404、401 的照实写状态码。查询能力只能选同一 + 端点上、绑定这个端点的只读能力。 - **登记的端点**:管理员按测试、生产两个环境登记外部系统的地址和认证头。只有登记过、开了「允许访问内网」的端点能连 内网。绑定端点的 HTTP 工具只填方法、路径模板、查询参数和请求体,路径参数逐段编码代入,模型拿不到任意地址,不跟随 跳转。正式运行走生产环境;探索运行里写入走测试环境(没有就演练),读取走生产环境。凭据模式选「服务凭据 + 身份 diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index feca296c..a30dd51b 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -691,7 +691,7 @@ async def list_instances(app_id: str, who: str = "mine", entry: str | None = Non async def get_instance(app_id: str, run_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """一个实例:列表里的那一行,加上实例页的视图模型(应用里叫 instance 的页;没有就给一页默认的)。""" - from app.apps.resolve import ViewRefused, instance_page, instance_row, pending_counts, resolve_page + from app.apps.resolve import ViewRefused, instance_page, instance_rows, resolve_page principal = _principal(x_actor) app, role = await _app(session, app_id, principal, "use") @@ -715,8 +715,7 @@ async def get_instance(app_id: str, run_id: str, session: AsyncSession = Depends version=version, actor=actor_of(x_actor)) except ViewRefused as e: raise _view_refused(e) from e - counts = await pending_counts(session, [run.id]) - return {**instance_row(run, counts.get(run.id, 0)), "view": view} + return {**(await instance_rows(session, [run]))[0], "view": view} class RefreshIn(BaseModel): diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index c266a7e8..77ca9adf 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -42,16 +42,18 @@ def principal_out(p: Principal) -> dict[str, Any]: @router.get("/me") -async def me(x_actor: str | None = Header(default=None)) -> Any: +async def me(x_actor: str | None = Header(default=None), session: AsyncSession = Depends(get_session)) -> Any: """当前是谁。关登录:mode=off、role=admin、username 是自报的署名。开登录没登录:401,带 mode(登录页据此显示 - 账号密码还是单点登录)。""" + 账号密码还是单点登录)和 can_bootstrap:账号密码登录、库里还没有能登录的管理员时为真(环境变量直接指定了 local + 的新部署),登录页据此给「建第一个管理员」——条件和 POST /bootstrap 同一个,不多说别的。""" principal = current_principal() if principal.mode == "off": actor = decode_actor(x_actor) return principal_out(Principal(username=actor, display_name=actor, role="admin", mode="off")) if not principal.username: + can_bootstrap = principal.mode == "local" and not await _local_admin_exists(session) return JSONResponse(status_code=401, content={"detail": "请先登录", "code": "auth_required", - "mode": principal.mode}) + "mode": principal.mode, "can_bootstrap": can_bootstrap}) return principal_out(principal) diff --git a/backend/app/api/capabilities.py b/backend/app/api/capabilities.py index 90f46194..c056d439 100644 --- a/backend/app/api/capabilities.py +++ b/backend/app/api/capabilities.py @@ -7,7 +7,8 @@ require_role("builder"),写接口在里面再要 admin。 - 治理带 version 乐观锁:读到的是 v3、提交时已经是 v4 的回 409,界面重新读一遍再改; - 字段语义沿用数据目录「项」的状态和审阅(data/capability_review.py):GET /capabilities/review-queue 列推断的项, POST /capabilities/review/batch 批量确认、驳回、撤销审阅(逐个能力按 version 乐观锁,写审计); -- 端点的认证头只回掩码,提交掩码值表示保持原值;allow_intranet 只有管理员能开(写接口本身只给管理员)。 +- 端点的认证头只回掩码,提交掩码值表示保持原值;头的名字和值不合法(空名字、带空格、换行、中文)的 422 说清是哪一项; + allow_intranet 只有管理员能开(写接口本身只给管理员)。 """ from __future__ import annotations @@ -175,6 +176,8 @@ async def create_endpoint(payload: EndpointIn, session: AsyncSession = Depends(g ensure_role("admin") if (await session.execute(select(Endpoint.id).where(Endpoint.name == payload.name))).first(): raise HTTPException(409, f"已存在名为「{payload.name}」的端点,请换一个名称") + if problem := ep.idempotency_header_problem(payload.idempotency_header): + raise HTTPException(422, problem) try: envs = ep.seal_environments(payload.environments, None) except ep.EndpointError as e: @@ -202,6 +205,8 @@ async def update_endpoint(endpoint_id: str, payload: EndpointPatch, session: Asy if (await session.execute(select(Endpoint.id).where(Endpoint.name == payload.name))).first(): raise HTTPException(409, f"已存在名为「{payload.name}」的端点,请换一个名称") values["name"] = payload.name + if payload.idempotency_header is not None and (problem := ep.idempotency_header_problem(payload.idempotency_header)): + raise HTTPException(422, problem) if payload.environments is not None: try: values["environments"] = ep.seal_environments(payload.environments, row.environments) diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index 7cbe0267..5b15bf67 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api import health from app.api.coded import PROFILE_SETTINGS_INVALID, CodedHTTPException -from app.api.runs import actor_of +from app.api.deps import signer_of from app.core.errors import AUTH, NETWORK, TIMEOUT, classify, explain, raw from app.core.config import settings from app.core.crypto import encrypt, mask @@ -1065,8 +1065,8 @@ async def purge_raw( raise CodedHTTPException(422, "请填写清除原件的理由", "reason_required") if len(reason) > _PURGE_REASON_MAX: raise CodedHTTPException(422, f"理由不能超过 {_PURGE_REASON_MAX} 字", "reason_required") - signed = body.get("signed_by") - signed_by = signed.strip()[:_SIGNED_MAX] if isinstance(signed, str) and signed.strip() else actor_of(x_actor) + # 开了登录是登录的人;关登录时请求体的 signed_by 优先,其次 X-Actor(signer_of) + signed_by = signer_of(body.get("signed_by"), x_actor, _SIGNED_MAX) if imp.raw_state == "absent": raise CodedHTTPException(409, "这次导入没有保存原件", "raw_absent") if imp.raw_state == "purged": diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 4cea8618..7448f913 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -92,6 +92,17 @@ def actor_of(header: str | None) -> str | None: return decode_actor(header) +def signer_of(given: Any, header: str | None, limit: int = 100) -> str | None: + """请求体里自带署名(signed_by)的留痕记谁:开了登录一律是认证过的用户名,请求体里填的不看——不然随手填一个名字就 + 盖掉了登录的人;关登录时请求体里填的优先(截到 limit 个字),其次 X-Actor,都只原样记下(未认证)。""" + principal = _principal.get() + if principal is not None and principal.mode != "off": + return principal.username + if isinstance(given, str) and given.strip(): + return given.strip()[:limit] + return decode_actor(header) + + def current_principal() -> Principal: """这次请求的完整身份。关登录时:等同 admin、未认证,username 是中间件按 X-Actor 解出的自报署名;中间件没写 (不在请求里,比如测试直接调函数、启动时的整理、调度循环)时同样等同 admin、未认证,username 为空。""" diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index e888f45c..75a77730 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -128,6 +128,7 @@ from app.engine.evidence import ( withhold_metrics, ) from app.engine.judge import FIELDS_MAX, SETTLED, UNSUPPORTED, VERDICTS, candidates, outdated +from app.engine.lineage import untraced from app.engine.merge_query import MERGE_SOURCE, trace_cell from app.engine.schema import CLAIMS_JUDGE, GraphSpec, challenge_report, dual_check_card from app.engine.toolcalls import QUERY_PREFIX @@ -962,6 +963,10 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s # 期 4:这个指标做没做过双盲复算、结果如何。没有双盲复算复核过这份指标集时没有这个键,老运行一字不差 if checked := _dual_check_of(sealed, str(entry.get("node_id") or ""), artifact, metric_id, metric): step["dual_check"] = checked + if metric is not None and metric.get("inputs"): + # 输入追没追到原始数据(engine/lineage.py):和「有出处」(确定性复算一致)是两件事,面板分开说。追不到的照实说 + # 出处追到哪个节点为止;卡上没有输入(常量)的没有可追的,不给这个键 + step["untraced"] = _untraced_steps(metric, sealed) catalog = doc.get("catalog") or {} inputs: list[dict[str, Any]] = [] #: 输入指到的查询快照 → 被引用的格;同一份快照只给一个查询步骤,格合在一起高亮 @@ -969,6 +974,9 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s held, word = await _metric_masks(metric or {}, str(entry.get("node_id") or ""), artifact, sealed, masks, card=card) for item in (metric or {}).get("inputs") or []: one = {"step": "input", **item} + if item.get("through"): + # 穿过原样解析查询结果的整形节点按位置取的格:面板写明经过的是哪个节点(图里的名字) + one["through_label"] = _node_label(sealed, item["through"]) artifact_of = item.get("artifact") if artifact_of and item.get("via") in ("tool_cell", "agent_field"): one["query"] = _query_alias(catalog, artifact_of) @@ -991,6 +999,26 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s return [step, *inputs, *queries] +def _node_label(sealed: _Sealed, node_id: Any) -> str: + """封存的图里这个节点的名字,没有名字的写 id。""" + node = next((n for n in (sealed.graph or {}).get("nodes") or [] if isinstance(n, dict) and n.get("id") == node_id), {}) + return str((node.get("data") or {}).get("label") or node_id or "") + + +def _untraced_steps(metric: dict[str, Any], sealed: _Sealed) -> list[dict[str, Any]]: + """指标的输入里追不到原始数据的:[{path, node_id, label, type}],label 是封存的图里那个节点的名字、type 是节点类型 + (面板按它写「数据整形」「沙箱代码」)。认不出是哪个节点的,node_id、label 为 None。""" + nodes = {n.get("id"): n for n in (sealed.graph or {}).get("nodes") or [] if isinstance(n, dict)} + out = [] + for item in untraced(metric.get("inputs") or []): + node_id = item.get("node_id") + node = nodes.get(node_id) or {} + out.append({"path": item.get("path"), "node_id": node_id, + "label": _node_label(sealed, node_id) if node_id else None, + "type": node.get("type") or None}) + return out + + @dataclass(frozen=True) class _Held: """一个输入不给值的原因(_InputMasks.check、_metric_masks)。kind: diff --git a/backend/app/api/identity.py b/backend/app/api/identity.py index 41aa7d8a..75eadbe5 100644 --- a/backend/app/api/identity.py +++ b/backend/app/api/identity.py @@ -1,4 +1,5 @@ -"""用户、组、审计(BE-1,只给管理员:main.py 挂 require_role("admin"))。设计文档 §2.7、§3.10。 +"""用户、组、审计(BE-1,只给管理员:main.py 挂 require_role("admin"));人和组的名录(只读,给搭建者及以上)。 +设计文档 §2.7、§3.10。 - 用户名一经建立就不改(各「人」列开了登录就写它);改叫法改显示名。单点登录来的用户角色每次登录按映射重算,这里改了 下次登录会被映射盖掉; @@ -23,6 +24,9 @@ from app.db.models import AuditEvent, Group, GroupMember, User from app.db.models_identity import ROLES router = APIRouter(prefix="/api", tags=["identity"]) +#: 人和组的名录(GET /api/directory):只读、给搭建者及以上(main.py 挂 require_role("builder")),岗位绑定、应用授权的 +#: 选人控件据此给候选、核对填的名字在不在。只有用户名、显示名、是否停用和组名,不含邮箱、角色、登录时间 +directory_router = APIRouter(prefix="/api", tags=["identity"]) def _iso(value: Any) -> str | None: @@ -205,6 +209,19 @@ async def delete_group(group_id: str, session: AsyncSession = Depends(get_sessio await session.commit() +# ---------------------------------------------------------------- 名录 + + +@directory_router.get("/directory") +async def directory(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + """选人用的名录。停用的人也列出来(标 disabled):已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」。""" + users = (await session.execute(select(User).order_by(User.username))).scalars() + groups = (await session.execute(select(Group).order_by(Group.name))).scalars() + return {"users": [{"username": u.username, "display_name": u.display_name or u.username, "disabled": u.disabled} + for u in users], + "groups": [{"name": g.name, "label": g.label or g.name} for g in groups]} + + # ---------------------------------------------------------------- 审计 diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index 9b160db5..327405f2 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -410,7 +410,8 @@ async def _get(session: AsyncSession, schedule_id: str) -> Schedule: @router.get("/variables") async def list_variables(tz: str | None = Query(default=None, alias="timezone")) -> list[dict[str, str]]: - """输入模板能用的变量:[{name, label, example}]。示例按现在这一刻、timezone(默认 Asia/Shanghai)触发算。""" + """输入模板能用的变量:[{name, label, example, note}]。示例按现在这一刻、timezone(默认 Asia/Shanghai)触发算; + note 是补充说明(ISO 周的跨年规则),没有的是空串。""" zone = _timezone(tz) if tz is not None else service.DEFAULT_TIMEZONE return service.variables(tz=zone) diff --git a/backend/app/api/source_versions.py b/backend/app/api/source_versions.py index 2dbbaf54..7d8a07ba 100644 --- a/backend/app/api/source_versions.py +++ b/backend/app/api/source_versions.py @@ -6,7 +6,7 @@ **写操作一律要 confirm: true 和界面看到的当前版本(expected_current_snapshot_id)**(7.7):确认框是按那个版本 写的后果,别人在这期间提交、启用过,锁内比对不上就 409 base_changed,不能照旧切过去。移除、作废还要写理由。 -署名取请求体的 signed_by,其次请求头 X-Actor,只原样记下(未认证)。 +署名:开了登录是登录的人;关登录时取请求体的 signed_by,其次请求头 X-Actor,只原样记下(未认证)。 路径都是 /{source_id}/snapshots… 和 /{source_id}/imports/{import_id}/(manifest|remove|revoke-acceptance),与 datasources.py 的 /{source_id}/imports(GET)、/{source_id}/imports/{import_id}/purge-raw 不重叠;在 main.py 里 @@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.datasources import _out -from app.api.runs import actor_of +from app.api.deps import signer_of from app.data import source_versions, table_versions from app.data.recipe_imports import ImportRefused from app.db.base import get_session @@ -55,10 +55,8 @@ async def _body(request: Request) -> dict[str, Any]: def _signed(body: dict[str, Any], header: str | None) -> str | None: - raw = body.get("signed_by") - if isinstance(raw, str) and raw.strip(): - return raw.strip()[:_SIGNED_MAX] - return actor_of(header) + """开了登录是登录的人;关登录时请求体的 signed_by 优先,其次 X-Actor(api/deps.signer_of)。""" + return signer_of(body.get("signed_by"), header, _SIGNED_MAX) async def _source(session: AsyncSession, source_id: str) -> DataSource: diff --git a/backend/app/api/table_imports.py b/backend/app/api/table_imports.py index f2309bfa..24e13370 100644 --- a/backend/app/api/table_imports.py +++ b/backend/app/api/table_imports.py @@ -28,7 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.datasources import _NAME_PATTERN, _out, read_upload -from app.api.runs import actor_of +from app.api.deps import current_principal, signer_of from app.data import recipe_imports, table_versions from app.data.recipe_imports import EditRequest, ImportRefused from app.data.recipe_types import Acceptance, AiAvailability, PeriodInput, Recipe, Selection @@ -53,11 +53,13 @@ def _refused(e: Exception) -> CodedHTTPException: def _signed(body: dict[str, Any] | None, header: str | None) -> str | None: - """署名:请求体的 signed_by 优先,其次请求头 X-Actor。只原样记下(未认证)。""" + """署名:开了登录是登录的人;关登录时请求体的 signed_by 优先,其次请求头 X-Actor,只原样记下(未认证)。""" raw = (body or {}).get("signed_by") if isinstance(body, dict) else None - if isinstance(raw, str) and raw.strip(): - return raw.strip()[:_SIGNED_MAX] - return actor_of(header) + return signer_of(raw, header, _SIGNED_MAX) + + +def _login_on() -> bool: + return current_principal().mode != "off" #: JSON 请求体的字节上限和嵌套层数上限(SE-5)。最大的合法配方(200 个标签、几十个分段)也只有一两百 KB、 @@ -371,9 +373,10 @@ def _context_inputs(raw: Any, signed_by: str | None) -> dict[str, PeriodInput] | if start > end: raise bad who = value.get("signed_by") + # 开了登录时 signed_by 已经是登录的人,这里填的不看(signer_of 同一条) out[key] = PeriodInput(start=start, end=end, - signed_by=(str(who).strip()[:_SIGNED_MAX] if isinstance(who, str) and who.strip() - else signed_by)) + signed_by=(str(who).strip()[:_SIGNED_MAX] + if isinstance(who, str) and who.strip() and not _login_on() else signed_by)) return out diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 4a75848d..ac7eb2fa 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -15,7 +15,7 @@ from app.capabilities.endpoints import bound_endpoint_ref, find_endpoint from app.core.errors import explain, raw from app.db.base import get_session from app.db.models import CustomTool, Endpoint, McpServer -from app.tools.custom import ToolFailure, schema_problem, trial +from app.tools.custom import ToolFailure, held_write, http_failure, schema_problem, trial from app.tools.mcp_manager import mcp_manager from app.tools.registry import ( ToolArgsError, ToolBuildError, ToolContext, Versions, all_specs, build_tools, call_is_dangerous, @@ -138,7 +138,7 @@ async def _side_effect(name: str, args: dict[str, Any], session: AsyncSession) - if ref := bound_endpoint_ref(row): endpoint = await find_endpoint(session, ref) return (f"将经登记的端点「{endpoint.name if endpoint else ref}」发送 {cfg.get('method') or 'GET'} 请求" - "(试用按探索运行处理:有测试环境时发往测试环境,写入在没有测试环境时只演练、不发送)") + "(试用按探索运行处理:有测试环境时发往测试环境,没有时发往生产环境)") if row.kind == "http": return (f"将调用自定义接口「{name}」" f"({cfg.get('method') or 'GET'} {cfg.get('url') or '(未填写地址)'})") @@ -152,6 +152,16 @@ async def _side_effect(name: str, args: dict[str, Any], session: AsyncSession) - return None +async def _held(name: str, custom: CustomTool | None, session: AsyncSession) -> bool: + """在工具库里执行它是不是只演练:写外部系统的能力。自定义工具按它绑定的端点认(tools/custom.held_write),别的按 + 能力登记的治理认。""" + if custom is not None: + return await held_write(session, custom) + cap = await capability_registry.get_capability(session, name) + return cap is not None and capability_registry.writes_externally( + name, capability_registry.effective_effect(cap), cap.endpoint_id) + + @router.post("/{tool_name:path}/run") async def run_tool( tool_name: str, @@ -164,8 +174,20 @@ async def run_tool( 有副作用的工具先 409 说清它会做什么,带上 confirm 再来一次才执行。工作流里 同一个「需确认」意味着要人工审批,在这里却点一下就真跑了——同一个标签两种 含义,工具库里试 file_write、shell_exec 的人会以为还有一道关。 + + 写入外部系统的能力(capabilities.registry.writes_externally,和运行时同一个判断)在这里一律演练、不发送,结果标 + rehearsal:对外写入只经调用工具节点(要确认、带幂等键、留生效记录,A5)。没有副作用,也就不先 409。 """ - if not (confirm or payload.confirm): + import json + import time + + custom = (await session.execute(select(CustomTool).where(CustomTool.name == tool_name))).scalar_one_or_none() + rehearsal = await _held(tool_name, custom, session) + if rehearsal and custom is None: + # 被治理为写入的 MCP、数据源、内置工具:拼不出将要发送的请求,不执行,交回这次的参数 + return {"ok": True, "rehearsal": True, "duration_ms": 0, + "result": {"rehearsal": True, "message": "演练:写入能力不在这里真的执行", "args": payload.args}} + if not (rehearsal or confirm or payload.confirm): effect = await _side_effect(tool_name, payload.args, session) if effect: # 只说后果,不说怎么确认:弹窗还是再点一次是界面的事,写死在这里 @@ -175,8 +197,6 @@ async def run_tool( # 不在运行里:上传表格查当前版本(运行里查的是发起时固定的那一版) ctx = ToolContext(run_id="playground", node_id="playground", sandbox_session=payload.sandbox_session, data_versions=Versions.CURRENT) - import time - started = time.perf_counter() notes: list[str] = [] try: @@ -196,13 +216,23 @@ async def run_tool( "detail": raw(e), "duration_ms": int((time.perf_counter() - started) * 1000), } - return { - "ok": True, - "result": result, - "duration_ms": int((time.perf_counter() - started) * 1000), - # 参数名写错、被替换成唯一候选跑通了:运行时会报出来,这里也得说 - **({"note": notes[0]} if notes else {}), - } + took = int((time.perf_counter() - started) * 1000) + # 参数名写错、被替换成唯一候选跑通了:运行时会报出来,这里也得说 + extra = {"note": notes[0]} if notes else {} + if rehearsal: + # 演练回执是平台拼的 JSON:按对象交回,界面逐项画(方法、地址、请求头、请求体)。拼不出请求(路径参数不合法)的 + # 交回的是 {error}:照实说失败 + try: + value = json.loads(result) if isinstance(result, str) else result + except ValueError: + value = None + if not (isinstance(value, dict) and value.get("rehearsal")): + error = value.get("error") if isinstance(value, dict) else None + return {"ok": False, "error": str(error or result), "hint": "", "detail": "", "duration_ms": took, **extra} + return {"ok": True, "rehearsal": True, "result": value, "duration_ms": took, **extra} + if custom is not None and (failed := http_failure(custom, result)): + return {"ok": False, **failed, "duration_ms": took, **extra} + return {"ok": True, "result": result, "duration_ms": took, **extra} # -------------------------------------------------------------------------- @@ -249,10 +279,16 @@ def _refuse_bad_schema(parameters: Any) -> None: def _seal_config(config: dict[str, Any], previous: dict[str, Any] | None) -> dict[str, Any]: - """请求头的值加密;提交的是原值的掩码时保持原值。""" + """请求头的值加密;提交的是原值的掩码时保持原值。名字不合法(空的、带空格、冒号、中文)的不让存:存进去之后每次 + 调用都在发出之前失败。值里可以写 {{ 参数 }} 模板,不查。""" if "headers" not in config: return config - return {**config, "headers": vault.seal(config.get("headers"), (previous or {}).get("headers"))} + headers = config.get("headers") + if headers is not None and not isinstance(headers, dict): + raise HTTPException(422, "请求头需要写成 JSON 对象,每个请求头名称对应一个值") + if problem := vault.header_problem(headers, check_values=False): + raise HTTPException(422, problem) + return {**config, "headers": vault.seal(headers, (previous or {}).get("headers"))} async def _refuse_bad_binding(session: AsyncSession, kind: str, config: dict[str, Any]) -> None: @@ -322,8 +358,9 @@ async def delete_custom(tool_id: str, session: AsyncSession = Depends(get_sessio await capability_registry.sync_all(session) -async def _trial(row: CustomTool, args: dict[str, Any]) -> dict[str, Any]: - """试跑一次,返回和工具库「执行」同一种形状:{ok, result | error/hint/detail, duration_ms, note?}。""" +async def _trial(row: CustomTool, args: dict[str, Any], session: AsyncSession) -> dict[str, Any]: + """试跑一次,返回和工具库「执行」同一种形状:{ok, result | error/hint/detail, duration_ms, note?, rehearsal?}。 + 写入能力只演练(rehearsal: true,结果是将要发送的请求);HTTP 工具的结果按状态码说成败,非 2xx 是失败。""" import json import time @@ -341,6 +378,7 @@ async def _trial(row: CustomTool, args: dict[str, Any]) -> dict[str, Any]: "hint": '参数定义是 JSON Schema,格式如 {"type": "object", "properties": ' '{"n": {"type": "integer", "description": "…"}}, "required": ["n"]}', "detail": "", "duration_ms": 0} + rehearsal = await held_write(session, row) try: result, note = await trial(row, args, ctx) except ToolArgsError as e: @@ -363,6 +401,10 @@ async def _trial(row: CustomTool, args: dict[str, Any]) -> dict[str, Any]: return {"ok": False, "error": error, "hint": hint, "detail": json.dumps(dict(result), ensure_ascii=False), "duration_ms": took(), **extra} + if rehearsal: + return {"ok": True, "rehearsal": True, "result": result, "duration_ms": took(), **extra} + if failed := http_failure(row, result): + return {"ok": False, **failed, "duration_ms": took(), **extra} return {"ok": True, "result": result, "duration_ms": took(), **extra} @@ -395,6 +437,8 @@ async def test_custom_draft(payload: CustomToolDraftIn, session: AsyncSession = 立刻用上一个还没试过的版本。 编辑已保存的工具时,界面上的请求头是掩码:按同名工具存着的值认回来,不然试跑带着掩码去请求。 + 绑定端点的草稿按草稿的方法、路径、请求体拼请求(不是同名的已保存那一版);读写按已保存的治理和草稿的方法里更严的 + 一个认(tools/custom._effect_of):把只读的改成 POST 去试,只演练、不发送。 """ problem = _draft_problem(payload.kind, payload.config) if problem: @@ -402,7 +446,7 @@ async def test_custom_draft(payload: CustomToolDraftIn, session: AsyncSession = saved = (await session.execute(select(CustomTool).where(CustomTool.name == payload.name))).scalar_one_or_none() draft = CustomTool(name=payload.name or "draft_tool", kind=payload.kind, parameters=payload.parameters, config=_seal_config(payload.config, saved.config if saved else None)) - return await _trial(draft, payload.args) + return await _trial(draft, payload.args, session) @custom_router.post("/{tool_id}/test") @@ -412,7 +456,7 @@ async def test_custom( row = await session.get(CustomTool, tool_id) if not row: raise HTTPException(404, "工具不存在,可能已被删除") - return await _trial(row, payload.args) + return await _trial(row, payload.args, session) # -------------------------------------------------------------------------- diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 021163e0..801b6e99 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -64,6 +64,8 @@ class WorkflowOut(BaseModel): status: str = "draft" published_version: int | None = None published_by: str | None = None + #: 当前发布版本的级别(published / governed)。status 在画布上改过之后会回到 draft,「这是不是受管工作流」看这一项 + published_level: str | None = None created_at: Any = None updated_at: Any = None run_count: int = 0 @@ -71,6 +73,15 @@ class WorkflowOut(BaseModel): model_config = {"from_attributes": True} +async def _published_levels(session: AsyncSession, ids: list[str] | None = None) -> dict[str, str]: + """各工作流当前发布版本的级别 {工作流 id: level}。没发布过、或老数据没记级别的不在里面。""" + stmt = select(WorkflowVersion.workflow_id, WorkflowVersion.level).join( + Workflow, (Workflow.id == WorkflowVersion.workflow_id) & (Workflow.published_version == WorkflowVersion.version)) + if ids is not None: + stmt = stmt.where(WorkflowVersion.workflow_id.in_(ids)) + return {wid: level for wid, level in (await session.execute(stmt)).all() if level} + + async def _run_counts(session: AsyncSession, ids: list[str] | None = None) -> dict[str, int]: """各工作流的运行次数 {工作流 id: 次数},ids 为 None 时算全部。列表和单个工作流的接口都按它算:以前只有列表 计数,详情、保存、恢复的返回落在 WorkflowOut 的默认值 0 上,同一个工作流两处说的次数不一样。""" @@ -84,6 +95,7 @@ async def _workflow_out(session: AsyncSession, workflow: Workflow) -> WorkflowOu """返回单个工作流的接口都走这里,run_count 和列表一致。""" item = WorkflowOut.model_validate(workflow) item.run_count = (await _run_counts(session, [workflow.id])).get(workflow.id, 0) + item.published_level = (await _published_levels(session, [workflow.id])).get(workflow.id) return item @@ -93,10 +105,12 @@ async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[W (await session.execute(select(Workflow).order_by(Workflow.updated_at.desc()))).scalars() ) counts = await _run_counts(session) + levels = await _published_levels(session) out: list[WorkflowOut] = [] for row in rows: item = WorkflowOut.model_validate(row) item.run_count = counts.get(row.id, 0) + item.published_level = levels.get(row.id) out.append(item) return out @@ -240,11 +254,12 @@ class VersionOut(BaseModel): created_at: Any = None #: 这一版按哪一档发布的(published / governed)。没发布过、或者是记级别之前发布的为 null level: str | None = None - #: 这一版是谁、什么时候发布的;退役了的是什么时候、谁退的。存量为 null + #: 这一版是谁、什么时候发布的;退役了的是什么时候、谁退的、退役时写的说明(没写为 null)。存量为 null published_at: Any = None published_by: str | None = None retired_at: Any = None retired_by: str | None = None + retire_note: str | None = None model_config = {"from_attributes": True} @@ -277,7 +292,6 @@ class VersionDetailOut(VersionOut): catalog_changes: list[dict[str, Any]] = Field(default_factory=list) #: 入口字段换成表单(agentlab.form/1):发起表单、应用入口用同一个渲染器(X2) input_schema: dict[str, Any] = Field(default_factory=dict) - retire_note: str | None = None #: 账本(L4):固定这一版的应用、生效记录数、正式运行数 usage: dict[str, Any] = Field(default_factory=dict) @@ -417,6 +431,10 @@ async def publish_workflow( workflow = await session.get(Workflow, workflow_id) if not workflow: raise HTTPException(404, _GONE) + if payload.level != "governed" and workflow.published_version and \ + (await _published_levels(session, [workflow_id])).get(workflow_id) == "governed": + # 把受管工作流降成已发布,之后的正式运行就不再过治理门禁:和以受管级别发布是同一级的决定(Q8) + _require_admin("把受管工作流降为已发布") version = payload.version or workflow.version snapshot = ( diff --git a/backend/app/apps/catalog.py b/backend/app/apps/catalog.py index 28c5bc65..e3642ddc 100644 --- a/backend/app/apps/catalog.py +++ b/backend/app/apps/catalog.py @@ -4,6 +4,8 @@ 「名字 → 渲染器」的表,遇到不认识的组件显示「此组件需要更新前端」,不报错、不空白。 每个组件登记:名字、中文名、类别、版本、props 的 JSON Schema、允许的绑定种类、能不能有子组件。 +props 里每一项都写 title(属性面板上的中文名),取值不直观的另写 description;枚举写 x-enum-labels(和 enum 按位置 +对应,同表单 agentlab.form/1 的写法)。这几样是注解,不参与校验。 服务端校验器和解析器由 app/apps/ 的实现按名字注册(BE-3);开发者扩展组件时,目录项、校验器、解析器、前端渲染器 四样都要给,缺一样启动即报错(check_registry)。 """ @@ -19,65 +21,88 @@ def _props(**properties: Any) -> dict[str, Any]: return {"type": "object", "additionalProperties": False, "properties": properties} -_TEXT = {"type": "string", "maxLength": 200} -_LONG = {"type": "string", "maxLength": 4000} +def _text(title: str, **more: Any) -> dict[str, Any]: + return {"type": "string", "maxLength": 200, "title": title, **more} + + +def _long(title: str) -> dict[str, Any]: + return {"type": "string", "maxLength": 4000, "title": title} + + +def _enum(title: str, **labels: str) -> dict[str, Any]: + """枚举:值是键,中文是 x-enum-labels(按位置对应)。""" + return {"enum": list(labels), "x-enum-labels": list(labels.values()), "title": title} + + +_CHART = _enum("图形", line="折线图", bar="柱状图") COMPONENTS: list[dict[str, Any]] = [ # ---- 布局 ---- {"name": "page", "label": "页面", "category": "layout", "version": 1, "children": True, "bindings": [], - "props": _props(title=_TEXT)}, + "props": _props(title=_text("标题"))}, {"name": "section", "label": "分区", "category": "layout", "version": 1, "children": True, "bindings": [], - "props": _props(title=_TEXT, description=_LONG)}, + "props": _props(title=_text("标题"), description=_long("说明"))}, {"name": "columns", "label": "分栏", "category": "layout", "version": 1, "children": True, "bindings": [], - "props": _props(count={"type": "integer", "minimum": 1, "maximum": 4})}, + "props": _props(count={"type": "integer", "minimum": 1, "maximum": 4, "title": "列数"})}, {"name": "tabs", "label": "页签", "category": "layout", "version": 1, "children": True, "bindings": [], - "props": _props(labels={"type": "array", "items": _TEXT, "maxItems": 8})}, + "props": _props(labels={"type": "array", "items": {"type": "string", "maxLength": 200}, "maxItems": 8, + "title": "页签名", "description": "按顺序对应里面的子组件"})}, {"name": "divider", "label": "分隔线", "category": "layout", "version": 1, "children": False, "bindings": [], "props": _props()}, # ---- 展示 ---- {"name": "stamp", "label": "印章", "category": "display", "version": 1, "children": False, - "bindings": ["run"], "props": _props(compact={"type": "boolean"})}, + "bindings": ["run"], "props": _props(compact={"type": "boolean", "title": "紧凑显示"})}, {"name": "metric", "label": "指标块", "category": "display", "version": 1, "children": False, - "bindings": ["metric", "cell"], "props": _props(label=_TEXT, unit=_TEXT, conv=_TEXT)}, + "bindings": ["metric", "cell"], + "props": _props(label=_text("名称"), + unit=_text("单位", description="绑定单元格时写在数下面;绑定指标时数里已经带着口径卡的单位"), + conv=_text("换算", description="万、亿、pct(百分比)、int(取整)或 .N(保留 N 位小数)"))}, {"name": "series", "label": "趋势", "category": "display", "version": 1, "children": False, - "bindings": ["series"], "props": _props(label=_TEXT, chart={"enum": ["line", "bar"]})}, + "bindings": ["series"], "props": _props(label=_text("名称"), chart=_CHART)}, {"name": "table", "label": "表格", "category": "display", "version": 1, "children": False, - "bindings": ["query"], "props": _props(title=_TEXT, columns={"type": "array", "items": _TEXT}, - sort={"type": "string"}, limit={"type": "integer", "minimum": 1})}, + "bindings": ["query"], + "props": _props(title=_text("标题"), + columns={"type": "array", "items": {"type": "string", "maxLength": 200}, "title": "显示的列", + "description": "列名,不填显示全部列"}, + sort={"type": "string", "title": "排序列", "description": "按哪一列排序,写列名;前面加「-」为降序"}, + limit={"type": "integer", "minimum": 1, "title": "最多显示行数"})}, {"name": "chart", "label": "图表", "category": "display", "version": 1, "children": False, - "bindings": ["query"], "props": _props(title=_TEXT, chart={"enum": ["line", "bar"]}, x=_TEXT, - y={"type": "array", "items": _TEXT, "maxItems": 4})}, + "bindings": ["query"], + "props": _props(title=_text("标题"), chart=_CHART, x=_text("横轴列"), + y={"type": "array", "items": {"type": "string", "maxLength": 200}, "maxItems": 4, + "title": "纵轴列", "description": "最多 4 列"})}, {"name": "report", "label": "报告", "category": "display", "version": 1, "children": False, - "bindings": ["report"], "props": _props(title=_TEXT)}, + "bindings": ["report"], "props": _props(title=_text("标题"))}, {"name": "text", "label": "文字", "category": "display", "version": 1, "children": False, "bindings": [], - "props": _props(markdown=_LONG)}, + "props": _props(markdown=_long("正文(Markdown)"))}, {"name": "notice", "label": "提示", "category": "display", "version": 1, "children": False, "bindings": [], - "props": _props(text=_LONG, tone={"enum": ["info", "warn", "error"]})}, + "props": _props(text=_long("提示文字"), tone=_enum("样式", info="说明", warn="提醒", error="错误"))}, {"name": "field", "label": "字段", "category": "display", "version": 1, "children": False, - "bindings": ["input", "output", "run"], "props": _props(label=_TEXT)}, + "bindings": ["input", "output", "run"], "props": _props(label=_text("名称"))}, {"name": "status", "label": "状态", "category": "display", "version": 1, "children": False, - "bindings": ["run"], "props": _props(label=_TEXT)}, + "bindings": ["run"], "props": _props(label=_text("名称"))}, # ---- 输入 ---- {"name": "form", "label": "表单", "category": "input", "version": 1, "children": False, "bindings": [], - "props": _props(form={"type": "string"}, submit_label=_TEXT)}, + "props": _props(form={"type": "string", "title": "表单"}, submit_label=_text("提交按钮文字"))}, # ---- 流程 ---- {"name": "entry_button", "label": "发起按钮", "category": "process", "version": 1, "children": False, - "bindings": [], "props": _props(entry={"type": "string"}, label=_TEXT)}, + "bindings": [], "props": _props(entry={"type": "string", "title": "入口"}, label=_text("按钮文字"))}, {"name": "instance_list", "label": "实例列表", "category": "process", "version": 1, "children": False, - "bindings": [], "props": _props(entry={"type": "string"}, who={"enum": ["mine", "all"]}, - limit={"type": "integer", "minimum": 1, "maximum": 200})}, + "bindings": [], "props": _props(entry={"type": "string", "title": "入口"}, + who=_enum("显示谁的", mine="我发起的", all="全部实例"), + limit={"type": "integer", "minimum": 1, "maximum": 200, "title": "最多显示几条"})}, {"name": "instance_header", "label": "实例概况", "category": "process", "version": 1, "children": False, "bindings": [], "props": _props()}, {"name": "instance_timeline", "label": "实例进度", "category": "process", "version": 1, "children": False, "bindings": [], "props": _props()}, {"name": "task_list", "label": "待办列表", "category": "process", "version": 1, "children": False, - "bindings": [], "props": _props(scope={"enum": ["mine", "instance"]})}, + "bindings": [], "props": _props(scope=_enum("范围", mine="派给我的", instance="本实例的"))}, {"name": "task_form", "label": "待办表单", "category": "process", "version": 1, "children": False, "bindings": ["task"], "props": _props()}, {"name": "signoffs", "label": "会签进度", "category": "process", "version": 1, "children": False, "bindings": ["task"], "props": _props()}, {"name": "action_status", "label": "动作状态", "category": "process", "version": 1, "children": False, - "bindings": ["action"], "props": _props(label=_TEXT)}, + "bindings": ["action"], "props": _props(label=_text("名称"))}, ] BY_NAME: dict[str, dict[str, Any]] = {c["name"]: c for c in COMPONENTS} diff --git a/backend/app/apps/derive.py b/backend/app/apps/derive.py index 181c0e47..e368d3cf 100644 --- a/backend/app/apps/derive.py +++ b/backend/app/apps/derive.py @@ -1,9 +1,11 @@ """从工作流版本确定性派生应用(A8):不靠模型,助手失败了也有可用的应用(P3)。 -入口字段 → 入口表单;人工节点 → 待办(岗位留空等人绑定);口径卡 → 指标块;报告 → 报告块;取数节点 → 表格; -写入的调用工具节点 → 动作确认待办 + 动作状态。 +入口字段 → 入口表单;人工节点 → 待办;图里引用到的岗位(派给谁、超时升级给谁、动作确认派给谁,tree.slot_refs)→ +岗位,留空等人绑定;口径卡 → 指标块;报告 → 报告块;取数节点 → 表格;写入的调用工具节点 → 动作确认待办 + 动作状态。 -视图优先绑定已有的定时任务(同一工作流、正式运行、启用着的):看板的数从定时任务的封存运行来,打开页面不发起运行。 +看板(use=view)的首页看「最近封存运行」,视图优先绑定已有的定时任务(同一工作流、正式运行、启用着的):数从定时任务的 +封存运行来,打开页面不发起运行。流程(use=process)的首页只放发起和我发起的:每件事的结果在它自己的实例页看,首页没有 +「最近一次」可看——派生出来只会挂一块永远空着的印章。 """ from __future__ import annotations @@ -15,7 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.apps.forms import input_schema from app.apps.spec import APP_FORMAT, parse_spec -from app.apps.tree import input_fields, node_map +from app.apps.tree import input_fields, node_map, slot_refs PIN = "main" VIEW = "latest" @@ -37,9 +39,8 @@ def _safe(text: str) -> str: async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, name: str | None = None) -> dict[str, Any]: """返回一份 agentlab.app/1 spec(未发布的草稿)。""" - from app.apps.pins import PinState + from app.apps.pins import PinState, write_nodes from app.apps.versions import version_row - from app.capabilities.registry import effective_effect, get_capability from app.db.models import Schedule, Workflow workflow = await session.get(Workflow, workflow_id) @@ -53,15 +54,10 @@ async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, n nodes = node_map(graph) fields = input_fields(graph) humans = {nid: n for nid, n in nodes.items() if n["type"] == "human"} - writes: dict[str, dict[str, Any]] = {} - lookups = True - for nid, node in nodes.items(): - tool = node["config"].get("tool") if node["type"] == "tool" else None - if isinstance(tool, str): - cap = await get_capability(session, tool) - if cap is not None and effective_effect(cap) == "write": - writes[nid] = node - lookups = lookups and bool(getattr(cap, "lookup", None)) + # 写外部系统的调用工具节点:和运行时同一个认法(pins.write_nodes),只有它们开发送确认卡、结果未知时要人核实 + caps = await write_nodes(session, graph) + writes = {nid: nodes[nid] for nid in caps} + lookups = all(getattr(cap, "lookup", None) for cap in caps.values()) process = bool(humans or writes) spec: dict[str, Any] = { "format": APP_FORMAT, "name": name or workflow.name, "description": workflow.description or "", @@ -70,10 +66,9 @@ async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, n "level": state.level, "use": "process" if process else "view"}], "slots": {}, "entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [], } + for ref in slot_refs(graph, writes): + spec["slots"].setdefault(ref["slot"], {"label": ref["slot"]}) for nid, node in humans.items(): - assignee = node["config"].get("assignee") - if isinstance(assignee, dict) and assignee.get("kind") == "slot" and assignee.get("slot"): - spec["slots"].setdefault(str(assignee["slot"]), {"label": str(assignee["slot"])}) spec["task_ui"][nid] = {"title": node["title"]} if writes and not lookups: spec["slots"][RECONCILER] = {"label": "动作核实"} @@ -87,15 +82,16 @@ async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, n **({"instance_title": {"pattern": f"{workflow.name}·{{{first}}}", "fields": [first]}} if first and re.fullmatch(r"[A-Za-z0-9_㐀-鿿]+", first) else {}), }) - schedule = (await session.execute(select(Schedule).where( - Schedule.workflow_id == workflow_id, Schedule.run_class == "formal", Schedule.enabled.is_(True)) - .order_by(Schedule.created_at))).scalars().first() - spec["views"][VIEW] = {"source": {"kind": "latest_sealed", "pin": PIN, - "from": f"schedule:{schedule.id}" if schedule else "formal"}} + if not process: + schedule = (await session.execute(select(Schedule).where( + Schedule.workflow_id == workflow_id, Schedule.run_class == "formal", Schedule.enabled.is_(True)) + .order_by(Schedule.created_at))).scalars().first() + spec["views"][VIEW] = {"source": {"kind": "latest_sealed", "pin": PIN, + "from": f"schedule:{schedule.id}" if schedule else "formal"}} if spec["entries"]: spec["views"][INSTANCE_VIEW] = {"source": {"kind": "instance"}} - spec["pages"].append({"id": "home", "title": workflow.name, "body": _results(nodes, VIEW, "home") - + _entries(spec)}) + spec["pages"].append({"id": "home", "title": workflow.name, + "body": ([] if process else _results(nodes, VIEW, "home")) + _entries(spec)}) if spec["entries"]: spec["pages"].append({"id": "instance", "title": "实例", "body": [ {"id": "inst_header", "type": "instance_header"}, diff --git a/backend/app/apps/forms.py b/backend/app/apps/forms.py index b43e5393..632d670e 100644 --- a/backend/app/apps/forms.py +++ b/backend/app/apps/forms.py @@ -201,8 +201,9 @@ def _error(path: str, code: str, field: dict[str, Any]) -> FormError: def input_schema(fields: list[dict[str, Any]]) -> dict[str, Any]: - """输入节点的 fields(name / required / default / description / example,以及新增的 type / enum / format)→ - agentlab.form/1。没写 type 的按 string。 + """输入节点的 fields(name / required / default / description / example,以及新增的 type / enum / format,数字类型的 + minimum / maximum)→ agentlab.form/1。没写 type 的按 string。上下限只带数字类型的、写成数的那几个:表单子集只有闭区间的 + minimum / maximum(没有 exclusiveMinimum),「大于 0」的金额写成 minimum 0.01。 有默认值的必填字段不进 required:没填时入口节点补默认值(engine/nodes/io.py 的 run_input),表单不该逼人填。 不认识的 type、format 退回文字:老图里随手写的值不该让整张表单作废。""" @@ -227,6 +228,8 @@ def input_schema(fields: list[dict[str, Any]]) -> dict[str, Any]: prop["type"] = kind = "string" if kind == "string" and f.get("format") in FORM_FORMATS: prop["format"] = f["format"] + if kind in ("number", "integer"): + prop.update({k: f[k] for k in _NUMBER_KEYS if _number(f.get(k))}) default = f.get("default") if default not in (None, ""): prop["default"] = default diff --git a/backend/app/apps/lint.py b/backend/app/apps/lint.py index ad01137d..b4030f0d 100644 --- a/backend/app/apps/lint.py +++ b/backend/app/apps/lint.py @@ -14,7 +14,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.apps.spec import APP_ROOTS, DISPOSITIONS, parse_spec -from app.apps.tree import bind_kind, components, input_fields, node_map, pins_by_alias, resolve_form +from app.apps.tree import bind_kind, components, input_fields, node_map, pins_by_alias, resolve_form, slot_refs #: 按需视图缓存时效:默认 600 秒,最多一天(§2.3) TTL_DEFAULT = 600 @@ -27,6 +27,9 @@ USER_FIELDS = ("username", "display_name") _DIGIT = re.compile(r"\d") #: 文字里的一个数:千分位的逗号算在数里(「5,000」就是 5000) _NUMBER = re.compile(r"\d[\d,,]*(?:\.\d+)?") +#: 岗位在图里的用处(tree.SLOT_ROLES),检查的说明里写清是哪一步要它 +_SLOT_USE = {"assignee": "人工节点「{title}」派给", "escalation": "人工节点「{title}」超时升级给", + "confirm": "调用工具节点「{title}」的发送确认派给"} _REPORT_TEXT = re.compile(r"^\s*\{\{\s*nodes\.([A-Za-z0-9_-]+)\.text\s*\}\}\s*$") _PLACEHOLDER = re.compile(r"\{([A-Za-z0-9_㐀-鿿]+)\}") @@ -262,6 +265,8 @@ def _lint_components(spec: dict[str, Any], pins: dict[str, Any], views: dict[str out.error("app.props_invalid", f"组件「{comp.get('id')}」({entry['label']})不能放子组件", f"{at}.children") _lint_text(comp, entry, at, level, out, constants) _lint_bind(spec, comp, entry, pins, views, at, out) + if level == "governed": + _lint_metric_origin(spec, comp, pins, at, out) _lint_visible(comp, views, at, out) _lint_refs(spec, comp, at, out) @@ -346,6 +351,42 @@ def _lint_bind(spec: dict[str, Any], comp: dict[str, Any], entry: dict[str, Any] out.error("app.binding_target_missing", f"组件「{cid}」绑定的{_KIND_LABEL[kind]}在引用的工作流版本里不存在", where) +def _lint_metric_origin(spec: dict[str, Any], comp: dict[str, Any], pins: dict[str, Any], at: str, out: _Issues) -> None: + """受管级应用里绑定的指标(指标块、趋势):表达式的输入追不到原始数据(查询快照里的格、运行输入)的给提示。 + + 指标照样是口径卡确定性算出来的、面板上照样「有出处」,但点开追到某个节点就断了:受管级的应用要的是一路追到数据。 + 和运行时、证据面板同一套认法(engine/lineage.static_stops),只看图不看值。转抄别处口径卡的指标在上游那一版里, + 不在这里追;目标不存在的由 _lint_bind 报。""" + from app.engine.labels import type_label + from app.engine.lineage import static_stops + + bind = comp.get("bind") + kind = bind_kind(bind) if isinstance(bind, dict) else None + if kind not in ("metric", "series"): + return + node_id, _, metric_id = str(bind.get(kind) or "").partition(".") + for graph in _view_graphs(spec, pins, bind.get("view")): + nodes = node_map(graph) + card = nodes.get(node_id) + if card is None or card["type"] != "metrics" or isinstance(card["config"].get("caliber_from"), dict): + continue + metric = next((m for m in card["config"].get("metrics") or [] if isinstance(m, dict) and m.get("id") == metric_id), + None) + if metric is None: + continue + stops = static_stops(nodes, str(metric.get("expression") or "")) + if not stops: + continue + where = ";".join( + f"{s.path} 停在「{nodes[s.node_id]['title']}」({type_label(nodes[s.node_id]['type'])})" if s.node_id in nodes + else f"{s.path} 无法确定来自哪个节点" for s in stops[:3]) + out.warning("app.metric_untraced", + f"组件「{comp.get('id')}」绑定的指标「{metric.get('name') or metric_id}」的输入没有追到原始数据({where}):" + "数是口径卡算出来的,但点开出处只能追到那里,看不到查询快照里的格。请在口径卡里用 cell() 按格取数," + "或让数据整形只原样解析调用工具节点的结果", f"{at}.bind", node_id=node_id) + return + + _KIND_LABEL = {"metric": "指标", "series": "指标", "cell": "单元格", "query": "查询结果", "report": "报告", "input": "运行输入", "run": "运行信息", "output": "成果字段", "task": "待办", "action": "动作"} @@ -480,8 +521,11 @@ def _lint_entries(spec: dict[str, Any], pins: dict[str, Any], out: _Issues) -> N async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[str, Any], bindings: dict[str, Any], out: _Issues) -> None: - """流程钉住项:岗位都绑了人、写入动作有人核实、图本身经得起升级(process.* 升成 app.process_unsafe)。""" - from app.capabilities.registry import effective_effect, get_capability + """流程钉住项:图里引用到的岗位(tree.slot_refs:派给谁、超时升级给谁、动作确认派给谁)都声明了、绑了人,写入动作 + 有人核实,图本身经得起升级(process.* 升成 app.process_unsafe)。岗位没人,流程走到那一步就派给了「任何人」:升级、 + 确认悄悄失效,所以报错。哪些调用工具节点是写入(开发送确认卡、要人核实)按 pins.write_nodes 认,和运行时、派生同一个 + 认法。""" + from app.apps.pins import write_nodes from app.engine.governance import publish_issues from app.engine.lint_process import load_subgraphs, with_subgraphs from app.engine.schema import GraphSpec @@ -491,29 +535,30 @@ async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[ return bound = (bindings or {}).get("slots") or {} slots = dict(spec.get("slots") or {}) + used: dict[str, list[str]] = {} + writes = {state.alias: await write_nodes(session, state.graph) for state in process} for state in process: - for node_id, node in node_map(state.graph).items(): - assignee = node["config"].get("assignee") if node["type"] == "human" else None - if isinstance(assignee, dict) and assignee.get("kind") == "slot" and assignee.get("slot"): - slot = str(assignee["slot"]) - if slot not in slots: - out.error("app.slot_unbound", f"「{state.label}」的人工节点「{node['title']}」派给岗位「{slot}」," - "应用里没有声明这个岗位", "slots", node_id=node_id) - slots[slot] = {"label": slot} + for ref in slot_refs(state.graph, writes[state.alias]): + slot, title = ref["slot"], ref["title"] + what = _SLOT_USE[ref["role"]].format(title=title) + used.setdefault(slot, []) + if f"{what}它" not in used[slot]: + used[slot].append(f"{what}它") + if slot not in slots: + out.error("app.slot_unbound", f"「{state.label}」里{what}岗位「{slot}」,应用里没有声明这个岗位", + "slots", node_id=ref["node_id"]) + slots[slot] = {"label": slot} for slot, info in slots.items(): if not bound.get(slot): label = (info or {}).get("label") or slot - out.error("app.slot_unbound", f"岗位「{label}」还没有绑定到人或组", f"slots.{slot}") + why = f"({';'.join(used[slot][:3])}):没有人,流程走到这一步就派给了任何人" if used.get(slot) else "" + out.error("app.slot_unbound", f"岗位「{label}」还没有绑定到人或组{why}", f"slots.{slot}") for state in process: nodes = node_map(state.graph) - for node_id, node in nodes.items(): - if node["type"] != "tool" or not isinstance(node["config"].get("tool"), str): - continue - cap = await get_capability(session, node["config"]["tool"]) - if cap is not None and effective_effect(cap) == "write" and not getattr(cap, "lookup", None) \ - and not bound.get("reconciler"): - out.error("app.reconciler_unbound", f"「{state.label}」的「{node['title']}」会写外部系统,写入的能力没有登记查询," - "结果未知时要人核实:请绑定核实岗位", "slots", node_id=node_id) + for node_id, cap in writes[state.alias].items(): + if not getattr(cap, "lookup", None) and not bound.get("reconciler"): + out.error("app.reconciler_unbound", f"「{state.label}」的「{nodes[node_id]['title']}」会写外部系统,写入的能力" + "没有登记查询,结果未知时要人核实:请绑定核实岗位", "slots", node_id=node_id) try: graph = GraphSpec.model_validate(state.graph) except ValueError: diff --git a/backend/app/apps/pins.py b/backend/app/apps/pins.py index 427dd85b..438097da 100644 --- a/backend/app/apps/pins.py +++ b/backend/app/apps/pins.py @@ -114,3 +114,23 @@ async def reads_live(session: AsyncSession, graph: dict[str, Any]) -> bool: origins = dict((await session.execute(select(DataSource.name, DataSource.origin) .where(DataSource.name.in_(sorted(sources))))).all()) return any(origins.get(s) != "upload" for s in sources) + + +async def write_nodes(session: AsyncSession, graph: dict[str, Any]) -> dict[str, Any]: + """图里写外部系统的调用工具节点:{节点 id: 能力登记},按节点出现的顺序。认法和运行时同一个(调用工具节点 + engine/nodes/tools._write_capability → capabilities.registry.writes_externally:治理后效果为 write,或绑定端点、视参数 + 而定):只有它们走动作流程——开发送确认卡、留生效记录、结果未知时要人核实。派生(动作状态、核实岗位)和检查(发送 + 确认派给的岗位、核实岗位)共用这一份。只读的调用工具节点从不开确认卡,它写的(或取图级默认的)「派给谁」用不到岗位。 + 登记里没有的不算:运行时同样不按写入走。""" + from app.apps.tree import node_map + from app.capabilities.registry import effective_effect, get_capability, writes_externally + + out: dict[str, Any] = {} + for node_id, node in node_map(graph).items(): + name = node["config"].get("tool") if node["type"] == "tool" else None + if not isinstance(name, str) or not name: + continue + cap = await get_capability(session, name) + if cap is not None and writes_externally(name, effective_effect(cap), getattr(cap, "endpoint_id", None)): + out[node_id] = cap + return out diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 55e27e88..14f0d645 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -45,7 +45,6 @@ PAGE_CACHE_SIZE = 256 #: 按新口径补算的运行来源(engine/origin.py) BACKFILL_ORIGIN = "app_backfill" -VIEW_EMPTY = "view.source_empty" VIEW_REFUSED = "app_view_refused" DAILY_LIMIT = "app_view_daily_limit" NEEDS_UPGRADE = "app_needs_upgrade" @@ -54,7 +53,8 @@ STATUS_TEXT = {"queued": "排队中", "running": "运行中", "interrupted": " "failed": "失败", "cancelled": "已取消"} TIER_TEXT = {"formal": "完整出具", "degraded": "降档出具", "withheld": "不予出具"} NO_TIER = "未声明出具契约" -EMPTY_TEXT = "还没有可读的封存运行" +TIER_BY_METRICS = "(按指标)" +EMPTY_TEXT = "还没有数据" _ACTIVE = ("queued", "running", "interrupted") _PERIOD = re.compile(r"(period|week|month|quarter|year|date|day|期|周|月|季|年|日)", re.I) _DIGIT = re.compile(r"\d") @@ -265,7 +265,9 @@ async def stamp_of(session: AsyncSession, source: Source) -> dict[str, Any]: "graph_hash": (run.version_hash or "")[:8], "calibers": calibers, "tier": tier if tier in TIER_TEXT else None, - "tier_text": TIER_TEXT.get(str(tier), NO_TIER), + # 纯指标看板按指标出具(issuance.metrics_only):印章上说清这一档核的是指标本身,不是没核 + "tier_text": TIER_TEXT.get(str(tier), NO_TIER) + (TIER_BY_METRICS if tier in TIER_TEXT and isinstance( + issuance, dict) and issuance.get("mode") == "metrics" else ""), "from": await _from_text(session, source), "sealed": bool(sealed.trusted), **({"notes": notes} if notes else {}), @@ -463,7 +465,7 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip # 键:应用版本、页面、各来源取的那次运行、角色。另加各钉住项所在工作流的当前发布版和退役与否:印章上「定时任务已改为 # 运行 vN」「已退役」随它们变,运行没换也得重新算;遮罩设置的指纹同理(_mask_fingerprint) key = (app.id, "draft" if draft else (version.version if version else None), page_id, - tuple((v, s.run.id if s.run else None, s.state) for v, s in sorted(ctx.sources.items())), + tuple((v, s.run.id if s.run else None, s.state, s.message) for v, s in sorted(ctx.sources.items())), tuple((a, p.workflow.published_version if p.workflow else None, p.retired) for a, p in sorted(ctx.pins.items())), role, principal.username, principal.role, await _mask_fingerprint(session), @@ -473,11 +475,10 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip if cacheable and key in _PAGE_CACHE: _PAGE_CACHE.move_to_end(key) return copy.deepcopy(_PAGE_CACHE[key]) + # 还没有数据(没有封存运行、按需视图没刷新、不在实例里)是正常的空态,不是解析出错:各块就地写明还没有数据、什么时候 + # 会有(Source.message),不进 problems——进了,页面顶上挂一块「无法显示」,使用者以为应用坏了 for source in ctx.sources.values(): await _load(session, source) - for vid, source in ctx.sources.items(): - if source.state == "empty": - ctx.problem(vid, VIEW_EMPTY, source.message or EMPTY_TEXT) for entry in spec.get("entries") or []: form = resolve_form(spec, entry.get("form")) if form is not None and entry.get("id"): @@ -546,7 +547,8 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: elif kind == "latest_sealed": run = await latest_sealed_run(session, state, src.get("from")) if run is None: - return Source(id=vid, kind=kind, state="empty", pin=state, message=EMPTY_TEXT, origin=src.get("from")) + return Source(id=vid, kind=kind, state="empty", pin=state, origin=src.get("from"), + message=await _empty_text(session, state, src.get("from"))) else: run = await _on_demand_run(session, ctx, vid, src, state) if run is None: @@ -561,6 +563,20 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: return source +async def _empty_text(session: AsyncSession, state: PinState, origin: Any) -> str: + """最近封存运行的视图还没有数据时说什么:还没有、什么时候会有(定时任务的下次运行时间,或者要等一次正式运行)。""" + if isinstance(origin, str) and origin.startswith("schedule:"): + schedule = await session.get(Schedule, origin[len("schedule:"):]) + if schedule is None: + return f"{EMPTY_TEXT}:取数的定时任务已删除,请管理应用的人换一个来源" + if not schedule.enabled: + return f"{EMPTY_TEXT}:定时任务「{schedule.name}」已停用,启用并运行完成一次后这里才有数" + if schedule.next_run_at is not None: + return f"{EMPTY_TEXT}:定时任务「{schedule.name}」下一次在 {_when(schedule.next_run_at)} 运行,完成并封存后这里就有数" + return f"{EMPTY_TEXT}:定时任务「{schedule.name}」运行完成并封存后这里就有数" + return f"{EMPTY_TEXT}:「{state.label}」有一次正式运行成功并封存后这里就有数" + + async def _load(session: AsyncSession, source: Source) -> None: """读封存范围(核对清单哈希)、算印章。挑运行很便宜,这一步不便宜:页面缓存命中时不做。""" from app.api.evidence import _sealed @@ -823,8 +839,7 @@ async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, r if mode == "recompute": breaks.append({"from": None, "to": series[-1]["version"], "text": f"升版到 v{series[-1]['version']} 时选择了按新口径重算:只画新版本的数"}) - if not any(s["points"] for s in series): - ctx.problem(cid, VIEW_EMPTY, EMPTY_TEXT) + # 一个点都没有是空态(界面写「还没有数据点」),不是解析出错,不进 problems return {"state": "ok", "mode": mode, "unit": unit, "series": series, "breaks": breaks} @@ -1030,16 +1045,124 @@ async def pending_counts(session: AsyncSession, run_ids: list[str]) -> dict[str, return dict(rows.all()) -def instance_row(run: Run, pending: int = 0) -> dict[str, Any]: +#: 等人处理时,按在等的那条待办的种类说在等什么 +WAITING_TEXT = {"approve": "等待审批", "tool": "等待审批", "code": "等待审批", "input": "等待补充输入", + "edit": "等待改稿", "dual_check": "等待复核", "action_confirm": "等待确认发送", + "reconcile": "等待核实写入结果", "signal": "等待外部系统"} +#: 写入外部系统的结果未知(已发出、没拿到确定的结果),等查询或人工核实 +UNKNOWN_TEXT = "等待核实写入结果" +HELD_TEXT = "已挂起,等待继续运行" + + +@dataclass +class _Trail: + """一次运行里说得清「办到哪了」的几样:待办(按开出的先后)、动作记录(按先后)、节点事件(按先后)。""" + tasks: list[Any] = field(default_factory=list) + actions: list[Any] = field(default_factory=list) + events: list[tuple[str, str]] = field(default_factory=list) + + +async def _trails(session: AsyncSession, runs: list[Run]) -> dict[str, _Trail]: + from app.db.models_process import ActionRecord, Task + + ids = [r.id for r in runs] + out = {run_id: _Trail() for run_id in ids} + if not ids: + return out + for row in (await session.execute(select(Task.run_id, Task.node_id, Task.kind, Task.status, Task.outcome) + .where(Task.run_id.in_(ids)).order_by(Task.created_at))).all(): + out[row.run_id].tasks.append(row) + for row in (await session.execute(select(ActionRecord).where(ActionRecord.run_id.in_(ids)) + .order_by(ActionRecord.created_at, ActionRecord.attempt))).scalars(): + out[row.run_id].actions.append(row) + # 节点事件只有结束了的运行要看(走到了哪个成果节点、停在哪个节点上) + wanted = [r.id for r in runs if r.status in ("succeeded", "failed")] + if wanted: + for run_id, etype, node_id in (await session.execute( + select(RunEvent.run_id, RunEvent.type, RunEvent.node_id).where( + RunEvent.run_id.in_(wanted), RunEvent.type.in_(("node.finished", "node.failed"))) + .order_by(RunEvent.seq))).all(): + if node_id: + out[run_id].events.append((etype, node_id)) + return out + + +def _decision(trail: _Trail, node_id: str) -> tuple[str | None, str | None]: + """这个节点最近一条处理完的待办:(种类, 结论 approve / reject / expired_reject / submit)。""" + done = [t for t in trail.tasks if t.node_id == node_id and t.status in ("resolving", "resolved")] + if not done: + return None, None + return done[-1].kind, (done[-1].outcome or {}).get("decision") + + +def instance_state(run: Run, trail: _Trail) -> dict[str, str]: + """实例给人看的状态 {code, text}:code 是界面画状态标记用的状态(运行的那几种,加 held),text 说这件事办到哪了、 + 结论是什么,不只是运行状态(P1)。 + + - 走完了:图里有几个成果节点(分支的几种结局,如「已通过」「已退回」)、恰好走到了其中一个的,写走到的那一个;只有 + 一个成果节点的、几个成果节点并行都走到了的(「周报」「明细」一起出)写「已完成」——那不是哪一种结局; + - 停着:有已发出、结果未知的动作,写「等待核实写入结果」;否则按在等的待办说(等待审批、等待确认发送……); + - 失败:停在一个被人驳回的待办上的——动作确认被驳回(「付款」未确认(被驳回))、审批设了驳回即终止、补充输入被 + 驳回——是人的决定,不是故障,code 记 cancelled;核实为未生效、选了放弃的同理。外部系统拒绝之类照写失败。 + + 运行本身的状态不改(驳回动作确认在引擎里就是节点失败、运行结束):这里只是把它说清楚。""" + from app.apps.tree import node_map + + nodes = node_map(run.graph) + + def title(node_id: str) -> str: + return (nodes.get(node_id) or {}).get("title") or node_id + + if run.status == "interrupted": + if any(a.state in ("sent", "unknown") for a in trail.actions): + return {"code": "waiting", "text": UNKNOWN_TEXT} + pending = [t for t in trail.tasks if t.status == "pending"] + if pending: + return {"code": "waiting", "text": WAITING_TEXT.get(pending[-1].kind, STATUS_TEXT["interrupted"])} + return {"code": "held", "text": HELD_TEXT} + if run.status == "succeeded": + outputs = {nid for nid, n in nodes.items() if n["type"] == "output"} + reached = [nid for etype, nid in trail.events if etype == "node.finished" and nid in outputs] + if len(outputs) > 1 and len(set(reached)) == 1: + return {"code": "succeeded", "text": title(reached[0])} + return {"code": "succeeded", "text": STATUS_TEXT["succeeded"]} + if run.status == "failed": + failed = [nid for etype, nid in trail.events if etype == "node.failed"] + if failed: + node_id = failed[-1] + kind, decision = _decision(trail, node_id) + if kind == "action_confirm" and decision == "reject": + return {"code": "cancelled", "text": f"「{title(node_id)}」未确认(被驳回)"} + if decision in ("reject", "expired_reject"): + return {"code": "cancelled", "text": f"「{title(node_id)}」被驳回"} + record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None) + if record is not None and record.state == "failed": + if (record.resolution or {}).get("outcome") == "abandon": + return {"code": "cancelled", "text": f"「{title(node_id)}」核实为未生效,已放弃发送"} + return {"code": "failed", "text": f"「{title(node_id)}」写入没有生效"} + return {"code": "failed", "text": STATUS_TEXT["failed"]} + return {"code": run.status, "text": STATUS_TEXT.get(run.status, run.status)} + + +def instance_row(run: Run, pending: int = 0, state: dict[str, str] | None = None) -> dict[str, Any]: output = run.output if isinstance(run.output, dict) else {} issuance = output.get("_issuance") if isinstance(output.get("_issuance"), dict) else {} + state = state or {"code": run.status, "text": STATUS_TEXT.get(run.status, run.status)} return {"run_id": run.id, "title": instance_title(run), "entry_id": (run.origin or {}).get("entry"), - "status": run.status, "started_by": run.started_by, + "status": run.status, "status_code": state["code"], "status_text": state["text"], + "started_by": run.started_by, "created_at": run.created_at.isoformat() if run.created_at else None, "finished_at": run.finished_at.isoformat() if run.finished_at else None, "pending_tasks": pending, "tier": issuance.get("tier")} +async def instance_rows(session: AsyncSession, runs: list[Run]) -> list[dict[str, Any]]: + """实例列表的那几行(列表、实例页的头):待处理的待办数和给人看的状态(instance_state)一次取齐。""" + counts = await pending_counts(session, [r.id for r in runs]) + trails = await _trails(session, runs) + return [instance_row(r, counts.get(r.id, 0), instance_state(r, trails[r.id])) for r in runs] + + async def list_instances(session: AsyncSession, app_id: str, *, mine: str | None, entry: str | None = None, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]: """应用的实例(入口、钩子发起的正式运行;按需视图的运行不算实例)。mine 给了只列这个人发起的。""" @@ -1052,9 +1175,7 @@ async def list_instances(session: AsyncSession, app_id: str, *, mine: str | None .scalars()) if entry: rows = [r for r in rows if (r.origin or {}).get("entry") == entry] - rows = rows[:max(1, min(limit, 200))] - counts = await pending_counts(session, [r.id for r in rows]) - return [instance_row(r, counts.get(r.id, 0)) for r in rows] + return await instance_rows(session, rows[:max(1, min(limit, 200))]) async def _instance_list(session: AsyncSession, ctx: _Page, props: dict[str, Any]) -> dict[str, Any]: @@ -1075,18 +1196,23 @@ async def _instance_header(session: AsyncSession, ctx: _Page) -> dict[str, Any]: run = ctx.instance if run is None: return {"state": "empty"} - counts = await pending_counts(session, [run.id]) stamp = next((s.stamp for s in ctx.sources.values() if s.kind == "instance" and s.stamp), None) - return {"state": "ok", **instance_row(run, counts.get(run.id, 0)), - "status_text": STATUS_TEXT.get(run.status, run.status), "stamp": stamp} + return {"state": "ok", **(await instance_rows(session, [run]))[0], "stamp": stamp} +#: 节点事件 → 进度里这一步的状态。人回复了(human.resolved)只说明这一步接着往下办:人工节点紧接着就结束,调用工具节点 +#: 确认之后还要发送——以前把它记成「完成」,结果未知时进度上写「付款 完成」 _STEP_STATE = {"node.started": "running", "node.finished": "done", "node.failed": "failed", "node.skipped": "skipped", - "human.requested": "waiting", "human.resolved": "done"} + "human.requested": "waiting", "human.resolved": "running"} +_STEP_TEXT = {"running": "进行中", "done": "完成", "failed": "失败", "skipped": "已跳过", "waiting": "等待处理", + "unknown": "结果未知,等待核实", "rejected": "被驳回"} +_DECISION_TEXT = {"approve": "已同意", "reject": "已驳回", "expired_reject": "超时驳回"} async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]: - """实例进度:服务端摘要,按节点给一行(标题、状态、时间),不下发原始事件。""" + """实例进度:服务端摘要,按节点给一行(标题、状态、给人看的说法、时间),不下发原始事件。 + + 写入动作这一步按生效记录说(结果未知、核实为未生效);有待办的步骤按待办说(在等什么、同意还是驳回)。""" from app.apps.tree import node_map run = ctx.instance @@ -1095,6 +1221,7 @@ async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]: titles = {k: v["title"] for k, v in node_map(run.graph).items()} rows = (await session.execute(select(RunEvent.type, RunEvent.node_id, RunEvent.ts).where( RunEvent.run_id == run.id, RunEvent.type.in_(tuple(_STEP_STATE))).order_by(RunEvent.seq))).all() + trail = (await _trails(session, [run]))[run.id] steps: dict[str, dict[str, Any]] = {} for etype, node_id, ts in rows: if not node_id: @@ -1103,14 +1230,36 @@ async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]: "started_at": datetime.fromtimestamp(ts, timezone.utc).isoformat()}) step["state"] = _STEP_STATE[etype] step["at"] = datetime.fromtimestamp(ts, timezone.utc).isoformat() - return {"state": "ok", "status": run.status, "status_text": STATUS_TEXT.get(run.status, run.status), + for node_id, step in steps.items(): + text = None + record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None) + kind, decision = _decision(trail, node_id) + pending = [t for t in trail.tasks if t.node_id == node_id and t.status == "pending"] + if record is not None and record.state in ("sent", "unknown"): + step["state"] = "unknown" + elif step["state"] == "waiting" and pending: + text = WAITING_TEXT.get(pending[-1].kind) + elif step["state"] == "failed" and kind == "action_confirm" and decision == "reject": + step["state"], text = "rejected", "未确认(被驳回)" + elif step["state"] == "failed" and decision in ("reject", "expired_reject"): + step["state"], text = "rejected", _DECISION_TEXT[decision] + elif step["state"] == "failed" and record is not None and (record.resolution or {}).get("outcome") == "abandon": + step["state"], text = "rejected", "核实为未生效,已放弃发送" + elif step["state"] == "done" and kind == "approve" and decision in _DECISION_TEXT: + text = _DECISION_TEXT[decision] + step["text"] = text or _STEP_TEXT[step["state"]] + state = instance_state(run, trail) + return {"state": "ok", "status": run.status, "status_code": state["code"], "status_text": state["text"], "steps": list(steps.values())} -def _task_row(task: Any) -> dict[str, Any]: +def _task_row(task: Any, instance: str | None = None) -> dict[str, Any]: + """待办的摘要。instance 是所属实例的标题:同一个人工节点开出的待办标题一样,列表里靠它(和开出的时间)分开。""" return {"id": task.id, "run_id": task.run_id, "node_id": task.node_id, "kind": task.kind, "title": task.title, "status": task.status, "due_at": task.due_at.isoformat() if task.due_at else None, - "policy": task.policy, "escalation_level": task.escalation_level} + "policy": task.policy, "escalation_level": task.escalation_level, + "created_at": task.created_at.isoformat() if task.created_at else None, + **({"instance_title": instance} if instance else {})} async def _task_list(session: AsyncSession, ctx: _Page, props: dict[str, Any]) -> dict[str, Any]: @@ -1132,11 +1281,52 @@ async def _task_list(session: AsyncSession, ctx: _Page, props: dict[str, Any]) - | ((TaskAssignment.principal_kind == "group") & TaskAssignment.principal.in_(list(p.groups) or [""]))) query = query.where(Task.id.in_(mine)) rows = list((await session.execute(query.order_by(Task.created_at.desc()).limit(100))).scalars()) - return {"scope": scope, "items": [_task_row(t) for t in rows]} + runs = {run_id: str((origin or {}).get("title") or name or "") for run_id, origin, name in (await session.execute( + select(Run.id, Run.origin, Run.workflow_name).where(Run.id.in_({t.run_id for t in rows})))).all()} + return {"scope": scope, "items": [_task_row(t, runs.get(t.run_id)) for t in rows]} + + +def _assignment_row(a: Any) -> dict[str, Any]: + return {"id": a.id, "principal_kind": a.principal_kind, "principal": a.principal, "source": a.source, + "status": a.status, "responded_by": a.responded_by, + "responded_at": _aware(a.responded_at).isoformat() if a.responded_at else None, + "decision": (a.response or {}).get("decision") if isinstance(a.response, dict) else None, + "verified": bool(a.verified)} + + +def _action_row(record: Any, *, user: bool) -> dict[str, Any]: + """一条生效记录给人看的样子。error 只放确定没生效的原因(失败原因);结果未知、事后核实的经过放 note:核实为已生效 + 之后还挂着第一次发送时的「ReadTimeout」当失败原因,看的人会以为钱没付出去。""" + resolution = record.resolution or {} + how = resolution.get("how") + who = "按幂等键查询" if how == "lookup" else f"「{resolution.get('by')}」" if resolution.get("by") else "人工" + out: dict[str, Any] = {"id": record.id, "state": record.state, "environment": record.environment, + "attempt": record.attempt, "payload_hash": (record.payload_hash or "")[:8]} + first = f"发送时没有拿到确定的结果({record.error})" if record.error else "发送时没有拿到确定的结果" + if record.state in ("sent", "unknown"): + out["note"] = f"{first},等待核实:在外部系统里可能已经生效,不会自动重发" + elif record.state == "confirmed" and how: + external = resolution.get("external_id") + out["note"] = f"{first},经{who}核实为已生效" + (f"(外部单号 {external})" if external else "") + elif record.state == "failed" and resolution.get("outcome") == "resend": + out["note"] = f"{first},经{who}核实为未生效,已重新发送" + elif record.state == "failed" and resolution.get("outcome") == "abandon": + out["note"] = f"{first},经{who}核实为未生效,已放弃发送" + elif record.state == "failed": + out["error"] = record.error or "没有生效" + # 回执是外部系统的原文:user 角色不给(P4),给哈希和状态就够核对 + if not user: + out["receipt"] = record.receipt + return out async def _process_data(session: AsyncSession, ctx: _Page, ctype: Any, kind: str, node_id: str) -> dict[str, Any]: + """待办(task_form、signoffs)、动作(action_status)。 + + 待办这里就判定读者是不是这条待办的处理人(process.tasks.handles):是的,界面按 id 取完整的待办画待办卡(能处理的 + 给表单);不是的(发起人、旁观的人)只看这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是 404)。""" from app.db.models_process import ActionRecord, Task, TaskAssignment + from app.process.tasks import handles, my_response, principal_with_actor, progress run = ctx.instance if run is None: @@ -1146,25 +1336,22 @@ async def _process_data(session: AsyncSession, ctx: _Page, ctype: Any, kind: str .order_by(Task.created_at.desc()))).scalars().first() if task is None: return {"state": "empty", "node": node_id} - out: dict[str, Any] = {"state": "ok", "task": {**_task_row(task), "form": task.form}, - "task_ui": (ctx.spec.get("task_ui") or {}).get(node_id) or {}} - if ctype == "signoffs": - assignments = (await session.execute(select(TaskAssignment).where(TaskAssignment.task_id == task.id) - .order_by(TaskAssignment.created_at))).scalars() - out["assignments"] = [{"principal_kind": a.principal_kind, "principal": a.principal, "status": a.status, - "responded_by": a.responded_by, - "decision": (a.response or {}).get("decision") if isinstance(a.response, dict) - else None} for a in assignments] - return out + rows = list((await session.execute(select(TaskAssignment).where(TaskAssignment.task_id == task.id) + .order_by(TaskAssignment.created_at))).scalars()) + reader = principal_with_actor(ctx.principal, ctx.actor) + resolved_at = _aware(task.resolved_at).isoformat() if task.resolved_at else None + return {"state": "ok", "task": {**_task_row(task, instance_title(run)), "form": task.form, + "resolved_by": task.resolved_by, "resolved_at": resolved_at, + "outcome": {"decision": (task.outcome or {}).get("decision")} + if task.outcome else None}, + "task_ui": (ctx.spec.get("task_ui") or {}).get(node_id) or {}, + "handler": handles(rows, reader), "assignments": [_assignment_row(a) for a in rows], + "progress": progress(task, rows), "my_response": my_response(rows, reader)} records = (await session.execute(select(ActionRecord).where(ActionRecord.run_id == run.id, ActionRecord.node_id == node_id) - .order_by(ActionRecord.created_at))).scalars() + .order_by(ActionRecord.created_at, ActionRecord.attempt))).scalars() user = ctx.principal.mode != "off" and ctx.principal.role == "user" - return {"state": "ok", "node": node_id, "records": [ - {"id": r.id, "state": r.state, "environment": r.environment, "attempt": r.attempt, - "payload_hash": (r.payload_hash or "")[:8], "error": r.error, - # 回执是外部系统的原文:user 角色不给(P4),给哈希和状态就够核对 - **({} if user else {"receipt": r.receipt})} for r in records]} + return {"state": "ok", "node": node_id, "records": [_action_row(r, user=user) for r in records]} def instance_page(spec: dict[str, Any]) -> str | None: diff --git a/backend/app/apps/tree.py b/backend/app/apps/tree.py index 0ba6daf3..c1314d1d 100644 --- a/backend/app/apps/tree.py +++ b/backend/app/apps/tree.py @@ -2,7 +2,7 @@ from __future__ import annotations import hashlib -from collections.abc import Iterator +from collections.abc import Collection, Iterator from typing import Any from app.core.artifact_store import canonical_json @@ -83,6 +83,44 @@ def node_map(graph: Any) -> dict[str, dict[str, Any]]: return out +#: 图里引用岗位的几处:人工节点派给谁、人工节点超时升级给谁、调用工具节点(写入动作)的发送确认派给谁 +SLOT_ROLES = ("assignee", "escalation", "confirm") + + +def slot_refs(graph: Any, writes: Collection[str] = ()) -> list[dict[str, str]]: + """图里引用到的岗位:[{slot, node_id, title, role}],role 见 SLOT_ROLES,按节点出现的顺序。派生(列出岗位)和检查 + (岗位没声明、没绑定)共用这一份:以前两边都只认人工节点的派给谁,超时升级的岗位、动作确认的岗位一个也没列,发布后 + 升级、确认悄悄派给了任何人。 + + writes 是写外部系统的调用工具节点(pins.write_nodes,和运行时同一个认法):只有它们开发送确认卡,派给谁才算用到岗位。 + 只读的调用工具节点从不开确认卡,它写的(或从图级 defaults 继承来的)派给谁不算——不然派生多出用不到的岗位,检查说 + 「调用工具节点「取数」的发送确认派给它」,可取数从不要人确认。 + + 取值规则和运行时一样(NodeContext.cfg):节点上没写的取图级 defaults。核实岗位(reconciler)不在图里——写入能力没登记 + 查询时才要,派生和检查按能力另算。""" + defaults = (graph or {}).get("defaults") or {} + out: list[dict[str, str]] = [] + for nid, node in node_map(graph).items(): + if node["type"] != "human" and not (node["type"] == "tool" and nid in writes): + continue + cfg = node["config"] + + def pick(key: str) -> Any: + value = cfg.get(key) + return defaults.get(key) if value in (None, "") else value + + assignee = pick("assignee") + if isinstance(assignee, dict) and assignee.get("kind") == "slot" and str(assignee.get("slot") or "").strip(): + out.append({"slot": str(assignee["slot"]).strip(), "node_id": nid, "title": node["title"], + "role": "assignee" if node["type"] == "human" else "confirm"}) + timeout = pick("on_timeout") if node["type"] == "human" else None + if isinstance(timeout, dict) and timeout.get("action") == "escalate" \ + and str(timeout.get("to_slot") or "").strip(): + out.append({"slot": str(timeout["to_slot"]).strip(), "node_id": nid, "title": node["title"], + "role": "escalation"}) + return out + + def input_fields(graph: Any) -> list[dict[str, Any]]: """入口节点声明的字段(原样,含新增的 type / enum / format / description)。""" out: list[dict[str, Any]] = [] diff --git a/backend/app/capabilities/endpoints.py b/backend/app/capabilities/endpoints.py index 66fd883d..a0a2eb15 100644 --- a/backend/app/capabilities/endpoints.py +++ b/backend/app/capabilities/endpoints.py @@ -78,15 +78,23 @@ def seal_environments(given: dict[str, Any] | None, previous: dict[str, Any] | N raise EndpointError(f"{_ENV_LABEL[key]}的地址需要以 http:// 或 https:// 开头") if "?" in base or "#" in base: raise EndpointError(f"{_ENV_LABEL[key]}的地址不能带查询参数,参数请写在能力的配置里") - out[key] = {"base_url": base, - "headers": vault.seal(env.get("headers"), (before.get(key) or {}).get("headers")), - "allow_intranet": bool(env.get("allow_intranet")), + headers = vault.seal(env.get("headers"), (before.get(key) or {}).get("headers")) + # 按掩码换回原值之后再查:界面提交的是掩码,查的是真正要发出去的那一份 + if problem := vault.header_problem(vault.reveal(headers)): + raise EndpointError(f"{_ENV_LABEL[key]}的认证头有误:{problem}") + out[key] = {"base_url": base, "headers": headers, "allow_intranet": bool(env.get("allow_intranet")), "verify_tls": env.get("verify_tls", True) is not False} if not out: raise EndpointError("端点至少需要配置一个环境") return out +def idempotency_header_problem(name: str) -> str | None: + """幂等请求头的名字合不合法:写入时按它带上幂等键,名字不合法每次写入都发不出去。""" + problem = vault.header_problem({name: ""}) + return f"幂等请求头有误:{problem}" if problem else None + + _ENV_LABEL = {"test": "测试环境", "prod": "生产环境"} @@ -217,10 +225,14 @@ def _parse(resp: httpx.Response) -> Any: return text -async def _resolve(session: AsyncSession, capability: Any) -> tuple[Capability | None, CustomTool, Endpoint]: +async def _resolve(session: AsyncSession, capability: Any, tool: CustomTool | None = None + ) -> tuple[Capability | None, CustomTool, Endpoint]: + """能力 → (登记行, 自定义工具, 端点)。给了 tool 就按它拼请求(工具页试运行的草稿:发的是眼前编辑的配置),没给按能力名 + 找已保存的那一行。""" name = capability if isinstance(capability, str) else capability.name cap = capability if isinstance(capability, Capability) else await session.get(Capability, name) - tool = (await session.execute(select(CustomTool).where(CustomTool.name == name))).scalar_one_or_none() + if tool is None: + tool = (await session.execute(select(CustomTool).where(CustomTool.name == name))).scalar_one_or_none() if tool is None or bound_endpoint_ref(tool) is None: raise EndpointError(f"能力「{name}」没有绑定登记的端点,不能由平台执行") endpoint = await find_endpoint(session, bound_endpoint_ref(tool)) @@ -237,6 +249,58 @@ async def preview_request(session: AsyncSession, capability: Any, args: dict[str return {**req, "endpoint": endpoint.name, "environment": environment} +REHEARSAL_MESSAGE = "演练:写入能力不在这里真的发送,下面是将要发送的请求" + + +def rehearsal(endpoint: Endpoint, config: dict[str, Any], capability: Any, args: dict[str, Any]) -> dict[str, Any]: + """写入能力在工具页试运行、工具库执行(不在运行里)时交回的演练回执:不发送,交回将要发送的方法、地址、查询参数、 + 请求体和请求头的名字(值不交回)。地址按探索运行定环境:有测试环境写测试环境的,没有时写正式运行要发往的生产环境。 + 对外写入只经调用工具节点发送:要确认、带幂等键、留生效记录(A5)——试运行绕过这些真发一次,打到的就是对方的系统。""" + envs = endpoint.environments or {} + environment = "test" if envs.get("test") else "prod" + req = build_request(endpoint, environment, config, args) + headers = sorted((envs.get(environment) or {}).get("headers") or {}) + headers.append(endpoint.idempotency_header or "Idempotency-Key") + if capability is not None and getattr(capability, "credential_mode", None) == "service_on_behalf": + from app.auth.signing import ASSERTION_HEADER + + headers.append(ASSERTION_HEADER) + return {"rehearsal": True, "message": REHEARSAL_MESSAGE, "endpoint": endpoint.name, "environment": environment, + "request": {"method": req["method"], "url": req["url"], "query": req["query"], "body": req["body"], + "headers": headers}} + + +def describe_status(status: int) -> str: + """对方回的 HTTP 状态码怎么说(非 2xx)。试运行、工具库执行和端点的测试连接共用,说法只有这一份。""" + if 300 <= status < 400: + return f"对方返回跳转(HTTP {status}),平台不跟随跳转" + if status in (401, 403): + return f"对方拒绝了认证(HTTP {status})" + if status == 404: + return "对方系统上没有这个地址(HTTP 404)" + if 400 <= status < 500: + return f"对方拒绝了这个请求(HTTP {status})" + if status >= 500: + return f"对方系统出错(HTTP {status})" + return f"对方返回 HTTP {status}" + + +def not_sent_reason(e: BaseException, timeout_s: float) -> str: + """请求没拿到响应的原因(测试连接用)。连接没建立、请求在发出之前就被拒(头不合法)和发出之后没回分开说。""" + text = str(e).lower() + if isinstance(e, httpx.ConnectTimeout): + return f"无法连接:{timeout_s:g} 秒内没有连上,请检查地址、端口和网络" + if isinstance(e, httpx.ConnectError): + if "certificate" in text or "ssl" in text: + return "无法连接:对方证书校验没有通过。内网自签的证书可在端点里关闭「校验证书」" + return "无法连接:地址无法解析、端口上没有服务,或网络不通" + if isinstance(e, httpx.LocalProtocolError): + return "请求没有发出:请求头的名称或值不合法,请检查认证头" + if isinstance(e, httpx.TimeoutException): + return f"已连接,但对方 {timeout_s:g} 秒内没有响应" + return "连接中途断开,没有拿到响应" + + def on_behalf_parts(on_behalf: Any) -> tuple[str | None, str | None]: """on_behalf 的两种写法 → (发起人, 确认人)。 @@ -250,12 +314,13 @@ def on_behalf_parts(on_behalf: Any) -> tuple[str | None, str | None]: async def endpoint_request(session: AsyncSession, capability: Any, args: dict[str, Any], *, environment: str, idem_key: str | None = None, on_behalf: str | dict[str, Any] | None = None, - run_id: str | None = None, node_id: str | None = None) -> EndpointResult: + run_id: str | None = None, node_id: str | None = None, + tool: CustomTool | None = None) -> EndpointResult: """平台执行器(registry.endpoint_request 的实现,签名同附录 C)。environment 由调用方按运行类别定(pick_environment); 传 test 而端点没有测试环境时演练。超时、连接中断交 unknown=True:对方可能已经处理了,写入要转核实,不能重发。 - on_behalf 是发起人(字符串),或者 {"sub": 发起人, "act": 确认人}(on_behalf_parts)。""" + on_behalf 是发起人(字符串),或者 {"sub": 发起人, "act": 确认人}(on_behalf_parts)。tool 见 _resolve。""" try: - cap, tool, endpoint = await _resolve(session, capability) + cap, tool, endpoint = await _resolve(session, capability, tool) envs = endpoint.environments or {} if environment == "test" and not envs.get("test"): environment = "simulated" @@ -287,6 +352,11 @@ async def endpoint_request(session: AsyncSession, capability: Any, args: dict[st # 开了登录,发起人就是认证过的用户名;关登录时是自报的署名,断言里照实写 verified=false headers[ASSERTION_HEADER] = assertion(endpoint.name, subject, actor=actor, verified=bool(subject) and current_mode() != "off") + if problem := vault.header_problem(headers): + # 存量的坏头(加校验之前存进去的 {"": ""}):发不出去,照实说是哪一项、去哪里改,不是一句 LocalProtocolError + return EndpointResult(status=None, body=None, environment=environment, + error=f"端点「{endpoint.name}」的{_ENV_LABEL.get(environment, environment)}认证头有误:" + f"{problem}。请到「工具 → 端点」修改") try: async with _client(timeout=endpoint.timeout_s or settings.http_tool_timeout, follow_redirects=False, trust_env=False, verify=env.get("verify_tls", True) is not False) as client: @@ -296,6 +366,11 @@ async def endpoint_request(session: AsyncSession, capability: Any, args: dict[st # 连接没建立:请求没发出去,是确定的失败 return EndpointResult(status=None, body=None, error=f"无法连接端点「{endpoint.name}」:{e}", environment=environment) + except httpx.LocalProtocolError: + # 请求在发出之前就被拒(头、地址不合法):同样没发出去,不能当成结果未知去核实 + return EndpointResult(status=None, body=None, environment=environment, + error=f"端点「{endpoint.name}」的请求没有发出:请求头的名称或值不合法," + "请到「工具 → 端点」检查认证头") except httpx.HTTPError as e: # 发出去之后超时、连接中断:对方可能已经处理了,结果未知 return EndpointResult(status=None, body=None, unknown=True, environment=environment, @@ -345,18 +420,32 @@ def tool_result(result: EndpointResult) -> dict[str, Any]: async def probe(endpoint: Endpoint, environment: str) -> dict[str, Any]: - """测一下这个环境连不连得上:对 base_url 发一个 GET,只看有没有回应,不看状态码。""" + """测一下这个环境:对 base_url 带认证头发一个 GET。{ok, reachable, status, message}:reachable 是连没连上、拿没拿到 + 响应;ok 只在 2xx 时为真——能连上但对方回 404(基础地址上没有内容)、401(认证没过)不能说成「连接成功」。""" env = (endpoint.environments or {}).get(environment) if not env: - return {"ok": False, "status": None, + return {"ok": False, "reachable": False, "status": None, "message": f"端点「{endpoint.name}」没有配置{_ENV_LABEL.get(environment, environment)}"} + headers = vault.reveal(env.get("headers")) + if problem := vault.header_problem(headers): + return {"ok": False, "reachable": False, "status": None, "message": f"认证头有误,请求没有发出:{problem}"} + timeout = endpoint.timeout_s or settings.http_tool_timeout try: await assert_safe_url(env["base_url"], allow_intranet=bool(env.get("allow_intranet"))) - async with _client(timeout=endpoint.timeout_s or settings.http_tool_timeout, follow_redirects=False, - trust_env=False, verify=env.get("verify_tls", True) is not False) as client: - resp = await client.get(env["base_url"], headers=vault.reveal(env.get("headers"))) + async with _client(timeout=timeout, follow_redirects=False, trust_env=False, + verify=env.get("verify_tls", True) is not False) as client: + resp = await client.get(env["base_url"], headers=headers) except UnsafeUrlError as e: - return {"ok": False, "status": None, "message": str(e)} + return {"ok": False, "reachable": False, "status": None, "message": str(e)} except httpx.HTTPError as e: - return {"ok": False, "status": None, "message": f"无法连接:{type(e).__name__}"} - return {"ok": True, "status": resp.status_code, "message": f"已连接,返回状态码 {resp.status_code}"} + return {"ok": False, "reachable": False, "status": None, "message": not_sent_reason(e, timeout)} + status = resp.status_code + if 200 <= status < 300: + return {"ok": True, "reachable": True, "status": status, "message": f"连接成功:基础地址返回 HTTP {status}"} + message = f"能连上,但{describe_status(status)}" + if status == 404: + # 不少系统只在具体路径上提供接口,基础地址本身就是 404:说清这一点,别让人以为端点配错了 + message += "。对方只在具体路径上提供接口时这不影响调用,可用一个读取能力试运行确认" + elif status in (401, 403): + message += ",请检查认证头" + return {"ok": False, "reachable": True, "status": status, "message": message} diff --git a/backend/app/capabilities/registry.py b/backend/app/capabilities/registry.py index cc6e1406..17928768 100644 --- a/backend/app/capabilities/registry.py +++ b/backend/app/capabilities/registry.py @@ -7,7 +7,9 @@ capabilities 一行 = 图里一个可调用的工具(主键就是图里写的 endpoint_id。每改一次 version 加一,写审计。 同步得出的效果(effect_system)只说系统知道的: -- 内置工具:有副作用的(ToolSpec.dangerous)为 dynamic,别的为 read; +- 内置工具:有副作用的(ToolSpec.dangerous,以及改平台自身状态的 ToolSpec.writes_state,如写记忆)为 dynamic,别的为 + read。写平台自身的状态不是对外写入(writes_externally 只认写入和绑定端点的),照旧能给 Agent 用; + 只是不能记成只读:只读的才能正式运行免审批、才能当写入的查询能力; - 数据源:只读源的查询、查表结构为 read;可写源的查询为 dynamic(SELECT 只读、DML 是写,看这一次的 SQL); - 自定义工具:python 和没绑定端点的 http 为 dynamic(平台不知道它做什么);绑定端点的 http 按方法,GET / HEAD 为 read, 其余为 write——「写」只出在登记的端点上,老工具一个都不会因为升级被当成写入能力拒掉; @@ -79,7 +81,7 @@ async def _discover(session: AsyncSession) -> dict[str, dict[str, Any]]: for name, spec in all_specs().items(): found[name] = {"system": "builtin", "title": name, "description": spec.description, "params_schema": _json_schema(spec.args_schema), - "effect_system": "dynamic" if spec.dangerous else "read"} + "effect_system": "dynamic" if spec.dangerous or spec.writes_state else "read"} for src in (await session.execute(select(DataSource))).scalars(): found[f"{QUERY_PREFIX}{src.name}"] = { @@ -296,13 +298,14 @@ async def snapshot_owned(artifact_id: str, run_id: str, name: str) -> bool: async def endpoint_request(session: AsyncSession, capability: Any, args: dict[str, Any], *, environment: str, idem_key: str | None = None, on_behalf: str | None = None, - run_id: str | None = None, node_id: str | None = None) -> EndpointResult: + run_id: str | None = None, node_id: str | None = None, + tool: Any = None) -> EndpointResult: """平台执行器:按登记的端点发请求(只认登记的 base_url,参数按路径段编码代入),声明了 tabular 的读取落快照。 - 实现在 capabilities/endpoints.py。""" + 实现在 capabilities/endpoints.py。tool:按这一行自定义工具拼请求(工具页试运行的草稿),不给按能力名找已保存的。""" from app.capabilities.endpoints import endpoint_request as run return await run(session, capability, args, environment=environment, idem_key=idem_key, on_behalf=on_behalf, - run_id=run_id, node_id=node_id) + run_id=run_id, node_id=node_id, tool=tool) async def snapshot_masked_columns(snap: dict[str, Any]) -> set[str]: @@ -372,6 +375,21 @@ def validate_governance(changes: dict[str, Any]) -> dict[str, Any]: return out +async def _check_lookup(session: AsyncSession, name: str, endpoint_id: str | None, lookup: Any) -> None: + """查询能力(写入结果未知时按幂等键去查实际结果,process/actions._lookup)由平台按登记的端点替它发请求 + (endpoint_request),所以只能是同一个端点上、绑定端点的只读能力:写记忆这类平台自身的写、别的系统上的读取、 + MCP 和 Python 工具都查不了这一笔写入。能力页的候选按同一条规矩列。""" + from app.capabilities.endpoints import bound_endpoint_ref + from app.db.models import Capability, CustomTool + + ref = str(lookup) + finder = await session.get(Capability, ref) + tool = (await session.execute(select(CustomTool).where(CustomTool.name == ref))).scalar_one_or_none() + if (ref == name or finder is None or finder.missing or effective_effect(finder) != "read" or tool is None + or not bound_endpoint_ref(tool) or not endpoint_id or finder.endpoint_id != endpoint_id): + raise GovernanceError("查询能力只能选同一端点上、绑定这个端点的只读能力:平台按登记的端点替它按幂等键去查") + + class VersionConflict(Exception): """治理字段在读到之后被别人改过(version 对不上)。""" @@ -404,6 +422,8 @@ async def govern(session: AsyncSession, name: str, changes: dict[str, Any], *, v raise GovernanceError("绑定登记端点的能力由平台按登记发请求,分不出某一次是读还是写:效果只能是只读或写入") if clean.get("approval", row.approval) == "none" and effect != "read": raise GovernanceError("只有只读能力可以免审批:写入和视参数而定的能力,审批至少要跟随信任设置") + if clean.get("lookup"): + await _check_lookup(session, name, endpoint_id, clean["lookup"]) before = {k: getattr(row, k) for k in clean} done = await session.execute( update(Capability).where(Capability.name == name, Capability.version == version) diff --git a/backend/app/capabilities/secrets.py b/backend/app/capabilities/secrets.py index a51c6e7d..1152b4f6 100644 --- a/backend/app/capabilities/secrets.py +++ b/backend/app/capabilities/secrets.py @@ -5,10 +5,14 @@ """ from __future__ import annotations +import re from typing import Any from app.core import crypto +#: HTTP 请求头名能用的字符(RFC 9110 的 token) +_HEADER_NAME = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") + def seal(values: dict[str, Any] | None, previous: dict[str, Any] | None = None) -> dict[str, str]: """提交上来的字典 → 存库的样子:值加密;正好等于原值掩码的那一项保持原值(原值是升级前的明文也一样加密)。""" @@ -23,6 +27,21 @@ def seal(values: dict[str, Any] | None, previous: dict[str, Any] | None = None) return out +def header_problem(values: dict[str, Any] | None, *, check_values: bool = True) -> str | None: + """请求头(名字 → 明文值)哪里不合法,没问题交回 None。名字不能为空、只能是 HTTP 的 token:存成 {"": ""} 时,这一组 + 头的每个请求都在发出之前就失败,报出来的只是一句 LocalProtocolError。值不能换行、只能是可打印的英文字符(中文、全角 + 字符编不进请求头);值里写了 {{ 参数 }} 模板的(自定义工具)由调用方关掉值的检查。""" + for name, value in (values or {}).items(): + key = str(name) + if not key.strip(): + return "请求头名称不能为空" + if not _HEADER_NAME.match(key): + return f"请求头名称「{key}」不合法:只能用英文字母、数字和 - _ . 这类符号,不能有空格、冒号或中文" + if check_values and any(not (" " <= ch <= "~" or ch == "\t") for ch in str(value or "")): + return f"请求头「{key}」的值不合法:只能用英文字母、数字、空格和常用符号,不能换行,也不能用中文或全角字符" + return None + + def masked(values: dict[str, Any] | None) -> dict[str, str]: """给接口返回的样子:每个值都换成掩码。""" return {str(k): crypto.mask(v) if v else "" for k, v in (values or {}).items()} diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 1eb49123..75a33045 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -8,6 +8,7 @@ from typing import Any, Callable from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.engine.issuance import metrics_only from app.engine.labels import contract_label, field_label, judge_label, option_label, q from app.engine.schema import ( CLAIMS_JUDGE, @@ -15,6 +16,7 @@ from app.engine.schema import ( GraphSpec, NodeType, ValidationResult, + _ancestors, _caliber_inputs, claims_problem, contract_needs_metrics, @@ -235,10 +237,16 @@ def _lint_contract( # 引用模式(report_from)核对的是报告撰写节点产出的文档,数字回指靠文档里的引用标记, # 用不上 narrative。report_from 指错了(不是报告撰写节点、不在出口上游)由 validate_graph # 报 error(contract.report_from_invalid),发布时两者一起过,照样挡住 - if contract.get("report_from") in (None, "") and not str(contract.get("narrative") or "").strip(): + # 只写了「指标来自」、上游没有报告撰写节点的是指标出具(纯指标看板,board_only):出具的就是指标本身,核对成果字段 + # 里的数和指标的出处,不缺核对。上游有报告的照旧要指明核哪一份——不然报告的正文没人核 + by_metrics = board_only(contract, spec, node_id) + if contract.get("report_from") in (None, "") and not str(contract.get("narrative") or "").strip() \ + and not by_metrics: flag("出具契约既没有设置「报告来自」(核对哪个「报告撰写」节点的文档),也没有填写「叙述」," - "不会执行数字核对", code="contract.report_from_missing", node_id=node_id, hard=True, - field="contract.report_from") + "不会执行数字核对" + ("" if sources else ";纯指标看板请设置「指标来自」,按指标出具"), + code="contract.report_from_missing", node_id=node_id, hard=True, field="contract.report_from") + if strict and by_metrics: + _lint_metric_lineage(spec, sources, flag, node_id=node_id) # 引用模式下整张图没有口径卡(只引查询单元格的问数据图):没有指标可列,strict + cells 已经保证 # 每个数点得开出处、对不上就不予出具(用户 2026-09-29 拍板)。有口径卡的照旧要列必需指标—— @@ -540,6 +548,43 @@ def _card_feeders(graph: _Graph, card: GraphNode) -> list[GraphNode]: return list(out.values()) +def board_only(contract: Any, spec: GraphSpec, exit_id: str) -> bool: + """按指标出具(纯指标看板):契约只写了「指标来自」(issuance.metrics_only),成果上游没有报告撰写节点,成果字段里也 + 没有模型写的话。有报告的,报告正文要按引用核对;有模型写的话的,那就是叙述——两种都不能因为契约少写了一样就改成 + 只核指标。门禁和成果节点(nodes/io)用这一处。""" + if not metrics_only(contract): + return False + nodes = spec.node_map() + exit_node = nodes.get(exit_id) + if exit_node is None or any(nodes[a].type == NodeType.REPORT for a in _ancestors(spec, exit_id) if a in nodes): + return False + return not exit_text_fields(spec, exit_node) + + +def _lint_metric_lineage(spec: GraphSpec, sources: list[str], flag: Callable[..., None], *, node_id: str) -> None: + """受管级别按指标出具:每个指标的输入都要追到原始数据(查询快照的格、运行输入),静态地按 engine/lineage 认。 + 没有叙述可核,指标的出处就是出具的全部依据;追不到的那一个,「完整出具」这一档就没有根据。""" + from app.engine.lineage import static_stops + + nodes = {n.id: {"type": str(n.type), "title": n.title, "config": n.config} for n in spec.nodes} + for card_id in sources: + card = nodes.get(card_id) + if card is None or card["type"] != str(NodeType.METRICS): + continue + for metric in card["config"].get("metrics") or []: + if not isinstance(metric, dict) or not metric.get("expression"): + continue + stops = static_stops(nodes, str(metric["expression"])) + if not stops: + continue + where = "、".join(dict.fromkeys( + f"{s.path}(停在「{nodes[s.node_id]['title']}」)" if s.node_id in nodes else s.path for s in stops[:3])) + flag(f"受管级别按指标出具时,每个指标都要追到原始数据:口径卡「{card['title']}」的指标" + f"「{metric.get('name') or metric.get('id')}」的输入 {where} 没追到。" + "请在口径卡里用 cell() 按格取数,或让数据整形只原样解析调用工具节点的结果", + code="governed.metric_untraced", node_id=card_id, hard=True, field="metrics") + + def _lint_evidence(spec: GraphSpec, flag: Callable[..., None], *, strict: bool) -> None: graph = _Graph(spec) outputs = [n for n in spec.nodes if n.type == NodeType.OUTPUT] diff --git a/backend/app/engine/issuance.py b/backend/app/engine/issuance.py index b71a6687..4f389a68 100644 --- a/backend/app/engine/issuance.py +++ b/backend/app/engine/issuance.py @@ -409,6 +409,17 @@ def trace_numbers( return report +def metrics_only(contract: Any) -> bool: + """指标出具:契约写了「指标来自」,没写「报告来自」也没写「叙述」——出具的就是指标本身(纯指标看板,用户 2026-10-15 + 拍板)。三档的语义不变,核对的对象从叙述换成指标:必需指标缺了不予出具;成果字段里给人看的数要回指到指标集; + 每个指标的输入要追到原始数据(engine/lineage),追不到的照实写进缺口、不盖完整出具。""" + if not isinstance(contract, dict): + return False + sources = contract.get("metrics_from") or [] + return bool(sources) and contract.get("report_from") in (None, "") \ + and not str(contract.get("narrative") or "").strip() + + def decide_tier( *, missing_required: list[str], diff --git a/backend/app/engine/lineage.py b/backend/app/engine/lineage.py new file mode 100644 index 00000000..fd9bd004 --- /dev/null +++ b/backend/app/engine/lineage.py @@ -0,0 +1,189 @@ +"""口径卡的输入追没追到原始数据:一处认定,三处用。 + +「原始数据」是查询快照里的格(调用工具节点、合并查询交回的结果;Agent 按出处交来的字段落在的那一格)和运行输入(发起 +运行的人填的)。证据面板上的「有出处」说的是另一件事:这个数是口径卡按登记的表达式确定性算出来的、拿代入式复算一致。 +两件事分开说,这里只管前一件: + +- 运行时(nodes/metrics._input_of):「只是把调用工具节点的结果原样按 JSON 解析」的数据整形节点(parsed_tool)不改动任何 + 一格,vars..rows[i][j](row_cell)确定地对到那份查询快照的第 i 行第 j 列,和 cell() 一样按快照核对,记下穿过的整形 + 节点(through); +- 证据面板(api/evidence 的指标步骤):卡上记的输入里追不到原始数据的(untraced),照实说出处追到哪个节点为止; +- 应用检查(apps/lint):受管级应用里绑定的指标,按 static_stops 静态地认(只有图、没有运行),追不到的给提示。 +""" +from __future__ import annotations + +import ast +import re +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression + +#: 整形节点的模板整串就是一个节点的全部产出:{{ nodes.fetch }}(不带过滤器、前后不带别的字) +_WHOLE_NODE = re.compile(r"^\s*\{\{\s*nodes\.([A-Za-z0-9_\-]+)\s*\}\}\s*$") +_REF_HEAD = re.compile(r"^(vars|nodes|input)\.([A-Za-z_一-鿿][\w\-一-鿿]*)(.*)$", re.S) +#: 落在查询快照一格上的取值方式(cell() 取的格、Agent 按出处交来的字段),和 engine/recompute、dual_check 同一组 +SNAPSHOT_VIAS = ("tool_cell", "agent_field") +#: 核对得了快照的状态:一致,或者对不上(对不上另有说法,但出处是追到了的) +_CHECKED = ("verified", "mismatch") +#: 直接交回查询结果(带快照)的节点:cell() 的第一个参数指到它们就是快照 +_TABLE_NODES = ("tool", "merge") + + +def parsed_tool(node_type: Any, config: Mapping[str, Any] | None, type_of: Callable[[str], Any]) -> str | None: + """这个节点只是把调用工具节点的结果原样按 JSON 解析:数据整形、按 JSON 解析(mode=json)、模板整串就是 + {{ nodes. }}、那个节点是调用工具节点。是的话交回那个调用工具节点的 id,不是返回 None。 + + 只认这一种:表达式、模板拼出来的结构即使带着 rows 和快照 id,行也可能被筛过、换过顺序,第 i 行对不到快照的第 i 行。""" + cfg = config or {} + if str(node_type) != "transform" or cfg.get("mode") != "json": + return None + m = _WHOLE_NODE.match(str(cfg.get("template") or "")) + if not m or str(type_of(m.group(1)) or "") != "tool": + return None + return m.group(1) + + +@dataclass(frozen=True) +class RowCell: + """按位置取的一格:..rows[row][column]。root 是 vars 或 nodes,row 是从 0 数的行号,column 是列号或列名。""" + + root: str + name: str + row: int + column: int | str + + @property + def table(self) -> str: + """那份结果本身的取值链:vars.q""" + return f"{self.root}.{self.name}" + + +def row_cell(path: str) -> RowCell | None: + """路径正好是 vars.q.rows[0][0]、nodes.parse.rows[1]['gmv'] 这种按位置取一格的样子时拆开;行号是非负整数字面量, + 列是非负整数或文本字面量。别的样子(负数下标、下标是变量、多走了一层)返回 None。""" + try: + tree = ast.parse(path, mode="eval").body + except SyntaxError: + return None + if not isinstance(tree, ast.Subscript) or not isinstance(tree.value, ast.Subscript): + return None + column, row, rows = tree.slice, tree.value.slice, tree.value.value + if not (isinstance(rows, ast.Attribute) and rows.attr == "rows" and isinstance(rows.value, ast.Attribute) + and isinstance(rows.value.value, ast.Name) and rows.value.value.id in ("vars", "nodes")): + return None + if not (isinstance(row, ast.Constant) and type(row.value) is int and row.value >= 0): + return None + if not (isinstance(column, ast.Constant) and ((type(column.value) is int and column.value >= 0) + or (isinstance(column.value, str) and column.value))): + return None + return RowCell(rows.value.value.id, rows.value.attr, row.value, column.value) + + +def traced(item: Mapping[str, Any]) -> bool: + """卡上记的一个输入追没追到原始数据:落在查询快照的一格上、核对得了快照(cell() 取的格、穿过 JSON 解析按位置取的格、 + Agent 按出处交来的字段),或者是运行输入。""" + via = item.get("via") + if via in SNAPSHOT_VIAS: + return bool(item.get("artifact")) and item.get("status") in _CHECKED + return via == "input" + + +def untraced(inputs: Iterable[Any]) -> list[dict[str, Any]]: + """卡上记的输入里追不到原始数据的。没有值的不算(缺输入另有说法:值都没有,谈不上追到哪里)。""" + return [item for item in inputs + if isinstance(item, dict) and item.get("value") is not None and not traced(item)] + + +# -------------------------------------------------------------------------- +# 静态地认(只有图) +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Stop: + """一个追不到原始数据的输入:路径和停在哪个节点(认不出是哪个节点时为 None)。""" + + path: str + node_id: str | None + + +def static_stops(nodes: Mapping[str, Mapping[str, Any]], expression: str) -> list[Stop]: + """静态地认一个指标表达式里追不到原始数据的输入。nodes 是节点 id → {type, config}(apps/tree.node_map 的形状)。 + + 和运行时同一套认法,只是不看值:cell() 的第一个参数是查询结果(调用工具、合并查询的产出,或原样解析它的整形节点)、 + 按位置取的格穿过原样解析的整形节点、运行输入、开了按出处核对字段的 Agent 交来的字段,算追到了;别的(表达式整形、 + 沙箱代码、模型调用、转抄别的口径卡……)停在写它的那个节点。表达式写错了的不在这里报(画布的校验报)。""" + try: + tree = parse_expression(expression)[0] + except ExpressionError: + return [] + out: list[Stop] = [] + for path in leaf_refs(tree): + stop = _static_stop(nodes, path) + if stop is not None: + out.append(stop) + return out + + +def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str) -> Stop | None: + type_of = lambda node_id: (nodes.get(node_id) or {}).get("type") # noqa: E731 + if (parts := cell_parts(path)) is not None: + writers = _writers(nodes, parts[0]) + bad = [w for w in writers if not _table(nodes, w, type_of)] + return Stop(path, bad[0]) if bad or not writers else None + if (at := row_cell(path)) is not None: + writers = _writers(nodes, at.table) + node = nodes.get(writers[0]) if len(writers) == 1 else None + if node is not None and parsed_tool(node.get("type"), node.get("config"), type_of): + return None + head = _REF_HEAD.match(path) + if head and head.group(1) == "input": + return None + writers = _writers(nodes, path) + if not writers: + return Stop(path, None) + field = head.group(3).lstrip(".") if head else "" + for writer in writers: + node = nodes[writer] + if node.get("type") == "input": + continue + if node.get("type") == "agent" and _cited(node, head.group(1) if head else "", field): + continue + return Stop(path, writer) + return None + + +def _writers(nodes: Mapping[str, Mapping[str, Any]], source: str) -> list[str]: + """取值链的根是谁写的:nodes.X 是 X;vars.X 是 assign_to 为 X 的节点(入口节点声明了字段 X 的也算)。""" + head = _REF_HEAD.match(source) + if not head: + return [] + root, name = head.group(1), head.group(2) + if root == "nodes": + return [name] if name in nodes else [] + if root == "input": + return [n for n, node in nodes.items() if node.get("type") == "input"] + out = [] + for node_id, node in nodes.items(): + cfg = node.get("config") or {} + if str(cfg.get("assign_to") or "").strip() == name: + out.append(node_id) + elif node.get("type") == "input" and any(isinstance(f, dict) and str(f.get("name") or "").strip() == name + for f in cfg.get("fields") or []): + out.append(node_id) + return out + + +def _table(nodes: Mapping[str, Mapping[str, Any]], node_id: str, type_of: Callable[[str], Any]) -> bool: + node = nodes.get(node_id) or {} + return node.get("type") in _TABLE_NODES or parsed_tool(node.get("type"), node.get("config"), type_of) is not None + + +def _cited(node: Mapping[str, Any], root: str, field: str) -> bool: + """开了按出处核对字段的 Agent 交来的字段(vars.X.字段,或 nodes..data.字段):逐格核对过快照,是原始数据。""" + cfg = node.get("config") or {} + if not (cfg.get("output_schema") and cfg.get("cite_fields") is True): + return False + return root == "vars" or field.startswith("data.") or field.startswith("data[") diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 303255ac..0152179c 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -613,12 +613,13 @@ async def _query_input(item: dict[str, Any], sources: _Sources, aliases: dict[st return {}, f"输入 {item['path']} 取的是一整段查询结果,盲算只能复核单个格子" if item["path"] not in tops: return {}, f"输入 {item['path']} 决定取哪一格,不能单独复算" - value = cell_value(item.get("value")) - if not _number(value): - return {}, f"输入 {item['path']} 不是数值" + # 先认遮罩、再认是不是数:遮罩列上的号码、名称多半是文本,说「不是数值」会让审核人以为改成数就能复算 hidden = await sources.hidden(artifact) if column.lower() in hidden: return {}, f"输入 {item['path']} 取自设置了遮罩的列,不能用于复算" + value = cell_value(item.get("value")) + if not _number(value): + return {}, f"输入 {item['path']} 不是数值" found = await sources.source(source) derived = derived_columns(snap, set(hidden), found.kind if found is not None else None) if hidden else None if derived and column.lower() in derived: diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index febbedfb..a1b956af 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -63,7 +63,8 @@ async def run_output(state: GraphState, ctx: NodeContext) -> dict[str, Any]: governed = await governed_formal(ctx.run.run_id) cells = cells_allowed(contract, governed=governed) - result["_issuance"] = _apply_contract(contract, state, ctx, reports=reports, cells=cells, governed=governed) + result["_issuance"] = _apply_contract(contract, state, ctx, reports=reports, cells=cells, governed=governed, + fields=result) evidence = _evidence_of(reports, contract) if evidence: result["_evidence"] = evidence @@ -144,9 +145,11 @@ def _evidence_of(reports: list[dict[str, Any]], contract: Any) -> dict[str, Any] def _apply_contract( contract: dict[str, Any], state: GraphState, ctx: NodeContext, *, reports: list[dict[str, Any]] | None = None, cells: bool = True, governed: bool = False, + fields: dict[str, Any] | None = None, ) -> dict[str, Any]: from datetime import datetime, timezone + from app.engine.governance import board_only from app.engine.issuance import decide_tier, incomplete_gaps, sql_check_findings, trace_numbers nodes = state.get("nodes") or {} @@ -200,11 +203,19 @@ def _apply_contract( trace = None cited: dict[str, Any] = {} + by_metrics = not citations and board_only(contract, ctx.run.spec, ctx.node.id) if citations: cited = _check_citations(str(report_from), contract, state, ctx, reports or [], cells=cells, governed=governed) gaps.extend(cited["gaps"]) unmatched = cited["unmatched"] + elif by_metrics: + # 指标出具:没有叙述,给人看的是成果字段——字段里的数回指指标集,和叙述同一套规则;每个指标的输入要追到原始 + # 数据,追不到的是「这个数有算式、没出处」,照实记成缺口 + shown = _shown_text(fields or {}) + trace = trace_numbers(shown, metrics, allow=contract.get("allow_numbers")) if shown.strip() else None + unmatched = trace.unmatched if trace else [] + gaps.extend(_untraced_gaps(metrics, ctx)) else: # 2) 叙述数字回指 declared_narrative = str(contract.get("narrative", "")) @@ -286,6 +297,7 @@ def _apply_contract( ][:50] issuance = { "tier": tier, + **({"mode": "metrics"} if by_metrics else {}), "calibers": calibers, # 口径版本照常印 "metrics_checked": len(metrics), "missing_required": missing_required, @@ -301,6 +313,7 @@ def _apply_contract( ctx.emit( EventType.ISSUANCE, tier=tier, + **({"mode": "metrics"} if by_metrics else {}), missing_required=missing_required, missing_expected=missing_expected, unmatched=len(unmatched), @@ -316,6 +329,34 @@ def _apply_contract( return issuance +def _shown_text(fields: dict[str, Any]) -> str: + """指标出具时给人看的文字:成果字段里的文字和数(成果自己的 _issuance、_evidence 不算)。表格、对象这类结构化的值不在 + 这里核:它们是查询结果,出处是自己的快照,不是口径卡的指标。""" + parts = [] + for name, value in fields.items(): + if str(name).startswith("_") or isinstance(value, bool): + continue + if isinstance(value, (str, int, float)): + parts.append(str(value)) + return "\n".join(parts) + + +def _untraced_gaps(metrics: list[dict[str, Any]], ctx: NodeContext) -> list[str]: + """指标出具的缺口:输入没追到原始数据的指标,每个一句,说清停在哪个节点(engine/lineage 的认法)。""" + from app.engine.lineage import untraced + + titles = {n.id: n.title for n in ctx.run.spec.nodes} + out = [] + for m in metrics: + stops = untraced(m.get("inputs") or []) + if not stops: + continue + where = "、".join(dict.fromkeys(f"{s.get('path')}(停在「{titles.get(s.get('node_id') or '', s.get('node_id') or '未知来源')}」)" + for s in stops[:3])) + out.append(f"指标「{m.get('name') or m.get('id')}」的输入没追到原始数据:{where}") + return out + + def _upstream_issuance_gaps(state: GraphState, ctx: NodeContext) -> list[str]: """上游节点产出里的 issuance_gaps(字符串列表),每条前面加上是哪个节点说的。 diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 67d9effc..06649df3 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -33,6 +33,7 @@ from app.engine.expressions import ( substitute, ) from app.engine.labels import field_label +from app.engine.lineage import parsed_tool, row_cell from app.engine.sql_problems import CHECK_FIELDS, QueryProblem, merge_problems, node_of, query_problems, reason_text from app.engine.state import GraphState, template_context @@ -528,12 +529,16 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, vars.X 要反查是谁写的:assign_to 为 X 的节点里,按执行顺序最后跑的那一个—— 和状态里 vars 的覆盖顺序一致。入口节点把输入字段也写进 vars。 - 两种来历能追到查询快照里的那一格: + 三种来历能追到查询快照里的那一格(engine/lineage.py 说「追到了原始数据」的就是这几种,加上运行输入): - cell(nodes.fetch, 0, 'gmv'):via tool_cell,带 locator、artifact、eid,按快照核对 + - vars.q.rows[0][0],q 由「只是把调用工具节点的结果原样按 JSON 解析」的整形节点写:同 cell(),via tool_cell, + 另记 through(穿过的整形节点) - agent 开了 cite_fields 的字段:via agent_field,接上它的 data_evidence(eid、核对状态) """ if (parts := cell_parts(path)) is not None: return _cell_input(path, value, parts, state, ctx, tctx or template_context(state)) + if (parsed := _parsed_cell(path, value, state, ctx, tctx or template_context(state))) is not None: + return parsed spec = ctx.run.spec nodes = spec.node_map() node_id: str | None = None @@ -642,16 +647,42 @@ def _agent_element(evidence: dict[str, Any], key: str) -> dict[str, Any] | None: return out +def _parsed_cell(path: str, value: Any, state: GraphState, ctx: NodeContext, + tctx: dict[str, Any]) -> dict[str, Any] | None: + """穿过「只是把调用工具节点的结果原样按 JSON 解析」的整形节点按位置取的一格(vars.q.rows[0][0]、 + nodes.parse.rows[0]['gmv']):整形节点不改动任何一格,第 i 行第 j 列就是那份查询快照的第 i 行第 j 列,和 cell() 一样 + 按快照核对(_cell_input),through 记下穿过的整形节点。 + + 不是这种取法(不是 .rows[行][列] 的样子、写它的不是这种整形节点)返回 None,按一般的来历记:表达式、模板拼出来的 + 结构即使带着 rows 和快照 id,行也可能被筛过、换过顺序,对不到快照的同一行(见 engine/lineage.parsed_tool)。""" + at = row_cell(path) + if at is None: + return None + nodes = ctx.run.spec.node_map() + transform = at.name if at.root == "nodes" else _writer_of(at.name, state, ctx) + node = nodes.get(transform) if transform else None + tool = parsed_tool(node.type, node.config, lambda i: nodes[i].type if i in nodes else None) if node else None + if node is None or tool is None: + return None + return _cell_input(path, value, (at.table, repr(at.row), repr(at.column)), state, ctx, tctx, + through=(node.id, tool)) + + def _cell_input(path: str, value: Any, parts: tuple[str, str, str], state: GraphState, ctx: NodeContext, - tctx: dict[str, Any]) -> dict[str, Any]: + tctx: dict[str, Any], *, through: tuple[str, str] | None = None) -> dict[str, Any]: """cell() 取的那一格:它来自哪次查询(台账里的 query 条目)、第几行哪一列、快照里是不是这个值。 查询结果经 transform 解析过也认得出来:数据源工具交回的 JSON 里带着快照的工件 id。 取不到快照、或者快照里那一格和算的时候用的值不一样,status 如实写,不替它圆。 + + through 是 (整形节点, 调用工具节点):穿过原样按 JSON 解析的整形节点按位置取的格(_parsed_cell)。台账按工件认不出时 + 按那个调用工具节点找它最后一次查询,卡上记下穿过的整形节点。 """ source, row_src, column_src = parts entry: dict[str, Any] = {"path": path, "value": value if value is None or isinstance(value, (int, float, str, bool)) else _brief(value), "node_id": None, "via": "tool_cell"} + if through is not None: + entry["through"] = through[0] table = _value_of(source, tctx) row, column = _value_of(row_src, tctx), _value_of(column_src, tctx) data = table @@ -664,19 +695,21 @@ def _cell_input(path: str, value: Any, parts: tuple[str, str, str], state: Graph queries = [e for e in state.get("evidence") or [] if isinstance(e, dict) and e.get("kind") == "query"] ledger = next((e for e in reversed(queries) if artifact and e.get("artifact") == artifact), None) head = _REF_HEAD.match(source) - if ledger is None and head and head.group(1) == "nodes": + producer = through[1] if through is not None else head.group(2) if head and head.group(1) == "nodes" else None + if ledger is None and producer: # 台账是按工件认的;工具交回的不是带工件 id 的 JSON 时,按取值链上的节点找它最后一次查询 - ledger = next((e for e in reversed(queries) if e.get("node_id") == head.group(2)), None) + ledger = next((e for e in reversed(queries) if e.get("node_id") == producer), None) if ledger is None: - entry.update(node_id=head.group(2) if head and head.group(1) == "nodes" else None, - status="unresolved", reason="找不到这份数据对应的查询快照:cell() 需要读取查询工具返回的结果") + entry.update(node_id=producer, status="unresolved", + reason="找不到这份数据对应的查询快照:cell() 需要读取查询工具返回的结果" if through is None + else "找不到这份数据对应的查询快照:调用工具节点交回的不是数据源查询的结果") return entry entry["node_id"] = ledger.get("node_id") artifact = ledger.get("artifact") try: raw, name = locate_cell(data if isinstance(data, dict) else table, row, column) except CellError as e: - entry.update(status="unresolved", reason=f"cell() {e}", artifact=artifact) + entry.update(status="unresolved", reason=f"cell() {e}" if through is None else f"{path}:{e}", artifact=artifact) return entry locator = {"row": row, "column": name} entry.update(locator=locator, artifact=artifact, eid=cell_eid(artifact, row, name)) diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index d861955c..48b21149 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -290,7 +290,10 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[ await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, note=decision.note, actor=ctx.actor(), payload_hash=confirmed) if not decision.approved: - raise NodeError(ctx.node.id, f"「{name}」的发送已被驳回" + (f":{decision.note}" if decision.note else "")) + # 驳回是人的决定,不是故障:运行到这里结束(状态记失败,没有可走的出口),说法写成「未确认(被驳回)」, + # 实例页按待办的结论另说(apps/resolve.instance_states) + raise NodeError(ctx.node.id, f"「{ctx.node.title}」未确认(被驳回),没有发送" + + (f":{decision.note}" if decision.note else "")) if confirmed != prep.payload_hash: # 动作确认不接受改参数:要改在上游的表单里改。确认之后上游的数据变了,看过的就不是要发的,拒发 raise NodeError(ctx.node.id, f"「{name}」确认时看到的内容和现在要发送的不一致(内容指纹对不上)," @@ -300,7 +303,7 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[ # 外部系统会把审批人当成申请人;无人值守的写入没有人确认,不写 act on_behalf = {"sub": prep.run.get("started_by"), "act": confirmer} try: - result = await actions.send(prep, capability, args, run_id=ctx.run.run_id, node_id=ctx.node.id, + result = await actions.send(prep, capability, run_id=ctx.run.run_id, node_id=ctx.node.id, exec_no=exec_no, emit=ctx.emit, on_behalf=on_behalf) except actions.ActionFailed as e: raise NodeError(ctx.node.id, str(e)) from e diff --git a/backend/app/main.py b/backend/app/main.py index 5d1c961d..3d0f645a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -217,6 +217,8 @@ for router, guard in ( # 应用平台。登录、登出、OIDC、JWKS 匿名可用,设置在接口里要管理员;入站钩子只认签名 (auth_api.router, []), (identity_api.router, _ADMIN), + # 选人用的名录(用户名、显示名、是否停用、组名):岗位绑定、应用授权由搭建者做,只读 + (identity_api.directory_router, _BUILDER), # 能力登记、端点:搭建者只读,改动在接口里要管理员 (capabilities_api.router, _BUILDER), # 应用、待办、生效记录、通知:各自按应用授权、派给谁再判断 diff --git a/backend/app/process/actions.py b/backend/app/process/actions.py index 9c8424a6..c1a21ffb 100644 --- a/backend/app/process/actions.py +++ b/backend/app/process/actions.py @@ -3,8 +3,8 @@ ActionRecord 是权威记录:先写记录再发送,结果未知永不自动重发。调用工具节点(engine/nodes/tools.py)在能力治理后 效果为 write 时走这里: - 1. prepare:渲染好的参数 → payload_hash;按 action_key 查记录——已生效的交回存下的回执,已发出 / 结果未知的挂起, - 失败且有人核实为「未生效,重新发送」的 attempt 加一; + 1. prepare:先按 action_key 查记录——已生效的交回存下的回执,已发出 / 结果未知的挂起,失败且有人核实为「未生效,重新 + 发送」的 attempt 加一;真要发送时才把渲染好的参数按能力的参数定义收成声明的类型(typed_args)→ payload_hash; 2. (节点)需要确认时在 tool.start 之前中断(kind=action_confirm),恢复后重算哈希,对不上拒绝发送; 3. send:先写 pending 并提交 → 改成 sent 并提交 → 发请求(幂等键 {action_key}:{attempt})→ 2xx confirmed(回执落工件)、 4xx(429 除外)failed、超时 / 中断 / 5xx unknown(抛 ActionPending,运行挂起、不封存)。 @@ -21,7 +21,10 @@ from __future__ import annotations import copy import hashlib +import json import logging +import math +import re from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any, Callable @@ -90,6 +93,130 @@ def masked(doc: Any, pointers: list[str]) -> Any: return out +# -------------------------------------------------------------------------- +# 参数按声明的类型收好 +# -------------------------------------------------------------------------- + +#: 参数类型的叫法(收不成声明的类型时说给人听) +_TYPE_TEXT = {"number": "数", "integer": "整数", "boolean": "是或否(true / false)", "object": "对象(JSON)", + "array": "列表(JSON)"} +_INTEGER = re.compile(r"[+-]?\d+") +#: 文字里的数:和前端表单读数的写法一样(lib/schemaForm),不认 nan、inf、带下划线的 +_NUMBER = re.compile(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?") + + +class _Untyped(Exception): + def __init__(self, where: str, kind: str, value: Any) -> None: + super().__init__(where) + self.where, self.kind, self.value = where, kind, value + + +def typed_args(capability: Any, args: dict[str, Any]) -> dict[str, Any]: + """按能力登记的参数定义(params_schema,JSON Schema)把渲染好的参数收成声明的类型。确认卡的预览、内容指纹、发出去的 + 请求都用收好的这一份:看过的就是发出去的。 + + 模板渲染出来的一律是文字:{"amount": "{{ input.amount }}"} 得到 "1280.5",照原样发,外部系统收到的金额是字符串。 + 这里只收 number / integer / boolean,以及 object / array(是 JSON 文字的先解开,再按里面的定义逐层收);声明为文字、 + 没写类型的原样不动,参数定义里没有的键也原样不动——没登记参数定义的能力发出去的和以前一字不差。 + 可选参数渲染出来是空的(模板没取到值):不发这个键,不拿空串冒充数;必填的空着照实失败。 + 收不成("abc" 填给数)抛 ActionFailed,说清是哪个参数,一次都不发。""" + schema = getattr(capability, "params_schema", None) + if not isinstance(args, dict) or not isinstance(schema, dict) or not isinstance(schema.get("properties"), dict): + return args + try: + return _typed_object(schema, args, "") + except _Untyped as e: + title = getattr(capability, "title", None) or capability.name + shown = e.value if isinstance(e.value, str) else json.dumps(e.value, ensure_ascii=False, default=str) + if e.kind == "required": + raise ActionFailed(f"「{title}」的必填参数 {e.where} 是空的(参数模板没有取到值),没有发送。" + "请检查节点里的参数和上游填的值") from e + raise ActionFailed(f"「{title}」的参数 {e.where} 应为{_TYPE_TEXT.get(e.kind, e.kind)},实际是「{shown[:80]}」," + "没有发送。请检查节点里的参数和上游填的值") from e + + +def _kinds(spec: dict[str, Any]) -> list[str]: + kind = spec.get("type") + if isinstance(kind, list): + return [k for k in kind if isinstance(k, str)] + return [kind] if isinstance(kind, str) else [] + + +def _typed_object(spec: dict[str, Any], obj: dict[str, Any], path: str) -> dict[str, Any]: + props = spec.get("properties") if isinstance(spec.get("properties"), dict) else {} + required = set(spec.get("required") or []) if isinstance(spec.get("required"), list) else set() + out: dict[str, Any] = {} + for key, value in obj.items(): + sub = props.get(key) + where = f"{path}.{key}" if path else str(key) + kinds = _kinds(sub) if isinstance(sub, dict) else [] + blank = value is None or (isinstance(value, str) and not value.strip()) + if blank and kinds and "string" not in kinds: + if "null" in kinds: + out[key] = None + elif key in required: + raise _Untyped(where, "required", value) + continue + out[key] = _typed(value, sub, where) if isinstance(sub, dict) else value + return out + + +def _typed(value: Any, spec: dict[str, Any], where: str) -> Any: + kinds = [k for k in _kinds(spec) if k != "null"] + if not kinds or ("string" in kinds and isinstance(value, str)): + return value + first: _Untyped | None = None + for kind in kinds: + try: + return _as(kind, value, spec, where) + except _Untyped as e: + first = first or e + raise first or _Untyped(where, kinds[0], value) + + +def _as(kind: str, value: Any, spec: dict[str, Any], where: str) -> Any: + if kind == "string": + return value + if kind in ("number", "integer"): + number: Any = None + if isinstance(value, (int, float)) and not isinstance(value, bool): + number = value + elif isinstance(value, str): + text = value.strip() + if _INTEGER.fullmatch(text): + number = int(text) + elif _NUMBER.fullmatch(text): + number = float(text) + if number is None or isinstance(number, float) and not math.isfinite(number): + raise _Untyped(where, kind, value) + if kind == "integer": + if isinstance(number, float) and not number.is_integer(): + raise _Untyped(where, kind, value) + return int(number) + return number + if kind == "boolean": + if isinstance(value, bool): + return value + if isinstance(value, str) and value.strip().lower() in ("true", "false"): + return value.strip().lower() == "true" + raise _Untyped(where, kind, value) + if kind in ("object", "array"): + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + raise _Untyped(where, kind, value) from None + if kind == "object" and isinstance(value, dict): + return _typed_object(spec, value, where) + if kind == "array" and isinstance(value, list): + items = spec.get("items") + if not isinstance(items, dict): + return value + return [_typed(v, items, f"{where}[{i}]") for i, v in enumerate(value)] + raise _Untyped(where, kind, value) + return value + + # -------------------------------------------------------------------------- # 准备与发送(调用工具节点用) # -------------------------------------------------------------------------- @@ -107,6 +234,8 @@ class Prepared: #: 已经生效(或演练过)的:存下的回执,节点直接交回,不再发 receipt: dict[str, Any] | None = None run: dict[str, Any] = field(default_factory=dict) + #: 按参数定义收好类型的参数(typed_args):哈希、预览按它算,send 发的也是它 + args: dict[str, Any] = field(default_factory=dict) @property def idem_key(self) -> str: @@ -121,47 +250,53 @@ async def latest(session: AsyncSession, key: str) -> ActionRecord | None: async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, args: dict[str, Any]) -> Prepared: """节点每次执行都先走这里:按 action_key 查记录,决定发不发、第几次发、走哪个环境。 - sent / unknown 抛 ActionPending(不发);failed 且没有人核实为「重新发送」的抛 ActionFailed。""" + sent / unknown 抛 ActionPending(不发);failed 且没有人核实为「重新发送」的抛 ActionFailed。先查记录再收类型:已生效 + (演练过)的交回存下的回执、已发出 / 结果未知的挂起,都和这一次渲染出来的参数无关——发出之后能力的参数定义改了、手上的 + 值收不成声明的类型(升级时停在结果未知的运行),已经生效的写入照样交回回执,不判成失败。只有真要发送(头一次、记下了 + 还没发出去、核实为未生效要重发)时才按参数定义收成声明的类型(typed_args),收不成的抛 ActionFailed;之后的哈希、 + 预览、发送都用收好的那一份(Prepared.args)。""" from app.capabilities.registry import masked_paths name = capability.name key = action_key(run_id, node_id, exec_no) - digest = payload_hash(name, args) async with SessionLocal() as session: run = await session.get(Run, run_id) endpoint = await _endpoint(session, capability) prev = await latest(session, key) + facts = {"app_id": run.app_id, "workflow_id": run.workflow_id, "workflow_version": run.version, + "graph_hash": run.version_hash, "started_by": run.started_by} if run is not None else {} + endpoint_name = endpoint.name if endpoint is not None else None + if prev is not None and prev.state in ("confirmed", "simulated"): + # 交回的是生效的那一次:指纹、预览、环境、第几次都照记录,不按这一次的参数重算 + return Prepared(key=key, attempt=prev.attempt, payload_hash=prev.payload_hash, preview=prev.preview, + environment=prev.environment, endpoint=endpoint_name, receipt=_receipt_of(prev), run=facts) + if prev is not None and prev.state in ("sent", "unknown"): + raise ActionPending(prev.id) formal = run is not None and run.run_class == "formal" + attempt = 1 environment = "prod" if formal else "test" if endpoint is not None and (endpoint.environments or {}).get("test") \ else "simulated" - prep = Prepared(key=key, attempt=1, payload_hash=digest, preview=masked(args, masked_paths(capability)), - environment=environment, endpoint=endpoint.name if endpoint is not None else None, - run={"app_id": run.app_id, "workflow_id": run.workflow_id, "workflow_version": run.version, - "graph_hash": run.version_hash, "started_by": run.started_by} if run is not None else {}) - if prev is None: - return prep - if prev.state in ("confirmed", "simulated"): - prep.receipt = _receipt_of(prev) - return prep - if prev.state in ("sent", "unknown"): - raise ActionPending(prev.id) - if prev.state == "pending": + if prev is not None and prev.state == "pending": # 记下了、还没发出去(发之前进程没了):照原样再发,同一个幂等键 - prep.attempt, prep.environment = prev.attempt, prev.environment - return prep - outcome = (prev.resolution or {}).get("outcome") - if outcome == "resend": - prep.attempt = prev.attempt + 1 - return prep - if outcome == "abandon": - raise ActionFailed(f"「{name}」这次写入核实为未生效,已选择放弃发送") - raise ActionFailed(f"「{name}」上一次发送被外部系统拒绝({prev.error or '未生效'}),不会自动重发。" - "请检查上游的数据后重新发起运行") + attempt, environment = prev.attempt, prev.environment + elif prev is not None: + outcome = (prev.resolution or {}).get("outcome") + if outcome == "abandon": + raise ActionFailed(f"「{name}」这次写入核实为未生效,已选择放弃发送") + if outcome != "resend": + raise ActionFailed(f"「{name}」上一次发送被外部系统拒绝({prev.error or '未生效'}),不会自动重发。" + "请检查上游的数据后重新发起运行") + attempt = prev.attempt + 1 + args = typed_args(capability, args) + return Prepared(key=key, attempt=attempt, payload_hash=payload_hash(name, args), + preview=masked(args, masked_paths(capability)), environment=environment, endpoint=endpoint_name, + run=facts, args=args) -async def send(prep: Prepared, capability: Any, args: dict[str, Any], *, run_id: str, node_id: str, exec_no: int, +async def send(prep: Prepared, capability: Any, *, run_id: str, node_id: str, exec_no: int, emit: Callable[..., None], on_behalf: str | dict[str, Any] | None = None) -> dict[str, Any]: - """先写记录再发送。返回节点的产出;结果未知抛 ActionPending,确定失败抛 ActionFailed。 + """先写记录再发送。返回节点的产出;结果未知抛 ActionPending,确定失败抛 ActionFailed。发的是 prepare 收好类型的 + 那一份参数(prep.args),和确认卡上的哈希是同一份。 on_behalf 原样交给平台执行器写身份断言:{"sub": 运行的发起人, "act": 确认人}(capabilities.endpoints.on_behalf_parts)。""" from app.capabilities.registry import endpoint_request, masked_paths @@ -200,7 +335,7 @@ async def send(prep: Prepared, capability: Any, args: dict[str, Any], *, run_id: environment=prep.environment) try: async with SessionLocal() as session: - result = await endpoint_request(session, capability, args, environment=prep.environment, + result = await endpoint_request(session, capability, prep.args, environment=prep.environment, idem_key=prep.idem_key, on_behalf=on_behalf, run_id=run_id, node_id=node_id) except Exception as e: # noqa: BLE001 - 请求可能已经发出去了:一律按结果未知处理,不能当成失败再发 diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index c3bc1ae4..78f125f7 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -221,8 +221,11 @@ async def announce(task_id: str, event: EventType = EventType.TASK_OPENED) -> No def _matches(a: TaskAssignment, principal: Any) -> bool: - if a.status != "pending": - return False + return a.status == "pending" and _for(a, principal) + + +def _for(a: TaskAssignment, principal: Any) -> bool: + """这一份派的是不是这个人(不看回没回复)。""" if getattr(principal, "mode", "off") == "off": return True if a.principal_kind == "user": @@ -234,6 +237,14 @@ def _matches(a: TaskAssignment, principal: Any) -> bool: return a.principal_kind == "hook" and principal.at_least("admin") +def handles(rows: list[TaskAssignment], principal: Any) -> bool: + """这个人是不是这条待办的处理人:有一份没撤回的派给他(已经回复过的也算)。关登录时人人都算。 + + 实例页的待办组件按它分:处理人给待办卡(能处理的给表单),别人只看这一步的状态,不去取待办——待办里带着中断内容 + (报告概况、动作预览),按 id 读只给处理人和搭建者以上(visible)。""" + return any(a.status != "withdrawn" and _for(a, principal) for a in rows) + + def _pick(task: Task, rows: list[TaskAssignment], principal: Any) -> TaskAssignment: """这个人回复的是哪一份。同一个人在一条会签待办上只能投一票(关登录时按自报的署名认,没署名的认不出)。""" name = principal.username @@ -344,6 +355,36 @@ def _tally(task: Task, rows: list[TaskAssignment], mine: TaskAssignment, verdict return "approve" if yes >= m else "reject" if no > n - m else None +def progress(task: Task, rows: list[TaskAssignment]) -> dict[str, Any] | None: + """会签进度(给人看的那一份,界面照着画):只算批准 / 驳回这一类。口径同 _tally——升级加派的那一份不算进会签人数, + 它一人说了算;以前界面把它和原来的人算在一起,升级后写「0/3 已同意」,其实总监一人同意就通过。 + + voters 是会签的人数:待处理时按没撤回的算;判定之后剩下没回复的都撤回了,按判定那一刻记下的人数(outcome.voters), + 老数据没记的按回复过的算。escalation 是升级加派的那几份:升级给的岗位、派给了谁、谁一人定了什么。""" + if task.kind not in VOTING: + return None + policy = task.policy or {"kind": "any"} + own = [a for a in rows if a.source != "escalation"] + said = [(a.response or {}).get("decision") for a in own if a.status == "responded"] + recorded = (task.outcome or {}).get("voters") + if task.status == "pending": + voters = len([a for a in own if a.status != "withdrawn"]) + else: + voters = recorded if isinstance(recorded, int) else max(len(said), 1) + kind = policy.get("kind", "any") + need = voters if kind == "all" else min(int(policy.get("m") or 1), voters) if kind == "quorum" else 1 + out: dict[str, Any] = {"policy": kind, "voters": voters, "need": need, "agreed": said.count("approve"), + "rejected": said.count("reject")} + escalated = [a for a in rows if a.source == "escalation"] + if escalated: + decider = next((a for a in escalated if a.status == "responded"), None) + out["escalation"] = {"slot": str((task.on_timeout or {}).get("to_slot") or "").strip() or None, + "assignees": [_who(a) for a in escalated], + "decided": (decider.response or {}).get("decision") if decider else None, + "by": decider.responded_by if decider else None} + return out + + def _signoffs(rows: list[TaskAssignment]) -> list[dict[str, Any]]: return [{"by": a.responded_by, "decision": (a.response or {}).get("decision"), "note": (a.response or {}).get("note") or "", "at": _iso(a.responded_at), "verified": a.verified, @@ -396,10 +437,12 @@ async def respond(task_id: str, principal: Any, *, decision: Any, values: dict[s final = {"approved": decided == "approve", "note": ";".join(s["note"] for s in signoffs if s["note"]), "signoffs": signoffs, "policy": task.policy} + # 会签人数记在结论里:判定之后没回复的都撤回了,进度要按判定那一刻的人数说(progress) claimed = await session.execute( update(Task).where(Task.id == task.id, Task.status == "pending") .values(status="resolving", resolved_by=principal.username, - outcome={"decision": decided, "by_assignment": mine.id, "signoffs": _signoffs(rows)})) + outcome={"decision": decided, "by_assignment": mine.id, "signoffs": _signoffs(rows), + "voters": len([a for a in voters if a.source != "escalation"])})) if claimed.rowcount != 1: await session.rollback() raise TaskRefused(_closed("resolving")) @@ -650,7 +693,8 @@ async def _on_timeout(task_id: str, now: datetime) -> int: spawn(_notify(task_id, "task.reminded", [a.principal for a in pending if a.principal_kind == "user"])) return 1 if action == "escalate" and task.escalation_level == 0: - slot = str(timeout.get("to_slot") or "") + # 和派生、检查认的是同一个岗位名(apps/tree.slot_refs 去掉了首尾空白) + slot = str(timeout.get("to_slot") or "").strip() claimed = await session.execute(update(Task).where( Task.id == task_id, Task.status == "pending", Task.escalation_level == 0) .values(escalation_level=Task.escalation_level + 1)) @@ -668,8 +712,11 @@ async def _on_timeout(task_id: str, now: datetime) -> int: return 1 if action != "reject" or task.due_at is None or _aware(task.due_at) > now: return 0 + rows = (await _assignments(session, [task_id])).get(task_id, []) + voters = len([a for a in rows if a.status != "withdrawn" and a.source != "escalation"]) claimed = await session.execute(update(Task).where(Task.id == task_id, Task.status == "pending") - .values(status="resolving", outcome={"decision": "expired_reject"})) + .values(status="resolving", + outcome={"decision": "expired_reject", "voters": voters})) await session.commit() if claimed.rowcount != 1: return 0 @@ -858,7 +905,8 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> approvals = {a.id: a for a in (await session.execute( select(Approval).where(Approval.id.in_([t.approval_id for t in tasks if t.approval_id])))).scalars()} runs = {r.id: r for r in (await session.execute( - select(Run.id, Run.workflow_name, Run.graph).where(Run.id.in_({t.run_id for t in tasks})))).all()} + select(Run.id, Run.workflow_name, Run.graph, Run.origin) + .where(Run.id.in_({t.run_id for t in tasks})))).all()} apps = dict((await session.execute(select(App.id, App.name).where( App.id.in_({t.app_id for t in tasks if t.app_id})))).all()) out = [] @@ -872,7 +920,10 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> "id": task.id, "approval_id": task.approval_id, "run_id": task.run_id, "node_id": task.node_id, "node_label": _node_label(run.graph if run else None, task.node_id), "app_id": task.app_id, "app_name": apps.get(task.app_id), "app_version": task.app_version, - "workflow_name": run.workflow_name if run else None, "kind": task.kind, "title": task.title, + "workflow_name": run.workflow_name if run else None, + # 实例的标题(应用入口按 instance_title 起的):同一个人工节点的待办标题一样,列表里靠它分开 + "instance_title": str((run.origin or {}).get("title") or "") or None if run else None, + "kind": task.kind, "title": task.title, "form": task.form, "policy": task.policy or {"kind": "any"}, "status": task.status, "due_at": _iso(task.due_at), "escalation_level": task.escalation_level, "payload": payload, "assignments": [{ @@ -881,6 +932,8 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> "decision": (a.response or {}).get("decision"), "verified": a.verified} for a in rows], "can_respond": task.status == "pending" and any(_matches(a, principal) for a in rows) and not _voted(task, rows, principal), + "progress": progress(task, rows), + "my_response": my_response(rows, principal), "outcome": task.outcome, "resolved_by": task.resolved_by, "resolved_at": _iso(task.resolved_at), "created_at": _iso(task.created_at), }) @@ -898,6 +951,16 @@ def visible(item: dict[str, Any], principal: Any) -> bool: for a in item["assignments"]) +def my_response(rows: list[TaskAssignment], principal: Any) -> dict[str, Any] | None: + """这个人在这条待办上的回复(决定、时间、是否登录核实):处理过的人再打开,写「你已处理:同意(时间)」,不写 + 「没有派给你」。认不出是谁(关登录又没署名)的是 None。""" + name = getattr(principal, "username", None) + mine = next((a for a in rows if name and a.status == "responded" and a.responded_by == name), None) + if mine is None: + return None + return {"decision": (mine.response or {}).get("decision"), "at": _iso(mine.responded_at), "verified": mine.verified} + + def _voted(task: Task, rows: list[TaskAssignment], principal: Any) -> bool: name = getattr(principal, "username", None) return bool(name) and task.kind in VOTING and any(a.status == "responded" and a.responded_by == name diff --git a/backend/app/report_export/runtime.js b/backend/app/report_export/runtime.js index a654e781..a66b748c 100644 --- a/backend/app/report_export/runtime.js +++ b/backend/app/report_export/runtime.js @@ -37,6 +37,10 @@ dSource: '来源', dPinned: '口径来源', dUpgrade: '升版处置', dDual: '双盲复算', dArtifact: '工件', dNode: '来源节点', dStatus: '状态', dDecimals: '小数位', recomputeOk: '复算一致', recomputeBad: '复算不一致:代入式的计算结果与记录值不符', recomputeNone: '无法复算', + // 输入追没追到原始数据(查询快照里的格、运行输入):和「有出处」(确定性复算一致)是两件事,同证据面板的说法 + dOrigin: '原始数据', originTraced: '输入都追到了原始数据(查询快照里的格或运行输入)', + originStop: (path, label) => `${path} 的出处追到「${label}」为止,没追到原始数据`, + originUnknown: (path) => `${path} 无法确定来自哪个节点,没追到原始数据`, incomplete: '结果不完整', incompleteWhy: (t) => `基于被截断的查询结果计算,结果不完整${t ? `:${t}` : ''}`, sqlCheckFailed: 'SQL 检查未通过', sqlCheckWhy: (t) => `所依据的查询未通过 SQL 检查,结果不可靠${t ? `:${t}` : ''}`, missingValue: '缺少输入:口径卡本次未计算出该指标的值', @@ -190,9 +194,15 @@ // 计算方式:原式 + 代入式 body.push(h('p', { class: 'sub' }, T.dExpression), h('pre', { class: 'sql' }, String(step.expression || NONE))) body.push(h('p', { class: 'sub' }, T.dSubstituted), h('pre', { class: 'sql' }, String(step.substituted || NONE))) + const untraced = Array.isArray(step.untraced) ? step.untraced.filter(isObj) : null body.push(h('div', {}, kv([ [T.dRecompute, step.recompute_ok == null ? T.recomputeNone : step.recompute_ok ? T.recomputeOk : T.recomputeBad], + // 卡上没有输入、导出之前的服务端没给时不写 + untraced ? [T.dOrigin, untraced.length + ? untraced.map((u) => (u.node_id ? T.originStop(String(u.path || NONE), u.label || nodeLabel(u.node_id)) + : T.originUnknown(String(u.path || NONE)))).join(';') + : T.originTraced] : null, ]))) // 双盲复算(期 4):复核过这个指标才有 if (isObj(step.dual_check)) { diff --git a/backend/app/scheduler/service.py b/backend/app/scheduler/service.py index 022d5ba5..14c25443 100644 --- a/backend/app/scheduler/service.py +++ b/backend/app/scheduler/service.py @@ -31,20 +31,30 @@ RUN_CLASSES = ("formal", "exploratory") #: 挂起的另外按失败推一次通知(webhook._state_of),不然任务就此静默停摆 ACTIVE_RUN = ("queued", "running", "interrupted") -#: 模板里能用的变量:(变量名, 说明)。说明写在变量清单接口里,和示例一起给界面 -VARIABLES: tuple[tuple[str, str], ...] = ( - ("today", "今天(YYYY-MM-DD)"), - ("yesterday", "昨天(YYYY-MM-DD)"), - ("this_week_start", "本周一(YYYY-MM-DD)"), - ("last_week_start", "上周一(YYYY-MM-DD)"), - ("last_week_end", "上周日(YYYY-MM-DD)"), - ("this_month_start", "本月 1 日(YYYY-MM-DD)"), - ("last_month_start", "上月 1 日(YYYY-MM-DD)"), - ("last_month_end", "上月最后一天(YYYY-MM-DD)"), - ("fire_time", "本次计划触发时间(带时区偏移的 ISO 时间)"), - ("last_fire_time", "上一次计划触发时间(带时区偏移的 ISO 时间)"), +#: ISO 周不直观的那一处:跨年那几天按所在的周算,年份跟着周走。写在变量清单接口里(note),界面放在变量下面 +ISO_WEEK_NOTE = ("ISO 周从周一开始,含当年 1 月 4 日的那一周是第 1 周;跨年那几天按所在的周算," + "例如 2027-01-01(周五)属于 2026-W53") + +#: 模板里能用的变量:(变量名, 说明, 补充说明)。说明、补充说明写在变量清单接口里,和示例一起给界面 +VARIABLES: tuple[tuple[str, str, str], ...] = ( + ("today", "今天(YYYY-MM-DD)", ""), + ("yesterday", "昨天(YYYY-MM-DD)", ""), + ("this_week", "本周(ISO 周,YYYY-Www)", ISO_WEEK_NOTE), + ("last_week", "上周(ISO 周,YYYY-Www)", ISO_WEEK_NOTE), + ("week_before_last", "上上周(ISO 周,YYYY-Www)", ISO_WEEK_NOTE), + ("this_week_start", "本周一(YYYY-MM-DD)", ""), + ("last_week_start", "上周一(YYYY-MM-DD)", ""), + ("last_week_end", "上周日(YYYY-MM-DD)", ""), + ("this_month", "本月(YYYY-MM)", ""), + ("last_month", "上月(YYYY-MM)", ""), + ("month_before_last", "上上月(YYYY-MM)", ""), + ("this_month_start", "本月 1 日(YYYY-MM-DD)", ""), + ("last_month_start", "上月 1 日(YYYY-MM-DD)", ""), + ("last_month_end", "上月最后一天(YYYY-MM-DD)", ""), + ("fire_time", "本次计划触发时间(带时区偏移的 ISO 时间)", ""), + ("last_fire_time", "上一次计划触发时间(带时区偏移的 ISO 时间)", ""), ) -_NAMES = tuple(f"schedule.{key}" for key, _ in VARIABLES) +_NAMES = tuple(f"schedule.{key}" for key, _, _ in VARIABLES) _TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL) #: 触发记下来、运行还没发起时先写成这样:发起成功再改成 started。进程在这中间被强杀,留下的记录照实说没有发起。 @@ -76,9 +86,11 @@ def current_value(value: Any) -> str: def template_values(fire_at: datetime, tz: str, cron_expr: str | cron.CronSpec) -> dict[str, str]: - """按计划触发时间在任务时区算出的变量(键不带 schedule. 前缀)。日期写 YYYY-MM-DD,时间写带偏移的 ISO、只到秒。 + """按计划触发时间在任务时区算出的变量(键不带 schedule. 前缀)。日期写 YYYY-MM-DD,ISO 周写 YYYY-Www(2026-W42), + 月份写 YYYY-MM,时间写带偏移的 ISO、只到秒。 按 fire_at 算而不是按「现在」:补发晚了一小时、立即运行在零点前后点下去,「昨天」都是那次触发的昨天。 + ISO 周的年份是周所在的年(isocalendar),不是日历年:2027-01-01 是 2026-W53,2025-12-29 是 2026-W01。 last_fire_time 是按 cron 往前找的上一次计划触发,不看历史记录:同一个 (cron, 时区, fire_at) 永远渲染出同一份输入。 """ zone = cron.zone(tz) @@ -87,15 +99,22 @@ def template_values(fire_at: datetime, tz: str, cron_expr: str | cron.CronSpec) week_start = today - timedelta(days=today.weekday()) month_start = today.replace(day=1) last_month_end = month_start - timedelta(days=1) + last_month_start = last_month_end.replace(day=1) previous = cron.prev_fire(cron_expr, zone, fire_at) return { "today": _day(today), "yesterday": _day(today - timedelta(days=1)), + "this_week": _week(today), + "last_week": _week(today - timedelta(days=7)), + "week_before_last": _week(today - timedelta(days=14)), "this_week_start": _day(week_start), "last_week_start": _day(week_start - timedelta(days=7)), "last_week_end": _day(week_start - timedelta(days=1)), + "this_month": _month(month_start), + "last_month": _month(last_month_start), + "month_before_last": _month(last_month_start - timedelta(days=1)), "this_month_start": _day(month_start), - "last_month_start": _day(last_month_end.replace(day=1)), + "last_month_start": _day(last_month_start), "last_month_end": _day(last_month_end), "fire_time": local.isoformat(timespec="seconds"), "last_fire_time": previous.astimezone(zone).isoformat(timespec="seconds") if previous else "", @@ -106,6 +125,16 @@ def _day(value: date) -> str: return value.isoformat() +def _week(value: date) -> str: + """这一天所在的 ISO 周:2026-W42。周号补足两位,和 ISO 8601 的写法一致(2026-W05,不写 2026-W5)。""" + year, week, _ = value.isocalendar() + return f"{year}-W{week:02d}" + + +def _month(value: date) -> str: + return f"{value.year:04d}-{value.month:02d}" + + def render_inputs(template: dict[str, str], values: dict[str, str]) -> dict[str, str]: """渲染输入模板。用项目通用的 {{ }} 渲染(engine/expressions.render_template),变量挂在 schedule 下。""" ctx = {"schedule": values} @@ -155,13 +184,14 @@ def _check_text(name: str, text: str) -> None: def variables(now: datetime | None = None, tz: str = DEFAULT_TIMEZONE) -> list[dict[str, str]]: - """变量清单:[{name, label, example}]。示例按 now(默认现在)这一刻触发算;上一次计划触发时间没有 cron - 可依,示例按每天同一时刻触发算。""" + """变量清单:[{name, label, example, note}]。示例按 now(默认现在)这一刻触发算;上一次计划触发时间没有 cron + 可依,示例按每天同一时刻触发算。note 是补充说明(ISO 周的跨年规则),没有的是空串。""" moment = (now or utcnow()).replace(second=0, microsecond=0) local = moment.astimezone(cron.zone(tz)) daily = f"{local.minute} {local.hour} * * *" values = template_values(moment, tz, daily) - return [{"name": f"schedule.{key}", "label": label, "example": values[key]} for key, label in VARIABLES] + return [{"name": f"schedule.{key}", "label": label, "example": values[key], "note": note} + for key, label, note in VARIABLES] # -------------------------------------------------------------------------- diff --git a/backend/app/seed.py b/backend/app/seed.py index cb56b4b8..389af049 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -238,7 +238,7 @@ TEMPLATES: list[dict[str, Any]] = [ _n("start", "input", "周期", fields=[ {"name": "week", "default": "2026-W37", "description": "报告周期(ISO 周)"}, {"name": "prev_week", "default": "2026-W36", "description": "对比的上一周期"}]), - # 聚合放在 SQL 里做完,口径卡只做标量运算。列的顺序就是口径卡里 rows[0][n] 的 n + # 聚合放在 SQL 里做完,口径卡只做标量运算 _n("fetch", "tool", "取数(查库)", tool="db_query__shop", args={"sql": ( "SELECT\n" " SUM(CASE WHEN week = '{{ input.week }}' THEN amount ELSE 0 END) AS gmv,\n" @@ -246,23 +246,25 @@ TEMPLATES: list[dict[str, Any]] = [ " SUM(CASE WHEN week = '{{ input.week }}' THEN 1 ELSE 0 END) AS order_cnt,\n" " SUM(CASE WHEN week = '{{ input.week }}' AND refunded = 1 THEN 1 ELSE 0 END) AS refund_cnt\n" "FROM orders")}), - # 查询工具交回来的是 JSON 文本 {columns, rows},解析成对象口径卡才取得到里面的数 - _n("parse", "transform", "解析查询结果", mode="json", template="{{ nodes.fetch }}", - assign_to="q"), + # 口径卡用 cell(查询节点, 行, 列名) 直接取查询结果的一格:每个数点开都追到这次查询的那一格(查询快照), + # 列的顺序改了也取不错。不必先经数据整形解析 _n("caliber", "metrics", "周报口径卡", caliber="周报口径", caliber_version="v1", metrics=[ {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands", - "expression": "vars.q.rows[0][0]"}, + "expression": "cell(nodes.fetch, 0, 'gmv')"}, {"id": "orders", "name": "订单数", "unit": "单", "format": "thousands", - "expression": "vars.q.rows[0][2]"}, + "expression": "cell(nodes.fetch, 0, 'order_cnt')"}, # 分母为 0 时记为空值(缺输入),交给契约按 expected 降档,而不是整张卡失败 {"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain", - "expression": "round((vars.q.rows[0][0] - vars.q.rows[0][1]) / vars.q.rows[0][1] * 100, 1)" - " if vars.q.rows[0][1] else None"}, + "expression": "round((cell(nodes.fetch, 0, 'gmv') - cell(nodes.fetch, 0, 'gmv_prev'))" + " / cell(nodes.fetch, 0, 'gmv_prev') * 100, 1)" + " if cell(nodes.fetch, 0, 'gmv_prev') else None"}, {"id": "aov", "name": "客单价", "unit": "元", "decimals": 2, "format": "thousands", - "expression": "round(vars.q.rows[0][0] / vars.q.rows[0][2], 2) if vars.q.rows[0][2] else None"}, + "expression": "round(cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'order_cnt'), 2)" + " if cell(nodes.fetch, 0, 'order_cnt') else None"}, {"id": "refund_rate", "name": "退款率", "decimals": 4, "format": "percent_of_ratio", - "expression": "vars.q.rows[0][3] / vars.q.rows[0][2] if vars.q.rows[0][2] else None"}, + "expression": "cell(nodes.fetch, 0, 'refund_cnt') / cell(nodes.fetch, 0, 'order_cnt')" + " if cell(nodes.fetch, 0, 'order_cnt') else None"}, ]), # 受管出具要求报告撰写节点写明最严的三项(发布门禁 G3),模板照着写,拿来就能发布成受管 _n("write", "report", "报告撰写", metrics_from=["caliber"], @@ -281,7 +283,7 @@ TEMPLATES: list[dict[str, Any]] = [ }), ], "edges": [ - _e("start", "fetch"), _e("fetch", "parse"), _e("parse", "caliber"), + _e("start", "fetch"), _e("fetch", "caliber"), _e("caliber", "write"), _e("write", "done"), ], }, @@ -321,10 +323,11 @@ TEMPLATES: list[dict[str, Any]] = [ "nodes": [ _n("start", "input", "发起报销", fields=[ {"name": "applicant", "required": True, "description": "报销人"}, - {"name": "amount", "required": True, "description": "金额(元)"}, + {"name": "amount", "type": "number", "minimum": 0.01, "required": True, "description": "金额(元)"}, {"name": "reason", "required": True, "description": "事由"}]), + # 标题带上金额:同一个人的两笔报销,待办列表里一眼分得开 _n("review", "human", "财务会签", mode="approve", - title="报销审批:{{ input.applicant }}", + title="报销审批:{{ input.applicant }} · {{ input.amount }} 元", message="报销人:{{ input.applicant }}\n金额(元):{{ input.amount }}\n事由:{{ input.reason }}", assignee={"kind": "slot", "slot": "finance"}, policy="all", due="PT1H", on_timeout={"action": "escalate", "to_slot": "director"}), diff --git a/backend/app/tools/builtin/knowledge.py b/backend/app/tools/builtin/knowledge.py index b0d517d0..fd8688ab 100644 --- a/backend/app/tools/builtin/knowledge.py +++ b/backend/app/tools/builtin/knowledge.py @@ -65,6 +65,8 @@ class MemoryWriteArgs(BaseModel): description="把一条值得长期记住的信息写入记忆。相似内容会自动去重。", args_schema=MemoryWriteArgs, dangerous=False, + # 写的是平台自己的记忆:默认不用人确认,但不是只读 + writes_state=True, ) async def memory_write(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: args = MemoryWriteArgs(**kwargs) diff --git a/backend/app/tools/custom.py b/backend/app/tools/custom.py index 2ba6ffcf..476047e7 100644 --- a/backend/app/tools/custom.py +++ b/backend/app/tools/custom.py @@ -132,6 +132,9 @@ async def _run_http(row: CustomTool, args: dict[str, Any]) -> Any: ctx = dict(args) url = render_template(config.get("url", ""), ctx) headers = render_deep(vault.reveal(config.get("headers")), ctx) + if problem := vault.header_problem(headers): + # 发不出去的头(空名字、换行):照实说是哪一项,不是一句 LocalProtocolError + return ToolFailure(error=f"自定义工具「{row.name}」的{problem},请到「工具」页修改请求头") body = config.get("body") json_body = None if body: @@ -171,13 +174,69 @@ async def _run_info(session: AsyncSession, run_id: str) -> tuple[bool, str | Non return run.run_class == "formal", run.started_by, True +#: 效果从宽到严:只读、视参数而定、写入 +_STRICTNESS = {"read": 0, "dynamic": 1, "write": 2} + + +def _system_effect(row: CustomTool) -> str: + """按同步的规矩(capabilities.registry.sync_all)算这一行的效果:绑定端点的按方法,GET / HEAD 为只读、其余为写入; + 别的视参数而定。""" + if bound_endpoint_ref(row): + return "read" if str((row.config or {}).get("method") or "GET").upper() in ("GET", "HEAD") else "write" + return "dynamic" + + +def _effect_of(row: CustomTool, cap: Any) -> str: + """治理后的效果;登记里还没有(刚建、没保存的草稿)按同步的规矩算。 + + 治理是对登记时的那个样子做的:手上这一行按同步的规矩算出来的和登记里的(effect_system)对不上——工具页试运行的草稿 + 把 GET 改成了 POST——取两者里更严的一个,不然草稿凭已保存那一版的「只读」把写入真发出去。对得上的(保存过的那一行) + 照治理的走:管理员把一个用 POST 的查询接口治理成只读,是他的决定。""" + from app.capabilities.registry import effective_effect + + own = _system_effect(row) + if cap is None: + return own + governed = effective_effect(cap) + if own == getattr(cap, "effect_system", own): + return governed + return max(governed, own, key=lambda e: _STRICTNESS.get(e, len(_STRICTNESS))) + + +async def held_write(session: AsyncSession, row: CustomTool) -> bool: + """这个自定义工具是不是写外部系统的能力(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)。 + 是的话不在运行里(工具页试运行、工具库执行)一律演练、不发送:对外写入只经调用工具节点,要确认、带幂等键、留生效 + 记录(A5)。绑定的端点已经没了的不算:执行时照实报端点不存在。""" + from app.capabilities.registry import get_capability + + ref = bound_endpoint_ref(row) + endpoint = await find_endpoint(session, ref) if ref else None + if ref and endpoint is None: + return False + return _holds(row, await get_capability(session, row.name), endpoint.id if endpoint else None) + + +def _holds(row: CustomTool, cap: Any, endpoint_id: str | None) -> bool: + from app.capabilities.registry import writes_externally + + return writes_externally(row.name, _effect_of(row, cap), endpoint_id) + + +def _write_refused(name: str) -> ToolFailure: + """写入能力只能经调用工具节点发送:运行里别的路(Agent、直接调用)碰到它时的报错。""" + return ToolFailure(error=f"「{name}」是写入能力,只能经「调用工具」节点的动作确认后发送") + + async def _run_endpoint(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> Any: """绑定登记端点的 HTTP 工具:由平台执行器发请求(capabilities/endpoints.py),模型只给参数、拿不到任意地址。 - 写入(capabilities.registry.writes_externally:治理后效果为 write,或者视参数而定——执行器分不出读写)在运行里不从这里发:它要先留生效记录、按幂等键发送、结果未知时转核实,那是调用工具节点 - 的动作流程(app/process/actions.py)。这里只给工具库试用、试跑发写入,按探索运行走测试环境或演练。""" + 写入(held_write:治理后效果为 write,或者视参数而定——执行器分不出读写)不从这里发:运行里它要先留生效记录、按幂等键 + 发送、结果未知时转核实,那是调用工具节点的动作流程(app/process/actions.py);工具页试运行、工具库执行(不在运行里) + 只演练,交回将要发送的请求(endpoints.rehearsal)。读取照常发,按探索运行走测试环境,没有测试环境时走生产环境。 + + 请求按手上这一行拼(tool=row):工具页试运行的草稿发的是眼前编辑的方法、路径和请求体,不是按名字找到的已保存的那一版。""" from app.capabilities import endpoints as ep - from app.capabilities.registry import effective_effect, endpoint_request, get_capability, writes_externally + from app.capabilities.registry import endpoint_request, get_capability from app.db.base import SessionLocal async with SessionLocal() as session: @@ -185,15 +244,19 @@ async def _run_endpoint(row: CustomTool, args: dict[str, Any], ctx: ToolContext) if endpoint is None: return ToolFailure(error=f"自定义工具「{row.name}」绑定的端点不存在,可能已被删除。请到「工具」页重新选择端点") cap = await get_capability(session, row.name) - method = str((row.config or {}).get("method") or "GET").upper() - effect = effective_effect(cap) if cap is not None else ("read" if method in ("GET", "HEAD") else "write") + effect = _effect_of(row, cap) formal, starter, in_run = await _run_info(session, ctx.run_id) - if writes_externally(row.name, effect, endpoint.id) and in_run: - return ToolFailure(error=f"「{row.name}」是写入能力,只能经「调用工具」节点的动作确认后发送") + if _holds(row, cap, endpoint.id): + if in_run: + return _write_refused(row.name) + try: + return ep.rehearsal(endpoint, row.config or {}, cap, args) + except ep.EndpointError as e: + return ToolFailure(error=str(e)) environment = ep.pick_environment(endpoint, formal=formal, effect=effect) result = await endpoint_request(session, cap if cap is not None else row.name, args, environment=environment, on_behalf=starter, - run_id=ctx.run_id if in_run else None, node_id=ctx.node_id) + run_id=ctx.run_id if in_run else None, node_id=ctx.node_id, tool=row) out = ep.tool_result(result) return ToolFailure(out) if "error" in out else out @@ -274,14 +337,65 @@ async def build_custom_tools( return tools -async def _execute(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> Any: +def _rehearse_unbound(row: CustomTool, args: dict[str, Any]) -> dict[str, Any]: + """没绑定端点、被治理为写入的自定义工具在工具页试运行、工具库执行时的演练回执:HTTP 工具交回将要发送的方法、地址、 + 请求体和请求头的名字;Python 工具不执行代码,交回这次的参数。""" + from app.capabilities.endpoints import REHEARSAL_MESSAGE + + config = row.config or {} if row.kind == "python": - return await _run_python(row, args, ctx) + return {"rehearsal": True, "message": "演练:写入能力不在这里真的执行,代码没有运行", "args": args} + body = config.get("body") + if body: + rendered = render_deep(body, args) if isinstance(body, dict) else render_template(str(body), args) + try: + body = rendered if isinstance(rendered, dict) else json.loads(rendered or "null") + except json.JSONDecodeError: + body = rendered + return {"rehearsal": True, "message": REHEARSAL_MESSAGE, + "request": {"method": str(config.get("method") or "GET").upper(), + "url": render_template(config.get("url", ""), args), "body": body or None, + "headers": sorted(config.get("headers") or {})}} + + +async def _execute(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> Any: if bound_endpoint_ref(row): return await _run_endpoint(row, args, ctx) + from app.db.base import SessionLocal + + # 没绑定端点、被治理为写入的(effect=write):和绑定端点的写入一样,运行里不从这里发,试运行只演练 + async with SessionLocal() as session: + if await held_write(session, row): + return _write_refused(row.name) if (await _run_info(session, ctx.run_id))[2] else _rehearse_unbound(row, args) + if row.kind == "python": + return await _run_python(row, args, ctx) return await _run_http(row, args) +def http_failure(row: CustomTool, result: Any) -> dict[str, Any] | None: + """HTTP 工具(绑定端点的、模板的)交回的结果带着非 2xx 的状态码:试运行、工具库执行照实说失败和状态码, + 交回 {error, hint, detail};2xx 和别的结果交回 None。运行里照旧原样交给模型,它看得到状态码。结果的形状是平台拼的 + ({status, data | body}),工具库执行经 call_tool 拿到的是它的 JSON 文本,一并认。""" + from app.capabilities.endpoints import describe_status + + if row.kind != "http": + return None + value = result + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + return None + status = value.get("status") if isinstance(value, dict) else None + if not isinstance(status, int) or isinstance(status, bool) or 200 <= status < 300: + return None + hint = ("请检查认证头" if status in (401, 403) else "请检查路径和地址" if status == 404 + else "请对照对方返回的内容(见技术细节)检查参数和请求体" if 400 <= status < 500 + else "通常是对方系统的问题,请稍后重试或联系对方" if status >= 500 else "") + return {"error": f"请求失败:{describe_status(status)}", "hint": hint, + "detail": json.dumps(value, ensure_ascii=False, default=str)[:4000]} + + async def trial(row: CustomTool, args: dict[str, Any], ctx: ToolContext) -> tuple[Any, str | None]: """试跑一次,参数的处理和运行时一样:先按参数 schema 校验、纠正,再执行。 diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 4fd4a8ab..f200be53 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -59,6 +59,9 @@ class ToolSpec: dangerous: bool = False # 函数第一个参数是否接收 ToolContext needs_context: bool = True + # 改平台自身的状态(写记忆):不危险到默认要人确认,但也不是只读——能力登记里不能记成只读(只读的才能正式运行免审批、 + # 才能当写入的查询能力),见 capabilities.registry._discover + writes_state: bool = False def json_schema(self) -> dict[str, Any]: return self.args_schema.model_json_schema() @@ -75,6 +78,7 @@ def register( *, dangerous: bool = False, needs_context: bool = True, + writes_state: bool = False, ) -> Callable[[ToolFunc], ToolFunc]: def deco(func: ToolFunc) -> ToolFunc: _REGISTRY[name] = ToolSpec( @@ -85,6 +89,7 @@ def register( func=func, dangerous=dangerous, needs_context=needs_context, + writes_state=writes_state, ) return func diff --git a/backend/tests/fakes/fake_erp.py b/backend/tests/fakes/fake_erp.py index 113d3a70..a2cb0a1f 100644 --- a/backend/tests/fakes/fake_erp.py +++ b/backend/tests/fakes/fake_erp.py @@ -74,7 +74,7 @@ def endpoint_request_for(erp: FakeErp, *, lookup_capability: str = "erp_lookup") async def fake(session: Any, capability: Any, args: dict[str, Any], *, environment: str, idem_key: str | None = None, on_behalf: str | None = None, run_id: str | None = None, - node_id: str | None = None) -> EndpointResult: + node_id: str | None = None, tool: Any = None) -> EndpointResult: if environment == "simulated": return EndpointResult(status=None, body={"simulated": True}, environment="simulated") if capability.name == lookup_capability: diff --git a/backend/tests/test_app_catalog.py b/backend/tests/test_app_catalog.py new file mode 100644 index 00000000..ac5d977e --- /dev/null +++ b/backend/tests/test_app_catalog.py @@ -0,0 +1,44 @@ +"""组件目录(app/apps/catalog.py)的 props 写给人看的中文名。 + +- 每个组件的每一项 props 都有 title,而且是中文:应用编辑的属性面板按它写属性名,不露 label、chart、sort 这些键名 +- 枚举都有 x-enum-labels,和 enum 一一对应(同表单 agentlab.form/1 的写法),选项不露 line / bar +- title、description、x-enum-labels 只是注解:校验照旧,取值不对的照样报 app.props_invalid +""" +from __future__ import annotations + +import re + +from jsonschema import Draft202012Validator + +from app.apps.catalog import BY_NAME, COMPONENTS, catalog_payload + +_CJK = re.compile(r"[㐀-鿿]") + + +def test_every_prop_has_a_chinese_title(): + missing = [(c["name"], key) for c in COMPONENTS for key, prop in c["props"]["properties"].items() + if not (isinstance(prop.get("title"), str) and _CJK.search(prop["title"]))] + assert missing == [] + # 接口交出去的就是这一份 + assert catalog_payload()["components"][0]["props"]["properties"]["title"]["title"] == "标题" + + +def test_enums_have_labels_in_the_same_order(): + enums = [(c["name"], key, prop) for c in COMPONENTS for key, prop in c["props"]["properties"].items() if "enum" in prop] + assert enums, "目录里应当有枚举属性" + for name, key, prop in enums: + labels = prop.get("x-enum-labels") + assert isinstance(labels, list) and len(labels) == len(prop["enum"]), (name, key) + assert all(isinstance(t, str) and _CJK.search(t) for t in labels), (name, key) + assert BY_NAME["series"]["props"]["properties"]["chart"]["x-enum-labels"] == ["折线图", "柱状图"] + + +def test_annotations_do_not_change_validation(): + schema = BY_NAME["notice"]["props"] + assert not list(Draft202012Validator(schema).iter_errors({"text": "说明", "tone": "warn"})) + errors = list(Draft202012Validator(schema).iter_errors({"tone": "提醒"})) + assert [list(e.absolute_path) for e in errors] == [["tone"]] + table = BY_NAME["table"]["props"] + assert not list(Draft202012Validator(table).iter_errors({"title": "门店", "sort": "-gmv", "limit": 20})) + assert list(Draft202012Validator(table).iter_errors({"limit": 0})) + assert list(Draft202012Validator(table).iter_errors({"unknown": 1})) diff --git a/backend/tests/test_app_forms.py b/backend/tests/test_app_forms.py index a81aa315..58326c36 100644 --- a/backend/tests/test_app_forms.py +++ b/backend/tests/test_app_forms.py @@ -99,6 +99,40 @@ def test_input_fields_become_a_form(): assert validate_form_schema(form) == [] +def test_number_bounds_carry_into_the_form_and_are_enforced(): + """数字字段的上下限带进表单(只带数字类型、写成数的);服务端校验照它拦,前端同一份子集(lib/schemaForm.ts)照它拦。""" + form = input_schema([ + {"name": "amount", "type": "number", "minimum": 0.01, "maximum": 50000, "required": True}, + {"name": "count", "type": "integer", "minimum": 1}, + {"name": "note", "minimum": 3}, + {"name": "bad", "type": "number", "minimum": "零"}, + ]) + props = form["properties"] + assert (props["amount"]["minimum"], props["amount"]["maximum"]) == (0.01, 50000) and props["count"]["minimum"] == 1 + assert "minimum" not in props["note"] and "minimum" not in props["bad"] + assert validate_form_schema(form) == [] + assert [e["code"] for e in validate_form(form, {"amount": -5})] == ["minimum"] + assert [e["code"] for e in validate_form(form, {"amount": 0})] == ["minimum"] + assert validate_form(form, {"amount": 60000})[0]["message"] == "「amount」不能大于 50000" + assert validate_form(form, {"amount": 0.01, "count": 1}) == [] + + +def test_the_seeded_expense_template_refuses_a_negative_amount(): + """样例⑩:金额是数、不能小于 0.01(表单子集没有 exclusiveMinimum,「大于 0」写成 0.01);待办标题带上金额。""" + from app.apps.tree import input_fields + from app.seed import TEMPLATES + + template = next(t for t in TEMPLATES if t["name"] == "⑩ 报销审批(会签)") + form = input_schema(input_fields({"nodes": template["nodes"]})) + assert form["properties"]["amount"] == {"type": "number", "title": "amount", "description": "金额(元)", + "minimum": 0.01} + assert validate_form(form, {"applicant": "钱七", "amount": -5, "reason": "出差"})[0]["code"] == "minimum" + assert validate_form(form, {"applicant": "钱七", "amount": "很多", "reason": "出差"})[0]["code"] == "type" + assert validate_form(form, {"applicant": "钱七", "amount": 1280.5, "reason": "出差"}) == [] + review = next(n for n in template["nodes"] if n["id"] == "review")["data"]["config"] + assert "{{ input.amount }}" in review["title"] and "{{ input.amount }}" in review["message"] + + # -------------------------------------------------------------------------- # 表达式 # -------------------------------------------------------------------------- diff --git a/backend/tests/test_app_instance_page.py b/backend/tests/test_app_instance_page.py new file mode 100644 index 00000000..83112a8f --- /dev/null +++ b/backend/tests/test_app_instance_page.py @@ -0,0 +1,239 @@ +"""流程应用的实例页和待办怎么说(apps/resolve.py 的流程组件、process/tasks.py 的待办描述)。 + +端到端验收(报销审批:财务会签、超时升级给总监)发现的几处「说得不对」: +- 发起人打开实例页,待办表单去取一条不是派给他的待办,红框写「待办不存在」:服务端解析时就判定读者是不是处理人, + 不是的只给这一步的状态(派给了谁、会签进度、时限),不让界面去取; +- 会签进度把登录核实过的审批人写成「自报署名」、没有处理时间:页面解析给的派给谁要带上这两项; +- 同意过的人再打开写「没有派给你」:待办上带这个人的回复,写「你已处理」; +- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点; +- 升级之后写「0/3 已同意」,其实总监一人同意就通过:会签人数不算升级加派的那一份,升级后的规则单列; +- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间。 +""" +from __future__ import annotations + +import asyncio +import uuid +from datetime import timedelta + +import pytest +from sqlalchemy import select + +from app.api.deps import Principal +from app.db.base import SessionLocal, utcnow +from app.db.models import App, AppVersion, Group, GroupMember, Run, Task, User +from app.engine.runner import run_manager +from app.process import tasks + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def expense() -> dict: + nodes = [node("start", "input", "发起报销", fields=[{"name": "applicant"}, {"name": "amount", "type": "number"}]), + node("review", "human", "财务会签", mode="approve", title="报销审批:{{ input.applicant }}", + assignee={"kind": "slot", "slot": "finance"}, policy="all", due="PT1H", + on_timeout={"action": "escalate", "to_slot": "director"}), + node("ok", "output", "已通过", fields=[{"name": "结论", "value": "已通过"}]), + node("no", "output", "已退回", fields=[{"name": "结论", "value": "已退回"}])] + edges = [{"source": "start", "target": "review"}, + {"source": "review", "target": "ok", "sourceHandle": "approved"}, + {"source": "review", "target": "no", "sourceHandle": "rejected"}] + return {"nodes": nodes, "edges": edges} + + +SPEC = {"format": "agentlab.app/1", "name": "报销", "pages": [{"id": "instance", "title": "实例", "body": [ + {"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"}, + {"id": "form", "type": "task_form", "bind": {"task": "review"}}, + {"id": "signs", "type": "signoffs", "bind": {"task": "review"}}, + {"id": "mine", "type": "task_list", "props": {"scope": "mine"}}]}]} + + +def user(name: str) -> Principal: + return Principal(username=name, role="user", mode="local", verified=True) + + +async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run: + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row and row.status in statuses and not run_manager.is_active(run_id): + return row + await asyncio.sleep(0.05) + raise AssertionError(f"运行 {run_id} 没有进入 {statuses}") + + +async def _setup() -> tuple[str, str, str, str]: + """一个应用:财务岗位绑一个组(两个人),总监岗位绑一个人。返回 (应用 id, 财务甲, 财务乙, 总监)。""" + u = uuid.uuid4().hex[:6] + a, b, boss, group = f"zhang{u}", f"wang{u}", f"zhao{u}", f"finance{u}" + async with SessionLocal() as session: + people = [User(username=n, role="user") for n in (a, b)] + session.add_all(people) + g = Group(name=group) + session.add(g) + await session.flush() + session.add_all([GroupMember(group_id=g.id, user_id=p.id) for p in people]) + app = App(name=f"报销-{u}", published_version=1) + session.add(app) + await session.flush() + session.add(AppVersion(app_id=app.id, version=1, spec=SPEC, spec_hash="x" * 64, bindings={"slots": { + "finance": [{"kind": "group", "name": group}], "director": [{"kind": "user", "name": boss}]}})) + await session.commit() + return app.id, a, b, boss + + +async def _start(app_id: str, title: str, amount: float = 1280.5) -> str: + run = await run_manager.start(graph=expense(), input_payload={"applicant": "钱七", "amount": amount}, + app_id=app_id, app_version=1, started_by="qianqi", run_class="formal", + origin_kind="app_entry", origin={"entry": "start", "title": title}) + await _wait(run.id, ("interrupted",)) + return run.id + + +async def _task(run_id: str) -> Task: + async with SessionLocal() as session: + return (await session.execute(select(Task).where(Task.run_id == run_id))).scalars().one() + + +async def _page(app_id: str, run_id: str, reader: Principal) -> dict: + from app.apps.resolve import resolve_page + + async with SessionLocal() as session: + app = await session.get(App, app_id) + version = (await session.execute(select(AppVersion).where(AppVersion.app_id == app_id))).scalars().one() + run = await session.get(Run, run_id) + vm = await resolve_page(session, app, page_id="instance", principal=reader, role="use", instance=run, + version=version) + return {n["id"]: n.get("data") for n in vm["tree"]} + + +async def test_the_initiator_sees_the_step_not_a_missing_task(): + app_id, a, b, _ = await _setup() + run_id = await _start(app_id, "报销审批·钱七") + task = await _task(run_id) + + view = await _page(app_id, run_id, user("qianqi")) + form = view["form"] + # 发起人不是处理人:不让界面去取待办(取了是 404),给这一步的状态 + assert form["handler"] is False + assert sorted((x["principal"], x["status"]) for x in form["assignments"]) == \ + sorted([(a, "pending"), (b, "pending")]) + assert form["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 0} + assert form["task"]["due_at"] and form["task"]["instance_title"] == "报销审批·钱七" + assert tasks.visible(await tasks.detail(task.id, user("qianqi")), user("qianqi")) is False + # 处理人:给待办卡(界面按 id 取完整的待办) + assert (await _page(app_id, run_id, user(a)))["form"]["handler"] is True + # 关登录时人人都算处理人(同待办服务) + assert (await _page(app_id, run_id, Principal(username=None, role="admin", mode="off")))["form"]["handler"] is True + hdr = view["hdr"] + assert (hdr["status_code"], hdr["status_text"]) == ("waiting", "等待审批") + assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "等待审批" + + +async def test_signoffs_say_who_verified_and_when_and_the_voter_sees_their_own_answer(): + app_id, a, b, _ = await _setup() + run_id = await _start(app_id, "报销审批·钱七") + task = await _task(run_id) + assert (await tasks.respond(task.id, user(a), decision="approve"))["resolved"] is False + + signs = (await _page(app_id, run_id, user("qianqi")))["signs"] + done = next(x for x in signs["assignments"] if x["principal"] == a) + # 登录核实过的写「已登录核实」,不是「自报署名」;带处理时间 + assert done["status"] == "responded" and done["verified"] is True and done["responded_at"] + assert done["decision"] == "approve" and done["source"] == "slot:finance" + assert signs["progress"]["agreed"] == 1 and signs["progress"]["need"] == 2 + + # 同意过的人再打开:不能再回复,带上他的回复(写「你已处理:同意」,不写「没有派给你」) + mine = await tasks.detail(task.id, user(a)) + assert mine["can_respond"] is False and mine["my_response"]["decision"] == "approve" + assert mine["my_response"]["at"] and mine["my_response"]["verified"] is True + assert (await tasks.detail(task.id, user(b)))["my_response"] is None + assert (await _page(app_id, run_id, user(a)))["form"]["my_response"]["decision"] == "approve" + + +async def test_the_instance_says_which_outcome_it_reached(): + app_id, a, b, _ = await _setup() + passed = await _start(app_id, "报销审批·钱七") + task = await _task(passed) + await tasks.respond(task.id, user(a), decision="approve") + await tasks.respond(task.id, user(b), decision="approve") + await _wait(passed, ("succeeded",)) + view = await _page(app_id, passed, user("qianqi")) + assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("succeeded", "已通过") + review = next(s for s in view["tl"]["steps"] if s["node"] == "review") + assert (review["state"], review["text"]) == ("done", "已同意") + + back = await _start(app_id, "报销审批·钱七") + await tasks.respond((await _task(back)).id, user(b), decision="reject", note="票据不全") + await _wait(back, ("succeeded",)) + view = await _page(app_id, back, user("qianqi")) + # 走了退回分支:运行是「已完成」,流程的结论是「已退回」 + assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("succeeded", "已退回") + assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "已驳回" + # 判定之后没回复的都撤回了:会签人数按判定那一刻的算,不缩成 1 + assert view["signs"]["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 1} + from app.apps.resolve import list_instances + + async with SessionLocal() as session: + rows = {r["run_id"]: r for r in await list_instances(session, app_id, mine="qianqi")} + assert (rows[back]["status_text"], rows[passed]["status_text"]) == ("已退回", "已通过") + + +async def test_outputs_reached_in_parallel_are_not_one_outcome(): + """几个成果节点并行都走到了(「周报」「明细」一起出):那不是哪一种结局,写「已完成」,不写最后走到的那个节点的名字。""" + from app.apps.resolve import instance_rows + + graph = {"nodes": [node("start", "input", "发起", fields=[{"name": "week"}]), + node("weekly", "output", "周报", fields=[{"name": "周", "value": "{{ input.week }}"}]), + node("detail", "output", "明细", fields=[{"name": "周", "value": "{{ input.week }}"}])], + "edges": [{"source": "start", "target": "weekly"}, {"source": "start", "target": "detail"}]} + run = await run_manager.start(graph=graph, input_payload={"week": "2026-W41"}) + done = await _wait(run.id, ("succeeded", "failed")) + assert done.status == "succeeded", done.error + async with SessionLocal() as session: + [row] = await instance_rows(session, [await session.get(Run, run.id)]) + assert (row["status_code"], row["status_text"]) == ("succeeded", "已完成") + + +async def test_after_escalation_the_rule_is_one_director_not_zero_of_three(): + app_id, a, b, boss = await _setup() + run_id = await _start(app_id, "报销审批·钱七") + task = await _task(run_id) + await tasks.respond(task.id, user(a), decision="approve") + assert await tasks.tick(utcnow() + timedelta(hours=2)) >= 1 + detail = await tasks.detail(task.id, user(boss)) + p = detail["progress"] + # 升级加派的那一份不算进会签人数:还是 1/2,另写升级给了谁、他一人说了算 + assert (p["voters"], p["need"], p["agreed"]) == (2, 2, 1) + assert p["escalation"] == {"slot": "director", "assignees": [{"kind": "user", "name": boss}], "decided": None, + "by": None} + assert detail["can_respond"] is True + assert (await tasks.respond(task.id, user(boss), decision="approve"))["resolved"] is True + await _wait(run_id, ("succeeded",)) + p = (await tasks.detail(task.id, user(boss)))["progress"] + assert (p["voters"], p["agreed"]) == (2, 1) and (p["escalation"]["decided"], p["escalation"]["by"]) == \ + ("approve", boss) + signs = (await _page(app_id, run_id, user("qianqi")))["signs"] + assert signs["progress"]["escalation"]["by"] == boss + + +async def test_tasks_with_the_same_title_carry_the_instance_and_when(): + app_id, a, _, _ = await _setup() + first = await _start(app_id, "报销审批·钱七·第一笔") + second = await _start(app_id, "报销审批·钱七·第二笔", amount=300) + listed = [t for t in await tasks.list_tasks(user(a), app_id=app_id)] + assert [t["title"] for t in listed] == ["报销审批:钱七", "报销审批:钱七"] + assert {t["run_id"]: t["instance_title"] for t in listed} == {first: "报销审批·钱七·第一笔", + second: "报销审批·钱七·第二笔"} + rows = (await _page(app_id, first, user(a)))["mine"]["items"] + assert {r["instance_title"] for r in rows} == {"报销审批·钱七·第一笔", "报销审批·钱七·第二笔"} + assert all(r["created_at"] for r in rows) diff --git a/backend/tests/test_app_platform_v1.py b/backend/tests/test_app_platform_v1.py index 069c4a6e..aa096903 100644 --- a/backend/tests/test_app_platform_v1.py +++ b/backend/tests/test_app_platform_v1.py @@ -259,6 +259,10 @@ async def test_dashboard_app_on_a_schedule(client, source): res = await client.post(f"/api/workflows/{wf_id}/versions/1/retire", json={"note": "改按支付日"}) assert res.status_code == 200, res.text assert [a["app_id"] for a in res.json()["affected_apps"]] == [old_app] + # 版本列表照样给退役的人、时间和说明:版本历史的列表里直接看得到,不用逐个点开 + listed = {v["version"]: v for v in (await client.get(f"/api/workflows/{wf_id}/versions")).json()} + assert listed[1]["retired_at"] and listed[1]["retire_note"] == "改按支付日" and "retired_by" in listed[1] + assert listed[2]["retired_at"] is None and listed[2]["retire_note"] is None apps = {a["id"]: a for a in (await client.get("/api/apps")).json()["items"]} assert apps[old_app]["needs_upgrade"] is True and apps[app_id]["needs_upgrade"] is False assert apps[app_id]["my_role"] == "manage" @@ -301,7 +305,10 @@ async def test_a_draft_preview_reads_sealed_runs_without_launching(client, sourc vm = (await client.get(f"/api/apps/{app_id}/pages/home", params={"draft": 1})).json() assert vm["app"]["version"] == 0 and vm["sources"]["latest"]["state"] == "empty" assert vm["sources"]["od"]["state"] == "empty" - assert {p["code"] for p in vm["problems"]} == {"view.source_empty"} + # 还没有数据是安静的空态,不进「无法显示」;印章上写明还没有、什么时候会有 + assert vm["problems"] == [] + assert vm["sources"]["latest"]["message"].startswith("还没有数据:") and "正式运行成功并封存后" in \ + vm["sources"]["latest"]["message"] async with SessionLocal() as session: after = len((await session.execute(select(Run.id).where(Run.workflow_id == wf_id))).all()) assert after == before, "草稿预览不该发起运行" diff --git a/backend/tests/test_app_spec_tools.py b/backend/tests/test_app_spec_tools.py index 87f53814..4d5a7873 100644 --- a/backend/tests/test_app_spec_tools.py +++ b/backend/tests/test_app_spec_tools.py @@ -325,7 +325,10 @@ async def test_derive_a_process_leaves_slots_empty(monkeypatch): spec = await derive_app(session, wf, 1, name="报销审批") assert spec["name"] == "报销审批" and spec["pins"][0]["use"] == "process" assert spec["slots"] == {"finance": {"label": "finance"}, "reconciler": {"label": "动作核实"}} - assert spec["views"]["latest"]["source"]["from"] == "formal" and spec["views"]["inst"]["source"]["kind"] == "instance" + # 流程的首页只放发起和我发起的:结果在实例页看,不派生「最近封存运行」(派生出来只会挂一块永远空着的印章) + assert "latest" not in spec["views"] and spec["views"]["inst"]["source"]["kind"] == "instance" + home = next(p for p in spec["pages"] if p["id"] == "home") + assert [c["type"] for c in home["body"]] == ["entry_button", "instance_list"] inst = next(p for p in spec["pages"] if p["id"] == "instance") assert {"task_form", "signoffs", "action_status", "instance_header"} <= {c["type"] for c in inst["body"]} assert spec["task_ui"] == {"check": {"title": "财务审批"}} @@ -335,6 +338,125 @@ async def test_derive_a_process_leaves_slots_empty(monkeypatch): assert {"app.slot_unbound", "app.reconciler_unbound"} <= codes(issues, "error") +async def test_an_empty_dashboard_says_when_data_will_come(): + """看板还没有封存运行:不是解析出错,写明还没有数据、什么时候会有(定时任务下次运行的时间,或者要等一次正式运行)。""" + from datetime import datetime, timezone + + from app.apps.pins import load_pins + from app.apps.resolve import _empty_text + + wf, info = await make_workflow(weekly()) + async with SessionLocal() as session: + on = Schedule(name="周一早上", workflow_id=wf, cron="0 9 * * 1", timezone="Asia/Shanghai", run_class="formal", + next_run_at=datetime(2026, 10, 19, 1, 0, tzinfo=timezone.utc)) + paused = Schedule(name="停着的", workflow_id=wf, cron="0 9 * * 1", timezone="Asia/Shanghai", + run_class="formal", enabled=False) + session.add_all([on, paused]) + await session.commit() + state = (await load_pins(session, base_spec(wf, info)))["main"] + assert "定时任务「周一早上」下一次在 2026-10-19" in await _empty_text(session, state, f"schedule:{on.id}") + assert "定时任务「停着的」已停用" in await _empty_text(session, state, f"schedule:{paused.id}") + assert "已删除" in await _empty_text(session, state, "schedule:gone") + assert (await _empty_text(session, state, "formal")).endswith(f"「{info['name']} v1」有一次正式运行成功并封存后这里就有数") + + +def test_slot_refs_cover_every_place_a_graph_names_a_slot(): + """派给谁、超时升级给谁、动作确认派给谁,加上节点上没写、取图级 defaults 的:派生和检查认的是同一份。调用工具节点只有 + 写入的(会开发送确认卡)才算用到岗位:只读的写了派给谁、或者从 defaults 继承来的,都不算。""" + from app.apps.tree import slot_refs + + graph = chain( + node("a", "human", "财务会签", assignee={"kind": "slot", "slot": " finance "}, due="PT1H", + on_timeout={"action": "escalate", "to_slot": "director"}), + node("b", "human", "复核"), + node("pay", "tool", "付款", tool="erp_pay", assignee={"kind": "slot", "slot": "cashier"}), + node("fetch", "tool", "取数", tool="erp_orders", assignee={"kind": "slot", "slot": "clerk"}), + node("sync", "tool", "同步", tool="erp_sync"), + node("c", "human", "补材料", assignee={"kind": "initiator"}, due="PT1H", + on_timeout={"action": "remind", "to_slot": "不算"}), + ) + graph["defaults"] = {"assignee": {"kind": "slot", "slot": "auditor"}} + assert [(r["slot"], r["node_id"], r["role"]) for r in slot_refs(graph, {"pay", "sync"})] == [ + ("finance", "a", "assignee"), ("director", "a", "escalation"), ("auditor", "b", "assignee"), + ("cashier", "pay", "confirm"), ("auditor", "sync", "confirm")] + # 没有写入节点:调用工具节点一个都不算 + assert [r["node_id"] for r in slot_refs(graph)] == ["a", "a", "b"] + + +async def test_read_only_tool_nodes_do_not_use_slots_and_writes_follow_the_runtime_rule(monkeypatch): + """只读的调用工具节点从不开确认卡:它写的派给谁(或图级默认的)不派生岗位、检查也不说「发送确认派给它」。哪些是写入 + 和运行时同一个认法(writes_externally):绑定端点、视参数而定的也是写入,要动作状态、要人核实。""" + from types import SimpleNamespace + + from app.capabilities import registry + + caps = {"erp_pay": SimpleNamespace(effect="write", endpoint_id=None, lookup="erp_lookup"), + "erp_orders": SimpleNamespace(effect="read", endpoint_id="ep-1", lookup=None), + "erp_sync": SimpleNamespace(effect="dynamic", endpoint_id="ep-1", lookup=None)} + + async def get_capability(session, name): + return caps.get(name) + + monkeypatch.setattr(registry, "get_capability", get_capability) + graph = chain( + node("start", "input", "开始", fields=[{"name": "amount", "type": "number", "required": True}]), + node("fetch", "tool", "取数", tool="erp_orders", args={}, assignee={"kind": "slot", "slot": "clerk"}), + node("check", "human", "财务审批", mode="approve", assignee={"kind": "slot", "slot": "finance"}), + node("pay", "tool", "付款", tool="erp_pay", args={}), + node("sync", "tool", "同步", tool="erp_sync", args={}), + node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]), + ) + graph["defaults"] = {"assignee": {"kind": "slot", "slot": "cashier"}} + wf, _ = await make_workflow(graph) + async with SessionLocal() as session: + spec = await derive_app(session, wf, 1) + assert set(spec["slots"]) == {"finance", "cashier", "reconciler"} + inst = next(p for p in spec["pages"] if p["id"] == "instance") + assert [c["bind"]["action"] for c in inst["body"] if c["type"] == "action_status"] == ["pay", "sync"] + issues = await lint_app(session, spec, {"slots": {"finance": [{"kind": "user", "name": "u"}]}}) + unbound = [i for i in issues if i["code"] == "app.slot_unbound"] + cashier = next(i for i in unbound if i["path"] == "slots.cashier") + assert "调用工具节点「付款」的发送确认派给它" in cashier["message"] and "「同步」" in cashier["message"] + assert not any("取数" in i["message"] for i in issues), [i["message"] for i in issues] + [reconcile] = [i for i in issues if i["code"] == "app.reconciler_unbound"] + assert reconcile["node_id"] == "sync" and "「同步」会写外部系统" in reconcile["message"] + + +async def test_escalation_and_confirm_slots_are_derived_and_must_be_bound(monkeypatch): + """超时升级的岗位、动作确认的岗位以前既不派生也不检查:发布后升级、确认悄悄派给了任何人。""" + from app.capabilities import registry + + class Cap: + effect = "write" + lookup = "erp_lookup" + + async def get_capability(session, name): + return Cap() if name == "erp_pay" else None + + monkeypatch.setattr(registry, "get_capability", get_capability) + graph = approval() + graph["nodes"][1]["data"]["config"].update(due="PT1H", on_timeout={"action": "escalate", "to_slot": "director"}) + graph["nodes"][2]["data"]["config"]["assignee"] = {"kind": "slot", "slot": "cashier"} + wf, info = await make_workflow(graph) + async with SessionLocal() as session: + spec = await derive_app(session, wf, 1) + assert set(spec["slots"]) == {"finance", "director", "cashier"} + everyone = {"slots": {s: [{"kind": "user", "name": "u"}] for s in ("finance", "director", "cashier")}} + assert not codes(await lint_app(session, spec, everyone)) & {"app.slot_unbound"} + + # 绑了两个、升级给的总监没绑:报错,说清是哪一步要它 + issues = await lint_app(session, spec, {"slots": {k: v for k, v in everyone["slots"].items() if k != "director"}}) + [unbound] = [i for i in issues if i["code"] == "app.slot_unbound"] + assert unbound["level"] == "error" and unbound["path"] == "slots.director" + assert "人工节点「财务审批」超时升级给它" in unbound["message"] + + # 应用里根本没声明:按引用它的那一步报,落到节点上 + del spec["slots"]["cashier"] + issues = await lint_app(session, spec, everyone) + undeclared = next(i for i in issues if i["code"] == "app.slot_unbound" and i.get("node_id") == "pay") + assert "调用工具节点「付款」的发送确认派给岗位「cashier」" in undeclared["message"] + + async def test_describe_a_version_has_no_secrets(): graph = copy.deepcopy(weekly()) graph["nodes"][1]["data"]["config"]["args"] = {"sql": "SELECT * FROM t WHERE phone = '19912345678'"} diff --git a/backend/tests/test_app_use.py b/backend/tests/test_app_use.py index 16f25e7d..9843a6a1 100644 --- a/backend/tests/test_app_use.py +++ b/backend/tests/test_app_use.py @@ -215,7 +215,8 @@ async def test_on_demand_views(client): app_id = await published_app(client, kpi_spec(wf, hash_), name="按需看板") params = {"params": '{"week": "W40"}'} vm = (await client.get(f"/api/apps/{app_id}/pages/home", params=params)).json() - assert vm["sources"]["od"]["state"] == "empty" and vm["problems"][0]["code"] == "view.source_empty" + assert vm["sources"]["od"]["state"] == "empty" and vm["problems"] == [] + assert "刷新" in vm["sources"]["od"]["message"] assert find(vm, "gmv")["data"]["state"] == "empty" bad = await client.post(f"/api/apps/{app_id}/views/od/refresh", json={"params": {}}) diff --git a/backend/tests/test_app_versions.py b/backend/tests/test_app_versions.py index 17b02d3e..e6ae7a7d 100644 --- a/backend/tests/test_app_versions.py +++ b/backend/tests/test_app_versions.py @@ -383,3 +383,20 @@ async def test_legacy_versions_with_formal_runs_are_backfilled_as_published(): WorkflowVersion.__table__.select().where(WorkflowVersion.workflow_id == wf.id))).all()} assert levels == {1: "published", 2: None, 3: None} assert await backfill_legacy_levels(session) == 0 + + +async def test_only_an_admin_downgrades_a_governed_workflow(client, monkeypatch): + """当前发布版是受管级的工作流,以「已发布」发布新版本就是降级——之后的正式运行不再过治理门禁,和以受管级别发布 + 同一级的决定(Q8):开登录时只给管理员。画布上改过之后 status 回到 draft,按当前发布版的级别认(published_level)。""" + from app.api import deps + + wf = await workflow({1: "governed", 2: None}, current=1, status="draft") + assert (await client.get(f"/api/workflows/{wf}")).json()["published_level"] == "governed" + monkeypatch.setattr(deps, "current_principal", + lambda: deps.Principal(username="bob", role="builder", verified=True, mode="local")) + res = await client.post(f"/api/workflows/{wf}/publish", json={"level": "published", "version": 2}) + assert res.status_code == 403 and res.json()["code"] == "admin_only" and "降为已发布" in res.json()["detail"] + # 已发布级的工作流照旧由搭建者发布 + plain = await workflow({1: "published", 2: None}, current=1, status="draft") + ok = await client.post(f"/api/workflows/{plain}/publish", json={"level": "published", "version": 2}) + assert ok.status_code == 200, ok.text diff --git a/backend/tests/test_auth_identity.py b/backend/tests/test_auth_identity.py index 94afbfce..00fadaf5 100644 --- a/backend/tests/test_auth_identity.py +++ b/backend/tests/test_auth_identity.py @@ -18,7 +18,7 @@ import httpx import pytest from sqlalchemy import select, update -from app.api.deps import Principal, actor_of, reset_principal, set_principal +from app.api.deps import Principal, actor_of, reset_principal, set_principal, signer_of from app.db.base import SessionLocal, utcnow from app.db.models import AuditEvent, AuthSession, Group, User from tests.fakes import auth_env @@ -55,9 +55,12 @@ async def test_actor_of_ignores_header_once_logged_in(): token = set_principal(Principal(username="alice", role="user", verified=True, mode="local")) try: assert actor_of("mallory") == "alice" + assert signer_of("请求体署名", "mallory") == "alice", "开了登录:数据导入这类请求体里自带的署名也不看" finally: reset_principal(token) assert actor_of("mallory") == "mallory" + assert signer_of(" 请求体署名 ", "mallory") == "请求体署名" and signer_of("", "mallory") == "mallory", \ + "关登录:请求体里填的优先,其次 X-Actor" # ---------------------------------------------------------------- local @@ -90,6 +93,7 @@ async def test_roles_401_403_and_forged_actor(): assert r.status_code == 401 me = await anon.get("/api/auth/me") assert me.status_code == 401 and me.json()["mode"] == "local", "登录页据此显示账号密码" + assert me.json()["can_bootstrap"] is False, "已经有管理员:登录页不给建管理员" uma = await auth_env.login("uma") assert (await uma.get("/api/workflows")).status_code == 403 @@ -105,6 +109,27 @@ async def test_roles_401_403_and_forged_actor(): assert (audit.actor, audit.verified) == ("root", True), "伪造的 X-Actor 被忽略,审计照实记" +async def test_directory_is_read_only_for_builders_and_leaves_out_the_rest(): + admin = await auth_env.turn_on_local() + await auth_env.make_user("bob", "builder", email="bob@example.com") + await auth_env.make_user("uma", "user", disabled=True) + assert (await admin.post("/api/groups", json={"name": "fin", "label": "财务"})).status_code == 201 + bob = await auth_env.login("bob") + r = await bob.get("/api/directory") + assert r.status_code == 200, "搭建者绑岗位、授权要选人:名录给搭建者" + body = r.json() + assert {u["username"]: u["disabled"] for u in body["users"]} == {"root": False, "bob": False, "uma": True}, \ + "停用的人也列出来,标明停用" + assert set(body["users"][0]) == {"username", "display_name", "disabled"}, "不含邮箱、角色这些" + assert body["groups"] == [{"name": "fin", "label": "财务"}] + assert (await bob.post("/api/directory", json={})).status_code == 405, "只读" + async with SessionLocal() as s: + await s.execute(update(User).where(User.username == "uma").values(disabled=False)) + await s.commit() + uma = await auth_env.login("uma") + assert (await uma.get("/api/directory")).status_code == 403, "使用者不给" + + async def test_csrf_header_and_origin(): admin = await auth_env.turn_on_local() r = await admin.post("/api/groups", json={"name": "a"}, headers={"X-AgentLab-Request": ""}) @@ -163,8 +188,12 @@ async def test_env_mode_wins_and_bootstrap(monkeypatch): monkeypatch.setenv("AGENTLAB_AUTH_MODE", "local") c = auth_env.client() assert (await c.get("/api/workflows")).status_code == 401 + me = await c.get("/api/auth/me") + assert me.status_code == 401 and me.json()["can_bootstrap"] is True, "新部署:登录页据此给「建第一个管理员」" r = await c.post("/api/auth/bootstrap", json={"username": "first", "password": "password-123"}) assert r.status_code == 200 and r.json()["role"] == "admin" + me = await auth_env.client().get("/api/auth/me") + assert me.status_code == 401 and me.json()["can_bootstrap"] is False, "有了管理员就不再给" assert (await c.post("/api/auth/bootstrap", json={"username": "second", "password": "password-123"}) ).status_code == 409 r = await c.put("/api/auth/settings", json={"mode": "off"}) diff --git a/backend/tests/test_capabilities_registry.py b/backend/tests/test_capabilities_registry.py index 657dd7c5..ae131b4d 100644 --- a/backend/tests/test_capabilities_registry.py +++ b/backend/tests/test_capabilities_registry.py @@ -2,7 +2,8 @@ - 同步:主键是图里的工具名;同步得出的效果只说系统知道的(可写数据源为视参数而定,绑定端点的非 GET 为写入); source_hash 没变不动;来源没了记 missing、不删行; -- 治理:version 乐观锁、写审计;只读能力才能免审批; +- 治理:version 乐观锁、写审计;只读能力才能免审批;查询能力只能是同一端点上、绑定端点的只读能力; +- 写记忆这类改平台自身状态的内置工具不记成只读(不能免审批、不能当查询能力),照旧能给 Agent 用; - is_platform_query:数据源查询,和绑定端点、声明了表格、治理后只读的 http 工具;Python 工具写了表格也不算; - build_tools(allow_write=False) 拒绝写入能力,视参数而定的照旧放行; - 正式运行:只读 + 免审批的能力直接放行(修 F7),其余照旧问人;总要确认的探索运行里也问; @@ -68,11 +69,12 @@ async def _row(name: str) -> Capability: async def test_sync_derives_effects_and_only_touches_what_changed(world): effects = {n: (await _row(n)).effect_system for n in ( - "calculator", "shell_exec", "db_query__sales_ro", "db_query__sales_rw", "db_schema__sales_rw", - "erp_orders", "erp_pay", "calc_py", "mcp:kb/search")} - assert effects == {"calculator": "read", "shell_exec": "dynamic", "db_query__sales_ro": "read", - "db_query__sales_rw": "dynamic", "db_schema__sales_rw": "read", "erp_orders": "read", - "erp_pay": "write", "calc_py": "dynamic", "mcp:kb/search": "dynamic"} + "calculator", "shell_exec", "memory_recall", "memory_write", "db_query__sales_ro", "db_query__sales_rw", + "db_schema__sales_rw", "erp_orders", "erp_pay", "calc_py", "mcp:kb/search")} + assert effects == {"calculator": "read", "shell_exec": "dynamic", "memory_recall": "read", "memory_write": "dynamic", + "db_query__sales_ro": "read", "db_query__sales_rw": "dynamic", "db_schema__sales_rw": "read", + "erp_orders": "read", "erp_pay": "write", "calc_py": "dynamic", "mcp:kb/search": "dynamic"}, \ + "写记忆改的是平台自己的状态:不是只读" pay = await _row("erp_pay") assert (pay.system, pay.endpoint_id) == ("erp", world["erp"]) async with SessionLocal() as s: @@ -107,6 +109,37 @@ async def test_governance_lock_audit_and_rules(world): assert "nope" not in snap and versions == {"mcp:kb/search": 2, "erp_pay": 1} +async def test_lookup_is_a_read_on_the_same_endpoint(world): + c = auth_env.client() + for wrong in ("memory_write", "db_query__sales_ro", "mcp:kb/search", "erp_pay", "nope"): + r = await c.put("/api/capabilities/erp_pay/governance", json={"version": 1, "lookup": wrong}) + assert r.status_code == 422 and "同一端点" in r.json()["detail"], wrong + r = await c.put("/api/capabilities/erp_pay/governance", json={"version": 1, "lookup": "erp_orders"}) + assert r.status_code == 200 and r.json()["lookup"] == "erp_orders", r.text + r = await c.put("/api/capabilities/erp_pay/governance", json={"version": 2, "lookup": None}) + assert r.status_code == 200 and r.json()["lookup"] is None, "清掉照旧可以" + async with SessionLocal() as s: + await s.execute(update(Capability).where(Capability.name == "memory_write").values(effect="read")) + await s.commit() + r = await c.put("/api/capabilities/erp_pay/governance", json={"version": 3, "lookup": "memory_write"}) + assert r.status_code == 422, "治理成只读的内置工具也不是端点上的读取,查不了这一笔写入" + ctx = ToolContext(run_id="t", node_id="n", data_versions=Versions.CURRENT) + async with SessionLocal() as s: + await s.execute(update(Capability).where(Capability.name == "memory_write").values(effect=None)) + await s.commit() + assert [t.name for t in await build_tools(["memory_write"], ctx, session=s)] == ["memory_write"], \ + "写记忆不是对外写入,Agent 照旧能用" + + +async def test_tool_library_rehearses_anything_governed_as_a_write(world): + c = auth_env.client() + r = await c.put("/api/capabilities/mcp:kb/search/governance", json={"version": 1, "effect": "write"}) + assert r.status_code == 200, r.text + out = await c.post("/api/tools/mcp:kb/search/run", json={"args": {"q": "x"}}) + assert out.status_code == 200, "演练不发送,没有副作用,不先要确认" + assert out.json()["rehearsal"] is True and out.json()["result"]["args"] == {"q": "x"} + + async def test_platform_query_is_by_identity_not_by_shape(world): c = auth_env.client() tabular = {"rows": "/items", "columns": {"单号": "/id"}} diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 85cb8b75..436cd9bc 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -1232,12 +1232,19 @@ MIX = {"id": "mix", "name": "销售额加首组名称字数", "unit": "元", "de "expression": "cell(nodes.fetch, 0, 'gmv') + len(vars.res.rows[0][0])"} -def with_regions(spec: dict, source: str) -> dict: +#: 数据整形解析第二份查询结果的两种写法。原样解析(模板整串就是 {{ nodes.regions }})的,vars.res.rows[0][0] 确定地对到 +#: 那份快照的第 0 行第 0 列,卡上记成 cell() 一样的单元格取数(engine/lineage.py);模板带了过滤器的不算原样解析,按位置 +#: 取的格卡上只记路径和值,遮罩顺着取值链认(_positional_masks)——下面几个测试守的是后一条路 +PARSED = "{{ nodes.regions }}" +FILTERED = "{{ nodes.regions | trim }}" + + +def with_regions(spec: dict, source: str, template: str = FILTERED) -> dict: """取数之后另查一次按地区分组的结果(第二份快照),数据整形把它解析进 vars.res:口径卡按位置取它的格。""" nodes = list(spec["nodes"]) at = next(i for i, n in enumerate(nodes) if n["id"] == "fetch") + 1 nodes[at:at] = [node("regions", "tool", tool=f"db_query__{source}", args={"sql": BY_REGION}), - node("parse", "transform", mode="json", template="{{ nodes.regions }}", assign_to="res")] + node("parse", "transform", mode="json", template=template, assign_to="res")] return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} @@ -1277,6 +1284,28 @@ async def test_an_unmasked_cell_read_by_position_from_another_snapshot_is_blind_ assert "len('华东')" in task +async def test_a_cell_read_through_a_plain_parse_is_judged_like_cell(monkeypatch, masked_shop, shop): + """数据整形只是原样解析第二次查询的结果:vars.res.rows[0][0] 卡上记成单元格取数(落在第二份快照的第 0 行 region 列), + 和 cell(nodes.regions, 0, 'region') 一个说法——遮罩列上的照实说是遮罩、不盲算、取值不进任何产出;没遮罩时它是查询来 + 的文本,和 cell() 取的文本格一样不能单独复算,照实说不是数值。""" + for (name, _), reason in ((masked_shop, "输入 vars.res.rows[0][0] 取自设置了遮罩的列,不能用于复算"), + (shop, "输入 vars.res.rows[0][0] 不是数值")): + blind = Blind(monkeypatch, name, {"销售额加首组名称字数": (RIGHT, "gmv")}) + run = await start(with_regions(graph(name, RIGHT, metrics=[MIX], dual={"definitions": {}}), name, PARSED)) + assert run.status == "succeeded", run.error + evs = await events(run.id) + [card] = [e.data for e in evs if e.type == "node.finished" and e.node_id == "card"] + item = next(i for i in artifact_store.load(card["artifact"])["metrics"][0]["inputs"] + if i["path"] == "vars.res.rows[0][0]") + assert (item["via"], item["through"], item["node_id"], item["locator"]) == ( + "tool_cell", "parse", "regions", {"row": 0, "column": "region"}), item + out = dual_output(run, evs) + [row] = out["metrics"] + assert row["status"] == "unchecked" and row["reason"] == reason, row + assert blind.tasks == [] and blind.runs == 0, blind.tasks + assert "华东" not in json.dumps(out, ensure_ascii=False) + + #: 按位置取的那一格:数据整形解析好的第二份查询结果(首组地区名),快照 id 是编的 POSITIONAL = {"path": "vars.res.rows[0][0]", "value": "华东", "node_id": "parse", "via": "transform", "status": "ok"} LOST = "cd" * 32 diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 22560cd6..3186d9cd 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -7,7 +7,9 @@ - 带幂等头;service_on_behalf 带 EdDSA 身份断言,公钥在 /api/auth/jwks; - 超时 → 结果未知;连接没建立 → 确定的失败; - 声明了表格的读取落成查询快照同一个形状的端点快照,台账照 query 处理; -- 运行里的写入不从自定义工具直接发; +- 运行里的写入不从自定义工具直接发;工具页试运行、工具库执行写入能力一律演练(不发送,交回将要发送的请求); +- 试运行、测试连接按 HTTP 状态照实说:2xx 才算成功,能连上但 404 / 401 和连不上分开说; +- 认证头名字为空、不合法的存不进去(422 说人话),存量的坏头发请求时照实说是哪一项,不是 LocalProtocolError; - 自定义工具请求头、MCP env 和请求头:加密存、掩码回、启动时存量明文加密一遍(幂等);MCP http 地址过 SSRF 检查或绑定端点。 """ from __future__ import annotations @@ -39,11 +41,15 @@ class Recorder: def __init__(self) -> None: self.requests: list[httpx.Request] = [] self.raise_: Exception | None = None + #: 按路径改回的状态码(测 4xx、404 时用) + self.status: dict[str, int] = {} def __call__(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) if self.raise_ is not None: raise self.raise_ + if (code := self.status.get(request.url.path)) is not None: + return httpx.Response(code, json={"error": "nope"}) if request.url.path.startswith("/orders"): return httpx.Response(200, json={"items": [{"id": "A1", "amount": 12.5, "phone": "13900000001"}, {"id": "A2", "amount": 7, "phone": "13900000002"}]}) @@ -277,3 +283,124 @@ async def test_evidence_panel_masks_an_endpoint_snapshot_by_column(): "rows": [["A1", "13800000000", 12]], "mask_columns": ["电话"]} sets = await _mask_sets(snap, "erp", _Masks()) assert sets.hidden == {"电话"} and sets.sure == {"电话"} and not sets.gone + + +async def test_header_names_are_checked_when_saved_and_when_sent(erp): + c, endpoint = erp["client"], erp["endpoint"] + for headers, words in (({"": ""}, "名称不能为空"), ({"X Token": "1"}, "不合法"), ({"X-Token": "第一\n第二"}, "不能换行")): + r = await c.patch(f"/api/endpoints/{endpoint['id']}", json={"version": 1, "environments": { + "prod": {"base_url": BASE, "headers": headers, "allow_intranet": True}}}) + assert r.status_code == 422 and "生产环境的认证头有误" in r.json()["detail"] and words in r.json()["detail"], r.text + r = await c.post("/api/endpoints", json={"name": "erp2", "idempotency_header": "Idem Key", "environments": { + "prod": {"base_url": BASE}}}) + assert r.status_code == 422 and "幂等请求头" in r.json()["detail"] + # 加校验之前存进去的 {"": ""}:发请求、测连接都照实说是认证头的事,请求没有发出,也不当成结果未知 + async with SessionLocal() as s: + row = await s.get(Endpoint, endpoint["id"]) + row.environments = {"prod": {**row.environments["prod"], "headers": {"": ""}}} + await s.commit() + before = len(erp["recorder"].requests) + cap = await s.get(Capability, "erp_pay") + result = await ep.endpoint_request(s, cap, {"amount": 1}, environment="prod", idem_key="k:9") + assert not result.unknown and "认证头有误" in result.error and "名称不能为空" in result.error + probe = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json() + assert probe["ok"] is False and probe["reachable"] is False and "认证头有误" in probe["message"] + assert len(erp["recorder"].requests) == before, "坏头的请求一个都没发" + + +async def test_probe_tells_reachable_apart_from_ok(erp): + c, endpoint, recorder = erp["client"], erp["endpoint"], erp["recorder"] + ok = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json() + assert ok["ok"] is True and ok["status"] == 201 and "连接成功" in ok["message"] + recorder.status["/"] = 404 + missing = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json() + assert (missing["ok"], missing["reachable"], missing["status"]) == (False, True, 404) + assert "能连上" in missing["message"] and "404" in missing["message"] and "成功" not in missing["message"], \ + "能连上但路径不存在,不能说成连接成功" + recorder.status["/"] = 401 + denied = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json() + assert denied["ok"] is False and "认证" in denied["message"] + recorder.raise_ = httpx.ConnectError("refused") + down = (await c.post(f"/api/endpoints/{endpoint['id']}/test?env=prod")).json() + assert (down["ok"], down["reachable"]) == (False, False) and "无法连接" in down["message"] + + +async def test_trying_a_write_capability_rehearses_instead_of_sending(erp): + c, endpoint, recorder = erp["client"], erp["endpoint"], erp["recorder"] + tool = next(t for t in (await c.get("/api/custom-tools")).json() if t["name"] == "erp_pay") + before = len(recorder.requests) + trial = (await c.post(f"/api/custom-tools/{tool['id']}/test", json={"args": {"amount": 3}})).json() + assert trial["ok"] is True and trial["rehearsal"] is True, trial + req = trial["result"]["request"] + assert (req["method"], req["url"], req["body"]) == ("POST", f"{BASE}/payments", {"amount": 3}) + assert trial["result"]["environment"] == "prod", "端点没有测试环境:写正式运行要发往的生产环境" + assert req["headers"] == ["Authorization", "Idempotency-Key"], "只交回请求头的名字" + assert "prod-secret-token" not in json.dumps(trial, ensure_ascii=False) + draft = (await c.post("/api/custom-tools/test", json={**tool, "args": {"amount": 3}})).json() + assert draft["rehearsal"] is True, "没保存的草稿同样只演练" + # 工具库执行:同样演练,没有副作用也就不先要确认 + run = await c.post("/api/tools/erp_pay/run", json={"args": {"amount": 3}}) + assert run.status_code == 200 and run.json()["rehearsal"] is True and run.json()["result"]["request"]["method"] == "POST" + assert len(recorder.requests) == before, "写入能力在工具页一个请求都没发" + # 读取照旧真的发,对方回 4xx 时照实说失败和状态码 + recorder.status["/orders"] = 400 + read = (await c.post("/api/tools/erp_orders/run?confirm=true", json={"args": {"q": "x"}})).json() + assert read["ok"] is False and "HTTP 400" in read["error"] and "nope" in read["detail"] + orders = next(t for t in (await c.get("/api/custom-tools")).json() if t["name"] == "erp_orders") + tried = (await c.post(f"/api/custom-tools/{orders['id']}/test", json={"args": {"q": "x"}})).json() + assert tried["ok"] is False and "HTTP 400" in tried["error"] and not tried.get("rehearsal") + assert len(recorder.requests) == before + 2 + + +async def test_a_draft_trial_sends_the_draft_not_the_saved_tool(erp): + """工具页试运行的草稿按草稿的配置拼请求,读写按已保存的治理和草稿的方法里更严的一个认。以前按名字找已保存的那一版: + 把已保存的 GET /orders 改成草稿 POST /payments 去试,真发出去的是 GET /orders,结果写「成功」。""" + c, recorder = erp["client"], erp["recorder"] + orders = next(t for t in (await c.get("/api/custom-tools")).json() if t["name"] == "erp_orders") + before = len(recorder.requests) + post = {**orders, "config": {**orders["config"], "method": "POST", "path": "/payments", "query": {}}} + trial = (await c.post("/api/custom-tools/test", json={**post, "args": {"q": "x"}})).json() + assert trial["ok"] is True and trial["rehearsal"] is True, trial + req = trial["result"]["request"] + assert (req["method"], req["url"]) == ("POST", f"{BASE}/payments"), req + assert len(recorder.requests) == before, "草稿改成了写入:已保存的那一版是只读也只演练,一个请求都不发" + + # 草稿只改了路径和查询参数(还是读取):真的发,发的是草稿的 + moved = {**orders, "config": {**orders["config"], "path": "/orders/open", "query": {"status": "{{ q }}"}}} + read = (await c.post("/api/custom-tools/test", json={**moved, "args": {"q": "x"}})).json() + assert read["ok"] is True and not read.get("rehearsal"), read + [sent] = recorder.requests[before:] + assert (sent.method, sent.url.path, dict(sent.url.params)) == ("GET", "/orders/open", {"status": "x"}) + + # 还没保存过的绑定端点的读取:同样按草稿发(以前按名字找不到已保存的工具,报没有绑定端点) + fresh = (await c.post("/api/custom-tools/test", json={**moved, "name": "erp_orders_new", "args": {"q": "y"}})).json() + assert fresh["ok"] is True, fresh + assert recorder.requests[-1].url.path == "/orders/open" and len(recorder.requests) == before + 2 + + # 保存过的那一行和登记对得上:照治理的走(管理员把 POST 的查询接口治理成只读,是他的决定) + async with SessionLocal() as s: + from app.tools.custom import _effect_of + + pay_cap = await s.get(Capability, "erp_pay") + pay = (await s.execute(select(CustomTool).where(CustomTool.name == "erp_pay"))).scalar_one() + pay_cap.effect = "read" + assert _effect_of(pay, pay_cap) == "read" + draft = CustomTool(name="erp_pay", kind="http", config={**pay.config, "method": "GET"}) + assert _effect_of(draft, pay_cap) == "read" + pay_cap.effect = None + assert _effect_of(draft, pay_cap) == "write", "草稿改成了 GET:已保存的那一版是写入,取更严的" + await s.rollback() + + +async def test_custom_tool_header_names_are_checked(): + c = auth_env.client() + r = await c.post("/api/custom-tools", json={"name": "bad_headers", "kind": "http", "parameters": {}, + "config": {"url": "https://crm.example.com", "headers": {"": "x"}}}) + assert r.status_code == 422 and "名称不能为空" in r.json()["detail"] + r = await c.post("/api/custom-tools", json={"name": "bad_headers", "kind": "http", "parameters": {}, + "config": {"url": "https://crm.example.com", + "headers": {"Authorization": "Bearer {{ 令牌 }}"}}}) + assert r.status_code == 201, "值里的模板不查" + async with SessionLocal() as s: + await s.execute(delete(CustomTool).where(CustomTool.name == "bad_headers")) + await s.commit() diff --git a/backend/tests/test_metric_origin.py b/backend/tests/test_metric_origin.py new file mode 100644 index 00000000..924c4ad8 --- /dev/null +++ b/backend/tests/test_metric_origin.py @@ -0,0 +1,251 @@ +"""口径卡的输入追没追到原始数据(engine/lineage.py):一处认定,运行时、证据面板、应用检查三处用。 + +- 「有出处」说的是数由口径卡确定性算出、复算一致;「追到原始数据」说的是输入一路对到查询快照里的格(或运行输入)。 + 证据接口的指标步骤另给 untraced:追不到的输入停在哪个节点(名字、类型),面板据此照实说,不能只写「有出处」 +- 数据整形只是原样解析调用工具节点的结果(模板整串就是 {{ nodes.fetch }})时,vars.q.rows[i][j] 穿过它接上快照; + 表达式整形、模板带过滤器、包了一层的都不算 +- 受管级应用里绑定的指标,输入追不到原始数据的,应用检查给提示(app.metric_untraced,警告);已发布级不提示 + +库和数字都是编的。 +""" +from __future__ import annotations + +import asyncio +import sqlite3 +import uuid +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.apps.lint import _Issues, _lint_components +from app.apps.tree import node_map +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run +from app.engine.lineage import RowCell, Stop, parsed_tool, row_cell, static_stops, traced, untraced +from app.engine.runner import run_manager +from app.main import app + +SQL = "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders" + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +# -------------------------------------------------------------------------- +# 纯函数 +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path, want", [ + ("vars.q.rows[0][0]", RowCell("vars", "q", 0, 0)), + ("nodes.parse.rows[2]['gmv']", RowCell("nodes", "parse", 2, "gmv")), + ("vars.q.rows[-1][0]", None), # 负数下标:数的是取回的最后一行,对不到确定的行号 + ("vars.q.rows[vars.i][0]", None), # 下标是变量 + ("vars.q.rows[0]", None), # 整行 + ("vars.w.res.rows[0][0]", None), # 多包了一层 + ("cell(nodes.fetch, 0, 'gmv')", None), + ("input.week", None), +]) +def test_row_cell(path, want): + assert row_cell(path) == want + + +@pytest.mark.parametrize("config, want", [ + ({"mode": "json", "template": "{{ nodes.fetch }}"}, "fetch"), + ({"mode": "json", "template": " {{nodes.fetch}}\n"}, "fetch"), + ({"mode": "json", "template": "{{ nodes.fetch | trim }}"}, None), # 带过滤器:不算原样 + ({"mode": "json", "template": '{"res": {{ nodes.fetch }}}'}, None), # 包了一层 + ({"mode": "json", "template": "{{ nodes.note }}"}, None), # 不是调用工具节点 + ({"mode": "template", "template": "{{ nodes.fetch }}"}, None), + ({"mode": "expression", "expression": "nodes.fetch"}, None), +]) +def test_parsed_tool(config, want): + types = {"fetch": "tool", "note": "llm"} + assert parsed_tool("transform", config, types.get) == want + assert parsed_tool("code", config, types.get) is None + + +def test_traced_and_untraced(): + cell = {"path": "cell(nodes.fetch, 0, 'gmv')", "value": 1, "via": "tool_cell", "artifact": "a", "status": "verified"} + mismatch = {**cell, "status": "mismatch"} # 对得到快照,只是值不一样:另有说法,出处是追到了的 + lost = {**cell, "status": "unresolved", "artifact": None} # 找不到快照 + field = {"path": "vars.kpi.gmv", "value": 2, "via": "agent_field", "artifact": "a", "status": "verified"} + run_input = {"path": "input.target", "value": 3, "via": "input", "status": "ok"} + shaped = {"path": "vars.t.x", "value": 4, "node_id": "mid", "via": "transform", "status": "ok"} + empty = {"path": "vars.t.y", "value": None, "node_id": "mid", "via": "transform", "status": "missing"} + assert [traced(i) for i in (cell, mismatch, lost, field, run_input, shaped)] == [True, True, False, True, True, False] + # 没有值的不算在追不到里:缺输入另有说法 + assert untraced([cell, lost, field, run_input, shaped, empty, "坏数据"]) == [lost, shaped] + + +def graph_nodes(): + return node_map(chain( + node("start", "input", "开始", fields=[{"name": "target"}]), + node("fetch", "tool", "取数", tool="db_query__shop", args={"sql": SQL}), + node("parse", "transform", "解析", mode="json", template="{{ nodes.fetch }}", assign_to="q"), + node("raw_n", "transform", "解析(过滤器)", mode="json", template="{{ nodes.fetch | trim }}", assign_to="raw"), + node("calc", "transform", "加工", mode="expression", expression="{'x': 1}", assign_to="t"), + node("bot", "agent", "取数助手", output_schema={"type": "object"}, cite_fields=True, assign_to="kpi"), + node("loose", "agent", "没核对的助手", output_schema={"type": "object"}, assign_to="guess"), + node("box", "code", "沙箱", evidence_role="source", assign_to="c"), + node("card", "metrics", "口径卡", metrics=[]), + )) + + +@pytest.mark.parametrize("expression, stops", [ + ("cell(nodes.fetch, 0, 'gmv')", []), + ("cell(vars.q, 0, 'gmv') + vars.q.rows[0][1]", []), # 原样解析的结果:按格、按位置都追到快照 + ("nodes.parse.rows[0]['gmv']", []), + ("input.target + vars.target", []), # 运行输入 + ("vars.kpi.gmv + nodes.bot.data.gmv", []), # 开了按出处核对字段的 Agent + ("vars.raw.rows[0][0]", [Stop("vars.raw.rows[0][0]", "raw_n")]), + ("cell(vars.t, 0, 0)", [Stop("cell(vars.t, 0, 0)", "calc")]), + ("vars.t.x * 2", [Stop("vars.t.x", "calc")]), + ("vars.guess.gmv", [Stop("vars.guess.gmv", "loose")]), + ("vars.c.gmv", [Stop("vars.c.gmv", "box")]), # 沙箱取数核对不到快照 + ("vars.nowhere + 1", [Stop("vars.nowhere", None)]), + ("vars.t.x +", []), # 写错了的表达式由画布的校验报,这里不报 +]) +def test_static_stops(expression, stops): + assert static_stops(graph_nodes(), expression) == stops + + +# -------------------------------------------------------------------------- +# 证据接口:指标步骤说追没追到原始数据 +# -------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture +async def shop(tmp_path): + path = tmp_path / "shop.db" + with sqlite3.connect(path) as db: + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL);") + db.executemany("INSERT INTO orders VALUES (?, ?)", [(1, 100.5), (2, 200.0), (3, 300.0)]) + name = f"origin_{uuid.uuid4().hex[:6]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + await session.commit() + source_id = row.id + yield name + await engines.invalidate(source_id) + + +async def finish(graph: dict) -> Run: + run = await run_manager.start(graph=graph, input_payload={"target": 5}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + return row + raise AssertionError(f"运行 {run.id} 没跑完:{row.status}") + + +async def test_the_metric_step_says_how_far_the_inputs_were_traced(client, shop): + graph = chain( + node("start", "input", "开始", fields=[{"name": "target"}]), + node("fetch", "tool", "取数", tool=f"db_query__{shop}", args={"sql": SQL}), + node("parse", "transform", "解析查询结果", mode="json", template="{{ nodes.fetch }}", assign_to="q"), + node("raw_n", "transform", "整理查询结果", mode="json", template="{{ nodes.fetch | trim }}", assign_to="raw"), + node("card", "metrics", "口径卡", caliber="口径", metrics=[ + {"id": "gmv", "name": "销售额", "decimals": 2, "expression": "vars.q.rows[0][0]"}, + {"id": "raw", "name": "销售额(过滤器)", "decimals": 2, "expression": "vars.raw.rows[0][0] + input.target"}, + {"id": "one", "name": "常量", "expression": "1"}, + ]), + node("out", "output", "成果", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]), + ) + row = await finish(graph) + assert row.status == "succeeded", row.error + + gmv = (await client.get(f"/api/runs/{row.id}/evidence/metrics/card/gmv")).json()["chain"] + step = gmv[0] + assert step["step"] == "metric" and step["recompute_ok"] is True and step["untraced"] == [] + [inp] = [s for s in gmv if s["step"] == "input"] + assert (inp["via"], inp["through"], inp["through_label"], inp["status"]) == ("tool_cell", "parse", "解析查询结果", + "verified") + # 穿过整形节点接上了查询快照:链的最后一步是那次查询,高亮那一格 + assert gmv[-1]["step"] == "query" and gmv[-1]["highlight"]["cells"] == [[0, "gmv"]] + + raw = (await client.get(f"/api/runs/{row.id}/evidence/metrics/card/raw")).json()["chain"] + # 复算照样一致(有出处),可输入停在了整形节点:运行输入那一项追到了,不列 + assert raw[0]["recompute_ok"] is True + assert raw[0]["untraced"] == [{"path": "vars.raw.rows[0][0]", "node_id": "raw_n", "label": "整理查询结果", + "type": "transform"}] + assert not [s for s in raw if s["step"] == "query"] + + # 没有输入的常量:没有可追的,不给这个键 + one = (await client.get(f"/api/runs/{row.id}/evidence/metrics/card/one")).json()["chain"] + assert "untraced" not in one[0] + + +# -------------------------------------------------------------------------- +# 应用检查:受管级应用里绑定的指标 +# -------------------------------------------------------------------------- + + +def app_spec() -> dict: + return { + "format": "agentlab.app/1", "name": "看板", "pins": [{"alias": "main", "workflow_id": "wf", "version": 1, + "graph_hash": "h", "level": "governed", "use": "view"}], + "views": {"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}}, + "pages": [{"id": "home", "title": "首页", "body": [ + {"id": "m_gmv", "type": "metric", "props": {"label": "销售额"}, "bind": {"view": "latest", "metric": "card.gmv"}}, + {"id": "m_raw", "type": "metric", "props": {"label": "整理过的"}, "bind": {"view": "latest", "metric": "card.raw"}}, + {"id": "s_raw", "type": "series", "props": {"label": "趋势"}, "bind": {"view": "latest", "series": "card.raw"}}, + ]}], + } + + +def pinned_graph() -> dict: + return chain( + node("fetch", "tool", "取数", tool="db_query__shop", args={"sql": SQL}), + node("parse", "transform", "解析查询结果", mode="json", template="{{ nodes.fetch }}", assign_to="q"), + node("calc", "transform", "加工", mode="expression", expression="{'gmv': 1}", assign_to="t"), + node("card", "metrics", "口径卡", metrics=[ + {"id": "gmv", "name": "销售额", "expression": "vars.q.rows[0][0] + cell(nodes.fetch, 0, 'orders')"}, + {"id": "raw", "name": "加工后的销售额", "expression": "vars.t.gmv * 2"}, + ]), + ) + + +def lint(level: str) -> list[dict]: + spec = app_spec() + pins = {"main": SimpleNamespace(row=object(), graph=pinned_graph(), level=level, label="周报 v1")} + out = _Issues() + _lint_components(spec, pins, spec["views"], level, out) + return [i for i in out.items if i["code"] == "app.metric_untraced"] + + +def test_governed_apps_flag_metrics_whose_inputs_stop_halfway(): + found = lint("governed") + assert [(i["path"], i["level"], i["node_id"]) for i in found] == [ + ("pages[0].body[1].bind", "warning", "card"), ("pages[0].body[2].bind", "warning", "card")] + message = found[0]["message"] + assert "「加工后的销售额」" in message and "vars.t.gmv 停在「加工」(数据整形)" in message and "cell()" in message + # 原样解析的按位置取格、cell() 取的格都追到了快照:销售额不提示 + assert not [i for i in found if i["path"].startswith("pages[0].body[0]")] + + +def test_published_apps_are_not_flagged(): + assert lint("published") == [] diff --git a/backend/tests/test_metrics_issuance.py b/backend/tests/test_metrics_issuance.py new file mode 100644 index 00000000..dc0516c4 --- /dev/null +++ b/backend/tests/test_metrics_issuance.py @@ -0,0 +1,170 @@ +"""指标出具(engine/issuance.metrics_only,用户 2026-10-15 拍板):纯指标看板——契约只写「指标来自」,没有「报告来自」 +也没有「叙述」——出具的就是指标本身。三档的语义不变,核对的对象从叙述换成指标: + +- 必需指标齐、每个指标的输入追到原始数据(查询快照的格、运行输入)、成果字段里给人看的数都回指得上:完整出具 +- 指标的输入停在别的节点(表达式整形、带过滤器的解析……):降档,缺口写明哪个指标停在哪个节点 +- 成果字段里有回指不上的数:降档(strict 下不予出具);必需指标缺了:不予出具 +- 受管门禁:只写「指标来自」不再报「既没有报告来自、也没有叙述」;每个指标都要静态地追到原始数据,追不到的挡住发布 + +库和数字都是编的。 +""" +from __future__ import annotations + +import asyncio +import sqlite3 +import uuid + +import pytest + +from app.data.engine import engines +from app.db.base import SessionLocal +from app.db.models import DataSource, Run +from app.engine.governance import lint_for_publish +from app.engine.issuance import metrics_only +from app.engine.runner import run_manager +from app.engine.schema import GraphSpec + +SQL = "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders" + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +def board(source: str, *, gmv: str = "vars.q.rows[0][0]", fields=None, required=("gmv", "orders"), strict=False): + return chain( + node("start", "input", "开始", fields=[{"name": "week", "default": "2026-W40"}]), + node("fetch", "tool", "取数", tool=f"db_query__{source}", args={"sql": SQL}), + node("parse", "transform", "解析查询结果", mode="json", template="{{ nodes.fetch }}", assign_to="q"), + node("raw_n", "transform", "整理查询结果", mode="json", template="{{ nodes.fetch | trim }}", assign_to="raw"), + node("card", "metrics", "周报口径卡", caliber="周报口径", caliber_version="v1", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "expression": gmv}, + {"id": "orders", "name": "订单数", "expression": "cell(nodes.fetch, 0, 'orders')"}, + ]), + node("out", "output", "成果", fields=fields if fields is not None else [ + {"name": "本期", "value": "{{ input.week }}"}, {"name": "指标", "value": "{{ nodes.card.text }}"}], + contract={"metrics_from": ["card"], "required": list(required), "strict": strict}), + ) + + +@pytest.mark.parametrize("contract, want", [ + ({"metrics_from": ["card"], "required": ["gmv"]}, True), + ({"metrics_from": "card"}, True), + ({"metrics_from": ["card"], "narrative": "销售额 {{ nodes.card.text }}"}, False), + ({"metrics_from": ["card"], "report_from": "write"}, False), + ({"required": ["gmv"]}, False), + ("card", False), +]) +def test_metrics_only(contract, want): + assert metrics_only(contract) is want + + +# -------------------------------------------------------------------------- +# 运行时 +# -------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def shop(tmp_path): + path = tmp_path / "shop.db" + with sqlite3.connect(path) as db: + db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL);") + db.executemany("INSERT INTO orders VALUES (?, ?)", [(1, 100.5), (2, 200.0), (3, 300.0)]) + name = f"mx_{uuid.uuid4().hex[:6]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True) + session.add(row) + await session.commit() + source_id = row.id + yield name + await engines.invalidate(source_id) + + +async def issuance_of(graph: dict) -> dict: + run = await run_manager.start(graph=graph, input_payload={"week": "2026-W40"}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + assert row.status == "succeeded", row.error + return (row.output or {})["_issuance"] + raise AssertionError(f"运行 {run.id} 没跑完:{row.status}") + + +async def test_a_board_whose_metrics_reach_the_data_is_formal(shop): + issuance = await issuance_of(board(shop)) + assert (issuance["tier"], issuance["mode"]) == ("formal", "metrics") + # 不再记「出具契约未设置『叙述』」:没有叙述不是缺口,核的是指标本身 + assert issuance["gaps"] == [] and issuance["unmatched_numbers"] == [] + # 成果字段里的数(口径卡的文字)都回指到了指标 + assert issuance["matched_numbers"] >= 2 + + +async def test_an_input_that_stops_short_of_the_data_degrades(shop): + issuance = await issuance_of(board(shop, gmv="vars.raw.rows[0][0]")) + assert issuance["tier"] == "degraded" + [gap] = issuance["gaps"] + assert "销售额" in gap and "没追到原始数据" in gap and "整理查询结果" in gap + + +async def test_numbers_in_the_fields_must_trace_and_required_metrics_must_be_there(shop): + loose = await issuance_of(board(shop, fields=[{"name": "备注", "value": "本周目标 12345 元"}])) + assert loose["tier"] == "degraded" and [u["token"] for u in loose["unmatched_numbers"]] == ["12345"] + strict = await issuance_of(board(shop, fields=[{"name": "备注", "value": "本周目标 12345 元"}], strict=True)) + assert strict["tier"] == "withheld" + missing = await issuance_of(board(shop, required=("gmv", "orders", "refund_rate"))) + assert (missing["tier"], missing["missing_required"]) == ("withheld", ["refund_rate"]) + + +# -------------------------------------------------------------------------- +# 受管门禁 +# -------------------------------------------------------------------------- + + +def gate(graph: dict, level: str = "governed") -> dict[str, str]: + return {i.code: i.level for i in lint_for_publish(GraphSpec.model_validate(graph), level=level).issues if i.code} + + +def test_a_metrics_only_contract_is_not_missing_its_checks(): + codes = gate(board("shop")) + assert "contract.report_from_missing" not in codes and "governed.report_from_required" not in codes + assert "governed.metric_untraced" not in codes + + +def test_governed_metrics_must_reach_the_data_statically(): + codes = gate(board("shop", gmv="vars.raw.rows[0][0]")) + assert codes.get("governed.metric_untraced") == "error" + # 已发布级不按这一条挡:运行时照样降档、照实写缺口 + assert "governed.metric_untraced" not in gate(board("shop", gmv="vars.raw.rows[0][0]"), level="published") + + +def test_a_contract_that_checks_nothing_is_still_refused(): + graph = board("shop") + graph["nodes"][-1]["data"]["config"]["contract"] = {"required": ["gmv"]} + assert gate(graph).get("contract.report_from_missing") == "error" + + +def test_a_board_with_a_report_or_model_text_is_not_metrics_only(): + """上游有报告撰写节点、或成果字段里有模型写的话:那是叙述,不能因为契约少写了「报告来自」就改成只核指标。""" + with_report = board("shop") + with_report["nodes"].insert(-1, node("write", "report", "写周报", metrics_from=["card"])) + with_report["edges"] = [*with_report["edges"][:-1], {"source": "card", "target": "write"}, + {"source": "write", "target": "out"}] + assert gate(with_report).get("contract.report_from_missing") == "error" + with_model = board("shop", fields=[{"name": "点评", "value": "{{ nodes.say.text }}"}]) + with_model["nodes"].insert(-1, node("say", "llm", "点评", prompt="点评一下")) + with_model["edges"] = [*with_model["edges"][:-1], {"source": "card", "target": "say"}, + {"source": "say", "target": "out"}] + assert gate(with_model, level="published").get("contract.report_from_missing") == "warning" diff --git a/backend/tests/test_positional_masks_elsewhere.py b/backend/tests/test_positional_masks_elsewhere.py index 8c91aa31..633dfe05 100644 --- a/backend/tests/test_positional_masks_elsewhere.py +++ b/backend/tests/test_positional_masks_elsewhere.py @@ -2,7 +2,9 @@ recompute 的还原(_positional),用 recompute 的地方都跟着变,这里把变化钉住。 - 双盲复算的预检(dual_check._plan_metric 什么都不改先重算一遍):以前这种指标照常盲算,遮着的那一格还会作为字面量 - 写进给盲算模型的计算式;现在预检判它用到了遮罩列,按无法复算记,原因不说「推演」(这里不是推演) + 写进给盲算模型的计算式;现在预检判它用到了遮罩列,按无法复算记,原因不说「推演」(这里不是推演)。数据整形只是原样 + 解析查询结果时(模板整串就是 {{ nodes.fetch }}),这一格卡上直接记成单元格取数(engine/lineage.py),和 cell() 取的 + 遮罩格同一个说法;模板带了过滤器的不算原样解析,走顺着取值链认遮罩的那条路——两种写法都守 - 运行对比(engine/compare.py):这种输入两边的值都不给,标 masked,和 cell() 取的遮罩格一样 库和数字都是编的:号码写成带横线的文本,区域名是「东区」这类。 @@ -11,6 +13,8 @@ from __future__ import annotations import json +import pytest + from app.engine import compare from app.engine.runner import run_manager from tests.fixtures.compare_runs import BASE_ROWS, NEXT_ROWS, metric, scalar_in, week @@ -26,12 +30,17 @@ from tests.test_evidence_metric_masks import ( # noqa: F401 - 夹具按名字 ) -def dual_graph(): +#: 数据整形的两种写法:原样解析、模板带过滤器(不算原样解析) +PARSED = "{{ nodes.fetch }}" +FILTERED = "{{ nodes.fetch | trim }}" + + +def dual_graph(template: str = PARSED): """取数 → 数据整形(vars.res)→ 口径卡 → 双盲复算(只复算 mix)→ 成果。""" nodes = [ node("start", "input"), node("fetch", "tool", tool="db_query__shop", args={"sql": SQL}), - node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="res"), + node("parse", "transform", mode="json", template=template, assign_to="res"), node("card", "metrics", caliber="遮罩口径", caliber_version="v1", metrics=METRICS), node("dual", "dual_check", metrics=["mix"]), node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.card.text }}"}]), @@ -52,9 +61,11 @@ async def dual_output(run_id: str) -> dict: return artifact_store.load(finished.data["artifact"]) -async def test_the_dual_check_does_not_blind_compute_a_metric_reading_a_masked_cell_by_position(shop): +@pytest.mark.parametrize("template", [PARSED, FILTERED]) +async def test_the_dual_check_does_not_blind_compute_a_metric_reading_a_masked_cell_by_position(shop, template): """mix = round(cell(nodes.fetch, 1, 'amount') + len(vars.res.rows[1][2]), 1):cell() 那一格不在遮罩列上,按位置取的 - 号码在。认出它取自遮罩列,按无法复算记缺口,原因点名是哪个输入、不说「推演」;没遮罩时预检照常通过、指标进入盲算。""" + 号码在。认出它取自遮罩列,按无法复算记缺口,原因点名是哪个输入、不说「推演」。没遮罩时:原样解析的那一格是查询来的 + 文本,和 cell() 取的文本格一样不能单独复算,照实说不是数值;模板带了过滤器的,预检照常通过、指标进入盲算。""" import asyncio from app.db.base import SessionLocal @@ -62,7 +73,7 @@ async def test_the_dual_check_does_not_blind_compute_a_metric_reading_a_masked_c async def run(options): await set_source(shop, options) - run_id = (await run_manager.start(graph=dual_graph(), input_payload={})).id + run_id = (await run_manager.start(graph=dual_graph(template), input_payload={})).id for _ in range(400): await asyncio.sleep(0.05) async with SessionLocal() as session: @@ -81,10 +92,15 @@ async def test_the_dual_check_does_not_blind_compute_a_metric_reading_a_masked_c assert not [p for p in PHONES if p in json.dumps(masked, ensure_ascii=False)] assert any("遮罩" in gap for gap in masked["issuance_gaps"]) - # 没遮罩:预检通过,原因不再是遮罩(盲算模型没配,按盲算没交回数记,但已经走到了盲算这一步) plain = await run({}) [mix] = plain["metrics"] - assert "遮罩" not in (mix.get("reason") or "") and "无法按原表达式重算" not in (mix.get("reason") or "") + if template == PARSED: + # 原样解析:号码那一格是查询快照里的文本,和 cell() 取的文本格同一个说法 + assert mix["status"] == "unchecked" and mix["reason"] == "输入 vars.res.rows[1][2] 不是数值", mix + else: + # 没遮罩:预检通过,原因不再是遮罩(盲算模型没配,按盲算没交回数记,但已经走到了盲算这一步) + assert "遮罩" not in (mix.get("reason") or "") and "无法按原表达式重算" not in (mix.get("reason") or "") + assert "不是数值" not in (mix.get("reason") or ""), mix def region_len(fetch, bot, rows, bot_rows): diff --git a/backend/tests/test_process_actions.py b/backend/tests/test_process_actions.py index d4706f0f..f11ae634 100644 --- a/backend/tests/test_process_actions.py +++ b/backend/tests/test_process_actions.py @@ -316,3 +316,178 @@ def test_masking_by_json_pointer(): out = actions.masked(doc, ["/a/b", "/a/c/1", "/d~0e", "/f~1g", "/missing/x"]) assert out == {"a": {"b": "••••", "c": [1, "••••"]}, "d~e": "••••", "f/g": "••••"} assert doc["a"]["b"] == "secret" + + +# -------------------------------------------------------------------------- +# 参数按能力的参数定义收好类型(typed_args):哈希、预览、发出去的都是收好的那一份 +# -------------------------------------------------------------------------- + +PARAMS = {"type": "object", "required": ["amount"], "properties": { + "amount": {"type": "number"}, "count": {"type": "integer"}, "urgent": {"type": "boolean"}, + "card": {"type": "string"}, "memo": {"type": "number"}, + "payee": {"type": "object", "properties": {"id": {"type": "integer"}, "vip": {"type": "boolean"}}}, + "splits": {"type": "array", "items": {"type": "number"}}}} + + +def _cap(schema=PARAMS): + from types import SimpleNamespace + + return SimpleNamespace(name=PAY, title="付款", params_schema=schema) + + +def test_typed_args_follow_the_declared_types(): + out = actions.typed_args(_cap(), { + "amount": "1280.5", "count": "3", "urgent": "true", "card": "6222", "extra": "原样", + "payee": '{"id": "42", "vip": "false", "name": "钱七"}', "splits": ["1", 2.5], "memo": ""}) + assert out == {"amount": 1280.5, "count": 3, "urgent": True, "card": "6222", "extra": "原样", + "payee": {"id": 42, "vip": False, "name": "钱七"}, "splits": [1, 2.5]} + assert actions.typed_args(_cap(), {"amount": "120"})["amount"] == 120 and \ + isinstance(actions.typed_args(_cap(), {"amount": "120"})["amount"], int) + assert actions.typed_args(_cap(), {"amount": 7, "count": "4.0"}) == {"amount": 7, "count": 4} + # 没登记参数定义的能力:原样不动,发出去的和以前一字不差 + assert actions.typed_args(_cap({}), {"amount": "1280.5"}) == {"amount": "1280.5"} + + +@pytest.mark.parametrize("args, where, want", [ + ({"amount": "abc"}, "amount", "应为数"), + ({"amount": "nan"}, "amount", "应为数"), + ({"amount": "1", "count": "2.5"}, "count", "应为整数"), + ({"amount": "1", "urgent": "yes"}, "urgent", "应为是或否"), + ({"amount": "1", "payee": {"id": "x"}}, "payee.id", "应为整数"), + ({"amount": "1", "splits": ["1", "两元"]}, "splits[1]", "应为数"), + ({"amount": "1", "payee": "{坏的"}, "payee", "应为对象(JSON)"), + ({"amount": " "}, "amount", "必填参数"), +]) +def test_typed_args_refuse_what_cannot_be_the_declared_type(args, where, want): + with pytest.raises(actions.ActionFailed) as refused: + actions.typed_args(_cap(), args) + assert f" {where} " in str(refused.value) and want in str(refused.value) and "没有发送" in str(refused.value) + + +async def test_a_template_amount_is_sent_as_a_number_and_the_hash_covers_what_is_sent(erp, capabilities): + caps = capabilities(await _endpoint(test=True), lookup=False) + caps[PAY].params_schema = {"type": "object", "properties": {"amount": {"type": "number"}, + "card": {"type": "string"}}} + run_id = await _formal(pay_graph(), amount=1280.5) + task = await _task(run_id, "action_confirm") + card = (await tasks.detail(task.id, ANN))["payload"] + assert card["preview"] == {"amount": 1280.5, "card": "••••"} + sent = {"amount": 1280.5, "card": "6222020200112233"} + assert card["payload_hash"] == actions.payload_hash(PAY, sent) + assert card["payload_hash"] != actions.payload_hash(PAY, {**sent, "amount": "1280.5"}) + await _confirm(run_id) + final = await _wait(run_id, ("succeeded", "failed")) + assert final.status == "succeeded", final.error + [request] = erp.writes("prod") + assert request["body"] == sent and isinstance(request["body"]["amount"], float) + [rec] = await _records(run_id) + assert rec.payload_hash == card["payload_hash"] and rec.preview["amount"] == 1280.5 + + +async def test_an_untyped_value_fails_the_node_without_sending(erp, capabilities): + caps = capabilities(await _endpoint(test=True), lookup=False) + caps[PAY].params_schema = {"type": "object", "properties": {"amount": {"type": "number"}}} + run_id = await _formal(pay_graph(), amount="abc") + final = await _wait(run_id, ("succeeded", "failed", "interrupted")) + assert final.status == "failed" and "参数 amount 应为数" in final.error and "「abc」" in final.error + assert erp.requests == [] and await _records(run_id) == [] + async with SessionLocal() as session: # 收不成的在确认之前就停下:不开确认卡,看过的不会是发不出去的 + assert (await session.execute(select(Task).where(Task.run_id == run_id))).scalars().all() == [] + + +async def test_a_changed_params_schema_does_not_fail_a_write_that_already_went_out(erp, capabilities): + """发出之后能力的参数定义改了(升级时停在结果未知的运行):先查记录再收类型。结果未知的照旧挂起等核实,核实为已生效 + 之后节点重新执行交回存下的回执——手上的「1,280」收不成新声明的数,也不把已经生效的写入判成失败。""" + caps = capabilities(await _endpoint(test=True), lookup=True) + erp.mode = "timeout_after_write" + run_id = await _formal(pay_graph(), amount="1,280") + await _confirm(run_id) + assert (await _wait(run_id, ("interrupted", "failed", "succeeded"))).status == "interrupted" + [rec] = await _records(run_id) + assert rec.state == "unknown" and rec.preview["amount"] == "1,280" + caps[PAY].params_schema = {"type": "object", "properties": {"amount": {"type": "number"}}} + args = {"amount": "1,280", "card": "6222020200112233"} + with pytest.raises(actions.ActionPending): + await actions.prepare(run_id=run_id, node_id="pay", exec_no=1, capability=caps[PAY], args=args) + + assert await actions.tick(utcnow() + timedelta(minutes=2)) == 1 + final = await _wait(run_id, ("succeeded", "failed")) + assert final.status == "succeeded", final.error + assert len(erp.writes()) == 1 + [rec] = await _records(run_id) + assert rec.state == "confirmed" + # 再执行一次(接着跑、重放):交回生效那一次的回执,指纹、预览照记录,不按这一次的参数重算 + prep = await actions.prepare(run_id=run_id, node_id="pay", exec_no=1, capability=caps[PAY], args=args) + assert prep.receipt is not None and prep.receipt["receipt"]["id"] == final.output["单号"] + assert (prep.payload_hash, prep.preview["amount"], prep.attempt) == (rec.payload_hash, "1,280", 1) + # 真要发的(新的一次执行)照旧按参数定义收类型:收不成的不发 + with pytest.raises(actions.ActionFailed, match="参数 amount 应为数"): + await actions.prepare(run_id=run_id, node_id="pay", exec_no=2, capability=caps[PAY], args=args) + assert len(erp.writes()) == 1 + + +# -------------------------------------------------------------------------- +# 实例页怎么说:驳回动作确认、结果未知、核实之后 +# -------------------------------------------------------------------------- + + +async def _instance_view(run_id: str) -> dict: + """按一页实例页(概况、进度、付款的动作状态)解析这次运行:{组件 id: data}。""" + from app.apps.resolve import resolve_page + from app.db.models import App, AppVersion + + spec = {"format": "agentlab.app/1", "name": "付款", "pages": [{"id": "instance", "title": "实例", "body": [ + {"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"}, + {"id": "act", "type": "action_status", "props": {"label": "付款"}, "bind": {"action": "pay"}}]}]} + async with SessionLocal() as session: + app = App(name="付款", published_version=1) + session.add(app) + await session.flush() + version = AppVersion(app_id=app.id, version=1, spec=spec, spec_hash="x" * 64) + session.add(version) + await session.commit() + run = await session.get(Run, run_id) + vm = await resolve_page(session, app, page_id="instance", principal=ANN, role="manage", instance=run, + version=version) + return {n["id"]: n.get("data") for n in vm["tree"]} + + +async def test_a_rejected_confirmation_reads_as_a_decision_not_a_fault(erp, capabilities): + capabilities(await _endpoint(test=True), lookup=False) + run_id = await _formal(pay_graph()) + task = await _task(run_id, "action_confirm") + await tasks.respond(task.id, ANN, decision="reject", note="金额不对") + final = await _wait(run_id, ("succeeded", "failed")) + # 引擎里驳回就是节点失败、运行结束(没有可走的出口);说法写成未确认(被驳回),实例页按人的决定说 + assert final.status == "failed" and final.error.startswith("「pay」未确认(被驳回),没有发送:金额不对") + view = await _instance_view(run_id) + assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("cancelled", "「pay」未确认(被驳回)") + step = next(s for s in view["tl"]["steps"] if s["node"] == "pay") + assert (step["state"], step["text"]) == ("rejected", "未确认(被驳回)") + assert erp.requests == [] + + +async def test_unknown_then_reconciled_reads_as_waiting_then_a_footnote(erp, capabilities): + capabilities(await _endpoint(test=True), lookup=True) + erp.mode = "timeout_after_write" + run_id = await _formal(pay_graph()) + await _confirm(run_id) + await _wait(run_id, ("interrupted",)) + view = await _instance_view(run_id) + # 结果未知:在等核实,不是「等待审批」;付款这一步不是「完成」 + assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("waiting", "等待核实写入结果") + step = next(s for s in view["tl"]["steps"] if s["node"] == "pay") + assert (step["state"], step["text"]) == ("unknown", "结果未知,等待核实") + [row] = view["act"]["records"] + assert row["state"] == "unknown" and "error" not in row and "ReadTimeout" in row["note"] + + assert await actions.tick(utcnow() + timedelta(minutes=2)) == 1 + final = await _wait(run_id, ("succeeded", "failed")) + assert final.status == "succeeded", final.error + view = await _instance_view(run_id) + assert view["hdr"]["status_text"] == "已完成" + assert next(s for s in view["tl"]["steps"] if s["node"] == "pay")["state"] == "done" + [row] = view["act"]["records"] + # 核实为已生效之后:第一次发送超时只作历史记一笔,不再当失败原因 + assert row["state"] == "confirmed" and "error" not in row + assert "ReadTimeout" in row["note"] and "按幂等键查询核实为已生效" in row["note"] diff --git a/backend/tests/test_scheduler_inputs.py b/backend/tests/test_scheduler_inputs.py index 7e274aca..974c5568 100644 --- a/backend/tests/test_scheduler_inputs.py +++ b/backend/tests/test_scheduler_inputs.py @@ -2,6 +2,7 @@ - 变量都按 fire_at(计划触发时间)在任务时区算,不按「现在」:补跑晚了一小时,昨天还是计划那天的昨天 - 跨月、跨年、跨时区(同一时刻在上海已经是周一,在纽约还是周日);周从周一算起 +- ISO 周(2026-W42)的年份跟着周走:2026-12-31、2027-01-01 都是 2026-W53,2025-12-29 是 2026-W01;月份写 YYYY-MM - schedule.last_fire_time 是上一次计划触发的时间(按 cron 往前找),和本次一样写成带偏移的 ISO 时间 - 模板写错(未知变量、未知过滤器、{{ }} 不成对、空的 {{ }}、值不是文本)抛 TemplateError,中文说明哪个输入 - 声明了入口字段的工作流:模板里多出来的字段名、缺的必填输入(没有默认值)都拦下 @@ -34,9 +35,15 @@ def test_monday_morning_in_shanghai(): assert v == { "today": "2026-10-05", "yesterday": "2026-10-04", + "this_week": "2026-W41", + "last_week": "2026-W40", + "week_before_last": "2026-W39", "this_week_start": "2026-10-05", "last_week_start": "2026-09-28", "last_week_end": "2026-10-04", + "this_month": "2026-10", + "last_month": "2026-09", + "month_before_last": "2026-08", "this_month_start": "2026-10-01", "last_month_start": "2026-09-01", "last_month_end": "2026-09-30", @@ -62,6 +69,45 @@ def test_across_a_year_boundary(): assert v["last_fire_time"] == "2026-12-01T08:00:00+08:00" +@pytest.mark.parametrize("day, this_week, last_week, before_last", [ + # 2026 年 1 月 1 日是周四,这一年有 53 周:年底那几天、2027 年开头的周五到周日都还在 2026-W53 + ((2026, 12, 31), "2026-W53", "2026-W52", "2026-W51"), + ((2027, 1, 1), "2026-W53", "2026-W52", "2026-W51"), + ((2027, 1, 3), "2026-W53", "2026-W52", "2026-W51"), + # 周一换周:上周是上一年的最后一周 + ((2027, 1, 4), "2027-W01", "2026-W53", "2026-W52"), + # 反过来,2025 年最后几天已经算 2026 年的第 1 周 + ((2025, 12, 29), "2026-W01", "2025-W52", "2025-W51"), + ((2026, 1, 5), "2026-W02", "2026-W01", "2025-W52"), +]) +def test_iso_weeks_across_the_year(day, this_week, last_week, before_last): + """ISO 周的年份是周所在的年,不是日历年;周号补足两位""" + y, m, d = day + v = values(datetime(y, m, d, 1, 0, tzinfo=UTC)) # 上海当天 09:00 + assert (v["this_week"], v["last_week"], v["week_before_last"]) == (this_week, last_week, before_last) + + +def test_iso_week_and_month_follow_the_schedule_timezone(): + """UTC 2026-12-31 16:30:上海已经是 2027-01-01 00:30(周五,仍在 2026-W53),伦敦还是 2026-12-31""" + moment = datetime(2026, 12, 31, 16, 30, tzinfo=UTC) + shanghai = values(moment, "Asia/Shanghai", "30 0 * * *") + london = values(moment, "Europe/London", "30 16 * * *") + assert (shanghai["this_week"], shanghai["this_month"], shanghai["last_month"], shanghai["month_before_last"]) == ( + "2026-W53", "2027-01", "2026-12", "2026-11") + assert (london["this_week"], london["this_month"], london["last_month"]) == ("2026-W53", "2026-12", "2026-11") + # 跨到周一那一刻:上海已经是 2027-W01,纽约还在 2026-W53 + monday = datetime(2027, 1, 3, 20, 0, tzinfo=UTC) + assert values(monday, "Asia/Shanghai", "0 4 * * *")["this_week"] == "2027-W01" + assert values(monday, "America/New_York", "0 15 * * *")["this_week"] == "2026-W53" + + +def test_months_across_a_year_boundary(): + v = values(datetime(2027, 2, 1, 1, 0, tzinfo=UTC), cron="0 9 1 * *") + assert (v["this_month"], v["last_month"], v["month_before_last"]) == ("2027-02", "2027-01", "2026-12") + v = values(datetime(2027, 1, 1, 1, 0, tzinfo=UTC), cron="0 9 1 * *") + assert (v["this_month"], v["last_month"], v["month_before_last"]) == ("2027-01", "2026-12", "2026-11") + + def test_leap_february_ends_on_the_29th(): v = values(datetime(2028, 3, 1, 1, 0, tzinfo=UTC), cron="0 9 1 * *") assert (v["last_month_start"], v["last_month_end"]) == ("2028-02-01", "2028-02-29") @@ -98,6 +144,11 @@ def test_render_inputs(): "tag": "{{ schedule.today | upper }}", }, v) assert out == {"stat_date": "2026-10-04", "week": "2026-09-28~2026-10-04", "region": "华东", "tag": "2026-10-05"} + # 按周统计的周报:周一早上出上周的报告,和上上周比 + weekly = service.render_inputs({"week": "{{ schedule.last_week }}", "prev_week": "{{ schedule.week_before_last }}", + "month": "{{ schedule.last_month }}"}, v) + assert weekly == {"week": "2026-W40", "prev_week": "2026-W39", "month": "2026-09"} + assert service.check_template({"week": "{{ schedule.this_week }}", "month": "{{ schedule.this_month }}"}) def test_valid_templates_pass(): @@ -135,12 +186,17 @@ def test_variable_catalog(): items = service.variables(datetime(2026, 10, 5, 1, 0, tzinfo=UTC), "Asia/Shanghai") names = [i["name"] for i in items] assert names == [f"schedule.{key}" for key in ( - "today", "yesterday", "this_week_start", "last_week_start", "last_week_end", - "this_month_start", "last_month_start", "last_month_end", "fire_time", "last_fire_time")] + "today", "yesterday", "this_week", "last_week", "week_before_last", "this_week_start", "last_week_start", + "last_week_end", "this_month", "last_month", "month_before_last", "this_month_start", "last_month_start", + "last_month_end", "fire_time", "last_fire_time")] by_name = {i["name"]: i for i in items} assert by_name["schedule.yesterday"] == {"name": "schedule.yesterday", "label": "昨天(YYYY-MM-DD)", - "example": "2026-10-04"} + "example": "2026-10-04", "note": ""} + assert by_name["schedule.last_week"]["example"] == "2026-W40" and "2026-W53" in by_name["schedule.last_week"]["note"] + assert by_name["schedule.this_month"]["example"] == "2026-10" assert all(i["label"] and i["example"] for i in items) + # 补充说明只写在 ISO 周上 + assert [i["name"] for i in items if i["note"]] == ["schedule.this_week", "schedule.last_week", "schedule.week_before_last"] # -------------------------------------------------------------------------- diff --git a/backend/tests/test_schedules_api.py b/backend/tests/test_schedules_api.py index cec1eeca..bd9a5ce1 100644 --- a/backend/tests/test_schedules_api.py +++ b/backend/tests/test_schedules_api.py @@ -522,7 +522,7 @@ async def test_variables(client): res = await client.get("/api/schedules/variables") assert res.status_code == 200 items = res.json() - assert len(items) == 10 and all(set(i) == {"name", "label", "example"} for i in items) + assert len(items) == 16 and all(set(i) == {"name", "label", "example", "note"} for i in items) assert {"name": "schedule.yesterday", "label": "昨天(YYYY-MM-DD)"}.items() <= next( i for i in items if i["name"] == "schedule.yesterday").items() assert (await client.get("/api/schedules/variables", params={"timezone": "Nowhere/City"})).status_code == 422 diff --git a/backend/tests/test_template_traceable.py b/backend/tests/test_template_traceable.py index a7013dc1..19898b08 100644 --- a/backend/tests/test_template_traceable.py +++ b/backend/tests/test_template_traceable.py @@ -1,8 +1,9 @@ -"""内置模板「⑨ 可追溯周报」:tool 查库 → 解析 → 口径卡 → 报告撰写 → 出具(引用模式)。 +"""内置模板「⑨ 可追溯周报」:tool 查库 → 口径卡 → 报告撰写 → 出具(引用模式)。 模板既是示范也是活文档:它要能通过 validate_graph,接上一个真实的库(这里用 SQLite 建一个示例 shop 库)要能从头跑到尾、完整出具,报告里每个数都点得开。 -模板里只许出现通用示例名。 +口径卡用 cell() 按列名取查询结果的格,每个指标的输入都一路追到查询快照(engine/lineage.py): +模板教的就是这种写法。模板里只许出现通用示例名。 """ from __future__ import annotations @@ -44,12 +45,57 @@ def test_template_passes_validation(): assert {"name": "周报", "value": f"{{{{ nodes.{report_id}.text }}}}"} in out["data"]["config"]["fields"] +def test_every_metric_of_the_template_reaches_the_query_snapshot(): + """口径卡的每个指标都用 cell() 取数:静态地认(和应用检查同一套),没有一个输入停在半路。""" + from app.apps.tree import node_map + from app.engine.lineage import static_stops + + nodes = node_map(graph()) + [card] = [n for n in nodes.values() if n["type"] == "metrics"] + metrics = card["config"]["metrics"] + assert metrics and all("cell(nodes.fetch" in m["expression"] for m in metrics) + assert {m["id"]: static_stops(nodes, m["expression"]) for m in metrics} == {m["id"]: [] for m in metrics} + + +async def test_every_metric_of_a_run_is_traced_to_the_snapshot(engine_up, shop, monkeypatch, client): + """跑一遍:每个指标的输入都是单元格取数、与快照一致;证据接口的指标步骤说输入都追到了原始数据。""" + from app.providers import mock_model + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", + lambda self, messages: AIMessage(content="销售额 [[m:gmv]]。[[see:m:gmv]]")) + run = await run_manager.start(graph=graph(), input_payload={}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + break + assert row.status == "succeeded", row.error + for metric in ("gmv", "orders", "wow", "aov", "refund_rate"): + chain = (await client.get(f"/api/runs/{run.id}/evidence/metrics/caliber/{metric}")).json()["chain"] + step = chain[0] + assert step["step"] == "metric" and step["untraced"] == [], (metric, step.get("untraced")) + inputs = [s for s in chain if s["step"] == "input"] + assert inputs and all(i["via"] == "tool_cell" and i["status"] == "verified" for i in inputs), metric + assert chain[-1]["step"] == "query" + + def test_template_is_listed_once_and_keeps_template_eight(): names = [t["name"] for t in TEMPLATES] assert names.count(NAME) == 1 assert any(n.startswith("⑧ 周报") for n in names) # 老模板不改,免得覆盖用户复制过的那一份 +@pytest.fixture +async def client(): + from httpx import ASGITransport, AsyncClient + + from app.main import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + @pytest.fixture async def engine_up(): await run_manager.setup() diff --git a/backend/tests/test_tool_evidence.py b/backend/tests/test_tool_evidence.py index f98fde94..fef08339 100644 --- a/backend/tests/test_tool_evidence.py +++ b/backend/tests/test_tool_evidence.py @@ -349,6 +349,40 @@ async def test_cell_lineage_points_at_the_exact_cell(engine_up, shop): assert prev["eid"] == make_eid("cell", query["artifact"], {"row": 0, "column": "gmv_prev"}) +async def test_a_positional_cell_through_a_plain_parse_reaches_the_snapshot(engine_up, shop): + """数据整形只是把调用工具节点的结果原样按 JSON 解析(模板整串就是 {{ nodes.fetch }}):vars.q.rows[0][0] 确定地对到 + 查询快照的第 0 行第 0 列,和 cell() 一样核对快照、带 locator / artifact / eid,through 记下穿过的整形节点。 + nodes.parse.rows[0]['gmv'] 按列名取也一样。模板带了过滤器、或者多包了一层的,不算原样解析:照旧记成数据整形的产出。""" + card = node("card", "metrics", caliber="周报口径", metrics=[ + {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands", + "expression": "vars.q.rows[0][0]"}, + {"id": "prev", "name": "上期", "decimals": 1, "format": "plain", "expression": "nodes.parse.rows[0]['gmv_prev']"}, + {"id": "raw", "name": "过滤器", "decimals": 1, "format": "plain", "expression": "vars.raw.rows[0][0]"}, + {"id": "wrap", "name": "包一层", "decimals": 1, "format": "plain", "expression": "vars.w.res.rows[0][0]"}, + ]) + parse = node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="q") + raw = node("parse_raw", "transform", mode="json", template="{{ nodes.fetch | trim }}", assign_to="raw") + wrap = node("wrap_n", "transform", mode="json", template='{"res": {{ nodes.fetch }}}', assign_to="w") + row = await finish(fetch_graph(parse, raw, wrap, card)) + assert row.status == "succeeded", row.error + query = next(e for e in await ledger(row.id) if e["kind"] == "query") + finished = (await events(row.id, "node.finished", "card"))[0] + metrics = {m["id"]: m for m in artifact_store.load(finished.data["artifact"])["metrics"]} + + gmv = metrics["gmv"] + assert gmv["value"] == 600.5 and gmv["recompute_ok"] is True + [cell] = gmv["inputs"] + assert cell == {"path": "vars.q.rows[0][0]", "value": 600.5, "node_id": "fetch", "via": "tool_cell", + "through": "parse", "status": "verified", "locator": {"row": 0, "column": "gmv"}, + "artifact": query["artifact"], "eid": make_eid("cell", query["artifact"], {"row": 0, "column": "gmv"})} + [by_name] = metrics["prev"]["inputs"] + assert (by_name["via"], by_name["through"], by_name["locator"], by_name["status"]) == ( + "tool_cell", "parse", {"row": 0, "column": "gmv_prev"}, "verified") + for metric_id, writer in (("raw", "parse_raw"), ("wrap", "wrap_n")): + [item] = metrics[metric_id]["inputs"] + assert (item["via"], item["node_id"]) == ("transform", writer) and "through" not in item, item + + async def test_card_computes_on_integer_decimal_text(engine_up, shop): """sqlite 没有 DECIMAL:CAST(… AS TEXT) 落进快照的,和 MySQL 整数列 SUM() 的 str(Decimal) 一模一样。""" sql = ("SELECT CAST(SUM(refunded) AS TEXT) AS refunds, CAST(COUNT(*) AS TEXT) AS orders " diff --git a/backend/tests/test_whatif_api.py b/backend/tests/test_whatif_api.py index 5307976f..0cbb3240 100644 --- a/backend/tests/test_whatif_api.py +++ b/backend/tests/test_whatif_api.py @@ -597,32 +597,40 @@ async def test_excluding_rows_of_an_old_run_never_says_unchanged(client, monkeyp async def test_only_inputs_computed_after_the_snapshot_came_back_are_doubted(client, monkeypatch): """写在查询之前的容器(early)排除行影响不到它;查询之后才算出来、又对不上任何快照的(late)说不准。 - 按位置取自整份结果的一格(vars.res.rows[0][2])同样说不准:排除第 0 行后它取到的是另一行。""" + + 按位置取自整份结果的一格分两种:数据整形只是原样解析查询结果的(vars.res,模板整串就是 {{ nodes.fetch }}),那一格 + 卡上记成快照的第 0 行,和 cell() 一样——排除第 0 行,这个指标就照实说那一行已排除、算不出;模板带了过滤器的 + (vars.raw)不算原样解析,认不出是快照的哪一行,说不准:排除第 0 行后它取到的是另一行。""" metrics = [ {"id": "m_early", "name": "加早", "decimals": 1, "expression": f"round({AMOUNT5} + sum(vars.early.items), 1)"}, {"id": "m_late", "name": "加晚", "decimals": 1, "expression": f"round({AMOUNT5} + sum(vars.late.items), 1)"}, {"id": "first", "name": "首单占比", "decimals": 2, "expression": "round(vars.res.rows[0][2] / len(vars.res.rows), 2)"}, + {"id": "first_raw", "name": "首单占比(过滤器)", "decimals": 2, + "expression": "round(vars.raw.rows[0][2] / len(vars.raw.rows), 2)"}, ] row = await run_graph(monkeypatch, chain( node("start", "input"), node("early_n", "transform", mode="expression", expression="{'items': [1.5, 2.5]}", assign_to="early"), node("fetch", "tool", tool="db_query__shop", args={"sql": SQL}), node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="res"), + node("parse_raw", "transform", mode="json", template="{{ nodes.fetch | trim }}", assign_to="raw"), node("late_n", "transform", mode="expression", expression="{'items': [1.5, 2.5]}", assign_to="late"), node("card", "metrics", caliber="推演口径", metrics=metrics), node("write", "report", instructions="写周报", on_violation="flag"), node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}])), - text="加早 [[m:m_early]],加晚 [[m:m_late]],首单占比 [[m:first]]。") + text="加早 [[m:m_early]],加晚 [[m:m_late]],首单占比 [[m:first]],[[m:first_raw]]。") snapshot = await snapshot_of(row.id) assert [(m, s, new == old) for _, m, s, new, old in await faithful(row.id)] == [ - ("m_early", "ok", True), ("m_late", "ok", True), ("first", "ok", True)] + ("m_early", "ok", True), ("m_late", "ok", True), ("first", "ok", True), ("first_raw", "ok", True)] metrics = by_id(await whatif(client, row.id, {"card": "card", "overrides": {"exclude_rows": {snapshot: [0]}}})) assert metrics["m_early"]["status"] == "unchanged" and metrics["m_early"]["changed"] is False assert metrics["m_late"]["status"] == "unsupported" and "vars.late.items" in metrics["m_late"]["reason"] assert "对不上任何查询快照" in metrics["m_late"]["reason"] - assert metrics["first"]["status"] == "unsupported" and metrics["first"]["new"] is None - assert "vars.res.rows[0][2]" in metrics["first"]["reason"] + assert metrics["first"]["status"] == "excluded" and metrics["first"]["new"] is None + assert "vars.res.rows[0][2] 所在的第 1 行已排除" in metrics["first"]["reason"], metrics["first"]["reason"] + assert metrics["first_raw"]["status"] == "unsupported" and metrics["first_raw"]["new"] is None + assert "vars.raw.rows[0][2]" in metrics["first_raw"]["reason"] # -------------------------------------------------------------------------- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d217bb49..b6fc9d74 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,7 @@ import { composeTitle, flagAttention, notifyInBackground, notifySupported, paintFavicon, useSignals, } from './lib/notify' import type { Attention, FaviconDot } from './lib/notify' +import type { Principal } from './typesApp' import { useLeaveBlocker } from './lib/leave' import { useLocalActor } from './lib/actor' import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, TASK_NOTIFY_TEXT, runName } from './lib/terms' @@ -65,15 +66,20 @@ export default function App() { useEffect(() => { void (async () => { // 认人最多等 AUTH_WAIT_MS:服务端卡着不回时不能把目录也一起拖住(启动页的「比平时慢」要点得出是哪几张表)。 - // 晚到的 401 自己会整页跳登录页(api/http) - const me = await Promise.race([ - useAuth.getState().load(), - new Promise((resolve) => setTimeout(() => resolve(null), AUTH_WAIT_MS)), - ]) - if (needsLogin(me)) { + // 晚到的「还没登录」照样送去登录页(认人接口的 401 不整页跳,见 api/http)。load 同一时刻只发一个请求: + // 开发模式下这个 effect 执行两次,也只认一次人 + const loading = useAuth.getState().load() + const toLoginPage = (who: Principal | null) => { + if (!needsLogin(who)) return false if (window.location.pathname !== '/login') navigate(loginUrl(), { replace: true }) - return + return true } + const me = await Promise.race([ + loading, + new Promise((resolve) => setTimeout(() => resolve(undefined), AUTH_WAIT_MS)), + ]) + if (me === undefined) void loading.then(toLoginPage) + else if (toLoginPage(me)) return void useCatalog.getState().refresh() })() // 只在启动时认一次;登录、退出都整页重新加载 diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 95ad3125..51990394 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -2,12 +2,15 @@ * 身份的接口(/api/auth、/api/users、/api/groups、/api/audit),挂在 api.auth / api.identity 上。形状见 typesApp.ts。 * 开登录时:没登录的请求回 401(前端跳 /login),角色不够回 403。关登录时 /me 恒为 mode='off'。 */ -import type { AuditRow, AuthMode, GroupRow, Principal, Role, UserRow } from '../typesApp' +import type { AuditRow, AuthMode, Directory, GroupRow, Principal, Role, UserRow } from '../typesApp' import { del, get, patch, post, put, qs, type RequestOptions } from './http' export const auth = { me: (opts?: RequestOptions) => get('/auth/me', opts), login: (body: { username: string; password: string }) => post('/auth/login', body), + /** 建第一个管理员并以他登录:账号密码登录模式下、还没有能登录的管理员时(/auth/me 的 401 说 can_bootstrap) */ + bootstrap: (body: { username: string; password: string; display_name?: string }) => + post('/auth/bootstrap', body), logout: () => post('/auth/logout'), /** OIDC:浏览器整页跳转到这里 */ oidcStartUrl: (next?: string) => `/api/auth/oidc/start${qs({ next })}`, @@ -24,6 +27,8 @@ export const identity = { updateUser: (id: string, body: { display_name?: string; email?: string; role?: Role; disabled?: boolean; password?: string }) => patch(`/users/${encodeURIComponent(id)}`, body), groups: () => get<{ items: GroupRow[] }>('/groups'), + /** 选人用的名录:搭建者及以上只读(/users、/groups 只给管理员) */ + directory: () => get('/directory'), createGroup: (body: { name: string; label?: string }) => post('/groups', body), setMembers: (id: string, members: string[]) => put(`/groups/${encodeURIComponent(id)}/members`, { members }), deleteGroup: (id: string) => del(`/groups/${encodeURIComponent(id)}`), diff --git a/frontend/src/api/capabilities.ts b/frontend/src/api/capabilities.ts index 4cfe843c..3f2af1bd 100644 --- a/frontend/src/api/capabilities.ts +++ b/frontend/src/api/capabilities.ts @@ -114,8 +114,10 @@ export const endpoints = { update: (id: string, body: Partial & { version: number }) => patch(`/endpoints/${encodeURIComponent(id)}`, body), remove: (id: string) => del(`/endpoints/${encodeURIComponent(id)}`), + /** ok:基础地址回了 2xx;reachable:连上了、拿到了响应(回 404、401 时连上了但不算成功)。message 是服务端照实写的那句 */ test: (id: string, env: 'test' | 'prod') => - post<{ ok: boolean; status: number | null; message: string }>(`/endpoints/${encodeURIComponent(id)}/test${qs({ env })}`), + post<{ ok: boolean; reachable?: boolean; status: number | null; message: string }>( + `/endpoints/${encodeURIComponent(id)}/test${qs({ env })}`), } export const notify = { diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index d2309c6d..d7903892 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -97,10 +97,12 @@ export function actorHeader(): Record { /** * 开了登录、会话没了(过期、被注销):服务端回 401,整页跳到登录页,登录后回到原处。整页跳而不是路由跳: - * 外壳里各个 store 拿的都是上一个人的数据,重新加载一遍最干净。登录接口自己的 401(密码不对)和登录页上的请求不跳 + * 外壳里各个 store 拿的都是上一个人的数据,重新加载一遍最干净。登录接口自己的 401(密码不对)和登录页上的请求不跳。 + * 认人接口(/auth/me)的 401 也不跳:它是「还没登录」的回答,外壳拿到后自己去登录页(App 启动时)——在这里再整页跳一次, + * 登录页加载起来又认一遍人,同一次进门要请求好几次 */ function toLogin(path?: string) { - if (typeof window === 'undefined' || path === '/auth/login') return + if (typeof window === 'undefined' || path === '/auth/login' || path === '/auth/me') return const { pathname, search } = window.location if (pathname === '/login') return const back = `${pathname}${search}` diff --git a/frontend/src/apps/components/display.tsx b/frontend/src/apps/components/display.tsx index 16dfcf2e..cf3984b0 100644 --- a/frontend/src/apps/components/display.tsx +++ b/frontend/src/apps/components/display.tsx @@ -100,7 +100,10 @@ export function MetricBlock({ node }: NodeProps) { const label = str(node.props.label) || str(data.name) || node.id const state = blockState(node) const text = str(data.text) - const unit = str(node.props.unit) || str(data.unit) + // 单位只写一处。口径卡写了单位的,服务端交来的字已经带着它(render_metric:「18,218.54元」),data.unit 只是说明是哪个 + // 单位,不再在下面另写一行;口径卡没写单位(data.unit 是空串)、绑定单元格(字不带单位,render_cell)的,写搭建者在 + // 属性里填的单位 + const unit = str(data.unit) ? '' : str(node.props.unit) return (
{label}
diff --git a/frontend/src/apps/components/process.tsx b/frontend/src/apps/components/process.tsx index 85e34962..cbbfbac8 100644 --- a/frontend/src/apps/components/process.tsx +++ b/frontend/src/apps/components/process.tsx @@ -11,12 +11,15 @@ import { ArrowRight, Hourglass, Play } from 'lucide-react' import clsx from 'clsx' import { api } from '../../api/client' import { ErrorState, Spinner, StatusPill, useTicker } from '../../components/ui' -import { Due, TaskCard, Signoffs } from '../../run/TaskCard' -import { formatDateTime, formatRelative } from '../../lib/format' import { - ACTION_ENV_LABEL, ACTION_STATE_LABEL, ACTION_TEXT, APP_TEXT, ENTRY_TEXT, INSTANCE_TEXT, TASK_KIND_LABEL, VIEW_TEXT, + Due, Resolved, SignoffHead, Signoffs, TaskCard, assigneeName, respondedText, +} from '../../run/TaskCard' +import { formatDateTime, formatRelative, formatTime } from '../../lib/format' +import { + ACTION_ENV_LABEL, ACTION_STATE_LABEL, ACTION_TEXT, APP_TEXT, ENTRY_TEXT, INSTANCE_TEXT, TASK_KIND_LABEL, + TASK_STATUS_LABEL, TASK_TEXT, VIEW_TEXT, } from '../../lib/termsApp' -import type { ActionRecordRow, ActionState, InstanceRow, Task } from '../../typesApp' +import type { ActionRecordRow, ActionState, InstanceRow, Task, TaskAssignment } from '../../typesApp' import { Stamp } from '../Stamp' import { sourceOf, str, useView, type NodeProps } from '../context' @@ -88,7 +91,7 @@ export function InstanceItem({ row, appId, preview }: { row: InstanceRow; appId: {APP_TEXT.pendingTasks(row.pending_tasks)} )} - + {formatRelative(row.created_at)} ) @@ -109,7 +112,7 @@ export function InstanceHeader({ node }: NodeProps) {

{str(d.title) || model.page.title}

- {d.status && } + {d.status && }
{d.started_by && {INSTANCE_TEXT.startedBy(str(d.started_by))}} @@ -121,13 +124,17 @@ export function InstanceHeader({ node }: NodeProps) { ) } -/** 实例进度:服务端按节点给一行(标题、状态、时间),不下发原始事件。状态的剪影同运行的状态徽标 */ +/** + * 实例进度:服务端按节点给一行(标题、状态、给人看的说法、时间),不下发原始事件。状态的剪影同运行的状态徽标: + * 结果未知(unknown)画成等人处理,被人驳回而结束(rejected)画成取消——是人的决定,不是故障 + */ const STEP_STATUS: Record = { running: 'running', done: 'succeeded', failed: 'failed', skipped: 'skipped', waiting: 'interrupted', + unknown: 'interrupted', rejected: 'cancelled', } export function InstanceTimeline({ node }: NodeProps) { - const steps: { node?: string; title?: string; state?: string; at?: string }[] = + const steps: { node?: string; title?: string; state?: string; text?: string; at?: string }[] = Array.isArray(node.data?.steps) ? node.data!.steps : [] return (
@@ -135,8 +142,8 @@ export function InstanceTimeline({ node }: NodeProps) {
    {steps.map((it, i) => (
  1. - + {it.title || it.node} {it.at && {formatRelative(it.at)}}
  2. @@ -186,36 +193,67 @@ export function TaskList({ node }: NodeProps) { } /** 待办的摘要(页面解析给的那几项) */ -type TaskSummary = Pick +type TaskSummary = Pick +/** 一行待办:标题之外写所属实例和开出的时间——同一个节点开出的待办标题一样,靠它们分开 */ function TaskRow({ task }: { task: TaskSummary }) { const now = useTicker(60_000) + const where = [task.instance_title, task.created_at ? TASK_TEXT.createdAt(formatTime(task.created_at)) : null].filter(Boolean) return (
    {TASK_KIND_LABEL[task.kind] ?? task.kind} - {task.title} + + {task.title} + {where.length > 0 && {where.join(' · ')}} +
    ) } -/** 一条待办(task_form、signoffs):服务端给的是摘要,按 id 取完整的再画;预览里只读 */ -function useFullTask(summary: TaskSummary | undefined, preview: boolean) { +/** + * 页面解析给的这一步(task_form、signoffs)拼成待办的样子:摘要、派给谁(服务端给的原样)、会签进度、我的回复。 + * 只用来画状态,不能拿去处理——处理的待办卡要按 id 取完整的 + */ +function taskOf(data: Record): Task { + const summary = data.task as TaskSummary & Partial + const raw: Partial[] = Array.isArray(data.assignments) ? data.assignments : [] + return { + ...summary, policy: summary.policy ?? { kind: 'any' }, payload: {}, can_respond: false, + outcome: summary.outcome ?? null, resolved_by: summary.resolved_by ?? null, resolved_at: summary.resolved_at ?? null, + progress: data.progress ?? null, my_response: data.my_response ?? null, + assignments: raw.map((a, i) => ({ + id: a.id ?? String(i), principal_kind: a.principal_kind ?? 'anyone', principal: str(a.principal), + source: str(a.source), status: a.status ?? 'pending', responded_by: a.responded_by ?? null, + responded_at: a.responded_at ?? null, decision: a.decision ?? null, verified: !!a.verified, + })), + } as Task +} + +/** 一条待办(task_form):处理人按 id 取完整的再画待办卡;预览里只读 */ +function useFullTask(summary: TaskSummary | undefined, enabled: boolean) { const id = summary?.id - const fetcher = id && !preview ? () => api.tasks.get(id).then((t) => ({ items: [t] })) : null + const fetcher = id && enabled ? () => api.tasks.get(id).then((t) => ({ items: [t] })) : null const { items, error, retry } = useList(undefined, fetcher, id) return { task: items?.[0], error, retry } } +/** + * 待办表单:服务端解析时就判定了读者是不是这条待办的处理人(data.handler)。是的按 id 取完整的待办画待办卡(能处理的给 + * 表单);不是的(发起人、旁观的人)只画这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是「待办不存在」) + */ export function TaskForm({ node }: NodeProps) { const { preview, onReload } = useView() const summary = node.data?.task as TaskSummary | undefined - const { task, error, retry } = useFullTask(summary, preview) + const handler = node.data?.handler !== false + const { task, error, retry } = useFullTask(summary, !preview && handler) if (!summary) return
    {VIEW_TEXT.tasks.empty}
    return ( -
    +
    {preview ? + : !handler ? : error ? : !task ?
    : { retry(); onReload?.() }} />} @@ -223,20 +261,41 @@ export function TaskForm({ node }: NodeProps) { ) } -/** 会签进度:页面解析给了派给谁、谁回复了什么,拼成待办卡上那一块的样子 */ +/** 这一步的状态(不是处理人看的):种类、标题、状态、时限、升级;在等谁、会签进度;处理完了写谁、什么时候、结果 */ +function TaskState({ task }: { task: Task }) { + const now = useTicker(60_000) + const pending = task.status === 'pending' + const waiting = task.assignments.filter((a) => a.status === 'pending').map(assigneeName) + const voting = task.progress && (task.assignments.length > 1 || task.policy.kind !== 'any') + return ( +
    +
    + {TASK_KIND_LABEL[task.kind] ?? task.kind} + {task.title || TASK_TEXT.untitled} + {pending && {TASK_STATUS_LABEL.pending}} + {pending && } + {task.escalation_level > 0 && ( + + {TASK_TEXT.escalated(task.escalation_level)} + + )} +
    + {!pending && } + {pending && waiting.length > 0 && ( +
    {TASK_TEXT.waitingOn(waiting.join('、'))}
    + )} + {voting &&
    } + {pending && task.my_response && ( +
    {respondedText(task.my_response)}
    + )} +
    + ) +} + +/** 会签进度:页面解析给了派给谁、谁回复了什么(登录核实与否、什么时候)、会签进度,拼成待办卡上那一块的样子 */ export function SignoffsBlock({ node }: NodeProps) { - const summary = node.data?.task as (TaskSummary & { policy?: Task['policy'] }) | undefined - const raw: Record[] = Array.isArray(node.data?.assignments) ? node.data!.assignments : [] - if (!summary) return
    {VIEW_TEXT.tasks.empty}
    - const task = { - ...summary, policy: summary.policy ?? { kind: 'any' }, - assignments: raw.map((a, i) => ({ - id: String(i), principal_kind: a.principal_kind, principal: str(a.principal), source: '', status: a.status, - responded_by: a.responded_by ?? null, responded_at: a.responded_at ?? null, decision: a.decision ?? null, - verified: !!a.verified, - })), - } as unknown as Task - return
    + if (!node.data?.task) return
    {VIEW_TEXT.tasks.empty}
    + return
    } const STATE_COLOR: Record = { @@ -244,10 +303,13 @@ const STATE_COLOR: Record = { unknown: 'var(--st-waiting)', simulated: 'var(--text-dim)', } -/** 动作状态:状态、环境、哈希前 8 位、回执(开登录时使用者看不到回执原文);失败写原因 */ +/** + * 动作状态:状态、环境、哈希前 8 位、回执(开登录时使用者看不到回执原文)。确定没生效的写失败原因(error);结果未知、 + * 事后核实的经过(note,如「发送时没有拿到确定的结果……经按幂等键查询核实为已生效」)只记一笔,不当失败原因 + */ export function ActionStatus({ node }: NodeProps) { const label = str(node.props.label) - const records: Partial[] = Array.isArray(node.data?.records) ? node.data!.records : [] + const records: (Partial & { note?: string })[] = Array.isArray(node.data?.records) ? node.data!.records : [] return (
    {label &&
    {label}
    } @@ -263,7 +325,8 @@ export function ActionStatus({ node }: NodeProps) { {(a.attempt ?? 1) > 1 && {ACTION_TEXT.attempt(a.attempt!)}} {a.payload_hash && {ACTION_TEXT.hash(a.payload_hash.slice(0, 8))}}
    - {a.error &&
    {ACTION_TEXT.error}:{a.error}
    } + {a.error &&
    {ACTION_TEXT.error}:{a.error}
    } + {a.note &&
    {a.note}
    } {a.receipt && (
    {ACTION_TEXT.receipt} diff --git a/frontend/src/apps/context.ts b/frontend/src/apps/context.ts index 75e0d554..82c872a6 100644 --- a/frontend/src/apps/context.ts +++ b/frontend/src/apps/context.ts @@ -8,7 +8,8 @@ * 各组件的 data(ViewNode.data,照 backend/app/apps/resolve.py 的 _data),没有的键都可以缺。绑定了视图的组件都带 * state(ok / missing / masked / withheld / empty / running / error)和可选的 message,说这一块为什么没有数据: * - stamp:{view, state, stamp, message?} - * - metric:{state, text, name, unit, evidence}(evidence 的 kind 为 metric,点开走指标链接口) + * - metric:{state, text, name, unit, evidence}(evidence 的 kind 为 metric,点开走指标链接口)。text 已按口径卡带着 + * 单位,unit 只说明是哪个单位;绑定单元格时是 {state, text, evidence},text 不带单位 * - series:{state, mode(single / dual / incomparable / recompute), unit, series: [{version, label, points: [{run_id, * as_of, value, text, evidence}]}], breaks: [{after_run?, from, to, text}]} * - table、chart:{state, columns, rows(二维数组,遮罩的格子服务端已换成「已遮罩」), masked, total_rows, truncated, @@ -18,11 +19,14 @@ * - form:{form}(表单名,表单在 ViewModel.forms 里)。提交即按这些值重新解析本页(按需视图的参数),见 input.tsx * - entry_button:{entry, label, disabled, reason?} * - instance_list:{who, entry, items: InstanceRow[]} - * - instance_header:InstanceRow 的各项加 {state, status_text, stamp} - * - instance_timeline:{state, status, status_text, steps: [{node, title, state, started_at, at}]} - * - task_list:{scope, items}(待办的摘要,不是完整的待办;要处理时按 id 取完整的) - * - task_form、signoffs:{state, task(摘要), task_ui, assignments?} - * - action_status:{state, node, records: [{id, state, environment, attempt, payload_hash(前 8 位), error, receipt?}]} + * - instance_header:InstanceRow 的各项(含 status_code、status_text:给人看的状态和结论)加 {state, stamp} + * - instance_timeline:{state, status, status_code, status_text, steps: [{node, title, state, text, started_at, at}]} + * (state 另有 unknown:写入结果未知;rejected:被人驳回而结束。text 是这一步给人看的说法) + * - task_list:{scope, items}(待办的摘要,带 instance_title、created_at;要处理时按 id 取完整的) + * - task_form、signoffs:{state, task(摘要), task_ui, handler(读者是不是处理人), assignments, progress, my_response} + * ——不是处理人的不取待办,只画这一步的状态 + * - action_status:{state, node, records: [{id, state, environment, attempt, payload_hash(前 8 位), error?(确定没生效 + * 的原因), note?(结果未知、事后核实的经过), receipt?}]} */ import { createContext, useContext } from 'react' import type { ReactNode } from 'react' diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index aed3b0d4..d0a099e8 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -1392,7 +1392,9 @@ function InputFieldType({ uid, field, onChange }: { @@ -1405,6 +1407,15 @@ function InputFieldType({ uid, field, onChange }: { )}
    + {(type === 'number' || type === 'integer') && ( +
    + {(['minimum', 'maximum'] as const).map((key) => ( + + onChange({ [key]: n })} /> + + ))} +
    + )} {type !== 'boolean' && (