diff --git a/Controller/YltAnalyticsController.py b/Controller/YltAnalyticsController.py index ff15a88..7b4c1f2 100644 --- a/Controller/YltAnalyticsController.py +++ b/Controller/YltAnalyticsController.py @@ -214,29 +214,97 @@ async def export_ai_report_docx(req: AiReportRequest): @router.get("/api/ai/pricing/strategy-summary") async def ai_pricing_strategy_summary(): async def generate_stream(): - resp = await get_operators_hourly_prices() - data = resp.get("operators", []) - text_data = [] - for item in data: - text_data.append({"operator": item.get("operator"), "series": item.get("series")}) - - prompt = ( - "下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)基于最新爬取数据计算出的平均24小时分时电价:\n" - f"{text_data}\n" - "请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平," - "指出我司在不同时段可能存在的潜在问题和风险(例如明显偏贵、价格结构不合理等),并给出2-3条可执行的优化建议。" - "请使用Markdown格式输出,重点可以使用加粗,如有公式可使用LaTeX格式。" - "回答控制在800字以内。" - ) - - async for chunk in get_llm_response( - prompt, - stream=True, - system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。", - ): - yield chunk + try: + # 发送初始信息并增加一些空白填充,防止某些代理缓存 + yield "正在收集各供应商价格数据,请稍候...\n\n" + (" " * 512) + "\n" + print("AI分析开始: 获取运营商价格数据...") + + # 使用 asyncio.wait_for 防止数据库查询无限挂起 + try: + # 1. 获取当前最新 24 小时平均价格 + resp = await asyncio.wait_for(get_operators_hourly_prices(), timeout=30.0) + + # 2. 获取最近 3 天的价格变动趋势 + trend_resp = await asyncio.wait_for(get_operators_price_trends(days=3), timeout=30.0) + except asyncio.TimeoutError: + print("获取价格数据超时") + yield "\n\n**错误**: 获取价格数据超时,数据库响应过慢,请稍后重试。" + return - 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]) diff --git a/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc b/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc index bcfba8a..e94bf76 100644 Binary files a/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc and b/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc differ diff --git a/Util/LlmUtil.py b/Util/LlmUtil.py index 2d2f6df..46bbf8a 100644 --- a/Util/LlmUtil.py +++ b/Util/LlmUtil.py @@ -45,7 +45,7 @@ async def get_llm_response(query_text: str, stream: bool = True, system_prompt: 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: # 流式输出模式,返回生成器 diff --git a/Util/__pycache__/LlmUtil.cpython-310.pyc b/Util/__pycache__/LlmUtil.cpython-310.pyc index 69b5a82..36a4f51 100644 Binary files a/Util/__pycache__/LlmUtil.cpython-310.pyc and b/Util/__pycache__/LlmUtil.cpython-310.pyc differ diff --git a/static/dashboard.html b/static/dashboard.html index 673f2c0..1d118fa 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -5,6 +5,7 @@ 分时电价分析 - 驿来特AI智能大脑 +
@@ -82,7 +83,7 @@ 🤖 AI 调价策略建议
-
+
AI 正在深度分析中
@@ -95,9 +96,9 @@ - - - + + + diff --git a/static/js/dashboard.js b/static/js/dashboard.js index cf112d0..329ccd6 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -283,11 +283,20 @@ createApp({ const reader = response.body.getReader(); const decoder = new TextDecoder(); + let fullText = ""; while (true) { const {done, value} = await reader.read(); if (done) break; 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(); if (aiBoxRef.value) { aiBoxRef.value.scrollTop = aiBoxRef.value.scrollHeight; @@ -325,7 +334,7 @@ createApp({ }); } - // 降级用的简易 Markdown 解析器 + // 降级用的简易 Markdown 解析器 (增强版) const simpleMarkdown = (text) => { if (!text) return ''; let lines = text.split('\n'); @@ -334,6 +343,7 @@ createApp({ const parseInline = (str) => { return str .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') .replace(/`(.*?)`/g, '$1'); }; for (let line of lines) { @@ -343,13 +353,28 @@ createApp({ html += '
'; continue; } - if (trimmed.startsWith('### ')) { + if (trimmed.startsWith('# ')) { + if (inList) { html += ''; inList = false; } + html += `

${parseInline(trimmed.substring(2))}

`; + } else if (trimmed.startsWith('## ')) { + if (inList) { html += ''; inList = false; } + html += `

${parseInline(trimmed.substring(3))}

`; + } else if (trimmed.startsWith('### ')) { if (inList) { html += ''; inList = false; } html += `

${parseInline(trimmed.substring(4))}

`; } else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) { if (!inList) { html += ''; inList = false; } + let cells = trimmed.split('|').filter(c => c.trim() || c === ''); + html += '
'; + cells.forEach(c => { + html += `
${parseInline(c.trim())}
`; + }); + html += '
'; } else { if (inList) { html += ''; inList = false; } html += `

${parseInline(trimmed)}

`; @@ -363,44 +388,45 @@ createApp({ const renderMarkdownAndLatex = (text) => { if (!text) return ''; + // 如果包含提示信息且还没被清除,先简单处理显示 + if (text.includes('正在收集各供应商价格数据') || text.includes('正在进行 AI 深度分析')) { + return `
${text.replace(/\n/g, '
')}
`; + } + try { - // 1. 处理 LaTeX (简单替换,先处理 $$ 再处理 $) let processedText = text; - // 处理块级 LaTeX: $$ ... $$ - processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => { - try { - if (typeof katex !== 'undefined') { + // 1. 处理 LaTeX (如果 katex 加载成功) + if (typeof katex !== 'undefined') { + // 处理块级 LaTeX: $$ ... $$ + processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => { + try { return '
' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '
'; - } - return match; - } catch (e) { - return match; - } - }); - - // 处理行内 LaTeX: $ ... $ - processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => { - try { - if (typeof katex !== 'undefined') { + } catch (e) { return match; } + }); + + // 处理行内 LaTeX: $ ... $ + processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => { + try { return katex.renderToString(formula, { displayMode: false, throwOnError: false }); - } - return match; - } catch (e) { - return match; - } - }); - - // 2. 使用 marked 解析 Markdown - if (typeof marked !== 'undefined') { - return marked.parse(processedText); - } else { - // 降级使用之前的 simpleMarkdown - return simpleMarkdown(processedText); + } catch (e) { return match; } + }); } + + // 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) { - console.error('Markdown/LaTeX rendering error:', e); - return text; + console.error('Markdown rendering error:', e); + return simpleMarkdown(text); } };