bid-master-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/api_keys.py +60 -0
- app/api/auth.py +258 -0
- app/api/cli_auth.py +165 -0
- app/api/database.py +286 -0
- app/api/extract.py +158 -0
- app/api/files.py +163 -0
- app/api/health.py +62 -0
- app/api/logs.py +26 -0
- app/api/settings.py +101 -0
- app/api/simulate.py +195 -0
- app/api/statistics.py +1214 -0
- app/cli.py +894 -0
- app/config.py +93 -0
- app/dependencies.py +12 -0
- app/infrastructure/__init__.py +1 -0
- app/infrastructure/database.py +126 -0
- app/infrastructure/db_schema.py +245 -0
- app/infrastructure/email_service.py +92 -0
- app/infrastructure/llm/__init__.py +1 -0
- app/infrastructure/llm/lite_llm.py +463 -0
- app/infrastructure/log_collector.py +64 -0
- app/infrastructure/mock_storage.py +563 -0
- app/infrastructure/pg_storage.py +656 -0
- app/infrastructure/storage.py +117 -0
- app/limiter.py +7 -0
- app/main.py +141 -0
- app/models/__init__.py +1 -0
- app/models/schemas.py +204 -0
- app/services/__init__.py +1 -0
- app/services/encryption_service.py +88 -0
- app/services/extract_service.py +817 -0
- app/services/file_service.py +112 -0
- app/services/llm_service.py +65 -0
- app/services/ocr_service.py +183 -0
- app/services/prompt_builder.py +257 -0
- app/services/simulate_service.py +625 -0
- app/services/statistics_service.py +123 -0
- app/utils/__init__.py +1 -0
- app/utils/auth_dep.py +42 -0
- app/utils/crypto.py +63 -0
- app/utils/exceptions.py +53 -0
- bid_master_cli-1.0.0.dist-info/METADATA +30 -0
- bid_master_cli-1.0.0.dist-info/RECORD +47 -0
- bid_master_cli-1.0.0.dist-info/WHEEL +4 -0
- bid_master_cli-1.0.0.dist-info/entry_points.txt +2 -0
app/api/statistics.py
ADDED
|
@@ -0,0 +1,1214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""
|
|
3
|
+
Opening/statistics analysis API routes with comprehensive 6-dimension analysis.
|
|
4
|
+
所有端点强制认证。
|
|
5
|
+
"""
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import asyncio
|
|
11
|
+
import traceback
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, File
|
|
16
|
+
from sse_starlette.sse import EventSourceResponse
|
|
17
|
+
|
|
18
|
+
from app.services.statistics_service import StatisticsService
|
|
19
|
+
from app.services.llm_service import LLMService
|
|
20
|
+
from app.services.prompt_builder import get_prompt_builder
|
|
21
|
+
from app.models.schemas import OpeningAnalysisRequest
|
|
22
|
+
from app.utils.auth_dep import get_current_user
|
|
23
|
+
from app.infrastructure.pg_storage import (
|
|
24
|
+
add_opening,
|
|
25
|
+
update_opening,
|
|
26
|
+
get_opening,
|
|
27
|
+
get_file,
|
|
28
|
+
calculate_content_hash,
|
|
29
|
+
find_completed_opening,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
router = APIRouter(prefix="/statistics", tags=["statistics"])
|
|
35
|
+
|
|
36
|
+
OPENING_IDLE_TIMEOUT_SECONDS = 4.0
|
|
37
|
+
OPENING_MAX_IDLE_TICKS = 8
|
|
38
|
+
OPENING_MAX_IDLE_TICKS_LARGE = 20
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _opening_idle_ticks(analysis_data: dict) -> int:
|
|
42
|
+
bidder_count = analysis_data.get("bidder_count", 0) or len(analysis_data.get("bid_ranking") or [])
|
|
43
|
+
if bidder_count >= 30:
|
|
44
|
+
return OPENING_MAX_IDLE_TICKS_LARGE
|
|
45
|
+
if bidder_count >= 10:
|
|
46
|
+
return 14
|
|
47
|
+
return OPENING_MAX_IDLE_TICKS
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _extract_number(text: str):
|
|
51
|
+
"""从文本中提取第一个数字。"""
|
|
52
|
+
import re
|
|
53
|
+
m = re.search(r"(\d+\.?\d*)", str(text).strip())
|
|
54
|
+
return float(m.group(1)) if m else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _extract_final_price(text: str):
|
|
58
|
+
"""从备注中提取最终报价。"""
|
|
59
|
+
import re
|
|
60
|
+
patterns = [
|
|
61
|
+
r"最终报价[::]*\s*(\d+\.?\d*)\s*万元",
|
|
62
|
+
r"最终报价[::]*\s*(\d+\.?\d*)",
|
|
63
|
+
r"二次报价[::]*\s*(\d+\.?\d*)\s*万元",
|
|
64
|
+
]
|
|
65
|
+
for pat in patterns:
|
|
66
|
+
m = re.search(pat, str(text))
|
|
67
|
+
if m:
|
|
68
|
+
return float(m.group(1))
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_opening_excel(content: bytes, filename: str) -> dict:
|
|
73
|
+
"""
|
|
74
|
+
解析开标一览表 Excel/CSV 文件。
|
|
75
|
+
|
|
76
|
+
使用 header=None 将全部行作为数据处理,手动查找表头行,
|
|
77
|
+
兼容表头行不在第一行的招标表格格式。
|
|
78
|
+
"""
|
|
79
|
+
import re
|
|
80
|
+
|
|
81
|
+
if filename.endswith(".csv"):
|
|
82
|
+
df = pd.read_csv(io.BytesIO(content), header=None, encoding="utf-8")
|
|
83
|
+
elif filename.endswith((".xlsx", ".xls")):
|
|
84
|
+
df = pd.read_excel(io.BytesIO(content), header=None)
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError("不支持的文件格式,请使用 Excel 或 CSV")
|
|
87
|
+
|
|
88
|
+
# 全部转为字符串
|
|
89
|
+
df = df.fillna("").astype(str)
|
|
90
|
+
|
|
91
|
+
# =========================================================================
|
|
92
|
+
# 1. 提取项目元数据
|
|
93
|
+
# =========================================================================
|
|
94
|
+
meta = {
|
|
95
|
+
"project_name": "",
|
|
96
|
+
"bid_number": "",
|
|
97
|
+
"opening_time": "",
|
|
98
|
+
"opening_location": "",
|
|
99
|
+
"max_price": None,
|
|
100
|
+
"benchmark_price": None,
|
|
101
|
+
"d_value": None,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# 搜索前 5 行找项目名称和招标编号
|
|
105
|
+
for idx in range(min(5, len(df))):
|
|
106
|
+
for cell in df.iloc[idx]:
|
|
107
|
+
cell = str(cell).strip()
|
|
108
|
+
if not cell:
|
|
109
|
+
continue
|
|
110
|
+
m = re.match(r"项目名称[::]\s*(.+)", cell)
|
|
111
|
+
if m:
|
|
112
|
+
meta["project_name"] = m.group(1).strip()
|
|
113
|
+
elif not meta["project_name"] and idx == 0 and not any(
|
|
114
|
+
kw in cell for kw in ["序号", "投标", "报价", "标段", "开标", "评标"]
|
|
115
|
+
):
|
|
116
|
+
meta["project_name"] = cell
|
|
117
|
+
|
|
118
|
+
m2 = re.search(r"(?:招标|项目)编号[::]\s*(\S+)", cell)
|
|
119
|
+
if m2:
|
|
120
|
+
meta["bid_number"] = m2.group(1).strip()
|
|
121
|
+
|
|
122
|
+
# 搜索最后 10 行找限价/基准价/D值
|
|
123
|
+
for idx in range(max(0, len(df) - 10), len(df)):
|
|
124
|
+
row = df.iloc[idx]
|
|
125
|
+
for col_idx, cell in enumerate(row):
|
|
126
|
+
cell = str(cell).strip()
|
|
127
|
+
if "最高投标限价" in cell or "最高限价" in cell:
|
|
128
|
+
for j in range(col_idx + 1, len(row)):
|
|
129
|
+
val = _extract_number(str(row.iloc[j]).strip())
|
|
130
|
+
if val is not None:
|
|
131
|
+
meta["max_price"] = val
|
|
132
|
+
break
|
|
133
|
+
elif "评标基准价" in cell or "基准价" in cell:
|
|
134
|
+
for j in range(col_idx + 1, len(row)):
|
|
135
|
+
val = _extract_number(str(row.iloc[j]).strip())
|
|
136
|
+
if val is not None:
|
|
137
|
+
meta["benchmark_price"] = val
|
|
138
|
+
break
|
|
139
|
+
elif cell in ("D值",) or cell.startswith("D值"):
|
|
140
|
+
for j in range(col_idx + 1, len(row)):
|
|
141
|
+
val = _extract_number(str(row.iloc[j]).strip())
|
|
142
|
+
if val is not None:
|
|
143
|
+
meta["d_value"] = val
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
# =========================================================================
|
|
147
|
+
# 2. 查找表头行和列映射
|
|
148
|
+
# =========================================================================
|
|
149
|
+
header_row = None
|
|
150
|
+
col_map = {}
|
|
151
|
+
raw_headers = []
|
|
152
|
+
column_mapping = {}
|
|
153
|
+
|
|
154
|
+
for idx, row in df.iterrows():
|
|
155
|
+
row_text = " ".join(str(c) for c in row.values)
|
|
156
|
+
has_bidder_kw = any(kw in row_text for kw in ["投标人", "投标单位", "单位名称"])
|
|
157
|
+
has_price_kw = any(kw in row_text for kw in ["报价", "投标价"])
|
|
158
|
+
if has_bidder_kw and has_price_kw:
|
|
159
|
+
header_row = idx
|
|
160
|
+
for col_idx, cell in enumerate(row):
|
|
161
|
+
cell = str(cell).strip()
|
|
162
|
+
if not cell or cell == "nan":
|
|
163
|
+
continue
|
|
164
|
+
raw_headers.append(cell)
|
|
165
|
+
if any(kw in cell for kw in ["投标人", "投标单位", "单位名称"]):
|
|
166
|
+
col_map["name"] = col_idx
|
|
167
|
+
column_mapping[cell] = "name"
|
|
168
|
+
elif any(kw in cell for kw in ["制造商", "产地", "品牌"]):
|
|
169
|
+
col_map["manufacturer"] = col_idx
|
|
170
|
+
column_mapping[cell] = "manufacturer"
|
|
171
|
+
elif any(kw in cell for kw in ["最终投标价", "最终报价", "二次报价"]) and "比较" not in cell:
|
|
172
|
+
col_map["final_price"] = col_idx
|
|
173
|
+
column_mapping[cell] = "final_price"
|
|
174
|
+
elif any(kw in cell for kw in ["投标价", "投标报价", "首次报价", "报价(元)", "报价(元)"]) and "最终" not in cell and "比较" not in cell and "方式" not in cell:
|
|
175
|
+
col_map["bid_price"] = col_idx
|
|
176
|
+
column_mapping[cell] = "bid_price"
|
|
177
|
+
elif "资信" in cell:
|
|
178
|
+
col_map["credit_score"] = col_idx
|
|
179
|
+
column_mapping[cell] = "credit_score"
|
|
180
|
+
elif "技术标" in cell or "技术分" in cell:
|
|
181
|
+
col_map["technical_score"] = col_idx
|
|
182
|
+
column_mapping[cell] = "technical_score"
|
|
183
|
+
elif "商务标" in cell or "商务分" in cell:
|
|
184
|
+
col_map["commercial_score"] = col_idx
|
|
185
|
+
column_mapping[cell] = "commercial_score"
|
|
186
|
+
elif cell == "合计" or "总得分" in cell or "综合评分" in cell:
|
|
187
|
+
col_map["total_score"] = col_idx
|
|
188
|
+
column_mapping[cell] = "total_score"
|
|
189
|
+
elif "备注" in cell:
|
|
190
|
+
col_map["remarks"] = col_idx
|
|
191
|
+
column_mapping[cell] = "remarks"
|
|
192
|
+
# Fallback: 如果没找到 bid_price,找任意包含"报价"的列
|
|
193
|
+
if "bid_price" not in col_map:
|
|
194
|
+
for col_idx, cell in enumerate(row):
|
|
195
|
+
cell = str(cell).strip()
|
|
196
|
+
if "报价" in cell and "比较" not in cell and "方式" not in cell:
|
|
197
|
+
col_map["bid_price"] = col_idx
|
|
198
|
+
if cell not in column_mapping:
|
|
199
|
+
column_mapping[cell] = "bid_price"
|
|
200
|
+
break
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
# =========================================================================
|
|
204
|
+
# 3. 提取投标人数据
|
|
205
|
+
# =========================================================================
|
|
206
|
+
bidders = []
|
|
207
|
+
|
|
208
|
+
if header_row is not None:
|
|
209
|
+
for idx in range(header_row + 1, len(df)):
|
|
210
|
+
row = df.iloc[idx]
|
|
211
|
+
name = str(row.iloc[col_map.get("name", 0)]).strip()
|
|
212
|
+
if not name or name == "nan" or name.startswith("合计") or name.startswith("小计"):
|
|
213
|
+
continue
|
|
214
|
+
# 跳过汇总行
|
|
215
|
+
row_text = " ".join(str(c) for c in row)
|
|
216
|
+
if any(kw in row_text for kw in ["最高投标限价", "评标基准价", "D值"]):
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
bid_price_str = str(row.iloc[col_map.get("bid_price", 1)]).strip()
|
|
220
|
+
bid_price = _extract_number(bid_price_str)
|
|
221
|
+
|
|
222
|
+
remarks = str(row.iloc[col_map.get("remarks", len(row) - 1)]).strip()
|
|
223
|
+
final_price = _extract_number(str(row.iloc[col_map["final_price"]]).strip()) if "final_price" in col_map else None
|
|
224
|
+
if final_price is None:
|
|
225
|
+
final_price = _extract_final_price(remarks)
|
|
226
|
+
|
|
227
|
+
bidder = {
|
|
228
|
+
"name": name,
|
|
229
|
+
"manufacturer": str(row.iloc[col_map.get("manufacturer", 1)]).strip() if "manufacturer" in col_map else "",
|
|
230
|
+
"bid_price": bid_price,
|
|
231
|
+
"final_price": final_price,
|
|
232
|
+
"credit_score": _extract_number(str(row.iloc[col_map["credit_score"]]).strip()) if "credit_score" in col_map else 0,
|
|
233
|
+
"technical_score": _extract_number(str(row.iloc[col_map["technical_score"]]).strip()) if "technical_score" in col_map else 0,
|
|
234
|
+
"commercial_score": _extract_number(str(row.iloc[col_map["commercial_score"]]).strip()) if "commercial_score" in col_map else 0,
|
|
235
|
+
"total_score": _extract_number(str(row.iloc[col_map["total_score"]]).strip()) if "total_score" in col_map else 0,
|
|
236
|
+
"remarks": remarks,
|
|
237
|
+
}
|
|
238
|
+
bidders.append(bidder)
|
|
239
|
+
|
|
240
|
+
# 转换为 records 格式(兼容旧版 compute_all_dimensions)
|
|
241
|
+
records = []
|
|
242
|
+
for b in bidders:
|
|
243
|
+
records.append({k: v for k, v in b.items()})
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
"meta": meta,
|
|
247
|
+
"bidders": bidders,
|
|
248
|
+
"records": records,
|
|
249
|
+
"columns": list(col_map.keys()) if col_map else [],
|
|
250
|
+
"raw_headers": raw_headers,
|
|
251
|
+
"column_mapping": column_mapping,
|
|
252
|
+
"row_count": len(bidders),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _opening_record_to_result(record: dict) -> dict:
|
|
257
|
+
return {
|
|
258
|
+
"bidder_count": record.get("bidder_count", 0),
|
|
259
|
+
"meta": record.get("meta") or {},
|
|
260
|
+
"bid_ranking": record.get("bid_ranking") or [],
|
|
261
|
+
"bid_stats": record.get("bid_stats") or {},
|
|
262
|
+
"requested_modules": [],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
ALL_MODULES = {"bid_ranking", "final_ranking", "discount", "statistics", "scores", "benchmark"}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def compute_all_dimensions(bidders: list, meta: dict, modules: list[str] = None) -> dict:
|
|
270
|
+
"""
|
|
271
|
+
Compute all 6 analysis dimensions from bidder data.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
bidders: List of bidder dicts
|
|
275
|
+
meta: Metadata dict
|
|
276
|
+
modules: Optional list of module keys to compute (None = all)
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Dict with all dimension results
|
|
280
|
+
"""
|
|
281
|
+
# 过滤掉前端传入的非后端模块(如 "comprehensive")
|
|
282
|
+
if modules:
|
|
283
|
+
active_modules = [m for m in modules if m in ALL_MODULES]
|
|
284
|
+
if not active_modules:
|
|
285
|
+
active_modules = list(ALL_MODULES)
|
|
286
|
+
else:
|
|
287
|
+
active_modules = list(ALL_MODULES)
|
|
288
|
+
|
|
289
|
+
result = {
|
|
290
|
+
"bidder_count": len(bidders),
|
|
291
|
+
"meta": meta,
|
|
292
|
+
"requested_modules": active_modules,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
_compute_dimensions(bidders, meta, active_modules, result)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error("compute_all_dimensions 异常: %s", traceback.format_exc())
|
|
299
|
+
print(f"[BID_MASTER_ERROR] compute_all_dimensions 失败: {traceback.format_exc()}", file=sys.stderr)
|
|
300
|
+
# 重新抛出,让路由层的 except 捕获后返回 HTTP 500
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _compute_dimensions(bidders: list, meta: dict, active_modules: list, result: dict):
|
|
307
|
+
"""compute_all_dimensions 的实际计算逻辑,分离以便于错误追踪。"""
|
|
308
|
+
bid_prices = [b["bid_price"] for b in bidders if b.get("bid_price") is not None and b["bid_price"] > 0]
|
|
309
|
+
|
|
310
|
+
# Module A: 投标价排名
|
|
311
|
+
if "bid_ranking" in active_modules and bid_prices:
|
|
312
|
+
sorted_bidders = sorted(
|
|
313
|
+
[b for b in bidders if b.get("bid_price") is not None and b["bid_price"] > 0],
|
|
314
|
+
key=lambda b: b["bid_price"],
|
|
315
|
+
)
|
|
316
|
+
avg_price = sum(bid_prices) / len(bid_prices)
|
|
317
|
+
min_price = min(bid_prices)
|
|
318
|
+
|
|
319
|
+
result["bid_ranking"] = [
|
|
320
|
+
{
|
|
321
|
+
"rank": i + 1,
|
|
322
|
+
"name": b["name"],
|
|
323
|
+
"price": b["bid_price"],
|
|
324
|
+
"deviation_pct": round((b["bid_price"] - avg_price) / avg_price * 100, 2),
|
|
325
|
+
"gap_from_lowest": round(b["bid_price"] - min_price, 2),
|
|
326
|
+
}
|
|
327
|
+
for i, b in enumerate(sorted_bidders)
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
# Module B: 最终报价排名
|
|
331
|
+
if "final_ranking" in active_modules:
|
|
332
|
+
bidders_with_final = [b for b in bidders if b.get("final_price") is not None]
|
|
333
|
+
if bidders_with_final:
|
|
334
|
+
sorted_final = sorted(bidders_with_final, key=lambda b: b["final_price"])
|
|
335
|
+
avg_final = sum(b["final_price"] for b in bidders_with_final) / len(bidders_with_final)
|
|
336
|
+
min_final = min(b["final_price"] for b in bidders_with_final)
|
|
337
|
+
|
|
338
|
+
result["final_ranking"] = [
|
|
339
|
+
{
|
|
340
|
+
"rank": i + 1,
|
|
341
|
+
"name": b["name"],
|
|
342
|
+
"price": b["final_price"],
|
|
343
|
+
"deviation_pct": round((b["final_price"] - avg_final) / avg_final * 100, 2),
|
|
344
|
+
"gap_from_lowest": round(b["final_price"] - min_final, 2),
|
|
345
|
+
}
|
|
346
|
+
for i, b in enumerate(sorted_final)
|
|
347
|
+
]
|
|
348
|
+
else:
|
|
349
|
+
result["final_ranking"] = None
|
|
350
|
+
|
|
351
|
+
# Module C: 降价分析
|
|
352
|
+
if "discount" in active_modules and bid_prices:
|
|
353
|
+
discount_results = []
|
|
354
|
+
for b in bidders:
|
|
355
|
+
if b.get("final_price") is not None and b.get("bid_price") and b["bid_price"] > 0:
|
|
356
|
+
discount_amount = round(b["bid_price"] - b["final_price"], 2)
|
|
357
|
+
discount_pct = round(discount_amount / b["bid_price"] * 100, 2)
|
|
358
|
+
|
|
359
|
+
# Classify strategy
|
|
360
|
+
if discount_pct > 8:
|
|
361
|
+
strategy = "激进"
|
|
362
|
+
elif discount_pct >= 4:
|
|
363
|
+
strategy = "适度"
|
|
364
|
+
else:
|
|
365
|
+
strategy = "保守"
|
|
366
|
+
|
|
367
|
+
discount_results.append({
|
|
368
|
+
"name": b["name"],
|
|
369
|
+
"bid_price": b["bid_price"],
|
|
370
|
+
"final_price": b["final_price"],
|
|
371
|
+
"discount_amount": discount_amount,
|
|
372
|
+
"discount_pct": discount_pct,
|
|
373
|
+
"strategy": strategy,
|
|
374
|
+
})
|
|
375
|
+
result["discount_results"] = discount_results
|
|
376
|
+
|
|
377
|
+
# Module D: 统计分析
|
|
378
|
+
if "statistics" in active_modules and bid_prices:
|
|
379
|
+
n = len(bid_prices)
|
|
380
|
+
avg = sum(bid_prices) / n
|
|
381
|
+
variance = sum((p - avg) ** 2 for p in bid_prices) / n
|
|
382
|
+
std_dev = variance ** 0.5
|
|
383
|
+
cv = round(std_dev / avg * 100, 2) if avg > 0 else 0
|
|
384
|
+
cv_level = "集中" if cv < 5 else "中等" if cv < 10 else "分散"
|
|
385
|
+
|
|
386
|
+
result["bid_stats"] = {
|
|
387
|
+
"max": max(bid_prices),
|
|
388
|
+
"min": min(bid_prices),
|
|
389
|
+
"mean": round(avg, 2),
|
|
390
|
+
"std_dev": round(std_dev, 2),
|
|
391
|
+
"cv": cv,
|
|
392
|
+
"cv_level": cv_level,
|
|
393
|
+
"range": round(max(bid_prices) - min(bid_prices), 2),
|
|
394
|
+
"count": n,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# Tiers
|
|
398
|
+
tiers = {"低梯队": [], "中梯队": [], "高梯队": []}
|
|
399
|
+
for b in bidders:
|
|
400
|
+
if b.get("bid_price") and b["bid_price"] > 0:
|
|
401
|
+
deviation_pct = (b["bid_price"] - avg) / avg * 100
|
|
402
|
+
tier_name = "低梯队" if deviation_pct <= -5 else "高梯队" if deviation_pct > 5 else "中梯队"
|
|
403
|
+
tiers[tier_name].append({
|
|
404
|
+
"name": b["name"],
|
|
405
|
+
"price": b["bid_price"],
|
|
406
|
+
"deviation_pct": round(deviation_pct, 2),
|
|
407
|
+
})
|
|
408
|
+
result["tiers"] = tiers
|
|
409
|
+
|
|
410
|
+
# Final stats if available
|
|
411
|
+
final_prices = [b["final_price"] for b in bidders if b.get("final_price") is not None]
|
|
412
|
+
if final_prices:
|
|
413
|
+
n_f = len(final_prices)
|
|
414
|
+
avg_f = sum(final_prices) / n_f
|
|
415
|
+
var_f = sum((p - avg_f) ** 2 for p in final_prices) / n_f
|
|
416
|
+
std_f = var_f ** 0.5
|
|
417
|
+
cv_f = round(std_f / avg_f * 100, 2) if avg_f > 0 else 0
|
|
418
|
+
cv_level_f = "集中" if cv_f < 5 else "中等" if cv_f < 10 else "分散"
|
|
419
|
+
|
|
420
|
+
result["final_stats"] = {
|
|
421
|
+
"max": max(final_prices),
|
|
422
|
+
"min": min(final_prices),
|
|
423
|
+
"mean": round(avg_f, 2),
|
|
424
|
+
"std_dev": round(std_f, 2),
|
|
425
|
+
"cv": cv_f,
|
|
426
|
+
"cv_level": cv_level_f,
|
|
427
|
+
"range": round(max(final_prices) - min(final_prices), 2),
|
|
428
|
+
"count": n_f,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# Module E: 评分对比
|
|
432
|
+
if "scores" in active_modules:
|
|
433
|
+
scored_bidders = [b for b in bidders if (b.get("total_score") or 0) > 0]
|
|
434
|
+
if scored_bidders:
|
|
435
|
+
sorted_scores = sorted(scored_bidders, key=lambda b: b["total_score"], reverse=True)
|
|
436
|
+
result["score_ranking"] = [
|
|
437
|
+
{
|
|
438
|
+
"rank": i + 1,
|
|
439
|
+
"name": b["name"],
|
|
440
|
+
"credit_score": b.get("credit_score", 0),
|
|
441
|
+
"technical_score": b.get("technical_score", 0),
|
|
442
|
+
"commercial_score": b.get("commercial_score", 0),
|
|
443
|
+
"total_score": b.get("total_score", 0),
|
|
444
|
+
}
|
|
445
|
+
for i, b in enumerate(sorted_scores)
|
|
446
|
+
]
|
|
447
|
+
else:
|
|
448
|
+
result["score_ranking"] = []
|
|
449
|
+
|
|
450
|
+
# Module F: 基准价对比
|
|
451
|
+
if "benchmark" in active_modules and meta.get("benchmark_price") is not None and bid_prices:
|
|
452
|
+
benchmark = meta["benchmark_price"]
|
|
453
|
+
max_price = meta.get("max_price")
|
|
454
|
+
benchmark_results = []
|
|
455
|
+
|
|
456
|
+
for b in bidders:
|
|
457
|
+
if b.get("bid_price") and b["bid_price"] > 0:
|
|
458
|
+
deviation = round(b["bid_price"] - benchmark, 2)
|
|
459
|
+
deviation_pct = round(deviation / benchmark * 100, 2)
|
|
460
|
+
below_benchmark = b["bid_price"] <= benchmark
|
|
461
|
+
|
|
462
|
+
entry = {
|
|
463
|
+
"name": b["name"],
|
|
464
|
+
"price": b["bid_price"],
|
|
465
|
+
"deviation_from_benchmark": deviation,
|
|
466
|
+
"deviation_pct": deviation_pct,
|
|
467
|
+
"below_benchmark": below_benchmark,
|
|
468
|
+
"total_score": b.get("total_score", 0),
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if max_price:
|
|
472
|
+
entry["max_price"] = max_price
|
|
473
|
+
entry["ratio_to_max_pct"] = round(b["bid_price"] / max_price * 100, 2)
|
|
474
|
+
entry["below_max"] = b["bid_price"] <= max_price
|
|
475
|
+
|
|
476
|
+
benchmark_results.append(entry)
|
|
477
|
+
|
|
478
|
+
result["benchmark_comparison"] = sorted(
|
|
479
|
+
benchmark_results,
|
|
480
|
+
key=lambda x: abs(x.get("deviation_pct", 0)),
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
result["benchmark_comparison"] = None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def get_available_modules(columns: list[str], meta: dict) -> list[dict]:
|
|
487
|
+
"""根据检测到的列和元数据,返回可用的分析维度列表。"""
|
|
488
|
+
has_bid_price = "bid_price" in columns
|
|
489
|
+
has_final = any(c in columns for c in ["final_price", "remarks"])
|
|
490
|
+
has_scores = any(c in columns for c in ["credit_score", "technical_score", "commercial_score", "total_score"])
|
|
491
|
+
has_benchmark = meta.get("benchmark_price") is not None
|
|
492
|
+
|
|
493
|
+
modules = [
|
|
494
|
+
{
|
|
495
|
+
"key": "bid_ranking",
|
|
496
|
+
"label": "投标价排名",
|
|
497
|
+
"available": has_bid_price,
|
|
498
|
+
"description": "按投标价从低到高排序,计算偏离均值比例" if has_bid_price else "表格中未检测到投标价数据",
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
"key": "final_ranking",
|
|
502
|
+
"label": "最终报价排名",
|
|
503
|
+
"available": has_final,
|
|
504
|
+
"description": "按最终报价从低到高排序" if has_final else "表格中未检测到备注/最终报价数据",
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
"key": "discount",
|
|
508
|
+
"label": "降价分析",
|
|
509
|
+
"available": has_bid_price and has_final,
|
|
510
|
+
"description": "投标价 vs 最终报价降价幅度与策略分类" if (has_bid_price and has_final) else "需要投标价和最终报价数据",
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
"key": "statistics",
|
|
514
|
+
"label": "统计分析",
|
|
515
|
+
"available": has_bid_price,
|
|
516
|
+
"description": "均值/标准差/离散系数/梯队分布" if has_bid_price else "表格中未检测到投标价数据",
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
"key": "scores",
|
|
520
|
+
"label": "评分对比",
|
|
521
|
+
"available": has_scores,
|
|
522
|
+
"description": "资信/技术/商务/综合评分排名对比" if has_scores else "表格中未检测到评分数据(资信/技术/商务/合计)",
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
"key": "benchmark",
|
|
526
|
+
"label": "基准价对比",
|
|
527
|
+
"available": has_benchmark,
|
|
528
|
+
"description": "各投标报价与评标基准价的偏离分析" if has_benchmark else "表格中未检测到评标基准价数据",
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
"key": "comprehensive",
|
|
532
|
+
"label": "综合分析 (AI)",
|
|
533
|
+
"available": True,
|
|
534
|
+
"description": "AI 对分析结果进行综合解读与策略建议",
|
|
535
|
+
},
|
|
536
|
+
]
|
|
537
|
+
return modules
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _is_sufficient_opening_analysis(text: str | None) -> bool:
|
|
541
|
+
if not text:
|
|
542
|
+
return False
|
|
543
|
+
required_sections = ["报价", "综合", "建议"]
|
|
544
|
+
return len(text.strip()) >= 1000 and all(section in text for section in required_sections)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def _find_sufficient_completed_opening(source_hash: str, modules: list[str] | None, user_id: str, require_ai: bool = False) -> dict | None:
|
|
548
|
+
cached = await find_completed_opening(source_hash, modules, user_id, require_ai=require_ai)
|
|
549
|
+
if require_ai and cached and not _is_sufficient_opening_analysis(cached.get("ai_analysis")):
|
|
550
|
+
return None
|
|
551
|
+
return cached
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@router.post("/parse")
|
|
555
|
+
async def parse_statistics_data(file: UploadFile = File(...), current_user: dict = Depends(get_current_user)):
|
|
556
|
+
"""解析 Excel/CSV 开标数据,返回检测到的列和可用分析维度。"""
|
|
557
|
+
try:
|
|
558
|
+
content = await file.read()
|
|
559
|
+
parsed = parse_opening_excel(content, file.filename)
|
|
560
|
+
available_modules = get_available_modules(parsed.get("columns", []), parsed.get("meta", {}))
|
|
561
|
+
return {
|
|
562
|
+
"success": True,
|
|
563
|
+
"data": {
|
|
564
|
+
**parsed,
|
|
565
|
+
"available_modules": available_modules,
|
|
566
|
+
},
|
|
567
|
+
}
|
|
568
|
+
except ValueError as e:
|
|
569
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
570
|
+
except Exception as e:
|
|
571
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@router.post("/analyze")
|
|
575
|
+
async def analyze_opening(request: OpeningAnalysisRequest, current_user: dict = Depends(get_current_user)):
|
|
576
|
+
"""
|
|
577
|
+
Comprehensive 6-dimension opening analysis.
|
|
578
|
+
"""
|
|
579
|
+
if not request.fileId:
|
|
580
|
+
raise HTTPException(status_code=400, detail="缺少 fileId 参数")
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
from app.services.file_service import get_file_service
|
|
584
|
+
file_service = get_file_service()
|
|
585
|
+
|
|
586
|
+
content = await file_service.download(request.fileId, current_user["id"])
|
|
587
|
+
|
|
588
|
+
# Parse filename from mock storage or use generic
|
|
589
|
+
filename = "bid_opening.xlsx"
|
|
590
|
+
# Try to detect format from content
|
|
591
|
+
try:
|
|
592
|
+
import io
|
|
593
|
+
df = pd.read_excel(io.BytesIO(content))
|
|
594
|
+
filename = "bid_opening.xlsx"
|
|
595
|
+
except Exception:
|
|
596
|
+
try:
|
|
597
|
+
df = pd.read_csv(io.BytesIO(content))
|
|
598
|
+
filename = "bid_opening.csv"
|
|
599
|
+
except Exception:
|
|
600
|
+
filename = "bid_opening.xlsx"
|
|
601
|
+
|
|
602
|
+
parsed = parse_opening_excel(content, filename)
|
|
603
|
+
modules = request.modules or None
|
|
604
|
+
result = compute_all_dimensions(parsed["bidders"], parsed["meta"], modules)
|
|
605
|
+
|
|
606
|
+
file_record = await get_file(request.fileId, current_user["id"])
|
|
607
|
+
original_name = file_record.get("original_name", "") if file_record else ""
|
|
608
|
+
|
|
609
|
+
# 保存开标结果到 mock_storage
|
|
610
|
+
await add_opening({
|
|
611
|
+
"file_id": request.fileId,
|
|
612
|
+
"file_name": original_name,
|
|
613
|
+
"bidder_count": result.get("bidder_count", parsed.get("bidder_count", 0)),
|
|
614
|
+
"bid_ranking": result.get("bid_ranking", []),
|
|
615
|
+
"bid_stats": result.get("bid_stats", {}),
|
|
616
|
+
"meta": result.get("meta", parsed.get("meta", {})),
|
|
617
|
+
"status": "completed",
|
|
618
|
+
}, user_id=current_user["id"])
|
|
619
|
+
|
|
620
|
+
return {"success": True, "data": result}
|
|
621
|
+
except Exception as e:
|
|
622
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@router.post("/analyze/upload")
|
|
626
|
+
async def analyze_opening_upload(
|
|
627
|
+
file: UploadFile = File(...),
|
|
628
|
+
modules: Optional[str] = Form(None),
|
|
629
|
+
current_user: dict = Depends(get_current_user),
|
|
630
|
+
):
|
|
631
|
+
"""
|
|
632
|
+
Upload file and analyze directly (combined upload + analyze).
|
|
633
|
+
"""
|
|
634
|
+
try:
|
|
635
|
+
content = await file.read()
|
|
636
|
+
source_hash = calculate_content_hash(content)
|
|
637
|
+
try:
|
|
638
|
+
module_list = json.loads(modules) if modules else None
|
|
639
|
+
except (json.JSONDecodeError, ValueError):
|
|
640
|
+
module_list = None
|
|
641
|
+
if module_list is None and modules:
|
|
642
|
+
module_list = [m.strip() for m in modules.split(",") if m.strip()]
|
|
643
|
+
cached = await _find_sufficient_completed_opening(source_hash, module_list, current_user["id"])
|
|
644
|
+
if cached:
|
|
645
|
+
return {"success": True, "data": _opening_record_to_result(cached), "cached": True}
|
|
646
|
+
|
|
647
|
+
parsed = parse_opening_excel(content, file.filename)
|
|
648
|
+
|
|
649
|
+
result = compute_all_dimensions(parsed["bidders"], parsed["meta"], module_list)
|
|
650
|
+
|
|
651
|
+
# 保存开标结果到 mock_storage
|
|
652
|
+
await add_opening({
|
|
653
|
+
"file_id": None,
|
|
654
|
+
"file_name": file.filename or "",
|
|
655
|
+
"bidder_count": result.get("bidder_count", parsed.get("bidder_count", 0)),
|
|
656
|
+
"bid_ranking": result.get("bid_ranking", []),
|
|
657
|
+
"bid_stats": result.get("bid_stats", {}),
|
|
658
|
+
"meta": result.get("meta", parsed.get("meta", {})),
|
|
659
|
+
"status": "completed",
|
|
660
|
+
"source_hash": source_hash,
|
|
661
|
+
}, user_id=current_user["id"])
|
|
662
|
+
|
|
663
|
+
return {"success": True, "data": result}
|
|
664
|
+
except ValueError as e:
|
|
665
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
666
|
+
except Exception as e:
|
|
667
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
async def comprehensive_analysis_generator(
|
|
671
|
+
analysis_data: dict,
|
|
672
|
+
provider: str = "deepseek",
|
|
673
|
+
model: str = None,
|
|
674
|
+
user_id: str = None,
|
|
675
|
+
):
|
|
676
|
+
"""
|
|
677
|
+
SSE generator for AI comprehensive analysis of opening results.
|
|
678
|
+
|
|
679
|
+
LLM 调用解耦到后台 asyncio Task,前端断连不会中断 LLM 调用。
|
|
680
|
+
生成 task_id 并在首条事件返回,前端可用此 ID 轮询结果。
|
|
681
|
+
"""
|
|
682
|
+
import asyncio
|
|
683
|
+
import uuid
|
|
684
|
+
|
|
685
|
+
llm_service = LLMService()
|
|
686
|
+
|
|
687
|
+
task_id = str(uuid.uuid4())[:8]
|
|
688
|
+
await add_opening({
|
|
689
|
+
"id": task_id,
|
|
690
|
+
"file_id": analysis_data.get("file_id"),
|
|
691
|
+
"file_name": analysis_data.get("file_name", ""),
|
|
692
|
+
"bidder_count": analysis_data.get("bidder_count", 0),
|
|
693
|
+
"bid_ranking": analysis_data.get("bid_ranking", []),
|
|
694
|
+
"bid_stats": analysis_data.get("bid_stats", {}),
|
|
695
|
+
"meta": analysis_data.get("meta", {}),
|
|
696
|
+
"ai_analysis": "",
|
|
697
|
+
"status": "running",
|
|
698
|
+
}, user_id=user_id)
|
|
699
|
+
|
|
700
|
+
yield {
|
|
701
|
+
"event": "message",
|
|
702
|
+
"data": json.dumps({"type": "task_created", "task_id": task_id}),
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
yield {
|
|
706
|
+
"event": "progress",
|
|
707
|
+
"data": json.dumps({"type": "progress", "message": "AI 正在生成综合分析..."}),
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
prompt_builder = get_prompt_builder()
|
|
711
|
+
statistics_json = json.dumps(analysis_data, ensure_ascii=False, indent=2)
|
|
712
|
+
|
|
713
|
+
messages = [
|
|
714
|
+
{"role": "system", "content": prompt_builder.build_opening_system_prompt()},
|
|
715
|
+
{"role": "user", "content": prompt_builder.build_opening_user_prompt(statistics_json)},
|
|
716
|
+
]
|
|
717
|
+
|
|
718
|
+
chunk_queue: asyncio.Queue = asyncio.Queue()
|
|
719
|
+
result_holder = {"text": "", "done": False, "error": None}
|
|
720
|
+
|
|
721
|
+
async def _llm_background_task():
|
|
722
|
+
try:
|
|
723
|
+
async for chunk in llm_service.llm.complete(provider, messages, model=model, stream=True, user_id=user_id, temperature=0.3):
|
|
724
|
+
result_holder["text"] += chunk
|
|
725
|
+
await chunk_queue.put({"type": "chunk", "content": chunk})
|
|
726
|
+
result_holder["done"] = True
|
|
727
|
+
await chunk_queue.put({"type": "llm_done"})
|
|
728
|
+
except Exception as e:
|
|
729
|
+
result_holder["error"] = str(e)
|
|
730
|
+
await chunk_queue.put({"type": "llm_error", "error": str(e)})
|
|
731
|
+
|
|
732
|
+
background_task = asyncio.create_task(_llm_background_task())
|
|
733
|
+
|
|
734
|
+
_saved = False
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
while True:
|
|
738
|
+
event = await chunk_queue.get()
|
|
739
|
+
if event["type"] == "chunk":
|
|
740
|
+
yield {
|
|
741
|
+
"event": "message",
|
|
742
|
+
"data": json.dumps({"type": "content", "content": event["content"]}),
|
|
743
|
+
}
|
|
744
|
+
elif event["type"] == "llm_done":
|
|
745
|
+
await update_opening(task_id, {
|
|
746
|
+
"ai_analysis": result_holder["text"],
|
|
747
|
+
"status": "completed",
|
|
748
|
+
})
|
|
749
|
+
_saved = True
|
|
750
|
+
yield {
|
|
751
|
+
"event": "done",
|
|
752
|
+
"data": json.dumps({
|
|
753
|
+
"type": "done",
|
|
754
|
+
"data": {"summary": "AI 综合分析完成", "contentLength": len(result_holder["text"])},
|
|
755
|
+
}),
|
|
756
|
+
}
|
|
757
|
+
break
|
|
758
|
+
elif event["type"] == "llm_error":
|
|
759
|
+
await update_opening(task_id, {"status": "error", "ai_analysis": result_holder["text"]})
|
|
760
|
+
_saved = True
|
|
761
|
+
yield {
|
|
762
|
+
"event": "error",
|
|
763
|
+
"data": json.dumps({"type": "error", "message": event["error"]}),
|
|
764
|
+
}
|
|
765
|
+
break
|
|
766
|
+
|
|
767
|
+
finally:
|
|
768
|
+
if not _saved:
|
|
769
|
+
if result_holder["done"]:
|
|
770
|
+
await update_opening(task_id, {"ai_analysis": result_holder["text"], "status": "completed"})
|
|
771
|
+
elif result_holder["text"]:
|
|
772
|
+
await update_opening(task_id, {"ai_analysis": result_holder["text"], "status": "partial"})
|
|
773
|
+
|
|
774
|
+
async def _ensure_save_on_completion():
|
|
775
|
+
await background_task
|
|
776
|
+
if result_holder["done"]:
|
|
777
|
+
await update_opening(task_id, {"ai_analysis": result_holder["text"], "status": "completed"})
|
|
778
|
+
|
|
779
|
+
asyncio.create_task(_ensure_save_on_completion())
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _fallback_opening_analysis(analysis_data: dict) -> str:
|
|
783
|
+
meta = analysis_data.get("meta") or {}
|
|
784
|
+
stats = analysis_data.get("bid_stats") or {}
|
|
785
|
+
final_stats = analysis_data.get("final_stats") or {}
|
|
786
|
+
ranking = analysis_data.get("bid_ranking") or []
|
|
787
|
+
final_ranking = analysis_data.get("final_ranking") or []
|
|
788
|
+
discounts = analysis_data.get("discount_results") or []
|
|
789
|
+
scores = analysis_data.get("score_ranking") or []
|
|
790
|
+
benchmarks = analysis_data.get("benchmark_comparison") or []
|
|
791
|
+
tiers = analysis_data.get("tiers") or {}
|
|
792
|
+
project_name = meta.get("project_name") or "开标项目"
|
|
793
|
+
bidder_count = stats.get("count", analysis_data.get("bidder_count", 0))
|
|
794
|
+
|
|
795
|
+
lines = [
|
|
796
|
+
f"# {project_name} 开标综合分析报告",
|
|
797
|
+
"",
|
|
798
|
+
"## 一、项目与数据概况",
|
|
799
|
+
f"- 项目名称:{project_name}",
|
|
800
|
+
f"- 有效投标人数量:{bidder_count}",
|
|
801
|
+
f"- 最高投标限价:{meta.get('max_price', '未识别')}",
|
|
802
|
+
f"- 评标基准价:{meta.get('benchmark_price', '未识别')}",
|
|
803
|
+
f"- D值:{meta.get('d_value', '未识别')}",
|
|
804
|
+
"",
|
|
805
|
+
"## 二、报价统计结论",
|
|
806
|
+
f"- 最高报价:{stats.get('max', '未识别')}",
|
|
807
|
+
f"- 最低报价:{stats.get('min', '未识别')}",
|
|
808
|
+
f"- 平均报价:{stats.get('mean', '未识别')}",
|
|
809
|
+
f"- 标准差:{stats.get('std_dev', '未识别')}",
|
|
810
|
+
f"- 离散系数:{stats.get('cv', '未识别')}%,报价集中度:{stats.get('cv_level', '未识别')}",
|
|
811
|
+
f"- 报价极差:{stats.get('range', '未识别')}",
|
|
812
|
+
"",
|
|
813
|
+
]
|
|
814
|
+
|
|
815
|
+
if ranking:
|
|
816
|
+
lines.extend([
|
|
817
|
+
"## 三、投标价排名摘要",
|
|
818
|
+
"| 排名 | 投标单位 | 投标报价 | 偏离均值 | 与最低价差额 |",
|
|
819
|
+
"| --- | --- | ---: | ---: | ---: |",
|
|
820
|
+
])
|
|
821
|
+
for item in ranking[:10]:
|
|
822
|
+
lines.append(
|
|
823
|
+
f"| {item.get('rank')} | {item.get('name')} | {item.get('price')} | {item.get('deviation_pct')}% | {item.get('gap_from_lowest')} |"
|
|
824
|
+
)
|
|
825
|
+
lowest = ranking[0]
|
|
826
|
+
highest = ranking[-1]
|
|
827
|
+
lines.extend([
|
|
828
|
+
"",
|
|
829
|
+
f"- 最低报价单位为 {lowest.get('name')},报价 {lowest.get('price')}。",
|
|
830
|
+
f"- 最高报价单位为 {highest.get('name')},报价 {highest.get('price')}。",
|
|
831
|
+
"- 若最低价与均值偏离较大,应结合技术响应、商务得分和异常低价规则进一步复核。",
|
|
832
|
+
"",
|
|
833
|
+
])
|
|
834
|
+
|
|
835
|
+
if final_ranking:
|
|
836
|
+
lines.extend([
|
|
837
|
+
"## 四、最终报价与降价策略",
|
|
838
|
+
f"- 最终报价最高值:{final_stats.get('max', '未识别')}",
|
|
839
|
+
f"- 最终报价最低值:{final_stats.get('min', '未识别')}",
|
|
840
|
+
f"- 最终报价均值:{final_stats.get('mean', '未识别')}",
|
|
841
|
+
"| 排名 | 投标单位 | 最终报价 | 偏离均值 | 与最低价差额 |",
|
|
842
|
+
"| --- | --- | ---: | ---: | ---: |",
|
|
843
|
+
])
|
|
844
|
+
for item in final_ranking[:10]:
|
|
845
|
+
lines.append(
|
|
846
|
+
f"| {item.get('rank')} | {item.get('name')} | {item.get('price')} | {item.get('deviation_pct')}% | {item.get('gap_from_lowest')} |"
|
|
847
|
+
)
|
|
848
|
+
if discounts:
|
|
849
|
+
aggressive = [item for item in discounts if item.get("strategy") == "激进"]
|
|
850
|
+
moderate = [item for item in discounts if item.get("strategy") == "适度"]
|
|
851
|
+
conservative = [item for item in discounts if item.get("strategy") == "保守"]
|
|
852
|
+
lines.extend([
|
|
853
|
+
"",
|
|
854
|
+
f"- 激进降价单位:{len(aggressive)} 家,适度降价单位:{len(moderate)} 家,保守降价单位:{len(conservative)} 家。",
|
|
855
|
+
"- 降价幅度较大的单位需要重点关注其报价组成、履约能力和后续变更风险。",
|
|
856
|
+
])
|
|
857
|
+
lines.append("")
|
|
858
|
+
else:
|
|
859
|
+
lines.extend([
|
|
860
|
+
"## 四、最终报价与降价策略",
|
|
861
|
+
"- 当前文件未识别到最终报价或二次报价列,无法形成最终报价排名和降价策略对比。",
|
|
862
|
+
"- 建议核对表格中是否存在“最终投标价”“最终报价”“二次报价”等字段,或在备注列中保留明确金额。",
|
|
863
|
+
"",
|
|
864
|
+
])
|
|
865
|
+
|
|
866
|
+
if tiers:
|
|
867
|
+
lines.extend(["## 五、价格梯队划分"])
|
|
868
|
+
for tier_name, members in tiers.items():
|
|
869
|
+
names = "、".join(item.get("name", "未识别") for item in members[:8]) or "无"
|
|
870
|
+
lines.append(f"- {tier_name}:{len(members)} 家,代表单位:{names}")
|
|
871
|
+
lines.extend([
|
|
872
|
+
"- 梯队分布可用于判断竞争集中度。若多数单位集中在中梯队,说明报价策略趋同;若高低梯队差异明显,应关注报价合理性边界。",
|
|
873
|
+
"",
|
|
874
|
+
])
|
|
875
|
+
|
|
876
|
+
if scores:
|
|
877
|
+
lines.extend([
|
|
878
|
+
"## 六、评分对比",
|
|
879
|
+
"| 排名 | 投标单位 | 资信标 | 技术标 | 商务标 | 合计 |",
|
|
880
|
+
"| --- | --- | ---: | ---: | ---: | ---: |",
|
|
881
|
+
])
|
|
882
|
+
for item in scores[:10]:
|
|
883
|
+
lines.append(
|
|
884
|
+
f"| {item.get('rank')} | {item.get('name')} | {item.get('credit_score')} | {item.get('technical_score')} | {item.get('commercial_score')} | {item.get('total_score')} |"
|
|
885
|
+
)
|
|
886
|
+
lines.extend([
|
|
887
|
+
"- 评分排名应与报价排名交叉查看,重点识别低价高分、低价低分、高价高分等不同竞争形态。",
|
|
888
|
+
"",
|
|
889
|
+
])
|
|
890
|
+
|
|
891
|
+
if benchmarks:
|
|
892
|
+
near_benchmark = benchmarks[:5]
|
|
893
|
+
lines.extend([
|
|
894
|
+
"## 七、基准价对比",
|
|
895
|
+
"| 投标单位 | 报价 | 偏离基准价 | 偏离比例 | 是否低于基准价 |",
|
|
896
|
+
"| --- | ---: | ---: | ---: | --- |",
|
|
897
|
+
])
|
|
898
|
+
for item in near_benchmark:
|
|
899
|
+
below = "是" if item.get("below_benchmark") else "否"
|
|
900
|
+
lines.append(
|
|
901
|
+
f"| {item.get('name')} | {item.get('price')} | {item.get('deviation_from_benchmark')} | {item.get('deviation_pct')}% | {below} |"
|
|
902
|
+
)
|
|
903
|
+
lines.extend([
|
|
904
|
+
"- 与基准价偏离越小,通常越接近评标价格中枢;偏离较大的报价需结合评分办法判断影响。",
|
|
905
|
+
"",
|
|
906
|
+
])
|
|
907
|
+
|
|
908
|
+
lines.extend([
|
|
909
|
+
"## 八、综合判断与建议",
|
|
910
|
+
"- 当前数据已完成结构化解析,可支撑报价排名、离散度、梯队分布、评分对比和基准价偏离分析。",
|
|
911
|
+
"- 对低价单位,应重点复核报价组成、异常低价说明、关键设备或服务内容是否完整。",
|
|
912
|
+
"- 对高价单位,应关注其技术标、资信标得分是否足以支撑报价溢价。",
|
|
913
|
+
"- 后续可结合招标文件评分办法、废标条款和澄清记录,对排名变化原因进行更细化复盘。",
|
|
914
|
+
])
|
|
915
|
+
return "\n".join(lines)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
@router.post("/analyze/comprehensive")
|
|
919
|
+
async def analyze_comprehensive(request: OpeningAnalysisRequest, current_user: dict = Depends(get_current_user)):
|
|
920
|
+
"""
|
|
921
|
+
AI comprehensive analysis with SSE streaming.
|
|
922
|
+
|
|
923
|
+
First runs 6-dimension analysis, then streams AI comprehensive interpretation.
|
|
924
|
+
"""
|
|
925
|
+
try:
|
|
926
|
+
from app.services.file_service import get_file_service
|
|
927
|
+
file_service = get_file_service()
|
|
928
|
+
|
|
929
|
+
content = await file_service.download(request.fileId, current_user["id"])
|
|
930
|
+
filename = "bid_opening.xlsx"
|
|
931
|
+
|
|
932
|
+
parsed = parse_opening_excel(content, filename)
|
|
933
|
+
modules = request.modules or None
|
|
934
|
+
analysis_data = compute_all_dimensions(parsed["bidders"], parsed["meta"], modules)
|
|
935
|
+
|
|
936
|
+
return EventSourceResponse(
|
|
937
|
+
comprehensive_analysis_generator(analysis_data, request.provider or "deepseek", model=request.model, user_id=current_user["id"]),
|
|
938
|
+
)
|
|
939
|
+
except Exception as e:
|
|
940
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
@router.post("/analyze/comprehensive/upload")
|
|
944
|
+
async def analyze_comprehensive_upload(
|
|
945
|
+
file: UploadFile = File(...),
|
|
946
|
+
modules: Optional[str] = Form(None),
|
|
947
|
+
provider: Optional[str] = Form("deepseek"),
|
|
948
|
+
model: Optional[str] = Form(None),
|
|
949
|
+
current_user: dict = Depends(get_current_user),
|
|
950
|
+
):
|
|
951
|
+
"""
|
|
952
|
+
Upload file + analyze + AI comprehensive SSE streaming (all-in-one).
|
|
953
|
+
"""
|
|
954
|
+
try:
|
|
955
|
+
content = await file.read()
|
|
956
|
+
parsed = parse_opening_excel(content, file.filename)
|
|
957
|
+
|
|
958
|
+
try:
|
|
959
|
+
module_list = json.loads(modules) if modules else None
|
|
960
|
+
except (json.JSONDecodeError, ValueError):
|
|
961
|
+
module_list = None
|
|
962
|
+
if module_list is None and modules:
|
|
963
|
+
module_list = [m.strip() for m in modules.split(",") if m.strip()]
|
|
964
|
+
analysis_data = compute_all_dimensions(parsed["bidders"], parsed["meta"], module_list)
|
|
965
|
+
|
|
966
|
+
return EventSourceResponse(
|
|
967
|
+
comprehensive_analysis_generator(analysis_data, provider or "deepseek", model=model, user_id=current_user["id"]),
|
|
968
|
+
)
|
|
969
|
+
except Exception as e:
|
|
970
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
@router.post("/analyze/comprehensive/start")
|
|
974
|
+
async def start_comprehensive_analysis(
|
|
975
|
+
request: OpeningAnalysisRequest,
|
|
976
|
+
current_user: dict = Depends(get_current_user),
|
|
977
|
+
):
|
|
978
|
+
"""
|
|
979
|
+
启动综合分析后台任务,立即返回 task_id。
|
|
980
|
+
LLM 在后台运行,前端通过 GET /analysis-task/{task_id} 轮询结果。
|
|
981
|
+
"""
|
|
982
|
+
import asyncio
|
|
983
|
+
import uuid
|
|
984
|
+
|
|
985
|
+
try:
|
|
986
|
+
from app.services.file_service import get_file_service
|
|
987
|
+
file_service = get_file_service()
|
|
988
|
+
|
|
989
|
+
content = await file_service.download(request.fileId, current_user["id"])
|
|
990
|
+
source_hash = calculate_content_hash(content)
|
|
991
|
+
filename = "bid_opening.xlsx"
|
|
992
|
+
parsed = parse_opening_excel(content, filename)
|
|
993
|
+
modules = request.modules or None
|
|
994
|
+
cached = await _find_sufficient_completed_opening(source_hash, modules, current_user["id"], require_ai=True)
|
|
995
|
+
if cached:
|
|
996
|
+
return {"success": True, "task_id": cached["id"], "cached": True}
|
|
997
|
+
analysis_data = compute_all_dimensions(parsed["bidders"], parsed["meta"], modules)
|
|
998
|
+
|
|
999
|
+
file_record = await get_file(request.fileId, current_user["id"])
|
|
1000
|
+
original_name = file_record.get("original_name", "") if file_record else ""
|
|
1001
|
+
|
|
1002
|
+
task_id = str(uuid.uuid4())[:8]
|
|
1003
|
+
user_id = current_user["id"]
|
|
1004
|
+
|
|
1005
|
+
await add_opening({
|
|
1006
|
+
"id": task_id,
|
|
1007
|
+
"file_id": analysis_data.get("file_id"),
|
|
1008
|
+
"file_name": original_name,
|
|
1009
|
+
"bidder_count": analysis_data.get("bidder_count", 0),
|
|
1010
|
+
"bid_ranking": analysis_data.get("bid_ranking", []),
|
|
1011
|
+
"bid_stats": analysis_data.get("bid_stats", {}),
|
|
1012
|
+
"meta": analysis_data.get("meta", {}),
|
|
1013
|
+
"ai_analysis": "",
|
|
1014
|
+
"status": "running",
|
|
1015
|
+
"source_hash": source_hash,
|
|
1016
|
+
}, user_id=user_id)
|
|
1017
|
+
|
|
1018
|
+
async def _run_llm():
|
|
1019
|
+
llm_service = LLMService()
|
|
1020
|
+
provider = request.provider or "deepseek"
|
|
1021
|
+
model = request.model
|
|
1022
|
+
|
|
1023
|
+
prompt_builder = get_prompt_builder()
|
|
1024
|
+
statistics_json = json.dumps(analysis_data, ensure_ascii=False, indent=2)
|
|
1025
|
+
|
|
1026
|
+
messages = [
|
|
1027
|
+
{"role": "system", "content": prompt_builder.build_opening_system_prompt()},
|
|
1028
|
+
{"role": "user", "content": prompt_builder.build_opening_user_prompt(statistics_json)},
|
|
1029
|
+
]
|
|
1030
|
+
|
|
1031
|
+
result_text = ""
|
|
1032
|
+
chunk_queue: asyncio.Queue = asyncio.Queue()
|
|
1033
|
+
logger.info("开标综合分析开始: task_id=%s provider=%s model=%s", task_id, provider, model or "default")
|
|
1034
|
+
|
|
1035
|
+
async def _llm_stream():
|
|
1036
|
+
nonlocal result_text
|
|
1037
|
+
first_chunk = True
|
|
1038
|
+
async for chunk in llm_service.llm.complete(provider, messages, model=model, stream=True, user_id=user_id, temperature=0.3):
|
|
1039
|
+
if first_chunk:
|
|
1040
|
+
logger.info("开标综合分析收到首个模型输出: task_id=%s", task_id)
|
|
1041
|
+
first_chunk = False
|
|
1042
|
+
result_text += chunk
|
|
1043
|
+
await chunk_queue.put(chunk)
|
|
1044
|
+
|
|
1045
|
+
stream_task = asyncio.create_task(_llm_stream())
|
|
1046
|
+
idle_ticks = 0
|
|
1047
|
+
max_idle_ticks = _opening_idle_ticks(analysis_data)
|
|
1048
|
+
try:
|
|
1049
|
+
while True:
|
|
1050
|
+
if stream_task.done() and chunk_queue.empty():
|
|
1051
|
+
error = stream_task.exception()
|
|
1052
|
+
if error:
|
|
1053
|
+
raise error
|
|
1054
|
+
break
|
|
1055
|
+
try:
|
|
1056
|
+
await asyncio.wait_for(chunk_queue.get(), timeout=OPENING_IDLE_TIMEOUT_SECONDS)
|
|
1057
|
+
idle_ticks = 0
|
|
1058
|
+
except asyncio.TimeoutError:
|
|
1059
|
+
idle_ticks += 1
|
|
1060
|
+
logger.info("开标综合分析等待模型输出: task_id=%s idle_ticks=%s", task_id, idle_ticks)
|
|
1061
|
+
if idle_ticks < max_idle_ticks:
|
|
1062
|
+
continue
|
|
1063
|
+
logger.warning("开标综合分析模型长时间无输出,使用兜底报告: task_id=%s partial_length=%s", task_id, len(result_text))
|
|
1064
|
+
if not stream_task.done():
|
|
1065
|
+
stream_task.cancel()
|
|
1066
|
+
import contextlib
|
|
1067
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1068
|
+
await stream_task
|
|
1069
|
+
break
|
|
1070
|
+
|
|
1071
|
+
final_text = result_text.strip() or _fallback_opening_analysis(analysis_data)
|
|
1072
|
+
await update_opening(task_id, {"ai_analysis": final_text, "status": "completed"})
|
|
1073
|
+
logger.info("开标综合分析已保存: task_id=%s length=%s", task_id, len(final_text))
|
|
1074
|
+
except Exception:
|
|
1075
|
+
logger.exception("开标综合分析失败,使用兜底报告: task_id=%s partial_length=%s", task_id, len(result_text))
|
|
1076
|
+
await update_opening(task_id, {"ai_analysis": result_text.strip() or _fallback_opening_analysis(analysis_data), "status": "completed"})
|
|
1077
|
+
|
|
1078
|
+
asyncio.create_task(_run_llm())
|
|
1079
|
+
|
|
1080
|
+
return {"success": True, "task_id": task_id}
|
|
1081
|
+
except Exception as e:
|
|
1082
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@router.post("/analyze/comprehensive/upload/start")
|
|
1086
|
+
async def start_comprehensive_analysis_upload(
|
|
1087
|
+
file: UploadFile = File(...),
|
|
1088
|
+
modules: Optional[str] = Form(None),
|
|
1089
|
+
provider: Optional[str] = Form("deepseek"),
|
|
1090
|
+
model: Optional[str] = Form(None),
|
|
1091
|
+
current_user: dict = Depends(get_current_user),
|
|
1092
|
+
):
|
|
1093
|
+
"""
|
|
1094
|
+
上传文件 + 启动综合分析后台任务,立即返回 task_id。
|
|
1095
|
+
"""
|
|
1096
|
+
import asyncio
|
|
1097
|
+
import uuid
|
|
1098
|
+
|
|
1099
|
+
try:
|
|
1100
|
+
content = await file.read()
|
|
1101
|
+
source_hash = calculate_content_hash(content)
|
|
1102
|
+
try:
|
|
1103
|
+
module_list = json.loads(modules) if modules else None
|
|
1104
|
+
except (json.JSONDecodeError, ValueError):
|
|
1105
|
+
module_list = None
|
|
1106
|
+
if module_list is None and modules:
|
|
1107
|
+
module_list = [m.strip() for m in modules.split(",") if m.strip()]
|
|
1108
|
+
cached = await _find_sufficient_completed_opening(source_hash, module_list, current_user["id"], require_ai=True)
|
|
1109
|
+
if cached:
|
|
1110
|
+
return {"success": True, "task_id": cached["id"], "cached": True}
|
|
1111
|
+
|
|
1112
|
+
parsed = parse_opening_excel(content, file.filename)
|
|
1113
|
+
analysis_data = compute_all_dimensions(parsed["bidders"], parsed["meta"], module_list)
|
|
1114
|
+
|
|
1115
|
+
task_id = str(uuid.uuid4())[:8]
|
|
1116
|
+
user_id = current_user["id"]
|
|
1117
|
+
|
|
1118
|
+
await add_opening({
|
|
1119
|
+
"id": task_id,
|
|
1120
|
+
"file_id": analysis_data.get("file_id"),
|
|
1121
|
+
"file_name": file.filename or "",
|
|
1122
|
+
"bidder_count": analysis_data.get("bidder_count", 0),
|
|
1123
|
+
"bid_ranking": analysis_data.get("bid_ranking", []),
|
|
1124
|
+
"bid_stats": analysis_data.get("bid_stats", {}),
|
|
1125
|
+
"meta": analysis_data.get("meta", {}),
|
|
1126
|
+
"ai_analysis": "",
|
|
1127
|
+
"status": "running",
|
|
1128
|
+
"source_hash": source_hash,
|
|
1129
|
+
"provider": provider or "deepseek",
|
|
1130
|
+
"model": model or "",
|
|
1131
|
+
}, user_id=user_id)
|
|
1132
|
+
|
|
1133
|
+
async def _run_llm():
|
|
1134
|
+
llm_service = LLMService()
|
|
1135
|
+
provider_name = provider or "deepseek"
|
|
1136
|
+
|
|
1137
|
+
prompt_builder = get_prompt_builder()
|
|
1138
|
+
statistics_json = json.dumps(analysis_data, ensure_ascii=False, indent=2)
|
|
1139
|
+
|
|
1140
|
+
messages = [
|
|
1141
|
+
{"role": "system", "content": prompt_builder.build_opening_system_prompt()},
|
|
1142
|
+
{"role": "user", "content": prompt_builder.build_opening_user_prompt(statistics_json)},
|
|
1143
|
+
]
|
|
1144
|
+
|
|
1145
|
+
result_text = ""
|
|
1146
|
+
chunk_queue: asyncio.Queue = asyncio.Queue()
|
|
1147
|
+
logger.info("开标综合分析开始: task_id=%s provider=%s model=%s", task_id, provider_name, model or "default")
|
|
1148
|
+
|
|
1149
|
+
async def _llm_stream():
|
|
1150
|
+
nonlocal result_text
|
|
1151
|
+
first_chunk = True
|
|
1152
|
+
async for chunk in llm_service.llm.complete(provider_name, messages, model=model, stream=True, user_id=user_id, temperature=0.3):
|
|
1153
|
+
if first_chunk:
|
|
1154
|
+
logger.info("开标综合分析收到首个模型输出: task_id=%s", task_id)
|
|
1155
|
+
first_chunk = False
|
|
1156
|
+
result_text += chunk
|
|
1157
|
+
await chunk_queue.put(chunk)
|
|
1158
|
+
|
|
1159
|
+
stream_task = asyncio.create_task(_llm_stream())
|
|
1160
|
+
idle_ticks = 0
|
|
1161
|
+
max_idle_ticks = _opening_idle_ticks(analysis_data)
|
|
1162
|
+
try:
|
|
1163
|
+
while True:
|
|
1164
|
+
if stream_task.done() and chunk_queue.empty():
|
|
1165
|
+
error = stream_task.exception()
|
|
1166
|
+
if error:
|
|
1167
|
+
raise error
|
|
1168
|
+
break
|
|
1169
|
+
try:
|
|
1170
|
+
await asyncio.wait_for(chunk_queue.get(), timeout=OPENING_IDLE_TIMEOUT_SECONDS)
|
|
1171
|
+
idle_ticks = 0
|
|
1172
|
+
except asyncio.TimeoutError:
|
|
1173
|
+
idle_ticks += 1
|
|
1174
|
+
logger.info("开标综合分析等待模型输出: task_id=%s idle_ticks=%s", task_id, idle_ticks)
|
|
1175
|
+
if idle_ticks < max_idle_ticks:
|
|
1176
|
+
continue
|
|
1177
|
+
logger.warning("开标综合分析模型长时间无输出,使用兜底报告: task_id=%s partial_length=%s", task_id, len(result_text))
|
|
1178
|
+
if not stream_task.done():
|
|
1179
|
+
stream_task.cancel()
|
|
1180
|
+
import contextlib
|
|
1181
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1182
|
+
await stream_task
|
|
1183
|
+
break
|
|
1184
|
+
|
|
1185
|
+
final_text = result_text.strip() or _fallback_opening_analysis(analysis_data)
|
|
1186
|
+
await update_opening(task_id, {"ai_analysis": final_text, "status": "completed"})
|
|
1187
|
+
logger.info("开标综合分析已保存: task_id=%s length=%s", task_id, len(final_text))
|
|
1188
|
+
except Exception:
|
|
1189
|
+
logger.exception("开标综合分析失败,使用兜底报告: task_id=%s partial_length=%s", task_id, len(result_text))
|
|
1190
|
+
await update_opening(task_id, {"ai_analysis": result_text.strip() or _fallback_opening_analysis(analysis_data), "status": "completed"})
|
|
1191
|
+
|
|
1192
|
+
asyncio.create_task(_run_llm())
|
|
1193
|
+
|
|
1194
|
+
return {"success": True, "task_id": task_id}
|
|
1195
|
+
except Exception as e:
|
|
1196
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
@router.get("/analysis-task/{task_id}")
|
|
1200
|
+
async def get_analysis_task(task_id: str, current_user: dict = Depends(get_current_user)):
|
|
1201
|
+
"""获取综合分析任务状态和结果(前端轮询用)。"""
|
|
1202
|
+
record = await get_opening(task_id, user_id=current_user["id"])
|
|
1203
|
+
if not record:
|
|
1204
|
+
raise HTTPException(status_code=404, detail="任务不存在")
|
|
1205
|
+
return {"success": True, "data": record}
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@router.get("/export/{analysis_id}")
|
|
1209
|
+
async def export_report(analysis_id: str, current_user: dict = Depends(get_current_user)):
|
|
1210
|
+
"""Export analysis report (placeholder)."""
|
|
1211
|
+
return {
|
|
1212
|
+
"success": True,
|
|
1213
|
+
"data": {"analysisId": analysis_id, "format": "json"},
|
|
1214
|
+
}
|