refactor(eval+memoir):精简内部评测路由与服务,composite/对话摘要与 judge 能力补强

- 访谈:新增 interview_state_hints,联动 orchestrator 与提示词
- 回忆录:story_pipeline_sync/state/memory/post_commit 与 Celery 任务调整
- 基建:开发用 celery broker、compose/development 脚本、依赖注入
- eval-web:移除数据集/实验/版本等页面与流式轮询,突出 Playground
- 文档与单测同步
This commit is contained in:
Kevin
2026-04-08 21:36:12 +08:00
parent 2a0c80987d
commit 064ad2161d
64 changed files with 3412 additions and 3068 deletions

View File

@@ -617,6 +617,242 @@ def _ensure_chapter_record(
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 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,
) -> 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,
)
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 = _maybe_generate_title(
narrative_agent,
chapter_category=chapter_category,
md=md,
slot_snippets=slot_snippets,
user_profile=user_profile,
user_birth_year=user_birth_year,
llm=llm,
oral_scope=oral_norm,
)
st = create_story_with_version_sync(
session,
user_id=user_id,
title=story_title,
canonical_markdown=md,
stage=chapter_category,
)
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,
*,
@@ -640,210 +876,50 @@ def _run_batch_plan_writes(
memoir_correlation_id: str | None = None,
) -> set[str]:
dispatch_ids: set[str] = set()
max_chars = int(settings.story_append_max_canonical_chars)
max_ver = int(settings.story_append_max_versions)
for unit in plan.units:
t0 = time.perf_counter()
unit_text = _ordered_text_for_segment_ids(category_segments, unit.segment_ids)
oral_unit = normalize_oral_for_memoir(unit_text, llm=llm)
ut_raw = (unit_text or "").strip()
ut_norm = (oral_unit or "").strip()
if ut_raw != ut_norm:
logger.info(
"event=oral_normalized context=batch_unit raw_len={} norm_len={}",
len(ut_raw),
len(ut_norm),
)
new_content_input = format_narrative_user_content(oral_unit, evidence_text)
target_story_id: str | None = None
existing_for_narrative = ""
decision_source = "batch_plan"
if unit.decision == "append_story" and unit.target_story_id:
st = session.get(Story, unit.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=batch_plan",
str(st.id),
len(canon),
vc,
)
target_story_id = None
existing_for_narrative = ""
decision_source = "forced_new_due_to_append_limit"
else:
target_story_id = st.id
existing_for_narrative = canon
elif (
unit.decision == "new_story"
and chapter_category in APPEND_FIRST_CHAPTER_CATEGORIES
and candidate_stories
and len(ut_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(ut_norm),
tid_g,
)
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,
)
raw_gen = narrative_agent.generate_narrative(
stage=chapter_category,
slots=slot_snippets,
new_content=new_content_input,
existing_content=existing_for_narrative,
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,
birth_year=user_birth_year,
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,
fallback_plain_oral=ut_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_unit,
raw_gen,
llm,
existing_canonical=existing_for_narrative or None,
)
narrative_raw, fb_apply = _apply_narrative_fallbacks(
narrative_raw,
oral_unit,
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_unit.strip(),
existing_for_narrative or "",
)
md, inv_fb = _apply_narrative_body_safety(
md,
oral=oral_unit,
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}"
)
if target_story_id:
sid_s = str(target_story_id)
ver = append_story_version_sync(session, sid_s, md)
dlg = _dialogue_lineage_dict_for_segment_ids(
category_segments, list(unit.segment_ids)
)
_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,
)
dispatch_ids.add(sid_s)
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 = _maybe_generate_title(
narrative_agent,
chapter_category=chapter_category,
md=md,
slot_snippets=slot_snippets,
user_profile=user_profile,
user_birth_year=user_birth_year,
llm=llm,
oral_scope=ut_norm,
)
st = create_story_with_version_sync(
session,
user_id=user_id,
title=story_title,
canonical_markdown=md,
stage=chapter_category,
)
dispatch_ids.add(str(st.id))
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:
dlg = _dialogue_lineage_dict_for_segment_ids(
category_segments, list(unit.segment_ids)
)
_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=batch "
"decision_source={} route_decision={} route_planned={} "
"unit_segments={} used_evidence={} narrative_json_valid={} fidelity_passed={} "
"fallback_type={} oral_len={} md_len={} chapter_category={} is_append={} "
"story_id={} seconds={:.3f} oral_normalize_changed={}",
memoir_correlation_id or "",
decision_source,
"append_story" if is_append else "new_story",
unit.decision,
len(unit.segment_ids),
bool(evidence_text.strip()),
_is_json_narrative(raw_gen),
fb_gate == "none",
fallback_type,
len(ut_norm),
len(md.strip()),
chapter_category,
is_append,
sid_log,
elapsed,
ut_raw != ut_norm,
memoir_correlation_id=memoir_correlation_id,
)
if sid:
dispatch_ids.add(sid)
return dispatch_ids
@@ -864,6 +940,7 @@ def run_story_pipeline_for_category_batch(
"""
返回 (chapter, needs_cover_enqueue, story_ids_to_dispatch_after_commit)。
"""
pipeline_phase_timings: dict[str, float] = {}
narrative_agent = NarrativeAgent()
route_agent = StoryRouteAgent()
dispatch_ids: set[str] = set()
@@ -878,6 +955,7 @@ def run_story_pipeline_for_category_batch(
top_k = int(settings.evidence_top_k_large_batch)
emb = get_embedding_provider()
embedding_available = emb.is_available()
_t0 = time.perf_counter()
try:
evidence = retrieve_evidence_sync(
session,
@@ -895,6 +973,7 @@ def run_story_pipeline_for_category_batch(
"timeline_hints": [],
"relevant_stories": [],
}
pipeline_phase_timings["evidence"] = time.perf_counter() - _t0
logger.info(
"memoir_evidence_retrieved user_id={} chunks={} facts={} summaries={} stories={} vector_ok={}",
@@ -907,7 +986,9 @@ def run_story_pipeline_for_category_batch(
)
evidence_text = format_evidence_chunks_for_prompt(evidence)
_t0 = time.perf_counter()
oral_for_memoir = normalize_oral_for_memoir(combined_text, llm=llm)
pipeline_phase_timings["oral_normalize"] = time.perf_counter() - _t0
ct_raw = (combined_text or "").strip()
om_norm = (oral_for_memoir or "").strip()
if ct_raw != om_norm:
@@ -959,6 +1040,7 @@ def run_story_pipeline_for_category_batch(
calculated_order_index = STAGE_TO_ORDER.get(chapter_category, 999)
_t0 = time.perf_counter()
use_batch_plan = (
llm
and len(category_segments) >= 2
@@ -976,6 +1058,7 @@ def run_story_pipeline_for_category_batch(
valid_story_ids=valid_ids,
story_meta=story_meta,
)
pipeline_phase_timings["route"] = time.perf_counter() - _t0
chapter = _ensure_chapter_record(
session,
@@ -986,6 +1069,7 @@ def run_story_pipeline_for_category_batch(
calculated_order_index=calculated_order_index,
)
_t0 = time.perf_counter()
if plan is not None:
dispatch_ids = _run_batch_plan_writes(
session,
@@ -1019,203 +1103,72 @@ def run_story_pipeline_for_category_batch(
story_meta=story_meta,
)
t0 = time.perf_counter()
target_story_id: str | None = None
existing_for_narrative = ""
decision_source = "fallback_no_llm" if not llm else "single_decide"
max_chars = int(settings.story_append_max_canonical_chars)
max_ver = int(settings.story_append_max_versions)
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=single_decide",
str(st.id),
len(canon),
vc,
)
target_story_id = None
existing_for_narrative = ""
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 candidates
and len(om_norm)
<= int(settings.memoir_story_route_append_guardrail_oral_chars)
):
tid_g = default_append_target_story_id(candidates, 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={} route_type=single",
memoir_correlation_id or "",
chapter_category,
len(om_norm),
tid_g,
)
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,
)
raw_gen = narrative_agent.generate_narrative(
stage=chapter_category,
slots=slot_snippets,
new_content=new_content_input,
existing_content=existing_for_narrative,
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,
birth_year=user_birth_year,
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,
fallback_plain_oral=om_norm,
memoir_correlation_id=memoir_correlation_id,
)
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
if sid:
dispatch_ids.add(sid)
narrative_raw, fb_gate = _gate_narrative_fidelity(
oral_for_memoir,
raw_gen,
llm,
existing_canonical=existing_for_narrative or None,
)
narrative_raw, fb_apply = _apply_narrative_fallbacks(
narrative_raw,
oral_for_memoir,
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_for_memoir.strip(),
existing_for_narrative or "",
)
md, inv_fb = _apply_narrative_body_safety(
md,
oral=oral_for_memoir,
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}"
)
do_append = target_story_id is not None
dlg_single = _dialogue_lineage_dict_for_segment_ids(
category_segments,
[str(s.id) for s in category_segments],
)
if do_append:
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=top_k,
dialogue_lineage=dlg_single,
)
dispatch_ids.add(sid_s)
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 = _maybe_generate_title(
narrative_agent,
chapter_category=chapter_category,
md=md,
slot_snippets=slot_snippets,
user_profile=user_profile,
user_birth_year=user_birth_year,
llm=llm,
oral_scope=om_norm,
)
st = create_story_with_version_sync(
session,
user_id=user_id,
title=story_title,
canonical_markdown=md,
stage=chapter_category,
)
dispatch_ids.add(str(st.id))
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=top_k,
dialogue_lineage=dlg_single,
)
elapsed = time.perf_counter() - t0
logger.info(
"event=story_generated memoir_correlation_id={} route_type=single "
"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} oral_normalize_changed={}",
memoir_correlation_id or "",
decision_source,
route.decision,
len(category_segments),
bool(evidence_text.strip()),
_is_json_narrative(raw_gen),
fb_gate == "none",
fallback_type,
len(om_norm),
len(md.strip()),
chapter_category,
is_append,
sid_log,
elapsed,
ct_raw != om_norm,
)
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 chapter, needs_cover, dispatch_ids