feat(接入): 场景检索与片段挑选接入——HTTP端点、前端语料源切换与反馈语料类型

- 资料研究路由:scene-slices/search 检索与 reindex 构建端点
- 参考挑选反馈请求增语料类型(method/slice,缺省method存量兼容);信号聚合与明细只计方法语料,片段挑选只落账留痕
- 选段改写页:语料源切换(方法卡/场景片段),片段卡显示书名+预览,挑选进 slice_references,落账按源分派
- 集成a10037:片段挑选HTTP落账不进方法信号轨;场景检索端点真实接缝
This commit is contained in:
zizi 2026-09-20 00:17:11 +08:00
parent 6d1f635003
commit c777c89e4a
8 changed files with 393 additions and 7 deletions

View File

@ -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": "种类",

View File

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

View File

@ -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条之间")

View File

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

View File

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

View File

@ -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 行集)

View File

@ -60,7 +60,16 @@ export function Ui选段改写({
[error, setError] = useState("");
const [refQuery, setRefQuery] = useState("");
const [refDomain, setRefDomain] = useState<string>("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<string[]>([]);
@ -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选段改写({
</label>
</div>
<div className="structure-form" aria-label="参考方法">
<h3>参考方法(可选)</h3>
<h3>参考材料(可选)</h3>
<p>
检索跨作品方法卡,挑 1~8 个作为改写参考;不想要的可以点踩,跳过不影响改写。
切换语料源:方法卡是跨作品写法范式,场景片段是参考书原文;挑 1~8 个作为改写参考,不想要的可以点踩,跳过不影响改写。
</p>
<label>
语料源
<select
value={refSource}
onChange={(e) => {
setRefSource(e.target.value as "method" | "slice");
setRefResults([]);
setSliceResults([]);
setRefSelected([]);
setRefRejected([]);
refFeedbackDone.current = false;
}}
disabled={refBusy || busy || Boolean(pending.current)}
>
<option value="method">方法卡</option>
<option value="slice">场景片段(参考书原文)</option>
</select>
</label>
<label>
检索意图
<input
@ -378,7 +456,34 @@ export function Ui选段改写({
>
{refBusy ? "检索中…" : "检索参考候选"}
</button>
{refResults.length > 0 && (
{refSource === "slice" && sliceResults.length > 0 && (
<ul className="reference-candidates">
{sliceResults.map((行, 序) => (
<li key={行.slice_id} data-recommended={序 < 3 ? "true" : undefined}>
<span>
#{序 + 1} {行.书名}(场景片段)
</span>
<span className="reference-reason">{行.预览}…</span>
<button
disabled={busy || Boolean(pending.current)}
aria-pressed={refSelected.includes(行.slice_id)}
onClick={() => 切换选中(行.slice_id)}
>
选用
</button>
<button
disabled={busy || Boolean(pending.current)}
aria-pressed={refRejected.includes(行.slice_id)}
onClick={() => 切换拒绝(行.slice_id)}
>
点踩
</button>
</li>
))}
</ul>
)}
{refSource === "method" &&
refResults.length > 0 && (
<ul className="reference-candidates">
{refResults.map((行, 序) => (
<li key={行.version_id} data-recommended={序 < 3 ? "true" : undefined}>
@ -408,7 +513,7 @@ export function Ui选段改写({
))}
</ul>
)}
{refSelected.length > 0 && <p>已选 {refSelected.length}/8 个参考方法。</p>}
{refSelected.length > 0 && <p>已选 {refSelected.length}/8 个参考。</p>}
{refNoticed && (
<p role="status">本次挑选落账失败,不影响改写;信号未计入偏好。</p>
)}

View File

@ -1640,6 +1640,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/scene-slices/reindex": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** 构建场景索引 */
post: operations["rebuild_scene_slice_index"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/scene-slices/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 检索场景 */
get: operations["search_scene_slices"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/methods/types": {
parameters: {
query?: never;
@ -7374,6 +7408,11 @@ export interface components {
* @default
*/
weight_version: string;
/**
* 语料类型
* @default method
*/
"\u8BED\u6599\u7C7B\u578B": string;
/**
* 种类
* @default 参考挑选
@ -11834,6 +11873,60 @@ export interface operations {
};
};
};
rebuild_scene_slice_index: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
};
};
search_scene_slices: {
parameters: {
query: {
q: string;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_method_types: {
parameters: {
query?: never;