'commit'
This commit is contained in:
@@ -57,6 +57,11 @@ LLM_NON_STATION_NAME_KEYWORDS = [
|
||||
"收费停车",
|
||||
"限时免费停车",
|
||||
"停车费",
|
||||
"免费停车",
|
||||
"免停车费",
|
||||
"停车优惠",
|
||||
"停车三小时",
|
||||
"免费停车三小时",
|
||||
"END",
|
||||
"End",
|
||||
"end",
|
||||
@@ -192,6 +197,7 @@ async def run_ocr_rect(image_path, log_path=None):
|
||||
"- 该距离文本所在行的左侧、且在同一卡片中的那一行文字,就是对应的场站标题 station_name;\n"
|
||||
"- 忙闲信息通常出现在卡片右侧的彩色小块中,例如 \"快 3/4\"、\"慢 0/0\"、\"超 1/4\";\n"
|
||||
"- 即使 station_name 中不包含“充电站”“超快充”等字样,但只要与某个 \"x.xkm\" 行在同一卡片区域内,也应视为一个完整的场站名称。\n"
|
||||
"- 包含“免费停车”“免停车费”“停车优惠”等停车收费文案的文本不允许作为 station_name,如果模型误把这类文本当作场站名称,应当直接忽略该卡片或把这些文本视为停车信息而不是名称。\n"
|
||||
"要求:\n"
|
||||
"- 场站按从上到下排序;\n"
|
||||
"- station_name 不能取距离行本身(如 \"1.4km\"),而是要取与之成一对的标题行;\n"
|
||||
|
||||
Binary file not shown.
@@ -1,16 +1,16 @@
|
||||
# 黄海在公司内网开发时的配置信息
|
||||
# DORIS_HOST = "10.10.14.204"
|
||||
# DORIS_PORT = 9030
|
||||
# DORIS_FENODES = "10.10.14.204:8030"
|
||||
# REDIS_HOST = '10.10.14.14'
|
||||
# REDIS_PASSWORD = None # 如果没有密码则设为 None
|
||||
DORIS_HOST = "10.10.14.204"
|
||||
DORIS_PORT = 9030
|
||||
DORIS_FENODES = "10.10.14.204:8030"
|
||||
REDIS_HOST = '10.10.14.14'
|
||||
REDIS_PASSWORD = None # 如果没有密码则设为 None
|
||||
|
||||
# 黄海在家开发时的配置信息
|
||||
DORIS_HOST = "www.hzkjai.com"
|
||||
DORIS_PORT = 27025
|
||||
DORIS_FENODES = "www.hzkjai.com:27024"
|
||||
REDIS_HOST = '127.0.0.1'
|
||||
REDIS_PASSWORD = "DsideaL147258369"
|
||||
# DORIS_HOST = "www.hzkjai.com"
|
||||
# DORIS_PORT = 27025
|
||||
# DORIS_FENODES = "www.hzkjai.com:27024"
|
||||
# REDIS_HOST = '127.0.0.1'
|
||||
# REDIS_PASSWORD = "DsideaL147258369"
|
||||
|
||||
# 视觉模型配置
|
||||
VL_MODEL_NAME = "qwen3-vl-flash"
|
||||
|
||||
Binary file not shown.
@@ -4,11 +4,15 @@ import json
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
|
||||
|
||||
from DbKit.Db import Db
|
||||
from Config.Config import DB_URL
|
||||
from Util.LlmUtil import get_llm_response
|
||||
from Tools.T6_Export import export_excel, DorisExcelExporter, extract_hourly_prices_from_schedule
|
||||
import tempfile
|
||||
import os
|
||||
import zipfile
|
||||
from Model.YltAnalyticsModel import (
|
||||
StationBase,
|
||||
CompetitorStation,
|
||||
@@ -33,6 +37,89 @@ async def close_db():
|
||||
await db.close()
|
||||
|
||||
|
||||
@router.get("/api/operators/hourly-prices")
|
||||
async def get_operators_hourly_prices():
|
||||
operators = ["新电途", "特来电", "驿来特", "艾特吉易充"]
|
||||
exporter = DorisExcelExporter(db_url=DB_URL)
|
||||
await exporter.init()
|
||||
try:
|
||||
result = []
|
||||
for op in operators:
|
||||
rows = await exporter.fetch_current_station_rows(op)
|
||||
if not rows:
|
||||
result.append({"operator": op, "series": [None] * 24})
|
||||
continue
|
||||
sums = [0.0] * 24
|
||||
counts = [0] * 24
|
||||
for row in rows:
|
||||
schedule_json = row.get("schedule_json")
|
||||
series = extract_hourly_prices_from_schedule(schedule_json)
|
||||
for i in range(24):
|
||||
v = series[i]
|
||||
if v is None:
|
||||
continue
|
||||
sums[i] += float(v)
|
||||
counts[i] += 1
|
||||
avg_series = []
|
||||
for i in range(24):
|
||||
c = counts[i]
|
||||
if c > 0:
|
||||
avg_series.append(sums[i] / c)
|
||||
else:
|
||||
avg_series.append(None)
|
||||
result.append({"operator": op, "series": avg_series})
|
||||
finally:
|
||||
await exporter.close()
|
||||
return {"operators": result}
|
||||
|
||||
|
||||
@router.get("/api/export/prices-zip")
|
||||
async def export_prices_zip():
|
||||
operators = ["新电途", "特来电", "驿来特", "艾特吉易充"]
|
||||
tmp_dir = tempfile.mkdtemp(prefix="price_export_")
|
||||
excel_paths = []
|
||||
for op in operators:
|
||||
filename = f"{op}_{asyncio.get_event_loop().time():.0f}.xlsx"
|
||||
output_path = os.path.join(tmp_dir, filename)
|
||||
await export_excel(op, output_path)
|
||||
excel_paths.append(output_path)
|
||||
zip_path = os.path.join(tmp_dir, "prices_export.zip")
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in excel_paths:
|
||||
arcname = os.path.basename(p)
|
||||
zf.write(p, arcname=arcname)
|
||||
return FileResponse(
|
||||
zip_path,
|
||||
media_type="application/zip",
|
||||
filename="多供应商电价导出.zip",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/ai/pricing/strategy-summary")
|
||||
async def ai_pricing_strategy_summary():
|
||||
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")})
|
||||
text = (
|
||||
"下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)基于最新爬取数据计算出的平均24小时分时电价:\n"
|
||||
f"{text_data}\n"
|
||||
"请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平,"
|
||||
"指出我司在不同时段可能存在的潜在问题和风险(例如明显偏贵、价格结构不合理等),并给出2-3条可执行的优化建议。"
|
||||
"回答控制在600字以内。"
|
||||
)
|
||||
chunks: List[str] = []
|
||||
async for chunk in get_llm_response(
|
||||
text,
|
||||
stream=False,
|
||||
system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
|
||||
):
|
||||
chunks.append(chunk)
|
||||
summary_text = "".join(chunks)
|
||||
return JSONResponse({"summary": summary_text})
|
||||
|
||||
|
||||
@router.get("/api/ylt/stations", response_model=List[StationBase])
|
||||
async def list_ylt_stations(q: Optional[str] = None):
|
||||
base_sql = """
|
||||
|
||||
Binary file not shown.
@@ -217,6 +217,31 @@ class DorisExcelExporter:
|
||||
return await self.db.find(sql, {"operator": operator})
|
||||
|
||||
|
||||
def extract_hourly_prices_from_schedule(schedule_json: Any) -> List[Optional[float]]:
|
||||
items = _parse_schedule(schedule_json)
|
||||
series: List[Optional[float]] = [None] * 24
|
||||
if not items:
|
||||
return series
|
||||
for idx, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
price = _to_float(item.get("price_kwh") if "price_kwh" in item else item.get("price"))
|
||||
if price is None:
|
||||
continue
|
||||
start = item.get("start") or item.get("begin") or item.get("start_time")
|
||||
h = None
|
||||
if isinstance(start, str) and ":" in start:
|
||||
try:
|
||||
h = int(start.split(":", 1)[0])
|
||||
except Exception:
|
||||
h = None
|
||||
if h is None:
|
||||
h = idx
|
||||
if 0 <= h < 24:
|
||||
series[h] = float(price)
|
||||
return series
|
||||
|
||||
|
||||
def _build_station_sheet(ws, row: Dict[str, Any]):
|
||||
station_name = row.get("station_name") or ""
|
||||
address = row.get("address") or ""
|
||||
|
||||
BIN
Tools/__pycache__/T6_Export.cpython-310.pyc
Normal file
BIN
Tools/__pycache__/T6_Export.cpython-310.pyc
Normal file
Binary file not shown.
@@ -2,64 +2,56 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>驿来特价格分析大屏</title>
|
||||
<title>多供应商分时电价分析大屏</title>
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="left-panel">
|
||||
<div class="controls">
|
||||
<span class="label">搜索驿来特场站</span>
|
||||
<input v-model="searchKeyword" placeholder="输入名称或地址关键字" style="min-width:220px;">
|
||||
<button @click="loadStations">查询场站</button>
|
||||
<span class="label">选择供应商</span>
|
||||
<select v-model="selectedOperator" @change="loadOperatorPrices">
|
||||
<option v-for="op in operators" :key="op.value" :value="op.value">{{ op.label }}</option>
|
||||
</select>
|
||||
<button @click="loadAllOperatorsPrices" :disabled="loading">查询四家最新24小时电价</button>
|
||||
<div class="controls-spacer"></div>
|
||||
<span class="label">当前场站:{{ selectedStation ? selectedStation.station_name : "未选择" }}</span>
|
||||
<button @click="startAiStream" :disabled="!selectedStation || aiLoading">AI价差分析</button>
|
||||
<button @click="exportAllPrices" :disabled="exporting">一键导出各供应商电价</button>
|
||||
</div>
|
||||
<div class="station-list">
|
||||
<div class="station-list-header">驿来特场站列表</div>
|
||||
<div class="station-list-header">四家供应商24小时平均电价对比</div>
|
||||
<table class="station-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:32%;">场站名称</th>
|
||||
<th style="width:48%;">地址</th>
|
||||
<th style="width:10%;">当前价</th>
|
||||
<th style="width:10%;">操作</th>
|
||||
<th style="width:20%;">小时</th>
|
||||
<th v-for="op in operators" :key="op.value" style="width:20%;">{{ op.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in stations" :key="s.station_hash" class="station-row" :class="{active:selectedStation && selectedStation.station_hash===s.station_hash}" @click="selectStation(s)">
|
||||
<td>{{ s.station_name }}</td>
|
||||
<td>{{ s.address }}</td>
|
||||
<td>{{ formatCell(s.current_price) }}</td>
|
||||
<td><button @click.stop="selectStation(s)" :disabled="loading">查看价差</button></td>
|
||||
<tr v-for="(row, idx) in priceTableRows" :key="idx">
|
||||
<td>{{ row.hour }}</td>
|
||||
<td v-for="cell in row.values" :key="cell.operator">{{ formatCell(cell.price) }}</td>
|
||||
</tr>
|
||||
<tr v-if="stations.length===0">
|
||||
<td colspan="4">暂无数据,请先查询场站</td>
|
||||
<tr v-if="priceTableRows.length===0">
|
||||
<td :colspan="operators.length + 1">暂无数据,请先查询</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="chart"></div>
|
||||
<div class="price-table" v-if="priceTableColumns.length">
|
||||
<div class="price-table-title">24 小时分时电价表(元/度)</div>
|
||||
<table class="price-table-inner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="col in priceTableColumns" :key="col">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row,idx) in priceTableRows" :key="idx">
|
||||
<td v-for="(cell,j) in row" :key="j">{{ formatCell(cell) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<div class="ai-title">AI 辅助分析</div>
|
||||
<div class="ai-box">{{ aiText || placeholder }}</div>
|
||||
<div class="ai-box">
|
||||
<div class="ai-question">
|
||||
<div class="label">默认问题</div>
|
||||
<div class="question-text">
|
||||
请根据爬取的各供应商分时电价等信息,对各司的定价策略,
|
||||
与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
|
||||
</div>
|
||||
</div>
|
||||
<button @click="startAiAnalysis" :disabled="aiLoading">发起AI综合分析</button>
|
||||
<div class="ai-result">{{ aiText || placeholder }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="js/vue.global.js"></script>
|
||||
|
||||
160
static/js/app.js
160
static/js/app.js
@@ -1,128 +1,127 @@
|
||||
const {createApp,onMounted,ref} = Vue
|
||||
|
||||
createApp({
|
||||
setup(){
|
||||
const apiBase = ref(window.location.origin || "http://localhost:8000")
|
||||
const stationHash = ref("")
|
||||
const operators = ref([
|
||||
{label:"新电途",value:"新电途"},
|
||||
{label:"特来电",value:"特来电"},
|
||||
{label:"驿来特",value:"驿来特"},
|
||||
{label:"艾特吉易充",value:"艾特吉易充"}
|
||||
])
|
||||
const selectedOperator = ref("驿来特")
|
||||
const aiText = ref("")
|
||||
const placeholder = ref("在左侧列表中选择驿来特场站,点击“查看价差”加载24小时分时电价与竞品对比,再点击“AI价差分析”获取模型给出的策略建议。")
|
||||
const placeholder = ref("点击左侧按钮查询四家供应商最新24小时分时电价,再使用右侧AI综合分析功能对各司定价策略进行对比。")
|
||||
const loading = ref(false)
|
||||
const aiLoading = ref(false)
|
||||
const stations = ref([])
|
||||
const searchKeyword = ref("")
|
||||
const selectedStation = ref(null)
|
||||
const priceTableColumns = ref([])
|
||||
const priceTableRows = ref([])
|
||||
const hourlyPricesByOperator = ref({})
|
||||
let chartInstance = null
|
||||
let es = null
|
||||
|
||||
const initChart = () => {
|
||||
const dom = document.getElementById("chart")
|
||||
if (dom && !chartInstance) {
|
||||
chartInstance = echarts.init(dom)
|
||||
}
|
||||
}
|
||||
const loadStations = async () => {
|
||||
try{
|
||||
const url = apiBase.value + "/api/ylt/stations"
|
||||
const params = searchKeyword.value ? {params:{q:searchKeyword.value}} : {}
|
||||
const res = await axios.get(url,params)
|
||||
stations.value = Array.isArray(res.data) ? res.data : []
|
||||
if (!selectedStation.value && stations.value.length > 0) {
|
||||
selectStation(stations.value[0])
|
||||
}
|
||||
}catch(e){
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
const buildPriceTable = data => {
|
||||
const cols = []
|
||||
cols.push("时段")
|
||||
cols.push("驿来特")
|
||||
data.competitors.forEach(c => {
|
||||
cols.push(c.operator)
|
||||
})
|
||||
priceTableColumns.value = cols
|
||||
|
||||
const buildPriceTable = () => {
|
||||
const rows = []
|
||||
for (let i = 0; i < data.hours.length; i++) {
|
||||
const row = []
|
||||
row.push(data.hours[i] + ":00")
|
||||
row.push(data.ylt.series[i])
|
||||
data.competitors.forEach(c => {
|
||||
row.push(c.series[i])
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const row = {hour: (h.toString().padStart(2,"0") + ":00"), values: []}
|
||||
operators.value.forEach(op => {
|
||||
const series = hourlyPricesByOperator.value[op.value] || []
|
||||
const price = series[h] !== undefined ? series[h] : null
|
||||
row.values.push({operator: op.value, price})
|
||||
})
|
||||
rows.push(row)
|
||||
}
|
||||
priceTableRows.value = rows
|
||||
if (chartInstance) {
|
||||
const series = operators.value.map(op => {
|
||||
const seriesData = []
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const list = hourlyPricesByOperator.value[op.value] || []
|
||||
seriesData.push(list[h] !== undefined ? list[h] : null)
|
||||
}
|
||||
const loadComparison = async () => {
|
||||
if (!stationHash.value) return
|
||||
loading.value = true
|
||||
try{
|
||||
const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value
|
||||
const res = await axios.get(url)
|
||||
const data = res.data
|
||||
const hours = data.hours.map(h => h + ":00")
|
||||
const series = []
|
||||
series.push({
|
||||
name:"驿来特",
|
||||
type:"line",
|
||||
smooth:true,
|
||||
data:data.ylt.series
|
||||
return {name: op.label, type:"line", smooth:true, data:seriesData}
|
||||
})
|
||||
data.competitors.forEach(c=>{
|
||||
series.push({
|
||||
name:c.operator,
|
||||
type:"line",
|
||||
smooth:true,
|
||||
data:c.series
|
||||
})
|
||||
})
|
||||
buildPriceTable(data)
|
||||
const hours = rows.map(r => r.hour)
|
||||
const option = {
|
||||
tooltip:{trigger:"axis"},
|
||||
legend:{data:series.map(s=>s.name),textStyle:{color:"#e5e7eb"}},
|
||||
legend:{data:operators.value.map(o=>o.label),textStyle:{color:"#e5e7eb"}},
|
||||
xAxis:{type:"category",data:hours,axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"}},
|
||||
yAxis:{type:"value",name:"元/度",axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"},splitLine:{lineStyle:{color:"#111827"}}},
|
||||
grid:{left:40,right:20,top:30,bottom:30},
|
||||
series:series
|
||||
}
|
||||
initChart()
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAllOperatorsPrices = async () => {
|
||||
loading.value = true
|
||||
try{
|
||||
const res = await axios.get(apiBase.value + "/api/operators/hourly-prices")
|
||||
if (res && res.data && Array.isArray(res.data.operators)) {
|
||||
const dict = {}
|
||||
res.data.operators.forEach(item => {
|
||||
dict[item.operator] = item.series || []
|
||||
})
|
||||
hourlyPricesByOperator.value = dict
|
||||
buildPriceTable()
|
||||
}
|
||||
}catch(e){
|
||||
console.error(e)
|
||||
}finally{
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const selectStation = s => {
|
||||
selectedStation.value = s
|
||||
stationHash.value = s.station_hash
|
||||
loadComparison()
|
||||
|
||||
const loadOperatorPrices = async () => {
|
||||
if (!selectedOperator.value) return
|
||||
await loadAllOperatorsPrices()
|
||||
}
|
||||
const startAiStream = () => {
|
||||
if (!selectedStation.value || !stationHash.value) return
|
||||
if (es) {
|
||||
es.close()
|
||||
es = null
|
||||
|
||||
const exportAllPrices = async () => {
|
||||
try{
|
||||
loading.value = true
|
||||
const res = await axios.get(apiBase.value + "/api/export/prices-zip",{responseType:"blob"})
|
||||
const blob = new Blob([res.data],{type:"application/zip"})
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "多供应商电价导出.zip"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}catch(e){
|
||||
console.error(e)
|
||||
}finally{
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startAiAnalysis = async () => {
|
||||
if (aiLoading.value) return
|
||||
aiText.value = ""
|
||||
aiLoading.value = true
|
||||
const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value + "/sse"
|
||||
es = new EventSource(url)
|
||||
es.onmessage = ev => {
|
||||
aiText.value += ev.data
|
||||
}
|
||||
es.onerror = () => {
|
||||
try{
|
||||
const res = await axios.get(apiBase.value + "/api/ai/pricing/strategy-summary")
|
||||
aiText.value = res.data && res.data.summary ? res.data.summary : ""
|
||||
}catch(e){
|
||||
console.error(e)
|
||||
}finally{
|
||||
aiLoading.value = false
|
||||
if (es) {
|
||||
es.close()
|
||||
es = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(()=>{
|
||||
initChart()
|
||||
loadStations()
|
||||
loadAllOperatorsPrices()
|
||||
})
|
||||
|
||||
const formatCell = v => {
|
||||
if (v === null || v === undefined || v === "") return "-"
|
||||
if (typeof v === "number") {
|
||||
@@ -131,6 +130,7 @@ return v.toFixed(2)
|
||||
}
|
||||
return v
|
||||
}
|
||||
return {stationHash,aiText,placeholder,loading,aiLoading,stations,searchKeyword,selectedStation,priceTableColumns,priceTableRows,loadStations,loadComparison,selectStation,startAiStream,formatCell}
|
||||
|
||||
return {apiBase,operators,selectedOperator,aiText,placeholder,loading,aiLoading,priceTableRows,loadOperatorPrices,loadAllOperatorsPrices,exportAllPrices,startAiAnalysis,formatCell}
|
||||
}
|
||||
}).mount("#app")
|
||||
|
||||
Reference in New Issue
Block a user