PMFlow/backend/tests/test_pmflow_change_review_a...

766 lines
34 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
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON, func, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test")
from app.api import pmflow_os_api # noqa: E402
from app.core.database import get_db # noqa: E402
from app.dependencies.auth import get_current_user # noqa: E402
from app.models import ( # noqa: E402
AICallLog,
DevWorkspaceBinding,
FeedbackIngest,
FeishuItem,
GitHubWebhookDelivery,
IterationRecommendation,
JudgeRun,
ModelCatalogItem,
ModelEvalRun,
ModelRecommendationSnapshot,
ComplianceCheckRun,
PMFlowApproval,
PMFlowAIFeedbackEvent,
PMFlowChangeItem,
PMFlowChangeSet,
PMFlowEvidenceLink,
PMFlowLaunchDraft,
PMFlowThread,
PMFlowThreadMessage,
OptimizerCandidate,
ProgressEvent,
Project,
ProjectModelBinding,
PromptPackage,
PromptRelease,
ReleaseValidationRun,
RepoBinding,
RevenueEvent,
ROISnapshot,
SemanticFact,
Signal,
Task,
TaskCodeLink,
TruthNode,
User,
UserProject,
)
from app.models.base import Base # noqa: E402
from app.services.prompt_assets_service import PromptAssetService # noqa: E402
TABLES = [
User.__table__,
FeishuItem.__table__,
Signal.__table__,
Project.__table__,
UserProject.__table__,
PMFlowLaunchDraft.__table__,
PMFlowThread.__table__,
PMFlowThreadMessage.__table__,
PMFlowChangeSet.__table__,
PMFlowChangeItem.__table__,
PMFlowApproval.__table__,
PMFlowEvidenceLink.__table__,
PMFlowAIFeedbackEvent.__table__,
AICallLog.__table__,
TruthNode.__table__,
Task.__table__,
SemanticFact.__table__,
ModelCatalogItem.__table__,
ProjectModelBinding.__table__,
ModelRecommendationSnapshot.__table__,
ModelEvalRun.__table__,
PromptRelease.__table__,
JudgeRun.__table__,
OptimizerCandidate.__table__,
PromptPackage.__table__,
DevWorkspaceBinding.__table__,
RepoBinding.__table__,
TaskCodeLink.__table__,
ProgressEvent.__table__,
ReleaseValidationRun.__table__,
ComplianceCheckRun.__table__,
FeedbackIngest.__table__,
IterationRecommendation.__table__,
RevenueEvent.__table__,
ROISnapshot.__table__,
GitHubWebhookDelivery.__table__,
]
def _use_sqlite_json_types() -> None:
for table in TABLES:
for column in table.columns:
type_name = column.type.__class__.__name__.upper()
if type_name in {"JSONB", "ARRAY"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_env():
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=TABLES))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
async def _seed_user_project(session, *, tenant_id: int = 1, role: str = "MEMBER", member: bool = True):
user = User(
tenant_id=tenant_id,
username=f"user-{tenant_id}-{role}-{member}",
email=f"user-{tenant_id}-{role}-{member}@example.com",
password_hash="hashed",
name="Owner",
role=role,
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(user)
await session.flush()
project = Project(
tenant_id=tenant_id,
name="真实项目",
title="真实项目",
description="用于 Change Review 测试",
owner_id=user.id,
status="active",
assigned_role="BUILDER",
signal_count=0,
)
session.add(project)
await session.flush()
if member:
session.add(UserProject(tenant_id=tenant_id, user_id=user.id, project_id=project.id))
await session.commit()
return user, project
def _build_app(session, current_user) -> FastAPI:
app = FastAPI()
app.include_router(pmflow_os_api.router)
async def _fake_get_db():
yield session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_current_user] = lambda: current_user
return app
def _proposed_items():
return [
{
"group": "new_object",
"object_type": "Requirement",
"title": "新增需求:审查真实落库",
"summary": "把 AI 候选变更写入待审状态机。",
"diff": "新增真实 ChangeItem。",
"payload": {"summary": "候选需求"},
"confidence": 0.91,
"risk_level": "medium",
},
{
"group": "risk",
"object_type": "Risk",
"title": "新增风险:高风险需记录审批",
"summary": "高风险项本轮先记录 approval。",
"diff": "新增高风险候选。",
"payload": {"summary": "候选风险"},
"confidence": 0.78,
"risk_level": "high",
},
{
"group": "decision",
"object_type": "Decision",
"title": "待确认决策:先审查后 commit",
"summary": "C1 不直接写项目真相层。",
"diff": "新增决策候选。",
"payload": {"summary": "候选决策"},
"confidence": 0.83,
"risk_level": "medium",
},
]
def _commit_proposed_items():
return [
{
"group": "new_object",
"object_type": "Requirement",
"title": "新增需求:真实写入项目记忆",
"summary": "确认后的需求必须进入 truth_nodes。",
"diff": "写入 requirement truth node。",
"payload": {"summary": "确认后的需求", "stage": "planning"},
"confidence": 0.92,
"risk_level": "medium",
},
{
"group": "new_object",
"object_type": "Task",
"title": "新增任务:生成开发上下文包",
"summary": "确认后的任务必须进入 tasks。",
"diff": "写入 task。",
"payload": {
"summary": "确认后的任务",
"description": "生成开发上下文包并等待执行桥消费。",
"priority": "high",
"acceptanceCriteria": ["任务进入 tasks", "任务绑定 truth_node"],
},
"confidence": 0.88,
"risk_level": "medium",
},
{
"group": "risk",
"object_type": "Risk",
"title": "新增风险:commit 需要审批",
"summary": "高风险项必须有 approval 才能写入。",
"diff": "写入 high risk。",
"payload": {"summary": "确认后的高风险", "mitigation": "提交前检查 approval gate。"},
"confidence": 0.81,
"risk_level": "high",
},
]
async def _create_message_and_extraction(client, project_id: int):
message = await client.post(
f"/pmflow-os/projects/{project_id}/threads/thread-project-{project_id}/messages",
json={"content": "请把本轮对话提取成待审变更", "source": "web", "attachments": []},
)
assert message.status_code == 200
msg = message.json()["data"]
extraction = await client.post(
f"/pmflow-os/projects/{project_id}/extractions",
json={
"thread_id": msg["threadId"],
"message_id": msg["id"],
"scope": "latest_message",
"headline": "本轮待审变更",
"summary": "生成 3 个候选变更。",
"proposed_items": _proposed_items(),
},
)
assert extraction.status_code == 200
return msg, extraction.json()["data"]
async def _create_commit_extraction(client, project_id: int):
message = await client.post(
f"/pmflow-os/projects/{project_id}/threads/thread-project-{project_id}/messages",
json={"content": "请把本轮确认项写入项目真相层", "source": "web", "attachments": []},
)
assert message.status_code == 200
msg = message.json()["data"]
extraction = await client.post(
f"/pmflow-os/projects/{project_id}/extractions",
json={
"thread_id": msg["threadId"],
"message_id": msg["id"],
"scope": "latest_message",
"headline": "待提交变更",
"summary": "生成 3 个可提交候选。",
"proposed_items": _commit_proposed_items(),
},
)
assert extraction.status_code == 200
return msg, extraction.json()["data"]
@pytest.mark.asyncio
async def test_change_review_message_extraction_detail_and_projection(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
msg, detail = await _create_message_and_extraction(client, project.id)
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert msg["threadId"].isdigit()
assert await session.scalar(select(func.count(PMFlowThread.id))) == 1
assert await session.scalar(select(func.count(PMFlowThreadMessage.id))) == 1
assert await session.scalar(select(func.count(PMFlowChangeSet.id))) == 1
assert await session.scalar(select(func.count(PMFlowChangeItem.id))) == 3
assert await session.scalar(select(func.count(PMFlowEvidenceLink.id))) == 1
assert detail["changeSet"]["status"] == "open"
assert len(detail["items"]) == 3
assert detail["items"][0]["status"] == "pending_review"
assert detail["evidence"][0]["sourceType"] == "thread_message"
assert detail["evidence"][0]["sourceId"] == msg["id"]
truth_payload = truth.json()["data"]
assert truth_payload["loopStage"] == "review_open"
assert truth_payload["activeChangeSetId"] == detail["changeSet"]["id"]
assert len(truth_payload["changeSets"]) == 1
assert len(truth_payload["changeItems"]) == 3
assert len(truth_payload["evidence"]) == 1
@pytest.mark.asyncio
async def test_thread_message_can_trigger_real_ai_extraction_without_mock_proposed_items(db_env, monkeypatch) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
calls: list[dict[str, object]] = []
async def _fake_generate_structured_object(prompt: str, **kwargs):
calls.append({"prompt": prompt, **kwargs})
return SimpleNamespace(
payload={
"title": "会议助手",
"requirements": [
{"title": "自动记录会议", "summary": "支持会议实时转写和发言人区分。", "acceptance_criteria": ["能看到逐字稿"]}
],
"tasks": [
{"title": "实现转写接入", "summary": "接入语音转写并回写会议纪要。", "priority": "high", "acceptance_criteria": ["产生纪要"]}
],
"risks": [
{"title": "隐私合规", "summary": "会议内容涉及敏感信息。", "level": "high", "mitigation": "上线前完成权限与脱敏设计。"}
],
"decisions": [
{"title": "先接飞书", "reason": "目标团队主要使用飞书。", "owner": "产品负责人"}
],
"confidence": 0.86,
"warnings": [],
}
)
monkeypatch.setattr("app.services.pmflow_change_review_service.llm_service.generate_structured_object", _fake_generate_structured_object)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
message = await client.post(
f"/pmflow-os/projects/{project.id}/threads/thread-project-{project.id}/messages",
json={"content": "做一个会议助手,自动记录、总结、跟踪待办,先接飞书。", "source": "web", "attachments": []},
)
msg = message.json()["data"]
extraction = await client.post(
f"/pmflow-os/projects/{project.id}/extractions",
json={"thread_id": msg["threadId"], "message_id": msg["id"], "scope": "latest_message"},
)
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert extraction.status_code == 200
payload = extraction.json()["data"]
assert calls
assert calls[0]["function_name"] == "pmflow_change_review_extraction_v1"
assert calls[0]["max_tokens"] == 5600
assert calls[0]["routing_meta"]["scene"] == "pmflow_os_thread_extraction"
assert calls[0]["routing_meta"]["scene_key"] == "change_review"
assert calls[0]["routing_meta"]["prompt_key"] == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION"
assert calls[0]["routing_meta"]["function_runtime_max_tokens"] == 5600
assert payload["changeSet"]["status"] == "open"
assert payload["changeSet"]["routingMeta"]["aiExtraction"]["confidence"] == 0.86
assert payload["changeSet"]["routingMeta"]["aiExtraction"]["maxTokens"] == 5600
assert len(payload["items"]) == 4
assert {item["objectType"] for item in payload["items"]} == {"Requirement", "Task", "Risk", "Decision"}
assert all(item["status"] == "pending_review" for item in payload["items"])
assert truth.json()["data"]["activeChangeSetId"] == payload["changeSet"]["id"]
@pytest.mark.asyncio
async def test_ai_extraction_failure_does_not_create_fake_changeset(db_env, monkeypatch) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async def _fake_generate_structured_object(prompt: str, **kwargs):
return SimpleNamespace(payload={"title": "空结果", "requirements": [], "tasks": [], "risks": [], "decisions": [], "confidence": 0.2})
monkeypatch.setattr("app.services.pmflow_change_review_service.llm_service.generate_structured_object", _fake_generate_structured_object)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
message = await client.post(
f"/pmflow-os/projects/{project.id}/threads/thread-project-{project.id}/messages",
json={"content": "先看看这个方向。", "source": "web", "attachments": []},
)
msg = message.json()["data"]
extraction = await client.post(
f"/pmflow-os/projects/{project.id}/extractions",
json={"thread_id": msg["threadId"], "message_id": msg["id"], "scope": "latest_message"},
)
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert extraction.status_code == 409
assert "证据不足" in extraction.json()["message"]
assert await session.scalar(select(func.count(PMFlowChangeSet.id))) == 0
assert await session.scalar(select(func.count(PMFlowChangeItem.id))) == 0
assert await session.scalar(select(func.count(PMFlowThreadMessage.id))) == 2
truth_messages = truth.json()["data"]["messages"]
assert truth_messages[-1]["role"] == "assistant"
assert "没有形成可审查的需求、任务、风险或决策" in truth_messages[-1]["content"]
@pytest.mark.asyncio
async def test_change_item_accept_edit_defer_write_approvals_and_do_not_write_memory(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
item_ids = [item["id"] for item in detail["items"]]
accepted = await client.post(f"/pmflow-os/change-items/{item_ids[0]}/accept")
edited = await client.post(
f"/pmflow-os/change-items/{item_ids[1]}/edit",
json={"summary": "人工修订摘要", "diff": "人工修订 diff", "payload_patch": {"owner": "user"}, "reason": "收紧范围"},
)
deferred = await client.post(f"/pmflow-os/change-items/{item_ids[2]}/defer", json={"reason": "下一轮再看"})
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert accepted.status_code == 200
assert edited.status_code == 200
assert deferred.status_code == 200
assert await session.scalar(select(func.count(PMFlowApproval.id))) == 3
assert await session.scalar(select(func.count(TruthNode.id))) == 0
assert await session.scalar(select(func.count(Task.id))) == 0
assert await session.scalar(select(func.count(SemanticFact.id))) == 0
change_set = (await session.execute(select(PMFlowChangeSet))).scalar_one()
assert change_set.status == "reviewed"
edited_row = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(item_ids[1])))).scalar_one()
assert edited_row.status == "edited"
assert edited_row.edited_payload["owner"] == "user"
assert truth.json()["data"]["projectMemory"]["sourceSummary"]["truth_nodes"] == 0
assert len(truth.json()["data"]["approvals"]) == 3
@pytest.mark.asyncio
async def test_review_decision_request_and_approve_high_risk_satisfies_commit_gate(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
reviewer = User(
tenant_id=1,
username="reviewer",
email="reviewer@example.com",
password_hash="hashed",
name="审查负责人",
role="REVIEWER",
assigned_domains=[],
workload=0,
status="active",
is_active=True,
)
session.add(reviewer)
await session.flush()
session.add(UserProject(tenant_id=1, user_id=reviewer.id, project_id=project.id))
await session.commit()
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_commit_extraction(client, project.id)
change_set_id = detail["changeSet"]["id"]
high_risk_id = next(item["id"] for item in detail["items"] if item["riskLevel"] == "high")
request_review = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "request_review", "reviewer_id": reviewer.id, "reason": "请 reviewer 复核高风险项。"},
)
approve = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "approve", "reviewer_id": reviewer.id, "reason": "高风险项通过。", "item_ids": [high_risk_id]},
)
commit = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/commit",
json={"item_ids": [high_risk_id], "mode": "atomic"},
)
assert request_review.status_code == 200
request_payload = request_review.json()["data"]
assert request_payload["warnings"] == []
routing_item = request_payload["truthState"]["reviewerRouting"]["items"][0]
assert routing_item["reviewDecision"] == "request_review"
assert routing_item["approvalState"] == "requested"
assert routing_item["requestedAt"]
assert approve.status_code == 200
approve_payload = approve.json()["data"]
assert approve_payload["truthState"]["reviewerRouting"]["items"][0]["approvalState"] == "approved"
assert approve_payload["truthState"]["reviewerRouting"]["items"][0]["decisionReason"] == "高风险项通过。"
approval_rows = (await session.execute(select(PMFlowApproval).where(PMFlowApproval.change_set_id == int(change_set_id)))).scalars().all()
assert any(row.action == "approve" and row.decision == "approved" and row.change_item_id == int(high_risk_id) for row in approval_rows)
assert any(row.action == "approve" and row.actor_user_id == user.id for row in approval_rows)
high_risk = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(high_risk_id)))).scalar_one()
assert high_risk.status == "committed"
assert commit.status_code == 200
assert await session.scalar(select(func.count(TruthNode.id))) == 1
@pytest.mark.asyncio
async def test_review_decision_request_changes_and_reject_do_not_commit_memory(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
change_set_id = detail["changeSet"]["id"]
item_ids = [item["id"] for item in detail["items"]]
changes = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "request_changes", "reason": "摘要需要补证据。", "item_ids": [item_ids[0]]},
)
rejected = await client.post(
f"/pmflow-os/change-sets/{change_set_id}/review-decisions",
json={"action": "reject", "reason": "风险项本轮不采纳。", "item_ids": [item_ids[1]]},
)
assert changes.status_code == 200
assert changes.json()["data"]["truthState"]["reviewerRouting"]["items"][0]["approvalState"] == "changes_requested"
assert rejected.status_code == 200
assert rejected.json()["data"]["truthState"]["reviewerRouting"]["items"][0]["approvalState"] == "rejected"
assert await session.scalar(select(func.count(PMFlowApproval.id))) == 2
assert await session.scalar(select(func.count(TruthNode.id))) == 0
assert await session.scalar(select(func.count(Task.id))) == 0
rejected_item = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(item_ids[1])))).scalar_one()
assert rejected_item.status == "deferred"
@pytest.mark.asyncio
async def test_change_set_commit_writes_truth_nodes_tasks_and_semantic_facts(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_commit_extraction(client, project.id)
item_ids = [item["id"] for item in detail["items"]]
await client.post(f"/pmflow-os/change-items/{item_ids[0]}/accept")
await client.post(f"/pmflow-os/change-items/{item_ids[1]}/accept")
await client.post(
f"/pmflow-os/change-items/{item_ids[2]}/edit",
json={"summary": "人工确认后的高风险", "payload_patch": {"mitigation": "人工确认后再落库"}},
)
committed = await client.post(
f"/pmflow-os/change-sets/{detail['changeSet']['id']}/commit",
json={"mode": "atomic", "note": "确认写入项目记忆"},
)
truth = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert committed.status_code == 200
payload = committed.json()["data"]
assert payload["changeSet"]["status"] == "committed"
assert payload["changeSet"]["committedBy"] == "Owner"
assert payload["changeSet"]["committedById"] == str(user.id)
assert payload["changeSet"]["writebackAt"]
assert len(payload["committedItems"]) == 3
assert {item["committedBy"] for item in payload["committedItems"]} == {"Owner"}
assert payload["failedItems"] == []
assert await session.scalar(select(func.count(TruthNode.id))) == 3
assert await session.scalar(select(func.count(Task.id))) == 1
assert await session.scalar(select(func.count(SemanticFact.id))) == 4
task = (await session.execute(select(Task))).scalar_one()
assert task.priority == "high"
assert task.status == "todo"
assert task.manual_overrides["acceptance_criteria"] == ["任务进入 tasks", "任务绑定 truth_node"]
truth_payload = truth.json()["data"]
assert truth_payload["activeChangeSetId"] is None
assert truth_payload["lastWritebackBy"] == "Owner"
assert truth_payload["lastWritebackById"] == str(user.id)
assert truth_payload["projectMemory"]["sourceSummary"]["truth_nodes"] == 3
assert truth_payload["projectMemory"]["sourceSummary"]["semantic_facts"] == 4
@pytest.mark.asyncio
async def test_change_set_commit_blocks_high_risk_without_approval_and_rolls_back(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_commit_extraction(client, project.id)
high_risk_id = detail["items"][2]["id"]
item = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(high_risk_id)))).scalar_one()
item.status = "accepted"
change_set = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.id == int(item.change_set_id)))).scalar_one()
change_set.status = "reviewed"
await session.commit()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
blocked = await client.post(
f"/pmflow-os/change-sets/{detail['changeSet']['id']}/commit",
json={"item_ids": [high_risk_id], "mode": "atomic"},
)
assert blocked.status_code == 409
assert "high_risk_approval_required" in blocked.json()["message"]
assert await session.scalar(select(func.count(TruthNode.id))) == 0
assert await session.scalar(select(func.count(Task.id))) == 0
assert await session.scalar(select(func.count(SemanticFact.id))) == 0
@pytest.mark.asyncio
async def test_change_item_can_be_reworked_before_commit_and_closed_changeset_guard(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
item_id = detail["items"][0]["id"]
first = await client.post(f"/pmflow-os/change-items/{item_id}/accept")
second = await client.post(
f"/pmflow-os/change-items/{item_id}/edit",
json={"summary": "写入前继续修订", "diff": "写入前继续修订 diff", "payload_patch": {"summary": "写入前继续修订"}, "reason": "入库前仍可修改"},
)
third = await client.post(f"/pmflow-os/change-items/{item_id}/defer", json={"reason": "先暂缓"})
restored = await client.post(f"/pmflow-os/change-items/{item_id}/accept")
assert first.status_code == 200
assert second.status_code == 200
assert third.status_code == 200
assert restored.status_code == 200
item = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.id == int(item_id)))).scalar_one()
assert item.status == "accepted"
assert item.summary == "写入前继续修订"
pending = (await session.execute(select(PMFlowChangeItem).where(PMFlowChangeItem.status == "pending_review"))).scalars().first()
change_set = (await session.execute(select(PMFlowChangeSet).where(PMFlowChangeSet.id == pending.change_set_id))).scalar_one()
change_set.status = "closed"
await session.commit()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
blocked = await client.post(f"/pmflow-os/change-items/{pending.id}/accept")
assert blocked.status_code == 409
assert "changeset_not_open" in blocked.json()["message"]
@pytest.mark.asyncio
async def test_change_review_project_permission_boundary(db_env) -> None:
session = db_env
owner, project = await _seed_user_project(session)
outsider, _ = await _seed_user_project(session, tenant_id=1, member=False)
owner_app = _build_app(session, SimpleNamespace(id=owner.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=owner_app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
outsider_app = _build_app(session, SimpleNamespace(id=outsider.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=outsider_app), base_url="http://test") as client:
denied = await client.get(f"/pmflow-os/projects/{project.id}/change-sets/{detail['changeSet']['id']}")
denied_action = await client.post(f"/pmflow-os/change-items/{detail['items'][0]['id']}/accept")
assert denied.status_code == 403
assert denied_action.status_code == 403
cross_tenant_app = _build_app(session, SimpleNamespace(id=outsider.id, tenant_id=2, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=cross_tenant_app), base_url="http://test") as client:
missing = await client.get(f"/pmflow-os/projects/{project.id}/change-sets/{detail['changeSet']['id']}")
assert missing.status_code == 404
@pytest.mark.asyncio
async def test_ai_feedback_records_prompt_quality_signal_and_returns_in_truth_state(db_env) -> None:
session = db_env
user, project = await _seed_user_project(session)
app = _build_app(session, SimpleNamespace(id=user.id, tenant_id=1, role="MEMBER"))
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
_, detail = await _create_message_and_extraction(client, project.id)
change_set_id = detail["changeSet"]["id"]
response = await client.post(
f"/pmflow-os/projects/{project.id}/ai-feedback",
json={
"target_type": "change_set",
"target_id": change_set_id,
"answer_kind": "structured_extraction",
"rating": "negative",
"reason_tags": ["漏掉重点", "不够可执行"],
"thread_id": detail["changeSet"]["threadId"],
"change_set_id": change_set_id,
"scene_key": "pmflow_os_thread_extraction",
"prompt_key": "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION",
},
)
truth_state = await client.get(f"/pmflow-os/projects/{project.id}/truth-state")
assert response.status_code == 200
payload = response.json()["data"]
assert payload["targetType"] == "change_set"
assert payload["rating"] == "negative"
assert payload["reasonTags"] == ["漏掉重点", "不够可执行"]
assert payload["promptKey"] == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION"
assert payload["metadata"]["promptOptimization"]["status"] == "draft_optimizer_candidate"
candidate_id = int(payload["metadata"]["promptOptimization"]["candidateId"])
judge_run_id = int(payload["metadata"]["promptOptimization"]["judgeRunId"])
assert truth_state.status_code == 200
feedback = truth_state.json()["data"]["aiFeedback"]
assert len(feedback) == 1
assert feedback[0]["targetId"] == change_set_id
assert feedback[0]["sceneKey"] == "pmflow_os_thread_extraction"
assert feedback[0]["metadata"]["promptOptimizationUse"] == "quality_signal_for_scene_prompt_iteration"
assert feedback[0]["metadata"]["promptOptimization"]["candidateId"] == candidate_id
judge = await session.scalar(select(JudgeRun).where(JudgeRun.id == judge_run_id))
assert judge is not None
assert judge.prompt_key == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION"
assert judge.judge_status == "feedback_review"
assert len(judge.judge_status) <= 16
assert judge.findings[0]["code"] == "missed_key_point"
candidate = await session.scalar(select(OptimizerCandidate).where(OptimizerCandidate.id == candidate_id))
assert candidate is not None
assert candidate.status == "draft"
assert candidate.target_type == "prompt_key"
assert candidate.target_ref == "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION"
assert "关键对象覆盖检查" in candidate.suggestion["recommended_actions"][0]
quality = await PromptAssetService(session).get_quality_summary(
tenant_id=1,
prompt_key="PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION",
window="7d",
)
assert quality["pmflow_feedback"]["negative_feedback"] == 1
assert quality["pmflow_feedback"]["reason_tag_counts"][0] == {"tag": "不够可执行", "count": 1}
assert quality["optimizer_candidates"][0]["id"] == candidate_id
platform_quality = await PromptAssetService(session).get_quality_summary(
tenant_id=999,
prompt_key="PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION",
window="7d",
include_all_pmflow_feedback=True,
)
assert platform_quality["pmflow_feedback"]["negative_feedback"] == 1
assert platform_quality["pmflow_feedback"]["recent_feedback"][0]["tenant_id"] == 1
assert platform_quality["optimizer_candidates"][0]["tenant_id"] == 1
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
positive_response = await client.post(
f"/pmflow-os/projects/{project.id}/ai-feedback",
json={
"target_type": "change_set",
"target_id": change_set_id,
"answer_kind": "structured_extraction",
"rating": "positive",
"thread_id": detail["changeSet"]["threadId"],
"change_set_id": change_set_id,
"scene_key": "pmflow_os_thread_extraction",
"prompt_key": "PROMPT_PMFLOW_CHANGE_REVIEW_EXTRACTION",
},
)
assert positive_response.status_code == 200
assert positive_response.json()["data"]["metadata"]["promptOptimization"]["status"] == "superseded_by_positive_feedback"
await session.refresh(candidate)
assert candidate.status == "superseded"