""" Router for CBPOA reports endpoints Generates and manages risk assessment reports """ from fastapi import APIRouter, HTTPException, Query from datetime import datetime, timedelta from typing import List, Literal, Dict import re from config import DATA_DIR, REPORTS_DIR, RISK_HIGH from models import ( ReportResponse, ReportListResponse, ReportMetadata, ReportSummary, ReportSection, ReportRecommendation, DiagnosisBreakdown, ) from utils.date_helpers import get_latest_date, get_available_dates from utils.geojson import parse_geojson_file from data.case_loader import get_combined_data router = APIRouter(prefix="/api/reports", tags=["reports"]) def calculate_report_summary(grids: List[dict], period_days: int, case_data=None) -> ReportSummary: """Calculate summary statistics for report""" if not grids: return ReportSummary( total_cases=0, avg_risk=0.0, peak_risk_date="", peak_risk_value=0.0, high_risk_areas=0, trend_direction="stable" ) risk_values = [g["risk_value"] for g in grids] avg_risk = sum(risk_values) / len(risk_values) high_risk_count = sum(1 for v in risk_values if v >= RISK_HIGH) peak_risk_value = max(risk_values) peak_grid = next(g for g in grids if g["risk_value"] == peak_risk_value) latest_date = get_latest_date() peak_risk_date = latest_date trend_direction = "stable" if len(grids) > 0: avg_3d = sum(g.get("risk_3d", g["risk_value"]) for g in grids) / len(grids) if avg_risk > avg_3d * 1.05: trend_direction = "worsening" elif avg_risk < avg_3d * 0.95: trend_direction = "improving" if case_data is not None and len(case_data) > 0: total_cases = len(case_data) else: total_cases = int(len(grids) * avg_risk * 0.1 * period_days) return ReportSummary( total_cases=total_cases, avg_risk=round(avg_risk, 4), peak_risk_date=peak_risk_date, peak_risk_value=round(peak_risk_value, 4), high_risk_areas=high_risk_count, trend_direction=trend_direction ) def compute_diagnosis_breakdown(case_data) -> list: """Compute diagnosis breakdown from case data. Returns list of dicts.""" if case_data is None or len(case_data) == 0: return [] breakdown = case_data.groupby(['diagnosis', 'type']).size().unstack(fill_value=0) if 'outpatient' not in breakdown.columns: breakdown['outpatient'] = 0 if 'inpatient' not in breakdown.columns: breakdown['inpatient'] = 0 breakdown['total'] = breakdown['outpatient'] + breakdown['inpatient'] return [ {"diagnosis": str(d), "outpatient": int(row['outpatient']), "inpatient": int(row['inpatient']), "total": int(row['total'])} for d, row in breakdown.sort_values('total', ascending=False).head(10).iterrows() ] def generate_report_sections(summary: ReportSummary, grids: List[Dict], period_days: int) -> List[ReportSection]: """Generate report sections""" sections = [ ReportSection( title="执行摘要", content=( f"本期报告覆盖{period_days}天的监测数据。全市平均风险指数为{summary.avg_risk:.4f}," f"共识别出{summary.high_risk_areas}个高风险区域。" f"总体趋势{summary.trend_direction}," f"峰值风险出现在{summary.peak_risk_date},风险值为{summary.peak_risk_value:.4f}。" ), charts=["overview_chart", "trend_line"] ), ReportSection( title="风险空间分布", content=( f"高风险区域主要集中在人口密集区域。" f"平均风险值{summary.avg_risk:.4f},表明整体风险处于可控范围。" f"建议加强对高风险网格的监测和干预措施。" ), charts=["risk_map", "heatmap"] ), ReportSection( title="时间趋势分析", content=( f"过去{period_days}天内,风险水平呈现{summary.trend_direction}趋势。" f"累计报告病例约{summary.total_cases}例。" f"需要持续关注风险变化趋势,及时调整防控策略。" ), charts=["time_series", "daily_comparison"] ), ReportSection( title="重点区域识别", content=( f"识别出{summary.high_risk_areas}个高风险网格,需要优先关注。" f"建议对这些区域实施精准防控措施,加强监测频率。" ), charts=["hotspot_map", "district_ranking"] ), ] return sections def generate_recommendations(summary: ReportSummary, grids: List[Dict]) -> List[ReportRecommendation]: """Generate report recommendations""" recommendations = [] if summary.high_risk_areas > 0: high_risk_grids = [g["grid_id"] for g in grids if g["risk_value"] >= RISK_HIGH][:5] recommendations.append( ReportRecommendation( priority="high", category="intervention", title="加强高风险区域干预", description=f"对{summary.high_risk_areas}个高风险区域实施精准干预措施,包括增加监测频次、加强防控力度。", target_areas=high_risk_grids ) ) if summary.trend_direction == "worsening": recommendations.append( ReportRecommendation( priority="high", category="monitoring", title="提升监测预警级别", description="风险趋势恶化,建议提升监测预警级别,增加数据采集频率,密切跟踪风险变化。", target_areas=[] ) ) recommendations.append( ReportRecommendation( priority="medium", category="prevention", title="加强健康宣教", description="在人口密集区域加强健康宣教,提高公众防护意识,减少暴露风险。", target_areas=[] ) ) recommendations.append( ReportRecommendation( priority="medium", category="resource_allocation", title="优化资源配置", description="根据风险分布优化医疗资源配置,确保高风险区域有充足的医疗资源储备。", target_areas=[] ) ) if summary.avg_risk < 0.3: recommendations.append( ReportRecommendation( priority="low", category="monitoring", title="维持常规监测", description="当前风险水平较低,建议维持常规监测,保持防控力度不放松。", target_areas=[] ) ) return recommendations def generate_report_id(report_type: str, date_str: str) -> str: """Generate unique report ID""" return f"RPT-{report_type.upper()}-{date_str}" @router.get("/list", response_model=ReportListResponse) async def get_reports_list( report_type: Literal["daily", "weekly", "monthly", "all"] = Query( default="all", description="Filter by report type" ), limit: int = Query(default=20, ge=1, le=100, description="Maximum reports to return"), ): """ Get list of available reports Args: report_type: Filter by report type (daily, weekly, monthly, or all) limit: Maximum number of reports to return (1-100) Returns: List of report metadata """ available_dates = get_available_dates(90) reports = [] for date_str in available_dates[:limit]: report_date = datetime.strptime(date_str, "%Y%m%d") if report_type != "all": if report_type == "daily": pass elif report_type == "weekly" and report_date.weekday() != 6: continue elif report_type == "monthly" and report_date.day != 1: continue reports.append( ReportMetadata( report_id=generate_report_id(report_type, date_str), title=f"武汉市健康风险评估报告 ({date_str})", type=report_type if report_type != "all" else "daily", generated_at=datetime.now().isoformat(), period_start=(report_date - timedelta(days=6)).strftime("%Y%m%d"), period_end=date_str ) ) return ReportListResponse( reports=reports, total=len(reports), timestamp=datetime.now().isoformat() ) @router.get("/{report_id}", response_model=ReportResponse) async def get_report(report_id: str): """ Get full report by ID Args: report_id: Report identifier (e.g., RPT-DAILY-20240115) Returns: Full report with sections and recommendations """ match = re.search(r"RPT-\w+-([0-9]{8})", report_id) if not match: raise HTTPException(status_code=400, detail="Invalid report ID format") date_str = match.group(1) filepath = DATA_DIR / f"risk_{date_str}.geojson" if not filepath.exists(): raise HTTPException(status_code=404, detail=f"No data found for date {date_str}") grids = parse_geojson_file(filepath) if not grids: raise HTTPException(status_code=404, detail="No grid data found") report_date = datetime.strptime(date_str, "%Y%m%d") report_type = "daily" if report_date.weekday() == 6: report_type = "weekly" if report_date.day == 1: report_type = "monthly" period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30 # Load case data for the report's date range case_data = None try: case_data = get_combined_data() if case_data is not None and len(case_data) > 0: report_start = datetime.strptime(date_str, "%Y%m%d") - timedelta(days=period_days - 1) report_end = datetime.strptime(date_str, "%Y%m%d") case_data = case_data[ (case_data['date'] >= report_start) & (case_data['date'] <= report_end) ] except Exception: case_data = None summary = calculate_report_summary(grids, period_days, case_data) sections = generate_report_sections(summary, grids, period_days) recommendations = generate_recommendations(summary, grids) diagnosis_breakdown = compute_diagnosis_breakdown(case_data) metadata = ReportMetadata( report_id=report_id, title=f"武汉市健康风险评估报告 ({date_str})", type=report_type, generated_at=datetime.now().isoformat(), period_start=(report_date - timedelta(days=period_days-1)).strftime("%Y%m%d"), period_end=date_str, author="CBPOA System" ) attachments = [ f"/reports/{date_str}/summary.pdf", f"/reports/{date_str}/maps.zip", f"/reports/{date_str}/data.csv" ] return ReportResponse( metadata=metadata, summary=summary, sections=sections, recommendations=recommendations, attachments=attachments, timestamp=datetime.now().isoformat(), diagnosis_breakdown=[DiagnosisBreakdown(**d) for d in diagnosis_breakdown], ) @router.get("/generate/{report_type}", response_model=ReportResponse) async def generate_new_report( report_type: Literal["daily", "weekly", "monthly"], date: str | None = Query(default=None, description="Date in YYYYMMDD format"), ): """ Generate a new report Args: report_type: Type of report to generate (daily, weekly, monthly) date: Optional date in YYYYMMDD format. Defaults to latest. Returns: Newly generated report """ if date is None: date = get_latest_date() try: report_date = datetime.strptime(date, "%Y%m%d") except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD.") if report_type == "weekly" and report_date.weekday() != 6: raise HTTPException( status_code=400, detail="Weekly reports can only be generated for Sundays (weekday 6)" ) if report_type == "monthly" and report_date.day != 1: raise HTTPException( status_code=400, detail="Monthly reports can only be generated for the 1st of the month" ) filepath = DATA_DIR / f"risk_{date}.geojson" if not filepath.exists(): raise HTTPException(status_code=404, detail=f"No data found for date {date}") grids = parse_geojson_file(filepath) if not grids: raise HTTPException(status_code=404, detail="No grid data found") report_id = generate_report_id(report_type, date) period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30 # Load case data for the report's date range case_data = None try: case_data = get_combined_data() if case_data is not None and len(case_data) > 0: report_start = datetime.strptime(date, "%Y%m%d") - timedelta(days=period_days - 1) report_end = datetime.strptime(date, "%Y%m%d") case_data = case_data[ (case_data['date'] >= report_start) & (case_data['date'] <= report_end) ] except Exception: case_data = None summary = calculate_report_summary(grids, period_days, case_data) sections = generate_report_sections(summary, grids, period_days) recommendations = generate_recommendations(summary, grids) diagnosis_breakdown = compute_diagnosis_breakdown(case_data) metadata = ReportMetadata( report_id=report_id, title=f"武汉市健康风险评估报告 ({date})", type=report_type, generated_at=datetime.now().isoformat(), period_start=(report_date - timedelta(days=period_days-1)).strftime("%Y%m%d"), period_end=date, author="CBPOA System" ) attachments = [ f"/reports/{date}/summary.pdf", f"/reports/{date}/maps.zip", f"/reports/{date}/data.csv" ] return ReportResponse( metadata=metadata, summary=summary, sections=sections, recommendations=recommendations, attachments=attachments, timestamp=datetime.now().isoformat(), diagnosis_breakdown=[DiagnosisBreakdown(**d) for d in diagnosis_breakdown], ) @router.get("/summary/latest", response_model=ReportSummary) async def get_latest_summary(): """ Get latest risk summary Returns: Current risk summary statistics """ latest_date = get_latest_date() filepath = DATA_DIR / f"risk_{latest_date}.geojson" if not filepath.exists(): raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}") grids = parse_geojson_file(filepath) if not grids: raise HTTPException(status_code=404, detail="No grid data found") return calculate_report_summary(grids, 1)