diff --git a/.cursor/rules/Backend-Develop-Guideline.mdc b/.cursor/rules/Backend-Develop-Guideline.mdc index 9d59b72..7a65b7d 100644 --- a/.cursor/rules/Backend-Develop-Guideline.mdc +++ b/.cursor/rules/Backend-Develop-Guideline.mdc @@ -22,3 +22,4 @@ alwaysApply: true 13. **`uv.lock` 必须纳入版本控制**:保证 CI 和本地环境精确一致 14. **安装环境统一用 `uv sync`**:开发环境 `uv sync --dev`,生产环境 `uv sync --no-dev` 15. **运行命令统一用 `uv run`**:如 `uv run pytest`、`uv run alembic upgrade head`、`uv run uvicorn ...` +16. 每次添加新代码时,一定要阅读已有部分代码,确保符合项目架构,pattern,loguru模式 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 396cf56..730e4b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ -# temp -node_modules/ -app-expo/ - # Python __pycache__/ .pytest_cache/ diff --git a/api/.env.example b/api/.env.example index b1305bb..acdbd2b 100644 --- a/api/.env.example +++ b/api/.env.example @@ -66,12 +66,15 @@ TENCENT_SECRET_KEY=your_tencent_asr_secret_key # TENCENT_ASR_APP_ID= # ============================================================================= -# TTS (openai | tencent) +# TTS(文字转语音,Agent 回复播音)— 与上方 ASR 完全独立 # ============================================================================= +# ENABLE_TTS:仅控制是否合成并下发 TTS_AUDIO;不影响用户语音转写(ASR) +# false / 0 / no 关闭语音合成,不调 TTS 厂商 API +ENABLE_TTS=true TTS_PROVIDER=tencent # 仅 TTS_PROVIDER=openai 时需要 # OPENAI_API_KEY=your_openai_api_key -# 仅 TTS_PROVIDER=tencent 时生效,与 ASR 共用 TENCENT_SECRET_ID / TENCENT_SECRET_KEY +# 仅 TTS_PROVIDER=tencent 时生效;密钥变量名可与 ASR 相同,但开关与流程互不关联 # 音色 ID 见 https://cloud.tencent.com/document/product/1073/92668 TTS_VOICE_TYPE=502001 TTS_CODEC=mp3 diff --git a/api/alembic/versions/0001_baseline_empty.py b/api/alembic/versions/0001_baseline_empty.py deleted file mode 100644 index 5d02494..0000000 --- a/api/alembic/versions/0001_baseline_empty.py +++ /dev/null @@ -1,27 +0,0 @@ -"""baseline_empty - -空基线迁移:标记现有数据库 schema 为起点。 -对已有数据库执行 alembic stamp head 即可,不做任何 DDL。 - -Revision ID: 0001_baseline -Revises: -Create Date: 2026-03-16 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "0001_baseline" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - pass - - -def downgrade() -> None: - pass diff --git a/api/alembic/versions/0001_initial_schema.py b/api/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..f416843 --- /dev/null +++ b/api/alembic/versions/0001_initial_schema.py @@ -0,0 +1,74 @@ +"""initial schema (squashed) + +单一迁移:pgvector + 当前全部 ORM 表(含 conversations.deleted_at 软删除);并补充 models 未声明的 +story_image_intents.asset_id → assets 外键,以及每个 story 仅一条 primary intent 的唯一索引。 +chapters 含 story 物化字段:markdown_compose_dirty、markdown_composed_at(随 ORM 一并 create_all)。 + +新库 / 删库重来:`alembic upgrade head`。 + +Revision ID: 0001_initial +Revises: +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +revision: str = "0001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _import_all_models() -> None: + from app.features.asset import models as _asset_models # noqa: F401 + from app.features.auth import models as _auth_models # noqa: F401 + from app.features.conversation import models as _conv_models # noqa: F401 + from app.features.memory import models as _memory_models # noqa: F401 + from app.features.memoir import models as _memoir_models # noqa: F401 + from app.features.payment import models as _payment_models # noqa: F401 + from app.features.story import models as _story_models # noqa: F401 + from app.features.user import models as _user_models # noqa: F401 + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + + from app.core.db import Base + + _import_all_models() + Base.metadata.create_all(bind=conn) + + op.create_foreign_key( + "fk_story_image_intents_asset_id_assets", + "story_image_intents", + "assets", + ["asset_id"], + ["id"], + ondelete="SET NULL", + ) + op.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_story_primary_image_intent + ON story_image_intents (story_id) + WHERE intent_role = 'primary' + """ + ) + + +def downgrade() -> None: + conn = op.get_bind() + from app.core.db import Base + + op.execute("DROP INDEX IF EXISTS uq_story_primary_image_intent") + op.drop_constraint( + "fk_story_image_intents_asset_id_assets", + "story_image_intents", + type_="foreignkey", + ) + + _import_all_models() + Base.metadata.drop_all(bind=conn) + conn.execute(text("DROP EXTENSION IF EXISTS vector")) diff --git a/api/alembic/versions/0002_create_initial_schema.py b/api/alembic/versions/0002_create_initial_schema.py deleted file mode 100644 index 58350fd..0000000 --- a/api/alembic/versions/0002_create_initial_schema.py +++ /dev/null @@ -1,49 +0,0 @@ -"""create_initial_schema - -从 Base.metadata 创建所有表(新库部署用)。 -对已有数据库:若已 stamp 0001_baseline,本迁移会跳过已存在的表(create_all checkfirst)。 - -Revision ID: 0002_schema -Revises: 0001_baseline -Create Date: 2026-03-18 - -""" - -from typing import Sequence, Union - -from alembic import op -from sqlalchemy import text - -revision: str = "0002_schema" -down_revision: Union[str, None] = "0001_baseline" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - conn = op.get_bind() - conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) - - # 导入所有 model 以注册到 Base.metadata - from app.core.db import Base - from app.features.auth import models as _auth_models # noqa: F401 - from app.features.conversation import models as _conv_models # noqa: F401 - from app.features.memory import models as _memory_models # noqa: F401 - from app.features.memoir import models as _memoir_models # noqa: F401 - from app.features.payment import models as _payment_models # noqa: F401 - from app.features.user import models as _user_models # noqa: F401 - - Base.metadata.create_all(bind=conn) - - -def downgrade() -> None: - conn = op.get_bind() - from app.core.db import Base - from app.features.auth import models as _auth_models # noqa: F401 - from app.features.conversation import models as _conv_models # noqa: F401 - from app.features.memory import models as _memory_models # noqa: F401 - from app.features.memoir import models as _memoir_models # noqa: F401 - from app.features.payment import models as _payment_models # noqa: F401 - from app.features.user import models as _user_models # noqa: F401 - - Base.metadata.drop_all(bind=conn) diff --git a/api/alembic/versions/0002_drop_chapter_sections.py b/api/alembic/versions/0002_drop_chapter_sections.py new file mode 100644 index 0000000..31bcb6f --- /dev/null +++ b/api/alembic/versions/0002_drop_chapter_sections.py @@ -0,0 +1,29 @@ +"""drop chapter_sections + memoir_images.section_id (stories-first) + +Revision ID: 0002_drop_chapter_sections +Revises: 0001_initial +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0002_drop_chapter_sections" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # memoir_images.section_id -> chapter_sections 必须先解除,再删表 + op.execute( + "ALTER TABLE memoir_images DROP CONSTRAINT IF EXISTS memoir_images_section_id_fkey" + ) + op.execute("ALTER TABLE memoir_images DROP COLUMN IF EXISTS section_id") + op.execute("DROP TABLE IF EXISTS chapter_sections CASCADE") + + +def downgrade() -> None: + raise NotImplementedError( + "0002 为破坏性迁移:不恢复 chapter_sections;请从备份还原数据库。" + ) diff --git a/api/alembic/versions/0003_story_first_markdown_first_schema.py b/api/alembic/versions/0003_story_first_markdown_first_schema.py deleted file mode 100644 index e4da989..0000000 --- a/api/alembic/versions/0003_story_first_markdown_first_schema.py +++ /dev/null @@ -1,207 +0,0 @@ -"""story_first_markdown_first_schema - -Story-First + Markdown-First 架构:新增 stories/story_versions/story_evidence_links, -重定义 chapters(canonical_markdown),新增 chapter_versions/chapter_story_links。 - -Revision ID: 0003_story_first -Revises: 0002_schema -Create Date: 2026-03-19 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "0003_story_first" -down_revision: Union[str, Sequence[str], None] = "0002_schema" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # 1. stories - op.create_table( - "stories", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("stage", sa.String(), nullable=True), - sa.Column("story_type", sa.String(), nullable=True), - sa.Column("summary", sa.Text(), nullable=True), - sa.Column("canonical_markdown", sa.Text(), nullable=True), - sa.Column("time_start", sa.String(), nullable=True), - sa.Column("time_end", sa.String(), nullable=True), - sa.Column("people_refs", sa.JSON(), nullable=True), - sa.Column("place_refs", sa.JSON(), nullable=True), - sa.Column("tag_refs", sa.JSON(), nullable=True), - sa.Column("status", sa.String(), nullable=True), - sa.Column("confidence", sa.Float(), nullable=True), - sa.Column("current_version_id", sa.String(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["user_id"], ["users.id"]), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_stories_user_id"), - "stories", - ["user_id"], - unique=False, - if_not_exists=True, - ) - - # 2. story_versions - op.create_table( - "story_versions", - sa.Column("id", sa.String(), nullable=False), - sa.Column("story_id", sa.String(), nullable=False), - sa.Column("version_no", sa.Integer(), nullable=False), - sa.Column("markdown_snapshot", sa.Text(), nullable=False), - sa.Column("change_summary", sa.Text(), nullable=True), - sa.Column("actor_type", sa.String(), nullable=True), - sa.Column("source_type", sa.String(), nullable=True), - sa.Column("parent_version_id", sa.String(), nullable=True), - sa.Column("prompt_meta", sa.JSON(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ["parent_version_id"], - ["story_versions.id"], - ondelete="SET NULL", - ), - sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_story_versions_story_id"), - "story_versions", - ["story_id"], - unique=False, - if_not_exists=True, - ) - - # 3. story_evidence_links - op.create_table( - "story_evidence_links", - sa.Column("id", sa.String(), nullable=False), - sa.Column("story_id", sa.String(), nullable=False), - sa.Column("evidence_type", sa.String(), nullable=False), - sa.Column("evidence_id", sa.String(), nullable=False), - sa.Column("role", sa.String(), nullable=True), - sa.Column("weight", sa.Float(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_story_evidence_links_story_id"), - "story_evidence_links", - ["story_id"], - unique=False, - if_not_exists=True, - ) - - # 4. chapters 新增列(PostgreSQL ADD COLUMN IF NOT EXISTS) - conn = op.get_bind() - for col_sql in [ - "ALTER TABLE chapters ADD COLUMN IF NOT EXISTS book_id VARCHAR", - "ALTER TABLE chapters ADD COLUMN IF NOT EXISTS summary TEXT", - "ALTER TABLE chapters ADD COLUMN IF NOT EXISTS canonical_markdown TEXT", - "ALTER TABLE chapters ADD COLUMN IF NOT EXISTS cover_asset_id VARCHAR", - "ALTER TABLE chapters ADD COLUMN IF NOT EXISTS current_version_id VARCHAR", - "ALTER TABLE chapters ADD COLUMN IF NOT EXISTS created_at TIMESTAMP WITH TIME ZONE", - ]: - conn.execute(sa.text(col_sql)) - # FK 若已存在则跳过 - from sqlalchemy import inspect - - insp = inspect(conn) - fk_names = [fk.get("name") for fk in insp.get_foreign_keys("chapters") or []] - if "fk_chapters_book_id" not in fk_names: - op.create_foreign_key( - "fk_chapters_book_id", - "chapters", - "books", - ["book_id"], - ["id"], - ondelete="SET NULL", - ) - - # 5. chapter_versions - op.create_table( - "chapter_versions", - sa.Column("id", sa.String(), nullable=False), - sa.Column("chapter_id", sa.String(), nullable=False), - sa.Column("version_no", sa.Integer(), nullable=False), - sa.Column("markdown_snapshot", sa.Text(), nullable=False), - sa.Column("change_summary", sa.Text(), nullable=True), - sa.Column("actor_type", sa.String(), nullable=True), - sa.Column("source_type", sa.String(), nullable=True), - sa.Column("parent_version_id", sa.String(), nullable=True), - sa.Column("prompt_meta", sa.JSON(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["chapter_id"], ["chapters.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint( - ["parent_version_id"], - ["chapter_versions.id"], - ondelete="SET NULL", - ), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_chapter_versions_chapter_id"), - "chapter_versions", - ["chapter_id"], - unique=False, - if_not_exists=True, - ) - - # 6. chapter_story_links - op.create_table( - "chapter_story_links", - sa.Column("id", sa.String(), nullable=False), - sa.Column("chapter_id", sa.String(), nullable=False), - sa.Column("story_id", sa.String(), nullable=False), - sa.Column("order_index", sa.Integer(), nullable=False), - sa.Column("role", sa.String(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["chapter_id"], ["chapters.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_chapter_story_links_chapter_id"), - "chapter_story_links", - ["chapter_id"], - unique=False, - if_not_exists=True, - ) - op.create_index( - op.f("ix_chapter_story_links_story_id"), - "chapter_story_links", - ["story_id"], - unique=False, - if_not_exists=True, - ) - - -def downgrade() -> None: - op.drop_table("chapter_story_links") - op.drop_table("chapter_versions") - op.drop_constraint("fk_chapters_book_id", "chapters", type_="foreignkey") - op.drop_column("chapters", "created_at") - op.drop_column("chapters", "current_version_id") - op.drop_column("chapters", "cover_asset_id") - op.drop_column("chapters", "canonical_markdown") - op.drop_column("chapters", "summary") - op.drop_column("chapters", "book_id") - op.drop_table("story_evidence_links") - op.drop_table("story_versions") - op.drop_table("stories") diff --git a/api/alembic/versions/0004_migrate_sections_to_canonical_markdown.py b/api/alembic/versions/0004_migrate_sections_to_canonical_markdown.py deleted file mode 100644 index 0be7dc4..0000000 --- a/api/alembic/versions/0004_migrate_sections_to_canonical_markdown.py +++ /dev/null @@ -1,105 +0,0 @@ -"""migrate sections to canonical_markdown - -将旧章节(有 sections 但 canonical_markdown 为空)从 sections 推导并写入 canonical_markdown。 -同时创建 chapter_version 记录(source_type=migration)。 - -Revision ID: 0004_migrate_md -Revises: 0003_story_first -Create Date: 2026-03-19 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.orm import Session, selectinload - -revision: str = "0004_migrate_md" -down_revision: Union[str, Sequence[str], None] = "0003_story_first" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def _sections_to_markdown(chapter) -> str: - """从 sections 推导 markdown,与 helpers.sections_to_content_and_images 一致。""" - sections = getattr(chapter, "sections", None) or [] - ordered = sorted(sections, key=lambda s: getattr(s, "order_index", 0)) - parts = [] - for s in ordered: - text = (getattr(s, "content", None) or "").strip() - if text: - parts.append(text) - img = _section_image_to_dict(s) - if img: - placeholder = (img.get("placeholder") or "").strip() - if placeholder: - parts.append(placeholder) - return "\n\n".join(parts) if parts else "" - - -def _section_image_to_dict(section) -> dict | None: - """与 helpers.section_image_to_dict 一致。""" - from app.features.memoir.memoir_images.serializers import memoir_image_to_dict - - if getattr(section, "image_record", None): - return memoir_image_to_dict(section.image_record) - return None - - -def upgrade() -> None: - from app.features.memoir.models import Chapter, ChapterSection, ChapterVersion - - conn = op.get_bind() - session = Session(bind=conn) - - chapters = ( - session.query(Chapter) - .options( - selectinload(Chapter.sections).selectinload(ChapterSection.image_record), - ) - .filter( - sa.or_( - Chapter.canonical_markdown.is_(None), - Chapter.canonical_markdown == "", - ), - ) - .all() - ) - - for ch in chapters: - md = _sections_to_markdown(ch) - if not md.strip(): - continue - - # 创建 chapter_version(source_type=migration) - import uuid - - from sqlalchemy import func - - count_stmt = sa.select(func.count(ChapterVersion.id)).where( - ChapterVersion.chapter_id == ch.id - ) - version_no = (session.execute(count_stmt).scalar() or 0) + 1 - - version = ChapterVersion( - id=str(uuid.uuid4()), - chapter_id=ch.id, - version_no=version_no, - markdown_snapshot=md, - actor_type="system", - source_type="migration", - ) - session.add(version) - session.flush() - - ch.canonical_markdown = md - ch.current_version_id = version.id - - # 由 alembic context 管理事务提交 - session.close() - - -def downgrade() -> None: - # 数据迁移不可逆,downgrade 不清理 canonical_markdown - pass diff --git a/api/alembic/versions/0005_add_story_image_intents.py b/api/alembic/versions/0005_add_story_image_intents.py deleted file mode 100644 index fbd12e0..0000000 --- a/api/alembic/versions/0005_add_story_image_intents.py +++ /dev/null @@ -1,60 +0,0 @@ -"""add story_image_intents - -Revision ID: 0005_story_image_intents -Revises: 0004_migrate_md -Create Date: 2026-03-19 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "0005_story_image_intents" -down_revision: Union[str, Sequence[str], None] = "0004_migrate_md" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "story_image_intents", - sa.Column("id", sa.String(), nullable=False), - sa.Column("story_id", sa.String(), nullable=False), - sa.Column("story_version_id", sa.String(), nullable=True), - sa.Column("intent_role", sa.String(), nullable=False), - sa.Column("source_span", sa.JSON(), nullable=True), - sa.Column("caption", sa.String(), nullable=True), - sa.Column("prompt_brief", sa.Text(), nullable=True), - sa.Column("style_profile", sa.String(), nullable=True), - sa.Column("status", sa.String(), nullable=False), - sa.Column("asset_id", sa.String(), nullable=True), - sa.Column("error", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["story_id"], ["stories.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint( - ["story_version_id"], - ["story_versions.id"], - ondelete="SET NULL", - ), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_story_image_intents_story_id"), - "story_image_intents", - ["story_id"], - unique=False, - if_not_exists=True, - ) - - -def downgrade() -> None: - op.drop_index( - op.f("ix_story_image_intents_story_id"), - table_name="story_image_intents", - ) - op.drop_table("story_image_intents") diff --git a/api/alembic/versions/0006_add_chapter_cover_intents.py b/api/alembic/versions/0006_add_chapter_cover_intents.py deleted file mode 100644 index 9b057a2..0000000 --- a/api/alembic/versions/0006_add_chapter_cover_intents.py +++ /dev/null @@ -1,57 +0,0 @@ -"""add chapter_cover_intents - -Revision ID: 0006_chapter_cover_intents -Revises: 0005_story_image_intents -Create Date: 2026-03-19 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "0006_chapter_cover_intents" -down_revision: Union[str, Sequence[str], None] = "0005_story_image_intents" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "chapter_cover_intents", - sa.Column("id", sa.String(), nullable=False), - sa.Column("chapter_id", sa.String(), nullable=False), - sa.Column("chapter_version_id", sa.String(), nullable=True), - sa.Column("story_ids", sa.JSON(), nullable=True), - sa.Column("prompt_brief", sa.Text(), nullable=True), - sa.Column("status", sa.String(), nullable=False), - sa.Column("asset_id", sa.String(), nullable=True), - sa.Column("error", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(["chapter_id"], ["chapters.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint( - ["chapter_version_id"], - ["chapter_versions.id"], - ondelete="SET NULL", - ), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_chapter_cover_intents_chapter_id"), - "chapter_cover_intents", - ["chapter_id"], - unique=False, - if_not_exists=True, - ) - - -def downgrade() -> None: - op.drop_index( - op.f("ix_chapter_cover_intents_chapter_id"), - table_name="chapter_cover_intents", - ) - op.drop_table("chapter_cover_intents") diff --git a/api/alembic/versions/0007_add_assets_table.py b/api/alembic/versions/0007_add_assets_table.py deleted file mode 100644 index b372a1d..0000000 --- a/api/alembic/versions/0007_add_assets_table.py +++ /dev/null @@ -1,49 +0,0 @@ -"""add assets table - -Revision ID: 0007_assets -Revises: 0006_chapter_cover_intents -Create Date: 2026-03-19 - -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -revision: str = "0007_assets" -down_revision: Union[str, Sequence[str], None] = "0006_chapter_cover_intents" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "assets", - sa.Column("id", sa.String(), nullable=False), - sa.Column("asset_type", sa.String(), nullable=False), - sa.Column("storage_key", sa.String(), nullable=False), - sa.Column("url", sa.String(), nullable=True), - sa.Column("provider", sa.String(), nullable=True), - sa.Column("style_profile", sa.String(), nullable=True), - sa.Column("prompt_final", sa.Text(), nullable=True), - sa.Column("status", sa.String(), nullable=False), - sa.Column("width", sa.Integer(), nullable=True), - sa.Column("height", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint("id"), - if_not_exists=True, - ) - op.create_index( - op.f("ix_assets_asset_type"), - "assets", - ["asset_type"], - unique=False, - if_not_exists=True, - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_assets_asset_type"), table_name="assets") - op.drop_table("assets") diff --git a/api/alembic/versions/0008_migrate_legacy_images_to_assets.py b/api/alembic/versions/0008_migrate_legacy_images_to_assets.py deleted file mode 100644 index 7f0e83b..0000000 --- a/api/alembic/versions/0008_migrate_legacy_images_to_assets.py +++ /dev/null @@ -1,110 +0,0 @@ -"""migrate legacy placeholders and memoir_images to assets - -1. 从 chapters.canonical_markdown 移除 {{IMAGE:...}} / {{{{IMAGE:...}}}} 占位符 -2. 将已完成 memoir_images(含 storage_key)写入 assets;章节封面绑定 cover_asset_id - -Revision ID: 0008_legacy_assets -Revises: 0007_assets -Create Date: 2026-03-19 - -""" - -import uuid -from datetime import datetime, timezone -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "0008_legacy_assets" -down_revision: Union[str, Sequence[str], None] = "0007_assets" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - from app.features.memoir.asset_resolver import strip_legacy_image_placeholders - - conn = op.get_bind() - - rows_ch = conn.execute( - sa.text( - "SELECT id, canonical_markdown FROM chapters WHERE canonical_markdown IS NOT NULL" - ) - ).fetchall() - for cid, md in rows_ch: - if not md or not str(md).strip(): - continue - cleaned = strip_legacy_image_placeholders(str(md)) - if cleaned != str(md).strip(): - conn.execute( - sa.text("UPDATE chapters SET canonical_markdown = :md WHERE id = :id"), - {"md": cleaned, "id": cid}, - ) - - rows_mi = conn.execute( - sa.text( - """ - SELECT id, chapter_id, section_id, storage_key, url, provider, style, status - FROM memoir_images - WHERE status = 'completed' - AND storage_key IS NOT NULL - AND TRIM(storage_key) <> '' - """ - ) - ).fetchall() - - existing = { - r[0] - for r in conn.execute(sa.text("SELECT storage_key FROM assets")).fetchall() - if r[0] - } - - for row in rows_mi: - _mid, chapter_id, section_id, storage_key, url, provider, style, _status = row - sk = (storage_key or "").strip() - if not sk or sk in existing: - continue - aid = str(uuid.uuid4()) - asset_type = "chapter_cover" if section_id is None else "story_image" - now = datetime.now(timezone.utc) - conn.execute( - sa.text( - """ - INSERT INTO assets ( - id, asset_type, storage_key, url, provider, style_profile, - prompt_final, status, width, height, created_at - ) VALUES ( - :id, :atype, :sk, :url, :prov, :style, - :prompt, 'completed', NULL, NULL, :created - ) - """ - ), - { - "id": aid, - "atype": asset_type, - "sk": sk, - "url": url, - "prov": provider, - "style": style, - "prompt": None, - "created": now, - }, - ) - existing.add(sk) - if section_id is None and chapter_id: - conn.execute( - sa.text( - """ - UPDATE chapters - SET cover_asset_id = :aid - WHERE id = :cid - AND (cover_asset_id IS NULL OR cover_asset_id = '') - """ - ), - {"aid": aid, "cid": chapter_id}, - ) - - -def downgrade() -> None: - pass diff --git a/api/alembic/versions/0009_story_image_intent_constraints.py b/api/alembic/versions/0009_story_image_intent_constraints.py deleted file mode 100644 index 8a07fe7..0000000 --- a/api/alembic/versions/0009_story_image_intent_constraints.py +++ /dev/null @@ -1,72 +0,0 @@ -"""story_image_intents: one primary per story + optional FK to assets - -Revision ID: 0009_si_constraints -Revises: 0008_legacy_assets -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -revision: str = "0009_si_constraints" -down_revision: Union[str, Sequence[str], None] = "0008_legacy_assets" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - bind = op.get_bind() - bind.execute( - sa.text( - """ - UPDATE story_image_intents SET asset_id = NULL - WHERE asset_id IS NOT NULL - AND NOT EXISTS (SELECT 1 FROM assets a WHERE a.id = story_image_intents.asset_id) - """ - ) - ) - # 去重:同一 story 多条 primary 时保留最新一条 - bind.execute( - sa.text( - """ - DELETE FROM story_image_intents - WHERE id IN ( - SELECT id FROM ( - SELECT id, - ROW_NUMBER() OVER ( - PARTITION BY story_id - ORDER BY created_at DESC NULLS LAST, id DESC - ) AS rn - FROM story_image_intents - WHERE intent_role = 'primary' - ) t - WHERE rn > 1 - ) - """ - ) - ) - op.create_foreign_key( - "fk_story_image_intents_asset_id_assets", - "story_image_intents", - "assets", - ["asset_id"], - ["id"], - ondelete="SET NULL", - ) - op.execute( - """ - CREATE UNIQUE INDEX IF NOT EXISTS uq_story_primary_image_intent - ON story_image_intents (story_id) - WHERE intent_role = 'primary' - """ - ) - - -def downgrade() -> None: - op.execute("DROP INDEX IF EXISTS uq_story_primary_image_intent") - op.drop_constraint( - "fk_story_image_intents_asset_id_assets", - "story_image_intents", - type_="foreignkey", - ) diff --git a/api/alembic/versions/0010_intent_claim_fields.py b/api/alembic/versions/0010_intent_claim_fields.py deleted file mode 100644 index 488deee..0000000 --- a/api/alembic/versions/0010_intent_claim_fields.py +++ /dev/null @@ -1,50 +0,0 @@ -"""add claim fields to story/chapter image intents - -Revision ID: 0010_intent_claims -Revises: 0009_si_constraints -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - - -revision: str = "0010_intent_claims" -down_revision: Union[str, Sequence[str], None] = "0009_si_constraints" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def _add_claim_columns(table_name: str) -> None: - op.add_column(table_name, sa.Column("claim_token", sa.String(), nullable=True)) - op.add_column( - table_name, sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True) - ) - op.add_column( - table_name, - sa.Column( - "attempt_count", - sa.Integer(), - nullable=True, - server_default="0", - ), - ) - op.execute(f"UPDATE {table_name} SET attempt_count = 0 WHERE attempt_count IS NULL") - op.alter_column(table_name, "attempt_count", nullable=False, server_default=None) - - -def _drop_claim_columns(table_name: str) -> None: - op.drop_column(table_name, "attempt_count") - op.drop_column(table_name, "claimed_at") - op.drop_column(table_name, "claim_token") - - -def upgrade() -> None: - _add_claim_columns("story_image_intents") - _add_claim_columns("chapter_cover_intents") - - -def downgrade() -> None: - _drop_claim_columns("chapter_cover_intents") - _drop_claim_columns("story_image_intents") diff --git a/api/app/adapters/llm/deepseek.py b/api/app/adapters/llm/deepseek.py index 99c133e..40a5fff 100644 --- a/api/app/adapters/llm/deepseek.py +++ b/api/app/adapters/llm/deepseek.py @@ -6,7 +6,12 @@ from langchain_openai import ChatOpenAI class DeepSeekLLMProvider: - """LangChain-based LLM adapter for DeepSeek and OpenAI-compatible APIs.""" + """LangChain-based LLM adapter for DeepSeek and OpenAI-compatible APIs. + + `langchain_llm` 供 Agent / 任务同步调用;若需 JSON object 模式,请用 + `app.core.langchain_llm.bind_json_object_mode` 绑定,勿使用已废弃的 + `bind(model_kwargs={"response_format": ...})`。 + """ def __init__( self, diff --git a/api/app/adapters/tts/tencent_tts.py b/api/app/adapters/tts/tencent_tts.py index 0b0a6b5..3011d58 100644 --- a/api/app/adapters/tts/tencent_tts.py +++ b/api/app/adapters/tts/tencent_tts.py @@ -127,7 +127,12 @@ class TencentTTSProvider: logger.error("Tencent TTS credentials not configured") return b"" - voice_type = VOICE_MAP.get(voice.lower(), self._voice_type) + # Default "alloy" aligns with OpenAI TTS naming; Tencent uses VoiceType IDs from settings. + v = voice.lower() + if v == "alloy": + voice_type = self._voice_type + else: + voice_type = VOICE_MAP.get(v, self._voice_type) chunks = _chunk_text(text) if not chunks: return b"" diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index 7070f7a..3cfadf5 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -204,12 +204,10 @@ class InterviewAgent: ) -> List[str]: """生成空对话开场白,不持久化(由 Orchestrator 负责)""" if not self.llm: - return ["你好呀~ 有空聊聊你的人生故事吗?你小时候是在哪儿长大的?"] + return ["你好呀~ 又见面了,今天有没有哪段回忆或近况想聊聊?"] try: empty_slots = memoir_state.empty_slots_for_current_stage() empty_slots_readable = [SLOT_NAME_MAP.get(s, s) for s in empty_slots] - if not empty_slots_readable: - empty_slots_readable = ["成长的地方", "难忘的事", "重要的人"] prompt = get_opening_prompt( current_stage=memoir_state.current_stage, empty_slots_readable=empty_slots_readable, @@ -226,4 +224,4 @@ class InterviewAgent: return messages[:2] if messages else [response_text] except Exception as e: logger.error("生成开场白失败: %s", e, exc_info=True) - return ["你好呀~ 有空聊聊你的人生故事吗?你童年里印象最深的一件事是什么?"] + return ["你好呀~ 又见面了,最近有没有什么事想跟我说说?"] diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index d3b67e1..e0830ad 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -236,7 +236,12 @@ class ChatOrchestrator: memoir_state: MemoirStateSchema, user_profile_context: str = "", ) -> List[str]: - """委托 InterviewAgent 生成开场白,并写入 Redis""" + """ + 委托 InterviewAgent 生成访谈开场白,并写入 Redis。 + + 调用方(如 WS)须在「空会话」分支前通过 ConversationService 从 DB 回填 Redis, + 避免与多 Agent 契约混淆:本编排器不读取 segments,只假定 Redis 已反映是否已有轮次。 + """ responses = await self.interview_agent.generate_opening_message( conversation_id=conversation_id, memoir_state=memoir_state, diff --git a/api/app/agents/chat/profile_agent.py b/api/app/agents/chat/profile_agent.py index 2ab5046..b2433bf 100644 --- a/api/app/agents/chat/profile_agent.py +++ b/api/app/agents/chat/profile_agent.py @@ -8,15 +8,15 @@ from typing import Any, Dict, List, Optional from langchain_core.messages import AIMessage, HumanMessage -from app.core.dependencies import get_llm_provider -from app.core.logging import get_logger - from app.agents.chat.helpers import format_history_string, get_history_messages from app.agents.chat.prompts_profile import ( get_profile_extraction_prompt, get_profile_followup_prompt, get_profile_greeting_prompt, ) +from app.core.dependencies import get_llm_provider +from app.core.langchain_llm import bind_json_object_mode +from app.core.logging import get_logger from app.features.memoir.memoir_images.json_payload import extract_json_payload logger = get_logger(__name__) @@ -62,10 +62,7 @@ class ProfileAgent: prompt = get_profile_extraction_prompt( user_message, missing_fields, recent_dialogue=recent_dialogue or None ) - json_llm = self.llm.bind( - model_kwargs={"response_format": {"type": "json_object"}}, - max_tokens=512, - ) + json_llm = bind_json_object_mode(self.llm, max_tokens=512) response = await json_llm.ainvoke(prompt) content = response.content.strip() parsed = json.loads(extract_json_payload(content)) diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index da37716..1e89ded 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -185,22 +185,46 @@ def get_opening_prompt( "belief": "人生信念", } stage_name = stage_name_map.get(current_stage, current_stage) - topics_str = ( - "、".join(empty_slots_readable) - if empty_slots_readable - else "人生故事、童年、经历等" - ) + if empty_slots_readable: + topics_str = "、".join(empty_slots_readable) + topics_heading = ( + f"## 当前建议话题({stage_name})\n可以从中选一个来问:{topics_str}" + ) + task_question = ( + "2. **必须问一个问题**:接着问一个**具体、好回答**的问题,引导用户开始分享;" + "优先落在上述还未聊透的方向上。不要问太宽泛的「有什么想聊的」。" + ) + style_examples = ( + "示例(仅供参考风格):\n" + '"你好呀~ 有空的话想听听你的人生故事。你小时候是在哪儿长大的?那边有什么特别让你怀念的?"\n或\n' + '"在的!今天想聊聊你。你童年里印象最深的一件事是什么?"' + ) + else: + topics_heading = ( + f"## 当前阶段({stage_name})\n" + "访谈结构化槽位里,这一阶段的主要问题在素材侧**已有覆盖**。" + "开场要像老朋友重逢:接近况、接续上次聊过的事、或任何用户可能想提起的新片段;" + "**禁止**为了凑问题而默认再从「童年在哪长大」等已覆盖模板重头盘问。" + ) + task_question = ( + "2. **问候 + 轻巧引子**:用一句温暖的话接上对话;若自然,可以问一个与近况、" + "想续上的回忆、或新冒出来的小事有关的问题。若不适合追问,问候 + 一句开放式引子即可。" + ) + style_examples = ( + "示例(仅供参考风格):\n" + '"嘿,又见面啦~ 今天有没有哪件事突然从脑子里冒出来,想跟我说说?"\n或\n' + '"在的!上次聊到那儿我还记着,你后来还有想起什么细节吗?"' + ) profile_section = ( f"\n## 用户基本信息\n{user_profile_context}\n" if user_profile_context else "" ) return f"""你是「岁月知己」,用户的老朋友。用户刚通过「打个招呼」进入空对话,**还没有发任何消息**,需要你先开口。 {profile_section} -## 当前建议话题({stage_name}) -可以从中选一个来问:{topics_str} +{topics_heading} ## 你的任务 -1. **先开口**:用一两句亲切的问候开场(如「你好呀,有空聊聊你的故事吗」)。 -2. **必须问一个问题**:接着问一个**具体、好回答**的问题,引导用户开始分享(如童年、家乡、印象深的事等)。不要问太宽泛的「有什么想聊的」。 +1. **先开口**:用一两句亲切的问候开场。 +{task_question} 3. 语气像老朋友,自然、温暖。 ## 回复格式 @@ -208,10 +232,7 @@ def get_opening_prompt( - **严禁**输出括号、注释、思考过程。 - **严禁**模拟或虚构用户的回答。你只能输出「你的问候 + 你的问题」,不能替用户回答,不能自问自答。最多 2 段(问候 + 问题),禁止更多。 -示例(仅供参考风格): -"你好呀~ 有空的话想听听你的人生故事。你小时候是在哪儿长大的?那边有什么特别让你怀念的?" -或 -"在的!今天想聊聊你。你童年里印象最深的一件事是什么?" +{style_examples} 直接输出你要说的话(多条用 [SPLIT] 分隔):""" diff --git a/api/app/agents/memoir/__init__.py b/api/app/agents/memoir/__init__.py index 9d10082..6423297 100644 --- a/api/app/agents/memoir/__init__.py +++ b/api/app/agents/memoir/__init__.py @@ -6,19 +6,16 @@ from app.agents.memoir.orchestrator import MemoirOrchestrator from app.agents.memoir.extraction_agent import ExtractionAgent, ExtractionResult from app.agents.memoir.classification_agent import ClassificationAgent from app.agents.memoir.narrative_agent import NarrativeAgent -from app.agents.memoir.placeholder_agent import inject_placeholders -from app.agents.memoir.story_builder_orchestrator import StoryBuilderOrchestrator -from app.agents.memoir.chapter_composer_orchestrator import ChapterComposerOrchestrator +from app.agents.memoir.story_route_agent import StoryRouteAgent, StoryRouteDecision __all__ = [ "MemoryAgent", "BackgroundTaskRunner", "MemoirOrchestrator", - "StoryBuilderOrchestrator", - "ChapterComposerOrchestrator", + "StoryRouteAgent", + "StoryRouteDecision", "ExtractionAgent", "ExtractionResult", "ClassificationAgent", "NarrativeAgent", - "inject_placeholders", ] diff --git a/api/app/agents/memoir/chapter_composer_orchestrator.py b/api/app/agents/memoir/chapter_composer_orchestrator.py deleted file mode 100644 index 6d15082..0000000 --- a/api/app/agents/memoir/chapter_composer_orchestrator.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -ChapterComposerOrchestrator — 读取 stories/evidence,生成章节 markdown。 - -Agent 只产出结构化结果,不直接写 DB。 -""" - -from __future__ import annotations - -from typing import Any - -from app.core.logging import get_logger - -logger = get_logger(__name__) - - -class ChapterComposerOrchestrator: - """ - 生成章节大纲和章节 markdown。 - 仅返回 markdown,不落库。 - """ - - def compose_chapter_markdown( - self, - *, - title: str, - category: str, - evidence: dict, - existing_markdown: str = "", - user_profile: str = "", - birth_year: int | None = None, - llm: Any = None, - ) -> str: - """ - 从 evidence 生成章节 markdown。 - 若有 existing_markdown 则追加/合并。 - 返回 markdown 正文,不写 DB。 - """ - from app.agents.memoir.narrative_agent import NarrativeAgent - - chunks = evidence.get("relevant_chunks", []) - facts = evidence.get("relevant_facts", []) - new_content = self._format_evidence_for_prompt(chunks, facts) - - agent = NarrativeAgent() - narrative = agent.generate_narrative( - stage=category, - slots={}, - new_content=new_content, - existing_content=existing_markdown, - user_profile=user_profile, - birth_year=birth_year, - llm=llm, - ) - return self._to_markdown(narrative) - - def _format_evidence_for_prompt(self, chunks: list, facts: list) -> str: - """将 evidence 格式化为 prompt 输入。""" - parts = [] - for c in chunks[:10]: - content = ( - c.get("content", "") - if isinstance(c, dict) - else getattr(c, "content", "") - ) - if content: - parts.append(content.strip()) - for f in facts[:5]: - if isinstance(f, dict): - subj = f.get("subject", "") - pred = f.get("predicate", "") - obj = f.get("object_json", "") - if subj or pred: - parts.append(f"{subj} {pred} {obj}") - else: - parts.append( - f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}" - ) - return "\n\n".join(parts) if parts else "" - - def _to_markdown(self, narrative: str) -> str: - """将 narrative(JSON 或纯文本)转为 markdown。正文不含占位符。""" - if not narrative or not narrative.strip(): - return "" - if narrative.strip().startswith("{") and "paragraphs" in narrative: - import json - - try: - data = json.loads(narrative) - paras = data.get("paragraphs", []) - if isinstance(paras, list): - parts = [] - for p in paras: - if isinstance(p, dict): - text = p.get("content", p.get("text", "")) - else: - text = str(p) - if text.strip(): - parts.append(text.strip()) - md = "\n\n".join(parts) - else: - md = narrative - except json.JSONDecodeError: - md = narrative - else: - md = narrative.strip() - return md diff --git a/api/app/agents/memoir/extraction_agent.py b/api/app/agents/memoir/extraction_agent.py index 90c2ae7..4598191 100644 --- a/api/app/agents/memoir/extraction_agent.py +++ b/api/app/agents/memoir/extraction_agent.py @@ -9,11 +9,11 @@ import json from dataclasses import dataclass from typing import Any, Dict +from app.agents.memoir.prompts import get_state_extraction_prompt +from app.core.langchain_llm import bind_json_object_mode from app.core.logging import get_logger from app.features.memoir.memoir_images.json_payload import extract_json_payload -from app.agents.memoir.prompts import get_state_extraction_prompt - logger = get_logger(__name__) @@ -56,10 +56,7 @@ class ExtractionAgent: for k, v in (stage_slots or {}).items() }, ) - json_llm = llm.bind( - model_kwargs={"response_format": {"type": "json_object"}}, - max_tokens=1024, - ) + json_llm = bind_json_object_mode(llm, max_tokens=1024) response = json_llm.invoke(prompt) parsed = json.loads(extract_json_payload(response.content)) detected_stage = parsed.get("detected_stage", detected_stage) diff --git a/api/app/agents/memoir/memory_agent.py b/api/app/agents/memoir/memory_agent.py index 590a44e..c79960d 100644 --- a/api/app/agents/memoir/memory_agent.py +++ b/api/app/agents/memoir/memory_agent.py @@ -6,15 +6,15 @@ import json from typing import Dict, List, Optional -from app.core.dependencies import get_llm_provider -from app.core.logging import get_logger - from app.agents.memoir.prompts import ( CHAPTER_CATEGORIES, STAGE_TO_ORDER, get_chapter_classification_prompt, get_text_rewrite_prompt, ) +from app.core.dependencies import get_llm_provider +from app.core.langchain_llm import bind_json_object_mode +from app.core.logging import get_logger from app.features.memoir.memoir_images.json_payload import extract_json_payload logger = get_logger(__name__) @@ -67,10 +67,7 @@ class MemoryAgent: prompt = get_text_rewrite_prompt( segments_text, chapter_category, existing_content or "" ) - json_llm = self.llm.bind( - model_kwargs={"response_format": {"type": "json_object"}}, - max_tokens=4096, - ) + json_llm = bind_json_object_mode(self.llm, max_tokens=4096) response = await json_llm.ainvoke(prompt) content = ( response.content if hasattr(response, "content") else str(response) diff --git a/api/app/agents/memoir/narrative_agent.py b/api/app/agents/memoir/narrative_agent.py index 9bd95af..b13650d 100644 --- a/api/app/agents/memoir/narrative_agent.py +++ b/api/app/agents/memoir/narrative_agent.py @@ -7,12 +7,12 @@ from __future__ import annotations from typing import Any, Dict, Optional -from app.core.logging import get_logger - from app.agents.memoir.prompts import ( get_creative_title_prompt, get_narrative_json_prompt, ) +from app.core.langchain_llm import bind_json_object_mode +from app.core.logging import get_logger logger = get_logger(__name__) @@ -70,10 +70,7 @@ class NarrativeAgent: user_profile=user_profile, birth_year=birth_year, ) - json_llm = llm.bind( - model_kwargs={"response_format": {"type": "json_object"}}, - max_tokens=4096, - ) + json_llm = bind_json_object_mode(llm, max_tokens=4096) response = json_llm.invoke(prompt) return (response.content or "").strip() except Exception as e: diff --git a/api/app/agents/memoir/placeholder_agent.py b/api/app/agents/memoir/placeholder_agent.py deleted file mode 100644 index 36ed1b3..0000000 --- a/api/app/agents/memoir/placeholder_agent.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -PlaceholderInjectAgent:对 narrative 做占位符模板注入。 -对应现有逻辑:inject_image_placeholder_template -纯函数式,无 LLM 调用。 -""" - -from app.agents.memoir.prompts import inject_image_placeholder_template - - -def inject_placeholders(content: str) -> str: - """ - 对章节正文做占位符处理:匹配所有图片占位符,拼上固定模板。 - 与 inject_image_placeholder_template 行为一致。 - """ - return inject_image_placeholder_template(content) diff --git a/api/app/agents/memoir/prompts.py b/api/app/agents/memoir/prompts.py index 595d6a1..25fb6ea 100644 --- a/api/app/agents/memoir/prompts.py +++ b/api/app/agents/memoir/prompts.py @@ -400,3 +400,62 @@ def get_narrative_json_prompt( 如果对话中没有值得记录的人生经历内容,输出:{{"paragraphs": []}} """ + + +def get_story_route_prompt( + *, + chapter_category: str, + chapter_title: str, + batch_transcript: str, + candidate_stories_json: str, +) -> str: + """Celery 批次:判断写入新 story 还是追加已有 story。输出严格 JSON。""" + return f"""你是回忆录编辑助手。根据本批用户口述与候选故事列表,决定: +- append_story:内容明显延续、补充某一已有故事的主题与时间线,且能对应到具体 candidate id +- new_story:新话题、新人生阶段片段,或与所有候选故事都不够贴合 + +当前章节(写作容器): +- category: {chapter_category} +- title: {chapter_title} + +【本批口述合并文本】 +{batch_transcript} + +【候选故事】(仅允许在 append 时选择其中的 id;id 必须原样复制) +{candidate_stories_json} + +## 输出 JSON(仅此一个对象,不要 markdown) +{{ + "decision": "new_story" | "append_story", + "target_story_id": "", + "new_story_title": "<短标题,6-20 字;new_story 时必填,append 时可 null>", + "reason": "<一句中文理由>" +}} + +规则: +- 若无法自信匹配某一候选,选 new_story +- new_story_title 应概括本批新内容,不要与候选标题重复 +""" + + +def format_evidence_chunks_for_prompt(evidence: dict) -> str: + """将 retrieve_evidence 结果格式化为简短文本,供叙事 prompt 使用。""" + chunks = evidence.get("relevant_chunks") or [] + facts = evidence.get("relevant_facts") or [] + parts: list[str] = [] + for c in chunks[:10]: + content = ( + c.get("content", "") if isinstance(c, dict) else getattr(c, "content", "") + ) + if content: + parts.append(content.strip()) + for f in facts[:5]: + if isinstance(f, dict): + subj = f.get("subject", "") + pred = f.get("predicate", "") + obj = f.get("object_json", "") + if subj or pred: + parts.append(f"{subj} {pred} {obj}") + else: + parts.append(f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}") + return "\n\n".join(parts) if parts else "" diff --git a/api/app/agents/memoir/story_builder_orchestrator.py b/api/app/agents/memoir/story_builder_orchestrator.py deleted file mode 100644 index e3bd558..0000000 --- a/api/app/agents/memoir/story_builder_orchestrator.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -StoryBuilderOrchestrator — 组织 evidence,调用 StorySynthesisAgent,产出 story markdown。 - -Agent 只产出结构化结果,不直接写 DB。 -""" - -from __future__ import annotations - -from typing import Any - -from app.core.logging import get_logger - -logger = get_logger(__name__) - - -class StoryBuilderOrchestrator: - """ - 判断新增 story、补充现有 story、合并重复 story。 - 组织 evidence bundle,生成或更新 story markdown。 - 仅返回结构化输出,不落库。 - """ - - def build_story_markdown( - self, - *, - evidence: dict, - stage: str, - story_type: str | None = None, - existing_markdown: str = "", - user_profile: str = "", - birth_year: int | None = None, - llm: Any = None, - ) -> str: - """ - 从 evidence 生成 story markdown。 - 若有 existing_markdown 则做补充/合并。 - 返回 markdown 正文,不写 DB。 - """ - from app.agents.memoir.narrative_agent import NarrativeAgent - - chunks = evidence.get("relevant_chunks", []) - facts = evidence.get("relevant_facts", []) - new_content = self._format_evidence_for_prompt(chunks, facts) - - agent = NarrativeAgent() - markdown = agent.generate_narrative( - stage=stage, - slots={}, - new_content=new_content, - existing_content=existing_markdown, - user_profile=user_profile, - birth_year=birth_year, - llm=llm, - ) - return self._to_markdown(markdown) - - def _format_evidence_for_prompt(self, chunks: list, facts: list) -> str: - """将 evidence 格式化为 prompt 输入。""" - parts = [] - for c in chunks[:10]: - content = ( - c.get("content", "") - if isinstance(c, dict) - else getattr(c, "content", "") - ) - if content: - parts.append(content.strip()) - for f in facts[:5]: - if isinstance(f, dict): - subj = f.get("subject", "") - pred = f.get("predicate", "") - obj = f.get("object_json", "") - if subj or pred: - parts.append(f"{subj} {pred} {obj}") - else: - parts.append( - f"{getattr(f, 'subject', '')} {getattr(f, 'predicate', '')}" - ) - return "\n\n".join(parts) if parts else "" - - def _to_markdown(self, narrative: str) -> str: - """将 narrative(JSON 或纯文本)转为 markdown。正文不包含占位符,图片意图由 StoryImageIntentExtractor 提取。""" - if not narrative or not narrative.strip(): - return "" - if narrative.strip().startswith("{") and "paragraphs" in narrative: - import json - - try: - data = json.loads(narrative) - paras = data.get("paragraphs", []) - if isinstance(paras, list): - parts = [] - for p in paras: - if isinstance(p, dict): - text = p.get("content", p.get("text", "")) - else: - text = str(p) - if text.strip(): - parts.append(text.strip()) - md = "\n\n".join(parts) - else: - md = narrative - except json.JSONDecodeError: - md = narrative - else: - md = narrative.strip() - return md diff --git a/api/app/agents/memoir/story_route_agent.py b/api/app/agents/memoir/story_route_agent.py new file mode 100644 index 0000000..174da01 --- /dev/null +++ b/api/app/agents/memoir/story_route_agent.py @@ -0,0 +1,114 @@ +""" +StoryRouteAgent:Celery 批次内判断 new_story vs append_story(JSON)。 +""" + +from __future__ import annotations + +import json +from typing import Any, Literal + +from pydantic import BaseModel, field_validator + +from app.agents.memoir.prompts import get_story_route_prompt +from app.core.langchain_llm import bind_json_object_mode +from app.core.logging import get_logger +from app.features.story.models import Story + +logger = get_logger(__name__) + + +class StoryRouteDecision(BaseModel): + decision: Literal["new_story", "append_story"] + target_story_id: str | None = None + new_story_title: str | None = None + reason: str | None = None + + @field_validator("target_story_id", mode="before") + @classmethod + def empty_str_to_none(cls, v: Any) -> str | None: + if v is None or v == "": + return None + if isinstance(v, str): + return v.strip() or None + return str(v) + + +def _build_candidate_json(stories: list[Story], *, preview_chars: int = 220) -> str: + rows: list[dict[str, Any]] = [] + for s in stories: + md = (s.canonical_markdown or "").strip().replace("\n", " ") + preview = md[:preview_chars] + ("…" if len(md) > preview_chars else "") + links: list[str] = [] + for cl in getattr(s, "chapter_links", None) or []: + ch = getattr(cl, "chapter", None) + if ch is None: + continue + cat = getattr(ch, "category", None) or "" + tit = getattr(ch, "title", None) or "" + links.append(f"{tit}({cat})") + rows.append( + { + "id": s.id, + "title": s.title, + "preview": preview, + "linked_chapters": links, + } + ) + return json.dumps(rows, ensure_ascii=False, indent=2) + + +class StoryRouteAgent: + def decide( + self, + *, + chapter_category: str, + chapter_title: str, + batch_transcript: str, + candidate_stories: list[Story], + llm: Any, + valid_story_ids: set[str], + ) -> StoryRouteDecision: + if not llm: + return StoryRouteDecision( + decision="new_story", + new_story_title=None, + reason="no_llm", + ) + payload = _build_candidate_json(candidate_stories) + prompt = get_story_route_prompt( + chapter_category=chapter_category, + chapter_title=chapter_title, + batch_transcript=batch_transcript, + candidate_stories_json=payload, + ) + try: + json_llm = bind_json_object_mode(llm, max_tokens=1024) + response = json_llm.invoke(prompt) + raw = (response.content or "").strip() + data = json.loads(raw) + decision = StoryRouteDecision.model_validate(data) + except Exception as e: + logger.warning("StoryRouteAgent 解析失败: %s", e) + return StoryRouteDecision( + decision="new_story", + new_story_title=None, + reason="parse_error", + ) + + if decision.decision == "append_story": + tid = decision.target_story_id + if not tid or tid not in valid_story_ids: + logger.warning( + "StoryRoute append 无效 target_story_id=%s,回退 new_story", + tid, + ) + return StoryRouteDecision( + decision="new_story", + new_story_title=decision.new_story_title, + reason="invalid_target", + ) + if decision.decision == "new_story" and not ( + decision.new_story_title and decision.new_story_title.strip() + ): + decision.new_story_title = None + return decision diff --git a/api/app/core/config.py b/api/app/core/config.py index 77bb94d..7457097 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -59,7 +59,8 @@ class Settings(BaseSettings): tencent_secret_key: str = "" tencent_asr_app_id: str = "" - # ── TTS (openai | tencent) ─────────────────────────────── + # ── TTS (openai | tencent),与 ASR 独立:仅控制回复侧语音合成 ── + enable_tts: bool = True tts_provider: str = "tencent" openai_api_key: str = "" tts_voice_type: int = 502001 # Tencent 音色 ID,见 https://cloud.tencent.com/document/product/1073/92668 diff --git a/api/app/core/cos_url_keys.py b/api/app/core/cos_url_keys.py new file mode 100644 index 0000000..dc4e412 --- /dev/null +++ b/api/app/core/cos_url_keys.py @@ -0,0 +1,43 @@ +"""从 URL 解析当前环境腾讯云 COS object key(仅当 host 与配置一致时)。""" + +from urllib.parse import urlparse + +from app.core.config import settings + + +def extract_cos_object_key_if_owned(url: str | None) -> str | None: + """ + 若 url 指向 settings 中配置的 COS 域名,返回去掉前导 / 的 object key。 + 非 http(s)、或 host 不匹配时返回 None。 + """ + if not url: + return None + s = str(url).strip() + if not s.startswith(("http://", "https://")): + return None + parsed = urlparse(s) + host = (parsed.netloc or "").lower() + if not host: + return None + + candidates: list[str] = [] + bucket = (settings.tencent_cos_bucket or "").strip().lower() + region = (settings.tencent_cos_region or "").strip().lower() + if bucket and region: + candidates.append(f"{bucket}.cos.{region}.myqcloud.com") + base = (settings.tencent_cos_base_url or "").strip() + if base: + base_parsed = urlparse(base if "://" in base else f"https://{base}") + bh = (base_parsed.netloc or "").lower() + if bh: + candidates.append(bh) + + if not candidates: + return None + + matched = any(host == c for c in candidates if c) + if not matched: + return None + + key = (parsed.path or "").lstrip("/") + return key or None diff --git a/api/app/core/db.py b/api/app/core/db.py index e2fbb13..928d368 100644 --- a/api/app/core/db.py +++ b/api/app/core/db.py @@ -52,6 +52,7 @@ async_engine = create_async_engine( echo=False, pool_size=5, max_overflow=10, + pool_pre_ping=True, ) AsyncSessionLocal = async_sessionmaker( diff --git a/api/app/core/langchain_llm.py b/api/app/core/langchain_llm.py new file mode 100644 index 0000000..8f292ff --- /dev/null +++ b/api/app/core/langchain_llm.py @@ -0,0 +1,18 @@ +""" +与 `get_llm_provider().langchain_llm` 配合使用的 LangChain Runnable 约定。 + +langchain-openai 要求用顶层 `response_format` 绑定 JSON 模式,禁止对 `.bind()` 传入 +`model_kwargs={"response_format": ...}`(会错误传入底层 `completions.create`)。 +""" + +from __future__ import annotations + +from typing import Any + + +def bind_json_object_mode(llm: Any, *, max_tokens: int) -> Any: + """返回绑定 `response_format=json_object` 与 `max_tokens` 的 Runnable(通常为 ChatOpenAI)。""" + return llm.bind( + response_format={"type": "json_object"}, + max_tokens=max_tokens, + ) diff --git a/api/app/core/logging.py b/api/app/core/logging.py index e0f2b66..b14a0b3 100644 --- a/api/app/core/logging.py +++ b/api/app/core/logging.py @@ -28,7 +28,11 @@ class InterceptHandler(logging.Handler): def setup_logging() -> None: - """Call once at application startup, before any other import that logs.""" + """Call once at process entry (API:`main`;Worker:`celery_app` 在首行调用)。 + + Celery 需在 `app.tasks.celery_app` 中设置 `worker_hijack_root_logger=False`,否则 + 会覆盖根 logger,无法与下方 InterceptHandler + loguru 格式对齐。 + """ logger.remove() logger.add( @@ -36,7 +40,7 @@ def setup_logging() -> None: level="INFO", format=( "{time:YYYY-MM-DD HH:mm:ss.SSS} | " - "{level: <8} | " + "{level.name: <8} | " "{name}:{function}:{line} | " "{extra[request_id]} | " "{message}" diff --git a/api/app/core/redis.py b/api/app/core/redis.py index 87bd957..1aa9a77 100644 --- a/api/app/core/redis.py +++ b/api/app/core/redis.py @@ -4,13 +4,13 @@ Redis 客户端与会话/缓存能力:供应用生命周期、会话历史、 """ import json -from app.core.logging import get_logger from datetime import datetime, timezone from typing import Any, Dict, List, Optional import redis.asyncio as aioredis from app.core.config import settings +from app.core.logging import get_logger logger = get_logger(__name__) @@ -62,6 +62,21 @@ class RedisService: logger.error("获取对话历史失败: %s", e) return [] + async def set_conversation_history( + self, conversation_id: str, history: List[Dict[str, Any]] + ) -> bool: + """整表覆盖会话历史(用于从 DB 回填),应用 session_ttl。""" + try: + client = await self.get_client() + key = self._conversation_key(conversation_id) + await client.setex( + key, self.session_ttl, json.dumps(history, ensure_ascii=False) + ) + return True + except Exception as e: + logger.error("写入对话历史失败: %s", e) + return False + async def add_message( self, conversation_id: str, @@ -102,6 +117,25 @@ class RedisService: logger.error("清除对话历史失败: %s", e) return False + async def delete_keys_matching_pattern(self, pattern: str) -> int: + """按 SCAN 批量删除 key,避免阻塞式 KEYS *。""" + try: + client = await self.get_client() + batch: list[str] = [] + deleted = 0 + + async for key in client.scan_iter(match=pattern): + batch.append(key) + if len(batch) >= 200: + deleted += int(await client.delete(*batch)) + batch.clear() + if batch: + deleted += int(await client.delete(*batch)) + return deleted + except Exception as e: + logger.error("按 pattern 删除 Redis key 失败: %s", e) + return 0 + async def extend_session_ttl(self, conversation_id: str) -> bool: try: client = await self.get_client() diff --git a/api/app/core/storage_purge.py b/api/app/core/storage_purge.py new file mode 100644 index 0000000..7d0ca48 --- /dev/null +++ b/api/app/core/storage_purge.py @@ -0,0 +1,28 @@ +"""对象存储批量删除(尽力而为,不打断主流程)。""" + +from collections.abc import Iterable + +from app.core.logging import get_logger +from app.ports.storage import ObjectStorage + +logger = get_logger(__name__) + + +def delete_object_storage_keys_best_effort( + storage: ObjectStorage | None, + keys: Iterable[str], + *, + log_prefix: str, +) -> None: + if storage is None: + return + seen: set[str] = set() + for raw in keys: + key = (raw or "").strip() + if not key or key in seen: + continue + seen.add(key) + try: + storage.delete(key) + except Exception as e: + logger.warning("%s: COS 删除失败 key=%s err=%s", log_prefix, key, e) diff --git a/api/app/features/conversation/deps.py b/api/app/features/conversation/deps.py index 0033531..f0ec578 100644 --- a/api/app/features/conversation/deps.py +++ b/api/app/features/conversation/deps.py @@ -4,13 +4,18 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_async_db +from app.core.dependencies import get_object_storage from app.features.conversation.service import ConversationService from app.features.quota.deps import get_quota_service from app.features.quota.service import QuotaService +from app.ports.storage import ObjectStorage def get_conversation_service( db: AsyncSession = Depends(get_async_db), quota_service: QuotaService = Depends(get_quota_service), + object_storage: ObjectStorage = Depends(get_object_storage), ) -> ConversationService: - return ConversationService(db=db, quota_service=quota_service) + return ConversationService( + db=db, quota_service=quota_service, object_storage=object_storage + ) diff --git a/api/app/features/conversation/models.py b/api/app/features/conversation/models.py index d32919b..932072a 100644 --- a/api/app/features/conversation/models.py +++ b/api/app/features/conversation/models.py @@ -17,6 +17,7 @@ class Conversation(Base): status = Column(String, default="active") current_topic = Column(String, nullable=True) conversation_stage = Column(String, nullable=True) + deleted_at = Column(DateTime(timezone=True), nullable=True) user = relationship("User", back_populates="conversations") segments = relationship( diff --git a/api/app/features/conversation/repo.py b/api/app/features/conversation/repo.py index a2e35c8..550622c 100644 --- a/api/app/features/conversation/repo.py +++ b/api/app/features/conversation/repo.py @@ -15,7 +15,10 @@ async def get_conversation( async def get_user_conversations(user_id: str, db: AsyncSession) -> list[Conversation]: stmt = ( select(Conversation) - .where(Conversation.user_id == user_id) + .where( + Conversation.user_id == user_id, + Conversation.deleted_at.is_(None), + ) .order_by( func.coalesce(Conversation.last_message_at, Conversation.started_at).desc() ) @@ -64,7 +67,10 @@ async def count_segments_for_user(user_id: str, db: AsyncSession) -> int: select(func.count(Segment.id)) .select_from(Segment) .join(Conversation, Segment.conversation_id == Conversation.id) - .where(Conversation.user_id == user_id) + .where( + Conversation.user_id == user_id, + Conversation.deleted_at.is_(None), + ) ) result = await db.execute(stmt) return result.scalar() or 0 diff --git a/api/app/features/conversation/router.py b/api/app/features/conversation/router.py index 9301ea0..8b31eab 100644 --- a/api/app/features/conversation/router.py +++ b/api/app/features/conversation/router.py @@ -2,11 +2,10 @@ 对话 feature — conversations 路由 """ -from app.core.logging import get_logger - from fastapi import APIRouter, Depends, HTTPException from app.core.dependencies import get_current_user +from app.core.logging import get_logger from app.features.conversation.deps import get_conversation_service from app.features.conversation.service import ConversationService from app.features.user.models import User diff --git a/api/app/features/conversation/service.py b/api/app/features/conversation/service.py index 7f3c8b6..6e6c9c2 100644 --- a/api/app/features/conversation/service.py +++ b/api/app/features/conversation/service.py @@ -1,15 +1,21 @@ """Conversation service — 对话编排(列表、创建、结束、删除、消息、整理)。""" -from app.core.logging import get_logger import uuid from datetime import datetime, timezone from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from app.core.cos_url_keys import extract_cos_object_key_if_owned +from app.core.logging import get_logger +from app.core.redis import redis_service +from app.core.storage_purge import delete_object_storage_keys_best_effort from app.features.conversation import repo -from app.features.conversation.models import Conversation, Segment +from app.features.conversation.models import Conversation +from app.features.conversation.session_history import segments_to_redis_history +from app.features.memory import repo as memory_repo from app.features.quota.service import QuotaService +from app.ports.storage import ObjectStorage from app.tasks.memoir_tasks import process_memoir_segments logger = get_logger(__name__) @@ -75,23 +81,44 @@ def _build_messages_from_history( class ConversationService: - def __init__(self, db: AsyncSession, quota_service: QuotaService): + def __init__( + self, + db: AsyncSession, + quota_service: QuotaService, + *, + object_storage: ObjectStorage | None = None, + ): self._db = db self._quota = quota_service + self._object_storage = object_storage async def _get_history(self, conversation_id: str) -> list[dict]: - from app.core.redis import redis_service - return await redis_service.get_conversation_history(conversation_id) async def _clear_history(self, conversation_id: str) -> None: try: - from app.core.redis import redis_service - await redis_service.clear_conversation_history(conversation_id) except Exception: pass + async def ensure_redis_history_from_segments( + self, conversation_id: str + ) -> list[dict]: + """ + 供 WS 与 get_messages 使用:优先 Redis;若为空则用 DB segments 重建并写回。 + 会话层编排,非 Agent 职责(ChatOrchestrator 只读写已存在的 Redis 流)。 + """ + history = await redis_service.get_conversation_history(conversation_id) + if history: + return history + segments = await repo.get_segments_for_conversation(conversation_id, self._db) + if not segments: + return [] + rebuilt = segments_to_redis_history(segments) + if rebuilt: + await redis_service.set_conversation_history(conversation_id, rebuilt) + return rebuilt + async def list_for_user(self, user_id: str) -> list[dict]: conversations = await repo.get_user_conversations(user_id, self._db) result = [] @@ -134,7 +161,7 @@ class ConversationService: async def get_or_404(self, conversation_id: str, user_id: str) -> Conversation: conv = await repo.get_conversation(conversation_id, self._db) - if not conv or conv.user_id != user_id: + if not conv or conv.user_id != user_id or conv.deleted_at is not None: raise HTTPException(status_code=404, detail="Conversation not found") return conv @@ -170,14 +197,31 @@ class ConversationService: async def delete(self, conversation_id: str, user_id: str) -> None: conv = await self.get_or_404(conversation_id, user_id) + cos_keys: set[str] = set( + await memory_repo.list_storage_keys_for_conversation( + self._db, conversation_id + ) + ) + segments = await repo.get_segments_for_conversation(conversation_id, self._db) + for seg in segments: + k = extract_cos_object_key_if_owned(seg.audio_url) + if k: + cos_keys.add(k) + await self._clear_history(conversation_id) - await self._db.delete(conv) + conv.deleted_at = datetime.now(timezone.utc) await self._db.commit() + delete_object_storage_keys_best_effort( + self._object_storage, + sorted(cos_keys), + log_prefix=f"conversation_soft_delete id={conversation_id}", + ) + async def get_messages(self, conversation_id: str, user_id: str) -> list[dict]: conv = await self.get_or_404(conversation_id, user_id) try: - history = await self._get_history(conversation_id) + history = await self.ensure_redis_history_from_segments(conversation_id) return _build_messages_from_history( conversation_id=conversation_id, history=history, diff --git a/api/app/features/conversation/session_history.py b/api/app/features/conversation/session_history.py new file mode 100644 index 0000000..7606b80 --- /dev/null +++ b/api/app/features/conversation/session_history.py @@ -0,0 +1,63 @@ +""" +会话 transcript 与 Redis 历史条目的纯映射(无 I/O)。 + +仅由 ConversationService 使用:对齐 ChatOrchestrator 经 save_message 写入 Redis 的字段形状, +不属于 Agent 层 —— 多 Agent 模块只消费已就绪的 history,不负责从 DB 重建。 +""" + +from __future__ import annotations + +from datetime import timezone +from typing import Any, Dict, List + +from app.features.conversation.models import Segment + + +def _voice_session_id_from_audio_url(audio_url: str | None) -> str | None: + if not audio_url: + return None + prefix = "audio-segment:" + if not audio_url.startswith(prefix): + return None + payload = audio_url[len(prefix) :] + voice_session_id_raw, sep, _ = payload.rpartition(":") + if sep and voice_session_id_raw: + return voice_session_id_raw + return None + + +def _segment_timestamp_iso(seg: Segment) -> str | None: + if not seg.created_at: + return None + dt = seg.created_at + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.isoformat() + + +def segments_to_redis_history(segments: List[Segment]) -> List[Dict[str, Any]]: + """Segment 行 → Redis conversation history 项(与 ChatOrchestrator 写入格式一致)。""" + history: List[Dict[str, Any]] = [] + for seg in segments: + ts = _segment_timestamp_iso(seg) + is_voice = bool(seg.audio_url) + human: Dict[str, Any] = { + "role": "human", + "content": seg.transcript_text or "", + "messageType": "audio" if is_voice else "text", + "timestamp": ts, + } + vsid = _voice_session_id_from_audio_url(seg.audio_url) + if vsid: + human["voiceSessionId"] = vsid + history.append(human) + if seg.agent_response and seg.agent_response.strip(): + history.append( + { + "role": "ai", + "content": seg.agent_response.strip(), + "messageType": "text", + "timestamp": ts, + } + ) + return history diff --git a/api/app/features/conversation/ws/connection_manager.py b/api/app/features/conversation/ws/connection_manager.py index 7733575..d43fef7 100644 --- a/api/app/features/conversation/ws/connection_manager.py +++ b/api/app/features/conversation/ws/connection_manager.py @@ -1,10 +1,11 @@ """WebSocket 连接管理器:仅负责连接注册/注销和消息收发""" -from app.core.logging import get_logger from typing import Dict from fastapi import HTTPException, WebSocket +from app.core.logging import get_logger + logger = get_logger(__name__) diff --git a/api/app/features/conversation/ws/pipeline.py b/api/app/features/conversation/ws/pipeline.py index 1622802..62a98f1 100644 --- a/api/app/features/conversation/ws/pipeline.py +++ b/api/app/features/conversation/ws/pipeline.py @@ -2,12 +2,13 @@ import asyncio import base64 -from app.core.logging import get_logger import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple +from app.core.logging import get_logger + if TYPE_CHECKING: from app.features.quota.service import QuotaService @@ -17,7 +18,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.agents import ConversationAgent, MemoryAgent from app.agents.chat import ChatOrchestrator from app.agents.memoir import BackgroundTaskRunner +from app.core.config import settings from app.core.db import AsyncSessionLocal +from app.core.dependencies import get_asr_provider, get_tts_provider from app.features.conversation.models import Conversation, Segment from app.features.conversation.ws.connection_manager import manager from app.features.conversation.ws.message_types import ( @@ -30,14 +33,14 @@ from app.features.conversation.ws.profile_collector import ( get_missing_profile_fields, ) from app.features.user.models import User -from app.core.config import settings -from app.core.dependencies import get_asr_provider, get_tts_provider logger = get_logger(__name__) async def _send_tts_audio(conversation_id: str, text: str) -> None: """Synthesize text to speech and send TTS_AUDIO if successful.""" + if not settings.enable_tts: + return try: tts = get_tts_provider() audio_bytes = await tts.synthesize(text) @@ -325,7 +328,7 @@ async def process_audio_segment( async with AsyncSessionLocal() as db: conversation = await db.get(Conversation, conversation_id) user = await db.get(User, user_id) - if not conversation: + if not conversation or conversation.deleted_at is not None: await manager.send_message( conversation_id, { @@ -562,7 +565,7 @@ async def process_conversation_segments( 配额检查通过注入的 quota_service 完成,不直接 import quota 内部函数。 """ conversation = await db.get(Conversation, conversation_id) - if not conversation: + if not conversation or conversation.deleted_at is not None: return stmt = select(Segment).where( diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py index 7463b10..71c8ffd 100644 --- a/api/app/features/conversation/ws/router.py +++ b/api/app/features/conversation/ws/router.py @@ -4,7 +4,7 @@ WebSocket 路由:实时对话通信 """ import asyncio -from app.core.logging import get_logger +import base64 import uuid from datetime import datetime, timezone @@ -13,8 +13,11 @@ from starlette.websockets import WebSocketState from app.agents.chat.prompts_profile import format_user_profile_context from app.core.db import AsyncSessionLocal +from app.core.dependencies import get_asr_provider +from app.core.logging import get_logger from app.core.security import verify_token from app.features.conversation.models import Conversation, Segment +from app.features.conversation.service import ConversationService from app.features.conversation.ws.connection_manager import manager from app.features.conversation.ws.message_types import MessageType from app.features.conversation.ws.pipeline import ( @@ -34,13 +37,9 @@ from app.features.conversation.ws.pipeline import ( ) from app.features.conversation.ws.profile_collector import get_missing_profile_fields from app.features.conversation.ws.quota_guard import check_ws_quota +from app.features.memoir.state_service import get_or_create_state from app.features.quota.service import QuotaService from app.features.user.models import User -import base64 - -from app.core.dependencies import get_asr_provider -from app.core.redis import redis_service -from app.features.memoir.state_service import get_or_create_state logger = get_logger(__name__) @@ -88,6 +87,7 @@ async def websocket_endpoint( await manager.connect(websocket, conversation_id) quota_service = QuotaService(db=db) + conversation_service = ConversationService(db=db, quota_service=quota_service) try: await manager.send_message( @@ -127,8 +127,26 @@ async def websocket_endpoint( code=status.WS_1008_POLICY_VIOLATION, reason="无权访问此对话" ) return + if conversation.deleted_at is not None: + try: + await manager.send_message( + conversation_id, + { + "type": MessageType.ERROR, + "data": {"message": "对话已删除"}, + "timestamp": datetime.now(timezone.utc).isoformat(), + }, + ) + except Exception: + pass + await websocket.close( + code=status.WS_1008_POLICY_VIOLATION, reason="对话已删除" + ) + return - history = await redis_service.get_conversation_history(conversation_id) + history = await conversation_service.ensure_redis_history_from_segments( + conversation_id + ) if not history: missing_profile = get_missing_profile_fields(user) if missing_profile: diff --git a/api/app/features/memoir/asset_resolver.py b/api/app/features/memoir/asset_resolver.py index 27e79a3..1bd520e 100644 --- a/api/app/features/memoir/asset_resolver.py +++ b/api/app/features/memoir/asset_resolver.py @@ -34,15 +34,19 @@ def collect_asset_ids_from_markdown(markdown: str) -> list[str]: def collect_asset_ids_for_chapter(chapter) -> set[str]: - """章节正文(canonical + 各 section)与 cover_asset_id 中出现的 asset id。""" + """章节正文 canonical、收录的各 story 正文、cover_asset_id 中的 asset id。""" ids: set[str] = set() md = getattr(chapter, "canonical_markdown", None) or "" ids.update(collect_asset_ids_from_markdown(md)) - for sec in getattr(chapter, "sections", None) or []: - ids.update(collect_asset_ids_from_markdown(getattr(sec, "content", None) or "")) cid = getattr(chapter, "cover_asset_id", None) if cid: ids.add(str(cid)) + for link in getattr(chapter, "story_links", None) or []: + st = getattr(link, "story", None) + if st is None: + continue + smd = getattr(st, "canonical_markdown", None) or "" + ids.update(collect_asset_ids_from_markdown(smd)) return ids diff --git a/api/app/features/memoir/chapter_markdown_compose.py b/api/app/features/memoir/chapter_markdown_compose.py new file mode 100644 index 0000000..cadac9f --- /dev/null +++ b/api/app/features/memoir/chapter_markdown_compose.py @@ -0,0 +1,39 @@ +""" +按 chapter_story_links 顺序将各 story 正文物化为单一 markdown(无 LLM)。 +保留 story 内 asset:// 引用不变。 +""" + +from typing import Any + + +def compose_ordered_stories_to_markdown( + ordered: list[tuple[str, str]], +) -> str: + """ + :param ordered: (story_title, canonical_markdown) 已按阅读顺序排好 + :return: 章节级 markdown;每个故事为 ## 标题 + 正文,故事之间用 markdown 水平线 --- 分隔 + (配图在 story 正文中,自然落在该故事块内、--- 之前) + """ + parts: list[str] = [] + for title, md in ordered: + title = (title or "").strip() or "故事" + body = (md or "").strip() + parts.append(f"## {title}\n\n{body}" if body else f"## {title}") + return "\n\n---\n\n".join(parts) + + +def materialize_chapter_markdown_from_loaded_chapter(chapter: Any) -> str: + """要求 chapter.story_links 已 eager-load,且各 link.story 可用。""" + links = sorted( + list(getattr(chapter, "story_links", None) or []), + key=lambda x: getattr(x, "order_index", 0), + ) + pairs: list[tuple[str, str]] = [] + for link in links: + st = getattr(link, "story", None) + if st is None: + continue + title = (getattr(st, "title", None) or "").strip() + body = (getattr(st, "canonical_markdown", None) or "").strip() + pairs.append((title, body)) + return compose_ordered_stories_to_markdown(pairs) diff --git a/api/app/features/memoir/cover_eligibility.py b/api/app/features/memoir/cover_eligibility.py new file mode 100644 index 0000000..fc3e6e8 --- /dev/null +++ b/api/app/features/memoir/cover_eligibility.py @@ -0,0 +1,51 @@ +"""章节封面是否可入队(与 Celery 任务共享,避免循环 import)。""" + +from __future__ import annotations + +from typing import Any + +from app.features.memoir.memoir_images.schema import ( + IMAGE_STATUS_FAILED, + IMAGE_STATUS_PENDING, +) + + +def primary_chapter_memoir_image(chapter: Any) -> Any | None: + """章节级 MemoirImage(封面槽位):按 order_index 最小取第一条。""" + imgs = sorted( + getattr(chapter, "images", None) or [], + key=lambda m: getattr(m, "order_index", 0), + ) + return imgs[0] if imgs else None + + +def chapter_needs_cover_enqueue(chapter) -> bool: + """尚无 cover_asset 且章节有正文时,可派发 generate_chapter_cover。""" + if not chapter: + return False + if getattr(chapter, "cover_asset_id", None): + return False + md = (getattr(chapter, "canonical_markdown", None) or "").strip() + return bool(md) + + +def chapter_has_cover_to_generate(chapter) -> bool: + """章节是否有待生成的封面图(任一条 chapter 级 MemoirImage 为 pending/failed)。""" + for m in getattr(chapter, "images", None) or []: + status = (getattr(m, "status") or "").strip() + if status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED): + return True + return False + + +def cover_memoir_image_pending_or_failed(chapter: Any) -> Any | None: + """用于补图任务:按 order_index 找到第一条 pending/failed 的章节配图行。""" + images = sorted( + getattr(chapter, "images", None) or [], + key=lambda m: getattr(m, "order_index", 0), + ) + for m in images: + st = (getattr(m, "status") or "").strip() + if st in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED): + return m + return None diff --git a/api/app/features/memoir/deps.py b/api/app/features/memoir/deps.py index 30c89be..9866c5e 100644 --- a/api/app/features/memoir/deps.py +++ b/api/app/features/memoir/deps.py @@ -4,13 +4,18 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_async_db +from app.core.dependencies import get_object_storage +from app.features.memoir.service import MemoirService from app.features.memory.deps import get_memory_service from app.features.memory.service import MemoryService -from app.features.memoir.service import MemoirService +from app.ports.storage import ObjectStorage def get_memoir_service( db: AsyncSession = Depends(get_async_db), memory_service: MemoryService = Depends(get_memory_service), + object_storage: ObjectStorage = Depends(get_object_storage), ) -> MemoirService: - return MemoirService(db=db, memory_service=memory_service) + return MemoirService( + db=db, memory_service=memory_service, object_storage=object_storage + ) diff --git a/api/app/features/memoir/helpers.py b/api/app/features/memoir/helpers.py index 1600874..d3bef09 100644 --- a/api/app/features/memoir/helpers.py +++ b/api/app/features/memoir/helpers.py @@ -20,11 +20,20 @@ from app.features.memoir.memoir_images.storage import ( normalize_cos_url, resolve_image_storage_key, ) +from app.features.memoir.cover_eligibility import primary_chapter_memoir_image from app.features.memoir.models import Chapter logger = get_logger(__name__) +def first_normalized_image_for_api(img: dict | None) -> dict | None: + """单条图片经 schema 归一化后仍可能为空(例如非法状态且无可用字段),勿直接 [0]。""" + if not img: + return None + out = normalize_image_assets_for_api([img]) + return out[0] if out else None + + def normalize_image_assets_for_api(images: list[dict] | None) -> list[dict]: bucket = settings.tencent_cos_bucket or "" region = settings.tencent_cos_region or "" @@ -76,19 +85,12 @@ def is_image_permanently_unavailable(rec) -> bool: return False -def section_image_to_dict(section) -> dict | None: - if getattr(section, "image_record", None): - return memoir_image_to_dict(section.image_record) - return None - - def chapter_cover_to_dict( ch: Chapter, asset_url_map: dict[str, str] | None = None ) -> dict | None: - images = getattr(ch, "images", None) or [] - for m in images: - if getattr(m, "section_id", None) is None: - return memoir_image_to_dict(m) + m = primary_chapter_memoir_image(ch) + if m: + return memoir_image_to_dict(m) asset_url_map = asset_url_map or {} aid = getattr(ch, "cover_asset_id", None) if aid and asset_url_map.get(str(aid)): @@ -115,31 +117,19 @@ def chapter_cover_to_dict( def sections_to_content_and_images(ch: Chapter) -> tuple[str, list[dict]]: - sections = getattr(ch, "sections", None) or [] - ordered = sorted(sections, key=lambda s: getattr(s, "order_index", 0)) - parts = [] - images = [] - for s in ordered: - text = (getattr(s, "content", None) or "").strip() - if text: - parts.append(text) - img = section_image_to_dict(s) - if img: - images.append(img) - placeholder = (img.get("placeholder") or "").strip() - if placeholder: - parts.append(placeholder) - content = "\n\n".join(parts) if parts else "" - return content, images + """ + stories-first:正文以 canonical_markdown 为准;正文插图在 story 层,章节 API 不返回 section 级配图列表。 + """ + md = _chapter_markdown(ch) + return md, [] def _chapter_markdown(ch: Chapter) -> str: - """正文真源:优先 canonical_markdown,否则从 sections 推导(兼容旧数据)。""" + """正文真源:canonical_markdown。""" md = getattr(ch, "canonical_markdown", None) if md and str(md).strip(): return str(md).strip() - content, _ = sections_to_content_and_images(ch) - return content + return "" def chapter_to_list_dict( @@ -150,7 +140,7 @@ def chapter_to_list_dict( 含 status、canonical_markdown、content、cover_image(与 cover_asset 同构)、images、sections、word_count。 """ cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map) - cover_normalized = normalize_image_assets_for_api([cover])[0] if cover else None + cover_normalized = first_normalized_image_for_api(cover) canonical_raw = _chapter_markdown(ch) wcount = len(canonical_raw.strip()) if canonical_raw else 0 return { @@ -182,19 +172,8 @@ def chapter_to_dict(ch: Chapter, asset_url_map: dict[str, str] | None = None) -> content = resolve_asset_refs_in_markdown(content, resolve) normalized_images = normalize_image_assets_for_api(images_list) cover = chapter_cover_to_dict(ch, asset_url_map=asset_url_map) - cover_normalized = normalize_image_assets_for_api([cover])[0] if cover else None - sections_data = [] - if getattr(ch, "sections", None): - for s in sorted(ch.sections, key=lambda x: getattr(x, "order_index", 0)): - sec_img = section_image_to_dict(s) - sec_img = normalize_image_assets_for_api([sec_img])[0] if sec_img else None - raw_sec = (getattr(s, "content", None) or "").strip() - sections_data.append( - { - "content": resolve_asset_refs_in_markdown(raw_sec, resolve), - "image": sec_img, - } - ) + cover_normalized = first_normalized_image_for_api(cover) + sections_data: list[dict] = [] # 正文真源:优先 canonical_markdown canonical_md = _chapter_markdown(ch) canonical_md = resolve_asset_refs_in_markdown(canonical_md, resolve) diff --git a/api/app/features/memoir/memoir_images/prompting.py b/api/app/features/memoir/memoir_images/prompting.py index 7a9e38e..dbaa71d 100644 --- a/api/app/features/memoir/memoir_images/prompting.py +++ b/api/app/features/memoir/memoir_images/prompting.py @@ -1,8 +1,10 @@ import json -from app.core.logging import get_logger import re from typing import Any, Optional +from app.core.langchain_llm import bind_json_object_mode +from app.core.logging import get_logger + from .json_payload import extract_json_payload from .settings import MemoirImageSettings @@ -59,10 +61,7 @@ class MemoirImagePromptService: if self.llm: raw_response = None try: - json_llm = self.llm.bind( - model_kwargs={"response_format": {"type": "json_object"}}, - max_tokens=512, - ) + json_llm = bind_json_object_mode(self.llm, max_tokens=512) response = json_llm.invoke( "Return JSON only with keys prompt, style, size. " "Convert the memoir scene into an image-generation prompt.\n" @@ -123,10 +122,7 @@ class MemoirImagePromptService: if self.llm: try: - json_llm = self.llm.bind( - model_kwargs={"response_format": {"type": "json_object"}}, - max_tokens=512, - ) + json_llm = bind_json_object_mode(self.llm, max_tokens=512) response = json_llm.invoke( "Return JSON only with keys prompt, style, size. " "Create an image-generation prompt for a memoir chapter COVER. " diff --git a/api/app/features/memoir/memoir_images/schema.py b/api/app/features/memoir/memoir_images/schema.py index 55114d5..fe6df39 100644 --- a/api/app/features/memoir/memoir_images/schema.py +++ b/api/app/features/memoir/memoir_images/schema.py @@ -19,22 +19,51 @@ _PLACEHOLDER_DESCRIPTION_RE = re.compile( def normalize_image_asset(asset: dict[str, Any] | None) -> dict[str, Any] | None: + """归一化单条图片 dict。 + + - 兼容旧正文中的 {{{{IMAGE:…}}}} / {{IMAGE:…}} 占位符(需能解析出 description)。 + - 新模型:插图不嵌入 markdown,可无占位符;已完成且带 url/storage_key 即可通过, + description 缺省时用「插图」;pending/processing 至少要有 description、占位符或 prompt。 + """ if not isinstance(asset, dict): return None - placeholder = _as_non_empty_string(asset.get("placeholder")) - description = _as_non_empty_string( - asset.get("description") - ) or _extract_description_from_placeholder(placeholder) - if not placeholder or not description: + ph_in = _as_non_empty_string(asset.get("placeholder")) + desc_in = _as_non_empty_string(asset.get("description")) + desc_from_ph = _extract_description_from_placeholder(ph_in) if ph_in else None + merged_description = desc_in or desc_from_ph + + prompt_s = _as_non_empty_string(asset.get("prompt")) + error_s = _as_optional_string(asset.get("error")) + url_s = _as_optional_string(asset.get("url")) + storage_key_s = _as_optional_string(asset.get("storage_key")) + has_url_or_key = bool(url_s or storage_key_s) + + status = _as_non_empty_string(asset.get("status")) or IMAGE_STATUS_PENDING + + if ph_in and merged_description: + placeholder_out = ph_in + description_out = merged_description + elif status == IMAGE_STATUS_COMPLETED and has_url_or_key: + placeholder_out = ph_in or "" + description_out = merged_description or "插图" + elif status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_PROCESSING): + if not (merged_description or ph_in or prompt_s): + return None + placeholder_out = ph_in or "" + description_out = merged_description or prompt_s or "插图" + elif status == IMAGE_STATUS_FAILED: + if not (merged_description or ph_in or error_s): + return None + placeholder_out = ph_in or "" + description_out = merged_description or "插图" + else: return None normalized = dict(asset) normalized["index"] = _coerce_int(asset.get("index"), default=0) - normalized["placeholder"] = placeholder - normalized["description"] = description - - status = _as_non_empty_string(asset.get("status")) or IMAGE_STATUS_PENDING + normalized["placeholder"] = placeholder_out + normalized["description"] = description_out if status not in VALID_IMAGE_STATUSES: normalized["status"] = IMAGE_STATUS_FAILED normalized["error"] = asset.get("error") or f"invalid image status: {status}" diff --git a/api/app/features/memoir/memoir_images/serializers.py b/api/app/features/memoir/memoir_images/serializers.py index f339aca..996b567 100644 --- a/api/app/features/memoir/memoir_images/serializers.py +++ b/api/app/features/memoir/memoir_images/serializers.py @@ -41,7 +41,7 @@ def memoir_image_to_dict(m: MemoirImage | None) -> dict[str, Any] | None: def image_dict_to_row_kwargs(d: dict[str, Any] | None) -> dict[str, Any]: - """从单条图片 dict 提取可写入 MemoirImage 的字段(不含 id/chapter_id/section_id/order_index)。""" + """从单条图片 dict 提取可写入 MemoirImage 的字段(不含 id/chapter_id/order_index)。""" if not d or not isinstance(d, dict): return {} created = d.get("created_at") diff --git a/api/app/features/memoir/models.py b/api/app/features/memoir/models.py index a05ca0b..c9562e6 100644 --- a/api/app/features/memoir/models.py +++ b/api/app/features/memoir/models.py @@ -36,15 +36,12 @@ class Chapter(Base): is_new = Column(Boolean, default=True) is_active = Column(Boolean, default=True) source_segments = Column(JSON, nullable=True) + # story-backed 章节:story 变更后标 True,由 Celery 重组 canonical_markdown + markdown_compose_dirty = Column(Boolean, default=False, nullable=False) + markdown_composed_at = Column(DateTime(timezone=True), nullable=True) user = relationship("User", back_populates="chapters") book = relationship("Book", back_populates="chapters") - sections = relationship( - "ChapterSection", - back_populates="chapter", - order_by="ChapterSection.order_index", - cascade="all, delete-orphan", - ) images = relationship( "MemoirImage", back_populates="chapter", @@ -74,31 +71,6 @@ class Chapter(Base): ) -class ChapterSection(Base): - __tablename__ = "chapter_sections" - - id = Column(String, primary_key=True) - chapter_id = Column( - String, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False - ) - order_index = Column(Integer, nullable=False) - content = Column(Text, nullable=False) - image_id = Column( - String, ForeignKey("memoir_images.id", ondelete="SET NULL"), nullable=True - ) - updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) - - chapter = relationship("Chapter", back_populates="sections") - image_record = relationship( - "MemoirImage", - back_populates="section", - uselist=False, - foreign_keys="ChapterSection.image_id", - cascade="all, delete-orphan", - single_parent=True, - ) - - class MemoirImage(Base): __tablename__ = "memoir_images" @@ -106,9 +78,6 @@ class MemoirImage(Base): chapter_id = Column( String, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False ) - section_id = Column( - String, ForeignKey("chapter_sections.id", ondelete="CASCADE"), nullable=True - ) order_index = Column(Integer, nullable=False, default=0) placeholder = Column(Text, nullable=True) description = Column(Text, nullable=True) @@ -125,11 +94,6 @@ class MemoirImage(Base): updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now) chapter = relationship("Chapter", back_populates="images") - section = relationship( - "ChapterSection", - back_populates="image_record", - foreign_keys="ChapterSection.image_id", - ) class ChapterVersion(Base): diff --git a/api/app/features/memoir/narrative_to_markdown.py b/api/app/features/memoir/narrative_to_markdown.py new file mode 100644 index 0000000..5f99c36 --- /dev/null +++ b/api/app/features/memoir/narrative_to_markdown.py @@ -0,0 +1,33 @@ +"""将 NarrativeAgent / LLM 返回的 JSON 或纯文本规范为 markdown 正文。""" + +from __future__ import annotations + +import json + + +def narrative_to_markdown(narrative: str) -> str: + """ + 将 narrative(JSON paragraphs 或纯文本)转为 markdown。 + 与已删除的 ChapterComposerOrchestrator._to_markdown 行为一致。 + """ + if not narrative or not str(narrative).strip(): + return "" + stripped = narrative.strip() + if stripped.startswith("{") and "paragraphs" in stripped: + try: + data = json.loads(stripped) + paras = data.get("paragraphs", []) + if isinstance(paras, list): + parts = [] + for p in paras: + if isinstance(p, dict): + text = p.get("content", p.get("text", "")) + else: + text = str(p) + if text.strip(): + parts.append(text.strip()) + return "\n\n".join(parts) + return stripped + except json.JSONDecodeError: + return stripped + return stripped diff --git a/api/app/features/memoir/repo.py b/api/app/features/memoir/repo.py index faa0a63..737b029 100644 --- a/api/app/features/memoir/repo.py +++ b/api/app/features/memoir/repo.py @@ -1,19 +1,26 @@ """Memoir repository — Book, Chapter, MemoirState data access.""" import uuid -from datetime import datetime, timezone -from sqlalchemy import select +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session, joinedload +from app.core.db import utc_now +from app.features.asset.models import Asset +from app.features.memoir.asset_resolver import collect_asset_ids_for_chapter +from app.features.memoir.chapter_markdown_compose import ( + materialize_chapter_markdown_from_loaded_chapter, +) from app.features.memoir.models import ( Book, Chapter, - ChapterSection, + ChapterCoverIntent, + ChapterStoryLink, ChapterVersion, MemoirState, ) +from app.features.story.models import Story async def get_current_book(user_id: str, db: AsyncSession) -> Book | None: @@ -27,20 +34,20 @@ async def get_current_book(user_id: str, db: AsyncSession) -> Book | None: return result.scalar_one_or_none() -async def get_chapters_with_sections( +async def get_chapters_for_memoir_list( user_id: str, db: AsyncSession, *, active_only: bool = True, is_new_only: bool | None = None, ) -> list[Chapter]: + """列表/详情:stories-first,预加载 story_links 与 images。""" stmt = ( select(Chapter) .where(Chapter.user_id == user_id) .options( - joinedload(Chapter.sections), joinedload(Chapter.images), - joinedload(Chapter.sections).joinedload(ChapterSection.image_record), + joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), ) .order_by(Chapter.order_index) ) @@ -52,14 +59,26 @@ async def get_chapters_with_sections( return list(result.unique().scalars().all()) +async def get_chapters_with_sections( + user_id: str, + db: AsyncSession, + *, + active_only: bool = True, + is_new_only: bool | None = None, +) -> list[Chapter]: + """兼容旧名:与 get_chapters_for_memoir_list 相同。""" + return await get_chapters_for_memoir_list( + user_id, db, active_only=active_only, is_new_only=is_new_only + ) + + async def get_chapter_by_id(chapter_id: str, db: AsyncSession) -> Chapter | None: stmt = ( select(Chapter) .where(Chapter.id == chapter_id) .options( - joinedload(Chapter.sections), joinedload(Chapter.images), - joinedload(Chapter.sections).joinedload(ChapterSection.image_record), + joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), ) ) result = await db.execute(stmt) @@ -83,19 +102,13 @@ def get_archived_chapter_summaries_sync( Chapter.category == category, Chapter.is_active == False, # noqa: E712 ) - .options(joinedload(Chapter.sections)) .order_by(Chapter.updated_at.desc()) ) result = session.execute(stmt) chapters = list(result.unique().scalars().all()) summaries: list[tuple[str, str]] = [] for ch in chapters: - sections = getattr(ch, "sections", None) or [] - parts = [ - (s.content or "").strip() - for s in sorted(sections, key=lambda x: getattr(x, "order_index", 0)) - ] - combined = "".join(parts) + combined = (getattr(ch, "canonical_markdown", None) or "").strip() preview = (combined[:200] + "...") if len(combined) > 200 else combined if preview.strip(): summaries.append((ch.title or "", preview)) @@ -109,7 +122,7 @@ def ensure_chapter_markdown_and_version_sync( ) -> None: """ 为已有 chapter 设置 canonical_markdown 并创建 chapter_version。 - 由 _save_narrative_to_sections 调用,确保 markdown 真源与版本链。 + 供非 story 物化路径(如 save_chapter_markdown_sync)使用。 """ from sqlalchemy import func @@ -198,3 +211,208 @@ def save_chapter_markdown_sync( session.flush() session.refresh(chapter) return chapter + + +async def count_chapter_story_links(db: AsyncSession, chapter_id: str) -> int: + stmt = ( + select(func.count()) + .select_from(ChapterStoryLink) + .where(ChapterStoryLink.chapter_id == chapter_id) + ) + n = await db.scalar(stmt) + return int(n or 0) + + +async def get_chapter_ids_linked_to_story(db: AsyncSession, story_id: str) -> list[str]: + stmt = select(ChapterStoryLink.chapter_id).where( + ChapterStoryLink.story_id == story_id + ) + result = await db.execute(stmt) + return list(dict.fromkeys(result.scalars().all())) + + +async def mark_chapters_dirty_for_story(db: AsyncSession, story_id: str) -> None: + ids = await get_chapter_ids_linked_to_story(db, story_id) + if not ids: + return + await db.execute( + update(Chapter).where(Chapter.id.in_(ids)).values(markdown_compose_dirty=True) + ) + + +def mark_chapters_dirty_for_story_sync(session: Session, story_id: str) -> None: + stmt = select(ChapterStoryLink.chapter_id).where( + ChapterStoryLink.story_id == story_id + ) + ids = list(dict.fromkeys(session.scalars(stmt).all())) + if not ids: + return + session.execute( + update(Chapter).where(Chapter.id.in_(ids)).values(markdown_compose_dirty=True) + ) + + +async def get_chapter_with_story_links_for_compose( + chapter_id: str, db: AsyncSession +) -> Chapter | None: + stmt = ( + select(Chapter) + .where(Chapter.id == chapter_id) + .options( + joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), + ) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + +async def append_chapter_compose_version_async( + db: AsyncSession, + chapter: Chapter, + markdown: str, +) -> None: + count_stmt = select(func.count(ChapterVersion.id)).where( + ChapterVersion.chapter_id == chapter.id + ) + version_no = (await db.execute(count_stmt)).scalar() or 0 + version_no += 1 + vid = str(uuid.uuid4()) + version = ChapterVersion( + id=vid, + chapter_id=chapter.id, + version_no=version_no, + markdown_snapshot=markdown, + actor_type="system", + source_type="compose_from_stories", + ) + db.add(version) + await db.flush() + chapter.canonical_markdown = markdown + chapter.current_version_id = vid + chapter.markdown_compose_dirty = False + chapter.markdown_composed_at = utc_now() + + +def append_chapter_compose_version_sync( + session: Session, + chapter: Chapter, + markdown: str, +) -> None: + count_stmt = select(func.count(ChapterVersion.id)).where( + ChapterVersion.chapter_id == chapter.id + ) + version_no = (session.execute(count_stmt).scalar() or 0) + 1 + vid = str(uuid.uuid4()) + version = ChapterVersion( + id=vid, + chapter_id=chapter.id, + version_no=version_no, + markdown_snapshot=markdown, + actor_type="system", + source_type="compose_from_stories", + ) + session.add(version) + session.flush() + chapter.canonical_markdown = markdown + chapter.current_version_id = vid + chapter.markdown_compose_dirty = False + chapter.markdown_composed_at = utc_now() + + +def compose_chapter_from_story_links_sync(session: Session, chapter_id: str) -> bool: + """ + 按 story_links 重组 canonical_markdown 并写入版本链。 + 若无 story_links 则清除 dirty 并返回 False。 + """ + stmt = ( + select(Chapter) + .where(Chapter.id == chapter_id) + .options( + joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), + ) + ) + chapter = session.execute(stmt).unique().scalar_one_or_none() + if not chapter: + return False + links = list(chapter.story_links or []) + if not links: + chapter.markdown_compose_dirty = False + session.flush() + return False + md = materialize_chapter_markdown_from_loaded_chapter(chapter) + append_chapter_compose_version_sync(session, chapter, md) + return True + + +async def replace_chapter_story_links_async( + db: AsyncSession, + *, + chapter_id: str, + user_id: str, + story_ids: list[str], +) -> None: + chapter = await db.get(Chapter, chapter_id) + if not chapter or chapter.user_id != user_id: + raise ValueError("Chapter not found or access denied") + if len(story_ids) != len(set(story_ids)): + raise ValueError("Duplicate story_id in story_ids") + if story_ids: + stmt = select(Story.id).where( + Story.id.in_(story_ids), + Story.user_id == user_id, + ) + result = await db.execute(stmt) + found = set(result.scalars().all()) + missing = set(story_ids) - found + if missing: + raise ValueError(f"Stories not found or not owned: {sorted(missing)}") + await db.execute( + delete(ChapterStoryLink).where(ChapterStoryLink.chapter_id == chapter_id) + ) + await db.flush() + for i, sid in enumerate(story_ids): + db.add( + ChapterStoryLink( + id=str(uuid.uuid4()), + chapter_id=chapter_id, + story_id=sid, + order_index=i, + ) + ) + await db.flush() + + +async def collect_cos_storage_keys_for_chapter( + db: AsyncSession, chapter: Chapter +) -> list[str]: + """ + 章节内插图 MemoirImage、正文 asset:// 引用的 Asset、封面 cover_asset、封面意图绑定的 Asset 的 storage_key。 + 用于软删除章节后回收 COS 空间。 + """ + keys: set[str] = set() + for img in getattr(chapter, "images", None) or []: + sk = getattr(img, "storage_key", None) + if sk: + keys.add(sk) + + asset_ids = set(collect_asset_ids_for_chapter(chapter)) + intent_rows = await db.execute( + select(ChapterCoverIntent.asset_id).where( + ChapterCoverIntent.chapter_id == chapter.id, + ChapterCoverIntent.asset_id.isnot(None), + ) + ) + for aid in intent_rows.scalars().all(): + if aid: + asset_ids.add(str(aid)) + + if asset_ids: + row_keys = await db.execute( + select(Asset.storage_key).where( + Asset.id.in_(asset_ids), + Asset.storage_key.isnot(None), + ) + ) + keys.update(k for k in row_keys.scalars().all() if k) + + return sorted(keys) diff --git a/api/app/features/memoir/router.py b/api/app/features/memoir/router.py index 13a205e..dbab237 100644 --- a/api/app/features/memoir/router.py +++ b/api/app/features/memoir/router.py @@ -2,14 +2,18 @@ 回忆录 feature — books / chapters / memoir-state 合并路由 """ -from app.core.logging import get_logger from typing import List, Optional from fastapi import APIRouter, Body, Depends, Query from app.core.dependencies import get_current_user +from app.core.logging import get_logger from app.features.memoir.deps import get_memoir_service -from app.features.memoir.schemas import ExportPdfRequest, UpdateBookRequest +from app.features.memoir.schemas import ( + ExportPdfRequest, + SetChapterStoryOrderRequest, + UpdateBookRequest, +) from app.features.memoir.service import MemoirService from app.features.user.models import User @@ -129,6 +133,21 @@ async def regenerate_chapter( return await service.regenerate_chapter(chapter_id, current_user.id) +@router.put("/chapters/{chapter_id}/story-order") +async def set_chapter_story_order( + chapter_id: str, + request: SetChapterStoryOrderRequest = Body(...), + current_user: User = Depends(get_current_user), + service: MemoirService = Depends(get_memoir_service), +): + """ + 设置章节收录的 stories 顺序(覆盖 chapter_story_links),并立即物化 canonical_markdown。 + """ + return await service.set_chapter_story_order( + chapter_id, current_user.id, request.story_ids + ) + + # =========================================================================== # Memoir-state endpoints # =========================================================================== diff --git a/api/app/features/memoir/schemas.py b/api/app/features/memoir/schemas.py index dc6bf68..4dbcf09 100644 --- a/api/app/features/memoir/schemas.py +++ b/api/app/features/memoir/schemas.py @@ -8,3 +8,9 @@ class UpdateBookRequest(BaseModel): class ExportPdfRequest(BaseModel): book_id: str + + +class SetChapterStoryOrderRequest(BaseModel): + """按顺序绑定本章节要收录的 stories(覆盖原有 chapter_story_links)。""" + + story_ids: list[str] diff --git a/api/app/features/memoir/service.py b/api/app/features/memoir/service.py index 21b4c1f..e2e7c8a 100644 --- a/api/app/features/memoir/service.py +++ b/api/app/features/memoir/service.py @@ -1,9 +1,8 @@ """Memoir service — 回忆录编排(章节生成、状态流转);通过 MemoryService 获取 evidence。""" -from datetime import datetime, timezone +import asyncio from typing import List, Optional -from app.core.logging import get_logger from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,6 +13,8 @@ from app.agents.memoir.prompts import ( CHAPTER_ORDER, STAGE_TO_ORDER, ) +from app.core.logging import get_logger +from app.core.storage_purge import delete_object_storage_keys_best_effort from app.features.memoir import repo from app.features.memoir.asset_resolver import ( collect_asset_ids_for_chapter, @@ -21,14 +22,19 @@ from app.features.memoir.asset_resolver import ( strip_legacy_image_placeholders, ) from app.features.memoir.asset_urls import signed_urls_for_asset_ids +from app.features.memoir.chapter_markdown_compose import ( + materialize_chapter_markdown_from_loaded_chapter, +) +from app.features.memoir.cover_eligibility import primary_chapter_memoir_image from app.features.memoir.helpers import ( chapter_to_dict, chapter_to_list_dict, is_image_permanently_unavailable, ) -from app.features.memoir.models import Book, Chapter, ChapterSection from app.features.memoir.memoir_images.settings import MemoirImageSettings +from app.features.memoir.models import Book, Chapter from app.features.memory.service import MemoryService +from app.ports.storage import ObjectStorage logger = get_logger(__name__) @@ -43,9 +49,12 @@ class MemoirService: self, db: AsyncSession, memory_service: Optional[MemoryService] = None, + *, + object_storage: ObjectStorage | None = None, ): self._db = db self._memory = memory_service + self._object_storage = object_storage async def get_evidence(self, user_id: str, query: str, *, top_k: int = 10) -> dict: """通过 MemoryService 获取检索证据(章节生成时优先使用)。""" @@ -59,13 +68,10 @@ class MemoirService: return await self._memory.retrieve(user_id, query, top_k=top_k) async def _cleanup_unavailable_images(self, ch: Chapter) -> None: - sections = getattr(ch, "sections", None) or [] cleaned = False - for s in sections: - rec = getattr(s, "image_record", None) + for rec in getattr(ch, "images", None) or []: if rec and is_image_permanently_unavailable(rec): - logger.info("清理不可用配图: chapter=%s, section=%s", ch.id, s.id) - s.image_id = None + logger.info("清理不可用配图: chapter=%s, image=%s", ch.id, rec.id) await self._db.delete(rec) cleaned = True if cleaned: @@ -125,8 +131,8 @@ class MemoirService: select(Chapter) .where(Chapter.user_id == user_id, Chapter.is_active == True) .options( - joinedload(Chapter.sections).joinedload(ChapterSection.image_record), joinedload(Chapter.images), + joinedload(Chapter.story_links), ) .order_by(Chapter.order_index) ) @@ -205,13 +211,19 @@ class MemoirService: return chapter_to_dict(chapter, asset_url_map=asset_map) async def disable_chapter(self, chapter_id: str, user_id: str) -> dict: - chapter = await self._db.get(Chapter, chapter_id) + chapter = await repo.get_chapter_by_id(chapter_id, self._db) if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != user_id: raise HTTPException(status_code=403, detail="无权操作此章节") + cos_keys = await repo.collect_cos_storage_keys_for_chapter(self._db, chapter) chapter.is_active = False await self._db.commit() + delete_object_storage_keys_best_effort( + self._object_storage, + cos_keys, + log_prefix=f"chapter_soft_delete id={chapter_id}", + ) return {"status": "ok", "message": "章节已清除"} async def regenerate_chapter(self, chapter_id: str, user_id: str) -> dict: @@ -220,9 +232,46 @@ class MemoirService: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != user_id: raise HTTPException(status_code=403, detail="无权操作此章节") - # TODO: 实现重新整理逻辑 + n = await repo.count_chapter_story_links(self._db, chapter_id) + if n > 0: + raise HTTPException( + status_code=400, + detail="该章节由故事编排驱动,请更新故事正文或调整故事顺序,不支持在此处整章再生。", + ) + # TODO: 非 story-backed 章节的 LLM 重新整理 return {"status": "ok", "message": "Chapter regeneration triggered"} + async def set_chapter_story_order( + self, chapter_id: str, user_id: str, story_ids: list[str] + ) -> dict: + chapter = await self._db.get(Chapter, chapter_id) + if not chapter: + raise HTTPException(status_code=404, detail="Chapter not found") + if chapter.user_id != user_id: + raise HTTPException(status_code=403, detail="无权操作此章节") + if not chapter.is_active: + raise HTTPException(status_code=404, detail="Chapter not found") + try: + await repo.replace_chapter_story_links_async( + self._db, + chapter_id=chapter_id, + user_id=user_id, + story_ids=story_ids, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + ch = await repo.get_chapter_with_story_links_for_compose(chapter_id, self._db) + if not ch: + raise HTTPException(status_code=404, detail="Chapter not found") + if not ch.story_links: + md = "" + else: + md = materialize_chapter_markdown_from_loaded_chapter(ch) + await repo.append_chapter_compose_version_async(self._db, ch, md) + await self._db.commit() + return {"status": "ok", "chapter_id": chapter_id, "story_count": len(story_ids)} + async def get_memoir_state(self, user_id: str) -> dict: from app.features.memoir.state_service import get_or_create_state @@ -244,7 +293,7 @@ class MemoirService: 有正文、尚无 cover_asset、且 legacy 封面 MemoirImage 未 completed 时, 派发 generate_chapter_cover(由 intent/asset 闭环完成)。 """ - from app.tasks.chapter_cover_tasks import generate_chapter_cover + from app.tasks.chapter_cover_enqueue import try_enqueue_generate_chapter_cover img_settings = MemoirImageSettings.from_env() if not img_settings.enabled: @@ -260,30 +309,18 @@ class MemoirService: if getattr(ch, "cover_asset_id", None): continue md = (ch.canonical_markdown or "").strip() - section_blob = "\n\n".join( - (s.content or "").strip() - for s in getattr(ch, "sections", None) or [] - if (s.content or "").strip() - ) - body = md or strip_legacy_image_placeholders(section_blob).strip() + body = strip_legacy_image_placeholders(md).strip() if md else "" if not body: continue - images = getattr(ch, "images", None) or [] - cover_rec = next( - (m for m in images if getattr(m, "section_id", None) is None), - None, - ) - if ( - cover_rec - and (getattr(cover_rec, "status") or "").strip() == "completed" - ): + cover_rec = primary_chapter_memoir_image(ch) + if cover_rec and (cover_rec.status or "").strip() == "completed": continue - try: - generate_chapter_cover.delay(ch.id) + enqueued = await asyncio.to_thread( + try_enqueue_generate_chapter_cover, ch.id, "http" + ) + if enqueued: triggered.append(ch.id) logger.info("触发生成章节封面(asset): chapter=%s", ch.id) - except Exception as exc: - logger.warning("封面任务派发失败: chapter=%s, error=%s", ch.id, exc) return {"triggered": triggered} async def mark_memoir_read(self, user_id: str) -> dict: diff --git a/api/app/features/memoir/story_pipeline_sync.py b/api/app/features/memoir/story_pipeline_sync.py new file mode 100644 index 0000000..3d49001 --- /dev/null +++ b/api/app/features/memoir/story_pipeline_sync.py @@ -0,0 +1,236 @@ +""" +Celery 用:按批次将 transcript 写入 Story,并物化 Chapter canonical_markdown。 +""" + +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from app.agents.memoir.narrative_agent import NarrativeAgent +from app.agents.memoir.prompts import STAGE_TO_ORDER, format_evidence_chunks_for_prompt +from app.agents.memoir.story_route_agent import StoryRouteAgent +from app.agents.state_schema import MemoirStateSchema +from app.core.logging import get_logger +from app.features.memoir.cover_eligibility import chapter_needs_cover_enqueue +from app.features.memoir.helpers import _chapter_markdown +from app.features.memoir.memoir_images.settings import MemoirImageSettings +from app.features.memoir.models import Chapter +from app.features.memoir.narrative_to_markdown import narrative_to_markdown +from app.features.memoir.repo import compose_chapter_from_story_links_sync +from app.features.story.models import Story +from app.features.story.sync_write import ( + append_story_version_sync, + create_story_with_version_sync, + ensure_chapter_story_link_sync, + list_active_stories_for_user_sync, +) +from app.features.memory.repo import retrieve_evidence_sync + +logger = get_logger(__name__) + + +def _is_json_narrative(text: str) -> bool: + if not text or not text.strip(): + return False + s = text.strip() + return s.startswith("{") and "paragraphs" in s + + +def run_story_pipeline_for_category_batch( + session: Session, + *, + user_id: str, + chapter_category: str, + category_segments: list, + state: MemoirStateSchema, + user_profile: str, + user_birth_year: int | None, + llm: Any, +) -> tuple[Chapter | None, bool, set[str]]: + """ + 返回 (chapter, needs_cover_enqueue, story_ids_to_dispatch_after_commit)。 + """ + narrative_agent = NarrativeAgent() + route_agent = StoryRouteAgent() + dispatch_ids: set[str] = set() + + segment_texts = [seg.transcript_text or "" for seg in category_segments] + combined_text = "\n\n".join(segment_texts) + source_ids = [seg.id for seg in category_segments] + + try: + evidence = retrieve_evidence_sync(session, user_id, combined_text, top_k=10) + except Exception as e: + logger.warning("Evidence 检索跳过: %s", e) + evidence = { + "relevant_chunks": [], + "relevant_summaries": [], + "relevant_facts": [], + "timeline_hints": [], + "relevant_stories": [], + } + + evidence_text = format_evidence_chunks_for_prompt(evidence) + new_content_input = ( + f"{combined_text}\n\n【相关记忆摘录】\n{evidence_text}" + if evidence_text.strip() + else combined_text + ) + + stmt_chapter = ( + select(Chapter) + .where( + Chapter.user_id == user_id, + Chapter.category == chapter_category, + Chapter.is_active == True, # noqa: E712 + ) + .options( + joinedload(Chapter.images), + joinedload(Chapter.story_links), + ) + ) + chapter = session.execute(stmt_chapter).unique().scalar_one_or_none() + + slot_snippets: dict[str, str] = {} + stage_slots = state.slots.get(chapter_category, {}) or {} + for key, value in stage_slots.items(): + snip = getattr(value, "snippet", None) or ( + value.get("snippet") if isinstance(value, dict) else None + ) + if snip: + slot_snippets[key] = snip + + title = chapter.title if chapter else f"{chapter_category} 回忆" + existing_chapter_md = _chapter_markdown(chapter) if chapter else "" + + if not chapter: + title = narrative_agent.generate_title( + stage=chapter_category, + emotion="neutral", + slots=slot_snippets, + user_profile=user_profile, + birth_year=user_birth_year, + llm=llm, + ) + + candidates = list_active_stories_for_user_sync(session, user_id) + valid_ids = {s.id for s in candidates} + + batch_for_route = ( + f"{combined_text}\n\n{evidence_text}" + if evidence_text.strip() + else combined_text + ) + route = route_agent.decide( + chapter_category=chapter_category, + chapter_title=title, + batch_transcript=batch_for_route, + candidate_stories=candidates, + llm=llm, + valid_story_ids=valid_ids, + ) + + target_story_id: str | None = None + existing_for_narrative = "" + if route.decision == "append_story" and route.target_story_id: + st = session.get(Story, route.target_story_id) + if st and st.user_id == user_id: + target_story_id = st.id + existing_for_narrative = (st.canonical_markdown or "").strip() + + narrative_raw = narrative_agent.generate_narrative( + stage=chapter_category, + slots=slot_snippets, + new_content=new_content_input, + existing_content=existing_for_narrative, + user_profile=user_profile, + birth_year=user_birth_year, + llm=llm, + ) + + if ( + existing_for_narrative + and not _is_json_narrative(narrative_raw) + and len(narrative_raw) < len(existing_for_narrative) * 0.8 + ): + logger.warning("叙事长度异常: 回退为原文追加") + narrative_raw = f"{existing_for_narrative}\n\n{combined_text}" + + if ( + not existing_for_narrative + and existing_chapter_md + and not _is_json_narrative(narrative_raw) + and len(narrative_raw) < len(existing_chapter_md) * 0.8 + ): + logger.warning( + "章节级长度异常: 回退为 transcript 追加, category=%s", + chapter_category, + ) + narrative_raw = f"{existing_chapter_md}\n\n{combined_text}" + + md = narrative_to_markdown(narrative_raw) + if not md.strip(): + md = combined_text.strip() + + calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) + + if not chapter: + chapter = Chapter( + id=str(uuid.uuid4()), + user_id=user_id, + title=title, + order_index=calculated_order_index, + status="completed", + category=chapter_category, + cover_image=None, + is_new=True, + source_segments=source_ids, + ) + session.add(chapter) + session.flush() + else: + chapter.source_segments = list( + set((chapter.source_segments or []) + source_ids) + ) + chapter.is_new = True + + do_append = target_story_id is not None + + if do_append: + append_story_version_sync(session, target_story_id, md) + dispatch_ids.add(target_story_id) + ensure_chapter_story_link_sync( + session, chapter_id=chapter.id, story_id=target_story_id + ) + else: + story_title = (route.new_story_title or "").strip() + if not story_title: + story_title = narrative_agent.generate_title( + stage=chapter_category, + emotion="neutral", + slots=slot_snippets, + user_profile=user_profile, + birth_year=user_birth_year, + llm=llm, + ) + st = create_story_with_version_sync( + session, + user_id=user_id, + title=story_title, + canonical_markdown=md, + stage=chapter_category, + ) + dispatch_ids.add(st.id) + ensure_chapter_story_link_sync(session, chapter_id=chapter.id, story_id=st.id) + + compose_chapter_from_story_links_sync(session, chapter.id) + session.flush() + + image_settings = MemoirImageSettings.from_env() + needs_cover = image_settings.enabled and chapter_needs_cover_enqueue(chapter) + + return chapter, needs_cover, dispatch_ids diff --git a/api/app/features/memory/repo.py b/api/app/features/memory/repo.py index 9bbbe81..93a62ad 100644 --- a/api/app/features/memory/repo.py +++ b/api/app/features/memory/repo.py @@ -305,3 +305,15 @@ async def get_timeline_events_for_user( ) result = await db.execute(stmt) return list(result.unique().scalars().all()) + + +async def list_storage_keys_for_conversation( + db: AsyncSession, conversation_id: str +) -> list[str]: + """对话关联的 memory_sources 上记录的 COS object key(若有)。""" + stmt = select(MemorySource.storage_key).where( + MemorySource.conversation_id == conversation_id, + MemorySource.storage_key.isnot(None), + ) + result = await db.execute(stmt) + return sorted({r for r in result.scalars().all() if r}) diff --git a/api/app/features/quota/service.py b/api/app/features/quota/service.py index 02e6d1a..9374a3b 100644 --- a/api/app/features/quota/service.py +++ b/api/app/features/quota/service.py @@ -45,7 +45,10 @@ async def get_segment_count(user_id: str, db: AsyncSession) -> int: stmt = ( select(func.count(Segment.id)) .join(Conversation, Segment.conversation_id == Conversation.id) - .where(Conversation.user_id == user_id) + .where( + Conversation.user_id == user_id, + Conversation.deleted_at.is_(None), + ) ) result = await db.execute(stmt) return result.scalar() or 0 diff --git a/api/app/features/story/service.py b/api/app/features/story/service.py index 754e327..8e21d68 100644 --- a/api/app/features/story/service.py +++ b/api/app/features/story/service.py @@ -11,6 +11,7 @@ from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger +from app.features.memoir import repo as memoir_repo from app.features.story.image_intent_extractor import extract_primary_image_intent from app.features.story.repo import ( count_story_versions, @@ -134,14 +135,21 @@ class StoryService: version=version, markdown=canonical_markdown, ) + if canonical_markdown: + await memoir_repo.mark_chapters_dirty_for_story(self._db, story.id) await self._db.commit() if canonical_markdown: + from app.tasks.chapter_compose_tasks import recompose_chapters_for_story from app.tasks.story_image_tasks import generate_story_image try: generate_story_image.delay(story.id) except Exception as exc: logger.warning("派发 generate_story_image 失败: %s", exc) + try: + recompose_chapters_for_story.delay(story.id) + except Exception as exc: + logger.warning("派发 recompose_chapters_for_story 失败: %s", exc) return story.id async def append_version( @@ -179,13 +187,19 @@ class StoryService: version=version, markdown=markdown_snapshot, ) + await memoir_repo.mark_chapters_dirty_for_story(self._db, story_id) await self._db.commit() + from app.tasks.chapter_compose_tasks import recompose_chapters_for_story from app.tasks.story_image_tasks import generate_story_image try: generate_story_image.delay(story_id) except Exception as exc: logger.warning("派发 generate_story_image 失败: %s", exc) + try: + recompose_chapters_for_story.delay(story_id) + except Exception as exc: + logger.warning("派发 recompose_chapters_for_story 失败: %s", exc) return version.id async def link_evidence( diff --git a/api/app/features/story/sync_write.py b/api/app/features/story/sync_write.py new file mode 100644 index 0000000..718eca1 --- /dev/null +++ b/api/app/features/story/sync_write.py @@ -0,0 +1,224 @@ +""" +Story 同步写入(Celery / sync Session)。 + +与 StoryService 行为对齐:版本链、主图 intent、章节 dirty;不 commit,由调用方提交。 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session, joinedload + +from app.core.db import utc_now +from app.core.logging import get_logger +from app.features.memoir.models import ChapterStoryLink +from app.features.memoir import repo as memoir_repo +from app.features.story.image_intent_extractor import extract_primary_image_intent +from app.features.story.models import Story, StoryImageIntent, StoryVersion + +logger = get_logger(__name__) + + +def count_story_versions_sync(session: Session, story_id: str) -> int: + stmt = select(func.count(StoryVersion.id)).where(StoryVersion.story_id == story_id) + return int(session.execute(stmt).scalar() or 0) + + +def _delete_pending_failed_intents_sync(session: Session, story_id: str) -> None: + session.execute( + delete(StoryImageIntent).where( + StoryImageIntent.story_id == story_id, + StoryImageIntent.intent_role == "primary", + StoryImageIntent.status.in_(["pending", "failed"]), + ) + ) + + +def _get_primary_intent_sync( + session: Session, story_id: str +) -> StoryImageIntent | None: + stmt = select(StoryImageIntent).where( + StoryImageIntent.story_id == story_id, + StoryImageIntent.intent_role == "primary", + ) + return session.execute(stmt).scalar_one_or_none() + + +def _extract_and_store_image_intent_sync( + session: Session, + *, + story: Story, + version: StoryVersion, + markdown: str, +) -> None: + _delete_pending_failed_intents_sync(session, story.id) + result = extract_primary_image_intent( + markdown, + title=story.title or "", + stage=story.stage, + summary=story.summary, + people_refs=story.people_refs or [], + place_refs=story.place_refs or [], + time_start=story.time_start, + time_end=story.time_end, + ) + existing = _get_primary_intent_sync(session, story.id) + now = datetime.now(timezone.utc) + + if existing and existing.story_version_id == version.id: + st = (existing.status or "").strip() + if st in ("processing", "completed"): + return + existing.caption = result.caption + existing.prompt_brief = result.prompt_brief + existing.source_span = result.source_span + existing.style_profile = result.style_profile + existing.status = "pending" + existing.error = None + existing.asset_id = None + existing.updated_at = now + return + + if existing and existing.story_version_id != version.id: + existing.story_version_id = version.id + existing.caption = result.caption + existing.prompt_brief = result.prompt_brief + existing.source_span = result.source_span + existing.style_profile = result.style_profile + existing.status = "pending" + existing.error = None + existing.asset_id = None + existing.updated_at = now + return + + session.add( + StoryImageIntent( + id=str(uuid.uuid4()), + story_id=story.id, + story_version_id=version.id, + intent_role="primary", + source_span=result.source_span, + caption=result.caption, + prompt_brief=result.prompt_brief, + style_profile=result.style_profile, + status="pending", + ) + ) + + +def create_story_with_version_sync( + session: Session, + *, + user_id: str, + title: str, + canonical_markdown: str, + stage: str | None = None, +) -> Story: + story = Story( + id=str(uuid.uuid4()), + user_id=user_id, + title=title, + stage=stage, + canonical_markdown=canonical_markdown or "", + ) + session.add(story) + session.flush() + vid = str(uuid.uuid4()) + version = StoryVersion( + id=vid, + story_id=story.id, + version_no=1, + markdown_snapshot=canonical_markdown or "", + actor_type="ai", + source_type="generate", + ) + session.add(version) + session.flush() + story.current_version_id = vid + if (canonical_markdown or "").strip(): + _extract_and_store_image_intent_sync( + session, story=story, version=version, markdown=canonical_markdown + ) + memoir_repo.mark_chapters_dirty_for_story_sync(session, story.id) + return story + + +def append_story_version_sync( + session: Session, + story_id: str, + markdown_snapshot: str, + *, + actor_type: str = "ai", + source_type: str = "generate", +) -> StoryVersion: + story = session.get(Story, story_id) + if not story: + raise ValueError(f"Story {story_id} not found") + parent_id = story.current_version_id + version_no = count_story_versions_sync(session, story_id) + 1 + vid = str(uuid.uuid4()) + version = StoryVersion( + id=vid, + story_id=story_id, + version_no=version_no, + markdown_snapshot=markdown_snapshot, + actor_type=actor_type, + source_type=source_type, + parent_version_id=parent_id, + ) + session.add(version) + session.flush() + story.current_version_id = vid + story.canonical_markdown = markdown_snapshot + _extract_and_store_image_intent_sync( + session, story=story, version=version, markdown=markdown_snapshot + ) + memoir_repo.mark_chapters_dirty_for_story_sync(session, story_id) + return version + + +def ensure_chapter_story_link_sync( + session: Session, + *, + chapter_id: str, + story_id: str, +) -> None: + """若章节尚未关联该 story,则在末尾追加一条 chapter_story_link。""" + exists = session.scalars( + select(ChapterStoryLink) + .where( + ChapterStoryLink.chapter_id == chapter_id, + ChapterStoryLink.story_id == story_id, + ) + .limit(1) + ).first() + if exists is not None: + return + max_stmt = select(func.coalesce(func.max(ChapterStoryLink.order_index), -1)).where( + ChapterStoryLink.chapter_id == chapter_id + ) + max_idx = int(session.execute(max_stmt).scalar() or -1) + session.add( + ChapterStoryLink( + id=str(uuid.uuid4()), + chapter_id=chapter_id, + story_id=story_id, + order_index=max_idx + 1, + ) + ) + session.flush() + + +def list_active_stories_for_user_sync(session: Session, user_id: str) -> list[Story]: + stmt = ( + select(Story) + .where(Story.user_id == user_id, Story.status == "active") + .options( + joinedload(Story.chapter_links).joinedload(ChapterStoryLink.chapter), + ) + .order_by(Story.updated_at.desc()) + ) + return list(session.execute(stmt).unique().scalars().all()) diff --git a/api/app/features/user/repo.py b/api/app/features/user/repo.py index 895ad45..bf35fc4 100644 --- a/api/app/features/user/repo.py +++ b/api/app/features/user/repo.py @@ -1,7 +1,135 @@ +"""User 数据访问:查询与「清空用户业务数据」批量删除。""" + +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession +from app.features.asset.models import Asset +from app.features.auth.models import RefreshToken +from app.features.conversation.models import Conversation, Segment +from app.features.memoir.models import ( + Book, + Chapter, + ChapterCoverIntent, + MemoirImage, + MemoirState, +) +from app.features.memory.models import ( + MemoryChunk, + MemoryCurationAction, + MemoryFact, + MemorySource, + MemorySummary, + TimelineEvent, +) +from app.features.payment.models import Order +from app.features.story.models import Story, StoryImageIntent from app.features.user.models import User async def get_user_by_id(user_id: str, db: AsyncSession) -> User | None: return await db.get(User, user_id) + + +async def collect_purge_context( + db: AsyncSession, user_id: str +) -> tuple[list[str], list[str], list[str]]: + """在删除前收集 Redis / 分布式锁相关 id。""" + conv_rows = await db.execute( + select(Conversation.id).where(Conversation.user_id == user_id) + ) + conv_ids = list(conv_rows.scalars().all()) + + ch_rows = await db.execute(select(Chapter.id).where(Chapter.user_id == user_id)) + chapter_ids = list(ch_rows.scalars().all()) + + st_rows = await db.execute(select(Story.id).where(Story.user_id == user_id)) + story_ids = list(st_rows.scalars().all()) + + return conv_ids, chapter_ids, story_ids + + +async def related_asset_ids(db: AsyncSession, user_id: str) -> set[str]: + """用户故事插图 / 章节封面意图引用的 Asset.id。""" + asset_rows = await db.execute( + select(StoryImageIntent.asset_id) + .join(Story, StoryImageIntent.story_id == Story.id) + .where(Story.user_id == user_id, StoryImageIntent.asset_id.isnot(None)) + ) + from_story = {r for r in asset_rows.scalars().all() if r} + + asset_rows_ch = await db.execute( + select(ChapterCoverIntent.asset_id) + .join(Chapter, ChapterCoverIntent.chapter_id == Chapter.id) + .where(Chapter.user_id == user_id, ChapterCoverIntent.asset_id.isnot(None)) + ) + return from_story | {r for r in asset_rows_ch.scalars().all() if r} + + +async def collect_object_storage_keys_before_purge( + db: AsyncSession, user_id: str +) -> list[str]: + """删除行前收集 COS 等对象存储 key(尽力删除孤儿对象)。""" + keys: set[str] = set() + + r = await db.execute( + select(MemorySource.storage_key).where( + MemorySource.user_id == user_id, + MemorySource.storage_key.isnot(None), + ) + ) + keys.update(x for x in r.scalars().all() if x) + + r2 = await db.execute( + select(MemoirImage.storage_key) + .join(Chapter, MemoirImage.chapter_id == Chapter.id) + .where(Chapter.user_id == user_id, MemoirImage.storage_key.isnot(None)) + ) + keys.update(x for x in r2.scalars().all() if x) + + asset_ids = await related_asset_ids(db, user_id) + if asset_ids: + r3 = await db.execute( + select(Asset.storage_key).where( + Asset.id.in_(asset_ids), + Asset.storage_key.isnot(None), + ) + ) + keys.update(x for x in r3.scalars().all() if x) + + return sorted(keys) + + +async def purge_user_related_rows(db: AsyncSession, user_id: str) -> None: + """ + 物理删除当前用户除账号(users 行)外的业务数据。 + 顺序按外键依赖:memory → 资源意图关联的 assets → story/chapter/book → 对话 → 订单与 refresh token 等。 + """ + await db.execute(delete(MemoryFact).where(MemoryFact.user_id == user_id)) + await db.execute(delete(MemoryChunk).where(MemoryChunk.user_id == user_id)) + await db.execute(delete(MemorySource).where(MemorySource.user_id == user_id)) + await db.execute(delete(MemorySummary).where(MemorySummary.user_id == user_id)) + await db.execute(delete(TimelineEvent).where(TimelineEvent.user_id == user_id)) + await db.execute( + delete(MemoryCurationAction).where(MemoryCurationAction.user_id == user_id) + ) + + asset_ids = await related_asset_ids(db, user_id) + if asset_ids: + await db.execute(delete(Asset).where(Asset.id.in_(asset_ids))) + + await db.execute(delete(Story).where(Story.user_id == user_id)) + await db.execute(delete(Chapter).where(Chapter.user_id == user_id)) + await db.execute(delete(Book).where(Book.user_id == user_id)) + + await db.execute( + delete(Segment).where( + Segment.conversation_id.in_( + select(Conversation.id).where(Conversation.user_id == user_id) + ) + ) + ) + await db.execute(delete(Conversation).where(Conversation.user_id == user_id)) + + await db.execute(delete(MemoirState).where(MemoirState.user_id == user_id)) + await db.execute(delete(Order).where(Order.user_id == user_id)) + await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user_id)) diff --git a/api/app/features/user/router.py b/api/app/features/user/router.py index 3c2ef1d..d077b2b 100644 --- a/api/app/features/user/router.py +++ b/api/app/features/user/router.py @@ -1,14 +1,16 @@ -from app.core.logging import get_logger import uuid from fastapi import APIRouter, Depends, HTTPException, status from app.core.config import settings -from app.core.dependencies import get_current_user +from app.core.dependencies import get_current_user, get_object_storage +from app.core.logging import get_logger from app.features.user.deps import get_user_service from app.features.user.models import User from app.features.user.schemas import ( FeedbackResponse, + PurgeUserDataRequest, + PurgeUserDataResponse, SubmitFeedbackRequest, TestSubscriptionRequest, TestSubscriptionResponse, @@ -16,6 +18,7 @@ from app.features.user.schemas import ( UserProfileResponse, ) from app.features.user.service import UserService +from app.ports.storage import ObjectStorage logger = get_logger(__name__) @@ -66,6 +69,29 @@ async def update_user_profile( return await service.update_profile(current_user.id, body) +@router.post("/data/purge", response_model=PurgeUserDataResponse) +async def purge_user_data( + body: PurgeUserDataRequest, + current_user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), + object_storage: ObjectStorage = Depends(get_object_storage), +): + """ + 永久删除当前账号下的业务数据:对话与片段、记忆层、故事与插图意图、书籍与章节(含图片任务行)、 + 回忆录状态、订单记录、刷新令牌;并清理会话 Redis 历史、任务追踪与相关分布式锁 key; + 对 memory_sources / memoir_images / 关联 Asset 中记录的 storage_key 尽力删除对象存储对象。 + 不删除 users 表中的账号(手机号、密码等);口令见请求体 schema 说明。 + """ + try: + return await service.purge_all_user_data( + current_user.id, + confirmation=body.confirmation, + object_storage=object_storage, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + @router.post("/test-subscription", response_model=TestSubscriptionResponse) async def test_subscription( body: TestSubscriptionRequest, diff --git a/api/app/features/user/schemas.py b/api/app/features/user/schemas.py index eb7a0e4..0fa3312 100644 --- a/api/app/features/user/schemas.py +++ b/api/app/features/user/schemas.py @@ -47,3 +47,22 @@ class FeedbackResponse(BaseModel): id: str message: str + + +# 与前端约定:请求体必须携带该固定口令,防止误触清空。 +PURGE_USER_DATA_CONFIRMATION = "我确认永久删除我的全部回忆与对话数据" + + +class PurgeUserDataRequest(BaseModel): + """清空账号下全部业务数据(保留登录账号与手机号等身份字段)。""" + + confirmation: str = Field( + ..., + min_length=1, + description=f"必须为「{PURGE_USER_DATA_CONFIRMATION}」", + ) + + +class PurgeUserDataResponse(BaseModel): + success: bool + message: str diff --git a/api/app/features/user/service.py b/api/app/features/user/service.py index 2734136..1906e90 100644 --- a/api/app/features/user/service.py +++ b/api/app/features/user/service.py @@ -3,13 +3,21 @@ from datetime import timedelta from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import utc_now +from app.core.logging import get_logger +from app.core.redis import redis_service +from app.core.task_tracker import task_tracker from app.features.user import repo from app.features.user.models import User from app.features.user.schemas import ( + PURGE_USER_DATA_CONFIRMATION, + PurgeUserDataResponse, TestSubscriptionResponse, UpdateUserProfileRequest, UserProfileResponse, ) +from app.ports.storage import ObjectStorage + +logger = get_logger(__name__) def _user_to_profile(user: User) -> UserProfileResponse: @@ -74,3 +82,72 @@ class UserService: message="已关闭测试订阅,恢复免费体验版", subscription_type="free", ) + + async def purge_all_user_data( + self, + user_id: str, + *, + confirmation: str, + object_storage: ObjectStorage | None = None, + ) -> PurgeUserDataResponse: + """物理删除该用户业务数据(不含 users 账号行);提交后再清 Redis / 任务追踪 / 锁 key。""" + if confirmation != PURGE_USER_DATA_CONFIRMATION: + raise ValueError("确认文案不正确,请按提示完整输入口令") + + user = await repo.get_user_by_id(user_id, self._db) + if not user: + raise ValueError("用户不存在") + + storage_keys = await repo.collect_object_storage_keys_before_purge( + self._db, user_id + ) + conv_ids, chapter_ids, story_ids = await repo.collect_purge_context( + self._db, user_id + ) + await repo.purge_user_related_rows(self._db, user_id) + await self._db.commit() + + if object_storage and storage_keys: + for key in storage_keys: + try: + object_storage.delete(key) + except Exception as e: + logger.warning( + "对象存储删除失败 user_id=%s key=%s err=%s", user_id, key, e + ) + + for cid in conv_ids: + try: + await redis_service.clear_conversation_history(cid) + except Exception as e: + logger.warning( + "清空会话 Redis 历史失败 conversation_id=%s err=%s", cid, e + ) + + try: + await task_tracker.clear_user_tasks(user_id) + except Exception as e: + logger.warning("清空用户任务追踪失败 user_id=%s err=%s", user_id, e) + + try: + await redis_service.delete_keys_matching_pattern( + f"lock:chapter:{user_id}:*" + ) + for ch_id in chapter_ids: + await redis_service.delete_keys_matching_pattern( + f"lock:chapter-images:{ch_id}" + ) + for sid in story_ids: + await redis_service.delete_keys_matching_pattern( + f"lock:story-image:{sid}" + ) + except Exception as e: + logger.warning("清理 Redis 锁 key 失败 user_id=%s err=%s", user_id, e) + + return PurgeUserDataResponse( + success=True, + message=( + "已清空该账号下的对话、记忆、故事、章节、订单等业务数据,并已尝试删除关联的对象存储文件;" + "所有登录会话已失效,请重新登录" + ), + ) diff --git a/api/app/main.py b/api/app/main.py index 34d92e0..21e12a9 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -88,7 +88,7 @@ async def startup_event(): logger.info("Alembic 迁移已就绪") except Exception as e: logger.warning( - "Alembic 迁移失败(可能数据库未启动或 DATABASE_URL 未配置): %s", e + "Alembic 迁移失败(可能数据库未启动或 DATABASE_URL 未配置): {}", e ) try: @@ -97,7 +97,7 @@ async def startup_event(): await redis_service.get_client() logger.info("Redis 连接已建立") except Exception as e: - logger.warning("Redis 连接失败(会话存储将不可用): %s", e) + logger.warning("Redis 连接失败(会话存储将不可用): {}", e) try: from app.core.dependencies import get_asr_provider @@ -116,11 +116,11 @@ async def startup_event(): if settings.asr_provider == "tencent" else "本地 Whisper" ) - logger.info("ASR 服务已就绪(%s)", name) + logger.info("ASR 服务已就绪({})", name) else: logger.warning("ASR 服务未就绪,语音转写将不可用") except Exception as e: - logger.warning("ASR 初始化检查失败: %s", e) + logger.warning("ASR 初始化检查失败: {}", e) try: @@ -148,7 +148,7 @@ async def shutdown_event(): await redis_service.close() logger.info("Redis 连接已关闭") except Exception as e: - logger.warning("关闭 Redis 连接失败: %s", e) + logger.warning("关闭 Redis 连接失败: {}", e) # ── Feature routers ────────────────────────────────────────── diff --git a/api/app/tasks/celery_app.py b/api/app/tasks/celery_app.py index 29a42ab..d81b4f2 100644 --- a/api/app/tasks/celery_app.py +++ b/api/app/tasks/celery_app.py @@ -2,20 +2,24 @@ Celery 应用配置 配置从 app.core.config.settings 读取。 Worker 启动时需聚合注册所有 feature 的 model,否则 User 等 relationship("Order", ...) 解析时会报找不到 Order。 +与 main.py / Alembic 一致:下方 import 仅用于注册 ORM model。 """ +from app.core.logging import setup_logging + +# 与 app.main 一致:先配置 loguru + InterceptHandler,再加载会打日志的依赖 +setup_logging() + from celery import Celery from app.core.config import settings - -# 与 main.py / Alembic 一致:注册所有 model,避免 mapper 初始化时 relationship 字符串找不到类 from app.features.asset import models as _asset_models # noqa: F401 - register Asset from app.features.auth import models as _auth_models # noqa: F401 from app.features.conversation import models as _conv_models # noqa: F401 -from app.features.memory import models as _memory_models # noqa: F401 from app.features.memoir import models as _memoir_models # noqa: F401 -from app.features.story import models as _story_models # noqa: F401 +from app.features.memory import models as _memory_models # noqa: F401 from app.features.payment import models as _payment_models # noqa: F401 +from app.features.story import models as _story_models # noqa: F401 from app.features.user import models as _user_models # noqa: F401 REDIS_URL = settings.redis_url @@ -29,11 +33,14 @@ celery_app = Celery( "app.tasks.memoir_tasks", "app.tasks.story_image_tasks", "app.tasks.chapter_cover_tasks", + "app.tasks.chapter_compose_tasks", ], ) # Celery 配置 celery_app.conf.update( + # 不劫持根 logger,便于与 loguru + InterceptHandler 统一格式与等级 + worker_hijack_root_logger=False, # 任务序列化 task_serializer="json", accept_content=["json"], diff --git a/api/app/tasks/chapter_compose_tasks.py b/api/app/tasks/chapter_compose_tasks.py new file mode 100644 index 0000000..d8a1743 --- /dev/null +++ b/api/app/tasks/chapter_compose_tasks.py @@ -0,0 +1,43 @@ +"""Celery:story 变更后重组关联章节的 canonical_markdown(物化视图)。""" + +from celery import shared_task +from sqlalchemy import select + +from app.core.db import get_sync_db +from app.core.logging import get_logger +from app.features.memoir import repo as memoir_repo +from app.features.memoir.models import Chapter, ChapterStoryLink + +logger = get_logger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=30) +def recompose_chapters_for_story(self, story_id: str) -> dict: + try: + with get_sync_db() as session: + stmt = ( + select(Chapter.id) + .join( + ChapterStoryLink, + ChapterStoryLink.chapter_id == Chapter.id, + ) + .where( + ChapterStoryLink.story_id == story_id, + Chapter.markdown_compose_dirty.is_(True), + ) + ) + ids = list(session.scalars(stmt).all()) + for cid in ids: + memoir_repo.compose_chapter_from_story_links_sync(session, cid) + session.commit() + logger.info( + "recompose_chapters_for_story: story=%s recomposed_chapters=%s", + story_id, + ids, + ) + return {"story_id": story_id, "recomposed_chapter_ids": ids} + except Exception as exc: + logger.warning( + "recompose_chapters_for_story failed story=%s err=%s", story_id, exc + ) + raise self.retry(exc=exc) from exc diff --git a/api/app/tasks/chapter_cover_enqueue.py b/api/app/tasks/chapter_cover_enqueue.py new file mode 100644 index 0000000..adcee7b --- /dev/null +++ b/api/app/tasks/chapter_cover_enqueue.py @@ -0,0 +1,131 @@ +""" +章节封面 Celery 任务入队闸门:DB 二次校验 + Redis 短时去重,避免重复 delay 同一 chapter。 +""" + +from __future__ import annotations + +from typing import Literal + +import redis +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +from app.core.config import settings +from app.core.db import get_sync_db +from app.core.logging import get_logger +from app.features.memoir.asset_resolver import strip_legacy_image_placeholders +from app.features.memoir.cover_eligibility import primary_chapter_memoir_image +from app.features.memoir.models import Chapter + +logger = get_logger(__name__) + +CHAPTER_COVER_ENQUEUE_DEDUP_TTL_SECONDS = 450 +_ENQUEUE_KEY_PREFIX = "enqueue:chapter-cover:" + + +def _enqueue_dedup_key(chapter_id: str) -> str: + return f"{_ENQUEUE_KEY_PREFIX}{chapter_id}" + + +def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool: + """与 MemoirService.check_and_trigger_cover_generation 循环条件一致。""" + if not chapter: + return False + if not chapter.category or chapter.status == "empty": + return False + if getattr(chapter, "cover_asset_id", None): + return False + md = (chapter.canonical_markdown or "").strip() + body = md or "" + if not body.strip(): + return False + body = strip_legacy_image_placeholders(body).strip() + if not body: + return False + cover_rec = primary_chapter_memoir_image(chapter) + if cover_rec and (cover_rec.status or "").strip() == "completed": + return False + return True + + +def _chapter_eligible_for_pipeline_enqueue(chapter: Chapter | None) -> bool: + """与 memoir.cover_eligibility.chapter_needs_cover_enqueue 一致。""" + if not chapter: + return False + if getattr(chapter, "cover_asset_id", None): + return False + return bool((getattr(chapter, "canonical_markdown", None) or "").strip()) + + +def _load_chapter_for_enqueue_sync(chapter_id: str) -> Chapter | None: + with get_sync_db() as db: + stmt = ( + select(Chapter) + .where(Chapter.id == chapter_id) + .options( + joinedload(Chapter.images), + ) + ) + return db.execute(stmt).unique().scalar_one_or_none() + + +def try_enqueue_generate_chapter_cover( + chapter_id: str, + source: Literal["http", "pipeline"] = "pipeline", +) -> bool: + """ + 若章节仍需要生成封面,则 SET NX 去重后派发 generate_chapter_cover。 + + Returns: + True 当且仅当成功调用 delay(通过闸门)。 + """ + chapter = _load_chapter_for_enqueue_sync(chapter_id) + if source == "http": + eligible = _chapter_eligible_for_http_enqueue(chapter) + else: + eligible = _chapter_eligible_for_pipeline_enqueue(chapter) + if not eligible: + return False + + key = _enqueue_dedup_key(chapter_id) + try: + client = redis.from_url(settings.redis_url, decode_responses=True) + if not client.set( + key, "1", nx=True, ex=CHAPTER_COVER_ENQUEUE_DEDUP_TTL_SECONDS + ): + logger.debug( + "chapter_cover enqueue skipped (dedup): chapter=%s source=%s", + chapter_id, + source, + ) + return False + except Exception as exc: + logger.warning( + "chapter_cover enqueue dedup redis failed, allowing enqueue: chapter=%s error=%s", + chapter_id, + exc, + ) + + from app.tasks.chapter_cover_tasks import generate_chapter_cover + + try: + generate_chapter_cover.delay(chapter_id) + except Exception as exc: + logger.warning( + "chapter_cover delay failed: chapter=%s error=%s", + chapter_id, + exc, + ) + try: + client = redis.from_url(settings.redis_url, decode_responses=True) + client.delete(key) + except Exception: + pass + return False + + logger.info( + "chapter_cover enqueued: chapter=%s source=%s", + chapter_id, + source, + ) + return True diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index cb64dc5..bea3b1e 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -3,10 +3,11 @@ """ import json + from app.core.logging import get_logger import uuid from io import BytesIO -from typing import Dict, List +from typing import Dict, List, Set from datetime import datetime, timezone import redis @@ -20,24 +21,17 @@ from app.features.conversation.models import Segment from app.features.memoir.models import ( Book, Chapter, - ChapterSection, MemoirImage, MemoirState, ) from app.features.user.models import User from app.core.dependencies import get_llm_provider from app.agents.state_schema import MemoirStateSchema, SlotData, default_state -from app.agents.memoir.prompts import ( - STAGE_TO_ORDER, - get_narrative_json_prompt, -) from app.agents.memoir import MemoirOrchestrator -from app.agents.memoir.narrative_agent import NarrativeAgent from app.agents.chat.prompts_profile import format_user_profile_context from app.features.memoir.memoir_images.parser import ( build_initial_image_assets, parse_image_placeholders, - parse_narrative_to_sections, ) import hashlib from app.core.dependencies import get_image_generator @@ -60,19 +54,18 @@ from app.features.memoir.memoir_images.storage import ( TencentCosStorageService, CosUploadError, ) +from app.features.memoir.cover_eligibility import ( + chapter_needs_cover_enqueue, + cover_memoir_image_pending_or_failed, +) +from app.features.memoir.story_pipeline_sync import ( + run_story_pipeline_for_category_batch, +) logger = get_logger(__name__) _REDIS_CLIENTS: dict[bool, redis.Redis] = {} -def _is_json_narrative(text: str) -> bool: - """检测 narrative 是否为 JSON 格式(paragraphs 结构)""" - if not text or not text.strip(): - return False - s = text.strip() - return s.startswith("{") and "paragraphs" in s - - def _get_llm(): """Celery 任务内获取 LangChain LLM(通过 port)""" try: @@ -206,7 +199,6 @@ def chapter_has_images_to_generate(images: list[dict] | None) -> bool: def _memoir_image_from_asset( chapter_id: str, - section_id: str | None, order_index: int, image_asset: dict, ) -> MemoirImage: @@ -215,59 +207,11 @@ def _memoir_image_from_asset( return MemoirImage( id=str(uuid.uuid4()).replace("-", "")[:32], chapter_id=chapter_id, - section_id=section_id, order_index=order_index, **kwargs, ) -def _section_has_image_to_generate(section) -> bool: - """章节段落是否有待生成的配图(从 image_record / image_id 关联的 memoir_images 读取)。""" - r = getattr(section, "image_record", None) - if not r: - return False - status = (getattr(r, "status") or "").strip() - return status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED) - - -def _chapter_has_any_section_images_to_generate(chapter) -> bool: - if not chapter or not getattr(chapter, "sections", None): - return False - return any(_section_has_image_to_generate(s) for s in chapter.sections) - - -def _chapter_has_cover_to_generate(chapter) -> bool: - """章节是否有待生成的封面图(MemoirImage section_id=None 且 status 为 pending/failed)。""" - images = getattr(chapter, "images", None) or [] - for m in images: - if getattr(m, "section_id", None) is None: - status = (getattr(m, "status") or "").strip() - return status in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED) - return False - - -def _chapter_needs_cover_enqueue(chapter) -> bool: - """尚无 cover_asset 且章节有正文时,可派发 generate_chapter_cover。""" - if not chapter: - return False - if getattr(chapter, "cover_asset_id", None): - return False - md = (getattr(chapter, "canonical_markdown", None) or "").strip() - if md: - return True - sections = getattr(chapter, "sections", None) or [] - return any((getattr(s, "content", None) or "").strip() for s in sections) - - -def _get_cover_memoir_image(chapter): - """获取章节封面 MemoirImage(section_id=None),若无可生成则返回 None。""" - images = getattr(chapter, "images", None) or [] - for m in images: - if getattr(m, "section_id", None) is None: - return m - return None - - def _select_placeholders_for_effective_max( placeholders: list[dict], existing_images: list[dict] | None, @@ -296,126 +240,8 @@ def _select_placeholders_for_effective_max( return [{**item, "index": index} for index, item in enumerate(selected)] -def _save_narrative_to_sections( - db: Session, - chapter, - narrative: str, - title: str, - category: str, - order_index: int, - source_segments: list, - user_id: str, -): - """ - 将 narrative 拆成 chapter_sections 并写入(段落不配 MemoirImage)。 - 已有 section 不删除,仅追加新内容。章节封面由 generate_chapter_cover + cover_asset_id 闭环处理。 - chapter 可为已有章节或 None(会新建)。返回 chapter。 - """ - if chapter is None: - chapter = Chapter( - id=str(uuid.uuid4()), - user_id=user_id, - title=title, - order_index=order_index, - status="completed", - category=category, - cover_image=None, - is_new=True, - source_segments=source_segments or [], - ) - db.add(chapter) - db.flush() - - # 已有 sections 不删除,只追加新内容 - existing_sections = ( - db.execute( - select(ChapterSection) - .where(ChapterSection.chapter_id == chapter.id) - .order_by(ChapterSection.order_index) - ) - .scalars() - .all() - ) - if existing_sections: - existing_content = "\n\n".join( - (s.content or "").strip() - for s in existing_sections - if (s.content or "").strip() - ) - if existing_content and narrative.startswith(existing_content): - new_part = narrative[len(existing_content) :].lstrip() - else: - new_part = (narrative or "").strip() - if not new_part: - chapter.title = title - chapter.is_new = True - chapter.source_segments = list( - set((chapter.source_segments or []) + (source_segments or [])) - ) - from app.features.memoir.repo import ( - ensure_chapter_markdown_and_version_sync, - ) - - ensure_chapter_markdown_and_version_sync(db, chapter, narrative) - return chapter - narrative_to_parse = new_part - order_base = max(s.order_index for s in existing_sections) + 1 - else: - narrative_to_parse = (narrative or "").strip() - order_base = 0 - - segments = parse_narrative_to_sections(narrative_to_parse) - if not segments: - sec = ChapterSection( - id=str(uuid.uuid4()), - chapter_id=chapter.id, - order_index=order_base, - content=(narrative_to_parse or "").strip() or "", - image_id=None, - ) - db.add(sec) - db.flush() - chapter.title = title - chapter.is_new = True - chapter.source_segments = list( - set((chapter.source_segments or []) + (source_segments or [])) - ) - from app.features.memoir.repo import ensure_chapter_markdown_and_version_sync - - ensure_chapter_markdown_and_version_sync(db, chapter, narrative) - return chapter - - # 段落不再绑定配图(每故事/章节结构化出图);仅章节封面走 MemoirImage - for i, seg in enumerate(segments): - order_idx = order_base + i - content = (seg.get("content") or "").strip() - sec = ChapterSection( - id=str(uuid.uuid4()), - chapter_id=chapter.id, - order_index=order_idx, - content=content, - image_id=None, - ) - db.add(sec) - db.flush() - db.flush() - - chapter.title = title - chapter.is_new = True - chapter.source_segments = list( - set((chapter.source_segments or []) + (source_segments or [])) - ) - # 确保 canonical_markdown 与版本链(agent 产出由 repo 落库) - from app.features.memoir.repo import ensure_chapter_markdown_and_version_sync - - ensure_chapter_markdown_and_version_sync(db, chapter, narrative) - return chapter - - def initialize_chapter_images(_chapter): - """ - 兼容旧调用:若章节已改为 sections 存储,则图片初始化已在 _save_narrative_to_sections 中完成,直接返回。 - """ + """兼容旧调用:封面由 generate_chapter_cover 处理。""" logger.info("initialize_chapter_images: 封面由 generate_chapter_cover 处理,跳过") return [] @@ -563,12 +389,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): occupation=user_obj.occupation, ) - narrative_agent = NarrativeAgent() - chapter_composer = __import__( - "app.agents.memoir.chapter_composer_orchestrator", - fromlist=["ChapterComposerOrchestrator"], - ).ChapterComposerOrchestrator() - from app.features.memory.repo import retrieve_evidence_sync + story_dispatch_ids: Set[str] = set() def _process_category( chapter_category: str, @@ -578,133 +399,24 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): birth_year, llm, ): - """单章节处理:ChapterComposerOrchestrator 生成 markdown(或 NarrativeAgent 回退),repo 落库""" - segment_texts = [seg.transcript_text or "" for seg in category_segments] - combined_text = "\n\n".join(segment_texts) - source_ids = [seg.id for seg in category_segments] - - # 证据检索(writing RAG) - try: - evidence = retrieve_evidence_sync( - db, user_id, combined_text, top_k=10 - ) - except Exception as e: - logger.warning("Evidence 检索跳过: %s", e) - evidence = { - "relevant_chunks": [], - "relevant_summaries": [], - "relevant_facts": [], - "timeline_hints": [], - "relevant_stories": [], - } - - stmt_chapter = ( - select(Chapter) - .where( - Chapter.user_id == user_id, - Chapter.category == chapter_category, - Chapter.is_active == True, - ) - .options( - joinedload(Chapter.sections).joinedload( - ChapterSection.image_record - ), - joinedload(Chapter.images), - ) - ) - result_chapter = db.execute(stmt_chapter) - chapter = result_chapter.unique().scalar_one_or_none() - - slot_snippets = {} - stage_slots = state.slots.get(chapter_category, {}) or {} - for key, value in stage_slots.items(): - snip = getattr(value, "snippet", None) or ( - value.get("snippet") if isinstance(value, dict) else None - ) - if snip: - slot_snippets[key] = snip - - title = chapter.title if chapter else f"{chapter_category} 回忆" - existing_markdown = "" - if chapter: - existing_markdown = ( - getattr(chapter, "canonical_markdown", None) or "" - ) - if not existing_markdown and getattr(chapter, "sections", None): - existing_markdown = "\n\n".join( - s.content - for s in sorted( - chapter.sections, key=lambda x: x.order_index - ) - if (s.content or "").strip() - ) - - if not chapter: - title = narrative_agent.generate_title( - stage=chapter_category, - emotion="neutral", - slots=slot_snippets, - user_profile=profile, - birth_year=birth_year, - llm=llm, - ) - # ChapterComposerOrchestrator 产出 markdown(agent 不落库) - narrative = chapter_composer.compose_chapter_markdown( - title=title, - category=chapter_category, - evidence=evidence, - existing_markdown=existing_markdown, + """stories-first:路由 + 写 story,物化 chapter。""" + nonlocal story_dispatch_ids + chapter, needs_cover, disp = run_story_pipeline_for_category_batch( + db, + user_id=user_id, + chapter_category=chapter_category, + category_segments=category_segments, + state=state, user_profile=profile, - birth_year=birth_year, + user_birth_year=birth_year, llm=llm, ) - if not narrative or not narrative.strip(): - new_narrative = narrative_agent.generate_narrative( - stage=chapter_category, - slots=slot_snippets, - new_content=combined_text, - existing_content=existing_markdown, - user_profile=profile, - birth_year=birth_year, - llm=llm, - ) - if _is_json_narrative(new_narrative): - narrative = new_narrative - elif existing_markdown: - narrative = f"{existing_markdown}\n\n{new_narrative}" - else: - narrative = new_narrative - - if ( - existing_markdown - and not _is_json_narrative(narrative) - and len(narrative) < len(existing_markdown) * 0.8 - ): - logger.warning( - "内容长度异常: existing=%d, new=%d, category=%s. 回退为追加模式", - len(existing_markdown), - len(narrative), - chapter_category, - ) - narrative = f"{existing_markdown}\n\n{combined_text}" - - calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999) - - chapter = _save_narrative_to_sections( - db, - chapter, - narrative, - title=title, - category=chapter_category, - order_index=calculated_order_index, - source_segments=source_ids, - user_id=user_id, - ) + story_dispatch_ids |= disp db.flush() db.refresh(chapter) needs_cover_enqueue = ( - image_settings.enabled and _chapter_needs_cover_enqueue(chapter) + image_settings.enabled and chapter_needs_cover_enqueue(chapter) ) stmt_book = ( @@ -754,16 +466,26 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): db.commit() - from app.tasks.chapter_cover_tasks import generate_chapter_cover + from app.tasks.chapter_compose_tasks import recompose_chapters_for_story + from app.tasks.story_image_tasks import generate_story_image + + for sid in story_dispatch_ids: + try: + generate_story_image.delay(sid) + except Exception as exc: + logger.warning("generate_story_image delay: %s", exc) + try: + recompose_chapters_for_story.delay(sid) + except Exception as exc: + logger.warning("recompose_chapters_for_story delay: %s", exc) + + from app.tasks.chapter_cover_enqueue import ( + try_enqueue_generate_chapter_cover, + ) for chapter_id in sorted(chapters_to_enqueue): - try: + if try_enqueue_generate_chapter_cover(chapter_id, source="pipeline"): logger.info(f"派发章节封面任务: chapter={chapter_id}") - generate_chapter_cover.delay(chapter_id) - except Exception as exc: - logger.warning( - f"章节封面任务派发失败: chapter={chapter_id}, error={exc}" - ) logger.info(f"回忆录处理完成: user_id={user_id}, task_id={task_id}") @@ -799,93 +521,61 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): try: with get_sync_db() as db: llm = _get_llm() - - # 查找 active 章节并预加载 sections - stmt = ( - select(Chapter) - .where( - Chapter.user_id == user_id, - Chapter.category == stage, - Chapter.is_active == True, - ) - .options(joinedload(Chapter.sections)) - ) - result = db.execute(stmt) - chapter = result.unique().scalar_one_or_none() - existing_content = "" - if chapter and getattr(chapter, "sections", None): - existing_content = "\n\n".join( - s.content - for s in sorted(chapter.sections, key=lambda x: x.order_index) - if (s.content or "").strip() + user_obj = db.get(User, user_id) + user_profile = "" + user_birth_year = None + if user_obj: + user_birth_year = user_obj.birth_year + user_profile = format_user_profile_context( + birth_year=user_obj.birth_year, + birth_place=user_obj.birth_place, + grew_up_place=user_obj.grew_up_place, + occupation=user_obj.occupation, ) - if llm: - prompt = get_narrative_json_prompt( - stage=stage, - slots={}, - new_content=new_content, - existing_content=existing_content, - ) - json_llm = llm.bind( - model_kwargs={"response_format": {"type": "json_object"}}, - max_tokens=4096, - ) - response = json_llm.invoke(prompt) - new_narrative = response.content.strip() - if _is_json_narrative(new_narrative): - narrative = new_narrative - elif existing_content: - narrative = f"{existing_content}\n\n{new_narrative}" - else: - narrative = new_narrative - else: - narrative = ( - f"{existing_content}\n\n{new_content}" - if existing_content - else new_content - ) + class _Seg: + def __init__(self, text: str): + self.id = str(uuid.uuid4()) + self.transcript_text = text - # 安全检查:新内容不应比旧内容短(仅非 JSON 格式) - if ( - existing_content - and not _is_json_narrative(narrative) - and len(narrative) < len(existing_content) * 0.8 - ): - logger.warning( - f"内容长度异常: existing={len(existing_content)}, " - f"new={len(narrative)}, stage={stage}. 回退为追加模式" - ) - narrative = f"{existing_content}\n\n{new_content}" - - calculated_order_index = STAGE_TO_ORDER.get(stage, 999) - title = chapter.title if chapter else f"{stage} 回忆" - chapter = _save_narrative_to_sections( + state = _get_or_create_state_sync(user_id, db) + chapter, _, dispatch_ids = run_story_pipeline_for_category_batch( db, - chapter, - narrative, - title=title, - category=stage, - order_index=calculated_order_index, - source_segments=[], user_id=user_id, + chapter_category=stage, + category_segments=[_Seg(new_content)], + state=state, + user_profile=user_profile, + user_birth_year=user_birth_year, + llm=llm, ) db.commit() db.refresh(chapter) + + from app.tasks.chapter_compose_tasks import recompose_chapters_for_story + from app.tasks.story_image_tasks import generate_story_image + + for sid in dispatch_ids: + try: + generate_story_image.delay(sid) + except Exception as exc: + logger.warning("generate_story_image delay: %s", exc) + try: + recompose_chapters_for_story.delay(sid) + except Exception as exc: + logger.warning("recompose_chapters_for_story delay: %s", exc) + image_settings = MemoirImageSettings.from_env() if ( image_settings.enabled and chapter - and _chapter_needs_cover_enqueue(chapter) + and chapter_needs_cover_enqueue(chapter) ): - from app.tasks.chapter_cover_tasks import generate_chapter_cover + from app.tasks.chapter_cover_enqueue import ( + try_enqueue_generate_chapter_cover, + ) - try: - generate_chapter_cover.delay(chapter.id) - except Exception as exc: - logger.warning( - "章节封面任务派发失败: chapter=%s, error=%s", chapter.id, exc - ) + try_enqueue_generate_chapter_cover(chapter.id, source="pipeline") return {"status": "success"} except Exception as e: @@ -901,7 +591,7 @@ def build_cos_key(user_id: str, chapter_id: str, index: int | str, prompt: str) @shared_task(bind=True, max_retries=3, default_retry_delay=30) def generate_chapter_images(self, chapter_id: str): - """异步补图:处理封面 MemoirImage 与历史遗留的段落配图(pending/failed)。""" + """异步补图:仅处理章节级 MemoirImage(pending/failed)。正文配图走 story_image_tasks。""" lock_acquired = False provider = None with get_sync_db() as db: @@ -909,34 +599,16 @@ def generate_chapter_images(self, chapter_id: str): stmt = ( select(Chapter) .where(Chapter.id == chapter_id) - .options( - joinedload(Chapter.sections).joinedload( - ChapterSection.image_record - ), - joinedload(Chapter.images), - ) + .options(joinedload(Chapter.images)) ) chapter = db.execute(stmt).unique().scalar_one_or_none() if not chapter: logger.info("章节补图跳过: chapter=%s, reason=not_found", chapter_id) return {"status": "no_chapter"} - sections = getattr(chapter, "sections", None) or [] - sections_with_pending = [ - (idx, s) - for idx, s in enumerate(sections) - if _section_has_image_to_generate(s) - ] - cover_rec = _get_cover_memoir_image(chapter) - cover_to_generate = ( - cover_rec - if cover_rec - and (getattr(cover_rec, "status") or "").strip() - in (IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED) - else None - ) - if not sections_with_pending and not cover_to_generate: + cover_to_generate = cover_memoir_image_pending_or_failed(chapter) + if not cover_to_generate: logger.info( - "章节补图跳过: chapter=%s, reason=no_pending_images", chapter_id + "章节补图跳过: chapter=%s, reason=no_pending_cover", chapter_id ) return {"status": "no_images"} @@ -954,9 +626,8 @@ def generate_chapter_images(self, chapter_id: str): image_generator = get_image_generator() storage = TencentCosStorageService.from_env() logger.info( - "章节补图开始: chapter=%s, pending_sections=%d, cover=%s", + "章节封面补图开始: chapter=%s, cover=%s", chapter_id, - len(sections_with_pending), bool(cover_to_generate), ) retryable_failures: list[str] = [] @@ -976,7 +647,7 @@ def generate_chapter_images(self, chapter_id: str): rec.retryable = d.get("retryable") rec.updated_at = datetime.now(timezone.utc) - # 先处理封面图 + # 封面图(正文来自 canonical_markdown) if cover_to_generate: current_item = memoir_image_to_dict(cover_to_generate) or {} current_item.setdefault("placeholder", "") @@ -986,15 +657,10 @@ def generate_chapter_images(self, chapter_id: str): _apply_item_to_memoir_image(cover_to_generate, current_item) db.commit() try: - sections_ordered = sorted( - sections, key=lambda s: getattr(s, "order_index", 0) - ) - first_content = ( - (sections_ordered[0].content or "").strip() - if sections_ordered - else "" - ) - context_excerpt = " ".join(first_content.split("\n")[:5])[:200] + raw_md = ( + getattr(chapter, "canonical_markdown", None) or "" + ).strip() + context_excerpt = " ".join(raw_md.split("\n")[:5])[:200] prompt_data = prompt_orchestrator.build_cover_prompt( chapter_title=chapter.title, chapter_category=chapter.category or "", @@ -1059,91 +725,6 @@ def generate_chapter_images(self, chapter_id: str): _apply_item_to_memoir_image(cover_to_generate, current_item) db.commit() - for sec_index, section in sections_with_pending: - item = ( - memoir_image_to_dict(section.image_record) - if section.image_record - else {} - ) - current_item = dict(item) if item else {} - current_item.setdefault("placeholder", "") - current_item.setdefault("description", "") - current_item["status"] = IMAGE_STATUS_PROCESSING - current_item["updated_at"] = datetime.now(timezone.utc).isoformat() - _apply_item_to_memoir_image(section.image_record, current_item) - db.commit() - - try: - context_lines = (section.content or "").strip().split("\n")[:5] - context_excerpt = " ".join(context_lines)[:200] - prompt_data = prompt_orchestrator.build_prompt( - chapter_title=chapter.title, - chapter_category=chapter.category or "", - description=current_item.get("description", ""), - context_excerpt=context_excerpt, - ) - result = image_generator.generate( - prompt_data["prompt"], - prompt_data["size"], - prompt_data["style"], - ) - if result.status != TaskStatus.COMPLETED or not result.image_url: - raise RuntimeError(result.error or "Image generation failed") - image_bytes = _normalize_image_bytes_for_storage( - image_generator.download_image(result.image_url) - ) - key = build_cos_key( - chapter.user_id, chapter.id, sec_index, prompt_data["prompt"] - ) - current_item["storage_key"] = key - current_item["url"] = storage.upload_bytes( - image_bytes, key, "image/png" - ) - current_item["prompt"] = prompt_data["prompt"] - current_item["style"] = prompt_data["style"] - current_item["size"] = prompt_data["size"] - current_item["status"] = IMAGE_STATUS_COMPLETED - current_item["error"] = None - current_item["retryable"] = None - current_item["updated_at"] = datetime.now(timezone.utc).isoformat() - _apply_item_to_memoir_image(section.image_record, current_item) - db.commit() - logger.info( - "章节补图成功: chapter=%s, section_index=%s, url=%s", - chapter_id, - sec_index, - current_item["url"], - ) - except Exception as exc: - failure_msg = f"section_index={sec_index}, error={exc}" - if isinstance(exc, CosUploadError) and not exc.retryable: - permanent_failures.append(failure_msg) - logger.error( - "图片上传不可重试,清理配图: chapter=%s, %s", - chapter_id, - failure_msg, - ) - mi = section.image_record - section.image_id = None - if mi: - db.delete(mi) - db.commit() - else: - current_item["status"] = IMAGE_STATUS_FAILED - current_item["error"] = str(exc) - current_item["retryable"] = True - retryable_failures.append(failure_msg) - logger.warning( - "图片生成失败(可重试): chapter=%s, %s", - chapter_id, - failure_msg, - ) - current_item["updated_at"] = datetime.now( - timezone.utc - ).isoformat() - _apply_item_to_memoir_image(section.image_record, current_item) - db.commit() - if retryable_failures: raise RuntimeError( f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}" diff --git a/api/app/tasks/story_image_tasks.py b/api/app/tasks/story_image_tasks.py index 5e82ec9..e3a35d6 100644 --- a/api/app/tasks/story_image_tasks.py +++ b/api/app/tasks/story_image_tasks.py @@ -15,6 +15,7 @@ from sqlalchemy import and_, func, or_, select, update from app.core.db import get_sync_db from app.core.dependencies import get_image_generator +from app.core.logging import get_logger from app.core.redis_lock import acquire_redis_lock, release_redis_lock from app.features.asset.models import Asset from app.features.memoir.memoir_images.storage import TencentCosStorageService @@ -22,14 +23,35 @@ from app.features.story.backfill import backfill_image_into_markdown from app.features.story.models import Story, StoryImageIntent, StoryVersion from app.ports.image_gen import TaskStatus -from app.core.logging import get_logger - logger = get_logger(__name__) STORY_IMAGE_LOCK_TTL_SECONDS = 1800 STORY_IMAGE_CLAIM_TTL_SECONDS = 1800 +def _enqueue_chapter_recompose_for_story(story_id: str) -> None: + """story 正文因主图回填变更后,标记关联章节 dirty 并异步物化。""" + try: + with get_sync_db() as session: + from app.features.memoir import repo as memoir_repo + + memoir_repo.mark_chapters_dirty_for_story_sync(session, story_id) + session.commit() + except Exception as exc: + logger.warning( + "mark_chapters_dirty_for_story_sync failed story=%s: %s", story_id, exc + ) + return + try: + from app.tasks.chapter_compose_tasks import recompose_chapters_for_story + + recompose_chapters_for_story.delay(story_id) + except Exception as exc: + logger.warning( + "recompose_chapters_for_story.delay failed story=%s: %s", story_id, exc + ) + + def _build_story_image_cos_key( user_id: str, story_id: str, intent_id: str, prompt: str ) -> str: @@ -269,6 +291,8 @@ def generate_story_image(self, story_id: str): db.commit() + _enqueue_chapter_recompose_for_story(story_id) + logger.info( "generate_story_image: story=%s, asset=%s, url=%s", story_id, @@ -292,6 +316,6 @@ def generate_story_image(self, story_id: str): intent_db.updated_at = datetime.now(timezone.utc) db.commit() logger.warning("generate_story_image failed: story=%s, error=%s", story_id, exc) - raise self.retry(exc=exc) + raise self.retry(exc=exc) from exc finally: release_redis_lock(lock_handle) diff --git a/api/development.sh b/api/development.sh index e19bd6b..624b788 100755 --- a/api/development.sh +++ b/api/development.sh @@ -144,6 +144,25 @@ start_infra() { print_ok "基础设施已就绪" } +# Docker 刚启动时 Postgres 可能尚未接受连接,立即跑 Alembic 会误报失败 +wait_postgres_ready() { + local retries=30 + local i=0 + print_header "等待 PostgreSQL 就绪" + cd "${ROOT_DIR}" + while (( i < retries )); do + if docker compose -f docker-compose.dev.yml exec -T postgres \ + pg_isready -U postgres >/dev/null 2>&1; then + print_ok "PostgreSQL 已就绪" + return 0 + fi + sleep 1 + i=$((i + 1)) + done + print_warn "PostgreSQL 在 ${retries}s 内未就绪,迁移可能失败" + return 1 +} + ensure_venv() { print_header "检查 Python 虚拟环境" @@ -185,6 +204,14 @@ start_services() { print_header "启动 FastAPI 和 Celery" cd "${ROOT_DIR}" + if command -v lsof >/dev/null 2>&1; then + if lsof -nP -iTCP:"${API_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then + print_err "端口 ${API_PORT} 已被占用,无法启动新的 Uvicorn。" + print_err "请先结束占用进程,例如: lsof -nP -iTCP:${API_PORT} -sTCP:LISTEN" + exit 1 + fi + fi + "${UVICORN_BIN}" main:app --reload --host "${API_HOST}" --port "${API_PORT}" & API_PID=$! print_ok "FastAPI 已启动 (PID: ${API_PID})" @@ -209,6 +236,7 @@ main() { trap cleanup EXIT INT TERM start_infra + wait_postgres_ready || true ensure_venv check_env_file run_migrations diff --git a/api/docker-compose.dev.yml b/api/docker-compose.dev.yml index 2718a82..66830b4 100644 --- a/api/docker-compose.dev.yml +++ b/api/docker-compose.dev.yml @@ -1,5 +1,3 @@ -version: '3.8' - # 开发环境 Docker Compose # 使用方法: docker-compose -f docker-compose.dev.yml up -d diff --git a/api/pyproject.toml b/api/pyproject.toml index 5f7b567..a1a10f3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -53,6 +53,7 @@ ignore = ["E501", "B008", "E712"] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] "main.py" = ["E402", "I001"] +"app/tasks/celery_app.py" = ["E402"] [tool.ruff.format] quote-style = "double" diff --git a/api/tests/conftest.py b/api/tests/conftest.py index b41bd8b..b573716 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -8,6 +8,7 @@ if str(_api_dir) not in sys.path: sys.path.insert(0, str(_api_dir)) # 聚合导入所有 feature models,使 SQLAlchemy 能解析 User -> Order 等字符串 relationship +from app.features.asset import models as _asset_models # noqa: F401 from app.features.auth import models as _auth_models # noqa: F401 from app.features.conversation import models as _conv_models # noqa: F401 from app.features.memory import models as _memory_models # noqa: F401 diff --git a/api/tests/test_asset_resolver.py b/api/tests/test_asset_resolver.py index 7da80fb..e1d6360 100644 --- a/api/tests/test_asset_resolver.py +++ b/api/tests/test_asset_resolver.py @@ -1,6 +1,7 @@ """asset_resolver:旧占位符清理与 asset:// 解析。""" import unittest +from types import SimpleNamespace from app.features.memoir.asset_resolver import ( collect_asset_ids_for_chapter, @@ -9,7 +10,7 @@ from app.features.memoir.asset_resolver import ( split_markdown_by_asset_refs, strip_legacy_image_placeholders, ) -from app.features.memoir.models import Chapter, ChapterSection +from app.features.memoir.models import Chapter class AssetResolverTest(unittest.TestCase): @@ -49,16 +50,24 @@ class AssetResolverTest(unittest.TestCase): canonical_markdown="![x](asset://a1)", cover_asset_id="cov1", ) - ch.sections = [ - ChapterSection( - id="s1", - chapter_id="c1", - order_index=0, - content="![y](asset://a2)", - ) - ] ids = collect_asset_ids_for_chapter(ch) - self.assertEqual(ids, {"a1", "a2", "cov1"}) + self.assertEqual(ids, {"a1", "cov1"}) + + def test_collect_asset_ids_includes_linked_story_markdown(self): + ch = SimpleNamespace( + canonical_markdown="", + sections=[], + cover_asset_id=None, + story_links=[ + SimpleNamespace( + story=SimpleNamespace( + canonical_markdown="![主图](asset://from-story-1)" + ) + ) + ], + ) + ids = collect_asset_ids_for_chapter(ch) + self.assertEqual(ids, {"from-story-1"}) if __name__ == "__main__": diff --git a/api/tests/test_chapter_cover_enqueue.py b/api/tests/test_chapter_cover_enqueue.py new file mode 100644 index 0000000..cb3b148 --- /dev/null +++ b/api/tests/test_chapter_cover_enqueue.py @@ -0,0 +1,98 @@ +"""Tests for chapter cover Celery enqueue deduplication gate.""" + +from unittest.mock import MagicMock, patch + +from app.tasks.chapter_cover_enqueue import ( + _enqueue_dedup_key, + try_enqueue_generate_chapter_cover, +) + + +def _eligible_pipeline_chapter(): + ch = MagicMock() + ch.cover_asset_id = None + ch.canonical_markdown = "some body" + ch.images = [] + return ch + + +@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") +@patch("app.tasks.chapter_cover_enqueue.redis.from_url") +@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") +def test_try_enqueue_false_when_chapter_missing(mock_load, mock_redis, mock_gen_task): + mock_load.return_value = None + assert try_enqueue_generate_chapter_cover("missing-id", "pipeline") is False + mock_gen_task.delay.assert_not_called() + mock_redis.assert_not_called() + + +@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") +@patch("app.tasks.chapter_cover_enqueue.redis.from_url") +@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") +def test_try_enqueue_false_when_cover_asset_exists( + mock_load, mock_redis, mock_gen_task +): + ch = _eligible_pipeline_chapter() + ch.cover_asset_id = "asset-1" + mock_load.return_value = ch + assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False + mock_gen_task.delay.assert_not_called() + mock_redis.assert_not_called() + + +@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") +@patch("app.tasks.chapter_cover_enqueue.redis.from_url") +@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") +def test_try_enqueue_false_when_redis_nx_not_acquired( + mock_load, mock_redis, mock_gen_task +): + mock_load.return_value = _eligible_pipeline_chapter() + r = MagicMock() + r.set.return_value = False + mock_redis.return_value = r + assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False + mock_gen_task.delay.assert_not_called() + r.set.assert_called_once() + + +@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") +@patch("app.tasks.chapter_cover_enqueue.redis.from_url") +@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") +def test_try_enqueue_true_when_eligible_and_nx_ok(mock_load, mock_redis, mock_gen_task): + mock_load.return_value = _eligible_pipeline_chapter() + r = MagicMock() + r.set.return_value = True + mock_redis.return_value = r + mock_gen_task.delay = MagicMock() + assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is True + mock_gen_task.delay.assert_called_once_with("ch-1") + r.set.assert_called_once() + + +@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") +@patch("app.tasks.chapter_cover_enqueue.redis.from_url") +@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") +def test_try_enqueue_deletes_dedup_key_when_delay_fails( + mock_load, mock_redis, mock_gen_task +): + mock_load.return_value = _eligible_pipeline_chapter() + r = MagicMock() + r.set.return_value = True + mock_redis.return_value = r + mock_gen_task.delay = MagicMock(side_effect=RuntimeError("broker down")) + assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False + mock_gen_task.delay.assert_called_once_with("ch-1") + r.delete.assert_called_once_with(_enqueue_dedup_key("ch-1")) + + +@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") +@patch("app.tasks.chapter_cover_enqueue.redis.from_url") +@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") +def test_try_enqueue_http_skips_empty_category(mock_load, mock_redis, mock_gen_task): + ch = _eligible_pipeline_chapter() + ch.category = None + ch.status = "completed" + mock_load.return_value = ch + assert try_enqueue_generate_chapter_cover("ch-1", "http") is False + mock_gen_task.delay.assert_not_called() + mock_redis.assert_not_called() diff --git a/api/tests/test_chapter_markdown_compose.py b/api/tests/test_chapter_markdown_compose.py new file mode 100644 index 0000000..6f4bacb --- /dev/null +++ b/api/tests/test_chapter_markdown_compose.py @@ -0,0 +1,58 @@ +import unittest + +from app.features.memoir.chapter_markdown_compose import ( + compose_ordered_stories_to_markdown, + materialize_chapter_markdown_from_loaded_chapter, +) + + +class ChapterMarkdownComposeTest(unittest.TestCase): + def test_orders_and_separates_with_headings(self): + md = compose_ordered_stories_to_markdown( + [("第一章", "正文A"), ("第二章", "正文B")] + ) + self.assertIn("## 第一章", md) + self.assertIn("正文A", md) + self.assertIn("## 第二章", md) + self.assertIn("正文B", md) + self.assertIn("\n\n---\n\n", md) + self.assertTrue(md.index("正文A") < md.index("---")) + self.assertTrue(md.index("---") < md.index("第二章")) + self.assertTrue(md.index("第一章") < md.index("第二章")) + + def test_preserves_asset_refs(self): + body = "![x](asset://abc-123)" + md = compose_ordered_stories_to_markdown([("S", body)]) + self.assertIn("asset://abc-123", md) + + def test_empty_title_uses_fallback(self): + md = compose_ordered_stories_to_markdown([("", "仅正文")]) + self.assertIn("## 故事", md) + + def test_empty_body_keeps_heading_only(self): + md = compose_ordered_stories_to_markdown([("仅标题", "")]) + self.assertEqual(md, "## 仅标题") + + def test_materialize_respects_order_index(self): + class _S: + def __init__(self, title: str, body: str): + self.title = title + self.canonical_markdown = body + + class _L: + def __init__(self, o: int, story: _S): + self.order_index = o + self.story = story + + ch = type( + "Ch", + (), + { + "story_links": [ + _L(1, _S("B", "后")), + _L(0, _S("A", "先")), + ] + }, + )() + md = materialize_chapter_markdown_from_loaded_chapter(ch) + self.assertLess(md.index("先"), md.index("后")) diff --git a/api/tests/test_chapter_story_compose_flow.py b/api/tests/test_chapter_story_compose_flow.py new file mode 100644 index 0000000..631948f --- /dev/null +++ b/api/tests/test_chapter_story_compose_flow.py @@ -0,0 +1,59 @@ +"""Story 变更触发章节物化:调用链与 Celery 派发的行为验证(无真实 DB)。""" + +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from app.features.story.service import StoryService + + +class ChapterStoryComposeFlowTest(unittest.TestCase): + def test_append_version_marks_dirty_and_delays_recompose(self): + async def run(): + db = MagicMock() + db.flush = AsyncMock() + db.commit = AsyncMock() + db.add = MagicMock() + story = MagicMock() + story.id = "story-1" + story.current_version_id = None + story.title = "T" + + version = MagicMock() + version.id = "ver-new" + + with ( + patch( + "app.features.story.service.get_story_by_id", + new_callable=AsyncMock, + return_value=story, + ), + patch( + "app.features.story.service.count_story_versions", + new_callable=AsyncMock, + return_value=0, + ), + patch( + "app.features.story.service.create_story_version", + new_callable=AsyncMock, + return_value=version, + ), + patch( + "app.features.story.service._extract_and_store_image_intent", + new_callable=AsyncMock, + ), + patch( + "app.features.memoir.repo.mark_chapters_dirty_for_story", + new_callable=AsyncMock, + ) as m_mark, + patch( + "app.tasks.chapter_compose_tasks.recompose_chapters_for_story" + ) as m_task, + ): + m_task.delay = MagicMock() + svc = StoryService(db=db) + await svc.append_version("story-1", "# 新正文") + m_mark.assert_awaited_once_with(db, "story-1") + m_task.delay.assert_called_once_with("story-1") + + asyncio.run(run()) diff --git a/api/tests/test_chapters_router_images.py b/api/tests/test_chapters_router_images.py index a58c00d..0bb0c84 100644 --- a/api/tests/test_chapters_router_images.py +++ b/api/tests/test_chapters_router_images.py @@ -23,16 +23,14 @@ def _image_stub(**kwargs): "retryable": None, "created_at": None, "updated_at": None, - "section_id": None, } defaults.update(kwargs) return type("ImageStub", (), defaults)() -def _chapter_stub(*, images=None, sections=None): - """构造带 images/sections 的 chapter stub。images 为 image_stub 列表(section_id=None 的作封面),sections 为 section 列表(含 image_record)。""" +def _chapter_stub(*, images=None, canonical_markdown="正文"): + """stories-first:章节配图均为 chapter 级 MemoirImage(按 order_index 取封面)。""" images = images or [] - sections = sections or [] return type( "ChapterStub", (), @@ -43,9 +41,10 @@ def _chapter_stub(*, images=None, sections=None): "order_index": 0, "status": "completed", "category": "childhood", + "canonical_markdown": canonical_markdown, "images": images, - "sections": sections, "cover_image": None, + "cover_asset_id": None, "updated_at": None, "is_new": False, "source_segments": [], @@ -54,7 +53,7 @@ def _chapter_stub(*, images=None, sections=None): class ChaptersRouterImagesTest(unittest.TestCase): - @patch("app.features.memoir.router.TencentCosStorageService") + @patch("app.features.memoir.helpers.TencentCosStorageService") @patch.dict( os.environ, { @@ -78,26 +77,23 @@ class ChaptersRouterImagesTest(unittest.TestCase): prompt="A serene southern China town", url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", storage_key="memoirs/u1/c1/0-demo.png", - section_id=None, order_index=0, ) - sec = type( - "SectionStub", - (), - {"content": "", "order_index": 0, "image_record": img0, "image_id": None}, - )() - chapter = _chapter_stub(images=[img0], sections=[sec]) + chapter = _chapter_stub(images=[img0]) payload = _chapter_to_dict(chapter) + self.assertEqual(payload["images"], []) self.assertEqual( - payload["images"][0]["url"], + payload["cover_image"]["url"], "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123", ) - self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town") - self.assertNotIn("storage_key", payload["images"][0]) + self.assertEqual( + payload["cover_image"]["prompt"], "A serene southern China town" + ) + self.assertNotIn("storage_key", payload["cover_image"]) - @patch("app.features.memoir.router.TencentCosStorageService") + @patch("app.features.memoir.helpers.TencentCosStorageService") @patch.dict( os.environ, { @@ -123,39 +119,35 @@ class ChaptersRouterImagesTest(unittest.TestCase): prompt="A serene southern China town", url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", storage_key="memoirs/u1/c1/0-demo.png", - section_id=None, order_index=0, ) - sec = type( - "SectionStub", - (), - {"content": "", "order_index": 0, "image_record": img0, "image_id": None}, - )() - chapter = _chapter_stub(images=[img0], sections=[sec]) + chapter = _chapter_stub(images=[img0]) payload = _chapter_to_dict(chapter) - self.assertEqual(payload["images"][0]["status"], "completed") - self.assertIsNone(payload["images"][0]["url"]) - self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town") - self.assertEqual(payload["images"][0]["error"], "image delivery unavailable") - self.assertNotIn("storage_key", payload["images"][0]) - - @patch("app.features.memoir.router.TencentCosStorageService") - def test_chapter_to_dict_drops_malformed_image_assets(self, storage_cls): - storage_cls.from_settings.return_value = Mock() - # 无 sections 时 content/images 来自 _sections_to_content_and_images 得到 [];无有效封面(images 的 section_id 非空) - img = _image_stub( - status="completed", placeholder="", description="", section_id="sec1" + self.assertEqual(payload["images"], []) + self.assertEqual(payload["cover_image"]["status"], "completed") + self.assertIsNone(payload["cover_image"]["url"]) + self.assertEqual( + payload["cover_image"]["prompt"], "A serene southern China town" ) - chapter = _chapter_stub(images=[img], sections=[]) + self.assertEqual(payload["cover_image"]["error"], "image delivery unavailable") + self.assertNotIn("storage_key", payload["cover_image"]) + + @patch("app.features.memoir.helpers.TencentCosStorageService") + def test_chapter_to_dict_inline_images_list_empty(self, storage_cls): + storage_cls.from_settings.return_value = Mock() + img = _image_stub( + status="completed", placeholder="", description="", order_index=0 + ) + chapter = _chapter_stub(images=[img]) payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"], []) - @patch("app.features.memoir.router.MemoirImageSettings") - @patch("app.features.memoir.router.TencentCosStorageService") + @patch("app.features.memoir.helpers.MemoirImageSettings") + @patch("app.features.memoir.helpers.TencentCosStorageService") def test_chapter_to_dict_hides_non_completed_assets_when_feature_disabled( self, storage_cls, memoir_img_settings_cls ): @@ -166,7 +158,6 @@ class ChaptersRouterImagesTest(unittest.TestCase): storage_cls.from_settings.return_value = storage memoir_img_settings_cls.from_settings.return_value = Mock(enabled=False) - # 仅一个 completed 的 section;enabled=False 时 completed_image_assets 只保留 completed,故仍为 1 条 img_completed = _image_stub( placeholder="{{IMAGE:奶奶坐在院子里的藤椅上}}", description="奶奶坐在院子里的藤椅上", @@ -174,26 +165,15 @@ class ChaptersRouterImagesTest(unittest.TestCase): url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/1-demo.png", storage_key="memoirs/u1/c1/1-demo.png", order_index=0, - section_id="s1", ) - sec = type( - "SectionStub", - (), - { - "content": "", - "order_index": 0, - "image_record": img_completed, - "image_id": None, - }, - )() - chapter = _chapter_stub(images=[img_completed], sections=[sec]) + chapter = _chapter_stub(images=[img_completed]) payload = _chapter_to_dict(chapter) - self.assertEqual(len(payload["images"]), 1) - self.assertEqual(payload["images"][0]["status"], "completed") + self.assertEqual(payload["images"], []) + self.assertEqual(payload["cover_image"]["status"], "completed") - @patch("app.features.memoir.router.TencentCosStorageService") + @patch("app.features.memoir.helpers.TencentCosStorageService") @patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False) def test_chapter_to_dict_preserves_retryable_flag_for_failed_assets( self, storage_cls @@ -209,14 +189,10 @@ class ChaptersRouterImagesTest(unittest.TestCase): retryable=False, order_index=0, ) - sec = type( - "SectionStub", - (), - {"content": "", "order_index": 0, "image_record": img, "image_id": None}, - )() - chapter = _chapter_stub(images=[img], sections=[sec]) + chapter = _chapter_stub(images=[img]) payload = _chapter_to_dict(chapter) - self.assertEqual(payload["images"][0]["status"], "failed") - self.assertFalse(payload["images"][0]["retryable"]) + self.assertEqual(payload["images"], []) + self.assertEqual(payload["cover_image"]["status"], "failed") + self.assertFalse(payload["cover_image"]["retryable"]) diff --git a/api/tests/test_cos_url_keys.py b/api/tests/test_cos_url_keys.py new file mode 100644 index 0000000..7e480d7 --- /dev/null +++ b/api/tests/test_cos_url_keys.py @@ -0,0 +1,32 @@ +"""cos_url_keys:仅当 host 匹配配置时才解析 key。""" + +import unittest +from unittest.mock import patch + +from app.core.cos_url_keys import extract_cos_object_key_if_owned + + +class TestExtractCosObjectKeyIfOwned(unittest.TestCase): + def test_non_http_returns_none(self): + self.assertIsNone(extract_cos_object_key_if_owned("audio-segment:x:0")) + self.assertIsNone(extract_cos_object_key_if_owned(None)) + + @patch("app.core.cos_url_keys.settings") + def test_matching_host_returns_key(self, mock_settings): + mock_settings.tencent_cos_bucket = "mybucket" + mock_settings.tencent_cos_region = "ap-shanghai" + mock_settings.tencent_cos_base_url = "" + url = "https://mybucket.cos.ap-shanghai.myqcloud.com/chapters/u1/c1/a.png" + self.assertEqual(extract_cos_object_key_if_owned(url), "chapters/u1/c1/a.png") + + @patch("app.core.cos_url_keys.settings") + def test_foreign_host_returns_none(self, mock_settings): + mock_settings.tencent_cos_bucket = "mybucket" + mock_settings.tencent_cos_region = "ap-shanghai" + mock_settings.tencent_cos_base_url = "" + url = "https://evil.com/chapters/u1/c1/a.png" + self.assertIsNone(extract_cos_object_key_if_owned(url)) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_generate_chapter_images_persistence.py b/api/tests/test_generate_chapter_images_persistence.py index 3e98163..f46a3cc 100644 --- a/api/tests/test_generate_chapter_images_persistence.py +++ b/api/tests/test_generate_chapter_images_persistence.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch from app.ports.image_gen import ImageResult, TaskStatus -from app.tasks.memoir_tasks import generate_chapter_images +from app.tasks.memoir_tasks import build_cos_key, generate_chapter_images _ONE_BY_ONE_PNG = base64.b64decode( @@ -12,9 +12,10 @@ _ONE_BY_ONE_PNG = base64.b64decode( ) -def _image_record(img_dict): +def _cover_image_record(img_dict): d = dict(img_dict or {}) return SimpleNamespace( + id="cover-img-1", order_index=d.get("index", 0), placeholder=d.get("placeholder"), description=d.get("description"), @@ -33,7 +34,7 @@ def _image_record(img_dict): def _chapter_stub(): - rec = _image_record( + rec = _cover_image_record( { "index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", @@ -42,19 +43,14 @@ def _chapter_stub(): "url": None, } ) - section = SimpleNamespace( - content="那条路我一直记得。", - image_id="image-1", - image_record=rec, - order_index=0, - ) return SimpleNamespace( id="chapter-1", user_id="user-1", title="童年的夏天", category="childhood", - sections=[section], - images=[], + canonical_markdown="# 标题\n\n那条路我一直记得。", + sections=[], + images=[rec], cover_image=None, ) @@ -76,6 +72,7 @@ class GenerateChapterImagesPersistenceTest(unittest.TestCase): get_sync_db_mock, ): chapter = _chapter_stub() + cover = chapter.images[0] db = Mock() db.execute.return_value.unique.return_value.scalar_one_or_none.return_value = ( chapter @@ -83,11 +80,13 @@ class GenerateChapterImagesPersistenceTest(unittest.TestCase): get_sync_db_mock.return_value.__enter__.return_value = db get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_prompt.return_value = { + prompt_data = { "prompt": "A serene southern China town", "style": "watercolor", "size": "1024x1024", + "prompt_context": "childhood: 童年的夏天", } + prompt_service_cls.return_value.build_cover_prompt.return_value = prompt_data mock_gen = Mock() mock_gen.generate.return_value = ImageResult( status=TaskStatus.COMPLETED, @@ -97,14 +96,18 @@ class GenerateChapterImagesPersistenceTest(unittest.TestCase): mock_gen.download_image.return_value = _ONE_BY_ONE_PNG get_image_generator_mock.return_value = mock_gen storage_cls.from_env.return_value.upload_bytes.return_value = ( - "https://cos.example.com/memoirs/user-1/chapter-1/0.png" + "https://cos.example.com/memoirs/user-1/chapter-1/cover.png" ) generate_chapter_images.run("chapter-1") - record = chapter.sections[0].image_record - self.assertEqual(record.status, "completed") + self.assertEqual(cover.status, "completed") self.assertEqual( - record.url, "https://cos.example.com/memoirs/user-1/chapter-1/0.png" + cover.url, + "https://cos.example.com/memoirs/user-1/chapter-1/cover.png", + ) + self.assertEqual(cover.prompt, "A serene southern China town") + self.assertEqual( + cover.storage_key, + build_cos_key("user-1", "chapter-1", "cover", prompt_data["prompt"]), ) - self.assertEqual(record.prompt, "A serene southern China town") diff --git a/api/tests/test_generate_chapter_images_task.py b/api/tests/test_generate_chapter_images_task.py index 15a9c17..bf1432d 100644 --- a/api/tests/test_generate_chapter_images_task.py +++ b/api/tests/test_generate_chapter_images_task.py @@ -7,7 +7,7 @@ from PIL import Image from app.ports.image_gen import ImageResult, TaskStatus from app.tasks import memoir_tasks -from app.tasks.memoir_tasks import generate_chapter_images +from app.tasks.memoir_tasks import build_cos_key, generate_chapter_images def _mock_image_generator( @@ -30,56 +30,38 @@ def _mock_image_generator( return gen -def _section_image_record(img_dict): - """把图片 dict 转成 image_record 用的 SimpleNamespace(可被任务更新属性)。""" - d = dict(img_dict or {}) - return SimpleNamespace( - order_index=d.get("index", 0), - placeholder=d.get("placeholder"), - description=d.get("description"), - status=d.get("status"), - prompt=d.get("prompt"), - url=d.get("url"), - storage_key=d.get("storage_key"), - provider=d.get("provider"), - style=d.get("style"), - size=d.get("size"), - error=d.get("error"), - retryable=d.get("retryable"), - created_at=d.get("created_at"), - updated_at=d.get("updated_at"), +def _chapter_with_cover_memoir_image( + *, + cover_status: str = "pending", + cover_url: str | None = None, + canonical_markdown: str = "# 童年\n\n那条路我一直记得。", +): + """stories-first:章节级 MemoirImage(order_index 最小为封面槽位)。""" + cover_rec = SimpleNamespace( + id="cover-img-1", + order_index=0, + placeholder="", + description="", + status=cover_status, + url=cover_url, + storage_key=None, + prompt=None, + provider=None, + style=None, + size=None, + error=None, + retryable=None, + created_at=None, + updated_at=None, ) - - -def _chapter_with_sections(sections_data): - """构造带 sections 的 chapter stub,供 generate_chapter_images 使用(任务从 section.image_record 读/写)。""" - sections = [] - for i, d in enumerate(sections_data): - img = d.get("image") - if img: - rec = _section_image_record(img) - sec = SimpleNamespace( - content=d.get("content", ""), - image_id="img-%s-%s" % (i, id(rec)), - image_record=rec, - order_index=d.get("order_index", i), - ) - else: - sec = SimpleNamespace( - content=d.get("content", ""), - image_id=None, - image_record=None, - order_index=d.get("order_index", i), - ) - sections.append(sec) return SimpleNamespace( id="chapter-1", user_id="user-1", title="童年的夏天", category="childhood", + canonical_markdown=canonical_markdown, cover_image=None, - images=[], - sections=sections, + images=[cover_rec], ) @@ -107,20 +89,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): get_sync_db_mock, redis_from_url, ): - chapter = _chapter_with_sections( - [ - { - "content": "那条路我一直记得。", - "image": { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - }, - }, - ] - ) + chapter = _chapter_with_cover_memoir_image() db = Mock() _bind_db_execute_to_chapter(db, chapter) get_sync_db_mock.return_value.__enter__.return_value = db @@ -140,7 +109,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_retries_when_any_item_generation_fails( + def test_generate_chapter_images_retries_when_cover_generation_fails( self, _acquire_lock_mock, _release_lock_mock, @@ -149,25 +118,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, get_sync_db_mock, ): - chapter = _chapter_with_sections( - [ - { - "content": "那条路我一直记得。", - "image": { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - }, - }, - ] - ) + chapter = _chapter_with_cover_memoir_image() + cover = chapter.images[0] db = Mock() _bind_db_execute_to_chapter(db, chapter) get_sync_db_mock.return_value.__enter__.return_value = db get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_prompt.return_value = { + prompt_service_cls.return_value.build_cover_prompt.return_value = { "prompt": "A serene southern China town", "style": "watercolor", "size": "1024x1024", @@ -186,10 +143,8 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): generate_chapter_images.run.__func__(task_self, "chapter-1") self.assertIs(ctx.exception, retry_error) - self.assertEqual(chapter.sections[0].image_record.status, "failed") - self.assertEqual( - chapter.sections[0].image_record.error, "transient provider error" - ) + self.assertEqual(cover.status, "failed") + self.assertEqual(cover.error, "transient provider error") task_self.retry.assert_called_once() storage_cls.from_env.return_value.upload_bytes.assert_not_called() @@ -199,7 +154,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_marks_successful_item_completed( + def test_generate_chapter_images_marks_successful_cover_completed( self, _acquire_lock_mock, _release_lock_mock, @@ -208,51 +163,39 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, get_sync_db_mock, ): - chapter = _chapter_with_sections( - [ - { - "content": "那条路我一直记得。", - "image": { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - }, - }, - ] - ) + chapter = _chapter_with_cover_memoir_image() + cover = chapter.images[0] db = Mock() _bind_db_execute_to_chapter(db, chapter) get_sync_db_mock.return_value.__enter__.return_value = db get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_prompt.return_value = { + prompt_data = { "prompt": "A serene southern China town", "style": "watercolor", "size": "1024x1024", "prompt_context": "childhood: 童年的夏天", } + prompt_service_cls.return_value.build_cover_prompt.return_value = prompt_data get_image_generator_mock.return_value = _mock_image_generator() storage_inst = storage_cls.from_env.return_value storage_inst.upload_bytes.return_value = ( - "https://cos.example.com/memoirs/u1/c1/0.png" + "https://cos.example.com/memoirs/u1/c1/cover.png" ) generate_chapter_images.run("chapter-1") - self.assertEqual(chapter.sections[0].image_record.status, "completed") - self.assertEqual( - chapter.sections[0].image_record.storage_key, - "memoirs/user-1/chapter-1/0-7e1f860790.png", + self.assertEqual(cover.status, "completed") + expected_key = build_cos_key( + "user-1", "chapter-1", "cover", prompt_data["prompt"] ) + self.assertEqual(cover.storage_key, expected_key) self.assertEqual( - chapter.sections[0].image_record.url, - "https://cos.example.com/memoirs/u1/c1/0.png", - ) - self.assertEqual( - chapter.sections[0].image_record.prompt, "A serene southern China town" + cover.url, + "https://cos.example.com/memoirs/u1/c1/cover.png", ) + self.assertEqual(cover.prompt, "A serene southern China town") get_image_generator_mock.return_value.generate.assert_called_once() + prompt_service_cls.return_value.build_cover_prompt.assert_called_once() db.commit.assert_called() @patch("app.tasks.memoir_tasks.get_sync_db") @@ -268,20 +211,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, get_sync_db_mock, ): - chapter = _chapter_with_sections( - [ - { - "content": "那条路我一直记得。", - "image": { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - }, - }, - ] - ) + chapter = _chapter_with_cover_memoir_image() settings_from_env.return_value = SimpleNamespace( enabled=False, max_per_chapter=2, @@ -320,41 +250,28 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, get_sync_db_mock, ): - chapter = _chapter_with_sections( - [ - { - "content": "那条路我一直记得。", - "image": { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - }, - }, - ] - ) - image_buffer = BytesIO() - Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG") - jpeg_bytes = image_buffer.getvalue() - + chapter = _chapter_with_cover_memoir_image() db = Mock() _bind_db_execute_to_chapter(db, chapter) get_sync_db_mock.return_value.__enter__.return_value = db get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_prompt.return_value = { + prompt_service_cls.return_value.build_cover_prompt.return_value = { "prompt": "A serene southern China town", "style": "watercolor", "size": "1024x1024", "prompt_context": "childhood: 童年的夏天", } + image_buffer = BytesIO() + Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG") + jpeg_bytes = image_buffer.getvalue() + get_image_generator_mock.return_value = _mock_image_generator( image_url="https://provider.example.com/1.jpg", image_bytes=jpeg_bytes, ) storage_inst = storage_cls.from_env.return_value storage_inst.upload_bytes.return_value = ( - "https://cos.example.com/memoirs/u1/c1/0.png" + "https://cos.example.com/memoirs/u1/c1/cover.png" ) generate_chapter_images.run("chapter-1") @@ -378,25 +295,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, get_sync_db_mock, ): - chapter = _chapter_with_sections( - [ - { - "content": "那条路我一直记得。", - "image": { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "pending", - "url": None, - }, - }, - ] - ) + chapter = _chapter_with_cover_memoir_image() + cover = chapter.images[0] db = Mock() _bind_db_execute_to_chapter(db, chapter) get_sync_db_mock.return_value.__enter__.return_value = db get_sync_db_mock.return_value.__exit__.return_value = False - prompt_service_cls.return_value.build_prompt.return_value = { + prompt_service_cls.return_value.build_cover_prompt.return_value = { "prompt": "A serene southern China town", "style": "watercolor", "size": "1024x1024", @@ -408,13 +313,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): "AccessDenied", retryable=False, request_id="req-403" ) task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock()) - img_rec = chapter.sections[0].image_record result = generate_chapter_images.run.__func__(task_self, "chapter-1") self.assertEqual(result, {"status": "success"}) - self.assertIsNone(chapter.sections[0].image_id) - db.delete.assert_called_with(img_rec) + db.delete.assert_called_with(cover) task_self.retry.assert_not_called() @patch("app.tasks.memoir_tasks.get_sync_db") @@ -423,7 +326,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): @patch("app.tasks.memoir_tasks.ImagePromptOrchestrator") @patch("app.tasks.memoir_tasks._release_chapter_image_lock") @patch("app.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) - def test_generate_chapter_images_skips_completed_items_for_idempotency( + def test_generate_chapter_images_skips_completed_cover_for_idempotency( self, _acquire_lock_mock, _release_lock_mock, @@ -432,26 +335,17 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): storage_cls, get_sync_db_mock, ): - chapter = _chapter_with_sections( - [ - { - "content": "那条路我一直记得。", - "image": { - "index": 0, - "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", - "description": "南方小镇的青石板路", - "status": "completed", - "url": "https://cos.example.com/already-there.png", - }, - }, - ] + chapter = _chapter_with_cover_memoir_image( + cover_status="completed", + cover_url="https://cos.example.com/already-there.png", ) db = Mock() _bind_db_execute_to_chapter(db, chapter) get_sync_db_mock.return_value.__enter__.return_value = db get_sync_db_mock.return_value.__exit__.return_value = False - generate_chapter_images.run("chapter-1") + result = generate_chapter_images.run("chapter-1") + self.assertEqual(result, {"status": "no_images"}) get_image_generator_mock.return_value.generate.assert_not_called() storage_cls.from_env.return_value.upload_bytes.assert_not_called() diff --git a/api/tests/test_memoir_image_schema.py b/api/tests/test_memoir_image_schema.py index 2d5bc80..69e4f9b 100644 --- a/api/tests/test_memoir_image_schema.py +++ b/api/tests/test_memoir_image_schema.py @@ -32,6 +32,31 @@ class MemoirImageSchemaTest(unittest.TestCase): self.assertIsNone(asset) + def test_normalize_image_asset_completed_without_inline_placeholder(self): + asset = normalize_image_asset( + { + "index": 0, + "placeholder": "", + "description": "章节封面", + "status": "completed", + "url": "https://cos.example.com/cover.png", + } + ) + self.assertIsNotNone(asset) + self.assertEqual(asset["placeholder"], "") + self.assertEqual(asset["description"], "章节封面") + + def test_normalize_image_asset_completed_defaults_description(self): + asset = normalize_image_asset( + { + "status": "completed", + "url": "https://cos.example.com/x.png", + } + ) + self.assertIsNotNone(asset) + self.assertEqual(asset["placeholder"], "") + self.assertEqual(asset["description"], "插图") + def test_normalize_image_asset_preserves_retryable_for_failed_assets(self): asset = normalize_image_asset( { diff --git a/api/tests/test_process_memoir_segments_image_enqueue.py b/api/tests/test_process_memoir_segments_image_enqueue.py index 1b8fd14..bd056bf 100644 --- a/api/tests/test_process_memoir_segments_image_enqueue.py +++ b/api/tests/test_process_memoir_segments_image_enqueue.py @@ -14,7 +14,19 @@ def _mock_get_sync_db(db): return _cm() # 返回 context manager 实例,供 with 使用 +def _fake_chapter_for_pipeline(): + return SimpleNamespace( + id="chapter-1", + canonical_markdown="# 标题\n\n正文若干字。", + cover_asset_id=None, + images=[], + ) + + class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): + @patch("app.tasks.chapter_compose_tasks.recompose_chapters_for_story.delay") + @patch("app.tasks.story_image_tasks.generate_story_image.delay") + @patch("app.tasks.memoir_tasks.run_story_pipeline_for_category_batch") @patch( "app.features.memory.repo.retrieve_evidence_sync", return_value={ @@ -36,14 +48,14 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): ) @patch("app.tasks.memoir_tasks._get_or_create_state_sync") @patch("app.tasks.memoir_tasks._get_llm") - @patch("app.tasks.chapter_cover_tasks.generate_chapter_cover.delay") + @patch("app.tasks.chapter_cover_enqueue.try_enqueue_generate_chapter_cover") @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.MemoirImageSettings.from_env") def test_process_memoir_segments_parses_markdown_wrapped_state_extraction_json( self, settings_from_env, get_sync_db_mock, - delay_mock, + try_enqueue_mock, get_llm_mock, get_state_mock, _classify_mock, @@ -53,6 +65,9 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): _update_status_mock, ingest_mock, retrieve_mock, + mock_pipeline, + _delay_story_image, + _delay_recompose, ): settings_from_env.return_value = MemoirImageSettings( enabled=True, @@ -70,6 +85,7 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): update_slot_mock.return_value = SimpleNamespace( current_stage="childhood", slots={} ) + mock_pipeline.return_value = (_fake_chapter_for_pipeline(), True, {"story-1"}) llm = Mock() bound_llm = Mock() bound_llm.invoke.side_effect = [ @@ -83,14 +99,10 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): } ```""" ), - SimpleNamespace( - content='{"paragraphs":[{"content":"新的章节正文","image_description":"南方小镇的青石板路"}]}' - ), ] llm.bind.return_value = bound_llm llm.invoke.side_effect = [ SimpleNamespace(content="childhood"), - SimpleNamespace(content="童年的门前"), ] get_llm_mock.return_value = llm @@ -103,26 +115,12 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): segments_result = Mock() segments_result.scalars.return_value.all.return_value = [segment] - unique_result = Mock() - unique_result.scalar_one_or_none.return_value = None - chapter_result = Mock() - chapter_result.unique.return_value = unique_result - book_result = Mock() book_result.scalar_one_or_none.return_value = None - empty_sections_result = Mock() - empty_sections_result.scalars.return_value.all.return_value = [] - - version_count_result = Mock() - version_count_result.scalar.return_value = 0 - db = Mock() db.execute.side_effect = [ segments_result, - chapter_result, - empty_sections_result, # _save_narrative_to_sections 内查询 ChapterSection - version_count_result, # ensure_chapter_markdown_and_version_sync 内 count book_result, ] db.get.return_value = None @@ -130,7 +128,9 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): events: list[str] = [] db.commit.side_effect = lambda: events.append("commit") - delay_mock.side_effect = lambda chapter_id: events.append(f"delay:{chapter_id}") + try_enqueue_mock.side_effect = lambda chapter_id, source="pipeline": ( + events.append(f"enqueue:{chapter_id}") + ) task_self = SimpleNamespace( request=SimpleNamespace(id="task-1"), @@ -146,11 +146,15 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): ["segment-1"], db, ) + mock_pipeline.assert_called_once() self.assertIn("commit", events) - delay_events = [event for event in events if event.startswith("delay:")] - self.assertEqual(len(delay_events), 1) - self.assertGreater(events.index(delay_events[0]), events.index("commit")) + enqueue_events = [event for event in events if event.startswith("enqueue:")] + self.assertEqual(len(enqueue_events), 1) + self.assertGreater(events.index(enqueue_events[0]), events.index("commit")) + @patch("app.tasks.chapter_compose_tasks.recompose_chapters_for_story.delay") + @patch("app.tasks.story_image_tasks.generate_story_image.delay") + @patch("app.tasks.memoir_tasks.run_story_pipeline_for_category_batch") @patch( "app.features.memory.repo.retrieve_evidence_sync", return_value={ @@ -171,14 +175,14 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): ) @patch("app.tasks.memoir_tasks._get_or_create_state_sync") @patch("app.tasks.memoir_tasks._get_llm", return_value=None) - @patch("app.tasks.chapter_cover_tasks.generate_chapter_cover.delay") + @patch("app.tasks.chapter_cover_enqueue.try_enqueue_generate_chapter_cover") @patch("app.tasks.memoir_tasks.get_sync_db") @patch("app.tasks.memoir_tasks.MemoirImageSettings.from_env") def test_process_memoir_segments_does_not_enqueue_image_jobs_when_feature_disabled( self, settings_from_env, get_sync_db_mock, - delay_mock, + try_enqueue_mock, _get_llm, get_state_mock, _classify_mock, @@ -187,6 +191,9 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): _update_status_mock, ingest_mock, retrieve_mock, + mock_pipeline, + _delay_story_image, + _delay_recompose, ): settings_from_env.return_value = MemoirImageSettings( enabled=False, @@ -201,6 +208,7 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): get_state_mock.return_value = SimpleNamespace( current_stage="childhood", slots={} ) + mock_pipeline.return_value = (_fake_chapter_for_pipeline(), True, set()) segment = SimpleNamespace( id="segment-1", @@ -211,26 +219,12 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): segments_result = Mock() segments_result.scalars.return_value.all.return_value = [segment] - unique_result = Mock() - unique_result.scalar_one_or_none.return_value = None - chapter_result = Mock() - chapter_result.unique.return_value = unique_result - book_result = Mock() book_result.scalar_one_or_none.return_value = None - empty_sections_result = Mock() - empty_sections_result.scalars.return_value.all.return_value = [] - - version_count_result = Mock() - version_count_result.scalar.return_value = 0 - db = Mock() db.execute.side_effect = [ segments_result, - chapter_result, - empty_sections_result, # _save_narrative_to_sections 内查询 ChapterSection - version_count_result, # ensure_chapter_markdown_and_version_sync 内 count book_result, ] db.get.return_value = None @@ -242,4 +236,4 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase): ) process_memoir_segments.run.__func__(task_self, "user-1", ["segment-1"]) - delay_mock.assert_not_called() + try_enqueue_mock.assert_not_called() diff --git a/api/tests/test_session_history.py b/api/tests/test_session_history.py new file mode 100644 index 0000000..07b2bb1 --- /dev/null +++ b/api/tests/test_session_history.py @@ -0,0 +1,44 @@ +"""session_history 纯映射测试(ConversationService 的会话层,非 Agent)。""" + +import unittest +from datetime import datetime, timezone + +from app.features.conversation.models import Segment +from app.features.conversation.session_history import segments_to_redis_history + + +class SegmentsToRedisHistoryTest(unittest.TestCase): + def test_text_turn_maps_to_human_and_ai(self): + seg = Segment( + id="s1", + conversation_id="c1", + transcript_text="我在杭州长大", + audio_url=None, + created_at=datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc), + agent_response="听起来很温润的城市。", + ) + h = segments_to_redis_history([seg]) + self.assertEqual(len(h), 2) + self.assertEqual(h[0]["role"], "human") + self.assertEqual(h[0]["messageType"], "text") + self.assertEqual(h[0]["content"], "我在杭州长大") + self.assertEqual(h[1]["role"], "ai") + self.assertEqual(h[1]["content"], "听起来很温润的城市。") + + def test_voice_segment_sets_voice_session_id(self): + seg = Segment( + id="s1", + conversation_id="c1", + transcript_text="嗯", + audio_url="audio-segment:vs-9:0", + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + agent_response=None, + ) + h = segments_to_redis_history([seg]) + self.assertEqual(len(h), 1) + self.assertEqual(h[0]["messageType"], "audio") + self.assertEqual(h[0]["voiceSessionId"], "vs-9") + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_story_route_agent.py b/api/tests/test_story_route_agent.py new file mode 100644 index 0000000..4007085 --- /dev/null +++ b/api/tests/test_story_route_agent.py @@ -0,0 +1,73 @@ +"""StoryRouteAgent:LLM JSON 决策与非法 target 回退。""" + +import unittest +from types import SimpleNamespace +from unittest.mock import Mock + +from app.agents.memoir.story_route_agent import StoryRouteAgent + + +def _story_stub(sid: str, title: str = "T"): + return SimpleNamespace( + id=sid, + title=title, + canonical_markdown="预览正文", + chapter_links=[], + ) + + +class StoryRouteAgentTest(unittest.TestCase): + def test_no_llm_returns_new_story(self): + agent = StoryRouteAgent() + out = agent.decide( + chapter_category="childhood", + chapter_title="童年", + batch_transcript="hello", + candidate_stories=[_story_stub("s1")], + llm=None, + valid_story_ids={"s1"}, + ) + self.assertEqual(out.decision, "new_story") + self.assertIsNone(out.new_story_title) + + def test_append_invalid_id_falls_back_to_new_story(self): + agent = StoryRouteAgent() + llm = Mock() + bound = Mock() + llm.bind.return_value = bound + bound.invoke.return_value = SimpleNamespace( + content='{"decision":"append_story","target_story_id":"unknown"}' + ) + out = agent.decide( + chapter_category="childhood", + chapter_title="童年", + batch_transcript="hello", + candidate_stories=[_story_stub("s1")], + llm=llm, + valid_story_ids={"s1"}, + ) + self.assertEqual(out.decision, "new_story") + self.assertEqual(out.reason, "invalid_target") + + def test_append_valid_target(self): + agent = StoryRouteAgent() + llm = Mock() + bound = Mock() + llm.bind.return_value = bound + bound.invoke.return_value = SimpleNamespace( + content='{"decision":"append_story","target_story_id":"s1"}' + ) + out = agent.decide( + chapter_category="childhood", + chapter_title="童年", + batch_transcript="more text", + candidate_stories=[_story_stub("s1")], + llm=llm, + valid_story_ids={"s1"}, + ) + self.assertEqual(out.decision, "append_story") + self.assertEqual(out.target_story_id, "s1") + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_websocket_baseline.py b/api/tests/test_websocket_baseline.py index c651213..58f2df4 100644 --- a/api/tests/test_websocket_baseline.py +++ b/api/tests/test_websocket_baseline.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch from starlette.websockets import WebSocketDisconnect, WebSocketState +from app.features.conversation import service as conversation_feature_service from app.features.conversation.models import Conversation, Segment from app.features.conversation.ws import router as ws_router @@ -185,7 +186,7 @@ def _session_local_factory(fake_db): def _redis_empty_history_patch(): """Patch redis to return empty history so websocket sends opening (or skips if mocked).""" return patch.object( - ws_router.redis_service, + conversation_feature_service.redis_service, "get_conversation_history", new=AsyncMock(return_value=[]), ) diff --git a/app-expo/.env.development b/app-expo/.env.development new file mode 100644 index 0000000..dd9d8cf --- /dev/null +++ b/app-expo/.env.development @@ -0,0 +1,2 @@ +EXPO_PUBLIC_API_URL=http://192.168.10.178:8000 +EXPO_PUBLIC_WS_URL=ws://192.168.10.178:8000 diff --git a/app-expo/.env.example b/app-expo/.env.example new file mode 100644 index 0000000..dd9d8cf --- /dev/null +++ b/app-expo/.env.example @@ -0,0 +1,2 @@ +EXPO_PUBLIC_API_URL=http://192.168.10.178:8000 +EXPO_PUBLIC_WS_URL=ws://192.168.10.178:8000 diff --git a/app-expo/.env.production b/app-expo/.env.production new file mode 100644 index 0000000..ca37760 --- /dev/null +++ b/app-expo/.env.production @@ -0,0 +1,2 @@ +EXPO_PUBLIC_API_URL=https://lifecho.worldsplats.com +EXPO_PUBLIC_WS_URL=wss://lifecho.worldsplats.com diff --git a/app-expo/.env.staging b/app-expo/.env.staging new file mode 100644 index 0000000..af9112a --- /dev/null +++ b/app-expo/.env.staging @@ -0,0 +1,2 @@ +EXPO_PUBLIC_API_URL=https://staging.lifecho.worldsplats.com +EXPO_PUBLIC_WS_URL=wss://staging.lifecho.worldsplats.com diff --git a/app-expo/scripts/use-env.js b/app-expo/scripts/use-env.js new file mode 100644 index 0000000..eb68fd0 --- /dev/null +++ b/app-expo/scripts/use-env.js @@ -0,0 +1,12 @@ +const fs = require('fs'); +const path = require('path'); +const env = process.argv[2] || 'development'; +const src = path.join(__dirname, '..', `.env.${env}`); +const dest = path.join(__dirname, '..', '.env'); +if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + console.log(`Switched to .env.${env}`); +} else { + console.error(`Missing .env.${env}`); + process.exit(1); +} diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index 7e01605..af8b324 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -307,6 +307,9 @@ export default function ChapterScreen() { const canonicalMarkdown = (chapter.canonical_markdown ?? '').trim(); const renderedAssets = chapter.rendered_assets ?? chapter.images ?? []; + /** 与 ScreenHeader(reading、useSafeArea)可视高度对齐,避免返回栏与首屏内容之间出现空隙 */ + const headerOccupiedHeight = Math.max(insets.top, 12) + 56; + const handleDeletePress = () => { Alert.alert( t('chapterReading.deleteChapter'), @@ -374,7 +377,7 @@ export default function ChapterScreen() { void; @@ -446,8 +455,29 @@ function ChatInputBar({ tapToEndLabel: string; cancelRecordingLabel: string; disabled?: boolean; + /** 发送后递增,强制重建 TextInput,避免多行高度卡在 4 行 */ + textInputKey?: number; }) { const colors = useThemeColors(); + const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H); + /** 空串时立即单行高度,避免仅依赖 state 时发送后仍沿用 4 行测量值 */ + const inputDisplayHeight = value === '' ? CHAT_INPUT_LINE_H : textHeight; + + useEffect(() => { + if (value === '') { + setTextHeight(CHAT_INPUT_LINE_H); + } + }, [value]); + + const onInputContentSizeChange = useCallback( + (e: NativeSyntheticEvent) => { + const h = e.nativeEvent.contentSize.height; + const next = Math.min(Math.max(h, CHAT_INPUT_LINE_H), CHAT_INPUT_MAX_H); + setTextHeight(next); + }, + [], + ); + const hasText = value.trim().length > 0; const showSend = inputMode === 'text' && hasText; @@ -487,15 +517,18 @@ function ChatInputBar({ {inputMode === 'text' ? ( @@ -600,6 +633,7 @@ export default function ConversationScreen() { } = useRecorder(handleRecordingComplete); const [input, setInput] = useState(''); + const [inputResetKey, setInputResetKey] = useState(0); const [inputMode, setInputMode] = useState('text'); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); @@ -609,6 +643,30 @@ export default function ConversationScreen() { const onShow = (e: { endCoordinates: { height: number } }) => { setIsKeyboardVisible(true); setKeyboardHeight(e.endCoordinates.height); + // #region agent log + void fetch( + 'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Debug-Session-Id': 'a82a4c', + }, + body: JSON.stringify({ + sessionId: 'a82a4c', + hypothesisId: 'H3', + location: 'ConversationScreen:keyboardShow', + message: 'keyboard open; composer uses insetBottom 0 when text+kb', + data: { + kbH: e.endCoordinates.height, + insetBottom: insets.bottom, + os: Platform.OS, + }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion InteractionManager.runAfterInteractions(() => { listRef.current?.scrollToEnd({ animated: true }); }); @@ -617,13 +675,18 @@ export default function ConversationScreen() { setIsKeyboardVisible(false); setKeyboardHeight(0); }; - const subShow = Keyboard.addListener('keyboardDidShow', onShow); - const subHide = Keyboard.addListener('keyboardDidHide', onHide); + // iOS:Will* 与系统动画同步;KeyboardAvoidingView 在 iOS 上易与 safe area 叠出缝(见 RN #52626) + const showEvt = + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvt = + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const subShow = Keyboard.addListener(showEvt, onShow); + const subHide = Keyboard.addListener(hideEvt, onHide); return () => { subShow.remove(); subHide.remove(); }; - }, []); + }, [insets.bottom]); const flattenedData = flattenMessagesForList(messages ?? []); @@ -642,6 +705,27 @@ export default function ConversationScreen() { if (!text) return; sendText(text); setInput(''); + setInputResetKey((k) => k + 1); + // #region agent log + void fetch( + 'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Debug-Session-Id': 'a82a4c', + }, + body: JSON.stringify({ + sessionId: 'a82a4c', + hypothesisId: 'H4', + location: 'ConversationScreen:handleSend', + message: 'cleared input + bumped textInputKey', + data: { sentLen: text.length }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion }; const connectionLabel = @@ -651,129 +735,142 @@ export default function ConversationScreen() { ? t('connectionConnecting') : t('connectionDisconnected'); - const keyboardOffset = Platform.OS === 'ios' ? insets.top + 56 : 0; - const kavEnabled = inputMode === 'text' && isKeyboardVisible; - const kavBehavior = Platform.OS === 'ios' ? 'padding' : 'height'; + /** iOS:用键盘高度直接顶起根布局,替代 KAV(避免与 safe area 叠出缝,见 RN #52626) */ + const keyboardLift = + Platform.OS === 'ios' && inputMode === 'text' && isKeyboardVisible + ? keyboardHeight + : 0; + const androidKavOn = + Platform.OS === 'android' && inputMode === 'text' && isKeyboardVisible; + + const composerZeroBottomInset = isKeyboardVisible && inputMode === 'text'; + + const screen = ( + + + {tApp('name')} + {playerStatus === 'playing' && ( + + )} + + + {connectionLabel} + + + + } + backAccessibilityLabel={t('chatTitle')} + /> + + {/* Message list - flex 1, takes remaining space */} + item.listKey} + renderItem={({ item }) => ( + + )} + onContentSizeChange={() => + InteractionManager.runAfterInteractions(() => { + listRef.current?.scrollToEnd({ animated: true }); + }) + } + ListFooterComponent={ + streamingMessage ? ( + + ) : null + } + /> + + {/* WeChat-style input bar - 贴在底部,非浮空 */} + + { + setInputMode((m) => { + if (m === 'text') { + Keyboard.dismiss(); + } + return m === 'text' ? 'voice' : 'text'; + }); + }} + onAddPress={() => {}} + onStartRecording={handleStartRecording} + onStopRecording={() => void stopRecording()} + onCancelRecording={() => void cancelRecording()} + isRecording={isRecording} + recordingDuration={recordingDuration} + placeholder={t('inputPlaceholder')} + placeholderVoice={t('inputPlaceholderVoice')} + addMoreLabel={t('addMore')} + sendLabel={t('send')} + switchToVoiceLabel={t('switchToVoice')} + switchToTextLabel={t('switchToText')} + tapToStartLabel={t('tapToStartRecording')} + tapToEndLabel={t('tapToEndRecording')} + cancelRecordingLabel={t('cancelRecording')} + disabled={connectionState !== 'connected'} + /> + + + ); + + if (Platform.OS === 'ios') { + return ( + + {screen} + + ); + } return ( - - - {tApp('name')} - {playerStatus === 'playing' && ( - - )} - - - {connectionLabel} - - - - } - backAccessibilityLabel={t('chatTitle')} - /> - - {/* Message list - flex 1, takes remaining space */} - item.listKey} - renderItem={({ item }) => ( - - )} - onContentSizeChange={() => - InteractionManager.runAfterInteractions(() => { - listRef.current?.scrollToEnd({ animated: true }); - }) - } - ListFooterComponent={ - streamingMessage ? ( - - ) : null - } - /> - - {/* WeChat-style input bar - 贴在底部,非浮空 */} - - { - setInputMode((m) => { - if (m === 'text') { - Keyboard.dismiss(); - } - return m === 'text' ? 'voice' : 'text'; - }); - }} - onAddPress={() => {}} - onStartRecording={handleStartRecording} - onStopRecording={() => void stopRecording()} - onCancelRecording={() => void cancelRecording()} - isRecording={isRecording} - recordingDuration={recordingDuration} - placeholder={t('inputPlaceholder')} - placeholderVoice={t('inputPlaceholderVoice')} - addMoreLabel={t('addMore')} - sendLabel={t('send')} - switchToVoiceLabel={t('switchToVoice')} - switchToTextLabel={t('switchToText')} - tapToStartLabel={t('tapToStartRecording')} - tapToEndLabel={t('tapToEndRecording')} - cancelRecordingLabel={t('cancelRecording')} - disabled={connectionState !== 'connected'} - /> - - + {screen} ); } @@ -966,7 +1063,7 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(141, 140, 144, 0.14)', paddingHorizontal: 16, paddingVertical: 11, - justifyContent: 'center', + justifyContent: 'flex-start', }, inputCenterFlex: { flex: 1, @@ -1037,11 +1134,11 @@ const styles = StyleSheet.create({ } as const, textInput: { fontSize: 16, - lineHeight: 22, + lineHeight: CHAT_INPUT_LINE_H, color: CHAT_COLORS.onSurface, padding: 0, - minHeight: 22, // 保持空内容时至少一行高度 - maxHeight: 88, // 4 lines * 22 lineHeight + maxHeight: CHAT_INPUT_MAX_H, + width: '100%', }, sendButton: { height: 44, diff --git a/app-expo/src/app/(main)/delete-data.tsx b/app-expo/src/app/(main)/delete-data.tsx index 6be7cfa..0644bf1 100644 --- a/app-expo/src/app/(main)/delete-data.tsx +++ b/app-expo/src/app/(main)/delete-data.tsx @@ -1,29 +1,157 @@ -import React from 'react'; -import { ScrollView, View } from 'react-native'; +import React, { useState } from 'react'; +import { KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; +import { PURGE_USER_DATA_CONFIRMATION } from '@/features/profile/constants'; +import { usePurgeUserData } from '@/features/profile/hooks'; +import { cn } from '@/lib/utils'; + +/** ScreenHeader 可视高度近似:安全区顶 + 上下 padding + minHeight 行 */ +function headerKeyboardOffset(topInset: number) { + return Platform.OS === 'ios' ? topInset + 72 : 0; +} export default function DeleteDataScreen() { const { t } = useTranslation('profile'); + const insets = useSafeAreaInsets(); + const [phrase, setPhrase] = useState(''); + const [confirmOpen, setConfirmOpen] = useState(false); + const purge = usePurgeUserData(); + + const phraseOk = phrase.trim() === PURGE_USER_DATA_CONFIRMATION; + + const runPurge = () => { + purge.mutate( + { confirmation: PURGE_USER_DATA_CONFIRMATION }, + { + onSettled: () => setConfirmOpen(false), + }, + ); + }; + + const scrollBottomPad = Math.max(insets.bottom, 16) + 120; return ( - - - - {t('dataPrivacy.deleteAll')} - - - {t('dataPrivacy.deleteUnderDevelopment')} - - - + + + + + {t('dataPrivacy.purgeWarningTitle')} + + + {t('dataPrivacy.purgeWarningBody')} + + + + + + {t('dataPrivacy.purgePhraseHint')} + + + + {PURGE_USER_DATA_CONFIRMATION} + + + + + + + {t('dataPrivacy.purgeInputLabel')} + + + + + {purge.error && ( + + {purge.error.message} + + )} + + + + + + + + + + + {t('dataPrivacy.purgeDialogTitle')} + + + {t('dataPrivacy.purgeDialogDescription')} + + + + + {t('dataPrivacy.purgeDialogCancel')} + + + + {purge.isPending + ? t('dataPrivacy.purgeSubmitting') + : t('dataPrivacy.purgeDialogConfirm')} + + + + + ); } diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx index 2a1fd86..c936124 100644 --- a/app-expo/src/app/(tabs)/memoir.tsx +++ b/app-expo/src/app/(tabs)/memoir.tsx @@ -1,7 +1,6 @@ -import { useFocusEffect } from '@react-navigation/native'; import { Image } from 'expo-image'; import { router } from 'expo-router'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Platform, Pressable, @@ -400,12 +399,13 @@ export default function MemoirScreen() { const createConversation = useCreateConversation(); const checkCover = useCheckCoverGeneration(); const [refreshing, setRefreshing] = useState(false); + const didRunInitialCoverCheckRef = useRef(false); - useFocusEffect( - useCallback(() => { - checkCover.mutate(undefined); - }, [checkCover]), - ); + useEffect(() => { + if (didRunInitialCoverCheckRef.current) return; + didRunInitialCoverCheckRef.current = true; + checkCover.mutate(undefined); + }, [checkCover]); const handleRefresh = useCallback(async () => { setRefreshing(true); diff --git a/app-expo/src/core/app-settings-context.tsx b/app-expo/src/core/app-settings-context.tsx index 79cc7a8..391789f 100644 --- a/app-expo/src/core/app-settings-context.tsx +++ b/app-expo/src/core/app-settings-context.tsx @@ -48,7 +48,7 @@ export function AppSettingsProvider({ children }: PropsWithChildren) { const { t } = useTranslation('app'); const [language, setLanguageState] = useState(null); - const [largeText, setLargeTextState] = useState(false); + const [largeText, setLargeTextState] = useState(true); const [darkMode, setDarkModeState] = useState(false); const [themeName, setThemeNameState] = useState('default'); const [ready, setReady] = useState(false); diff --git a/app-expo/src/core/settings/app-settings.ts b/app-expo/src/core/settings/app-settings.ts index 243ec6c..17ddec1 100644 --- a/app-expo/src/core/settings/app-settings.ts +++ b/app-expo/src/core/settings/app-settings.ts @@ -56,6 +56,7 @@ export async function clearAppLanguage(): Promise { export async function getLargeText(): Promise { const v = await getStored(KEY_LARGE_TEXT); + if (v == null || v === '') return true; return v === 'true'; } diff --git a/app-expo/src/features/memoir/markdown-renderer.tsx b/app-expo/src/features/memoir/markdown-renderer.tsx new file mode 100644 index 0000000..087fe7b --- /dev/null +++ b/app-expo/src/features/memoir/markdown-renderer.tsx @@ -0,0 +1,459 @@ +/** + * Markdown 渲染器:使用 react-native-markdown-display 渲染 canonical_markdown。 + * 线上正文以 asset:// 或已解析的 https 为准;遗留 {{IMAGE:...}} 仅从展示层剥离,不作为协议。 + */ + +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import React from 'react'; +import { Platform, StyleSheet, Text, View } from 'react-native'; +import Markdown from 'react-native-markdown-display'; + +import type { ImageAsset } from './types'; + +const PLACEHOLDER_RE = /\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}/g; + +function buildPlaceholderToAssetMap( + assets: ImageAsset[], +): Map { + const map = new Map(); + for (const a of assets) { + if (a.placeholder?.trim()) { + map.set(a.placeholder.trim(), a); + } + } + return map; +} + +/** 移除遗留 IMAGE 占位符(不参与正文协议)。 */ +export function stripLegacyImagePlaceholders(markdown: string): string { + return markdown.replace(PLACEHOLDER_RE, '').replace(/\n{3,}/g, '\n\n'); +} + +/** + * 预处理正文:先用 assets 替换可匹配的遗留占位符,再剥离剩余占位符。 + */ +export function replaceImagePlaceholders( + markdown: string, + assets: ImageAsset[], +): string { + const assetMap = buildPlaceholderToAssetMap(assets); + let out = markdown; + if (assetMap.size > 0) { + out = markdown.replace(PLACEHOLDER_RE, (match) => { + const asset = assetMap.get(match.trim()); + if (!asset?.url) return ''; + const caption = (asset.description ?? '').replace(/[\]\\]/g, '\\$&'); + return `![${caption}](${asset.url})`; + }); + } + return stripLegacyImagePlaceholders(out); +} + +/** 顶层正文段落(body 直属,非列表/引用内)用于首行缩进 */ +interface AstNodeLite { + type: string; + key: string; + children?: AstNodeLite[]; +} + +function isTopLevelBodyParagraph(parentNodes: AstNodeLite[]): boolean { + const pi = parentNodes.findIndex((n) => n.type === 'paragraph'); + if (pi < 0) return false; + return parentNodes[pi + 1]?.type === 'body'; +} + +function isFirstParagraphUnderBody( + paragraph: AstNodeLite, + body: AstNodeLite, +): boolean { + if (!body.children?.length) return false; + for (const c of body.children) { + if (c.type === 'paragraph') return c.key === paragraph.key; + } + return false; +} + +function firstGrapheme(s: string): string { + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + for (const { segment } of seg.segment(s)) { + return segment; + } + return ''; + } + const cp = s.codePointAt(0); + if (cp === undefined) return ''; + return String.fromCodePoint(cp); +} + +const PARA_FIRST_LINE_INDENT = '\u3000\u3000'; + +const READING_COLORS = { + primary: '#8177A6', + onSurface: '#1b1b1f', + onSurfaceVariant: '#48454e', + divider: 'rgba(141, 140, 144, 0.2)', + /** 故事间 `---` 分隔线:需明显高于正文底色的对比度,否则几乎不可见 */ + horizontalRule: 'rgba(121, 117, 127, 0.42)', +}; + +const FONT_FAMILIES = { + serif: + Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ?? + 'serif', + sans: + Platform.select({ + ios: 'System', + android: 'sans-serif', + default: 'sans-serif', + }) ?? 'sans-serif', +}; + +const FONT_SIZES = { small: 16, default: 20, large: 24 }; +const LINE_HEIGHTS = { small: 30, default: 38, large: 44 }; + +export interface MarkdownRendererProps { + markdown: string; + renderedAssets: ImageAsset[]; + coverImageUrl: string | null; + fontSize: 'small' | 'default' | 'large'; + fontFamily: 'serif' | 'sans'; + backgroundColor: string; + contentWidth: number; +} + +export function MarkdownRenderer({ + markdown, + renderedAssets, + coverImageUrl, + fontSize, + fontFamily, + backgroundColor, + contentWidth, +}: MarkdownRendererProps) { + const processedMarkdown = React.useMemo( + () => replaceImagePlaceholders(markdown, renderedAssets), + [markdown, renderedAssets], + ); + + const [heroLoadFailed, setHeroLoadFailed] = React.useState(false); + /** 每段第一个 text 节点只缩进一次;全文仅首段首字下沉 */ + const paragraphFirstTextKeysRef = React.useRef>(new Set()); + const dropCapConsumedRef = React.useRef(false); + + React.useEffect(() => { + setHeroLoadFailed(false); + }, [coverImageUrl]); + + React.useEffect(() => { + paragraphFirstTextKeysRef.current = new Set(); + dropCapConsumedRef.current = false; + }, [processedMarkdown]); + + const hasCoverImage = !!coverImageUrl && !heroLoadFailed; + const bodySize = FONT_SIZES[fontSize]; + const lineHeight = LINE_HEIGHTS[fontSize]; + const fontFam = FONT_FAMILIES[fontFamily]; + + const markdownStyles = React.useMemo( + () => + StyleSheet.create({ + body: { + color: READING_COLORS.onSurfaceVariant, + fontSize: bodySize, + lineHeight, + fontFamily: fontFam, + }, + heading1: { + color: READING_COLORS.primary, + fontSize: bodySize * 1.5, + fontWeight: '700', + fontFamily: fontFam, + marginTop: 24, + marginBottom: 12, + }, + heading2: { + color: READING_COLORS.primary, + fontSize: bodySize * 1.2, + fontWeight: '700', + fontFamily: fontFam, + marginTop: 16, + marginBottom: 12, + }, + heading3: { + color: READING_COLORS.primary, + fontSize: bodySize * 1.1, + fontWeight: '700', + fontFamily: fontFam, + marginTop: 12, + marginBottom: 8, + }, + paragraph: { + marginTop: 0, + marginBottom: 16, + color: READING_COLORS.onSurfaceVariant, + fontSize: bodySize, + lineHeight, + fontFamily: fontFam, + }, + blockquote: { + backgroundColor: 'transparent', + borderLeftColor: READING_COLORS.primary, + borderLeftWidth: 4, + marginLeft: 0, + paddingLeft: 16, + marginVertical: 16, + color: READING_COLORS.onSurfaceVariant, + fontSize: bodySize, + lineHeight, + fontFamily: fontFam, + }, + hr: { + width: '100%', + alignSelf: 'stretch', + backgroundColor: READING_COLORS.horizontalRule, + height: Platform.OS === 'android' ? 2 : StyleSheet.hairlineWidth, + marginVertical: 28, + }, + image: { + width: '100%', + aspectRatio: 16 / 9, + borderRadius: 12, + marginVertical: 16, + }, + text: { + color: READING_COLORS.onSurfaceVariant, + fontSize: bodySize, + lineHeight, + fontFamily: fontFam, + }, + }), + [bodySize, lineHeight, fontFam], + ); + + const rules = React.useMemo(() => { + const dropCapFontSize = bodySize * 2.15; + /** 首行必须容纳下沉字高,否则父级 lineHeight 会裁切嵌套大字 */ + const dropCapLineHeight = Math.max( + lineHeight, + Math.ceil(dropCapFontSize * 1.12), + ); + const dropCapTextStyle = { + color: READING_COLORS.primary, + fontSize: dropCapFontSize, + lineHeight: Math.ceil(dropCapFontSize * 1.08), + fontFamily: fontFam, + fontWeight: '600' as const, + }; + + return { + hr: (node: { key: string }) => ( + + ), + image: ( + node: { key: string; attributes: Record }, + _children: React.ReactNode[], + _parent: unknown[], + _styles: unknown, + ) => { + const src = node.attributes.src; + const alt = node.attributes.alt; + if (!src || (!src.startsWith('http') && !src.startsWith('https'))) + return null; + return ( + {alt + ); + }, + text: ( + node: { key: string; content: string }, + _children: React.ReactNode[], + parentNodes: AstNodeLite[], + styles: { text: object }, + inheritedStyles: Record = {}, + ) => { + const baseStyle = [inheritedStyles, styles.text]; + const content = node.content ?? ''; + + if (!isTopLevelBodyParagraph(parentNodes)) { + return ( + + {content} + + ); + } + + const paragraph = parentNodes.find((n) => n.type === 'paragraph'); + const body = parentNodes.find((n) => n.type === 'body'); + if (!paragraph || !body) { + return ( + + {content} + + ); + } + + const seen = paragraphFirstTextKeysRef.current.has(paragraph.key); + if (!seen && content.trim() === '') { + return ( + + {content} + + ); + } + + if (seen) { + return ( + + {content} + + ); + } + + paragraphFirstTextKeysRef.current.add(paragraph.key); + + const leadingWs = content.match(/^\s*/)?.[0] ?? ''; + const afterWs = content.slice(leadingWs.length); + const docFirstParagraph = isFirstParagraphUnderBody(paragraph, body); + const wantDropCap = + docFirstParagraph && + !dropCapConsumedRef.current && + afterWs.length > 0; + + if (!wantDropCap) { + return ( + + {PARA_FIRST_LINE_INDENT} + {content} + + ); + } + + const g = firstGrapheme(afterWs); + if (!g) { + return ( + + {PARA_FIRST_LINE_INDENT} + {content} + + ); + } + + dropCapConsumedRef.current = true; + const tail = afterWs.slice(g.length); + + return ( + + {PARA_FIRST_LINE_INDENT} + {leadingWs} + {g} + {tail} + + ); + }, + }; + }, [bodySize, fontFam, lineHeight, markdownStyles.image]); + + return ( + <> + {hasCoverImage && ( + + Chapter hero setHeroLoadFailed(true)} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + }} + /> + + + )} + + + + {processedMarkdown ? ( + + {processedMarkdown} + + ) : null} + + {processedMarkdown.trim().length > 0 && ( + + + + )} + + + + ); +} diff --git a/app-expo/src/features/profile/api.ts b/app-expo/src/features/profile/api.ts index 12afdda..8a47e6b 100644 --- a/app-expo/src/features/profile/api.ts +++ b/app-expo/src/features/profile/api.ts @@ -8,6 +8,8 @@ import type { LegalDocType, Plan, QuotaCheck, + PurgeUserDataRequest, + PurgeUserDataResponse, SubmitFeedbackRequest, UpdateProfileRequest, UserProfile, @@ -42,6 +44,10 @@ export const profileApi = { return api.post('/api/feedback', { body }); }, + purgeUserData(body: PurgeUserDataRequest) { + return api.post('/api/user/data/purge', { body }); + }, + /** * Legal docs are HTML responses, not JSON. * Fetched as raw text from the API base URL. diff --git a/app-expo/src/features/profile/constants.ts b/app-expo/src/features/profile/constants.ts new file mode 100644 index 0000000..9b2c27a --- /dev/null +++ b/app-expo/src/features/profile/constants.ts @@ -0,0 +1,6 @@ +/** + * 与后端 `api/app/features/user/schemas.py` 中 + * `PURGE_USER_DATA_CONFIRMATION` 必须完全一致。 + */ +export const PURGE_USER_DATA_CONFIRMATION = + '我确认永久删除我的全部回忆与对话数据' as const; diff --git a/app-expo/src/features/profile/hooks.ts b/app-expo/src/features/profile/hooks.ts index 2bbec36..dbb52eb 100644 --- a/app-expo/src/features/profile/hooks.ts +++ b/app-expo/src/features/profile/hooks.ts @@ -1,8 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { router } from 'expo-router'; + +import { tokenManager } from '@/core/auth/token-manager'; +import { authKeys } from '@/features/auth/hooks'; import { profileApi } from './api'; import type { LegalDocType, + PurgeUserDataRequest, SubmitFeedbackRequest, UpdateProfileRequest, } from './types'; @@ -82,6 +87,24 @@ export function useSubmitFeedback() { }); } +/** + * 永久清空服务端业务数据;成功后服务端会吊销所有 refresh token, + * 因此仅清本地会话并跳转登录(不再调用 logout 接口)。 + */ +export function usePurgeUserData() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (body: PurgeUserDataRequest) => profileApi.purgeUserData(body), + onSuccess: async () => { + await tokenManager.clearTokens(); + queryClient.clear(); + queryClient.setQueryData(authKeys.tokenCheck, false); + router.replace('/(auth)/login'); + }, + }); +} + // ─── Legal ─── export function useLegalDoc(type: LegalDocType) { diff --git a/app-expo/src/features/profile/types.ts b/app-expo/src/features/profile/types.ts index 8e806d2..0d9ea83 100644 --- a/app-expo/src/features/profile/types.ts +++ b/app-expo/src/features/profile/types.ts @@ -86,6 +86,17 @@ export interface FeedbackResponse { message: string; } +// ─── Purge user data ─── + +export interface PurgeUserDataRequest { + confirmation: string; +} + +export interface PurgeUserDataResponse { + success: boolean; + message: string; +} + // ─── Legal ─── export type LegalDocType = 'terms' | 'privacy'; diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index b342a58..70290db 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -140,13 +140,13 @@ interface Resources { }; appExperience: { language: 'Language'; - languageDesc: 'App display language'; + languageDesc: 'Display language'; largeText: 'Large Text'; largeTextDesc: 'Make reading easier'; nightMode: 'Night Mode'; nightModeDesc: 'Use dark theme'; theme: 'Theme'; - themeDesc: 'App color theme'; + themeDesc: 'Color theme'; title: 'App Experience'; }; dataPrivacy: { @@ -154,6 +154,17 @@ interface Resources { deleteUnderDevelopment: 'Delete data feature is under development.'; exportAll: 'Export All Data'; exportUnderDevelopment: 'Export feature is under development.'; + purgeDialogCancel: 'Cancel'; + purgeDialogConfirm: 'Delete permanently'; + purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.'; + purgeDialogTitle: 'Final confirmation'; + purgeInputLabel: 'Confirmation phrase'; + purgeInputPlaceholder: 'Type the phrase shown above'; + purgeOpenConfirm: 'I understand, continue'; + purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:'; + purgeSubmitting: 'Deleting…'; + purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.'; + purgeWarningTitle: 'Before you continue'; title: 'Data & Privacy'; }; editAvatar: 'Edit Profile Picture'; diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index e84a8c9..b7109ec 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -19,6 +19,17 @@ "deleteUnderDevelopment": "Delete data feature is under development.", "exportAll": "Export All Data", "exportUnderDevelopment": "Export feature is under development.", + "purgeDialogCancel": "Cancel", + "purgeDialogConfirm": "Delete permanently", + "purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.", + "purgeDialogTitle": "Final confirmation", + "purgeInputLabel": "Confirmation phrase", + "purgeInputPlaceholder": "Type the phrase shown above", + "purgeOpenConfirm": "I understand, continue", + "purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:", + "purgeSubmitting": "Deleting…", + "purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.", + "purgeWarningTitle": "Before you continue", "title": "Data & Privacy" }, "editAvatar": "Edit Profile Picture", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index 6b02721..eef8896 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -19,6 +19,17 @@ "deleteUnderDevelopment": "删除数据功能开发中,敬请期待。", "exportAll": "导出所有数据", "exportUnderDevelopment": "导出功能开发中,敬请期待。", + "purgeDialogCancel": "取消", + "purgeDialogConfirm": "确认永久删除", + "purgeDialogDescription": "确定要执行吗?删除后立即生效,无法撤销。", + "purgeDialogTitle": "最后确认", + "purgeInputLabel": "确认口令", + "purgeInputPlaceholder": "在此输入完整确认句", + "purgeOpenConfirm": "我了解后果,继续删除", + "purgePhraseHint": "请在下方输入框中完整输入以下句子(须一字不差,含标点):", + "purgeSubmitting": "正在删除…", + "purgeWarningBody": "将永久删除账号下的对话、记忆、故事、章节、订单等业务数据,并删除云端已关联的图片等文件。所有设备将立即退出登录。\n您的手机号与账号仍可登录,但此前内容无法恢复。", + "purgeWarningTitle": "清空数据前请知悉", "title": "数据与隐私" }, "editAvatar": "编辑头像", diff --git a/docs/plans/2026-03-19-story-first-markdown-first-design.md b/docs/plans/2026-03-19-story-first-markdown-first-design.md index 27256b7..5063ebd 100644 --- a/docs/plans/2026-03-19-story-first-markdown-first-design.md +++ b/docs/plans/2026-03-19-story-first-markdown-first-design.md @@ -52,9 +52,9 @@ flowchart LR A["Conversation / Segments"] --> B["Memory Ingest"] B --> C["Evidence Layer"] - C --> D["Story Builder"] + C --> D["Celery: Route + Narrative"] D --> E["Story Layer"] - E --> F["Chapter Composer"] + E --> F["Compose from story_links"] F --> G["Chapter Layer"] G --> H["App Reading"] G --> I["PDF Export"] @@ -124,6 +124,18 @@ flowchart LR - `memoir_images` 或后续统一 `assets` - PDF 渲染与导出任务 +### 4.5 Celery 批次:路由 + 仅 Story 落库 + +异步任务 `process_memoir_segments` 不再写入 `chapter_sections`。每个「章节类别 + 本批 segments」在 Celery 内走统一流水线(见 `story_pipeline_sync.run_story_pipeline_for_category_batch`): + +1. **检索**:`retrieve_evidence_sync` 提供 RAG evidence,与 transcript 合并为叙事输入。 +2. **路由**:`StoryRouteAgent` 读取用户全部 `active` stories(标题 + 正文摘要 + 已关联章节提示),结构化输出 `new_story` 或 `append_story`(`target_story_id` 必须在候选列表中,否则回退 `new_story`)。 +3. **叙事**:`NarrativeAgent` 产出 JSON/文本,经 `narrative_to_markdown` 规范为 markdown。 +4. **落库**:`create_story_with_version_sync` 或 `append_story_version_sync`,并 `ensure_chapter_story_link_sync` 将当前类别下的 active 章节与该 story 关联(若尚未关联则追加 link);再 `compose_chapter_from_story_links_sync` 物化 `chapters.canonical_markdown`。 +5. **后续**:commit 后派发 `generate_story_image`、`recompose_chapters_for_story`;章节补图任务 `generate_chapter_images` 仅处理**章节级** MemoirImage(封面槽位,按 `order_index`),正文插图由 `story_image_tasks` 消费 story 主图 intent。 + +对话侧多 Agent 仍负责访谈节奏与槽位;与 Celery 的衔接点是 segment 落库与异步任务,不共享同一套「写 story」代码路径。 + ## 5. 数据模型 ### 5.1 现有保留表