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