PMFlow/backend/tests/test_adaptive_rag_external_...

1418 lines
50 KiB
Python
Raw Permalink 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
from types import SimpleNamespace
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",
"active_workspace",
"context_capsules",
"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,
}
async def _resolve_pending_clarification_selection_semantics(
self,
*,
question: str,
payload: dict[str, Any],
) -> dict[str, Any]:
text = str(question or "").strip()
normalized = text.replace(" ", "")
kind = str(payload.get("kind") or "").strip().lower()
if kind == "query_route_selection":
selected_key = ""
if "外部" in normalized or normalized.upper() == "A":
selected_key = "A"
elif "内部" in normalized or normalized.upper() == "B":
selected_key = "B"
return {
"selection_status": "resolved" if selected_key else "unresolved",
"selected_key": selected_key,
"selected_index": 0,
"confidence": 0.9 if selected_key else 0.0,
"reason": "test_double",
}
selected_index = 0
if any(token in normalized for token in ("第二", "第2", "2")):
selected_index = 2
elif any(token in normalized for token in ("第一", "第1", "1")):
selected_index = 1
return {
"selection_status": "resolved" if selected_index else "unresolved",
"selected_key": "",
"selected_index": selected_index,
"confidence": 0.9 if selected_index else 0.0,
"reason": "test_double",
}
@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 = await 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_selected_followup_clarification_anchor_binds_exact_qa_record(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,
selected_anchor_reply_id=2,
)
assert resolution.anchor_mode == "clarification_selection"
assert resolution.anchor_reply_id == 2
assert resolution.anchor_reply is not None
assert resolution.anchor_reply["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_project_followup_state_prefers_answer_project_name_over_raw_question(db_session) -> None:
service = _Harness(db_session)
state = SimpleNamespace(
last_subject=None,
last_entity_ids=[],
last_intent="question",
ambiguity="clear",
rewrite_history=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
await service._update_session_state_after_turn(
session_state=state,
question="帮我看下采购经理那个需求是否存在系统?",
effective_question="帮我看下采购经理那个需求是否存在系统?",
answer=(
"有,命中1个项目:项目ID 123,项目名“采购经理每日实时库存上报服务”,"
"状态 active,负责人 系统管理员。"
),
query_type="existence",
ambiguity="clear",
rewrite_meta={"mode": "direct"},
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects", "matched_project_ids": [123]},
}
],
)
assert state.last_subject == "采购经理每日实时库存上报服务"
assert state.last_entity_ids == [123]
rewritten, rewrite_meta = service._rewrite_question_with_state(
question="这个项目怎么样?",
conversation=[],
session_state=state,
anchor_resolution=FollowupAnchorResolution(
followup_mode="referential",
anchor_mode="implicit_recent",
),
)
assert rewritten == "围绕“采购经理每日实时库存上报服务”回答:这个项目怎么样?"
assert rewrite_meta["mode"] == "state_subject"
@pytest.mark.asyncio
async def test_project_followup_state_extracts_unquoted_project_name_from_answer(db_session) -> None:
service = _Harness(db_session)
state = SimpleNamespace(
last_subject=None,
last_entity_ids=[],
last_intent="question",
ambiguity="clear",
rewrite_history=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
await service._update_session_state_after_turn(
session_state=state,
question="帮我看下采购经理那个需求是否存在系统?",
effective_question="帮我看下采购经理那个需求是否存在系统?",
answer=(
"有,命中 1 个项目:项目ID 123、项目名 采购经理每日实时库存上报服务、"
"状态 active、负责人 系统管理员。"
),
query_type="existence",
ambiguity="clear",
rewrite_meta={"mode": "direct"},
sources=[
{
"source": "db",
"label": "Project database",
"score": 0.99,
"metadata": {"table": "projects", "matched_project_ids": [123]},
}
],
)
assert state.last_subject == "采购经理每日实时库存上报服务"
@pytest.mark.asyncio
async def test_project_followup_state_prefers_customer_visible_project_focus_over_kg_entities(db_session) -> None:
service = _Harness(db_session)
state = SimpleNamespace(
last_subject=None,
last_entity_ids=[],
last_intent="question",
ambiguity="clear",
rewrite_history=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
await service._update_session_state_after_turn(
session_state=state,
question="现在有什么项目",
effective_question="现在有什么项目",
answer="你当前有 1 个项目:童析-学龄前儿童行为特质AI评估助手。",
query_type="project_inventory",
ambiguity="clear",
rewrite_meta={"mode": "direct"},
sources=[
{
"source": "graph",
"label": "Knowledge Graph",
"score": 0.8,
"metadata": {
"subject_entity_id": 14308,
"object_entity_id": 14311,
"subject_name": "个人空间",
"object_name": "/pmflow-os/projects/{project_id}/truth-state",
},
},
{
"source": "pmflow",
"label": "项目数据",
"score": 1.0,
"metadata": {
"object_ref": "project:141",
"project_id": 141,
"source_type": "customer_visible_object",
"subject_name": "童析-学龄前儿童行为特质AI评估助手",
"query_keyword": "童析-学龄前儿童行为特质AI评估助手",
},
},
],
)
assert state.last_subject == "童析-学龄前儿童行为特质AI评估助手"
assert state.last_entity_ids == [141]
@pytest.mark.asyncio
async def test_external_research_workspace_persists_candidate_set_and_survives_followup_rewrite(db_session) -> None:
service = _Harness(db_session)
state = SimpleNamespace(
last_subject=None,
last_entity_ids=[],
last_intent="question",
ambiguity="clear",
rewrite_history=[],
active_workspace=None,
context_capsules=[],
pending_clarification=None,
pending_clarification_expires_at=None,
)
await service._update_session_state_after_turn(
session_state=state,
question="这个项目的竞品有哪些",
effective_question="这个项目的竞品有哪些",
answer="我先给你一组候选竞品。",
query_type="compare",
ambiguity="clear",
rewrite_meta={"mode": "direct"},
sources=[
{
"source": "pmflow",
"label": "项目数据",
"score": 1.0,
"metadata": {
"object_ref": "project:141",
"project_id": 141,
"source_type": "customer_visible_object",
"subject_name": "童析-学龄前儿童行为特质AI评估助手",
"query_keyword": "童析-学龄前儿童行为特质AI评估助手",
},
}
],
route_decision={"effective_source_policy": "external"},
response_payload={
"research_workspace": {
"workspace_id": "ws_competitor_141",
"workspace_kind": "hybrid_research",
"topic": "竞品研究",
"primary_object_ref": "project:141",
"primary_object_id": 141,
"primary_object_title": "童析-学龄前儿童行为特质AI评估助手",
"candidate_set_binding": "cset_competitors_141",
"candidate_items": [
{"candidate_ref": "candidate:goally", "display_name": "Goally"},
{"candidate_ref": "candidate:abcmouse", "display_name": "ABCmouse"},
],
}
},
)
rewritten, meta = service._rewrite_question_with_state(
question="详细对比下",
conversation=[],
session_state=state,
anchor_resolution=SimpleNamespace(followup_mode="referential", anchor_mode="none"),
)
assert state.active_workspace["candidate_set_binding"] == "cset_competitors_141"
assert state.context_capsules[0]["workspace_id"] == "ws_competitor_141"
assert rewritten.startswith("围绕当前研究工作区")
assert meta["mode"] == "active_workspace"
@pytest.mark.asyncio
async def test_state_scope_from_qa_record_prefers_customer_focus_object(db_session) -> None:
service = _Harness(db_session)
qa = QARecord(
tenant_id=1,
question="现在有什么项目",
answer="你当前有 1 个项目:童析-学龄前儿童行为特质AI评估助手。",
sources=[],
intent="question",
domains_used=["pmflow"],
response_time_ms=100,
asked_by=1,
asked_via="pmflow",
session_id=1,
turn_index=1,
routing_snapshot={
"response_payload": {
"customer_interaction_context_packet": {
"current_focus_object": {
"object_ref": "project:141",
"object_id": 141,
"object_type": "project",
"title": "童析-学龄前儿童行为特质AI评估助手",
}
}
}
},
)
scope = service._state_scope_from_qa_record(qa)
assert scope["project_id"] == 141
assert scope["target_ref"] == "project:141"
assert scope["target_entity_type"] == "project"
@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_state_rewrite_governance_skips_rewrite_when_current_scope_switches_project(
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: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="测试产能警告这个项目,如果只考虑外部信号源,你会优先推荐哪 3 类?",
answer="先看测试执行结果、发布记录、工单反馈。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=12,
asked_by=None,
asked_via="feishu",
project_id=81,
session_id=int(session.id),
turn_index=1,
routing_snapshot={
"query_semantics": {
"query_profile": {"referenced_objects": ["project_object"]},
"target_resolution": {
"target_entity_type": "project",
"target_ref": "project:81",
"resolved_label": "测试产能警告",
},
}
},
created_at=now,
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
state.last_subject = "围绕“测试产能警告这个项目,如果只考虑外部信号源...”回答"
await db_session.flush()
async def _fake_query_semantics(**kwargs):
_ = kwargs
return {
"query_profile": {"referenced_objects": ["project_object"]},
"source_policy": "internal",
"effective_source_policy": "internal",
}
async def _fake_resolve_project_scope_for_question(**kwargs):
_ = kwargs
return (101, "AI客服", "AI客服")
monkeypatch.setattr(service, "_resolve_query_semantics", _fake_query_semantics, raising=False)
monkeypatch.setattr(
service,
"_resolve_project_scope_for_question",
_fake_resolve_project_scope_for_question,
raising=False,
)
rewrite_governance = await service._build_state_rewrite_governance(
question="AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?",
project_id=None,
session_id=int(session.id),
source_policy_hint=None,
query_semantics=None,
session_state=state,
anchor_resolution=FollowupAnchorResolution(followup_mode="referential"),
)
assert rewrite_governance["allow_state_rewrite"] is False
assert rewrite_governance["reason"] == "new_project_scope"
assert rewrite_governance["current_scope"]["project_id"] == 101
assert rewrite_governance["previous_scope"]["project_id"] == 81
rewritten, rewrite_meta = service._rewrite_question_with_state(
question="AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?",
conversation=[],
session_state=state,
anchor_resolution=FollowupAnchorResolution(followup_mode="referential"),
rewrite_governance=rewrite_governance,
)
assert rewritten == "AI客服这个项目里,用户洞察数据源现在主要是拿来解决什么问题?"
assert rewrite_meta["mode"] == "scope_reset"
assert rewrite_meta["reason"] == "new_project_scope"
@pytest.mark.asyncio
async def test_state_rewrite_governance_skips_rewrite_when_current_scope_switches_business_target(
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: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="当前还没定义明确接收人。",
sources=[],
intent="question",
domains_used=[],
response_time_ms=12,
asked_by=None,
asked_via="feishu",
project_id=81,
session_id=int(session.id),
turn_index=1,
routing_snapshot={
"query_semantics": {
"query_profile": {"referenced_objects": ["project_object", "signal_object"]},
"target_resolution": {
"target_entity_type": "signal",
"target_ref": "signal:alert_receiver",
"resolved_label": "告警接收人",
},
}
},
created_at=now,
)
)
await db_session.commit()
state = await service._ensure_session_state(session_id=int(session.id))
state.last_subject = "测试产能警告这个项目里,告警接收人现在是谁?"
await db_session.flush()
async def _fake_resolve_project_scope_for_question(**kwargs):
_ = kwargs
return (81, "测试产能警告", "测试产能警告")
monkeypatch.setattr(
service,
"_resolve_project_scope_for_question",
_fake_resolve_project_scope_for_question,
raising=False,
)
rewrite_governance = await service._build_state_rewrite_governance(
question="测试产能警告这个项目里,用户洞察数据源现在主要是拿来解决什么问题?",
project_id=None,
session_id=int(session.id),
source_policy_hint=None,
query_semantics={
"query_profile": {"referenced_objects": ["project_object", "signal_object"]},
"target_resolution": {
"target_entity_type": "signal",
"target_ref": "signal:user_insight_source",
"resolved_label": "用户洞察数据源",
},
},
session_state=state,
anchor_resolution=FollowupAnchorResolution(followup_mode="referential"),
)
assert rewrite_governance["allow_state_rewrite"] is False
assert rewrite_governance["reason"] == "new_business_scope"
assert rewrite_governance["current_scope"]["project_id"] == 81
assert rewrite_governance["current_scope"]["target_ref"] == "signal:user_insight_source"
assert rewrite_governance["previous_scope"]["target_ref"] == "signal:alert_receiver"
@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"]