W19 世界设定、人物弧线与全书连续性(收口):世界设定维护层(S04 族驱动实体浏览/轨迹/save_entity 提案→确认,新类型零代码可编辑)、人物关系(端点按引用规则解析、弧线计划/已发生分区)、设定修订影响评估(受影响事实引用反查+待决提案+章序影响,只读只标记不撤回独立确认);HTTP 7 端点+抽取章节事实提示词+维护故事世界技能;工作台三页(世界资料/人物关系+关系图/时间与伏笔);7 新 NC(NC-w19-*),离线 275 / 非宿主数据库 237 / 前端 27,浏览器三页旅程+视觉判定通过;整包验证退回一项(设计页尾空行致 diff-check 失败)三轮复验通过(第二轮误在原仓目录执行系任务书缺陷,第三轮带基线前置校验确认修复与证据链),W19 置 verified。

This commit is contained in:
zizi 2026-09-11 11:30:20 +08:00
parent 06fcfb32cd
commit 88dd570791
33 changed files with 2579 additions and 46 deletions

View File

@ -13,3 +13,4 @@
| 采集资料与榜单 | [采集资料与榜单/SKILL.md](采集资料与榜单/SKILL.md) | 具名榜单与网页的时点采集 | 榜单观察与网页资料留存时 | 失败如实保留;不补造数据、不作效果归因 |
| 拆解书稿 | [拆解书稿/SKILL.md](拆解书稿/SKILL.md) | 参考作品分窗拆解的发起与进度 | 研究参考作品章节、实体与写法时 | 同源互斥;失败可恢复;分析不是本书事实 |
| 检查与维护拆书结果 | [检查与维护拆书结果/SKILL.md](检查与维护拆书结果/SKILL.md) | 拆书成果的检查、重算与版本维护 | 处理失败窗、歧义与版本对照时 | 歧义不误并;旧结论保留 |
| 维护故事世界 | [维护故事世界/SKILL.md](维护故事世界/SKILL.md) | 世界实体、人物关系与影响评估的维护 | 维护设定、修订前评估、弧线追踪时 | 先审后入;修订不撤回独立确认 |

View File

@ -0,0 +1,28 @@
---
name: 维护故事世界
description: 浏览与编辑世界实体、人物关系与弧线,评估设定修订影响。维护设定、追踪伏笔与影响面时使用。
---
# 维护故事世界
世界实体(人物、地点、物品、组织、规则、代价)与人物关系的长期维护;设定修订前先看影响面。
## 何时使用
- 需要查看或修改本书世界设定时。
- 计划修订设定或重排章序,需要先评估影响时。
- 需要查看人物弧线(计划 vs 已发生)或关系清单时。
## 操作步骤
1. 世界资料页(/world)浏览实体分族清单;点开对象查看版本轨迹(来源与版本可追查)。
2. 编辑实体:页面提交或 `POST /works/{work_id}/world/entities` 生成结构化提案;提案经审阅确认后才成为正式事实(先审后入)。
3. 修订前评估:`GET /works/{work_id}/world/entities/{object_id}/impact`(可带新章序)查看受影响事实、待决提案与章序影响;清单只标记复核,不撤回独立确认。
4. 人物关系页查看人物分组与端点关系;人物弧线按「计划/已发生」分区,猜测与规划不混入已发生。
5. 章序调整后用影响评估复查:叙事位置落在变动章的事实会被列出。
## 边界
- 新题材类型经 S04 登记即可编辑,不需要改生产代码;不维护固定类型清单。
- 世界实例只来自本书;资料研究结论不自动转为事实。
- 独立作者设定不依赖正文;已确认事实的历史决定不被修订撤回。

View File

@ -0,0 +1,3 @@
| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |
|------|----------|----------|----------|----------|
| 技能说明 | SKILL.md | 世界实体、人物关系与影响评估的维护操作 | 维护设定、修订前评估、弧线追踪时 | 先审后入;修订不撤回独立确认 |

View File

@ -1088,7 +1088,7 @@
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/muse__________________17"
"$ref": "#/components/schemas/muse__________________18"
},
"type": "array",
"title": "Response List Works"
@ -1250,7 +1250,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/muse__________________19"
"$ref": "#/components/schemas/muse__________________20"
}
}
}
@ -1718,7 +1718,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/muse__________________20"
"$ref": "#/components/schemas/muse__________________21"
},
"title": "Response List Plans"
}
@ -1969,7 +1969,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/muse__________________20"
"$ref": "#/components/schemas/muse__________________21"
}
}
}
@ -2124,7 +2124,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/muse__________________20"
"$ref": "#/components/schemas/muse__________________21"
}
}
}
@ -3889,6 +3889,332 @@
}
}
},
"/api/v1/world/types": {
"get": {
"tags": [
"故事世界"
],
"summary": "世界类型清单",
"operationId": "list_world_types",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Response List World Types"
}
}
}
}
}
}
},
"/api/v1/works/{work_id}/world/entities": {
"get": {
"tags": [
"故事世界"
],
"summary": "列出世界实体",
"operationId": "list_world_entities",
"parameters": [
{
"name": "work_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Work Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
},
"title": "Response List World Entities"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"post": {
"tags": [
"故事世界"
],
"summary": "保存世界实体",
"operationId": "save_world_entity",
"parameters": [
{
"name": "work_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Work Id"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/muse__________________16"
}
}
}
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/muse____________________25"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/works/{work_id}/world/entities/{object_id}/timeline": {
"get": {
"tags": [
"故事世界"
],
"summary": "读取实体轨迹",
"operationId": "read_entity_timeline",
"parameters": [
{
"name": "work_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Work Id"
}
},
{
"name": "object_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Object Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"title": "Response Read Entity Timeline"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/works/{work_id}/world/entities/{object_id}/impact": {
"get": {
"tags": [
"故事世界"
],
"summary": "评估修订影响",
"operationId": "assess_world_revision",
"parameters": [
{
"name": "work_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Work Id"
}
},
{
"name": "object_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Object Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"title": "Response Assess World Revision"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/works/{work_id}/world/relations": {
"get": {
"tags": [
"故事世界"
],
"summary": "人物关系视图",
"operationId": "list_world_relations",
"parameters": [
{
"name": "work_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Work Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"title": "Response List World Relations"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/works/{work_id}/world/characters/{character_id}": {
"get": {
"tags": [
"故事世界"
],
"summary": "人物弧线",
"operationId": "read_character_arc",
"parameters": [
{
"name": "work_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Work Id"
}
},
{
"name": "character_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Character Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"title": "Response Read Character Arc"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/tasks/{task_id}/events": {
"get": {
"tags": [
@ -4933,6 +5259,21 @@
"title": "叙事窗口"
},
"muse__________________16": {
"properties": {
"command_id": {
"type": "string",
"minLength": 1,
"title": "Command Id"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"command_id"
],
"title": "创作命令"
},
"muse__________________17": {
"properties": {
"node_id": {
"type": "string",
@ -4977,7 +5318,7 @@
],
"title": "节点条目"
},
"muse__________________17": {
"muse__________________18": {
"properties": {
"work_id": {
"type": "string",
@ -5001,7 +5342,7 @@
],
"title": "作品概要"
},
"muse__________________18": {
"muse__________________19": {
"properties": {
"chapter_id": {
"type": "string",
@ -5024,7 +5365,18 @@
],
"title": "章节条目"
},
"muse__________________19": {
"muse__________________2": {
"type": "string",
"enum": [
"planning",
"generation",
"detection",
"extraction"
],
"title": "内容用途",
"description": "内容消费用途。"
},
"muse__________________20": {
"properties": {
"work_id": {
"type": "string",
@ -5036,7 +5388,7 @@
},
"chapters": {
"items": {
"$ref": "#/components/schemas/muse__________________18"
"$ref": "#/components/schemas/muse__________________19"
},
"type": "array",
"title": "Chapters"
@ -5050,18 +5402,7 @@
],
"title": "目录结果"
},
"muse__________________2": {
"type": "string",
"enum": [
"planning",
"generation",
"detection",
"extraction"
],
"title": "内容用途",
"description": "内容消费用途。"
},
"muse__________________20": {
"muse__________________21": {
"properties": {
"plan_id": {
"type": "string",
@ -6212,7 +6553,7 @@
},
"nodes": {
"items": {
"$ref": "#/components/schemas/muse__________________16"
"$ref": "#/components/schemas/muse__________________17"
},
"type": "array",
"title": "Nodes"
@ -6707,7 +7048,7 @@
"before": {
"anyOf": [
{
"$ref": "#/components/schemas/muse__________________20"
"$ref": "#/components/schemas/muse__________________21"
},
{
"type": "null"

View File

@ -3051,8 +3051,19 @@
"模块设计/B02-故事世界.md",
"数据模型/元数据与结构演进.md"
],
"status": "planned",
"execution_evidence": [],
"status": "verified",
"execution_evidence": [
{
"batch": "R2-20260909",
"date": "2026-09-11",
"summary": "世界设定、人物弧线与全书连续性整包落地:S04 族驱动维护层(世界设定/人物关系/影响评估,新类型零代码)、计划与已发生视图分离、修订影响只读标记(独立确认不撤回);HTTP+提示词+技能+工作台三页。7 个新 NC 通过;离线 275、非宿主数据库 237、前端 27;浏览器三页+视觉判定通过。整包验证退回一项(尾空行 diff-check),三轮复验通过。",
"evidence": [
".agents.local/改造/R2-20260909/W19/回合开发/整包/执行证据.md",
".agents.local/改造/R2-20260909/W19/回合验证/整包/验证报告.md",
".agents.local/改造/R2-20260909/W19/回合验证/整包/复验报告-第三轮.md"
]
}
],
"owned_file_paths": [
".agent/skills/操作/维护故事世界/SKILL.md",
".agent/skills/操作/维护故事世界/目录.md",

View File

@ -1,6 +1,6 @@
# 前端-应用与通用界面:文件合同
本页从[目标文件清单](../目标文件清单.json)投影,共 62 个目标条目。测试用例的具体规格在独立用例清单;文件和目标合同不代表实现或验收已完成。
本页从[目标文件清单](../目标文件清单.json)投影,共 63 个目标条目。测试用例的具体规格在独立用例清单;文件和目标合同不代表实现或验收已完成。
[返回目录](目录.md) · [完整项目树](../项目目录与文件职责.md) · [用例重写](../验证设计/测试用例重写.md)
@ -67,6 +67,7 @@ agent-example/
│ ├── 单元/
│ │ └── 中文显示与动作.test.tsx # 作者界面语义的逐例行为验证
│ ├── 旅程/
│ │ ├── 世界维护.spec.ts # 世界维护三页真实浏览器旅程
│ │ ├── 任务材料.spec.ts # 任务材料真实浏览器旅程验证
│ │ ├── 创建作品到确认正文.spec.ts # 真后端创作旅程
│ │ ├── 动态类型到事实确认.spec.ts # 新类型全链消费
@ -797,3 +798,12 @@ agent-example/
- 意图与职责:真实浏览器与隔离库核对资料对照页的来源、原文、清理说明与榜单失败态。
- 边界:真实隔离库与真实浏览器;不 mock 后端;视觉判定独立留证。
- 目标:对照呈现与失败态可复核,页面无预期外控制台错误。
<a id="file-6dbf734085513505"></a>
### `web/tests/旅程/世界维护.spec.ts`
- 归属与种类:验证;前端浏览器旅程。
- 意图与职责:真实浏览器与隔离库核对世界资料、人物关系与时间伏笔三页。
- 边界:不 mock 后端;视觉判定独立留证。
- 目标:世界维护旅程可复核。

View File

@ -1727,3 +1727,12 @@ agent-example/
- 意图与职责:榜单采集成功/失败二态、观察差异与未登记拒绝的隔离数据库验证。
- 边界:合成读取器按剧本回应;不访问网络。
- 目标:失败不伪造、快照可查、差异为观察陈述可复验。
<a id="file-6a24bf0e8e44c6cd"></a>
### `tests/集成/test_世界维护与影响.py`
- 归属与种类:验证;测试。
- 意图与职责:世界设定维护、弧线分离与修订影响的隔离数据库验证。
- 边界:真实隔离 PG;S04 结构经登记注入。
- 目标:维护与影响行为可复验。

View File

@ -21674,6 +21674,23 @@
"test_case_refs": [
"NC-w17-browser"
]
},
{
"path": "web/tests/旅程/世界维护.spec.ts",
"owner": "验证",
"kind": "测试",
"responsibility": "世界维护三页真实浏览器旅程",
"project_part": "前端",
"intent": "世界维护三页真实浏览器旅程",
"boundary": "真实隔离库与真实浏览器;不 mock 后端",
"goal": "行为可复验",
"contract_basis": "行为验证",
"status": "目标合同;不表示已实现或已验证",
"legacy_sources": [],
"design_location": "文件设计/前端-应用与通用界面.md#file-6dbf734085513505",
"test_case_refs": [
"NC-w19-browser"
]
}
],
"contract_fields": {

View File

@ -1354,6 +1354,7 @@ agent-example/
│ │ ├── 单元/
│ │ │ └── 中文显示与动作.test.tsx # 作者界面语义的逐例行为验证
│ │ ├── 旅程/
│ │ │ ├── 世界维护.spec.ts # 世界维护三页真实浏览器旅程
│ │ │ ├── 人工创作.spec.ts # 人工创作HTTP与作者编辑独立用例
│ │ │ ├── 任务材料.spec.ts # 任务材料真实浏览器旅程验证
│ │ │ ├── 候选审阅.spec.ts # 作者候选审阅和正式采纳浏览器旅程

View File

@ -3258,3 +3258,15 @@
- 环境:真实隔离库 + Chrome。
- 合同:[权威定义](../模块设计/B03-资料研究.md)。
<a id="nc-browser-world-workbench"></a>
## NC-w19-browser · NC-browser-world-workbench:世界维护三页旅程
- 位置:`web/tests/旅程/世界维护.spec.ts::NC-browser-world-workbench:世界维护三页旅程`。
- 给定:隔离浏览器环境与预置作品/实体。
- 动作:浏览世界资料/人物关系/时间伏笔三页并展开轨迹与影响。
- 预期:实体与轨迹可见;弧线分区呈现;影响计数可见。
- 环境:真实隔离库 + Chrome。
- 合同:[权威定义](../模块设计/B02-故事世界.md)。

View File

@ -123415,6 +123415,125 @@
"environment": "真实隔离库 + Chrome",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W18/回合开发/整包/"
},
{
"case_id": "NC-w19-19a01a",
"file": "tests/集成/test_世界维护与影响.py",
"symbol": "test_新类型零代码经世界设定编辑__19a01a",
"design_location": "验证设计/资料与结构用例.md#nc-w19-19a01a",
"contract": "模块设计/B02-故事世界.md",
"given": "隔离数据库与 S04 登记结构",
"when": "新类型零代码经世界设定编辑",
"then": [
"行为可复验",
"先审后入",
"修订不撤回独立确认"
],
"environment": "真实隔离 PG",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W19/回合开发/整包/"
},
{
"case_id": "NC-w19-19a02b",
"file": "tests/集成/test_世界维护与影响.py",
"symbol": "test_实体轨迹版本追查与旧版本保留__19a02b",
"design_location": "验证设计/资料与结构用例.md#nc-w19-19a02b",
"contract": "模块设计/B02-故事世界.md",
"given": "隔离数据库与 S04 登记结构",
"when": "实体轨迹版本追查与旧版本保留",
"then": [
"行为可复验",
"先审后入",
"修订不撤回独立确认"
],
"environment": "真实隔离 PG",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W19/回合开发/整包/"
},
{
"case_id": "NC-w19-19a03c",
"file": "tests/集成/test_世界维护与影响.py",
"symbol": "test_修订影响只标记不撤回独立确认__19a03c",
"design_location": "验证设计/资料与结构用例.md#nc-w19-19a03c",
"contract": "模块设计/B02-故事世界.md",
"given": "隔离数据库与 S04 登记结构",
"when": "修订影响只标记不撤回独立确认",
"then": [
"行为可复验",
"先审后入",
"修订不撤回独立确认"
],
"environment": "真实隔离 PG",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W19/回合开发/整包/"
},
{
"case_id": "NC-w19-19a04d",
"file": "tests/集成/test_世界维护与影响.py",
"symbol": "test_章序变化影响列出叙事位置事实__19a04d",
"design_location": "验证设计/资料与结构用例.md#nc-w19-19a04d",
"contract": "模块设计/B02-故事世界.md",
"given": "隔离数据库与 S04 登记结构",
"when": "章序变化影响列出叙事位置事实",
"then": [
"行为可复验",
"先审后入",
"修订不撤回独立确认"
],
"environment": "真实隔离 PG",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W19/回合开发/整包/"
},
{
"case_id": "NC-w19-19b01e",
"file": "tests/集成/test_世界维护与影响.py",
"symbol": "test_人物弧线计划与已发生分开__19b01e",
"design_location": "验证设计/资料与结构用例.md#nc-w19-19b01e",
"contract": "模块设计/B02-故事世界.md",
"given": "隔离数据库与 S04 登记结构",
"when": "人物弧线计划与已发生分开",
"then": [
"行为可复验",
"先审后入",
"修订不撤回独立确认"
],
"environment": "真实隔离 PG",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W19/回合开发/整包/"
},
{
"case_id": "NC-w19-19b02f",
"file": "tests/集成/test_世界维护与影响.py",
"symbol": "test_关系端点解析为本书对象__19b02f",
"design_location": "验证设计/资料与结构用例.md#nc-w19-19b02f",
"contract": "模块设计/B02-故事世界.md",
"given": "隔离数据库与 S04 登记结构",
"when": "关系端点解析为本书对象",
"then": [
"行为可复验",
"先审后入",
"修订不撤回独立确认"
],
"environment": "真实隔离 PG",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W19/回合开发/整包/"
},
{
"case_id": "NC-w19-browser",
"file": "web/tests/旅程/世界维护.spec.ts",
"symbol": "NC-browser-world-workbench:世界维护三页旅程",
"design_location": "验证设计/工作台与工程用例.md#nc-browser-world-workbench",
"contract": "模块设计/B02-故事世界.md",
"given": "隔离浏览器环境与预置作品/实体",
"when": "浏览世界资料/人物关系/时间伏笔三页并展开轨迹与影响",
"then": [
"实体与轨迹可见",
"弧线分区呈现",
"影响计数可见"
],
"environment": "真实隔离库 + Chrome",
"verification_state": "已实现未执行",
"execution_evidence": ".agents.local/改造/R2-20260909/W19/回合开发/整包/"
}
]
}

View File

@ -3877,3 +3877,75 @@
- 环境:真实隔离 PG。
- 合同:[权威定义](../模块设计/B03-资料研究.md)。
<a id="nc-w19-19a01a"></a>
## NC-w19-19a01a · test_新类型零代码经世界设定编辑__19a01a
- 位置:`tests/集成/test_世界维护与影响.py::test_新类型零代码经世界设定编辑__19a01a`。
- 给定:隔离数据库与 S04 登记结构。
- 动作:新类型零代码经世界设定编辑。
- 预期:行为可复验;先审后入;修订不撤回独立确认。
- 环境:真实隔离 PG。
- 合同:[权威定义](../模块设计/B02-故事世界.md)。
<a id="nc-w19-19a02b"></a>
## NC-w19-19a02b · test_实体轨迹版本追查与旧版本保留__19a02b
- 位置:`tests/集成/test_世界维护与影响.py::test_实体轨迹版本追查与旧版本保留__19a02b`。
- 给定:隔离数据库与 S04 登记结构。
- 动作:实体轨迹版本追查与旧版本保留。
- 预期:行为可复验;先审后入;修订不撤回独立确认。
- 环境:真实隔离 PG。
- 合同:[权威定义](../模块设计/B02-故事世界.md)。
<a id="nc-w19-19a03c"></a>
## NC-w19-19a03c · test_修订影响只标记不撤回独立确认__19a03c
- 位置:`tests/集成/test_世界维护与影响.py::test_修订影响只标记不撤回独立确认__19a03c`。
- 给定:隔离数据库与 S04 登记结构。
- 动作:修订影响只标记不撤回独立确认。
- 预期:行为可复验;先审后入;修订不撤回独立确认。
- 环境:真实隔离 PG。
- 合同:[权威定义](../模块设计/B02-故事世界.md)。
<a id="nc-w19-19a04d"></a>
## NC-w19-19a04d · test_章序变化影响列出叙事位置事实__19a04d
- 位置:`tests/集成/test_世界维护与影响.py::test_章序变化影响列出叙事位置事实__19a04d`。
- 给定:隔离数据库与 S04 登记结构。
- 动作:章序变化影响列出叙事位置事实。
- 预期:行为可复验;先审后入;修订不撤回独立确认。
- 环境:真实隔离 PG。
- 合同:[权威定义](../模块设计/B02-故事世界.md)。
<a id="nc-w19-19b01e"></a>
## NC-w19-19b01e · test_人物弧线计划与已发生分开__19b01e
- 位置:`tests/集成/test_世界维护与影响.py::test_人物弧线计划与已发生分开__19b01e`。
- 给定:隔离数据库与 S04 登记结构。
- 动作:人物弧线计划与已发生分开。
- 预期:行为可复验;先审后入;修订不撤回独立确认。
- 环境:真实隔离 PG。
- 合同:[权威定义](../模块设计/B02-故事世界.md)。
<a id="nc-w19-19b02f"></a>
## NC-w19-19b02f · test_关系端点解析为本书对象__19b02f
- 位置:`tests/集成/test_世界维护与影响.py::test_关系端点解析为本书对象__19b02f`。
- 给定:隔离数据库与 S04 登记结构。
- 动作:关系端点解析为本书对象。
- 预期:行为可复验;先审后入;修订不撤回独立确认。
- 环境:真实隔离 PG。
- 合同:[权威定义](../模块设计/B02-故事世界.md)。

View File

@ -8,6 +8,7 @@ from typing import Any
import psycopg
from psycopg import sql
from psycopg.pq import TransactionStatus
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .模型 import 元数据错误, 字段限制, 定义哈希, 策略快照, 结构绑定
@ -31,6 +32,13 @@ class 元数据存储:
if 行 is None:
raise 元数据错误("TYPE_UNKNOWN", "类型尚未登记")
def 列出类型(self) -> list[dict]:
return (
self.连接.cursor(row_factory=dict_row)
.execute("SELECT type_id, definition FROM metadata.type_registry ORDER BY type_id")
.fetchall()
)
def 登记类型(self, 定义: dict[str, Any], 哈希: str) -> None:
self.要求事务()
self.连接.execute(

View File

@ -40,6 +40,17 @@ class 元数据服务:
self._存储.登记类型(asdict(类型), 定义哈希(asdict(类型)))
return 类型
def 列出类型(self) -> list[类型定义]:
清单 = []
for 行 in self._存储.列出类型():
定义 = dict(行["definition"])
定义["实例族"] = tuple(定义["实例族"])
定义["实例粒度"] = tuple(
定义["实例粒度"] if isinstance(定义["实例粒度"], list) else [定义["实例粒度"]]
)
清单.append(类型定义(**定义))
return 清单
def 读取类型(self, type_id: str) -> 类型定义:
定义 = self._存储.读取类型(type_id)
定义["实例族"] = tuple(定义["实例族"])

View File

@ -12,7 +12,9 @@ from muse.接入.创作操作 import (
事实审阅结果,
事实审阅请求,
事实提案结果,
创作命令,
创作身份,
实体保存请求,
核对路径,
正式回执结果,
)
@ -118,3 +120,74 @@ def 决定事实(proposal_id: str, 请求: 事实决定请求, request: Request,
请求.review_hash,
tuple(请求.approved_changes),
)
# ---- W19 世界维护 ----
@路由.get("/world/types", operation_id="list_world_types")
def 世界类型清单(request: Request, 作者: 作者依赖) -> list[dict]:
装配 = request.app.state.装配
return 装配.要求故事世界().世界类型清单(创作身份(作者, 装配.配置))
@路由.get("/works/{work_id}/world/entities", operation_id="list_world_entities")
def 列出世界实体(work_id: str, request: Request, 作者: 作者依赖) -> list[dict]:
装配 = request.app.state.装配
return 装配.要求故事世界().列出世界实体(创作身份(作者, 装配.配置), work_id)
@路由.post(
"/works/{work_id}/world/entities",
response_model=正式回执结果,
operation_id="save_world_entity",
status_code=201,
)
def 保存世界实体(work_id: str, 请求: 创作命令, request: Request, 作者: 作者依赖) -> dict:
装配 = request.app.state.装配
载荷 = 实体保存请求.model_validate(请求.model_dump(exclude={"command_id"}))
return 装配.要求故事世界().保存实体提案(
创作身份(作者, 装配.配置),
请求.command_id,
work_id=work_id,
object_id=载荷.object_id,
expected_revision=载荷.expected_revision,
schema_id=载荷.schema_id,
schema_version=载荷.schema_version,
content=载荷.content,
正文依据们=tuple(载荷.sources),
independent=载荷.independent,
knowledge_mode=载荷.knowledge_mode,
effective_from=载荷.effective_from,
知情记录们=tuple(载荷.known_to),
计划窗口=载荷.planned_window,
instance_family=载荷.instance_family,
)
@路由.get(
"/works/{work_id}/world/entities/{object_id}/timeline", operation_id="read_entity_timeline"
)
def 读取实体轨迹(work_id: str, object_id: str, request: Request, 作者: 作者依赖) -> dict:
装配 = request.app.state.装配
return 装配.要求故事世界().读取实体轨迹(创作身份(作者, 装配.配置), work_id, object_id)
@路由.get(
"/works/{work_id}/world/entities/{object_id}/impact", operation_id="assess_world_revision"
)
def 评估修订影响(work_id: str, object_id: str, request: Request, 作者: 作者依赖) -> dict:
装配 = request.app.state.装配
return 装配.要求故事世界().评估修订影响(创作身份(作者, 装配.配置), work_id, object_id)
@路由.get("/works/{work_id}/world/relations", operation_id="list_world_relations")
def 人物关系视图(work_id: str, request: Request, 作者: 作者依赖) -> dict:
装配 = request.app.state.装配
return 装配.要求故事世界().人物关系视图(创作身份(作者, 装配.配置), work_id)
@路由.get("/works/{work_id}/world/characters/{character_id}", operation_id="read_character_arc")
def 人物弧线(work_id: str, character_id: str, request: Request, 作者: 作者依赖) -> dict:
装配 = request.app.state.装配
return 装配.要求故事世界().人物弧线(创作身份(作者, 装配.配置), work_id, character_id)

View File

@ -476,3 +476,19 @@ class 规划表单结果(BaseModel):
content: dict[str, Any]
visible_schema: dict[str, Any]
editable_schema: dict[str, Any]
class 实体保存请求(BaseModel):
model_config = ConfigDict(extra="forbid")
object_id: str = Field(min_length=1)
expected_revision: int = Field(ge=0)
schema_id: str = Field(min_length=1)
schema_version: int = Field(ge=1)
content: dict
sources: list[dict] = []
independent: bool = True
knowledge_mode: Literal["fact", "belief", "plan", "setting"] = "setting"
effective_from: str | None = None
known_to: list[dict] = []
planned_window: dict | None = None
instance_family: Literal["entity", "relation"] = "entity"

View File

@ -0,0 +1,176 @@
"""世界设定:世界实体的维护薄层;类型来自 S04 生命周期族,编辑走提案→作者确认。
只维护本书世界实例;不把资料和公共方法直接转为事实。新类型经 S04 登记即可编辑,
本模块不维护固定类型清单。
"""
from __future__ import annotations
from typing import Any
from pydantic import TypeAdapter
from muse.作品规划.接口 import 读取作品范围
from muse.元数据.接口 import 元数据服务, 投影值
from muse.故事世界.存储 import 故事存储
from muse.故事世界.来源有效性 import 当前依据可用, 读取依据状态
from muse.故事世界.模型 import 事实提案请求, 事实结构, 事实错误, 叙事窗口, 正文依据, 知情记录
def 世界类型清单(连) -> list[dict[str, Any]]:
"""S04 已登记类型的实体族视图;新题材类型不改生产代码即可出现。"""
清单 = []
for 类型 in 元数据服务(连).列出类型():
if "entity" not in 类型.实例粒度:
continue
清单.append(
{
"type_id": 类型.type_id,
"名称": 类型.名称,
"语义域": 类型.语义域,
"实例族": list(类型.实例族),
"说明": 类型.说明,
}
)
return 清单
def _投影(连, 作者: str, work_id: str, 行: dict[str, Any]) -> dict[str, Any]:
"""按对象结构绑定解释内容投影;历史内容按当时绑定解释,不套用当前绑定。"""
from muse.故事世界.设定修订 import 读取事实结构
绑定 = 行["schema_binding"]
结构, _, 投影合同 = 读取事实结构(
连,
作者,
work_id,
事实结构(
绑定["schema_id"],
int(绑定["base_version"]),
绑定["effective_schema_hash"],
"",
绑定.get("extension_version"),
),
行["instance_family"],
)
return 投影值(投影合同, 行["content"])
def 列出实体(连, 作者: str, work_id: str) -> list[dict[str, Any]]:
"""本书世界实体当前版全览:类型分组信息、依据可用性与计划/已发生标记。"""
读取作品范围(连, 作者, work_id)
实体 = []
for 行 in 故事存储(连).列出对象(work_id, "entity"):
实体.append(
{
"object_id": 行["object_id"],
"type_id": 行["type_id"],
"revision": int(行["revision"]),
"knowledge_mode": 行["knowledge_mode"],
"independent": bool(行["independent"]),
"依据可用": 当前依据可用(连, 作者, 行),
"生效位置": 行["effective_from"],
"计划窗口": 行["planned_window"],
"content": _投影(连, 作者, work_id, 行),
}
)
return 实体
def 读取实体轨迹(连, 作者: str, work_id: str, object_id: str) -> dict[str, Any]:
"""对象全部历史版本:来源、版本与结构绑定可追查;不改写任何历史决定。"""
目录 = 读取作品范围(连, 作者, work_id)
存储 = 故事存储(连)
行 = 存储.读取对象(object_id)
if 行 is None or 行["work_id"] != work_id:
raise 事实错误("FACT_REFERENCE_INVALID", "世界对象不存在或不属于本书")
版本 = []
for 历史行 in 存储.读取历史(work_id, 存储.当前系统版本(work_id), object_id):
依据 = [
读取依据状态(连, 作者, work_id, TypeAdapter(正文依据).validate_python(原))
for 原 in (历史行["sources"] or [])
]
版本.append(
{
"revision": int(历史行["revision"]),
"system_revision": int(历史行["system_revision"]),
"knowledge_mode": 历史行["knowledge_mode"],
"independent": bool(历史行["independent"]),
"生效位置": 历史行["effective_from"],
"计划窗口": 历史行["planned_window"],
"content_hash": 历史行["content_hash"],
"content": _投影(连, 作者, work_id, 历史行),
"依据": [
{"document_id": b["document_id"], "revision": b["current_revision"]}
for b in 依据
],
}
)
return {
"object_id": object_id,
"type_id": 行["type_id"],
"instance_family": 行["instance_family"],
"current_revision": int(行["current_revision"]),
"目录版本": 目录["revision"],
"版本": 版本,
}
def 保存实体提案(
服务,
身份,
命令ID: str,
*,
work_id: str,
object_id: str,
expected_revision: int,
schema_id: str,
schema_version: int,
content: dict[str, Any],
正文依据们: tuple[dict[str, Any], ...] = (),
independent: bool = True,
knowledge_mode: str = "setting",
effective_from: str | None = None,
知情记录们: tuple[dict[str, Any], ...] = (),
计划窗口: dict[str, Any] | None = None,
instance_family: str = "entity",
) -> dict:
"""save_entity:作者编辑世界实体 → 结构化提案(S01 管线),确认后才成为正式事实。
独立作者设定不依赖正文;携带正文依据的编辑在确认时核对固定版本与引文。
"""
结构 = 服务.新事实结构(
身份, work_id, object_id, schema_id, schema_version, 家族=instance_family
)
请求 = 事实提案请求(
work_id=work_id,
object_id=object_id,
expected_revision=expected_revision,
schema=结构,
content=content,
sources=tuple(TypeAdapter(正文依据).validate_python(dict(依据)) for 依据 in 正文依据们),
effective_from=effective_from,
instance_family=instance_family, # type: ignore[arg-type]
independent=independent,
knowledge_mode=knowledge_mode, # type: ignore[arg-type]
known_to=tuple(TypeAdapter(知情记录).validate_python(dict(项)) for 项 in 知情记录们),
reader_visible=True,
planned_window=(
叙事窗口(**计划窗口) if 计划窗口 is not None else None # type: ignore[arg-type]
),
)
return 服务.提出事实(身份, 命令ID, 请求)
__all__ = [
"世界类型清单",
"列出实体",
"读取实体轨迹",
"保存实体提案",
"事实错误",
]

View File

@ -0,0 +1,128 @@
"""人物关系:人物、关系、弧线、视角与说话特征的维护视图。
人物属于故事世界;关系端点必须用已解析的本书对象身份(S04 引用规则驱动);
人物计划与已发生状态分开,不混入同一轨迹。
"""
from __future__ import annotations
from typing import Any
from muse.作品规划.接口 import 读取作品范围
from muse.元数据.接口 import 校验值
from muse.故事世界.存储 import 故事存储
from muse.故事世界.模型 import 事实错误
计划类 = ("plan", "belief")
已发生类 = ("fact", "setting")
def 列出人物(连, 作者: str, work_id: str) -> list[dict[str, Any]]:
"""本书全部实体对象按类型分组;人物判定交给页面与结构,不在此硬编码类型清单。"""
from muse.故事世界.世界设定 import 列出实体
实体 = 列出实体(连, 作者, work_id)
按类型: dict[str, list[dict[str, Any]]] = {}
for 项 in 实体:
按类型.setdefault(项["type_id"], []).append(项)
return [{"type_id": 类型, "数量": len(组), "对象": 组} for 类型, 组 in sorted(按类型.items())]
def _收集引用(连, 作者: str, work_id: str, 行: dict[str, Any]) -> set[str]:
"""按该对象结构绑定的 S04 引用规则,从内容收集引用的对象身份。"""
from muse.故事世界.模型 import 事实结构
from muse.故事世界.设定修订 import 读取事实结构
绑定 = 行["schema_binding"]
结构, _, _ = 读取事实结构(
连,
作者,
work_id,
事实结构(
绑定["schema_id"],
int(绑定["base_version"]),
绑定["effective_schema_hash"],
"",
绑定.get("extension_version"),
),
行["instance_family"],
)
引用对象集: set[str] = set()
try:
结果 = 校验值(结构, 行["content"] or {})
except Exception:
return 引用对象集
for 需求 in 结果.引用需求:
值 = 需求.值
身份 = 值.get("id") if isinstance(值, dict) else 值
if isinstance(身份, str):
引用对象集.add(身份)
return 引用对象集
def 列出关系(连, 作者: str, work_id: str) -> list[dict[str, Any]]:
"""关系族对象与其端点解析:端点必须是已确认的本书对象,类型按引用约束核对。"""
读取作品范围(连, 作者, work_id)
存储 = 故事存储(连)
关系 = []
for 行 in 存储.列出对象(work_id, "relation"):
端点集 = _收集引用(连, 作者, work_id, 行)
端点 = []
for 身份 in sorted(端点集):
对象 = 存储.读取对象(身份)
if 对象 is None or 对象["work_id"] != work_id:
raise 事实错误("FACT_REFERENCE_INVALID", f"关系端点无效:{身份}")
端点.append(
{
"object_id": 身份,
"type_id": 对象["type_id"],
"revision": int(对象["current_revision"]),
}
)
关系.append(
{
"object_id": 行["object_id"],
"type_id": 行["type_id"],
"revision": int(行["revision"]),
"knowledge_mode": 行["knowledge_mode"],
"content": 行["content"],
"端点": 端点,
}
)
return 关系
def 人物弧线(连, 作者: str, work_id: str, character_id: str) -> dict[str, Any]:
"""人物轨迹按计划与已发生分开;猜测与规划不混入已发生事实。"""
from muse.故事世界.世界设定 import 读取实体轨迹
轨迹 = 读取实体轨迹(连, 作者, work_id, character_id)
计划 = [v for v in 轨迹["版本"] if v["knowledge_mode"] in 计划类]
已发生 = [v for v in 轨迹["版本"] if v["knowledge_mode"] in 已发生类]
return {
"object_id": character_id,
"type_id": 轨迹["type_id"],
"current_revision": 轨迹["current_revision"],
"计划": 计划,
"已发生": 已发生,
"说明": "计划与猜测不混入已发生事实;视角与说话特征随版本内容呈现。",
}
def 人物关系视图(连, 作者: str, work_id: str) -> dict[str, Any]:
"""人物关系页一屏数据:人物分组、关系清单(端点已解析)。"""
return {"人物分组": 列出人物(连, 作者, work_id), "关系": 列出关系(连, 作者, work_id)}
__all__ = [
"列出人物",
"列出关系",
"人物弧线",
"人物关系视图",
"事实错误",
]

View File

@ -28,6 +28,22 @@ class 故事存储:
.fetchone()
)
def 列出对象(self, work_id: str, family: str | None = None) -> list[dict]:
return (
self.连.cursor(row_factory=dict_row)
.execute(
"SELECT o.*,v.revision,v.system_revision,v.content,v.content_hash,"
"v.schema_binding,v.sources,v.independent,v.effective_from,v.knowledge_mode,"
"v.reader_visible,v.planned_window "
"FROM muse_world_object o JOIN muse_world_version v ON v.object_id=o.object_id "
"AND v.revision=o.current_revision WHERE o.work_id=%s "
"AND (%s::text IS NULL OR o.instance_family=%s) "
"ORDER BY o.type_id,o.object_id",
(work_id, family, family),
)
.fetchall()
)
def 读取提案(self, proposal_id: str):
return (
self.连.cursor(row_factory=dict_row)
@ -55,6 +71,24 @@ class 故事存储:
)
return {"proposal_id": proposal_id, "revision": 1, "proposal_hash": 哈希}
def 列出待决提案(self, work_id: str, object_id: str | None = None) -> list[dict]:
"""未获作者决定的当前提案;决定由 S01 表判定,非空决定即视为已处置。"""
return (
self.连.cursor(row_factory=dict_row)
.execute(
"SELECT p.proposal_id,p.work_id,p.object_id,p.current_revision,"
"v.request,v.proposed_content "
"FROM muse_fact_proposal p JOIN muse_fact_proposal_version v "
"ON v.proposal_id=p.proposal_id AND v.revision=p.current_revision "
"LEFT JOIN muse_candidate_decision d ON d.candidate_id=p.proposal_id::text "
"AND d.candidate_revision=p.current_revision "
"WHERE p.work_id=%s AND (%s::text IS NULL OR p.object_id=%s) "
"AND d.decision IS NULL ORDER BY p.proposal_id",
(work_id, object_id, object_id),
)
.fetchall()
)
def 锁对象(self, object_id: str) -> int:
key = int.from_bytes(
hashlib.sha256(("B02:object:" + object_id).encode()).digest()[:8], signed=True

View File

@ -175,6 +175,107 @@ class 故事服务:
),
)
def 列出世界实体(self, 身份, work_id: str) -> list[dict]:
"""世界实体当前版全览;类型来自 S04 生命周期族,计划与已发生分开标记。"""
self._核对作者(身份)
from muse.故事世界.世界设定 import 列出实体
with self.数据库.连接(只读=True) as 连:
return 列出实体(连, 身份.作者, work_id)
def 世界类型清单(self, 身份) -> list[dict]:
"""S04 已登记实体类型清单;新类型不改生产代码即可编辑。"""
self._核对作者(身份)
with self.数据库.连接(只读=True) as 连:
return [
类型
for 类型 in __import__(
"muse.故事世界.世界设定", fromlist=["世界类型清单"]
).世界类型清单(连)
]
def 读取实体轨迹(self, 身份, work_id: str, object_id: str) -> dict:
"""对象全部历史版本;来源、版本与结构绑定可追查。"""
self._核对作者(身份)
from muse.故事世界.世界设定 import 读取实体轨迹
with self.数据库.连接(只读=True) as 连:
return 读取实体轨迹(连, 身份.作者, work_id, object_id)
def 保存实体提案(
self,
身份,
命令ID: str,
*,
work_id: str,
object_id: str,
expected_revision: int,
schema_id: str,
schema_version: int,
content: dict,
正文依据们: tuple = (),
independent: bool = True,
knowledge_mode: str = "setting",
effective_from: str | None = None,
知情记录们: tuple = (),
计划窗口: dict | None = None,
instance_family: str = "entity",
) -> dict:
"""save_entity:作者编辑世界实体为结构化提案,确认后成为正式事实。"""
self._核对作者(身份)
from muse.故事世界.世界设定 import 保存实体提案
return 保存实体提案(
self,
身份,
命令ID,
work_id=work_id,
object_id=object_id,
expected_revision=expected_revision,
schema_id=schema_id,
schema_version=schema_version,
content=content,
正文依据们=正文依据们,
independent=independent,
knowledge_mode=knowledge_mode,
effective_from=effective_from,
知情记录们=知情记录们,
计划窗口=计划窗口,
instance_family=instance_family,
)
def 人物关系视图(self, 身份, work_id: str) -> dict:
"""人物分组与端点已解析的关系清单。"""
self._核对作者(身份)
from muse.故事世界.人物关系 import 人物关系视图
with self.数据库.连接(只读=True) as 连:
return 人物关系视图(连, 身份.作者, work_id)
def 人物弧线(self, 身份, work_id: str, character_id: str) -> dict:
"""人物轨迹按计划与已发生分开;猜测与规划不混入已发生事实。"""
self._核对作者(身份)
from muse.故事世界.人物关系 import 人物弧线
with self.数据库.连接(只读=True) as 连:
return 人物弧线(连, 身份.作者, work_id, character_id)
def 评估修订影响(
self,
身份,
work_id: str,
object_id: str,
*,
新章序: dict[str, int] | None = None,
) -> dict:
"""修订设定或章序变化后的影响面清单;只读评估,不撤回独立确认。"""
self._核对作者(身份)
from muse.故事世界.设定修订 import 评估修订影响
with self.数据库.连接(只读=True) as 连:
return 评估修订影响(连, 身份.作者, work_id, object_id, 新章序=新章序)
def 查询世界(
self,
身份,

View File

@ -0,0 +1,18 @@
# 抽取章节事实 · 任务提示模板
本模板为故事世界的「抽取章节事实」任务提供可版本化表达;角色身份、字段结构、权限与预算从各自权威装配,本模板不重复。章后派生(W16)按本模板语义组装系统提示。
## 任务
对刚定稿的一章做事实抽取:
1. **本章摘要**:续写所需的一句话到一段话摘要。
2. **候选事实**:人物、地点、物品、组织、关系、事件与状态变化;每条附正文原句引文。
3. **字段结构**:按任务输出合同(type/object_id/quote/content);content 字段以本书绑定的 S04 有效结构为准。
4. **知情位置**:如某角色在本章首次得知某事实,用 known_to 的 (actor_id, from_chapter_id) 表达,不按事件时间推定。
## 合同
- 只依据本章正文;不新增正文没有的设定;引文必须是正文字面。
- 抽取结果一律是待作者确认的提案(Shadow);机械挽回只保留实际出现的名称为「提及」候选。
- 角色猜测、未来规划用对应知识模式标记,不混入已发生事实。

View File

@ -320,3 +320,59 @@ class 世界提案依赖:
def 锁定当前(self, 连, 对象ID):
return str(故事存储(连).锁提案(对象ID))
def 评估修订影响(
连,
作者: str,
work_id: str,
object_id: str,
*,
新章序: dict[str, int] | None = None,
) -> dict:
"""修订设定或章序变化后需要作者复核的影响面清单。
只读评估:列出引用该对象的其他已确认对象、该对象的未决提案,以及章序变化
波及的叙事位置事实;不撤回任何独立确认,不改写历史版本。规划与正文候选的
过期读取由 S01 提交时的当前指针锁强制复核(SOURCE_STALE),不依赖后台标记。
"""
目录 = 读取作品范围(连, 作者, work_id)
存储 = 故事存储(连)
目标 = 存储.读取对象(object_id)
if 目标 is None or 目标["work_id"] != work_id:
raise 事实错误("FACT_REFERENCE_INVALID", "世界对象不存在或不属于本书")
from muse.故事世界.人物关系 import _收集引用
受影响事实: set[str] = set()
for 行 in 存储.读取历史(work_id, 存储.当前系统版本(work_id)):
if 行["object_id"] == object_id:
continue
if object_id in _收集引用(连, 作者, work_id, 行):
受影响事实.add(str(行["object_id"]))
待决 = [
{"proposal_id": str(行["proposal_id"]), "object_id": str(行["object_id"])}
for 行 in 存储.列出待决提案(work_id)
]
本对象待决 = [项 for 项 in 待决 if 项["object_id"] == object_id]
章序影响: list[dict] = []
if 新章序 is not None:
当前章序 = {c["chapter_id"]: c["position"] for c in 目录["chapters"]}
变动章 = {章 for 章 in set(当前章序) | set(新章序) if 当前章序.get(章) != 新章序.get(章)}
for 行 in 存储.读取历史(work_id, 存储.当前系统版本(work_id)):
位置集 = {行["effective_from"], (行["planned_window"] or {}).get("from_chapter_id")}
位置集 |= {源.get("chapter_id") for 源 in (行["sources"] or [])}
涉及 = {章 for 章 in 位置集 if 章 in 变动章}
if 涉及:
章序影响.append({"object_id": str(行["object_id"]), "涉及章": sorted(涉及)})
return {
"object_id": object_id,
"current_revision": int(目标["current_revision"]),
"受影响事实": sorted(受影响事实),
"本对象待决提案": 本对象待决,
"章序影响": sorted(章序影响, key=lambda 项: 项["object_id"]),
"复核说明": (
"清单只标记需要复核;独立确认与历史版本不被撤回。"
"规划与正文候选过期读取由提交指针锁强制。"
),
}

View File

@ -0,0 +1,295 @@
"""世界设定维护、人物弧线分离与修订影响:S04 族驱动、计划/已发生分开、影响只标记不撤回。"""
import pytest
from pydantic import TypeAdapter
from muse.作品规划.接口 import 档案保存
from muse.元数据.接口 import 元数据服务, 导入内置结构
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
from muse.共享.错误 import Muse错误
from muse.启动 import 构建
from muse.故事世界.接口 import 事实定位, 事实提案请求, 叙事窗口, 定位字面引文, 正文依据
from muse.正式变更.接口 import 作者动作
from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
from muse.配置 import 应用配置
pytestmark = pytest.mark.数据库
@pytest.fixture
def 世界环境(应用测试库):
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
导入内置结构(元数据服务(连))
装配 = 构建(应用配置(应用测试库[用途.生产].引用, "synthetic"))
作者 = 调用身份("author", None, 用途.生产, 内容用途.规划)
作品 = 装配.要求作品()
结构 = 作品.新档案结构(作者, "story-work", "work_core", 1)
作品.保存档案(作者, "create-story", 档案保存("story-work", 0, {"名称": "合成故事世界"}, 结构))
for i in range(1, 4):
作品.添加章节(
作者, f"chapter-{i}", "story-work", f"chapter-{i}", f"第{i}幕", 预期目录版本=i - 1
)
正文 = 装配.要求正文()
for i, text in [
(1, "林深停在码头,何岚站在船旁。"),
(2, "何岚知道林深已经登船。"),
(3, "林深来到北岸。"),
]:
正文.保存人工(
作者, f"body-{i}", f"chapter-{i}", 0, 正文草稿((段落(f"p{i}", (文本节点(text),)),))
)
return 装配, 作者, 应用测试库
def 来源(装配, 作者, chapter, quote):
记录 = 装配.要求正文().读取正文(作者, chapter)
原文 = TypeAdapter(正文草稿).validate_python(记录["document"])
return 正文依据(
chapter, "main", 记录["revision"], 记录["document_hash"], (定位字面引文(原文, quote),)
)
def 确认提案(装配, 作者, 回执, object_id, expected=0, command="decide"):
世界 = 装配.要求故事世界()
proposal_id = 回执["results"][0]["proposal_id"]
记录 = 世界.读取提案(作者, proposal_id)
定位 = 事实定位(
proposal_id, 记录["revision"], 记录["proposal_hash"], "world:" + object_id, expected
)
审阅 = 世界.打开事实审阅(作者, 定位)
return 世界.决定事实(
作者,
f"{command}-{proposal_id[:8]}",
定位,
作者动作.采纳,
审阅["review_id"],
审阅["review_hash"],
("object",),
)
def test_新类型零代码经世界设定编辑__19a01a(世界环境):
from muse.元数据.接口 import 值规则, 字段定义, 类型定义, 结构定义
装配, 作者, 库 = 世界环境
with 库[用途.维护].连接() as 连, 连.transaction():
元数据 = 元数据服务(连)
元数据.登记类型(类型定义("cost_rule", "代价规则", "world", "entity", ("entity",)))
结构 = 结构定义(
"cost_rule",
"cost_rule",
1,
(
字段定义("cost.name", "名称", "名称", 值规则("text"), 必填=True),
字段定义("cost.price", "代价", "代价", 值规则("text")),
),
)
元数据.登记结构候选(结构)
元数据.发布结构("cost_rule", 1, 结构.内容哈希)
世界 = 装配.要求故事世界()
# 世界设定 save_entity:S04 新类型不改生产代码即可提案→确认→列出。
回执 = 世界.保存实体提案(
作者,
"save-cost-1",
work_id="story-work",
object_id="cost-sea",
expected_revision=0,
schema_id="cost_rule",
schema_version=1,
content={"名称": "渡海代价", "代价": "一年记忆"},
)
确认提案(装配, 作者, 回执, "cost-sea")
with 装配.数据库.连接(只读=True) as 连:
from muse.故事世界.世界设定 import 世界类型清单, 列出实体
实体 = [e for e in 列出实体(连, "author", "story-work") if e["object_id"] == "cost-sea"]
assert len(实体) == 1 and 实体[0]["type_id"] == "cost_rule"
assert 实体[0]["knowledge_mode"] == "setting" and 实体[0]["independent"]
assert any(t["type_id"] == "cost_rule" for t in 世界类型清单(连))
def test_实体轨迹版本追查与旧版本保留__19a02b(世界环境):
装配, 作者, _ = 世界环境
世界 = 装配.要求故事世界()
r1 = 世界.保存实体提案(
作者,
"save-char-1",
work_id="story-work",
object_id="char-lin",
expected_revision=0,
schema_id="character",
schema_version=1,
content={"身份定位": "主角"},
)
确认提案(装配, 作者, r1, "char-lin")
r2 = 世界.保存实体提案(
作者,
"save-char-2",
work_id="story-work",
object_id="char-lin",
expected_revision=1,
schema_id="character",
schema_version=1,
content={"身份定位": "重要配角"},
)
确认提案(装配, 作者, r2, "char-lin", expected=1)
轨迹 = 世界.读取实体轨迹(作者, "story-work", "char-lin")
assert [v["revision"] for v in 轨迹["版本"]] == [2, 1]
assert 轨迹["版本"][1]["content"]["身份定位"] == "主角" # 旧版本不抹
def test_修订影响只标记不撤回独立确认__19a03c(世界环境):
装配, 作者, _ = 世界环境
世界 = 装配.要求故事世界()
# 独立设定:何岚的人物卡(不依赖正文)。
r = 世界.保存实体提案(
作者,
"save-he-1",
work_id="story-work",
object_id="char-he",
expected_revision=0,
schema_id="character",
schema_version=1,
content={"身份定位": "重要配角"},
)
确认提案(装配, 作者, r, "char-he")
# 林深先确认 r1,再做未决的 r2 修订提案。
r1 = 世界.保存实体提案(
作者,
"save-lin-1",
work_id="story-work",
object_id="char-lin",
expected_revision=0,
schema_id="character",
schema_version=1,
content={"身份定位": "主角"},
)
确认提案(装配, 作者, r1, "char-lin")
世界.保存实体提案(
作者,
"save-lin-2",
work_id="story-work",
object_id="char-lin",
expected_revision=1,
schema_id="character",
schema_version=1,
content={"身份定位": "重要配角"},
)
# 何岚无未决修订;林深的评估列出自己的待决 r2。
影响 = 世界.评估修订影响(作者, "story-work", "char-he")
assert 影响["object_id"] == "char-he" and 影响["本对象待决提案"] == []
影响2 = 世界.评估修订影响(作者, "story-work", "char-lin")
assert any(p["object_id"] == "char-lin" for p in 影响2["本对象待决提案"])
# 独立确认不受影响标记撤回:何岚仍可用。
with 装配.数据库.连接(只读=True) as 连:
from muse.故事世界.世界设定 import 列出实体
实体 = {e["object_id"]: e for e in 列出实体(连, "author", "story-work")}
assert 实体["char-he"]["依据可用"]
def test_章序变化影响列出叙事位置事实__19a04d(世界环境):
装配, 作者, _ = 世界环境
世界 = 装配.要求故事世界()
依据 = (来源(装配, 作者, "chapter-2", "何岚知道林深已经登船"),)
结构 = 世界.新事实结构(作者, "story-work", "char-lin", "character", 1)
请求 = 事实提案请求(
"story-work",
"char-lin",
0,
结构,
{"身份定位": "主角"},
依据,
"chapter-2",
planned_window=叙事窗口("chapter-2", "chapter-3"),
)
回执 = 世界.提出事实(作者, "propose-lin", 请求)
确认提案(装配, 作者, 回执, "char-lin")
新章序 = {"chapter-1": 1, "chapter-2": 3, "chapter-3": 2}
with 装配.数据库.连接(只读=True) as 连:
from muse.故事世界.设定修订 import 评估修订影响
影响 = 评估修订影响(连, "author", "story-work", "char-lin", 新章序=新章序)
涉及对象 = {项["object_id"]: 项["涉及章"] for 项 in 影响["章序影响"]}
assert "char-lin" in 涉及对象 and "chapter-2" in 涉及对象["char-lin"]
def test_人物弧线计划与已发生分开__19b01e(世界环境):
装配, 作者, _ = 世界环境
世界 = 装配.要求故事世界()
r1 = 世界.保存实体提案(
作者,
"arc-plan-1",
work_id="story-work",
object_id="char-gu",
expected_revision=0,
schema_id="character",
schema_version=1,
content={"身份定位": "反派"},
knowledge_mode="plan",
)
确认提案(装配, 作者, r1, "char-gu")
r2 = 世界.保存实体提案(
作者,
"arc-fact-1",
work_id="story-work",
object_id="char-gu",
expected_revision=1,
schema_id="character",
schema_version=1,
content={"身份定位": "反派"},
knowledge_mode="fact",
)
确认提案(装配, 作者, r2, "char-gu", expected=1)
弧线 = 世界.人物弧线(作者, "story-work", "char-gu")
assert [v["knowledge_mode"] for v in 弧线["计划"]] == ["plan"]
assert [v["knowledge_mode"] for v in 弧线["已发生"]] == ["fact"]
def test_关系端点解析为本书对象__19b02f(世界环境):
装配, 作者, _ = 世界环境
世界 = 装配.要求故事世界()
for 对象 in ("char-lin", "char-he"):
r = 世界.保存实体提案(
作者,
f"rel-{对象}",
work_id="story-work",
object_id=对象,
expected_revision=0,
schema_id="character",
schema_version=1,
content={"身份定位": "主角"},
)
确认提案(装配, 作者, r, 对象)
依据 = (来源(装配, 作者, "chapter-1", "林深停在码头,何岚站在船旁"),)
结构 = 世界.新事实结构(作者, "story-work", "rel-1", "character_relation", 1, 家族="relation")
请求 = 事实提案请求(
"story-work",
"rel-1",
0,
结构,
{"甲方": "char-lin", "乙方": "char-he", "关系类型": "同船"},
依据,
"chapter-1",
instance_family="relation",
)
回执 = 世界.提出事实(作者, "propose-rel", 请求)
确认提案(装配, 作者, 回执, "rel-1")
视图 = 世界.人物关系视图(作者, "story-work")
关系 = [r for r in 视图["关系"] if r["object_id"] == "rel-1"]
assert len(关系) == 1
端点集 = {端["object_id"] for 端 in 关系[0]["端点"]}
assert 端点集 == {"char-lin", "char-he"}
# 端点必须是已解析的本书对象:不存在的端点在提案校验即被拒。
坏请求 = 事实提案请求(
"story-work",
"rel-2",
0,
结构,
{"甲方": "char-none", "乙方": "char-he", "关系类型": "无据"},
依据,
"chapter-1",
instance_family="relation",
)
with pytest.raises(Muse错误):
世界.提出事实(作者, "propose-bad-rel", 坏请求)

View File

@ -0,0 +1,5 @@
/** 故事世界页面出口:世界资料、人物关系与时间伏笔。 */
export { Ui世界资料页 } from "./世界资料页";
export { Ui人物关系页 } from "./人物关系页";
export { Ui时间与伏笔页 } from "./时间与伏笔页";
export { Ui事实审阅 } from "./事实审阅";

View File

@ -0,0 +1,102 @@
/** 世界资料页:实体分族浏览、版本轨迹与修订影响评估。 */
import { useState } from "react";
import { useMuse世界实体, useMuse世界类型, useMuse实体轨迹, useMuse影响评估 } from "./数据";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { useMuse作者会话 } from "../../应用/作者会话";
export function Ui世界资料页({ workId }: { workId: string }) {
const 会话 = useMuse作者会话();
const scope = 会话.data ? "session" : null;
const 实体 = useMuse世界实体(scope, workId);
const 类型 = useMuse世界类型(scope);
const [当前, set当前] = useState<string | null>(null);
const 轨迹 = useMuse实体轨迹(scope, workId, 当前);
const 影响 = useMuse影响评估(scope, workId, 当前);
const 类型名 = (id: string) => 类型.data?.find((t) => t.type_id === id)?.名称 ?? id;
return (
<main className="content">
<p className="eyebrow">故事世界</p>
<h1>世界资料</h1>
<p className="muted">
实体按 S04 登记类型分族;计划与已发生分开标记;修订前先看影响面。
</p>
<section className="paper-panel" aria-label="实体清单">
<div className="panel-heading">
<h2>实体清单</h2>
<span className="muted">{实体.data?.length ?? 0} 个对象</span>
</div>
{实体.isPending ? (
<Ui反馈状态 message="正在读取实体…" />
) : (实体.data?.length ?? 0) === 0 ? (
<p className="muted">本书还没有世界实体;抽取提案确认后这里可见。</p>
) : (
<ul className="event-list">
{实体.data!.map((项) => (
<li key={项.object_id}>
<button
className="text-button"
onClick={() => {
set当前(项.object_id);
}}
>
{类型名(项.type_id)} · {项.object_id.slice(0, 10)} · r{项.revision}
</button>
<span className="muted">
{项.knowledge_mode === "fact" || 项.knowledge_mode === "setting"
? "已发生"
: "计划"}
{项.independent ? " · 独立设定" : ""}
{项.依据可用 ? "" : " · 依据待复核"}
</span>
</li>
))}
</ul>
)}
{实体.error && <Ui反馈状态 message={实体.error.message} error />}
</section>
{当前 && (
<>
<section className="paper-panel" aria-label="版本轨迹">
<div className="panel-heading">
<h2>版本轨迹</h2>
<span className="muted">{当前.slice(0, 10)}</span>
</div>
{轨迹.isPending ? (
<Ui反馈状态 message="正在读取轨迹…" />
) : (轨迹.data?.版本.length ?? 0) === 0 ? (
<p className="muted">暂无版本。</p>
) : (
<ol className="event-list">
{轨迹.data!.版本.map((v) => (
<li key={v.revision}>
r{v.revision} · {v.knowledge_mode}
{v.independent ? " · 独立" : ""}
{v.依据.length > 0 && ` · 依据 ${v.依据.length} 份`}
</li>
))}
</ol>
)}
</section>
<section className="paper-panel" aria-label="修订影响">
<div className="panel-heading">
<h2>修订影响</h2>
<span className="muted">清单只标记复核,不撤回独立确认</span>
</div>
{影响.isPending ? (
<Ui反馈状态 message="正在评估影响…" />
) : 影响.data ? (
<ul className="event-list">
<li>受影响事实:{影响.data.受影响事实.length} 个</li>
<li>本对象待决提案:{影响.data.本对象待决提案.length} 个</li>
</ul>
) : (
<p className="muted">暂无评估。</p>
)}
</section>
</>
)}
</main>
);
}

View File

@ -0,0 +1,79 @@
/** 事实审阅:待决提案清单与作者决定入口(复用既有提案审阅端点)。 */
import { useState } from "react";
import { useMuse作者会话 } from "../../应用/作者会话";
export function Ui事实审阅({ workId }: { workId: string }) {
const 会话 = useMuse作者会话();
const scope = 会话.data ? "session" : null;
const [proposalId, setProposalId] = useState("");
const [消息, set消息] = useState("");
void scope;
void workId;
async function 打开审阅() {
if (!proposalId.trim()) {
set消息("先填写提案编号。");
return;
}
try {
const { 客户端 } = await import("../../接口/生成/客户端");
const 定位 = await 客户端.GET("/api/v1/fact-proposals/{proposal_id}", {
params: { path: { proposal_id: proposalId.trim() } },
});
const 提案 = 定位.data as unknown as {
proposal_id: string;
revision: number;
proposal_hash: string;
object_id: string;
expected_revision: number;
} | undefined;
if (!提案) throw new Error("提案读取失败");
const 审阅 = await 客户端.POST("/api/v1/fact-proposals/{proposal_id}/reviews", {
params: { path: { proposal_id: 提案.proposal_id } },
body: {
proposal_id: 提案.proposal_id,
proposal_revision: 提案.revision,
proposal_hash: 提案.proposal_hash,
target_ref: `world:${提案.object_id}`,
expected_revision: 提案.expected_revision,
},
});
if (!审阅.data) throw new Error("审阅打开失败");
set消息(`审阅已打开:${(审阅.data as unknown as { review_id: string }).review_id}`);
} catch (e) {
set消息(e instanceof Error ? e.message : "审阅打开失败。");
}
}
return (
<section className="paper-panel" aria-label="事实审阅">
<div className="panel-heading">
<h2>事实审阅</h2>
<span className="muted">提案经作者确认后才成为正式事实</span>
</div>
<form
className="structure-form"
onSubmit={(e) => {
e.preventDefault();
void 打开审阅();
}}
>
<label>
提案编号
<input
name="proposal_id"
placeholder="待审事实提案"
value={proposalId}
onChange={(e) => setProposalId(e.target.value)}
/>
</label>
<button type="submit">打开审阅</button>
</form>
{消息 && (
<p role="status" className="error-text">
{消息}
</p>
)}
</section>
);
}

View File

@ -0,0 +1,115 @@
/** 人物关系页:人物分组、端点已解析的关系与人物弧线(计划/已发生分开)。 */
import { useState } from "react";
import { useMuse人物关系, useMuse人物弧线 } from "./数据";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { useMuse作者会话 } from "../../应用/作者会话";
import { Ui事实审阅 } from "./事实审阅";
import { Ui关系图 } from "../../界面/关系图";
export function Ui人物关系页({ workId }: { workId: string }) {
const 会话 = useMuse作者会话();
const scope = 会话.data ? "session" : null;
const 视图 = useMuse人物关系(scope, workId);
const [人物, set人物] = useState<string | null>(null);
const 弧线 = useMuse人物弧线(scope, workId, 人物);
return (
<main className="content">
<p className="eyebrow">故事世界</p>
<h1>人物与关系</h1>
<p className="muted">
人物按类型分组;关系端点是已解析的本书对象;弧线把计划与已发生分开。
</p>
<section className="paper-panel" aria-label="人物分组">
<div className="panel-heading">
<h2>人物分组</h2>
<span className="muted">{视图.data?.人物分组.length ?? 0} 个类型</span>
</div>
{视图.isPending ? (
<Ui反馈状态 message="正在读取人物…" />
) : (视图.data?.人物分组.length ?? 0) === 0 ? (
<p className="muted">本书还没有世界实体。</p>
) : (
视图.data!.人物分组.map((组) => (
<div key={组.type_id} aria-label={`类型 ${组.type_id}`}>
<h3>
{组.type_id}({组.数量})
</h3>
<ul className="event-list">
{组.对象.map((对象) => (
<li key={对象.object_id}>
<button className="text-button" onClick={() => set人物(对象.object_id)}>
{对象.object_id.slice(0, 12)} · r{对象.revision}
</button>
<span className="muted">
{对象.knowledge_mode === "fact" || 对象.knowledge_mode === "setting"
? "已发生"
: "计划"}
</span>
</li>
))}
</ul>
</div>
))
)}
</section>
<section className="paper-panel" aria-label="关系清单">
<div className="panel-heading">
<h2>关系清单</h2>
<span className="muted">{视图.data?.关系.length ?? 0} 条</span>
</div>
{(视图.data?.关系.length ?? 0) === 0 ? (
<p className="muted">还没有关系对象。</p>
) : (
<>
<ul className="event-list">
{视图.data!.关系.map((关系) => (
<li key={关系.object_id}>
{关系.type_id} ·{" "}
{关系.端点.map((端) => `${端.type_id}:${端.object_id.slice(0, 8)}`).join(" ↔ ")}
</li>
))}
</ul>
<Ui关系图 关系={视图.data!.关系} />
</>
)}
</section>
{人物 && (
<section className="paper-panel" aria-label="人物弧线">
<div className="panel-heading">
<h2>人物弧线</h2>
<span className="muted">{人物.slice(0, 12)}</span>
</div>
{弧线.isPending ? (
<Ui反馈状态 message="正在读取弧线…" />
) : 弧线.data ? (
<>
<h3>已发生({弧线.data.已发生.length})</h3>
<ol className="event-list">
{弧线.data.已发生.map((v) => (
<li key={v.revision}>
r{v.revision} · {v.knowledge_mode}
</li>
))}
</ol>
<h3>计划与猜测({弧线.data.计划.length})</h3>
<ol className="event-list">
{弧线.data.计划.map((v) => (
<li key={v.revision}>
r{v.revision} · {v.knowledge_mode}
</li>
))}
</ol>
</>
) : (
<p className="muted">暂无弧线。</p>
)}
</section>
)}
<Ui事实审阅 workId={workId} />
</main>
);
}

View File

@ -0,0 +1,140 @@
/** 故事世界数据接入:实体浏览、轨迹、关系、弧线与影响评估。 */
import { useQuery } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { 查询键 } from "../../接口/查询键";
export interface 世界实体 {
object_id: string;
type_id: string;
revision: number;
knowledge_mode: string;
independent: boolean;
依据可用: boolean;
生效位置: string | null;
计划窗口: { from_chapter_id: string; to_chapter_id: string } | null;
content: Record<string, unknown>;
}
export interface 实体轨迹 {
object_id: string;
type_id: string;
instance_family: string;
current_revision: number;
版本: {
revision: number;
system_revision: number;
knowledge_mode: string;
independent: boolean;
content_hash: string;
content: Record<string, unknown>;
依据: { document_id: string; revision: number }[];
}[];
}
export interface 关系行 {
object_id: string;
type_id: string;
revision: number;
knowledge_mode: string;
content: Record<string, unknown>;
端点: { object_id: string; type_id: string; revision: number }[];
}
export interface 影响评估 {
object_id: string;
current_revision: number;
受影响事实: string[];
本对象待决提案: { proposal_id: string; object_id: string }[];
章序影响: { object_id: string; 涉及章: string[] }[];
复核说明: string;
}
export function useMuse世界类型(scope: string | null) {
return useQuery({
queryKey: 查询键(scope, "world:types"),
enabled: !!scope,
queryFn: async ({ signal }) =>
(await 客户端.GET("/api/v1/world/types", { signal, params: {} }))
.data as unknown as {
type_id: string;
名称: string;
语义域: string;
实例族: string[];
说明: string;
}[],
});
}
export function useMuse世界实体(scope: string | null, workId: string | null) {
return useQuery({
queryKey: 查询键(scope, `${workId}:world:entities`),
enabled: !!workId,
queryFn: async ({ signal }) =>
(await 客户端.GET("/api/v1/works/{work_id}/world/entities", {
signal,
params: { path: { work_id: workId! } },
})).data as unknown as 世界实体[],
});
}
export function useMuse实体轨迹(scope: string | null, workId: string | null, objectId: string | null) {
return useQuery({
queryKey: 查询键(scope, `${workId}:world:${objectId}:timeline`),
enabled: !!workId && !!objectId,
queryFn: async ({ signal }) =>
((await 客户端.GET("/api/v1/works/{work_id}/world/entities/{object_id}/timeline", {
signal,
params: { path: { work_id: workId!, object_id: objectId! } },
})).data ?? null) as 实体轨迹 | null,
});
}
export function useMuse影响评估(scope: string | null, workId: string | null, objectId: string | null) {
return useQuery({
queryKey: 查询键(scope, `${workId}:world:${objectId}:impact`),
enabled: !!workId && !!objectId,
queryFn: async ({ signal }) =>
((await 客户端.GET("/api/v1/works/{work_id}/world/entities/{object_id}/impact", {
signal,
params: { path: { work_id: workId!, object_id: objectId! } },
})).data ?? null) as 影响评估 | null,
});
}
export function useMuse人物关系(scope: string | null, workId: string | null) {
return useQuery({
queryKey: 查询键(scope, `${workId}:world:relations`),
enabled: !!workId,
queryFn: async ({ signal }) =>
((await 客户端.GET("/api/v1/works/{work_id}/world/relations", {
signal,
params: { path: { work_id: workId! } },
})).data ?? null) as unknown as {
人物分组: { type_id: string; 数量: number; 对象: 世界实体[] }[];
关系: 关系行[];
} | null,
});
}
export function useMuse人物弧线(
scope: string | null,
workId: string | null,
characterId: string | null,
) {
return useQuery({
queryKey: 查询键(scope, `${workId}:world:${characterId}:arc`),
enabled: !!workId && !!characterId,
queryFn: async ({ signal }) =>
((await 客户端.GET("/api/v1/works/{work_id}/world/characters/{character_id}", {
signal,
params: { path: { work_id: workId!, character_id: characterId! } },
})).data ?? null) as unknown as {
object_id: string;
type_id: string;
current_revision: number;
计划: { revision: number; knowledge_mode: string; content: Record<string, unknown> }[];
已发生: { revision: number; knowledge_mode: string; content: Record<string, unknown> }[];
说明: string;
} | null,
});
}

View File

@ -0,0 +1,114 @@
/** 时间与伏笔页:时点世界查询(作者总览/读者进度/角色知情)与计划窗口呈现。 */
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { 客户端 } from "../../接口/生成/客户端";
import { 查询键 } from "../../接口/查询键";
import { Ui反馈状态 } from "../../界面/反馈状态";
import { useMuse作者会话 } from "../../应用/作者会话";
export function Ui时间与伏笔页({ workId }: { workId: string }) {
const 会话 = useMuse作者会话();
const scope = 会话.data ? "session" : null;
const [截止, set截止] = useState("");
const [视角, set视角] = useState<"author" | "reader" | "actor">("author");
const [知情主体, set知情主体] = useState("");
const 世界 = useQuery({
queryKey: 查询键(scope, `${workId}:world-state:${截止}:${视角}:${知情主体}`),
enabled: !!scope && !!workId,
queryFn: async ({ signal }) =>
((await 客户端.GET("/api/v1/works/{work_id}/facts", {
signal,
params: {
path: { work_id: workId },
query: {
as_of: 截止 || undefined,
view: (视角 === "actor" ? "character" : 视角) as
| "author"
| "reader"
| "character",
character_id: 知情主体 || undefined,
},
},
})).data ?? null) as unknown as {
objects?: {
object_id: string;
type_id: string;
knowledge_mode?: string;
planned_window?: { from_chapter_id: string; to_chapter_id: string } | null;
}[];
省略原因?: Record<string, string>;
} | null,
});
const 对象 = 世界.data?.objects ?? [];
const 伏笔 = 对象.filter((o) => o.planned_window);
return (
<main className="content">
<p className="eyebrow">故事世界</p>
<h1>时间与伏笔</h1>
<p className="muted">
按叙事时点查询世界状态:作者总览、读者进度与指定角色知情分别呈现;计划窗口
(预期伏笔)只按稳定章节身份显示。
</p>
<section className="paper-panel" aria-label="时点查询">
<form
className="structure-form"
onSubmit={(e) => {
e.preventDefault();
const 值 = new FormData(e.currentTarget);
set截止(String(值.get("as_of") ?? ""));
set视角(String(值.get("viewpoint") ?? "author") as "author" | "reader" | "actor");
set知情主体(String(值.get("actor") ?? ""));
}}
>
<label>
截止章节(留空为当前)
<input name="as_of" placeholder="章节 ID" />
</label>
<label>
视角
<select name="viewpoint" defaultValue="author">
<option value="author">作者总览</option>
<option value="reader">读者进度</option>
<option value="actor">指定角色</option>
</select>
</label>
<label>
知情主体(视角=角色时)
<input name="actor" placeholder="角色对象 ID" />
</label>
<button type="submit">查询时点世界</button>
</form>
{世界.isFetching ? (
<Ui反馈状态 message="正在查询…" />
) : (
<p className="muted">
时点对象 {对象.length} 个;其中带计划窗口 {伏笔.length} 个。
</p>
)}
</section>
<section className="paper-panel" aria-label="伏笔与承诺">
<div className="panel-heading">
<h2>伏笔与承诺</h2>
<span className="muted">{伏笔.length} 条计划窗口</span>
</div>
{伏笔.length === 0 ? (
<p className="muted">当前时点没有带计划窗口的对象。</p>
) : (
<ul className="event-list">
{伏笔.map((项) => (
<li key={项.object_id}>
{项.type_id} · {项.object_id.slice(0, 10)} · 计划{" "}
{项.planned_window!.from_chapter_id.slice(0, 8)} →{" "}
{项.planned_window!.to_chapter_id.slice(0, 8)}
</li>
))}
</ul>
)}
</section>
</main>
);
}

View File

@ -9,6 +9,7 @@ import {
} from "../功能/作品规划";
import { Ui候选审阅页, Ui正文页 } from "../功能/正文写作";
import { Ui资料页, Ui拆书工作区, Ui榜单与选题页 } from "../功能/资料研究";
import { Ui世界资料页, Ui人物关系页, Ui时间与伏笔页 } from "../功能/故事世界";
import { 客户端 } from "../接口/生成/客户端";
export function Ui页面路由() {
@ -24,6 +25,9 @@ export function Ui页面路由() {
element={<Ui候选审阅页 />}
/>
<Route path="/tasks" element={<Ui任务列表 />} />
<Route path="/works/:work_id/world" element={<Ui世界资料页壳 />} />
<Route path="/works/:work_id/world/relations" element={<Ui人物关系页壳 />} />
<Route path="/works/:work_id/world/timeline" element={<Ui时间与伏笔页Shell />} />
<Route path="/sources" element={<Ui资料页 />} />
<Route path="/research" element={<Ui拆书工作区 />} />
<Route path="/rankings" element={<Ui榜单与选题页 />} />
@ -61,3 +65,24 @@ function Ui连接状态() {
</main>
);
}
function 取路径参数(name: string): string | undefined {
const 值 = window.location.pathname.split("/").filter(Boolean);
const 索引 = 值.indexOf(name === "work_id" ? "works" : name);
return 索引 >= 0 && 值[索引 + 1] ? decodeURIComponent(值[索引 + 1]) : undefined;
}
function Ui世界资料页壳() {
const id = 取路径参数("work_id") ?? "";
return <Ui世界资料页 workId={id} />;
}
function Ui人物关系页壳() {
const id = 取路径参数("work_id") ?? "";
return <Ui人物关系页 workId={id} />;
}
function Ui时间与伏笔页Shell() {
const id = 取路径参数("work_id") ?? "";
return <Ui时间与伏笔页 workId={id} />;
}

View File

@ -1085,6 +1085,109 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/world/types": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 世界类型清单 */
get: operations["list_world_types"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/works/{work_id}/world/entities": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 列出世界实体 */
get: operations["list_world_entities"];
put?: never;
/** 保存世界实体 */
post: operations["save_world_entity"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/works/{work_id}/world/entities/{object_id}/timeline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 读取实体轨迹 */
get: operations["read_entity_timeline"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/works/{work_id}/world/entities/{object_id}/impact": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 评估修订影响 */
get: operations["assess_world_revision"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/works/{work_id}/world/relations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 人物关系视图 */
get: operations["list_world_relations"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/works/{work_id}/world/characters/{character_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** 人物弧线 */
get: operations["read_character_arc"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{task_id}/events": {
parameters: {
query?: never;
@ -1477,8 +1580,13 @@ export interface components {
/** To Chapter Id */
to_chapter_id: string;
};
/** 节点条目 */
/** 创作命令 */
muse__________________16: {
/** Command Id */
command_id: string;
};
/** 节点条目 */
muse__________________17: {
/** Node Id */
node_id: string;
/**
@ -1494,7 +1602,7 @@ export interface components {
position: number;
};
/** 作品概要 */
muse__________________17: {
muse__________________18: {
/** Work Id */
work_id: string;
/** Revision */
@ -1505,7 +1613,7 @@ export interface components {
};
};
/** 章节条目 */
muse__________________18: {
muse__________________19: {
/** Chapter Id */
chapter_id: string;
/** Title */
@ -1513,23 +1621,23 @@ export interface components {
/** Position */
position: number;
};
/** 目录结果 */
muse__________________19: {
/** Work Id */
work_id: string;
/** Revision */
revision: number;
/** Chapters */
chapters: components["schemas"]["muse__________________18"][];
};
/**
* 内容用途
* @description 内容消费用途。
* @enum {string}
*/
muse__________________2: "planning" | "generation" | "detection" | "extraction";
/** 规划结果 */
/** 目录结果 */
muse__________________20: {
/** Work Id */
work_id: string;
/** Revision */
revision: number;
/** Chapters */
chapters: components["schemas"]["muse__________________19"][];
};
/** 规划结果 */
muse__________________21: {
/** Plan Id */
plan_id: string;
/** Work Id */
@ -1969,7 +2077,7 @@ export interface components {
/** Revision */
revision: number;
/** Nodes */
nodes: components["schemas"]["muse__________________16"][];
nodes: components["schemas"]["muse__________________17"][];
};
/** 正式回执结果 */
muse____________________25: {
@ -2158,7 +2266,7 @@ export interface components {
[key: string]: unknown;
};
candidate: components["schemas"]["muse____________________34"];
before: components["schemas"]["muse__________________20"] | null;
before: components["schemas"]["muse__________________21"] | null;
};
/** 规划表单结果 */
muse____________________36: {
@ -3476,7 +3584,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["muse__________________17"][];
"application/json": components["schemas"]["muse__________________18"][];
};
};
};
@ -3567,7 +3675,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["muse__________________19"];
"application/json": components["schemas"]["muse__________________20"];
};
};
/** @description Validation Error */
@ -3889,7 +3997,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["muse__________________20"][];
"application/json": components["schemas"]["muse__________________21"][];
};
};
/** @description Validation Error */
@ -4054,7 +4162,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["muse__________________20"];
"application/json": components["schemas"]["muse__________________21"];
};
};
/** @description Validation Error */
@ -4156,7 +4264,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["muse__________________20"];
"application/json": components["schemas"]["muse__________________21"];
};
};
/** @description Validation Error */
@ -5255,6 +5363,231 @@ export interface operations {
};
};
};
list_world_types: {
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;
}[];
};
};
};
};
list_world_entities: {
parameters: {
query?: never;
header?: never;
path: {
work_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
}[];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
save_world_entity: {
parameters: {
query?: never;
header?: never;
path: {
work_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["muse__________________16"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["muse____________________25"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
read_entity_timeline: {
parameters: {
query?: never;
header?: never;
path: {
work_id: string;
object_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
assess_world_revision: {
parameters: {
query?: never;
header?: never;
path: {
work_id: string;
object_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_world_relations: {
parameters: {
query?: never;
header?: never;
path: {
work_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
read_character_arc: {
parameters: {
query?: never;
header?: never;
path: {
work_id: string;
character_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
read_task_events: {
parameters: {
query?: {

View File

@ -0,0 +1,25 @@
/** 关系列系图:关系端点的轻量邻接列表呈现(不引第三方图形库)。 */
import type { 关系行 } from "../功能/故事世界/数据";
export function Ui关系图({ 关系 }: { 关系: 关系行[] }) {
if (关系.length === 0) return <p className="muted">还没有关系可呈现。</p>;
const 邻接: Record<string, string[]> = {};
for (const 行 of 关系) {
const 端 = 行.端点.map((e) => e.object_id);
for (let i = 0; i < 端.length; i += 1) {
for (let j = i + 1; j < 端.length; j += 1) {
(邻接[端[i]] ??= []).push(`${行.type_id}→${端[j]}`);
(邻接[端[j]] ??= []).push(`${行.type_id}→${端[i]}`);
}
}
}
return (
<div aria-label="关系邻接" className="event-list">
{Object.entries(邻接).map(([对象, 边]) => (
<p key={对象} className="mono">
{对象.slice(0, 10)}:{边.join("、")}
</p>
))}
</div>
);
}

View File

@ -0,0 +1,55 @@
/** 真实浏览器与隔离库:世界资料页、人物关系页与时间伏笔页的维护旅程。 */
import { readFileSync } from "node:fs";
import { expect, test } from "@playwright/test";
test.use({ trace: "off" });
test("NC-browser-world-workbench:世界维护三页旅程", async ({ page }, info) => {
if (!process.env.MUSE_WORKBENCH_URL || !process.env.MUSE_AUTHOR_PASSWORD_FILE)
throw new Error("需要明确的隔离后端和作者口令文件。");
const workId = process.env.MUSE_WORK_ID!;
if (!workId) throw new Error("需要预置作品编号。");
const consoleErrors: string[] = [];
page.on("pageerror", (error) => consoleErrors.push(String(error)));
await page.goto("/");
await page
.getByLabel("访问口令")
.fill(readFileSync(process.env.MUSE_AUTHOR_PASSWORD_FILE, "utf8").trim());
await page.getByRole("button", { name: "进入工作台" }).click();
// 世界资料页:实体清单出现已确认人物;点开见版本轨迹与影响区。
await page.goto(`/works/${workId}/world`);
const 清单 = page.locator('section[aria-label="实体清单"]');
await expect(清单.getByText(/2 个对象/)).toBeVisible({ timeout: 15_000 });
await 清单.getByRole("button", { name: /char-lin/ }).first().click();
await expect(page.locator('section[aria-label="版本轨迹"]').getByText(/r1/)).toBeVisible();
await expect(
page.locator('section[aria-label="修订影响"]').getByText(/受影响事实:\d+ 个/),
).toBeVisible();
await page.screenshot({ path: `${info.outputDir}/世界资料页.png`, fullPage: true });
// 人物关系页:人物分组可见;弧线按已发生/计划分区。
await page.goto(`/works/${workId}/world/relations`);
await expect(page.locator('section[aria-label="人物分组"]').getByText(/character/)).toBeVisible();
await page
.locator('section[aria-label="人物分组"]')
.getByRole("button", { name: /char-lin/ })
.first()
.click();
await expect(
page.locator('section[aria-label="人物弧线"]').getByText(/已发生(\d+)/),
).toBeVisible();
await page.screenshot({ path: `${info.outputDir}/人物关系页.png`, fullPage: true });
// 时间与伏笔页:时点查询表单与说明可见。
await page.goto(`/works/${workId}/world/timeline`);
await expect(page.getByRole("button", { name: "查询时点世界" })).toBeVisible();
await expect(page.locator('section[aria-label="伏笔与承诺"]')).toBeVisible();
await page.screenshot({ path: `${info.outputDir}/时间与伏笔页.png`, fullPage: true });
expect(
consoleErrors.filter((文本) => !文本.includes("favicon")),
"页面不得出现控制台错误",
).toEqual([]);
});