Files
life-echo/api/app/features/story/post_commit.py
Kevin ac49bc7f23 feat(eval): memoir A/B chapter judging and eval-web parity with dialogue
- Judge baseline excerpt and library chapter separately; build_memoir_compare_summary for gate, nine-dim and leaf deltas.

- Memoir SSE chapter payload: baseline_judge, compare_summary, baseline_judge_error.

- MemoirJudgeOutput: loose score coercion and post-validate clamp; memoir judge prompt caps from settings.

- app-eval-web: two-column MemoirScoreCard layout, MemoirCompareSummary, chapter blocks and CSS.

- Add memoir_compare_summary, log_events, celery_log_context, memoir_pipeline_progress; tests and migration 0014.

- Misc: memory/evidence and enrichment paths, task/orchestrator updates, internal-eval docs, env examples.
2026-04-10 10:25:15 +08:00

240 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Story 写入提交后的统一异步调度:插图、章节物化、可选 memory compaction。
enqueue 失败不回滚已提交数据,仅记录日志;依赖后续触发或重试收敛。
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, cast
import redis
from app.core.config import settings
from app.core.logging import get_logger
from app.core.memoir_pipeline_progress import merge_pipeline_run
from app.core.memory_compaction_schedule import schedule_memory_compaction_run
logger = get_logger(__name__)
_redis_client: redis.Redis | None = None
_redis_lock = threading.Lock()
def _story_image_enqueue_key(story_id: str) -> str:
return f"enqueue:story-image:{story_id}"
def _get_redis() -> redis.Redis:
"""进程内复用单个 Redis 客户端,避免重复创建连接池。"""
global _redis_client
if _redis_client is None:
with _redis_lock:
if _redis_client is None:
_redis_client = redis.from_url(
settings.redis_url, decode_responses=True
)
return _redis_client
@dataclass
class PostCommitResult:
enqueued_story_image_count: int = 0
enqueued_chapter_recompose_count: int = 0
compaction_scheduled: bool = False
quality_pass_scheduled: bool = False
errors: list[str] = field(default_factory=list)
def enqueue_story_post_commit_effects(
*,
user_id: str,
story_ids: set[str],
chapter_ids: set[str],
trigger_source: str,
need_image: bool = True,
need_recompose: bool = True,
need_compaction: bool = False,
need_quality_pass: bool = False,
compaction_extra: dict[str, Any] | None = None,
memoir_correlation_id: str | None = None,
) -> PostCommitResult:
"""
Unified post-commit fan-out: story images, chapter recompose, compaction,
and quality pass. story_ids 为空则跳过 imagechapter_ids 为空则跳过 recompose。
need_compaction=True 时仅按 user_id 调度 compaction不依赖 story/chapter 集合)。
"""
result = PostCommitResult()
r = _get_redis()
ttl = int(settings.story_image_enqueue_dedup_ttl)
if need_image and story_ids:
from app.tasks.story_image_tasks import (
generate_story_image as gen_story_image_task,
)
for sid in sorted(story_ids):
key = _story_image_enqueue_key(sid)
try:
if not r.set(key, "1", nx=True, ex=ttl):
logger.debug(
"story_image enqueue skipped (dedup): story={} trigger={}",
sid,
trigger_source,
)
continue
except Exception as exc:
logger.warning(
"story_image enqueue dedup redis failed, allowing enqueue: "
"story={} err={}",
sid,
exc,
)
try:
img_ar = cast(Any, gen_story_image_task).delay(
sid, memoir_correlation_id=memoir_correlation_id
)
result.enqueued_story_image_count += 1
tid_img = getattr(img_ar, "id", None)
if memoir_correlation_id and tid_img:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"story_images": [
{
"story_id": sid,
"task_id": str(tid_img),
"status": "enqueued",
}
]
},
},
)
except Exception as exc:
logger.warning(
"generate_story_image.delay failed story={} trigger={}: {}",
sid,
trigger_source,
exc,
)
result.errors.append(f"generate_story_image:{sid}:{exc}")
try:
r.delete(key)
except Exception as e:
logger.debug("Redis key 清理失败: {}", e)
if need_recompose and chapter_ids:
from app.tasks.chapter_compose_tasks import (
recompose_chapter as recompose_chapter_task,
)
cd = int(settings.recompose_chapter_delay_seconds)
for cid in sorted(chapter_ids):
try:
rkwargs: dict[str, Any] = {}
if memoir_correlation_id:
rkwargs["memoir_correlation_id"] = memoir_correlation_id
rec_ar = cast(Any, recompose_chapter_task).apply_async(
args=[cid],
kwargs=rkwargs,
countdown=max(0, cd),
)
result.enqueued_chapter_recompose_count += 1
tid_rec = getattr(rec_ar, "id", None)
if memoir_correlation_id and tid_rec:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"recompose_chapters": [
{
"chapter_id": cid,
"task_id": str(tid_rec),
"status": "enqueued",
}
]
},
},
)
except Exception as exc:
logger.warning(
"recompose_chapter.apply_async failed chapter={} trigger={}: {}",
cid,
trigger_source,
exc,
)
result.errors.append(f"recompose_chapter:{cid}:{exc}")
if need_compaction:
try:
ctx: dict[str, Any] = {
"trigger_source": trigger_source,
"trigger_time": datetime.now(timezone.utc).isoformat(),
"story_ids": sorted(story_ids),
"chapter_ids": sorted(chapter_ids),
}
if compaction_extra:
ctx.update(compaction_extra)
schedule_memory_compaction_run(user_id, ctx)
result.compaction_scheduled = True
if memoir_correlation_id:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"compaction": {
"status": "scheduled",
"note": "debounce",
},
},
},
)
except Exception as exc:
logger.warning(
"schedule_memory_compaction_run failed user_id={} trigger={}: {}",
user_id,
trigger_source,
exc,
)
result.errors.append(f"compaction:{exc}")
if need_quality_pass and settings.memoir_quality_pass_enabled and story_ids:
try:
from app.tasks.memoir_quality_pass_tasks import (
memoir_quality_pass as quality_pass_task,
)
cd = int(settings.memoir_quality_pass_delay_seconds)
qp_ar = cast(Any, quality_pass_task).apply_async(
args=[user_id, sorted(story_ids), sorted(chapter_ids)],
kwargs={"memoir_correlation_id": memoir_correlation_id},
countdown=max(0, cd),
)
result.quality_pass_scheduled = True
tid_qp = getattr(qp_ar, "id", None)
if memoir_correlation_id and tid_qp:
merge_pipeline_run(
memoir_correlation_id,
{
"fanout": {
"quality_pass": {
"task_id": str(tid_qp),
"status": "enqueued",
},
},
},
)
except Exception as exc:
logger.warning(
"memoir_quality_pass enqueue failed user_id={} trigger={}: {}",
user_id,
trigger_source,
exc,
)
result.errors.append(f"quality_pass:{exc}")
return result