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