PMFlow/backend/tests/test_pmflow_ai_project_exec...

479 lines
20 KiB
Python
Raw Permalink 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 pytest
from app.schemas.pmflow_ai_turn_runtime import (
PMFlowAIAnswerState,
PMFlowAIEvidenceFinding,
PMFlowAIGoalFrame,
PMFlowAIRuntimeAction,
PMFlowAITargetScope,
PMFlowAITurnActionState,
PMFlowAITurnEvidenceState,
PMFlowAITurnObjectRef,
PMFlowAITurnObjectState,
PMFlowAITurnState,
)
from app.schemas.rag import RAGAskRequest
from app.services.pmflow_ai_evidence_absorber import PMFlowAIEvidenceAbsorber
from app.services import pmflow_ai_evidence_tool_registry as tool_registry_module
from app.services.pmflow_ai_evidence_tool_registry import PMFlowAIEvidenceToolRegistry
from app.services.pmflow_ai_turn_runtime_service import (
PMFlowAITurnRuntimeService,
_apply_project_operating_answer_verification_to_answer,
_apply_project_operating_answer_verification_to_canvas,
_apply_project_operating_answer_verification_to_packet,
_project_operating_answer_verification_packet,
_render_project_operating_answer_from_turn_state,
)
class _FakeProjectionPayload:
def __init__(self, payload: dict):
self.payload = payload
def model_dump(self, *, mode: str = "json") -> dict:
_ = mode
return self.payload
class _FakeProjectionService:
def __init__(self, db, *, tenant_id: int):
_ = db, tenant_id
async def get_truth_state(self, *, project_id: int, current_user_id: int):
_ = current_user_id
return _FakeProjectionPayload(
{
"loopStage": "command_idle",
"project": {
"id": str(project_id),
"name": "童析",
"stage": "development",
"status": "active",
},
"projectMemory": {
"truthNodeFacts": [
{"summary": "MVP聚焦单次视频到单次报告闭环"},
],
},
"changeSets": [
{"id": "cs-1", "title": "补齐加密方案评审", "status": "pending_review"},
],
"changeItems": [
{"id": "ci-1", "title": "传输加密验收标准", "riskLevel": "high"},
],
"approvals": [
{"id": "approval-1", "state": "requested"},
],
"reviewerRouting": {
"summary": {
"totalPendingReview": 1,
"highRiskCount": 1,
"missingReviewerCount": 0,
},
"items": [
{
"reviewReason": "高风险儿童数据传输方案需要技术审查",
"suggestedReviewer": {
"displayName": "安全负责人",
"reason": "需要确认数据保护方案",
},
}
],
},
"feed": [
{
"title": "加密方案进入评审",
"summary": "等待测试证据和技术审查",
"kind": "review",
}
],
"risks": [
{"title": "儿童数据合规风险", "summary": "需要补齐加密和匿名化证据"},
],
}
)
async def get_execution_summary(self, *, project_id: int, current_user_id: int):
_ = project_id, current_user_id
return _FakeProjectionPayload(
{
"execution": {
"taskId": "194",
"status": "pr_open",
"prompt": "",
},
"acceptance": {
"status": "waiting_test_evidence",
"requiredAction": "run_required_tests",
"blockingReasons": ["缺少 test_passed 或 checks_passed 证据。"],
},
"releaseGate": {
"status": "needs_confirmation",
"title": "需要人工确认",
"summary": "已有执行回写,但缺少通过测试证据。",
"blockingReasons": ["缺少 PR 证据。"],
"nextActions": ["补充测试回写。"],
},
"qualityContract": {
"summary": {"releaseBlockingMissing": 4},
"blockingReasons": ["代码仓库联调与 PR 证据"],
},
"releaseSignals": {
"feedback": [{"title": "家长样本反馈"}],
"recommendations": [{"title": "优先补齐安全声明"}],
},
"recentProgressEvents": [
{"title": "PR 已打开", "status": "pr_open"},
{"title": "测试证据待回写", "status": "waiting"},
],
"recentDeliveries": [
{"title": "加密方案评审包", "status": "submitted"},
],
"deliveryArtifacts": {
"test_outputs": [
{"name": "pytest", "status": "passed"},
],
},
"artifactReview": {
"items": [
{"title": "PR 证据", "status": "needs_review"},
],
"blockingReasons": ["缺少截图证据"],
},
"activeDevTasks": [
{
"id": "194",
"title": "完成儿童数据匿名化与传输加密方案评审",
"status": "in_progress",
},
{
"id": "188",
"title": "设计视频上传-分析-报告三步信息架构与低保真原型",
"status": "todo",
},
],
}
)
def _project_execution_state() -> PMFlowAITurnState:
frame = PMFlowAIGoalFrame(
frame_id="project-execution",
operation_kind="inspect_object",
target_scope=PMFlowAITargetScope(scope_type="current_project", binding_strength="explicit"),
answer_shape="execution_guidance",
target_object_requirement="single",
)
return PMFlowAITurnState(
selected_frame=frame,
candidate_frames=[frame],
answer_state=PMFlowAIAnswerState(answer_shape="execution_guidance"),
object_state=PMFlowAITurnObjectState(
primary_object=PMFlowAITurnObjectRef(
object_type="project",
object_ref="project:141",
title="童析",
binding_strength="strong",
)
),
)
@pytest.mark.asyncio
async def test_packet_k_project_status_tool_preserves_execution_projection(monkeypatch) -> None:
monkeypatch.setattr(tool_registry_module, "PMFlowOSProjectionService", _FakeProjectionService)
state = _project_execution_state()
payload = await PMFlowAIEvidenceToolRegistry(object(), tenant_id=12).run(
action_type="inspect_project_status",
request=RAGAskRequest(
question="现在我的项目怎么推进好呢",
surface_context={
"space_runtime_context": {
"workbench": {
"projects": [
{"id": 141, "object_ref": "project:141", "title": "童析", "status": "active"},
]
}
}
},
),
current_user=object(),
turn_state=state,
)
result = payload["runtime_evidence_tool_result"]
facts = "\n".join(item["fact"] for item in result["findings"])
packet = result["structured_evidence"]["project_operating_evidence_packet"]
assert "项目概况:童析,阶段:development" in facts
assert "当前执行任务:完成儿童数据匿名化与传输加密方案评审" in facts
assert "发布/验收门禁:门禁状态:needs_confirmation" in facts
assert "阻塞项:缺少 test_passed 或 checks_passed 证据。" in facts
assert "候选任务:当前有 2 个执行/待执行任务" in facts
assert "项目状态 active" not in facts
assert packet["packet_version"] == "project_operating_evidence.v1"
assert packet["project_ref"] == "project:141"
assert packet["command_state"]["coverage_status"] == "covered"
assert packet["execution_state"]["coverage_status"] == "covered"
assert packet["quality_test_state"]["coverage_status"] == "covered"
assert packet["release_growth_state"]["coverage_status"] == "covered"
assert packet["team_state"]["coverage_status"] == "covered"
assert packet["recent_timeline_state"]["coverage_status"] == "covered"
assert packet["project_memory_state"]["coverage_status"] == "covered"
assert packet["risk_blocker_state"]["facts"][0] == "缺少 test_passed 或 checks_passed 证据。"
assert "team_state" not in packet["coverage"]["missing_facets"]
assert any("待审路由" in fact for fact in packet["team_state"]["facts"])
assert any("进展事件" in fact for fact in packet["execution_state"]["facts"])
assert any("反馈输入" in fact for fact in packet["release_growth_state"]["facts"])
assert packet["coverage"]["coverage_score"] > 0.5
assert payload["runtime_observation"]["items"][0]["business_values"]["coverage_score"] == packet["coverage"]["coverage_score"]
assert payload["runtime_observation"]["can_answer_operations"] == ["inspect_object"]
@pytest.mark.asyncio
async def test_packet_q_absorber_consumes_project_operating_evidence_packet(monkeypatch) -> None:
monkeypatch.setattr(tool_registry_module, "PMFlowOSProjectionService", _FakeProjectionService)
state = _project_execution_state()
payload = await PMFlowAIEvidenceToolRegistry(object(), tenant_id=12).run(
action_type="inspect_project_status",
request=RAGAskRequest(
question="现在我的项目怎么推进好呢",
surface_context={
"space_runtime_context": {
"workbench": {
"projects": [
{"id": 141, "object_ref": "project:141", "title": "童析", "status": "active"},
]
}
}
},
),
current_user=object(),
turn_state=state,
)
absorption = PMFlowAIEvidenceAbsorber().absorb(
turn_state=state,
action=PMFlowAIRuntimeAction(
action_id="inspect_project_status",
action_type="inspect_project_status",
tool_name="project_status_evidence_tool",
),
payload=payload,
)
absorbed_facts = "\n".join(item.fact for item in absorption.findings)
assert "童析 / 测试与质量" in absorbed_facts
assert "童析 / 发布与增长" in absorbed_facts
assert "童析 / 团队层" in absorbed_facts
assert "team_state_missing" not in absorption.remaining_gaps
assert absorption.structured_evidence["project_operating_evidence_packet"]["project_ref"] == "project:141"
def test_packet_l_coverage_gate_forces_project_execution_probe_before_answer() -> None:
state = _project_execution_state()
state.sufficiency_state.can_answer_now = True
state.action_state = PMFlowAITurnActionState(
candidate_actions=[
PMFlowAIRuntimeAction(
action_id="synthesize_answer",
action_type="synthesize_answer",
tool_name="turn_state_answer_synthesizer",
)
]
)
service = PMFlowAITurnRuntimeService(object(), tenant_id=12)
decision = service.assess_sufficiency(state)
action = service.select_next_minimal_action(state)
assert decision.needs_action is True
assert decision.next_action_type == "inspect_project_status"
assert action is not None
assert action.action_type == "inspect_project_status"
def test_packet_s_coverage_gate_ignores_legacy_text_findings_without_profile() -> None:
state = _project_execution_state()
state.sufficiency_state.can_answer_now = True
state.evidence_state = PMFlowAITurnEvidenceState(
known_facts=[
PMFlowAIEvidenceFinding(
fact="项目概况:童析,阶段:development,状态:active。",
source="PMFlow 项目执行态证据",
),
PMFlowAIEvidenceFinding(
fact="当前执行任务:完成儿童数据匿名化与传输加密方案评审,任务状态:in_progress,执行状态:pr_open。",
source="PMFlow 项目执行态证据",
),
PMFlowAIEvidenceFinding(
fact="发布/验收门禁:门禁状态:needs_confirmation,需要人工确认。",
source="PMFlow 项目执行态证据",
),
PMFlowAIEvidenceFinding(
fact="候选任务:当前有 2 个执行/待执行任务。",
source="PMFlow 项目执行态证据",
),
]
)
decision = PMFlowAITurnRuntimeService(object(), tenant_id=12).assess_sufficiency(state)
assert decision.needs_action is True
assert decision.next_action_type == "inspect_project_status"
def test_packet_s_coverage_gate_allows_answer_from_structured_profile() -> None:
state = _project_execution_state()
state.sufficiency_state.can_answer_now = True
state.evidence_state = PMFlowAITurnEvidenceState(
structured_evidence={
"project_operating_evidence_packets": [_covered_project_operating_packet()],
"project_operating_evidence_packet": _covered_project_operating_packet(),
}
)
decision = PMFlowAITurnRuntimeService(object(), tenant_id=12).assess_sufficiency(state)
assert decision.can_answer_now is True
assert decision.needs_action is False
def test_packet_t_project_operating_verification_bounds_final_answer_surface() -> None:
packet = _verification_project_operating_packet()
state = _project_execution_state()
state.answer_state = PMFlowAIAnswerState(answer_shape="execution_guidance")
state.object_state = PMFlowAITurnObjectState(
primary_object=PMFlowAITurnObjectRef(
object_type="project",
object_ref="project:141",
title="童析",
binding_strength="explicit",
)
)
state.evidence_state = PMFlowAITurnEvidenceState(
structured_evidence={
"project_operating_evidence_packets": [packet],
"project_operating_evidence_packet": packet,
}
)
verification = _project_operating_answer_verification_packet(state)
assert verification["packet_version"] == "project_operating_answer_verification.v1"
assert verification["can_claim_full_execution_guidance"] is False
assert verification["missing_or_partial_facets"][0]["display_label"] == "测试质量"
assert verification["safe_next_actions"][0]["display_label"] == "整理加密评审材料"
assert verification["blocked_actions"][0]["display_label"] == "直接发布"
customer_packet = _apply_project_operating_answer_verification_to_packet(
packet={"limitations": [], "suggested_actions": []},
verification=verification,
)
canvas = _apply_project_operating_answer_verification_to_canvas(
canvas={"limitations": [], "next_actions": []},
verification=verification,
)
answer = _apply_project_operating_answer_verification_to_answer(
answer="当前可以继续推进,但要先补齐证据。",
verification=verification,
)
assert any("测试质量" in item for item in customer_packet["limitations"])
assert customer_packet["suggested_actions"][0]["title"] == "整理加密评审材料"
assert canvas["next_actions"][0]["title"] == "整理加密评审材料"
assert not any(action.get("title") == "直接发布" for action in canvas["next_actions"])
assert "可推进:整理加密评审材料" in answer
assert "不能直接做:直接发布" in answer
assert "需要注意:测试质量" in answer
def test_packet_ab_project_operating_packet_renders_concrete_project_answer() -> None:
packet = _verification_project_operating_packet()
state = _project_execution_state()
state.answer_state = PMFlowAIAnswerState(answer_shape="execution_guidance")
state.evidence_state = PMFlowAITurnEvidenceState(
structured_evidence={
"project_operating_evidence_packets": [packet],
"project_operating_evidence_packet": packet,
}
)
answer = _render_project_operating_answer_from_turn_state(
question="现在这个项目怎么样了,详细跟我说下",
turn_state=state,
)
assert "童析" in answer
assert "开发执行:PR 已打开" in answer
assert "风险阻塞:发布前需解除加密评审阻塞" in answer
assert "整理加密评审材料" in answer
assert "直接发布" in answer
assert "无法提供" not in answer
assert "请补充" not in answer
def _covered_project_operating_packet() -> dict:
return {
"packet_version": "project_operating_evidence.v1",
"project_ref": "project:141",
"project_title": "童析",
"command_state": {"coverage_status": "covered", "summary": "阶段:development"},
"execution_state": {"coverage_status": "covered", "summary": "执行状态:pr_open"},
"workbench_state": {"coverage_status": "covered", "summary": "执行/待执行任务 2 个"},
"risk_blocker_state": {"coverage_status": "covered", "summary": "缺少测试证据"},
"decision_review_state": {"coverage_status": "missing", "summary": ""},
"release_growth_state": {"coverage_status": "missing", "summary": ""},
}
def _verification_project_operating_packet() -> dict:
def facet(key: str, label: str, status: str, summary: str = "", gaps: list[str] | None = None) -> dict:
return {
"facet_key": key,
"display_label": label,
"coverage_status": status,
"summary": summary,
"facts": [summary] if summary else [],
"remaining_gaps": gaps or [],
}
return {
"packet_version": "project_operating_evidence.v1",
"project_id": 141,
"project_ref": "project:141",
"project_title": "童析",
"project_status": "active",
"command_state": facet("command_state", "指挥台", "covered", "阶段:development"),
"workbench_state": facet("workbench_state", "项目工作台", "covered", "有 2 个下一步"),
"decision_review_state": facet("decision_review_state", "决策与待确认", "covered", "有高风险评审"),
"execution_state": facet("execution_state", "开发执行", "covered", "PR 已打开"),
"quality_test_state": facet("quality_test_state", "测试质量", "partial", "缺少测试运行记录", ["缺少测试运行记录"]),
"release_growth_state": facet("release_growth_state", "发布增长", "missing", "", ["缺少发布门禁结果"]),
"team_state": facet("team_state", "团队层", "covered", "建议安全负责人评审"),
"project_memory_state": facet("project_memory_state", "项目记忆", "covered", "已记录 MVP 边界"),
"risk_blocker_state": facet("risk_blocker_state", "风险阻塞", "covered", "发布前需解除加密评审阻塞"),
"recent_timeline_state": facet("recent_timeline_state", "最近动态", "covered", "最近进入评审"),
"safe_next_actions": [
{
"action_key": "prepare_review",
"display_label": "整理加密评审材料",
"safety": "prepare_only",
"reason": "来自项目执行态的准备动作,执行前仍需确认。",
"target_ref": "project:141",
}
],
"blocked_actions": [
{
"action_key": "release_now",
"display_label": "直接发布",
"safety": "blocked",
"reason": "当前证据显示发布门禁未解除。",
"target_ref": "project:141",
}
],
}