from types import SimpleNamespace import pytest from app.api import rag_ask_contract from app.api.space_api import _attach_space_operating_conversation from app.schemas.rag import RAGAskRequest from app.services.customer_answer_presentation_service import CustomerAnswerPresentationService from app.services.space_runtime_context_builder import SpaceRuntimeContextBuilder @pytest.mark.asyncio async def test_space_runtime_context_builder_merges_backend_projects_into_surface_context(monkeypatch) -> None: builder = SpaceRuntimeContextBuilder(object(), tenant_id=1) project = SimpleNamespace( id=141, title="童析-学龄前儿童行为特质AI评估助手", name="童析", description="儿童行为特质评估", status="active", updated_at=None, ) async def load_projects(*, current_user, limit): return [project] async def load_task_counts(*, project_ids): return {141: {"total": 3, "completed": 1, "in_progress": 1, "pending": 1, "blocked": 0}} async def load_saved_ideas(*, user_id, limit): return [ { "id": "idea_1", "object_ref": "saved_idea:idea_1", "title": "AI 教练想法", "summary": "先暂存,等待整理", "status": "saved", "readiness_score": 0.4, "href": "/space", } ] monkeypatch.setattr(builder, "_load_visible_projects", load_projects) monkeypatch.setattr(builder, "_load_task_counts", load_task_counts) monkeypatch.setattr(builder, "_load_saved_ideas", load_saved_ideas) runtime_context = await builder.build( current_user=SimpleNamespace(id=7, tenant_id=1, role="MEMBER"), surface_context={"workbench": {"counts": {"pendingConfirmations": 2}}}, ) merged = builder.merge_surface_context(surface_context={}, runtime_context=runtime_context) assert runtime_context["source"] == "backend_authoritative" assert runtime_context["space_operating_packet"]["packet_version"] == "space_operating_packet.v1" assert runtime_context["space_operating_packet"]["visible_projects"][0]["object_ref"] == "project:141" assert runtime_context["space_operating_packet"]["attention_queue"][0]["href"] == "/pmflow-os?projectId=141" assert runtime_context["space_operating_packet"]["attention_queue"][0]["object_anchor"] == { "object_ref": "project:141", "object_type": "project", "object_id": "141", "surface": "command_center", "project_id": "141", "project_title": "童析-学龄前儿童行为特质AI评估助手", "href": "/pmflow-os?projectId=141", "parent_object_ref": None, } forbidden_actions = set(runtime_context["space_operating_packet"]["forbidden_actions"]) assert { "project_create_without_confirmation", "project_memory_write", "changeset_commit", "execution_handoff", "external_command_execution", "publish_or_deploy", "provider_model_prompt_diagnosis", }.issubset(forbidden_actions) assert runtime_context["workbench"]["counts"]["projects"] == 1 assert runtime_context["workbench"]["counts"]["savedIdeas"] == 1 assert runtime_context["workbench"]["attention_queue"][0]["kind"] == "next_action" assert merged["workbench"]["recent_projects"][0]["object_ref"] == "project:141" assert merged["workbench"]["recent_projects"][0]["next_action"] == "继续推进待处理任务" assert merged["workbench"]["top_next_actions"][0]["href"] == "/pmflow-os?projectId=141" assert merged["workbench"]["top_next_actions"][0]["object_ref"] == "project:141" assert merged["workbench"]["proactive_suggestions"][0]["requires_user_confirm"] is True assert merged["workbench"]["proactive_suggestions"][0]["why_now"] assert merged["workbench"]["proactive_suggestions"][0]["required_confirmation"] == "human_confirm_on_target_surface" assert merged["workbench"]["proactive_suggestions"][0]["audit_event"] == "space_ai.proactive_suggestion.created" assert merged["space_operating_packet"]["automation_policy"]["mode"] == "suggest_only" assert "external_command_execution" in merged["space_operating_packet"]["forbidden_actions"] assert merged["space_runtime_context"]["source_mix"]["projects"] == "backend" assert merged["space_operating_packet"]["packet_version"] == "space_operating_packet.v1" def test_enriched_runtime_context_feeds_surface_direct_project_answer() -> None: builder = SpaceRuntimeContextBuilder(object(), tenant_id=1) runtime_context = { "packet_version": "space_runtime_context.v1", "visibility": "customer", "source": "backend_authoritative", "workbench": { "counts": {"projects": 1, "savedIdeas": 0, "nextActions": 1}, "recent_projects": [ { "id": 141, "project_id": 141, "object_ref": "project:141", "title": "童析-学龄前儿童行为特质AI评估助手", "status": "active", "stage": "execution", "next_action": "检查进行中任务进展", "href": "/pmflow-os?projectId=141", } ], "top_next_actions": [ { "title": "检查进行中任务进展", "project": "童析-学龄前儿童行为特质AI评估助手", "priority": "normal", "href": "/execution-bridge?projectId=141&taskId=task-1&evidenceId=progress-1", "object_ref": "task:task-1", "object_anchor": { "object_ref": "task:task-1", "object_type": "task", "object_id": "task-1", "surface": "execution_bridge", "project_id": "141", "project_title": "童析-学龄前儿童行为特质AI评估助手", "href": "/execution-bridge?projectId=141&taskId=task-1&evidenceId=progress-1", "parent_object_ref": "project:141", }, } ], }, } request = RAGAskRequest( question="现在我有哪些项目", surface_context=builder.merge_surface_context(surface_context={}, runtime_context=runtime_context), ) payload = rag_ask_contract.build_surface_direct_answer( request=request, current_user=SimpleNamespace(id=7, tenant_id=1), presentation_service=CustomerAnswerPresentationService(object(), tenant_id=1), pmflow_ai_route_plan={ "source_strategy": "internal_only", "surface_direct_eligible": True, "allowed_executors": ["surface_direct"], "internal_plan": {"objects": ["projects"], "expected_answer_shape": "list"}, }, ) assert payload is not None assert "童析-学龄前儿童行为特质AI评估助手" in payload["answer"] assert payload["answer_canvas"]["object_refs"][0]["object_ref"] == "project:141" assert payload["answer_canvas"]["next_actions"][0]["href"] == "/execution-bridge?projectId=141&taskId=task-1&evidenceId=progress-1" assert payload["answer_canvas"]["next_actions"][0]["object_ref"] == "task:task-1" assert payload["answer_canvas"]["next_actions"][0]["object_anchor"]["parent_object_ref"] == "project:141" def test_surface_direct_refuses_project_mvp_gap_contract() -> None: request = RAGAskRequest( question="这个项目的MVP还差多少做完", surface_context={ "workbench": { "recent_projects": [ { "id": 141, "object_ref": "project:141", "title": "童析-学龄前儿童行为特质AI评估助手", "status": "active", "next_action": "继续推进待处理任务", } ] } }, ) payload = rag_ask_contract.build_surface_direct_answer( request=request, current_user=SimpleNamespace(id=7, tenant_id=1), presentation_service=CustomerAnswerPresentationService(object(), tenant_id=1), pmflow_ai_route_plan={ "source_strategy": "internal_only", "answer_contract": "project_mvp_gap_assessment", "analysis_task": "gap_assessment", "business_frame": "mvp_delivery", "surface_direct_eligible": True, "surface_direct_allowed": False, "allowed_executors": ["surface_direct", "internal_query"], }, ) assert payload is None def test_space_operating_conversation_contract_adds_mode_and_blocks() -> None: request = RAGAskRequest( question="我今天应该先处理什么?", surface_context={ "surface": "personal_space", "workbench": { "counts": {"nextActions": 1, "pendingConfirmations": 1, "aiProgress": 0, "savedIdeas": 1}, }, "space_operating_packet": { "attention_queue": [ { "kind": "pending_confirmation", "object_ref": "change_item:21", "title": "高风险需求待确认", "reason": "范围会影响执行计划", "urgency": "high", "next_action": "进入待确认处理", "href": "/change-review?projectId=141&itemId=21", "object_anchor": { "object_ref": "change_item:21", "object_type": "change_item", "object_id": "21", "surface": "change_review", "project_id": "141", "project_title": "童析", "href": "/change-review?projectId=141&itemId=21", "parent_object_ref": "project:141", }, } ], "pending_confirmations": [ {"object_ref": "change_item:21", "title": "高风险需求待确认", "href": "/change-review?projectId=141&itemId=21"} ], "saved_ideas": [{"object_ref": "saved_idea:idea-1", "title": "AI 教练", "readiness_score": 0.7}], }, }, ) payload = _attach_space_operating_conversation({"answer": "今天先处理高风险需求。", "answer_canvas": {}}, request=request) assert payload["pmflow_ai_mode"] == "space_operating_partner" assert payload["conversation_frame"]["frame_type"] == "agent_run" assert payload["answer_canvas"]["conversation_mode"] == "space_operating_partner" assert "conversation_blocks" not in payload["answer_canvas"]