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"] == "进度延期预警"