2026-03-20 10:30:07 +08:00
|
|
|
|
"""
|
|
|
|
|
|
StoryService — Story 层业务逻辑。
|
|
|
|
|
|
|
|
|
|
|
|
- 创建 story、版本、evidence 关联
|
|
|
|
|
|
- 不直接依赖 agent,由 orchestrator 调用
|
|
|
|
|
|
- story 正文生成后提取 primary image intent 并落库
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
from app.core.db import transactional
|
|
|
|
|
|
from app.core.errors import NotFoundError
|
2026-03-20 10:30:07 +08:00
|
|
|
|
from app.core.logging import get_logger
|
2026-03-20 15:15:35 +08:00
|
|
|
|
from app.features.memoir import repo as memoir_repo
|
2026-03-30 11:53:04 +08:00
|
|
|
|
from app.features.memoir.asset_resolver import strip_asset_image_refs_from_markdown
|
2026-03-23 13:54:41 +08:00
|
|
|
|
from app.features.memoir.memoir_images.settings import MemoirImageSettings
|
2026-03-20 10:30:07 +08:00
|
|
|
|
from app.features.story.image_intent_extractor import extract_primary_image_intent
|
|
|
|
|
|
from app.features.story.repo import (
|
|
|
|
|
|
count_story_versions,
|
|
|
|
|
|
create_story,
|
|
|
|
|
|
create_story_evidence_link,
|
|
|
|
|
|
create_story_image_intent,
|
|
|
|
|
|
create_story_version,
|
|
|
|
|
|
delete_story_image_intents_by_story,
|
|
|
|
|
|
get_stories_for_user,
|
|
|
|
|
|
get_story_by_id,
|
|
|
|
|
|
get_story_image_intent_by_story,
|
|
|
|
|
|
)
|
2026-03-30 11:53:04 +08:00
|
|
|
|
from app.features.story.time_hints import apply_infer_story_time_start_to_model
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _extract_and_store_image_intent(
|
|
|
|
|
|
db,
|
|
|
|
|
|
*,
|
|
|
|
|
|
story,
|
|
|
|
|
|
version,
|
|
|
|
|
|
markdown: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
从 markdown 提取 primary intent。
|
|
|
|
|
|
仅移除 pending/failed,避免删掉正在 processing 的旧任务行;同版本则原地更新行以幂等。
|
|
|
|
|
|
"""
|
2026-03-23 13:54:41 +08:00
|
|
|
|
img_settings = MemoirImageSettings.from_env()
|
|
|
|
|
|
plain = strip_asset_image_refs_from_markdown(markdown or "").strip()
|
|
|
|
|
|
min_chars = img_settings.story_image_min_body_chars
|
|
|
|
|
|
if min_chars > 0 and len(plain) < min_chars:
|
|
|
|
|
|
await delete_story_image_intents_by_story(
|
|
|
|
|
|
db, story.id, statuses=["pending", "failed"]
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.debug(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"story image intent skipped: body below min chars story={} len={} min={}",
|
2026-03-23 13:54:41 +08:00
|
|
|
|
story.id,
|
|
|
|
|
|
len(plain),
|
|
|
|
|
|
min_chars,
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-20 10:30:07 +08:00
|
|
|
|
await delete_story_image_intents_by_story(
|
|
|
|
|
|
db, story.id, statuses=["pending", "failed"]
|
|
|
|
|
|
)
|
|
|
|
|
|
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 = await get_story_image_intent_by_story(db, story.id, role="primary")
|
|
|
|
|
|
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:
|
|
|
|
|
|
# 复用同一主键行,避免删行导致进行中的 Celery 任务找不到 intent
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
await create_story_image_intent(
|
|
|
|
|
|
db,
|
|
|
|
|
|
story_id=story.id,
|
|
|
|
|
|
story_version_id=version.id,
|
|
|
|
|
|
caption=result.caption,
|
|
|
|
|
|
prompt_brief=result.prompt_brief,
|
|
|
|
|
|
style_profile=result.style_profile,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StoryService:
|
|
|
|
|
|
def __init__(self, db: AsyncSession):
|
|
|
|
|
|
self._db = db
|
|
|
|
|
|
|
|
|
|
|
|
async def create_story(
|
|
|
|
|
|
self,
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
stage: str | None = None,
|
|
|
|
|
|
story_type: str | None = None,
|
|
|
|
|
|
summary: str | None = None,
|
|
|
|
|
|
canonical_markdown: str | None = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Create story, commit, return story_id."""
|
2026-03-20 17:25:42 +08:00
|
|
|
|
md = strip_asset_image_refs_from_markdown(canonical_markdown or "")
|
2026-05-22 13:44:50 +08:00
|
|
|
|
async with transactional(self._db):
|
|
|
|
|
|
story = await create_story(
|
2026-03-20 10:30:07 +08:00
|
|
|
|
self._db,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
title=title,
|
|
|
|
|
|
stage=stage,
|
|
|
|
|
|
story_type=story_type,
|
|
|
|
|
|
summary=summary,
|
|
|
|
|
|
canonical_markdown=md,
|
2026-03-20 10:30:07 +08:00
|
|
|
|
)
|
|
|
|
|
|
await self._db.flush()
|
2026-05-22 13:44:50 +08:00
|
|
|
|
apply_infer_story_time_start_to_model(story)
|
|
|
|
|
|
if md.strip():
|
|
|
|
|
|
version = await create_story_version(
|
|
|
|
|
|
self._db,
|
|
|
|
|
|
story_id=story.id,
|
|
|
|
|
|
version_no=1,
|
|
|
|
|
|
markdown_snapshot=md,
|
|
|
|
|
|
actor_type="ai",
|
|
|
|
|
|
source_type="generate",
|
|
|
|
|
|
)
|
|
|
|
|
|
await self._db.flush()
|
|
|
|
|
|
story.current_version_id = version.id
|
|
|
|
|
|
await _extract_and_store_image_intent(
|
|
|
|
|
|
self._db,
|
|
|
|
|
|
story=story,
|
|
|
|
|
|
version=version,
|
|
|
|
|
|
markdown=md,
|
|
|
|
|
|
)
|
|
|
|
|
|
if md.strip():
|
|
|
|
|
|
await memoir_repo.mark_chapters_dirty_for_story(self._db, story.id)
|
|
|
|
|
|
story_id = story.id
|
2026-03-20 17:25:42 +08:00
|
|
|
|
if md.strip():
|
2026-03-30 11:53:04 +08:00
|
|
|
|
from app.features.memoir.repo import get_chapter_ids_linked_to_story
|
|
|
|
|
|
from app.features.story.post_commit import enqueue_story_post_commit_effects
|
|
|
|
|
|
|
2026-05-22 13:44:50 +08:00
|
|
|
|
chapter_ids = set(await get_chapter_ids_linked_to_story(self._db, story_id))
|
2026-03-30 11:53:04 +08:00
|
|
|
|
pc = enqueue_story_post_commit_effects(
|
|
|
|
|
|
user_id=user_id,
|
2026-05-22 13:44:50 +08:00
|
|
|
|
story_ids={story_id},
|
2026-03-30 11:53:04 +08:00
|
|
|
|
chapter_ids=chapter_ids,
|
|
|
|
|
|
trigger_source="manual_api",
|
|
|
|
|
|
need_compaction=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"event=story_post_commit user_id={} trigger=manual_api "
|
|
|
|
|
|
"enqueued_story_image_count={} enqueued_chapter_recompose_count={} "
|
|
|
|
|
|
"errors={}",
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
pc.enqueued_story_image_count,
|
|
|
|
|
|
pc.enqueued_chapter_recompose_count,
|
|
|
|
|
|
pc.errors,
|
|
|
|
|
|
)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
return story_id
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
async def append_version(
|
|
|
|
|
|
self,
|
|
|
|
|
|
story_id: str,
|
|
|
|
|
|
markdown_snapshot: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
actor_type: str = "ai",
|
|
|
|
|
|
source_type: str = "generate",
|
|
|
|
|
|
change_summary: str | None = None,
|
|
|
|
|
|
prompt_meta: dict | None = None,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Append new version, update canonical_markdown, return version_id."""
|
|
|
|
|
|
story = await get_story_by_id(self._db, story_id)
|
|
|
|
|
|
if not story:
|
2026-05-22 13:44:50 +08:00
|
|
|
|
raise NotFoundError(f"Story {story_id} not found")
|
2026-03-20 17:25:42 +08:00
|
|
|
|
md = strip_asset_image_refs_from_markdown(markdown_snapshot or "")
|
2026-03-20 10:30:07 +08:00
|
|
|
|
parent_id = story.current_version_id
|
|
|
|
|
|
version_no = (await count_story_versions(self._db, story_id)) + 1
|
2026-05-22 13:44:50 +08:00
|
|
|
|
async with transactional(self._db):
|
|
|
|
|
|
version = await create_story_version(
|
|
|
|
|
|
self._db,
|
|
|
|
|
|
story_id=story_id,
|
|
|
|
|
|
version_no=version_no,
|
|
|
|
|
|
markdown_snapshot=md,
|
|
|
|
|
|
actor_type=actor_type,
|
|
|
|
|
|
source_type=source_type,
|
|
|
|
|
|
parent_version_id=parent_id,
|
|
|
|
|
|
prompt_meta=prompt_meta,
|
|
|
|
|
|
)
|
|
|
|
|
|
version.change_summary = change_summary
|
|
|
|
|
|
story.current_version_id = version.id
|
|
|
|
|
|
story.canonical_markdown = md
|
|
|
|
|
|
apply_infer_story_time_start_to_model(story)
|
|
|
|
|
|
await _extract_and_store_image_intent(
|
|
|
|
|
|
self._db,
|
|
|
|
|
|
story=story,
|
|
|
|
|
|
version=version,
|
|
|
|
|
|
markdown=md,
|
|
|
|
|
|
)
|
|
|
|
|
|
await memoir_repo.mark_chapters_dirty_for_story(self._db, story_id)
|
|
|
|
|
|
version_id = version.id
|
2026-03-30 11:53:04 +08:00
|
|
|
|
from app.features.memoir.repo import get_chapter_ids_linked_to_story
|
|
|
|
|
|
from app.features.story.post_commit import enqueue_story_post_commit_effects
|
|
|
|
|
|
|
|
|
|
|
|
chapter_ids = set(await get_chapter_ids_linked_to_story(self._db, story_id))
|
|
|
|
|
|
pc = enqueue_story_post_commit_effects(
|
|
|
|
|
|
user_id=story.user_id,
|
|
|
|
|
|
story_ids={story_id},
|
|
|
|
|
|
chapter_ids=chapter_ids,
|
|
|
|
|
|
trigger_source="manual_api",
|
|
|
|
|
|
need_compaction=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"event=story_post_commit user_id={} trigger=manual_api_append "
|
|
|
|
|
|
"enqueued_story_image_count={} enqueued_chapter_recompose_count={} errors={}",
|
|
|
|
|
|
story.user_id,
|
|
|
|
|
|
pc.enqueued_story_image_count,
|
|
|
|
|
|
pc.enqueued_chapter_recompose_count,
|
|
|
|
|
|
pc.errors,
|
|
|
|
|
|
)
|
2026-05-22 13:44:50 +08:00
|
|
|
|
return version_id
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
async def link_evidence(
|
|
|
|
|
|
self,
|
|
|
|
|
|
story_id: str,
|
|
|
|
|
|
evidence_type: str,
|
|
|
|
|
|
evidence_id: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
role: str = "primary",
|
|
|
|
|
|
weight: float | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Add evidence link. Caller must ensure story exists."""
|
2026-05-22 13:44:50 +08:00
|
|
|
|
async with transactional(self._db):
|
|
|
|
|
|
await create_story_evidence_link(
|
|
|
|
|
|
self._db,
|
|
|
|
|
|
story_id=story_id,
|
|
|
|
|
|
evidence_type=evidence_type,
|
|
|
|
|
|
evidence_id=evidence_id,
|
|
|
|
|
|
role=role,
|
|
|
|
|
|
weight=weight,
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
|
|
|
|
|
async def get_stories(
|
|
|
|
|
|
self, user_id: str, *, status: str | None = "active"
|
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
|
"""List stories for user."""
|
|
|
|
|
|
stories = await get_stories_for_user(self._db, user_id, status=status)
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": s.id,
|
|
|
|
|
|
"title": s.title,
|
|
|
|
|
|
"stage": s.stage,
|
|
|
|
|
|
"story_type": s.story_type,
|
|
|
|
|
|
"summary": s.summary,
|
|
|
|
|
|
"canonical_markdown": s.canonical_markdown,
|
|
|
|
|
|
"status": s.status,
|
|
|
|
|
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
for s in stories
|
|
|
|
|
|
]
|