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
@@ -0,0 +1,563 @@
1
+ """
2
+ Persistent mock data storage with JSON file backing.
3
+ Data is kept in memory for fast reads and persisted to disk on writes.
4
+ Survives container restarts and sleep/wake cycles.
5
+ """
6
+ import json
7
+ import os
8
+ import uuid
9
+ import threading
10
+ from datetime import datetime, timezone
11
+ from typing import Optional
12
+
13
+ # --- Config ---
14
+ # 默认使用项目目录下的 data/,避免 /tmp 在部署重启时被清空
15
+ _DEFAULT_DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data")
16
+ DATA_DIR = os.environ.get("DATA_DIR", _DEFAULT_DATA_DIR)
17
+ STORAGE_FILE = os.path.join(DATA_DIR, "mock_storage.json")
18
+
19
+ # --- In-memory dicts (loaded from disk on startup) ---
20
+ _mock_files: dict = {}
21
+ _mock_simulates: dict = {}
22
+ _mock_openings: dict = {}
23
+ _mock_extracts: dict = {}
24
+ _mock_users: dict = {}
25
+ _mock_api_keys: dict = {}
26
+
27
+ DEMO_USER_ID = "demo-user"
28
+
29
+
30
+ def _now() -> str:
31
+ return datetime.now(timezone.utc).isoformat()
32
+
33
+
34
+ # --- Persistence ---
35
+ _save_lock = threading.Lock()
36
+
37
+
38
+ def _load_from_disk():
39
+ """Load all data from JSON file on startup."""
40
+ global _mock_files, _mock_simulates, _mock_openings, _mock_extracts, _mock_users, _mock_api_keys
41
+ if not os.path.exists(STORAGE_FILE):
42
+ return
43
+ try:
44
+ with open(STORAGE_FILE, "r", encoding="utf-8") as f:
45
+ data = json.load(f)
46
+ _mock_files = data.get("files", {})
47
+ _mock_simulates = data.get("simulates", {})
48
+ _mock_openings = data.get("openings", {})
49
+ _mock_extracts = data.get("extracts", {})
50
+ _mock_users = data.get("users", {})
51
+ _mock_api_keys = data.get("api_keys", {})
52
+ except (json.JSONDecodeError, OSError):
53
+ pass
54
+
55
+
56
+ def _save_to_disk():
57
+ """Persist all data to JSON file."""
58
+ os.makedirs(DATA_DIR, exist_ok=True)
59
+ with _save_lock:
60
+ try:
61
+ data = {
62
+ "files": _mock_files,
63
+ "simulates": _mock_simulates,
64
+ "openings": _mock_openings,
65
+ "extracts": _mock_extracts,
66
+ "users": _mock_users,
67
+ "api_keys": _mock_api_keys,
68
+ }
69
+ with open(STORAGE_FILE, "w", encoding="utf-8") as f:
70
+ json.dump(data, f, ensure_ascii=False, indent=2)
71
+ except OSError:
72
+ pass
73
+
74
+
75
+ def _init_mock_data():
76
+ """Initialize with sample data for development (only if no persisted data)."""
77
+ if _mock_users:
78
+ return
79
+
80
+ # Files
81
+ f1_id = str(uuid.uuid4())[:8]
82
+ f2_id = str(uuid.uuid4())[:8]
83
+ _mock_files[f1_id] = {
84
+ "id": f1_id,
85
+ "original_name": "招标文件-第一包.pdf",
86
+ "path": f"/storage/{f1_id}.enc",
87
+ "size": 1024000,
88
+ "type": "pdf",
89
+ "user_id": DEMO_USER_ID,
90
+ "created_at": _now(),
91
+ }
92
+ _mock_files[f2_id] = {
93
+ "id": f2_id,
94
+ "original_name": "投标文件-A公司.docx",
95
+ "path": f"/storage/{f2_id}.enc",
96
+ "size": 2048000,
97
+ "type": "word",
98
+ "user_id": DEMO_USER_ID,
99
+ "created_at": _now(),
100
+ }
101
+
102
+ # Simulate tasks
103
+ s1_id = str(uuid.uuid4())[:8]
104
+ _mock_simulates[s1_id] = {
105
+ "task_id": s1_id,
106
+ "status": "completed",
107
+ "current_step": 4,
108
+ "params": {"template_type": "construction"},
109
+ "step_results": {
110
+ "step1": "PDF 转换完成",
111
+ "step2": "要素提取完成",
112
+ "step3": "对比分析完成",
113
+ "step4": "模拟编制完成",
114
+ },
115
+ "file_ids": [f1_id],
116
+ "files": [{"id": f1_id, "original_name": "招标文件-第一包.pdf", "type": "pdf"}],
117
+ "user_id": DEMO_USER_ID,
118
+ "created_at": _now(),
119
+ }
120
+
121
+ # Opening results
122
+ o1_id = str(uuid.uuid4())[:8]
123
+ _mock_openings[o1_id] = {
124
+ "id": o1_id,
125
+ "file_id": f1_id,
126
+ "bidder_count": 5,
127
+ "bid_ranking": [
128
+ {"rank": 1, "name": "A公司", "price": 1150000},
129
+ {"rank": 2, "name": "B公司", "price": 1180000},
130
+ {"rank": 3, "name": "C公司", "price": 1250000},
131
+ {"rank": 4, "name": "D公司", "price": 1280000},
132
+ {"rank": 5, "name": "E公司", "price": 1320000},
133
+ ],
134
+ "bid_stats": {
135
+ "mean": 1236000,
136
+ "std": 64000,
137
+ "cv": 5.2,
138
+ "min": 1150000,
139
+ "max": 1320000,
140
+ "range": 170000,
141
+ },
142
+ "user_id": DEMO_USER_ID,
143
+ "created_at": _now(),
144
+ }
145
+
146
+ # Extract results
147
+ e1_id = str(uuid.uuid4())[:8]
148
+ _mock_extracts[e1_id] = {
149
+ "id": e1_id,
150
+ "file_id": f1_id,
151
+ "template_type": "construction",
152
+ "mode": "fast",
153
+ "content": """## 资质要求
154
+ 投标人须具有建筑工程施工总承包一级资质
155
+
156
+ ## 评标办法
157
+ 综合评估法,商务标占60%,技术标占40%
158
+
159
+ ## 业绩门槛
160
+ 近三年内完成过三个以上类似项目
161
+
162
+ ## 定标方法
163
+ 最低价中标法
164
+
165
+ ## 合同条款
166
+ 合同工期12个月,付款方式为按月进度支付""",
167
+ "user_id": DEMO_USER_ID,
168
+ "created_at": _now(),
169
+ }
170
+
171
+ _save_to_disk()
172
+
173
+
174
+ # Load persisted data first, then seed demo data if empty
175
+ _load_from_disk()
176
+ _init_mock_data()
177
+
178
+
179
+ # --- CRUD Helpers (所有查询按 user_id 隔离) ---
180
+
181
+ def get_stats(user_id: str) -> dict:
182
+ return {
183
+ "files": sum(1 for r in _mock_files.values() if r.get("user_id") == user_id),
184
+ "simulate_tasks": sum(1 for r in _mock_simulates.values() if r.get("user_id") == user_id),
185
+ "opening_results": sum(1 for r in _mock_openings.values() if r.get("user_id") == user_id),
186
+ "extract_results": sum(1 for r in _mock_extracts.values() if r.get("user_id") == user_id),
187
+ }
188
+
189
+
190
+ def list_files(page: int = 1, page_size: int = 20, file_type: Optional[str] = None, user_id: Optional[str] = None) -> dict:
191
+ records = [r for r in _mock_files.values() if r.get("user_id") == user_id]
192
+ if file_type:
193
+ records = [r for r in records if r["type"] == file_type]
194
+ records.sort(key=lambda r: r["created_at"], reverse=True)
195
+ total = len(records)
196
+ start = (page - 1) * page_size
197
+ end = start + page_size
198
+ return {
199
+ "total": total,
200
+ "page": page,
201
+ "page_size": page_size,
202
+ "files": records[start:end],
203
+ }
204
+
205
+
206
+ def get_file(file_id: str, user_id: Optional[str] = None) -> Optional[dict]:
207
+ record = _mock_files.get(file_id)
208
+ if record and user_id and record.get("user_id") != user_id:
209
+ return None
210
+ return record
211
+
212
+
213
+ def delete_file(file_id: str, user_id: Optional[str] = None) -> bool:
214
+ record = _mock_files.get(file_id)
215
+ if not record:
216
+ return False
217
+ if user_id and record.get("user_id") != user_id:
218
+ return False
219
+ del _mock_files[file_id]
220
+ _save_to_disk()
221
+ return True
222
+
223
+
224
+ def list_simulates(page: int = 1, page_size: int = 20, status: Optional[str] = None, user_id: Optional[str] = None) -> dict:
225
+ records = [r for r in _mock_simulates.values() if r.get("user_id") == user_id]
226
+ if status:
227
+ records = [r for r in records if r["status"] == status]
228
+ records.sort(key=lambda r: r["created_at"], reverse=True)
229
+ total = len(records)
230
+ start = (page - 1) * page_size
231
+ end = start + page_size
232
+ return {
233
+ "total": total,
234
+ "page": page,
235
+ "page_size": page_size,
236
+ "tasks": records[start:end],
237
+ }
238
+
239
+
240
+ def get_simulate(task_id: str, user_id: Optional[str] = None) -> Optional[dict]:
241
+ record = _mock_simulates.get(task_id)
242
+ if record and user_id and record.get("user_id") != user_id:
243
+ return None
244
+ return record
245
+
246
+
247
+ def delete_simulate(task_id: str, user_id: Optional[str] = None) -> bool:
248
+ record = _mock_simulates.get(task_id)
249
+ if not record:
250
+ return False
251
+ if user_id and record.get("user_id") != user_id:
252
+ return False
253
+ del _mock_simulates[task_id]
254
+ _save_to_disk()
255
+ return True
256
+
257
+
258
+ def list_openings(page: int = 1, page_size: int = 20, user_id: Optional[str] = None) -> dict:
259
+ records = [r for r in _mock_openings.values() if r.get("user_id") == user_id]
260
+ records.sort(key=lambda r: r["created_at"], reverse=True)
261
+ total = len(records)
262
+ start = (page - 1) * page_size
263
+ end = start + page_size
264
+ return {
265
+ "total": total,
266
+ "page": page,
267
+ "page_size": page_size,
268
+ "results": records[start:end],
269
+ }
270
+
271
+
272
+ def get_opening(task_id: str, user_id: Optional[str] = None) -> Optional[dict]:
273
+ record = _mock_openings.get(task_id)
274
+ if record and user_id and record.get("user_id") != user_id:
275
+ return None
276
+ return record
277
+
278
+
279
+ def delete_opening(task_id: str, user_id: Optional[str] = None) -> bool:
280
+ record = _mock_openings.get(task_id)
281
+ if not record:
282
+ return False
283
+ if user_id and record.get("user_id") != user_id:
284
+ return False
285
+ del _mock_openings[task_id]
286
+ _save_to_disk()
287
+ return True
288
+
289
+
290
+ def update_opening(opening_id: str, updates: dict) -> bool:
291
+ """Update an opening analysis record."""
292
+ if opening_id in _mock_openings:
293
+ _mock_openings[opening_id].update(updates)
294
+ _save_to_disk()
295
+ return True
296
+ return False
297
+
298
+
299
+ def list_extracts(page: int = 1, page_size: int = 20, user_id: Optional[str] = None) -> dict:
300
+ records = [r for r in _mock_extracts.values() if r.get("user_id") == user_id]
301
+ records.sort(key=lambda r: r["created_at"], reverse=True)
302
+ total = len(records)
303
+ start = (page - 1) * page_size
304
+ end = start + page_size
305
+ return {
306
+ "total": total,
307
+ "page": page,
308
+ "page_size": page_size,
309
+ "results": records[start:end],
310
+ }
311
+
312
+
313
+ def get_extract(result_id: str, user_id: Optional[str] = None) -> Optional[dict]:
314
+ record = _mock_extracts.get(result_id)
315
+ if record and user_id and record.get("user_id") != user_id:
316
+ return None
317
+ return record
318
+
319
+
320
+ def delete_extract(result_id: str, user_id: Optional[str] = None) -> bool:
321
+ record = _mock_extracts.get(result_id)
322
+ if not record:
323
+ return False
324
+ if user_id and record.get("user_id") != user_id:
325
+ return False
326
+ del _mock_extracts[result_id]
327
+ _save_to_disk()
328
+ return True
329
+
330
+
331
+ # --- Write Helpers (register data from other modules) ---
332
+
333
+
334
+ def add_file(record: dict, user_id: Optional[str] = None) -> dict:
335
+ """Register a file record (from upload or other source)."""
336
+ if "id" not in record:
337
+ record["id"] = str(uuid.uuid4())[:8]
338
+ if "created_at" not in record:
339
+ record["created_at"] = _now()
340
+ if user_id:
341
+ record["user_id"] = user_id
342
+ _mock_files[record["id"]] = record
343
+ _save_to_disk()
344
+ return record
345
+
346
+
347
+ def add_simulate(record: dict, user_id: Optional[str] = None) -> dict:
348
+ """Register a simulate task record."""
349
+ if "task_id" not in record:
350
+ record["task_id"] = str(uuid.uuid4())[:8]
351
+ if "created_at" not in record:
352
+ record["created_at"] = _now()
353
+ if "name" not in record:
354
+ ts = datetime.now().strftime("%m%d_%H%M")
355
+ file_names = record.get("file_names") or []
356
+ file_part = "_".join(n.rsplit(".", 1)[0] for n in file_names[:2]) if file_names else ""
357
+ record["name"] = f"模拟编制_{file_part}_{ts}" if file_part else f"模拟编制_{ts}"
358
+ if user_id:
359
+ record["user_id"] = user_id
360
+ _mock_simulates[record["task_id"]] = record
361
+ _save_to_disk()
362
+ return record
363
+
364
+
365
+ def add_opening(record: dict, user_id: Optional[str] = None) -> dict:
366
+ """Register an opening analysis result."""
367
+ if "id" not in record:
368
+ record["id"] = str(uuid.uuid4())[:8]
369
+ if "created_at" not in record:
370
+ record["created_at"] = _now()
371
+ if "name" not in record:
372
+ ts = datetime.now().strftime("%m%d_%H%M")
373
+ file_name = record.get("file_name", "")
374
+ if file_name:
375
+ file_part = file_name.rsplit(".", 1)[0]
376
+ record["name"] = f"开标分析_{file_part}_{ts}"
377
+ else:
378
+ project_name = (record.get("meta") or {}).get("project_name", "")
379
+ record["name"] = f"开标分析_{project_name}_{ts}" if project_name else f"开标分析_{ts}"
380
+ if user_id:
381
+ record["user_id"] = user_id
382
+ _mock_openings[record["id"]] = record
383
+ _save_to_disk()
384
+ return record
385
+
386
+
387
+ def add_extract(record: dict, user_id: Optional[str] = None) -> dict:
388
+ """Register an extraction result."""
389
+ if "id" not in record:
390
+ record["id"] = str(uuid.uuid4())[:8]
391
+ if "created_at" not in record:
392
+ record["created_at"] = _now()
393
+ if "name" not in record:
394
+ ts = datetime.now().strftime("%m%d_%H%M")
395
+ file_name = record.get("file_name", "")
396
+ if file_name:
397
+ file_part = file_name.rsplit(".", 1)[0]
398
+ record["name"] = f"要素提取_{file_part}_{ts}"
399
+ else:
400
+ template = record.get("template_type", "")
401
+ record["name"] = f"要素提取_{template}_{ts}" if template else f"要素提取_{ts}"
402
+ if user_id:
403
+ record["user_id"] = user_id
404
+ _mock_extracts[record["id"]] = record
405
+ _save_to_disk()
406
+ return record
407
+
408
+
409
+ def update_file(file_id: str, updates: dict) -> bool:
410
+ """Update a file record."""
411
+ if file_id in _mock_files:
412
+ _mock_files[file_id].update(updates)
413
+ _save_to_disk()
414
+ return True
415
+ return False
416
+
417
+
418
+ def update_simulate(task_id: str, updates: dict) -> bool:
419
+ """Update a simulate task record."""
420
+ if task_id in _mock_simulates:
421
+ _mock_simulates[task_id].update(updates)
422
+ _save_to_disk()
423
+ return True
424
+ return False
425
+
426
+
427
+ # --- User Helpers ---
428
+
429
+
430
+ def add_user(record: dict) -> dict:
431
+ """Register a user record."""
432
+ if "id" not in record:
433
+ record["id"] = str(uuid.uuid4())[:8]
434
+ if "created_at" not in record:
435
+ record["created_at"] = _now()
436
+ _mock_users[record["id"]] = record
437
+ _save_to_disk()
438
+ return record
439
+
440
+
441
+ def get_user_by_username(username: str) -> Optional[dict]:
442
+ """Find a user by username."""
443
+ for user in _mock_users.values():
444
+ if user.get("username") == username:
445
+ return user
446
+ return None
447
+
448
+
449
+ def get_user_by_email(email: str) -> Optional[dict]:
450
+ """Find a user by email."""
451
+ for user in _mock_users.values():
452
+ if user.get("email") == email:
453
+ return user
454
+ return None
455
+
456
+
457
+ def get_user_by_id(user_id: str) -> Optional[dict]:
458
+ """Find a user by ID."""
459
+ return _mock_users.get(user_id)
460
+
461
+
462
+ # ==============================================
463
+ # API Key Storage
464
+ # ==============================================
465
+
466
+
467
+ def save_api_key(user_id: str, provider: str, encrypted_key: str) -> dict:
468
+ """Save or update an encrypted API key for a user/provider pair."""
469
+ key = f"{user_id}:{provider}"
470
+ record = {
471
+ "user_id": user_id,
472
+ "provider": provider,
473
+ "encrypted_key": encrypted_key,
474
+ "updated_at": _now(),
475
+ }
476
+ _mock_api_keys[key] = record
477
+ _save_to_disk()
478
+ return record
479
+
480
+
481
+ def get_api_key(user_id: str, provider: str) -> Optional[str]:
482
+ """Get encrypted API key for a user/provider pair. Returns None if not found."""
483
+ record = _mock_api_keys.get(f"{user_id}:{provider}")
484
+ return record["encrypted_key"] if record else None
485
+
486
+
487
+ def delete_api_key(user_id: str, provider: str) -> bool:
488
+ """Delete API key for a user/provider pair."""
489
+ key = f"{user_id}:{provider}"
490
+ if key in _mock_api_keys:
491
+ del _mock_api_keys[key]
492
+ _save_to_disk()
493
+ return True
494
+ return False
495
+
496
+
497
+ def list_user_api_keys(user_id: str) -> list[dict]:
498
+ """List all providers for which a user has stored keys (no sensitive data returned)."""
499
+ return [
500
+ {"provider": v["provider"], "updated_at": v["updated_at"]}
501
+ for v in _mock_api_keys.values()
502
+ if v["user_id"] == user_id
503
+ ]
504
+
505
+
506
+ # ==============================================
507
+ # Verification Code Storage (in-memory, not persisted)
508
+ # ==============================================
509
+ _verification_codes: dict = {}
510
+
511
+
512
+ def save_verification_code(email: str, code: str, expires_at: datetime) -> None:
513
+ _verification_codes[email] = {"code": code, "expires_at": expires_at.isoformat()}
514
+
515
+
516
+ def verify_code(email: str, code: str) -> bool:
517
+ record = _verification_codes.get(email)
518
+ if not record:
519
+ return False
520
+ if record["code"] != code:
521
+ return False
522
+ if datetime.fromisoformat(record["expires_at"]) < datetime.now(timezone.utc):
523
+ del _verification_codes[email]
524
+ return False
525
+ return True
526
+
527
+
528
+ def delete_verification_code(email: str) -> None:
529
+ _verification_codes.pop(email, None)
530
+
531
+
532
+ # ==============================================
533
+ # Password Reset Token Storage (in-memory, not persisted)
534
+ # ==============================================
535
+ _reset_tokens: dict = {}
536
+
537
+
538
+ def save_reset_token(token: str, user_id: str, expires_at: datetime) -> None:
539
+ _reset_tokens[token] = {"user_id": user_id, "expires_at": expires_at.isoformat()}
540
+
541
+
542
+ def get_reset_token(token: str) -> Optional[dict]:
543
+ record = _reset_tokens.get(token)
544
+ if not record:
545
+ return None
546
+ if datetime.fromisoformat(record["expires_at"]) < datetime.now(timezone.utc):
547
+ del _reset_tokens[token]
548
+ return None
549
+ return record
550
+
551
+
552
+ def delete_reset_token(token: str) -> None:
553
+ _reset_tokens.pop(token, None)
554
+
555
+
556
+ def update_user_password(user_id: str, password_hash: str, salt_hex: str) -> bool:
557
+ user = _mock_users.get(user_id)
558
+ if not user:
559
+ return False
560
+ user["password_hash"] = password_hash
561
+ user["salt"] = salt_hex
562
+ _save_to_disk()
563
+ return True