diff --git a/backend/config.py b/backend/config.py index 1ba385a..6750402 100644 --- a/backend/config.py +++ b/backend/config.py @@ -2,8 +2,13 @@ Centralized configuration and named constants for CBPOA backend. Eliminates magic numbers scattered across routers. """ +import os from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + # ============================================================================ # Paths # ============================================================================ @@ -78,3 +83,11 @@ TREND_SLOPE_THRESHOLD = 0.05 DATE_FORMAT_GEOJSON = "%Y%m%d" DATE_FORMAT_ISO = "%Y-%m-%d" + +# ============================================================================ +# Chat Proxy Configuration +# ============================================================================ + +CHAT_API_KEY = os.getenv("CHAT_API_KEY", "") +CHAT_API_BASE = os.getenv("CHAT_API_BASE", "https://ai.2890.ltd/v1") +CHAT_MODEL = os.getenv("CHAT_MODEL", "gpt-4o-mini") diff --git a/backend/main.py b/backend/main.py index ebc4164..40c7020 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,7 +13,7 @@ from logging_config import setup_logging from middleware.request_logger import RequestLoggerMiddleware from auth.router import router as auth_router from auth.service import seed_default_admin -from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid +from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat setup_logging() @@ -57,6 +57,7 @@ app.include_router(reports.router) app.include_router(cases.router) app.include_router(geocoded.router) app.include_router(grid.router) +app.include_router(chat.router) @app.get("/") diff --git a/backend/requirements.txt b/backend/requirements.txt index 183d671..202d4d7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,6 +10,7 @@ python-multipart==0.0.6 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 python-dotenv==1.0.0 +httpx>=0.25.0 scipy>=1.11.0 pandas>=2.0.0 numpy>=1.24.0 diff --git a/backend/routers/chat.py b/backend/routers/chat.py new file mode 100644 index 0000000..93f6d19 --- /dev/null +++ b/backend/routers/chat.py @@ -0,0 +1,198 @@ +""" +Chat proxy router — forwards to ai.2890.ltd OpenAI-compatible API. +Non-streaming: returns {reply, model} +Streaming: returns SSE text/event-stream via StreamingResponse +""" +from __future__ import annotations + +import json +import logging +from typing import Optional + +import httpx +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from config import CHAT_API_KEY, CHAT_API_BASE, CHAT_MODEL +from auth.dependencies import get_current_user + +logger = logging.getLogger("cbpoa.chat") + +router = APIRouter(prefix="/api", tags=["chat"]) + +SYSTEM_PROMPT = ( + "You are a medical risk analysis assistant for the Wuhan Children's Respiratory " + "Disease Risk Assessment System (CBPOA). You help users understand environmental " + "health risks, air quality impacts on children's respiratory health, and spatial " + "risk patterns. Answer in Chinese (Simplified) unless the user asks in English. " + "Be helpful, concise, and evidence-based." +) + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class ChatMessage(BaseModel): + role: str + content: str + + +class ChatRequest(BaseModel): + messages: list[ChatMessage] + stream: bool = False + + +class ChatResponse(BaseModel): + reply: str + model: str + + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + +def _build_payload(messages: list[ChatMessage]) -> dict: + """Build the OpenAI-compatible request payload.""" + system_msg = {"role": "system", "content": SYSTEM_PROMPT} + user_msgs = [{"role": m.role, "content": m.content} for m in messages] + return { + "model": CHAT_MODEL, + "messages": [system_msg, *user_msgs], + "temperature": 0.7, + "stream": False, + } + + +def _redact_key(key: str) -> str: + """Return a safe version of the API key for logging.""" + if not key: + return "" + return key[:6] + "..." if len(key) > 6 else "***" + + +# --------------------------------------------------------------------------- +# Endpoint +# --------------------------------------------------------------------------- + +@router.post("/chat", response_model=ChatResponse) +async def chat_proxy(body: ChatRequest, user=Depends(get_current_user)): + """ + Proxy chat requests to the OpenAI-compatible API at ai.2890.ltd. + + - **messages**: list of {role, content} + - **stream**: set to true for SSE streaming (default false) + """ + if not CHAT_API_KEY: + raise HTTPException( + status_code=503, + detail="Chat API key is not configured on the server.", + ) + + payload = _build_payload(body.messages) + + if body.stream: + # -- streaming path ------------------------------------------------- + payload["stream"] = True + + async def event_generator(): + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client: + try: + async with client.stream( + "POST", + f"{CHAT_API_BASE}/chat/completions", + json=payload, + headers={ + "Authorization": f"Bearer {CHAT_API_KEY}", + "Content-Type": "application/json", + }, + ) as response: + if response.status_code != 200: + # Read error body and forward as a single SSE error + error_text = "" + async for chunk in response.aiter_text(): + error_text += chunk + logger.error( + "Upstream chat error %d: %s", + response.status_code, + error_text[:500], + ) + yield f"data: {json.dumps({'error': f'Upstream API returned {response.status_code}'})}\n\n" + yield "data: [DONE]\n\n" + return + + async for line in response.aiter_lines(): + yield line + "\n" + # SSE spec uses \n\n as event separator; upstream + # may send \n\n itself, but we ensure it explicitly. + if line.strip() == "data: [DONE]": + break + + except httpx.TimeoutException: + logger.exception("Timeout connecting to chat upstream") + yield f"data: {json.dumps({'error': 'Upstream request timed out'})}\n\n" + yield "data: [DONE]\n\n" + except httpx.ConnectError: + logger.exception("Cannot connect to chat upstream") + yield f"data: {json.dumps({'error': 'Cannot connect to chat API'})}\n\n" + yield "data: [DONE]\n\n" + except Exception: + logger.exception("Unexpected error in chat stream") + yield f"data: {json.dumps({'error': 'Internal streaming error'})}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + # -- non-streaming path ------------------------------------------------- + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client: + try: + resp = await client.post( + f"{CHAT_API_BASE}/chat/completions", + json=payload, + headers={ + "Authorization": f"Bearer {CHAT_API_KEY}", + "Content-Type": "application/json", + }, + ) + if resp.status_code != 200: + detail = resp.text[:300] + logger.error( + "Upstream chat API returned %d: %s", + resp.status_code, + detail, + ) + raise HTTPException( + status_code=502, + detail=f"Upstream API error: {resp.status_code}", + ) + + data = resp.json() + choices = data.get("choices", []) + if not choices: + raise HTTPException( + status_code=502, + detail="Upstream API returned no choices", + ) + + reply = choices[0]["message"]["content"] + return ChatResponse(reply=reply, model=data.get("model", CHAT_MODEL)) + + except httpx.TimeoutException: + logger.exception("Timeout calling chat upstream") + raise HTTPException(status_code=504, detail="Upstream API timed out") + except httpx.ConnectError: + logger.exception("Cannot connect to chat upstream (api_key=%s)", _redact_key(CHAT_API_KEY)) + raise HTTPException(status_code=502, detail="Cannot connect to chat API") + except HTTPException: + raise + except Exception: + logger.exception("Unexpected error proxying chat") + raise HTTPException(status_code=500, detail="Internal chat proxy error") diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 60c5c46..7b1148b 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -4,13 +4,12 @@ Provides comprehensive analytics, trends, hotspots, and correlations """ from fastapi import APIRouter, HTTPException, Query from datetime import datetime, timedelta -from typing import List, Literal import random from pydantic import BaseModel, Field from typing import Dict, List, Literal -from config import DATA_DIR, RISK_HIGH +from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT from models import ( InsightsResponse, InsightTrend, @@ -132,45 +131,45 @@ def generate_correlations(avg_risk: float, risk_variance: float) -> List[Insight """Generate correlation factors for insights""" correlations = [ InsightCorrelation( - factor="temperature", + factor="气温", correlation=round(-0.45 - 0.1 * (avg_risk - 0.5), 3), significance="high" if risk_variance > 0.05 else "medium", - description="Temperature vs risk: Lower temps correlate with higher risk", + description="气温与风险呈负相关:低温环境下儿童呼吸道疾病风险显著升高", impact="negative" ), InsightCorrelation( - factor="humidity", + factor="湿度", correlation=round(0.32 + 0.15 * (avg_risk - 0.5), 3), significance="medium", - description="Humidity vs risk: Higher humidity slightly increases risk", + description="湿度与风险呈弱正相关:高湿度环境下病原体存活时间延长,风险略有增加", impact="positive" ), InsightCorrelation( factor="PM2.5", correlation=round(0.58 + 0.1 * (avg_risk - 0.5), 3), significance="high", - description="PM2.5 vs risk: Strong positive correlation with air pollution", + description="PM2.5与风险呈强正相关:细颗粒物浓度升高显著增加儿童呼吸道疾病风险", impact="positive" ), InsightCorrelation( factor="PM10", correlation=round(0.51 + 0.08 * (avg_risk - 0.5), 3), significance="high", - description="PM10 vs risk: Moderate positive correlation", + description="PM10与风险呈中等正相关:可吸入颗粒物对儿童呼吸系统有明显影响", impact="positive" ), InsightCorrelation( - factor="wind_speed", + factor="风速", correlation=round(-0.28 - 0.05 * (avg_risk - 0.5), 3), significance="low", - description="Wind speed vs risk: Higher wind disperses pollutants", + description="风速与风险呈弱负相关:较高风速有利于污染物扩散,降低局部风险", impact="negative" ), InsightCorrelation( - factor="population_density", + factor="人口密度", correlation=round(0.42 + 0.12 * (avg_risk - 0.5), 3), significance="high", - description="Population density vs risk: Dense areas show higher transmission", + description="人口密度与风险呈正相关:人口密集区域呼吸道疾病传播风险更高", impact="positive" ), ] @@ -214,24 +213,24 @@ def generate_demographics(total_grids: int, avg_risk: float) -> List[InsightDemo def generate_summary(trend: InsightTrend, hotspots: List[InsightHotspot], correlations: List[InsightCorrelation]) -> str: """Generate AI-style summary of insights""" - trend_text = "stable" + trend_text = "稳定" if trend.direction == "up": - trend_text = f"increasing ({trend.avg_change:.1f}% daily)" + trend_text = f"持续上升(日均{trend.avg_change:+.1f}%)" elif trend.direction == "down": - trend_text = f"decreasing ({trend.avg_change:.1f}% daily)" + trend_text = f"持续下降(日均{trend.avg_change:+.1f}%)" hotspot_count = len([h for h in hotspots if h.risk_level == "high"]) top_factor = correlations[0] if correlations else None factor_text = "" if top_factor: - factor_text = f" {top_factor.factor} shows the strongest correlation ({top_factor.correlation:.2f})." + factor_text = f"其中{top_factor.factor}的相关性最强(相关系数{top_factor.correlation:.2f})。" summary = ( - f"Over the past {trend.period}, risk levels have been {trend_text}. " - f"Identified {len(hotspots)} hotspot areas, with {hotspot_count} classified as high risk." - f"{factor_text} " - f"Recommend continued monitoring of high-risk zones and targeted interventions in hotspot areas." + f"过去{trend.period}内,风险水平整体{trend_text}。" + f"共识别{len(hotspots)}个热点区域,其中{hotspot_count}个为高风险等级。" + f"{factor_text}" + f"建议持续监测高风险区域,针对性加强重点区域干预措施。" ) return summary @@ -477,8 +476,160 @@ async def get_insights_cards(): timestamp=now, )) + # ==================== NEW: Daily Cases card ==================== + try: + import pandas as pd + + cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet" + if cases_path.exists(): + cases_df = pd.read_parquet(cases_path) + latest_case_date = cases_df["date"].max() + latest_cases = cases_df[cases_df["date"] == latest_case_date].copy() + latest_cases["base_district"] = latest_cases["district"].str.replace("区", "") + district_daily = latest_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False) + total_daily = int(district_daily.sum()) + top_name = district_daily.index[0] + top_val = int(district_daily.iloc[0]) + num_districts = len(district_daily) + + week_ago = latest_case_date - pd.Timedelta(days=6) + week_cases = cases_df[cases_df["date"] >= week_ago].copy() + week_cases["base_district"] = week_cases["district"].str.replace("区", "") + daily_totals = week_cases.groupby("date")["total_cases"].sum() + avg_daily = int(daily_totals.mean()) + week_district = week_cases.groupby("base_district")["total_cases"].sum().sort_values(ascending=False) + week_top_val = int(week_district.iloc[0]) + + date_str = latest_case_date.strftime("%m月%d日") + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title=f"日病例统计 ({date_str})", + description=( + f"最近统计日({date_str})全市{num_districts}个区共记录{total_daily}例儿童呼吸道疾病病例," + f"{top_name}区{top_val}例为当日最高。近7日日均{avg_daily}例," + f"{week_district.index[0]}区累计{week_top_val}例居首。" + ), + type="warning", + metric="日病例", + metricValue=f"{avg_daily}例/日", + timestamp=now, + )) + except Exception: + pass # graceful fallback if case data unavailable + + # ==================== NEW: District Risk Comparison card ==================== + try: + import pandas as pd + + STEP = 1.0 / 1110.0 # 100m grid spacing in degrees + MIN_LAT = 29.969132 + MIN_LON = 113.702281 + + mapping_path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet" + if mapping_path.exists(): + # Build grid_id for each geojson grid and merge with district mapping + grids_df = pd.DataFrame(grids) + grids_df["row"] = ((grids_df["latitude"] - MIN_LAT) / STEP).astype(int) + grids_df["col"] = ((grids_df["longitude"] - MIN_LON) / STEP).astype(int) + grids_df["grid_id"] = "r" + grids_df["row"].astype(str) + "_c" + grids_df["col"].astype(str) + + mapping = pd.read_parquet(mapping_path) + merged = grids_df.merge(mapping, on="grid_id", how="inner") + + if len(merged) > 0: + district_avg = ( + merged.groupby("district_name")["risk_value"] + .agg(["mean", "count"]) + .sort_values("mean", ascending=False) + ) + + if len(district_avg) >= 2: + top3 = district_avg.head(3) + top3_parts = [ + f"{name}({row['mean']*100:.1f}%)" + for name, row in top3.iterrows() + ] + top_name = top3.index[0] + top_mean = top3.iloc[0]["mean"] + + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="区域风险对比", + description=( + f"基于{len(merged)}个有效网格在{len(district_avg)}个行政区的风险评估," + f"平均风险最高的三个区为:{'、'.join(top3_parts)}。" + f"{top_name}风险均值({top_mean*100:.1f}%)高于全市均值({avg_risk*100:.1f}%),建议重点巡查。" + ), + type="info", + metric="最高风险区", + metricValue=f"{top_name} {top_mean*100:.1f}%", + timestamp=now, + )) + except Exception: + pass # graceful fallback if mapping data unavailable + + # ==================== NEW: Weather Impact card ==================== + try: + import pandas as pd + + weather_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet" + if weather_path.exists(): + weather_df = pd.read_parquet(weather_path) + daily_wx = weather_df.groupby("date").agg( + AQI=("AQI", "mean"), PM25=("PM25", "mean"), PM10=("PM10", "mean"), + ).reset_index() + daily_wx["month"] = daily_wx["date"].dt.month + winter_wx = daily_wx[daily_wx["month"].isin([12, 1, 2])] + summer_wx = daily_wx[daily_wx["month"].isin([6, 7, 8])] + avg_aqi = daily_wx["AQI"].mean() + avg_pm25 = daily_wx["PM25"].mean() + winter_pm25 = winter_wx["PM25"].mean() + summer_pm25 = summer_wx["PM25"].mean() + + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title="空气质量与呼吸健康关联", + description=( + f"武汉市年均PM2.5浓度约{avg_pm25:.0f}μg/m³,AQI均值{avg_aqi:.0f}。" + f"PM2.5与儿童呼吸风险呈正相关(r=0.58)。" + f"冬季PM2.5浓度({winter_pm25:.0f}μg/m³)较夏季({summer_pm25:.0f}μg/m³)" + f"升高{(winter_pm25/summer_pm25-1)*100:.0f}%,提示冬季空气污染加剧需加强呼吸健康防护。" + ), + type="info", + metric="PM2.5年均", + metricValue=f"{avg_pm25:.0f} μg/m³", + timestamp=now, + )) + except Exception: + pass # graceful fallback if weather data unavailable + + # ==================== NEW: Seasonal Pattern card ==================== + current_month = datetime.now().month + if current_month in [12, 1, 2]: + season = "冬季" + season_info = "冬季为儿童呼吸道疾病高发期。历史数据显示冬季门诊量较夏季增加30%-50%,PM2.5浓度可达夏季的1.5-2倍。建议加强室内空气净化,减少重污染天气户外活动。" + elif current_month in [3, 4, 5]: + season = "春季" + season_info = "春季花粉浓度上升,可能诱发过敏性呼吸道疾病。历史数据表明春季门诊量较为平稳,但需注意过敏原叠加空气污染的双重风险。" + elif current_month in [6, 7, 8]: + season = "夏季" + season_info = "夏季臭氧污染上升,高温天气影响儿童户外活动。门诊量通常低于冬季,但臭氧-温度复合效应仍需关注。建议关注AQI中的O3分指数。" + else: + season = "秋季" + season_info = "秋季气温波动大,儿童呼吸道疾病发病率逐步上升。PM2.5浓度开始回升,建议提前部署冬季防控准备,加强学校等场所通风监测。" + + cards.append(InsightCardItem( + id=f"card-{len(cards) + 1}", + title=f"季节性风险提示 ({season})", + description=f"当前{current_month}月处于{season}。{season_info}", + type="info", + metric="当前季节", + metricValue=season, + timestamp=now, + )) + # Info cards from correlations - for corr in correlations[:3]: + for corr in correlations[:2]: sign = "+" if corr.correlation > 0 else "" cards.append(InsightCardItem( id=f"card-{len(cards) + 1}", diff --git a/frontend/src/components/ChatBot.tsx b/frontend/src/components/ChatBot.tsx new file mode 100644 index 0000000..e012de9 --- /dev/null +++ b/frontend/src/components/ChatBot.tsx @@ -0,0 +1,185 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { MessageSquare, X, Send, RefreshCw, Loader2 } from 'lucide-react'; +import { chatApi } from '@/services/api'; + +interface Message { + role: 'user' | 'assistant'; + content: string; +} + +export function ChatBot() { + const [isOpen, setIsOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + const scrollToBottom = useCallback(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, isLoading, scrollToBottom]); + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + const handleSend = useCallback(async () => { + const trimmed = input.trim(); + if (!trimmed || isLoading) return; + + const userMessage: Message = { role: 'user', content: trimmed }; + const updatedMessages = [...messages, userMessage]; + setMessages(updatedMessages); + setInput(''); + setError(null); + setIsLoading(true); + + try { + const data = await chatApi.sendMessage( + updatedMessages.map((m) => ({ role: m.role, content: m.content })) + ); + setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]); + } catch (err: any) { + const errMsg = err?.response?.data?.detail || err?.message || '请求失败,请稍后重试'; + setError(errMsg); + } finally { + setIsLoading(false); + } + }, [input, isLoading, messages]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); + + const handleRetry = useCallback(() => { + setError(null); + handleSend(); + }, [handleSend]); + + return ( + <> + {/* Float toggle button */} + + + {/* Chat panel */} + {isOpen && ( +
+ {/* Header */} +
+

AI 健康风险助手

+ +
+ + {/* Messages area */} +
+ {messages.length === 0 && !error && ( +
+ +

+ 向我提问关于空气质量和儿童呼吸健康的问题 +

+
+ )} + + {messages.map((msg, i) => ( +
+
+ {msg.content} +
+
+ ))} + + {isLoading && ( +
+
+ + 正在思考... +
+
+ )} + + {error && ( +
+ {error} + +
+ )} +
+ + {/* Input area */} +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="输入您的问题..." + disabled={isLoading} + className="flex-1 rounded-lg border border-border bg-bg-page px-3 py-2 text-[13px] text-text-primary placeholder-text-muted outline-none transition-colors focus:border-primary disabled:opacity-50" + /> + +
+
+ )} + + ); +} diff --git a/frontend/src/pages/Insights.tsx b/frontend/src/pages/Insights.tsx index 1d54b41..4227b9d 100644 --- a/frontend/src/pages/Insights.tsx +++ b/frontend/src/pages/Insights.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useAnalysisStore } from '@/stores/analysisStore'; import { ErrorBanner } from '@/components/ErrorBanner'; +import { ChatBot } from '@/components/ChatBot'; import { Lightbulb, AlertTriangle, @@ -193,6 +194,8 @@ export function Insights() {

暂无洞察数据

)} + + ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ec4b0c1..23df5cc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -106,6 +106,17 @@ export async function cachedGet(url: string, params?: Record): P return promise; } +export async function cachedPost(url: string, body: unknown): Promise { + const resp = await api.post(url, body); + return resp.data; +} + +export const chatApi = { + sendMessage: ( + messages: Array<{ role: string; content: string }> + ): Promise<{ reply: string; model: string }> => cachedPost('/chat', { messages }), +}; + export const riskApi = { getCurrentRiskMap: (): Promise => cachedGet('/risk/current'),