This commit is contained in:
HuangHai
2026-01-21 08:51:44 +08:00
parent 7206bd554c
commit 2fda6f0dcf
6 changed files with 156 additions and 61 deletions

View File

@@ -214,29 +214,97 @@ async def export_ai_report_docx(req: AiReportRequest):
@router.get("/api/ai/pricing/strategy-summary") @router.get("/api/ai/pricing/strategy-summary")
async def ai_pricing_strategy_summary(): async def ai_pricing_strategy_summary():
async def generate_stream(): async def generate_stream():
resp = await get_operators_hourly_prices() try:
data = resp.get("operators", []) # 发送初始信息并增加一些空白填充,防止某些代理缓存
text_data = [] yield "正在收集各供应商价格数据,请稍候...\n\n" + (" " * 512) + "\n"
for item in data: print("AI分析开始: 获取运营商价格数据...")
text_data.append({"operator": item.get("operator"), "series": item.get("series")})
# 使用 asyncio.wait_for 防止数据库查询无限挂起
prompt = ( try:
"下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)基于最新爬取数据计算出的平均24小时分时电价:\n" # 1. 获取当前最新 24 小时平均价格
f"{text_data}\n" resp = await asyncio.wait_for(get_operators_hourly_prices(), timeout=30.0)
"请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平,"
"指出我司在不同时段可能存在的潜在问题和风险例如明显偏贵、价格结构不合理等并给出2-3条可执行的优化建议。" # 2. 获取最近 3 天的价格变动趋势
"请使用Markdown格式输出重点可以使用加粗如有公式可使用LaTeX格式。" trend_resp = await asyncio.wait_for(get_operators_price_trends(days=3), timeout=30.0)
"回答控制在800字以内。" except asyncio.TimeoutError:
) print("获取价格数据超时")
yield "\n\n**错误**: 获取价格数据超时,数据库响应过慢,请稍后重试。"
async for chunk in get_llm_response( return
prompt,
stream=True,
system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
):
yield chunk
return StreamingResponse(generate_stream(), media_type="text/event-stream") # 处理当前价格数据
data = resp.get("operators", [])
text_data = []
for item in data:
text_data.append({"operator": item.get("operator"), "series": item.get("series")})
# 处理 3 天趋势数据
trend_dates = trend_resp.get("dates", [])
trend_series = trend_resp.get("series", [])
trend_text = []
for s in trend_series:
trend_text.append({"operator": s.get("name"), "daily_avg_prices": s.get("data")})
print(f"数据获取完成准备请求LLM. 数据条数: {len(text_data)}, 趋势天数: {len(trend_dates)}")
yield "数据收集完成,正在分析最近 3 天的价格波动趋势并生成深度建议...\n\n"
# 增加一个心跳,确保连接不断开
yield " " * 128 + "\n"
prompt = (
"你是一位专业的充电桩调价策略分析顾问。下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)的电价分析数据:\n\n"
"### 1. 当前最新 24 小时平均分时电价 (元/kWh)\n"
f"{json.dumps(text_data, ensure_ascii=False)}\n\n"
"### 2. 最近 3 天的价格变动趋势 (每日平均电价)\n"
f"日期序列: {trend_dates}\n"
f"各司趋势: {json.dumps(trend_text, ensure_ascii=False)}\n\n"
"请根据以上数据进行深度分析:\n"
"1. **现状对比**:对比我司(驿来特)与竞对在不同时段的电价水平,找出我司偏高或偏低的关键时段。\n"
"2. **趋势洞察**:分析最近 3 天各供应商的价格调整动态,判断市场整体是在涨价、降价还是保持稳定,我司的反应是否及时。\n"
"3. **问题诊断**:指出我司目前定价中存在的潜在风险(如价格倒挂、错失高峰收益、低谷缺乏竞争力等)。\n"
"4. **优化方案**:给出 2-3 条具体的、可落地的调价建议,并说明理由。\n\n"
"要求:\n"
"- 使用专业、客观的语气。\n"
"- 采用 Markdown 格式,适当使用加粗和表格。\n"
"- 回答控制在 800-1000 字以内。"
)
# 清空之前的提示信息,开始正式输出 AI 内容
yield "---CLEAR_PREVIOUS_HINTS---\n"
chunk_count = 0
# 使用 asyncio.wait_for 防止 LLM 请求完全死掉
try:
# 某些时候 LLM 可能会卡住,设置一个合理的整体超时
async for chunk in get_llm_response(
prompt,
stream=True,
system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
):
chunk_count += 1
if chunk_count == 1:
print("收到LLM首个chunk")
yield chunk
except Exception as llm_e:
print(f"LLM请求异常: {str(llm_e)}")
yield f"\n\n**AI 分析服务异常**: {str(llm_e)}。这可能是由于大模型服务商(如 DeepSeek响应过慢或连接中断导致的。"
return
print(f"AI分析完成共发送 {chunk_count} 个chunks")
except Exception as e:
error_msg = f"\n\n**分析过程出现严重错误**: {str(e)}"
print(error_msg)
yield error_msg
return StreamingResponse(
generate_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"Content-Type": "text/event-stream; charset=utf-8"
}
)
@router.get("/api/ylt/stations", response_model=List[StationBase]) @router.get("/api/ylt/stations", response_model=List[StationBase])

View File

@@ -45,7 +45,7 @@ async def get_llm_response(query_text: str, stream: bool = True, system_prompt:
request_params['temperature'] = temperature request_params['temperature'] = temperature
# 创建请求 # 创建请求
completion = await client.chat.completions.create(**request_params) completion = await asyncio.wait_for(client.chat.completions.create(**request_params), timeout=60.0)
if stream: if stream:
# 流式输出模式,返回生成器 # 流式输出模式,返回生成器

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分时电价分析 - 驿来特AI智能大脑</title> <title>分时电价分析 - 驿来特AI智能大脑</title>
<link rel="stylesheet" href="css/dashboard.css"> <link rel="stylesheet" href="css/dashboard.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.1/github-markdown-dark.min.css">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@@ -82,7 +83,7 @@
<span>🤖 AI 调价策略建议</span> <span>🤖 AI 调价策略建议</span>
</div> </div>
<div class="ai-box" ref="aiBoxRef"> <div class="ai-box" ref="aiBoxRef">
<div v-if="!aiText && !aiLoading" style="text-align: center; color: #64748b; margin-top: 40px;"> <div v-if="!aiText" style="text-align: center; color: #64748b; margin-top: 40px;">
<span class="loading-dots">AI 正在深度分析中</span> <span class="loading-dots">AI 正在深度分析中</span>
</div> </div>
<div v-else class="markdown-body" v-html="renderedAiText"></div> <div v-else class="markdown-body" v-html="renderedAiText"></div>
@@ -95,9 +96,9 @@
<script src="js/axios.min.js"></script> <script src="js/axios.min.js"></script>
<script src="js/echarts.min.js"></script> <script src="js/echarts.min.js"></script>
<!-- Markdown & LaTeX Support --> <!-- Markdown & LaTeX Support -->
<link rel="stylesheet" href="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"> <link rel="stylesheet" href="https://unpkg.com/katex@0.16.9/dist/katex.min.css">
<script src="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> <script src="https://unpkg.com/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://gcore.jsdelivr.net/npm/marked@12.0.0/lib/marked.umd.js"></script> <script src="https://unpkg.com/marked@12.0.0/marked.min.js"></script>
<script src="js/dashboard.js"></script> <script src="js/dashboard.js"></script>
</body> </body>
</html> </html>

View File

@@ -283,11 +283,20 @@ createApp({
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let fullText = "";
while (true) { while (true) {
const {done, value} = await reader.read(); const {done, value} = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value, {stream: true}); const chunk = decoder.decode(value, {stream: true});
aiText.value += chunk; fullText += chunk;
if (fullText.includes("---CLEAR_PREVIOUS_HINTS---")) {
const parts = fullText.split("---CLEAR_PREVIOUS_HINTS---");
aiText.value = parts[parts.length - 1];
} else {
aiText.value = fullText;
}
await Vue.nextTick(); await Vue.nextTick();
if (aiBoxRef.value) { if (aiBoxRef.value) {
aiBoxRef.value.scrollTop = aiBoxRef.value.scrollHeight; aiBoxRef.value.scrollTop = aiBoxRef.value.scrollHeight;
@@ -325,7 +334,7 @@ createApp({
}); });
} }
// 降级用的简易 Markdown 解析器 // 降级用的简易 Markdown 解析器 (增强版)
const simpleMarkdown = (text) => { const simpleMarkdown = (text) => {
if (!text) return ''; if (!text) return '';
let lines = text.split('\n'); let lines = text.split('\n');
@@ -334,6 +343,7 @@ createApp({
const parseInline = (str) => { const parseInline = (str) => {
return str return str
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code style="background:rgba(148, 163, 184, 0.1); padding:2px 4px; border-radius:4px;">$1</code>'); .replace(/`(.*?)`/g, '<code style="background:rgba(148, 163, 184, 0.1); padding:2px 4px; border-radius:4px;">$1</code>');
}; };
for (let line of lines) { for (let line of lines) {
@@ -343,13 +353,28 @@ createApp({
html += '<br>'; html += '<br>';
continue; continue;
} }
if (trimmed.startsWith('### ')) { if (trimmed.startsWith('# ')) {
if (inList) { html += '</ul>'; inList = false; }
html += `<h1>${parseInline(trimmed.substring(2))}</h1>`;
} else if (trimmed.startsWith('## ')) {
if (inList) { html += '</ul>'; inList = false; }
html += `<h2>${parseInline(trimmed.substring(3))}</h2>`;
} else if (trimmed.startsWith('### ')) {
if (inList) { html += '</ul>'; inList = false; } if (inList) { html += '</ul>'; inList = false; }
html += `<h3>${parseInline(trimmed.substring(4))}</h3>`; html += `<h3>${parseInline(trimmed.substring(4))}</h3>`;
} else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) { } else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
if (!inList) { html += '<ul>'; inList = true; } if (!inList) { html += '<ul>'; inList = true; }
let content = trimmed.replace(/^(- |\d+\. )/, ''); let content = trimmed.replace(/^(- |\d+\. )/, '');
html += `<li>${parseInline(content)}</li>`; html += `<li>${parseInline(content)}</li>`;
} else if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
// 简单的表格识别 (仅作示意,复杂表格仍需 marked)
if (inList) { html += '</ul>'; inList = false; }
let cells = trimmed.split('|').filter(c => c.trim() || c === '');
html += '<div style="display:flex; border-bottom:1px solid #334155; padding:4px;">';
cells.forEach(c => {
html += `<div style="flex:1; padding:4px;">${parseInline(c.trim())}</div>`;
});
html += '</div>';
} else { } else {
if (inList) { html += '</ul>'; inList = false; } if (inList) { html += '</ul>'; inList = false; }
html += `<p>${parseInline(trimmed)}</p>`; html += `<p>${parseInline(trimmed)}</p>`;
@@ -363,44 +388,45 @@ createApp({
const renderMarkdownAndLatex = (text) => { const renderMarkdownAndLatex = (text) => {
if (!text) return ''; if (!text) return '';
// 如果包含提示信息且还没被清除,先简单处理显示
if (text.includes('正在收集各供应商价格数据') || text.includes('正在进行 AI 深度分析')) {
return `<div style="color: #64748b; font-style: italic;">${text.replace(/\n/g, '<br>')}</div>`;
}
try { try {
// 1. 处理 LaTeX (简单替换,先处理 $$ 再处理 $)
let processedText = text; let processedText = text;
// 处理块级 LaTeX: $$ ... $$ // 1. 处理 LaTeX (如果 katex 加载成功)
processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => { if (typeof katex !== 'undefined') {
try { // 处理块级 LaTeX: $$ ... $$
if (typeof katex !== 'undefined') { processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => {
try {
return '<div class="katex-block">' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '</div>'; return '<div class="katex-block">' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '</div>';
} } catch (e) { return match; }
return match; });
} catch (e) {
return match; // 处理行内 LaTeX: $ ... $
} processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
}); try {
// 处理行内 LaTeX: $ ... $
processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return katex.renderToString(formula, { displayMode: false, throwOnError: false }); return katex.renderToString(formula, { displayMode: false, throwOnError: false });
} } catch (e) { return match; }
return match; });
} catch (e) {
return match;
}
});
// 2. 使用 marked 解析 Markdown
if (typeof marked !== 'undefined') {
return marked.parse(processedText);
} else {
// 降级使用之前的 simpleMarkdown
return simpleMarkdown(processedText);
} }
// 2. 使用 marked 解析 Markdown (优先使用)
if (typeof marked !== 'undefined') {
// 兼容不同版本的 marked (UMD 版本中 marked 可能是函数或对象)
const parseFn = (typeof marked.parse === 'function') ? marked.parse.bind(marked) : (typeof marked === 'function' ? marked : null);
if (parseFn) {
return parseFn(processedText);
}
}
// 3. 降级使用改进后的 simpleMarkdown
return simpleMarkdown(processedText);
} catch (e) { } catch (e) {
console.error('Markdown/LaTeX rendering error:', e); console.error('Markdown rendering error:', e);
return text; return simpleMarkdown(text);
} }
}; };