555 lines
21 KiB
Python
555 lines
21 KiB
Python
"""组装 Chapter/Story 评测证据闭包并格式化为评审输入。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Literal
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.features.conversation import repo as conversation_repo
|
||
from app.features.conversation.lineage_schemas import aggregate_lineage_from_segments
|
||
from app.features.evaluation.eval_trace_format import (
|
||
build_segment_transcript,
|
||
format_chapter_for_judge,
|
||
format_story_for_judge,
|
||
)
|
||
from app.features.evaluation.eval_trace_repo import (
|
||
fetch_ai_messages_for_segments,
|
||
fetch_memory_closure_for_conversations,
|
||
fetch_segments_for_user,
|
||
get_chapter_for_eval_trace,
|
||
get_story_for_eval_trace,
|
||
list_chapter_ids_for_story,
|
||
load_chunks_by_ids,
|
||
load_facts_by_ids,
|
||
load_summaries_by_ids,
|
||
load_timeline_by_ids,
|
||
normalize_source_segment_ids,
|
||
story_link_ids_by_type,
|
||
)
|
||
from app.features.evaluation.eval_trace_schemas import (
|
||
ChapterEvidenceBundle,
|
||
FormattedMemoirEvidence,
|
||
StoryEvidenceBundle,
|
||
)
|
||
from app.features.memoir.chapter_evidence_snapshot import (
|
||
EVIDENCE_SNAPSHOT_SCHEMA_VERSION,
|
||
)
|
||
from app.features.memoir.models import Chapter
|
||
from app.features.story.models import Story, StoryVersion
|
||
|
||
_MAX_EVIDENCE_CONVERSATIONS = 8
|
||
_MAX_EVIDENCE_TRANSCRIPT_CHARS = 16_000
|
||
|
||
|
||
def _segments_in_order(segments: list, segment_ids: list[str]) -> list:
|
||
order = {str(sid): i for i, sid in enumerate(segment_ids)}
|
||
return sorted(segments, key=lambda s: order.get(str(s.id), 9999))
|
||
|
||
|
||
def _trim_fallback_transcript(text: str) -> str:
|
||
s = (text or "").strip()
|
||
if len(s) <= _MAX_EVIDENCE_TRANSCRIPT_CHARS:
|
||
return s
|
||
return f"{s[:_MAX_EVIDENCE_TRANSCRIPT_CHARS]}\n\n…(访谈证据已截断)"
|
||
|
||
|
||
async def fallback_user_transcript_evidence(db: AsyncSession, user_id: str) -> str:
|
||
"""legacy:最近若干会话全文(仅作 fallback,调用方须声明 tier=fallback)。"""
|
||
conversations = await conversation_repo.get_user_conversations(user_id, db)
|
||
if not conversations:
|
||
return ""
|
||
parts: list[str] = []
|
||
for conv in reversed(conversations[:_MAX_EVIDENCE_CONVERSATIONS]):
|
||
rows = await conversation_repo.get_conversation_messages(str(conv.id), db)
|
||
blocks: list[str] = []
|
||
for row in rows:
|
||
role = str(row.role or "").lower()
|
||
body = (row.content or "").strip()
|
||
if not body:
|
||
continue
|
||
label = "用户" if role == "human" else "AI"
|
||
blocks.append(f"{label}: {body}")
|
||
transcript = "\n\n".join(blocks)
|
||
if transcript:
|
||
parts.append(f"## 会话 {str(conv.id)}\n{transcript}")
|
||
return _trim_fallback_transcript("\n\n".join(parts))
|
||
|
||
|
||
class EvalTraceService:
|
||
def __init__(self, db: AsyncSession) -> None:
|
||
self._db = db
|
||
|
||
async def _story_dialogue_lineage(
|
||
self,
|
||
st: Story,
|
||
segments: list,
|
||
segment_ids_ordered: list[str],
|
||
) -> dict | None:
|
||
if getattr(st, "current_version_id", None):
|
||
ver = await self._db.get(StoryVersion, st.current_version_id)
|
||
if ver and isinstance(getattr(ver, "lineage_json", None), dict):
|
||
lj = ver.lineage_json
|
||
if lj.get("turns"):
|
||
return lj
|
||
if segments and segment_ids_ordered:
|
||
ordered = _segments_in_order(segments, segment_ids_ordered)
|
||
conv_ids = sorted(
|
||
{str(s.conversation_id) for s in segments if s.conversation_id}
|
||
)
|
||
return aggregate_lineage_from_segments(
|
||
ordered, conversation_id_fallback=conv_ids[0] if conv_ids else None
|
||
)
|
||
return None
|
||
|
||
def _chapter_closure_tier(
|
||
self,
|
||
*,
|
||
segment_ids_resolved: list[str],
|
||
chunk_ids: list[str],
|
||
fact_ids: list[str],
|
||
tl_ids: list[str],
|
||
sum_ids: list[str],
|
||
) -> Literal["strict", "partial", "fallback"]:
|
||
has_seg = bool(segment_ids_resolved)
|
||
has_mem = bool(chunk_ids or fact_ids or tl_ids or sum_ids)
|
||
if has_seg and has_mem:
|
||
return "strict"
|
||
if has_seg:
|
||
return "partial"
|
||
if has_mem:
|
||
return "partial"
|
||
return "fallback"
|
||
|
||
async def build_chapter_bundle(
|
||
self, user_id: str, chapter: Chapter
|
||
) -> ChapterEvidenceBundle:
|
||
notes: list[str] = []
|
||
live_segment_ids = normalize_source_segment_ids(
|
||
getattr(chapter, "source_segments", None)
|
||
)
|
||
|
||
row = getattr(chapter, "current_evidence_snapshot", None)
|
||
row_has_closure = bool(
|
||
(row and (row.segment_ids or []))
|
||
or (
|
||
row
|
||
and (
|
||
row.memory_chunk_ids
|
||
or row.memory_fact_ids
|
||
or row.timeline_event_ids
|
||
or row.summary_ids
|
||
)
|
||
)
|
||
)
|
||
if (
|
||
row is not None
|
||
and str(row.user_id) == str(user_id)
|
||
and str(row.chapter_id) == str(chapter.id)
|
||
and int(row.schema_version or 0) == EVIDENCE_SNAPSHOT_SCHEMA_VERSION
|
||
and row_has_closure
|
||
):
|
||
segment_ids = [str(x) for x in (row.segment_ids or []) if str(x).strip()]
|
||
conv_ids = sorted(
|
||
{str(x) for x in (row.conversation_ids or []) if str(x).strip()}
|
||
)
|
||
chunk_ids = [str(x) for x in (row.memory_chunk_ids or []) if str(x).strip()]
|
||
fact_ids = [str(x) for x in (row.memory_fact_ids or []) if str(x).strip()]
|
||
tl_ids = [str(x) for x in (row.timeline_event_ids or []) if str(x).strip()]
|
||
sum_ids = [str(x) for x in (row.summary_ids or []) if str(x).strip()]
|
||
notes.extend([str(x) for x in (row.notes or []) if x])
|
||
notes.append("evidence_from_chapter_evidence_snapshot_table")
|
||
tier = self._chapter_closure_tier(
|
||
segment_ids_resolved=segment_ids,
|
||
chunk_ids=chunk_ids,
|
||
fact_ids=fact_ids,
|
||
tl_ids=tl_ids,
|
||
sum_ids=sum_ids,
|
||
)
|
||
if live_segment_ids and set(live_segment_ids) != set(segment_ids):
|
||
notes.append(
|
||
"live_source_segments_differ_from_snapshot_reconcile_in_pipeline"
|
||
)
|
||
dlg = getattr(row, "message_lineage_json", None)
|
||
return ChapterEvidenceBundle(
|
||
user_id=user_id,
|
||
chapter_id=str(chapter.id),
|
||
segment_ids=segment_ids,
|
||
conversation_ids=conv_ids,
|
||
memory_chunk_ids=chunk_ids,
|
||
memory_fact_ids=fact_ids,
|
||
timeline_event_ids=tl_ids,
|
||
summary_ids=sum_ids,
|
||
lineage_tier=tier,
|
||
notes=notes,
|
||
dialogue_lineage=dlg if isinstance(dlg, dict) else None,
|
||
)
|
||
|
||
snap = getattr(chapter, "evidence_bundle_json", None)
|
||
snap_uid = str(snap.get("user_id") or "") if isinstance(snap, dict) else ""
|
||
snap_has_closure = bool(
|
||
(isinstance(snap, dict) and (snap.get("segment_ids") or []))
|
||
or (
|
||
isinstance(snap, dict)
|
||
and (
|
||
snap.get("memory_chunk_ids")
|
||
or snap.get("memory_fact_ids")
|
||
or snap.get("timeline_event_ids")
|
||
or snap.get("summary_ids")
|
||
)
|
||
)
|
||
)
|
||
use_snap = (
|
||
isinstance(snap, dict)
|
||
and int(snap.get("schema_version") or 0) == EVIDENCE_SNAPSHOT_SCHEMA_VERSION
|
||
and str(snap.get("chapter_id") or "") == str(chapter.id)
|
||
and (not snap_uid or snap_uid == str(user_id))
|
||
and snap_has_closure
|
||
)
|
||
|
||
if use_snap and isinstance(snap, dict):
|
||
segment_ids = [
|
||
str(x) for x in (snap.get("segment_ids") or []) if str(x).strip()
|
||
]
|
||
conv_ids = sorted(
|
||
{str(x) for x in (snap.get("conversation_ids") or []) if str(x).strip()}
|
||
)
|
||
chunk_ids = [
|
||
str(x) for x in (snap.get("memory_chunk_ids") or []) if str(x).strip()
|
||
]
|
||
fact_ids = [
|
||
str(x) for x in (snap.get("memory_fact_ids") or []) if str(x).strip()
|
||
]
|
||
tl_ids = [
|
||
str(x) for x in (snap.get("timeline_event_ids") or []) if str(x).strip()
|
||
]
|
||
sum_ids = [
|
||
str(x) for x in (snap.get("summary_ids") or []) if str(x).strip()
|
||
]
|
||
notes.extend([str(x) for x in (snap.get("notes") or []) if x])
|
||
notes.append("evidence_from_chapter_evidence_bundle_json_column")
|
||
tier = self._chapter_closure_tier(
|
||
segment_ids_resolved=segment_ids,
|
||
chunk_ids=chunk_ids,
|
||
fact_ids=fact_ids,
|
||
tl_ids=tl_ids,
|
||
sum_ids=sum_ids,
|
||
)
|
||
if live_segment_ids and set(live_segment_ids) != set(segment_ids):
|
||
notes.append(
|
||
"live_source_segments_differ_from_snapshot_reconcile_in_pipeline"
|
||
)
|
||
snap_dlg = (
|
||
snap.get("message_lineage_json") if isinstance(snap, dict) else None
|
||
)
|
||
return ChapterEvidenceBundle(
|
||
user_id=user_id,
|
||
chapter_id=str(chapter.id),
|
||
segment_ids=segment_ids,
|
||
conversation_ids=conv_ids,
|
||
memory_chunk_ids=chunk_ids,
|
||
memory_fact_ids=fact_ids,
|
||
timeline_event_ids=tl_ids,
|
||
summary_ids=sum_ids,
|
||
lineage_tier=tier,
|
||
notes=notes,
|
||
dialogue_lineage=snap_dlg if isinstance(snap_dlg, dict) else None,
|
||
)
|
||
|
||
segment_ids = live_segment_ids
|
||
if not segment_ids:
|
||
notes.append("no_source_segments")
|
||
notes.append("fallback_lineage_transcript_pending")
|
||
return ChapterEvidenceBundle(
|
||
user_id=user_id,
|
||
chapter_id=str(chapter.id),
|
||
segment_ids=[],
|
||
conversation_ids=[],
|
||
lineage_tier="fallback",
|
||
notes=notes,
|
||
dialogue_lineage=None,
|
||
)
|
||
|
||
segments = await fetch_segments_for_user(
|
||
self._db, user_id=user_id, segment_ids=segment_ids
|
||
)
|
||
resolved_seg_ids = [s.id for s in segments] or segment_ids
|
||
if len(segments) < len(segment_ids):
|
||
notes.append("some_segments_missing_or_foreign_user")
|
||
conv_ids = sorted(
|
||
{str(s.conversation_id) for s in segments if s.conversation_id}
|
||
)
|
||
(
|
||
chunk_ids,
|
||
fact_ids,
|
||
tl_ids,
|
||
sum_ids,
|
||
) = await fetch_memory_closure_for_conversations(
|
||
self._db, user_id=user_id, conversation_ids=conv_ids
|
||
)
|
||
tier = self._chapter_closure_tier(
|
||
segment_ids_resolved=resolved_seg_ids,
|
||
chunk_ids=chunk_ids,
|
||
fact_ids=fact_ids,
|
||
tl_ids=tl_ids,
|
||
sum_ids=sum_ids,
|
||
)
|
||
if tier == "partial":
|
||
notes.append(
|
||
"chapter_source_segments_union_semantics=partial_lineage_until_snapshot"
|
||
)
|
||
elif tier == "strict":
|
||
notes.append("chapter_lineage_strict_segments_plus_memory_closure")
|
||
segs_ord = _segments_in_order(segments, resolved_seg_ids)
|
||
dlg_live = aggregate_lineage_from_segments(
|
||
segs_ord, conversation_id_fallback=conv_ids[0] if conv_ids else None
|
||
)
|
||
return ChapterEvidenceBundle(
|
||
user_id=user_id,
|
||
chapter_id=str(chapter.id),
|
||
segment_ids=resolved_seg_ids,
|
||
conversation_ids=conv_ids,
|
||
memory_chunk_ids=chunk_ids,
|
||
memory_fact_ids=fact_ids,
|
||
timeline_event_ids=tl_ids,
|
||
summary_ids=sum_ids,
|
||
lineage_tier=tier,
|
||
notes=notes,
|
||
dialogue_lineage=dlg_live,
|
||
)
|
||
|
||
async def format_chapter_bundle(
|
||
self, bundle: ChapterEvidenceBundle
|
||
) -> tuple[FormattedMemoirEvidence, ChapterEvidenceBundle]:
|
||
"""若 tier=fallback,调用方应先将要并入 transcripts 写入 session;此处只负责 segment 路径。"""
|
||
if bundle.lineage_tier == "fallback":
|
||
ft = await fallback_user_transcript_evidence(self._db, bundle.user_id)
|
||
notes = list(bundle.notes)
|
||
notes.append("used_legacy_recent_conversations_transcript")
|
||
bundle = bundle.model_copy(update={"notes": notes})
|
||
formatted = format_chapter_for_judge(
|
||
bundle,
|
||
transcript=ft,
|
||
chunks=[],
|
||
facts=[],
|
||
events=[],
|
||
summaries=[],
|
||
)
|
||
return formatted, bundle
|
||
|
||
segs = await fetch_segments_for_user(
|
||
self._db, user_id=bundle.user_id, segment_ids=bundle.segment_ids
|
||
)
|
||
ai_map = await fetch_ai_messages_for_segments(
|
||
self._db, user_id=bundle.user_id, segment_ids=[s.id for s in segs]
|
||
)
|
||
transcript = build_segment_transcript(segs, ai_map)
|
||
chunks = await load_chunks_by_ids(
|
||
self._db, user_id=bundle.user_id, chunk_ids=bundle.memory_chunk_ids
|
||
)
|
||
facts = await load_facts_by_ids(
|
||
self._db, user_id=bundle.user_id, fact_ids=bundle.memory_fact_ids
|
||
)
|
||
events = await load_timeline_by_ids(
|
||
self._db, user_id=bundle.user_id, event_ids=bundle.timeline_event_ids
|
||
)
|
||
summaries = await load_summaries_by_ids(
|
||
self._db, user_id=bundle.user_id, summary_ids=bundle.summary_ids
|
||
)
|
||
formatted = format_chapter_for_judge(
|
||
bundle,
|
||
transcript=transcript,
|
||
chunks=chunks,
|
||
facts=facts,
|
||
events=events,
|
||
summaries=summaries,
|
||
)
|
||
return formatted, bundle
|
||
|
||
async def build_story_bundle(
|
||
self, user_id: str, story_id: str
|
||
) -> StoryEvidenceBundle:
|
||
st = await get_story_for_eval_trace(
|
||
self._db, user_id=user_id, story_id=story_id
|
||
)
|
||
if not st:
|
||
return StoryEvidenceBundle(
|
||
user_id=user_id,
|
||
story_id=story_id,
|
||
lineage_tier="fallback",
|
||
notes=["story_not_found"],
|
||
dialogue_lineage=None,
|
||
)
|
||
|
||
links = list(st.evidence_links or [])
|
||
lc, lf, lt, ls = story_link_ids_by_type(links)
|
||
notes: list[str] = []
|
||
chapter_ids = await list_chapter_ids_for_story(
|
||
self._db, user_id=user_id, story_id=str(st.id)
|
||
)
|
||
|
||
if lc or lf or lt or ls:
|
||
# 结构化以 link 为准;会话级 transcript 尝试从挂靠章节 source_segments 收缩
|
||
seg_ids: list[str] = []
|
||
conv_ids: list[str] = []
|
||
for cid in chapter_ids:
|
||
ch = await get_chapter_for_eval_trace(
|
||
self._db, user_id=user_id, chapter_id=cid
|
||
)
|
||
if not ch:
|
||
continue
|
||
seg_ids.extend(normalize_source_segment_ids(ch.source_segments))
|
||
# 保序去重
|
||
seen_s: set[str] = set()
|
||
dedup_seg: list[str] = []
|
||
for s in seg_ids:
|
||
if s not in seen_s:
|
||
seen_s.add(s)
|
||
dedup_seg.append(s)
|
||
segments = await fetch_segments_for_user(
|
||
self._db, user_id=user_id, segment_ids=dedup_seg
|
||
)
|
||
conv_ids = sorted(
|
||
{str(s.conversation_id) for s in segments if s.conversation_id}
|
||
)
|
||
if dedup_seg and not segments:
|
||
notes.append("chapter_segment_ids_unresolved")
|
||
if conv_ids:
|
||
notes.append("transcript_from_chapter_source_segments")
|
||
else:
|
||
notes.append("no_chapter_segments_for_transcript_context")
|
||
bound_transcript = bool(segments)
|
||
story_tier: Literal["strict", "partial", "fallback"] = "strict"
|
||
if (lc or lf or lt or ls) and not bound_transcript:
|
||
notes.append("structured_evidence_without_bound_transcript")
|
||
story_tier = "partial"
|
||
dlg = await self._story_dialogue_lineage(st, segments, dedup_seg)
|
||
return StoryEvidenceBundle(
|
||
user_id=user_id,
|
||
story_id=str(st.id),
|
||
segment_ids=[s.id for s in segments] or dedup_seg,
|
||
conversation_ids=conv_ids,
|
||
memory_chunk_ids=lc,
|
||
memory_fact_ids=lf,
|
||
timeline_event_ids=lt,
|
||
summary_ids=ls,
|
||
lineage_tier=story_tier,
|
||
notes=notes,
|
||
augmented_with_chapter_context=bool(chapter_ids),
|
||
story_link_evidence_count=len(links),
|
||
fallback_chapter_ids=chapter_ids,
|
||
dialogue_lineage=dlg,
|
||
)
|
||
|
||
# 无 StoryEvidenceLink:由章节 source_segments 推导 partial;再不行则 fallback
|
||
seg_ids = []
|
||
conv_ids: list[str] = []
|
||
for cid in chapter_ids:
|
||
ch = await get_chapter_for_eval_trace(
|
||
self._db, user_id=user_id, chapter_id=cid
|
||
)
|
||
if not ch:
|
||
continue
|
||
seg_ids.extend(normalize_source_segment_ids(ch.source_segments))
|
||
seen_s = set()
|
||
dedup_seg = []
|
||
for s in seg_ids:
|
||
if s not in seen_s:
|
||
seen_s.add(s)
|
||
dedup_seg.append(s)
|
||
if dedup_seg:
|
||
segments = await fetch_segments_for_user(
|
||
self._db, user_id=user_id, segment_ids=dedup_seg
|
||
)
|
||
conv_ids = sorted(
|
||
{str(s.conversation_id) for s in segments if s.conversation_id}
|
||
)
|
||
(
|
||
chunk_ids,
|
||
fact_ids,
|
||
tl_ids,
|
||
sum_ids,
|
||
) = await fetch_memory_closure_for_conversations(
|
||
self._db, user_id=user_id, conversation_ids=conv_ids
|
||
)
|
||
notes.append("fallback_lineage_no_story_evidence_links")
|
||
notes.append("augmented_with_chapter_context")
|
||
dlg2 = await self._story_dialogue_lineage(st, segments, dedup_seg)
|
||
return StoryEvidenceBundle(
|
||
user_id=user_id,
|
||
story_id=str(st.id),
|
||
segment_ids=[s.id for s in segments] or dedup_seg,
|
||
conversation_ids=conv_ids,
|
||
memory_chunk_ids=chunk_ids,
|
||
memory_fact_ids=fact_ids,
|
||
timeline_event_ids=tl_ids,
|
||
summary_ids=sum_ids,
|
||
lineage_tier="partial",
|
||
notes=notes,
|
||
augmented_with_chapter_context=True,
|
||
story_link_evidence_count=0,
|
||
fallback_chapter_ids=chapter_ids,
|
||
dialogue_lineage=dlg2,
|
||
)
|
||
|
||
notes.append("no_story_evidence_links_and_no_chapter_segments")
|
||
notes.append("fallback_lineage_transcript_pending")
|
||
dlg3 = await self._story_dialogue_lineage(st, [], [])
|
||
return StoryEvidenceBundle(
|
||
user_id=user_id,
|
||
story_id=str(st.id),
|
||
lineage_tier="fallback",
|
||
notes=notes,
|
||
story_link_evidence_count=0,
|
||
fallback_chapter_ids=chapter_ids,
|
||
dialogue_lineage=dlg3,
|
||
)
|
||
|
||
async def format_story_bundle(
|
||
self, bundle: StoryEvidenceBundle
|
||
) -> tuple[FormattedMemoirEvidence, StoryEvidenceBundle]:
|
||
if bundle.lineage_tier == "fallback":
|
||
ft = await fallback_user_transcript_evidence(self._db, bundle.user_id)
|
||
notes = list(bundle.notes)
|
||
notes.append("used_legacy_recent_conversations_transcript")
|
||
bundle = bundle.model_copy(update={"notes": notes})
|
||
formatted = format_story_for_judge(
|
||
bundle,
|
||
transcript=ft,
|
||
chunks=[],
|
||
facts=[],
|
||
events=[],
|
||
summaries=[],
|
||
)
|
||
return formatted, bundle
|
||
|
||
segs = await fetch_segments_for_user(
|
||
self._db, user_id=bundle.user_id, segment_ids=bundle.segment_ids
|
||
)
|
||
ai_map = await fetch_ai_messages_for_segments(
|
||
self._db, user_id=bundle.user_id, segment_ids=[s.id for s in segs]
|
||
)
|
||
transcript = build_segment_transcript(segs, ai_map)
|
||
|
||
chunks = await load_chunks_by_ids(
|
||
self._db, user_id=bundle.user_id, chunk_ids=bundle.memory_chunk_ids
|
||
)
|
||
facts = await load_facts_by_ids(
|
||
self._db, user_id=bundle.user_id, fact_ids=bundle.memory_fact_ids
|
||
)
|
||
events = await load_timeline_by_ids(
|
||
self._db, user_id=bundle.user_id, event_ids=bundle.timeline_event_ids
|
||
)
|
||
summaries = await load_summaries_by_ids(
|
||
self._db, user_id=bundle.user_id, summary_ids=bundle.summary_ids
|
||
)
|
||
formatted = format_story_for_judge(
|
||
bundle,
|
||
transcript=transcript,
|
||
chunks=chunks,
|
||
facts=facts,
|
||
events=events,
|
||
summaries=summaries,
|
||
)
|
||
return formatted, bundle
|