From d41a9b5ea433b64f6fe5bbc138bafe941f95d9e6 Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 19 Sep 2026 00:31:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B7=A5=E4=BD=9C=E5=8F=B0):=20=E5=A4=8D?= =?UTF-8?q?=E5=88=A9=E6=9C=BA=E5=88=B6=E6=89=B9=E6=AC=A13=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E2=80=94=E2=80=94=E9=80=89=E6=AE=B5=E6=94=B9=E5=86=99?= =?UTF-8?q?=E9=A1=B5=E5=8F=82=E8=80=83=E6=8C=91=E9=80=89=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=E6=8C=91=E9=80=89=E5=8F=8D=E9=A6=88HTTP/CLI=E6=8E=A5?= =?UTF-8?q?=E7=BC=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/用例清单.json | 66 +++++++++++ tests/集成/test_参考挑选接入.py | 143 ++++++++++++++++++++++++ web/src/功能/正文写作/选段改写.tsx | 172 ++++++++++++++++++++++++++++- 3 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 tests/集成/test_参考挑选接入.py diff --git a/tests/用例清单.json b/tests/用例清单.json index 3add562..f487b74 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -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;结构升级为明确的已完成状态夹具,不代替作者升级旅程", diff --git a/tests/集成/test_参考挑选接入.py b/tests/集成/test_参考挑选接入.py new file mode 100644 index 0000000..09bdec3 --- /dev/null +++ b/tests/集成/test_参考挑选接入.py @@ -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()) diff --git a/web/src/功能/正文写作/选段改写.tsx b/web/src/功能/正文写作/选段改写.tsx index a3b78c1..9116599 100644 --- a/web/src/功能/正文写作/选段改写.tsx +++ b/web/src/功能/正文写作/选段改写.tsx @@ -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(); const [busy, setBusy] = useState(false), [error, setError] = useState(""); + const [refQuery, setRefQuery] = useState(""); + const [refDomain, setRefDomain] = useState("combat"); + const [refResults, setRefResults] = useState<检索结果行[]>([]); + const [refGeneration, setRefGeneration] = useState(0); + const [refSelected, setRefSelected] = useState([]); + const [refRejected, setRefRejected] = useState([]); + const [refSignals, setRefSignals] = useState>({}); + 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 = {}; + 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)} /> + +
+

参考方法(可选)

+

+ 检索跨作品方法卡,挑 1~8 个作为改写参考;不想要的可以点踩,跳过不影响改写。 +

+ + + + {refResults.length > 0 && ( +
    + {refResults.map((行, 序) => ( +
  • + + #{序 + 1} {行.title}({行.type_id}) + + + 关键词 {行.关键词得分} · 向量 {行.向量得分} · 历史选用{" "} + {refSignals[行.version_id] ?? 0} 次 + {行.已绑定本书 ? " · 已绑定本书" : ""} + + + +
  • + ))} +
+ )} + {refSelected.length > 0 &&

已选 {refSelected.length}/8 个参考方法。

} + {refNoticed.current && ( +

本次挑选落账失败,不影响改写;信号未计入偏好。

+ )}