- 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.
240 lines
8.6 KiB
Python
240 lines
8.6 KiB
Python
"""
|
||
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 为空则跳过 image;chapter_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
|