muse-agent-example/tests/集成/test_配置探针与章后绑定.py
zizi d909d1bd1b 后端实现与用例身份:19 包集成落地并修复收尾缺陷
实现侧:
- 上下文:任务范围拆分为 范围校验/范围授权;索引按可发现口径重建、索引新鲜度改对称差;依赖校验统一快照漂移说明。
- 知识方法:方法与材料读取口径统一;超限方法材料按可选省略,核对路径不再二次计费;删除无合同的读时重算。
- 任务运行:新增 context.usage/tool.denied 事件类型;连接池常驻并在装配生命周期内开关;调用结算与核对分列。
- 效果评测/审校修订/交付连载/作者经验/作品规划:凭据冻结、标定消费、导出补证、事实引文核对等收尾修复。
- 资源加载:能力正文不再夹带索引用的导航注记(该注记此前进入角色与技能的模型提示)。
- 元数据:受保护骨架与代码保护属性对齐;字段校验与内置结构口径同步。
- 基础设施:环境预检进入装配生命周期;数据库连接运行期字段不参与相等比较;索引指纹归一化 jsonb 浮点。
- 删除被替代实现:7 份旧提示词模板与空壳 资料来源 读取器。

用例侧:
- 用例身份与导航元信息迁移;夹具补生命周期、同库暴露与模板封存;
- 本轮定向修复:方法材料省略、事实引文、迁移回执、额度与暂停用例、慢用例超时预算等。
2026-09-18 01:15:00 +08:00

389 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""运行配置真实探针启用与章后自动绑定:CLI 入口、生产装配与作者推进合同。"""
from __future__ import annotations
import json
import threading
from contextlib import ExitStack
from dataclasses import asdict
from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
from muse.启动 import 构建, 构建默认运行
from muse.接入.cli.管理命令 import 运行配置命令
from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
from muse.编排.运行装配 import 推进作者任务
from muse.配置 import 读取配置
pytestmark = pytest.mark.数据库
草案 = """\
"宿主" = "direct"
"宿主版本" = "1"
"角色策略版本" = "{策略版本}"
"资源发布身份" = "{发布身份}"
"预算策略引用" = "账户-1"
"计价版本" = "{计价版本}"
["角色配置".extractor]
provider = "网关"
model = "claude-opus-4-8"
thinking = "off"
tools = []
[["凭据"]]
"名称" = "key"
"来源" = "受控存储"
"位置" = "{凭据路径}"
[["提供方"]]
"身份" = "网关"
"协议" = "chat-completions"
"地址" = "{网关地址}"
"凭据名称" = "key"
"""
def _启动网关(收到: list[dict]) -> ThreadingHTTPServer:
class 处理(BaseHTTPRequestHandler):
def do_POST(self):
数据 = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
收到.append(数据)
提示 = (数据.get("messages") or [{}])[0].get("content", "")
文本 = (
json.dumps({"ok": True})
if "运行配置探针" in 提示
else json.dumps(
{
"summary": "林深称量的动作被记录。",
"facts": [
{
"type": "character",
"object_id": "lin-shen",
"quote": "林深",
"content": {"名称": "林深"},
},
{
"type": "character",
"object_id": "lin-shen",
"quote": "药材",
"content": {"名称": "林深"},
},
],
},
ensure_ascii=False,
)
)
事件 = [
{
"model": "claude-opus-4-8",
"id": "网关响应-1",
"choices": [{"index": 0, "delta": {}}],
},
{
"model": "claude-opus-4-8",
"id": "网关响应-1",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"delta": {"content": 文本},
}
],
},
{
"model": "claude-opus-4-8",
"id": "网关响应-1",
"choices": [],
"usage": {
"prompt_tokens": 120,
"completion_tokens": 30,
"prompt_tokens_details": {"cached_tokens": 0},
},
},
]
载荷 = (
"".join("data: " + json.dumps(项, ensure_ascii=False) + "\n\n" for 项 in 事件)
+ "data: [DONE]\n\n"
)
编码 = 载荷.encode()
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(编码)))
self.end_headers()
self.wfile.write(编码)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), 处理)
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
def _作品与章(装配, 作者) -> None:
from muse.作品规划.接口 import 档案保存
作品 = 装配.要求作品()
结构选择 = 作品.新档案结构(作者, "post-work", "work_core", 1)
作品.保存档案(
作者,
"create-post-work",
档案保存(
"post-work",
0,
{"名称": "称量", "题材定位": "现实", "主题立意": "守约"},
结构选择,
),
)
for i in (1, 2):
作品.添加章节(
作者, f"add-post-work-{i}", "post-work", f"ch-{i}", f"第{i}章", 预期目录版本=i - 1
)
@pytest.mark.case_id(
"NC-w30-db0001",
environment="隔离 PostgreSQL + 本地合成网关",
given="隔离库、合成网关与文件费率;部署声明章后抽取配置ID",
when="CLI 保存-验证-启用配置后保存正文,经HTTP重建与推进章后派生",
then=[
"未启用时正文成功且不留孤儿任务",
"启用后主线保存自动登记章后任务并同事务绑定配置",
"按真实用量结算且正文不被派生重复提交",
"HTTP 可回查派生摘要与索引",
],
contract="docs/系统架构/新版设计/模块设计/S02-任务运行.md",
)
def test_运行配置探针启用与章后自动绑定推进__db0001(内置结构测试库, tmp_path: Path) -> None:
应用测试库 = 内置结构测试库
from muse.任务运行.接口 import 任务状态, 角色策略目录
from muse.任务运行.配置版本 import 配置版本管理
from muse.任务运行.预算管理 import 预算管理, 额度策略
库 = 应用测试库[用途.生产]
策略 = 角色策略目录.从发布包()
收到: list[dict] = []
网关 = _启动网关(收到)
凭据 = tmp_path / "网关凭据.txt"
凭据.write_text("synthetic-gateway-key")
凭据.chmod(0o600)
费率 = tmp_path / "费率.toml"
费率.write_text(
'"版本" = "费率-测试"\n"兜底模型" = "claude-opus-4-8"\n'
'["费率"."claude-opus-4-8"]\n"输入单价" = "5"\n"输出单价" = "25"\n'
'"缓存读取单价" = "0.5"\n"缓存写入单价" = "6.25"\n'
)
应用文件 = tmp_path / "应用.toml"
口令 = tmp_path / "工作台口令.txt"
口令.write_text("synthetic-author-access")
应用文件.write_text(
'["数据库"]\n"取值方式" = "受控存储"\n"位置" = '
+ json.dumps(库.引用.位置, ensure_ascii=False)
+ f'\n["资源"]\n"发布身份" = "{策略.资源发布身份}"\n'
+ '["运行"]\n"用途" = "production"\n'
+ f'["文件"]\n"原文暂存" = "{tmp_path / "原始"}"\n'
+ '["运行装配"]\n"计价文件" = "费率.toml"\n"章后抽取配置ID" = "post-config"\n'
+ '[HTTP]\n"作者ID" = "post-author"\n"口令文件" = '
+ json.dumps(str(口令), ensure_ascii=False)
+ '\n"公开地址" = "http://testserver"\n"允许来源" = ["http://testserver"]\n'
)
配置 = 读取配置(应用文件)
草案文件 = tmp_path / "草案.toml"
草案文件.write_text(
草案.format(
策略版本=策略.定义["version"],
发布身份=策略.资源发布身份,
计价版本="费率-测试",
凭据路径=凭据,
网关地址=f"http://127.0.0.1:{网关.server_port}/v1/chat/completions",
)
)
构建装配 = 构建(配置)
# 每次构建都是独立装配,常驻池只在自己的生命周期内可用;本用例全程复用两个装配。
with ExitStack() as 资源栈:
资源栈.enter_context(构建装配.生命周期())
作者装配 = 构建默认运行(配置)
资源栈.enter_context(作者装配.生命周期())
作者 = 调用身份("post-author", None, 用途.生产, 内容用途.抽取)
_作品与章(构建装配, 作者)
保存 = 作者装配.要求正文().保存人工(
作者,
"save-ch-1",
"ch-1",
0,
# 先保存但配置尚未启用:正文成功,派生如实缺配置。
正文草稿((段落("ch-1-p1", (文本节点("林深在药铺称量药材。"),)),)),
)
派生 = next(r for r in 保存["results"] if isinstance(r, dict) and "章后派生" in r)
assert 派生["章后派生"] == "config_not_enabled"
assert 作者装配.任务运行.列出任务("post-author") == [], "配置不可用时不得留下孤儿任务"
# 部署未声明抽取配置:如实呈现且同样不留孤儿任务。
无声明文件 = tmp_path / "应用无声明.toml"
无声明文件.write_text(
应用文件.read_text(encoding="utf-8").replace('"章后抽取配置ID" = "post-config"\n', "")
)
无声明装配 = 构建默认运行(读取配置(无声明文件))
资源栈.enter_context(无声明装配.生命周期())
assert 无声明装配.任务运行.章后抽取配置ID is None
保存未声明 = 无声明装配.要求正文().保存人工(
作者,
"save-ch-1-未声明",
"ch-1",
1,
正文草稿((段落("ch-1-p1", (文本节点("林深把药材放上秤盘,又取了下来。"),)),)),
)
未声明回执 = next(
r for r in 保存未声明["results"] if isinstance(r, dict) and "章后派生" in r
)
assert 未声明回执["章后派生"] == "config_not_declared"
assert 无声明装配.任务运行.列出任务("post-author") == [], "未声明配置不得留下孤儿任务"
# CLI 保存 → 验证(真实探针)→ 启用;未启用前不得产生验证回执。
保存结果 = 运行配置命令(构建装配, "保存", "post-config", "1", str(草案文件))
assert 保存结果["内容哈希"]
from datetime import UTC, datetime, timedelta
账户 = 预算管理(库, "账户-1")
账户.登记策略(额度策略("账户-1", "1", Decimal("20"), 40))
验证批准 = tmp_path / "验证批准.json"
验证批准.write_text(
json.dumps(
{
"command_id": "probe-1",
"budget": {
"command_id": "probe-budget-1",
"max_cost_usd": "1",
"valid_until": (datetime.now(UTC) + timedelta(minutes=10)).isoformat(),
"roles": [{"role": "extractor", "max_calls": 1, "per_call_usd": "1"}],
},
}
)
)
验证 = 运行配置命令(构建装配, "验证", "post-config", "1", str(验证批准))
assert 运行配置命令(构建装配, "验证", "post-config", "1", str(验证批准)) == 验证
调用账 = 构建装配.任务运行.读取作者调用(验证["task_id"], "post-author")
assert len(调用账["calls"]) == 1 and 调用账["calls"][0]["state"] == "settled"
assert Decimal(调用账["calls"][0]["actual_amount"]) > 0
批准文件 = tmp_path / "启用.json"
批准文件.write_text(
json.dumps(
{"验证回执": 验证["receipt_id"], "批准引用": "作者批准-1", "预期代次": 0},
ensure_ascii=False,
)
)
启用 = 运行配置命令(构建装配, "启用", "post-config", "1", str(批准文件))
assert 启用["generation"] == 1
assert len(收到) == 1, "探针必须真实调用网关"
# 配置已启用后,主线保存自动登记章后任务并同事务绑定启用配置。
保存2 = 作者装配.要求正文().保存人工(
作者,
"save-ch-2",
"ch-1",
2,
正文草稿((段落("ch-1-p1", (文本节点("林深把药材放上秤盘。"),)),)),
)
任务 = next(r for r in 保存2["results"] if isinstance(r, dict) and r.get("章后任务"))
任务ID = 任务["章后任务"]
assert 配置版本管理(库).读取任务绑定(任务ID).配置ID == "post-config"
# 章后按冻结包络登记任务预算;账户策略必须先经明确管理动作登记。
账户 = 预算管理(库, "账户-1")
账户.登记策略(额度策略("账户-1", "1", Decimal("20"), 40))
已执行调用 = len(收到)
while True:
快照 = 作者装配.任务运行.读取任务(任务ID)
if 快照.状态 in {任务状态.已完成, 任务状态.已失败, 任务状态.已取消}:
break
推进作者任务(作者装配, "post-author", 任务ID)
assert 快照.状态 == 任务状态.已完成
步骤 = {项["step_id"]: 项 for 项 in 快照.步骤}
检查点 = 步骤["登记摘要与索引"]["checkpoint"]
assert 检查点["摘要"] == "林深称量的动作被记录。"
assert 检查点["索引"]["revision"] == 3
事实检查点 = 步骤["登记事实提案"]["checkpoint"]
assert len(事实检查点["提案"]) == 1
原因列表 = [项["原因"] for 项 in 事实检查点["跳过"]]
assert "REPEAT_OBJECT_TYPE" in 原因列表, "同批同对象同类型只能提案一次"
assert len(收到) == 已执行调用 + 1
# 真实用量按费率文件结算;未知成本不得计为零。
with 库.连接(只读=True) as 连:
费用 = 账户.读取任务费用于(连, 任务ID)
assert 费用["has_unknown"] is False and 费用["has_pending"] is False
assert Decimal(费用["known_total_usd"]) > 0
# 正文在派生完成后仍是作者保存的版本,派生不重复提交正文。
assert 作者装配.要求正文().读取正文(作者, "ch-1")["revision"] == 3
# 唯一写入口 HTTP:保存新章 + 作者明确重建章后派生并按当前版本完成。
from fastapi.testclient import TestClient
from muse.接入.http.应用 import 创建应用
with TestClient(创建应用(配置)) as client:
client.headers["Origin"] = "http://testserver"
登录 = client.post("/api/v1/session", json={"password": "synthetic-author-access"})
assert 登录.status_code == 200
保存章2 = client.post(
"/api/v1/chapters/ch-2/documents",
json={
"command_id": "http-save-ch-2",
"chapter_id": "ch-2",
"expected_revision": 0,
"document": asdict(
正文草稿((段落("ch-2-p1", (文本节点("林深在账册上记下第二笔。"),)),))
),
},
)
assert 保存章2.status_code == 200, 保存章2.text
错误目标 = client.post(
"/api/v1/chapters/ch-2/post-derivations",
json={"command_id": "rebuild-bad", "work_id": "别的作品"},
)
assert 错误目标.status_code >= 400
任务数 = len(client.get("/api/v1/tasks").json())
未启用 = client.post(
"/api/v1/chapters/ch-2/post-derivations",
json={
"command_id": "rebuild-未启用配置",
"work_id": "post-work",
"config_id": "没有这个配置",
},
)
assert 未启用.status_code >= 400, 未启用.text
assert len(client.get("/api/v1/tasks").json()) == 任务数, "配置不可用不得留孤儿任务"
重建 = client.post(
"/api/v1/chapters/ch-2/post-derivations",
json={"command_id": "rebuild-ch-2", "work_id": "post-work"},
)
assert 重建.status_code == 202, 重建.text
重建任务 = 重建.json()["task_id"]
for _ in range(12):
当前 = client.get(f"/api/v1/tasks/{重建任务}").json()
if 当前["state"] in {"completed", "failed", "cancelled"}:
break
推进 = client.post(f"/api/v1/tasks/{重建任务}/advance", json={})
assert 推进.status_code == 200, 推进.text
assert 当前["state"] == "completed", 当前
派生状态 = client.get("/api/v1/chapters/ch-2/post-derivation")
assert 派生状态.status_code == 200, 派生状态.text
派生体 = 派生状态.json()
assert 派生体["exists"] and 派生体["状态"] == "completed"
assert 派生体["摘要"] == "林深称量的动作被记录。"
assert 派生体["索引"]["revision"] == 1
assert 派生体["revision"] == 1
网关.shutdown()
网关.server_close()