feat(资料研究): 场景切片派生缓存与向量代次索引——参考书原文确定性切窗入检索池

- V0061:切片台账(窗计划哈希锁切窗方案、禁原地改写)、切片向量索引与代次台账,代次守卫仿 V0059
- 场景切片.py:段落聚合切窗纯函数(目标1800字符、末窗并邻)、确定性切片身份、全量重建命令与授权读取
- 只纳入 reference_reading 用途来源的导入者视角;用途撤回即退出候选,切片行保留
- 底座种子表登记切片代次单例;单元4例+集成3例全过
This commit is contained in:
zizi 2026-09-19 23:43:51 +08:00
parent 97f2e4cb95
commit fea8f44327
8 changed files with 707 additions and 1 deletions

View File

@ -88,7 +88,7 @@ S02检查点是执行证据而非第二分析读取路径。运行中的完成
## 场景片段切片与索引
参考书原文可确定性切片入检索池,为参考改写提供场景原文语料。切片是纯函数:按段落边界聚合至目标窗长(1200~2400 字符,不足并入邻窗),切片参数与窗口清单记入窗计划哈希;同一来源版本只认一个窗计划,重算不一致即拒绝为陈旧。切片只追加不改删,原文以 `muse_source_version` 为权威,切片表只存定位与检索文本。
参考书原文可确定性切片入检索池,为参考改写提供场景原文语料。切片是纯函数:按段落边界聚合至目标窗长(1200~2400 字符,不足并入邻窗),切片参数与窗口清单记入窗计划哈希;同一来源版本只认一个窗计划,重算不一致即拒绝为陈旧。切片与索引是可全量重建的派生缓存(原文以 `muse_source_version` 为权威),禁原地改写,重建走整批替换;代次守卫随事务变更递增。
片段向量索引与方法索引同构:哈希 bigram embedder(零模型成本、确定性)、代次台账 active 唯一、检索时对不上即拒绝为陈旧。索引构建是显式命令(CLI/HTTP),不耦合导入入口;构建入口按当前具备 `reference_reading` 用途的来源版本全量对账,来源版本更新或用途撤回后重算。切片身份与授权解耦:授权变化只影响检索候选与材料读取,不删历史切片。

View File

@ -0,0 +1,221 @@
"""场景切片:参考书原文的确定性切窗与向量索引。
原文以 muse_source_version 为权威;切片与索引是可全量重建的派生缓存。
切窗是纯函数(同文本同参数同窗计划),嵌入用哈希 bigram,零模型成本。
"""
from __future__ import annotations
from typing import Any
from uuid import NAMESPACE_URL, uuid5
from psycopg.rows import dict_row
from muse.基础设施.模型.嵌入 import 特征哈希嵌入器
from muse.正式变更.接口 import 固定哈希
from muse.资料研究.存储 import 资料存储
from muse.资料研究.模型 import 资料错误
目标窗长 = 1800
最短窗长 = 1200
def 段落切窗(文本: str, *, 窗长: int = 目标窗长) -> list[dict[str, int]]:
"""按段落边界聚合至目标窗长;末窗过短并入邻窗。纯函数,同输入同输出。"""
if not isinstance(文本, str) or not 文本:
raise 资料错误("切片需要非空原文")
if type(窗长) is not int or 窗长 < 最短窗长:
raise 资料错误(f"窗长必须不小于 {最短窗长}")
段落: list[tuple[int, int]] = []
起点 = 0
for i, 字符 in enumerate(文本):
if 字符 == "\n":
if i > 起点:
段落.append((起点, i))
起点 = i + 1
if 起点 < len(文本):
段落.append((起点, len(文本)))
if not 段落:
raise 资料错误("原文没有可切片的段落")
窗口: list[dict[str, int]] = []
窗起: int | None = None
窗终 = 0
for 段起, 段终 in 段落:
if 窗起 is None:
窗起 = 段起
窗终 = 段终
if 窗终 - 窗起 >= 窗长:
窗口.append({"起点": 窗起, "终点": 窗终})
窗起 = None
if 窗起 is not None:
if 窗口 and 窗终 - 窗起 < 最短窗长:
窗口[-1]["终点"] = 窗终
else:
窗口.append({"起点": 窗起, "终点": 窗终})
return 窗口
def 切片计划哈希(窗口: list[dict[str, int]], 窗长: int) -> str:
return 固定哈希(
[窗长, [[w["起点"], w["终点"]] for w in 窗口]],
)
def 切片身份(source_id: str, revision: int, 计划哈希: str, 起点: int) -> str:
return str(
uuid5(
NAMESPACE_URL,
"muse:B03:slice:" + 固定哈希([source_id, revision, 计划哈希, 起点]),
)
)
def 计算切片(
文本: str, source_id: str, revision: int, *, 窗长: int = 目标窗长
) -> list[dict[str, Any]]:
"""全文→切片行(身份、定位、检索文本与哈希);纯函数供构建与测试复用。"""
窗口 = 段落切窗(文本, 窗长=窗长)
计划 = 切片计划哈希(窗口, 窗长)
行 = []
for w in 窗口:
检索文本 = 文本[w["起点"] : w["终点"]].strip()
if not 检索文本:
continue
行.append(
{
"slice_id": 切片身份(source_id, revision, 计划, w["起点"]),
"source_id": source_id,
"revision": revision,
"起点": w["起点"],
"终点": w["终点"],
"窗计划哈希": 计划,
"检索文本": 检索文本,
"检索文本哈希": 固定哈希([检索文本]),
}
)
if not 行:
raise 资料错误("原文切片后没有非空检索文本")
return 行
def 构建场景索引(连, 作者: str, *, 嵌入器: 特征哈希嵌入器 | None = None) -> dict[str, Any]:
"""按当前具备 reference_reading 用途的来源版本全量重建切片与向量索引。"""
嵌入器 = 嵌入器 or 特征哈希嵌入器()
存储 = 资料存储(连)
版本集 = 存储.查询(
"SELECT s.source_id, s.title, MAX(v.revision) AS revision, "
"(ARRAY_AGG(v.content ORDER BY v.revision DESC))[1] AS content, "
"(ARRAY_AGG(v.content_hash ORDER BY v.revision DESC))[1] AS content_hash, "
"(ARRAY_AGG(v.import_result->>'导入者' ORDER BY v.revision DESC))[1] AS author_id "
"FROM muse_source s JOIN muse_source_version v ON v.source_id=s.source_id "
"WHERE s.authorized_uses ? 'reference_reading' "
"GROUP BY s.source_id, s.title"
).fetchall()
切片行: list[dict[str, Any]] = []
来源数 = 0
for 版 in 版本集:
if 版["author_id"] != 作者:
continue
来源数 += 1
切片行.extend(计算切片(版["content"], str(版["source_id"]), int(版["revision"])))
索引行 = [
(
行["slice_id"],
行["检索文本"],
行["检索文本哈希"],
嵌入器.嵌入(行["检索文本"]),
嵌入器.身份,
)
for 行 in 切片行
]
with 连.transaction():
连.execute("LOCK TABLE public.muse_source_slice IN SHARE ROW EXCLUSIVE MODE")
连.execute("DELETE FROM public.muse_source_slice")
for 行 in 切片行:
连.execute(
"INSERT INTO public.muse_source_slice "
"(slice_id,source_id,revision,起点,终点,窗计划哈希,检索文本,检索文本哈希) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
(
行["slice_id"],
行["source_id"],
行["revision"],
行["起点"],
行["终点"],
行["窗计划哈希"],
行["检索文本"],
行["检索文本哈希"],
),
)
for 身份, 文本, 文本哈希, 向量, embedder in 索引行:
连.execute(
"INSERT INTO public.muse_source_slice_index "
"(slice_id,检索文本,检索文本哈希,vector,embedder) VALUES (%s,%s,%s,%s,%s)",
(身份, 文本, 文本哈希, 向量, embedder),
)
连.execute("UPDATE public.muse_source_slice_index_version SET active=false WHERE active")
指纹 = 固定哈希([嵌入器.身份, [(行[0], 行[2]) for 行 in 索引行]])
头 = (
连.cursor(row_factory=dict_row)
.execute(
"INSERT INTO public.muse_source_slice_index_version "
"(索引身份,embedder,slice_count,active) VALUES (%s,%s,%s,true) "
"RETURNING ledger_id",
(指纹, 嵌入器.身份, len(索引行)),
)
.fetchone()
)
return {
"ledger_id": int(头["ledger_id"]),
"索引身份": 指纹,
"embedder": 嵌入器.身份,
"切片数": len(索引行),
"来源数": 来源数,
}
def 读取场景切片(连, 作者: str, slice_ids: list[str]) -> list[dict[str, Any]]:
"""按身份读取切片与来源版本哈希;核对来源仍具 reference_reading 用途。"""
if not slice_ids:
return []
占位 = ",".join(["%s"] * len(slice_ids))
行 = (
资料存储(连)
.查询(
"SELECT x.slice_id, x.source_id, x.revision, x.起点, x.终点, x.检索文本, "
"v.content_hash, s.title, s.authorized_uses "
"FROM muse_source_slice x "
"JOIN muse_source_version v ON v.source_id=x.source_id AND v.revision=x.revision "
"JOIN muse_source s ON s.source_id=x.source_id "
f"WHERE x.slice_id IN ({占位})",
tuple(slice_ids),
)
.fetchall()
)
return [
{
"slice_id": str(条["slice_id"]),
"source_id": str(条["source_id"]),
"revision": int(条["revision"]),
"起点": int(条["起点"]),
"终点": int(条["终点"]),
"检索文本": 条["检索文本"],
"内容哈希": 条["content_hash"],
"书名": 条["title"],
"用途": 条["authorized_uses"],
}
for 条 in 行
if 条["authorized_uses"] is not None and "reference_reading" in 条["authorized_uses"]
]
__all__ = [
"目标窗长",
"最短窗长",
"段落切窗",
"切片计划哈希",
"切片身份",
"计算切片",
"构建场景索引",
"读取场景切片",
]

View File

@ -11,6 +11,7 @@ from muse.资料研究.分析保存 import 参考分析请求, 呈现分析, 登
from muse.资料研究.分析版本 import 承接历史分析, 版本台账, 版本身份, 登记模型分析, 读取版本状态
from muse.资料研究.分章切窗 import 切窗, 漂移检查, 窗口, 窗口载荷, 窗计划哈希, 覆盖核对
from muse.资料研究.原文版本 import 读取证据
from muse.资料研究.场景切片 import 构建场景索引, 计算切片, 读取场景切片
from muse.资料研究.存储 import 资料存储
from muse.资料研究.实体归并 import 归并, 收集实体
from muse.资料研究.拆书分析 import 执行窗口提取
@ -234,4 +235,7 @@ __all__ = [
"收集实体",
"执行窗口提取",
"选书交接单",
"构建场景索引",
"计算切片",
"读取场景切片",
]

View File

@ -0,0 +1,80 @@
"""场景切片纯函数:段落聚合切窗与确定性身份;同输入同输出。"""
import pytest
from muse.资料研究.场景切片 import 切片计划哈希, 切片身份, 段落切窗
from muse.资料研究.接口 import 计算切片
@pytest.mark.case_id(
"NC-w33-a10025",
environment="离线纯函数",
given="含多段落的参考书原文与目标窗长",
when="执行段落聚合切窗",
then=["窗内长度达目标即闭合;末窗过短并入邻窗;窗口首尾相接覆盖全文"],
contract="docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
)
def test_段落聚合切窗覆盖全文且末窗并邻__a10025():
段 = "机甲的合金装甲在月光下泛着冷光。\n"
文本 = 段 * 200 # 每段约 17 字,200 段约 3400 字
窗口 = 段落切窗(文本, 窗长=1800)
assert 窗口[0]["起点"] == 0
assert 窗口[-1]["终点"] == len(文本) - 1 # 尾部换行不是内容,不入窗
for 甲, 乙 in zip(窗口, 窗口[1:], strict=False):
assert 乙["起点"] > 甲["终点"] # 段间换行符不入窗
assert 甲["终点"] - 甲["起点"] >= 1800
assert len(窗口) == 2
@pytest.mark.case_id(
"NC-w33-a10026",
environment="离线纯函数",
given="短于最短窗长的原文",
when="执行段落切窗",
then=["整文成单窗,不因短而拒绝"],
contract="docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
)
def test_短文成单窗__a10026():
文本 = "短打斗。\n一刀封喉。\n"
窗口 = 段落切窗(文本, 窗长=1800)
assert len(窗口) == 1
assert 窗口[0] == {"起点": 0, "终点": len(文本) - 1}
@pytest.mark.case_id(
"NC-w33-a10027",
environment="离线纯函数",
given="同一原文、来源身份与窗参数",
when="重复计算切片",
then=["切片身份、窗计划哈希与检索文本哈希逐次一致(确定性)"],
contract="docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
)
def test_切片计算确定性__a10027():
文本 = "刀锋擦过装甲。\n火花四散。\n" * 300
甲 = 计算切片(文本, "11111111-1111-1111-1111-111111111111", 1)
乙 = 计算切片(文本, "11111111-1111-1111-1111-111111111111", 1)
assert [r["slice_id"] for r in 甲] == [r["slice_id"] for r in 乙]
assert 甲[0]["窗计划哈希"] == 切片计划哈希(段落切窗(文本, 窗长=1800), 1800)
assert 甲[0]["slice_id"] == 切片身份(
"11111111-1111-1111-1111-111111111111", 1, 甲[0]["窗计划哈希"], 甲[0]["起点"]
)
# 版本号变化即身份变化:同文本不同版本不同身份。
丙 = 计算切片(文本, "11111111-1111-1111-1111-111111111111", 2)
assert 甲[0]["slice_id"] != 丙[0]["slice_id"]
@pytest.mark.case_id(
"NC-w33-a10028",
environment="离线纯函数",
given="空原文或非法窗长",
when="执行段落切窗",
then=["拒绝为资料错误,不产出空窗"],
contract="docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
)
def test_空文与非法窗长拒绝__a10028():
from muse.资料研究.模型 import 资料错误
with pytest.raises(资料错误):
段落切窗("", 窗长=1800)
with pytest.raises(资料错误):
段落切窗("有字。\n", 窗长=100)

View File

@ -35,6 +35,7 @@ from muse.配置 import 数据库引用
{
("public", "muse_migration"), # 迁移账本,由迁移执行器维护
("public", "muse_method_index_guard"), # V0059 检索索引代次单例
("public", "muse_source_slice_guard"), # V0061 场景切片代次单例
("public", "muse_raw_namespace"), # V0004 原文命名空间单例
("evaluation", "muse_raw_namespace"), # V0004 原文命名空间单例(evaluation 库)
("metadata", "type_registry"), # 内置结构种子,由导入种子写入

View File

@ -27745,6 +27745,199 @@
"数据库"
]
},
{
"case_id": "NC-w33-a10025",
"environment": "离线纯函数",
"given": "含多段落的参考书原文与目标窗长",
"when": "执行段落聚合切窗",
"then": [
"窗内长度达目标即闭合;末窗过短并入邻窗;窗口首尾相接覆盖全文"
],
"contract": "docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
"file": "tests/单元/test_场景切片计算.py",
"symbol": "test_段落聚合切窗覆盖全文且末窗并邻__a10025",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_场景切片计算.py::test_段落聚合切窗覆盖全文且末窗并邻__a10025"
],
"fixtures": [
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{
"case_id": "NC-w33-a10026",
"environment": "离线纯函数",
"given": "短于最短窗长的原文",
"when": "执行段落切窗",
"then": [
"整文成单窗,不因短而拒绝"
],
"contract": "docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
"file": "tests/单元/test_场景切片计算.py",
"symbol": "test_短文成单窗__a10026",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_场景切片计算.py::test_短文成单窗__a10026"
],
"fixtures": [
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{
"case_id": "NC-w33-a10027",
"environment": "离线纯函数",
"given": "同一原文、来源身份与窗参数",
"when": "重复计算切片",
"then": [
"切片身份、窗计划哈希与检索文本哈希逐次一致(确定性)"
],
"contract": "docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
"file": "tests/单元/test_场景切片计算.py",
"symbol": "test_切片计算确定性__a10027",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_场景切片计算.py::test_切片计算确定性__a10027"
],
"fixtures": [
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{
"case_id": "NC-w33-a10028",
"environment": "离线纯函数",
"given": "空原文或非法窗长",
"when": "执行段落切窗",
"then": [
"拒绝为资料错误,不产出空窗"
],
"contract": "docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
"file": "tests/单元/test_场景切片计算.py",
"symbol": "test_空文与非法窗长拒绝__a10028",
"parameter_ids": [],
"node_ids": [
"tests/单元/test_场景切片计算.py::test_空文与非法窗长拒绝__a10028"
],
"fixtures": [
"request",
"测试资源接缝",
"源码资源",
"离线防护"
],
"markers": []
},
{
"case_id": "NC-w33-a10029",
"environment": "隔离PG与生成环境",
"given": "一个 reference_reading 授权来源与一个仅 analysis 来源",
"when": "构建场景索引并读取切片",
"then": [
"仅授权来源入池,切片数与确定性计算一致",
"切片行携带书名、定位与来源版本内容哈希",
"重复构建为幂等重建,索引身份一致"
],
"contract": "docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
"file": "tests/集成/test_场景切片.py",
"symbol": "test_构建场景索引只纳入授权来源__a10029",
"parameter_ids": [],
"node_ids": [
"tests/集成/test_场景切片.py::test_构建场景索引只纳入授权来源__a10029"
],
"fixtures": [
"request",
"tmp_path",
"tmp_path_factory",
"内置种子方案",
"内置结构测试库",
"切片环境",
"数据库底座",
"测试资源接缝",
"源码资源",
"生成环境",
"离线防护",
"隔离数据库URL"
],
"markers": [
"数据库"
]
},
{
"case_id": "NC-w33-a10030",
"environment": "隔离PG与生成环境",
"given": "已入池的授权来源",
"when": "撤回 reference_reading 用途后读取切片",
"then": [
"读取返回空,切片行保留(派生缓存不删历史),重建后来源数归零"
],
"contract": "docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
"file": "tests/集成/test_场景切片.py",
"symbol": "test_用途撤回后切片退出候选__a10030",
"parameter_ids": [],
"node_ids": [
"tests/集成/test_场景切片.py::test_用途撤回后切片退出候选__a10030"
],
"fixtures": [
"request",
"tmp_path",
"tmp_path_factory",
"内置种子方案",
"内置结构测试库",
"切片环境",
"数据库底座",
"测试资源接缝",
"源码资源",
"生成环境",
"离线防护",
"隔离数据库URL"
],
"markers": [
"数据库"
]
},
{
"case_id": "NC-w33-a10031",
"environment": "隔离PG与生成环境",
"given": "他人导入的授权来源",
"when": "以当前作者构建场景索引",
"then": [
"来源不计入切片池:切片只对导入者视角构建"
],
"contract": "docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
"file": "tests/集成/test_场景切片.py",
"symbol": "test_非导入者来源不入池__a10031",
"parameter_ids": [],
"node_ids": [
"tests/集成/test_场景切片.py::test_非导入者来源不入池__a10031"
],
"fixtures": [
"request",
"tmp_path",
"tmp_path_factory",
"内置种子方案",
"内置结构测试库",
"切片环境",
"数据库底座",
"测试资源接缝",
"源码资源",
"生成环境",
"离线防护",
"隔离数据库URL"
],
"markers": [
"数据库"
]
},
{
"case_id": "NC-work-current-binding",
"environment": "隔离 PostgreSQL;结构升级为明确的已完成状态夹具,不代替作者升级旅程",

View File

@ -0,0 +1,120 @@
"""场景切片构建与读取:授权来源入池,用途撤回即退出候选;切片可全量重建。"""
import pytest
import test_模型修订任务 as 修订测试
from muse.资料研究.场景切片 import 计算切片
from muse.资料研究.接口 import (
导入请求,
构建场景索引,
读取场景切片,
资料服务,
)
pytestmark = pytest.mark.数据库
生成环境 = 修订测试.生成环境
def _导入(env, 连, *, 标题: str, origin: str, 用途: tuple[str, ...]) -> str:
来源 = 资料服务().导入(
连,
env["作者"].作者,
导入请求(
kind="reference",
title=标题,
origin=origin,
content="刀锋擦过装甲,反震顺着掌心爬上来。\n火花在真空中凝成细线。\n" * 400,
authorized_uses=用途,
),
)
return str(来源.source_id)
@pytest.fixture
def 切片环境(生成环境):
env = 生成环境
with env["装配"].要求数据库().连接() as 连:
env["授权来源"] = _导入(
env, 连, 标题="机甲打斗参考", origin="seed://slice-ref", 用途=("reference_reading",)
)
env["未授权来源"] = _导入(
env, 连, 标题="分析专用资料", origin="seed://slice-analysis", 用途=("analysis",)
)
return env
@pytest.mark.case_id(
"NC-w33-a10029",
environment="隔离PG与生成环境",
given="一个 reference_reading 授权来源与一个仅 analysis 来源",
when="构建场景索引并读取切片",
then=[
"仅授权来源入池,切片数与确定性计算一致",
"切片行携带书名、定位与来源版本内容哈希",
"重复构建为幂等重建,索引身份一致",
],
contract="docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
)
def test_构建场景索引只纳入授权来源__a10029(切片环境):
env = 切片环境
库 = env["装配"].要求数据库()
with 库.连接() as 连:
回执 = 构建场景索引(连, env["作者"].作者)
assert 回执["来源数"] == 1
assert 回执["切片数"] > 0
assert 回执["embedder"].startswith("hash-bigram-")
再次 = 构建场景索引(连, env["作者"].作者)
assert 再次["切片数"] == 回执["切片数"]
assert 再次["索引身份"] == 回执["索引身份"]
raw = 连.execute("SELECT slice_id FROM muse_source_slice").fetchall()
切片集 = [str(r[0]) for r in raw]
明细 = 读取场景切片(连, env["作者"].作者, 切片集)
assert len(明细) == 回执["切片数"]
assert all(条["书名"] == "机甲打斗参考" for 条 in 明细)
assert all(条["内容哈希"] for 条 in 明细)
# 未授权来源切片不出现。
assert all(条["source_id"] == env["授权来源"] for 条 in 明细)
@pytest.mark.case_id(
"NC-w33-a10030",
environment="隔离PG与生成环境",
given="已入池的授权来源",
when="撤回 reference_reading 用途后读取切片",
then=["读取返回空,切片行保留(派生缓存不删历史),重建后来源数归零"],
contract="docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
)
def test_用途撤回后切片退出候选__a10030(切片环境):
env = 切片环境
库 = env["装配"].要求数据库()
with 库.连接() as 连:
构建场景索引(连, env["作者"].作者)
当前 = 连.execute("SELECT slice_id FROM muse_source_slice LIMIT 1").fetchone()
# 测试关注读侧授权过滤,直接置空用途模拟撤回后的来源状态。
连.execute(
"UPDATE muse_source SET authorized_uses='[]'::jsonb WHERE source_id=%s",
(env["授权来源"],),
)
明细 = 读取场景切片(连, env["作者"].作者, [str(当前[0])])
assert 明细 == []
保留 = 连.execute("SELECT COUNT(*) FROM muse_source_slice").fetchone()[0]
assert 保留 > 0
回执 = 构建场景索引(连, env["作者"].作者)
assert 回执["来源数"] == 0 and 回执["切片数"] == 0
@pytest.mark.case_id(
"NC-w33-a10031",
environment="隔离PG与生成环境",
given="他人导入的授权来源",
when="以当前作者构建场景索引",
then=["来源不计入切片池:切片只对导入者视角构建"],
contract="docs/系统架构/新版设计/模块设计/B03-资料研究.md#场景片段切片与索引",
)
def test_非导入者来源不入池__a10031(切片环境):
env = 切片环境
with env["装配"].要求数据库().连接() as 连:
回执 = 构建场景索引(连, "other-author")
assert 回执["来源数"] == 0 and 回执["切片数"] == 0
assert 计算切片("有字。\n", env["授权来源"], 1)

View File

@ -0,0 +1,87 @@
-- B03:场景切片派生缓存与向量代次索引——参考书原文的确定性切窗。
-- 原文以 muse_source_version 为权威;切片与索引是可全量重建的派生数据,禁原地改写。
DO $migration$
BEGIN
-- 切片台账:来源版本的定位窗;窗计划哈希锁定切窗方案,同版本只认一个窗计划。
CREATE TABLE public.muse_source_slice (
slice_id uuid PRIMARY KEY,
source_id uuid NOT NULL REFERENCES public.muse_source(source_id),
revision bigint NOT NULL CHECK (revision > 0),
起点 int NOT NULL CHECK (起点 >= 0),
终点 int NOT NULL CHECK (终点 > 起点),
窗计划哈希 text NOT NULL,
检索文本 text NOT NULL,
检索文本哈希 text NOT NULL CHECK (检索文本哈希 ~ '^[0-9a-f]{64}$'),
built_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
-- 同版本同窗计划内起点唯一;不同窗计划可并存,重建时整计划替换。
CREATE UNIQUE INDEX muse_source_slice_window
ON public.muse_source_slice (source_id, revision, 窗计划哈希, 起点);
CREATE INDEX muse_source_slice_by_source ON public.muse_source_slice (source_id, revision);
-- 派生数据禁原地改写;删除仅允许全量重建事务成批进行。
CREATE FUNCTION public.muse_guard_source_slice() RETURNS trigger LANGUAGE plpgsql AS $guard$
BEGIN
RAISE EXCEPTION '场景切片是派生数据,禁止原地改写;重建请整批删除后重算';
END;
$guard$;
CREATE TRIGGER muse_source_slice_guard BEFORE UPDATE ON public.muse_source_slice
FOR EACH ROW EXECUTE FUNCTION public.muse_guard_source_slice();
-- 切片向量索引:与 muse_method_index 同构(哈希 bigram,内存余弦)。
CREATE TABLE public.muse_source_slice_index (
slice_id uuid PRIMARY KEY REFERENCES public.muse_source_slice(slice_id) ON DELETE CASCADE,
检索文本 text NOT NULL,
检索文本哈希 text NOT NULL,
vector double precision[] NOT NULL CHECK (array_length(vector, 1) > 0),
embedder text NOT NULL,
built_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
-- 代次台账:当前有效索引身份;检索时对不上即拒绝为陈旧。
CREATE TABLE public.muse_source_slice_index_version (
ledger_id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
索引身份 text NOT NULL,
embedder text NOT NULL,
slice_count int NOT NULL CHECK (slice_count >= 0),
built_at timestamptz NOT NULL DEFAULT clock_timestamp(),
active boolean NOT NULL DEFAULT false
);
CREATE UNIQUE INDEX muse_source_slice_index_active
ON public.muse_source_slice_index_version (active) WHERE active;
-- 代次守卫:切片与索引的事务变更递增代次,检索按代次判新鲜(仿 V0059)。
CREATE TABLE public.muse_source_slice_guard (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
generation bigint NOT NULL DEFAULT 0 CHECK (generation >= 0)
);
INSERT INTO public.muse_source_slice_guard (singleton, generation) VALUES (true, 0);
CREATE FUNCTION public.muse_source_slice_changed() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $guard$
BEGIN
UPDATE public.muse_source_slice_guard
SET generation = generation + 1
WHERE singleton;
IF NOT FOUND THEN
RAISE EXCEPTION 'source slice mutation guard is missing';
END IF;
RETURN NULL;
END;
$guard$;
CREATE TRIGGER muse_source_slice_changed
AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON public.muse_source_slice
FOR EACH STATEMENT EXECUTE FUNCTION public.muse_source_slice_changed();
CREATE TRIGGER muse_source_slice_index_changed
AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON public.muse_source_slice_index
FOR EACH STATEMENT EXECUTE FUNCTION public.muse_source_slice_changed();
CREATE TRIGGER muse_source_slice_head_changed
AFTER INSERT OR UPDATE OR DELETE OR TRUNCATE ON public.muse_source_slice_index_version
FOR EACH STATEMENT EXECUTE FUNCTION public.muse_source_slice_changed();
REVOKE ALL ON public.muse_source_slice_guard FROM PUBLIC, muse_app, muse_eval;
GRANT SELECT ON public.muse_source_slice_guard TO muse_app, muse_eval;
REVOKE ALL ON FUNCTION public.muse_source_slice_changed() FROM PUBLIC;
END
$migration$;