diff --git a/scripts/a2a/smoke/test_a2a_vpc.py b/scripts/a2a/smoke/test_a2a_vpc.py index be3107f7e..15c8f78b7 100644 --- a/scripts/a2a/smoke/test_a2a_vpc.py +++ b/scripts/a2a/smoke/test_a2a_vpc.py @@ -17,6 +17,7 @@ import argparse import json import os +import re import socket import subprocess import sys @@ -342,27 +343,41 @@ def _normal_turn_completed(task: dict, *, timeout: float) -> bool: def _normal_turn_output(task: dict, cli_text: str, *, permission_submitted: bool) -> str: # A reply consumer may retain an old permission Task even after its owner - # finished. After the matching completion proof, accept only a whole ROS + # finished. After the matching completion proof, accept only a complete ROS # template from history or the just-finished CLI call, never a receipt. if not permission_submitted: # No reply/receipt call replaced the original sync output. Once that # task has released naturally, keep its own CLI reply for acceptance. return cli_text or _completed_task_text(task) + metadata = task.get('metadata') + iac = metadata.get('iac_code') if isinstance(metadata, dict) else None + input_resolved = not isinstance(iac, dict) or ( + iac.get('input') is None and not iac.get('pendingPermissions') + ) for text in (_completed_task_text(task), cli_text): wire = text.strip() lines = wire.splitlines() if len(lines) >= 3 and lines[0] in {'```', '```json'} and lines[-1] == '```': wire = '\n'.join(lines[1:-1]) - try: - value = json.loads(wire) - except ValueError: - continue - resources = value.get('Resources') if isinstance(value, dict) else None - if isinstance(resources, dict) and any( - isinstance(resource, dict) and resource.get('Type') == 'ALIYUN::ECS::VPC' - for resource in resources.values() - ): - return text + candidates = [wire] + if input_resolved: + # A final reply may introduce its fenced template with prose. Do + # not extract snippets from a still-pending permission prompt. + candidates.extend(re.findall( + r'^[ \t]*```(?:json)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```[ \t]*$', + text, flags=re.MULTILINE | re.DOTALL, + )) + for candidate in candidates: + try: + value = json.loads(candidate) + except ValueError: + continue + resources = value.get('Resources') if isinstance(value, dict) else None + if isinstance(resources, dict) and any( + isinstance(resource, dict) and resource.get('Type') == 'ALIYUN::ECS::VPC' + for resource in resources.values() + ): + return text return '' diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 08afe1e59..aed31da2a 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -13,7 +13,7 @@ uv run --no-sync python scripts/ci/run_e2e.py --suite full --jobs 3 uv run --no-sync python scripts/ci/run_e2e.py --list --suite all ``` -`fast` 包含 7 个 A2A、REPL、Web API 确定性契约用例;`full` 共 64 个,另含 A2A 执行控制与权限等待/恢复矩阵。`e3a-recovery` 保留运行中快照保存后的崩溃故障点,并验证用户手动继续后恢复同一任务;另设 `e3a-handoff-recovery` 验证已完成回合后的安全交接点重启,两者独立验收。Web API 用例明确使用 `--skip-browser`,不启动浏览器。只跑一个用例可用 `--case a2a-recovery-contract` 或 `--case a2a-handoff-recovery-contract`。`--jobs` 限定为 1–16,确定性套件默认 3,真实套件默认 12。每个用例用独立子进程、配置目录和日志目录;确定性用例不会继承常见 LLM 与阿里云凭证环境变量。 +`fast` 包含 7 个 A2A、REPL、Web API 确定性契约用例;`full` 共 66 个,另含 A2A 执行控制与权限等待/恢复矩阵。`e3a-recovery` 保留运行中快照保存后的崩溃故障点,并验证用户手动继续后恢复同一任务;另设 `e3a-handoff-recovery` 验证已完成回合后的安全交接点重启,两者独立验收。Web API 用例明确使用 `--skip-browser`,不启动浏览器。只跑一个用例可用 `--case a2a-recovery-contract` 或 `--case a2a-handoff-recovery-contract`。`--jobs` 限定为 1–16,确定性套件默认 3,真实套件默认 12。每个用例用独立子进程、配置目录和日志目录;确定性用例不会继承常见 LLM 与阿里云凭证环境变量。 权限批次新增四条 HTTP E2E,复用 `tests/a2a/test_permission_prompt_e2e.py`,仅模型与工具使用确定性替身。`permission-prompt-supersede-{same-process,after-restart}` 验证权限待确认时的新 prompt 取消整个未执行批次、补齐 tool_result,并阻止旧权限回复重新执行;`permission-confirmation-followup-{same-process,after-restart}` 验证连续正常确认权限后,工具只执行一次,下一条普通 prompt 正常处理,重复权限回复不会重跑工具。四条均纳入 `full`,也可单独执行: @@ -35,7 +35,7 @@ uv run --no-sync python scripts/ci/run_e2e.py --suite live --jobs 4 \ --allow-cloud-write ``` -默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 121 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 +默认三文件模式下,凭证目录须含 `.credentials.yml`、`.cloud-credentials.yml`、`settings.yml`。建议使用专用测试账号、受限权限和资源配额。`live` 共 130 个:42 个 selling flow A2A/REPL 场景、20 个 A2A 只读资源选择(含 12 个重启恢复矩阵)、6 个 AGUI HTTP/SSE 资源选择、16 个 REPL 旧场景、33 个 A2A 恢复场景、3 个 VPC 模板 smoke、1 个只读云 API canary、8 个 ROS 助手引导真实模型场景、1 个活动商品追问 A2A 场景。其中包含真实 ROS 资源创建用例。需要缩小范围时可选 `live-core`、`live-recovery`、`live-multimodal`、`live-readonly`、`live-legacy`、`live-safety`、`live-repl`、`live-smoke` 或 `live-agui`,也可用 `--case` 指定单个用例。AGUI 的 6 条覆盖普通聊天与 Pipeline 的选择、取消和直接输入,通过 HTTP/SSE 运行,不需要浏览器;使用文本模型池,查询已有 VPC,不创建资源,清理状态为“无需清理”。可单独运行 `--suite live-agui`,或指定 `--case agui-selector-normal-selected`。三个 VPC smoke 只接收 LLM 与 settings 配置,使用隔离的 HOME,不接收云凭证;它们验证生成模板,不创建 VPC。并行 selling 场景各用独立的 `10.250.0.0/16` 子网池,避免独立进程重复预留同一 VSwitch CIDR;原 runner 单独运行仍沿用原池。旧 A2A、REPL 和 selling 场景不强制测试 StackName;清理读取隔离会话中真实 CreateStack 接受回执,按实际 ID、名称和地域校验后删除。仅查询、等待或模型提及的资源不能授权删除;硬超时后由独立清理进程再次尝试。selling、REPL 旧用例和 A2A 恢复用例的硬超时为 45 分钟,之后最多留 15 分钟清理,再强制结束进程组。硬超时不代表清理成功,需检查报告和测试账号残留资源。 CI 可改用 `--cloud-credential-helper /path/to/helper.py`,此时源目录只需 LLM 凭证和 `settings.yml`。Helper 接口为 `cloud --output <目标 .cloud-credentials.yml 路径>`;它在每个真实云用例开始前调用,长用例每 10 分钟调用一次,异常清理前也会再次调用。若 helper 需要独立 Python 环境,传 `--cloud-credential-python /path/to/python`。Helper 必须原子地写入私有权限文件,且不得在标准输出或错误输出打印凭证。模板 smoke 不调用 helper。本地原有的三文件运行方式继续支持。 @@ -55,6 +55,31 @@ CI 可改用 `--cloud-credential-helper /path/to/helper.py`,此时源目录只 `live-readonly` 新增 `selector-restart-{normal,pipeline}-{sigterm,sigkill,released}-{selected,canceled}` 共 12 条,复用真实资源选择器重启脚本,查询已有 VPC,不创建云资源;隔离配置、模型分配、凭证刷新与安全报告沿用 selector runner。每条核对恢复后首个 SSE 帧的 execution 归属、消费回执以及下一轮普通 prompt。 +`ssf-candidate-batch-{same-process,cold-recovery}` 是 P18 的确定性回归/确认用例,纳入 `full`。使用生产 `selling_solution_first` 流程、AgentLoop、展示工具、会话文件和恢复逻辑,仅模型输出使用脚本替身:展示方案及详情 → 相同方案幂等重复展示 → 等待选择 → 用户选择方案。验证选择后直接进入模板生成阶段,保留原批次和所选方案,不再调用模型或要求重选;冷恢复用例重新构造 Runner/Storage 并从磁盘恢复,不模拟进程重启。用例在下一步骤启动时结束,不调用真实模型或云 API。 + +```bash +uv run --no-sync python scripts/ci/run_e2e.py \ + --case ssf-candidate-batch-same-process \ + --case ssf-candidate-batch-cold-recovery --jobs 1 --retries 0 +``` + +`ros-awareness-*` 纳入 `live-readonly`,验证生产 Step 1 引擎和工具在真实模型下的分流:优惠、续费、计费、GPU 库存价格、流量抵扣、默认地域,以及开关关闭、部署成本咨询两个对照场景。计费保留自然问法 `ros-awareness-billing` 和明确不部署的问法 `ros-awareness-billing-explicit`,两者均须正确分流。验收用户可见链接、地域、持久化结束原因及澄清边界;不替换模型输出,不覆盖 HTTP/UI,不调用云 API。只复制 LLM 凭证和 settings,不接收云凭证或调用云凭证 helper。可定向运行: + +```bash +uv run --no-sync python scripts/ci/run_e2e.py \ + --case ros-awareness-renewal --case ros-awareness-default-region \ + --case ros-awareness-disabled --case ros-awareness-deployment-pricing \ + --credential-source-dir /path/to/test-config --jobs 2 --retries 0 +``` + +`promotional-offer-followup-a2a` 通过生产 HTTP/SSE A2A Server 验证真实模型:先为“最低 2G、推荐 4G”的旅游 Agent 规划服务器,再保留会话追问活动商品,最后明确咨询活动购买资格。检查内存下限、部署上下文中的同地域助手引导,以及独立活动咨询的可见链接和持久化结束原因。测试不替换模型或工具结果、不选择部署方案,仅复制 LLM 凭证与 settings,不接收云凭证、不查询实际活动资格;首轮历史附图中的规格以文本复现。四模型覆盖需分别指定每个 `--text-model` 运行,不把单次调度当成四模型全覆盖。 + +```bash +uv run --no-sync python scripts/ci/run_e2e.py --case promotional-offer-followup-a2a \ + --text-model deepseek-v4.1-flash --credential-source-dir /path/to/test-config \ + --jobs 1 --retries 0 +``` + ## 真实用例的模型池与思考强度 真实套件默认采用滚动调度:文本用例使用 `deepseek-v4-flash-0731`、`glm-5.2-fast-preview`、`glm-5.3-prime`、`deepseek-v4.1-flash`,每模型最多两个用例;多模态用例使用 `qwen3.8-max`、`qwen3.8-max-0902`、`qwen3.8-flash`、`qwen3.8-omni-flash`,每模型最多一个用例。两类模型池不能互相借用,总共最多 12 个用例。仅有文本用例待运行时最多八个;仅有多模态用例时最多四个。 @@ -81,7 +106,29 @@ uv run --no-sync python scripts/ci/run_e2e.py --case ssf-a2a-happy-multi-plan \ ## CI 接入 -CI 调用同一个入口,并用 `--run-dir` 指定报告目录。`fast` 适合快速检查,`full` 适合完整确定性验收。真实场景必须提供测试专用配置目录和 `--allow-cloud-write`。配置文件内容应通过 CI 的 Secret 注入,且不要进入代码、作业输出或上传附件。 +`coupon-budget-a2a` 使用真实模型、HTTP/SSE A2A 和只读云 API,验证默认月费、明确小时与时长后的估价,以及当前部署中的优惠券查询不被分流。它也验证优惠券指南中的两条 API、字段与分页,不购买资源或实际使用优惠券: + +```bash +uv run python scripts/ci/run_e2e.py --case coupon-budget-a2a --suite live \ + --credential-source-dir /path/to/test-config --text-model glm-5.2-fast-preview + +# 仅验证指南 API,使用当前配置的云凭证,不调用模型 +uv run python scripts/ci/run_coupon_budget_a2a.py --api-only --allow-real-cloud \ + --run-dir /tmp/coupon-api-check +``` + +API 验证需要账号内实际存在优惠券及可查询的商品记录;空列表不能作为券字段和商品查询已验证的证明。API/模型原始结果留在私有运行目录,不上传为公开报告。该用例验证只读识别与规划,不证明特定订单或账单一定能抵扣。 + +`coupon-budget-mock-a2a` 补充没有真实可用券时的判断覆盖:A2A Server、模型和方案规划均使用真实实现,只在测试进程中替换 `DescribeCoupon`、`DescribeCouponItemList` 的业务返回;其它 BSS 操作在该测试进程中不开放。覆盖可用余额、余额不足、包年包月限制、GPU 排除、范围未知、过期、满减门槛、商品黑名单与查询失败九种情况。每种情况固定使用测试输入“8 小时 GPU 计算费 240 元”,核对模型表格中的抵扣额、需补现金及继续等待选案的流程状态;这不是 GPU 实时报价或实际结算证明。 + +```bash +uv run python scripts/ci/run_e2e.py --case coupon-budget-mock-a2a --suite live \ + --credential-source-dir /path/to/test-config --text-model glm-5.2-fast-preview +``` + +Mock 入口只位于 `scripts/ci/`,不修改应用运行时,也没有生产环境开关。真实结果与模拟结果使用不同用例名、报告目录及 `coupon_api_mocked` 标记;不可把该用例当成真实账单抵扣通过。 + +CI 调用同一个入口,并用 `--run-dir` 指定报告目录。`fast` 适合快速检查,`full` 适合完整确定性验收。真实场景必须提供测试专用配置目录;包含云写入的用例还需 `--allow-cloud-write`。配置文件内容应通过 CI 的 Secret 注入,且不要进入代码、作业输出或上传附件。 `report.md` 是概要表,`report.html` 可展开每个用例,`summary.json` 和各用例 `ci-result.json` 是结构化详情,`junit.xml` 供 CI 测试报告展示。确定性套件可上传 stdout/stderr;真实套件仅上传筛选后的状态文件,不上传原始日志、凭证、工作目录或场景原始 summary。 @@ -102,7 +149,7 @@ REPL 旧场景的 PTY 等待每 10 秒检查一次,每 60 秒在单用例的 s 先按 `report.md` 找到失败用例与首次失败检查,再看对应的 `ci-result.json`、场景 `summary.json`、日志和源码。Agent 应做有界复现,明确归类为产品缺陷、用例/断言缺陷、环境/凭证故障、云资源清理故障或超时,并写出证据、受影响场景和建议修复。配置的一次自动重跑之外,真实云用例再次复现前先核对残留资源与清理结果。报告里的“初步线索”只是索引,不能替代复盘结论。 -总入口目前登记 185 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 +总入口目前登记 199 个场景。暂未纳入的场景和原因在 `--list --suite all` 及每次报告中列出:Selling Web/Desktop、StartChat 权限等待、Qoder MCP 重连和浏览器 DOM 场景。Aone CI 没有浏览器;若未来提供浏览器运行机,再单独验证浏览器场景。真实用例在测试专用 Secret 配置后才能完成 CI 实跑验收。 ### 验收边界 diff --git a/scripts/ci/coupon_fixture_server.py b/scripts/ci/coupon_fixture_server.py new file mode 100644 index 000000000..2ed1b4cf0 --- /dev/null +++ b/scripts/ci/coupon_fixture_server.py @@ -0,0 +1,56 @@ +"""Test-process-only A2A bootstrap: substitute BSS results, preserve the real LLM.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from unittest.mock import patch + +from scripts.ci.coupon_fixtures import CASES, response + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--calls", type=Path, required=True) + parser.add_argument("--config", type=Path, required=True) + args = parser.parse_args() + from iac_code.cli.main import app + from iac_code.tools.base import ToolResult + from iac_code.tools.cloud.aliyun.aliyun_api import AliyunApi + + original = AliyunApi._execute_runtime_unattached + + async def execute(tool, *, api_input, **kwargs): + product = str(api_input.get("product", "")).casefold() + action = str(api_input.get("action", "")) + if product in {"bssopenapi", "bss-openapi", "bss"}: + case = json.loads(args.state.read_text(encoding="utf-8"))["case"] + if case not in CASES: + return ToolResult.error("Unknown coupon fixture") + params = api_input.get("params") or {} + with args.calls.open("a", encoding="utf-8") as out: + out.write(json.dumps({"case": case, "action": action, "mocked": True}) + "\n") + if case == "query_denied": + return ToolResult.error("AccessDenied: not authorized to query the current account's coupons.") + if api_input.get("version") != "2023-09-30": + return ToolResult.error("Coupon fixtures require the documented 2023-09-30 API version.") + if action not in {"DescribeCoupon", "DescribeCouponItemList"}: + return ToolResult.error("Only documented coupon read APIs are supported by this fixture.") + return ToolResult.success(json.dumps(response(case, action, params), ensure_ascii=False)) + if not action.startswith(("Describe", "Get", "List", "Query")): + with args.calls.open("a", encoding="utf-8") as out: + out.write(json.dumps({"blocked_cloud_write": True, "action": action}) + "\n") + return ToolResult.error("Cloud writes are blocked by this read-only test server.") + return await original(tool, api_input=api_input, **kwargs) + + # This entry point is never imported by the application or activated by a production flag. + with patch.object(AliyunApi, "_execute_runtime_unattached", execute): + sys.argv = [sys.argv[0], "a2a", "--config", str(args.config)] + app() + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/coupon_fixtures.py b/scripts/ci/coupon_fixtures.py new file mode 100644 index 000000000..4d113e157 --- /dev/null +++ b/scripts/ci/coupon_fixtures.py @@ -0,0 +1,86 @@ +"""Synthetic BSS business responses for real-model coupon acceptance tests only.""" + +from __future__ import annotations + +import copy +import uuid +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class CouponCase: + title: str + cash: int | None + requires_items: bool = False + + +CASES = { + "applicable": CouponCase("300 元可用余额覆盖 240 元计算费", 0, True), + "balance_60": CouponCase("面值 300、余额 60,需补现金 180", 180, True), + "prepaid_only": CouponCase("仅限包年包月新购,不适用按量账单", 240), + "gpu_excluded": CouponCase("支持 ECS,但明确排除 GPU", 240), + "scope_unknown": CouponCase("只确认 ECS 商品,GPU 条件不足", None, True), + "expired": CouponCase("有余额但已经过期", 240), + "threshold": CouponCase("满 1000 减 300,本次 240 不达门槛", 240), + "ecs_blacklisted": CouponCase("ECS 在不适用商品黑名单", 240, True), + "query_denied": CouponCase("查询权限不足,不能解释为没有券", None), +} + + +def coupon(case: str) -> dict[str, Any]: + index = list(CASES).index(case) + value: dict[str, Any] = { + "CouponId": 9900000000000 + index, + "CouponNo": f"STUDENT-300-{index:02d}", + "CouponType": "VOUCHER", "CouponTypeName": "抵用券", + "Amount": "300.00", "RemainAmount": "300.00", "Currency": "CNY", + "Status": "AVAILABLE", "StartTime": "2026-01-01T00:00Z", "EndTime": "2099-01-01T00:00Z", + "YhOrderTypes": ["阿里云按量付费账单"], "MoneyLimit": "无订单金额限制", + "OrderTimeRule": "按量付费账单无最低使用时长限制", "FirstBuy": False, + "ItemNames": ["云服务器ECS"], "SuitItemType": "white", + "Remark": ( + "学生优惠券。适用于 cn-beijing 地域的 ecs.gn6i-c16g1.4xlarge GPU 实例按量计算费用," + "无首购或活动渠道限制。系统盘、EIP 和公网流量费用不在抵扣范围内。" + ), + } + if case == "balance_60": + value["RemainAmount"] = "60.00" + elif case == "prepaid_only": + value.update(YhOrderTypes=["新购"], OrderTimeRule="仅包年包月新购订单", Remark="仅限包年包月新购。") + elif case == "gpu_excluded": + value["Remark"] = "仅支持非 GPU ECS 按量计算费用;明确排除所有 GPU 实例。" + elif case == "scope_unknown": + value["Remark"] = "学生券;实例规格、GPU、地域及活动渠道限制需另行核实。" + elif case == "expired": + value.update(Status="EXPIRED", StartTime="2019-01-01T00:00Z", EndTime="2020-01-01T00:00Z") + elif case == "threshold": + value.update(CouponType="CERTAIN", CouponTypeName="满减券", MoneyLimit="满1000元减300元") + elif case == "ecs_blacklisted": + value.update(SuitItemType="black", Remark="不适用商品以商品黑名单为准;黑名单优先。") + return value + + +def response(case: str, action: str, params: dict[str, Any]) -> dict[str, Any]: + """Honor the documented IDs and pagination instead of returning a canned page.""" + value = coupon(case) + if action == "DescribeCoupon": + rows = [value] + if params.get("CouponId") is not None and str(params["CouponId"]) != str(value["CouponId"]): + rows = [] + elif action == "DescribeCouponItemList": + rows = [{"Code": "vm", "Name": "云服务器ECS"}] + if str(params.get("CouponId", "")) != str(value["CouponId"]): + rows = [] + else: + raise ValueError("Unsupported coupon fixture action") + page = int(params.get("CurrentPage", 1)) + size = int(params.get("PageSize", 20)) + if page < 1 or not 1 <= size <= 100: + raise ValueError("Invalid coupon fixture pagination") + return { + # Do not expose the scenario name or expected decision in model-visible IDs. + "RequestId": str(uuid.uuid5(uuid.NAMESPACE_URL, f"coupon-test/{case}/{action}/{page}")).upper(), + "CurrentPage": page, "PageSize": size, "TotalCount": len(rows), + "Data": copy.deepcopy(rows[(page - 1) * size:page * size]), + } diff --git a/scripts/ci/run_coupon_budget_a2a.py b/scripts/ci/run_coupon_budget_a2a.py new file mode 100644 index 000000000..e8cbccdb9 --- /dev/null +++ b/scripts/ci/run_coupon_budget_a2a.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Verify coupon guide APIs and period-aware deployment planning with real services. + +Use run_e2e.py for isolated real-LLM HTTP/SSE runs. --api-only exercises every +documented API recipe with the configured cloud identity, without an LLM. +No cloud resource writes, purchases, coupon changes or model fixtures are used. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import re +import sys +import time +from decimal import Decimal +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + fetch_pipeline_state, + stream_message, + wait_for_server, +) +from scripts.ci.run_promotional_offer_followup_a2a import ( # noqa: E402 + completion_inputs, + pipeline_facts, + visible_pipeline_text, + walk, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +PROMPTS = ( + "在阿里云北京给我推荐一个 GPU 云服务器方案,显存 8–16 GiB,内存至少 32 GiB," + "用于推理测试。请展示方案等我选择,当前不要部署或创建任何云资源。", + "我不是说按小时租吗?只租 8 小时,GPU 显存和内存要求不变。先更新方案估算," + "不要选择方案或进入部署。", + "我有 300 元学生优惠券,想用于刚才这个 GPU 部署方案,能抵扣按量付费吗?" + "我不想再补现金。请查一下当前账号的券,说明哪些适用条件能确认、哪些还不清楚。" + "不要替我领券、用券、下单或部署。", +) + + +def hourly_costs_match(outlines: list[str], details: list[str]) -> bool: + """Priced items need the requested unit; free topology resources have no rate.""" + def hourly(value: str) -> bool: + # Check the stated estimate period, not a later explanation such as "非月费". + period = re.search(r"小时|hours?|月|months?", value, re.I) + return period is not None and period.group().casefold() in {"小时", "hour", "hours"} + + def free(value: str) -> bool: + return bool(re.fullmatch(r"\s*(?:(?:[¥¥]\s*)?0(?:\.0+)?|免费|无费用)(?:\s*[((].*[))])?\s*", value)) + + return bool(outlines) and all(hourly(value) for value in outlines) and all( + hourly(value) or free(value) for value in details + ) + + +async def verify_apis(run_dir: Path) -> dict[str, Any]: + from iac_code.services.providers.aliyun import AliyunCredentials + from iac_code.tools.base import ToolContext + from iac_code.tools.cloud.aliyun.runtime import create_aliyun_runtime_services + + runtime = create_aliyun_runtime_services(cache_dir=run_dir / "api-cache") + runtime.credential_provider = lambda: AliyunCredentials.refresh_oauth_if_needed(AliyunCredentials.load()) + evidence: list[dict[str, Any]] = [] + + async def call(action: str, params: dict[str, Any]) -> dict[str, Any]: + result = await runtime.internal_caller.call( + tool_input={ + "product": "BssOpenApi", "version": "2023-09-30", "action": action, + "region_id": "cn-hangzhou", "params": params, + }, + context=ToolContext(cwd=str(run_dir), pipeline_mode=True), + ) + if result.is_error: + raise RuntimeError(f"{action} did not return a successful business result") + body = json.loads(result.content) + if not isinstance(body.get("Data"), list): + raise ValueError(f"{action} returned an unexpected Data shape") + evidence.append({ + "action": action, "version": "2023-09-30", "page": params["CurrentPage"], + "count": len(body["Data"]), "total": body["TotalCount"], + "fields": sorted({key for item in body["Data"] for key in item}), + }) + return body + + try: + coupons = await call("DescribeCoupon", {"CurrentPage": 1, "PageSize": 20}) + required = { + "CouponId", "CouponType", "CouponTypeName", "Amount", "RemainAmount", "Currency", + "Status", "StartTime", "EndTime", "YhOrderTypes", "MoneyLimit", "OrderTimeRule", + "FirstBuy", "Remark", "ItemNames", "SuitItemType", + } + if not coupons["Data"]: + raise RuntimeError("A real coupon is required to verify the documented fields and item query") + present = {key for coupon in coupons["Data"] for key in coupon} + if not required <= present: + raise ValueError("Documented coupon fields have not all been observed in real responses") + for coupon in coupons["Data"]: + Decimal(coupon["Amount"]) + Decimal(coupon["RemainAmount"]) + if coupons["TotalCount"] > len(coupons["Data"]): + await call("DescribeCoupon", {"CurrentPage": 2, "PageSize": 20}) + items = await call("DescribeCouponItemList", { + "CouponId": int(coupons["Data"][0]["CouponId"]), "CurrentPage": 1, "PageSize": 20, + }) + if not items["Data"] or not all({"Code", "Name"} <= item.keys() for item in items["Data"]): + raise ValueError("A non-empty real coupon item list is required to verify Code and Name") + if items["TotalCount"] > len(items["Data"]): + await call("DescribeCouponItemList", { + "CouponId": int(coupons["Data"][0]["CouponId"]), "CurrentPage": 2, "PageSize": 20, + }) + return {"passed": True, "calls": evidence, "settlement_verified": False} + finally: + await runtime.aclose() + + +def tool_inputs(config_dir: Path) -> list[dict[str, Any]]: + found: dict[str, dict[str, Any]] = {} + for path in sorted((config_dir / "projects").rglob("session.jsonl")): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") == "tool_use": + found[str(node["id"])] = { + "id": node["id"], "name": node.get("name"), "input": node.get("input", {}), + } + return list(found.values()) + + +def successful_coupon_queries(config_dir: Path) -> set[str]: + successful = set() + for path in (config_dir / "projects").rglob("session.jsonl"): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") != "tool_result" or node.get("is_error") is not False: + continue + try: + body = json.loads(node.get("content", "")) + except (ValueError, TypeError): + continue + if isinstance(body, dict) and isinstance(body.get("Data"), list) and "TotalCount" in body: + successful.add(node["tool_use_id"]) + return successful + + +def run_a2a(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run through run_e2e.py with isolated configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", "IAC_CODE_PROVIDER": args.provider, + "IAC_CODE_MODEL": args.model, "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + ) + checks: dict[str, bool] = {} + turns = [] + context_id = task_id = "" + try: + server.start() + wait_for_server(f"http://127.0.0.1:{port}", timeout=60, owned_server=server) + for index, prompt in enumerate(PROMPTS): + started = time.monotonic() + before = len(tool_inputs(config_dir)) + summary = stream_message( + server_url=f"http://127.0.0.1:{port}", cwd=str(workspace), prompt=prompt, + context_id=context_id, task_id=task_id, name=f"turn-{index + 1}", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + context_id, task_id = summary.context_id, summary.task_id + fetch_pipeline_state( + server_url=f"http://127.0.0.1:{port}", context_id=context_id, task_id=task_id, + run_dir=args.run_dir, name=f"turn-{index + 1}", redaction_env=env, + ) + conclusion, steps = pipeline_facts(args.run_dir / f"turn-{index + 1}.events.jsonl") + completions = completion_inputs(config_dir) + conclusion = conclusion or (completions[-1] if completions else {}) + calls = tool_inputs(config_dir)[before:] + outline_costs = [ + str(candidate.get("total_cost", candidate.get("total_monthly_cost", ""))) + for call in calls if call["name"] == "show_architecture_plan" + for candidate in call["input"].get("candidates", []) + ] + detail_costs = [ + str(resource.get("rough_cost", resource.get("rough_monthly_cost", ""))) + for call in calls if call["name"] == "show_candidate_detail" + for resource in call["input"].get("resource_inventory", []) + if resource.get("rough_cost", resource.get("rough_monthly_cost")) + ] + text = visible_pipeline_text(args.run_dir / f"turn-{index + 1}.events.jsonl") + checks[f"turn {index + 1}: real HTTP/SSE response"] = summary.response_content_type == "text/event-stream" + checks[f"turn {index + 1}: remains in deployment planning"] = ( + not summary.normal_handoff_ready and conclusion.get("status") != "rejected" + and "materialize_selected_candidate" not in steps + and summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" + ) + if index == 0: + checks["default estimate is monthly"] = bool(outline_costs) and all( + re.search(r"月|month", cost, re.I) for cost in outline_costs + ) + elif index == 1: + checks["explicit hourly request updates total and details"] = hourly_costs_match( + outline_costs, detail_costs, + ) + checks["eight-hour duration is explained"] = bool(re.search( + r"8\s*(?:个)?\s*小时|8\s*hours?|八小时", text + json.dumps(calls, ensure_ascii=False), re.I, + )) + else: + checks["coupon guide was read on demand"] = any( + call["name"] == "read_file" and "coupons.md" in json.dumps(call["input"]) + for call in calls + ) + checks["coupon inquiry uses a real cloud API"] = any( + call["name"] == "aliyun_api" and call["input"].get("action") == "DescribeCoupon" + and call["id"] in successful_coupon_queries(config_dir) + for call in calls + ) + checks["coupon inquiry does not redirect to assistant"] = "ros.console.aliyun.com/" not in text + turns.append({ + "turn": index + 1, "seconds": round(time.monotonic() - started, 2), + "task_state": summary.last_status_state, "outline_costs": outline_costs, + "detail_costs": detail_costs, "started_steps": steps, + "tools": [call["name"] for call in calls], + }) + print(json.dumps({"model": args.model, **turns[-1]}, ensure_ascii=False), flush=True) + calls = tool_inputs(config_dir) + legacy_cost_fields = {"total_monthly_cost", "rough_monthly_cost", "monthly_range", "monthly_cost"} + checks["planning tool inputs use neutral cost names"] = all( + not legacy_cost_fields.intersection(node) + for call in calls if call["name"] in {"show_architecture_plan", "show_candidate_detail"} + for node in walk(call["input"]) + ) + checks["no cloud writes or deployment tools"] = all( + call["input"].get("action", "").startswith(("Describe", "Get", "List", "Query")) + for call in calls if call["name"] == "aliyun_api" + ) and not any(call["name"].startswith("ros_deploy") for call in calls) + finally: + server.terminate() + records = observe.stop() + for name in (".credentials.yml", ".cloud-credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["requested real model only"] = observed == [args.model] + return {"checks": checks, "turns": turns, "observed_models": observed} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", default="deepseek-v4.1-flash") + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--python") + parser.add_argument("--timeout", type=float, default=420) + parser.add_argument("--api-only", action="store_true") + parser.add_argument("--allow-real-cloud", action="store_true") + args = parser.parse_args() + if not args.allow_real_cloud: + parser.error("--allow-real-cloud is required") + args.run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + logging.disable(logging.CRITICAL) + from loguru import logger + logger.remove() + result: dict[str, Any] = {"scenario": "coupon-budget-a2a", "cleanup_status": "not-needed"} + try: + result["api_verification"] = asyncio.run(verify_apis(args.run_dir)) + if not args.api_only: + result.update(run_a2a(args)) + result["status"] = "passed" if all(result.get("checks", {}).values()) else "failed" + except Exception as exc: + result.update(status="failed", error_type=type(exc).__name__) + (args.run_dir / "summary.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False), flush=True) + return 0 if result["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_coupon_budget_mock_a2a.py b/scripts/ci/run_coupon_budget_mock_a2a.py new file mode 100644 index 000000000..c5946d203 --- /dev/null +++ b/scripts/ci/run_coupon_budget_mock_a2a.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Real HTTP/SSE A2A + real LLM, with only BSS coupon results substituted. + +The 240 CNY compute charge is a test input, not a current GPU quote. Other +read-only cloud calls retain the configured real credential and transport. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +from decimal import Decimal +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + stream_message, + wait_for_server, +) +from scripts.ci.coupon_fixtures import CASES, coupon # noqa: E402 +from scripts.ci.run_coupon_budget_a2a import tool_inputs # noqa: E402 +from scripts.ci.run_promotional_offer_followup_a2a import ( # noqa: E402 + pipeline_facts, + visible_pipeline_text, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +SETUP = ( + "准备在阿里云北京按量租一台 GPU 服务器,指定 ecs.gn6i-c16g1.4xlarge," + "显存 16 GiB、内存 62 GiB,只用 8 小时。请给一个方案让我选择,暂不部署," + "不创建、修改或删除任何云资源。" +) +QUESTION = ( + "继续刚才北京的 GPU 部署方案,我现在换用另一张面值 300 元的学生券,条件可能不同," + "请重新查询当前账号的券和适用商品,不沿用之前的券查询结果。" + "本次只核对 GPU 计算部分:按量使用 8 小时,控制台给出的计算费合计 240 元;" + "请按这 240 元分析用券,不另查价格。系统盘、EIP、公网流量不包括在这 240 元内,其他费用另算。" + "我希望这部分不补现金。请用表格分别列出 GPU 计算费、券的可用余额、" + "本次计算费可抵扣金额、计算部分需补现金、结论依据;" + "无法确认的填写待确认,不要猜测。暂不选择方案、不重新规划、不下单或用券。" +) + + +def has_assistant_reference(text: str) -> bool: + """Recognize console links by their parsed host, not a substring in an arbitrary URL.""" + for url in re.findall(r"https?://[^\s<>()\[\]]+", text): + try: + parsed = urlsplit(url) + if parsed.scheme == "https" and parsed.hostname == "ros.console.aliyun.com": + return True + except ValueError: + continue + return False + + +def answer_cell(text: str, label: str) -> str: + """Accept vertical or horizontal Markdown tables without using unrelated amounts.""" + rows = [ + [cell.strip() for cell in line.strip().strip("|").split("|")] + for line in text.splitlines() if line.strip().startswith("|") + ] + for index, cells in enumerate(rows): + # Only a row immediately above a Markdown separator is a horizontal + # header. A data cell may mention cash without naming the cash column. + if index + 2 >= len(rows) or not all( + re.fullmatch(r"[\s:-]+", item) for item in rows[index + 1] + ): + continue + for column, cell in enumerate(cells): + if re.search(label, cell) and len(rows[index + 2]) > column: + return rows[index + 2][column] + for cells in rows: + if len(cells) >= 2 and re.search(label, cells[0]): + return cells[1] + return "" + + +def cash_cell(text: str) -> str: + return answer_cell(text, r"现金|自付|自费|差价") + + +def amount_matches(cell: str, expected: int | None) -> bool: + uncertainty = re.sub(r"待确认项[::\s]*无|无待确认项", "", cell) + if expected is None: + return bool(re.search(r"待确认|未知|不确定|无法|未确认|不能确认|待核实|如果|若|假设", uncertainty)) + if re.search(r"待确认|未知|不确定|无法确认|未确认", uncertainty) and not re.search(r"预计|估算|预估", cell): + return False + amounts = [Decimal(value.replace(",", "")) for value in re.findall(r"\d[\d,]*(?:\.\d+)?", cell)] + return bool(amounts) and amounts[0] == Decimal(expected) + + +def evaluate_answer(case: str, text: str) -> dict[str, bool]: + cash = CASES[case].cash + coverage = None if cash is None else 240 - cash + return { + "cash calculation or unknown matches evidence": amount_matches(cash_cell(text), cash), + "deduction calculation or unknown matches evidence": amount_matches( + answer_cell(text, r"抵扣金额|抵扣费用"), coverage, + ), + } + + +def recorded_calls(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def run(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run through run_e2e.py with isolated configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + active = args.run_dir / "coupon-fixture-state.json" + ledger = args.run_dir / "coupon-fixture-calls.jsonl" + active.write_text(json.dumps({"case": "applicable"}), encoding="utf-8") + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", "IAC_CODE_PROVIDER": args.provider, + "IAC_CODE_MODEL": args.model, "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + server_args=[ + "-m", "scripts.ci.coupon_fixture_server", "--state", str(active), + "--calls", str(ledger), "--config", str(config), + ], + ) + checks: dict[str, bool] = {} + rows = [] + server_url = f"http://127.0.0.1:{port}" + try: + server.start() + wait_for_server(server_url, timeout=60, owned_server=server) + setup = stream_message( + server_url=server_url, cwd=str(workspace), prompt=SETUP, name="setup", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + checks["real setup waits for selection"] = ( + setup.last_status_state == "TASK_STATE_INPUT_REQUIRED" and not setup.normal_handoff_ready + ) + if not checks["real setup waits for selection"]: + raise RuntimeError("Real-model setup did not reach the planning selection boundary") + context_id, task_id = setup.context_id, setup.task_id + for case in args.scenario or CASES: + active.write_text(json.dumps({"case": case}), encoding="utf-8") + start = time.monotonic() + before = len(recorded_calls(ledger)) + prior_tool_ids = {call["id"] for call in tool_inputs(config_dir)} + summary = stream_message( + server_url=server_url, cwd=str(workspace), prompt=QUESTION, + context_id=context_id, task_id=task_id, name=case, + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + ) + context_id, task_id = summary.context_id, summary.task_id + path = args.run_dir / f"{case}.events.jsonl" + text = visible_pipeline_text(path) + # Fixtures contain no real user data. Keep the model answer for human review. + (args.run_dir / f"{case}.answer.txt").write_text(text, encoding="utf-8") + conclusion, steps = pipeline_facts(path) + calls = recorded_calls(ledger)[before:] + cell = cash_cell(text) + local = { + "real HTTP/SSE": summary.response_content_type == "text/event-stream", + "visible answer returned for the original question": bool(text.strip()), + "queried current coupon fixture": any( + call.get("case") == case and call.get("action") == "DescribeCoupon" + and call.get("mocked") is True for call in calls + ), + **evaluate_answer(case, text), + "stays in deployment planning": ( + summary.last_status_state == "TASK_STATE_INPUT_REQUIRED" + and not summary.normal_handoff_ready and conclusion.get("status") != "rejected" + and "materialize_selected_candidate" not in steps + ), + "no assistant handoff": not summary.normal_handoff_ready and conclusion.get("status") != "rejected", + } + if CASES[case].requires_items: + local["queried item scope"] = any( + call.get("case") == case and call.get("action") == "DescribeCouponItemList" for call in calls + ) + local["item query uses the current coupon ID"] = any( + call["id"] not in prior_tool_ids and call["name"] == "aliyun_api" + and call["input"].get("action") == "DescribeCouponItemList" + and str(call["input"].get("params", {}).get("CouponId")) == str(coupon(case)["CouponId"]) + and int(call["input"].get("params", {}).get("CurrentPage", 1)) == 1 + for call in tool_inputs(config_dir) + ) + if case == "applicable": + local["compute coverage is quantified"] = "240" in text + local["settlement caveat remains"] = bool(re.search(r"账单|结算|以实际|实际抵扣", text)) + for key, value in local.items(): + checks[f"{case}: {key}"] = value + row = { + "case": case, "title": CASES[case].title, "expected_cash": CASES[case].cash, + "cash_cell": cell, "passed": all(local.values()), "checks": local, + "assistant_reference_present": has_assistant_reference(text), + "seconds": round(time.monotonic() - start, 2), + } + rows.append(row) + print(json.dumps({"model": args.model, **row}, ensure_ascii=False), flush=True) + if summary.normal_handoff_ready: + break + all_calls = recorded_calls(ledger) + checks["no cloud writes reached transport"] = not any(c.get("blocked_cloud_write") for c in all_calls) + checks["all selected cases completed"] = len(rows) == len(args.scenario or CASES) + checks["coupon guide was actually read"] = any( + call["name"] == "read_file" and "coupons.md" in json.dumps(call["input"]) + for call in tool_inputs(config_dir) + ) + finally: + server.terminate() + records = observe.stop() + for name in (".credentials.yml", ".cloud-credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["only requested real model ran"] = observed == [args.model] + return { + "status": "passed" if all(checks.values()) else "failed", "checks": checks, "cases": rows, + "model": args.model, "observed_models": observed, + "coupon_api_mocked": True, "llm_mocked": False, "settlement_verified": False, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", default="glm-5.2-fast-preview") + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--python") + parser.add_argument("--scenario", choices=tuple(CASES), action="append") + parser.add_argument("--timeout", type=float, default=420) + parser.add_argument("--allow-real-cloud", action="store_true") + args = parser.parse_args() + if not args.allow_real_cloud: + parser.error("--allow-real-cloud is required for the remaining real read-only APIs and LLM") + args.run_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + result: dict[str, Any] = {"scenario": "coupon-budget-mock-a2a", "cleanup_status": "not-needed"} + try: + result.update(run(args)) + except Exception as exc: + result.update(status="failed", error_type=type(exc).__name__) + (args.run_dir / "summary.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False), flush=True) + return 0 if result["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_e2e.py b/scripts/ci/run_e2e.py index d26c9bd6f..717104d50 100644 --- a/scripts/ci/run_e2e.py +++ b/scripts/ci/run_e2e.py @@ -26,6 +26,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] CLOUD_REFRESH_SECONDS = 600 +LLM_ONLY_RUNNERS = frozenset({"smoke", "ros_awareness", "promotional_offer_followup"}) SAFE_LIVE_AUDIT_NOTE = re.compile( r"credential audit: source=(?:llm|cloud); " r"location=(?:logs|artifacts|workspace|templates|other); " @@ -90,6 +91,7 @@ from scripts.a2a.e2e.run_recovery_scenarios import MULTIMODAL_SCENARIOS as A2A_MULTIMODAL_SCENARIOS # noqa: E402 from scripts.ci.live_diagnostics import collect_live_diagnostics # noqa: E402 from scripts.ci.model_pool import MULTIMODAL_MODELS, TEXT_MODELS, ModelAssignment, scheduled_cases # noqa: E402 +from scripts.ci.run_ros_agent_awareness import SCENARIOS as ROS_AWARENESS_SCENARIOS # noqa: E402 from scripts.pipeline.e2e.selling_solution_first.run_scenarios import SCENARIOS as SELLING_SCENARIOS # noqa: E402 from scripts.repl.e2e.run_pipeline_scenarios import _SCENARIOS as REPL_PIPELINE_SCENARIOS # noqa: E402 from scripts.repl.e2e.run_pipeline_scenarios import MULTIMODAL_SCENARIOS as REPL_MULTIMODAL_SCENARIOS # noqa: E402 @@ -209,7 +211,15 @@ class CloudCredentialSetupError(RuntimeError): ) for decision in ("allow_once", "deny", "prompt") ) -CASES = FAST_CASES + EXECUTION_CASES + PERMISSION_CASES +CANDIDATE_BATCH_CASES = tuple( + Case( + "ssf-candidate-batch-" + mode, + "scripts/pipeline/e2e/run_candidate_batch_recovery.py", + ("--mode", mode), 90, "full", + ) + for mode in ("same-process", "cold-recovery") +) +CASES = FAST_CASES + EXECUTION_CASES + PERMISSION_CASES + CANDIDATE_BATCH_CASES LIVE_SCRIPT = "scripts/pipeline/e2e/selling_solution_first/run_scenarios.py" SELLING_CIDR_POOLS = tuple(str(pool) for pool in ipaddress.IPv4Network("10.250.0.0/16").subnets(new_prefix=22)) if len(SELLING_SCENARIOS) > len(SELLING_CIDR_POOLS): @@ -287,7 +297,25 @@ def _selling_group(spec: Any) -> str: "repl-aliyun-readonly-canary", "scripts/repl/e2e/run_real_aliyun_contract_canary.py", (), 900, "live", cleanup_grace=60, live_runner="canary", group="readonly", ), +) + tuple( + Case( + "ros-awareness-" + scenario, "scripts/ci/run_ros_agent_awareness.py", + ("--scenario", scenario), 240, "live", live_runner="ros_awareness", group="readonly", + ) + for scenario in ROS_AWARENESS_SCENARIOS ) + ( + Case( + "promotional-offer-followup-a2a", "scripts/ci/run_promotional_offer_followup_a2a.py", (), 1200, "live", + live_runner="promotional_offer_followup", group="readonly", + ), + Case( + "coupon-budget-a2a", "scripts/ci/run_coupon_budget_a2a.py", (), 1800, "live", + live_runner="coupon_budget", group="readonly", + ), + Case( + "coupon-budget-mock-a2a", "scripts/ci/run_coupon_budget_mock_a2a.py", (), 3600, "live", + live_runner="coupon_budget_mock", group="readonly", + ), Case("smoke-a2a-vpc", "scripts/a2a/smoke/test_a2a_vpc.py", (), 800, "live", live_runner="smoke", group="smoke"), Case("smoke-acp-vpc", "scripts/acp/smoke/test_acp_vpc.py", (), 450, "live", live_runner="smoke", group="smoke"), Case( @@ -373,7 +401,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.error("--cloud-credential-python must name an existing Python interpreter") if args.cloud_credential_python is not None and args.cloud_credential_helper is None: parser.error("--cloud-credential-python requires --cloud-credential-helper") - needs_cloud = any(case.live_runner != "smoke" for case in selected) + needs_cloud = any(case.live_runner not in LLM_ONLY_RUNNERS for case in selected) required_files = [".credentials.yml", "settings.yml"] if needs_cloud and args.cloud_credential_helper is None: required_files.append(".cloud-credentials.yml") @@ -396,7 +424,7 @@ def select_cases(args: argparse.Namespace) -> list[Case]: if args.suite == "fast": return list(FAST_CASES) if args.suite == "full": - return list(FAST_CASES + EXECUTION_CASES + PERMISSION_CASES) + return list(FAST_CASES + EXECUTION_CASES + PERMISSION_CASES + CANDIDATE_BATCH_CASES) if args.suite == "live": return list(LIVE_CASES) if args.suite.startswith("live-"): @@ -493,7 +521,10 @@ def _read_summary(case_dir: Path, source: str) -> dict[str, Any] | None: def _live_cleanup_status(case: Case, summary: dict[str, Any] | None) -> str: - if case.live_runner in {"selector", "agui_selector", "canary", "legacy_a2a_readonly", "smoke"}: + readonly_runners = { + "selector", "agui_selector", "canary", "legacy_a2a_readonly", "coupon_budget", "coupon_budget_mock", + } + if case.live_runner in readonly_runners | LLM_ONLY_RUNNERS: return "not-needed" if summary is None: return "unverified" @@ -735,6 +766,7 @@ def _public_live_summary(summary: dict[str, Any] | None, cleanup_status: str | N "text_exit_code", "text_output_length", "smoke_sync_output_length", "smoke_sync_task_count", "smoke_sync_trailing_agent_message_count", "smoke_sync_permission_response_count", + "coupon_visible_answer_followup_count", "cleanup_turn_event_count", "cleanup_turn_cleanup_event_count", "cleanup_target_count", "cleanup_ledger_pending_count", "cleanup_delete_tool_use_count", "cleanup_get_tool_use_count", "cleanup_failure_event_count", "cleanup_delete_http_status", "cleanup_get_http_status", @@ -1479,7 +1511,8 @@ def run_case( ) source_config_dir.mkdir(mode=0o700, exist_ok=True) filenames = ( - (".credentials.yml", "settings.yml") if case.live_runner == "smoke" or cloud_credential_helper is not None + (".credentials.yml", "settings.yml") + if case.live_runner in LLM_ONLY_RUNNERS or cloud_credential_helper is not None else (".credentials.yml", ".cloud-credentials.yml", "settings.yml") ) for filename in filenames: @@ -1493,9 +1526,11 @@ def run_case( command.extend(("--model", model_assignment.model)) if case.live_runner not in {"canary", "agui_selector"}: command.extend(("--provider", "dashscope")) - if case.live_runner != "smoke" and cloud_credential_helper is not None: + if case.live_runner not in LLM_ONLY_RUNNERS and cloud_credential_helper is not None: _prepare_cloud_credentials(cloud_credential_helper, source_config_dir, cloud_credential_python) - if case.live_runner != "smoke": + if case.live_runner in {"ros_awareness", "promotional_offer_followup"}: + command.append("--allow-real-llm") + elif case.live_runner not in LLM_ONLY_RUNNERS: command.append("--allow-real-cloud") if case.live_runner == "selling": command.extend( @@ -1510,7 +1545,9 @@ def run_case( command.extend(("--source-config-dir", str(source_config_dir))) elif case.live_runner == "repl": command.extend(("--source-config-dir", str(source_config_dir))) - if case in FAST_CASES or (case.suite == "live" and case.live_runner not in {"smoke", "agui_selector"}): + if case in FAST_CASES or ( + case.suite == "live" and case.live_runner not in LLM_ONLY_RUNNERS | {"agui_selector"} + ): command.extend(("--python", sys.executable)) case_env = _case_env(case_dir, case, case_user_id) case_env['IAC_CODE_E2E_CASES_DIR'] = str((run_dir / 'runs').resolve()) @@ -1521,7 +1558,7 @@ def run_case( if model_assignment is not None: case_env.update({"IAC_CODE_PROVIDER": "dashscope", "IAC_CODE_MODEL": model_assignment.model}) case_env["IAC_CODE_E2E_DIAGNOSIS_LOCK"] = str(run_dir.resolve() / ".diagnosis-slot") - if case.live_runner == "smoke": + if case.live_runner in LLM_ONLY_RUNNERS: isolated_home = case_dir / "home" isolated_home.mkdir(mode=0o700, exist_ok=True) case_env["HOME"] = str(isolated_home) @@ -1560,7 +1597,11 @@ def refresh_cloud_credentials() -> None: start_new_session=os.name != "nt", creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, ) - if cloud_credential_helper is not None and case.suite == "live" and case.live_runner != "smoke": + if ( + cloud_credential_helper is not None + and case.suite == "live" + and case.live_runner not in LLM_ONLY_RUNNERS + ): refresh_thread = threading.Thread(target=refresh_cloud_credentials, daemon=True) refresh_thread.start() try: diff --git a/scripts/ci/run_promotional_offer_followup_a2a.py b/scripts/ci/run_promotional_offer_followup_a2a.py new file mode 100644 index 000000000..cdbcff66b --- /dev/null +++ b/scripts/ci/run_promotional_offer_followup_a2a.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Verify minimum specs and promotional offer followups over real HTTP/SSE A2A. + +Run via run_e2e.py for private LLM-only configuration. No scripted model or +tool results are injected. Cloud credentials are not supplied to this case. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import Any +from urllib.request import Request, urlopen + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO)) +sys.path.insert(0, str(REPO / "src")) + +from scripts.a2a.debugger import ( # noqa: E402 + A2A_VERSION_HEADERS, + _extract_pipeline_envelopes, + build_task_cancel_payload, +) +from scripts.a2a.e2e.common import ( # noqa: E402 + ManagedServer, + _free_port, + _write_server_config, + fetch_pipeline_state, + stream_message, + wait_for_server, +) +from scripts.observability.local_observe.e2e_audit import ObserveCapture # noqa: E402 + +PROMPTS = ( + "我自己在做一个 agent 小项目,是旅游规划。建议腾讯云或者阿里云," + "这次用阿里云杭州,一台服务器,给我推荐一个方案。" + "算力要求:2核4G(最低2核2G,推荐4G内存以避免运行时内存不足)。" + "系统镜像要选 CentOS,防火墙开放端口:10010,9090,3000,59000,54328,需要公网访问。", + "为什么不能选那个99元的?ecs.e-c1m1.large 热门推荐,经济型 e,2 vCPU,2 GiB," + "76个可用区,X86计算-共享型,¥45.14/月,惠¥19.86/月。", + "我现在只想确认这个99元活动我的账号能不能买、优惠包含什么、从哪里买,暂时不继续部署。", +) +DESTINATION = "https://ros.console.aliyun.com/cn-hangzhou/chat" + + +def walk(value: Any): + if isinstance(value, dict): + yield value + for item in value.values(): + yield from walk(item) + elif isinstance(value, list): + for item in value: + yield from walk(item) + + +def completion_inputs(config_dir: Path) -> list[dict[str, Any]]: + found: dict[str, dict[str, Any]] = {} + paths = (config_dir / "projects").rglob("session.jsonl") + for path in sorted(paths, key=lambda item: item.stat().st_mtime_ns): + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for node in walk(json.loads(line)): + if node.get("type") == "tool_use" and node.get("name") == "complete_step": + value = node.get("input", {}).get("conclusion") + if isinstance(value, dict): + found[str(node.get("id", len(found)))] = value + return list(found.values()) + + +def memory_constraints(value: Any) -> list[dict[str, Any]]: + return [ + item + for node in walk(value) + for item in node.get("hard_constraints", []) + if isinstance(item, dict) and str(item.get("property", "")).casefold() in {"memory", "memory_gib"} + ] + + +def visible_pipeline_text(path: Path) -> str: + """Use only user-visible pipeline deltas, excluding tool arguments and thinking.""" + text = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for envelope in _extract_pipeline_envelopes(json.loads(line)): + if envelope.get("eventType") == "text_delta": + value = envelope.get("data", {}).get("text") + if isinstance(value, str): + text.append(value) + return "".join(text) + + +def pipeline_facts(path: Path) -> tuple[dict[str, Any], list[str]]: + conclusion: dict[str, Any] = {} + steps = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + for envelope in _extract_pipeline_envelopes(json.loads(line)): + data = envelope.get("data", {}) + value = data.get("normalizedConclusion") or data.get("conclusion") + if isinstance(value, dict) and value.get("status"): + conclusion = value + step = envelope.get("step", {}) + if envelope.get("eventType") == "step_started" and isinstance(step, dict) and step.get("id"): + steps.append(step["id"]) + return conclusion, steps + + +def run(args: argparse.Namespace) -> dict[str, Any]: + config_dir = Path(os.environ["IAC_CODE_CONFIG_DIR"]) + if not config_dir.resolve().is_relative_to(args.run_dir.resolve()): + raise ValueError("Run via run_e2e.py with isolated configuration") + if (config_dir / ".cloud-credentials.yml").exists(): + raise ValueError("This case requires LLM-only configuration") + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + port = _free_port("127.0.0.1") + config = _write_server_config(args.run_dir, host="127.0.0.1", port=port, auto_approve_permissions=True) + observe = ObserveCapture(args.run_dir / "telemetry").start() + env = dict(os.environ) + env.update(observe.env) + env.update({ + "PYTHONPATH": str(REPO / "src"), + "IAC_CODE_MODE": "pipeline", + "IAC_CODE_PIPELINE_NAME": "selling_solution_first", + "IAC_CODE_ROS_AGENT_AWARENESS": "true", + "IAC_CODE_TELEMETRY_LOCAL_ONLY": "1", + "IAC_CODE_PROVIDER": args.provider or "dashscope", + "IAC_CODE_MODEL": args.model, + "IAC_CODE_CONFIG_BACKUP_DIR": str(args.run_dir / "backup"), + }) + server = ManagedServer( + python_cmd=[sys.executable], config_path=config, process_cwd=str(REPO), + allowed_cwd=str(workspace), env=env, log_prefix=args.run_dir / "server", + ) + checks: dict[str, bool] = {} + turns = [] + context_id = task_id = "" + try: + server.start() + wait_for_server(f"http://127.0.0.1:{port}", timeout=60, owned_server=server) + for index, prompt in enumerate(PROMPTS): + started = time.monotonic() + canceled_unselected_implementation = False + + def stop_unselected_implementation(event: Any) -> None: + nonlocal canceled_unselected_implementation + if index != 1 or canceled_unselected_implementation: + return + for envelope in _extract_pipeline_envelopes(event): + if ( + envelope.get("eventType") == "step_started" + and envelope.get("step", {}).get("id") == "materialize_selected_candidate" + ): + request = Request( + f"http://127.0.0.1:{port}/", + data=json.dumps(build_task_cancel_payload(task_id=task_id)).encode("utf-8"), + headers={"Content-Type": "application/json", **A2A_VERSION_HEADERS}, + ) + with urlopen(request, timeout=30) as response: + canceled_unselected_implementation = not bool(json.load(response).get("error")) + + summary = stream_message( + server_url=f"http://127.0.0.1:{port}", cwd=str(workspace), prompt=prompt, + context_id=context_id, task_id=task_id, name=f"turn-{index + 1}", + run_dir=args.run_dir, timeout=args.timeout, redaction_env=env, + on_event=stop_unselected_implementation, + ) + context_id, task_id = summary.context_id, summary.task_id + state = fetch_pipeline_state( + server_url=f"http://127.0.0.1:{port}", context_id=context_id, task_id=task_id, + run_dir=args.run_dir, name=f"turn-{index + 1}", redaction_env=env, + ) + completions = completion_inputs(config_dir) + latest, steps = pipeline_facts(args.run_dir / f"turn-{index + 1}.events.jsonl") + latest = latest or (completions[-1] if completions else {}) + constraints = memory_constraints(latest or state) + evidence = { + "turn": index + 1, "seconds": round(time.monotonic() - started, 2), + "http_sse": summary.response_content_type == "text/event-stream", + "task_state": summary.last_status_state, + "pipeline_handoff": summary.normal_handoff_ready, + "step": summary.last_input_required_step_id, + "assistant_link_visible": DESTINATION in visible_pipeline_text( + args.run_dir / f"turn-{index + 1}.events.jsonl" + ), + "completion_status": latest.get("status"), + "memory_constraints": constraints, + "started_steps": steps, + "unselected_implementation_canceled": canceled_unselected_implementation, + } + turns.append(evidence) + checks[f"turn {index + 1}: production HTTP/SSE"] = evidence["http_sse"] + checks[f"turn {index + 1}: no failed task"] = summary.last_status_state not in { + "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "", + } + if index == 0: + checks["minimum 2 GiB remains the lower bound"] = bool(constraints) and all( + item.get("operator") == "gte" and item.get("value") == 2 for item in constraints + ) + checks["initial deployment remains in Step 1"] = ( + summary.last_input_required_step_id == "solution_planning_and_selection" + and not summary.normal_handoff_ready + ) + elif index == 1: + checks["activity followup offers the same-region assistant"] = evidence["assistant_link_visible"] + checks["activity followup saves the linked handoff"] = ( + latest.get("status") == "rejected" and DESTINATION in latest.get("rejection_reason", "") + ) + checks["activity followup does not advance to implementation"] = ( + "materialize_selected_candidate" not in steps and latest.get("status") != "selected" + ) + else: + checks["explicit activity inquiry has a visible assistant link"] = evidence["assistant_link_visible"] + checks["explicit activity handoff saves the linked reply"] = ( + latest.get("status") == "rejected" and DESTINATION in latest.get("rejection_reason", "") + ) + print(json.dumps({"model": args.model, **evidence}, ensure_ascii=False), flush=True) + if summary.normal_handoff_ready: + context_id = task_id = "" + finally: + server.terminate() + records = observe.stop() + checks["server stopped"] = server.process is not None and server.process.poll() is not None + for name in (".credentials.yml", "settings.yml"): + (config_dir / name).unlink(missing_ok=True) + checks["credential copies removed"] = not (config_dir / ".credentials.yml").exists() + observed = sorted({ + str(record.get("attributes", {}).get("gen_ai.request.model")) + for record in records + if record.get("attributes", {}).get("gen_ai.request.model") + }) + checks["only the requested real model ran"] = observed == [args.model] + return { + "scenario": "promotional-offer-followup-a2a", "model": args.model, + "status": "passed" if all(checks.values()) else "failed", + "cleanup_status": "not-needed", "checks": checks, "turns": turns, + "observed_models": observed, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--provider", default="dashscope") + parser.add_argument("--allow-real-llm", action="store_true") + parser.add_argument("--timeout", type=float, default=300) + args = parser.parse_args() + if not args.allow_real_llm: + parser.error("--allow-real-llm is required") + args.run_dir.mkdir(parents=True, exist_ok=True) + try: + summary = run(args) + except Exception as exc: + summary = { + "scenario": "promotional-offer-followup-a2a", "model": args.model, "status": "failed", + "cleanup_status": "not-needed", "error_type": type(exc).__name__, + } + (args.run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(summary, ensure_ascii=False), flush=True) + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_ros_agent_awareness.py b/scripts/ci/run_ros_agent_awareness.py new file mode 100644 index 000000000..7afe1b9ed --- /dev/null +++ b/scripts/ci/run_ros_agent_awareness.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Exercise Step 1 routing with a real LLM, production tools and session storage. + +This stops at handoff or the first clarification boundary. It does not replace +model responses or call cloud APIs; transport/UI behavior is outside its scope. +Invoke through run_e2e.py to receive isolated LLM-only configuration. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +import uuid +from contextlib import aclosing +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Scenario: + prompt: str + region: str | None = "cn-beijing" + enabled: bool = True + handoff: bool = True + + +SCENARIOS = { + "promotions": Scenario("阿里云现在有什么优惠活动和优惠券?我只想咨询,暂时不部署资源。"), + "renewal": Scenario("已有的 ECS 快到期了,续费有折扣吗?"), + "billing": Scenario("按量付费的 ECS 是如何计费的?欠费后怎么办?"), + "billing-explicit": Scenario("我只咨询已有 ECS 的按量账单,不打算部署或创建资源。按量付费如何计费,欠费后怎么办?"), + "gpu-availability": Scenario("北京地域的 GPU 实例现在有哪些库存和单价?暂时不部署。", region="cn-shanghai"), + "traffic-offset": Scenario("有没有能抵扣已有 ECS 公网流量费的流量包?"), + "default-region": Scenario("我只想咨询阿里云的续费优惠,暂时不创建资源。", region=None), + "disabled": Scenario("我只想咨询已有 ECS 的续费折扣。", enabled=False, handoff=False), + "deployment-pricing": Scenario( + "我想在阿里云部署一个网站,请先问清网站类型和访问规模再推荐方案,选型时也帮我比较按量与包月成本。", + handoff=False, + ), +} + + +async def run(args: argparse.Namespace) -> dict: + # Prefer this checkout even if the interpreter has another editable install. + repo = Path(__file__).resolve().parents[2] + sys.path.insert(0, str(repo / "src")) + from iac_code.config import get_active_provider_key, get_provider_config, load_credentials + from iac_code.pipeline.engine.pipeline_runner import PipelineRunner + from iac_code.providers.manager import ProviderManager + from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredential, use_aliyun_credential + from iac_code.services.session_storage import SessionStorage + from iac_code.tools.base import ToolRegistry + from iac_code.types.stream_events import ( + AskUserQuestionEvent, + ErrorEvent, + MessageEndEvent, + TextDeltaEvent, + ToolResultEvent, + ToolUseStartEvent, + ) + + scenario = SCENARIOS[args.scenario] + os.environ["IAC_CODE_ROS_AGENT_AWARENESS"] = "true" if scenario.enabled else "false" + os.environ["IAC_CODE_CONFIG_BACKUP_DIR"] = str(args.run_dir / "backup") + provider = args.provider or get_active_provider_key() + if not provider: + raise ValueError("No configured LLM provider") + config = get_provider_config(provider) + model = args.model or config.get("model") + if not model: + raise ValueError("No configured LLM model") + manager = ProviderManager( + model=model, + credentials=load_credentials(model), + provider_key_override=provider, + provider_config_override={**config, "modelFallbackEnabled": False}, + ignore_llm_source=True, + ) + workspace = args.run_dir / "workspace" + workspace.mkdir(exist_ok=True) + flow = PipelineRunner( + pipeline_dir=repo / "src/iac_code/pipeline/selling_solution_first", + provider_manager=manager, + # Step-local tools are registered by the production engine. Deliberately + # omit cloud tools: routing needs no cloud operation or cloud credential. + base_tool_registry=ToolRegistry(), + session_storage=SessionStorage(args.run_dir / "projects"), + session_id="ros-awareness-e2e-" + uuid.uuid4().hex, + cwd=str(workspace), + ) + text: list[str] = [] + tools: list[str] = [] + input_tokens = output_tokens = error_count = 0 + asked_question = False + credential = AliyunCredential(region_id=scenario.region) if scenario.region is not None else None + with use_aliyun_credential(credential): + async with aclosing(flow.run(scenario.prompt)) as events: + async for event in events: + if isinstance(event, TextDeltaEvent): + text.append(event.text) + elif isinstance(event, ToolUseStartEvent): + tools.append(event.name) + elif isinstance(event, MessageEndEvent): + input_tokens += event.usage.total_input_tokens + output_tokens += event.usage.output_tokens + elif isinstance(event, ErrorEvent) or isinstance(event, ToolResultEvent) and event.is_error: + error_count += 1 + elif isinstance(event, AskUserQuestionEvent): + asked_question = True + await flow.persist_pending_ask_user_question(event) + break + + visible = "".join(text) + conclusion = flow.context.get_conclusion("solution_selection") or {} + url = f"https://ros.console.aliyun.com/{scenario.region or DEFAULT_REGION}/chat" + checks = { + "real model returned token usage": input_tokens > 0 and output_tokens > 0, + "no model or tool errors": error_count == 0, + "no deployment selected": flow.context.get_conclusion("selected_plan") is None, + } + if scenario.handoff: + checks.update( + { + "same-region assistant link is visible": url in visible, + "only the expected assistant destination is used": visible.count("https://ros.console.aliyun.com/") + == visible.count(url) + > 0, + "guidance is saved in the early-exit conclusion": conclusion.get("status") == "rejected" + and conclusion.get("continue_pipeline") is False + and url in conclusion.get("rejection_reason", ""), + "handoff skips deployment questions and candidates": tools == ["complete_step"] and not asked_question, + } + ) + elif scenario.enabled: + checks.update( + { + "normal clarification remains available": asked_question and "ask_user_question" in tools, + "no assistant handoff": "https://ros.console.aliyun.com/" not in visible + and conclusion.get("status") != "rejected", + } + ) + else: + # The unchanged base flow permits clarification or a normal rejection + # when the user has already confirmed that no deployment is requested. + checks.update( + { + "disabled flow preserves clarification or normal early exit": asked_question + and "ask_user_question" in tools + or conclusion.get("status") == "rejected" + and bool(conclusion.get("rejection_reason")), + "disabled flow does not add an assistant destination": "https://ros.console.aliyun.com/" not in visible + and "https://ros.console.aliyun.com/" not in conclusion.get("rejection_reason", ""), + } + ) + return { + "scenario": args.scenario, + "status": "passed" if all(checks.values()) else "failed", + "cleanup_status": "not-needed", + "checks": checks, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenario", choices=SCENARIOS, required=True) + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--model") + parser.add_argument("--provider") + parser.add_argument("--allow-real-llm", action="store_true") + parser.add_argument("--timeout", type=float, default=180) + args = parser.parse_args(argv) + if not args.allow_real_llm: + parser.error("This calls a real LLM; pass --allow-real-llm") + args.run_dir.mkdir(parents=True, exist_ok=True) + started = time.monotonic() + try: + summary = asyncio.run(asyncio.wait_for(run(args), timeout=args.timeout)) + except Exception as exc: + # Do not copy provider messages/HTTP payloads into CI reports. + summary = { + "scenario": args.scenario, + "status": "failed", + "cleanup_status": "not-needed", + "checks": {"scenario completed within budget": False}, + "error_type": type(exc).__name__, + } + summary["duration_seconds"] = round(time.monotonic() - started, 2) + (args.run_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + print(json.dumps(summary), flush=True) + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pipeline/e2e/run_candidate_batch_recovery.py b/scripts/pipeline/e2e/run_candidate_batch_recovery.py new file mode 100644 index 000000000..2cd6d3abc --- /dev/null +++ b/scripts/pipeline/e2e/run_candidate_batch_recovery.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Run the deterministic P18 selection regression through the shared CI entrypoint.""" + +from __future__ import annotations + +import argparse +import json +import os +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--run-dir", type=Path, required=True) + parser.add_argument("--mode", choices=("same-process", "cold-recovery"), required=True) + args = parser.parse_args() + run_dir = args.run_dir.resolve() + run_dir.mkdir(parents=True, exist_ok=True) + os.chdir(Path(__file__).resolve().parents[3]) + junit_path = run_dir / "pytest.xml" + node = "tests/pipeline/selling_solution_first/test_candidate_batch_e2e.py::" + node += f"test_idempotent_outline_survives_selection_resume[{args.mode}]" + exit_code = pytest.main( + [ + "-q", + "--tb=short", + "-o", + "addopts=", + node, + "--basetemp=" + str(run_dir / "pytest"), + "--junitxml=" + str(junit_path), + ] + ) + try: + cases = ET.parse(junit_path).findall(".//testcase") + completed = len(cases) == 1 and not any( + case.find(tag) is not None for case in cases for tag in ("failure", "error", "skipped") + ) + except (OSError, ET.ParseError): + completed = False + passed = exit_code == 0 and completed + summary = { + "passed": passed, + "mode": args.mode, + "checks": {"unchanged candidate batch survives real pipeline persistence and selection": passed}, + } + (run_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + return 0 if passed else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py index 521b41085..6d66c86ef 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ "iac-aliyun-deploying", ) SELLING_SOLUTION_FIRST_REFERENCE_SKILLS = ( + "iac-aliyun-solution-first", "iac-aliyun-deploying", "iac-aliyun-materialize-selected-candidate", ) diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py index 299044be2..77046d0aa 100644 --- a/src/iac_code/a2a/transports/dispatcher.py +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -17,6 +17,7 @@ from a2a.server.events.event_queue_v2 import QueueShutDown from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.routes import create_jsonrpc_routes +from a2a.server.tasks import TaskManager from a2a.server.tasks.inmemory_task_store import DEFAULT_LIST_TASKS_PAGE_SIZE, decode_page_token, encode_page_token from a2a.types import ( CancelTaskRequest, @@ -138,10 +139,15 @@ class _ASGIResponseChunk: class _DetachedPermissionEventQueue(EventQueue): """Minimal producer queue for resuming an already-persisted A2A task.""" - def __init__(self, queue: asyncio.Queue[Any]) -> None: + def __init__(self, queue: asyncio.Queue[Any], task_manager: TaskManager) -> None: self._queue = queue + self._task_manager = task_manager async def enqueue_event(self, event: Any) -> None: + # This direct continuation bypasses DefaultRequestHandler's consumer. + # Keep GetTask in sync even when a nonstream caller discards the events + # or the response disconnects after its decision was committed. + await self._task_manager.process(event) await self._queue.put(event) @@ -1014,7 +1020,14 @@ async def _on_inactive_input_response_send_stream(self, params: SendMessageReque async def run_permission_response() -> None: nonlocal request_context try: - event_queue = _DetachedPermissionEventQueue(queue) + task_manager = TaskManager( + task_id=task.id, + context_id=task.context_id, + task_store=self.task_store, + context=context, + initial_message=None, + ) + event_queue = _DetachedPermissionEventQueue(queue, task_manager) RecoverableInputAdmissionCarrier.acknowledge_enqueued(request_context, asyncio.current_task()) try: await self.agent_executor.execute(request_context, event_queue) diff --git a/src/iac_code/agent/agent_loop.py b/src/iac_code/agent/agent_loop.py index c506cc0a7..b61ea3a01 100644 --- a/src/iac_code/agent/agent_loop.py +++ b/src/iac_code/agent/agent_loop.py @@ -3049,6 +3049,10 @@ def _tool_result_block_metadata(processed: Any, result_metadata: dict[str, Any] ) from iac_code.tools.cloud.base_stack import persisted_stack_metadata + # Idempotent display calls retain the original batch across transcript recovery. + candidate_set_id = result_metadata.get("candidate_set_id") + if isinstance(candidate_set_id, str) and candidate_set_id: + metadata["candidate_set_id"] = candidate_set_id aliyun_http = sanitize_aliyun_http_metadata(result_metadata.get(ALIYUN_HTTP_METADATA_KEY)) if aliyun_http is not None: metadata[ALIYUN_HTTP_METADATA_KEY] = aliyun_http diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index 79847ea92..33c62b865 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -6158,14 +6158,14 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Zeigen Sie vor der Erzeugung ausführlicher Details einen vollständigen " "Satz kompakter Kandidatenübersichten an. Übermitteln Sie jeden aktuellen " -"Kandidaten der Reihe nach mit Name, Zusammenfassung, monatlicher " -"Schätzung und wichtigster Abwägung. Fügen Sie keine Topologieknoten, " -"Ressourcenbestände oder detaillierten Kostenpositionen hinzu." +"Kandidaten der Reihe nach mit Name, Zusammenfassung, Kostenschätzung und " +"wichtigster Abwägung. Fügen Sie keine Topologieknoten, Ressourcenbestände" +" oder detaillierten Kostenpositionen hinzu." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6184,8 +6184,12 @@ msgid "Short product combination and architecture summary" msgstr "Kurze Zusammenfassung der Produktkombination und Architektur" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Ungefähre monatliche Spanne, z. B. ¥230~¥380/Monat" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Ungefähre Kostenspanne mit Zeitraum: standardmäßig monatlich oder in der " +"vom Benutzer gewünschten Zeiteinheit und Dauer." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6194,10 +6198,10 @@ msgstr "Wichtigste Abwägung bei Kosten, Verfügbarkeit oder Komplexität" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates muss ein nicht leeres Array eindeutiger Übersichten mit " -"candidate_name, summary, total_monthly_cost und key_tradeoff sein" +"candidate_name, summary, total_cost und key_tradeoff sein" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6296,14 +6300,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Zeigen Sie die vollständigen Details für genau einen Kandidaten aus dem " "neuesten show_architecture_plan-Satz. Rufen Sie die Funktion einmal pro " "Modellrunde in der Reihenfolge der Kandidatenindizes auf. Geben Sie " "Ressourcenlebenszyklus, Topologiegraf, Ressourcenbestand, Kostenannahmen " "und Entscheidungshinweise an; wiederholen Sie weder Zusammenfassung noch " -"Monatssumme." +"Gesamtkosten." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7684,11 +7688,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Die Alibaba-Cloud-API-Metadaten für {operation} sind vorübergehend nicht " -"verfügbar. Versuchen Sie es später erneut." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Die Alibaba-Cloud-API-Metadaten für {operation} sind vorübergehend nicht verfügbar. Wiederhole denselben Tool-Aufruf nach kurzer Wartezeit einmal. Falls er erneut fehlschlägt, melde die vorübergehende Störung, statt die API als nicht unterstützt einzustufen." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po index e9a01e43d..10dbe8a6d 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/webui.po @@ -4342,3 +4342,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Der Link konnte nicht geöffnet werden. Bitte erneut versuchen." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Geschätzte Kosten" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 023139453..9977637a2 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -6113,13 +6113,14 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Muestra un lote completo de resúmenes ligeros de candidatos antes de " "generar los detalles. Envía cada candidato actual en orden con su nombre," -" resumen, estimación mensual y principal contrapartida. No incluyas nodos" -" de topología, inventario de recursos ni partidas de coste detalladas." +" resumen, estimación de costes y principal contrapartida. No incluyas " +"nodos de topología, inventario de recursos ni partidas de coste " +"detalladas." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6138,8 +6139,12 @@ msgid "Short product combination and architecture summary" msgstr "Resumen breve de la combinación de productos y la arquitectura" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Intervalo mensual aproximado, por ejemplo ¥230~¥380/mes" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Intervalo de costes aproximado con su período: mensual por defecto, o " +"según la unidad y la duración solicitadas por el usuario." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6148,10 +6153,10 @@ msgstr "La contrapartida más importante de coste, disponibilidad o complejidad" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates debe ser un array no vacío de resúmenes únicos con " -"candidate_name, summary, total_monthly_cost y key_tradeoff" +"candidate_name, summary, total_cost y key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6247,14 +6252,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Muestra los detalles completos de exactamente un candidato del último " "lote de show_architecture_plan. Llama una vez por turno del modelo " "siguiendo el orden del índice de candidatos. Incluye la intención del " "ciclo de vida de los recursos, el grafo de topología, el inventario de " "recursos, los supuestos de coste y las notas de decisión; no repitas el " -"resumen ni el total mensual." +"resumen ni el coste total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7630,11 +7635,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Los metadatos de la API de Alibaba Cloud para {operation} no están " -"disponibles temporalmente. Vuelva a intentarlo más tarde." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Los metadatos de la API de Alibaba Cloud para {operation} no están disponibles temporalmente. Espera un momento y vuelve a realizar la misma llamada a la herramienta una vez; si vuelve a fallar, informa del fallo temporal en lugar de considerar que la API no es compatible." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po index c23ddb40a..aab10fa94 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/webui.po @@ -4331,3 +4331,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "No se pudo abrir el enlace. Inténtalo de nuevo." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Coste estimado" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 128abf20e..efaebf5d5 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -6120,12 +6120,12 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Affichez un lot complet de résumés légers des candidats avant de générer " "les détails. Soumettez chaque candidat actuel dans l’ordre avec son nom, " -"son résumé, son estimation mensuelle et son principal compromis. " +"son résumé, son estimation des coûts et son principal compromis. " "N’incluez ni nœuds de topologie, ni inventaire de ressources, ni postes " "de coût détaillés." @@ -6146,8 +6146,12 @@ msgid "Short product combination and architecture summary" msgstr "Bref résumé de la combinaison de produits et de l’architecture" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Fourchette mensuelle approximative, par exemple ¥230~¥380/mois" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Fourchette de coûts approximative avec sa période : mensuelle par défaut," +" ou selon l’unité et la durée demandées par l’utilisateur." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6158,10 +6162,10 @@ msgstr "" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates doit être un tableau non vide de résumés uniques contenant " -"candidate_name, summary, total_monthly_cost et key_tradeoff" +"candidate_name, summary, total_cost et key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6260,14 +6264,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Affichez les détails complets d’un seul candidat du dernier lot " "show_architecture_plan. Effectuez un appel par tour de modèle dans " "l’ordre des index de candidats. Incluez l’intention de cycle de vie des " "ressources, le graphe de topologie, l’inventaire des ressources, les " "hypothèses de coût et les notes de décision ; ne répétez ni le résumé ni " -"le total mensuel." +"le coût total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7649,11 +7653,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Les métadonnées de l'API Alibaba Cloud pour {operation} sont " -"temporairement indisponibles. Réessayez plus tard." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Les métadonnées de l’API Alibaba Cloud pour {operation} sont temporairement indisponibles. Réessayez une fois le même appel d’outil après une courte attente ; en cas de nouvel échec, signalez une panne temporaire plutôt que de considérer l’API comme non prise en charge." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po index 2c0505d26..9681be0d9 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/webui.po @@ -4345,3 +4345,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Impossible d’ouvrir le lien. Veuillez réessayer." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Coût estimé" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 65207836b..10505ae40 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -5753,9 +5753,9 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." -msgstr "詳細情報を生成する前に、軽量な候補概要を完全な1バッチとして表示します。現在の各候補を順番に、名前、概要、月額見積もり、主なトレードオフとともに送信してください。トポロジーノード、リソース一覧、詳細な費用項目は含めないでください。" +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." +msgstr "詳細情報を生成する前に、軽量な候補概要を完全な1バッチとして表示します。現在の各候補を順番に、名前、概要、費用見積もり、主なトレードオフとともに送信してください。トポロジーノード、リソース一覧、詳細な費用項目は含めないでください。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -5772,8 +5772,10 @@ msgid "Short product combination and architecture summary" msgstr "製品構成とアーキテクチャの簡潔な概要" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "月額のおおよその範囲(例: ¥230~¥380/月)" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "期間を含む概算費用の範囲。既定では月額とし、ユーザーが時間単位と利用期間を指定した場合はそれに従います。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -5782,9 +5784,9 @@ msgstr "コスト、可用性、または複雑性に関する最も重要なト #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" -"candidates は candidate_name、summary、total_monthly_cost、key_tradeoff " +"candidates は candidate_name、summary、total_cost、key_tradeoff " "を含む、一意な概要の空でない配列である必要があります" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py @@ -5878,10 +5880,10 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "最新の show_architecture_plan " -"バッチから候補を1つだけ選び、その詳細を表示します。モデルの各ターンで候補インデックス順に1回ずつ呼び出してください。リソースのライフサイクル意図、トポロジーグラフ、リソース一覧、費用の前提、判断メモを含め、概要や月額合計は繰り返さないでください。" +"バッチから候補を1つだけ選び、その詳細を表示します。モデルの各ターンで候補インデックス順に1回ずつ呼び出してください。リソースのライフサイクル意図、トポロジーグラフ、リソース一覧、費用の前提、判断メモを含め、概要や費用合計は繰り返さないでください。" #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7198,9 +7200,10 @@ msgstr "Alibaba Cloud API {operation} が見つかりません。プロダクト #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "{operation} の Alibaba Cloud API メタデータは一時的に利用できません。後でもう一度お試しください。" +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "{operation} の Alibaba Cloud API メタデータは一時的に利用できません。少し待ってから同じツール呼び出しを一度再試行してください。それでも失敗する場合は、API がサポートされていないと判断せず、一時的な障害として報告してください。" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po index 020528f08..7199582dc 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/webui.po @@ -4180,3 +4180,7 @@ msgstr "ローカルランタイムを再起動できませんでした。詳細 #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "リンクを開けませんでした。もう一度お試しください。" + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "概算費用" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index 9c19de080..a9de26615 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -6064,13 +6064,13 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." msgstr "" "Exiba um lote completo de resumos leves dos candidatos antes de gerar os " "detalhes. Envie cada candidato atual em ordem, com nome, resumo, " -"estimativa mensal e principal contrapartida. Não inclua nós de topologia," -" inventário de recursos nem itens de custo detalhados." +"estimativa de custo e principal contrapartida. Não inclua nós de " +"topologia, inventário de recursos nem itens de custo detalhados." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -6089,8 +6089,12 @@ msgid "Short product combination and architecture summary" msgstr "Resumo breve da combinação de produtos e da arquitetura" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "Faixa mensal aproximada, por exemplo ¥230~¥380/mês" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "" +"Faixa de custo aproximada com seu período: mensal por padrão, ou conforme" +" a unidade e a duração solicitadas pelo usuário." #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -6099,10 +6103,10 @@ msgstr "A contrapartida mais importante de custo, disponibilidade ou complexidad #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates deve ser um array não vazio de resumos exclusivos com " -"candidate_name, summary, total_monthly_cost e key_tradeoff" +"candidate_name, summary, total_cost e key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -6199,14 +6203,14 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "Exiba os detalhes completos de exatamente um candidato do lote mais " "recente de show_architecture_plan. Faça uma chamada por turno do modelo " "na ordem dos índices dos candidatos. Inclua a intenção do ciclo de vida " "dos recursos, o grafo de topologia, o inventário de recursos, as " -"premissas de custo e as notas de decisão; não repita o resumo nem o total" -" mensal." +"premissas de custo e as notas de decisão; não repita o resumo nem o custo" +" total." #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7569,11 +7573,10 @@ msgstr "" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "" -"Os metadados da API do Alibaba Cloud para {operation} estão " -"temporariamente indisponíveis. Tente novamente mais tarde." +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "Os metadados da API do Alibaba Cloud para {operation} estão temporariamente indisponíveis. Aguarde um pouco e repita a mesma chamada à ferramenta uma vez; se falhar novamente, informe a falha temporária em vez de considerar a API incompatível." #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po index 19990d106..07981bd3d 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/webui.po @@ -4317,3 +4317,7 @@ msgstr "" #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "Não foi possível abrir o link. Tente novamente." + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "Custo estimado" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 3f0e0b854..2ac6d6a56 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -5681,9 +5681,9 @@ msgstr "" msgid "" "Display one complete batch of lightweight candidate outlines before rich " "details are generated. Submit every current candidate in order with its " -"name, summary, monthly estimate and key trade-off. Do not include " -"topology nodes, resource inventory or detailed cost items." -msgstr "在生成详细信息前,先一次性展示一批完整的轻量候选方案概览。按顺序提交当前每个候选方案的名称、摘要、月度估价和关键取舍。不要包含拓扑节点、资源清单或详细费用项。" +"name, summary, cost estimate and key trade-off. Do not include topology " +"nodes, resource inventory or detailed cost items." +msgstr "在生成详细信息前,先一次性展示一批完整的轻量候选方案概览。按顺序提交当前每个候选方案的名称、摘要、费用估算和关键取舍。不要包含拓扑节点、资源清单或详细费用项。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" @@ -5700,8 +5700,10 @@ msgid "Short product combination and architecture summary" msgstr "简短的产品组合与架构摘要" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py -msgid "Rough monthly range, such as ¥230~¥380/month" -msgstr "粗略月费范围,例如 ¥230~¥380/月" +msgid "" +"Rough cost range including its period: monthly by default, or the user's " +"requested unit and duration." +msgstr "包含计费周期的粗略费用范围:默认按月,或按用户要求的时间单位和时长。" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "The most important cost, availability or complexity trade-off" @@ -5710,10 +5712,10 @@ msgstr "最重要的成本、可用性或复杂度取舍" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py msgid "" "candidates must be a non-empty array of unique outlines with " -"candidate_name, summary, total_monthly_cost and key_tradeoff" +"candidate_name, summary, total_cost and key_tradeoff" msgstr "" "candidates 必须是非空且不重名的候选方案概览数组,每项包含 " -"candidate_name、summary、total_monthly_cost 和 key_tradeoff" +"candidate_name、summary、total_cost 和 key_tradeoff" #: src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py #, python-brace-format @@ -5805,10 +5807,10 @@ msgid "" "show_architecture_plan batch. Call once per model turn in candidate index" " order. Include resource lifecycle intent, topology graph, resource " "inventory, cost assumptions and decision notes; do not repeat summary or " -"monthly total." +"cost total." msgstr "" "仅展示最新 show_architecture_plan " -"批次中一个候选方案的完整详情。每个模型轮次按候选方案索引顺序调用一次。包括资源生命周期意图、拓扑图、资源清单、费用假设和决策说明;不要重复摘要或月度总价。" +"批次中一个候选方案的完整详情。每个模型轮次按候选方案索引顺序调用一次。包括资源生命周期意图、拓扑图、资源清单、费用假设和决策说明;不要重复摘要或总费用。" #: src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py msgid "Zero-based index from the latest candidate outline batch" @@ -7101,9 +7103,10 @@ msgstr "未找到阿里云 API {operation}。请检查产品、版本和操作 #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format msgid "" -"Alibaba Cloud API metadata for {operation} is temporarily unavailable; " -"try again later." -msgstr "{operation} 的阿里云 API 元数据暂时不可用,请稍后重试。" +"Alibaba Cloud API metadata for {operation} is temporarily unavailable. " +"Retry the same tool call once after a short wait; if it still fails, " +"report the temporary failure rather than treating the API as unsupported." +msgstr "{operation} 的阿里云 API 元数据暂时不可用。请稍等后重试同一工具调用一次;若仍失败,请报告临时故障,不要判断为该 API 不受支持。" #: src/iac_code/tools/cloud/aliyun/public_errors.py #, python-brace-format diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po index ac4fa5d9c..d0b0fb6dd 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/webui.po @@ -4172,3 +4172,7 @@ msgstr "本地运行时无法重新启动。请打开诊断目录查看详情。 #: src/iac_code/web/static/js/app.js msgid "Could not open the link. Please try again." msgstr "无法打开链接,请重试。" + +#: src/iac_code/web/static/js/mermaid_render.js +msgid "Estimated cost" +msgstr "费用估算" diff --git a/src/iac_code/pipeline/engine/loader.py b/src/iac_code/pipeline/engine/loader.py index 6ed537c81..7e5e455ed 100644 --- a/src/iac_code/pipeline/engine/loader.py +++ b/src/iac_code/pipeline/engine/loader.py @@ -503,6 +503,8 @@ def _bind_hooks(steps: list[StepSpec], pipeline_dir: Path) -> None: module = _load_module_from_file(hook_path, f"pipeline_hook_{step.step_id}") if hasattr(module, "on_enter"): step.on_enter = module.on_enter + if hasattr(module, "build_prompt_context"): + step.prompt_context_builder = module.build_prompt_context if hasattr(module, "on_exit"): step.on_exit = module.on_exit if hasattr(module, "enrich_completion_input"): diff --git a/src/iac_code/pipeline/engine/step_executor.py b/src/iac_code/pipeline/engine/step_executor.py index 90c0a375b..1a25e00f6 100644 --- a/src/iac_code/pipeline/engine/step_executor.py +++ b/src/iac_code/pipeline/engine/step_executor.py @@ -841,16 +841,19 @@ def _build_full_system_prompt(self, step: StepSpec, context: PipelineContext) -> prompt_file = step.prompt_file_for_surface(self._surface) prompt_path = self._pipeline_dir / prompt_file step_prompt = prompt_path.read_text(encoding="utf-8") if prompt_file else "" + runtime_context = step.prompt_context_builder() if step.prompt_context_builder is not None else {} rendered_step_prompt = render_prompt( step_prompt, context, step.context_fields, - extra_context={"step_config": step.config}, + extra_context={"step_config": step.config, **runtime_context}, ) skill_content = "" if step.skill: skill_content = self._resolve_skill_prompt(step.skill) or "" + if step.prompt_context_builder is not None: + skill_content = render_prompt(skill_content, context, [], extra_context=runtime_context) parts = [p for p in [base_prompt, skill_content, rendered_step_prompt] if p] return "\n\n---\n\n".join(parts) diff --git a/src/iac_code/pipeline/engine/step_spec.py b/src/iac_code/pipeline/engine/step_spec.py index 1f74f7247..ff3279195 100644 --- a/src/iac_code/pipeline/engine/step_spec.py +++ b/src/iac_code/pipeline/engine/step_spec.py @@ -77,6 +77,8 @@ class StepSpec: on_enter: Callable[[PipelineContext], None] | None = None on_exit: Callable[[PipelineContext, dict], None] | None = None completion_enricher: Callable[..., dict[str, Any]] | None = None + #: Per-step skill/prompt replacements; never added to base prompt sections. + prompt_context_builder: Callable[[], dict[str, Any]] | None = None on_resource_observed: Callable[..., object] | None = None on_rollback_cleanup_required: Callable[..., object] | None = None #: Optional pre-check for structured waiting-input payloads. Returning a message keeps the step waiting. diff --git a/src/iac_code/pipeline/selling/references/coupons.md b/src/iac_code/pipeline/selling/references/coupons.md new file mode 100644 index 000000000..35d1b2b64 --- /dev/null +++ b/src/iac_code/pipeline/selling/references/coupons.md @@ -0,0 +1,77 @@ +# 阿里云优惠券查询与预算判断 + +## 使用原则 + +用户为部署方案询问优惠券、代金券或抵扣预算时,保留部署目标和规格,按需查询当前账号的券及适用条件。不要仅凭“学生券”“通用券”或面值,承诺可以用于目标配置,也不要因为用券问题中断部署规划。 + +可用、不适用或待确认只是用券结果,不代表用户取消部署;回答后以 `status: awaiting_selection` 保留候选等待选择,不提交 `rejected`。 + +券面值、剩余可抵扣金额与用户愿意补付的现金是三件事。只有用户明确要求不补现金时,才把可确认的抵扣额度作为可覆盖费用的上限;未明确时不要自行认定愿意补差价。无需为普通部署查询优惠券。 + +## 查询优惠券 + +使用当前会话的云凭证,只查询当前账号,不设置跨账号查询参数: + +```python +aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCoupon", + region_id="cn-hangzhou", params={"CurrentPage": 1, "PageSize": 20}) +``` + +返回 `Data` 为券数组,`CurrentPage`、`PageSize`、`TotalCount` 表示分页。需要继续寻找目标券时递增 `CurrentPage`;未读完列表时不能宣称账号没有该券。不要只按面值选择券;结合用户说明、券类型、状态和限制识别,多个候选无法区分时再澄清。不同券可能省略部分字段,缺失值保留未知,不当作零余额、无限制或已满足资格。 + +| 信息 | 返回字段与处理 | +|---|---| +| 券标识与类型 | `CouponId`、`CouponType`、`CouponTypeName`;不把不同类型的券都当作现金余额 | +| 金额 | `Amount` 为面值,`RemainAmount` 为余额,金额为字符串;计算时使用十进制金额并核对 `Currency` | +| 状态与期限 | `Status`、`StartTime`、`EndTime`;时间带时区,状态与当前时间均需核对,不能只看余额 | +| 消费场景 | `YhOrderTypes`,核对是否包含本次新购、续费或按量付费账单等场景 | +| 金额与购买时长限制 | `MoneyLimit`、`OrderTimeRule`;仅使用返回的明确规则,空值不等于无限制 | +| 首购等限制 | `FirstBuy`、`Remark`;有首购限制不代表当前账号已经满足资格 | +| 商品范围 | `ItemNames`、`SuitItemType`,必要时按下节查询编码;不能忽略白名单或黑名单语义 | + +适用场景和门槛按该张券的实际字段判断,不根据券类型统一推断“抵用券无门槛”或“满减券不能用于按量付费”。 + +## 查询适用商品 + +用上一接口返回的真实 `CouponId`,不要猜测或复用示例 ID: + +```python +aliyun_api(product="BssOpenApi", version="2023-09-30", action="DescribeCouponItemList", + region_id="cn-hangzhou", params={ + "CouponId": coupon_id, "CurrentPage": 1, "PageSize": 20 + }) +``` + +`Data` 为商品列表,每项包含 `Code` 和 `Name`,同样按 `TotalCount` 分页。结合该券的 `SuitItemType` 解释结果:`white` 表示指定商品适用,`black` 表示指定商品不适用,`all` 表示全产品通用范围;不能把黑名单中的商品当成可用商品。 + +商品编码和名称不是实例规格清单。即使包含 ECS,也不能仅凭这一项确认所有 GPU 规格、地域、付费方式或活动渠道均可用;缺少这些条件的证据时明确写“目标配置适用性待确认”。 + +## 判定结果 + +- **明确不可抵扣**:券已过期、用尽或作废,或者当前方案违反已返回的付费方式、商品排除、GPU 限制、金额门槛等规则。该券对本次费用的可抵扣金额为 0,不能再假设“也许仍能抵扣”。`Status` 为 `EXPIRED` 或 `EndTime` 已早于当前时间时,即使 `RemainAmount` 仍有数字,也不是有效余额;若只有这一张券,已知的本次费用需全部另付现金。 +- **条件性可抵扣**:券有效且余额、适用商品及所有已知限制均匹配时,按下面的费用范围计算;仍保留实际结算说明。 +- **待确认**:查询失败或缺少判断目标配置所必需的信息。不要把缺失信息当成已满足,也不要把已经明确不满足的条件重新写成待确认。适用性未确认时,可抵扣金额和需补现金也标为待确认,不能填入看似确定的金额后只在备注中说明未知;如展示假设计算,每个相关金额须明确标注“若适用”等前提。 + +## 结合方案解释费用 + +- 保持配置、地域、数量、计费方式和使用时长一致,再比较费用与可抵扣额度。按小时租用时使用有依据的按量单价,不用包月价除以固定小时数替代。 +- 分别说明资源粗估、已确认的券余额与适用条件,以及未确认的抵扣范围。优惠券余额不是折后资源单价,不直接从月费中减掉券面值来生成“每月优惠价”。 +- 只有代金券的适用范围及其限制已确认、且预计费用全部在可抵扣范围内时,才可给出条件性估算:预计需补现金为 `max(预计费用 - 可用余额, 0)`;仍注明实际抵扣以订单或账单为准。满减、折扣、门槛、部分费用不适用或多券叠加均不得套用该简式。 +- 条件已确认且可以计算时,现金栏填写带“预计”等限定的金额,实际结算说明单独列出。仅仅因为最终以账单结算为准,不把本来可以计算的预计金额改成“待确认”;缺少适用条件证据时才保留未知金额。 +- 仅明确按小时付费、未给出使用时长时,先展示小时粗估。只有抵扣条件足够明确时,才估算余额可覆盖的大致时长,并列出未覆盖的云盘、网络等费用;不能承诺券用完就自动停机或不再扣费。 + +## 查询失败与能力边界 + +接口成功但列表为空,只表示本次查询未返回券;权限不足、认证失败、分页未完成或服务异常都不能解释为没有券。保留当前部署方案,说明具体未知项,不编造抵扣结果。 + +用户明确要求重新查询或换用另一张券,而本轮查询失败时,不得把此前成功查询的券当成本轮依据;当前券余额、可抵扣金额和需补现金均保留待确认,不能沿用上一张券的门槛或排除规则计算。 + +用户已明确要求查询或重新查询时,按现有工具权限直接发起只读查询,不再用 `ask_user_question` 询问是否查询或确认查询方式。若查询后仍有多张券且无法确定目标券,再请用户选择;工具本身要求的权限确认仍走原权限机制。 + +所需只读权限分别为 `bss:DescribeCoupon` 和 `bss:DescribeCouponItemList`(资源 `*`);费用中心或子账号还可能因折扣查询权限受限,按接口实际错误提示处理,不自行改权限。查询 API 不会领取、使用、冻结或验证实际结算抵扣。 + +## 官方参考 + +- [DescribeCoupon:优惠券、余额与限制字段](https://help.aliyun.com/zh/user-center/developer-reference/api-bssopenapi-2023-09-30-describecoupon) +- [DescribeCouponItemList:商品编码与名称](https://help.aliyun.com/zh/user-center/developer-reference/api-bssopenapi-2023-09-30-describecouponitemlist) +- [优惠券规则与适用条件](https://help.aliyun.com/zh/user-center/how-to-use-coupons) diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md index da8f0ac80..22784ebee 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-cost/SKILL.md @@ -277,7 +277,7 @@ ros_estimate_template_cost( | 文件 | 内容 | 何时查阅 | |------|------|----------| -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | 修复 Parameters 定义(缺少 AssociationProperty、Label 等)时 | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | 修复资源定义、内置函数用法等模板结构问题时 | diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md index 09f51eca4..2ad002df2 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-deploying/SKILL.md @@ -177,5 +177,5 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md index 5162088eb..4b0bde0be 100644 --- a/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md +++ b/src/iac_code/pipeline/selling/skills/iac-aliyun-template-generating/SKILL.md @@ -113,5 +113,5 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py b/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py index 66fa5bc09..b52e25f0a 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/deploying.py @@ -23,6 +23,9 @@ on_resource_observed, on_rollback_cleanup_required, ) +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( + normalize_solution_selection_costs, +) logger = logging.getLogger(__name__) @@ -133,6 +136,7 @@ def evaluate_deployment_gate(selected_plan: Any) -> str: def on_enter(ctx: PipelineContext) -> None: """Record the deployment gate verdict inside the existing ``selected_plan`` conclusion.""" + normalize_solution_selection_costs(ctx.snapshot().get("solution_selection")) selected_plan = ctx.get_conclusion("selected_plan") error = evaluate_deployment_gate(selected_plan) if not isinstance(selected_plan, dict): diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py b/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py index 4a64e3d82..ffab44d63 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/materialize_selected_candidate.py @@ -15,6 +15,9 @@ from iac_code.pipeline.engine.complete_step_tool import CompletionEnrichmentError from iac_code.pipeline.engine.context import PipelineContext from iac_code.pipeline.engine.ui_contract import parse_deployment_confirmation +from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( + normalize_solution_selection_costs, +) from iac_code.tools.cloud.aliyun.ros_yaml import ros_yaml_load __all__ = [ @@ -68,6 +71,7 @@ def on_enter(ctx: PipelineContext) -> None: """Record candidate resolver state in Step 1 context for prompts and the deploy gate.""" selection = ctx.get_conclusion("solution_selection") + normalize_solution_selection_costs(selection) index, candidate, error = resolve_authoritative_candidate(selection) if not isinstance(selection, dict): return diff --git a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py index cff6337ce..f81869837 100644 --- a/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py +++ b/src/iac_code/pipeline/selling_solution_first/hooks/solution_planning_and_selection.py @@ -3,7 +3,9 @@ from __future__ import annotations import copy +import os import re +from pathlib import Path from typing import Any from iac_code.i18n import _ @@ -13,9 +15,33 @@ detail_record_matches, latest_candidate_detail_records, latest_candidate_outline_batch, + normalize_candidate_costs, + normalize_inventory_costs, ) -__all__ = ["enrich_completion_input"] +__all__ = ["build_prompt_context", "enrich_completion_input"] + + +def build_prompt_context() -> dict[str, Any]: + """Inject the short skill supplement only when ROS awareness is enabled.""" + enabled = os.environ.get("IAC_CODE_ROS_AGENT_AWARENESS", "").strip().casefold() in {"1", "true", "yes", "on"} + if not enabled: + return {"ros_agent_awareness": ""} + + from iac_code.services.providers.aliyun import DEFAULT_REGION, AliyunCredentials + + # The credential loader prefers the current session's request-local override. + credential = AliyunCredentials.load() + region = (credential.region_id if credential is not None else "").strip() + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", region): + region = DEFAULT_REGION + destination = f"[AI Assistant](https://ros.console.aliyun.com/{region}/chat)" + supplement = ( + Path(__file__).resolve().parents[1] + / "skills/iac-aliyun-solution-first/ros-agent-awareness.md" + ).read_text(encoding="utf-8").strip() + return {"ros_agent_awareness": "\n\n" + supplement.replace("{ai_assistant_destination}", destination)} + # 说服力字段与各自的最少条目数。缺失或空数组时方案卡只剩一句概述,用户无从判断为什么该选它, # 因此这里和 completion_input_schema 一样把它们当硬要求,而不是可选补充。 @@ -26,15 +52,16 @@ ("cons", 1), ) -_COMPOSED_MONTHLY_PRICE_RE = re.compile( +_COST_PERIOD_RE = r"(?:小时|天|日|周|月|年|hours?|days?|weeks?|months?|years?)" +_COMPOSED_PRICE_RE = re.compile( r"^\s*(?:约\s*)?(?:" r"[¥¥]\s*[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:[¥¥]\s*)?[\d,.]+)?" - r"\s*(?:元|CNY|RMB)?\s*(?:(?:[//]\s*)?月|每月)?" + rf"\s*(?:元|CNY|RMB)?\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" r"|[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:[¥¥]\s*)?[\d,.]+)?" - r"\s*(?:元\s*(?:(?:[//]\s*)?月)?|(?:[//]\s*)月|每月)" + rf"\s*(?:元\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?|[//]\s*{_COST_PERIOD_RE}|每{_COST_PERIOD_RE})" r"|(?:CNY|RMB)\s*[\d,.]+(?:\s*(?:[-~~—–]|至|到)\s*(?:CNY|RMB)?\s*[\d,.]+)?" - r"\s*(?:(?:[//]\s*)?月|每月)?" - r"|(?:免费|零费用|无费用)\s*(?:(?:[//]\s*)?月|每月)?" + rf"\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" + rf"|(?:免费|零费用|无费用)\s*(?:(?:[//]\s*|每)?{_COST_PERIOD_RE})?" r")\s*$", re.IGNORECASE, ) @@ -222,7 +249,7 @@ def _candidate_from_outline_and_detail( outline: dict[str, str], detail: dict[str, Any], ) -> dict[str, Any]: - inventory = copy.deepcopy(detail.get("resource_inventory")) + inventory = normalize_inventory_costs(detail.get("resource_inventory")) inventory = inventory if isinstance(inventory, list) else [] return { "name": outline["candidate_name"], @@ -233,7 +260,7 @@ def _candidate_from_outline_and_detail( "resource_inventory": inventory, "rough_cost": { "currency": "CNY", - "monthly_range": outline["total_monthly_cost"], + "cost_range": outline["total_cost"], "items": _cost_items_from_inventory(inventory), "assumptions": copy.deepcopy(detail.get("cost_assumptions") or []), "exclusions": copy.deepcopy(detail.get("cost_exclusions") or []), @@ -256,7 +283,7 @@ def _cost_items_from_inventory(inventory: list[Any]) -> list[dict[str, str]]: { "name": str(raw.get("product") or raw.get("resource_id") or "").strip(), "spec": spec, - "monthly_cost": str(raw.get("rough_monthly_cost") or "").strip(), + "cost": str(raw.get("rough_cost") or "").strip(), } ) return items @@ -278,7 +305,7 @@ def _normalize_candidate( if not isinstance(summary, str) or not summary.strip(): raise CompletionEnrichmentError(_("candidates[{index}].summary must be non-empty").format(index=index)) - normalized = copy.deepcopy(candidate) + normalized = normalize_candidate_costs(candidate) normalized["candidate_id"] = f"candidate-{index}" normalized["name"] = name.strip() normalized["summary"] = summary.strip() @@ -396,7 +423,7 @@ def _topology_text(candidate: dict[str, Any]) -> str: def _candidate_option_summary(candidate: dict[str, Any]) -> str: - """Compose 「概述;月度价格;首要代价」, idempotently. + """Compose 「概述;当前周期价格;首要代价」, idempotently. 重新规划时模型在自己的上下文里看到的是上一轮拼好的选项文案,会把它原样当成候选概述 交回来,于是价格与代价在方案卡里出现两遍。价格前的分隔符是上一轮拼接留下的标记, @@ -404,9 +431,9 @@ def _candidate_option_summary(candidate: dict[str, Any]) -> str: """ summary = str(candidate.get("summary") or "") rough_cost = candidate.get("rough_cost") - monthly_range = rough_cost.get("monthly_range") if isinstance(rough_cost, dict) else None + cost_range = rough_cost.get("cost_range") if isinstance(rough_cost, dict) else None notes = candidate.get("cons") - price = str(monthly_range or "") + price = str(cost_range or "") tradeoff = str(notes[0]) if isinstance(notes, list) and notes else "" summary = _strip_composed_option_tail(summary, current_price=price) summary = summary.strip() @@ -427,6 +454,6 @@ def _strip_composed_option_tail(summary: str, *, current_price: str) -> str: # A projected option always has content before the price and a trade-off after it. # Requiring a complete price-shaped segment avoids trimming ordinary prose that uses semicolons. for index in range(1, len(segments) - 1): - if _COMPOSED_MONTHLY_PRICE_RE.fullmatch(segments[index]): + if _COMPOSED_PRICE_RE.fullmatch(segments[index]): return ";".join(segments[:index]) return summary diff --git a/src/iac_code/pipeline/selling_solution_first/pipeline.yaml b/src/iac_code/pipeline/selling_solution_first/pipeline.yaml index 654ef8578..6b16ff34f 100644 --- a/src/iac_code/pipeline/selling_solution_first/pipeline.yaml +++ b/src/iac_code/pipeline/selling_solution_first/pipeline.yaml @@ -299,28 +299,28 @@ steps: billing_method: type: string description: 用于粗估的计费方式,例如按量付费。 - rough_monthly_cost: + rough_cost: type: string - description: 该清单项的月度粗估区间或免费说明。 + description: 该清单项的粗估区间及周期,默认按月,用户指定单位或时长时与其一致。 lifecycle: type: string enum: [create, use_existing, reference, forbid] description: 与 resource_intents 一致的资源生命周期。 rough_cost: type: object - description: Step 1 的架构级月度粗估,不是 ROS 精确询价结果。 - required: [currency, monthly_range, items, assumptions, exclusions, confidence] + description: 架构级费用粗估,默认按月,用户指定单位或时长时与其一致;不是 ROS 精确询价结果。 + required: [currency, cost_range, items, assumptions, exclusions, confidence] properties: currency: type: string enum: [CNY] description: 粗估费用币种,固定为 CNY。 - monthly_range: + cost_range: type: string - description: 面向用户的月度总费用区间,例如 ¥500~¥800/月。 + description: 面向用户的总费用区间及实际周期,例如 ¥500~¥800/月或 ¥3~¥5/小时。 items: type: array - description: 构成月度粗估的主要计费项。 + description: 构成当前周期粗估的主要计费项。 items: type: object description: 一项产品或资源的粗估费用。 @@ -331,9 +331,9 @@ steps: spec: type: string description: 采用的粗估规格与数量。 - monthly_cost: + cost: type: string - description: 该项月度粗估区间或免费说明。 + description: 该项粗估区间及实际周期,须与总费用使用同一单位和时长。 assumptions: type: array description: 粗估成立所依据的地域、用量、规格和计费假设。 diff --git a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md index dbd0b2fed..2d5232184 100644 --- a/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md +++ b/src/iac_code/pipeline/selling_solution_first/prompts/solution_planning_and_selection.md @@ -25,13 +25,14 @@ ### 选择恢复或回滚重规划 -上方状态为 `awaiting_selection` 时,用户已经看过候选。本轮消息只能按以下三类处理: +上方状态为 `awaiting_selection` 时,用户已经看过候选。本轮消息按以下四类处理: 1. **选择候选**:按候选坐标、唯一名称或自然语言偏好映射到已保存的候选,只提交下面的选择增量;不要重复 candidates、intent、options 或 selected_candidate。 2. **修改当前架构**:结合用户新增要求重新规划,用一次新的 `show_architecture_plan` 提交修改后的完整摘要 批次,再逐个细化;不得提交增量 patch,也不得把架构修改误报为已选择。 3. **替换部署目标**:本轮最新输入成为新的权威需求,丢弃旧 `intent`、候选和产品组合,重新执行技能的 意图、摘要批次和逐候选细化流程。新旧部署需求不得合并。 +4. **咨询当前方案**:配置、费用或用券适用性等追问先按事实可见答复,按需查询,并使用用户要求的表格等格式;不把咨询当成选择或取消部署。每次都先输出本轮可见答复,再调用 `complete_step`;只提交等待选择结论不能代替回答,重复提问或重新查询也不能省略答复。没有修改要求时保留候选并提交 `awaiting_selection`,不重新规划或进入实现。 用户只要求“重新选择方案”且没有增加架构要求或替换部署目标时,不要重新生成、展示或重复提交候选;提交 `{"conclusion":{"status":"awaiting_selection"}}`,Python 会从已保存的 `solution_selection` 恢复原候选并重新打开选择界面。 @@ -43,7 +44,6 @@ 本 Step 只选择架构,不接收部署参数。输入中的 `parameter_overrides`、`deployment_parameters` 或 `parameters` 不写入结论,部署参数统一交给下一步处理。 选择分支的 `complete_step` 形状: - `{"conclusion":{"status":"selected","selected_candidate_index":0}}` 首次规划或用户明确修改架构/替换部署目标时须提交包含 `status` 和 `intent` 的 @@ -57,3 +57,4 @@ candidate_id、output_path 和同序 options。 - `complete_step` 参数必须是 `{"conclusion": {...}}`,不得把结论字段放到工具参数顶层。 - 本 Step 不生成或写入模板、不做 ROS 精确询价、不执行云写操作。只读查询、记忆读取和展示的边界遵循技能与当前工具权限。 +{ros_agent_awareness} diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md index eb7499a2f..6aa9a1b1b 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-deploying/SKILL.md @@ -178,7 +178,7 @@ conclusion_schema: | 文件 | 内容 | |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md index eeeff3602..4268a3562 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-materialize-selected-candidate/SKILL.md @@ -212,7 +212,7 @@ Preview 明确表明新建资源受配额、容量或账号限制时,先用 `a | 文件 | 内容 | 何时查阅 | |------|------|----------| -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md) | 需要了解产品属性、规格选型、库存相关字段时 | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | 需要了解产品属性、规格选型、库存相关字段时 | | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组 | 生成或修复 Parameters 定义时 | | [references/ros-template.md](references/ros-template.md) | ROS 模板最佳实践:RunCommand、嵌套栈、条件部署 | 生成或修复资源定义、内置函数用法等模板结构问题时 | | [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) | 参数推荐与回溯规则、PreviewStack 参数集形成方法 | 求解库存/已有资源参数并形成预览参数集时 | diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md index 22329822b..6a6f2aadb 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/SKILL.md @@ -11,7 +11,7 @@ user_invocable: false 本流程只支持阿里云。用户明确要求 AWS、Azure、GCP、腾讯云、华为云等非阿里云平台时,不要输出对应平台资源,不要把它作为支持的基础设施需求继续推进;必须先澄清是否改为阿里云目标,或将其作为不支持/非阿里云需求结束。 -本步骤不生成模板、不询价、不执行任何云写操作。 +本步骤不生成模板、不做完整询价验证、不执行任何云写操作。 ## 第一部分:意图分类 @@ -122,6 +122,8 @@ user_invocable: false ### 硬约束提取规则 +CPU、内存、磁盘、带宽等规格直接影响费用,必须准确区分用户的最低要求、推荐配置与固定要求,不得擅自升降规格或把推荐值提升为硬约束。同一规格同时有最低值和推荐值时,最低值作为约束下限,推荐值仅作选型偏好。 + 每条硬约束是一个对象,字段为 `id`、`target`、`property`、`operator`、`value`、可选 `unit`、`verification_mode`、`source`、`source_text`: - `id`:当前请求内稳定且唯一的约束标识;用户修改同一约束时保持 ID,内容更新为最新要求。 @@ -136,7 +138,7 @@ user_invocable: false 提取规则: -- "2 核 4 GiB"可提取为同一目标的 `vcpu eq 2 count` 与 `memory eq 4 GiB` 两条约束;它们需要把实际产品规格映射到具体部署参数,因此使用 `verification_mode: tool`。这里只负责忠实表达,不选择具体实例规格或 API。 +- 用户明确固定为"2 核 4 GiB"时,提取 `vcpu eq 2 count` 与 `memory eq 4 GiB`;若同时说明"最低 2 GiB,推荐 4 GiB",内存只提取 `memory gte 2 GiB`,不得再加 `memory eq/gte 4 GiB`。规格需要映射到具体部署参数,使用 `verification_mode: tool`;这里只忠实表达,不选择具体实例规格或 API。 - 将用户口语单位规范化后写入结构化字段:CPU 的"核/核心/vCPU"统一为 `count`,内存语境中的 `g/G` 统一为 `GiB`、`m/M` 统一为 `MiB`;保留用户原始表达在 `source_text`,不要把内存单位误解为带宽单位。 - "至少 100 GiB""带宽不超过 20 Mbps""只能用 8.0""不要公网 IP"分别使用 `gte`、`lte`、`in/eq`、`eq false` 等通用表达。 - 实际值能从模板属性或最终部署参数直接定位时使用 `verification_mode: direct`;依赖云产品元数据、SKU 映射、库存或已有资源状态时使用 `verification_mode: tool`。 @@ -161,6 +163,10 @@ user_invocable: false ## 第二部分:详细架构规划 +规划、粗估或调用某个阿里云产品的 API 前,必须先用 `read_file` 读取 [references/cloud-products/](references/cloud-products/) 下该产品的对应文档,了解选型策略、配置限制和计费方式。同一上下文已完整读取时直接复用;只加载本次涉及产品的文档。 + +产品与文件映射:云服务器 ECS → `ecs.md`;轻量应用服务器 SWAS → `swas.md`;云数据库 RDS → `rds.md`;云数据库 Redis → `redis.md`;负载均衡 SLB/CLB/ALB/NLB → `slb.md`;专有网络 VPC → `vpc.md`;对象存储 OSS → `oss.md`;全球加速 GA → `ga.md`。 + ### 核心原则:按需设计,不过度发挥 方案数量取决于需求复杂度,而非固定出 2-3 个凑数: @@ -242,7 +248,7 @@ user_invocable: false "quantity": 2, "recommended_spec": "2 vCPU / 4 GiB,最终规格以库存和询价为准", "billing_method": "包年包月或按量付费", - "rough_monthly_cost": "¥400~¥700/月", + "rough_cost": "¥400~¥700/月", "lifecycle": "create" } ``` @@ -293,8 +299,8 @@ user_invocable: false ```json { "currency": "CNY", - "monthly_range": "¥1800~¥2600/月", - "items": [{"name": "ECS", "spec": "2 vCPU / 4 GiB × 2", "monthly_cost": "¥400~¥700/月"}], + "cost_range": "¥1800~¥2600/月", + "items": [{"name": "ECS", "spec": "2 vCPU / 4 GiB × 2", "cost": "¥400~¥700/月"}], "assumptions": ["地域 cn-hangzhou", "按量付费", "ECS 2 台"], "exclusions": ["公网流量费", "日志写入量", "跨地域流量"], "confidence": "low" @@ -302,12 +308,18 @@ user_invocable: false ``` - 只给区间,不要求精确到个位;费用估算基于阿里云公开定价的合理范围。 +- 默认按月估算;用户明确指定小时、天、周或年时,总价、资源明细和预算比较均使用该单位。另给出使用时长时,同时说明该时长的预计总费用;仅指定计费单位时,不擅自假定使用时长。 +- 用户修改周期或时长时,保留未改变的规格与部署目标,更新费用及其假设。不能用包月价直接推导按量单价,或拿不同周期的金额判断超预算。 +- 金额字符串必须明确标出实际估算周期,不能把小时、天或年费用显示为月费。 - 必须在 `assumptions` 中说明地域、计费方式、数量和规格假设。 +- 套餐型产品可复用上下文中的目标地域套餐参考信息,信息不足时按需查询;粗估不额外验证活动资格或逐套餐询价。说明价格来源和购买时长,不凭假设的促销价使方案满足预算;用户提供的活动价注明适用条件尚未确认。 - 不包含的费用必须显式列入 `exclusions`,例如公网流量、短信、日志写入量、跨地域流量。 - 无法形成可信估计时,使用较宽区间并标记 `confidence: low`,不得伪造精确金额。 - 有用户预算硬约束时,候选区间上限原则上不得超过预算;确实无法满足时先澄清或明确标记冲突,不得静默放宽预算。 - 本阶段**不调用** `ros_estimate_template_cost`,也**不输出** `OriginalAmount`/`TradeAmount`。精确询价由实现步骤完成。 +部署需求涉及优惠券、代金券或用券预算时,先用 `read_file` 按需读取 [优惠券指南](references/coupons.md),再识别与查询券的适用条件;同一上下文已读取时直接复用。此类问题留在部署流程内,不因提到优惠券而分流或拒绝部署。 + ### 方案说服力 方案卡直接展示 `decision_notes`,它决定用户能不能判断「为什么该选这个方案」。四个字段必填,且不得为空数组: @@ -358,7 +370,7 @@ user_invocable: false 展示分两阶段执行: 1. 先调用一次 `show_architecture_plan(candidates=[...])`,提交本轮**全部**轻量候选摘要。每项只包含 - `candidate_name`、`summary`、`total_monthly_cost` 和 `key_tradeoff`。数组顺序定义 0 基候选坐标, + `candidate_name`、`summary`、`total_cost` 和 `key_tradeoff`。数组顺序定义 0 基候选坐标, 不得在此工具中提交 `nodes`、`edges`、资源清单或详细价格项。用户要求增减方案时重新提交修改后的 完整摘要数组,不提交增量 patch。 2. 摘要批次成功后,按 0 基下标逐个调用 `show_candidate_detail`。每个模型轮次只细化一个候选,参数中的 @@ -377,12 +389,13 @@ user_invocable: false 没有成功摘要批次时不得调用 `show_candidate_detail`;详情数量、下标或名称不完整时不得调用 `complete_step`。某个详情失败时只修正并重试该候选。 -候选的 `summary`、`rough_cost.monthly_range` 和 `decision_notes` 应足够支持 Python 生成紧凑 options;不要只给泛化名称。 +候选的 `summary`、`rough_cost.cost_range` 和 `decision_notes` 应足够支持 Python 生成紧凑 options;不要只给泛化名称。 ### 处理选择 用户选择后本步骤会带着已保存的候选上下文恢复执行: +- "为什么不能选……""这个能用吗"等适用性或价格追问不是选案确认。先回答问题;没有明确选择或修改要求时,不提交 `selected`、不进入实现,也不重新规划。 - 结构化选择消息优先使用候选坐标 `selected_candidate_index` / `selected_evaluated_candidate_index`(两者一致,都是 0 基下标)。 - 用户提供候选名称时按 `name` 匹配;名称重复时必须用下标消歧。 - 本步骤只确定架构方案,不接收部署参数覆盖。结构化消息中的 `parameter_overrides`、`deployment_parameters` 或 `parameters` 不写入结论;模板生成后的部署参数统一由下一步处理。 diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json index c7c16568d..cdb5f87de 100644 --- a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/evals.json @@ -73,7 +73,7 @@ {"name": "single_candidate", "check": "candidates 数组长度为 1"}, {"name": "no_extra_resources", "check": "products 中只包含 VPC,不额外创建 VSwitch、SecurityGroup、ECS、RDS 或 SLB"}, {"name": "still_awaits_selection", "check": "status 为 awaiting_selection,即使只有一个候选也要用户明确选择"}, - {"name": "shows_outline_batch", "check": "先调用一次 show_architecture_plan,candidates 仅含 candidate_name、summary、total_monthly_cost、key_tradeoff"}, + {"name": "shows_outline_batch", "check": "先调用一次 show_architecture_plan,candidates 仅含 candidate_name、summary、total_cost、key_tradeoff"}, {"name": "shows_candidate_detail", "check": "再调用一次 show_candidate_detail,提交下标 0 的 topology_graph、resource_inventory、费用假设和 decision_notes"}, {"name": "compact_completion", "check": "complete_step 只提交 status 和 intent,不提交完整 candidates"} ] @@ -90,7 +90,7 @@ {"name": "meaningful_diff", "check": "方案之间的 products 组合或 topology 有实质差异,不是仅规格不同"}, {"name": "has_resource_inventory", "check": "每个候选的 resource_inventory 非空,且每条含 product、purpose、quantity、lifecycle"}, {"name": "has_topology_graph", "check": "每个候选的 topology_graph.nodes 非空,且 edges 的 source/target 都引用已定义的节点 id"}, - {"name": "has_rough_cost", "check": "每个候选的 rough_cost 含 monthly_range、assumptions 和 exclusions"}, + {"name": "has_rough_cost", "check": "每个候选的 rough_cost 含 cost_range、assumptions 和 exclusions"}, {"name": "no_exact_pricing", "check": "不调用 ros_estimate_template_cost,不输出 OriginalAmount/TradeAmount"} ] }, @@ -100,7 +100,7 @@ "prompt": "部署一个API服务,月预算500元以内", "expected_behavior": "在预算约束下给出 1-2 个候选,rough_cost 区间上限不超过预算,assumptions 说明地域和计费方式", "assertions": [ - {"name": "within_budget", "check": "所有候选 rough_cost.monthly_range 上限不超过 500 CNY"}, + {"name": "within_budget", "check": "所有候选 rough_cost.cost_range 上限不超过 500 CNY"}, {"name": "no_overengineering", "check": "不包含 SLB 集群、多可用区等超出预算的高可用配置"}, {"name": "documents_assumptions", "check": "rough_cost.assumptions 说明地域、计费方式和数量假设"}, {"name": "budget_recorded_as_constraint", "check": "预算写入 intent.budget_constraint,并作为硬约束快照传入候选"} diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references new file mode 120000 index 000000000..9fc803476 --- /dev/null +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/references @@ -0,0 +1 @@ +../../../selling/references \ No newline at end of file diff --git a/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md new file mode 100644 index 000000000..24b6fb588 --- /dev/null +++ b/src/iac_code/pipeline/selling_solution_first/skills/iac-aliyun-solution-first/ros-agent-awareness.md @@ -0,0 +1,4 @@ +ROS console override: +1) Deployment coupon/budget: read coupons.md, query/answer visibly; keep `awaiting_selection`; NEVER link/reject. +2) ELSE standalone billing, renewals/promos, prices/availability or traffic/selection promos: OUTPUT {ai_assistant_destination} as visible text, NOT only tool input; only `complete_step` with `status: rejected`, full URL in `rejection_reason`; override clarification/reselection. +Keep deployment architecture/cost tradeoffs here; do not promise discounts or session transfer. diff --git a/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py b/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py index 8058e0ee7..c3b49f415 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/candidate_planning_records.py @@ -7,6 +7,7 @@ from __future__ import annotations +import copy from typing import Any @@ -71,8 +72,9 @@ def normalize_outline_candidates(value: Any) -> list[dict[str, str]] | None: if not isinstance(item, dict): return None candidate: dict[str, str] = {} - for field in ("candidate_name", "summary", "total_monthly_cost", "key_tradeoff"): - raw = item.get(field) + for field in ("candidate_name", "summary", "total_cost", "key_tradeoff"): + # Old persisted outline records remain readable, but schemas expose only total_cost. + raw = item.get(field, item.get("total_monthly_cost") if field == "total_cost" else None) if not isinstance(raw, str) or not raw.strip(): return None candidate[field] = raw.strip() @@ -83,6 +85,52 @@ def normalize_outline_candidates(value: Any) -> list[dict[str, str]] | None: return normalized +def _rename_cost_field(value: dict[str, Any], old: str, new: str) -> None: + if old in value: + if new not in value: + value[new] = value[old] + del value[old] + + +def normalize_inventory_costs(value: Any) -> Any: + """Copy legacy resource costs into the period-neutral planning contract.""" + inventory = copy.deepcopy(value) + if isinstance(inventory, list): + for resource in inventory: + if isinstance(resource, dict): + _rename_cost_field(resource, "rough_monthly_cost", "rough_cost") + return inventory + + +def normalize_candidate_costs(value: Any) -> Any: + """Migrate only known planning fields, preserving values and canonical-key precedence.""" + candidate = copy.deepcopy(value) + if not isinstance(candidate, dict): + return candidate + if "resource_inventory" in candidate: + candidate["resource_inventory"] = normalize_inventory_costs(candidate["resource_inventory"]) + cost = candidate.get("rough_cost") + if isinstance(cost, dict): + _rename_cost_field(cost, "monthly_range", "cost_range") + items = cost.get("items") + if isinstance(items, list): + for item in items: + if isinstance(item, dict): + _rename_cost_field(item, "monthly_cost", "cost") + return candidate + + +def normalize_solution_selection_costs(value: Any) -> None: + """Normalize restored candidates in place without changing context versions or stale flags.""" + if not isinstance(value, dict): + return + candidates = value.get("candidates") + if isinstance(candidates, list): + value["candidates"] = [normalize_candidate_costs(candidate) for candidate in candidates] + if "selected_candidate" in value: + value["selected_candidate"] = normalize_candidate_costs(value["selected_candidate"]) + + def latest_candidate_detail_records( records: list[dict[str, Any]], batch: CandidateOutlineBatch, @@ -147,4 +195,7 @@ def _record_sequence(record: dict[str, Any], position: int) -> int: "latest_candidate_detail_records", "latest_candidate_outline_batch", "normalize_outline_candidates", + "normalize_inventory_costs", + "normalize_candidate_costs", + "normalize_solution_selection_costs", ] diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py index e4468d60e..299f24087 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/show_architecture_plan_tool.py @@ -49,7 +49,7 @@ def name(self) -> str: def description(self) -> str: return _( "Display one complete batch of lightweight candidate outlines before rich details are generated. " - "Submit every current candidate in order with its name, summary, monthly estimate and key trade-off. " + "Submit every current candidate in order with its name, summary, cost estimate and key trade-off. " "Do not include topology nodes, resource inventory or detailed cost items." ) @@ -79,10 +79,13 @@ def input_schema(self) -> dict[str, Any]: "minLength": 1, "description": _("Short product combination and architecture summary"), }, - "total_monthly_cost": { + "total_cost": { "type": "string", "minLength": 1, - "description": _("Rough monthly range, such as ¥230~¥380/month"), + "description": _( + "Rough cost range including its period: monthly by default, " + "or the user's requested unit and duration." + ), }, "key_tradeoff": { "type": "string", @@ -90,7 +93,7 @@ def input_schema(self) -> dict[str, Any]: "description": _("The most important cost, availability or complexity trade-off"), }, }, - "required": ["candidate_name", "summary", "total_monthly_cost", "key_tradeoff"], + "required": ["candidate_name", "summary", "total_cost", "key_tradeoff"], }, }, }, @@ -110,7 +113,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> return ToolResult.error( _( "candidates must be a non-empty array of unique outlines with candidate_name, summary, " - "total_monthly_cost and key_tradeoff" + "total_cost and key_tradeoff" ) ) records = self._completion_guard_state.get("tool_result_records") @@ -134,7 +137,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> candidate_name=candidate["candidate_name"], summary=candidate["summary"], cost_items=[], - total_monthly_cost=candidate["total_monthly_cost"], + total_monthly_cost=candidate["total_cost"], candidate_index=candidate_index, candidate_set_id=candidate_set_id, detail_stage="outline", diff --git a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py index 8607e1269..a1c99e743 100644 --- a/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py +++ b/src/iac_code/pipeline/selling_solution_first/tools/show_candidate_detail_tool.py @@ -10,6 +10,7 @@ from iac_code.pipeline.selling_solution_first.tools.candidate_planning_records import ( first_missing_candidate_detail_index, latest_candidate_outline_batch, + normalize_inventory_costs, ) from iac_code.pipeline.selling_solution_first.tools.show_architecture_plan_tool import ( render_architecture_graph, @@ -33,7 +34,7 @@ def description(self) -> str: return _( "Display the rich detail for exactly one candidate from the latest show_architecture_plan batch. " "Call once per model turn in candidate index order. Include resource lifecycle intent, topology graph, " - "resource inventory, cost assumptions and decision notes; do not repeat summary or monthly total." + "resource inventory, cost assumptions and decision notes; do not repeat summary or cost total." ) @property @@ -126,7 +127,12 @@ def input_schema(self) -> dict[str, Any]: "quantity": {"type": "integer", "minimum": 1}, "recommended_spec": {"type": "string"}, "billing_method": {"type": "string"}, - "rough_monthly_cost": {"type": "string"}, + "rough_cost": { + "type": "string", + "description": ( + "Rough cost including its period, matching the outline's unit and duration." + ), + }, "lifecycle": { "type": "string", "enum": ["create", "use_existing", "reference", "forbid"], @@ -236,7 +242,7 @@ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> candidate_name=expected_name, summary=outline["summary"], cost_items=cost_items, - total_monthly_cost=outline["total_monthly_cost"], + total_monthly_cost=outline["total_cost"], candidate_index=expected_index, candidate_set_id=batch.candidate_set_id, detail_stage="detail", @@ -283,7 +289,8 @@ def _cost_items_from_inventory(value: Any) -> list[dict[str, str]]: if not isinstance(value, list): return [] items: list[dict[str, str]] = [] - for raw in value: + # CandidateDetailEvent is the compatibility boundary for existing UI clients. + for raw in normalize_inventory_costs(value): if not isinstance(raw, dict): continue product = str(raw.get("product") or raw.get("resource_id") or "").strip() @@ -295,7 +302,7 @@ def _cost_items_from_inventory(value: Any) -> list[dict[str, str]]: { "name": product, "spec": spec, - "monthly_cost": str(raw.get("rough_monthly_cost") or "").strip(), + "monthly_cost": str(raw.get("rough_cost") or "").strip(), } ) return items diff --git a/src/iac_code/skills/bundled/iac_aliyun/SKILL.md b/src/iac_code/skills/bundled/iac_aliyun/SKILL.md index 3da1b458e..d68ae4ad1 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/SKILL.md +++ b/src/iac_code/skills/bundled/iac_aliyun/SKILL.md @@ -173,7 +173,7 @@ auto_trigger: |------|------| | [references/template-parameters.md](references/template-parameters.md) | 模板参数规范:AssociationProperty、Label、分组(ROS/Terraform 共用) | | [references/template-parameter-recommendation.md](references/template-parameter-recommendation.md) | 已有 ROS 可部署模板的部署前参数推荐流程 | -| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | +| [references/cloud-products/](references/cloud-products/) | 云产品选型文件(ecs.md、swas.md、rds.md、redis.md、slb.md、vpc.md、oss.md、ga.md) | | [references/ros-template.md](references/ros-template.md) | ROS 原生模板最佳实践:RunCommand、嵌套栈、条件部署 | | [references/terraform-template.md](references/terraform-template.md) | Terraform 最佳实践:文件组织、变量、Data Source、ROS 集成 | diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md index c27f625c5..d511514f1 100644 --- a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +++ b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md @@ -14,6 +14,10 @@ - 性价比优先配置:u1-/u2a-/u2i- - 性能优先配置:g9i/c9i/r9i +规格族特点: +- **经济型 e**:低成本共享型实例,适合轻量应用和开发测试,CPU 性能可能因资源竞争而波动。 +- **突发性能型 t 系列**:适合低平均负载、偶发高负载,通过 CPU 积分提供突发性能,需关注基准性能及积分费用。 + ### vCPU / 内存确定原则 - 用户明确指定 vCPU 或内存时,该数值是硬约束,直接覆盖本节的场景化推荐与档位推导。必须用 `DescribeInstanceTypes` 返回的实际规格验证,不能根据实例规格名猜测,也不能静默升级或降级。 diff --git a/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md new file mode 100644 index 000000000..6294ac7ab --- /dev/null +++ b/src/iac_code/skills/bundled/iac_aliyun/references/cloud-products/swas.md @@ -0,0 +1,84 @@ +# 阿里云轻量应用服务器(SWAS)选型指南 + +## 适用场景 + +- 适合个人网站、开发测试、小型 Web 和单机应用,优先考虑标准套餐及应用镜像,减少环境搭建工作。 +- 需要 GPU、复杂网络、弹性伸缩或更丰富实例规格时,选择 ECS。 +- SWAS 是独立产品,底层基于 ECS 的计算、存储和网络资源;API 产品标识为 `SWAS-OPEN`,套餐、镜像和防火墙使用 SWAS 自己的标识与接口。 + +## 套餐选择 + +根据目标地域下的实际套餐,选择满足应用要求的配置: + +| 维度 | 选择依据 | +|------|----------| +| 计算 | `Core`(vCPU)、`Memory`(GiB):满足应用及同机运行服务的 CPU、内存需求 | +| 存储 | `DiskSize`(GiB)、`DiskType`:系统、应用与数据所需容量及磁盘类型 | +| 网络 | `Bandwidth`(Mbps)、`Flow`(GB/月):峰值带宽与每月流量配额,结合访问量选择 | +| 操作系统 | `SupportPlatform`:套餐支持的 Linux / Windows 平台 | +| 价格 | `OriginPrice`、`Currency`:套餐包月参考价与币种 | + +以查询结果关联 `PlanId` 与规格,不根据套餐 ID 猜测配置。不同地域的套餐及镜像可能不同,不直接复用其他地域的选择。 + +`OriginPrice` 返回字符串金额,计算费用时按数值处理;`SupportPlatform` 是 JSON 数组形式的字符串(如 `["Linux","Windows"]`),需解析后匹配平台。`Flow` 可能缺失,不将缺失值直接解释为零流量或无限流量;结合当前套餐说明判断,信息不足时保留未知。 + +## 镜像选择 + +- 标准应用或容器环境:优先匹配应用镜像,例如 WordPress、Docker;核对镜像描述中的系统与组件版本是否适配应用。 +- 自定义技术栈:选择兼容的系统镜像,自行安装运行环境。 +- 复制已有环境:可使用轻量应用服务器的自定义镜像。 +- 使用 `ListImages` 返回的 SWAS `ImageId`,不要直接使用 ECS 镜像 ID,也不要把教程中的旧版本或示例 ID 当成当前可用值。 +- 镜像列表可能包含旧版本,优先选择仍受维护且满足应用依赖的版本,不按返回顺序直接取第一项。 + +## 存储与网络配置 + +- 套餐已包含系统盘和公网访问能力,不要为同一能力额外配置 ECS 系统盘或 EIP。 +- 应用数据超过系统盘容量时,可增加一块单独计费的数据盘;每台实例最多挂载一块,数据盘不能合并到系统分区。需要扩大系统盘时升级套餐。 +- 实例使用 SWAS 防火墙控制入流量。按最小授权原则配置业务端口与来源地址,不套用 ECS 安全组。 +- 流量政策按套餐区分:固定流量配额套餐可能产生超额流量费,无固定流量套餐按其计费说明处理;不要仅凭峰值带宽承诺无限流量。 + +## 计费与价格 + +- 套餐和数据盘按包年包月购买;适用的超额流量按量计费。 +- `ListPlans.OriginPrice` 是套餐的**包月价格**。参考总价可按「包月价格 × 购买月数 × 台数」折算,但该值不包含年付折扣、活动优惠或额外数据盘等费用。 +- 包月价、年付总价和活动价分别说明,比较时保持地域、套餐、购买时长、数量及费用范围一致。 +- 首购、学生、新人或续费等活动价格受活动条件约束,不凭规格相同就认定用户可享受同一优惠;实际费用以当前购买或询价结果为准。 + +## 套餐与镜像查询 + +### 查询目标地域的套餐 + +```python +aliyun_api(product="SWAS-OPEN", action="ListPlans", region_id="<目标地域>") +``` + +根据套餐字段筛选配置,保留对应的 `PlanId`、价格及币种。套餐列表描述规格和参考价格,不代表已经完成库存或创建可行性检查。 + +### 查询目标地域的镜像 + +```python +aliyun_api(product="SWAS-OPEN", action="ListImages", params={ + "ImageType": "system" +}, region_id="<目标地域>") +``` + +`ImageType` 可选 `system`(系统镜像)、`app`(应用镜像)或 `custom`(自定义镜像);根据镜像的 `Platform`、名称和描述匹配套餐平台与应用依赖。 + +## 最佳实践 + +- 应用镜像能够满足需求时,优先复用预装环境;自定义部署应匹配操作系统与依赖版本。 +- 网站或 API 仅开放必要业务入口;管理端口限制来源,数据库等内部服务避免直接向公网开放。 +- 重要数据保留备份;重置系统前备份系统盘数据,重置会清除原有系统盘内容。 +- 持续监控资源使用情况,容量不足时考虑升级套餐。套餐仅支持升配、不支持降配,也不支持单独升级 CPU、内存、系统盘或带宽;需要更复杂资源配置时评估迁移到 ECS。 + +## 官方参考 + +- [产品概述](https://help.aliyun.com/zh/simple-application-server/product-overview/what-is-simple-application-server) +- [与 ECS 对比](https://help.aliyun.com/zh/simple-application-server/product-overview/comparison-between-simple-application-server-and-ecs) +- [ListPlans:套餐信息与包月价格字段](https://help.aliyun.com/zh/simple-application-server/developer-reference/api-swas-open-2020-06-01-listplans) +- [ListImages:镜像信息](https://help.aliyun.com/zh/simple-application-server/developer-reference/api-swas-open-2020-06-01-listimages) +- [计费项](https://help.aliyun.com/zh/simple-application-server/product-overview/billable-items) +- [挂载数据盘及使用限制](https://help.aliyun.com/zh/simple-application-server/user-guide/attach-a-data-disk) +- [升级套餐配置及限制](https://help.aliyun.com/zh/simple-application-server/user-guide/upgrade-a-simple-application-server) +- [最佳实践:快速部署 Docker](https://help.aliyun.com/zh/simple-application-server/use-cases/deploy-and-use-docker) +- [管理防火墙](https://help.aliyun.com/zh/simple-application-server/user-guide/manage-the-firewall-of-a-server) diff --git a/src/iac_code/tools/cloud/aliyun/openmeta.py b/src/iac_code/tools/cloud/aliyun/openmeta.py index da61247da..f8dd0fa97 100644 --- a/src/iac_code/tools/cloud/aliyun/openmeta.py +++ b/src/iac_code/tools/cloud/aliyun/openmeta.py @@ -8,9 +8,11 @@ import json import logging import os +import random import re import sys import tempfile +import time from collections import OrderedDict from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, replace @@ -25,6 +27,14 @@ from iac_code.a2a.backup import run_sync_fenced from iac_code.tools.cloud.aliyun.api_identifiers import is_safe_api_version +from iac_code.tools.cloud.aliyun.retry_policy import ( + RetryBudget, + RetryExhausted, + RetryReason, + map_httpx_retry_reason, + map_retryable_status, + retry_delay, +) from iac_code.tools.cloud.aliyun.user_agent import build_user_agent from iac_code.utils.async_lifecycle import await_task_to_completion from iac_code.utils.file_security import ensure_private_dir @@ -73,6 +83,7 @@ _API_FRESH_TTL = timedelta(days=7) _STALE_TTL = timedelta(days=30) _NEGATIVE_TTL = timedelta(minutes=10) +_REQUEST_TIMEOUT_SECONDS = 8.0 logger = logging.getLogger(__name__) @@ -1523,46 +1534,105 @@ async def _request_json( url: str, *, max_bytes: int, + ) -> tuple[_JsonPayload | None, OpenMetaError | None, str]: + budget = RetryBudget(deadline=time.monotonic() + _REQUEST_TIMEOUT_SECONDS, random=random.random) + started = time.monotonic() + path = httpx.URL(url).path + for _ in range(budget.max_attempts): + try: + await budget.acquire() + result = await budget.run_attempt( + lambda: self._request_json_once(url, max_bytes=max_bytes), + retryable_call=True, + ) + except RetryExhausted: + logger.warning( + "OpenMeta request deadline exhausted: path=%s attempt=%s elapsed_ms=%d", + path, + budget.attempts, + (time.monotonic() - started) * 1000, + ) + break + except httpx.HTTPError as error: + status = None + retry_after = None + reason = map_httpx_retry_reason(error) + if isinstance(error, httpx.HTTPStatusError): + status = error.response.status_code + retry_after = error.response.headers.get("Retry-After") + reason = RetryReason.RETRYABLE_STATUS if status == 500 else map_retryable_status(status) + logger.warning( + "OpenMeta request failed: path=%s attempt=%s status=%s error=%s elapsed_ms=%d", + path, + budget.attempts, + status, + type(error).__name__, + (time.monotonic() - started) * 1000, + ) + if reason is None or budget.attempts >= budget.max_attempts: + break + try: + delay = retry_delay(budget, failed_attempt=budget.attempts, retry_after=retry_after, reason=reason) + except RetryExhausted: + break + await asyncio.sleep(delay) + else: + if budget.attempts > 1: + logger.debug( + "OpenMeta request completed: path=%s attempt=%s outcome=%s elapsed_ms=%d", + path, + budget.attempts, + result[1] or "success", + (time.monotonic() - started) * 1000, + ) + return result + return None, "temporarily_unavailable", url + + async def _request_json_once( + self, + url: str, + *, + max_bytes: int, ) -> tuple[_JsonPayload | None, OpenMetaError | None, str]: current = httpx.URL(url) for _ in range(3): - try: - async with self._client.stream("GET", current) as response: - if response.status_code in {301, 302, 303, 307, 308}: - location = response.headers.get("location") - if location is None: - return None, "temporarily_unavailable", str(current) - candidate = current.join(location) - if not _safe_redirect(candidate): - return None, "temporarily_unavailable", str(current) - current = candidate - continue - if response.status_code in {204, 404}: - return None, "not_found", str(current) - if response.status_code == 429 or response.status_code >= 500: + async with self._client.stream("GET", current) as response: + if response.status_code in {301, 302, 303, 307, 308}: + location = response.headers.get("location") + if location is None: return None, "temporarily_unavailable", str(current) - if response.status_code != 200: - return None, "protocol_error", str(current) - content_length = response.headers.get("content-length") - if content_length is not None: - try: - if int(content_length) > max_bytes: - return None, "protocol_error", str(current) - except ValueError: - pass - chunks: list[bytes] = [] - received = 0 - async for chunk in response.aiter_bytes(): - received += len(chunk) - if received > max_bytes: + candidate = current.join(location) + if not _safe_redirect(candidate): + return None, "temporarily_unavailable", str(current) + current = candidate + continue + if response.status_code in {204, 404}: + return None, "not_found", str(current) + if response.status_code == 500 or map_retryable_status(response.status_code) is not None: + response.raise_for_status() + if response.status_code == 429 or response.status_code >= 500: + return None, "temporarily_unavailable", str(current) + if response.status_code != 200: + return None, "protocol_error", str(current) + content_length = response.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > max_bytes: return None, "protocol_error", str(current) - chunks.append(chunk) - except httpx.HTTPError: - return None, "temporarily_unavailable", str(current) + except ValueError: + pass + chunks: list[bytes] = [] + received = 0 + async for chunk in response.aiter_bytes(): + received += len(chunk) + if received > max_bytes: + return None, "protocol_error", str(current) + chunks.append(chunk) try: async with self._decode_semaphore: payload = await run_sync_fenced(json.loads, b"".join(chunks)) except (json.JSONDecodeError, UnicodeDecodeError): + logger.warning("OpenMeta response is not valid JSON: path=%s", current.path) return None, "temporarily_unavailable", str(current) if not isinstance(payload, Mapping | list): return None, "protocol_error", str(current) diff --git a/src/iac_code/tools/cloud/aliyun/public_errors.py b/src/iac_code/tools/cloud/aliyun/public_errors.py index f896cded1..347e565df 100644 --- a/src/iac_code/tools/cloud/aliyun/public_errors.py +++ b/src/iac_code/tools/cloud/aliyun/public_errors.py @@ -140,9 +140,11 @@ def public_aliyun_error( operation=operation ) if code == "metadata_unavailable": - return _("Alibaba Cloud API metadata for {operation} is temporarily unavailable; try again later.").format( - operation=operation - ) + return _( + "Alibaba Cloud API metadata for {operation} is temporarily unavailable. " + "Retry the same tool call once after a short wait; if it still fails, " + "report the temporary failure rather than treating the API as unsupported." + ).format(operation=operation) if code == "metadata_protocol_error": return _( "Alibaba Cloud API metadata for {operation} returned an incompatible response. " diff --git a/src/iac_code/web/static/diagram-preview.html b/src/iac_code/web/static/diagram-preview.html index a4680ff42..130f42a65 100644 --- a/src/iac_code/web/static/diagram-preview.html +++ b/src/iac_code/web/static/diagram-preview.html @@ -106,6 +106,6 @@
- +