PMFlow/backend/tests/test_semantic_context_assem...

241 lines
8.9 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_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