""" 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.asset_resolver import strip_asset_image_refs_from_markdown 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.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.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", 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: md = strip_asset_image_refs_from_markdown(canonical_markdown or "") story = Story( id=str(uuid.uuid4()), user_id=user_id, title=title, stage=stage, canonical_markdown=md, ) session.add(story) session.flush() vid = str(uuid.uuid4()) version = StoryVersion( id=vid, story_id=story.id, version_no=1, markdown_snapshot=md, actor_type="ai", source_type="generate", ) session.add(version) session.flush() story.current_version_id = vid if md.strip(): _extract_and_store_image_intent_sync( session, story=story, version=version, markdown=md ) 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") md = strip_asset_image_refs_from_markdown(markdown_snapshot or "") 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=md, 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 = md _extract_and_store_image_intent_sync( session, story=story, version=version, markdown=md ) 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())