From 88dd57079173859398c19f8a9e6d5a644c8b92f2 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 11 Sep 2026 11:30:20 +0800 Subject: [PATCH] =?UTF-8?q?W19=20=E4=B8=96=E7=95=8C=E8=AE=BE=E5=AE=9A?= =?UTF-8?q?=E3=80=81=E4=BA=BA=E7=89=A9=E5=BC=A7=E7=BA=BF=E4=B8=8E=E5=85=A8?= =?UTF-8?q?=E4=B9=A6=E8=BF=9E=E7=BB=AD=E6=80=A7=EF=BC=88=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E4=B8=96=E7=95=8C=E8=AE=BE=E5=AE=9A=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E5=B1=82=EF=BC=88S04=20=E6=97=8F=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E6=B5=8F=E8=A7=88/=E8=BD=A8=E8=BF=B9/save=5F?= =?UTF-8?q?entity=20=E6=8F=90=E6=A1=88=E2=86=92=E7=A1=AE=E8=AE=A4=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E7=B1=BB=E5=9E=8B=E9=9B=B6=E4=BB=A3=E7=A0=81=E5=8F=AF?= =?UTF-8?q?=E7=BC=96=E8=BE=91=EF=BC=89=E3=80=81=E4=BA=BA=E7=89=A9=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=EF=BC=88=E7=AB=AF=E7=82=B9=E6=8C=89=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E8=A7=84=E5=88=99=E8=A7=A3=E6=9E=90=E3=80=81=E5=BC=A7=E7=BA=BF?= =?UTF-8?q?=E8=AE=A1=E5=88=92/=E5=B7=B2=E5=8F=91=E7=94=9F=E5=88=86?= =?UTF-8?q?=E5=8C=BA=EF=BC=89=E3=80=81=E8=AE=BE=E5=AE=9A=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=E8=AF=84=E4=BC=B0=EF=BC=88=E5=8F=97=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E4=BA=8B=E5=AE=9E=E5=BC=95=E7=94=A8=E5=8F=8D=E6=9F=A5?= =?UTF-8?q?+=E5=BE=85=E5=86=B3=E6=8F=90=E6=A1=88+=E7=AB=A0=E5=BA=8F?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=EF=BC=8C=E5=8F=AA=E8=AF=BB=E5=8F=AA=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E4=B8=8D=E6=92=A4=E5=9B=9E=E7=8B=AC=E7=AB=8B=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=EF=BC=89=EF=BC=9BHTTP=207=20=E7=AB=AF=E7=82=B9+?= =?UTF-8?q?=E6=8A=BD=E5=8F=96=E7=AB=A0=E8=8A=82=E4=BA=8B=E5=AE=9E=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E8=AF=8D+=E7=BB=B4=E6=8A=A4=E6=95=85=E4=BA=8B?= =?UTF-8?q?=E4=B8=96=E7=95=8C=E6=8A=80=E8=83=BD=EF=BC=9B=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E4=B8=89=E9=A1=B5=EF=BC=88=E4=B8=96=E7=95=8C=E8=B5=84?= =?UTF-8?q?=E6=96=99/=E4=BA=BA=E7=89=A9=E5=85=B3=E7=B3=BB+=E5=85=B3?= =?UTF-8?q?=E7=B3=BB=E5=9B=BE/=E6=97=B6=E9=97=B4=E4=B8=8E=E4=BC=8F?= =?UTF-8?q?=E7=AC=94=EF=BC=89=EF=BC=9B7=20=E6=96=B0=20NC=EF=BC=88NC-w19-*?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E7=A6=BB=E7=BA=BF=20275=20/=20=E9=9D=9E?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=95=B0=E6=8D=AE=E5=BA=93=20237=20/=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=2027=EF=BC=8C=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E4=B8=89=E9=A1=B5=E6=97=85=E7=A8=8B+=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E9=80=9A=E8=BF=87=EF=BC=9B=E6=95=B4=E5=8C=85?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E9=80=80=E5=9B=9E=E4=B8=80=E9=A1=B9=EF=BC=88?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E9=A1=B5=E5=B0=BE=E7=A9=BA=E8=A1=8C=E8=87=B4?= =?UTF-8?q?=20diff-check=20=E5=A4=B1=E8=B4=A5=EF=BC=89=E4=B8=89=E8=BD=AE?= =?UTF-8?q?=E5=A4=8D=E9=AA=8C=E9=80=9A=E8=BF=87=EF=BC=88=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=E8=AF=AF=E5=9C=A8=E5=8E=9F=E4=BB=93=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E7=B3=BB=E4=BB=BB=E5=8A=A1=E4=B9=A6=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=EF=BC=8C=E7=AC=AC=E4=B8=89=E8=BD=AE=E5=B8=A6=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8E=E8=AF=81=E6=8D=AE=E9=93=BE=EF=BC=89?= =?UTF-8?q?=EF=BC=8CW19=20=E7=BD=AE=20verified=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/skills/操作/目录.md | 1 + .agent/skills/操作/维护故事世界/SKILL.md | 28 ++ .agent/skills/操作/维护故事世界/目录.md | 3 + docs/接口契约/生成/openapi.generated.json | 387 ++++++++++++++++-- .../新版设计/改造计划/工作包清单.json | 15 +- .../新版设计/文件设计/前端-应用与通用界面.md | 12 +- docs/系统架构/新版设计/文件设计/测试与夹具.md | 9 + docs/系统架构/新版设计/目标文件清单.json | 17 + docs/系统架构/新版设计/项目目录与文件职责.md | 1 + .../新版设计/验证设计/工作台与工程用例.md | 12 + .../新版设计/验证设计/测试用例清单.json | 119 ++++++ .../新版设计/验证设计/资料与结构用例.md | 72 ++++ src/muse/元数据/存储.py | 8 + src/muse/元数据/结构版本.py | 11 + src/muse/接入/http/路由/故事世界.py | 73 ++++ src/muse/接入/创作操作.py | 16 + src/muse/故事世界/世界设定.py | 176 ++++++++ src/muse/故事世界/人物关系.py | 128 ++++++ src/muse/故事世界/存储.py | 34 ++ src/muse/故事世界/接口.py | 101 +++++ src/muse/故事世界/提示词/抽取章节事实.md | 18 + src/muse/故事世界/设定修订.py | 56 +++ tests/集成/test_世界维护与影响.py | 295 +++++++++++++ web/src/功能/故事世界/index.ts | 5 + web/src/功能/故事世界/世界资料页.tsx | 102 +++++ web/src/功能/故事世界/事实审阅.tsx | 79 ++++ web/src/功能/故事世界/人物关系页.tsx | 115 ++++++ web/src/功能/故事世界/数据.ts | 140 +++++++ web/src/功能/故事世界/时间与伏笔页.tsx | 114 ++++++ web/src/应用/路由.tsx | 25 ++ web/src/接口/生成/类型.ts | 373 ++++++++++++++++- web/src/界面/关系图.tsx | 25 ++ web/tests/旅程/世界维护.spec.ts | 55 +++ 33 files changed, 2579 insertions(+), 46 deletions(-) create mode 100644 .agent/skills/操作/维护故事世界/SKILL.md create mode 100644 .agent/skills/操作/维护故事世界/目录.md create mode 100644 src/muse/故事世界/世界设定.py create mode 100644 src/muse/故事世界/人物关系.py create mode 100644 src/muse/故事世界/提示词/抽取章节事实.md create mode 100644 tests/集成/test_世界维护与影响.py create mode 100644 web/src/功能/故事世界/index.ts create mode 100644 web/src/功能/故事世界/世界资料页.tsx create mode 100644 web/src/功能/故事世界/事实审阅.tsx create mode 100644 web/src/功能/故事世界/人物关系页.tsx create mode 100644 web/src/功能/故事世界/数据.ts create mode 100644 web/src/功能/故事世界/时间与伏笔页.tsx create mode 100644 web/src/界面/关系图.tsx create mode 100644 web/tests/旅程/世界维护.spec.ts diff --git a/.agent/skills/操作/目录.md b/.agent/skills/操作/目录.md index 315c6e1..3f5ef3f 100644 --- a/.agent/skills/操作/目录.md +++ b/.agent/skills/操作/目录.md @@ -13,3 +13,4 @@ | 采集资料与榜单 | [采集资料与榜单/SKILL.md](采集资料与榜单/SKILL.md) | 具名榜单与网页的时点采集 | 榜单观察与网页资料留存时 | 失败如实保留;不补造数据、不作效果归因 | | 拆解书稿 | [拆解书稿/SKILL.md](拆解书稿/SKILL.md) | 参考作品分窗拆解的发起与进度 | 研究参考作品章节、实体与写法时 | 同源互斥;失败可恢复;分析不是本书事实 | | 检查与维护拆书结果 | [检查与维护拆书结果/SKILL.md](检查与维护拆书结果/SKILL.md) | 拆书成果的检查、重算与版本维护 | 处理失败窗、歧义与版本对照时 | 歧义不误并;旧结论保留 | +| 维护故事世界 | [维护故事世界/SKILL.md](维护故事世界/SKILL.md) | 世界实体、人物关系与影响评估的维护 | 维护设定、修订前评估、弧线追踪时 | 先审后入;修订不撤回独立确认 | diff --git a/.agent/skills/操作/维护故事世界/SKILL.md b/.agent/skills/操作/维护故事世界/SKILL.md new file mode 100644 index 0000000..275afd5 --- /dev/null +++ b/.agent/skills/操作/维护故事世界/SKILL.md @@ -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 登记即可编辑,不需要改生产代码;不维护固定类型清单。 +- 世界实例只来自本书;资料研究结论不自动转为事实。 +- 独立作者设定不依赖正文;已确认事实的历史决定不被修订撤回。 diff --git a/.agent/skills/操作/维护故事世界/目录.md b/.agent/skills/操作/维护故事世界/目录.md new file mode 100644 index 0000000..cef55b0 --- /dev/null +++ b/.agent/skills/操作/维护故事世界/目录.md @@ -0,0 +1,3 @@ +| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 | +|------|----------|----------|----------|----------| +| 技能说明 | SKILL.md | 世界实体、人物关系与影响评估的维护操作 | 维护设定、修订前评估、弧线追踪时 | 先审后入;修订不撤回独立确认 | diff --git a/docs/接口契约/生成/openapi.generated.json b/docs/接口契约/生成/openapi.generated.json index 00b411d..110d6b0 100644 --- a/docs/接口契约/生成/openapi.generated.json +++ b/docs/接口契约/生成/openapi.generated.json @@ -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" diff --git a/docs/系统架构/新版设计/改造计划/工作包清单.json b/docs/系统架构/新版设计/改造计划/工作包清单.json index 691c5ff..79e0b9c 100644 --- a/docs/系统架构/新版设计/改造计划/工作包清单.json +++ b/docs/系统架构/新版设计/改造计划/工作包清单.json @@ -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", diff --git a/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md b/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md index d105373..0c2f4a5 100644 --- a/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md +++ b/docs/系统架构/新版设计/文件设计/前端-应用与通用界面.md @@ -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 后端;视觉判定独立留证。 - 目标:对照呈现与失败态可复核,页面无预期外控制台错误。 + + + +### `web/tests/旅程/世界维护.spec.ts` + +- 归属与种类:验证;前端浏览器旅程。 +- 意图与职责:真实浏览器与隔离库核对世界资料、人物关系与时间伏笔三页。 +- 边界:不 mock 后端;视觉判定独立留证。 +- 目标:世界维护旅程可复核。 diff --git a/docs/系统架构/新版设计/文件设计/测试与夹具.md b/docs/系统架构/新版设计/文件设计/测试与夹具.md index 99b4a64..af04015 100644 --- a/docs/系统架构/新版设计/文件设计/测试与夹具.md +++ b/docs/系统架构/新版设计/文件设计/测试与夹具.md @@ -1727,3 +1727,12 @@ agent-example/ - 意图与职责:榜单采集成功/失败二态、观察差异与未登记拒绝的隔离数据库验证。 - 边界:合成读取器按剧本回应;不访问网络。 - 目标:失败不伪造、快照可查、差异为观察陈述可复验。 + + + +### `tests/集成/test_世界维护与影响.py` + +- 归属与种类:验证;测试。 +- 意图与职责:世界设定维护、弧线分离与修订影响的隔离数据库验证。 +- 边界:真实隔离 PG;S04 结构经登记注入。 +- 目标:维护与影响行为可复验。 diff --git a/docs/系统架构/新版设计/目标文件清单.json b/docs/系统架构/新版设计/目标文件清单.json index 2543fbd..636d320 100644 --- a/docs/系统架构/新版设计/目标文件清单.json +++ b/docs/系统架构/新版设计/目标文件清单.json @@ -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": { diff --git a/docs/系统架构/新版设计/项目目录与文件职责.md b/docs/系统架构/新版设计/项目目录与文件职责.md index a2c47c7..3bb9df1 100644 --- a/docs/系统架构/新版设计/项目目录与文件职责.md +++ b/docs/系统架构/新版设计/项目目录与文件职责.md @@ -1354,6 +1354,7 @@ agent-example/ │ │ ├── 单元/ │ │ │ └── 中文显示与动作.test.tsx # 作者界面语义的逐例行为验证 │ │ ├── 旅程/ +│ │ │ ├── 世界维护.spec.ts # 世界维护三页真实浏览器旅程 │ │ │ ├── 人工创作.spec.ts # 人工创作HTTP与作者编辑独立用例 │ │ │ ├── 任务材料.spec.ts # 任务材料真实浏览器旅程验证 │ │ │ ├── 候选审阅.spec.ts # 作者候选审阅和正式采纳浏览器旅程 diff --git a/docs/系统架构/新版设计/验证设计/工作台与工程用例.md b/docs/系统架构/新版设计/验证设计/工作台与工程用例.md index a970146..fbc3119 100644 --- a/docs/系统架构/新版设计/验证设计/工作台与工程用例.md +++ b/docs/系统架构/新版设计/验证设计/工作台与工程用例.md @@ -3258,3 +3258,15 @@ - 环境:真实隔离库 + Chrome。 - 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + +## NC-w19-browser · NC-browser-world-workbench:世界维护三页旅程 + +- 位置:`web/tests/旅程/世界维护.spec.ts::NC-browser-world-workbench:世界维护三页旅程`。 +- 给定:隔离浏览器环境与预置作品/实体。 +- 动作:浏览世界资料/人物关系/时间伏笔三页并展开轨迹与影响。 +- 预期:实体与轨迹可见;弧线分区呈现;影响计数可见。 +- 环境:真实隔离库 + Chrome。 +- 合同:[权威定义](../模块设计/B02-故事世界.md)。 + diff --git a/docs/系统架构/新版设计/验证设计/测试用例清单.json b/docs/系统架构/新版设计/验证设计/测试用例清单.json index d2b1194..0c1489c 100644 --- a/docs/系统架构/新版设计/验证设计/测试用例清单.json +++ b/docs/系统架构/新版设计/验证设计/测试用例清单.json @@ -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/回合开发/整包/" } ] } diff --git a/docs/系统架构/新版设计/验证设计/资料与结构用例.md b/docs/系统架构/新版设计/验证设计/资料与结构用例.md index 7843c66..bf98be3 100644 --- a/docs/系统架构/新版设计/验证设计/资料与结构用例.md +++ b/docs/系统架构/新版设计/验证设计/资料与结构用例.md @@ -3877,3 +3877,75 @@ - 环境:真实隔离 PG。 - 合同:[权威定义](../模块设计/B03-资料研究.md)。 + + + +## NC-w19-19a01a · test_新类型零代码经世界设定编辑__19a01a + +- 位置:`tests/集成/test_世界维护与影响.py::test_新类型零代码经世界设定编辑__19a01a`。 +- 给定:隔离数据库与 S04 登记结构。 +- 动作:新类型零代码经世界设定编辑。 +- 预期:行为可复验;先审后入;修订不撤回独立确认。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B02-故事世界.md)。 + + + + +## NC-w19-19a02b · test_实体轨迹版本追查与旧版本保留__19a02b + +- 位置:`tests/集成/test_世界维护与影响.py::test_实体轨迹版本追查与旧版本保留__19a02b`。 +- 给定:隔离数据库与 S04 登记结构。 +- 动作:实体轨迹版本追查与旧版本保留。 +- 预期:行为可复验;先审后入;修订不撤回独立确认。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B02-故事世界.md)。 + + + + +## NC-w19-19a03c · test_修订影响只标记不撤回独立确认__19a03c + +- 位置:`tests/集成/test_世界维护与影响.py::test_修订影响只标记不撤回独立确认__19a03c`。 +- 给定:隔离数据库与 S04 登记结构。 +- 动作:修订影响只标记不撤回独立确认。 +- 预期:行为可复验;先审后入;修订不撤回独立确认。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B02-故事世界.md)。 + + + + +## NC-w19-19a04d · test_章序变化影响列出叙事位置事实__19a04d + +- 位置:`tests/集成/test_世界维护与影响.py::test_章序变化影响列出叙事位置事实__19a04d`。 +- 给定:隔离数据库与 S04 登记结构。 +- 动作:章序变化影响列出叙事位置事实。 +- 预期:行为可复验;先审后入;修订不撤回独立确认。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B02-故事世界.md)。 + + + + +## NC-w19-19b01e · test_人物弧线计划与已发生分开__19b01e + +- 位置:`tests/集成/test_世界维护与影响.py::test_人物弧线计划与已发生分开__19b01e`。 +- 给定:隔离数据库与 S04 登记结构。 +- 动作:人物弧线计划与已发生分开。 +- 预期:行为可复验;先审后入;修订不撤回独立确认。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B02-故事世界.md)。 + + + + +## NC-w19-19b02f · test_关系端点解析为本书对象__19b02f + +- 位置:`tests/集成/test_世界维护与影响.py::test_关系端点解析为本书对象__19b02f`。 +- 给定:隔离数据库与 S04 登记结构。 +- 动作:关系端点解析为本书对象。 +- 预期:行为可复验;先审后入;修订不撤回独立确认。 +- 环境:真实隔离 PG。 +- 合同:[权威定义](../模块设计/B02-故事世界.md)。 + diff --git a/src/muse/元数据/存储.py b/src/muse/元数据/存储.py index f82d0f3..7942ead 100644 --- a/src/muse/元数据/存储.py +++ b/src/muse/元数据/存储.py @@ -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( diff --git a/src/muse/元数据/结构版本.py b/src/muse/元数据/结构版本.py index efdca43..22f50b0 100644 --- a/src/muse/元数据/结构版本.py +++ b/src/muse/元数据/结构版本.py @@ -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(定义["实例族"]) diff --git a/src/muse/接入/http/路由/故事世界.py b/src/muse/接入/http/路由/故事世界.py index d9e40f0..2641cd0 100644 --- a/src/muse/接入/http/路由/故事世界.py +++ b/src/muse/接入/http/路由/故事世界.py @@ -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) diff --git a/src/muse/接入/创作操作.py b/src/muse/接入/创作操作.py index fb5deec..22fa3f0 100644 --- a/src/muse/接入/创作操作.py +++ b/src/muse/接入/创作操作.py @@ -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" diff --git a/src/muse/故事世界/世界设定.py b/src/muse/故事世界/世界设定.py new file mode 100644 index 0000000..d21900f --- /dev/null +++ b/src/muse/故事世界/世界设定.py @@ -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__ = [ + "世界类型清单", + "列出实体", + "读取实体轨迹", + "保存实体提案", + "事实错误", +] diff --git a/src/muse/故事世界/人物关系.py b/src/muse/故事世界/人物关系.py new file mode 100644 index 0000000..2168d92 --- /dev/null +++ b/src/muse/故事世界/人物关系.py @@ -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__ = [ + "列出人物", + "列出关系", + "人物弧线", + "人物关系视图", + "事实错误", +] diff --git a/src/muse/故事世界/存储.py b/src/muse/故事世界/存储.py index e7c4019..1752ff7 100644 --- a/src/muse/故事世界/存储.py +++ b/src/muse/故事世界/存储.py @@ -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 diff --git a/src/muse/故事世界/接口.py b/src/muse/故事世界/接口.py index 8d7ef54..bd4a547 100644 --- a/src/muse/故事世界/接口.py +++ b/src/muse/故事世界/接口.py @@ -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, 身份, diff --git a/src/muse/故事世界/提示词/抽取章节事实.md b/src/muse/故事世界/提示词/抽取章节事实.md new file mode 100644 index 0000000..243a541 --- /dev/null +++ b/src/muse/故事世界/提示词/抽取章节事实.md @@ -0,0 +1,18 @@ +# 抽取章节事实 · 任务提示模板 + +本模板为故事世界的「抽取章节事实」任务提供可版本化表达;角色身份、字段结构、权限与预算从各自权威装配,本模板不重复。章后派生(W16)按本模板语义组装系统提示。 + +## 任务 + +对刚定稿的一章做事实抽取: + +1. **本章摘要**:续写所需的一句话到一段话摘要。 +2. **候选事实**:人物、地点、物品、组织、关系、事件与状态变化;每条附正文原句引文。 +3. **字段结构**:按任务输出合同(type/object_id/quote/content);content 字段以本书绑定的 S04 有效结构为准。 +4. **知情位置**:如某角色在本章首次得知某事实,用 known_to 的 (actor_id, from_chapter_id) 表达,不按事件时间推定。 + +## 合同 + +- 只依据本章正文;不新增正文没有的设定;引文必须是正文字面。 +- 抽取结果一律是待作者确认的提案(Shadow);机械挽回只保留实际出现的名称为「提及」候选。 +- 角色猜测、未来规划用对应知识模式标记,不混入已发生事实。 diff --git a/src/muse/故事世界/设定修订.py b/src/muse/故事世界/设定修订.py index ec0a610..7ae60c7 100644 --- a/src/muse/故事世界/设定修订.py +++ b/src/muse/故事世界/设定修订.py @@ -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"]), + "复核说明": ( + "清单只标记需要复核;独立确认与历史版本不被撤回。" + "规划与正文候选过期读取由提交指针锁强制。" + ), + } diff --git a/tests/集成/test_世界维护与影响.py b/tests/集成/test_世界维护与影响.py new file mode 100644 index 0000000..4498bf3 --- /dev/null +++ b/tests/集成/test_世界维护与影响.py @@ -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", 坏请求) diff --git a/web/src/功能/故事世界/index.ts b/web/src/功能/故事世界/index.ts new file mode 100644 index 0000000..c6097e4 --- /dev/null +++ b/web/src/功能/故事世界/index.ts @@ -0,0 +1,5 @@ +/** 故事世界页面出口:世界资料、人物关系与时间伏笔。 */ +export { Ui世界资料页 } from "./世界资料页"; +export { Ui人物关系页 } from "./人物关系页"; +export { Ui时间与伏笔页 } from "./时间与伏笔页"; +export { Ui事实审阅 } from "./事实审阅"; diff --git a/web/src/功能/故事世界/世界资料页.tsx b/web/src/功能/故事世界/世界资料页.tsx new file mode 100644 index 0000000..7b2c54e --- /dev/null +++ b/web/src/功能/故事世界/世界资料页.tsx @@ -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(null); + const 轨迹 = useMuse实体轨迹(scope, workId, 当前); + const 影响 = useMuse影响评估(scope, workId, 当前); + const 类型名 = (id: string) => 类型.data?.find((t) => t.type_id === id)?.名称 ?? id; + + return ( +
+

故事世界

+

世界资料

+

+ 实体按 S04 登记类型分族;计划与已发生分开标记;修订前先看影响面。 +

+
+
+

实体清单

+ {实体.data?.length ?? 0} 个对象 +
+ {实体.isPending ? ( + + ) : (实体.data?.length ?? 0) === 0 ? ( +

本书还没有世界实体;抽取提案确认后这里可见。

+ ) : ( +
    + {实体.data!.map((项) => ( +
  • + + + {项.knowledge_mode === "fact" || 项.knowledge_mode === "setting" + ? "已发生" + : "计划"} + {项.independent ? " · 独立设定" : ""} + {项.依据可用 ? "" : " · 依据待复核"} + +
  • + ))} +
+ )} + {实体.error && } +
+ + {当前 && ( + <> +
+
+

版本轨迹

+ {当前.slice(0, 10)} +
+ {轨迹.isPending ? ( + + ) : (轨迹.data?.版本.length ?? 0) === 0 ? ( +

暂无版本。

+ ) : ( +
    + {轨迹.data!.版本.map((v) => ( +
  1. + r{v.revision} · {v.knowledge_mode} + {v.independent ? " · 独立" : ""} + {v.依据.length > 0 && ` · 依据 ${v.依据.length} 份`} +
  2. + ))} +
+ )} +
+
+
+

修订影响

+ 清单只标记复核,不撤回独立确认 +
+ {影响.isPending ? ( + + ) : 影响.data ? ( +
    +
  • 受影响事实:{影响.data.受影响事实.length} 个
  • +
  • 本对象待决提案:{影响.data.本对象待决提案.length} 个
  • +
+ ) : ( +

暂无评估。

+ )} +
+ + )} +
+ ); +} diff --git a/web/src/功能/故事世界/事实审阅.tsx b/web/src/功能/故事世界/事实审阅.tsx new file mode 100644 index 0000000..1ee4c0d --- /dev/null +++ b/web/src/功能/故事世界/事实审阅.tsx @@ -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 ( +
+
+

事实审阅

+ 提案经作者确认后才成为正式事实 +
+
{ + e.preventDefault(); + void 打开审阅(); + }} + > + + +
+ {消息 && ( +

+ {消息} +

+ )} +
+ ); +} diff --git a/web/src/功能/故事世界/人物关系页.tsx b/web/src/功能/故事世界/人物关系页.tsx new file mode 100644 index 0000000..dde5e51 --- /dev/null +++ b/web/src/功能/故事世界/人物关系页.tsx @@ -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(null); + const 弧线 = useMuse人物弧线(scope, workId, 人物); + + return ( +
+

故事世界

+

人物与关系

+

+ 人物按类型分组;关系端点是已解析的本书对象;弧线把计划与已发生分开。 +

+
+
+

人物分组

+ {视图.data?.人物分组.length ?? 0} 个类型 +
+ {视图.isPending ? ( + + ) : (视图.data?.人物分组.length ?? 0) === 0 ? ( +

本书还没有世界实体。

+ ) : ( + 视图.data!.人物分组.map((组) => ( +
+

+ {组.type_id}({组.数量}) +

+
    + {组.对象.map((对象) => ( +
  • + + + {对象.knowledge_mode === "fact" || 对象.knowledge_mode === "setting" + ? "已发生" + : "计划"} + +
  • + ))} +
+
+ )) + )} +
+ +
+
+

关系清单

+ {视图.data?.关系.length ?? 0} 条 +
+ {(视图.data?.关系.length ?? 0) === 0 ? ( +

还没有关系对象。

+ ) : ( + <> +
    + {视图.data!.关系.map((关系) => ( +
  • + {关系.type_id} ·{" "} + {关系.端点.map((端) => `${端.type_id}:${端.object_id.slice(0, 8)}`).join(" ↔ ")} +
  • + ))} +
+ + + )} +
+ + {人物 && ( +
+
+

人物弧线

+ {人物.slice(0, 12)} +
+ {弧线.isPending ? ( + + ) : 弧线.data ? ( + <> +

已发生({弧线.data.已发生.length})

+
    + {弧线.data.已发生.map((v) => ( +
  1. + r{v.revision} · {v.knowledge_mode} +
  2. + ))} +
+

计划与猜测({弧线.data.计划.length})

+
    + {弧线.data.计划.map((v) => ( +
  1. + r{v.revision} · {v.knowledge_mode} +
  2. + ))} +
+ + ) : ( +

暂无弧线。

+ )} +
+ )} + + +
+ ); +} diff --git a/web/src/功能/故事世界/数据.ts b/web/src/功能/故事世界/数据.ts new file mode 100644 index 0000000..de0850f --- /dev/null +++ b/web/src/功能/故事世界/数据.ts @@ -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; +} + +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; + 依据: { document_id: string; revision: number }[]; + }[]; +} + +export interface 关系行 { + object_id: string; + type_id: string; + revision: number; + knowledge_mode: string; + content: Record; + 端点: { 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 }[]; + 已发生: { revision: number; knowledge_mode: string; content: Record }[]; + 说明: string; + } | null, + }); +} diff --git a/web/src/功能/故事世界/时间与伏笔页.tsx b/web/src/功能/故事世界/时间与伏笔页.tsx new file mode 100644 index 0000000..8f21dc4 --- /dev/null +++ b/web/src/功能/故事世界/时间与伏笔页.tsx @@ -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; + } | null, + }); + + const 对象 = 世界.data?.objects ?? []; + const 伏笔 = 对象.filter((o) => o.planned_window); + + return ( +
+

故事世界

+

时间与伏笔

+

+ 按叙事时点查询世界状态:作者总览、读者进度与指定角色知情分别呈现;计划窗口 + (预期伏笔)只按稳定章节身份显示。 +

+
+
{ + 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") ?? "")); + }} + > + + + + +
+ {世界.isFetching ? ( + + ) : ( +

+ 时点对象 {对象.length} 个;其中带计划窗口 {伏笔.length} 个。 +

+ )} +
+ +
+
+

伏笔与承诺

+ {伏笔.length} 条计划窗口 +
+ {伏笔.length === 0 ? ( +

当前时点没有带计划窗口的对象。

+ ) : ( +
    + {伏笔.map((项) => ( +
  • + {项.type_id} · {项.object_id.slice(0, 10)} · 计划{" "} + {项.planned_window!.from_chapter_id.slice(0, 8)} →{" "} + {项.planned_window!.to_chapter_id.slice(0, 8)} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/web/src/应用/路由.tsx b/web/src/应用/路由.tsx index b13eaec..792965d 100644 --- a/web/src/应用/路由.tsx +++ b/web/src/应用/路由.tsx @@ -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={} /> } /> + } /> + } /> + } /> } /> } /> } /> @@ -61,3 +65,24 @@ function Ui连接状态() { ); } + +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 ; +} + +function Ui人物关系页壳() { + const id = 取路径参数("work_id") ?? ""; + return ; +} + +function Ui时间与伏笔页Shell() { + const id = 取路径参数("work_id") ?? ""; + return ; +} diff --git a/web/src/接口/生成/类型.ts b/web/src/接口/生成/类型.ts index 028aaf7..5e5c019 100644 --- a/web/src/接口/生成/类型.ts +++ b/web/src/接口/生成/类型.ts @@ -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?: { diff --git a/web/src/界面/关系图.tsx b/web/src/界面/关系图.tsx new file mode 100644 index 0000000..90ad674 --- /dev/null +++ b/web/src/界面/关系图.tsx @@ -0,0 +1,25 @@ +/** 关系列系图:关系端点的轻量邻接列表呈现(不引第三方图形库)。 */ +import type { 关系行 } from "../功能/故事世界/数据"; + +export function Ui关系图({ 关系 }: { 关系: 关系行[] }) { + if (关系.length === 0) return

还没有关系可呈现。

; + const 邻接: Record = {}; + 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 ( +
+ {Object.entries(邻接).map(([对象, 边]) => ( +

+ {对象.slice(0, 10)}:{边.join("、")} +

+ ))} +
+ ); +} diff --git a/web/tests/旅程/世界维护.spec.ts b/web/tests/旅程/世界维护.spec.ts new file mode 100644 index 0000000..d4ea203 --- /dev/null +++ b/web/tests/旅程/世界维护.spec.ts @@ -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([]); +});