342 lines
13 KiB
Python
342 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from sqlalchemy import JSON
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
|
||
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
|
||
|
||
from app.models.base import Base
|
||
from app.models.mounted_ai_runtime import (
|
||
MountedConversationRun,
|
||
MountedDraftWorkspace,
|
||
MountedTopicWorkspace,
|
||
)
|
||
from app.services.ai_pmo.semantic_context.assembler import SemanticContextAssembler
|
||
|
||
|
||
def _use_sqlite_json_types() -> None:
|
||
for table in (
|
||
MountedTopicWorkspace.__table__,
|
||
MountedDraftWorkspace.__table__,
|
||
MountedConversationRun.__table__,
|
||
):
|
||
for column in table.columns:
|
||
if column.name in {
|
||
"facts_json",
|
||
"goals_json",
|
||
"constraints_json",
|
||
"options_json",
|
||
"open_questions",
|
||
"pending_actions",
|
||
"bound_entities",
|
||
"metadata_json",
|
||
"requirement_card",
|
||
"draft_payload",
|
||
"required_confirmations",
|
||
"context_layers_hit",
|
||
"tool_calls",
|
||
"execution_outcome",
|
||
}:
|
||
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=[
|
||
MountedTopicWorkspace.__table__,
|
||
MountedDraftWorkspace.__table__,
|
||
MountedConversationRun.__table__,
|
||
],
|
||
)
|
||
)
|
||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||
async with maker() as session:
|
||
yield session
|
||
await engine.dispose()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_context_assembler_builds_packet_from_runtime_state(db_session) -> None:
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=7,
|
||
topic_key="feishu:chat_semantic:root:om_root_semantic",
|
||
source_system="feishu",
|
||
channel_ref="chat_semantic",
|
||
conversation_scope_key="feishu:chat_semantic:ou_semantic",
|
||
thread_key="thread_semantic",
|
||
root_message_id="om_root_semantic",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
facts_json={"title": "OpenCow 风格语义上下文迁移"},
|
||
goals_json=["统一语义入口"],
|
||
constraints_json={"must_not": ["db.write"]},
|
||
open_questions=["当前是不是新需求 topic"],
|
||
pending_actions=["continue_requirement_plan"],
|
||
readiness_score=0.4,
|
||
metadata_json={"last_message_preview": "这次先把语义上下文统一掉"},
|
||
)
|
||
db_session.add(topic)
|
||
await db_session.flush()
|
||
|
||
draft = MountedDraftWorkspace(
|
||
tenant_id=7,
|
||
topic_workspace_id=int(topic.id),
|
||
state="collecting",
|
||
draft_version=2,
|
||
requirement_card={"goal": "统一语义入口", "scope": "先做 shadow mode"},
|
||
draft_payload={"draft_text": "当前先保留 mounted runtime 作为底座"},
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
db_session.add(draft)
|
||
db_session.add(
|
||
MountedConversationRun(
|
||
tenant_id=7,
|
||
trace_id="mounted:feishu:prev",
|
||
topic_workspace_id=int(topic.id),
|
||
source_system="feishu",
|
||
source_message_id="om_prev",
|
||
mode_before="plan",
|
||
mode_after="plan",
|
||
active_agent="PlanCoach",
|
||
status="completed",
|
||
trigger_reason="shadow",
|
||
context_layers_hit={"topic": True},
|
||
execution_outcome={
|
||
"source_message_preview": "先别执行,先整理需求上下文",
|
||
"semantic_decision": {"mode_decision": "stay_plan"},
|
||
},
|
||
final_reply="我先把上下文和草稿整理出来。",
|
||
)
|
||
)
|
||
await db_session.commit()
|
||
|
||
packet = await SemanticContextAssembler(db_session).assemble(
|
||
tenant_id=7,
|
||
operator_user_id=99,
|
||
topic_workspace_id=int(topic.id),
|
||
latest_user_message="这轮继续按 OpenCow 风格整理语义上下文。",
|
||
)
|
||
|
||
assert packet.packet_version == "v1"
|
||
assert packet.topic.topic_key == "feishu:chat_semantic:root:om_root_semantic"
|
||
assert packet.topic.plan_status == "collecting"
|
||
assert packet.draft is not None
|
||
assert packet.draft.draft_version == 2
|
||
assert packet.mode_card.workflow_mode == "PLAN"
|
||
assert "draft.patch" in packet.mode_card.allowed_actions
|
||
assert "project.create" in packet.mode_card.forbidden_actions
|
||
assert packet.thread.recent_turns[0].role == "user"
|
||
assert packet.thread.recent_turns[-1].role == "assistant"
|
||
assert packet.thread.open_questions == ["当前是不是新需求 topic"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_context_assembler_prefers_accepted_semantic_requirement_reply_over_stale_final_reply(db_session) -> None:
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=7,
|
||
topic_key="feishu:chat_semantic:root:om_root_semantic_prefer",
|
||
source_system="feishu",
|
||
channel_ref="chat_semantic_prefer",
|
||
conversation_scope_key="feishu:chat_semantic_prefer:ou_semantic",
|
||
root_message_id="om_root_semantic_prefer",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True, "last_message_preview": "确认A,继续往下收。"},
|
||
)
|
||
db_session.add(topic)
|
||
await db_session.flush()
|
||
db_session.add(
|
||
MountedConversationRun(
|
||
tenant_id=7,
|
||
trace_id="mounted:feishu:semantic-prefer",
|
||
topic_workspace_id=int(topic.id),
|
||
source_system="feishu",
|
||
source_message_id="om_semantic_prefer_prev",
|
||
mode_before="plan",
|
||
mode_after="plan",
|
||
active_agent="PlanCoach",
|
||
status="completed",
|
||
trigger_reason="shadow",
|
||
context_layers_hit={"topic": True},
|
||
execution_outcome={
|
||
"source_message_preview": "做一个门店库存异常日报助手,先不创建项目,继续收第一版。",
|
||
"rag_result": {
|
||
"query_type": "plan",
|
||
"sources": [
|
||
{
|
||
"source": "system",
|
||
"metadata": {"reason_code": "requirement_planning_direct"},
|
||
}
|
||
],
|
||
},
|
||
"semantic_decision": {
|
||
"assistant_reply": "已确认选项 A(单一层级区域经理),继续收第二轮关键信息。",
|
||
},
|
||
"semantic_gate_result": {"accepted": True},
|
||
},
|
||
final_reply="收到,确认A方向,继续收敛项目性质。",
|
||
)
|
||
)
|
||
await db_session.commit()
|
||
|
||
packet = await SemanticContextAssembler(db_session).assemble(
|
||
tenant_id=7,
|
||
operator_user_id=99,
|
||
topic_workspace_id=int(topic.id),
|
||
latest_user_message="确认A,继续往下收。",
|
||
)
|
||
|
||
assistant_turns = [turn.text for turn in packet.thread.recent_turns if turn.role == "assistant"]
|
||
assert assistant_turns[-1] == "已确认选项 A(单一层级区域经理),继续收第二轮关键信息。"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_context_assembler_prefers_draft_title_for_thread_summary_when_topic_facts_are_minimal(db_session) -> None:
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=7,
|
||
topic_key="feishu:chat_semantic:root:om_root_semantic_draft_summary",
|
||
source_system="feishu",
|
||
channel_ref="chat_semantic_draft_summary",
|
||
conversation_scope_key="feishu:chat_semantic_draft_summary:ou_semantic",
|
||
root_message_id="om_root_semantic_draft_summary",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
facts_json={"plan_profile": "it_project"},
|
||
metadata_json={},
|
||
)
|
||
db_session.add(topic)
|
||
await db_session.flush()
|
||
db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=7,
|
||
topic_workspace_id=int(topic.id),
|
||
state="collecting",
|
||
draft_version=1,
|
||
requirement_card={"title": "门店库存异常晨报助手", "goal": "先收第一版需求"},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
)
|
||
await db_session.commit()
|
||
|
||
packet = await SemanticContextAssembler(db_session).assemble(
|
||
tenant_id=7,
|
||
operator_user_id=99,
|
||
topic_workspace_id=int(topic.id),
|
||
latest_user_message="继续往下收。",
|
||
)
|
||
|
||
assert packet.thread.thread_summary == "门店库存异常晨报助手"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_context_assembler_adds_draft_followup_ingress_actions(db_session) -> None:
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=7,
|
||
topic_key="feishu:chat_semantic:root:om_root_followup",
|
||
source_system="feishu",
|
||
channel_ref="chat_semantic",
|
||
conversation_scope_key="feishu:chat_semantic:ou_semantic",
|
||
thread_key="thread_semantic",
|
||
root_message_id="om_root_followup",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="awaiting_execute_confirm",
|
||
facts_json={"title": "换季穿搭推荐"},
|
||
goals_json=["确认草稿"],
|
||
constraints_json={},
|
||
open_questions=[],
|
||
pending_actions=[],
|
||
readiness_score=0.8,
|
||
metadata_json={"last_message_preview": "请确认当前草稿"},
|
||
)
|
||
db_session.add(topic)
|
||
await db_session.flush()
|
||
|
||
packet = await SemanticContextAssembler(db_session).assemble(
|
||
tenant_id=7,
|
||
operator_user_id=99,
|
||
topic_workspace_id=int(topic.id),
|
||
latest_user_message="是这种的可以很长,但是聊天内容发的那种就不行这么长",
|
||
ingress_context={
|
||
"candidate_kind": "draft_followup",
|
||
"source_system": "feishu",
|
||
"reply_anchor_present": True,
|
||
"allowed_followup_actions": ["confirm", "revise", "regenerate", "cancel", "answer_only"],
|
||
"thread_context": {"reply_to_message_id": "om_reply_1"},
|
||
},
|
||
)
|
||
|
||
assert packet.ingress is not None
|
||
assert packet.ingress.candidate_kind == "draft_followup"
|
||
assert packet.ingress.reply_anchor_present is True
|
||
assert "draft.followup.answer_only" in packet.mode_card.allowed_actions
|
||
assert "draft.followup.regenerate" in packet.mode_card.allowed_actions
|
||
assert "draft.followup.revise" in packet.mode_card.allowed_actions
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_semantic_context_assembler_adds_active_context_followup_actions(db_session) -> None:
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=7,
|
||
topic_key="feishu:chat_semantic:root:om_root_active_followup",
|
||
source_system="feishu",
|
||
channel_ref="chat_semantic",
|
||
conversation_scope_key="feishu:chat_semantic:ou_semantic",
|
||
thread_key="thread_semantic",
|
||
root_message_id="om_root_active_followup",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
facts_json={"title": "AI 直播复盘工具"},
|
||
goals_json=["继续收敛信号源模块"],
|
||
constraints_json={},
|
||
open_questions=["信号源这块第一版怎么收"],
|
||
pending_actions=[],
|
||
readiness_score=0.5,
|
||
metadata_json={"last_message_preview": "先把这版需求收清楚"},
|
||
)
|
||
db_session.add(topic)
|
||
await db_session.flush()
|
||
|
||
packet = await SemanticContextAssembler(db_session).assemble(
|
||
tenant_id=7,
|
||
operator_user_id=99,
|
||
topic_workspace_id=int(topic.id),
|
||
latest_user_message="信号源呢?",
|
||
ingress_context={
|
||
"candidate_kind": "active_context_followup",
|
||
"source_system": "feishu",
|
||
"reply_anchor_present": False,
|
||
"active_context_kind": "requirement_plan",
|
||
"allowed_context_scopes": [
|
||
"current_requirement",
|
||
"current_draft",
|
||
"independent_workflow",
|
||
"clarify",
|
||
],
|
||
"thread_context": {},
|
||
},
|
||
)
|
||
|
||
assert packet.ingress is not None
|
||
assert packet.ingress.candidate_kind == "active_context_followup"
|
||
assert packet.ingress.active_context_kind == "requirement_plan"
|
||
assert "context.followup.current_requirement" in packet.mode_card.allowed_actions
|
||
assert "context.followup.current_draft" in packet.mode_card.allowed_actions
|
||
assert "context.followup.independent_workflow" in packet.mode_card.allowed_actions
|
||
assert "context.followup.clarify" in packet.mode_card.allowed_actions
|