292 lines
17 KiB
Python
292 lines
17 KiB
Python
import calendar
|
||
import json
|
||
import logging
|
||
from datetime import datetime, date
|
||
from decimal import Decimal
|
||
from typing import List, Optional
|
||
|
||
from fastapi import APIRouter, Query
|
||
from fastapi.responses import StreamingResponse
|
||
|
||
from Model.DegreeModel import DegreeModel
|
||
from Util.LlmUtil import get_llm_response
|
||
|
||
|
||
router = APIRouter(prefix="/degree")
|
||
logger = logging.getLogger('DegreeController')
|
||
|
||
|
||
async def _query_month(year: int, month: int, station_names: Optional[List[str]]):
|
||
last_day = calendar.monthrange(year, month)[1]
|
||
start_time = f"{year:04d}-{month:02d}-01 00:00:00"
|
||
end_time = f"{year:04d}-{month:02d}-{last_day:02d} 23:59:59"
|
||
logger.info(
|
||
f"query_month year={year}, month={month}, start_time={start_time}, end_time={end_time}, stations={station_names}"
|
||
)
|
||
model = DegreeModel()
|
||
data = await model.get_degree(station_names=station_names, start_time=start_time, end_time=end_time)
|
||
return {"count": len(data), "list": data}
|
||
|
||
@router.get("/by-month")
|
||
async def by_month(year: int, month: int, station_names: Optional[List[str]] = Query(None)):
|
||
return await _query_month(year, month, station_names)
|
||
|
||
@router.get("/chat")
|
||
async def chat(q: str):
|
||
"""
|
||
SSE接口:接收自然语言查询,调用DeepSeek解析意图,查询数据,并流式返回结果
|
||
"""
|
||
async def event_generator():
|
||
yield f"data: {json.dumps({'step': 'start', 'message': '正在初始化查询...'}, ensure_ascii=False)}\n\n"
|
||
print(f"Starting SSE for query: {q}")
|
||
# 1. 解析用户意图
|
||
now = datetime.now()
|
||
system_prompt = f"""
|
||
你是一个数据查询助手。当前时间是: {now.strftime('%Y-%m-%d %H:%M:%S')}。
|
||
用户想查询场站充电度数报表。
|
||
请从用户的输入中提取查询参数:
|
||
- query_type (str): 查询类型。可选值:
|
||
- "month": 查询特定月份的数据(默认)。
|
||
- "trend": 查询近三个月的趋势变化数据。
|
||
- "company": 查询按企业充电量排名的TOP 10数据。
|
||
- year (int): 年份。如果用户未指定且为"month"或"company"类型,根据当前时间推断。
|
||
- month (int): 月份。
|
||
- station_names (list[str] or null): 场站名称列表。如果查询所有场站,则为null。
|
||
|
||
用户输入: "{q}"
|
||
|
||
请仅返回一个JSON字符串,格式如下:
|
||
{{"query_type": "month", "year": 2025, "month": 12, "station_names": null}}
|
||
或者:
|
||
{{"query_type": "trend", "station_names": null}}
|
||
或者:
|
||
{{"query_type": "company", "year": 2025, "month": 12}}
|
||
不要包含Markdown代码块或其他文本。
|
||
"""
|
||
|
||
# 获取意图解析结果(非流式读取)
|
||
intent_json_str = ""
|
||
try:
|
||
print("Requesting intent analysis from LLM...")
|
||
async for chunk in get_llm_response(system_prompt, stream=True):
|
||
intent_json_str += chunk
|
||
print(f"Intent analysis received: {intent_json_str}")
|
||
except Exception as e:
|
||
print(f"Error during intent analysis: {e}")
|
||
yield f"data: {json.dumps({'step': 'error', 'message': f'意图解析失败: {str(e)}'})}\n\n"
|
||
return
|
||
|
||
try:
|
||
# 清理可能的markdown标记
|
||
intent_json_str = intent_json_str.replace("```json", "").replace("```", "").strip()
|
||
params = json.loads(intent_json_str)
|
||
|
||
query_type = params.get("query_type", "month")
|
||
year = params.get("year")
|
||
month = params.get("month")
|
||
station_names = params.get("station_names")
|
||
|
||
model = DegreeModel()
|
||
|
||
# 1.1 如果用户指定了场站,进行场站名称匹配
|
||
if station_names:
|
||
yield f"data: {json.dumps({'step': 'analyzing', 'message': '正在匹配场站信息...'}, ensure_ascii=False)}\n\n"
|
||
all_stations = await model.getStationList()
|
||
all_station_names = [s['station_name'] for s in all_stations]
|
||
|
||
match_prompt = f"""
|
||
你是一个专业的场站名称匹配助手。
|
||
用户想查询以下场站的数据:{station_names}
|
||
数据库中存在的场站列表如下:
|
||
{all_station_names}
|
||
|
||
请从数据库场站列表中找出与用户描述最匹配的场站名称。
|
||
匹配规则:
|
||
1. 如果用户描述的名称在列表中能找到完全一致的,直接返回该名称。
|
||
2. 如果用户描述的是简称或部分名称(如“净月商贸城”匹配“驿来特充电站(净月商贸城)”),请返回列表中的完整名称。
|
||
3. 如果有多个匹配项,请全部返回。
|
||
4. 如果完全没有找到匹配的场站,请返回空列表 []。
|
||
|
||
请仅返回一个JSON数组字符串,例如:["场站1", "场站2"]
|
||
不要包含Markdown代码块或其他文本。
|
||
"""
|
||
|
||
match_result_str = ""
|
||
async for chunk in get_llm_response(match_prompt, stream=True):
|
||
match_result_str += chunk
|
||
|
||
# 清理并解析匹配结果
|
||
match_result_str = match_result_str.replace("```json", "").replace("```", "").strip()
|
||
matched_names = json.loads(match_result_str)
|
||
|
||
if not matched_names:
|
||
yield f"data: {json.dumps({'step': 'error', 'message': f'未找到与“{station_names}”相关的场站,请确认场站名称是否正确。'}, ensure_ascii=False)}\n\n"
|
||
return
|
||
|
||
station_names = matched_names
|
||
logger.info(f"Matched station names: {station_names}")
|
||
|
||
# 2. 执行数据查询
|
||
yield f"data: {json.dumps({'step': 'querying', 'message': '正在从数据库提取数据...'}, ensure_ascii=False)}\n\n"
|
||
|
||
if query_type == "trend":
|
||
from dateutil.relativedelta import relativedelta
|
||
m3_dt = now
|
||
m2_dt = now - relativedelta(months=1)
|
||
m1_dt = now - relativedelta(months=2)
|
||
month3 = m3_dt.strftime('%Y-%m')
|
||
month2 = m2_dt.strftime('%Y-%m')
|
||
month1 = m1_dt.strftime('%Y-%m')
|
||
|
||
data = await model.get_trend_change()
|
||
summary_prompt = f"""
|
||
你是一个专业的充电数据分析师。
|
||
用户查询了近三个月所有场站的充电趋势及变化。
|
||
数据库返回的数据如下(JSON格式):
|
||
{json.dumps(data, ensure_ascii=False)}
|
||
|
||
请根据这些数据:
|
||
1. 简要分析整体趋势。
|
||
2. 识别出变化(增长或减少)最显著的场站。
|
||
3. 使用Markdown表格展示数据,列名应为:场站名称, 第1月电量, 第2月电量, 第3月电量, 净变化, 绝对变化。
|
||
4. **重要**:提供一个 ECharts 6.0 柱状图或折线图来展示 TOP 10 变化最大的场站趋势。
|
||
- **配色要求**:请使用明亮且对比鲜明的配色方案,避免单调。
|
||
- 请确保图表在深色背景下清晰可见。
|
||
请使用以下格式输出 ECharts 配置(JSON 格式):
|
||
```echarts
|
||
{{
|
||
"title": {{ "text": "充电量趋势变化 TOP 10", "left": "center", "textStyle": {{ "color": "#f8fafc" }} }},
|
||
"tooltip": {{ "trigger": "axis", "axisPointer": {{ "type": "shadow" }} }},
|
||
"legend": {{ "data": ["{month1}", "{month2}", "{month3}"], "bottom": 0, "textStyle": {{ "color": "#94a3b8" }} }},
|
||
"grid": {{ "left": "3%", "right": "4%", "bottom": "10%", "containLabel": true }},
|
||
"xAxis": {{ "type": "category", "data": ["场站A", "场站B", ...], "axisLabel": {{ "color": "#94a3b8" }} }},
|
||
"yAxis": {{ "type": "value", "axisLabel": {{ "color": "#94a3b8" }}, "splitLine": {{ "lineStyle": {{ "color": "rgba(148, 163, 184, 0.1)" }} }} }},
|
||
"series": [
|
||
{{ "name": "{month1}", "type": "bar", "data": [100, 200, ...], "itemStyle": {{ "color": "#6366f1", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "{month2}", "type": "bar", "data": [120, 180, ...], "itemStyle": {{ "color": "#8b5cf6", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "{month3}", "type": "bar", "data": [150, 210, ...], "itemStyle": {{ "color": "#ec4899", "borderRadius": [4, 4, 0, 0] }} }}
|
||
]
|
||
}}
|
||
```
|
||
5. 最后给出一个简单的总结。
|
||
|
||
直接输出分析内容,不要解释。
|
||
"""
|
||
elif query_type == "company":
|
||
if not year or not month:
|
||
from dateutil.relativedelta import relativedelta
|
||
prev = now - relativedelta(months=1)
|
||
year = year or prev.year
|
||
month = month or prev.month
|
||
|
||
last_day = calendar.monthrange(year, month)[1]
|
||
start_time = f"{year:04d}-{month:02d}-01 00:00:00"
|
||
end_time = f"{year:04d}-{month:02d}-{last_day:02d} 23:59:59"
|
||
|
||
data = await model.get_company_top10(start_time=start_time, end_time=end_time)
|
||
summary_prompt = f"""
|
||
你是一个专业的充电数据分析师。
|
||
用户查询了 {year}年{month}月 企业充电量排名的 TOP 10 数据。
|
||
数据库返回的数据如下(JSON格式):
|
||
{json.dumps(data, ensure_ascii=False)}
|
||
|
||
请根据这些数据:
|
||
1. 简要分析该月企业充电量的整体情况。
|
||
2. 指出充电量排名前三的企业。
|
||
3. 使用Markdown表格展示数据,列名应为:企业名称, 总充电量, 峰电量, 平电量, 谷电量。
|
||
4. **重要**:提供一个 ECharts 6.0 柱状图展示 TOP 10 企业的充电量对比。
|
||
- **核心要求**:每个企业应展示 **4个柱子**,分别对应:**总量 (Total)**、**峰电量 (Peak)**、**平电量 (Flat)**、**谷电量 (Valley)**。
|
||
- **配色要求**:请使用丰富的配色方案,避免单调。建议:总量使用深蓝色 (#3b82f6),峰电量使用橙红色 (#ef4444),平电量使用绿色 (#10b981),谷电量使用黄色 (#f59e0b)。
|
||
- 请确保图例 (Legend) 清晰可见。
|
||
- 请确保图表在深色背景下清晰可见。
|
||
请使用以下格式输出 ECharts 配置(JSON 格式):
|
||
```echarts
|
||
{{
|
||
"title": {{ "text": "{year}年{month}月企业充电量 TOP 10", "left": "center", "textStyle": {{ "color": "#f8fafc" }} }},
|
||
"tooltip": {{ "trigger": "axis", "axisPointer": {{ "type": "shadow" }} }},
|
||
"legend": {{ "data": ["总量", "峰电量", "平电量", "谷电量"], "bottom": 0, "textStyle": {{ "color": "#94a3b8" }} }},
|
||
"grid": {{ "left": "3%", "right": "4%", "bottom": "10%", "containLabel": true }},
|
||
"xAxis": {{ "type": "category", "data": ["企业A", "企业B", ...], "axisLabel": {{ "color": "#94a3b8", "rotate": 30 }} }},
|
||
"yAxis": {{ "type": "value", "axisLabel": {{ "color": "#94a3b8" }}, "splitLine": {{ "lineStyle": {{ "color": "rgba(148, 163, 184, 0.1)" }} }} }},
|
||
"series": [
|
||
{{ "name": "总量", "type": "bar", "data": [100, 200, ...], "itemStyle": {{ "color": "#3b82f6", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "峰电量", "type": "bar", "data": [40, 80, ...], "itemStyle": {{ "color": "#ef4444", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "平电量", "type": "bar", "data": [30, 70, ...], "itemStyle": {{ "color": "#10b981", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "谷电量", "type": "bar", "data": [30, 50, ...], "itemStyle": {{ "color": "#f59e0b", "borderRadius": [4, 4, 0, 0] }} }}
|
||
]
|
||
}}
|
||
```
|
||
5. 最后给出一个简单的分析总结。
|
||
|
||
直接输出分析内容,不要解释。
|
||
"""
|
||
else:
|
||
# 默认按月查询
|
||
if not year or not month:
|
||
from dateutil.relativedelta import relativedelta
|
||
prev = now - relativedelta(months=1)
|
||
year = year or prev.year
|
||
month = month or prev.month
|
||
|
||
last_day = calendar.monthrange(year, month)[1]
|
||
start_time = f"{year:04d}-{month:02d}-01 00:00:00"
|
||
end_time = f"{year:04d}-{month:02d}-{last_day:02d} 23:59:59"
|
||
|
||
data = await model.get_degree(station_names=station_names, start_time=start_time, end_time=end_time)
|
||
|
||
summary_prompt = f"""
|
||
你是一个专业的充电数据分析师。
|
||
用户查询了 {year}年{month}月 的场站充电数据。
|
||
数据库返回的数据如下(JSON格式):
|
||
{json.dumps(data, ensure_ascii=False)}
|
||
|
||
请根据这些数据:
|
||
1. 简要总结该月的充电情况(总电量、峰平谷占比等)。
|
||
2. 如果是TOP 10查询,指出电量最高的几个场站。
|
||
3. 使用Markdown表格展示数据。
|
||
4. **重要**:提供一个 ECharts 6.0 图表。
|
||
- 无论是单个场站还是多个场站,请使用 **柱状图 (Bar Chart)**。
|
||
- **核心要求**:每个场站应展示 **4个柱子**,分别对应:**总量 (Total)**、**峰电量 (Peak)**、**平电量 (Flat)**、**谷电量 (Valley)**。
|
||
- **配色要求**:请使用丰富的配色方案,避免单调。建议:总量使用深蓝色 (#1e40af),峰电量使用橙红色 (#ef4444),平电量使用绿色 (#10b981),谷电量使用黄色 (#f59e0b)。
|
||
- 请确保图例 (Legend) 清晰可见。
|
||
请使用以下格式输出 ECharts 配置(JSON 格式):
|
||
```echarts
|
||
{{
|
||
"title": {{ "text": "{year}年{month}月充电量对比", "left": "center", "textStyle": {{ "color": "#f8fafc" }} }},
|
||
"tooltip": {{ "trigger": "axis", "axisPointer": {{ "type": "shadow" }} }},
|
||
"legend": {{ "data": ["总量", "峰电量", "平电量", "谷电量"], "bottom": 0, "textStyle": {{ "color": "#94a3b8" }} }},
|
||
"grid": {{ "left": "3%", "right": "4%", "bottom": "10%", "containLabel": true }},
|
||
"xAxis": {{ "type": "category", "data": ["场站1", "场站2", ...], "axisLabel": {{ "color": "#94a3b8" }} }},
|
||
"yAxis": {{ "type": "value", "axisLabel": {{ "color": "#94a3b8" }}, "splitLine": {{ "lineStyle": {{ "color": "rgba(148, 163, 184, 0.1)" }} }} }},
|
||
"series": [
|
||
{{ "name": "总量", "type": "bar", "data": [100, 200, ...], "itemStyle": {{ "color": "#3b82f6", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "峰电量", "type": "bar", "data": [40, 80, ...], "itemStyle": {{ "color": "#ef4444", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "平电量", "type": "bar", "data": [30, 70, ...], "itemStyle": {{ "color": "#10b981", "borderRadius": [4, 4, 0, 0] }} }},
|
||
{{ "name": "谷电量", "type": "bar", "data": [30, 50, ...], "itemStyle": {{ "color": "#f59e0b", "borderRadius": [4, 4, 0, 0] }} }}
|
||
]
|
||
}}
|
||
```
|
||
5. 给出一个简单的分析结论。
|
||
|
||
直接输出分析内容,不要解释。
|
||
"""
|
||
|
||
# 3. 生成总结并返回结果
|
||
yield f"data: {json.dumps({'step': 'summarizing', 'message': '正在生成分析总结...'}, ensure_ascii=False)}\n\n"
|
||
|
||
try:
|
||
async for chunk in get_llm_response(summary_prompt, stream=True):
|
||
yield f"data: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n"
|
||
yield "data: [DONE]\n\n"
|
||
except Exception as e:
|
||
logger.error(f"Inner processing error: {e}", exc_info=True)
|
||
print(f"Inner processing error: {e}")
|
||
yield f"data: {json.dumps({'step': 'error', 'message': f'处理数据时发生错误: {str(e)}'})}\n\n"
|
||
|
||
except Exception as e:
|
||
logger.error(f"Chat processing error: {e}", exc_info=True)
|
||
print(f"Chat processing error: {e}") # Debug print
|
||
yield f"data: {json.dumps({'step': 'error', 'message': f'处理请求时发生错误: {str(e)}'})}\n\n"
|
||
|
||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|