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.
Files changed (47) hide show
  1. app/__init__.py +1 -0
  2. app/api/__init__.py +1 -0
  3. app/api/api_keys.py +60 -0
  4. app/api/auth.py +258 -0
  5. app/api/cli_auth.py +165 -0
  6. app/api/database.py +286 -0
  7. app/api/extract.py +158 -0
  8. app/api/files.py +163 -0
  9. app/api/health.py +62 -0
  10. app/api/logs.py +26 -0
  11. app/api/settings.py +101 -0
  12. app/api/simulate.py +195 -0
  13. app/api/statistics.py +1214 -0
  14. app/cli.py +894 -0
  15. app/config.py +93 -0
  16. app/dependencies.py +12 -0
  17. app/infrastructure/__init__.py +1 -0
  18. app/infrastructure/database.py +126 -0
  19. app/infrastructure/db_schema.py +245 -0
  20. app/infrastructure/email_service.py +92 -0
  21. app/infrastructure/llm/__init__.py +1 -0
  22. app/infrastructure/llm/lite_llm.py +463 -0
  23. app/infrastructure/log_collector.py +64 -0
  24. app/infrastructure/mock_storage.py +563 -0
  25. app/infrastructure/pg_storage.py +656 -0
  26. app/infrastructure/storage.py +117 -0
  27. app/limiter.py +7 -0
  28. app/main.py +141 -0
  29. app/models/__init__.py +1 -0
  30. app/models/schemas.py +204 -0
  31. app/services/__init__.py +1 -0
  32. app/services/encryption_service.py +88 -0
  33. app/services/extract_service.py +817 -0
  34. app/services/file_service.py +112 -0
  35. app/services/llm_service.py +65 -0
  36. app/services/ocr_service.py +183 -0
  37. app/services/prompt_builder.py +257 -0
  38. app/services/simulate_service.py +625 -0
  39. app/services/statistics_service.py +123 -0
  40. app/utils/__init__.py +1 -0
  41. app/utils/auth_dep.py +42 -0
  42. app/utils/crypto.py +63 -0
  43. app/utils/exceptions.py +53 -0
  44. bid_master_cli-1.0.0.dist-info/METADATA +30 -0
  45. bid_master_cli-1.0.0.dist-info/RECORD +47 -0
  46. bid_master_cli-1.0.0.dist-info/WHEEL +4 -0
  47. 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
+ }