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"