- Phase1/2:移除 MemoirOrchestrator.run 与 process_memoir_segments 别名;文档改为 process_memoir_phase1。 - 槽位校验集中到 stage_constants(filter_stage_slots),批处理与顺序路径及 state_service 写库一致。 - StoryRoute:no_llm/parse_error/invalid_target 保守 new_story;短篇护栏不覆盖这些 fallback。 - Phase2 低置信单路径可选延迟(StoryPipelineResult.deferred):不写 Chapter/Story,Segment 记录 defer 元数据,冷却内不重复消费;上限后停自动重试,Phase1 同类目新段唤醒池内段。 - Alembic 0017:segments 表 narrative_defer_* 列。 - ProfileAgent:经 LlmGateway/注入 Provider 统一聊天与 JSON,新增测试。 - ImagePromptOrchestrator:LLM 初始化失败可依配置降级或硬失败;补充策略测试。 - 配套单测与 README/本地开发文档表述更新。 Co-authored-by: Cursor <cursoragent@cursor.com>
1253 lines
41 KiB
Python
1253 lines
41 KiB
Python
"""
|
||
Celery 用:按批次将 transcript 写入 Story,并标记 Chapter 需物化(markdown_compose_dirty)。
|
||
|
||
同步路径不执行 compose;物化由 commit 后 `recompose_chapter` 异步完成。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import time
|
||
import uuid
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.orm import Session, joinedload
|
||
|
||
from app.agents.memoir.narrative_agent import NarrativeAgent
|
||
from app.agents.memoir.prompts import format_narrative_user_content
|
||
from app.agents.memoir.story_route_agent import (
|
||
APPEND_FIRST_CHAPTER_CATEGORIES,
|
||
FALLBACK_NEW_STORY_REASONS,
|
||
PLAN_BATCH_MAX_SEGMENTS,
|
||
StoryBatchPlan,
|
||
StoryRouteAgent,
|
||
default_append_target_story_id,
|
||
)
|
||
from app.agents.stage_constants import (
|
||
CATEGORY_TO_CHAT_STAGE,
|
||
CHAPTER_CATEGORIES,
|
||
CHAT_STAGES,
|
||
STAGE_TO_ORDER,
|
||
)
|
||
from app.agents.state_schema import MemoirStateSchema
|
||
from app.core.config import settings
|
||
from app.core.logging import get_logger
|
||
from app.features.conversation.lineage_schemas import aggregate_lineage_from_segments
|
||
from app.features.memoir.chapter_evidence_snapshot import (
|
||
refresh_chapter_evidence_snapshot_with_retry_sync,
|
||
)
|
||
from app.features.memoir.cover_eligibility import chapter_needs_cover_enqueue
|
||
from app.features.memoir.memoir_images.settings import MemoirImageSettings
|
||
from app.features.memoir.models import Chapter
|
||
from app.features.memoir.narrative_safety import (
|
||
body_contains_prompt_artifact,
|
||
evidence_leakage_heuristic,
|
||
evidence_scene_anchor_leak,
|
||
strip_evidence_for_overlap_check,
|
||
)
|
||
from app.features.memoir.narrative_to_markdown import narrative_to_markdown
|
||
from app.features.memoir.oral_normalize import (
|
||
apply_oral_rules,
|
||
normalize_oral_for_memoir,
|
||
)
|
||
from app.features.memoir.repo import (
|
||
mark_chapter_dirty_sync,
|
||
reorder_chapter_story_links_by_life_order_sync,
|
||
)
|
||
from app.features.memory.evidence_format import format_evidence_chunks_for_prompt
|
||
from app.features.story.models import Story, StoryVersion
|
||
from app.features.story.sync_write import (
|
||
append_story_version_sync,
|
||
count_story_versions_sync,
|
||
create_story_with_version_sync,
|
||
ensure_chapter_story_link_sync,
|
||
list_active_stories_for_user_sync,
|
||
replace_story_evidence_links_sync,
|
||
)
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class StoryPipelineResult:
|
||
"""Phase2 故事管线结果。
|
||
|
||
- 正常写入:``deferred=False``,``chapter`` 非空。
|
||
- 低置信延迟:``deferred=True``,``chapter`` 为 None;调用方应把 ``defer_segment_ids``
|
||
标记为延迟态,不要置 ``narrated/processed``,也不要触发后置任务。
|
||
"""
|
||
|
||
chapter: Chapter | None
|
||
needs_cover: bool
|
||
dispatch_ids: set[str]
|
||
deferred: bool = False
|
||
defer_reason: str | None = None
|
||
defer_segment_ids: list[str] = field(default_factory=list)
|
||
|
||
|
||
def _dialogue_lineage_dict_for_segment_ids(
|
||
category_segments: list,
|
||
segment_ids: list[str],
|
||
) -> dict | None:
|
||
"""Merge DialogueLineage from contributing segments (memoir batch unit order)."""
|
||
if not segment_ids or not category_segments:
|
||
return None
|
||
order = {str(sid): i for i, sid in enumerate(segment_ids)}
|
||
picked = [s for s in category_segments if str(getattr(s, "id", "")) in order]
|
||
picked.sort(key=lambda s: order[str(s.id)])
|
||
conv_fb: str | None = None
|
||
if picked:
|
||
conv_fb = getattr(picked[0], "conversation_id", None)
|
||
if not conv_fb:
|
||
for s in picked:
|
||
c = getattr(s, "conversation_id", None)
|
||
if c:
|
||
conv_fb = str(c)
|
||
break
|
||
return aggregate_lineage_from_segments(
|
||
picked,
|
||
conversation_id_fallback=str(conv_fb) if conv_fb else None,
|
||
)
|
||
|
||
|
||
def _evidence_link_ids(
|
||
evidence: dict,
|
||
) -> tuple[list[str], list[str], list[str]]:
|
||
"""从 MemoryService.retrieve 结果提取稳定 ID 列表。"""
|
||
chunks: list[str] = []
|
||
for c in evidence.get("relevant_chunks") or []:
|
||
if isinstance(c, dict) and c.get("id"):
|
||
chunks.append(str(c["id"]))
|
||
facts: list[str] = []
|
||
for f in evidence.get("relevant_facts") or []:
|
||
if isinstance(f, dict) and f.get("id"):
|
||
facts.append(str(f["id"]))
|
||
summaries: list[str] = []
|
||
for s in evidence.get("relevant_summaries") or []:
|
||
if isinstance(s, dict) and s.get("id"):
|
||
summaries.append(str(s["id"]))
|
||
return chunks, facts, summaries
|
||
|
||
|
||
def _story_prompt_meta_for_lineage(
|
||
evidence: dict,
|
||
*,
|
||
memoir_correlation_id: str | None,
|
||
top_k: int,
|
||
) -> dict:
|
||
c, f, s = _evidence_link_ids(evidence)
|
||
return {
|
||
"memoir_retrieval": {
|
||
"correlation_id": memoir_correlation_id,
|
||
"top_k": top_k,
|
||
"chunk_ids": c,
|
||
"fact_ids": f,
|
||
"summary_ids": s,
|
||
}
|
||
}
|
||
|
||
|
||
def _persist_story_lineage_sync(
|
||
session: Session,
|
||
*,
|
||
story_id: str,
|
||
version: StoryVersion,
|
||
evidence: dict,
|
||
memoir_correlation_id: str | None,
|
||
top_k: int,
|
||
dialogue_lineage: dict | None = None,
|
||
) -> None:
|
||
"""写入 StoryEvidenceLink + 本版本 prompt_meta(可审计检索闭包)。"""
|
||
c, f, s = _evidence_link_ids(evidence)
|
||
replace_story_evidence_links_sync(
|
||
session,
|
||
story_id=story_id,
|
||
chunk_ids=c,
|
||
fact_ids=f,
|
||
timeline_event_ids=[],
|
||
summary_ids=s,
|
||
)
|
||
version.prompt_meta = _story_prompt_meta_for_lineage(
|
||
evidence, memoir_correlation_id=memoir_correlation_id, top_k=top_k
|
||
)
|
||
if dialogue_lineage:
|
||
version.lineage_json = dialogue_lineage
|
||
|
||
|
||
# 标题中若出现下列多字履历表述,则必须在 hay(正文+口述+传入标题的 slots)中逐字出现,否则剔除无果片段或降级占位
|
||
_MEMOIR_TITLE_HAY_GROUNDING_PHRASES: tuple[str, ...] = (
|
||
"晋升旅长",
|
||
"晋升为旅长",
|
||
"晋升师长",
|
||
"晋升军长",
|
||
"旅长职务",
|
||
"师长职务",
|
||
)
|
||
|
||
# summary 章节跨阶段汇总 slots 时的上限(防叙事 prompt 膨胀)
|
||
MAX_SUMMARY_SLOT_KEYS = 80
|
||
MAX_SUMMARY_SLOT_CHARS = 12_000
|
||
|
||
|
||
def _slot_snippets_for_narrative(
|
||
*,
|
||
state: MemoirStateSchema,
|
||
chapter_category: str,
|
||
user_id: str,
|
||
) -> dict[str, str]:
|
||
"""按章节类目收集 slot 片段;summary 时跨 CHAT_STAGES 汇总并做 key/字符上限。"""
|
||
slot_snippets: dict[str, str] = {}
|
||
if chapter_category == "summary":
|
||
total_chars = 0
|
||
keys_added = 0
|
||
capped = False
|
||
for chat_stage_key in CHAT_STAGES:
|
||
if keys_added >= MAX_SUMMARY_SLOT_KEYS:
|
||
capped = True
|
||
break
|
||
stage_slots = state.slots.get(chat_stage_key, {}) or {}
|
||
for key in sorted(stage_slots.keys()):
|
||
if keys_added >= MAX_SUMMARY_SLOT_KEYS:
|
||
capped = True
|
||
break
|
||
value = stage_slots[key]
|
||
snip = getattr(value, "snippet", None) or (
|
||
value.get("snippet") if isinstance(value, dict) else None
|
||
)
|
||
if not snip:
|
||
continue
|
||
composite = f"{chat_stage_key}_{key}"
|
||
s = str(snip).strip()
|
||
if total_chars + len(s) > MAX_SUMMARY_SLOT_CHARS:
|
||
remain = MAX_SUMMARY_SLOT_CHARS - total_chars
|
||
if remain > 32:
|
||
slot_snippets[composite] = s[:remain] + "…"
|
||
capped = True
|
||
break
|
||
slot_snippets[composite] = s
|
||
total_chars += len(s)
|
||
keys_added += 1
|
||
if capped:
|
||
break
|
||
if capped:
|
||
logger.info(
|
||
"event=summary_slot_snippets_capped user_id={} keys={} chars={}",
|
||
user_id,
|
||
len(slot_snippets),
|
||
total_chars,
|
||
)
|
||
return slot_snippets
|
||
|
||
chat_stage = CATEGORY_TO_CHAT_STAGE.get(chapter_category, chapter_category)
|
||
stage_slots = state.slots.get(chat_stage, {}) or {}
|
||
for key in sorted(stage_slots.keys()):
|
||
value = stage_slots[key]
|
||
snip = getattr(value, "snippet", None) or (
|
||
value.get("snippet") if isinstance(value, dict) else None
|
||
)
|
||
if snip:
|
||
slot_snippets[key] = str(snip).strip()
|
||
return slot_snippets
|
||
|
||
|
||
def _placeholder_title(chapter_category: str) -> str:
|
||
return CHAPTER_CATEGORIES.get(chapter_category, chapter_category)
|
||
|
||
|
||
def _title_slots_filtered_for_generation(
|
||
slot_snippets: dict[str, str], *, md: str, oral_scope: str
|
||
) -> dict[str, str]:
|
||
"""仅保留与正文或本批口述有文本重叠的 slot,降低档案/历史 slot 串台到标题。"""
|
||
if not settings.memoir_title_slots_require_body_or_oral_match:
|
||
return dict(slot_snippets)
|
||
hay = f"{(md or '').strip()}\n{(oral_scope or '').strip()}"
|
||
if not hay.strip():
|
||
return {}
|
||
out: dict[str, str] = {}
|
||
for k, v in (slot_snippets or {}).items():
|
||
if k == "content_excerpt":
|
||
continue
|
||
s = (v or "").strip()
|
||
if len(s) < 2:
|
||
continue
|
||
if s in hay:
|
||
out[k] = s
|
||
continue
|
||
prefix = s[: min(12, len(s))]
|
||
if len(prefix) >= 4 and prefix in hay:
|
||
out[k] = s
|
||
return out
|
||
|
||
|
||
def _title_hay_for_grounding(
|
||
merged_slots: dict[str, str], md: str, oral_scope: str
|
||
) -> str:
|
||
"""与标题模型可见材料一致的依据串(用于事后逐字 grounding)。"""
|
||
parts: list[str] = [(md or "").strip(), (oral_scope or "").strip()]
|
||
for k, v in (merged_slots or {}).items():
|
||
if k == "content_excerpt":
|
||
continue
|
||
if (v or "").strip():
|
||
parts.append(str(v).strip())
|
||
return "\n".join(p for p in parts if p)
|
||
|
||
|
||
def _strip_ungrounded_title_segments(
|
||
title: str,
|
||
hay: str,
|
||
*,
|
||
chapter_category: str,
|
||
) -> str:
|
||
"""
|
||
按 · / • 分节丢弃含未落地履历短语的小节;全部丢弃则占位。
|
||
"""
|
||
if not settings.memoir_title_hay_grounding_strict_phrases_enabled:
|
||
return (title or "").strip() or _placeholder_title(chapter_category)
|
||
t = (title or "").strip()
|
||
h = (hay or "").strip()
|
||
if not t:
|
||
return _placeholder_title(chapter_category)
|
||
segments = [s.strip() for s in re.split(r"\s*[·•]\s*", t) if s.strip()]
|
||
if not segments:
|
||
segments = [t]
|
||
kept: list[str] = []
|
||
for seg in segments:
|
||
bad = any(
|
||
phrase in seg and phrase not in h
|
||
for phrase in _MEMOIR_TITLE_HAY_GROUNDING_PHRASES
|
||
)
|
||
if bad:
|
||
logger.info(
|
||
"event=memoir_title_segment_ungrounded segment_preview={} chapter_category={}",
|
||
seg[:40],
|
||
chapter_category,
|
||
)
|
||
continue
|
||
kept.append(seg)
|
||
if not kept:
|
||
return _placeholder_title(chapter_category)
|
||
if len(kept) == 1:
|
||
return kept[0]
|
||
return " · ".join(kept)
|
||
|
||
|
||
def _maybe_generate_title(
|
||
narrative_agent: "NarrativeAgent",
|
||
*,
|
||
chapter_category: str,
|
||
md: str,
|
||
slot_snippets: dict[str, str],
|
||
user_profile: str,
|
||
user_birth_year: int | None,
|
||
llm: Any,
|
||
oral_scope: str = "",
|
||
narrow_profile_for_title: bool = True,
|
||
) -> str:
|
||
"""Generate a title only when body is long enough; otherwise return placeholder."""
|
||
body_len = len((md or "").strip())
|
||
if body_len < settings.story_title_min_body_chars:
|
||
return _placeholder_title(chapter_category)
|
||
content_excerpt = (md or "").strip()[:300]
|
||
merged_slots = _title_slots_filtered_for_generation(
|
||
slot_snippets, md=md, oral_scope=oral_scope
|
||
)
|
||
if content_excerpt and "content_excerpt" not in merged_slots:
|
||
merged_slots["content_excerpt"] = content_excerpt
|
||
# 标题默认不注入完整档案,仅年龄提示仍可用(来自 birth_year)
|
||
profile_for_title = "" if narrow_profile_for_title else user_profile
|
||
raw_title = narrative_agent.generate_title(
|
||
stage=chapter_category,
|
||
emotion="neutral",
|
||
slots=merged_slots,
|
||
user_profile=profile_for_title,
|
||
birth_year=user_birth_year,
|
||
llm=llm,
|
||
)
|
||
hay = _title_hay_for_grounding(merged_slots, md, oral_scope)
|
||
return _strip_ungrounded_title_segments(
|
||
raw_title, hay, chapter_category=chapter_category
|
||
)
|
||
|
||
|
||
def _route_segment_texts(category_segments: list) -> list[tuple[str, str]]:
|
||
"""批量路由 plan_batch:每段仅做规则归一,避免 N 次 LLM。"""
|
||
out: list[tuple[str, str]] = []
|
||
for seg in category_segments:
|
||
raw = seg.user_input_text or ""
|
||
if (
|
||
settings.memoir_oral_normalize_enabled
|
||
and (settings.memoir_oral_normalize_mode or "rules").strip().lower()
|
||
!= "off"
|
||
):
|
||
t = apply_oral_rules(raw)
|
||
else:
|
||
t = raw
|
||
out.append((str(seg.id), t))
|
||
return out
|
||
|
||
|
||
def _fidelity_fallback_json(oral: str, existing_canonical: str | None) -> str:
|
||
"""忠实度未通过时的安全回退:续写场景保留旧文 + 本段口述,避免只剩一句。"""
|
||
o = (oral or "").strip()[:15000]
|
||
ex = (existing_canonical or "").strip()[:15000]
|
||
if ex and o:
|
||
return json.dumps(
|
||
{"paragraphs": [{"content": ex}, {"content": o}]},
|
||
ensure_ascii=False,
|
||
)
|
||
if ex:
|
||
return json.dumps(
|
||
{"paragraphs": [{"content": ex}]},
|
||
ensure_ascii=False,
|
||
)
|
||
return json.dumps(
|
||
{"paragraphs": [{"content": o}]},
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
|
||
def _gate_narrative_fidelity(
|
||
oral_text: str,
|
||
narrative_raw: str,
|
||
llm: Any,
|
||
*,
|
||
existing_canonical: str | None = None,
|
||
fidelity_llm: Any | None = None,
|
||
) -> tuple[str, str]:
|
||
"""返回 (文本, fallback 原因);忠实度不通过时第二项为 fidelity_failed。"""
|
||
from app.agents.memoir.fidelity_check_agent import FidelityCheckAgent
|
||
|
||
check_llm = fidelity_llm if fidelity_llm is not None else llm
|
||
if not settings.memoir_fidelity_check_enabled or not check_llm:
|
||
return narrative_raw, "none"
|
||
agent = FidelityCheckAgent()
|
||
ex = (existing_canonical or "").strip() or None
|
||
is_append = bool(ex)
|
||
if agent.passes(
|
||
oral_text=oral_text,
|
||
narrative_json=narrative_raw,
|
||
llm=check_llm,
|
||
existing_canonical_markdown=ex,
|
||
is_append=is_append,
|
||
):
|
||
return narrative_raw, "none"
|
||
logger.warning(
|
||
"event=fidelity_gate_fallback oral_len={} merge={}",
|
||
len((oral_text or "").strip()),
|
||
bool(ex),
|
||
)
|
||
o = (oral_text or "").strip()
|
||
if not o and not ex:
|
||
return narrative_raw, "none"
|
||
return _fidelity_fallback_json(o, ex), "fidelity_failed"
|
||
|
||
|
||
def _apply_narrative_body_safety(
|
||
md: str,
|
||
*,
|
||
oral: str,
|
||
existing_for_narrative: str,
|
||
evidence_text: str,
|
||
chapter_category: str,
|
||
) -> tuple[str, str]:
|
||
"""prompt 标记或摘录子串疑似渗入正文时,回退为口述/旧文拼接。"""
|
||
m = (md or "").strip()
|
||
ex = (existing_for_narrative or "").strip()
|
||
o = (oral or "").strip()
|
||
min_len = int(settings.memoir_narrative_evidence_overlap_min_chars)
|
||
ev_plain = strip_evidence_for_overlap_check(evidence_text)
|
||
if m and body_contains_prompt_artifact(m):
|
||
logger.warning(
|
||
"event=narrative_invariant_failed reason=prompt_artifact chapter_category={}",
|
||
chapter_category,
|
||
)
|
||
return _coalesce_story_markdown("", oral, existing_for_narrative), (
|
||
"prompt_artifact_in_body"
|
||
)
|
||
if (
|
||
m
|
||
and evidence_text.strip()
|
||
and evidence_leakage_heuristic(m, ev_plain, o, ex, min_len)
|
||
):
|
||
logger.warning(
|
||
"event=narrative_invariant_failed reason=evidence_leak chapter_category={}",
|
||
chapter_category,
|
||
)
|
||
return _coalesce_story_markdown("", oral, existing_for_narrative), (
|
||
"evidence_leak_heuristic"
|
||
)
|
||
if (
|
||
settings.memoir_evidence_scene_anchor_check_enabled
|
||
and m
|
||
and evidence_text.strip()
|
||
and evidence_scene_anchor_leak(m, ev_plain, o, ex)
|
||
):
|
||
logger.warning(
|
||
"event=narrative_invariant_failed reason=evidence_scene_anchor chapter_category={}",
|
||
chapter_category,
|
||
)
|
||
return _coalesce_story_markdown("", oral, existing_for_narrative), (
|
||
"evidence_scene_anchor"
|
||
)
|
||
return m, "none"
|
||
|
||
|
||
def _coalesce_story_markdown(
|
||
md: str,
|
||
oral: str,
|
||
existing_for_narrative: str,
|
||
) -> str:
|
||
"""落库前对齐正文:空输出时续写场景保留「已有故事 + 本段口述」。"""
|
||
o = (oral or "").strip()
|
||
ex = (existing_for_narrative or "").strip()
|
||
m = (md or "").strip()
|
||
if not m:
|
||
if ex and o:
|
||
return f"{ex}\n\n{o}"
|
||
if o:
|
||
return o
|
||
return ex
|
||
return m
|
||
|
||
|
||
def _is_json_narrative(text: str) -> bool:
|
||
if not text or not text.strip():
|
||
return False
|
||
s = text.strip()
|
||
return s.startswith("{") and "paragraphs" in s
|
||
|
||
|
||
def _ordered_text_for_segment_ids(
|
||
category_segments: list, segment_ids: list[str]
|
||
) -> str:
|
||
id_to_text = {seg.id: (seg.user_input_text or "") for seg in category_segments}
|
||
return "\n\n".join(id_to_text.get(sid, "") for sid in segment_ids)
|
||
|
||
|
||
def _apply_narrative_fallbacks(
|
||
narrative_raw: str,
|
||
combined_unit_text: str,
|
||
existing_for_narrative: str,
|
||
*,
|
||
chapter_category: str,
|
||
) -> tuple[str, str]:
|
||
"""返回 (文本, fallback_type);无改写时为 none。
|
||
|
||
仅防 merge/append 场景下模型输出极端缩水(丢旧内容),不再按口述字数比例回退。
|
||
"""
|
||
if existing_for_narrative and _is_json_narrative(narrative_raw):
|
||
merged_md = narrative_to_markdown(narrative_raw).strip()
|
||
ex = (existing_for_narrative or "").strip()
|
||
if ex and len(ex) > 400 and len(merged_md) < len(ex) * 0.25:
|
||
logger.warning(
|
||
"event=narrative_fallback reason=merge_shrink action=append_oral "
|
||
"chapter_category={}",
|
||
chapter_category,
|
||
)
|
||
return f"{ex}\n\n{combined_unit_text.strip()}", "merge_shrink"
|
||
|
||
if (
|
||
existing_for_narrative
|
||
and not _is_json_narrative(narrative_raw)
|
||
and len(narrative_raw) < len(existing_for_narrative) * 0.5
|
||
):
|
||
logger.warning(
|
||
"event=narrative_fallback reason=length_anomaly action=append_raw "
|
||
"chapter_category={}",
|
||
chapter_category,
|
||
)
|
||
return (
|
||
f"{existing_for_narrative}\n\n{combined_unit_text}",
|
||
"coalesce_to_old_plus_oral",
|
||
)
|
||
|
||
return narrative_raw, "none"
|
||
|
||
|
||
def _merge_fallback_type(gate_ft: str, apply_ft: str) -> str:
|
||
if apply_ft != "none":
|
||
return apply_ft
|
||
return gate_ft
|
||
|
||
|
||
def _story_meta_for_route(
|
||
session: Session, candidates: list
|
||
) -> dict[str, dict[str, int]]:
|
||
if not candidates:
|
||
return {}
|
||
sids = [str(s.id) for s in candidates]
|
||
stmt = (
|
||
select(StoryVersion.story_id, func.count(StoryVersion.id))
|
||
.where(StoryVersion.story_id.in_(sids))
|
||
.group_by(StoryVersion.story_id)
|
||
)
|
||
rows = session.execute(stmt).all()
|
||
counts: dict[str, int] = {str(r[0]): int(r[1] or 0) for r in rows}
|
||
return {
|
||
str(s.id): {
|
||
"char_count": len((s.canonical_markdown or "").strip()),
|
||
"version_count": counts.get(str(s.id), 0),
|
||
}
|
||
for s in candidates
|
||
}
|
||
|
||
|
||
def _ensure_chapter_record(
|
||
session: Session,
|
||
*,
|
||
user_id: str,
|
||
chapter_category: str,
|
||
title: str,
|
||
source_ids: list[str],
|
||
calculated_order_index: int,
|
||
) -> Chapter:
|
||
stmt_chapter = (
|
||
select(Chapter)
|
||
.where(
|
||
Chapter.user_id == user_id,
|
||
Chapter.category == chapter_category,
|
||
Chapter.is_active == True, # noqa: E712
|
||
)
|
||
.options(
|
||
joinedload(Chapter.images),
|
||
joinedload(Chapter.story_links),
|
||
)
|
||
)
|
||
chapter = session.execute(stmt_chapter).unique().scalar_one_or_none()
|
||
if not chapter:
|
||
chapter = Chapter(
|
||
id=str(uuid.uuid4()),
|
||
user_id=user_id,
|
||
title=title,
|
||
order_index=calculated_order_index,
|
||
status="completed",
|
||
category=chapter_category,
|
||
is_new=True,
|
||
source_segments=source_ids,
|
||
)
|
||
session.add(chapter)
|
||
session.flush()
|
||
else:
|
||
chapter.source_segments = list(
|
||
set((chapter.source_segments or []) + source_ids)
|
||
)
|
||
chapter.is_new = True
|
||
session.flush()
|
||
return chapter
|
||
|
||
|
||
def _resolve_append_target(
|
||
session: Session,
|
||
*,
|
||
route_decision: str,
|
||
route_target_story_id: str | None,
|
||
user_id: str,
|
||
chapter_category: str,
|
||
oral_norm: str,
|
||
candidate_stories: list,
|
||
story_meta: dict[str, dict[str, int]],
|
||
decision_source: str,
|
||
memoir_correlation_id: str | None,
|
||
) -> tuple[str | None, str, str]:
|
||
"""Resolve append target and return (target_story_id, existing_for_narrative, decision_source)."""
|
||
max_chars = int(settings.story_append_max_canonical_chars)
|
||
max_ver = int(settings.story_append_max_versions)
|
||
target_story_id: str | None = None
|
||
existing_for_narrative = ""
|
||
|
||
if route_decision == "append_story" and route_target_story_id:
|
||
st = session.get(Story, route_target_story_id)
|
||
if st and st.user_id == user_id:
|
||
canon = (st.canonical_markdown or "").strip()
|
||
vc = count_story_versions_sync(session, str(st.id))
|
||
if len(canon) > max_chars or vc >= max_ver:
|
||
logger.info(
|
||
"event=append_overflow_to_new story_id={} canonical_chars={} "
|
||
"versions={} decision_source={}",
|
||
str(st.id),
|
||
len(canon),
|
||
vc,
|
||
decision_source,
|
||
)
|
||
decision_source = "forced_new_due_to_append_limit"
|
||
else:
|
||
target_story_id = st.id
|
||
existing_for_narrative = canon
|
||
elif (
|
||
route_decision == "new_story"
|
||
and chapter_category in APPEND_FIRST_CHAPTER_CATEGORIES
|
||
and candidate_stories
|
||
and decision_source not in FALLBACK_NEW_STORY_REASONS
|
||
and len(oral_norm)
|
||
<= int(settings.memoir_story_route_append_guardrail_oral_chars)
|
||
):
|
||
tid_g = default_append_target_story_id(candidate_stories, story_meta, settings)
|
||
if tid_g:
|
||
st = session.get(Story, tid_g)
|
||
if st and st.user_id == user_id:
|
||
canon = (st.canonical_markdown or "").strip()
|
||
vc = count_story_versions_sync(session, str(st.id))
|
||
if len(canon) <= max_chars and vc < max_ver:
|
||
target_story_id = st.id
|
||
existing_for_narrative = canon
|
||
decision_source = "append_guardrail_short_oral"
|
||
logger.info(
|
||
"event=story_route_append_guardrail memoir_correlation_id={} "
|
||
"chapter_category={} oral_len={} story_id={}",
|
||
memoir_correlation_id or "",
|
||
chapter_category,
|
||
len(oral_norm),
|
||
tid_g,
|
||
)
|
||
|
||
return target_story_id, existing_for_narrative, decision_source
|
||
|
||
|
||
def _execute_narrative_unit(
|
||
session: Session,
|
||
*,
|
||
oral_text: str,
|
||
evidence_text: str,
|
||
evidence: dict,
|
||
evidence_top_k: int,
|
||
chapter: Chapter,
|
||
chapter_category: str,
|
||
slot_snippets: dict[str, str],
|
||
user_id: str,
|
||
user_profile: str,
|
||
user_birth_year: int | None,
|
||
llm: Any,
|
||
narrative_agent: NarrativeAgent,
|
||
target_story_id: str | None,
|
||
existing_for_narrative: str,
|
||
decision_source: str,
|
||
route_decision: str,
|
||
route_type: str,
|
||
segment_ids: list[str],
|
||
category_segments: list,
|
||
background_voice: str = "default",
|
||
occupation: str = "",
|
||
memoir_correlation_id: str | None = None,
|
||
fidelity_llm: Any | None = None,
|
||
) -> tuple[str | None, bool]:
|
||
"""
|
||
Unified narrative unit executor: generate narrative, apply fidelity/safety,
|
||
persist story. Returns (story_id, is_append).
|
||
"""
|
||
t0 = time.perf_counter()
|
||
oral_norm = (oral_text or "").strip()
|
||
new_content_input = format_narrative_user_content(oral_text, evidence_text)
|
||
|
||
raw_gen = narrative_agent.generate_narrative(
|
||
stage=chapter_category,
|
||
slots=slot_snippets,
|
||
new_content=new_content_input,
|
||
existing_content=existing_for_narrative,
|
||
user_profile=user_profile,
|
||
birth_year=user_birth_year,
|
||
llm=llm,
|
||
background_voice=background_voice,
|
||
occupation=occupation,
|
||
fallback_plain_oral=oral_norm,
|
||
)
|
||
json_invalid = False
|
||
s0 = (raw_gen or "").strip()
|
||
if s0.startswith("{") and "paragraphs" in s0:
|
||
try:
|
||
json.loads(s0)
|
||
except json.JSONDecodeError:
|
||
json_invalid = True
|
||
|
||
narrative_raw, fb_gate = _gate_narrative_fidelity(
|
||
oral_text,
|
||
raw_gen,
|
||
llm,
|
||
existing_canonical=existing_for_narrative or None,
|
||
fidelity_llm=fidelity_llm,
|
||
)
|
||
narrative_raw, fb_apply = _apply_narrative_fallbacks(
|
||
narrative_raw,
|
||
oral_text,
|
||
existing_for_narrative,
|
||
chapter_category=chapter_category,
|
||
)
|
||
fallback_type = _merge_fallback_type(fb_gate, fb_apply)
|
||
if json_invalid and fallback_type == "none":
|
||
fallback_type = "json_invalid"
|
||
|
||
md = _coalesce_story_markdown(
|
||
narrative_to_markdown(narrative_raw).strip(),
|
||
oral_text.strip(),
|
||
existing_for_narrative or "",
|
||
)
|
||
md, inv_fb = _apply_narrative_body_safety(
|
||
md,
|
||
oral=oral_text,
|
||
existing_for_narrative=existing_for_narrative or "",
|
||
evidence_text=evidence_text,
|
||
chapter_category=chapter_category,
|
||
)
|
||
if inv_fb != "none":
|
||
fallback_type = (
|
||
inv_fb if fallback_type == "none" else f"{fallback_type}+{inv_fb}"
|
||
)
|
||
|
||
dlg = _dialogue_lineage_dict_for_segment_ids(category_segments, segment_ids)
|
||
|
||
if target_story_id:
|
||
sid_s = str(target_story_id)
|
||
ver = append_story_version_sync(session, sid_s, md)
|
||
_persist_story_lineage_sync(
|
||
session,
|
||
story_id=sid_s,
|
||
version=ver,
|
||
evidence=evidence,
|
||
memoir_correlation_id=memoir_correlation_id,
|
||
top_k=evidence_top_k,
|
||
dialogue_lineage=dlg,
|
||
)
|
||
ensure_chapter_story_link_sync(
|
||
session, chapter_id=str(chapter.id), story_id=sid_s
|
||
)
|
||
sid_log = target_story_id
|
||
is_append = True
|
||
else:
|
||
story_title = _placeholder_title(chapter_category)
|
||
st = create_story_with_version_sync(
|
||
session,
|
||
user_id=user_id,
|
||
title=story_title,
|
||
canonical_markdown=md,
|
||
stage=chapter_category,
|
||
)
|
||
try:
|
||
from app.tasks.story_title_tasks import generate_story_title_after_create
|
||
|
||
generate_story_title_after_create.delay(
|
||
str(st.id),
|
||
chapter_category,
|
||
oral_norm,
|
||
user_id,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"event=story_title_enqueue_failed story_id={} err={}",
|
||
st.id,
|
||
exc,
|
||
)
|
||
ensure_chapter_story_link_sync(
|
||
session, chapter_id=str(chapter.id), story_id=str(st.id)
|
||
)
|
||
sid_log = st.id
|
||
is_append = False
|
||
if st.current_version_id:
|
||
ver0 = session.get(StoryVersion, st.current_version_id)
|
||
if ver0:
|
||
_persist_story_lineage_sync(
|
||
session,
|
||
story_id=str(st.id),
|
||
version=ver0,
|
||
evidence=evidence,
|
||
memoir_correlation_id=memoir_correlation_id,
|
||
top_k=evidence_top_k,
|
||
dialogue_lineage=dlg,
|
||
)
|
||
|
||
elapsed = time.perf_counter() - t0
|
||
logger.info(
|
||
"event=story_generated memoir_correlation_id={} route_type={} "
|
||
"decision_source={} route_decision={} "
|
||
"unit_segments={} used_evidence={} narrative_json_valid={} fidelity_passed={} "
|
||
"fallback_type={} oral_len={} md_len={} chapter_category={} is_append={} "
|
||
"story_id={} seconds={:.3f}",
|
||
memoir_correlation_id or "",
|
||
route_type,
|
||
decision_source,
|
||
"append_story" if is_append else "new_story",
|
||
len(segment_ids),
|
||
bool(evidence_text.strip()),
|
||
_is_json_narrative(raw_gen),
|
||
fb_gate == "none",
|
||
fallback_type,
|
||
len(oral_norm),
|
||
len(md.strip()),
|
||
chapter_category,
|
||
is_append,
|
||
sid_log,
|
||
elapsed,
|
||
)
|
||
return str(sid_log), is_append
|
||
|
||
|
||
def _run_batch_plan_writes(
|
||
session: Session,
|
||
*,
|
||
plan: StoryBatchPlan,
|
||
category_segments: list,
|
||
chapter: Chapter,
|
||
chapter_category: str,
|
||
evidence_text: str,
|
||
evidence: dict,
|
||
evidence_top_k: int,
|
||
slot_snippets: dict[str, str],
|
||
user_id: str,
|
||
user_profile: str,
|
||
user_birth_year: int | None,
|
||
llm: Any,
|
||
narrative_agent: NarrativeAgent,
|
||
candidate_stories: list,
|
||
story_meta: dict[str, dict[str, int]],
|
||
background_voice: str = "default",
|
||
occupation: str = "",
|
||
memoir_correlation_id: str | None = None,
|
||
fidelity_llm: Any | None = None,
|
||
) -> set[str]:
|
||
dispatch_ids: set[str] = set()
|
||
for unit in plan.units:
|
||
unit_text = _ordered_text_for_segment_ids(category_segments, unit.segment_ids)
|
||
oral_unit = normalize_oral_for_memoir(unit_text, llm=llm)
|
||
|
||
target_story_id, existing_for_narrative, decision_source = (
|
||
_resolve_append_target(
|
||
session,
|
||
route_decision=unit.decision,
|
||
route_target_story_id=unit.target_story_id,
|
||
user_id=user_id,
|
||
chapter_category=chapter_category,
|
||
oral_norm=(oral_unit or "").strip(),
|
||
candidate_stories=candidate_stories,
|
||
story_meta=story_meta,
|
||
decision_source="batch_plan",
|
||
memoir_correlation_id=memoir_correlation_id,
|
||
)
|
||
)
|
||
|
||
sid, _ = _execute_narrative_unit(
|
||
session,
|
||
oral_text=oral_unit,
|
||
evidence_text=evidence_text,
|
||
evidence=evidence,
|
||
evidence_top_k=evidence_top_k,
|
||
chapter=chapter,
|
||
chapter_category=chapter_category,
|
||
slot_snippets=slot_snippets,
|
||
user_id=user_id,
|
||
user_profile=user_profile,
|
||
user_birth_year=user_birth_year,
|
||
llm=llm,
|
||
narrative_agent=narrative_agent,
|
||
target_story_id=target_story_id,
|
||
existing_for_narrative=existing_for_narrative,
|
||
decision_source=decision_source,
|
||
route_decision=unit.decision,
|
||
route_type="batch",
|
||
segment_ids=list(unit.segment_ids),
|
||
category_segments=category_segments,
|
||
background_voice=background_voice,
|
||
occupation=occupation,
|
||
memoir_correlation_id=memoir_correlation_id,
|
||
fidelity_llm=fidelity_llm,
|
||
)
|
||
if sid:
|
||
dispatch_ids.add(sid)
|
||
return dispatch_ids
|
||
|
||
|
||
def run_story_pipeline_for_category_batch(
|
||
session: Session,
|
||
*,
|
||
user_id: str,
|
||
chapter_category: str,
|
||
category_segments: list,
|
||
state: MemoirStateSchema,
|
||
user_profile: str,
|
||
user_birth_year: int | None,
|
||
llm: Any,
|
||
background_voice: str = "default",
|
||
occupation: str = "",
|
||
memoir_correlation_id: str | None = None,
|
||
llm_fast: Any | None = None,
|
||
memory_evidence: dict | None = None,
|
||
) -> StoryPipelineResult:
|
||
"""运行某 chapter_category 的 Phase2 写入管线。
|
||
|
||
返回 :class:`StoryPipelineResult`。低置信路由会被延迟而不创建 Story/Chapter。
|
||
"""
|
||
pipeline_phase_timings: dict[str, float] = {}
|
||
narrative_agent = NarrativeAgent()
|
||
route_agent = StoryRouteAgent()
|
||
dispatch_ids: set[str] = set()
|
||
llm_route = llm_fast if llm_fast is not None else llm
|
||
llm_fidelity = llm_fast if llm_fast is not None else llm
|
||
|
||
segment_texts = [seg.user_input_text or "" for seg in category_segments]
|
||
combined_text = "\n\n".join(segment_texts)
|
||
source_ids = [seg.id for seg in category_segments]
|
||
|
||
n_units = len(category_segments)
|
||
top_k = int(settings.evidence_top_k_default)
|
||
if n_units > int(settings.evidence_large_batch_threshold):
|
||
top_k = int(settings.evidence_top_k_large_batch)
|
||
|
||
def _oral_job() -> tuple[str, float]:
|
||
t_oral = time.perf_counter()
|
||
out = normalize_oral_for_memoir(combined_text, llm=llm)
|
||
return out, time.perf_counter() - t_oral
|
||
|
||
_t_parallel = time.perf_counter()
|
||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||
oral_future = pool.submit(_oral_job)
|
||
_t_ev = time.perf_counter()
|
||
evidence = memory_evidence or {
|
||
"relevant_chunks": [],
|
||
"relevant_summaries": [],
|
||
"relevant_facts": [],
|
||
"relevant_stories": [],
|
||
}
|
||
ev_elapsed = time.perf_counter() - _t_ev
|
||
oral_for_memoir, oral_elapsed = oral_future.result()
|
||
pipeline_phase_timings["evidence"] = ev_elapsed
|
||
pipeline_phase_timings["oral_normalize"] = oral_elapsed
|
||
pipeline_phase_timings["evidence_oral_parallel_wall"] = (
|
||
time.perf_counter() - _t_parallel
|
||
)
|
||
|
||
logger.info(
|
||
"memoir_evidence_retrieved user_id={} chunks={} facts={} summaries={} stories={} top_k={}",
|
||
user_id,
|
||
len(evidence.get("relevant_chunks") or []),
|
||
len(evidence.get("relevant_facts") or []),
|
||
len(evidence.get("relevant_summaries") or []),
|
||
len(evidence.get("relevant_stories") or []),
|
||
top_k,
|
||
)
|
||
|
||
evidence_text = format_evidence_chunks_for_prompt(evidence)
|
||
ct_raw = (combined_text or "").strip()
|
||
om_norm = (oral_for_memoir or "").strip()
|
||
if ct_raw != om_norm:
|
||
logger.info(
|
||
"event=oral_normalized context=category_batch raw_len={} norm_len={}",
|
||
len(ct_raw),
|
||
len(om_norm),
|
||
)
|
||
logger.info(
|
||
"event=memoir_story_pipeline_start memoir_correlation_id={} user_id={} "
|
||
"chapter_category={} segment_count={}",
|
||
memoir_correlation_id or "",
|
||
user_id,
|
||
chapter_category,
|
||
len(category_segments),
|
||
)
|
||
|
||
stmt_chapter = (
|
||
select(Chapter)
|
||
.where(
|
||
Chapter.user_id == user_id,
|
||
Chapter.category == chapter_category,
|
||
Chapter.is_active == True, # noqa: E712
|
||
)
|
||
.options(
|
||
joinedload(Chapter.images),
|
||
joinedload(Chapter.story_links),
|
||
)
|
||
)
|
||
chapter = session.execute(stmt_chapter).unique().scalar_one_or_none()
|
||
|
||
slot_snippets = _slot_snippets_for_narrative(
|
||
state=state,
|
||
chapter_category=chapter_category,
|
||
user_id=user_id,
|
||
)
|
||
|
||
title = chapter.title if chapter else _placeholder_title(chapter_category)
|
||
|
||
# 仅同 chapter_category(story.stage)的 Story 可作为 append 候选,避免跨章节链接导致多章内容相同
|
||
all_stories = list_active_stories_for_user_sync(session, user_id)
|
||
candidates = [s for s in all_stories if s.stage == chapter_category]
|
||
valid_ids = {str(s.id) for s in candidates}
|
||
story_meta = _story_meta_for_route(session, candidates)
|
||
|
||
# Story route 仅依据本批用户口述;evidence 只进入叙事/合并,不参与 new/append 判定。
|
||
route_transcript = oral_for_memoir
|
||
|
||
calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999)
|
||
|
||
_t0 = time.perf_counter()
|
||
use_batch_plan = (
|
||
llm_route
|
||
and len(category_segments) >= 2
|
||
and len(category_segments) <= PLAN_BATCH_MAX_SEGMENTS
|
||
)
|
||
plan: StoryBatchPlan | None = None
|
||
if use_batch_plan:
|
||
segs = _route_segment_texts(category_segments)
|
||
plan = route_agent.plan_batch(
|
||
chapter_category=chapter_category,
|
||
chapter_title=title,
|
||
segments=segs,
|
||
candidate_stories=candidates,
|
||
llm=llm_route,
|
||
valid_story_ids=valid_ids,
|
||
story_meta=story_meta,
|
||
)
|
||
|
||
single_route: Any = None
|
||
if plan is None:
|
||
single_route = route_agent.decide(
|
||
chapter_category=chapter_category,
|
||
chapter_title=title,
|
||
batch_transcript=route_transcript,
|
||
candidate_stories=candidates,
|
||
llm=llm_route,
|
||
valid_story_ids=valid_ids,
|
||
story_meta=story_meta,
|
||
)
|
||
pipeline_phase_timings["route"] = time.perf_counter() - _t0
|
||
|
||
if (
|
||
plan is None
|
||
and single_route is not None
|
||
and single_route.reason in FALLBACK_NEW_STORY_REASONS
|
||
and bool(settings.memoir_route_defer_enabled)
|
||
):
|
||
defer_ids = [str(s.id) for s in category_segments]
|
||
logger.info(
|
||
"event=memoir_pipeline_route_deferred memoir_correlation_id={} user_id={} "
|
||
"chapter_category={} segment_count={} reason={} "
|
||
"msg=Phase2 路由低置信,本批 segment 进入延迟池",
|
||
memoir_correlation_id or "",
|
||
user_id,
|
||
chapter_category,
|
||
len(defer_ids),
|
||
single_route.reason,
|
||
)
|
||
return StoryPipelineResult(
|
||
chapter=None,
|
||
needs_cover=False,
|
||
dispatch_ids=set(),
|
||
deferred=True,
|
||
defer_reason=str(single_route.reason),
|
||
defer_segment_ids=defer_ids,
|
||
)
|
||
|
||
chapter = _ensure_chapter_record(
|
||
session,
|
||
user_id=user_id,
|
||
chapter_category=chapter_category,
|
||
title=title,
|
||
source_ids=source_ids,
|
||
calculated_order_index=calculated_order_index,
|
||
)
|
||
|
||
_t0 = time.perf_counter()
|
||
if plan is not None:
|
||
dispatch_ids = _run_batch_plan_writes(
|
||
session,
|
||
plan=plan,
|
||
category_segments=category_segments,
|
||
chapter=chapter,
|
||
chapter_category=chapter_category,
|
||
evidence_text=evidence_text,
|
||
evidence=evidence,
|
||
evidence_top_k=top_k,
|
||
slot_snippets=slot_snippets,
|
||
user_id=user_id,
|
||
user_profile=user_profile,
|
||
user_birth_year=user_birth_year,
|
||
llm=llm,
|
||
narrative_agent=narrative_agent,
|
||
candidate_stories=candidates,
|
||
story_meta=story_meta,
|
||
background_voice=background_voice,
|
||
occupation=occupation,
|
||
memoir_correlation_id=memoir_correlation_id,
|
||
fidelity_llm=llm_fidelity,
|
||
)
|
||
else:
|
||
route = single_route
|
||
decision_source = (
|
||
route.reason
|
||
if route.reason in FALLBACK_NEW_STORY_REASONS
|
||
else ("fallback_no_llm" if not llm_route else "single_decide")
|
||
)
|
||
target_story_id, existing_for_narrative, decision_source = (
|
||
_resolve_append_target(
|
||
session,
|
||
route_decision=route.decision,
|
||
route_target_story_id=route.target_story_id,
|
||
user_id=user_id,
|
||
chapter_category=chapter_category,
|
||
oral_norm=om_norm,
|
||
candidate_stories=candidates,
|
||
story_meta=story_meta,
|
||
decision_source=decision_source,
|
||
memoir_correlation_id=memoir_correlation_id,
|
||
)
|
||
)
|
||
|
||
sid, _ = _execute_narrative_unit(
|
||
session,
|
||
oral_text=oral_for_memoir,
|
||
evidence_text=evidence_text,
|
||
evidence=evidence,
|
||
evidence_top_k=top_k,
|
||
chapter=chapter,
|
||
chapter_category=chapter_category,
|
||
slot_snippets=slot_snippets,
|
||
user_id=user_id,
|
||
user_profile=user_profile,
|
||
user_birth_year=user_birth_year,
|
||
llm=llm,
|
||
narrative_agent=narrative_agent,
|
||
target_story_id=target_story_id,
|
||
existing_for_narrative=existing_for_narrative,
|
||
decision_source=decision_source,
|
||
route_decision=route.decision,
|
||
route_type="single",
|
||
segment_ids=[str(s.id) for s in category_segments],
|
||
category_segments=category_segments,
|
||
background_voice=background_voice,
|
||
occupation=occupation,
|
||
memoir_correlation_id=memoir_correlation_id,
|
||
fidelity_llm=llm_fidelity,
|
||
)
|
||
if sid:
|
||
dispatch_ids.add(sid)
|
||
|
||
pipeline_phase_timings["narrative_writes"] = time.perf_counter() - _t0
|
||
|
||
_t0 = time.perf_counter()
|
||
reorder_chapter_story_links_by_life_order_sync(session, str(chapter.id))
|
||
mark_chapter_dirty_sync(session, str(chapter.id))
|
||
session.flush()
|
||
refresh_chapter_evidence_snapshot_with_retry_sync(session, str(chapter.id))
|
||
pipeline_phase_timings["finalize"] = time.perf_counter() - _t0
|
||
|
||
image_settings = MemoirImageSettings.from_env()
|
||
needs_cover = image_settings.enabled and chapter_needs_cover_enqueue(chapter)
|
||
|
||
timing_parts = " ".join(
|
||
f"{k}_seconds={v:.3f}" for k, v in pipeline_phase_timings.items()
|
||
)
|
||
logger.info(
|
||
"event=memoir_pipeline_phases memoir_correlation_id={} user_id={} "
|
||
"chapter_category={} segment_count={} route_type={} {}",
|
||
memoir_correlation_id or "",
|
||
user_id,
|
||
chapter_category,
|
||
len(category_segments),
|
||
"batch" if plan is not None else "single",
|
||
timing_parts,
|
||
)
|
||
|
||
return StoryPipelineResult(
|
||
chapter=chapter,
|
||
needs_cover=needs_cover,
|
||
dispatch_ids=dispatch_ids,
|
||
)
|