From bf39f50b2f1fadba0338102768b9d20353bf57f0 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 25 Jun 2026 12:53:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(qa):=20=E5=AE=9E=E6=96=BD=204=20=E7=BB=84?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=9E=8B=20QA=20=E9=A1=B9=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=88=A0=E9=99=A4/codex=20=E8=A7=92=E8=89=B2?= =?UTF-8?q?/=E5=A4=A7=E7=BA=B2=E5=8D=B7=E8=BF=87=E6=BB=A4/=E6=96=87?= =?UTF-8?q?=E9=A3=8E=E5=9B=9E=E7=82=89=E9=94=9A=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 规则 DELETE + id:暴露 RuleView.id(PK);新增 DELETE /projects/{id}/rules/{rule_id} (项目/规则不存在→404,成功→204,按 (id,project_id) 限定);rule_repo 加 list_for_project/delete;RulesPage 每条加删除(乐观删+回滚+toast)。assemble 侧 RuleView(缓存前缀)不动,列表另立 RuleListItemView。 #7 codex 角色 relations:写侧本已持久化、读端点 _existing_characters 硬编码 []。 加 _relations_from_jsonb 解析 {name,kind,note},CodexPage 渲染关系 chip。 #8 角色入库幂等:SqlCharacterWriteRepo.create 改 (project_id,name) app 层 upsert—— 重复入库改更新而非插入;不加 UNIQUE/迁移(线上已有重复行会让约束迁移失败)。 #1 大纲卷过滤:GET /outline 支持可选 ?volume(无参=全部,向后兼容);OutlineEditor 加「查看:全部/卷N」筛选,与生成目标卷解耦。 H3/#9 文风回炉锚点:StyleDriftSegment 加 text(逐字命中段),style.md 指示审稿输出; 前端按内容锚点定位回炉目标(idx 仅排序),命中失败 → 提示「无法定位该段」而非 静默 no-op。style golden fixture 已重生成。 契约变更已 pnpm gen:api(RuleView.id / DELETE rules / outline ?volume)。无迁移 (alembic 无漂移)。门禁绿:ruff/mypy(210)/alembic/pytest 760 · 前端 tsc/lint/vitest 329。 --- apps/api/tests/test_generation.py | 199 +++++++++++++++++- apps/api/tests/test_outline.py | 25 +++ apps/api/tests/test_rules.py | 52 ++++- apps/api/tests/test_toolbox_endpoints.py | 2 +- apps/api/ww_api/routers/generation.py | 41 +++- apps/api/ww_api/routers/outline.py | 7 +- apps/api/ww_api/routers/rules.py | 34 ++- apps/api/ww_api/schemas/rules.py | 7 +- apps/web/components/codex/CodexPage.tsx | 21 +- apps/web/components/outline/OutlineEditor.tsx | 46 +++- apps/web/components/review/ReviewReport.tsx | 30 ++- apps/web/components/rules/RulesPage.tsx | 21 +- apps/web/lib/api/schema.d.ts | 87 +++++++- apps/web/lib/generation/cards.test.ts | 13 ++ apps/web/lib/outline/outline.test.ts | 44 ++++ apps/web/lib/outline/outline.ts | 22 ++ apps/web/lib/review/sse.ts | 14 +- apps/web/lib/review/style-sse.test.ts | 35 ++- apps/web/lib/rules/rules.test.ts | 12 +- apps/web/lib/rules/useRules.ts | 35 ++- apps/web/lib/settings/providers.ts | 4 +- apps/web/lib/style/locateSegment.test.ts | 46 ++++ apps/web/lib/style/locateSegment.ts | 26 +++ apps/web/lib/style/style.test.ts | 36 +++- apps/web/lib/style/style.ts | 6 + apps/web/tsconfig.tsbuildinfo | 2 +- .../agents/tests/fixtures/prompt_hashes.json | 2 +- packages/agents/ww_agents/prompts/style.md | 5 +- packages/agents/ww_agents/schemas.py | 14 +- packages/core/tests/test_rules_repo.py | 20 +- packages/core/ww_core/domain/__init__.py | 8 +- .../core/ww_core/domain/character_repo.py | 27 ++- packages/core/ww_core/domain/rule_repo.py | 63 +++++- 33 files changed, 907 insertions(+), 99 deletions(-) create mode 100644 apps/web/lib/style/locateSegment.test.ts create mode 100644 apps/web/lib/style/locateSegment.ts diff --git a/apps/api/tests/test_generation.py b/apps/api/tests/test_generation.py index 67bd0bf..81f27bd 100644 --- a/apps/api/tests/test_generation.py +++ b/apps/api/tests/test_generation.py @@ -30,7 +30,7 @@ from ww_agents import ( ) from ww_core.domain.character_repo import CharacterWriteView from ww_core.domain.project_repo import ProjectCreate -from ww_core.domain.repositories import RuleView +from ww_core.domain.rule_repo import RuleListItemView from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_shared import AppError, ErrorCode from ww_skills import SkillRegistry @@ -81,8 +81,27 @@ class _FakeCharacterWriteRepo: tags: list[Any], relations: list[dict[str, Any]], ) -> CharacterWriteView: + # 幂等:按 (project_id, name) upsert——同名更新既有行而非插重复(镜像 Sql 实现)。 + existing = next( + (r for r in self.rows if r["project_id"] == project_id and r["name"] == name), + None, + ) + if existing is not None: + existing.update( + { + "role": role, + "traits": list(traits), + "arc": arc, + "speech_tics": list(speech_tics), + "tags": list(tags), + "relations": [dict(r) for r in relations], + } + ) + return CharacterWriteView(id=existing["id"], name=name, role=role) + row_id = uuid.uuid4() self.rows.append( { + "id": row_id, "project_id": project_id, "name": name, "role": role, @@ -93,14 +112,16 @@ class _FakeCharacterWriteRepo: "relations": [dict(r) for r in relations], } ) - return CharacterWriteView(id=uuid.uuid4(), name=name, role=role) + return CharacterWriteView(id=row_id, name=name, role=role) class _FakeRulesReadRepo: - def __init__(self, rules: list[RuleView] | None = None) -> None: + """实现规则 repo 的读侧 `list_for_project`(带 id,供 list_rules 端点)。""" + + def __init__(self, rules: list[RuleListItemView] | None = None) -> None: self._rules = rules or [] - async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]: + async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]: return list(self._rules) @@ -170,7 +191,7 @@ def _make_app( get_memory_repos, get_precheck_gateway, get_project_repo, - get_rules_read_repo, + get_rule_write_repo, get_skill_registry, get_worldbuilder_gateway, ) @@ -190,7 +211,7 @@ def _make_app( (lambda: memory) if memory is not None else _empty_memory_repos ) app.dependency_overrides[get_character_write_repo] = lambda: char_repo - app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo + app.dependency_overrides[get_rule_write_repo] = lambda: rules_repo app.dependency_overrides[get_skill_registry] = lambda: registry app.dependency_overrides[get_session] = lambda: session gw = _raise_no_creds if no_creds else (lambda: gateway) @@ -370,6 +391,118 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None: assert len(char_repo.rows) == 1 +@pytest.mark.asyncio +async def test_ingest_same_name_twice_updates_not_duplicates() -> None: + # Arrange:同一项目、同名角色入库两次,第二次字段不同。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()}) + char_repo = _FakeCharacterWriteRepo() + app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo) + + def _payload(role: str, trait: str) -> dict[str, Any]: + return { + "cards": [ + { + "name": "叶寒", + "role": role, + "traits": [trait], + "backstory": "孤儿", + "arc": "成长", + "speech_tics": [], + "tags": [], + "relations": [], + } + ] + } + + async with _client(app) as client: + # Act:先入主角/腹黑,再以同名入对手/隐忍。 + first = await client.post(f"/projects/{pid}/characters", json=_payload("主角", "腹黑")) + second = await client.post(f"/projects/{pid}/characters", json=_payload("对手", "隐忍")) + + # Assert:仍是一行(按 name upsert),字段被更新而非新增重复。 + assert first.status_code == 201 + assert second.status_code == 201 + assert len(char_repo.rows) == 1 + assert char_repo.rows[0]["role"] == "对手" + assert char_repo.rows[0]["traits"] == ["隐忍"] + + +@pytest.mark.asyncio +async def test_ingest_relations_persist_on_write() -> None: + # Arrange:入库带关系网的角色卡。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()}) + char_repo = _FakeCharacterWriteRepo() + app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo) + + relations = [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}] + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/characters", + json={ + "cards": [ + { + "name": "叶寒", + "role": "主角", + "traits": ["腹黑"], + "backstory": "孤儿", + "arc": "成长", + "speech_tics": [], + "tags": [], + "relations": relations, + } + ] + }, + ) + + # Assert:relations 持久化(写侧未丢)。 + assert resp.status_code == 201 + assert char_repo.rows[0]["relations"] == relations + + +def test_relations_from_jsonb_parses_and_skips_dirty() -> None: + # Arrange:混入脏条目(非 dict / 缺 name / 缺 kind)。 + from ww_api.routers.generation import _relations_from_jsonb + + raw = [ + {"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}, + {"name": "无类型"}, # 缺 kind → 跳过 + {"kind": "无名"}, # 缺 name → 跳过 + "not-a-dict", # 非 dict → 跳过 + {"name": "墨白", "kind": "师徒"}, # note 可缺 + ] + # Act + out = _relations_from_jsonb(raw) + # Assert:只保留有效两条,note 缺为 None。 + assert [(r.name, r.kind, r.note) for r in out] == [ + ("苏离", "宿敌", "灭门之仇"), + ("墨白", "师徒", None), + ] + + +@pytest.mark.asyncio +async def test_list_characters_includes_relations() -> None: + # Arrange:读侧 memory view 带 relations(JSONB list)。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + app, _ = _make_app( + project_repo=repo, + gateway=_SchemaRoutingGateway({}), + memory=_codex_memory_with_relations(), + ) + + async with _client(app) as client: + resp = await client.get(f"/projects/{pid}/characters") + + # Assert:读端点不再丢 relations(修 #7)。 + assert resp.status_code == 200 + card = resp.json()["characters"][0] + assert card["relations"] == [{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}] + + # ---- 读端点 ---- @@ -377,10 +510,11 @@ async def test_ingest_characters_acknowledged_conflict_writes() -> None: async def test_list_rules_returns_rules() -> None: repo = FakeProjectRepo() pid = await _seed_project(repo) + rid1, rid2 = uuid.uuid4(), uuid.uuid4() rules_repo = _FakeRulesReadRepo( [ - RuleView(level="project", content="主角不复活"), - RuleView(level="global", content="无脏话"), + RuleListItemView(id=rid1, level="project", content="主角不复活"), + RuleListItemView(id=rid2, level="global", content="无脏话"), ] ) gateway = _SchemaRoutingGateway({}) @@ -392,6 +526,8 @@ async def test_list_rules_returns_rules() -> None: assert resp.status_code == 200 rules = resp.json()["rules"] assert [r["content"] for r in rules] == ["主角不复活", "无脏话"] + # 列表带稳定 id(前端删除 handle)。 + assert [r["id"] for r in rules] == [str(rid1), str(rid2)] @pytest.mark.asyncio @@ -466,6 +602,53 @@ def _codex_memory() -> Any: ) +def _codex_memory_with_relations() -> Any: + """MemoryRepos:角色行携 relations(JSONB list),验读端点还原关系网(修 #7)。""" + from test_projects import ( + _EmptyDigestRepo, + _EmptyForeshadowRepo, + _EmptyOutlineRepo, + _EmptyRulesRepo, + _EmptyStyleRepo, + _StubProjectSpecRepo, + ) + from ww_core.domain.repositories import ( + CharacterView, + MemoryRepos, + WorldEntityView, + ) + + class _CharRepo: + async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: + return [ + CharacterView( + name="叶寒", + role="主角", + traits={"items": ["腹黑"]}, + backstory="孤儿出身", + arc={"text": "成长"}, + speech_tics={"items": []}, + tags=[], + relations=[{"name": "苏离", "kind": "宿敌", "note": "灭门之仇"}], + ) + ] + + class _WorldRepo: + async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]: + return [] + + return MemoryRepos( + outline=_EmptyOutlineRepo(), + character=_CharRepo(), + world_entity=_WorldRepo(), + digest=_EmptyDigestRepo(), + foreshadow=_EmptyForeshadowRepo(), + style=_EmptyStyleRepo(), + rules=_EmptyRulesRepo(), + project=_StubProjectSpecRepo(), + ) + + @pytest.mark.asyncio async def test_list_characters_unpacks_jsonb_to_api_shape() -> None: repo = FakeProjectRepo() diff --git a/apps/api/tests/test_outline.py b/apps/api/tests/test_outline.py index dc14c7e..e83b43f 100644 --- a/apps/api/tests/test_outline.py +++ b/apps/api/tests/test_outline.py @@ -239,6 +239,31 @@ async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_bea assert body["chapters"][1]["beats"] == ["冲突升级"] +@pytest.mark.asyncio +async def test_get_outline_with_volume_filters_to_that_volume() -> None: + # ?volume=N 只返回该卷章节;无该参数返回全部(向后兼容)。 + project_repo = FakeProjectRepo() + pid = await _seed_project(project_repo) + read_repo = FakeOutlineReadRepo() + read_repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·开篇"]) + read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·冲突"]) + read_repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·新篇"]) + client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo) + + async with client: + all_resp = await client.get(f"/projects/{pid}/outline") + vol2_resp = await client.get(f"/projects/{pid}/outline?volume=2") + + # 不带 volume → 全部三章。 + assert all_resp.status_code == 200 + assert [c["no"] for c in all_resp.json()["chapters"]] == [1, 2, 3] + # ?volume=2 → 仅卷二的第 3 章。 + assert vol2_resp.status_code == 200 + vol2_chapters = vol2_resp.json()["chapters"] + assert [c["no"] for c in vol2_chapters] == [3] + assert all(c["volume"] == 2 for c in vol2_chapters) + + @pytest.mark.asyncio async def test_get_outline_returns_empty_list_when_no_outline() -> None: project_repo = FakeProjectRepo() diff --git a/apps/api/tests/test_rules.py b/apps/api/tests/test_rules.py index f05f1a1..dcef43b 100644 --- a/apps/api/tests/test_rules.py +++ b/apps/api/tests/test_rules.py @@ -17,7 +17,7 @@ from cryptography.fernet import Fernet from fakes_projects import FakeProjectRepo, FakeSession from ww_api.services.credentials import STUB_OWNER_ID from ww_core.domain.project_repo import ProjectView -from ww_core.domain.rule_repo import RuleWriteView +from ww_core.domain.rule_repo import RuleListItemView, RuleWriteView class _FakeRuleWriteRepo: @@ -25,10 +25,22 @@ class _FakeRuleWriteRepo: self.rows: list[RuleWriteView] = [] async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: - view = RuleWriteView(project_id=project_id, level=level, content=content) + view = RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content) self.rows.append(view) return view + async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]: + return [ + RuleListItemView(id=r.id, level=r.level, content=r.content) + for r in self.rows + if r.project_id in (project_id, None) + ] + + async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool: + before = len(self.rows) + self.rows = [r for r in self.rows if not (r.id == rule_id and r.project_id == project_id)] + return len(self.rows) < before + def _make_client() -> tuple[ httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession, FakeProjectRepo, uuid.UUID @@ -72,6 +84,8 @@ async def test_create_rule_returns_201_and_commits() -> None: body = resp.json() assert body["level"] == "project" assert body["content"] == "主角不许中途复活" + # 创建回显带 id(前端删除 handle)。 + assert body["id"] == str(repo.rows[0].id) assert session.commits == 1 assert len(repo.rows) == 1 assert repo.rows[0].project_id == pid @@ -124,3 +138,37 @@ async def test_create_rule_unknown_project_returns_404() -> None: assert resp.status_code == 404 assert resp.json()["error"]["code"] == "NOT_FOUND" assert len(repo.rows) == 0 # 未触达写库 + + +@pytest.mark.asyncio +async def test_delete_rule_returns_204_and_commits() -> None: + # 作者显式删一条本作品规则(不变量 #3):204 + 端点提交 + 行被移除。 + client, repo, session, _project_repo, pid = _make_client() + seeded = await repo.create(pid, level="project", content="待删规则") + async with client: + resp = await client.delete(f"/projects/{pid}/rules/{seeded.id}") + assert resp.status_code == 204 + assert session.commits == 1 + assert len(repo.rows) == 0 + + +@pytest.mark.asyncio +async def test_delete_rule_unknown_rule_returns_404() -> None: + # 未知 rule_id → 404;不提交(删不到行)。 + client, repo, session, _project_repo, pid = _make_client() + async with client: + resp = await client.delete(f"/projects/{pid}/rules/{uuid.uuid4()}") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == "NOT_FOUND" + assert session.commits == 0 + + +@pytest.mark.asyncio +async def test_delete_rule_unknown_project_returns_404() -> None: + # 项目不存在 → 404(先于 rule 查校验),不触达删除。 + client, repo, session, _project_repo, _pid = _make_client() + async with client: + resp = await client.delete(f"/projects/{uuid.uuid4()}/rules/{uuid.uuid4()}") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == "NOT_FOUND" + assert session.commits == 0 diff --git a/apps/api/tests/test_toolbox_endpoints.py b/apps/api/tests/test_toolbox_endpoints.py index 105e767..1912a91 100644 --- a/apps/api/tests/test_toolbox_endpoints.py +++ b/apps/api/tests/test_toolbox_endpoints.py @@ -103,7 +103,7 @@ class _FakeRuleWriteRepo: async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: self.rows.append({"project_id": project_id, "level": level, "content": content}) - return RuleWriteView(project_id=project_id, level=level, content=content) + return RuleWriteView(id=uuid.uuid4(), project_id=project_id, level=level, content=content) class _FakeOutlineReadRepo: diff --git a/apps/api/ww_api/routers/generation.py b/apps/api/ww_api/routers/generation.py index 7158683..0522361 100644 --- a/apps/api/ww_api/routers/generation.py +++ b/apps/api/ww_api/routers/generation.py @@ -19,7 +19,7 @@ ledger,否则 usage 静默丢失,同 draft/review 纪律)。无凭据 → from __future__ import annotations import uuid -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession @@ -33,8 +33,9 @@ from ww_agents import ( from ww_core.domain import ( CharacterWriteRepo, ProjectRepo, + RuleWriteRepo, ) -from ww_core.domain.repositories import MemoryRepos, RulesRepo +from ww_core.domain.repositories import MemoryRepos from ww_core.orchestrator import ( precheck_generated_cards, run_character_gen, @@ -71,7 +72,7 @@ from ww_api.services.project_deps import ( get_memory_repos, get_precheck_gateway, get_project_repo, - get_rules_read_repo, + get_rule_write_repo, get_skill_registry, get_worldbuilder_gateway, ) @@ -84,7 +85,7 @@ skills_router = APIRouter(prefix="/skills", tags=["skills"]) ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)] -RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)] +RuleRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)] SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)] WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)] CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)] @@ -111,10 +112,28 @@ def _render_characters_context(cards: list[CharacterCard]) -> str: return "\n".join(f"- {c.name}({c.role}):{'、'.join(c.traits) or '(未列)'}" for c in cards) +def _relations_from_jsonb(raw: list[Any]) -> list[CharacterRelation]: + """`characters.relations` JSONB list(每条 {name, kind, note?})→ schema CharacterRelation。 + + 跳过缺 name/kind 的脏条目(历史/外部数据不可信,守输入校验边界)。 + """ + out: list[CharacterRelation] = [] + for item in raw or []: + if not isinstance(item, dict): + continue + name = item.get("name") + kind = item.get("kind") + if not name or not kind: + continue + out.append(CharacterRelation(name=str(name), kind=str(kind), note=item.get("note"))) + return out + + async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]: - """把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck)。 + """把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck / 设定库读端点)。 DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。 + `relations` 从 JSONB list 还原(设定库 Codex 需展示关系网;precheck 不读此字段,无害)。 """ views = await memory.character.list_for_project(project_id) cards: list[CharacterCard] = [] @@ -133,7 +152,7 @@ async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> li arc=arc or "", speech_tics=tics, tags=list(v.tags or []), - relations=[], + relations=_relations_from_jsonb(list(v.relations or [])), ) ) return cards @@ -377,14 +396,16 @@ async def list_world_entities( @router.get("/{project_id}/rules") async def list_rules( project_id: uuid.UUID, - repo: RulesReadRepoDep, + repo: RuleRepoDep, project_repo: ProjectRepoDep, ) -> RuleListResponse: - """规则列表(按读侧顺序)。规则页用。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。""" + """规则列表(带 id,供前端删除 handle)。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。""" if await project_repo.get(STUB_OWNER_ID, project_id) is None: raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") - rules = await repo.all_for_project(project_id) - return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules]) + rules = await repo.list_for_project(project_id) + return RuleListResponse( + rules=[RuleView(id=r.id, level=r.level, content=r.content) for r in rules] + ) @skills_router.get("") diff --git a/apps/api/ww_api/routers/outline.py b/apps/api/ww_api/routers/outline.py index a019dfc..8acbf19 100644 --- a/apps/api/ww_api/routers/outline.py +++ b/apps/api/ww_api/routers/outline.py @@ -143,10 +143,12 @@ async def get_outline( request: Request, project_repo: ProjectRepoDep, outline_repo: OutlineReadRepoDep, + volume: int | None = None, ) -> OutlineResponse: """读取已持久化的大纲(逐章,按 chapter_no 升序)。 - 项目不存在 → 404;项目存在但尚无大纲 → 200 空列表(非 404,页面初次访问的常态)。 + 可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。 + 项目不存在 → 404;项目存在但尚无大纲(或该卷无章节)→ 200 空列表(非 404)。 DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形, 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。 """ @@ -157,6 +159,8 @@ async def get_outline( raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") views = await outline_repo.list_for_project(project_id) + if volume is not None: + views = [v for v in views if v.volume == volume] chapters = [ OutlineChapterView( no=view.chapter_no, @@ -171,6 +175,7 @@ async def get_outline( "outline_read", project_id=str(project_id), request_id=request_id, + volume=volume, chapter_count=len(chapters), ) return OutlineResponse(chapters=chapters) diff --git a/apps/api/ww_api/routers/rules.py b/apps/api/ww_api/routers/rules.py index 9b427b8..17298e7 100644 --- a/apps/api/ww_api/routers/rules.py +++ b/apps/api/ww_api/routers/rules.py @@ -15,7 +15,7 @@ from __future__ import annotations import uuid from typing import Annotated -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, Response from sqlalchemy.ext.asyncio import AsyncSession from ww_core.domain import RuleWriteRepo from ww_core.domain.project_repo import ProjectRepo @@ -60,4 +60,34 @@ async def create_rule( request_id=request_id, level=body.level, ) - return RuleView(level=view.level, content=view.content) + return RuleView(id=view.id, level=view.level, content=view.content) + + +@router.delete("/{project_id}/rules/{rule_id}", status_code=204) +async def delete_rule( + project_id: uuid.UUID, + rule_id: uuid.UUID, + request: Request, + repo: RuleWriteRepoDep, + project_repo: ProjectRepoDep, + session: SessionDep, +) -> Response: + """删除一条本作品规则(204)。项目不存在 → 404;规则不存在 / 不属于该项目 → 404。 + + 删规则是**作者显式动作**(不变量 #3:规则增删不经 AI 静默写库)。repo.delete 只 flush, + 端点提交;删不到行(未知 id / 跨项目)→ 不提交、抛 404。 + """ + request_id = getattr(request.state, "request_id", None) + if await project_repo.get(STUB_OWNER_ID, project_id) is None: + raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") + deleted = await repo.delete(project_id, rule_id) + if not deleted: + raise AppError(ErrorCode.NOT_FOUND, f"rule not found: {rule_id}") + await session.commit() + log.info( + "rule_deleted", + project_id=str(project_id), + rule_id=str(rule_id), + request_id=request_id, + ) + return Response(status_code=204) diff --git a/apps/api/ww_api/schemas/rules.py b/apps/api/ww_api/schemas/rules.py index b45bdce..a7ab307 100644 --- a/apps/api/ww_api/schemas/rules.py +++ b/apps/api/ww_api/schemas/rules.py @@ -7,6 +7,7 @@ snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必 from __future__ import annotations +import uuid from typing import Annotated, Literal from pydantic import BaseModel, Field, StringConstraints @@ -25,7 +26,11 @@ class RuleCreateRequest(BaseModel): class RuleView(BaseModel): - """规则视图(创建后回显;snake_case)。""" + """规则视图(创建后回显 + 列表项;snake_case)。 + `id` 是该规则行的稳定主键——前端规则页用它作删除 handle(DELETE /rules/{rule_id})。 + """ + + id: uuid.UUID level: str content: str diff --git a/apps/web/components/codex/CodexPage.tsx b/apps/web/components/codex/CodexPage.tsx index ba12ffc..5963a52 100644 --- a/apps/web/components/codex/CodexPage.tsx +++ b/apps/web/components/codex/CodexPage.tsx @@ -87,13 +87,28 @@ export function CodexPage({ 已入库人物({characters.length}) {characters.length > 0 ? ( -