Files
life-echo/api/app/features/memoir/story_pipeline_sync.py
Kevin ccdc4e4277 feat(i18n): persist language preference and thread through chat, memoir, TTS
- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
  only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
  names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
  for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:16:49 +08:00

1276 lines
42 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.
"""
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, language: str = "zh") -> str:
if language == "en":
from app.agents.stage_constants import chapter_category_display
return chapter_category_display(chapter_category, language="en")
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,
language: str = "zh",
) -> str:
"""
按 · / • 分节丢弃含未落地履历短语的小节;全部丢弃则占位。
"""
if not settings.memoir_title_hay_grounding_strict_phrases_enabled:
return (title or "").strip() or _placeholder_title(
chapter_category, language=language
)
t = (title or "").strip()
h = (hay or "").strip()
if not t:
return _placeholder_title(chapter_category, language=language)
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, language=language)
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,
language: str = "zh",
) -> 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, language=language)
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,
language=language,
)
hay = _title_hay_for_grounding(merged_slots, md, oral_scope)
return _strip_ungrounded_title_segments(
raw_title,
hay,
chapter_category=chapter_category,
language=language,
)
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,
language: str = "zh",
) -> 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, language=language
)
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,
language=language,
)
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, language=language)
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,
language: str = "zh",
) -> 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,
language=language,
)
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,
language: str = "zh",
) -> 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, language=language
)
# 仅同 chapter_categorystory.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,
language=language,
)
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,
language=language,
)
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,
)