refactor(eval+memoir):精简内部评测路由与服务,composite/对话摘要与 judge 能力补强
- 访谈:新增 interview_state_hints,联动 orchestrator 与提示词 - 回忆录:story_pipeline_sync/state/memory/post_commit 与 Celery 任务调整 - 基建:开发用 celery broker、compose/development 脚本、依赖注入 - eval-web:移除数据集/实验/版本等页面与流式轮询,突出 Playground - 文档与单测同步
This commit is contained in:
@@ -36,7 +36,8 @@ celery_app = Celery(
|
||||
"app.tasks.chapter_cover_tasks",
|
||||
"app.tasks.chapter_compose_tasks",
|
||||
"app.tasks.memory_compaction_tasks",
|
||||
"app.tasks.evaluation_tasks",
|
||||
"app.tasks.memory_enrichment_tasks",
|
||||
"app.tasks.memoir_quality_pass_tasks",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"""评测实验 Celery 任务。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="evaluation.run_experiment",
|
||||
max_retries=1,
|
||||
soft_time_limit=1800,
|
||||
time_limit=2400,
|
||||
)
|
||||
def run_eval_experiment_task(self, experiment_id: str) -> None:
|
||||
from app.features.evaluation.execution_service import execute_experiment_full
|
||||
|
||||
logger.info("evaluation task start experiment_id={}", experiment_id)
|
||||
asyncio.run(execute_experiment_full(experiment_id))
|
||||
logger.info("evaluation task done experiment_id={}", experiment_id)
|
||||
177
api/app/tasks/memoir_quality_pass_tasks.py
Normal file
177
api/app/tasks/memoir_quality_pass_tasks.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
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.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.story.sync_write import append_story_version_sync
|
||||
|
||||
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.agents.stage_constants import CHAPTER_CATEGORIES
|
||||
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.
|
||||
"""
|
||||
if not settings.memoir_quality_pass_enabled:
|
||||
return {"status": "disabled"}
|
||||
|
||||
t0 = time.perf_counter()
|
||||
logger.info(
|
||||
"event=quality_pass_start user_id={} stories={} chapters={} "
|
||||
"memoir_correlation_id={}",
|
||||
user_id,
|
||||
len(story_ids),
|
||||
len(chapter_ids),
|
||||
memoir_correlation_id or "",
|
||||
)
|
||||
|
||||
try:
|
||||
llm = _get_llm()
|
||||
if not llm:
|
||||
logger.warning("event=quality_pass_no_llm user_id={}", user_id)
|
||||
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
|
||||
logger.info(
|
||||
"event=quality_pass_done user_id={} titles_polished={} "
|
||||
"chapters_dirtied={} seconds={:.3f} memoir_correlation_id={}",
|
||||
user_id,
|
||||
titles_polished,
|
||||
len(chapters_dirtied),
|
||||
elapsed,
|
||||
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:
|
||||
recompose_chapter_task.apply_async(args=[ch_id], countdown=2)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"quality_pass recompose enqueue failed chapter={}: {}",
|
||||
ch_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Set
|
||||
@@ -67,6 +68,56 @@ logger = get_logger(__name__)
|
||||
_REDIS_CLIENTS: dict[bool, redis.Redis] = {}
|
||||
|
||||
|
||||
def _run_post_pipeline_commit(
|
||||
*,
|
||||
user_id: str,
|
||||
story_dispatch_ids: set[str],
|
||||
recompose_chapter_ids: set[str],
|
||||
cover_chapter_ids: set[str],
|
||||
trigger_source: str,
|
||||
need_compaction: bool,
|
||||
need_quality_pass: bool = False,
|
||||
memoir_correlation_id: str | None = None,
|
||||
compaction_extra: dict | None = None,
|
||||
) -> None:
|
||||
"""Shared post-commit dispatch: images, recompose, compaction, quality pass, covers."""
|
||||
from app.features.story.post_commit import enqueue_story_post_commit_effects
|
||||
|
||||
pc = enqueue_story_post_commit_effects(
|
||||
user_id=user_id,
|
||||
story_ids=set(story_dispatch_ids),
|
||||
chapter_ids=recompose_chapter_ids,
|
||||
trigger_source=trigger_source,
|
||||
need_compaction=need_compaction,
|
||||
need_quality_pass=need_quality_pass,
|
||||
memoir_correlation_id=memoir_correlation_id,
|
||||
compaction_extra=compaction_extra,
|
||||
)
|
||||
logger.info(
|
||||
"event=story_post_commit user_id={} trigger={} "
|
||||
"enqueued_story_image_count={} enqueued_chapter_recompose_count={} "
|
||||
"compaction_scheduled={} quality_pass_scheduled={} errors={}",
|
||||
user_id,
|
||||
trigger_source,
|
||||
pc.enqueued_story_image_count,
|
||||
pc.enqueued_chapter_recompose_count,
|
||||
pc.compaction_scheduled,
|
||||
pc.quality_pass_scheduled,
|
||||
pc.errors,
|
||||
)
|
||||
|
||||
if cover_chapter_ids:
|
||||
image_settings = MemoirImageSettings.from_env()
|
||||
if image_settings.enabled:
|
||||
from app.tasks.chapter_cover_enqueue import (
|
||||
try_enqueue_generate_chapter_cover,
|
||||
)
|
||||
|
||||
for ch_id in sorted(cover_chapter_ids):
|
||||
if try_enqueue_generate_chapter_cover(ch_id, source=trigger_source):
|
||||
logger.info("派发章节封面任务: chapter={}", ch_id)
|
||||
|
||||
|
||||
def _get_llm():
|
||||
"""Celery 任务内获取 LangChain LLM(通过 port)"""
|
||||
try:
|
||||
@@ -352,6 +403,7 @@ def process_memoir_phase2(
|
||||
cid = effective_correlation_id(
|
||||
explicit=memoir_correlation_id, celery_task_id=str(task_id)
|
||||
)
|
||||
phase2_t0 = time.perf_counter()
|
||||
logger.info(
|
||||
"event=memoir_phase2_start user_id={} task_id={} chapter_category={} "
|
||||
"memoir_correlation_id={}",
|
||||
@@ -408,9 +460,11 @@ def process_memoir_phase2(
|
||||
chapters_to_enqueue: Set[str] = set()
|
||||
affected_chapter_ids: Set[str] = set()
|
||||
|
||||
lock_t0 = time.perf_counter()
|
||||
lock_handle = _acquire_chapter_lock(
|
||||
user_id, chapter_category, ttl_seconds=_chapter_lock_ttl()
|
||||
)
|
||||
lock_elapsed = time.perf_counter() - lock_t0
|
||||
if lock_handle is None:
|
||||
logger.warning(
|
||||
"event=memoir_phase2_lock_busy user_id={} chapter_category={}",
|
||||
@@ -426,6 +480,7 @@ def process_memoir_phase2(
|
||||
return {"status": "noop"}
|
||||
|
||||
state = get_or_create_state_sync(user_id, db)
|
||||
pipeline_t0 = time.perf_counter()
|
||||
chapter, needs_cover, disp = run_story_pipeline_for_category_batch(
|
||||
db,
|
||||
user_id=user_id,
|
||||
@@ -439,6 +494,7 @@ def process_memoir_phase2(
|
||||
occupation=user_occupation,
|
||||
memoir_correlation_id=cid,
|
||||
)
|
||||
pipeline_elapsed = time.perf_counter() - pipeline_t0
|
||||
story_dispatch_ids |= disp
|
||||
db.flush()
|
||||
if chapter is None:
|
||||
@@ -489,16 +545,15 @@ def process_memoir_phase2(
|
||||
|
||||
db.commit()
|
||||
|
||||
from app.features.story.post_commit import (
|
||||
enqueue_story_post_commit_effects,
|
||||
)
|
||||
|
||||
pc = enqueue_story_post_commit_effects(
|
||||
_run_post_pipeline_commit(
|
||||
user_id=user_id,
|
||||
story_ids=set(story_dispatch_ids),
|
||||
chapter_ids=affected_chapter_ids,
|
||||
story_dispatch_ids=story_dispatch_ids,
|
||||
recompose_chapter_ids=affected_chapter_ids,
|
||||
cover_chapter_ids=chapters_to_enqueue,
|
||||
trigger_source="pipeline_phase2",
|
||||
need_compaction=True,
|
||||
need_quality_pass=True,
|
||||
memoir_correlation_id=cid,
|
||||
compaction_extra={
|
||||
"pipeline_run_id": str(task_id),
|
||||
"memoir_correlation_id": cid,
|
||||
@@ -507,35 +562,21 @@ def process_memoir_phase2(
|
||||
"chapter_category": chapter_category,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"event=story_post_commit user_id={} trigger=pipeline_phase2 "
|
||||
"enqueued_story_image_count={} enqueued_chapter_recompose_count={} "
|
||||
"compaction_scheduled={} errors={}",
|
||||
user_id,
|
||||
pc.enqueued_story_image_count,
|
||||
pc.enqueued_chapter_recompose_count,
|
||||
pc.compaction_scheduled,
|
||||
pc.errors,
|
||||
)
|
||||
|
||||
from app.tasks.chapter_cover_enqueue import (
|
||||
try_enqueue_generate_chapter_cover,
|
||||
)
|
||||
|
||||
for chapter_id in sorted(chapters_to_enqueue):
|
||||
if try_enqueue_generate_chapter_cover(
|
||||
chapter_id, source="pipeline_phase2"
|
||||
):
|
||||
logger.info(f"派发章节封面任务: chapter={chapter_id}")
|
||||
|
||||
phase2_elapsed = time.perf_counter() - phase2_t0
|
||||
logger.info(
|
||||
"event=memoir_phase2_done user_id={} task_id={} chapter_category={} "
|
||||
"segment_count={} memoir_correlation_id={}",
|
||||
"segment_count={} memoir_correlation_id={} "
|
||||
"lock_seconds={:.3f} pipeline_seconds={:.3f} "
|
||||
"phase2_total_seconds={:.3f}",
|
||||
user_id,
|
||||
task_id,
|
||||
chapter_category,
|
||||
len(category_segments),
|
||||
cid,
|
||||
lock_elapsed,
|
||||
pipeline_elapsed,
|
||||
phase2_elapsed,
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -574,6 +615,7 @@ def process_memoir_phase1(self, user_id: str, segment_ids: List[str]):
|
||||
memoir_correlation_id,
|
||||
)
|
||||
_update_task_status_sync(user_id, task_id, "running")
|
||||
phase1_t0 = time.perf_counter()
|
||||
|
||||
try:
|
||||
with get_sync_db() as db:
|
||||
@@ -595,6 +637,7 @@ def process_memoir_phase1(self, user_id: str, segment_ids: List[str]):
|
||||
)
|
||||
return {"status": "no_segments"}
|
||||
|
||||
ingest_t0 = time.perf_counter()
|
||||
for seg in segments:
|
||||
conv_id = getattr(seg, "conversation_id", None) or ""
|
||||
text = (seg.user_input_text or "").strip()
|
||||
@@ -629,15 +672,17 @@ def process_memoir_phase1(self, user_id: str, segment_ids: List[str]):
|
||||
e,
|
||||
type(e).__name__,
|
||||
)
|
||||
ingest_elapsed = time.perf_counter() - ingest_t0
|
||||
|
||||
llm = _get_llm()
|
||||
llm_fast = _get_llm_fast()
|
||||
llm_fast = _get_llm_fast() or llm
|
||||
if (settings.llm_fast_model or "").strip():
|
||||
logger.info(
|
||||
"event=llm_fast_tier_used pipeline=memoir_prepare_batches model={}",
|
||||
settings.llm_fast_model,
|
||||
)
|
||||
|
||||
prep_t0 = time.perf_counter()
|
||||
memoir_orchestrator = MemoirOrchestrator()
|
||||
prepared = memoir_orchestrator.prepare_batches(
|
||||
segments=list(segments),
|
||||
@@ -654,6 +699,7 @@ def process_memoir_phase1(self, user_id: str, segment_ids: List[str]):
|
||||
memoir_batch=True,
|
||||
),
|
||||
)
|
||||
prep_elapsed = time.perf_counter() - prep_t0
|
||||
|
||||
skip_ids = prepared.segment_skip_story_ids
|
||||
missing_cat = [
|
||||
@@ -709,6 +755,7 @@ def process_memoir_phase1(self, user_id: str, segment_ids: List[str]):
|
||||
_schedule_phase2_timeout(user_id, cc, memoir_correlation_id)
|
||||
|
||||
categories_processed = sorted(prepared.category_to_segments.keys())
|
||||
phase1_elapsed = time.perf_counter() - phase1_t0
|
||||
_update_task_status_sync(
|
||||
user_id,
|
||||
task_id,
|
||||
@@ -721,12 +768,17 @@ def process_memoir_phase1(self, user_id: str, segment_ids: List[str]):
|
||||
)
|
||||
logger.info(
|
||||
"event=memoir_phase1_done user_id={} task_id={} segment_count={} "
|
||||
"categories={} memoir_correlation_id={}",
|
||||
"categories={} memoir_correlation_id={} "
|
||||
"memory_ingest_seconds={:.3f} prepare_batches_seconds={:.3f} "
|
||||
"phase1_total_seconds={:.3f}",
|
||||
user_id,
|
||||
task_id,
|
||||
len(segments),
|
||||
categories_processed,
|
||||
memoir_correlation_id,
|
||||
ingest_elapsed,
|
||||
prep_elapsed,
|
||||
phase1_elapsed,
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -818,38 +870,22 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
|
||||
db.commit()
|
||||
db.refresh(chapter)
|
||||
|
||||
from app.features.story.post_commit import enqueue_story_post_commit_effects
|
||||
|
||||
ch_ids: set[str] = {str(chapter.id)}
|
||||
pc = enqueue_story_post_commit_effects(
|
||||
cover_ids = (
|
||||
ch_ids
|
||||
if chapter_needs_cover_enqueue(chapter)
|
||||
else set()
|
||||
)
|
||||
_run_post_pipeline_commit(
|
||||
user_id=user_id,
|
||||
story_ids=set(dispatch_ids),
|
||||
chapter_ids=ch_ids,
|
||||
trigger_source="pipeline",
|
||||
story_dispatch_ids=set(dispatch_ids),
|
||||
recompose_chapter_ids=ch_ids,
|
||||
cover_chapter_ids=cover_ids,
|
||||
trigger_source="pipeline_generate_chapter",
|
||||
need_compaction=False,
|
||||
need_quality_pass=True,
|
||||
memoir_correlation_id=cid,
|
||||
)
|
||||
logger.info(
|
||||
"event=story_post_commit user_id={} trigger=pipeline_generate_chapter "
|
||||
"enqueued_story_image_count={} enqueued_chapter_recompose_count={} "
|
||||
"compaction_scheduled={} errors={}",
|
||||
user_id,
|
||||
pc.enqueued_story_image_count,
|
||||
pc.enqueued_chapter_recompose_count,
|
||||
pc.compaction_scheduled,
|
||||
pc.errors,
|
||||
)
|
||||
|
||||
image_settings = MemoirImageSettings.from_env()
|
||||
if (
|
||||
image_settings.enabled
|
||||
and chapter
|
||||
and chapter_needs_cover_enqueue(chapter)
|
||||
):
|
||||
from app.tasks.chapter_cover_enqueue import (
|
||||
try_enqueue_generate_chapter_cover,
|
||||
)
|
||||
|
||||
try_enqueue_generate_chapter_cover(chapter.id, source="pipeline")
|
||||
return {"status": "success"}
|
||||
|
||||
except Retry:
|
||||
|
||||
45
api/app/tasks/memory_enrichment_tasks.py
Normal file
45
api/app/tasks/memory_enrichment_tasks.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Memory enrichment Celery task — runs asynchronously after ingest to generate
|
||||
summaries, facts, and timeline events without blocking the memoir hot path.
|
||||
"""
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_sync_db
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=2, default_retry_delay=30)
|
||||
def enrich_memory_source(self, user_id: str, source_id: str):
|
||||
"""
|
||||
Post-ingest enrichment: session summary, rolling summary, facts, timeline.
|
||||
Runs outside the memoir Phase1 hot path so narrative generation isn't blocked.
|
||||
"""
|
||||
if not settings.memory_enrichment_enabled:
|
||||
return {"status": "disabled"}
|
||||
|
||||
try:
|
||||
with get_sync_db() as db:
|
||||
from app.features.memory.enrichment import enrich_memory_after_ingest_sync
|
||||
|
||||
enrich_memory_after_ingest_sync(db, user_id, source_id, llm=None)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"event=memory_enrichment_done user_id={} source_id={}",
|
||||
user_id,
|
||||
source_id,
|
||||
)
|
||||
return {"status": "success", "source_id": source_id}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"event=memory_enrichment_failed user_id={} source_id={} exc={} exc_type={}",
|
||||
user_id,
|
||||
source_id,
|
||||
e,
|
||||
type(e).__name__,
|
||||
)
|
||||
raise self.retry(exc=e) from e
|
||||
Reference in New Issue
Block a user