配置 SSOT(TOML + .env) 统一错误契约 Auth 与事务边界 Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client 可观测性(OpenTelemetry + LGTM)
244 lines
8.1 KiB
Python
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
|