diff --git a/apps/api/tests/test_chain.py b/apps/api/tests/test_chain.py index 9cb2948..d4a82b6 100644 --- a/apps/api/tests/test_chain.py +++ b/apps/api/tests/test_chain.py @@ -252,6 +252,28 @@ async def test_run_chain_returns_202_and_schedules_job( assert len(_noop_run_chain_job) == 1 +async def test_run_continue_volume_chain_returns_202_and_forwards_chain_key( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + """F2:continue_volume 链受支持 → 202,且 chain_key 透传给 run_chain_job(续写模式)。""" + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/continue_volume/run", + json={"start_chapter_no": 2, "count": 2}, + ) + + assert resp.status_code == 202 + body = resp.json() + assert body["chain_key"] == "continue_volume" + assert len(_noop_run_chain_job) == 1 + assert _noop_run_chain_job[0]["kwargs"]["chain_key"] == "continue_volume" + + async def test_run_chain_unknown_key_returns_404( _noop_run_chain_job: list[dict[str, Any]], ) -> None: @@ -343,6 +365,31 @@ async def test_resume_chain_awaiting_returns_202( assert len(_noop_run_chain_job) == 1 +async def test_resume_continue_volume_reports_true_chain_key( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + """回归守卫 #1:resume continue_volume run → 回执 chain_key="continue_volume"(读自 + job.result,run 时由 _chain_result 持久化),而非旧 bug 按 job.kind 推断的 "draft_volume"。 + 且转发给 run_chain_job 的 chain_key 也是真值(续写模式不被错降为 draft_volume)。""" + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + job = await job_repo.create(pid, "chain") # kind 恒为通用常量 "chain" + # run 时 interrupt 把真 chain_key 持久进 result(continue_volume)。 + await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1, "chain_key": "continue_volume"}) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/runs/{job.id}/resume", + json={"decisions": [{"conflict_index": 0, "verdict": "ignore"}]}, + ) + + assert resp.status_code == 202 + assert resp.json()["chain_key"] == "continue_volume" + assert _noop_run_chain_job[0]["kwargs"]["chain_key"] == "continue_volume" + + async def test_resume_chain_non_awaiting_returns_409( _noop_run_chain_job: list[dict[str, Any]], ) -> None: diff --git a/apps/api/tests/test_templates.py b/apps/api/tests/test_templates.py new file mode 100644 index 0000000..9b4a034 --- /dev/null +++ b/apps/api/tests/test_templates.py @@ -0,0 +1,144 @@ +"""F3b GET/POST/DELETE /templates 端点(内存替身,无 DB/无网络)。 + +覆盖: +- POST 201 + 回显 + 端点 commit;GET 列出;DELETE 204 + commit; +- 空 title → 422、空 body → 422(schema `min_length=1`,FastAPI 422); +- 删不存在 → 404(repo.delete 返 False → AppError NOT_FOUND)。 +""" + +from __future__ import annotations + +import os +import uuid + +import httpx +import pytest +from cryptography.fernet import Fernet +from fakes_projects import FakeSession +from ww_core.domain.template_repo import TemplateCreate, TemplateView + + +class _FakeTemplateRepo: + def __init__(self) -> None: + self.rows: dict[uuid.UUID, TemplateView] = {} + + async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: + view = TemplateView( + id=uuid.uuid4(), + title=data.title, + body=data.body, + category=data.category, + tool_key=data.tool_key, + ) + self.rows[view.id] = view + return view + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: + return list(self.rows.values()) + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: + if template_id in self.rows: + del self.rows[template_id] + return True + return False + + +def _make_client() -> tuple[httpx.AsyncClient, _FakeTemplateRepo, FakeSession]: + 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_template_repo + from ww_db import get_session + + repo = _FakeTemplateRepo() + session = FakeSession() + + app = create_app() + app.dependency_overrides[get_template_repo] = lambda: 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 + + +@pytest.mark.asyncio +async def test_create_template_returns_201_and_commits() -> None: + client, repo, session = _make_client() + async with client: + resp = await client.post( + "/templates", + json={"title": "爽文开局", "body": "主角穿越...", "tool_key": "expand"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["title"] == "爽文开局" + assert body["body"] == "主角穿越..." + assert body["tool_key"] == "expand" + assert session.commits == 1 + assert len(repo.rows) == 1 + + +@pytest.mark.asyncio +async def test_list_templates_returns_created() -> None: + client, repo, _session = _make_client() + async with client: + await client.post("/templates", json={"title": "a", "body": "b"}) + resp = await client.get("/templates") + assert resp.status_code == 200 + items = resp.json() + assert len(items) == 1 + assert items[0]["title"] == "a" + + +@pytest.mark.asyncio +async def test_create_template_empty_title_returns_422() -> None: + client, _repo, _session = _make_client() + async with client: + resp = await client.post("/templates", json={"title": "", "body": "b"}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_template_empty_body_returns_422() -> None: + client, _repo, _session = _make_client() + async with client: + resp = await client.post("/templates", json={"title": "a", "body": ""}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_template_whitespace_only_title_returns_422() -> None: + # strip 后为空 → min_length=1 不满足 → 422(防纯空白标题,审评 #2)。 + client, _repo, _session = _make_client() + async with client: + resp = await client.post("/templates", json={"title": " ", "body": "b"}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_template_whitespace_only_body_returns_422() -> None: + client, _repo, _session = _make_client() + async with client: + resp = await client.post("/templates", json={"title": "a", "body": " "}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_delete_template_returns_204_and_commits() -> None: + client, repo, session = _make_client() + async with client: + created = await client.post("/templates", json={"title": "a", "body": "b"}) + tid = created.json()["id"] + resp = await client.delete(f"/templates/{tid}") + assert resp.status_code == 204 + assert session.commits == 2 + assert repo.rows == {} + + +@pytest.mark.asyncio +async def test_delete_missing_template_returns_404() -> None: + client, _repo, session = _make_client() + async with client: + resp = await client.delete(f"/templates/{uuid.uuid4()}") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == "NOT_FOUND" + assert session.commits == 0 diff --git a/apps/api/tests/test_toolbox_endpoints.py b/apps/api/tests/test_toolbox_endpoints.py index 06d839d..105e767 100644 --- a/apps/api/tests/test_toolbox_endpoints.py +++ b/apps/api/tests/test_toolbox_endpoints.py @@ -31,6 +31,7 @@ from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView from ww_core.domain.outline_write_repo import OutlineWriteView from ww_core.domain.project_repo import ProjectCreate from ww_core.domain.repositories import OutlineView +from ww_core.domain.rule_repo import RuleWriteView from ww_core.domain.world_entity_repo import WorldEntityWriteView from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_shared import AppError, ErrorCode @@ -94,6 +95,17 @@ class _FakeOutlineWriteRepo: ) +class _FakeRuleWriteRepo: + """拆书入库 rules 用的内存替身:按 (level, content) 收行(只 flush 语义)。""" + + def __init__(self) -> None: + self.rows: list[dict[str, Any]] = [] + + 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) + + class _FakeOutlineReadRepo: """续写/按章工具读节拍用的内存替身:缺章 → None(端点降级到空节拍)。""" @@ -199,6 +211,7 @@ def _make_app( outline_write_repo: _FakeOutlineWriteRepo | None = None, chapter_repo: _FakeChapterRepo | None = None, outline_read_repo: _FakeOutlineReadRepo | None = None, + rule_write_repo: _FakeRuleWriteRepo | None = None, memory: Any = None, no_creds: bool = False, ) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]: @@ -210,6 +223,7 @@ def _make_app( get_outline_read_repo, get_outline_write_repo, get_project_repo, + get_rule_write_repo, get_tier_gateway_builder, get_world_entity_write_repo, ) @@ -220,6 +234,7 @@ def _make_app( outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo() chapter_repo = chapter_repo or _FakeChapterRepo() outline_read_repo = outline_read_repo or _FakeOutlineReadRepo() + rule_write_repo = rule_write_repo or _FakeRuleWriteRepo() async def _build_ok(_tier: str) -> Any: return gateway @@ -236,6 +251,7 @@ def _make_app( app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo + app.dependency_overrides[get_rule_write_repo] = lambda: rule_write_repo app.dependency_overrides[get_session] = lambda: session app.dependency_overrides[get_tier_gateway_builder] = lambda: ( _build_no_creds if no_creds else _build_ok @@ -285,10 +301,13 @@ async def test_list_toolbox_contains_all_keys() -> None: "teardown", } by_key = {t["key"]: t for t in tools} - # 4 个竞品生成器:非 legacy、纯预览(不可入库)。 - for key in ("continue", "expand", "de-ai", "teardown"): + # 续写/扩写/降AI:非 legacy、纯预览(不可入库)。 + for key in ("continue", "expand", "de-ai"): assert by_key[key]["is_legacy"] is False assert by_key[key]["ingestable"] is False + # F1:拆书非 legacy、可入库(落 rules)。 + assert by_key["teardown"]["is_legacy"] is False + assert by_key["teardown"]["ingestable"] is True # legacy 携 legacy_route + is_legacy;新工具 ingestable 标记。 assert by_key["worldbuilding"]["is_legacy"] is True assert by_key["worldbuilding"]["legacy_route"] @@ -622,3 +641,72 @@ async def test_ingest_outline_upserts_scenes() -> None: assert outline_repo.rows[0]["beats"] == ["主角觉醒", "初遇反派"] assert outline_repo.rows[0]["chapter_no"] == 3 assert session.commits == 1 + + +# ---- POST .../ingest(F1:拆书落库成 rules)---- + + +def _teardown_payload() -> dict[str, Any]: + return { + "teardown": { + "themes": ["逆袭", "复仇"], + "archetypes": ["废柴主角", "腹黑反派"], + "structure": "黄金三章立钩→铺垫→爆发", + "hooks": ["开局即巅峰", "扮猪吃虎"], + } + } + + +@pytest.mark.asyncio +async def test_ingest_teardown_writes_rules_201() -> None: + # F1:拆书结论拍平为可读 rules 条目落库(无 continuity 预检,仿 outline 无 409)。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + rule_repo = _FakeRuleWriteRepo() + gateway = _SchemaRoutingGateway({}) + app, session, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/teardown/ingest", json=_teardown_payload() + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["table"] == "rules" + assert body["rejected_tables"] == [] + # 真落行:themes/archetypes/structure/hooks 各拼成可读规则条目。 + assert len(rule_repo.rows) >= 1 + contents = "\n".join(r["content"] for r in rule_repo.rows) + assert "逆袭" in contents + assert "废柴主角" in contents + assert "黄金三章立钩" in contents + assert "开局即巅峰" in contents + # 全部落到 project 级 rules(owner stub 单用户)。 + for r in rule_repo.rows: + assert r["level"] == "project" + assert r["project_id"] == pid + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_teardown_generate_preview_does_not_write_rules() -> None: + # 负向:预览(generate)只产结构化产物,绝不写 rules(不变量 #3:入库经 ingest gate)。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + rule_repo = _FakeRuleWriteRepo() + parsed = BookTeardownResult( + themes=["逆袭"], archetypes=["废柴主角"], structure="立钩", hooks=["开局巅峰"] + ) + gateway = _CaptureGateway(parsed) + app, _, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/teardown/generate", + json={"kind": "某爆款", "text": "样本章节正文……"}, + ) + + assert resp.status_code == 200 + assert resp.json()["output_kind"] == "BookTeardownResult" + assert rule_repo.rows == [] # 预览不写库 diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index e64b5f6..e2e78c1 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -27,6 +27,7 @@ from ww_api.routers import ( rules, settings_providers, style, + templates, toolbox, ) from ww_api.security.credentials import _fernet @@ -115,6 +116,7 @@ def create_app() -> FastAPI: app.include_router(outline.router) app.include_router(rules.router) app.include_router(style.router) + app.include_router(templates.router) app.include_router(generation.router) app.include_router(generation.skills_router) app.include_router(toolbox.router) diff --git a/apps/api/ww_api/routers/chain.py b/apps/api/ww_api/routers/chain.py index 73ceea3..35f1061 100644 --- a/apps/api/ww_api/routers/chain.py +++ b/apps/api/ww_api/routers/chain.py @@ -59,8 +59,9 @@ log = get_logger("ww.api.chain") router = APIRouter(prefix="/projects", tags=["chain"]) -# 本期唯一内置链(§1.3/§3)。未知 key → 404(系统边界,fail fast)。 -SUPPORTED_CHAINS = frozenset({"draft_volume"}) +# 内置链(§1.3/§3):draft_volume=从 start 章按记忆量产;continue_volume=每章以上一章已验收 +# 正文末尾作前文引子续写(F2)。未知 key → 404(系统边界,fail fast)。 +SUPPORTED_CHAINS = frozenset({"draft_volume", "continue_volume"}) ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)] @@ -214,7 +215,9 @@ async def resume_chain( f"job {job_id} 已被并发续跑抢占或非 awaiting_input 态,不可重复续跑", ) - chain_key = claimed.kind if claimed.kind in SUPPORTED_CHAINS else "draft_volume" + # chain_key 真值:run 时 interrupt 把图 state 的 chain_key 持久进 job.result(_chain_result)。 + # 读回它,而非按 job.kind 推断(kind 恒为通用常量 JOB_KIND_CHAIN,从不在 SUPPORTED_CHAINS)。 + chain_key = _chain_key_from_result(claimed.result) accept_op = build_accept_op( session_factory=session_factory, @@ -250,6 +253,17 @@ async def resume_chain( return ChainResumeAccepted(job_id=job_id, chain_key=chain_key) +def _chain_key_from_result(result: dict[str, Any] | None) -> str: + """从 awaiting job 的 result 取真 chain_key(run 时由 _chain_result 持久化)。 + + 缺失/非受支持 key(旧行/异常)→ 回退 "draft_volume"(保守默认,不崩)。 + """ + key = (result or {}).get("chain_key") + if isinstance(key, str) and key in SUPPORTED_CHAINS: + return key + return "draft_volume" + + def _chapter_repo_factory(session: AsyncSession) -> object: """链节点(review 重读草稿)按 session 建 chapter draft repo。""" from ww_core.domain.chapter_repo import SqlChapterRepo diff --git a/apps/api/ww_api/routers/templates.py b/apps/api/ww_api/routers/templates.py new file mode 100644 index 0000000..f3c9a2a --- /dev/null +++ b/apps/api/ww_api/routers/templates.py @@ -0,0 +1,90 @@ +"""模板库端点(F3 / 契约 §F3)。 + +单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。 +**不做分享/市场**(需多租户)。owner_id 全程 stub(单用户原型)。 + +- GET /templates 列出当前用户的模板。 +- POST /templates 新建模板(201;title/body 空 → 422)。 +- DELETE /templates/:id 删除模板(204;不存在 → 404)。 + +提交边界:`TemplateRepo.create`/`delete` 只 `flush()`,端点写后 `await session.commit()` +(仿 rules/foreshadow 写侧,见 memory/gotchas)。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession +from ww_core.domain import TemplateCreate, TemplateRepo +from ww_db import get_session +from ww_shared import AppError, ErrorCode + +from ww_api.logging_config import get_logger +from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse +from ww_api.services.credentials import STUB_OWNER_ID +from ww_api.services.project_deps import get_template_repo + +log = get_logger("ww.api.templates") + +router = APIRouter(prefix="/templates", tags=["templates"]) + +TemplateRepoDep = Annotated[TemplateRepo, Depends(get_template_repo)] +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +@router.get("") +async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]: + """列出当前用户(stub)的模板。""" + views = await repo.list_for_owner(STUB_OWNER_ID) + return [ + TemplateResponse( + id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key + ) + for v in views + ] + + +@router.post("", status_code=201) +async def create_template( + body: TemplateCreateRequest, + request: Request, + repo: TemplateRepoDep, + session: SessionDep, +) -> TemplateResponse: + """新建模板(201)。title/body 空 → FastAPI 422(schema `min_length=1`)。""" + request_id = getattr(request.state, "request_id", None) + view = await repo.create( + STUB_OWNER_ID, + TemplateCreate( + title=body.title, body=body.body, category=body.category, tool_key=body.tool_key + ), + ) + await session.commit() + log.info("template_created", template_id=str(view.id), request_id=request_id) + return TemplateResponse( + id=view.id, + title=view.title, + body=view.body, + category=view.category, + tool_key=view.tool_key, + ) + + +@router.delete("/{template_id}", status_code=204) +async def delete_template( + template_id: uuid.UUID, + request: Request, + repo: TemplateRepoDep, + session: SessionDep, +) -> Response: + """删除模板(204)。不存在 → 404 NOT_FOUND。""" + request_id = getattr(request.state, "request_id", None) + deleted = await repo.delete(STUB_OWNER_ID, template_id) + if not deleted: + raise AppError(ErrorCode.NOT_FOUND, f"template not found: {template_id}") + await session.commit() + log.info("template_deleted", template_id=str(template_id), request_id=request_id) + return Response(status_code=204) diff --git a/apps/api/ww_api/routers/toolbox.py b/apps/api/ww_api/routers/toolbox.py index a4d3ef4..c7b754a 100644 --- a/apps/api/ww_api/routers/toolbox.py +++ b/apps/api/ww_api/routers/toolbox.py @@ -24,6 +24,7 @@ from ww_core.domain.chapter_repo import ChapterRepo from ww_core.domain.outline_write_repo import OutlineWriteRepo from ww_core.domain.project_repo import ProjectRepo from ww_core.domain.repositories import MemoryRepos, OutlineRepo +from ww_core.domain.rule_repo import RuleWriteRepo from ww_core.domain.world_entity_repo import WorldEntityWriteRepo from ww_core.orchestrator import run_generator from ww_db import get_session @@ -37,6 +38,7 @@ from ww_api.routers.generation import _project_context, _world_context from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView from ww_api.schemas.toolbox import ( OutlineSceneIngestView, + TeardownIngestView, ToolboxListResponse, ToolDescriptorView, ToolGeneratePreviewResponse, @@ -53,6 +55,7 @@ from ww_api.services.project_deps import ( get_outline_read_repo, get_outline_write_repo, get_project_repo, + get_rule_write_repo, get_tier_gateway_builder, get_world_entity_write_repo, ) @@ -79,6 +82,7 @@ OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)] OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)] +RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)] GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)] SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -316,6 +320,7 @@ async def ingest_with_tool( memory: MemoryReposDep, world_write_repo: WorldWriteRepoDep, outline_write_repo: OutlineWriteRepoDep, + rule_write_repo: RuleWriteRepoDep, build_gateway: GatewayBuilderDep, session: SessionDep, ) -> ToolIngestResponse: @@ -362,6 +367,16 @@ async def ingest_with_tool( request_id=request_id, tool_key=tool_key, ) + if table == "rules": + return await _ingest_rules( + spec, + body=body, + project_id=project_id, + rule_write_repo=rule_write_repo, + session=session, + request_id=request_id, + tool_key=tool_key, + ) # 描述符的 IngestSpec.table 已限白名单;其余表本期通用入库未实现。 raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table}) @@ -493,3 +508,72 @@ async def _ingest_outline( created_count=len(created), ) return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected) + + +# 拆书入库的 rules 级别(meta 规则非设定卡,落项目级;单用户原型 owner stub)。 +_TEARDOWN_RULE_LEVEL = "project" + + +def _flatten_teardown_rules(teardown: TeardownIngestView) -> list[tuple[str, str]]: + """把拆书结论拍平成可读 rules 条目:返回 (label, content) 列表(确定性顺序,跳过空字段)。 + + 主题/原型/钩子各一条(清单拼成可读文本);结构一条。空清单/空串的字段跳过,不落空规则。 + """ + entries: list[tuple[str, str]] = [] + if teardown.themes: + entries.append(("themes", f"核心主题:{';'.join(teardown.themes)}")) + if teardown.archetypes: + entries.append(("archetypes", f"人物原型:{';'.join(teardown.archetypes)}")) + if teardown.structure.strip(): + entries.append(("structure", f"叙事结构:{teardown.structure.strip()}")) + if teardown.hooks: + entries.append(("hooks", f"钩子套路:{';'.join(teardown.hooks)}")) + return entries + + +async def _ingest_rules( + spec: AgentSpec, + *, + body: ToolIngestRequest, + project_id: uuid.UUID, + rule_write_repo: RuleWriteRepo, + session: AsyncSession, + request_id: str | None, + tool_key: str, +) -> ToolIngestResponse: + """rules 入库(拆书):白名单 → 把拆书结论拍平为可读 rules 条目写入项目级 rules。 + + meta 规则非设定卡,无需 continuity 预检(仿 outline 无 409 路径);仍过 partition_writes + 白名单(teardown spec 声明 writes=["rules"])。无 teardown 产物 → VALIDATION。 + """ + if body.teardown is None: + raise AppError(ErrorCode.VALIDATION, "拆书入库需提供 teardown 产物", {"tool_key": tool_key}) + + entries = _flatten_teardown_rules(body.teardown) + + # gate:权限白名单(teardown spec 声明 writes=["rules"])。 + allowed, rejected = partition_writes(spec, {"rules": entries}) + if rejected: + log.warning( + "toolbox_ingest_rejected_over_permission", + tool_key=tool_key, + project_id=str(project_id), + request_id=request_id, + rejected_tables=rejected, + ) + + created: list[str] = [] + for label, content in allowed.get("rules", []): + await rule_write_repo.create(project_id, level=_TEARDOWN_RULE_LEVEL, content=content) + created.append(label) + + await session.commit() + log.info( + "toolbox_ingest_done", + tool_key=tool_key, + project_id=str(project_id), + request_id=request_id, + table="rules", + created_count=len(created), + ) + return ToolIngestResponse(table="rules", created=created, rejected_tables=rejected) diff --git a/apps/api/ww_api/schemas/templates.py b/apps/api/ww_api/schemas/templates.py new file mode 100644 index 0000000..0939d43 --- /dev/null +++ b/apps/api/ww_api/schemas/templates.py @@ -0,0 +1,35 @@ +"""模板库端点的请求/响应 schema(F3 / 契约 §F3)。 + +snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。 +单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。 +`title`/`body` 非空(先 strip 再 `min_length=1`,纯空白 → 422)。`category`/`tool_key` 可选。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from pydantic import BaseModel, Field, StringConstraints + +# 先去首尾空白再校验长度:纯空白(" ")strip 后为空 → min_length=1 不满足 → 422。 +NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] + + +class TemplateCreateRequest(BaseModel): + """POST /templates:新建一条提示词模板。""" + + title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422)") + body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text)") + category: str | None = Field(default=None, description="可选分类") + tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)") + + +class TemplateResponse(BaseModel): + """模板视图(列出/创建后回显;snake_case)。""" + + id: uuid.UUID + title: str + body: str + category: str | None = None + tool_key: str | None = None diff --git a/apps/api/ww_api/schemas/toolbox.py b/apps/api/ww_api/schemas/toolbox.py index 6401ed5..13d7e9e 100644 --- a/apps/api/ww_api/schemas/toolbox.py +++ b/apps/api/ww_api/schemas/toolbox.py @@ -105,12 +105,22 @@ class OutlineSceneIngestView(BaseModel): hook: str = "" +class TeardownIngestView(BaseModel): + """拆书入库产物(贴 ww_agents.BookTeardownResult;落 rules 表)。F1。""" + + themes: list[str] = Field(default_factory=list, description="核心主题/立意清单") + archetypes: list[str] = Field(default_factory=list, description="人物原型/角色模板清单") + structure: str = Field(default="", description="叙事结构概述") + hooks: list[str] = Field(default_factory=list, description="抓人钩子/爽点套路清单") + + class ToolIngestRequest(BaseModel): """通用入库请求:待持久化的产物 + 冲突确认。 仅声明了 `ingest` 的工具可用。按入库表取用对应字段: - world_entities(金手指/词条)→ `world_entities`(贴 WorldEntityCardView:type/name/rules); - - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。 + - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView); + - rules(拆书)→ `teardown`(贴 TeardownIngestView:themes/archetypes/structure/hooks)。 `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate)。 """ @@ -123,6 +133,9 @@ class ToolIngestRequest(BaseModel): scenes: list[OutlineSceneIngestView] = Field( default_factory=list, description="outline 入库的场景行(细纲)" ) + teardown: TeardownIngestView | None = Field( + default=None, description="rules 入库的拆书结论(F1;拍平为可读规则条目)" + ) acknowledge_conflicts: bool = Field( default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库" ) diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index 3c8af1e..41bec52 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -29,6 +29,7 @@ from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo +from ww_core.domain.template_repo import SqlTemplateRepo, TemplateRepo from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos from ww_db import get_session, get_sessionmaker @@ -159,6 +160,16 @@ def get_rule_write_repo( return SqlRuleWriteRepo(session) +async def get_template_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> TemplateRepo: + """模板库 repo(GET/POST/DELETE /templates;create/delete 只 flush,端点提交)。 + + 测试经 `app.dependency_overrides` 注入内存 fake。 + """ + return SqlTemplateRepo(session) + + async def get_skill_registry( session: Annotated[AsyncSession, Depends(get_session)], ) -> SkillRegistry: diff --git a/apps/web/app/templates/page.tsx b/apps/web/app/templates/page.tsx new file mode 100644 index 0000000..596d083 --- /dev/null +++ b/apps/web/app/templates/page.tsx @@ -0,0 +1,32 @@ +import { AppShell } from "@/components/AppShell"; +import { TemplatesManager } from "@/components/templates/TemplatesManager"; +import { fetchTemplates } from "@/lib/api/server"; +import type { TemplateResponse } from "@/lib/api/types"; + +// 提示词/模板库(F3,全局单用户本地版)。Server Component 预取列表;CRUD 在客户端组件。 +export default async function TemplatesPage() { + let initial: TemplateResponse[] = []; + let loadError = false; + try { + initial = await fetchTemplates(); + } catch { + loadError = true; + } + + return ( + +
+

+ 保存常用提示词,复用时一键填入生成器的 brief / 原文输入。仅本地、单用户,不做分享。 +

+ {loadError ? ( +

+ 无法连接后端服务,请确认 API 已启动后刷新。 +

+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/components/chain/ChainPage.tsx b/apps/web/components/chain/ChainPage.tsx index 0ba9178..33716b8 100644 --- a/apps/web/components/chain/ChainPage.tsx +++ b/apps/web/components/chain/ChainPage.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { useToast } from "@/components/Toast"; import { api } from "@/lib/api/client"; -import { chainPhase, chainResultView } from "@/lib/chain/chain"; +import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain"; import { friendlyError } from "@/lib/errors/messages"; import { useJobPoll } from "@/lib/jobs/useJobPoll"; import type { ProjectResponse } from "@/lib/api/types"; @@ -17,8 +17,6 @@ interface ChainPageProps { project: ProjectResponse; } -const CHAIN_KEY = "draft_volume"; - // 多章工作流链页(Scope B B1):发起链(POST run)→job 轮询进度→interrupt 命中则读该章冲突裁决→ // POST resume 续跑。复用 useJobPoll(轮询)+ ConflictCard/decisions(裁决)+ friendlyError(文案)。 // 链是后台任务,走 job 轮询而非 SSE。awaiting 时停轮询(后端把 job 置 awaiting_input, @@ -43,14 +41,18 @@ export function ChainPage({ project }: ChainPageProps) { }, [phase, poll]); const onStart = useCallback( - async (startChapterNo: number, count: number): Promise => { + async ( + chainKey: ChainKind, + startChapterNo: number, + count: number, + ): Promise => { setStarting(true); try { const { data, error } = await api.POST( "/projects/{project_id}/chains/{chain_key}/run", { params: { - path: { project_id: project.id, chain_key: CHAIN_KEY }, + path: { project_id: project.id, chain_key: chainKey }, }, body: { start_chapter_no: startChapterNo, count }, }, @@ -89,7 +91,10 @@ export function ChainPage({ project }: ChainPageProps) { activeNav="chains" >
- void onStart(s, c)} disabled={busy} /> + void onStart(k, s, c)} + disabled={busy} + /> {showProgress && result ? ( diff --git a/apps/web/components/chain/ChainStarter.tsx b/apps/web/components/chain/ChainStarter.tsx index 5ae691d..cc0fd52 100644 --- a/apps/web/components/chain/ChainStarter.tsx +++ b/apps/web/components/chain/ChainStarter.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; +import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain"; + interface ChainStarterProps { - // 发起一条链(起始章 + 章数);父层负责 POST run + 轮询。 - onStart: (startChapterNo: number, count: number) => void; + // 发起一条链(链类型 + 起始章 + 章数);父层负责 POST run + 轮询。 + onStart: (chainKey: ChainKind, startChapterNo: number, count: number) => void; // 链正在运行/等待裁决时禁用(避免重复发起)。 disabled: boolean; } @@ -12,10 +14,12 @@ interface ChainStarterProps { const DEFAULT_START = 1; const DEFAULT_COUNT = 3; const MAX_COUNT = 50; +const DEFAULT_CHAIN: ChainKind = "draft_volume"; // 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。 // count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。 export function ChainStarter({ onStart, disabled }: ChainStarterProps) { + const [chainKey, setChainKey] = useState(DEFAULT_CHAIN); const [start, setStart] = useState(String(DEFAULT_START)); const [count, setCount] = useState(String(DEFAULT_COUNT)); @@ -34,7 +38,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) { aria-label="发起多章链" onSubmit={(e) => { e.preventDefault(); - if (valid && !disabled) onStart(startNo, countNo); + if (valid && !disabled) onStart(chainKey, startNo, countNo); }} >
@@ -43,6 +47,33 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) { 从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。

+
+ 链类型 +
+ {CHAIN_KINDS.map((kind) => ( + + ))} +
+