Files
life-echo/api/app/tasks/memoir_quality_pass_tasks.py
Sully 53e0065e3e refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)
配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
2026-05-22 13:44:50 +08:00

244 lines
8.1 KiB
Python

"""
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, transactional_sync
from app.core.llm_gateway import LlmGateway, LlmUseCase
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
from app.features.memoir.constants import memoir
from app.features.story.constants import story
logger = get_logger(__name__)
def _get_llm():
try:
return LlmGateway().langchain_llm_for(LlmUseCase("memoir_quality_pass"))
except Exception:
return None
def _polish_story_title(
session: Session,
story: Story,
llm,
*,
chapter_category: str,
language: str = "zh",
) -> 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_zh = _placeholder_title(chapter_category, language="zh")
placeholder_en = _placeholder_title(chapter_category, language="en")
placeholder = _placeholder_title(chapter_category, language=language)
if current and current not in (placeholder_zh, placeholder_en):
return False
body = (story.canonical_markdown or "").strip()
if len(body) < 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,
language=language,
)
new_title = (new_title or "").strip()
if not new_title or new_title in (placeholder_zh, placeholder_en, placeholder):
return False
story.title = new_title
return True
@shared_task(bind=True, max_retries=2, default_retry_delay=30, ignore_result=True)
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 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:
from app.features.user.models import User
user_obj = db.get(User, user_id)
user_language = (
"en"
if user_obj is not None
and str(getattr(user_obj, "language_preference", "zh") or "zh").lower()
== "en"
else "zh"
)
with transactional_sync(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,
language=user_language,
):
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)
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)
raise self.retry(exc=e) from e