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.
This commit is contained in:
Kevin
2026-04-10 10:23:43 +08:00
parent b0251e5b26
commit ac49bc7f23
59 changed files with 4773 additions and 696 deletions

View File

@@ -6,6 +6,7 @@ Story 主插图生成 Celery 任务。
"""
import hashlib
import time
import uuid
from datetime import datetime, timedelta, timezone
@@ -17,6 +18,7 @@ from app.agents.image_prompt import get_image_prompt_orchestrator
from app.core.db import get_sync_db
from app.core.dependencies import get_image_generator
from app.core.logging import get_logger
from app.core.memoir_pipeline_progress import merge_fanout_item
from app.core.redis_lock import acquire_redis_lock, release_redis_lock
from app.features.asset.models import Asset
from app.features.memoir.asset_resolver import strip_asset_image_refs_from_markdown
@@ -149,15 +151,32 @@ def _claim_story_image_intent_sync(db, story_id: str, claim_token: str):
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
def generate_story_image(self, story_id: str):
def generate_story_image(
self, story_id: str, memoir_correlation_id: str | None = None
):
"""
为 story 生成主插图。
从 story_image_intents 原子认领 primary intent生成后写入 assets 并更新 intent。
"""
celery_tid = str(self.request.id)
t0 = time.perf_counter()
logger.info(
"event=story_image_task_start story_id={} task_id={} msg=故事主图生成任务开始",
story_id,
celery_tid,
)
lock_key = f"lock:story-image:{story_id}"
lock_handle = acquire_redis_lock(lock_key, ttl_seconds=STORY_IMAGE_LOCK_TTL_SECONDS)
if lock_handle is None:
logger.debug("generate_story_image: story={}, reason=locked", story_id)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="locked",
)
return {"status": "locked"}
claim_token = uuid.uuid4().hex
@@ -171,6 +190,14 @@ def generate_story_image(self, story_id: str):
"generate_story_image: story={}, reason=no_claimable_intent",
story_id,
)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="no_intent",
)
return {"status": "no_intent"}
intent, story = row
@@ -197,8 +224,25 @@ def generate_story_image(self, story_id: str):
len(plain),
min_body,
)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="skipped_body_too_short",
)
return {"status": "skipped_body_too_short"}
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="running",
)
image_generator = get_image_generator()
storage = TencentCosStorageService.from_env()
@@ -247,6 +291,14 @@ def generate_story_image(self, story_id: str):
getattr(intent_db, "status", None),
getattr(intent_db, "claim_token", None),
)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="superseded_or_cancelled",
)
return {"status": "superseded_or_cancelled"}
asset = Asset(
@@ -286,11 +338,27 @@ def generate_story_image(self, story_id: str):
url,
asset_id,
)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="success_stale",
)
return {"status": "success_stale", "asset_id": asset_id}
ver = db.get(StoryVersion, target_vid)
if not ver:
db.commit()
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="success_no_snapshot",
)
return {"status": "success_no_snapshot", "asset_id": asset_id}
base_md = strip_asset_image_refs_from_markdown(ver.markdown_snapshot or "")
@@ -326,10 +394,13 @@ def generate_story_image(self, story_id: str):
_enqueue_chapter_effects_after_image_backfill(story_id)
ms = (time.perf_counter() - t0) * 1000
logger.info(
"generate_story_image: story={}, asset={}",
"event=story_image_task_done story_id={} asset_id={} duration_ms={:.1f} "
"msg=故事主图生成完成",
story_id,
asset_id,
ms,
)
logger.debug(
"generate_story_image: story={} asset={} url={} cos_key={} prompt_final={}",
@@ -339,6 +410,14 @@ def generate_story_image(self, story_id: str):
cos_key,
prompt_final,
)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="success",
)
return {"status": "success", "asset_id": asset_id}
except Exception as exc:
if intent is not None:
@@ -355,7 +434,23 @@ def generate_story_image(self, story_id: str):
intent_db.error = str(exc)
intent_db.updated_at = datetime.now(timezone.utc)
db.commit()
logger.warning("generate_story_image failed: story={}, error={}", story_id, exc)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="failure",
extra={"error": str(exc)},
)
ms = (time.perf_counter() - t0) * 1000
logger.warning(
"event=story_image_task_failed story_id={} duration_ms={:.1f} error={} "
"msg=故事主图生成失败",
story_id,
ms,
exc,
)
raise self.retry(exc=exc) from exc
finally:
release_redis_lock(lock_handle)