""" Memoir quality pass — runs after fast draft commit to apply expensive quality enhancements without blocking the user-visible first draft. Enhancements: - Fidelity recheck on stories that skipped it during fast draft - Title polishing for stories with placeholder titles - LLM oral normalize rewrite (when memoir_oral_normalize_mode=llm) """ import time from celery import shared_task from celery.exceptions import Retry from sqlalchemy import select from sqlalchemy.orm import Session from app.agents.memoir.narrative_agent import NarrativeAgent from app.core.config import settings from app.core.db import get_sync_db from app.core.dependencies import get_llm_provider from app.core.logging import get_logger from app.core.memoir_pipeline_progress import merge_pipeline_run from app.features.memoir.models import Chapter from app.features.memoir.repo import mark_chapter_dirty_sync from app.features.story.models import Story logger = get_logger(__name__) def _get_llm(): try: return getattr(get_llm_provider(), "langchain_llm", None) except Exception: return None def _polish_story_title( session: Session, story: Story, llm, *, chapter_category: str, ) -> bool: """Re-generate title if current title is a placeholder. Returns True if updated.""" from app.features.memoir.story_pipeline_sync import _placeholder_title current = (story.title or "").strip() placeholder = _placeholder_title(chapter_category) if current and current != placeholder: return False body = (story.canonical_markdown or "").strip() if len(body) < settings.story_title_min_body_chars: return False narrative_agent = NarrativeAgent() content_excerpt = body[:300] new_title = narrative_agent.generate_title( stage=chapter_category, emotion="neutral", slots={"content_excerpt": content_excerpt}, user_profile="", birth_year=None, llm=llm, ) new_title = (new_title or "").strip() if not new_title or new_title == placeholder: return False story.title = new_title return True @shared_task(bind=True, max_retries=2, default_retry_delay=30) def memoir_quality_pass( self, user_id: str, story_ids: list[str], chapter_ids: list[str], memoir_correlation_id: str | None = None, ): """ Post-draft quality pass: polish titles, recheck fidelity on flagged stories. Runs asynchronously after the fast draft is committed and visible. """ qptid = str(self.request.id) if not settings.memoir_quality_pass_enabled: if memoir_correlation_id: merge_pipeline_run( memoir_correlation_id, { "fanout": { "quality_pass": {"task_id": qptid, "status": "disabled"}, }, }, ) return {"status": "disabled"} t0 = time.perf_counter() logger.info( "event=quality_pass_start user_id={} stories={} chapters={} " "memoir_correlation_id={} msg=成稿质量巡检开始", user_id, len(story_ids), len(chapter_ids), memoir_correlation_id or "", ) if memoir_correlation_id: merge_pipeline_run( memoir_correlation_id, { "fanout": { "quality_pass": {"task_id": qptid, "status": "running"}, }, }, ) try: llm = _get_llm() if not llm: logger.warning("event=quality_pass_no_llm user_id={}", user_id) if memoir_correlation_id: merge_pipeline_run( memoir_correlation_id, { "fanout": { "quality_pass": { "task_id": qptid, "status": "no_llm", }, }, }, ) return {"status": "no_llm"} titles_polished = 0 chapters_dirtied: set[str] = set() with get_sync_db() as db: for sid in story_ids: story = db.get(Story, sid) if not story or story.user_id != user_id: continue chapter_category = story.stage or "summary" if _polish_story_title( db, story, llm, chapter_category=chapter_category ): titles_polished += 1 stmt = ( select(Chapter.id) .where( Chapter.user_id == user_id, Chapter.category == chapter_category, Chapter.is_active == True, # noqa: E712 ) ) ch_id = db.execute(stmt).scalar_one_or_none() if ch_id: chapters_dirtied.add(str(ch_id)) for ch_id in chapters_dirtied: mark_chapter_dirty_sync(db, ch_id) if titles_polished > 0: db.commit() elapsed = time.perf_counter() - t0 duration_ms = elapsed * 1000 logger.info( "event=quality_pass_done user_id={} titles_polished={} " "chapters_dirtied={} duration_ms={:.1f} memoir_correlation_id={} " "msg=成稿质量巡检完成", user_id, titles_polished, len(chapters_dirtied), duration_ms, memoir_correlation_id or "", ) if chapters_dirtied: from app.tasks.chapter_compose_tasks import ( recompose_chapter as recompose_chapter_task, ) for ch_id in sorted(chapters_dirtied): try: rckw: dict = {} if memoir_correlation_id: rckw["memoir_correlation_id"] = memoir_correlation_id recompose_chapter_task.apply_async( args=[ch_id], kwargs=rckw, countdown=2 ) except Exception as exc: logger.warning( "quality_pass recompose enqueue failed chapter={}: {}", ch_id, exc, ) if memoir_correlation_id: merge_pipeline_run( memoir_correlation_id, { "fanout": { "quality_pass": { "task_id": qptid, "status": "success", "detail": { "titles_polished": titles_polished, "chapters_dirtied": len(chapters_dirtied), }, }, }, }, ) return { "status": "success", "titles_polished": titles_polished, "chapters_dirtied": len(chapters_dirtied), } except Retry: raise except Exception as e: logger.error( "event=quality_pass_failed user_id={} exc={}", user_id, e ) if memoir_correlation_id: merge_pipeline_run( memoir_correlation_id, { "fanout": { "quality_pass": { "task_id": qptid, "status": "failure", "detail": {"error": str(e)}, }, }, }, ) raise self.retry(exc=e) from e