From c777c89e4a65a100d1648ff168f9702c387566ae Mon Sep 17 00:00:00 2001 From: zizi Date: Sun, 20 Sep 2026 00:17:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=A5=E5=85=A5):=20=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E4=B8=8E=E7=89=87=E6=AE=B5=E6=8C=91=E9=80=89?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E2=80=94=E2=80=94HTTP=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E3=80=81=E5=89=8D=E7=AB=AF=E8=AF=AD=E6=96=99=E6=BA=90=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=B8=8E=E5=8F=8D=E9=A6=88=E8=AF=AD=E6=96=99=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资料研究路由:scene-slices/search 检索与 reindex 构建端点 - 参考挑选反馈请求增语料类型(method/slice,缺省method存量兼容);信号聚合与明细只计方法语料,片段挑选只落账留痕 - 选段改写页:语料源切换(方法卡/场景片段),片段卡显示书名+预览,挑选进 slice_references,落账按源分派 - 集成a10037:片段挑选HTTP落账不进方法信号轨;场景检索端点真实接缝 --- docs/接口契约/生成/openapi.generated.json | 81 +++++++++++++++ src/muse/作者经验/存储.py | 2 + src/muse/作者经验/模型.py | 2 + src/muse/接入/http/路由/资料研究.py | 25 ++++- tests/用例清单.json | 34 +++++++ tests/集成/test_参考挑选接入.py | 46 +++++++++ web/src/功能/正文写作/选段改写.tsx | 117 ++++++++++++++++++++-- web/src/接口/生成/类型.ts | 93 +++++++++++++++++ 8 files changed, 393 insertions(+), 7 deletions(-) diff --git a/docs/接口契约/生成/openapi.generated.json b/docs/接口契约/生成/openapi.generated.json index ebbe115..0460581 100644 --- a/docs/接口契约/生成/openapi.generated.json +++ b/docs/接口契约/生成/openapi.generated.json @@ -5448,6 +5448,82 @@ } } }, + "/api/v1/scene-slices/reindex": { + "post": { + "tags": [ + "资料研究" + ], + "summary": "构建场景索引", + "operationId": "rebuild_scene_slice_index", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Rebuild Scene Slice Index" + } + } + } + } + } + } + }, + "/api/v1/scene-slices/search": { + "get": { + "tags": [ + "资料研究" + ], + "summary": "检索场景", + "operationId": "search_scene_slices", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "title": "Q" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 50, + "minimum": 1, + "default": 10, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/methods/types": { "get": { "tags": [ @@ -21403,6 +21479,11 @@ "title": "Weight Version", "default": "" }, + "语料类型": { + "type": "string", + "title": "语料类型", + "default": "method" + }, "种类": { "type": "string", "title": "种类", diff --git a/src/muse/作者经验/存储.py b/src/muse/作者经验/存储.py index 6891668..2d11cf3 100644 --- a/src/muse/作者经验/存储.py +++ b/src/muse/作者经验/存储.py @@ -95,6 +95,7 @@ class 经验存储: WITH ref AS ( SELECT feedback_id, payload FROM muse_author_feedback WHERE author_id=%s AND payload->>'种类'='参考挑选' + AND COALESCE(payload->>'语料类型','method')='method' AND (%s='' OR payload->>'domain'=%s) ), cand AS ( SELECT feedback_id, c->>'method_version_id' AS mvid, (c->>'rank')::int AS rank @@ -143,6 +144,7 @@ class 经验存储: WITH ref AS ( SELECT feedback_id, created_at, payload FROM muse_author_feedback WHERE author_id=%s AND payload->>'种类'='参考挑选' + AND COALESCE(payload->>'语料类型','method')='method' AND (%s='' OR payload->>'domain'=%s) ), 动作 AS ( SELECT feedback_id, created_at, payload->>'domain' AS domain, diff --git a/src/muse/作者经验/模型.py b/src/muse/作者经验/模型.py index 90fdac5..5ad2fc6 100644 --- a/src/muse/作者经验/模型.py +++ b/src/muse/作者经验/模型.py @@ -160,10 +160,12 @@ class 参考挑选反馈请求: quote: str = "" index_generation: int = 0 weight_version: str = "" + 语料类型: str = "method" 种类: str = "参考挑选" def __post_init__(self): _需要(self.种类 == "参考挑选", "参考挑选反馈种类固定") + _需要(self.语料类型 in {"method", "slice"}, "语料类型只支持 method 或 slice") _需要(bool(self.query.strip()) and len(self.query) <= 500, "检索查询须非空且不超500字") _需要(bool(self.domain.strip()) and len(self.domain) <= 100, "领域标签须非空且不超100字") _需要(1 <= len(self.candidates) <= 50, "候选清单须在1至50条之间") diff --git a/src/muse/接入/http/路由/资料研究.py b/src/muse/接入/http/路由/资料研究.py index 834b7fe..7579115 100644 --- a/src/muse/接入/http/路由/资料研究.py +++ b/src/muse/接入/http/路由/资料研究.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field from muse.接入.http.作者会话 import 作者依赖 @@ -236,3 +236,26 @@ def 恢复拆书(task_id: str, request: Request, 作者: 作者依赖) -> dict: 命令ID="resume-" + task_id, ) return {"task_id": 快照.任务ID, "状态": 快照.状态.value} + + +@路由.post("/scene-slices/reindex", operation_id="rebuild_scene_slice_index") +def 构建场景索引(request: Request, 作者: 作者依赖) -> dict[str, Any]: + 装配 = _装配(request) + from muse.资料研究.接口 import 构建场景索引 as 构建 + + with 装配.要求数据库().连接() as 连: + return 构建(连, 创作身份(作者, 装配.配置).作者 or "") + + +@路由.get("/scene-slices/search", operation_id="search_scene_slices") +def 检索场景( + request: Request, + 作者: 作者依赖, + q: str = Query(min_length=1), + limit: int = Query(default=10, ge=1, le=50), +): + 装配 = _装配(request) + from muse.上下文.接口 import 场景检索 + + with 装配.要求数据库().连接(只读=True) as 连: + return 场景检索(连, q, 数量=limit) diff --git a/tests/用例清单.json b/tests/用例清单.json index d1f0c46..55669f8 100644 --- a/tests/用例清单.json +++ b/tests/用例清单.json @@ -28105,6 +28105,40 @@ "数据库" ] }, + { + "case_id": "NC-w33-a10037", + "environment": "隔离PG与合成HTTP,不经浏览器", + "given": "已构建的场景切片索引与片段语料挑选", + "when": "经HTTP落账片段挑选反馈再读方法信号", + "then": [ + "片段挑选落账成功;不进入方法信号轨(语料类型过滤)" + ], + "contract": "docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", + "file": "tests/集成/test_参考挑选接入.py", + "symbol": "test_片段语料挑选不进方法信号__a10037", + "parameter_ids": [], + "node_ids": [ + "tests/集成/test_参考挑选接入.py::test_片段语料挑选不进方法信号__a10037" + ], + "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 index 09bdec3..d8d07bd 100644 --- a/tests/集成/test_参考挑选接入.py +++ b/tests/集成/test_参考挑选接入.py @@ -15,6 +15,7 @@ from muse.配置 import 读取配置 pytestmark = pytest.mark.数据库 生成环境 = 改写测试.生成环境 参考环境 = 改写测试.参考环境 +片段环境 = 改写测试.片段环境 def _配置(env, tmp_path): @@ -141,3 +142,48 @@ def test_非法挑选请求在边界拒绝__a10014(参考环境, tmp_path): assert response.status_code >= 400 signals = client.get("/api/v1/reference-selection-signals") assert all(行["method_version_id"] != 版本 for 行 in signals.json()) + + +@pytest.mark.case_id( + "NC-w33-a10037", + environment="隔离PG与合成HTTP,不经浏览器", + given="已构建的场景切片索引与片段语料挑选", + when="经HTTP落账片段挑选反馈再读方法信号", + then=["片段挑选落账成功;不进入方法信号轨(语料类型过滤)"], + contract="docs/系统架构/新版设计/模块设计/B07-作者经验.md#偏好双轨关系", +) +def test_片段语料挑选不进方法信号__a10037(片段环境, tmp_path): + env = 片段环境 + config = _配置(env, tmp_path) + 片段 = env["片段"] + body = { + "command_id": "http-slice-sel-1", + "selection": { + "query": "刀锋划过装甲", + "domain": "other", + "work_id": "gen-work", + "quote": "", + "index_generation": 0, + "语料类型": "slice", + "种类": "参考挑选", + "candidates": [{"method_version_id": 片段["slice_id"], "rank": 1, "score": 0.0}], + "selected": [片段["slice_id"]], + "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"]["语料类型"] == "slice" + 检索 = client.get("/api/v1/scene-slices/search", params={"q": "刀锋", "limit": 3}) + assert 检索.status_code == 200, 检索.text + 信号 = client.get("/api/v1/reference-selection-signals", params={"domain": ""}) + assert 信号.status_code == 200, 信号.text + 行集 = 信号.json() + assert all(r["method_version_id"] != 片段["slice_id"] for r in 行集) diff --git a/web/src/功能/正文写作/选段改写.tsx b/web/src/功能/正文写作/选段改写.tsx index 4780214..b332bfc 100644 --- a/web/src/功能/正文写作/选段改写.tsx +++ b/web/src/功能/正文写作/选段改写.tsx @@ -60,7 +60,16 @@ export function Ui选段改写({ [error, setError] = useState(""); const [refQuery, setRefQuery] = useState(""); const [refDomain, setRefDomain] = useState("combat"); + const [refSource, setRefSource] = useState<"method" | "slice">("method"); const [refResults, setRefResults] = useState<检索结果行[]>([]); + type 片段行 = { + slice_id: string; + 书名: string; + 预览: string; + 关键词得分: number; + 向量得分: number; + }; + const [sliceResults, setSliceResults] = useState<片段行[]>([]); const [refGeneration, setRefGeneration] = useState(0); const [refWeightVersion, setRefWeightVersion] = useState(""); const [refSelected, setRefSelected] = useState([]); @@ -112,6 +121,21 @@ export function Ui选段改写({ setRefBusy(true); setError(""); try { + if (refSource === "slice") { + const { data } = await 客户端.GET("/api/v1/scene-slices/search", { + params: { query: { q: refQuery.trim(), limit: 20 } }, + }); + if (!data) throw new Error("检索未返回。"); + const 页 = data as unknown as { 结果: 片段行[] }; + setSliceResults(页.结果 ?? []); + setRefResults([]); + setRefSelected([]); + setRefRejected([]); + refFeedbackDone.current = false; + refCommandId.current = crypto.randomUUID(); + setRefNoticed(false); + return; + } const { data } = await 客户端.GET("/api/v1/methods/search", { params: { query: { @@ -166,6 +190,41 @@ export function Ui选段改写({ async function 落账挑选() { // 挑选与点踩是弱信号:落账失败不阻断改写,但必须对作者可见。 if (refFeedbackDone.current || (!refSelected.length && !refRejected.length)) return; + if (refSource === "slice") { + // 片段语料只留痕不进方法信号轨;身份字段复用候选容器。 + try { + const { error: 失败 } = await 客户端.POST("/api/v1/reference-selections", { + body: { + command_id: refCommandId.current || crypto.randomUUID(), + selection: { + query: refQuery.trim(), + domain: "", + work_id: current.work_id, + quote: "", + index_generation: 0, + weight_version: "", + 语料类型: "slice", + 种类: "参考挑选", + candidates: sliceResults.map((行, 序) => ({ + method_version_id: 行.slice_id, + rank: 序 + 1, + score: 0, + })), + selected: refSelected, + rejected: refRejected, + }, + }, + }); + if (失败) setRefNoticed(true); + else { + refFeedbackDone.current = true; + setRefNoticed(false); + } + } catch { + setRefNoticed(true); + } + return; + } if (!refResults.length) return; try { const { error: 失败 } = await 客户端.POST("/api/v1/reference-selections", { @@ -178,6 +237,7 @@ export function Ui选段改写({ quote: "", index_generation: refGeneration, weight_version: refWeightVersion, + 语料类型: "method", 种类: "参考挑选", candidates: refResults.map((行, 序) => ({ method_version_id: 行.version_id, @@ -235,8 +295,8 @@ export function Ui选段改写({ purpose, selections, protected_ranges, - references: refSelected, - slice_references: [], + references: refSource === "method" ? refSelected : [], + slice_references: refSource === "slice" ? refSelected : [], }, }; } @@ -345,10 +405,28 @@ export function Ui选段改写({
-

参考方法(可选)

+

参考材料(可选)

- 检索跨作品方法卡,挑 1~8 个作为改写参考;不想要的可以点踩,跳过不影响改写。 + 切换语料源:方法卡是跨作品写法范式,场景片段是参考书原文;挑 1~8 个作为改写参考,不想要的可以点踩,跳过不影响改写。

+