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 干净。
This commit is contained in:
Yaojia Wang
2026-06-24 17:17:35 +02:00
parent a4ef250fc9
commit 2fe3bedfba
13 changed files with 688 additions and 27 deletions

View File

@@ -9,7 +9,6 @@ bugruntime checkpointer 工厂曾把 `postgresql+psycopg://…`settings
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from ww_api.services import chain_deps from ww_api.services import chain_deps

View File

@@ -11,6 +11,8 @@ import httpx
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway 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 ( from ww_core.domain.repositories import (
CharacterView, CharacterView,
DigestView, DigestView,
@@ -122,6 +124,16 @@ def _make_client(
return client, project_repo, chapter_repo, gateway 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 @pytest.mark.asyncio
async def test_create_project_returns_201() -> None: async def test_create_project_returns_201() -> None:
client, _, _, _ = _make_client() client, _, _, _ = _make_client()
@@ -180,8 +192,8 @@ async def test_create_project_rejects_blank_title() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_stream_yields_sse_tokens_and_done() -> None: async def test_draft_stream_yields_sse_tokens_and_done() -> None:
gateway = FakeWriterGateway(chunks=["阿福", "走进门。"]) gateway = FakeWriterGateway(chunks=["阿福", "走进门。"])
client, _, _, _ = _make_client(gateway=gateway) client, project_repo, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4() pid = _seed_project(project_repo)
async with client: async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft") resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -200,8 +212,8 @@ async def test_draft_stream_yields_sse_tokens_and_done() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_draft_stream_threads_directive_into_volatile() -> None: async def test_draft_stream_threads_directive_into_volatile() -> None:
gateway = FakeWriterGateway(chunks=["正文"]) gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway) client, project_repo, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4() pid = _seed_project(project_repo)
async with client: async with client:
resp = await client.post( resp = await client.post(
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"} 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 @pytest.mark.asyncio
async def test_draft_stream_backward_compatible_without_body() -> None: async def test_draft_stream_backward_compatible_without_body() -> None:
gateway = FakeWriterGateway(chunks=["正文"]) gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway) client, project_repo, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4() pid = _seed_project(project_repo)
async with client: async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft") resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200 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 from ww_shared import AppError
gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom")) gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
client, _, _, _ = _make_client(gateway=gateway) client, project_repo, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4() pid = _seed_project(project_repo)
async with client: async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft") resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200 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 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 @pytest.mark.asyncio
async def test_put_draft_is_idempotent() -> None: async def test_put_draft_is_idempotent() -> None:
chapter_repo = FakeChapterRepo() chapter_repo = FakeChapterRepo()

View File

@@ -3,7 +3,8 @@
覆盖: 覆盖:
- 201 + 回显 level/content + 端点 commit - 201 + 回显 level/content + 端点 commit
- 非法 level → 422Pydantic Literal 校验FastAPI 422 - 非法 level → 422Pydantic Literal 校验FastAPI 422
- 空 content → 422 - 空 content → 422
- 项目不存在 → 404QA H2 回归:此前 FK 违例逃逸成 500
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,7 +14,9 @@ import uuid
import httpx import httpx
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from fakes_projects import FakeSession from fakes_projects import FakeProjectRepo, FakeSession
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.rule_repo import RuleWriteView from ww_core.domain.rule_repo import RuleWriteView
@@ -27,29 +30,39 @@ class _FakeRuleWriteRepo:
return view return view
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]: 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 import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app 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 from ww_db import get_session
repo = _FakeRuleWriteRepo() repo = _FakeRuleWriteRepo()
session = FakeSession() session = FakeSession()
project_repo = FakeProjectRepo()
pid = uuid.uuid4()
project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="作品"))
app = create_app() app = create_app()
app.dependency_overrides[get_rule_write_repo] = lambda: repo 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 app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test") client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session return client, repo, session, project_repo, pid
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_rule_returns_201_and_commits() -> None: async def test_create_rule_returns_201_and_commits() -> None:
client, repo, session = _make_client() client, repo, session, _project_repo, pid = _make_client()
pid = uuid.uuid4()
async with client: async with client:
resp = await client.post( resp = await client.post(
f"/projects/{pid}/rules", f"/projects/{pid}/rules",
@@ -66,8 +79,7 @@ async def test_create_rule_returns_201_and_commits() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_rule_invalid_level_returns_422() -> None: async def test_create_rule_invalid_level_returns_422() -> None:
client, _repo, _session = _make_client() client, _repo, _session, _project_repo, pid = _make_client()
pid = uuid.uuid4()
async with client: async with client:
resp = await client.post( resp = await client.post(
f"/projects/{pid}/rules", f"/projects/{pid}/rules",
@@ -78,11 +90,24 @@ async def test_create_rule_invalid_level_returns_422() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_rule_empty_content_returns_422() -> None: async def test_create_rule_empty_content_returns_422() -> None:
client, _repo, _session = _make_client() client, _repo, _session, _project_repo, pid = _make_client()
pid = uuid.uuid4()
async with client: async with client:
resp = await client.post( resp = await client.post(
f"/projects/{pid}/rules", f"/projects/{pid}/rules",
json={"level": "global", "content": ""}, json={"level": "global", "content": ""},
) )
assert resp.status_code == 422 assert resp.status_code == 422
@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 # 未触达写库

View File

@@ -252,6 +252,7 @@ async def stream_draft(
repos: MemoryReposDep, repos: MemoryReposDep,
gateway: GatewayDep, gateway: GatewayDep,
injection_repo: InjectionRepoDep, injection_repo: InjectionRepoDep,
project_repo: ProjectRepoDep,
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
body: DraftStreamRequest | None = None, body: DraftStreamRequest | None = None,
) -> StreamingResponse: ) -> StreamingResponse:
@@ -259,7 +260,12 @@ async def stream_draft(
`body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化 `body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化
无 body 的旧调用方仍可用(向后兼容)。 无 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) request_id = getattr(request.state, "request_id", None)
directive = body.directive if body else None directive = body.directive if body else None
override = await injection_repo.get(project_id, chapter_no) override = await injection_repo.get(project_id, chapter_no)

View File

@@ -18,17 +18,21 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import RuleWriteRepo from ww_core.domain import RuleWriteRepo
from ww_core.domain.project_repo import ProjectRepo
from ww_db import get_session from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger from ww_api.logging_config import get_logger
from ww_api.schemas.rules import RuleCreateRequest, RuleView 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") log = get_logger("ww.api.rules")
router = APIRouter(prefix="/projects", tags=["rules"]) router = APIRouter(prefix="/projects", tags=["rules"])
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)] RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -38,10 +42,16 @@ async def create_rule(
body: RuleCreateRequest, body: RuleCreateRequest,
request: Request, request: Request,
repo: RuleWriteRepoDep, repo: RuleWriteRepoDep,
project_repo: ProjectRepoDep,
session: SessionDep, session: SessionDep,
) -> RuleView: ) -> 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) 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) view = await repo.create(project_id, level=body.level, content=body.content)
await session.commit() await session.commit()
log.info( log.info(

1
apps/web/.gitignore vendored
View File

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

View File

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

View File

@@ -44,6 +44,7 @@ describe("toCreateRequest", () => {
sellingPoints: ["逆袭", "系统流"], sellingPoints: ["逆袭", "系统流"],
structure: "三幕", structure: "三幕",
premise: " ", premise: " ",
protagonist: "",
theme: "抗争", theme: "抗争",
}; };
expect(toCreateRequest(form)).toEqual({ expect(toCreateRequest(form)).toEqual({
@@ -56,6 +57,30 @@ describe("toCreateRequest", () => {
structure: "三幕", 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。 // wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。

View File

@@ -8,6 +8,7 @@ export interface WizardForm {
sellingPoints: string[]; sellingPoints: string[];
structure: string; structure: string;
premise: string; premise: string;
protagonist: string;
theme: string; theme: string;
} }
@@ -20,6 +21,7 @@ export const emptyWizardForm: WizardForm = {
sellingPoints: [], sellingPoints: [],
structure: "", structure: "",
premise: "", premise: "",
protagonist: "",
theme: "", theme: "",
}; };
@@ -50,11 +52,17 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
const v = s.trim(); const v = s.trim();
return v.length > 0 ? v : null; 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 { return {
title: form.title.trim(), title: form.title.trim(),
genre: trim(form.genre), genre: trim(form.genre),
logline: trim(form.logline), logline: trim(form.logline),
premise: trim(form.premise), premise,
theme: trim(form.theme), theme: trim(form.theme),
selling_points: form.sellingPoints, selling_points: form.sellingPoints,
structure: trim(form.structure), 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 跑通。