feat(工作台): 复利机制批次3前端——选段改写页参考挑选面板与挑选反馈HTTP/CLI接缝

This commit is contained in:
zizi 2026-09-19 00:31:34 +08:00
parent 02063c8454
commit d41a9b5ea4
3 changed files with 380 additions and 1 deletions

View File

@ -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;结构升级为明确的已完成状态夹具,不代替作者升级旅程",

View 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())

View File

@ -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 ||