2026-01-21 23:06:47 +01:00
|
|
|
|
"""
|
|
|
|
|
|
回忆录处理 Celery 任务
|
|
|
|
|
|
"""
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
import json
|
|
|
|
|
|
import uuid
|
2026-01-21 23:37:00 +01:00
|
|
|
|
from datetime import datetime, timezone
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from typing import Dict, List, Set
|
2026-01-21 23:06:47 +01:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
import redis
|
2026-01-21 23:06:47 +01:00
|
|
|
|
from celery import shared_task
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
2026-01-21 23:06:47 +01:00
|
|
|
|
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from app.agents.chat.prompts_profile import format_user_profile_context
|
|
|
|
|
|
from app.agents.memoir import MemoirOrchestrator
|
|
|
|
|
|
from app.agents.state_schema import MemoirStateSchema, SlotData, default_state
|
2026-03-30 11:53:04 +08:00
|
|
|
|
from app.core.chapter_pipeline_lock import (
|
|
|
|
|
|
acquire_chapter_pipeline_lock as _acquire_chapter_lock,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.core.chapter_pipeline_lock import (
|
|
|
|
|
|
release_chapter_pipeline_lock as _release_chapter_lock,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.core.config import settings
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.core.db import get_sync_db
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from app.core.dependencies import get_llm_provider
|
|
|
|
|
|
from app.core.logging import get_logger
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.conversation.models import Segment
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from app.features.memoir.cover_eligibility import (
|
|
|
|
|
|
chapter_needs_cover_enqueue,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
from app.features.memoir.memoir_images.parser import (
|
2026-03-13 11:12:10 +08:00
|
|
|
|
build_initial_image_assets,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.memoir.memoir_images.schema import (
|
2026-03-11 11:26:42 +08:00
|
|
|
|
IMAGE_STATUS_COMPLETED,
|
|
|
|
|
|
IMAGE_STATUS_FAILED,
|
|
|
|
|
|
IMAGE_STATUS_PENDING,
|
|
|
|
|
|
normalize_image_assets,
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.memoir.memoir_images.serializers import (
|
|
|
|
|
|
image_dict_to_row_kwargs,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.features.memoir.memoir_images.settings import MemoirImageSettings
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from app.features.memoir.models import (
|
|
|
|
|
|
Book,
|
|
|
|
|
|
MemoirImage,
|
|
|
|
|
|
MemoirState,
|
2026-03-20 15:15:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
from app.features.memoir.story_pipeline_sync import (
|
|
|
|
|
|
run_story_pipeline_for_category_batch,
|
|
|
|
|
|
)
|
2026-03-22 16:45:57 +08:00
|
|
|
|
from app.features.user.models import User
|
2026-01-21 23:06:47 +01:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
logger = get_logger(__name__)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
_REDIS_CLIENTS: dict[bool, redis.Redis] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
def _get_llm():
|
|
|
|
|
|
"""Celery 任务内获取 LangChain LLM(通过 port)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return getattr(get_llm_provider(), "langchain_llm", None)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
def _get_redis_client(*, decode_responses: bool = False) -> redis.Redis:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
2026-03-11 11:26:42 +08:00
|
|
|
|
client = _REDIS_CLIENTS.get(decode_responses)
|
|
|
|
|
|
if client is None:
|
|
|
|
|
|
client = redis.from_url(
|
2026-03-18 17:18:23 +08:00
|
|
|
|
settings.redis_url,
|
2026-03-11 11:26:42 +08:00
|
|
|
|
decode_responses=decode_responses,
|
|
|
|
|
|
)
|
|
|
|
|
|
_REDIS_CLIENTS[decode_responses] = client
|
|
|
|
|
|
return client
|
2026-01-21 23:06:47 +01:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
|
2026-03-30 11:53:04 +08:00
|
|
|
|
def _chapter_lock_ttl() -> int:
|
|
|
|
|
|
return int(settings.chapter_pipeline_lock_ttl_seconds)
|
2026-02-21 09:33:31 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 14:36:14 +08:00
|
|
|
|
def _update_task_status_sync(
|
|
|
|
|
|
user_id: str, task_id: str, status: str, result: Dict = None
|
|
|
|
|
|
):
|
2026-01-21 23:37:00 +01:00
|
|
|
|
"""同步更新任务状态(在 Celery 任务中使用)"""
|
|
|
|
|
|
try:
|
2026-03-11 11:26:42 +08:00
|
|
|
|
r = _get_redis_client(decode_responses=True)
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
key = f"task:user:{user_id}:tasks"
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
# 获取现有任务信息
|
|
|
|
|
|
data = r.hget(key, task_id)
|
|
|
|
|
|
if data:
|
|
|
|
|
|
task_info = json.loads(data)
|
|
|
|
|
|
else:
|
|
|
|
|
|
task_info = {"task_id": task_id}
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
task_info["status"] = status
|
|
|
|
|
|
task_info["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
if result is not None:
|
|
|
|
|
|
task_info["result"] = result
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
r.hset(key, task_id, json.dumps(task_info))
|
|
|
|
|
|
r.expire(key, 3600) # 1小时过期
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-03-26 12:13:36 +08:00
|
|
|
|
logger.debug("任务状态已更新: task_id={} status={}", task_id, status)
|
2026-01-21 23:37:00 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"更新任务状态失败: {e}")
|
|
|
|
|
|
|
2026-03-10 17:02:50 +08:00
|
|
|
|
|
|
|
|
|
|
def _merge_chapter_image_assets(
|
|
|
|
|
|
existing_images: list[dict] | None,
|
|
|
|
|
|
placeholders: list[dict],
|
|
|
|
|
|
provider: str,
|
|
|
|
|
|
style: str,
|
|
|
|
|
|
size: str,
|
|
|
|
|
|
now_iso: str,
|
|
|
|
|
|
) -> list[dict]:
|
2026-03-11 11:26:42 +08:00
|
|
|
|
normalized_existing_images = normalize_image_assets(existing_images)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
existing_by_placeholder = {
|
|
|
|
|
|
item.get("placeholder"): dict(item)
|
2026-03-11 11:26:42 +08:00
|
|
|
|
for item in normalized_existing_images
|
2026-03-10 17:02:50 +08:00
|
|
|
|
if item.get("placeholder")
|
|
|
|
|
|
}
|
|
|
|
|
|
merged_assets: list[dict] = []
|
|
|
|
|
|
|
|
|
|
|
|
for item in placeholders:
|
|
|
|
|
|
existing = existing_by_placeholder.get(item["placeholder"])
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
merged_item = dict(existing)
|
|
|
|
|
|
merged_item["index"] = item["index"]
|
|
|
|
|
|
merged_item["placeholder"] = item["placeholder"]
|
|
|
|
|
|
merged_item["description"] = item["description"]
|
|
|
|
|
|
merged_item["provider"] = merged_item.get("provider") or provider
|
|
|
|
|
|
merged_item["style"] = merged_item.get("style") or style
|
|
|
|
|
|
merged_item["size"] = merged_item.get("size") or size
|
|
|
|
|
|
merged_item["created_at"] = merged_item.get("created_at") or now_iso
|
|
|
|
|
|
merged_item["updated_at"] = merged_item.get("updated_at") or now_iso
|
2026-03-11 11:26:42 +08:00
|
|
|
|
if merged_item.get("status") == IMAGE_STATUS_COMPLETED and not (
|
2026-03-11 10:06:12 +08:00
|
|
|
|
merged_item.get("storage_key") or merged_item.get("url")
|
|
|
|
|
|
):
|
2026-03-11 11:26:42 +08:00
|
|
|
|
merged_item["status"] = IMAGE_STATUS_FAILED
|
2026-03-10 17:02:50 +08:00
|
|
|
|
merged_item["error"] = merged_item.get("error") or "missing image url"
|
|
|
|
|
|
else:
|
|
|
|
|
|
merged_item = build_initial_image_assets(
|
|
|
|
|
|
placeholders=[item],
|
|
|
|
|
|
provider=provider,
|
|
|
|
|
|
style=style,
|
|
|
|
|
|
size=size,
|
|
|
|
|
|
now_iso=now_iso,
|
|
|
|
|
|
)[0]
|
|
|
|
|
|
merged_assets.append(merged_item)
|
|
|
|
|
|
|
|
|
|
|
|
return merged_assets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def chapter_has_images_to_generate(images: list[dict] | None) -> bool:
|
2026-03-11 11:26:42 +08:00
|
|
|
|
return any(
|
|
|
|
|
|
item.get("status") in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED}
|
|
|
|
|
|
for item in normalize_image_assets(images)
|
|
|
|
|
|
)
|
2026-03-10 17:02:50 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 11:12:10 +08:00
|
|
|
|
def _memoir_image_from_asset(
|
|
|
|
|
|
chapter_id: str,
|
|
|
|
|
|
order_index: int,
|
|
|
|
|
|
image_asset: dict,
|
|
|
|
|
|
) -> MemoirImage:
|
|
|
|
|
|
"""从单条图片 dict 构建 MemoirImage 行(用于写入 memoir_images 表)。"""
|
|
|
|
|
|
kwargs = image_dict_to_row_kwargs(image_asset)
|
|
|
|
|
|
return MemoirImage(
|
|
|
|
|
|
id=str(uuid.uuid4()).replace("-", "")[:32],
|
|
|
|
|
|
chapter_id=chapter_id,
|
|
|
|
|
|
order_index=order_index,
|
|
|
|
|
|
**kwargs,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
def _coerce_state(model: MemoirState) -> MemoirStateSchema:
|
|
|
|
|
|
"""将数据库模型转换为 Schema"""
|
|
|
|
|
|
return MemoirStateSchema.model_validate(
|
|
|
|
|
|
{
|
|
|
|
|
|
"stage_order": model.stage_order or default_state().stage_order,
|
|
|
|
|
|
"current_stage": model.current_stage,
|
|
|
|
|
|
"covered_stages": model.covered_stages or [],
|
2026-03-19 14:36:14 +08:00
|
|
|
|
"slots": model.slots
|
|
|
|
|
|
if isinstance(model.slots, dict)
|
|
|
|
|
|
else default_state().slots,
|
2026-01-21 23:06:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_or_create_state_sync(user_id: str, db: Session) -> MemoirStateSchema:
|
|
|
|
|
|
"""同步获取或创建状态"""
|
|
|
|
|
|
stmt = select(MemoirState).where(MemoirState.user_id == user_id)
|
|
|
|
|
|
result = db.execute(stmt)
|
|
|
|
|
|
state = result.scalar_one_or_none()
|
|
|
|
|
|
if state:
|
|
|
|
|
|
return _coerce_state(state)
|
|
|
|
|
|
|
|
|
|
|
|
default = default_state()
|
|
|
|
|
|
state = MemoirState(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
stage_order=default.stage_order,
|
|
|
|
|
|
current_stage=default.current_stage,
|
|
|
|
|
|
covered_stages=default.covered_stages,
|
2026-03-19 14:36:14 +08:00
|
|
|
|
slots={
|
|
|
|
|
|
k: {sk: sv.model_dump() for sk, sv in v.items()}
|
|
|
|
|
|
for k, v in default.slots.items()
|
|
|
|
|
|
},
|
2026-01-21 23:06:47 +01:00
|
|
|
|
)
|
|
|
|
|
|
db.add(state)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(state)
|
|
|
|
|
|
return _coerce_state(state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _update_slot_sync(
|
|
|
|
|
|
user_id: str,
|
|
|
|
|
|
stage: str,
|
|
|
|
|
|
slot_name: str,
|
|
|
|
|
|
snippet: str,
|
|
|
|
|
|
segment_ids: List[str],
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
) -> MemoirStateSchema:
|
|
|
|
|
|
"""同步更新 slot"""
|
|
|
|
|
|
stmt = select(MemoirState).where(MemoirState.user_id == user_id)
|
|
|
|
|
|
result = db.execute(stmt)
|
|
|
|
|
|
state = result.scalar_one_or_none()
|
|
|
|
|
|
if not state:
|
|
|
|
|
|
_get_or_create_state_sync(user_id, db)
|
|
|
|
|
|
result = db.execute(stmt)
|
|
|
|
|
|
state = result.scalar_one()
|
|
|
|
|
|
|
|
|
|
|
|
slots: Dict[str, Dict] = state.slots or {}
|
|
|
|
|
|
stage_slots = slots.get(stage, {})
|
|
|
|
|
|
existing = stage_slots.get(slot_name, {})
|
|
|
|
|
|
|
|
|
|
|
|
merged_segment_ids = list({*(existing.get("segment_ids") or []), *segment_ids})
|
2026-03-19 14:36:14 +08:00
|
|
|
|
stage_slots[slot_name] = SlotData(
|
|
|
|
|
|
snippet=snippet, segment_ids=merged_segment_ids
|
|
|
|
|
|
).model_dump()
|
2026-01-21 23:06:47 +01:00
|
|
|
|
slots[stage] = stage_slots
|
|
|
|
|
|
state.slots = slots
|
2026-03-26 12:13:36 +08:00
|
|
|
|
state.current_stage = stage
|
2026-01-21 23:06:47 +01:00
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(state)
|
|
|
|
|
|
return _coerce_state(state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
|
|
|
|
|
def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
|
|
|
|
|
"""
|
|
|
|
|
|
处理回忆录段落的 Celery 任务
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
Args:
|
|
|
|
|
|
user_id: 用户 ID
|
|
|
|
|
|
segment_ids: 段落 ID 列表
|
|
|
|
|
|
"""
|
2026-01-21 23:37:00 +01:00
|
|
|
|
task_id = self.request.id
|
2026-03-19 14:36:14 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"开始处理回忆录段落: user_id={user_id}, task_id={task_id}, segments={len(segment_ids)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
# 更新任务状态为 running
|
|
|
|
|
|
_update_task_status_sync(user_id, task_id, "running")
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
try:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
with get_sync_db() as db:
|
2026-01-21 23:06:47 +01:00
|
|
|
|
# 获取段落
|
|
|
|
|
|
stmt = select(Segment).where(Segment.id.in_(segment_ids))
|
|
|
|
|
|
result = db.execute(stmt)
|
|
|
|
|
|
segments = result.scalars().all()
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
if not segments:
|
|
|
|
|
|
logger.warning(f"未找到段落: {segment_ids}")
|
|
|
|
|
|
return {"status": "no_segments"}
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-03-26 12:13:36 +08:00
|
|
|
|
# Memory ingest 先于回忆录流水线 commit,保证后续 retrieve_evidence_sync 可见本批 chunk
|
|
|
|
|
|
# (见 api/docs/memory-retrieval.md)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
conv_id = getattr(segments[0], "conversation_id", None) or ""
|
2026-03-26 12:13:36 +08:00
|
|
|
|
transcript = "\n\n".join(seg.user_input_text or "" for seg in segments)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
if transcript.strip():
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.features.memory.service import ingest_transcript_sync
|
|
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
source_id = ingest_transcript_sync(db, user_id, conv_id, transcript)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"event=memory_transcript_ingested user_id={} task_id={} "
|
|
|
|
|
|
"source_id={} conversation_id={} transcript_chars={} "
|
|
|
|
|
|
"segment_count={}",
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
source_id,
|
|
|
|
|
|
conv_id,
|
|
|
|
|
|
len(transcript),
|
|
|
|
|
|
len(segments),
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
except Exception as e:
|
2026-03-27 16:01:28 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Memory ingest 跳过: {} exc_type={}",
|
|
|
|
|
|
e,
|
|
|
|
|
|
type(e).__name__,
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
llm = _get_llm()
|
2026-03-11 11:26:42 +08:00
|
|
|
|
image_settings = MemoirImageSettings.from_env()
|
2026-03-01 10:12:23 +01:00
|
|
|
|
|
|
|
|
|
|
user_obj = db.get(User, user_id)
|
|
|
|
|
|
user_profile = ""
|
|
|
|
|
|
user_birth_year = None
|
|
|
|
|
|
if user_obj:
|
|
|
|
|
|
user_birth_year = user_obj.birth_year
|
|
|
|
|
|
user_profile = format_user_profile_context(
|
|
|
|
|
|
birth_year=user_obj.birth_year,
|
|
|
|
|
|
birth_place=user_obj.birth_place,
|
|
|
|
|
|
grew_up_place=user_obj.grew_up_place,
|
|
|
|
|
|
occupation=user_obj.occupation,
|
|
|
|
|
|
)
|
2026-03-01 10:50:58 +01:00
|
|
|
|
|
2026-03-20 15:15:35 +08:00
|
|
|
|
story_dispatch_ids: Set[str] = set()
|
2026-03-19 10:38:11 +08:00
|
|
|
|
|
|
|
|
|
|
memoir_orchestrator = MemoirOrchestrator()
|
2026-03-22 16:45:57 +08:00
|
|
|
|
prepared = memoir_orchestrator.prepare_batches(
|
|
|
|
|
|
segments=list(segments),
|
2026-03-19 10:38:11 +08:00
|
|
|
|
llm=llm,
|
|
|
|
|
|
get_or_create_state=lambda: _get_or_create_state_sync(user_id, db),
|
2026-03-19 14:36:14 +08:00
|
|
|
|
update_slot=lambda stage, slot_name, snippet, seg_ids: (
|
|
|
|
|
|
_update_slot_sync(user_id, stage, slot_name, snippet, seg_ids, db)
|
2026-03-19 10:38:11 +08:00
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-03-22 16:45:57 +08:00
|
|
|
|
chapters_to_enqueue: Set[str] = set()
|
2026-03-30 11:53:04 +08:00
|
|
|
|
affected_chapter_ids: Set[str] = set()
|
2026-03-22 16:45:57 +08:00
|
|
|
|
for (
|
|
|
|
|
|
chapter_category,
|
|
|
|
|
|
category_segments,
|
|
|
|
|
|
) in prepared.category_to_segments.items():
|
2026-03-30 11:53:04 +08:00
|
|
|
|
lock_handle = _acquire_chapter_lock(
|
|
|
|
|
|
user_id, chapter_category, ttl_seconds=_chapter_lock_ttl()
|
|
|
|
|
|
)
|
|
|
|
|
|
if lock_handle is None:
|
2026-03-22 16:45:57 +08:00
|
|
|
|
logger.warning(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"章节锁竞争: category={}, 延迟重试",
|
2026-03-22 16:45:57 +08:00
|
|
|
|
chapter_category,
|
|
|
|
|
|
)
|
|
|
|
|
|
raise self.retry(countdown=10)
|
|
|
|
|
|
try:
|
|
|
|
|
|
chapter, needs_cover, disp = run_story_pipeline_for_category_batch(
|
|
|
|
|
|
db,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
chapter_category=chapter_category,
|
|
|
|
|
|
category_segments=category_segments,
|
|
|
|
|
|
state=prepared.state,
|
|
|
|
|
|
user_profile=user_profile,
|
|
|
|
|
|
user_birth_year=user_birth_year,
|
|
|
|
|
|
llm=llm,
|
|
|
|
|
|
)
|
|
|
|
|
|
story_dispatch_ids |= disp
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
db.refresh(chapter)
|
2026-03-30 11:53:04 +08:00
|
|
|
|
affected_chapter_ids.add(chapter.id)
|
2026-03-22 16:45:57 +08:00
|
|
|
|
|
|
|
|
|
|
needs_cover_enqueue = (
|
|
|
|
|
|
image_settings.enabled and chapter_needs_cover_enqueue(chapter)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
stmt_book = (
|
|
|
|
|
|
select(Book)
|
|
|
|
|
|
.where(Book.user_id == user_id)
|
|
|
|
|
|
.order_by(Book.updated_at.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
result_book = db.execute(stmt_book)
|
|
|
|
|
|
book = result_book.scalar_one_or_none()
|
|
|
|
|
|
if not book:
|
|
|
|
|
|
book = Book(
|
|
|
|
|
|
id=str(uuid.uuid4()),
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
title="我的回忆录",
|
|
|
|
|
|
total_pages=0,
|
|
|
|
|
|
total_words=0,
|
|
|
|
|
|
cover_image_url=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(book)
|
|
|
|
|
|
book.has_update = True
|
|
|
|
|
|
book.last_update_chapter_id = chapter.id
|
|
|
|
|
|
|
|
|
|
|
|
if chapter and needs_cover_enqueue:
|
|
|
|
|
|
chapters_to_enqueue.add(chapter.id)
|
|
|
|
|
|
finally:
|
2026-03-30 11:53:04 +08:00
|
|
|
|
_release_chapter_lock(lock_handle)
|
2026-03-22 16:45:57 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
# 标记段落为已处理
|
|
|
|
|
|
for seg in segments:
|
|
|
|
|
|
seg.processed = True
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
db.commit()
|
2026-03-10 17:02:50 +08:00
|
|
|
|
|
2026-03-30 11:53:04 +08:00
|
|
|
|
from app.features.story.post_commit import enqueue_story_post_commit_effects
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
2026-03-30 11:53:04 +08:00
|
|
|
|
pc = enqueue_story_post_commit_effects(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
story_ids=set(story_dispatch_ids),
|
|
|
|
|
|
chapter_ids=affected_chapter_ids,
|
|
|
|
|
|
trigger_source="pipeline",
|
|
|
|
|
|
need_compaction=True,
|
|
|
|
|
|
compaction_extra={
|
|
|
|
|
|
"pipeline_run_id": str(task_id),
|
|
|
|
|
|
"story_dispatch_ids": sorted(story_dispatch_ids),
|
|
|
|
|
|
"chapters_to_enqueue": sorted(chapters_to_enqueue),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"event=story_post_commit user_id={} trigger=pipeline "
|
|
|
|
|
|
"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,
|
|
|
|
|
|
)
|
2026-03-20 15:15:35 +08:00
|
|
|
|
|
|
|
|
|
|
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"):
|
|
|
|
|
|
logger.info(f"派发章节封面任务: chapter={chapter_id}")
|
2026-03-10 17:02:50 +08:00
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
categories_processed = sorted(prepared.category_to_segments.keys())
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"回忆录处理完成: user_id={} task_id={} segment_count={} "
|
|
|
|
|
|
"categories_processed={}",
|
|
|
|
|
|
user_id,
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
len(segments),
|
|
|
|
|
|
categories_processed,
|
|
|
|
|
|
)
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
# 更新任务状态为成功
|
2026-03-19 14:36:14 +08:00
|
|
|
|
_update_task_status_sync(
|
2026-03-27 16:01:28 +08:00
|
|
|
|
user_id,
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
"success",
|
|
|
|
|
|
{
|
|
|
|
|
|
"processed": len(segments),
|
|
|
|
|
|
"categories_processed": categories_processed,
|
|
|
|
|
|
},
|
2026-03-19 14:36:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-27 16:01:28 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"processed": len(segments),
|
|
|
|
|
|
"categories_processed": categories_processed,
|
|
|
|
|
|
}
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"回忆录处理失败: {e}")
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:37:00 +01:00
|
|
|
|
# 更新任务状态为失败
|
|
|
|
|
|
_update_task_status_sync(user_id, task_id, "failure", {"error": str(e)})
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
# 重试
|
2026-03-22 16:45:57 +08:00
|
|
|
|
raise self.retry(exc=e) from e
|
2026-01-21 23:06:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
|
|
|
|
|
def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
|
|
|
|
|
|
"""
|
|
|
|
|
|
单独生成章节内容的任务(用于实时更新)
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
Args:
|
|
|
|
|
|
user_id: 用户 ID
|
|
|
|
|
|
stage: 阶段
|
|
|
|
|
|
new_content: 新内容
|
|
|
|
|
|
"""
|
|
|
|
|
|
logger.info(f"生成章节内容: user_id={user_id}, stage={stage}")
|
2026-03-19 14:36:14 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
try:
|
2026-03-18 17:18:23 +08:00
|
|
|
|
with get_sync_db() as db:
|
|
|
|
|
|
llm = _get_llm()
|
2026-03-20 15:15:35 +08:00
|
|
|
|
user_obj = db.get(User, user_id)
|
|
|
|
|
|
user_profile = ""
|
|
|
|
|
|
user_birth_year = None
|
|
|
|
|
|
if user_obj:
|
|
|
|
|
|
user_birth_year = user_obj.birth_year
|
|
|
|
|
|
user_profile = format_user_profile_context(
|
|
|
|
|
|
birth_year=user_obj.birth_year,
|
|
|
|
|
|
birth_place=user_obj.birth_place,
|
|
|
|
|
|
grew_up_place=user_obj.grew_up_place,
|
|
|
|
|
|
occupation=user_obj.occupation,
|
2026-03-19 14:36:14 +08:00
|
|
|
|
)
|
2026-02-21 09:33:31 +01:00
|
|
|
|
|
2026-03-20 15:15:35 +08:00
|
|
|
|
class _Seg:
|
|
|
|
|
|
def __init__(self, text: str):
|
|
|
|
|
|
self.id = str(uuid.uuid4())
|
2026-03-26 12:13:36 +08:00
|
|
|
|
self.user_input_text = text
|
2026-02-21 09:33:31 +01:00
|
|
|
|
|
2026-03-20 15:15:35 +08:00
|
|
|
|
state = _get_or_create_state_sync(user_id, db)
|
|
|
|
|
|
chapter, _, dispatch_ids = run_story_pipeline_for_category_batch(
|
2026-03-13 11:12:10 +08:00
|
|
|
|
db,
|
|
|
|
|
|
user_id=user_id,
|
2026-03-20 15:15:35 +08:00
|
|
|
|
chapter_category=stage,
|
|
|
|
|
|
category_segments=[_Seg(new_content)],
|
|
|
|
|
|
state=state,
|
|
|
|
|
|
user_profile=user_profile,
|
|
|
|
|
|
user_birth_year=user_birth_year,
|
|
|
|
|
|
llm=llm,
|
2026-03-13 11:12:10 +08:00
|
|
|
|
)
|
2026-01-21 23:06:47 +01:00
|
|
|
|
db.commit()
|
2026-03-19 09:11:54 +08:00
|
|
|
|
db.refresh(chapter)
|
2026-03-20 15:15:35 +08:00
|
|
|
|
|
2026-03-30 11:53:04 +08:00
|
|
|
|
from app.features.story.post_commit import enqueue_story_post_commit_effects
|
2026-03-20 15:15:35 +08:00
|
|
|
|
|
2026-03-30 11:53:04 +08:00
|
|
|
|
ch_ids: set[str] = set()
|
|
|
|
|
|
if chapter is not None:
|
|
|
|
|
|
ch_ids.add(str(chapter.id))
|
|
|
|
|
|
pc = enqueue_story_post_commit_effects(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
story_ids=set(dispatch_ids),
|
|
|
|
|
|
chapter_ids=ch_ids,
|
|
|
|
|
|
trigger_source="pipeline",
|
|
|
|
|
|
need_compaction=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-03-20 15:15:35 +08:00
|
|
|
|
|
2026-03-19 09:11:54 +08:00
|
|
|
|
image_settings = MemoirImageSettings.from_env()
|
2026-03-19 14:36:14 +08:00
|
|
|
|
if (
|
|
|
|
|
|
image_settings.enabled
|
|
|
|
|
|
and chapter
|
2026-03-20 15:15:35 +08:00
|
|
|
|
and chapter_needs_cover_enqueue(chapter)
|
2026-03-19 09:11:54 +08:00
|
|
|
|
):
|
2026-03-20 15:15:35 +08:00
|
|
|
|
from app.tasks.chapter_cover_enqueue import (
|
|
|
|
|
|
try_enqueue_generate_chapter_cover,
|
|
|
|
|
|
)
|
2026-03-20 10:30:07 +08:00
|
|
|
|
|
2026-03-20 15:15:35 +08:00
|
|
|
|
try_enqueue_generate_chapter_cover(chapter.id, source="pipeline")
|
2026-01-21 23:06:47 +01:00
|
|
|
|
return {"status": "success"}
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
2026-01-21 23:06:47 +01:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"章节生成失败: {e}")
|
2026-03-22 16:45:57 +08:00
|
|
|
|
raise self.retry(exc=e) from e
|