feat: Phase 0 — monorepo 骨架 + 全表迁移 + FastAPI/Next 骨架 + CI

- uv(workspace) + pnpm monorepo;docker-compose(pg+api+web)
- SQLAlchemy 16 MVP 表 + Alembic 初版迁移(无漂移,users stub)
- FastAPI 骨架:统一错误信封(带 request_id) + structlog + /jobs/:id + OpenAPI
- Next.js 骨架:纸感主题 token + OpenAPI→TS 客户端代码生成(gen:api)
- CI(ruff/mypy/pytest + pg service + alembic 漂移校验)
- 四份设计规格(PRODUCT/UX/ARCHITECTURE/DEV_PLAN) + CLAUDE.md
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
commit d3dc620a71
74 changed files with 12960 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
[project]
name = "ww-config"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2.7", "pydantic-settings>=2.3", "ww-shared"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_config"]
[tool.uv.sources]
ww-shared = { workspace = true }

View File

@@ -0,0 +1,3 @@
from ww_config.settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]

View File

@@ -0,0 +1,29 @@
"""应用配置env 解析)。提供商注册/档位默认见后续阶段。"""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "dev"
log_json: bool = False
database_url: str = "postgresql+asyncpg://writer:writer@localhost:5432/writer"
database_url_sync: str = "postgresql+psycopg://writer:writer@localhost:5432/writer"
credential_enc_key: str = ""
# 档位默认writer/analyst/light——具体 provider:model 经 tier_routing 覆盖
tier_defaults: dict[str, str] = {
"writer": "deepseek:deepseek-chat",
"analyst": "deepseek:deepseek-chat",
"light": "deepseek:deepseek-chat",
}
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,54 @@
"""Alembic async envCLAUDE.mdcheckpointer.setup 在迁移/CI此处仅领域表"""
from __future__ import annotations
import asyncio
from logging.config import fileConfig
import ww_db.models # noqa: F401 注册所有表到 metadata
from alembic import context
from sqlalchemy import Connection, pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from ww_config import get_settings
from ww_db.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 用 async URLasyncpg运行迁移
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_async_migrations())

View File

@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

View File

@@ -0,0 +1,366 @@
"""initial schema 16 MVP tables
Revision ID: 220ca2e3d53f
Revises:
Create Date: 2026-06-18 07:55:55.972365
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "220ca2e3d53f"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users",
sa.Column("email", sa.Text(), nullable=False),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_table(
"projects",
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("genre", sa.Text(), nullable=True),
sa.Column("logline", sa.Text(), nullable=True),
sa.Column("premise", sa.Text(), nullable=True),
sa.Column("theme", sa.Text(), nullable=True),
sa.Column(
"selling_points",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("structure", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"skills",
sa.Column("scope", sa.Text(), nullable=False),
sa.Column("owner_id", sa.Uuid(), nullable=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("tier", sa.Text(), nullable=False),
sa.Column("system_prompt", sa.Text(), nullable=False),
sa.Column("input_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("output_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"reads",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"writes",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("genre", sa.Text(), nullable=True),
sa.Column(
"examples",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"chapter_digests",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("facts", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_chapter_digests_project_id"), "chapter_digests", ["project_id"], unique=False
)
op.create_table(
"chapter_reviews",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("chapter_version", sa.Integer(), nullable=True),
sa.Column(
"conflicts",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"foreshadow_sug",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("style", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("pace", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("health_score", sa.Integer(), nullable=True),
sa.Column("decisions", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_chapter_reviews_project_id"), "chapter_reviews", ["project_id"], unique=False
)
op.create_table(
"chapters",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("volume", sa.Integer(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), server_default=sa.text("'draft'"), nullable=False),
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "chapter_no", "version"),
)
op.create_index(op.f("ix_chapters_project_id"), "chapters", ["project_id"], unique=False)
op.create_table(
"characters",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("role", sa.Text(), nullable=True),
sa.Column("traits", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("appearance", sa.Text(), nullable=True),
sa.Column("motive", sa.Text(), nullable=True),
sa.Column("backstory", sa.Text(), nullable=True),
sa.Column("arc", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("speech_tics", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"tags",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"relations",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("first_chapter", sa.Integer(), nullable=True),
sa.Column("latest_state", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_characters_project_id"), "characters", ["project_id"], unique=False)
op.create_table(
"foreshadow",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("code", sa.Text(), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), server_default=sa.text("'OPEN'"), nullable=False),
sa.Column("planted_at", sa.Integer(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("expected_close_from", sa.Integer(), nullable=True),
sa.Column("expected_close_to", sa.Integer(), nullable=True),
sa.Column("importance", sa.Text(), nullable=True),
sa.Column(
"links",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"progress",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "code"),
)
op.create_index(op.f("ix_foreshadow_project_id"), "foreshadow", ["project_id"], unique=False)
op.create_table(
"jobs",
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), server_default=sa.text("'queued'"), nullable=False),
sa.Column("progress", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"outline",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("volume", sa.Integer(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("beats", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"foreshadow_windows",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "chapter_no"),
)
op.create_index(op.f("ix_outline_project_id"), "outline", ["project_id"], unique=False)
op.create_table(
"provider_credentials",
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("api_key_enc", sa.LargeBinary(), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("owner_id", "project_id", "provider"),
)
op.create_table(
"rules",
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("level", sa.Text(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"style_fingerprint",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("dimensions_json", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("evidence_json", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_style_fingerprint_project_id"), "style_fingerprint", ["project_id"], unique=False
)
op.create_table(
"tier_routing",
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("tier", sa.Text(), nullable=False),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("model", sa.Text(), nullable=False),
sa.Column(
"fallback",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "tier"),
)
op.create_table(
"usage_ledger",
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("model", sa.Text(), nullable=False),
sa.Column("input_tokens", sa.Integer(), nullable=False),
sa.Column("output_tokens", sa.Integer(), nullable=False),
sa.Column("cache_read", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("cost_minor", sa.BigInteger(), nullable=False),
sa.Column("currency", sa.Text(), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
),
sa.ForeignKeyConstraint(
["project_id"],
["projects.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"world_entities",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("type", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("rules", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("first_chapter", sa.Integer(), nullable=True),
sa.Column("latest_state", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_world_entities_project_id"), "world_entities", ["project_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_world_entities_project_id"), table_name="world_entities")
op.drop_table("world_entities")
op.drop_table("usage_ledger")
op.drop_table("tier_routing")
op.drop_index(op.f("ix_style_fingerprint_project_id"), table_name="style_fingerprint")
op.drop_table("style_fingerprint")
op.drop_table("rules")
op.drop_table("provider_credentials")
op.drop_index(op.f("ix_outline_project_id"), table_name="outline")
op.drop_table("outline")
op.drop_table("jobs")
op.drop_index(op.f("ix_foreshadow_project_id"), table_name="foreshadow")
op.drop_table("foreshadow")
op.drop_index(op.f("ix_characters_project_id"), table_name="characters")
op.drop_table("characters")
op.drop_index(op.f("ix_chapters_project_id"), table_name="chapters")
op.drop_table("chapters")
op.drop_index(op.f("ix_chapter_reviews_project_id"), table_name="chapter_reviews")
op.drop_table("chapter_reviews")
op.drop_index(op.f("ix_chapter_digests_project_id"), table_name="chapter_digests")
op.drop_table("chapter_digests")
op.drop_table("skills")
op.drop_table("projects")
op.drop_table("users")
# ### end Alembic commands ###

View File

@@ -0,0 +1,23 @@
[project]
name = "ww-db"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.29",
"psycopg[binary]>=3.2",
"alembic>=1.13",
"ww-shared",
"ww-config",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_db"]
[tool.uv.sources]
ww-shared = { workspace = true }
ww-config = { workspace = true }

View File

@@ -0,0 +1,4 @@
from ww_db.base import Base
from ww_db.session import get_session, get_sessionmaker
__all__ = ["Base", "get_session", "get_sessionmaker"]

29
packages/db/ww_db/base.py Normal file
View File

@@ -0,0 +1,29 @@
"""SQLAlchemy 2.0 声明式基类 + 通用 mixin。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Uuid, func, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class UuidPk:
id: Mapped[uuid.UUID] = mapped_column(
Uuid, primary_key=True, server_default=text("gen_random_uuid()")
)
class CreatedAt:
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
class TimestampedMixin(CreatedAt):
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)

236
packages/db/ww_db/models.py Normal file
View File

@@ -0,0 +1,236 @@
"""ORM 模型——全部 MVP 表ARCH §3.1)。
snake_case无向量列P2 表 timeline/decisions 不建(见 DEV_PLAN T0.2)。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
BigInteger,
ForeignKey,
Integer,
LargeBinary,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from ww_db.base import Base, CreatedAt, TimestampedMixin, UuidPk
# ---- 运营/系统表 ----
class User(UuidPk, CreatedAt, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(Text)
# ---- 创作数据表 ----
class Project(UuidPk, TimestampedMixin, Base):
__tablename__ = "projects"
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
title: Mapped[str] = mapped_column(Text, nullable=False)
genre: Mapped[str | None] = mapped_column(Text)
logline: Mapped[str | None] = mapped_column(Text)
premise: Mapped[str | None] = mapped_column(Text)
theme: Mapped[str | None] = mapped_column(Text)
selling_points: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
structure: Mapped[str | None] = mapped_column(Text)
class Character(UuidPk, CreatedAt, Base):
__tablename__ = "characters"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str | None] = mapped_column(Text)
traits: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
appearance: Mapped[str | None] = mapped_column(Text)
motive: Mapped[str | None] = mapped_column(Text)
backstory: Mapped[str | None] = mapped_column(Text)
arc: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
speech_tics: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
tags: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
relations: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
first_chapter: Mapped[int | None] = mapped_column(Integer)
latest_state: Mapped[str | None] = mapped_column(Text)
class WorldEntity(UuidPk, CreatedAt, Base):
__tablename__ = "world_entities"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
type: Mapped[str] = mapped_column(Text, nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
rules: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
first_chapter: Mapped[int | None] = mapped_column(Integer)
latest_state: Mapped[str | None] = mapped_column(Text)
class Outline(UuidPk, Base):
__tablename__ = "outline"
__table_args__ = (UniqueConstraint("project_id", "chapter_no"),)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
volume: Mapped[int] = mapped_column(Integer, nullable=False)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
beats: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
foreshadow_windows: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
class ChapterDigest(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_digests"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
facts: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
class Foreshadow(UuidPk, Base):
__tablename__ = "foreshadow"
__table_args__ = (UniqueConstraint("project_id", "code"),)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
code: Mapped[str] = mapped_column(Text, nullable=False)
title: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'OPEN'"))
planted_at: Mapped[int | None] = mapped_column(Integer)
content: Mapped[str | None] = mapped_column(Text)
expected_close_from: Mapped[int | None] = mapped_column(Integer)
expected_close_to: Mapped[int | None] = mapped_column(Integer)
importance: Mapped[str | None] = mapped_column(Text)
links: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
progress: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
updated_at: Mapped[datetime] = mapped_column(
nullable=False, server_default=text("now()"), onupdate=text("now()")
)
class StyleFingerprint(UuidPk, CreatedAt, Base):
__tablename__ = "style_fingerprint"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
dimensions_json: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
evidence_json: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
class Rule(UuidPk, CreatedAt, Base):
__tablename__ = "rules"
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
level: Mapped[str] = mapped_column(Text, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
class Chapter(UuidPk, CreatedAt, Base):
__tablename__ = "chapters"
__table_args__ = (UniqueConstraint("project_id", "chapter_no", "version"),)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
volume: Mapped[int] = mapped_column(Integer, nullable=False)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
content: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'draft'"))
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
class ChapterReview(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_reviews"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
chapter_version: Mapped[int | None] = mapped_column(Integer)
conflicts: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
foreshadow_sug: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
style: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
pace: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
health_score: Mapped[int | None] = mapped_column(Integer)
decisions: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
# ---- 运营/系统表(续)----
class ProviderCredential(UuidPk, CreatedAt, Base):
__tablename__ = "provider_credentials"
__table_args__ = (UniqueConstraint("owner_id", "project_id", "provider"),)
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
provider: Mapped[str] = mapped_column(Text, nullable=False)
api_key_enc: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
class TierRouting(UuidPk, Base):
__tablename__ = "tier_routing"
__table_args__ = (UniqueConstraint("project_id", "tier"),)
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
tier: Mapped[str] = mapped_column(Text, nullable=False)
provider: Mapped[str] = mapped_column(Text, nullable=False)
model: Mapped[str] = mapped_column(Text, nullable=False)
fallback: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
class UsageLedger(UuidPk, CreatedAt, Base):
__tablename__ = "usage_ledger"
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"))
provider: Mapped[str] = mapped_column(Text, nullable=False)
model: Mapped[str] = mapped_column(Text, nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
cache_read: Mapped[int] = mapped_column(Integer, server_default=text("0"))
cost_minor: Mapped[int] = mapped_column(BigInteger, nullable=False)
currency: Mapped[str] = mapped_column(Text, nullable=False)
class Skill(UuidPk, CreatedAt, Base):
__tablename__ = "skills"
scope: Mapped[str] = mapped_column(Text, nullable=False)
owner_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"))
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
tier: Mapped[str] = mapped_column(Text, nullable=False)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
input_schema: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
output_schema: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
reads: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
writes: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
genre: Mapped[str | None] = mapped_column(Text)
examples: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
class Job(UuidPk, TimestampedMixin, Base):
__tablename__ = "jobs"
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
kind: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'queued'"))
progress: Mapped[int] = mapped_column(Integer, server_default=text("0"))
result: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
error: Mapped[str | None] = mapped_column(Text)

View File

@@ -0,0 +1,27 @@
"""async engine / sessionARCH §2.3同进程async-first"""
from __future__ import annotations
from collections.abc import AsyncIterator
from functools import lru_cache
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from ww_config import get_settings
@lru_cache
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
return async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI 依赖:每请求一个 session。"""
sm = get_sessionmaker()
async with sm() as session:
yield session

View File

@@ -0,0 +1,12 @@
[project]
name = "ww-shared"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2.7"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_shared"]

View File

@@ -0,0 +1,4 @@
from ww_shared.envelope import ErrorBody, ErrorEnvelope
from ww_shared.errors import AppError, ErrorCode
__all__ = ["AppError", "ErrorCode", "ErrorBody", "ErrorEnvelope"]

View File

@@ -0,0 +1,16 @@
"""统一错误信封ARCH §7.1)——带 request_id 以便端到端 grep。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class ErrorBody(BaseModel):
code: str
message: str
details: dict[str, object] = Field(default_factory=dict)
request_id: str | None = None
class ErrorEnvelope(BaseModel):
error: ErrorBody

View File

@@ -0,0 +1,44 @@
"""统一错误码与领域异常ARCH §7.1)。"""
from __future__ import annotations
from enum import StrEnum
class ErrorCode(StrEnum):
NOT_FOUND = "NOT_FOUND"
VALIDATION = "VALIDATION"
CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收
LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽
RATE_LIMITED = "RATE_LIMITED"
INTERNAL = "INTERNAL"
# 错误码 -> HTTP 状态
HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.VALIDATION: 422,
ErrorCode.CONFLICT_UNRESOLVED: 409,
ErrorCode.LLM_UNAVAILABLE: 503,
ErrorCode.RATE_LIMITED: 429,
ErrorCode.INTERNAL: 500,
}
class AppError(Exception):
"""领域异常:在边界处映射为统一错误信封。"""
def __init__(
self,
code: ErrorCode,
message: str,
details: dict[str, object] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
@property
def http_status(self) -> int:
return HTTP_STATUS.get(self.code, 500)

View File

@@ -0,0 +1,2 @@
# core (@llm/@backend) — 占位骨架
领域核心(domain/状态机)、编排器(orchestrator/LangGraph 图)、记忆服务(memory)。Phase 1+ 由对应 owner 填充。

View File