Compare commits

...

11 Commits

Author SHA1 Message Date
Yaojia Wang
01257b8952 merge: 实施 4 组设计型 QA 项(规则删除/codex 角色/大纲卷过滤/文风回炉锚点) 2026-06-25 12:53:03 +02:00
Yaojia Wang
bf39f50b2f fix(qa): 实施 4 组设计型 QA 项——规则删除/codex 角色/大纲卷过滤/文风回炉锚点
#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。
2026-06-25 12:53:03 +02:00
Yaojia Wang
e60eff7aa1 merge: 前端错误文案 friendlyFromApiError(QA #2/#11,去通用 toast) 2026-06-24 18:01:15 +02:00
Yaojia Wang
9a623688e8 fix(qa): 前端错误文案——按 API 信封 surface 真实错因,去掉通用 toast
新增 friendlyFromApiError(apiError):兼容业务信封 {error:{code,message}}(ARCH §7.1)
与 FastAPI 422 {detail:[...]},422 映射为友好 VALIDATION 文案(不泄露技术细节)。
接入三处此前吞掉错因、永显通用文案的调用点:
- useOutline(QA #2):原 env.error.code 漏读 422 detail,永显「大纲生成失败」。
- ChainPage / ChainAdjudication(QA #11):原 friendlyError(undefined) 永显通用 toast,
  现 surface 真实 code(如 LLM_UNAVAILABLE→去设置 provider / CONFLICT→提示裁决)。
+ messages.test.ts 加 3 例(业务码带动作 / 422→VALIDATION / 未知形兜底)。
门禁绿:vitest / tsc / eslint 干净。
2026-06-24 18:01:15 +02:00
Yaojia Wang
f4f01aeca9 merge: 修 3 个 QA MEDIUM(规则 GET 404 / 空白 content 422 / 未知 tier 422) 2026-06-24 17:51:03 +02:00
Yaojia Wang
3868f80502 fix(qa): 修 3 个 QA MEDIUM——规则/档位路由校验收紧
- 规则 GET 不存在 project → 404(原返误导性空 200,掩盖坏 id):list_rules 加
  项目存在校验(仿 C1/H2)。
- 规则 content 纯空白 → 422(原 strip 前校验 min_length,空白行入库成垃圾):
  改 StringConstraints(strip_whitespace=True, min_length=1)。
- 档位路由 tier 限定 writer/analyst/light(Tier Literal)→ 未知档位 422
  (原接受任意字符串)。

回归测试:test_generation(GET rules 404)/ test_rules(空白 content 422)/
test_settings_providers(未知 tier 422)。门禁绿:ruff/format/mypy(210)/pytest。
2026-06-24 17:51:02 +02:00
Yaojia Wang
7a40c7fbb5 merge: 修 QA C1/H1/H2(写章/规则项目校验 404 + 立项向导字段覆盖) 2026-06-24 17:17:35 +02:00
Yaojia Wang
2fe3bedfba fix(qa): 修 QA C1/H1/H2——写章/规则缺项目校验 + 立项向导字段覆盖
C1 (CRITICAL) stream_draft:对不存在 project 流式写章先 fail-fast 404,
  否则非法 id 静默烧一次付费/限流 LLM 调用并返 200。在触网关前查 project_repo.get。
H2 (HIGH) create_rule:给不存在 project 加规则原 FK 违例逃逸成 500 → 改为入库前
  校验项目存在返 404(仿 chain/_require_project)。
H1 (HIGH) ProjectWizard:第3步「立意」与第4步「主角/金手指」原共用 form.premise
  互相覆盖丢数据 → 新增独立 form.protagonist,toCreateRequest 合并两段进 premise
  (M1 projects 表仍只有 premise,不编造 API)。

回归测试:
- test_projects.py:stream_draft 不存在 project → 404 且网关零调用;已有 draft
  用例改 seed 真项目。
- test_rules.py:create_rule 不存在 project → 404 不写库;已有用例 seed 真项目。
- wizard.test.ts:premise+protagonist 合并不互相覆盖(2 例)。
门禁绿:ruff/format clean · mypy 210 · pytest 749 · 前端 tsc/lint/vitest 干净。
2026-06-24 17:17:35 +02:00
Yaojia Wang
a4ef250fc9 fix(review): 验收冲突显示与闸门对齐 — 验收报 CONFLICT_UNRESOLVED 时回拉最新审稿并回灌冲突;未审稿不再误显「可直接验收」 2026-06-24 16:17:54 +02:00
Yaojia Wang
d0c301349c merge: fix 多章链 checkpointer 连接串剥离驱动后缀(ProgrammingError 修复 + 回归测试) 2026-06-24 13:03:58 +02:00
Yaojia Wang
b5002a9864 fix(chain): checkpointer 连接串剥离 SQLAlchemy 驱动后缀
多章链 job 一发起即 failed(exc_type=ProgrammingError)。根因:runtime
checkpointer 工厂把 settings 的 sync URL `postgresql+psycopg://…` 原样喂给
`AsyncPostgresSaver.from_conn_string`,psycopg 不识别 `+psycopg` 方言段 →
`ProgrammingError: missing "=" …`,链在打开 saver 时即崩。

E2E 用 MemorySaver 覆盖 get_checkpointer_factory,从不走真 conn string,故此
路径无回归保护、bug 未被测出(迁移 d3e4f5a6b7c8 里 `_psycopg_conn_string` 已
正确剥离,runtime 缝漏了同款处理)。

修复:chain_deps 加 `_libpq_conn_string()`,剥离 `+psycopg`/`+asyncpg` 还原为
标准 libpq 串后再交 from_conn_string。+ 单测 test_chain_deps.py(3 参数化用例)。
已直连真 pg 复验:saver.aget OK,ProgrammingError 消除。
2026-06-24 12:40:40 +02:00
53 changed files with 1782 additions and 149 deletions

View File

@@ -0,0 +1,41 @@
"""chain_deps._libpq_conn_string 单测——剥离 SQLAlchemy 驱动后缀(回归)。
bugruntime checkpointer 工厂曾把 `postgresql+psycopg://…`settings 的 sync URL
原样喂给 `AsyncPostgresSaver.from_conn_string`psycopg 不识别 `+psycopg` 方言段 →
`ProgrammingError: missing "=" …` → 多章链 job 直接 failed。E2E 用 MemorySaver 覆盖,
从不走真 conn string故此路径无回归保护。本用例锁住「剥离后缀」语义。
"""
from __future__ import annotations
import pytest
from ww_api.services import chain_deps
class _StubSettings:
def __init__(self, url: str) -> None:
self.database_url_sync = url
@pytest.mark.parametrize(
("raw", "expected"),
[
(
"postgresql+psycopg://writer:writer@localhost:5432/writer",
"postgresql://writer:writer@localhost:5432/writer",
),
("postgresql+asyncpg://u:p@h:5432/db", "postgresql://u:p@h:5432/db"),
("postgresql://u:p@h:5432/db", "postgresql://u:p@h:5432/db"),
],
)
def test_libpq_conn_string_strips_driver_suffix(
monkeypatch: pytest.MonkeyPatch, raw: str, expected: str
) -> None:
# Arrange
monkeypatch.setattr(chain_deps, "get_settings", lambda: _StubSettings(raw))
# Act
result = chain_deps._libpq_conn_string()
# Assert
assert result == expected
assert "+psycopg" not in result
assert "+asyncpg" not in result

View File

@@ -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)
@@ -210,6 +231,16 @@ def _client(app: Any) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_list_rules_unknown_project_returns_404() -> None:
# QA MEDIUM 回归GET 规则列表对不存在 project 返 404原返误导性空 200掩盖坏 id
app, _ = _make_app(project_repo=FakeProjectRepo(), gateway=object())
async with _client(app) as client:
resp = await client.get(f"/projects/{uuid.uuid4()}/rules")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
# ---- 世界观生成 ----
@@ -360,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,
}
]
},
)
# Assertrelations 持久化(写侧未丢)。
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 带 relationsJSONB 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": "灭门之仇"}]
# ---- 读端点 ----
@@ -367,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({})
@@ -382,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
@@ -456,6 +602,53 @@ def _codex_memory() -> Any:
)
def _codex_memory_with_relations() -> Any:
"""MemoryRepos角色行携 relationsJSONB 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()

View File

@@ -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()

View File

@@ -11,6 +11,8 @@ import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import (
CharacterView,
DigestView,
@@ -122,6 +124,16 @@ def _make_client(
return client, project_repo, chapter_repo, gateway
def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
"""Seed 一个属于 STUB_OWNER_ID 的项目,返回其 pid。
stream_draft 现先校验项目存在QA C1流式用例须用已存在项目否则 404。
"""
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
return pid
@pytest.mark.asyncio
async def test_create_project_returns_201() -> None:
client, _, _, _ = _make_client()
@@ -180,8 +192,8 @@ async def test_create_project_rejects_blank_title() -> None:
@pytest.mark.asyncio
async def test_draft_stream_yields_sse_tokens_and_done() -> None:
gateway = FakeWriterGateway(chunks=["阿福", "走进门。"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -200,8 +212,8 @@ async def test_draft_stream_yields_sse_tokens_and_done() -> None:
@pytest.mark.asyncio
async def test_draft_stream_threads_directive_into_volatile() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
@@ -215,8 +227,8 @@ async def test_draft_stream_threads_directive_into_volatile() -> None:
@pytest.mark.asyncio
async def test_draft_stream_backward_compatible_without_body() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -229,8 +241,8 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
from ww_shared import AppError
gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
client, project_repo, _, _ = _make_client(gateway=gateway)
pid = _seed_project(project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
@@ -240,6 +252,18 @@ async def test_draft_stream_maps_error_to_sse_error_event() -> None:
assert ErrorCode.LLM_UNAVAILABLE in text
@pytest.mark.asyncio
async def test_draft_stream_unknown_project_returns_404_without_calling_gateway() -> None:
# QA C1 回归:对不存在的 project 流式写章必须 404且绝不触网关不烧 LLM 调用)。
gateway = FakeWriterGateway(chunks=["不该被生成"])
client, _project_repo, _, _ = _make_client(gateway=gateway)
async with client:
resp = await client.post(f"/projects/{uuid.uuid4()}/chapters/1/draft")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
assert len(gateway.requests) == 0 # 未触达网关
@pytest.mark.asyncio
async def test_put_draft_is_idempotent() -> None:
chapter_repo = FakeChapterRepo()

View File

@@ -3,7 +3,8 @@
覆盖:
- 201 + 回显 level/content + 端点 commit
- 非法 level → 422Pydantic Literal 校验FastAPI 422
- 空 content → 422
- 空 content → 422
- 项目不存在 → 404QA H2 回归:此前 FK 违例逃逸成 500
"""
from __future__ import annotations
@@ -13,8 +14,10 @@ import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_core.domain.rule_repo import RuleWriteView
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 RuleListItemView, RuleWriteView
class _FakeRuleWriteRepo:
@@ -22,34 +25,56 @@ 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)
]
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
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
]:
"""构建测试 client并 seed 一个属于 STUB_OWNER_ID 的项目;返回其 pid。
create_rule 现在先校验项目存在QA H2故必须 override get_project_repo 并 seed
否则正常用例会 404。返回的 pid 是已存在项目;未 seed 的随机 pid 即"不存在"
"""
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_rule_write_repo
from ww_api.services.project_deps import get_project_repo, get_rule_write_repo
from ww_db import get_session
repo = _FakeRuleWriteRepo()
session = FakeSession()
project_repo = FakeProjectRepo()
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="作品"))
app = create_app()
app.dependency_overrides[get_rule_write_repo] = lambda: repo
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
return client, repo, session, project_repo, pid
@pytest.mark.asyncio
async def test_create_rule_returns_201_and_commits() -> None:
client, repo, session = _make_client()
pid = uuid.uuid4()
client, repo, session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
@@ -59,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
@@ -66,8 +93,7 @@ async def test_create_rule_returns_201_and_commits() -> None:
@pytest.mark.asyncio
async def test_create_rule_invalid_level_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
client, _repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
@@ -78,11 +104,71 @@ async def test_create_rule_invalid_level_returns_422() -> None:
@pytest.mark.asyncio
async def test_create_rule_empty_content_returns_422() -> None:
client, _repo, _session = _make_client()
pid = uuid.uuid4()
client, _repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "global", "content": ""},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_rule_whitespace_content_returns_422() -> None:
# QA MEDIUM 回归:纯空白 contentstrip 后为空)应 422不可入库成垃圾行。
client, repo, _session, _project_repo, pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{pid}/rules",
json={"level": "project", "content": " "},
)
assert resp.status_code == 422
assert len(repo.rows) == 0
@pytest.mark.asyncio
async def test_create_rule_unknown_project_returns_404() -> None:
# QA H2 回归:给不存在的 project 加规则应 404不是 500 的 FK 违例逃逸)。
client, repo, _session, _project_repo, _pid = _make_client()
async with client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/rules",
json={"level": "project", "content": "x"},
)
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:
# 作者显式删一条本作品规则(不变量 #3204 + 端点提交 + 行被移除。
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

View File

@@ -91,3 +91,16 @@ def test_put_validation_rejects_blank_provider(client: TestClient) -> None:
json={"credentials": [{"provider": "", "api_key": "sk-x"}]},
)
assert resp.status_code == 422
def test_put_validation_rejects_unknown_tier(client: TestClient) -> None:
# QA MEDIUM 回归tier 限定 writer/analyst/light未知档位 → 422原接受任意字符串
resp = client.put(
"/settings/providers",
json={
"tier_routing": [
{"tier": "bogus", "provider": "deepseek", "model": "x", "fallback": []}
]
},
)
assert resp.status_code == 422

View File

@@ -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:

View File

@@ -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,11 +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:
"""规则列表(按读侧顺序)。规则页用"""
rules = await repo.all_for_project(project_id)
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
"""规则列表(带 id供前端删除 handle。项目不存在 → 404QA 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.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("")

View File

@@ -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)

View File

@@ -252,6 +252,7 @@ async def stream_draft(
repos: MemoryReposDep,
gateway: GatewayDep,
injection_repo: InjectionRepoDep,
project_repo: ProjectRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
body: DraftStreamRequest | None = None,
) -> StreamingResponse:
@@ -259,7 +260,12 @@ async def stream_draft(
`body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化
无 body 的旧调用方仍可用(向后兼容)。
项目不存在 → 404在触网关前 fail-fast否则非法 project_id 会静默烧一次付费/限流的
LLM 调用并返回 200QA C1
"""
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
request_id = getattr(request.state, "request_id", None)
directive = body.directive if body else None
override = await injection_repo.get(project_id, chapter_no)

View File

@@ -15,20 +15,24 @@ 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
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.rules import RuleCreateRequest, RuleView
from ww_api.services.project_deps import get_rule_write_repo
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_project_repo, get_rule_write_repo
log = get_logger("ww.api.rules")
router = APIRouter(prefix="/projects", tags=["rules"])
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -38,10 +42,16 @@ async def create_rule(
body: RuleCreateRequest,
request: Request,
repo: RuleWriteRepoDep,
project_repo: ProjectRepoDep,
session: SessionDep,
) -> RuleView:
"""新增一条规则201。非法 level / 空 content → FastAPI 422。"""
"""新增一条规则201。非法 level / 空 content → FastAPI 422;项目不存在 → 404
项目存在性须在 insert 前校验:否则 FK 违例会逃逸成 500QA H2而非干净的 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}")
view = await repo.create(project_id, level=body.level, content=body.content)
await session.commit()
log.info(
@@ -50,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)

View File

@@ -6,6 +6,7 @@ snake_case响应一律 **掩码 Key**,绝不含明文。前端经 OpenAPI
from __future__ import annotations
from pydantic import BaseModel, Field
from ww_llm_gateway.types import Tier
class ProviderView(BaseModel):
@@ -41,7 +42,8 @@ class ProviderCredentialInput(BaseModel):
class TierRoutingInput(BaseModel):
"""单条档位路由写入。"""
tier: str = Field(min_length=1)
# tier 限定已知档位 writer/analyst/light未知档位 → 422QA MEDIUM原接受任意字符串
tier: Tier
provider: str = Field(min_length=1)
model: str = Field(min_length=1)
fallback: list[str] = Field(default_factory=list)

View File

@@ -7,22 +7,30 @@ snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必
from __future__ import annotations
from typing import Literal
import uuid
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, StringConstraints
RuleLevel = Literal["global", "genre", "style", "project"]
# 先 strip 再校验长度:纯空白内容(" "strip 后为空 → 422QA MEDIUM此前被接受入库
RuleContent = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
class RuleCreateRequest(BaseModel):
"""POST /projects/:id/rules新增一条规则。"""
level: RuleLevel = Field(description="规则级别global/genre/style/project越具体越优先")
content: str = Field(min_length=1, description="规则正文")
content: RuleContent = Field(description="规则正文(首尾空白会被裁剪,不可全空白)")
class RuleView(BaseModel):
"""规则视图创建后回显snake_case"""
"""规则视图(创建后回显 + 列表项snake_case
`id` 是该规则行的稳定主键——前端规则页用它作删除 handleDELETE /rules/{rule_id})。
"""
id: uuid.UUID
level: str
content: str

View File

@@ -28,6 +28,18 @@ if TYPE_CHECKING:
CheckpointerFactory = Callable[[], AbstractAsyncContextManager[Any]]
def _libpq_conn_string() -> str:
"""从 settings 的 sync URL 派生 psycopg 的 libpq conn string。
`database_url_sync` 带 SQLAlchemy 驱动后缀(``postgresql+psycopg://…``),但
`AsyncPostgresSaver.from_conn_string` 直接交给 psycopg 解析——psycopg 不识别
``+psycopg``/``+asyncpg`` 方言段,会抛 ``ProgrammingError: missing "=" …``。
故剥离后缀还原为标准 libpq 串(``postgresql://…``)。与迁移 `d3e4f5a6b7c8`
的 `_psycopg_conn_string()` 同义。
"""
return get_settings().database_url_sync.replace("+psycopg", "").replace("+asyncpg", "")
def get_checkpointer_factory() -> CheckpointerFactory:
"""运行时 checkpointer 工厂:建连 `DATABASE_URL` 的 `AsyncPostgresSaver` 上下文。
@@ -38,7 +50,8 @@ def get_checkpointer_factory() -> CheckpointerFactory:
def _factory() -> AbstractAsyncContextManager[BaseCheckpointSaver[Any]]:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
# langgraph 的 Postgres saver 走 psycopg(同步驱动串)用 sync 串建连接。
return AsyncPostgresSaver.from_conn_string(get_settings().database_url_sync)
# langgraph 的 Postgres saver 走 psycopg(同步驱动串)须剥离 SQLAlchemy 驱动
# 后缀(+psycopg否则 psycopg 解析连接串报 ProgrammingError。
return AsyncPostgresSaver.from_conn_string(_libpq_conn_string())
return _factory

1
apps/web/.gitignore vendored
View File

@@ -2,3 +2,4 @@
/.next
/lib/api/openapi.json
next-env.d.ts
.gstack/

View File

@@ -274,7 +274,8 @@ function StepPremise({ form, update }: StepProps) {
}
function StepProtagonist({ form, update }: StepProps) {
// M1 projects 表无独立主角/金手指字段;先并入立意/总纲文本,避免编造 API
// M1 projects 表无独立主角/金手指字段;提交时由 toCreateRequest 合并进 premise与立意各占一段
// 必须绑定独立的 form.protagonist不能复用 form.premise否则与第 3 步立意互相覆盖QA H1
return (
<div>
<p className="mb-3 text-sm text-ink-soft">
@@ -283,8 +284,8 @@ function StepProtagonist({ form, update }: StepProps) {
<Field label="主角 / 金手指概要">
<textarea
className={`${inputCls} h-28 resize-none`}
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
value={form.protagonist}
onChange={(e) => update({ protagonist: e.target.value })}
placeholder="主角设定、金手指来源与限制……"
/>
</Field>

View File

@@ -6,7 +6,7 @@ import { ConflictCard } from "@/components/review/ConflictCard";
import { useToast } from "@/components/Toast";
import { api } from "@/lib/api/client";
import { buildResumeRequest } from "@/lib/chain/chain";
import { friendlyError } from "@/lib/errors/messages";
import { friendlyFromApiError } from "@/lib/errors/messages";
import type { ReviewConflict } from "@/lib/review/sse";
import {
allResolved,
@@ -99,7 +99,7 @@ export function ChainAdjudication({
},
);
if (error) {
toast(friendlyError(undefined).text, "error");
toast(friendlyFromApiError(error).text, "error");
setResuming(false);
return;
}

View File

@@ -6,7 +6,7 @@ import { AppShell } from "@/components/AppShell";
import { useToast } from "@/components/Toast";
import { api } from "@/lib/api/client";
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
import { friendlyError } from "@/lib/errors/messages";
import { friendlyFromApiError } from "@/lib/errors/messages";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import type { ProjectResponse } from "@/lib/api/types";
import { ChainAdjudication } from "./ChainAdjudication";
@@ -58,7 +58,7 @@ export function ChainPage({ project }: ChainPageProps) {
},
);
if (error || !data) {
toast(friendlyError(undefined).text, "error");
toast(friendlyFromApiError(error).text, "error");
return;
}
setJobId(data.job_id);

View File

@@ -87,13 +87,28 @@ export function CodexPage({
{characters.length}
</h3>
{characters.length > 0 ? (
<ul className="flex flex-wrap gap-2">
<ul className="flex flex-col gap-2">
{characters.map((c, i) => (
<li
key={`${c.name}-${i}`}
className="rounded bg-bg px-2 py-1 text-xs text-ink-soft"
className="rounded bg-bg px-2 py-1.5 text-xs text-ink-soft"
>
<span className="text-ink">
{c.name}{c.role}
</span>
{c.relations && c.relations.length > 0 ? (
<ul className="mt-1 flex flex-wrap gap-1">
{c.relations.map((r, j) => (
<li
key={`${r.name}-${r.kind}-${j}`}
className="rounded bg-panel px-1.5 py-0.5 text-[11px]"
title={r.note ?? undefined}
>
{r.kind} · {r.name}
</li>
))}
</ul>
) : null}
</li>
))}
</ul>

View File

@@ -4,7 +4,12 @@ import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
import { groupByVolume } from "@/lib/outline/outline";
import {
distinctVolumes,
filterByViewVolume,
groupByVolume,
type ViewVolume,
} from "@/lib/outline/outline";
import { useOutline } from "@/lib/outline/useOutline";
import { OutlineChapterRow } from "./OutlineChapterRow";
@@ -21,7 +26,13 @@ export function OutlineEditor({
}: OutlineEditorProps) {
const { chapters, status, error, generate } = useOutline(initialChapters);
const [volume, setVolume] = useState(1);
const volumes = useMemo(() => groupByVolume(chapters), [chapters]);
// 视图卷过滤("全部" 或某卷)——只影响展示,与生成的目标卷 `volume` 解耦。
const [viewVolume, setViewVolume] = useState<ViewVolume>("all");
const availableVolumes = useMemo(() => distinctVolumes(chapters), [chapters]);
const volumes = useMemo(
() => groupByVolume(filterByViewVolume(chapters, viewVolume)),
[chapters, viewVolume],
);
const generating = status === "generating";
return (
@@ -34,7 +45,32 @@ export function OutlineEditor({
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<div className="mb-4 flex items-center gap-3">
<h1 className="font-serif text-lg text-ink"></h1>
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft">
{availableVolumes.length > 0 ? (
<label htmlFor="view-vol" className="ml-auto text-xs text-ink-soft">
<select
id="view-vol"
value={viewVolume === "all" ? "all" : String(viewVolume)}
onChange={(e) =>
setViewVolume(
e.target.value === "all" ? "all" : Number(e.target.value),
)
}
className="ml-1 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
>
<option value="all"></option>
{availableVolumes.map((v) => (
<option key={v} value={String(v)}>
{v}
</option>
))}
</select>
</label>
) : null}
<label
htmlFor="vol"
className={`text-xs text-ink-soft${availableVolumes.length > 0 ? "" : " ml-auto"}`}
>
</label>
<input
@@ -74,7 +110,9 @@ export function OutlineEditor({
<div className="min-h-0 flex-1 overflow-auto">
{volumes.length === 0 ? (
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
AI
{chapters.length > 0 && viewVolume !== "all"
? `${viewVolume} 暂无大纲。切到「全部」查看其它卷,或点「✦ AI 排大纲」为本卷生成。`
: "暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。"}
</p>
) : (
volumes.map((group) => (

View File

@@ -9,6 +9,9 @@ import { ThinkingIndicator } from "@/components/ThinkingIndicator";
interface AcceptPanelProps {
projectId: string;
chapterNo: number;
// 本页是否已有审稿结果(进页带留痕 或 本次重审完成)。用于区分
// 「审过且 0 冲突→可直接验收」与「未审稿→先审稿」,避免把"未知"当"无冲突"。
reviewed: boolean;
conflictCount: number;
unresolvedCount: number;
// R3当前伏笔建议数验收前清单提醒只读不自动落库
@@ -22,6 +25,7 @@ interface AcceptPanelProps {
export function AcceptPanel({
projectId,
chapterNo,
reviewed,
conflictCount,
unresolvedCount,
foreshadowCount,
@@ -89,7 +93,9 @@ export function AcceptPanel({
<>
<p className="mb-2 text-xs text-ink-soft">
{conflictCount === 0
? reviewed
? "无冲突,可直接验收。"
: "尚未审稿——建议先点「重新审稿」确认本章无冲突,再验收。"
: "全部冲突已裁决,可验收本章。"}
</p>
{/* R3验收前「本次将更新」清单预期落库口径以验收回执为准。 */}

View File

@@ -16,10 +16,12 @@ import {
type Verdict,
} from "@/lib/review/decisions";
import {
latestReview,
normalizeConflicts,
normalizeForeshadowSug,
normalizePace,
} from "@/lib/review/history";
import { api } from "@/lib/api/client";
import {
displayOrder,
groupConflicts,
@@ -34,6 +36,7 @@ import { useRefine } from "@/lib/style/useRefine";
import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
import { normalizeStyleDrift } from "@/lib/style/style";
import { locateDriftSegment } from "@/lib/style/locateSegment";
import { useToast } from "@/components/Toast";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { AcceptPanel } from "./AcceptPanel";
@@ -78,10 +81,8 @@ export function ReviewReport({
const [rewriting, setRewriting] = useState<Set<number>>(new Set());
// 可编辑终稿 textarea 引用:供「跳转」/采纳后在正文里选中定位问题区域。
const editorRef = useRef<HTMLTextAreaElement>(null);
// 回炉中漂移段null=未打开 RefineView
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>(
null,
);
// 回炉中漂移段在终稿里定位到的原文null=未打开 RefineView内容锚定位,非位置 idx。
const [refineText, setRefineText] = useState<string | null>(null);
const seededRef = useRef(false);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
@@ -315,15 +316,42 @@ export function ReviewReport({
finalText,
drafts,
);
if (!outcome.conflictUnresolved) {
if (outcome.missingIndices.length > 0) {
setMissing(new Set(outcome.missingIndices));
}
return;
}
// 验收闸读的是「持久化的最新审稿」;本页冲突可能为空/过期(显示 0 冲突却被拦)。
// 回拉最新审稿留痕 → 回灌冲突 + 重置裁决草稿,把「看不见的冲突」显式呈现供裁决。
const { data } = await api.GET(
"/projects/{project_id}/chapters/{chapter_no}/reviews",
{ params: { path: { project_id: project.id, chapter_no: chapterNo } } },
);
const fresh = normalizeConflicts(latestReview(data?.reviews));
setMissing(new Set(outcome.missingIndices));
if (fresh.length > 0) {
review.seed({ conflicts: fresh, foreshadow, pace, style });
setDrafts(emptyDecisions(fresh.length));
toast("审稿报告已刷新:检测到未裁决冲突,请裁决后再验收", "error");
} else {
toast("尚有冲突未裁决,请先「重新审稿」查看", "error");
}
};
// 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split
const finalParas = useMemo(() => finalText.split(/\n{2,}/), [finalText]);
// 漂移段 idx → 终稿对应段正文(越界则空串)。
const segmentText = (idx: number): string => finalParas[idx]?.trim() ?? "";
// 一键回炉:用漂移段自带原文在终稿里做内容匹配定位(内容锚),定位不到则就 #9
// 给出明确提示而非静默 no-op定位到才打开 RefineView。
const onRefineSegment = (segment: StyleDriftSegment): void => {
const located = locateDriftSegment(finalParas, segment);
if (located === null) {
toast("无法定位该段(原文可能已改动),请手动选择要回炉的段落。", "error");
return;
}
setRefineText(located);
};
// 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
const onAdopt = (original: string, refined: string): void => {
@@ -335,7 +363,7 @@ export function ReviewReport({
}
return prev.slice(0, idx) + refined + prev.slice(idx + original.length);
});
setRefineSegment(null);
setRefineText(null);
toast("已合入终稿,记得验收时复核。", "success");
};
@@ -503,15 +531,15 @@ export function ReviewReport({
<StylePanel
style={style}
incomplete={sectionStatus("style") === "incomplete"}
onRefine={setRefineSegment}
onRefine={onRefineSegment}
/>
{refineSegment !== null ? (
{refineText !== null ? (
<RefineView
projectId={project.id}
chapterNo={chapterNo}
segment={segmentText(refineSegment.idx)}
segment={refineText}
onAdopt={onAdopt}
onClose={() => setRefineSegment(null)}
onClose={() => setRefineText(null)}
/>
) : null}
</div>
@@ -520,6 +548,7 @@ export function ReviewReport({
<AcceptPanel
projectId={project.id}
chapterNo={chapterNo}
reviewed={initialReview !== undefined || review.state.phase === "done"}
conflictCount={conflictCount}
unresolvedCount={resolved ? 0 : unresolved}
foreshadowCount={foreshadow.length}

View File

@@ -19,7 +19,7 @@ interface RulesPageProps {
// 规则页UX §7四级规则列表 + 新增(乐观 + 回滚)。
export function RulesPage({ project, initialRules }: RulesPageProps) {
const { items, busy, add } = useRules(initialRules);
const { items, busy, add, remove } = useRules(initialRules);
const [level, setLevel] = useState<RuleLevel>("project");
const [content, setContent] = useState("");
const groups = useMemo(() => groupByLevel(items), [items]);
@@ -88,12 +88,23 @@ export function RulesPage({ project, initialRules }: RulesPageProps) {
<p className="text-xs text-ink-soft"></p>
) : (
<ul className="flex flex-col gap-2">
{groups[lv].map((rule, i) => (
{groups[lv].map((rule) => (
<li
key={i}
className="rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
key={rule.id}
className="flex items-start justify-between gap-3 rounded border border-line bg-panel px-3 py-2 text-sm text-ink"
>
<span className="min-w-0 flex-1 break-words">
{rule.content}
</span>
<button
type="button"
disabled={busy}
onClick={() => void remove(project.id, rule.id)}
aria-label="删除规则"
className="shrink-0 text-xs text-ink-soft hover:text-cinnabar disabled:opacity-50"
>
</button>
</li>
))}
</ul>

View File

@@ -146,6 +146,9 @@ export interface paths {
*
* `body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化
* 无 body 的旧调用方仍可用(向后兼容)。
*
* 项目不存在 → 404在触网关前 fail-fast否则非法 project_id 会静默烧一次付费/限流的
* LLM 调用并返回 200QA C1
*/
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
delete?: never;
@@ -286,7 +289,8 @@ export interface paths {
* Get Outline
* @description 读取已持久化的大纲(逐章,按 chapter_no 升序)。
*
* 项目不存在 → 404项目存在但尚无大纲 → 200 空列表(非 404页面初次访问的常态)。
* 可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。
* 项目不存在 → 404项目存在但尚无大纲或该卷无章节→ 200 空列表(非 404
* DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
* 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
*/
@@ -312,13 +316,15 @@ export interface paths {
};
/**
* List Rules
* @description 规则列表(按读侧顺序)。规则页用
* @description 规则列表(带 id供前端删除 handle。项目不存在 → 404QA MEDIUM此前返误导性空 200
*/
get: operations["list_rules_projects__project_id__rules_get"];
put?: never;
/**
* Create Rule
* @description 新增一条规则201。非法 level / 空 content → FastAPI 422。
* @description 新增一条规则201。非法 level / 空 content → FastAPI 422;项目不存在 → 404
*
* 项目存在性须在 insert 前校验:否则 FK 违例会逃逸成 500QA H2而非干净的 404。
*/
post: operations["create_rule_projects__project_id__rules_post"];
delete?: never;
@@ -327,6 +333,29 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/rules/{rule_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Delete Rule
* @description 删除一条本作品规则204。项目不存在 → 404规则不存在 / 不属于该项目 → 404。
*
* 删规则是**作者显式动作**(不变量 #3规则增删不经 AI 静默写库。repo.delete 只 flush
* 端点提交;删不到行(未知 id / 跨项目)→ 不提交、抛 404。
*/
delete: operations["delete_rule_projects__project_id__rules__rule_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/style": {
parameters: {
query?: never;
@@ -1671,7 +1700,7 @@ export interface components {
level: "global" | "genre" | "style" | "project";
/**
* Content
* @description 规则正文
* @description 规则正文(首尾空白会被裁剪,不可全空白)
*/
content: string;
};
@@ -1685,9 +1714,16 @@ export interface components {
};
/**
* RuleView
* @description 规则视图创建后回显snake_case
* @description 规则视图(创建后回显 + 列表项snake_case
*
* `id` 是该规则行的稳定主键——前端规则页用它作删除 handleDELETE /rules/{rule_id})。
*/
RuleView: {
/**
* Id
* Format: uuid
*/
id: string;
/** Level */
level: string;
/** Content */
@@ -1859,8 +1895,11 @@ export interface components {
* @description 单条档位路由写入。
*/
TierRoutingInput: {
/** Tier */
tier: string;
/**
* Tier
* @enum {string}
*/
tier: "writer" | "analyst" | "light";
/** Provider */
provider: string;
/** Model */
@@ -2744,7 +2783,9 @@ export interface operations {
};
get_outline_projects__project_id__outline_get: {
parameters: {
query?: never;
query?: {
volume?: number | null;
};
header?: never;
path: {
project_id: string;
@@ -2874,6 +2915,36 @@ export interface operations {
};
};
};
delete_rule_projects__project_id__rules__rule_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
rule_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_style_projects__project_id__style_get: {
parameters: {
query?: never;

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { friendlyError } from "./messages";
import { friendlyError, friendlyFromApiError } from "./messages";
describe("friendlyError", () => {
it("maps LLM_UNAVAILABLE to copy + a settings action", () => {
@@ -26,3 +26,21 @@ describe("friendlyError", () => {
expect(friendlyError("WEIRD_CODE", " ").text).toBe("出错了,请稍后重试。");
});
});
describe("friendlyFromApiError", () => {
it("surfaces a business error envelope code + action (QA #2/#11)", () => {
const e = friendlyFromApiError({ error: { code: "LLM_UNAVAILABLE", message: "x" } });
expect(e.text).toContain("未连接");
expect(e.actionHref).toBe("/settings/providers");
});
it("maps a FastAPI 422 detail envelope to friendly VALIDATION copy (no leak)", () => {
const e = friendlyFromApiError({ detail: [{ msg: "Input should be >= 1", loc: ["volume"] }] });
expect(e.text).toBe("提交内容有误,请检查后重试。");
});
it("falls back to generic copy for null/unknown shapes", () => {
expect(friendlyFromApiError(null).text).toBe("出错了,请稍后重试。");
expect(friendlyFromApiError({ weird: 1 }).text).toBe("出错了,请稍后重试。");
});
});

View File

@@ -44,3 +44,22 @@ export function friendlyError(
const fallback = fallbackMessage?.trim();
return { text: fallback && fallback.length > 0 ? fallback : DEFAULT_TEXT };
}
// 从原始 API 错误对象openapi-fetch 的 `error`)提取友好文案。
// 兼容两种信封:业务错误 `{error:{code,message}}`ARCH §7.1)与 FastAPI 校验错误 `{detail:[...]}`。
// 422 一律映射为友好的 VALIDATION 文案(不泄露技术性 detail。未知形 → 通用兜底。
export function friendlyFromApiError(apiError: unknown): FriendlyError {
if (apiError && typeof apiError === "object") {
const env = apiError as {
error?: { code?: string; message?: string };
detail?: unknown;
};
if (env.error?.code) {
return friendlyError(env.error.code, env.error.message);
}
if (env.detail !== undefined) {
return friendlyError("VALIDATION");
}
}
return friendlyError(undefined);
}

View File

@@ -184,6 +184,19 @@ describe("mergeCharacterCards", () => {
const persisted = [card({ name: "甲" })];
expect(mergeCharacterCards(persisted, [])).toEqual(persisted);
});
it("preserves relations on the merged cards (Codex relation display)", () => {
const persisted = [
card({
name: "甲",
relations: [{ name: "乙", kind: "宿敌", note: "灭门之仇" }],
}),
];
const out = mergeCharacterCards(persisted, []);
expect(out[0]?.relations).toEqual([
{ name: "乙", kind: "宿敌", note: "灭门之仇" },
]);
});
});
describe("mergeWorldEntities", () => {

View File

@@ -5,6 +5,8 @@ import type {
OutlineChapterView,
} from "@/lib/api/types";
import {
distinctVolumes,
filterByViewVolume,
groupByVolume,
isCloseWindow,
windowBadgeLabel,
@@ -38,6 +40,48 @@ describe("groupByVolume", () => {
});
});
describe("distinctVolumes", () => {
it("returns sorted unique volume numbers", () => {
expect(
distinctVolumes([
ch({ no: 1, volume: 2 }),
ch({ no: 2, volume: 1 }),
ch({ no: 3, volume: 2 }),
]),
).toEqual([1, 2]);
});
it("handles undefined", () => {
expect(distinctVolumes(undefined)).toEqual([]);
});
});
describe("filterByViewVolume", () => {
const chapters = [
ch({ no: 1, volume: 1 }),
ch({ no: 2, volume: 1 }),
ch({ no: 3, volume: 2 }),
];
it("returns all chapters when view is 'all'", () => {
expect(filterByViewVolume(chapters, "all").map((c) => c.no)).toEqual([
1, 2, 3,
]);
});
it("keeps only the selected volume", () => {
expect(filterByViewVolume(chapters, 2).map((c) => c.no)).toEqual([3]);
});
it("returns empty for a volume with no chapters", () => {
expect(filterByViewVolume(chapters, 9)).toEqual([]);
});
it("handles undefined", () => {
expect(filterByViewVolume(undefined, "all")).toEqual([]);
});
});
describe("windowBadgeLabel", () => {
it("renders a range, half-open, or bare badge", () => {
expect(

View File

@@ -25,6 +25,28 @@ export function groupByVolume(
}));
}
// 视图卷过滤值:"all" = 全部卷;具体数字 = 只看该卷(对齐后端 GET ?volume
export type ViewVolume = number | "all";
// 已存大纲里出现过的卷号(升序、去重)——驱动「全部 / 卷 N」选择器选项。
export function distinctVolumes(
chapters: readonly OutlineChapterView[] | undefined,
): number[] {
const seen = new Set<number>();
for (const ch of chapters ?? []) seen.add(ch.volume);
return [...seen].sort((a, b) => a - b);
}
// 按视图卷过滤:选「全部」返回全部,选具体卷只留该卷(客户端过滤,避免重复请求)。
export function filterByViewVolume(
chapters: readonly OutlineChapterView[] | undefined,
view: ViewVolume,
): OutlineChapterView[] {
const all = [...(chapters ?? [])];
if (view === "all") return all;
return all.filter((ch) => ch.volume === view);
}
// 伏笔窗口徽标文案:`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
export function windowBadgeLabel(window: ForeshadowWindowView): string {
const from = window.expected_close_from;

View File

@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { friendlyFromApiError } from "@/lib/errors/messages";
import type { OutlineChapterView } from "@/lib/api/types";
export type OutlineStatus = "idle" | "generating" | "ready" | "error";
@@ -42,14 +43,12 @@ export function useOutline(initial: OutlineChapterView[]): UseOutline {
},
);
if (apiError || !data) {
const env = apiError as
| { error?: { code?: string; message?: string } }
| undefined;
const code = env?.error?.code ?? "OUTLINE_FAILED";
const message = env?.error?.message ?? "大纲生成失败,请重试。";
setError({ code, message });
// surface 真实错因(如 LLM_UNAVAILABLE→去设置 provider422→校验文案
// 而非永远的通用「大纲生成失败」QA #2原 env.error.code 漏读 422 的 detail 信封)。
const friendly = friendlyFromApiError(apiError);
setError({ code: "OUTLINE_FAILED", message: friendly.text });
setStatus("error");
toast(message, "error");
toast(friendly.text, "error");
return;
}
setChapters(data.chapters ?? []);

View File

@@ -46,8 +46,11 @@ export interface PaceReport {
}
// 文风漂移第四审C4 扩 T4.2):整体相似度 score + 段级漂移。
// `text` = 该漂移段从草稿逐字摘录的原文(内容锚,供前端内容匹配定位回炉目标,
// 不靠位置 idx旧数据/降级可为空串)。
export interface StyleDriftSegment {
idx: number;
text: string;
score: number;
label: string | null;
}
@@ -89,6 +92,7 @@ export interface StyleEvent {
score: number;
segments: {
idx: number;
text?: string | null;
score: number;
label?: string | null;
}[];
@@ -176,7 +180,14 @@ function asStyleSegments(v: unknown): StyleEvent["data"]["segments"] {
if (!Array.isArray(v)) return [];
return v.flatMap((x) =>
isRecord(x) && typeof x.idx === "number" && typeof x.score === "number"
? [{ idx: x.idx, score: x.score, label: nullableString(x.label) }]
? [
{
idx: x.idx,
text: nullableString(x.text),
score: x.score,
label: nullableString(x.label),
},
]
: [],
);
}
@@ -360,6 +371,7 @@ export function reduceReview(
score: event.data.score,
segments: event.data.segments.map((s) => ({
idx: s.idx,
text: s.text ?? "",
score: s.score,
label: s.label ?? null,
})),

View File

@@ -8,15 +8,28 @@ import {
} from "./sse";
describe("parseReviewBlock — style (C4 扩 T4.2)", () => {
it("parses a style frame", () => {
it("parses a style frame carrying the text anchor", () => {
const evt = parseReviewBlock(
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60,"label":"口语化"}]}',
'event:style\ndata:{"score":87,"segments":[{"idx":3,"text":"原文锚","score":60,"label":"口语化"}]}',
);
expect(evt).toEqual({
event: "style",
data: {
score: 87,
segments: [{ idx: 3, score: 60, label: "口语化" }],
segments: [{ idx: 3, text: "原文锚", score: 60, label: "口语化" }],
},
});
});
it("defaults missing segment text to null when parsing", () => {
const evt = parseReviewBlock(
'event:style\ndata:{"score":87,"segments":[{"idx":3,"score":60}]}',
);
expect(evt).toEqual({
event: "style",
data: {
score: 87,
segments: [{ idx: 3, text: null, score: 60, label: null }],
},
});
});
@@ -32,17 +45,20 @@ describe("reduceReview — style (replace-style like pace)", () => {
it("sets style report and marks reviewing", () => {
const evt: ReviewSseEvent = {
event: "style",
data: { score: 80, segments: [{ idx: 1, score: 50, label: "突兀" }] },
data: {
score: 80,
segments: [{ idx: 1, text: "原文锚", score: 50, label: "突兀" }],
},
};
const out = reduceReview(initialReviewState, evt);
expect(out.phase).toBe("reviewing");
expect(out.style).toEqual({
score: 80,
segments: [{ idx: 1, score: 50, label: "突兀" }],
segments: [{ idx: 1, text: "原文锚", score: 50, label: "突兀" }],
});
});
it("replaces (not accumulates) the style report and defaults label null", () => {
it("replaces (not accumulates) the style report and defaults text/label", () => {
const first = reduceReview(initialReviewState, {
event: "style",
data: { score: 70, segments: [{ idx: 0, score: 40 }] },
@@ -51,7 +67,12 @@ describe("reduceReview — style (replace-style like pace)", () => {
event: "style",
data: { score: 95, segments: [] },
});
expect(first.style?.segments[0]).toEqual({ idx: 0, score: 40, label: null });
expect(first.style?.segments[0]).toEqual({
idx: 0,
text: "",
score: 40,
label: null,
});
expect(second.style).toEqual({ score: 95, segments: [] });
});

View File

@@ -13,6 +13,9 @@ export interface AcceptOutcome {
result: AcceptResponse | null;
// 409 CONFLICT_UNRESOLVED 缺判下标(高亮报告卡)。
missingIndices: number[];
// 后端验收闸报「有未裁决冲突」——即便本页当前未显示冲突(快照过期/未审稿)。
// 调用方据此回拉持久化最新审稿、回灌冲突供裁决(修「显示 0 冲突却被拦」)。
conflictUnresolved: boolean;
}
export interface UseAccept {
@@ -58,16 +61,15 @@ export function useAccept(): UseAccept {
const code = env?.error?.code;
const missing = env?.error?.details?.missing_conflict_indices ?? [];
if (code === "CONFLICT_UNRESOLVED") {
toast("尚有冲突未裁决,请先处理高亮项", "error");
return { result: null, missingIndices: missing };
return { result: null, missingIndices: missing, conflictUnresolved: true };
}
toast("验收失败,请重试(正文未丢失)", "error");
return { result: null, missingIndices: [] };
return { result: null, missingIndices: [], conflictUnresolved: false };
}
setResult(data);
setStatus("accepted");
toast("本章已验收", "success");
return { result: data, missingIndices: [] };
return { result: data, missingIndices: [], conflictUnresolved: false };
},
[toast],
);

View File

@@ -19,15 +19,15 @@ describe("isRuleLevel", () => {
describe("groupByLevel", () => {
it("groups rules by level, unknown level falls to project", () => {
const rules: RuleView[] = [
{ level: "global", content: "g1" },
{ level: "project", content: "p1" },
{ level: "weird", content: "x1" },
{ id: "r1", level: "global", content: "g1" },
{ id: "r2", level: "project", content: "p1" },
{ id: "r3", level: "weird", content: "x1" },
];
const groups = groupByLevel(rules);
expect(groups.global).toEqual([{ level: "global", content: "g1" }]);
expect(groups.global).toEqual([{ id: "r1", level: "global", content: "g1" }]);
expect(groups.project).toEqual([
{ level: "project", content: "p1" },
{ level: "weird", content: "x1" },
{ id: "r2", level: "project", content: "p1" },
{ id: "r3", level: "weird", content: "x1" },
]);
expect(groups.genre).toEqual([]);
});

View File

@@ -16,6 +16,8 @@ export interface UseRules {
level: RuleLevel,
content: string,
) => Promise<boolean>;
// 删除一条规则(乐观移除 + 失败回滚 + Toast
remove: (projectId: string, ruleId: string) => Promise<boolean>;
}
// 规则页UX §7列出 + 新增规则。乐观追加,失败回滚(仿 useForeshadow
@@ -31,7 +33,12 @@ export function useRules(initial: RuleView[]): UseRules {
return false;
}
const snapshot = items;
const optimistic: RuleView = { level, content: content.trim() };
// 乐观项需一个临时 id删除 handle 之前);服务端返回后被权威行替换。
const optimistic: RuleView = {
id: crypto.randomUUID(),
level,
content: content.trim(),
};
setItems((prev) => [...prev, optimistic]);
setBusy(true);
try {
@@ -58,5 +65,29 @@ export function useRules(initial: RuleView[]): UseRules {
[items, toast],
);
return { items, busy, add };
const remove = useCallback<UseRules["remove"]>(
async (projectId, ruleId) => {
const snapshot = items;
setItems((prev) => prev.filter((r) => r.id !== ruleId));
setBusy(true);
try {
const { error } = await api.DELETE(
"/projects/{project_id}/rules/{rule_id}",
{ params: { path: { project_id: projectId, rule_id: ruleId } } },
);
if (error) {
setItems(snapshot); // 回滚
toast("删除规则失败,请稍后重试。", "error");
return false;
}
toast("已删除规则", "success");
return true;
} finally {
setBusy(false);
}
},
[items, toast],
);
return { items, busy, add, remove };
}

View File

@@ -59,7 +59,7 @@ export function defaultModelFor(providerId: string): string {
// 单条档位路由的可编辑草稿(纯数据)。
export interface RoutingDraft {
tier: string;
tier: Tier;
provider: string;
model: string;
}
@@ -96,7 +96,7 @@ export function applyProviderChange(
// 草稿 → PUT body 的 tier_routing只取选了 provider+model 的行(不可变)。
export function draftsToRoutingInput(
drafts: RoutingDraft[],
): { tier: string; provider: string; model: string }[] {
): { tier: Tier; provider: string; model: string }[] {
return drafts
.filter((d) => d.provider.trim() !== "" && d.model.trim() !== "")
.map((d) => ({ tier: d.tier, provider: d.provider, model: d.model }));

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { locateDriftSegment } from "./locateSegment";
const DRAFT = "第一段没问题。\n\n他乘坐迈巴赫扬长而去留下一地尘土。\n\n结尾。";
const PARAS = DRAFT.split(/\n{2,}/);
describe("locateDriftSegment", () => {
it("locates by content anchor, ignoring a mismatched idx", () => {
// idx 指向第 0 段,但 text 是第 2 段——内容锚优先,定位到真实原文。
const located = locateDriftSegment(PARAS, {
idx: 0,
text: "他乘坐迈巴赫扬长而去,留下一地尘土。",
});
expect(located).toBe("他乘坐迈巴赫扬长而去,留下一地尘土。");
});
it("trims the anchor before matching", () => {
const located = locateDriftSegment(PARAS, {
idx: 99,
text: " 他乘坐迈巴赫扬长而去,留下一地尘土。 ",
});
expect(located).toBe("他乘坐迈巴赫扬长而去,留下一地尘土。");
});
it("returns null when the anchor text is not found (#9 guard — no silent no-op)", () => {
// 自带原文但终稿里已被改动——不回退到可能错位的 idx判定为定位失败。
expect(
locateDriftSegment(PARAS, { idx: 1, text: "他乘坐保时捷离开了。" }),
).toBeNull();
});
it("falls back to positional idx when no text anchor (legacy data)", () => {
expect(locateDriftSegment(PARAS, { idx: 1, text: "" })).toBe(
"他乘坐迈巴赫扬长而去,留下一地尘土。",
);
});
it("returns null when no anchor and idx is out of range (#9 guard)", () => {
expect(locateDriftSegment(PARAS, { idx: 9, text: "" })).toBeNull();
});
it("returns null when no anchor and the positional paragraph is blank", () => {
expect(locateDriftSegment(["", " "], { idx: 0, text: "" })).toBeNull();
});
});

View File

@@ -0,0 +1,26 @@
// 文风漂移段的「内容锚」定位QA H3 / #9用漂移段携带的原文 `text` 在终稿里做
// 内容匹配定位回炉目标,而不是用位置索引 idx——因为审稿端的分段方式与前端按空行切段
// 不一定一致,靠 idx 取段会命中错段、越界则静默落空。纯逻辑,便于 node 环境单测。
import type { StyleDriftSegment } from "@/lib/review/sse";
// 在终稿里定位漂移段原文:
// 1) 段自带 text 且能在终稿里逐字搜到 → 返回该原文(最可靠,内容锚)。
// 2) text 为空/搜不到(旧数据、或作者已手改导致原文不在)→ 回退到位置 idx 取段;
// idx 越界或取到空段 → 返回 null由调用方就 #9 给出「无法定位」提示,不静默 no-op
export function locateDriftSegment(
finalParas: readonly string[],
segment: Pick<StyleDriftSegment, "idx" | "text">,
): string | null {
const anchor = segment.text.trim();
if (anchor.length > 0) {
// 内容匹配:原文需在某一段里出现(终稿整体含该原文即视为可定位)。
const found = finalParas.some((p) => p.includes(anchor));
if (found) return anchor;
// 自带原文但终稿里搜不到(已被手改)→ 不回退到可能错位的 idx直接判定为定位失败。
return null;
}
// 无内容锚(旧数据)→ 退回位置 idx 取段(兼容旧留痕)。
const para = finalParas[segment.idx]?.trim() ?? "";
return para.length > 0 ? para : null;
}

View File

@@ -59,13 +59,13 @@ describe("normalizeFingerprint", () => {
});
describe("normalizeStyleDrift", () => {
it("tightens style dict into report, filtering bad segments", () => {
it("tightens style dict into report, carrying text anchor, filtering bad segments", () => {
const out = normalizeStyleDrift(
reviewItem({
style: {
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
{ idx: 5, score: 72 },
"junk",
],
@@ -75,12 +75,24 @@ describe("normalizeStyleDrift", () => {
expect(out).toEqual({
score: 87,
segments: [
{ idx: 3, score: 60, label: "口语化" },
{ idx: 5, score: 72, label: null },
{ idx: 3, text: "他乘坐迈巴赫扬长而去。", score: 60, label: "口语化" },
{ idx: 5, text: "", score: 72, label: null },
],
});
});
it("defaults missing segment text to empty string", () => {
const out = normalizeStyleDrift(
reviewItem({ style: { score: 80, segments: [{ idx: 0, score: 50 }] } }),
);
expect(out?.segments[0]).toEqual({
idx: 0,
text: "",
score: 50,
label: null,
});
});
it("defaults score to 100 (degrade态) and returns null when missing", () => {
expect(normalizeStyleDrift(reviewItem({ style: {} }))).toEqual({
score: 100,
@@ -92,10 +104,22 @@ describe("normalizeStyleDrift", () => {
});
describe("narrowStyleEvent", () => {
it("narrows SSE style event data with label fallback", () => {
it("narrows SSE style event data with text anchor + label fallback", () => {
expect(
narrowStyleEvent({
score: 90,
segments: [{ idx: 1, text: "原文锚", score: 50 }],
}),
).toEqual({
score: 90,
segments: [{ idx: 1, text: "原文锚", score: 50, label: null }],
});
expect(
narrowStyleEvent({ score: 90, segments: [{ idx: 1, score: 50 }] }),
).toEqual({ score: 90, segments: [{ idx: 1, score: 50, label: null }] });
).toEqual({
score: 90,
segments: [{ idx: 1, text: "", score: 50, label: null }],
});
});
it("returns degrade态 for non-object data", () => {

View File

@@ -52,6 +52,10 @@ function asOptionalString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
function asString(v: unknown): string {
return typeof v === "string" ? v : "";
}
// 把松散 style dict 收窄成漂移报告;缺失/非 dict → null不渲染
export function normalizeStyleDrift(
item: ReviewHistoryItem | undefined,
@@ -64,6 +68,7 @@ export function normalizeStyleDrift(
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
text: asString(s["text"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));
@@ -82,6 +87,7 @@ export function narrowStyleEvent(data: unknown): StyleDriftReport {
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
text: asString(s["text"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));

View File

@@ -44,6 +44,7 @@ describe("toCreateRequest", () => {
sellingPoints: ["逆袭", "系统流"],
structure: "三幕",
premise: " ",
protagonist: "",
theme: "抗争",
};
expect(toCreateRequest(form)).toEqual({
@@ -56,6 +57,30 @@ describe("toCreateRequest", () => {
structure: "三幕",
});
});
// QA H1 回归立意premise与主角/金手指protagonist是两个独立输入
// 不能互相覆盖——提交时各占一段合并进 premise。
it("merges premise and protagonist into premise without overwriting", () => {
const form: WizardForm = {
...emptyWizardForm,
title: "书",
premise: "凡人逆袭的代价",
protagonist: "主角林川,金手指=吞噬术,限制:每次吞噬折寿",
};
const req = toCreateRequest(form);
expect(req.premise).toBe(
"凡人逆袭的代价\n\n主角/金手指:主角林川,金手指=吞噬术,限制:每次吞噬折寿",
);
});
it("keeps protagonist alone when premise is empty", () => {
const form: WizardForm = {
...emptyWizardForm,
title: "书",
protagonist: "主角设定",
};
expect(toCreateRequest(form).premise).toBe("主角/金手指:主角设定");
});
});
// wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。

View File

@@ -8,6 +8,7 @@ export interface WizardForm {
sellingPoints: string[];
structure: string;
premise: string;
protagonist: string;
theme: string;
}
@@ -20,6 +21,7 @@ export const emptyWizardForm: WizardForm = {
sellingPoints: [],
structure: "",
premise: "",
protagonist: "",
theme: "",
};
@@ -50,11 +52,17 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
const v = s.trim();
return v.length > 0 ? v : null;
};
// 立意premise与主角/金手指protagonist是两个独立输入但 M1 projects 表只有
// premise 一个字段 → 合并进 premise二者各占一段互不覆盖QA H1此前共用同一字段
const premise =
[trim(form.premise), form.protagonist.trim() ? `主角/金手指:${form.protagonist.trim()}` : null]
.filter((s): s is string => s !== null)
.join("\n\n") || null;
return {
title: form.title.trim(),
genre: trim(form.genre),
logline: trim(form.logline),
premise: trim(form.premise),
premise,
theme: trim(form.theme),
selling_points: form.sellingPoints,
structure: trim(form.structure),

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,86 @@
# Web Frontend QA Report
## 1. Executive Summary
**Overall health: Solid at the contract/logic layer, blocked at the browser layer.**
- **Areas tested:** 15 (12 functional+API areas, 2 full-LLM E2E areas, 1 cross-route browser smoke pass).
- **Backend contract + component logic:** Largely sound. Happy paths, validation (422), error envelopes (404/409), snake_case alignment, optimistic-update/rollback, and a11y structure mostly check out across all areas.
- **Full LLM E2E that genuinely ran:** write-sse (real Kimi draft stream, 1261 tokens), toolbox-generators (all 12 generators + 3 ingest flows), style (16-dim fingerprint + refine), chain (API + awaiting/resume seam verified against real jobs).
- **What could NOT be verified (environmental):** The running Next dev server had a corrupted `.next` build cache — every `/projects/[id]/*` SSR route returned HTTP 500 ("Cannot find module openapi-fetch@0.13.8.js"), and client JS chunks 404'd so React never hydrated. This blocked ALL in-browser/visual/keyboard/a11y verification. Root cause: stale build + two concurrent `next dev` processes; not a source defect. Fix: kill duplicate server, `rm -rf apps/web/.next`, restart.
**Issue counts (de-duplicated): 23 total**
- **CRITICAL: 2** (1 functional, 1 build-cache infra)
- **HIGH: 4** (1 functional, 3 infra — same root cause)
- **MEDIUM: 6**
- **LOW: 11**
The two functional CRITICAL/HIGH items are real and independent of the build issue. The build-cache 500 is a single environmental root cause counted once as CRITICAL (smoke pass) and re-observed as HIGH across multiple areas.
## 2. All Recorded Errors (de-duplicated, CRITICAL → LOW)
| Severity | Area / Component | Symptom | Repro |
|---|---|---|---|
| CRITICAL | write-sse — `routers/projects.py::stream_draft` | POST /draft for a NON-EXISTENT project returns 200 + a full LLM-generated chapter instead of 404 — burns a paid LLM call; sibling endpoints correctly 404 | `curl -X POST /projects/0000…0000/chapters/7/draft -H 'Accept: text/event-stream'` → 200 + token/done |
| CRITICAL | Build cache — `apps/web/.next` (all 10 project routes + hydration) | Corrupt `.next`: server bundles require missing `vendor-chunks/openapi-fetch@0.13.8.js` → all `/projects/[id]/*` routes 500; client JS chunks 404 → React never hydrates → entire app non-interactive | `curl localhost:3000/projects/{PID}/write` → 500; type valid title in wizard → 下一步 stays disabled |
| HIGH | ProjectWizard.tsx (StepPremise + StepProtagonist) | Step 3 (立意) and Step 4 (主角/金手指) bind to the SAME `form.premise` field — step-4 input silently overwrites step-3; both cannot be saved | /projects/new → type 立意 (step3) → type 主角 (step4) → submit → only step-4 text persists |
| HIGH | Build-cache 500 (re-observed in foreshadow / codex / skills / review / nav / chain) | Same missing-openapi-fetch vendor chunk 500s every `/projects/[id]/*` route, blocking browser QA | `curl localhost:3000/projects/{PID}/{route}` → 500 |
| HIGH | GET /skills backend registry | Skills registry empty (`{"skills":[]}`); loads only from DB `skills` table with no seed rows and no POST/seed endpoint → SkillsPage permanently shows "暂无已注册技能" | `curl localhost:8000/skills``{"skills":[]}` |
| HIGH | env — duplicate `next dev` processes | Two concurrent `next dev` on same `apps/web` clobber shared `.next` → likely root cause of the corrupt build | `ps aux \| grep 'next dev'` → two PIDs |
| HIGH | rules — `routers/rules.py` + `rule_repo.py` | POST rule to non-existent project returns 500 (unhandled FK violation) instead of 404 | `curl -X POST /projects/{random-uuid}/rules -d '{"level":"project","content":"x"}'` → 500 |
| HIGH | ReviewReport.tsx + style.md prompt + review_node | One-click 回炉 can target wrong/empty paragraph: style-auditor LLM segment idx and frontend `split(/\n{2,}/)` paragraph index use unrelated splitting schemes (no shared contract) | Draft with single-newline segments → style segments idx>0 → 回炉 refines wrong/empty para |
| MEDIUM | OutlineEditor.tsx + server.ts fetchOutline | 卷号 volume selector is misleading: GET ignores volume filter, editor always renders ALL volumes; picker only affects POST generate | Change 卷号 to 2 → all volumes still listed |
| MEDIUM | useOutline.ts:44-49 | 422 validation errors read `apiError.error.code`, but 422 uses `{detail:[...]}` → degrades to generic "OUTLINE_FAILED" message | POST /outline volume=0 → generic error, validation cause lost |
| MEDIUM | settings PUT /settings/providers + ProvidersSettings | PUT accepts arbitrary/unknown tier+provider (no enum/whitelist), upsert-only with no DELETE → bogus tier_routing row persists invisibly, pollutes gateway config | `PUT {tier_routing:[{tier:'bogus',…}]}` → 200, persists, unremovable via API |
| MEDIUM | backend char persist/read + CodexPage.tsx | Character relations silently dropped on persist/read (GET returns `relations:[]` despite ingest); codex list never renders relations anyway | POST character with relations → GET → `relations:[]` |
| MEDIUM | backend POST /projects/{id}/characters | Character ingest non-idempotent: re-ingesting same name creates duplicate row → duplicate chips (UI dedup only session-vs-persisted) | POST same card twice → GET count 2 |
| MEDIUM | foreshadow PATCH to_status (backend) | Invalid `to_status` value returns 500 INTERNAL instead of 422 VALIDATION; `to_status` is a free string, not enum-validated | `PATCH /foreshadow/FS-01 -d '{"to_status":"BOGUS"}'` → 500 |
| MEDIUM | ChainPage.tsx:61 + ChainAdjudication.tsx:102 | Chain run/resume failures call `friendlyError(undefined)` — discard error code/message; 503 LLM_UNAVAILABLE loses its "go to settings" action link | Trigger run with no provider → generic "出错了" toast, no settings link |
| MEDIUM | generation.py list_rules (GET rules) | GET rules for non-existent project returns 200 `{"rules":[]}` instead of 404, masking bad project ids | `curl /projects/{random-uuid}/rules` → 200 empty |
| LOW | rules / templates / foreshadow / projects / codex (CRUD gaps) | No DELETE/edit endpoint for rules; no DELETE /projects; whitespace-only content accepted server-side (min_length before trim) on rules/projects/foreshadow → unremovable junk rows | `curl -X POST .../rules -d '{"content":" "}'` → 201 |
| LOW | useDraftStream.ts (pre-stream error) | Pre-stream error envelope: extracts code+message but drops `error.request_id` → weakens end-to-end greppability | Force 503 before stream → surfaced error has no request_id |
| LOW | FastAPI 422 envelope inconsistency (write-sse, outline, accept, injection) | 422 errors return raw FastAPI `{detail:[...]}` instead of project `{error:{code,message,request_id}}` envelope → no request_id, no code mapping | `curl /projects/{PID}/chapters/abc/injection` → 422 `{detail:…}` |
| LOW | apps/api accept endpoint (useAccept.ts) | Accept 422 (empty final_text) uses raw FastAPI detail shape, not standard envelope (subset of above, accept-specific) | POST accept `{final_text:'',decisions:[]}` → 422 raw detail |
| LOW | ProvidersSettings.tsx testConnection | Test-connection collapses distinct 404 "needs key" vs 503 "probe failed" into one generic toast | Click test on unconfigured vs unreachable provider → identical toast |
| LOW | ProjectWizard.tsx submit + cards.ts + useGenerator | Create/generate failures show generic toast; backend validation detail / specific message discarded | Force a 422 → generic toast, backend reason lost |
| LOW | CommandPalette.tsx | Incomplete combobox a11y: input not `role=combobox`, no `aria-activedescendant`; `<li role=option>` has no `id` → AT won't announce highlighted command | Open ⌘K, ArrowDown with screen reader → no announcement |
| LOW | Toast.tsx | Error toasts use polite live region (`aria-live=polite`/`role=status`) instead of assertive → failures don't interrupt SR | `show('…','error')` → announced politely |
| LOW | RulesPage.tsx | Rule content textarea has no accessible label (placeholder only); list uses array index as React key (forced by GET not returning rule id) | Inspect 新增规则 form |
| LOW | TemplateFiller.tsx:23-40 | After transient GET /templates failure, shows misleading "暂无模板" empty state and never retries on reopen (useEffect guards on `templates===null`, but failure sets `[]`) | Open filler while API down → toast + empty; restore API, reopen → still empty |
| LOW | style refine_segment (routers/style.py) | Refine endpoint never validates chapter existence; any chapter_no returns 200 | `POST .../chapters/99/refine -d '{"segment":"x"}'` → 200 |
| LOW | TemplatesManager + backend | No max-length cap on title/body (5000-char title accepted, rendered inline unbounded); backend silently ignores unknown request fields | `POST /templates` long title → 201 |
| LOW | RegisterForm.tsx (toInt) | Non-integer numeric input (e.g. 'abc', '1.5') silently dropped with no validation feedback | Type 'abc' in 埋设章, submit → planted_at omitted silently |
| LOW | toolbox empty ingest | Empty ingest payload (`world_entities:[]`) returns 201 no-op instead of 4xx (client-guarded so UI-unreachable) | `POST .../glossary/ingest -d '{"world_entities":[]}'` → 201 |
*Several minor chain/style frontend timing edges (poll progress frozen 0→100, possible stale done-edge re-fire, no re-refine on identical segment text, ChainAdjudication 409 stuck panel, redundant poll.reset effect re-run) are noted in raw data as LOW/needs-browser and folded here to avoid noise.*
## 3. Per-Area Status
| Area | Status | Coverage note |
|---|---|---|
| projects-list+create | issues-found (1 HIGH) | API fully exercised via curl; wizard click-through needs-browser |
| outline-editor | issues-found (2 MED) | API + logic verified; read-only generate-only (no PUT by design); ⚑badge path dead vs real data |
| rules | issues-found (1 HIGH, 3 MED) | Full API curl + component read; a11y/SR needs-browser |
| foreshadow-board | issues-found (1 HIGH infra, 1 MED) | API fully exercised; board UI blocked by build 500 |
| templates | pass (3 LOW only) | CRUD fully verified per contract; known blank-field HIGH confirmed fixed |
| settings-providers | issues-found (1 MED) | API + OAuth contracts verified; no plaintext leak; live OAuth completion not run |
| codex (设定库) | issues-found (2 MED) | API contract verified; in-browser blocked by build 500 |
| skills | issues-found (2 HIGH) | Logic correct but registry empty + route 500; populated-state untested |
| review-display | issues-found (1 HIGH) | API + all normalizers verified; visual panels blocked by build 500 |
| nav-shell+command | issues-found (1 MED, 1 LOW a11y) | All routes/commands map correctly; 28 unit tests pass; browser blocked |
| write-sse (LLM E2E) | issues-found (1 CRITICAL) | Real Kimi stream ran end-to-end; non-empty injection path untestable (no chars/entities) |
| toolbox-generators (LLM E2E) | pass (2 LOW only) | All 12 generators + 3 ingest flows ran real Kimi; 38 unit tests pass |
| style (LLM E2E) | issues-found (1 HIGH) | Full fingerprint+refine ran real Kimi; drift idx-alignment is contract analysis |
| chain (LLM E2E) | issues-found (1 MED) | API + awaiting/resume seam verified vs real jobs; 12 unit tests; browser blocked |
| browser smoke (all 14 routes) | issues-found (2 CRITICAL infra) | 4 top-level routes SSR 200; 10 project routes 500; zero hydration anywhere |
## 4. Notable Gaps / Could Not Be Verified
- **All in-browser / visual / keyboard / screen-reader verification** — blocked by the corrupt `.next` build (project routes 500, no hydration). Every a11y finding (combobox, toast severity, textarea label) is source-confirmed only; visual rendering of kanban, beat bars, annotated-text highlights, accept-gate disabled state, typewriter stream, and optimistic-update rendering is unverified.
- **Skills populated-state** — registry is empty with no seed/write endpoint, so builtin/custom/community grouping + tier/genre/reads-writes badges were never rendered with real data (logic verified by code + unit tests only).
- **Style one-click 回炉 idx-alignment (HIGH)** — could not be deterministically triggered: existing PID has no fingerprint, so the style auditor degrades to score=100/segments=[]. Finding is from code/contract analysis; needs a learned fingerprint + a write+review run to confirm live.
- **write-sse non-empty injection panel** — the test PID has zero characters and zero world_entities, so `selected[]`/author_pin reason badge path is structurally untestable on this data.
- **Live 503 / LLM_UNAVAILABLE paths** — providers are configured, so 503 branches (outline, chain, settings test, draft pre-stream) could not be forced; verified by code reading only.
- **Foreshadow ⚑ / 可回收 badge** — all 30 chapters of the existing PID have empty `foreshadow_windows`, so this render path is exercised only by unit tests.
- **409 CONFLICT_UNRESOLVED ingest path (toolbox)** — could not trigger a real continuity conflict on throwaway data; adjudication logic verified by reading only.
- **Cleanup residue:** No DELETE /projects endpoint exists (405), so ~8 throwaway QA projects created during mutation testing remain in the DB. One chain job (project 887af2d4) is still running. The existing rich PID (71b3725e…) data was never modified.

View File

@@ -0,0 +1,426 @@
[
{
"area": "projects-list+create",
"severity": "HIGH",
"component": "apps/web/components/ProjectWizard.tsx (StepProtagonist + StepPremise)",
"symptom": "Step 4 (主角/金手指) and Step 3 (立意/premise) edit the SAME form field form.premise. Whatever the user types in step 4 overwrites their step-3 立意 input (and vice-versa if they go back). The '主角/金手指概要' and '立意' cannot coexist; only the last-edited survives into POST /projects.",
"evidence": "StepPremise: value={form.premise} onChange={(e)=>update({premise:e.target.value})}. StepProtagonist: value={form.premise} onChange={(e)=>update({premise:e.target.value})} — identical binding. WizardForm has no separate protagonist field.",
"repro": "Open /projects/new → fill title (step1) → next to step3, type 立意 text → next to step4, type 主角设定 text → submit. Resulting project.premise contains only the step-4 text; the step-3 立意 is lost. (Confirmed by code; needs-browser to screenshot.)"
},
{
"area": "projects-list+create",
"severity": "LOW",
"component": "apps/web/components/ProjectWizard.tsx submit (error toast)",
"symptom": "On create failure the user always sees the same generic toast '创建作品失败,请重试' regardless of cause (422 validation vs 503 vs 5xx). Validation detail from backend ({detail:[...]}) is discarded.",
"evidence": "if (error || !data) { toast('创建作品失败,请重试','error') } — no inspection of error.detail/error.error.message.",
"repro": "Force a 422 (only reachable if title bypasses client guard) → toast shows generic message; backend's specific reason not shown."
},
{
"area": "projects-list+create",
"severity": "LOW",
"component": "backend POST /projects validation",
"symptom": "Whitespace-only title accepted (HTTP 201). min_length=1 does not strip whitespace, so ' ' creates a blank-looking project.",
"evidence": "curl POST {\"title\":\" \"} → HTTP 201, title:' '. Frontend trims so not reachable via wizard, but API contract is permissive.",
"repro": "curl -X POST /projects -d '{\"title\":\" \"}' → 201."
},
{
"area": "outline-editor",
"severity": "MEDIUM",
"component": "apps/web/components/outline/OutlineEditor.tsx (volume input) + lib/api/server.ts fetchOutline",
"symptom": "Volume selector is misleading: it only parameterizes POST generate, but GET /outline ignores the volume filter and the editor always renders ALL volumes. After generating into volume N, the page still shows every volume; user has no way to view a single volume despite the picker.",
"evidence": "curl 'GET /projects/71b3.../outline?volume=2' returns count: 30 (filter ignored). OutlineEditor.tsx:24 groupByVolume(chapters) renders every group; volume state (line 23) is used only at generate() line 50.",
"repro": "Open outline page; change 卷号 to 2; observe all volumes still listed; GET endpoint takes no volume query."
},
{
"area": "outline-editor",
"severity": "MEDIUM",
"component": "apps/web/lib/outline/useOutline.ts:44-49",
"symptom": "422 validation errors from POST /outline are not surfaced with their detail — the hook reads env.error.code but FastAPI 422 uses {detail:[...]} (no .error), so it always falls back to generic 'OUTLINE_FAILED' / '大纲生成失败,请重试。'",
"evidence": "POST {volume:0} -> 422 {detail:[{type:greater_than_equal,...}]}; hook code: const env = apiError as {error?:{code?,message?}}; code = env?.error?.code ?? 'OUTLINE_FAILED'. openapi: 422 schema is HTTPValidationError (detail[]), business errors use ErrorEnvelope (error{code}).",
"repro": "Trigger POST /outline with volume=0 (UI clamps to >=1 so only reachable via direct API or a non-clamped path); error banner shows generic message, loses validation cause."
},
{
"area": "outline-editor",
"severity": "LOW",
"component": "apps/web/lib/outline/useOutline.ts:55-57 + OutlineEditor empty state",
"symptom": "Generation that yields zero chapters still reports success: status=ready, toast '大纲已生成', but the page renders the '暂无大纲' empty placeholder. Misleading success for an effectively no-op generate (e.g. project lacking premise/structure).",
"evidence": "POST {volume:1} on a content-less throwaway project returned HTTP 200 {chapters:[]}; nothing persisted (subsequent GET count: 0). Hook setChapters([]) + success toast; volumes.length===0 -> placeholder shown.",
"repro": "Create project with no premise/theme/structure, POST /outline; observe 200 + empty chapters; UI would toast success yet show empty state."
},
{
"area": "outline-editor",
"severity": "LOW",
"component": "outline area (task scope vs implementation)",
"symptom": "QA scope expects PUT edits (add/remove/reorder chapter, beats, foreshadow_windows) and optimistic-save/rollback, but none exist — outline is generate-and-display only, fully read-only after generation.",
"evidence": "openapi /projects/{id}/outline exposes only get+post; schema.d.ts line 295 put?: never. OutlineChapterRow.tsx renders static beats/badges + a '写此章' Link; no inputs, no save handler.",
"repro": "Inspect components — no editable fields, no PUT call anywhere; openapi has no PUT/PATCH/DELETE for outline."
},
{
"area": "rules",
"severity": "HIGH",
"component": "apps/api/ww_api/routers/rules.py + packages/core/ww_core/domain/rule_repo.py",
"symptom": "POST a rule to a non-existent project_id returns HTTP 500 (generic INTERNAL) instead of 404.",
"evidence": "curl -X POST http://localhost:8000/projects/00000000-0000-0000-0000-000000000000/rules -d '{\"level\":\"project\",\"content\":\"x\"}' -> 500 {\"error\":{\"code\":\"INTERNAL\",\"message\":\"服务器内部错误\",...}}. SqlRuleWriteRepo.create (rule_repo.py:44-49) inserts+flushes with no existence check; the FK violation escapes as 500.",
"repro": "POST /projects/{random-uuid}/rules with a valid body -> observe 500. Expected 404 (project not found)."
},
{
"area": "rules",
"severity": "MEDIUM",
"component": "apps/api/ww_api/schemas/rules.py + routers/rules.py",
"symptom": "Whitespace-only rule content is accepted and persisted (201). Server min_length:1 validates the raw string before trimming; no strip on the server.",
"evidence": "curl -X POST .../rules -d '{\"level\":\"project\",\"content\":\" \"}' -> 201; subsequent GET returns {\"level\":\"project\",\"content\":\" \"}. schemas/rules.py:21 content: str = Field(min_length=1) with no strip/validator.",
"repro": "POST a rule whose content is only spaces -> 201, junk row stored. (Frontend trims client-side so its own UI won't send this, but the API contract is permissive and the row is unremovable — see DELETE gap.)"
},
{
"area": "rules",
"severity": "MEDIUM",
"component": "apps/api/ww_api/routers/generation.py (list_rules)",
"symptom": "GET rules for a non-existent project returns 200 {\"rules\":[]} instead of 404, masking bad project ids.",
"evidence": "curl http://localhost:8000/projects/00000000-0000-0000-0000-000000000000/rules -> 200 {\"rules\":[]}. generation.py:377-384 queries rules by project_id with no project-existence check. Note the rules ROUTE page (app/.../rules/page.tsx) calls fetchProject first and would 404 in the UI, but the API itself does not.",
"repro": "GET /projects/{random-uuid}/rules -> 200 empty list."
},
{
"area": "rules",
"severity": "MEDIUM",
"component": "rules API surface (no router)",
"symptom": "No DELETE (or edit) endpoint for rules — CRUD is create+read only. A mistakenly-added rule (e.g. the whitespace row above) cannot be removed via API or UI.",
"evidence": "grep for delete in routers/rules.py + rule_repo.py = none; openapi.json exposes only POST+GET on /projects/{project_id}/rules. RulesPage.tsx renders rules as plain <li> with no delete control.",
"repro": "Add any rule, then attempt to remove it: no endpoint/UI exists."
},
{
"area": "rules",
"severity": "LOW",
"component": "apps/web/components/rules/RulesPage.tsx",
"symptom": "Rule-content textarea has no accessible label (only a placeholder), failing the label/role a11y bar; screen readers announce no field name.",
"evidence": "RulesPage.tsx:62-68 <textarea> has placeholder but no <label htmlFor>/aria-label/id. The level <select> (lines 47-60) is correctly wrapped in a <label>.",
"repro": "Inspect the 新增规则 form; the textarea has no programmatic label. needs-browser to confirm SR announcement but verifiable from source."
},
{
"area": "rules",
"severity": "LOW",
"component": "apps/web/components/rules/RulesPage.tsx",
"symptom": "Rule list uses array index as React key (key={i}); reordering/optimistic-replace can cause subtle reconciliation issues.",
"evidence": "RulesPage.tsx:92 key={i}. Root cause is the contract: RuleView (GET) returns only {level,content} with no id, so no stable key is available.",
"repro": "Code review; would need GET to expose a rule id to fix properly."
},
{
"area": "foreshadow-board",
"severity": "HIGH",
"component": "apps/web/app/projects/[id]/foreshadow/page.tsx (running dev server)",
"symptom": "Foreshadow board page does not render — returns Next.js _error page with statusCode 500 instead of the kanban board",
"evidence": "curl http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/foreshadow → __NEXT_DATA__ {pageProps:{statusCode:500}}, err.message: 'Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' from .next/server/app/projects/[id]/foreshadow/page.js. Source + node_modules (openapi-fetch@0.13.8) are correct, so this is a stale .next build cache for this route, not a source defect. Other routes (/, /projects) render OK.",
"repro": "Open http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/foreshadow in browser; observe error page. Likely cleared by rm -rf apps/web/.next and restarting dev server."
},
{
"area": "foreshadow-board",
"severity": "MEDIUM",
"component": "backend PATCH /projects/{id}/foreshadow/{code} (consumed by lib/foreshadow/useForeshadow.transition)",
"symptom": "Invalid to_status value returns HTTP 500 INTERNAL instead of 422 VALIDATION; frontend cannot map it to a friendly reason",
"evidence": "curl -X PATCH .../foreshadow/FS-01 -d '{\"to_status\":\"BOGUS\"}' → 500 {error:{code:INTERNAL,details:{}}}. Same for 'open' (case) and 'PARTIAL ' (trailing space). to_status is a free string in ForeshadowTransitionRequest schema. UI buttons only emit valid NEXT_STATUS values so it is not directly reachable via the board UI, but the contract accepts arbitrary strings and useForeshadow has no special handling — it would rollback + show generic '操作未通过校验' toast.",
"repro": "PATCH any existing foreshadow with body {\"to_status\":\"BOGUS\"} → 500. Backend should validate to_status against the status enum and return VALIDATION (reason: invalid_status) like the GET filter does."
},
{
"area": "foreshadow-board",
"severity": "LOW",
"component": "backend POST /projects/{id}/foreshadow (RegisterForm path)",
"symptom": "Whitespace-only code is accepted server-side, creating a foreshadow row with a blank-looking code",
"evidence": "curl -X POST .../foreshadow -d '{\"code\":\" \",\"title\":\"空白代号\"}' → 201, code stored as ' '. minLength:1 passes because spaces count. RegisterForm.tsx trims client-side (canSubmit uses form.code.trim()), so unreachable via UI, but the API boundary does not trim/reject — could break code-uniqueness assumptions and renders an empty code in ForeshadowCard.",
"repro": "POST foreshadow with code of only spaces → 201; GET shows a row with effectively empty code."
},
{
"area": "foreshadow-board",
"severity": "LOW",
"component": "apps/web/components/foreshadow/RegisterForm.tsx (toInt)",
"symptom": "Non-integer numeric input is silently dropped with no validation feedback",
"evidence": "RegisterForm.tsx:180 toInt() returns null for non-integer (e.g. 'abc' or '1.5' → Number.parseInt yields NaN/truncates); the field is then omitted from the request body with no user-facing message. inputMode='numeric' on type='text' does not enforce digits.",
"repro": "Open register form, type 'abc' in 埋设章, submit → planted_at silently omitted, no error shown."
},
{
"area": "templates",
"severity": "LOW",
"component": "apps/web/components/toolbox/TemplateFiller.tsx:23-40",
"symptom": "After a transient GET /templates failure, the filler shows the misleading empty-state '(暂无模板,去模板库新建。)' and never retries on reopen.",
"evidence": "load() catch/error branch does setTemplates([]); useEffect guard `if (open && templates === null) void load()` only fires when templates is null, so [] from a failure is sticky. A real empty list and a failed load are indistinguishable to the user, and reopening the panel won't re-fetch.",
"repro": "Open generator with brief/text field -> click '从模板填入' while API is down (or returns error) -> toast '加载模板失败' + shows '暂无模板'. Bring API back, click 收起 then reopen -> still shows '暂无模板', no refetch. Templates that do exist are hidden until full page reload."
},
{
"area": "templates",
"severity": "LOW",
"component": "apps/web/components/templates/TemplatesManager.tsx:42-57 / backend",
"symptom": "No max length / no client truncation on title and body; 5000-char title accepted (201).",
"evidence": "POST title with 5000 'A' chars -> HTTP 201. Schema has minLength:1 but no maxLength; frontend does not cap. Unbounded text could bloat list rendering (each body rendered in full with whitespace-pre-wrap).",
"repro": "POST /templates with a very long title/body -> 201; the manager list renders the entire body inline (line 162-164) with no clamp."
},
{
"area": "templates",
"severity": "LOW",
"component": "backend POST /templates",
"symptom": "Unknown/extra request fields are silently accepted (ignored), not rejected.",
"evidence": "POST {title,body,bogus:'z'} -> 201 (bogus ignored). Not a frontend bug since the typed client never sends extras, but worth noting for input-validation strictness.",
"repro": "curl -X POST /templates -d '{\"title\":\"X\",\"body\":\"Y\",\"bogus\":\"z\"}' -> 201."
},
{
"area": "settings-providers",
"severity": "MEDIUM",
"component": "apps/api settings/providers PUT (backend) + ProvidersSettings.tsx routing editor",
"symptom": "PUT /settings/providers accepts an arbitrary/unknown tier (e.g. 'bogus') and unknown provider names with no enum/whitelist validation, and persists them. No DELETE endpoint exists and PUT is upsert-only (never removes rows), so a stray tier_routing row can never be cleaned up via API/UI. The frontend renders only fixed TIERS (writer/analyst/light) via toRoutingDrafts, so the orphan row is invisible in the UI yet remains in the gateway routing config.",
"evidence": "curl PUT '{\"tier_routing\":[{\"tier\":\"bogus\",\"provider\":\"anthropic\",\"model\":\"x\"}]}' -> HTTP 200, then GET shows tiers ['analyst','light','writer','bogus']. Re-PUT of the 3 canonical tiers left 'bogus' present; had to delete it directly from the DB tier_routing table.",
"repro": "PUT /settings/providers with a tier_routing entry whose tier is not in {writer,analyst,light}; observe it persists in GET and cannot be removed via any endpoint."
},
{
"area": "settings-providers",
"severity": "LOW",
"component": "apps/web/components/settings/ProvidersSettings.tsx (testConnection)",
"symptom": "The 'test connection' result loses the backend's specific failure reason. A 404 NOT_FOUND (provider not configured) and a 503 LLM_UNAVAILABLE (probe failed) both surface as the same generic toast, so the user can't tell they simply need to add a key first.",
"evidence": "ProvidersSettings.tsx:107-108 `if (error || !data) { toast(\"测试连接失败\", \"error\"); return; }`. curl confirmed openai returns 404 and kimi returns 503 with distinct codes/messages.",
"repro": "On the providers page, click test-connection for an unconfigured provider vs a configured-but-unreachable one; both show identical generic error toast."
},
{
"area": "settings-providers",
"severity": "LOW",
"component": "apps/api oauth/start background poller + jobs table",
"symptom": "Each POST oauth/start makes a real external call to kimi.com and creates a kimi_oauth job whose background poller keeps the row status='queued' for the full device-code lifetime (expires_in=1800s). If the user starts a connect and never authorizes, the job lingers ~30 min before failing.",
"evidence": "POST start -> 202 with real user_code from https://www.kimi.com/code/authorize_device; jobs row stayed status='queued' and DELETE FROM jobs did not take effect while the in-process poller held it; will self-expire.",
"repro": "POST /settings/providers/kimi-code/oauth/start and do not authorize; the kimi_oauth job remains queued up to 1800s."
},
{
"area": "codex (设定库) — CodexPage + world entities/characters read & display",
"severity": "MEDIUM",
"component": "backend character persist/read (consumed by CodexPage via fetchCharacters) + CodexPage.tsx",
"symptom": "Character relations are silently dropped on the read path; CodexPage also never displays relations for persisted characters",
"evidence": "POST /projects/{id}/characters with relations:[{name:'乙',kind:'友',note:'n'}] -> GET /projects/{id}/characters returns relations:[]. CharacterRelationView exists in the OpenAPI contract and CharacterCardItem.tsx renders relations at preview-time, but they are lost once persisted and the codex list omits them entirely (renders only namerole).",
"repro": "curl -X POST :8000/projects/$PID/characters -d '{\"cards\":[{\"name\":\"甲\",\"role\":\"主角\",\"backstory\":\"b\",\"arc\":\"a\",\"relations\":[{\"name\":\"乙\",\"kind\":\"友\",\"note\":\"n\"}]}]}' then curl :8000/projects/$PID/characters -> relations:[]"
},
{
"area": "codex (设定库) — CodexPage + world entities/characters read & display",
"severity": "MEDIUM",
"component": "backend character ingest (POST /projects/{id}/characters) consumed by codex",
"symptom": "Character ingest is not idempotent — re-ingesting the same card name creates a duplicate persisted row, which renders as duplicate chips in the codex list",
"evidence": "Single fresh project: 1st ingest of '甲' -> count 1; 2nd identical ingest -> count 2. CodexPage's mergeCharacterCards only dedups persisted-vs-session by name, not duplicates already in the persisted list, so both rows render.",
"repro": "POST same {cards:[{name:'甲',...}]} twice to a fresh project, then GET characters -> count 2; load /projects/$PID/codex -> two '甲(主角)' chips"
},
{
"area": "codex (设定库) — CodexPage + world entities/characters read & display",
"severity": "LOW",
"component": "apps/web/components/codex/CodexPage.tsx (characters tab)",
"symptom": "Persisted-character list is a minimal chip (name + role only); traits/backstory/arc/speech_tics/tags/relations from CharacterCardView are never surfaced in the codex, so most ingested data is invisible after refresh",
"evidence": "CodexPage lines 91-98 render only `{c.name}{c.role}`; the richer CharacterCardItem is used only in the generator preview, not the persisted list",
"repro": "Ingest a character with full traits/tags, reload codex characters tab -> only 'namerole' shown"
},
{
"area": "codex (设定库) — CodexPage + world entities/characters read & display",
"severity": "LOW",
"component": "environment / apps/web .next dev build (blocks browser QA of codex)",
"symptom": "Every SSR route (codex, outline, projects list, settings) returns HTTP 500 from the running dev server",
"evidence": "500 body err: 'Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' (Require stack -> .next/server/app/projects/[id]/codex/page.js). vendor-chunks dir has no openapi-fetch chunk. home is 200. This is a stale .next cache, not codex source.",
"repro": "curl :3000/projects/$PID/codex -> 500; needs `rm -rf apps/web/.next` + restart `pnpm dev` to verify codex in-browser"
},
{
"area": "skills",
"severity": "HIGH",
"component": "GET /skills backend registry + apps/web/components/skills/SkillsPage.tsx",
"symptom": "Skills registry is empty in the running environment, so the SkillsPage always shows the empty state '(暂无已注册技能)'. None of the page's intended content (builtin/custom/community grouping, tier badges, genre badge, reads/writes badges) is ever displayed.",
"evidence": "curl -s http://localhost:8000/skills -> {\"skills\":[]} (HTTP 200). list_skills (generation.py:388) iterates registry.names(); registry is loaded from the `skills` DB table only (skill_registry.py SqlAlchemySkillRepo.list_all = select(Skill)). Initial migration 220ca2e3d53f creates the `skills` table but seeds no rows, and there is no POST /skills endpoint to populate it (openapi paths: /skills GET only).",
"repro": "curl -s http://localhost:8000/skills -> {\"skills\":[]}. Then open /projects/<PID>/skills -> renders only the '暂无已注册技能' paragraph."
},
{
"area": "skills",
"severity": "HIGH",
"component": "apps/web/app/projects/[id]/skills/page.tsx (and all backend-data routes)",
"symptom": "The skills page returns HTTP 500 instead of rendering; the page cannot be viewed in the running app.",
"evidence": "GET http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/skills -> 500. __NEXT_DATA__.err.message = \"Cannot find module './vendor-chunks/openapi-fetch@0.13.8.js'\" with require stack through .next/server/app/projects/[id]/skills/page.js. Same 500 reproduces on sibling routes /toolbox and /outline; home (/) is 200. Root cause is a stale/incomplete .next dev build (missing openapi-fetch vendor chunk), shared infra rather than skills code, but it fully blocks the skills page.",
"repro": "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/skills -> 500. Likely fixed by clearing .next and restarting the web dev server."
},
{
"area": "skills",
"severity": "LOW",
"component": "GET /projects/{id} (consumed by skills page fetchProject)",
"symptom": "Malformed (non-UUID) project id returns 422 rather than 404. The frontend page.tsx catches all errors and maps to notFound() so the user impact is none, but the API semantics differ from a clean 404.",
"evidence": "curl /projects/does-not-exist -> 422 uuid_parsing; curl /projects/00000000-0000-0000-0000-000000000000 -> 404. page.tsx wraps both in try/catch -> notFound(), so both surface as Next 404.",
"repro": "curl -s http://localhost:8000/projects/does-not-exist -> 422 detail uuid_parsing."
},
{
"area": "review-display",
"severity": "HIGH",
"component": "apps/web .next dev build (environment) — blocks app/projects/[id]/review/page.tsx and all SSR pages",
"symptom": "Review report page (and every server-rendered page) returns HTTP 500; browser shows blank, no review content renders.",
"evidence": "GET http://localhost:3000/projects/{PID}/review?chapter=1 -> 500. Console: 'Error: Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' in .next/server/webpack-runtime.js. Confirmed: .next/server/vendor-chunks/ contains only @swc+helpers and next chunks, NO openapi-fetch chunk. outline/foreshadow pages also 500 (same module). Backend API itself is healthy (200s).",
"repro": "Open http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/review?chapter=1 in browser, or curl it -> 500. Fix: stop dev server, rm -rf apps/web/.next, restart pnpm dev (cache rebuild). Likely a stale incremental build, not a source bug."
},
{
"area": "review-display",
"severity": "MEDIUM",
"component": "components/style/StylePanel.tsx + components/review/ReviewReport.tsx (segmentText)",
"symptom": "Style drift segments whose idx exceeds the chapter's paragraph count render a clickable '第 N 段 / 一键回炉' entry that silently no-ops (cannot locate text).",
"evidence": "ch2 GET reviews: style.segments has 100 items with idx 0..99, but ch2 draft splits into only 90 paragraphs (/\\n{2,}/). StylePanel renders all 100 as '第 {idx} 段'. ReviewReport.segmentText(idx) = finalParas[idx]?.trim() ?? '' returns '' for idx>=90; RefineView guards empty (RefineView.tsx:31 only refines if segment.trim().length>0, :56 shows empty state). So clicking 回炉 on idx 90-99 does nothing — no feedback, no toast.",
"repro": "Render ch2 review (once SSR fixed), scroll style panel to '第 90 段'+, click 一键回炉 — RefineView opens with empty-segment state, no AI call, no explanation. Backend produced more style segments than the draft has paragraphs (idx/paragraph contract mismatch)."
},
{
"area": "review-display",
"severity": "LOW",
"component": "apps/api accept endpoint (consumed by lib/review/useAccept.ts)",
"symptom": "422 validation errors on accept use raw FastAPI {\"detail\":[...]} shape instead of the project's standard {\"error\":{code,message,request_id}} envelope.",
"evidence": "POST accept with final_text:'' -> 422 {\"detail\":[{\"type\":\"string_too_short\",\"loc\":[\"body\",\"final_text\"]...}]}. Contrast: 409 and 404 return the standard envelope. useAccept treats any non-CONFLICT_UNRESOLVED error generically so no crash, but the inconsistency means no greppable request_id for 422s and a generic toast.",
"repro": "POST /projects/{PID}/chapters/1/accept body {\"final_text\":\"\",\"decisions\":[]} -> 422 raw detail. In UI this is unreachable normally (AnnotatedText always has content) but reachable if draft is empty."
},
{
"area": "nav-shell+command",
"severity": "HIGH",
"component": "Next.js dev server (.next build cache) — blocks apps/web/lib/api/client.ts consumers",
"symptom": "Every project-detail route (/projects/<id>/outline, /review, /codex, /toolbox, /rules, /skills, /style, /foreshadow, /write, /chains) returns HTTP 500 in the running app. Home (/), /templates, /settings/providers return 200.",
"evidence": "Server-rendered error body: 'Cannot find module ./vendor-chunks/openapi-fetch@0.13.8.js' (Require stack: .next/server/webpack-runtime.js). Confirmed the chunk is absent: `ls apps/web/.next/server/vendor-chunks/` lists only @swc+helpers and next chunks, no openapi-fetch. lib/api/client.ts:1 imports openapi-fetch; all 500ing pages transitively import it via lib/api/server.",
"repro": "1) App running at localhost:3000. 2) curl -o /dev/null -w '%{http_code}' http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/review -> 500. 3) Fix is environmental: stop dev server, `rm -rf apps/web/.next`, restart `pnpm dev`. NOTE: this is a stale dev-build cache artifact, NOT a source-code defect, but it currently breaks the running app and blocks the Browser QA phase for all project pages."
},
{
"area": "nav-shell+command",
"severity": "MEDIUM",
"component": "components/command/CommandPalette.tsx",
"symptom": "Command palette implements a listbox of options with keyboard arrow highlighting but the combobox a11y contract is incomplete — screen readers will not announce which command is highlighted as the user arrows up/down.",
"evidence": "Input (line 146-155) has aria-controls=\"command-list\" but is not role=\"combobox\" and lacks aria-activedescendant. Option <li> (line 165-177) has role=\"option\" + aria-selected but no id, so there is nothing for aria-activedescendant to point at. grep for 'aria-activedescendant|combobox|id={' in the file returns no matches.",
"repro": "Open ⌘K, type a query, press ArrowDown with a screen reader (VoiceOver/NVDA) active: the visual highlight moves but no option is announced. needs-browser to confirm AT announcement; code inspection confirms the missing attributes."
},
{
"area": "nav-shell+command",
"severity": "LOW",
"component": "components/Toast.tsx",
"symptom": "Error toasts are announced via a polite live region instead of an assertive one, so failure messages may not interrupt the screen reader.",
"evidence": "Toast.tsx lines 59-63: the toast container is a single region with aria-live=\"polite\" role=\"status\" used for all kinds (info/success/error). Per ARIA, error/failure feedback should use role=\"alert\" / aria-live=\"assertive\".",
"repro": "Trigger an error toast (e.g. show('...','error')); with a screen reader it is queued politely rather than interrupting. needs-browser to confirm AT behavior."
},
{
"area": "write-sse (LLM full E2E: chapter draft streaming, autosave, injection panel)",
"severity": "CRITICAL",
"component": "apps/api/ww_api/routers/projects.py :: stream_draft (POST /projects/{project_id}/chapters/{chapter_no}/draft)",
"symptom": "Streaming a draft for a NON-EXISTENT project returns HTTP 200 and a full LLM-generated chapter instead of 404. The stream endpoint never validates project existence before invoking the gateway, so an invalid project ID silently burns a real (paid, rate-limited) LLM call and emits token+done events as if valid.",
"evidence": "curl -X POST /projects/00000000-0000-0000-0000-000000000000/chapters/7/draft → HTTP 200 ct=text/event-stream, streamed a generic chapter ('由于您未提供前六章内容...'), terminated with `event: done {\"length\":2606}`. Compare: GET/PUT /injection and GET /draft on the SAME bad project ID correctly return 404 {error:{code:NOT_FOUND}}. Root cause: stream_draft calls assemble(repos, project_id, ...) which does not raise on missing project; no explicit existence guard (projects.py lines 263-283).",
"repro": "BAD=00000000-0000-0000-0000-000000000000; curl -sN -X POST http://localhost:8000/projects/$BAD/chapters/7/draft -H 'Accept: text/event-stream' -w '\\nHTTP %{http_code}\\n' — observe HTTP 200 + token/done events instead of 404."
},
{
"area": "write-sse (LLM full E2E: chapter draft streaming, autosave, injection panel)",
"severity": "LOW",
"component": "lib/stream/useDraftStream.ts (pre-stream error branch)",
"symptom": "On a pre-stream failure (e.g. 503 LLM_UNAVAILABLE returned as a JSON error envelope), useDraftStream extracts error.code and error.message but ignores error.request_id present in the envelope, so the correlation id is dropped from the UI/error state. Violates the CLAUDE.md logging rule that request_id should travel end-to-end for greppability.",
"evidence": "useDraftStream.ts lines 99-110: only `body.error?.code` and `body.error?.message` are read; no request_id capture. The StreamState.error type and the mid-stream `error` event DO carry request_id, so the two error paths are inconsistent.",
"repro": "Configure no LLM provider (or force 503) then click 写本章; the surfaced error has no request_id. Could not force live (provider configured)."
},
{
"area": "write-sse (LLM full E2E: chapter draft streaming, autosave, injection panel)",
"severity": "LOW",
"component": "apps/api (FastAPI validation) vs frontend hooks",
"symptom": "Path/body validation errors (422) return FastAPI's default {detail:[...]} shape, NOT the project's {error:{code,message,request_id}} envelope used by 404/503. Frontend hooks degrade gracefully (generic message), but the inconsistent envelope means 422s have no request_id and no error code for friendlyError mapping.",
"evidence": "GET /injection chapter_no=abc → 422 {detail:[{type:int_parsing,...}]}; PUT recent_n=0 → 422 {detail:[{type:greater_than_equal,...}]}. Contrast 404 → {error:{code:NOT_FOUND,...,request_id}}.",
"repro": "curl /projects/$PID/chapters/abc/injection → 422 {detail:..}."
},
{
"area": "toolbox-generators (LLM full E2E)",
"severity": "LOW",
"component": "apps/web/lib/generation/cards.ts:139 generationErrorMessage",
"symptom": "Server-side VALIDATION (422) errors from generate (e.g. text too short, kind invalid) are shown to the user as the generic '生成失败,请稍后重试。' rather than the specific backend message (e.g. '工具 de-ai 需要原文输入').",
"evidence": "generationErrorMessage only branches on errorCode===LLM_UNAVAILABLE; all other codes (VALIDATION/NOT_FOUND) fall through to generic text. Backend returns a clear message in error.message that is discarded.",
"repro": "Bypass client validation (or send a server-only-invalid value) -> generate -> toast shows generic message instead of the actionable backend reason. Mitigated because GeneratorRunner.onGenerate blocks empty required fields client-side first."
},
{
"area": "toolbox-generators (LLM full E2E)",
"severity": "LOW",
"component": "backend POST /projects/{id}/skills/{tool_key}/ingest (glossary/world_entities)",
"symptom": "Empty ingest payload (world_entities:[]) returns HTTP 201 with created:[] instead of a 4xx, i.e. a no-op 'success'.",
"evidence": "curl with {\"world_entities\":[],\"acknowledge_conflicts\":false} -> HTTP 201 {table:world_entities, created:[]}. ",
"repro": "POST ingest with empty array. Mitigated: GeneratorRunner.runIngest is gated by selected.size===0 / hasPreview and useGenerator.ingest returns early with toast '请至少选择一项入库。' when rows.length===0, so the empty body never leaves the client in normal use."
},
{
"area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
"severity": "HIGH",
"component": "ReviewReport.tsx (segmentText) + packages/agents/ww_agents/prompts/style.md + review_node.build_review_context",
"symptom": "One-click 回炉 can target the wrong paragraph (or an empty/out-of-range one) because the drift-segment idx produced by the style-auditor LLM and the frontend's paragraph index are derived by two unrelated splitting schemes.",
"evidence": "Backend feeds the draft verbatim to the reviewer (review_node.py:59-64 build_review_context — no injected paragraph numbering); prompt style.md instructs the LLM to number segments '段索引0起,与正文切分顺序一致' but never pins the split rule. Frontend ReviewReport.tsx:344-346 computes finalParas = finalText.split(/\\n{2,}/) and segmentText(idx)=finalParas[idx]?.trim() ?? '''. If the LLM segments differently (single-newline / sentence) the idx points to the wrong para; RefineView then either refines the wrong text or shows '该段在终稿中为空'.",
"repro": "Learn a fingerprint, write a chapter whose draft uses single-newline separation between logical segments, run review until style returns segments with idx>0, click '一键回炉' on a drift segment — observe the refined text is for a different paragraph than the one flagged. Needs a learned fingerprint + drift (integration/browser); could not deterministically trigger because existing PID 71b3... has no fingerprint (style review degrades to score=100/segments=[])."
},
{
"area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
"severity": "LOW",
"component": "apps/api/ww_api/routers/style.py refine_segment (line 198-240)",
"symptom": "Refine endpoint does not validate that the chapter exists; any chapter_no (even nonexistent) returns 200.",
"evidence": "POST /projects/71b3.../chapters/99/refine {\"segment\":\"测试段落\"} → HTTP 200 {\"original\":\"测试段落\",\"refined\":\"测试段落\"}. In code chapter_no is only used in log.info, never to look up/validate the chapter.",
"repro": "curl -X POST .../chapters/99/refine -d '{\"segment\":\"x\"}' → 200 instead of 404. Low impact because segment text is supplied by the client and refine is read-only (invariant #3)."
},
{
"area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
"severity": "LOW",
"component": "apps/web/components/style/StyleUpload.tsx (line 101-104) + apps/api job_runner",
"symptom": "Style-learn progress indicator is effectively static: shows '提取文风指纹中…0%' the entire time then jumps to done.",
"evidence": "GET /jobs/{id} reports progress 0 for all polls while queued, then 100 at done — no intermediate values; StyleUpload binds progress directly.",
"repro": "Submit learn, watch poll output: every poll progress=0 until the single done poll at 100."
},
{
"area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
"severity": "LOW",
"component": "apps/web/lib/style/useStyleLearn.ts (done-edge effect, line 53-66)",
"symptom": "Potential stale done-edge re-fire on a second 'learn' within the same mounted StylePage before the new job's first tick lands; could toast '文风指纹已更新' / refetch prematurely.",
"evidence": "Effect deps [poll.status, poll.error, toast]; reducePoll has no reset action, so state stays 'done' from run 1 after learn() calls poll.poll(). The intervening setPollStatus('polling') in learn (line 89) mitigates the visible state but the done branch keyed on poll.status only flips on first tick. Edge timing.",
"repro": "needs-browser: learn once, wait done, then learn again and observe whether a success toast fires before the new extraction completes."
},
{
"area": "LLM full E2E — style (fingerprint extraction + drift + refine/de-AI)",
"severity": "LOW",
"component": "apps/web/components/style/RefineView.tsx (effect deps line 30-36)",
"symptom": "Switching between two drift segments whose mapped paragraph text is identical (e.g. both out-of-range → '') will not re-trigger refine; stale prior result/state shown.",
"evidence": "useEffect deps are [segment, projectId, chapterNo]; if the selected segment's text string is unchanged the refine call is skipped.",
"repro": "needs-browser: two drift segments resolving to the same segmentText; click one then the other — second shows first's result or no refresh."
},
{
"area": "LLM full E2E — chain (multi-chapter workflow chain)",
"severity": "MEDIUM",
"component": "apps/web/components/chain/ChainPage.tsx:61 & apps/web/components/chain/ChainAdjudication.tsx:102",
"symptom": "Chain run/resume failures collapse to a generic toast ('出错了,请稍后重试。'); the structured error code/message from the API envelope is thrown away. Notably a 503 LLM_UNAVAILABLE (which the run endpoint explicitly returns and which maps to a 'go to settings → connect a provider' action link) and 409 CONFLICT lose all specificity.",
"evidence": "Both call `friendlyError(undefined)` with no args. openapi-fetch returns the ErrorEnvelope as `error` (schema.d.ts:1128 ErrorEnvelope{error:ErrorBody{code,message}}). Correct pattern exists elsewhere: ReviewReport.tsx:564 `friendlyError(error.code, error.message)` and Workbench.tsx:224. friendlyError/MESSAGES (errors/messages.ts:19-31) with the LLM_UNAVAILABLE provider-action link is effectively dead code on the chain paths.",
"repro": "On chains page, trigger run with no provider connected (API returns 503 LLM_UNAVAILABLE) → user sees generic '出错了' toast with no 'go to settings' link instead of the actionable LLM_UNAVAILABLE message."
},
{
"area": "LLM full E2E — chain (multi-chapter workflow chain)",
"severity": "LOW",
"component": "apps/web/components/chain/ChainAdjudication.tsx:94-110 (+ ChainPage.tsx:39-41)",
"symptom": "No recovery path if resume returns 409 (job no longer awaiting — e.g. two tabs, or job already resumed/moved). A toast fires and the adjudication panel stays open with all conflicts resolved, but the resume can never succeed and polling was already stopped (poll.reset() ran on entering awaiting). The UI is stuck with no re-sync of job state.",
"evidence": "onResume only toasts + setResuming(false) on error (ChainAdjudication.tsx:101-105). Backend returns 409 verified: POST resume on done/failed job -> 409 CONFLICT. ChainPage stops polling on awaiting (useEffect poll.reset(), ChainPage.tsx:39-41) and only re-polls via onResumed() which fires on success only.",
"repro": "Reach awaiting state in two tabs; resume in tab A (job leaves awaiting); resume in tab B → 409 → tab B panel stuck, no way to refresh job status without reload."
},
{
"area": "LLM full E2E — chain (multi-chapter workflow chain)",
"severity": "LOW",
"component": "apps/web/components/chain/ChainPage.tsx:39-41",
"symptom": "The poll.reset() effect lists `poll` in its dependency array, and `poll` is a fresh object every render (useJobPoll returns {...state, poll, reset}); so while phase==='awaiting' the effect re-runs on every render. reset() is idempotent (clears timer) so this is harmless, but it is a needless re-run / latent footgun.",
"evidence": "useJobPoll returns spread object each render (useJobPoll.ts:87); ChainPage effect deps [phase, poll] (ChainPage.tsx:41).",
"repro": "Static analysis; observable as repeated clearTimeout calls while awaiting."
},
{
"area": "LLM full E2E — chain (multi-chapter workflow chain)",
"severity": "HIGH",
"component": "Next.js dev server (infra) — apps/web/.next cache",
"symptom": "All /projects/[id]/* SSR pages (including /chains, plus /outline, /foreshadow, /codex, /skills) return HTTP 500 in the running dev server, blocking any browser-level QA of the chain UI.",
"evidence": "GET http://localhost:3000/projects/<PID>/chains -> 500 (x3). Decoded dev error: \"Cannot find module './vendor-chunks/openapi-fetch@0.13.8.js'\" referenced from .next/server/app/projects/[id]/outline/page.js. openapi-fetch@0.13.8 IS installed in node_modules but .next/server/vendor-chunks/ has no openapi chunk — stale/corrupt Next dev build cache. NOT a chain component defect.",
"repro": "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/chains -> 500. Fix: restart dev server or rm -rf apps/web/.next then re-run."
},
{
"area": "Browser smoke test — all 14 routes + key interactions (Next.js web app at localhost:3000)",
"severity": "CRITICAL",
"component": "apps/web/.next/server/app/projects/[id]/*/page.js (all 10 project routes)",
"symptom": "Every project-scoped route returns HTTP 500 with a full-screen Next.js Server Error; the entire authoring surface (outline/write/review/foreshadow/rules/style/codex/skills/toolbox/chains) is unusable",
"evidence": "curl + browser overlay: \"Error: Cannot find module './vendor-chunks/openapi-fetch@0.13.8.js'\" required from .next/server/webpack-runtime.js. Verified .next/server/vendor-chunks/ contains only @swc+helpers and next chunks — the openapi-fetch vendor chunk is missing from disk, while openapi-fetch@0.13.8 IS installed in node_modules (source is fine; the .next build is corrupt/incomplete)",
"repro": "curl -s http://localhost:3000/projects/71b3725e-25a7-4f12-ba1b-d4ea61fd92e8/write → 500; or navigate any project route in browser → Server Error dialog"
},
{
"area": "Browser smoke test — all 14 routes + key interactions (Next.js web app at localhost:3000)",
"severity": "CRITICAL",
"component": "apps/web next dev server / .next/static chunks (affects all 4 top-level routes)",
"symptom": "Client JS bundles 404, so React never hydrates — the whole app is a static, non-interactive shell. No button enables, no form submits, no client interaction works on ANY route",
"evidence": "Network log on /: main-app.js [404], app-pages-internals.js [404], app/layout.js [404], app/page.js [404]; curl confirms persistent 404 for main-app.js, polyfills.js, app-pages-internals.js, layout.js. .next/static/chunks holds production-hashed names (main-app-a84cd174a2d81b10.js, polyfills-42372ed130431b0a.js) but HTML requests dev-unhashed names. Proof of non-hydration: typed a valid title into wizard, evaluate_script returned inputValue='BugCheckTitle' but nextDisabled=true (canAdvance logic is correct, so onChange never fired)",
"repro": "Open http://localhost:3000/projects/new, type a book name → 下一步 stays disabled; or curl -s -o/dev/null -w '%{http_code}' http://localhost:3000/_next/static/chunks/main-app.js → 404"
},
{
"area": "Browser smoke test — all 14 routes + key interactions (Next.js web app at localhost:3000)",
"severity": "HIGH",
"component": "apps/web dev environment (process management)",
"symptom": "Two concurrent `next dev` processes run against the same apps/web, both writing the shared .next dir — most likely cause of the corrupted/inconsistent build (missing vendor chunks + mixed hashed/unhashed chunk names)",
"evidence": "ps aux shows PID 58504 and PID 60575 both = `node .../next/dist/bin/next dev` in apps/web, started 3:15pm and 3:16pm",
"repro": "ps aux | grep 'next dev' | grep apps/web → two PIDs"
}
]

View File

@@ -0,0 +1,50 @@
# 前端功能性 QA 报告 — 2026-06-24
> 方法:多 agent 并行 QA15 个 area agent。每个 area 读组件 + `lib/api` hook直接 curl 打 `:8000` APIhappy path + 404/422/503/空集/幂等边界),交叉核对组件逻辑 vs OpenAPI 契约;外加单浏览器 render/runtime 冒烟。LLM 流程按要求**完整端到端**跑真 Kimi。**仅记录、不改码。**
> 原始数据:`frontend-qa-errors-2026-06-24.json`(全部错误)+ `frontend-qa-2026-06-24.md`agent 合成稿,注意其严重度排序受下述环境故障污染)。
## 执行摘要
- 测试覆盖15 个 area14 路由 + 52 组件。
- 计数(**剔除环境故障后****CRITICAL 1 · HIGH 3 · MEDIUM 11 · LOW ~26**。
- ⚠️ **环境故障(非源码缺陷,已修复)**QA 期间运行环境有**两个并发 `next dev` 写同一个 `.next`**(我多次重启 `pnpm dev` 未杀旧进程所致),导致 `.next` 构建缓存损坏 → 全部 `/projects/[id]/*` 路由 500、静态 chunk 404、页面不 hydrate。这污染了 agent 报的 **2 个 CRITICAL + ~4 个 HIGH**"全站 500/不可交互")。**已 kill 重复进程 + `rm -rf .next` + 单实例重启**复验10 个 project 路由全部 **200**、静态 chunk **200**。这些不计入真实缺陷。
## 真实缺陷(按严重度)
### CRITICAL (1)
| # | 位置 | 问题 | 复现 |
|---|---|---|---|
| C1 | `apps/api/.../routers/projects.py::stream_draft``POST /projects/{pid}/chapters/{n}/draft` | 对**不存在的 project** 流式写章返回 **200 + 真生成整章**,而非 404。入口未校验项目存在性就调网关——非法 id 会**静默烧掉一次付费、限流的 LLM 调用**。 | `curl -sN -X POST .../projects/00000000-.../chapters/7/draft -H 'Accept: text/event-stream'` → 200 + token/done 事件 |
### HIGH (3)
| # | 位置 | 问题 | 复现 |
|---|---|---|---|
| H1 | `apps/web/components/ProjectWizard.tsx`StepPremise + StepProtagonist | 第3步「立意」与第4步「主角/金手指」**绑定同一个 `form.premise`**,后填覆盖先填 → 提交时丢数据,二者不能共存。 | /projects/new 填立意→下一步填主角→提交,`project.premise` 只剩主角文本 |
| H2 | `apps/api/.../routers/rules.py` + `rule_repo.py` | 给**不存在的 project** POST 规则返回 **500INTERNAL** 而非 404——repo 直接 insert+flushFK 违例逃逸成 500真后端 bugcurl 直测,与 next dev 无关)。 | `curl -X POST .../projects/{随机uuid}/rules -d '{"level":"project","content":"x"}'` → 500 |
| H3 | `review/ReviewReport.tsx`(segmentText) + `prompts/style.md` | 文风 drift 段 `idx`LLM 按自己的分段)与前端段落 index另一套切分口径不一致 → 「一键回炉」可能改**错段落**。(需已学指纹+drift 才能必现;当前 PID 无指纹故未必现。) | 学指纹→写章→style 返回 idx>0 段→点回炉→改的不是被标段 |
### MEDIUM (11)
- **outline**:卷号选择器只参与 POST 生成;`GET /outline?volume=N` **忽略过滤**,编辑器永远渲染全部卷,用户无法只看某卷。
- **outline**`useOutline``error.error.code`,但 FastAPI 422 用 `{detail:[...]}` → 校验错误细节丢失,永远显示通用「大纲生成失败」。
- **rules**:纯空白 content 被接受持久化201——`min_length:1` 在 trim 前校验,服务端不 strip。
- **rules**`GET /projects/{不存在}/rules`**200 `{"rules":[]}`** 而非 404掩盖坏 id。
- **rules****无 DELETE/编辑端点**——规则只能增+读,误加的规则(如上面的空白行)无法删除。
- **settings/providers**`PUT /settings/providers` 接受**任意未知 tier**(如 `bogus`)和未知 provider 名,无枚举/白名单校验。
- **codex**:角色 relations 在读路径被**静默丢弃**CodexPage 也从不展示已存角色的 relations。
- **codex**:角色入库**非幂等**——同名卡重复入库生成重复行UI 渲染重复 chip。
- **review/style**drift 段 `idx` 超过本章段落数时,渲染一个可点的「第 N 段/一键回炉」但点击**静默 no-op**。
- **command palette**listbox/combobox a11y 契约不完整,屏幕阅读器读不全。
- **chain**run/resume 失败统一塌成通用 toast「出错了请稍后重试」丢弃 API envelope 的结构化 code/message。
### LOW~26摘要
通用错误 toast 丢弃后端 detail多处wizard/chain/outline服务端接受纯空白标题201空大纲生成仍报「已生成」成功 toast 却显示空态outline 全程只读(无 PUT 编辑QA 预期与实现的范围差);多处表单缺 `<label>`/ariarules textarea 等a11y等。完整见 errors JSON。
## 信息性观察(非缺陷)
- **skills 注册表为空**`GET /skills``{"skills":[]}`):本环境 DB 未播种内置 skill故 SkillsPage 正确显示空态。页面本身工作正常(重建后 200。若期望展示内置 8/21 agent需要 skills 播种逻辑/数据。
## 各 area 状态
projects-list+createH1 + 2 LOW · outline1 MED×2 + 2 LOW只读· rulesH2 + 3 MED + 1 LOW · foreshadow环境 500 已修,功能正常)· templates空白校验此前已修· settings/providers1 MED · codex2 MED · skills空态数据未播种· review-display1 MEDidx· nav/shell+command1 MEDa11y· write-sse**C1** · toolbox-generators见下 · styleH3 · chain1 MED已确认可跑通
## 覆盖与缺口
- 环境故障期间**浏览器交互层**hydration 后的真实点击/键盘)被全站 500 阻断agent 多以源码+API 推断;建议在**已修复的环境**上重跑一次浏览器冒烟以补足 LLM 结果页的真实渲染验证。
- LLM 端到端:部分生成流程因 Kimi 限流/单 PID 数据(无 style 指纹)未能必现 H3chain 已独立确认 count=1 跑通。

View File

@@ -16,7 +16,7 @@
"outliner": "3086ba81fe8028687bf079db2c2fb227ba5a162b7fc09210914e6f4ebd9c0d2e",
"pace": "c6a023cb93fde4879a0fb93cd28e0227694a4cf867e64e768319ee3135d82746",
"refiner": "65a4baa298bedce4592829c02d6a16c15a19ef6fdbb2098278f9decc8ffeb813",
"style": "728d9ad2379d7923e1927c9de2b7b512ea8b39faef122a60005215aa069823f1",
"style": "3a7b2c078b62e4e08f62af91ecfae8d9a27adc598ca60f59927c3bcd136393c5",
"style_extract": "998c30ea0d0eab3936e1d6b319e832645eefaa7f7dd4a1c86d5c1ec9467e8b8c",
"teardown": "4fb7335c3e79e19ef276f199011a5de888330fc7f91a53dd483a6531b94b973e",
"worldbuilder": "3bf578c2df3018e0f969949c760bb8d214639df4aa17aa904d3ce803c6ddcc3e"

View File

@@ -39,7 +39,8 @@
- `score`:整数,整章相对文风指纹的**整体相似度**0100越高越贴合。无指纹降级时为 `100`
- `segments`:漂移段清单(数组)。无漂移段(含无指纹降级)则为**空数组** `[]`。每个元素:
- `idx`:整数,该段在本章的段索引(**0 起**必须与正文切分顺序严格一致,便于前端朱砂标注与一键回炉对齐
- `idx`:整数,该段在本章的段索引(**0 起**仅供前端展示排序
- `text`:字符串,**从本章草稿里逐字摘录**的该漂移段原文(含标点,**一字不改、不要改写或省略**)。前端靠这段原文在终稿里做内容匹配来定位回炉目标,所以它必须是草稿中真实存在、可被原样搜到的连续片段;
- `score`整数该段相对文风指纹的相似度0100**越低越偏离**
- `label`:字符串,漂移类型说明(如「机翻腔」「叙述拖沓」),可缺省(无合适标签时省略)。
@@ -47,4 +48,4 @@
- 只读、只报漂移诊断,**不改稿、不写库**(不变量 #3)。
- 依据指纹判定,**不臆造**;无明显漂移段则 `segments` 为空列表。
- `idx` 必须与正文段切分顺序一致;段级 `score` 越低代表越偏离。
- 每个漂移段的 `text` 必须是草稿里**逐字可搜到**的连续原文(含标点、不改写、不省略),否则前端无法定位、回炉会落空;段级 `score` 越低代表越偏离。

View File

@@ -194,13 +194,19 @@ class StyleFingerprintResult(BaseModel):
class StyleDriftSegment(BaseModel):
"""单个漂移段:段索引 + 相似度分 + 可选标签ARCH §5.4 打分轨)。
"""单个漂移段:原文锚 + 段索引 + 相似度分 + 可选标签ARCH §5.4 打分轨)。
`idx` 是本章段索引0 起),`score` 是该段相对文风指纹的相似度0100越低越偏
`label` 可缺(如「机翻腔」「叙述拖沓」等漂移类型说明)。
`text` 是本章草稿**逐字摘录**的该漂移段原文(含标点,不得改写)——前端据此用
内容匹配在终稿里定位回炉目标(**内容锚**,不靠位置 idx避免分段方式不一致导致定位
到错段 / 越界静默失败)。`idx` 仅供前端展示排序0 起);`score` 是该段相对文风指纹
的相似度0100越低越偏`label` 可缺(如「机翻腔」「叙述拖沓」等漂移类型说明)。
"""
idx: int = Field(description="本章段索引0 起)")
idx: int = Field(description="本章段索引0 起,仅供展示排序")
text: str = Field(
default="",
description="该漂移段从本章草稿**逐字摘录**的原文(含标点,不得改写);供前端内容锚定位回炉目标",
)
score: int = Field(description="该段相对文风指纹的相似度0100越低越偏离")
label: str | None = Field(default=None, description="漂移类型标签(如「机翻腔」);可缺")

View File

@@ -12,7 +12,7 @@ from dataclasses import dataclass, field
import pytest
from pydantic import ValidationError
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView
from ww_core.domain.rule_repo import RuleListItemView, RuleWriteRepo, RuleWriteView
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
@@ -23,11 +23,25 @@ class _FakeRuleWriteRepo:
flushed: int = 0
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)
self.flushed += 1
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 == project_id
]
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
for r in self.rows:
if r.id == rule_id and r.project_id == project_id:
self.rows.remove(r)
return True
return False
@pytest.mark.asyncio
async def test_create_returns_view() -> None:
@@ -41,6 +55,6 @@ async def test_create_returns_view() -> None:
def test_rule_view_is_frozen() -> None:
view = RuleWriteView(project_id=PROJECT, level="global", content="x")
view = RuleWriteView(id=uuid.uuid4(), project_id=PROJECT, level="global", content="x")
with pytest.raises(ValidationError):
view.content = "y"

View File

@@ -54,7 +54,12 @@ from ww_core.domain.project_repo import (
)
from ww_core.domain.repositories import DigestView, MemoryRepos
from ww_core.domain.review_repo import ReviewRepo, ReviewView, SqlReviewRepo
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView, SqlRuleWriteRepo
from ww_core.domain.rule_repo import (
RuleListItemView,
RuleWriteRepo,
RuleWriteView,
SqlRuleWriteRepo,
)
from ww_core.domain.style_repo import (
SqlStyleFingerprintWriteRepo,
StyleFingerprintView,
@@ -116,6 +121,7 @@ __all__ = [
"ReviewRepo",
"ReviewView",
"SqlReviewRepo",
"RuleListItemView",
"RuleWriteView",
"RuleWriteRepo",
"SqlRuleWriteRepo",

View File

@@ -24,6 +24,7 @@ import uuid
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Character
@@ -52,6 +53,10 @@ class CharacterWriteRepo(Protocol):
"""角色写侧接口(按 project_id 隔离;只 flush 不 commit
入参贴 `ww_agents.CharacterCard`(生成产物形);实现负责 schema → DB 列形变。
**幂等(按 (project_id, name) upsert**:同名重复入库 → 更新既有卡而非插重复行。
不加 DB UNIQUE 约束(线上库已存历史重复行,约束迁移会失败);改在 app 层
get-by-(project_id, name) → update else insertQA #8
"""
async def create(
@@ -70,7 +75,11 @@ class CharacterWriteRepo(Protocol):
class SqlCharacterWriteRepo:
"""SQLAlchemy 实现:一行 `characters`schema→DB 形变;只 flush 不 commit"""
"""SQLAlchemy 实现:upsert 一行 `characters`schema→DB 形变;只 flush 不 commit
幂等:先按 (project_id, name) 查既有行——命中则原地更新该行字段,否则插新行。
避免同名角色重复入库产生重复卡QA #8不加 DB 约束,纯 app 层)。
"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
@@ -88,6 +97,22 @@ class SqlCharacterWriteRepo:
tags: list[Any],
relations: list[dict[str, Any]],
) -> CharacterWriteView:
existing = (
await self._s.execute(
select(Character).where(Character.project_id == project_id, Character.name == name)
)
).scalar_one_or_none()
if existing is not None:
existing.role = role
existing.traits = _traits_to_jsonb(traits)
existing.backstory = backstory
existing.arc = _arc_to_jsonb(arc)
existing.speech_tics = _traits_to_jsonb(speech_tics)
existing.tags = list(tags)
existing.relations = [dict(r) for r in relations]
await self._s.flush()
await self._s.refresh(existing)
return CharacterWriteView(id=existing.id, name=existing.name, role=existing.role)
row = Character(
project_id=project_id,
name=name,

View File

@@ -1,12 +1,14 @@
"""规则**写侧** RepositoryC3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`)。
读侧(`all_for_project`,供 assemble 注入 + `merge_rules` 四级合并)已在
`ww_core.memory.sql_repositories.SqlRulesRepo` 提供C5不动)。本模块加**写**能力
命名加 `Write` 前缀避免歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。
`ww_core.memory.sql_repositories.SqlRulesRepo` 提供C5不动;其 `RuleView` 不带 id
专供缓存前缀拼装)。本模块加**写**能力 + **带 id 的列表 / 删除**——前端规则页需要稳定
handleid来删除某条规则不变量 #3删规则是作者显式动作不经 AI。命名加 `Write`
前缀避免与读侧 repo 歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。
`level` ∈ global/genre/style/project合法性由端点/schema 校验repo 只写)。
**提交边界**`create` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧
repo 一致,见 memory/gotchas
**提交边界**`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它
写侧 repo 一致,见 memory/gotchas
"""
from __future__ import annotations
@@ -15,6 +17,7 @@ import uuid
from typing import Protocol
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Rule
@@ -24,19 +27,34 @@ class RuleWriteView(BaseModel):
model_config = {"frozen": True}
id: uuid.UUID
project_id: uuid.UUID | None
level: str
content: str
class RuleListItemView(BaseModel):
"""规则列表项(带 id供前端规则页删除用snake_casefrozen"""
model_config = {"frozen": True}
id: uuid.UUID
level: str
content: str
class RuleWriteRepo(Protocol):
"""规则写侧接口(绑 project_id只 flush 不 commit"""
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: ...
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]: ...
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool: ...
class SqlRuleWriteRepo:
"""SQLAlchemy 实现:插一行 `rules`只 flush 不 commit"""
"""SQLAlchemy 实现:插一行 `rules` / 列表(带 id/ 删一行(均只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
@@ -46,4 +64,37 @@ class SqlRuleWriteRepo:
self._s.add(row)
await self._s.flush()
await self._s.refresh(row)
return RuleWriteView(project_id=row.project_id, level=row.level, content=row.content)
return RuleWriteView(
id=row.id, project_id=row.project_id, level=row.level, content=row.content
)
async def list_for_project(self, project_id: uuid.UUID) -> list[RuleListItemView]:
"""本作品规则 + 全局规则project_id 为空),带 id前端删除 handle
与读侧 `SqlRulesRepo.all_for_project` 同范围,但回传 id排序确定性
level 优先级在 assemble.merge_rules 处理,这里只保证稳定返回)。
"""
rows = (
await self._s.execute(
select(Rule).where((Rule.project_id == project_id) | (Rule.project_id.is_(None)))
)
).scalars()
return [RuleListItemView(id=r.id, level=r.level, content=r.content) for r in rows]
async def delete(self, project_id: uuid.UUID, rule_id: uuid.UUID) -> bool:
"""删除属于该项目的某条规则;删到行返回 True无匹配返回 False→ 端点 404
以 `(id, project_id)` 双条件定位——既防误删它项目规则也使删全局规则project_id
为空)经此端点天然不可达(项目维度规则页只删本作品规则,不变量 #3 作者显式动作)。
先取行再 `session.delete`(避免 bulk-delete 的 rowcount 类型坑),只 flush提交交端点。
"""
row = (
await self._s.execute(
select(Rule).where(Rule.id == rule_id, Rule.project_id == project_id)
)
).scalar_one_or_none()
if row is None:
return False
await self._s.delete(row)
await self._s.flush()
return True