PMFlow/backend/tests/test_executive_briefing_api.py

823 lines
29 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
from datetime import datetime
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.api.ai_steward_api import routes_executive_briefing
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id
from app.models.base import Base
from app.models.cognitive_conflict import CognitiveConflict
from app.models.feedback_center import FeedbackCenterRecord
from app.models.integration import Integration
from app.models.notification import Notification
from app.models.portfolio_snapshot import PortfolioSnapshot
from app.models.project import Project
from app.models.project_signal_link import ProjectSignalLink
from app.models.signal import Signal
from app.models.signal_source_registry import SignalSourceRegistry
from app.models.task import Task
from app.models.truth_node import TruthNode
from app.models.user import User
from app.models.user_workload import UserWorkload
from app.services.executive_briefing_service import ExecutiveBriefingService
def _use_sqlite_json_types() -> None:
Integration.__table__.c.config.type = JSON()
User.__table__.c.assigned_domains.type = JSON()
TruthNode.__table__.c.content.type = JSON()
Task.__table__.c.ai_recommended_assignees.type = JSON()
Task.__table__.c.ai_breakdown_payload.type = JSON()
Task.__table__.c.manual_overrides.type = JSON()
Task.__table__.c.required_skill_tag_ids.type = JSON()
SignalSourceRegistry.__table__.c.config.type = JSON()
SignalSourceRegistry.__table__.c.raw_context.type = JSON()
Notification.__table__.c.payload.type = JSON()
FeedbackCenterRecord.__table__.c.before_value.type = JSON()
FeedbackCenterRecord.__table__.c.after_value.type = JSON()
FeedbackCenterRecord.__table__.c.ai_snapshot.type = JSON()
FeedbackCenterRecord.__table__.c.reason_candidates.type = JSON()
CognitiveConflict.__table__.c.involved_user_ids.type = JSON()
PortfolioSnapshot.__table__.c.summary.type = JSON()
PortfolioSnapshot.__table__.c.payload.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=[
Integration.__table__,
User.__table__,
Project.__table__,
Signal.__table__,
TruthNode.__table__,
Task.__table__,
UserWorkload.__table__,
SignalSourceRegistry.__table__,
Notification.__table__,
FeedbackCenterRecord.__table__,
CognitiveConflict.__table__,
PortfolioSnapshot.__table__,
ProjectSignalLink.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
def _build_app(*, db_session, role: str = "REVIEWER") -> FastAPI:
app = FastAPI()
app.include_router(routes_executive_briefing.router)
async def _fake_get_db():
yield db_session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 1
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001,
tenant_id=1,
name="管理员",
username="admin",
role=role,
)
return app
@pytest.mark.asyncio
async def test_executive_briefing_snapshot_and_live_are_enveloped(db_session, monkeypatch) -> None:
async def fake_get_briefing(self, *, mode: str): # noqa: ANN001
_ = self
return {
"brief_date": "2026-03-30",
"mode": mode,
"hero": {
"title": "老板晨报",
"decision_count": 2,
"anomaly_count": 4,
},
"sections": [
{
"key": "decisions",
"title": "今日需拍板",
"summary": "有 2 项需要拍板",
"metrics": [{"label": "需拍板", "value": 2, "tone": "warning"}],
"items": [
{
"id": "conflict:1",
"title": "skill_mismatch",
"display_title": "技能错配",
"display_subtitle": "技能要求与成员能力不匹配",
"display_source": "协作冲突",
"technical_identifier": "skill_mismatch",
"detail": "技能要求与成员能力不匹配",
"detail_lines": ["技能要求与成员能力不匹配"],
"meta": [],
"tone": "warning",
"kind": "conflict",
}
],
"drilldown_tab": "signals",
}
],
}
monkeypatch.setattr(ExecutiveBriefingService, "get_briefing", fake_get_briefing)
app = _build_app(db_session=db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
snapshot_response = await client.get("/ai-steward/executive-briefing", params={"mode": "snapshot"})
live_response = await client.get("/ai-steward/executive-briefing", params={"mode": "live"})
assert snapshot_response.status_code == 200
snapshot_payload = snapshot_response.json()
assert snapshot_payload["code"] == 0
assert snapshot_payload["message"] == "success"
assert snapshot_payload["data"]["mode"] == "snapshot"
assert snapshot_payload["data"]["hero"]["decision_count"] == 2
assert live_response.status_code == 200
live_payload = live_response.json()
assert live_payload["code"] == 0
assert live_payload["data"]["mode"] == "live"
assert live_payload["data"]["sections"][0]["key"] == "decisions"
assert live_payload["data"]["sections"][0]["items"][0]["display_title"] == "技能错配"
assert live_payload["data"]["sections"][0]["items"][0]["display_subtitle"] == "技能要求与成员能力不匹配"
assert live_payload["data"]["sections"][0]["items"][0]["display_source"] == "协作冲突"
assert live_payload["data"]["sections"][0]["items"][0]["technical_identifier"] == "skill_mismatch"
@pytest.mark.asyncio
async def test_executive_briefing_config_round_trip_is_enveloped(db_session) -> None:
app = _build_app(db_session=db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
default_response = await client.get("/ai-steward/executive-briefing/config")
update_response = await client.put(
"/ai-steward/executive-briefing/config",
json={
"enabled": True,
"send_time": "09:15",
"send_cycle": "daily",
"delivery_target_type": "management_group",
"delivery_target_ref": "oc_management_group",
"timezone": "Asia/Shanghai",
},
)
refreshed_response = await client.get("/ai-steward/executive-briefing/config")
assert default_response.status_code == 200
default_payload = default_response.json()
assert default_payload["code"] == 0
assert default_payload["data"]["send_time"] == "08:30"
assert default_payload["data"]["send_cycle"] == "workdays"
assert update_response.status_code == 200
update_payload = update_response.json()
assert update_payload["code"] == 0
assert update_payload["data"]["send_time"] == "09:15"
assert update_payload["data"]["delivery_target_ref"] == "oc_management_group"
assert update_payload["data"]["updated_by"] == 9001
refreshed_payload = refreshed_response.json()
assert refreshed_payload["code"] == 0
assert refreshed_payload["data"]["send_time"] == "09:15"
assert refreshed_payload["data"]["send_cycle"] == "daily"
@pytest.mark.asyncio
async def test_executive_briefing_config_rejects_non_manager_users_with_envelope(db_session) -> None:
app = _build_app(db_session=db_session, role="BUILDER")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put(
"/ai-steward/executive-briefing/config",
json={
"enabled": True,
"send_time": "09:15",
"send_cycle": "daily",
"delivery_target_type": "management_group",
"delivery_target_ref": "oc_management_group",
"timezone": "Asia/Shanghai",
},
)
assert response.status_code == 403
payload = response.json()
assert payload["code"] == 403
assert payload["data"] is None
@pytest.mark.asyncio
async def test_executive_briefing_service_persists_config_in_integration_row(db_session) -> None:
service = ExecutiveBriefingService(db_session, tenant_id=1)
updated = await service.update_config(
payload={
"enabled": True,
"send_time": "09:45",
"send_cycle": "daily",
"delivery_target_type": "management_group",
"delivery_target_ref": "oc_management_group_2",
"timezone": "Asia/Shanghai",
},
operator_user_id=777,
)
await db_session.commit()
assert updated["send_time"] == "09:45"
assert updated["updated_by"] == 777
row = (
await db_session.execute(
Integration.__table__.select().where(Integration.tenant_id == 1, Integration.type == "executive_briefing")
)
).mappings().one()
assert row["config"]["send_time"] == "09:45"
assert row["config"]["delivery_target_ref"] == "oc_management_group_2"
@pytest.mark.asyncio
async def test_executive_briefing_service_formats_management_summary_text(db_session) -> None:
service = ExecutiveBriefingService(db_session, tenant_id=1)
text = service.render_management_group_message(
{
"brief_date": "2026-03-30",
"hero": {
"title": "简报中心",
"decision_count": 2,
"anomaly_count": 4,
"baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(),
},
"sections": [
{
"title": "今日需拍板",
"summary": "有 2 项需要拍板",
"items": [
{"title": "P0 信号无人接", "detail": "项目 A"},
{"title": "持续过载成员", "detail": "张三 128%"},
],
}
],
}
)
assert "简报中心" in text
assert "今日需拍板" in text
assert "P0 信号无人接" in text
@pytest.mark.asyncio
async def test_executive_briefing_pending_signal_sources_include_display_fields(db_session) -> None:
owner = User(
tenant_id=1,
username="pm",
email="pm@example.com",
password_hash="hash",
name="产品经理",
role="REVIEWER",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow",
title="现金流看板",
status="active",
)
db_session.add(project)
await db_session.flush()
db_session.add(
SignalSourceRegistry(
tenant_id=1,
project_id=project.id,
created_by_user_id=owner.id,
source_kind="github",
source_name="现金流日报仓库",
source_identifier="daily_cashflow_analysis",
description="每日现金流分析脚本仓库",
requested_via="ai_steward",
status="pending",
onboarding_status="pending",
config={},
raw_context={},
)
)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
items = await service._list_pending_signal_sources(limit=5)
assert len(items) == 1
assert items[0]["display_title"] == "现金流日报仓库"
assert items[0]["display_source"] == "GitHub 仓库"
assert items[0]["technical_identifier"] == "daily_cashflow_analysis"
assert "daily_cashflow_analysis" not in items[0]["display_subtitle"]
def test_snapshot_payload_without_display_fields_is_not_current() -> None:
payload = {
"hero": {"title": "简报中心"},
"sections": [
{
"key": "decisions",
"title": "今日需拍板",
"summary": "有 1 项需要拍板",
"items": [
{
"id": "conflict:1",
"title": "skill_mismatch",
"detail": "技能要求与成员能力不匹配",
"detail_lines": ["技能要求与成员能力不匹配"],
"meta": [],
"tone": "warning",
"kind": "conflict",
}
],
}
],
}
assert ExecutiveBriefingService._snapshot_payload_is_current(payload) is False
@pytest.mark.asyncio
async def test_executive_briefing_service_builds_detailed_signal_items_and_target_params(db_session) -> None:
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="OWNER",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
signal = Signal(
tenant_id=1,
source="feishu",
title="现金流预测功能需求",
summary="需要尽快评估接入方案",
priority="high",
processed=False,
matched_project_id=project.id,
status="pending",
planner_status="pending",
planner_assignee_user_id=owner.id,
created_at=datetime(2026, 3, 30, 7, 50, 0),
)
db_session.add(signal)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
assert payload["hero"]["title"] == "简报中心"
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
signal_item = next(item for item in decisions["items"] if item["kind"] == "signal")
assert signal_item["id"] == f"signal:{signal.id}"
assert signal_item["project_name"] == "现金流平台"
assert signal_item["owner_name"] == "李雷"
assert signal_item["occurred_at"] == "2026-03-30T07:50:00"
assert "优先级 高优" in signal_item["detail_lines"]
assert "状态 待处理" in signal_item["detail_lines"]
assert any(meta["label"] == "优先级" and meta["value"] == "高优" for meta in signal_item["meta"])
assert any(meta["label"] == "负责人" and meta["value"] == "李雷" for meta in signal_item["meta"])
assert signal_item["target"]["type"] == "project_board"
assert signal_item["target"]["params"]["stage"] == "signal"
assert signal_item["target"]["params"]["signal_keyword"] == "现金流预测功能需求"
assert signal_item["target"]["params"]["signal_status"] == "pending"
@pytest.mark.asyncio
async def test_executive_briefing_excludes_unassigned_signals_and_aligns_metrics(db_session) -> None:
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="OWNER",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
db_session.add_all(
[
Signal(
tenant_id=1,
source="feishu",
title="现金流预测功能需求",
summary="需要尽快评估接入方案",
priority="high",
processed=False,
matched_project_id=project.id,
status="pending",
planner_status="pending",
planner_assignee_user_id=owner.id,
created_at=datetime(2026, 3, 30, 7, 50, 0),
),
Signal(
tenant_id=1,
source="feishu",
title="孤立高优信号",
summary="还没有归属项目",
priority="high",
processed=False,
matched_project_id=None,
status="pending",
planner_status="pending",
planner_assignee_user_id=None,
created_at=datetime(2026, 3, 30, 7, 45, 0),
),
Signal(
tenant_id=1,
source="feishu",
title=None,
summary=None,
priority="high",
processed=False,
matched_project_id=None,
status="pending",
planner_status="pending",
planner_assignee_user_id=None,
created_at=datetime(2026, 3, 30, 7, 40, 0),
),
]
)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
input_health = next(section for section in payload["sections"] if section["key"] == "input_health")
decision_signal_items = [item for item in decisions["items"] if item["kind"] == "signal"]
input_signal_items = [item for item in input_health["items"] if item["kind"] == "signal"]
assert [item["title"] for item in decision_signal_items] == ["现金流预测功能需求"]
assert [item["title"] for item in input_signal_items] == ["现金流预测功能需求"]
assert all(item["project_id"] == project.id for item in decision_signal_items)
assert all(item["project_id"] == project.id for item in input_signal_items)
assert all(item["title"] != "待处理事项" for item in decision_signal_items + input_signal_items)
high_priority_metric = next(metric for metric in decisions["metrics"] if metric["label"] == "高优信号")
recent_signal_metric = next(metric for metric in input_health["metrics"] if metric["label"] == "24h 新信号")
pending_signal_metric = next(metric for metric in input_health["metrics"] if metric["label"] == "待处理信号")
assert high_priority_metric["value"] == 1
assert recent_signal_metric["value"] == 1
assert pending_signal_metric["value"] == 1
@pytest.mark.asyncio
async def test_executive_briefing_snapshot_rebuilds_outdated_payload_for_detail_ui(db_session, monkeypatch) -> None:
fixed_now = datetime(2026, 3, 30, 9, 0, 0)
monkeypatch.setattr("app.services.executive_briefing_service.now_cn_naive", lambda: fixed_now)
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="OWNER",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
signal = Signal(
tenant_id=1,
source="feishu",
title="现金流预测功能需求",
summary="需要尽快评估接入方案",
priority="high",
processed=False,
matched_project_id=project.id,
status="pending",
planner_status="pending",
planner_assignee_user_id=owner.id,
created_at=datetime(2026, 3, 30, 7, 50, 0),
)
db_session.add(signal)
await db_session.flush()
outdated_snapshot = PortfolioSnapshot(
tenant_id=1,
scope_type="executive_brief",
scope_ref="tenant:1",
summary={},
payload={
"brief_date": "2026-03-30",
"mode": "snapshot",
"hero": {
"title": "老板晨报中心",
"decision_count": 1,
"anomaly_count": 1,
"baseline_generated_at": datetime(2026, 3, 30, 8, 30, 0).isoformat(),
},
"sections": [
{
"key": "decisions",
"title": "今日需拍板",
"summary": "旧快照",
"metrics": [],
"items": [
{
"kind": "signal",
"tone": "warning",
"title": "📈 现金流预测功能需求",
"detail": "优先级 高优|状态 待处理|03-27 12:06",
"target": {"ref": "signals", "type": "ai_steward_tab", "label": "信号捕获"},
"project_id": project.id,
}
],
}
],
},
created_at=datetime(2026, 3, 30, 8, 30, 0),
)
db_session.add(outdated_snapshot)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.get_briefing(mode="snapshot")
assert payload["hero"]["title"] == "简报中心"
assert payload["mode"] == "snapshot"
assert payload["hero"]["baseline_generated_at"] == datetime(2026, 3, 30, 8, 30, 0).isoformat()
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
signal_item = next(item for item in decisions["items"] if item["kind"] == "signal")
assert signal_item["id"] == f"signal:{signal.id}"
assert signal_item["target"]["type"] == "project_board"
assert signal_item["target"]["params"]["stage"] == "signal"
assert signal_item["detail_lines"]
assert signal_item["meta"]
@pytest.mark.asyncio
async def test_executive_briefing_signal_source_target_does_not_apply_mismatched_keyword_filter(db_session) -> None:
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="OWNER",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
source = SignalSourceRegistry(
tenant_id=1,
project_id=project.id,
created_by_user_id=owner.id,
source_kind="scheduled",
source_name="每日现金流分析",
source_identifier="daily_cashflow_analysis",
onboarding_status="pending",
created_at=datetime(2026, 3, 30, 8, 10, 0),
)
db_session.add(source)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
input_health = next(section for section in payload["sections"] if section["key"] == "input_health")
source_item = next(item for item in input_health["items"] if item["kind"] == "signal_source")
assert source_item["target"]["type"] == "project_board"
assert source_item["target"]["params"] == {"stage": "signal"}
@pytest.mark.asyncio
async def test_executive_briefing_capacity_ratio_matches_displayed_hours(db_session) -> None:
user = User(
tenant_id=1,
username="owner",
email="owner@example.com",
password_hash="hash",
name="杨宇",
role="REVIEWER",
status="active",
is_active=True,
)
db_session.add(user)
await db_session.flush()
db_session.add(
UserWorkload(
tenant_id=1,
user_id=user.id,
current_assigned_hours=95.6,
max_available_hours_per_week=60,
workload_ratio=797,
)
)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
capacity = next(section for section in payload["sections"] if section["key"] == "team_capacity")
overload_item = next(item for item in capacity["items"] if item["kind"] == "capacity")
assert overload_item["title"] == "杨宇"
assert overload_item["detail_lines"][0] == "159% 负载"
assert any(meta["label"] == "负载" and meta["value"] == "159%" for meta in overload_item["meta"])
@pytest.mark.asyncio
async def test_executive_briefing_localizes_conflict_and_signal_source_codes(db_session) -> None:
owner = User(
tenant_id=1,
username="boss",
email="boss@example.com",
password_hash="hash",
name="李雷",
role="OWNER",
status="active",
is_active=True,
)
db_session.add(owner)
await db_session.flush()
project = Project(
tenant_id=1,
name="cashflow-forecast",
title="现金流平台",
description="现金流预测与预警",
owner_id=owner.id,
status="active",
)
db_session.add(project)
await db_session.flush()
truth_node = TruthNode(
tenant_id=1,
project_id=project.id,
created_by=owner.id,
node_type="risk",
title="风险节点",
content={"summary": "风险节点"},
version=1,
status="active",
)
db_session.add(truth_node)
await db_session.flush()
db_session.add(
SignalSourceRegistry(
tenant_id=1,
project_id=project.id,
created_by_user_id=owner.id,
source_kind="user_insight",
source_name="用户洞察数据源",
source_identifier="ai-customer-service-insight",
description="用户洞察相关信号源",
requested_via="ai_steward",
status="pending",
onboarding_status="pending",
config={},
raw_context={},
)
)
db_session.add_all(
[
CognitiveConflict(
tenant_id=1,
truth_node_id=truth_node.id,
conflict_type="quality_drop",
description="质量下降预警",
involved_user_ids=[],
status="active",
),
CognitiveConflict(
tenant_id=1,
truth_node_id=truth_node.id,
conflict_type="schedule_delay",
description="任务超时预警",
involved_user_ids=[],
status="active",
),
]
)
await db_session.commit()
service = ExecutiveBriefingService(db_session, tenant_id=1)
payload = await service.build_brief_payload(
mode="live",
baseline_generated_at=None,
live_generated_at=datetime(2026, 3, 30, 8, 30, 0),
)
decisions = next(section for section in payload["sections"] if section["key"] == "decisions")
input_health = next(section for section in payload["sections"] if section["key"] == "input_health")
source_item = next(item for item in input_health["items"] if item["kind"] == "signal_source")
assert source_item["display_source"] == "用户洞察来源"
assert source_item["display_subtitle"].startswith("用户洞察来源")
assert "user_insight" not in source_item["display_subtitle"]
conflict_titles = {
item["technical_identifier"]: item["display_title"]
for item in decisions["items"]
if item["kind"] == "conflict"
}
assert conflict_titles["quality_drop"] == "质量下降预警"
assert conflict_titles["schedule_delay"] == "进度延期预警"