PMFlow/backend/tests/test_adaptive_rag_external_...

884 lines
30 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.

from __future__ import annotations
from datetime import timedelta
import pytest
import pytest_asyncio
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.models.base import Base
from app.models.feishu_reply_outbox import FeishuReplyOutbox
from app.models.ingest_event import IngestEvent
from app.models.qa_record import QARecord
from app.models.qa_session import QASession
from app.models.qa_session_state import QASessionState
from app.services.adaptive_rag_service.mixin_core_intent import AdaptiveRAGCoreIntentMixin
from app.services.adaptive_rag_service.mixin_followup_anchor import (
AdaptiveRAGFollowupAnchorMixin,
FollowupAnchorResolution,
)
from app.services.adaptive_rag_service.mixin_qa_session_core import AdaptiveRAGQASessionCoreMixin
from app.services.adaptive_rag_service.mixin_session_state import AdaptiveRAGSessionStateMixin
from app.utils.timezone import now_cn_naive
def _use_sqlite_json_types() -> None:
for table in (QARecord.__table__, QASessionState.__table__, IngestEvent.__table__):
for column in table.columns:
if column.name in {
"sources",
"domains_used",
"citations",
"sources_used",
"evidence_pack",
"tool_trace",
"retrieval_metrics",
"last_entity_ids",
"rewrite_history",
"pending_clarification",
"routing_snapshot",
}:
column.type = JSON()
if table.name == "ingest_events" and column.name == "payload":
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
QASession.__table__,
QASessionState.__table__,
QARecord.__table__,
FeishuReplyOutbox.__table__,
IngestEvent.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
class _FakeContextCache:
async def get_session_messages(self, *, tenant_id: int, session_id: int | None):
_ = (tenant_id, session_id)
return []
async def append_session_messages(self, *, tenant_id: int, session_id: int | None, question: str, answer: str):
_ = (tenant_id, session_id, question, answer)
return None
@staticmethod
def messages_to_turns(messages):
_ = messages
return []
class _Harness(
AdaptiveRAGCoreIntentMixin,
AdaptiveRAGQASessionCoreMixin,
AdaptiveRAGSessionStateMixin,
AdaptiveRAGFollowupAnchorMixin,
):
def __init__(self, db) -> None:
super().__init__(db, tenant_id=1)
self.context_cache = _FakeContextCache()
async def _resolve_followup_anchor_semantics(
self,
*,
message: str,
has_explicit_reply_anchor: bool,
recent_candidates: list[dict[str, Any]],
) -> dict[str, Any]:
text = str(message or "").strip()
normalized = text.replace(" ", "")
if "项目有吗" in normalized or "有没有" in normalized:
return {
"followup_mode": "none",
"meta_followup_kind": "none",
"anchor_strategy": "none",
"candidate_index": 0,
"confidence": 0.9,
}
meta_kind = "none"
if any(token in normalized for token in ("哪个模型", "什么模型", "谁回复", "谁回答")):
meta_kind = "reply_model_identity"
elif any(token in normalized for token in ("什么情况", "怎么回事", "为什么失败", "为什么报错", "详细说", "详细解释")):
meta_kind = "reply_error_reason"
elif any(token in normalized for token in ("来源", "根据什么", "什么依据")):
meta_kind = "reply_source"
if meta_kind != "none":
followup_mode = "meta"
elif has_explicit_reply_anchor or recent_candidates:
followup_mode = "referential"
else:
followup_mode = "none"
if has_explicit_reply_anchor:
anchor_strategy = "explicit"
elif meta_kind != "none" and len(recent_candidates) > 1:
anchor_strategy = "clarify"
elif len(recent_candidates) == 1:
anchor_strategy = "implicit_recent"
elif len(recent_candidates) > 1:
anchor_strategy = "clarify"
else:
anchor_strategy = "none"
return {
"followup_mode": followup_mode,
"meta_followup_kind": meta_kind,
"anchor_strategy": anchor_strategy,
"candidate_index": 1 if anchor_strategy == "implicit_recent" and recent_candidates else 0,
"confidence": 0.9,
}
@pytest.mark.asyncio
async def test_external_conversation_key_creates_and_reuses_real_qa_session(db_session) -> None:
service = _Harness(db_session)
session_id, turn_index = await service._touch_session_for_new_record(
asked_by=None,
asked_via="feishu",
project_id=None,
question="帮我解释下系统架构",
external_conversation_key="feishu:chat-1:root-1",
)
assert session_id is not None
assert turn_index == 1
reused_session_id, reused_turn_index = await service._touch_session_for_new_record(
asked_by=None,
asked_via="feishu",
project_id=None,
question="系统有什么模型?",
external_conversation_key="feishu:chat-1:root-1",
)
assert reused_session_id == session_id
assert reused_turn_index == 2
row = await db_session.get(QASession, int(session_id))
assert row is not None
assert row.user_id is None
assert row.external_conversation_key == "feishu:chat-1:root-1"
@pytest.mark.asyncio
async def test_meta_followup_prefers_single_recent_reply_candidate(db_session) -> None:
service = _Harness(db_session)
session = QASession(
tenant_id=1,
user_id=None,
title="系统架构",
external_conversation_key="feishu:chat-1:root-1",
started_at=now_cn_naive(),
last_message_at=now_cn_naive(),
message_count=1,
)
db_session.add(session)
await db_session.flush()
db_session.add(
QARecord(
tenant_id=1,
question="帮我解释下系统的架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=10,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
resolution = await service._resolve_followup_anchor(
question="我问你这个是哪个模型执行回复的?",
session_id=int(session.id),
context_session_id=int(session.id),
session_state=state,
)
assert resolution.anchor_mode == "implicit_recent_reply"
assert resolution.anchor_reply_id is not None
assert resolution.is_meta_followup is True
assert resolution.candidate_replies == []
@pytest.mark.asyncio
async def test_followup_anchor_prefers_structured_semantics_without_keyword_hints(
db_session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="架构说明",
external_conversation_key="feishu:chat-1:root-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add(session)
await db_session.flush()
db_session.add(
QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=10,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:root-1",
created_at=now,
)
)
await db_session.commit()
async def _fake_semantics(**kwargs):
_ = kwargs
return {
"followup_mode": "meta",
"meta_followup_kind": "reply_explanation",
"anchor_strategy": "implicit_recent",
"candidate_index": 1,
"confidence": 0.93,
}
monkeypatch.setattr(
service,
"_resolve_followup_anchor_semantics",
_fake_semantics,
raising=False,
)
state = await service._ensure_session_state(session_id=int(session.id))
resolution = await service._resolve_followup_anchor(
question="沿着前一条把细节铺开",
session_id=int(session.id),
context_session_id=int(session.id),
session_state=state,
conversation_scope_key="feishu:chat-1:root-1",
)
assert resolution.anchor_mode == "implicit_recent_reply"
assert resolution.is_meta_followup is True
assert resolution.meta_followup_kind == "reply_explanation"
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "帮我解释下系统架构"
@pytest.mark.asyncio
async def test_ambiguous_recent_reply_returns_numbered_candidates(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="新会话",
external_conversation_key="feishu:chat-1:root-1",
started_at=now,
last_message_at=now,
message_count=2,
)
db_session.add(session)
await db_session.flush()
db_session.add_all(
[
QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=10,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
created_at=now - timedelta(minutes=1),
),
QARecord(
tenant_id=1,
question="系统有什么模型?",
answer="当前有 low/high 两层模型路由。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=12,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=2,
created_at=now,
),
]
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
resolution = await service._resolve_followup_anchor(
question="那你刚才那条是哪个模型回复的?",
session_id=int(session.id),
context_session_id=int(session.id),
session_state=state,
)
assert resolution.anchor_mode == "ambiguous_candidates"
assert resolution.anchor_reply_id is None
assert len(resolution.candidate_replies) == 2
assert resolution.candidate_replies[0]["index"] == 1
assert resolution.candidate_replies[1]["index"] == 2
@pytest.mark.asyncio
async def test_pending_clarification_selection_restores_original_question(db_session) -> None:
service = _Harness(db_session)
session = QASession(
tenant_id=1,
user_id=None,
title="新会话",
external_conversation_key="feishu:chat-1:root-1",
started_at=now_cn_naive(),
last_message_at=now_cn_naive(),
message_count=1,
)
db_session.add(session)
await db_session.flush()
state = await service._ensure_session_state(session_id=int(session.id))
state.pending_clarification = {
"kind": "followup_anchor_selection",
"original_question": "我问你这个是哪个模型执行回复的?",
"candidates": [
{"index": 1, "qa_id": 101, "label": "系统架构那条"},
{"index": 2, "qa_id": 102, "label": "系统模型那条"},
],
}
await db_session.commit()
resolved = service._consume_pending_clarification_selection(
question="第二条",
session_state=state,
)
assert resolved is not None
assert resolved["selected_qa_id"] == 102
assert resolved["effective_question"] == "我问你这个是哪个模型执行回复的?"
@pytest.mark.asyncio
async def test_no_anchor_clarification_prefers_reply_or_object_id_guidance(db_session) -> None:
service = _Harness(db_session)
payload = service._build_followup_clarification_payload(
question="沿着刚才那个继续说",
anchor_resolution=FollowupAnchorResolution(clarification_code="no_anchor_found"),
session_state=None,
)
assert payload is not None
assert "引用那条消息" in payload["answer"]
assert "对象 ID" in payload["answer"]
@pytest.mark.asyncio
async def test_explicit_reply_to_message_id_resolves_anchor_without_session_history(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
db_session.add(
IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om_user_message_1",
status="done",
payload={"message_text": "帮我解释下系统的架构"},
)
)
db_session.add(
FeishuReplyOutbox(
tenant_id=1,
trace_id="trace-1",
source_message_id="om_user_message_1",
sent_message_id="om_bot_reply_1",
chat_id="oc-chat-1",
message_text='<at user_id="ou-1">林宇航</at> PMFlow 系统采用前后端分离架构。',
status="sent",
created_at=now - timedelta(minutes=1),
sent_at=now - timedelta(minutes=1),
)
)
await db_session.commit()
resolution = await service._resolve_followup_anchor(
question="这个是哪个模型去回复我问题的?",
session_id=None,
context_session_id=None,
session_state=None,
thread_context={"reply_to_message_id": "om_bot_reply_1"},
)
assert resolution.anchor_mode == "explicit"
assert resolution.is_meta_followup is True
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "帮我解释下系统的架构"
assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"]
@pytest.mark.asyncio
async def test_explicit_reply_anchor_rewrite_takes_priority_over_session_state_subject(db_session) -> None:
service = _Harness(db_session)
session_state = QASessionState(
tenant_id=1,
session_id=999,
last_subject="_sync_pmflow_rag_async->backend/app/api/sstc.py",
last_entity_ids=[],
last_intent="question",
ambiguity="clear",
rewrite_history=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
rewritten, rewrite_meta = service._rewrite_question_with_state(
question="B",
conversation=[],
session_state=session_state,
anchor_resolution=FollowupAnchorResolution(
followup_mode="referential",
anchor_mode="explicit",
anchor_kind="feishu_reply_outbox",
anchor_reply={
"qa_id": 321,
"question": "你提到的是给运营公司打造「主播推流」的新需求",
"label": "主播推流新需求",
"anchor_reply_preview": "这是个典型的宽泛 IT 项目概念,我先给你拆成 A/B/C 三类。",
"source_message_id": "om_user_message_1",
},
),
)
assert rewritten == "围绕上一轮对话“主播推流新需求”继续回答:B"
assert rewrite_meta["mode"] == "followup_anchor_recent_reply"
assert rewrite_meta["qa_id"] == 321
@pytest.mark.asyncio
async def test_explicit_reply_falls_back_to_current_ingest_closure_turns_when_outbox_mapping_missing(db_session) -> None:
service = _Harness(db_session)
db_session.add(
IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om_current_message_1",
status="done",
payload={
"message_text": "这个是哪个模型去回复我问题的?",
"closure_turns": [
{"role": "user", "text": "帮我解释下系统的架构"},
{"role": "assistant", "text": "PMFlow 系统采用前后端分离架构。"},
{"role": "user", "text": "这个是哪个模型去回复我问题的?"},
],
},
)
)
await db_session.commit()
resolution = await service._resolve_followup_anchor(
question="这个是哪个模型去回复我问题的?",
session_id=None,
context_session_id=None,
session_state=None,
source_message_id="om_current_message_1",
thread_context={"reply_to_message_id": "om_unmapped_reply"},
)
assert resolution.anchor_mode == "explicit"
assert resolution.is_meta_followup is True
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "帮我解释下系统的架构"
assert "前后端分离架构" in resolution.anchor_reply["anchor_reply_preview"]
@pytest.mark.asyncio
async def test_short_reply_to_explanation_is_treated_as_meta_followup(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
db_session.add(
IngestEvent(
tenant_id=1,
channel="feishu_message",
event_key="om_user_message_explain_1",
status="done",
payload={"message_text": "你刚才说 adaptive_rag_answer_normal 调用失败。"},
)
)
db_session.add(
FeishuReplyOutbox(
tenant_id=1,
trace_id="trace-explain-1",
source_message_id="om_user_message_explain_1",
sent_message_id="om_bot_reply_explain_1",
chat_id="oc-chat-1",
message_text="系统处理失败:adaptive_rag_answer_normal LLM failed after retries: openai_chat_completion returned empty content after retries。"
"本次未执行入库。",
status="sent",
created_at=now - timedelta(minutes=1),
sent_at=now - timedelta(minutes=1),
)
)
await db_session.commit()
resolution = await service._resolve_followup_anchor(
question="这是什么情况,详细说下",
session_id=None,
context_session_id=None,
session_state=None,
thread_context={"reply_to_message_id": "om_bot_reply_explain_1"},
)
assert resolution.anchor_mode == "explicit"
assert resolution.is_meta_followup is True
assert resolution.meta_followup_kind == "reply_error_reason"
assert resolution.anchor_reply is not None
assert "你刚才说 adaptive_rag_answer_normal 调用失败" in resolution.anchor_reply["question"]
@pytest.mark.asyncio
async def test_single_recent_reply_allows_short_explanation_followup_without_explicit_reply_to(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="错误追问",
external_conversation_key="feishu:chat-1:ou-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add(session)
await db_session.flush()
db_session.add(
QARecord(
tenant_id=1,
question="你刚才说 adaptive_rag_answer_normal 调用失败",
answer="系统处理失败:adaptive_rag_answer_normal LLM failed after retries: openai_chat_completion returned empty content after retries。本次未执行入库。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=1200,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
source_message_id="om_user_error_1",
created_at=now,
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
resolution = await service._resolve_followup_anchor(
question="这是什么情况,详细说下",
session_id=int(session.id),
context_session_id=int(session.id),
session_state=state,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
)
assert resolution.anchor_mode == "implicit_recent_reply"
assert resolution.is_meta_followup is True
assert resolution.meta_followup_kind == "reply_error_reason"
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "你刚才说 adaptive_rag_answer_normal 调用失败"
@pytest.mark.asyncio
async def test_recent_followup_candidates_can_cross_sessions_by_conversation_scope_key(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
previous_session = QASession(
tenant_id=1,
user_id=None,
title="旧会话",
external_conversation_key="feishu:msg-old-1",
started_at=now - timedelta(minutes=2),
last_message_at=now - timedelta(minutes=1),
message_count=1,
)
current_session = QASession(
tenant_id=1,
user_id=None,
title="新会话",
external_conversation_key="feishu:msg-new-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add_all([previous_session, current_session])
await db_session.flush()
db_session.add(
QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=10,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(previous_session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
source_message_id="om_user_1",
created_at=now - timedelta(minutes=1),
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(current_session.id))
resolution = await service._resolve_followup_anchor(
question="这个是哪个模型回复的?",
session_id=int(current_session.id),
context_session_id=int(current_session.id),
session_state=state,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
)
assert resolution.anchor_mode == "implicit_recent_reply"
assert resolution.anchor_reply_id is not None
assert resolution.anchor_kind == "meta_recent_reply"
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["question"] == "帮我解释下系统架构"
@pytest.mark.asyncio
async def test_standalone_project_lookup_is_not_misclassified_as_followup_anchor(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="旧问题",
external_conversation_key="feishu:chat-1:ou-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add(session)
await db_session.flush()
db_session.add(
QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=12,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
source_message_id="om_user_1",
created_at=now,
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
resolution = await service._resolve_followup_anchor(
question="一个 AI 客服工具,这个项目有吗",
session_id=int(session.id),
context_session_id=int(session.id),
session_state=state,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
)
assert resolution.anchor_mode == "no_anchor"
assert resolution.anchor_reply_id is None
assert resolution.candidate_replies == []
@pytest.mark.asyncio
async def test_meta_followup_model_identity_reads_routing_snapshot_and_hides_real_model(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="系统架构",
external_conversation_key="feishu:chat-1:ou-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add(session)
await db_session.flush()
qa = QARecord(
tenant_id=1,
question="帮我解释下系统架构",
answer="PMFlow 系统采用前后端分离架构。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=12,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
source_message_id="om_user_1",
routing_snapshot={
"planned_tier": "low",
"executed_tier": "high",
"route_source": "default",
"route_rule_name": "engine_high_reasoning_glm5",
"routing_decision": "low_to_high_escalated",
"provider": "openai",
"model": "GLM-5",
},
)
db_session.add(qa)
await db_session.commit()
payload = await service._build_meta_followup_payload(
question="这个是哪个模型回复的?",
anchor_resolution=FollowupAnchorResolution(
followup_mode="meta",
anchor_mode="implicit_recent_reply",
anchor_reply_id=int(qa.id),
anchor_session_id=int(session.id),
anchor_kind="meta_recent_reply",
is_meta_followup=True,
meta_followup_kind="reply_model_identity",
anchor_reply={
"qa_id": int(qa.id),
"question": "帮我解释下系统架构",
"anchor_reply_preview": "PMFlow 系统采用前后端分离架构。",
"source_message_id": "om_user_1",
},
),
conversation_scope_key="feishu:chat-1:ou-1",
)
assert payload is not None
assert payload["meta_followup_kind"] == "reply_model_identity"
assert payload["reason_code"] == "meta_followup_answered"
assert "planned_tier=low" in payload["answer"]
assert "executed_tier=high" in payload["answer"]
assert "engine_high_reasoning_glm5" in payload["answer"]
assert "glm-5" not in payload["answer"].lower()
@pytest.mark.asyncio
async def test_meta_followup_error_reason_humanizes_llm_empty_content_failure(db_session) -> None:
service = _Harness(db_session)
now = now_cn_naive()
session = QASession(
tenant_id=1,
user_id=None,
title="错误追问",
external_conversation_key="feishu:chat-1:ou-1",
started_at=now,
last_message_at=now,
message_count=1,
)
db_session.add(session)
await db_session.flush()
qa = QARecord(
tenant_id=1,
question="这是什么情况,详细说下",
answer="系统处理失败:adaptive_rag_answer_normal LLM failed after retries: openai_chat_completion returned empty content after retries。本次未执行入库。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=1200,
asked_by=None,
asked_via="feishu",
project_id=None,
session_id=int(session.id),
turn_index=1,
conversation_scope_key="feishu:chat-1:ou-1",
source_chat_id="chat-1",
source_sender_open_id="ou-1",
source_message_id="om_user_error_1",
)
db_session.add(qa)
await db_session.commit()
payload = await service._build_meta_followup_payload(
question="这是什么情况,详细说下",
anchor_resolution=FollowupAnchorResolution(
anchor_mode="implicit_recent_reply",
anchor_reply_id=int(qa.id),
anchor_session_id=int(session.id),
anchor_kind="meta_recent_reply",
is_meta_followup=True,
meta_followup_kind="reply_error_reason",
anchor_reply={
"qa_id": int(qa.id),
"question": "你刚才说 adaptive_rag_answer_normal 调用失败",
"anchor_reply_preview": qa.answer,
"source_message_id": "om_user_error_1",
},
),
conversation_scope_key="feishu:chat-1:ou-1",
)
assert payload is not None
assert payload["meta_followup_kind"] == "reply_error_reason"
assert payload["reason_code"] == "meta_followup_answered"
assert "模型在生成答案时连续返回空正文" in payload["answer"]
assert "所以系统没有拿到可发出的有效回复" in payload["answer"]