feat(工作台): 复利机制批次3前端——选段改写页参考挑选面板与挑选反馈HTTP/CLI接缝
This commit is contained in:
parent
02063c8454
commit
d41a9b5ea4
@ -27241,6 +27241,72 @@
|
||||
"数据库"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "NC-w32-a10013",
|
||||
"environment": "隔离PG、实际CLI子进程与合成HTTP,不经浏览器",
|
||||
"given": "真实作者、已确认方法版本与检索回执形状的候选清单",
|
||||
"when": "经HTTP保存参考挑选反馈并按领域读回信号,再经CLI读同一信号",
|
||||
"then": [
|
||||
"反馈只追加落账;HTTP与CLI读回一致的选用/拒绝计数"
|
||||
],
|
||||
"contract": "docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系",
|
||||
"file": "tests/集成/test_参考挑选接入.py",
|
||||
"symbol": "test_HTTP与CLI读回一致的挑选信号__a10013",
|
||||
"parameter_ids": [],
|
||||
"node_ids": [
|
||||
"tests/集成/test_参考挑选接入.py::test_HTTP与CLI读回一致的挑选信号__a10013"
|
||||
],
|
||||
"fixtures": [
|
||||
"request",
|
||||
"tmp_path",
|
||||
"tmp_path_factory",
|
||||
"内置种子方案",
|
||||
"内置结构测试库",
|
||||
"参考环境",
|
||||
"数据库底座",
|
||||
"测试资源接缝",
|
||||
"源码资源",
|
||||
"生成环境",
|
||||
"离线防护",
|
||||
"隔离数据库URL"
|
||||
],
|
||||
"markers": [
|
||||
"数据库"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "NC-w32-a10014",
|
||||
"environment": "隔离PG与合成HTTP,不经浏览器",
|
||||
"given": "选中与拒绝重叠的非法请求",
|
||||
"when": "经HTTP保存参考挑选反馈",
|
||||
"then": [
|
||||
"边界校验拒绝,不落账"
|
||||
],
|
||||
"contract": "docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系",
|
||||
"file": "tests/集成/test_参考挑选接入.py",
|
||||
"symbol": "test_非法挑选请求在边界拒绝__a10014",
|
||||
"parameter_ids": [],
|
||||
"node_ids": [
|
||||
"tests/集成/test_参考挑选接入.py::test_非法挑选请求在边界拒绝__a10014"
|
||||
],
|
||||
"fixtures": [
|
||||
"request",
|
||||
"tmp_path",
|
||||
"tmp_path_factory",
|
||||
"内置种子方案",
|
||||
"内置结构测试库",
|
||||
"参考环境",
|
||||
"数据库底座",
|
||||
"测试资源接缝",
|
||||
"源码资源",
|
||||
"生成环境",
|
||||
"离线防护",
|
||||
"隔离数据库URL"
|
||||
],
|
||||
"markers": [
|
||||
"数据库"
|
||||
]
|
||||
},
|
||||
{
|
||||
"case_id": "NC-work-current-binding",
|
||||
"environment": "隔离 PostgreSQL;结构升级为明确的已完成状态夹具,不代替作者升级旅程",
|
||||
|
||||
143
tests/集成/test_参考挑选接入.py
Normal file
143
tests/集成/test_参考挑选接入.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""参考挑选反馈的真实 CLI/HTTP 接缝:落账、信号聚合读回;不经浏览器。"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import test_参考改写 as 改写测试
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muse.接入.http.应用 import 创建应用
|
||||
from muse.资源加载 import 加载清单
|
||||
from muse.配置 import 读取配置
|
||||
|
||||
pytestmark = pytest.mark.数据库
|
||||
生成环境 = 改写测试.生成环境
|
||||
参考环境 = 改写测试.参考环境
|
||||
|
||||
|
||||
def _配置(env, tmp_path):
|
||||
secret = tmp_path / "author-password"
|
||||
secret.write_text("synthetic-evaluation-only")
|
||||
secret.chmod(0o600)
|
||||
pool = env["库"]
|
||||
config = tmp_path / f"{pool.用途.value}.toml"
|
||||
config.write_text(
|
||||
'["数据库"]\n"取值方式"="受控存储"\n"位置"='
|
||||
+ json.dumps(pool.引用.位置)
|
||||
+ '\n["运行"]\n"用途"='
|
||||
+ json.dumps(pool.用途.value)
|
||||
+ '\n["资源"]\n"发布身份"="test"\n["HTTP"]\n"作者ID"="eval-author"'
|
||||
+ '\n"口令文件"='
|
||||
+ json.dumps(str(secret))
|
||||
+ '\n"公开地址"="http://testserver"\n"允许来源"=["http://testserver"]\n'
|
||||
)
|
||||
path = config
|
||||
path.write_text(
|
||||
path.read_text()
|
||||
.replace("eval-author", env["作者"].作者)
|
||||
.replace(
|
||||
'"发布身份"="test"',
|
||||
'"发布身份"=' + json.dumps(加载清单()["构建身份"]),
|
||||
)
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _cli(tmp_path, config, 动作, *args):
|
||||
response = subprocess.run(
|
||||
[sys.executable, "-I", "-m", "muse", "经验", str(config), 动作, *map(str, args)],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert response.returncode == 0, response.stderr
|
||||
return json.loads(response.stdout)
|
||||
|
||||
|
||||
@pytest.mark.case_id(
|
||||
"NC-w32-a10013",
|
||||
environment="隔离PG、实际CLI子进程与合成HTTP,不经浏览器",
|
||||
given="真实作者、已确认方法版本与检索回执形状的候选清单",
|
||||
when="经HTTP保存参考挑选反馈并按领域读回信号,再经CLI读同一信号",
|
||||
then=["反馈只追加落账;HTTP与CLI读回一致的选用/拒绝计数"],
|
||||
contract="docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系",
|
||||
)
|
||||
def test_HTTP与CLI读回一致的挑选信号__a10013(参考环境, tmp_path):
|
||||
env = 参考环境
|
||||
config = _配置(env, tmp_path)
|
||||
版本 = env["参考版本"]
|
||||
body = {
|
||||
"command_id": "http-ref-sel-1",
|
||||
"selection": {
|
||||
"query": "机甲近身刀法",
|
||||
"domain": "combat",
|
||||
"work_id": "gen-work",
|
||||
"quote": "",
|
||||
"index_generation": 0,
|
||||
"种类": "参考挑选",
|
||||
"candidates": [{"method_version_id": 版本, "rank": 1, "score": 0.031}],
|
||||
"selected": [版本],
|
||||
"rejected": [],
|
||||
},
|
||||
}
|
||||
with TestClient(创建应用(读取配置(config)), headers={"Origin": "http://testserver"}) as client:
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/session", json={"password": "synthetic-evaluation-only"}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
saved = client.post("/api/v1/reference-selections", json=body)
|
||||
assert saved.status_code == 201, saved.text
|
||||
assert saved.json()["payload"]["种类"] == "参考挑选"
|
||||
# 幂等重放:同命令同内容返回原记录
|
||||
replay = client.post("/api/v1/reference-selections", json=body)
|
||||
assert replay.status_code == 201
|
||||
assert replay.json()["feedback_id"] == saved.json()["feedback_id"]
|
||||
signals = client.get("/api/v1/reference-selection-signals", params={"domain": "combat"})
|
||||
assert signals.status_code == 200
|
||||
rows = {行["method_version_id"]: 行 for 行 in signals.json()}
|
||||
assert rows[版本]["选用次数"] == 1
|
||||
assert rows[版本]["拒绝次数"] == 0
|
||||
assert rows[版本]["选中位次"] == [1]
|
||||
|
||||
cli = _cli(tmp_path, config, "挑选信号", "combat")
|
||||
assert {行["method_version_id"]: 行["选用次数"] for 行 in cli} == {版本: 1}
|
||||
|
||||
|
||||
@pytest.mark.case_id(
|
||||
"NC-w32-a10014",
|
||||
environment="隔离PG与合成HTTP,不经浏览器",
|
||||
given="选中与拒绝重叠的非法请求",
|
||||
when="经HTTP保存参考挑选反馈",
|
||||
then=["边界校验拒绝,不落账"],
|
||||
contract="docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系",
|
||||
)
|
||||
def test_非法挑选请求在边界拒绝__a10014(参考环境, tmp_path):
|
||||
env = 参考环境
|
||||
config = _配置(env, tmp_path)
|
||||
版本 = env["参考版本"]
|
||||
body = {
|
||||
"command_id": "http-ref-sel-bad",
|
||||
"selection": {
|
||||
"query": "机甲近身刀法",
|
||||
"domain": "combat",
|
||||
"candidates": [{"method_version_id": 版本, "rank": 1, "score": 0.03}],
|
||||
"selected": [版本],
|
||||
"rejected": [版本],
|
||||
},
|
||||
}
|
||||
with TestClient(创建应用(读取配置(config)), headers={"Origin": "http://testserver"}) as client:
|
||||
assert (
|
||||
client.post(
|
||||
"/api/v1/session", json={"password": "synthetic-evaluation-only"}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
response = client.post("/api/v1/reference-selections", json=body)
|
||||
assert response.status_code >= 400
|
||||
signals = client.get("/api/v1/reference-selection-signals")
|
||||
assert all(行["method_version_id"] != 版本 for 行 in signals.json())
|
||||
@ -6,8 +6,20 @@ import { 客户端 } from "../../接口/生成/客户端";
|
||||
import { 接口错误 } from "../../接口/传输";
|
||||
import type { 原文坐标 } from "../../界面/编辑器/选区定位";
|
||||
import { useMuse作者会话 } from "../../应用/作者会话";
|
||||
import type { 检索结果行 } from "../知识方法/数据";
|
||||
import { 读取正文, type 正文记录 } from "./数据";
|
||||
|
||||
const 参考领域 = [
|
||||
["combat", "打戏"],
|
||||
["emotion", "情感"],
|
||||
["dialogue", "对话"],
|
||||
["inner", "内心"],
|
||||
["environment", "环境"],
|
||||
["style", "文风"],
|
||||
["pacing", "节奏"],
|
||||
["other", "其他"],
|
||||
] as const;
|
||||
|
||||
type 发起请求 =
|
||||
operations["start_model_revision"]["requestBody"]["content"]["application/json"];
|
||||
type 定位 = 原文坐标 & { paragraph_hash: string };
|
||||
@ -46,6 +58,16 @@ export function Ui选段改写({
|
||||
[protection, setProtection] = useState<typeof selection>();
|
||||
const [busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const [refQuery, setRefQuery] = useState("");
|
||||
const [refDomain, setRefDomain] = useState<string>("combat");
|
||||
const [refResults, setRefResults] = useState<检索结果行[]>([]);
|
||||
const [refGeneration, setRefGeneration] = useState(0);
|
||||
const [refSelected, setRefSelected] = useState<string[]>([]);
|
||||
const [refRejected, setRefRejected] = useState<string[]>([]);
|
||||
const [refSignals, setRefSignals] = useState<Record<string, number>>({});
|
||||
const [refBusy, setRefBusy] = useState(false);
|
||||
const refNoticed = useRef(false);
|
||||
const refFeedbackDone = useRef(false);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const task = params.get("selection_task");
|
||||
const pending = useRef<发起请求>();
|
||||
@ -84,10 +106,82 @@ export function Ui选段改写({
|
||||
canWrite &&
|
||||
chosen?.revision === current.revision &&
|
||||
(protection === undefined || protection.revision === current.revision);
|
||||
async function 检索参考() {
|
||||
setRefBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const { data } = await 客户端.GET("/api/v1/methods/search", {
|
||||
params: { query: { q: refQuery.trim(), work_id: current.work_id, limit: 20 } },
|
||||
});
|
||||
if (!data) throw new Error("检索未返回。");
|
||||
const 页 = data as unknown as { 结果: 检索结果行[]; index_generation?: number };
|
||||
setRefResults(页.结果 ?? []);
|
||||
setRefGeneration(Number(页.index_generation ?? 0));
|
||||
setRefSelected([]);
|
||||
setRefRejected([]);
|
||||
refFeedbackDone.current = false;
|
||||
const { data: 信号 } = await 客户端.GET("/api/v1/reference-selection-signals", {
|
||||
params: { query: { domain: refDomain } },
|
||||
});
|
||||
const 映射: Record<string, number> = {};
|
||||
for (const 行 of (信号 ?? []) as {
|
||||
method_version_id: string;
|
||||
选用次数: number;
|
||||
}[])
|
||||
映射[行.method_version_id] = 行.选用次数;
|
||||
setRefSignals(映射);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "参考检索失败。");
|
||||
} finally {
|
||||
setRefBusy(false);
|
||||
}
|
||||
}
|
||||
function 切换选中(id: string) {
|
||||
setRefRejected((旧) => 旧.filter((x) => x !== id));
|
||||
setRefSelected((旧) =>
|
||||
旧.includes(id) ? 旧.filter((x) => x !== id) : 旧.length >= 8 ? 旧 : [...旧, id],
|
||||
);
|
||||
}
|
||||
function 切换拒绝(id: string) {
|
||||
setRefSelected((旧) => 旧.filter((x) => x !== id));
|
||||
setRefRejected((旧) => (旧.includes(id) ? 旧.filter((x) => x !== id) : [...旧, id]));
|
||||
}
|
||||
async function 落账挑选() {
|
||||
// 挑选与点踩是弱信号:落账失败不阻断改写,但必须对作者可见。
|
||||
if (refFeedbackDone.current || (!refSelected.length && !refRejected.length)) return;
|
||||
if (!refResults.length) return;
|
||||
try {
|
||||
const { error: 失败 } = await 客户端.POST("/api/v1/reference-selections", {
|
||||
body: {
|
||||
command_id: crypto.randomUUID(),
|
||||
selection: {
|
||||
query: refQuery.trim(),
|
||||
domain: refDomain,
|
||||
work_id: current.work_id,
|
||||
quote: "",
|
||||
index_generation: refGeneration,
|
||||
种类: "参考挑选",
|
||||
candidates: refResults.map((行, 序) => ({
|
||||
method_version_id: 行.version_id,
|
||||
rank: 序 + 1,
|
||||
score: 行.得分,
|
||||
})),
|
||||
selected: refSelected,
|
||||
rejected: refRejected,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (失败) refNoticed.current = true;
|
||||
else refFeedbackDone.current = true;
|
||||
} catch {
|
||||
refNoticed.current = true;
|
||||
}
|
||||
}
|
||||
async function 发起() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await 落账挑选();
|
||||
if (!pending.current) {
|
||||
if (!valid || !chosen)
|
||||
throw new Error("请在当前已保存正文中重新圈选。");
|
||||
@ -114,7 +208,14 @@ export function Ui选段改写({
|
||||
pending.current = {
|
||||
command_id: crypto.randomUUID(),
|
||||
config_id: config,
|
||||
request: { ...target, mode, purpose, selections, protected_ranges, references: [] },
|
||||
request: {
|
||||
...target,
|
||||
mode,
|
||||
purpose,
|
||||
selections,
|
||||
protected_ranges,
|
||||
references: refSelected,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { data } = await 客户端.POST("/api/v1/revision-tasks", {
|
||||
@ -220,6 +321,75 @@ export function Ui选段改写({
|
||||
disabled={busy || Boolean(pending.current)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="structure-form" aria-label="参考方法">
|
||||
<h3>参考方法(可选)</h3>
|
||||
<p>
|
||||
检索跨作品方法卡,挑 1~8 个作为改写参考;不想要的可以点踩,跳过不影响改写。
|
||||
</p>
|
||||
<label>
|
||||
检索意图
|
||||
<input
|
||||
value={refQuery}
|
||||
onChange={(e) => setRefQuery(e.target.value)}
|
||||
placeholder="例如:机甲近身刀法"
|
||||
disabled={refBusy || busy || Boolean(pending.current)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
领域
|
||||
<select
|
||||
value={refDomain}
|
||||
onChange={(e) => setRefDomain(e.target.value)}
|
||||
disabled={refBusy || busy || Boolean(pending.current)}
|
||||
>
|
||||
{参考领域.map(([值, 名]) => (
|
||||
<option key={值} value={值}>
|
||||
{名}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
disabled={refBusy || busy || !refQuery.trim() || Boolean(pending.current)}
|
||||
onClick={() => void 检索参考()}
|
||||
>
|
||||
{refBusy ? "检索中…" : "检索参考候选"}
|
||||
</button>
|
||||
{refResults.length > 0 && (
|
||||
<ul className="reference-candidates">
|
||||
{refResults.map((行, 序) => (
|
||||
<li key={行.version_id} data-recommended={序 < 3 ? "true" : undefined}>
|
||||
<span>
|
||||
#{序 + 1} {行.title}({行.type_id})
|
||||
</span>
|
||||
<span className="reference-reason">
|
||||
关键词 {行.关键词得分} · 向量 {行.向量得分} · 历史选用{" "}
|
||||
{refSignals[行.version_id] ?? 0} 次
|
||||
{行.已绑定本书 ? " · 已绑定本书" : ""}
|
||||
</span>
|
||||
<button
|
||||
disabled={busy || Boolean(pending.current)}
|
||||
aria-pressed={refSelected.includes(行.version_id)}
|
||||
onClick={() => 切换选中(行.version_id)}
|
||||
>
|
||||
选用
|
||||
</button>
|
||||
<button
|
||||
disabled={busy || Boolean(pending.current)}
|
||||
aria-pressed={refRejected.includes(行.version_id)}
|
||||
onClick={() => 切换拒绝(行.version_id)}
|
||||
>
|
||||
点踩
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{refSelected.length > 0 && <p>已选 {refSelected.length}/8 个参考方法。</p>}
|
||||
{refNoticed.current && (
|
||||
<p role="status">本次挑选落账失败,不影响改写;信号未计入偏好。</p>
|
||||
)}
|
||||
<button
|
||||
disabled={
|
||||
busy ||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user