'commit'
This commit is contained in:
@@ -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])
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
# 流式输出模式,返回生成器
|
||||
|
||||
Binary file not shown.
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>分时电价分析 - 驿来特AI智能大脑</title>
|
||||
<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>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -82,7 +83,7 @@
|
||||
<span>🤖 AI 调价策略建议</span>
|
||||
</div>
|
||||
<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>
|
||||
</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/echarts.min.js"></script>
|
||||
<!-- Markdown & LaTeX Support -->
|
||||
<link rel="stylesheet" href="https://gcore.jsdelivr.net/npm/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://gcore.jsdelivr.net/npm/marked@12.0.0/lib/marked.umd.js"></script>
|
||||
<link rel="stylesheet" href="https://unpkg.com/katex@0.16.9/dist/katex.min.css">
|
||||
<script src="https://unpkg.com/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<script src="https://unpkg.com/marked@12.0.0/marked.min.js"></script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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, '<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>');
|
||||
};
|
||||
for (let line of lines) {
|
||||
@@ -343,13 +353,28 @@ createApp({
|
||||
html += '<br>';
|
||||
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; }
|
||||
html += `<h3>${parseInline(trimmed.substring(4))}</h3>`;
|
||||
} else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
|
||||
if (!inList) { html += '<ul>'; inList = true; }
|
||||
let content = trimmed.replace(/^(- |\d+\. )/, '');
|
||||
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 {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
html += `<p>${parseInline(trimmed)}</p>`;
|
||||
@@ -363,44 +388,45 @@ createApp({
|
||||
const renderMarkdownAndLatex = (text) => {
|
||||
if (!text) return '';
|
||||
|
||||
// 如果包含提示信息且还没被清除,先简单处理显示
|
||||
if (text.includes('正在收集各供应商价格数据') || text.includes('正在进行 AI 深度分析')) {
|
||||
return `<div style="color: #64748b; font-style: italic;">${text.replace(/\n/g, '<br>')}</div>`;
|
||||
}
|
||||
|
||||
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 '<div class="katex-block">' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '</div>';
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user