From cb0f6cf7f6084ac39b9571f63e696b235b8226cc Mon Sep 17 00:00:00 2001 From: Akiba So Date: Fri, 5 Jun 2026 03:10:28 +0800 Subject: [PATCH] feat: enrich insights cards, add AI chatbot Add 3 new data-driven insight cards (daily cases, district risk comparison, weather impact) with real parquet data. Fix season card to use current date instead of data date. Expand to 11 cards. Add POST /api/chat endpoint proxying to ai.2890.ltd with JWT auth. Create ChatBot frontend component with collapsible chat panel, message bubbles, and auto-scroll. Chat API key stored in .env only. Clean up duplicate typing imports in insights.py, export cachedPost. --- backend/config.py | 13 ++ backend/main.py | 3 +- backend/requirements.txt | 1 + backend/routers/chat.py | 198 ++++++++++++++++++++++++++++ backend/routers/insights.py | 193 ++++++++++++++++++++++++--- frontend/src/components/ChatBot.tsx | 185 ++++++++++++++++++++++++++ frontend/src/pages/Insights.tsx | 3 + frontend/src/services/api.ts | 11 ++ 8 files changed, 585 insertions(+), 22 deletions(-) create mode 100644 backend/routers/chat.py create mode 100644 frontend/src/components/ChatBot.tsx 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'),