PMFlow/backend/tests/test_semantic_context_assem...

342 lines
13 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
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