实现侧: - 上下文:任务范围拆分为 范围校验/范围授权;索引按可发现口径重建、索引新鲜度改对称差;依赖校验统一快照漂移说明。 - 知识方法:方法与材料读取口径统一;超限方法材料按可选省略,核对路径不再二次计费;删除无合同的读时重算。 - 任务运行:新增 context.usage/tool.denied 事件类型;连接池常驻并在装配生命周期内开关;调用结算与核对分列。 - 效果评测/审校修订/交付连载/作者经验/作品规划:凭据冻结、标定消费、导出补证、事实引文核对等收尾修复。 - 资源加载:能力正文不再夹带索引用的导航注记(该注记此前进入角色与技能的模型提示)。 - 元数据:受保护骨架与代码保护属性对齐;字段校验与内置结构口径同步。 - 基础设施:环境预检进入装配生命周期;数据库连接运行期字段不参与相等比较;索引指纹归一化 jsonb 浮点。 - 删除被替代实现:7 份旧提示词模板与空壳 资料来源 读取器。 用例侧: - 用例身份与导航元信息迁移;夹具补生命周期、同库暴露与模板封存; - 本轮定向修复:方法材料省略、事实引文、迁移回执、额度与暂停用例、慢用例超时预算等。
449 lines
18 KiB
Python
449 lines
18 KiB
Python
"""真实 HTTP/PG 的默认装配与点名单步推进;模型只走隔离合成服务。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
|
|
import httpx
|
|
import pytest
|
|
import test_两阶段写手 as 写手测试
|
|
import test_发布包独立运行 as 安装测试
|
|
import uvicorn
|
|
from fastapi.testclient import TestClient
|
|
|
|
from muse.任务运行.角色策略 import 角色策略目录
|
|
from muse.共享.调用身份 import 用途
|
|
from muse.启动 import 构建默认运行
|
|
from muse.接入.cli.任务命令 import 运行任务命令
|
|
from muse.接入.cli.管理命令 import 运行管理命令
|
|
from muse.接入.http.应用 import 创建应用
|
|
from muse.资源加载 import 加载清单
|
|
from muse.配置 import 应用配置, 数据库引用, 服务配置
|
|
|
|
生成环境 = 写手测试.生成环境
|
|
独立安装环境 = 安装测试.独立安装环境
|
|
|
|
|
|
def _登录(client: TestClient, password: str) -> None:
|
|
client.headers["Origin"] = "http://testserver"
|
|
response = client.post("/api/v1/session", json={"password": password})
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
@pytest.mark.case_id(
|
|
"NC-w29-29f001",
|
|
environment="离线",
|
|
given="显式作者、隔离配置、合成数据与已知发布构建",
|
|
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
|
then=["默认运行构建与备份只读面不需要数据库"],
|
|
contract="docs/系统架构/新版设计/模块设计/S02-任务运行.md",
|
|
)
|
|
def test_默认运行构建与备份只读面不需要数据库__29f001(tmp_path: Path) -> None:
|
|
"""处理器登记不读取流程资源或数据库;只读备份面仍能启动。"""
|
|
|
|
password = tmp_path / "http-password"
|
|
password.write_text("offline-only-password", encoding="utf-8")
|
|
policy = 角色策略目录.从发布包()
|
|
config = 应用配置(
|
|
数据库引用("环境变量", "MUSE_TEST_MISSING_DATABASE"),
|
|
policy.资源发布身份,
|
|
HTTP=服务配置(
|
|
str(password),
|
|
作者ID="offline-author",
|
|
公开地址="http://testserver",
|
|
允许来源=("http://testserver",),
|
|
),
|
|
)
|
|
assembly = 构建默认运行(config)
|
|
assert assembly.流程登记 is not None
|
|
assert all(
|
|
assembly.流程登记.已登记流程(flow_id, version)
|
|
for flow_id, version in (
|
|
("生成正文", "1.0.0"),
|
|
("生成规划", "1.0.0"),
|
|
("完整审校", "1.0.0"),
|
|
("审校修订", "1.0.0"),
|
|
("研究拆书", "1"),
|
|
("章后处理", "1"),
|
|
)
|
|
)
|
|
with TestClient(创建应用(config)) as client:
|
|
_登录(client, "offline-only-password")
|
|
response = client.get("/api/v1/system/backups")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"configured": False, "items": []}
|
|
|
|
|
|
@pytest.mark.case_id(
|
|
"NC-w29-29f002",
|
|
environment="隔离PG",
|
|
given="显式作者、隔离配置、合成数据与已知发布构建",
|
|
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
|
then=["HTTP与CLI从默认装配点名推进且无预算零外发并可重启"],
|
|
contract="docs/系统架构/新版设计/模块设计/S02-任务运行.md",
|
|
)
|
|
@pytest.mark.数据库
|
|
def test_HTTP与CLI从默认装配点名推进且无预算零外发并可重启__29f002(
|
|
生成环境,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""不手工登记业务处理器;流程只在发起时按包内资源名发布。"""
|
|
|
|
env = 生成环境
|
|
database = env["库"]
|
|
with database.连接() as connection, connection.transaction():
|
|
connection.execute(
|
|
"DELETE FROM muse_flow_version WHERE run_purpose=%s AND flow_id=%s AND version=%s",
|
|
(用途.生产.value, "生成正文", "1.0.0"),
|
|
)
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
password = tmp_path / "author-password"
|
|
password.write_text("synthetic-author-password", encoding="utf-8")
|
|
config = replace(
|
|
env["装配"].配置,
|
|
HTTP=服务配置(
|
|
str(password),
|
|
作者ID=env["作者"].作者,
|
|
公开地址="http://testserver",
|
|
允许来源=("http://testserver",),
|
|
),
|
|
)
|
|
app = 创建应用(config, 计价=写手测试.合成计价())
|
|
with TestClient(app) as client:
|
|
_登录(client, "synthetic-author-password")
|
|
response = client.post(
|
|
"/api/v1/works/gen-work/writing-tasks",
|
|
json={
|
|
"command_id": "default-runtime-writing",
|
|
"chapter_id": "ch-3",
|
|
"granularity": "new_chapter",
|
|
"branch_id": "main",
|
|
"purpose": "验证默认运行装配",
|
|
"config_id": "gen-config",
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
task_id = response.json()["task_id"]
|
|
assert app.state.装配.任务运行.读取任务(task_id).流程.流程ID == "生成正文"
|
|
|
|
injected = client.post(
|
|
f"/api/v1/tasks/{task_id}/advance",
|
|
json={"processor": "ctx.write", "script": "/tmp/run.py"},
|
|
)
|
|
assert injected.status_code == 422
|
|
assert env["收到"] == []
|
|
|
|
first = client.post(f"/api/v1/tasks/{task_id}/advance")
|
|
assert first.status_code == 200, first.text
|
|
assert first.json()["executed_step"] == {
|
|
"step_id": "预组装",
|
|
"processor_id": "ctx.prep",
|
|
"processor_version": "1",
|
|
}
|
|
assert env["收到"] == []
|
|
|
|
no_budget = client.post(f"/api/v1/tasks/{task_id}/advance")
|
|
assert no_budget.status_code == 422
|
|
assert no_budget.json()["code"] == "MUSE_BUDGET"
|
|
assert env["收到"] == []
|
|
waiting = app.state.装配.任务运行.读取任务(task_id)
|
|
assert waiting.状态.value == "running"
|
|
assert next(s for s in waiting.步骤 if s["step_id"] == "受限探索")["state"] == "pending"
|
|
|
|
other_config = replace(config, HTTP=replace(config.HTTP, 作者ID="other-author"))
|
|
with TestClient(创建应用(other_config, 计价=写手测试.合成计价())) as other:
|
|
_登录(other, "synthetic-author-password")
|
|
denied = other.post(f"/api/v1/tasks/{task_id}/advance")
|
|
assert denied.status_code == 403
|
|
assert env["收到"] == []
|
|
|
|
policy_path = tmp_path / "account.json"
|
|
policy_path.write_text(
|
|
json.dumps(
|
|
{"account_id": "synthetic", "version": "1", "window_usd": "20", "window_calls": 40}
|
|
)
|
|
)
|
|
# HTTP 客户端退出即结束装配生命周期;直接复用同一装配须自行进入生命周期。
|
|
with app.state.装配.生命周期():
|
|
额度回执 = 运行管理命令(app.state.装配, "登记额度", str(policy_path))
|
|
assert 额度回执["account_id"] == "synthetic"
|
|
env["剧本"].extend(
|
|
写手测试.正常剧本(
|
|
[
|
|
"雨点敲着铁皮屋顶,林深把退回的信压在掌心。",
|
|
"他沿跳板走向仓库,门后传来一声应答。",
|
|
]
|
|
)
|
|
)
|
|
|
|
restarted = 创建应用(config, 计价=写手测试.合成计价())
|
|
with TestClient(restarted) as client:
|
|
_登录(client, "synthetic-author-password")
|
|
budget_path = f"/api/v1/tasks/{task_id}/budget"
|
|
before = client.get(budget_path)
|
|
assert before.status_code == 200 and before.json()["plan"] is None
|
|
plan = {
|
|
"command_id": "default-runtime-approval",
|
|
"max_cost_usd": "10",
|
|
"valid_until": (datetime.now(UTC) + timedelta(minutes=10)).isoformat(),
|
|
"roles": [{"role": "writer", "max_calls": 8, "per_call_usd": "1"}],
|
|
}
|
|
wrong = {**plan, "roles": [{"role": "judge", "max_calls": 8, "per_call_usd": "1"}]}
|
|
assert client.post(budget_path, json=wrong).json()["code"] == "MUSE_BUDGET_STATE"
|
|
approved = client.post(budget_path, json=plan)
|
|
assert approved.status_code == 200, approved.text
|
|
assert client.post(budget_path, json=plan).json() == approved.json()
|
|
assert (
|
|
client.post(budget_path, json={**plan, "max_cost_usd": "11"}).json()["code"]
|
|
== "MUSE_BUDGET_STATE"
|
|
)
|
|
assert approved.json()["plan"]["总金额"] == "10.000000"
|
|
assert env["收到"] == []
|
|
cli_step = 运行任务命令(restarted.state.装配, "推进", task_id)
|
|
assert cli_step["executed_step"]["step_id"] == "受限探索"
|
|
executed = [cli_step["executed_step"]["step_id"]]
|
|
while cli_step["state"] != "completed":
|
|
response = client.post(f"/api/v1/tasks/{task_id}/advance")
|
|
assert response.status_code == 200, response.text
|
|
cli_step = response.json()
|
|
if cli_step["executed_step"] is not None:
|
|
executed.append(cli_step["executed_step"]["step_id"])
|
|
assert executed == ["受限探索", "冻结回放", "无工具写作", "检查与候选"]
|
|
result = next(
|
|
step["result"] for step in cli_step["steps"] if step["step_id"] == "检查与候选"
|
|
)
|
|
candidate = restarted.state.装配.要求正文().读取候选(env["作者"], result["candidate_id"])
|
|
assert candidate["origin"] == "model"
|
|
assert candidate["decision"] == "undecided"
|
|
assert len(env["收到"]) == 5
|
|
assert restarted.state.装配.任务运行.读取任务(task_id).状态.value == "completed"
|
|
|
|
|
|
@pytest.mark.case_id(
|
|
"NC-w29-29f003",
|
|
environment="隔离PG与浏览器",
|
|
given="显式作者、隔离配置、合成数据与已知发布构建",
|
|
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
|
then=["默认应用经浏览器批准额度单步执行及候选读回"],
|
|
contract="docs/系统架构/新版设计/模块设计/S02-任务运行.md",
|
|
)
|
|
@pytest.mark.数据库
|
|
@pytest.mark.浏览器
|
|
def test_默认应用经浏览器批准额度单步执行及候选读回__29f003(生成环境, tmp_path):
|
|
if os.environ.get("MUSE_RUN_BROWSER") != "1":
|
|
pytest.skip("需显式启用浏览器")
|
|
env = 生成环境
|
|
original = env["装配"].要求正文().读取正文(env["作者"], "ch-3")
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
base = f"http://127.0.0.1:{port}"
|
|
secret = tmp_path / "password"
|
|
secret.write_text("synthetic-browser-task")
|
|
secret.chmod(0o600)
|
|
cfg = replace(
|
|
env["装配"].配置,
|
|
HTTP=服务配置(str(secret), 作者ID=env["作者"].作者, 公开地址=base, 允许来源=(base,)),
|
|
)
|
|
app = 创建应用(cfg, 计价=写手测试.合成计价())
|
|
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
|
|
thread = Thread(target=server.run, daemon=True)
|
|
thread.start()
|
|
output = Path(os.environ.get("MUSE_BROWSER_ARTIFACTS", str(tmp_path / "browser"))).resolve()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
for _ in range(100):
|
|
if server.started:
|
|
break
|
|
time.sleep(0.05)
|
|
assert server.started
|
|
assert httpx.get(base + "/ready", trust_env=False).status_code == 200
|
|
with httpx.Client(base_url=base, trust_env=False, headers={"Origin": base}) as client:
|
|
assert (
|
|
client.post("/api/v1/session", json={"password": secret.read_text()}).status_code
|
|
== 200
|
|
)
|
|
response = client.post(
|
|
"/api/v1/works/gen-work/writing-tasks",
|
|
json={
|
|
"command_id": "browser-default-runtime",
|
|
"chapter_id": "ch-3",
|
|
"granularity": "new_chapter",
|
|
"branch_id": "main",
|
|
"purpose": "验证作者额度与逐步执行",
|
|
"config_id": "gen-config",
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
task_id = response.json()["task_id"]
|
|
policy = tmp_path / "account.json"
|
|
policy.write_text(
|
|
json.dumps(
|
|
{
|
|
"account_id": "synthetic",
|
|
"version": "1",
|
|
"window_usd": "20",
|
|
"window_calls": 40,
|
|
}
|
|
)
|
|
)
|
|
运行管理命令(app.state.装配, "登记额度", str(policy))
|
|
env["剧本"].extend(
|
|
写手测试.正常剧本(
|
|
[
|
|
"雨点敲着铁皮屋顶,林深把退回的信压在掌心。",
|
|
"他沿跳板走向仓库,门后传来一声应答。",
|
|
]
|
|
)
|
|
)
|
|
assert env["收到"] == []
|
|
environment = {
|
|
**os.environ,
|
|
"MUSE_WORKBENCH_URL": base,
|
|
"MUSE_AUTHOR_PASSWORD_FILE": str(secret),
|
|
"MUSE_EXECUTION_TASK_ID": task_id,
|
|
"MUSE_BROWSER_EXECUTABLE": (
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
),
|
|
}
|
|
with (output / "浏览器.json").open("w") as report:
|
|
result = subprocess.run(
|
|
[
|
|
"pnpm",
|
|
"exec",
|
|
"playwright",
|
|
"test",
|
|
"任务预算与推进.spec.ts",
|
|
"--reporter=json",
|
|
"--output",
|
|
str(output / "产物"),
|
|
],
|
|
cwd=Path(__file__).resolve().parents[2] / "web",
|
|
env=environment,
|
|
stdout=report,
|
|
stderr=subprocess.STDOUT,
|
|
timeout=90,
|
|
)
|
|
assert result.returncode == 0, "浏览器未通过,见浏览器.json"
|
|
task = app.state.装配.任务运行.读取任务(task_id)
|
|
assert task.状态.value == "completed" and len(env["收到"]) == 5
|
|
delivered = next(step["result"] for step in task.步骤 if step["step_id"] == "检查与候选")
|
|
candidate = app.state.装配.要求正文().读取候选(env["作者"], delivered["candidate_id"])
|
|
assert candidate["origin"] == "model" and candidate["decision"] == "undecided"
|
|
assert app.state.装配.要求正文().读取正文(env["作者"], "ch-3") == original
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(10)
|
|
assert not thread.is_alive()
|
|
|
|
|
|
@pytest.mark.case_id(
|
|
"NC-w29-29f004",
|
|
environment="隔离PG",
|
|
given="显式作者、隔离配置、合成数据与已知发布构建",
|
|
when="经真实维护、业务或浏览器入口执行并读取持久结果",
|
|
then=["安装CLI发起点名推进及额度批准零隐式外发"],
|
|
contract="docs/系统架构/新版设计/模块设计/S02-任务运行.md",
|
|
)
|
|
@pytest.mark.数据库
|
|
def test_安装CLI发起点名推进及额度批准零隐式外发__29f004(生成环境, 独立安装环境, tmp_path):
|
|
env = 生成环境
|
|
python, environment = 独立安装环境
|
|
with env["库"].连接() as conn, conn.transaction():
|
|
conn.execute(
|
|
"DELETE FROM muse_flow_version WHERE run_purpose=%s AND flow_id=%s",
|
|
(用途.生产.value, "生成正文"),
|
|
)
|
|
password = tmp_path / "password"
|
|
password.write_text("synthetic-installed-task")
|
|
password.chmod(0o600)
|
|
config = tmp_path / "author.toml"
|
|
config.write_text(
|
|
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
|
|
+ json.dumps(env["库"].引用.位置)
|
|
+ '\n["资源"]\n"发布身份"='
|
|
+ json.dumps(加载清单()["构建身份"])
|
|
+ '\n["HTTP"]\n"口令文件"='
|
|
+ json.dumps(str(password))
|
|
+ '\n"作者ID"='
|
|
+ json.dumps(env["作者"].作者)
|
|
+ "\n"
|
|
)
|
|
|
|
def cli(command, action, *args, success=True):
|
|
result = subprocess.run(
|
|
[str(python), "-I", "-m", "muse", command, str(config), action, *map(str, args)],
|
|
cwd=tmp_path,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
assert (result.returncode == 0) == success, result.stderr
|
|
return json.loads(result.stdout if success else result.stderr)
|
|
|
|
request = tmp_path / "writing.json"
|
|
request.write_text(
|
|
json.dumps(
|
|
{
|
|
"command_id": "installed-task",
|
|
"work_id": "gen-work",
|
|
"chapter_id": "ch-3",
|
|
"granularity": "new_chapter",
|
|
"branch_id": "main",
|
|
"purpose": "安装CLI验证",
|
|
"config_id": "gen-config",
|
|
}
|
|
)
|
|
)
|
|
created = cli("创作", "发起", request)
|
|
assert cli("创作", "发起", request)["task_id"] == created["task_id"]
|
|
tid = created["task_id"]
|
|
step = cli("任务", "推进", tid)
|
|
assert step["executed_step"]["step_id"] == "预组装"
|
|
assert cli("任务", "推进", tid, success=False)["code"] == "MUSE_BUDGET"
|
|
assert env["收到"] == []
|
|
account = tmp_path / "account.json"
|
|
account.write_text(
|
|
json.dumps(
|
|
{
|
|
"account_id": "synthetic",
|
|
"version": "1",
|
|
"window_usd": "20",
|
|
"window_calls": 40,
|
|
}
|
|
)
|
|
)
|
|
assert cli("管理", "登记额度", account)["account_id"] == "synthetic"
|
|
assert cli("任务", "预算", tid)["plan"] is None
|
|
budget = tmp_path / "budget.json"
|
|
budget.write_text(
|
|
json.dumps(
|
|
{
|
|
"command_id": "installed-budget",
|
|
"max_cost_usd": "10",
|
|
"valid_until": (datetime.now(UTC) + timedelta(minutes=10)).isoformat(),
|
|
"roles": [{"role": "writer", "max_calls": 8, "per_call_usd": "1"}],
|
|
}
|
|
)
|
|
)
|
|
approved = cli("任务", "批准额度", tid, "--额度文件", budget)
|
|
assert cli("任务", "批准额度", tid, "--额度文件", budget) == approved
|
|
assert cli("任务", "预算", tid)["plan"] == approved["plan"]
|
|
# 独立进程已读取同一持久预算,但批准本身不能推进模型步骤。
|
|
assert env["收到"] == []
|
|
task = env["装配"].任务运行.读取任务(tid)
|
|
assert task.状态.value == "running"
|
|
assert next(s for s in task.步骤 if s["step_id"] == "受限探索")["state"] == "pending"
|