本次 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 映射等回归测试,并加入图片占位符退役设计文档。
188 lines
5.1 KiB
Python
188 lines
5.1 KiB
Python
"""Story repository — Story, StoryVersion, StoryEvidenceLink data access."""
|
||
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
|
||
from sqlalchemy import delete, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.features.story.models import (
|
||
Story,
|
||
StoryEvidenceLink,
|
||
StoryImageIntent,
|
||
StoryVersion,
|
||
)
|
||
|
||
|
||
def _new_id() -> str:
|
||
return str(uuid.uuid4())
|
||
|
||
|
||
async def create_story(
|
||
db: AsyncSession,
|
||
*,
|
||
user_id: str,
|
||
title: str,
|
||
stage: str | None = None,
|
||
story_type: str | None = None,
|
||
summary: str | None = None,
|
||
canonical_markdown: str | None = None,
|
||
) -> Story:
|
||
"""Create a story. Caller must commit."""
|
||
story = Story(
|
||
id=_new_id(),
|
||
user_id=user_id,
|
||
title=title,
|
||
stage=stage,
|
||
story_type=story_type,
|
||
summary=summary,
|
||
canonical_markdown=canonical_markdown or "",
|
||
)
|
||
db.add(story)
|
||
return story
|
||
|
||
|
||
async def create_story_version(
|
||
db: AsyncSession,
|
||
*,
|
||
story_id: str,
|
||
version_no: int,
|
||
markdown_snapshot: str,
|
||
actor_type: str = "ai",
|
||
source_type: str = "generate",
|
||
parent_version_id: str | None = None,
|
||
prompt_meta: dict | None = None,
|
||
) -> StoryVersion:
|
||
"""Create a story version. Caller must commit."""
|
||
version = StoryVersion(
|
||
id=_new_id(),
|
||
story_id=story_id,
|
||
version_no=version_no,
|
||
markdown_snapshot=markdown_snapshot,
|
||
actor_type=actor_type,
|
||
source_type=source_type,
|
||
parent_version_id=parent_version_id,
|
||
prompt_meta=prompt_meta,
|
||
)
|
||
db.add(version)
|
||
return version
|
||
|
||
|
||
async def create_story_evidence_link(
|
||
db: AsyncSession,
|
||
*,
|
||
story_id: str,
|
||
evidence_type: str,
|
||
evidence_id: str,
|
||
role: str = "primary",
|
||
weight: float | None = None,
|
||
) -> StoryEvidenceLink:
|
||
"""Create story-evidence link. Caller must commit."""
|
||
link = StoryEvidenceLink(
|
||
id=_new_id(),
|
||
story_id=story_id,
|
||
evidence_type=evidence_type,
|
||
evidence_id=evidence_id,
|
||
role=role,
|
||
weight=weight,
|
||
)
|
||
db.add(link)
|
||
return link
|
||
|
||
|
||
async def get_story_by_id(db: AsyncSession, story_id: str) -> Story | None:
|
||
"""Fetch story by ID."""
|
||
return await db.get(Story, story_id)
|
||
|
||
|
||
async def get_stories_for_user(
|
||
db: AsyncSession, user_id: str, *, status: str | None = "active"
|
||
) -> list[Story]:
|
||
"""Fetch stories for user, optionally filtered by status."""
|
||
stmt = select(Story).where(Story.user_id == user_id)
|
||
if status:
|
||
stmt = stmt.where(Story.status == status)
|
||
stmt = stmt.order_by(Story.created_at.desc())
|
||
result = await db.execute(stmt)
|
||
return list(result.unique().scalars().all())
|
||
|
||
|
||
async def count_story_versions(db: AsyncSession, story_id: str) -> int:
|
||
"""Count versions for a story."""
|
||
from sqlalchemy import func
|
||
|
||
stmt = select(func.count(StoryVersion.id)).where(StoryVersion.story_id == story_id)
|
||
result = await db.execute(stmt)
|
||
return result.scalar() or 0
|
||
|
||
|
||
async def create_story_image_intent(
|
||
db: AsyncSession,
|
||
*,
|
||
story_id: str,
|
||
story_version_id: str | None,
|
||
caption: str,
|
||
prompt_brief: str,
|
||
source_span: dict | None = None,
|
||
style_profile: str | None = None,
|
||
) -> StoryImageIntent:
|
||
"""Create primary image intent for a story. Caller must commit."""
|
||
intent = StoryImageIntent(
|
||
id=_new_id(),
|
||
story_id=story_id,
|
||
story_version_id=story_version_id,
|
||
intent_role="primary",
|
||
source_span=source_span,
|
||
caption=caption,
|
||
prompt_brief=prompt_brief,
|
||
style_profile=style_profile,
|
||
status="pending",
|
||
)
|
||
db.add(intent)
|
||
return intent
|
||
|
||
|
||
async def get_story_image_intent_by_story(
|
||
db: AsyncSession, story_id: str, *, role: str = "primary"
|
||
) -> StoryImageIntent | None:
|
||
"""Get primary image intent for a story."""
|
||
stmt = (
|
||
select(StoryImageIntent)
|
||
.where(StoryImageIntent.story_id == story_id)
|
||
.where(StoryImageIntent.intent_role == role)
|
||
)
|
||
result = await db.execute(stmt)
|
||
return result.unique().scalar_one_or_none()
|
||
|
||
|
||
async def delete_story_image_intents_by_story(
|
||
db: AsyncSession,
|
||
story_id: str,
|
||
*,
|
||
role: str = "primary",
|
||
statuses: list[str] | None = None,
|
||
) -> int:
|
||
"""
|
||
删除指定 story 的配图 intent。
|
||
statuses 为 None 时删除该 role 下全部;否则仅删除列出的状态(如仅清 pending/failed,避免打断 processing)。
|
||
"""
|
||
stmt = delete(StoryImageIntent).where(
|
||
StoryImageIntent.story_id == story_id,
|
||
StoryImageIntent.intent_role == role,
|
||
)
|
||
if statuses is not None:
|
||
stmt = stmt.where(StoryImageIntent.status.in_(statuses))
|
||
result = await db.execute(stmt)
|
||
return result.rowcount or 0
|
||
|
||
|
||
async def get_stories_by_ids(db: AsyncSession, story_ids: list[str]) -> list[Story]:
|
||
"""Fetch stories by IDs."""
|
||
if not story_ids:
|
||
return []
|
||
stmt = select(Story).where(Story.id.in_(story_ids))
|
||
result = await db.execute(stmt)
|
||
stories = list(result.unique().scalars().all())
|
||
order = {sid: i for i, sid in enumerate(story_ids)}
|
||
return sorted(stories, key=lambda s: order.get(s.id, 999))
|