feat(接入): 场景检索与片段挑选接入——HTTP端点、前端语料源切换与反馈语料类型
- 资料研究路由:scene-slices/search 检索与 reindex 构建端点 - 参考挑选反馈请求增语料类型(method/slice,缺省method存量兼容);信号聚合与明细只计方法语料,片段挑选只落账留痕 - 选段改写页:语料源切换(方法卡/场景片段),片段卡显示书名+预览,挑选进 slice_references,落账按源分派 - 集成a10037:片段挑选HTTP落账不进方法信号轨;场景检索端点真实接缝
This commit is contained in:
parent
6d1f635003
commit
c777c89e4a
@ -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": "种类",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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条之间")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;结构升级为明确的已完成状态夹具,不代替作者升级旅程",
|
||||
|
||||
@ -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 行集)
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user