重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复

本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括:

1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。

2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。

3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。

4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。

5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。

6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。

7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
This commit is contained in:
Kevin
2026-03-20 10:30:07 +08:00
parent 13e3124b85
commit 7f57f96c25
67 changed files with 4751 additions and 832 deletions

View File

@@ -0,0 +1,207 @@
"""story_first_markdown_first_schema
Story-First + Markdown-First 架构:新增 stories/story_versions/story_evidence_links
重定义 chapterscanonical_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")

View File

@@ -0,0 +1,105 @@
"""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_versionsource_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

View File

@@ -0,0 +1,60 @@
"""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")

View File

@@ -0,0 +1,57 @@
"""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")

View File

@@ -0,0 +1,49 @@
"""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")

View File

@@ -0,0 +1,110 @@
"""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

View File

@@ -0,0 +1,72 @@
"""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",
)

View File

@@ -0,0 +1,50 @@
"""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")