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,463 @@
1
+ from __future__ import annotations
2
+ """
3
+ LiteLLM integration for multi-provider LLM support.
4
+ """
5
+ import json as _json
6
+ import logging
7
+ import time
8
+ from typing import AsyncGenerator, Any
9
+
10
+ import httpx
11
+ from app.config import get_settings
12
+ from app.infrastructure.log_collector import add_log as _add_log
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class LiteLLMService:
18
+ """LLM service using LiteLLM for unified multi-provider access."""
19
+
20
+ # Model mappings - must use litellm provider prefix format
21
+ MODEL_MAP = {
22
+ "openai": "openai/gpt-4o",
23
+ "deepseek": "deepseek/deepseek-chat",
24
+ "claude": "anthropic/claude-sonnet-4-20250514",
25
+ "dashscope": "openai/qwen3.6-plus",
26
+ "zhipu": "openai/glm-4-flash",
27
+ "minimax": "openai/MiniMax-M2.7",
28
+ "ollama": "ollama/llama3",
29
+ }
30
+
31
+ # 这些供应商通过 httpx 直接调用 OpenAI 兼容 API,
32
+ # 不经过 OpenAI SDK(Pydantic 响应校验与第三方不完全兼容)
33
+ # 也不经过 LiteLLM(openai/ 前缀无法可靠转发 api_base)
34
+ OPENAI_COMPATIBLE_PROVIDERS = {"zhipu", "dashscope", "minimax"}
35
+
36
+ def __init__(self):
37
+ self.settings = get_settings()
38
+
39
+ async def _demo_complete(self, messages: list[dict], stream: bool = True) -> AsyncGenerator[str, None]:
40
+ user_text = "\n".join(str(message.get("content", "")) for message in messages if message.get("role") == "user")
41
+ if "JSON" in user_text or "json" in user_text or "elements" in user_text:
42
+ text = _json.dumps(
43
+ {
44
+ "elements": [
45
+ {"name": "项目基本信息", "content": "本地 demo 模式已读取上传文件,可继续验证要素提取流程。"},
46
+ {"name": "资质要求", "content": "投标人需具备与项目相匹配的资质条件,具体以招标文件为准。"},
47
+ {"name": "评标办法", "content": "建议重点核对价格分、技术分、商务分及否决条款。"},
48
+ ]
49
+ },
50
+ ensure_ascii=False,
51
+ )
52
+ else:
53
+ text = "\n".join([
54
+ "## 本地 demo 分析结果",
55
+ "- 已在不调用外部 AI 的情况下完成演示流程。",
56
+ "- 上传、解析、任务推进和结果展示链路可用于本地功能验证。",
57
+ "- 正式分析请在 AI 设置中配置可用供应商 API Key。",
58
+ ])
59
+
60
+ if stream:
61
+ for index in range(0, len(text), 24):
62
+ yield text[index:index + 24]
63
+ else:
64
+ yield text
65
+
66
+ async def _get_api_key(self, provider: str, user_id: str = None) -> str:
67
+ """Get API key for provider. Checks user-stored key first, then falls back to env vars."""
68
+ if user_id:
69
+ try:
70
+ from app.infrastructure.pg_storage import get_api_key as _get_user_key
71
+ encrypted = await _get_user_key(user_id, provider)
72
+ if encrypted:
73
+ from app.services.encryption_service import get_encryption_service
74
+ key = get_encryption_service().decrypt(encrypted.encode()).decode().strip()
75
+ logger.info("使用用户存储的 %s API Key: %s...%s", provider, key[:6], key[-4:] if len(key) > 10 else "")
76
+ return key
77
+ except Exception as e:
78
+ logger.warning("解密 %s API Key 失败(user_id=%s): %s,将回退到环境变量", provider, user_id, e)
79
+
80
+ # 2. Fall back to environment variable
81
+ key_map = {
82
+ "openai": self.settings.openai_api_key,
83
+ "deepseek": self.settings.deepseek_api_key,
84
+ "claude": self.settings.claude_api_key,
85
+ "dashscope": self.settings.dashscope_api_key,
86
+ "zhipu": self.settings.zhipu_api_key,
87
+ "minimax": self.settings.minimax_api_key,
88
+ "ollama": self.settings.ollama_base_url,
89
+ }
90
+ key = key_map.get(provider, "")
91
+ if not key and provider != "ollama":
92
+ raise ValueError(f"未配置 {provider} 的 API Key,请在「AI 设置」中添加")
93
+ return key
94
+
95
+ def _get_model_name(self, provider: str, model: str = None) -> str:
96
+ """Get model name for provider with litellm prefix format."""
97
+ if model:
98
+ # If model already has provider prefix, use as-is
99
+ if "/" in model:
100
+ return model
101
+ # Otherwise add provider prefix for litellm
102
+ provider_prefixes = {
103
+ "deepseek": "deepseek/",
104
+ "openai": "openai/",
105
+ "claude": "anthropic/",
106
+ "dashscope": "openai/",
107
+ "zhipu": "openai/",
108
+ "minimax": "openai/",
109
+ "ollama": "ollama/",
110
+ }
111
+ prefix = provider_prefixes.get(provider, f"{provider}/")
112
+ return f"{prefix}{model}"
113
+ return self.MODEL_MAP.get(provider, "gpt-4o")
114
+
115
+ async def _complete_via_openai_compat(
116
+ self,
117
+ provider: str,
118
+ messages: list[dict],
119
+ model: str = None,
120
+ stream: bool = True,
121
+ user_id: str = None,
122
+ api_key_override: str = None,
123
+ temperature: float = None,
124
+ ) -> AsyncGenerator[str, None]:
125
+ """通过 httpx 直接调用第三方 OpenAI 兼容 API(绕过 OpenAI SDK 的 Pydantic 校验)。"""
126
+ model_name = self._get_model_name(provider, model)
127
+ if "/" in model_name:
128
+ model_name = model_name.split("/", 1)[1]
129
+
130
+ api_key = api_key_override or await self._get_api_key(provider, user_id)
131
+ if not api_key:
132
+ raise ValueError(f"未配置 {provider} 的 API Key,请在设置页面填写后保存")
133
+
134
+ base_url = {
135
+ "zhipu": self.settings.zhipu_base_url,
136
+ "dashscope": self.settings.dashscope_base_url,
137
+ "minimax": self.settings.minimax_base_url,
138
+ }[provider].rstrip("/")
139
+
140
+ url = f"{base_url}/chat/completions"
141
+ headers = {
142
+ "Authorization": f"Bearer {api_key}",
143
+ "Content-Type": "application/json",
144
+ }
145
+ payload = {
146
+ "model": model_name,
147
+ "messages": messages,
148
+ "stream": stream,
149
+ }
150
+ if temperature is not None:
151
+ payload["temperature"] = temperature
152
+
153
+ logger.info("调用 %s API: model=%s, base_url=%s", provider, model_name, base_url)
154
+ timeout = httpx.Timeout(180.0, connect=10.0)
155
+
156
+ try:
157
+ async with httpx.AsyncClient(timeout=timeout) as client:
158
+ if stream:
159
+ async with client.stream("POST", url, json=payload, headers=headers) as response:
160
+ if response.status_code != 200:
161
+ error_body = await response.aread()
162
+ error_msg = _parse_api_error(error_body, response.status_code)
163
+ raise RuntimeError(error_msg)
164
+
165
+ in_think = False
166
+ async for line in response.aiter_lines():
167
+ if not line.startswith("data: "):
168
+ continue
169
+ data = line[6:]
170
+ if data.strip() == "[DONE]":
171
+ break
172
+ try:
173
+ chunk = _json.loads(data)
174
+ if "error" in chunk:
175
+ err_detail = chunk["error"]
176
+ if isinstance(err_detail, dict):
177
+ err_msg = err_detail.get("message", str(err_detail))
178
+ else:
179
+ err_msg = str(err_detail)
180
+ logger.error("API 流式错误 (%s): %s", provider, err_msg)
181
+ raise RuntimeError(f"{provider} API 错误: {err_msg}")
182
+ choices = chunk.get("choices", [])
183
+ if not choices:
184
+ continue
185
+ delta = choices[0].get("delta", {})
186
+ # qwen3.x 思考模式:跳过 reasoning_content,只输出 content
187
+ content = delta.get("content", "")
188
+ if content:
189
+ if "<think>" in content:
190
+ in_think = True
191
+ before = content.split("<think>")[0]
192
+ if before:
193
+ yield before
194
+ continue
195
+ if "</think>" in content:
196
+ in_think = False
197
+ after = content.split("</think>", 1)[1]
198
+ if after:
199
+ yield after
200
+ continue
201
+ if in_think:
202
+ continue
203
+ yield content
204
+ except _json.JSONDecodeError:
205
+ continue
206
+ else:
207
+ response = await client.post(url, json=payload, headers=headers)
208
+ if response.status_code != 200:
209
+ error_msg = _parse_api_error(response.content, response.status_code)
210
+ raise RuntimeError(error_msg)
211
+
212
+ result = response.json()
213
+ choices = result.get("choices", [])
214
+ if not choices:
215
+ yield ""
216
+ return
217
+ msg = choices[0].get("message", {})
218
+ # qwen3.x 思考模式:优先取 content,忽略 reasoning_content
219
+ content = msg.get("content", "")
220
+ yield content
221
+
222
+ except httpx.TimeoutException:
223
+ raise RuntimeError(f"{provider} API 请求超时,请检查网络连接或稍后重试")
224
+ except httpx.ConnectError:
225
+ raise RuntimeError(f"无法连接到 {provider} API({base_url}),请检查网络或 API 地址是否正确")
226
+ except RuntimeError:
227
+ raise
228
+ except Exception as e:
229
+ raise RuntimeError(f"调用 {provider} API 失败: {e}") from e
230
+
231
+ async def complete(
232
+ self,
233
+ provider: str,
234
+ messages: list[dict],
235
+ model: str = None,
236
+ stream: bool = True,
237
+ user_id: str = None,
238
+ api_key_override: str = None,
239
+ temperature: float = None,
240
+ ) -> AsyncGenerator[str, None] | str:
241
+ """
242
+ Call LLM with messages.
243
+
244
+ Args:
245
+ provider: LLM provider name
246
+ messages: Chat messages
247
+ model: Optional model override
248
+ stream: Whether to stream response
249
+ user_id: Optional user ID for per-user API key lookup
250
+ api_key_override: Optional API key override (bypasses stored/env key lookup)
251
+ temperature: Optional sampling temperature (0.0-2.0, lower = more deterministic)
252
+
253
+ Yields:
254
+ Response chunks if streaming
255
+ """
256
+ if self.settings.demo_mode or self.settings.auth_disabled:
257
+ async for chunk in self._demo_complete(messages, stream):
258
+ yield chunk
259
+ return
260
+
261
+ # OpenAI 兼容第三方供应商走 httpx 直接调用
262
+ if provider in self.OPENAI_COMPATIBLE_PROVIDERS:
263
+ resolved_model = self._get_model_name(provider, model)
264
+ if "/" in resolved_model:
265
+ resolved_model = resolved_model.split("/", 1)[1]
266
+ _add_log("info", "llm_call", f"{provider}/{resolved_model} 调用开始 (openai_compat)", user_id=user_id)
267
+ try:
268
+ async for chunk in self._complete_via_openai_compat(provider, messages, model, stream, user_id, api_key_override=api_key_override, temperature=temperature):
269
+ yield chunk
270
+ except Exception as e:
271
+ _add_log("error", "llm_call", f"{provider}/{resolved_model} 调用失败: {str(e)[:200]}", user_id=user_id)
272
+ raise
273
+ return
274
+
275
+ try:
276
+ from litellm import acompletion
277
+
278
+ model_name = self._get_model_name(provider, model)
279
+ api_key = api_key_override or await self._get_api_key(provider, user_id)
280
+ _add_log("info", "llm_call", f"{provider}/{model_name} 调用开始", user_id=user_id)
281
+
282
+ # Configure based on provider
283
+ kwargs = {
284
+ "model": model_name,
285
+ "messages": messages,
286
+ "stream": stream,
287
+ }
288
+
289
+ if temperature is not None:
290
+ kwargs["temperature"] = temperature
291
+
292
+ # API key: all providers except ollama
293
+ if provider != "ollama":
294
+ kwargs["api_key"] = api_key
295
+
296
+ # API base URL: providers with custom endpoints
297
+ api_base_map = {
298
+ "deepseek": self.settings.deepseek_base_url,
299
+ "ollama": self.settings.ollama_base_url,
300
+ "zhipu": self.settings.zhipu_base_url,
301
+ "dashscope": self.settings.dashscope_base_url,
302
+ "minimax": self.settings.minimax_base_url,
303
+ }
304
+ if provider in api_base_map:
305
+ kwargs["api_base"] = api_base_map[provider]
306
+
307
+ response = await acompletion(**kwargs)
308
+
309
+ if stream:
310
+ in_think = False
311
+ async for chunk in response:
312
+ if not chunk.choices:
313
+ continue
314
+ content = chunk.choices[0].delta.content
315
+ if content:
316
+ if "<think>" in content:
317
+ in_think = True
318
+ before = content.split("<think>")[0]
319
+ if before:
320
+ yield before
321
+ continue
322
+ if "</think>" in content:
323
+ in_think = False
324
+ after = content.split("</think>", 1)[1]
325
+ if after:
326
+ yield after
327
+ continue
328
+ if in_think:
329
+ continue
330
+ yield content
331
+ else:
332
+ yield response.choices[0].message.content if response.choices else ""
333
+
334
+ except Exception as e:
335
+ _add_log("error", "llm_call", f"{provider} 调用失败: {str(e)[:200]}", user_id=user_id)
336
+ raise
337
+
338
+ async def test_connection(self, provider: str, user_id: str = None, model: str = None, api_key: str = None) -> dict[str, Any]:
339
+ """
340
+ Test connection to a provider.
341
+
342
+ Args:
343
+ provider: Provider name
344
+ user_id: Optional user ID for per-user API key lookup
345
+ model: Optional model name to test with
346
+ api_key: Optional API key override (for testing before saving)
347
+
348
+ Returns:
349
+ Dict with success status, latency, and optional error
350
+ """
351
+ # 先检查是否有可用的 API Key
352
+ effective_key = api_key or await self._get_api_key(provider, user_id)
353
+ if not effective_key and provider != "ollama":
354
+ return {
355
+ "success": False,
356
+ "error": f"未配置 {provider} 的 API Key,请在设置页面填写后保存",
357
+ "message": "Connection failed",
358
+ }
359
+
360
+ start_time = time.time()
361
+
362
+ try:
363
+ messages = [{"role": "user", "content": "Hi"}]
364
+ response_text = ""
365
+ async for chunk in self.complete(provider, messages, model=model, stream=False, user_id=user_id, api_key_override=effective_key):
366
+ response_text += chunk
367
+
368
+ if response_text.startswith("Error:"):
369
+ return {
370
+ "success": False,
371
+ "error": response_text,
372
+ "message": "Connection failed",
373
+ }
374
+
375
+ latency_ms = int((time.time() - start_time) * 1000)
376
+ return {
377
+ "success": True,
378
+ "message": "Connection successful",
379
+ "latencyMs": latency_ms,
380
+ }
381
+ except Exception as e:
382
+ return {
383
+ "success": False,
384
+ "error": str(e),
385
+ "message": "Connection failed",
386
+ }
387
+
388
+ @staticmethod
389
+ def get_providers() -> list[dict]:
390
+ """Get list of supported providers with their models."""
391
+ return [
392
+ {
393
+ "id": "deepseek",
394
+ "name": "DeepSeek",
395
+ "models": ["deepseek-chat", "deepseek-reasoner"],
396
+ },
397
+ {
398
+ "id": "dashscope",
399
+ "name": "阿里百炼",
400
+ "models": [
401
+ "qwen3.7-max", "qwen3.6-plus", "qwen3.6-flash",
402
+ "qwen-max", "qwen-plus", "qwen-turbo", "qwen-coder-turbo",
403
+ "qwen-vl-ocr", "qwen3-vl-plus", "qwen-vl-plus", "qwen-vl-max",
404
+ ],
405
+ },
406
+ {
407
+ "id": "zhipu",
408
+ "name": "智谱 AI",
409
+ "models": ["glm-4-flash", "glm-4-air", "glm-4-plus", "glm-5.1", "glm-4v", "glm-4v-plus"],
410
+ },
411
+ {
412
+ "id": "minimax",
413
+ "name": "MiniMax",
414
+ "models": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2-Her"],
415
+ },
416
+ {
417
+ "id": "openai",
418
+ "name": "OpenAI",
419
+ "models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
420
+ },
421
+ {
422
+ "id": "claude",
423
+ "name": "Claude",
424
+ "models": ["claude-sonnet-4-20250514", "claude-opus-4-5-20251101"],
425
+ },
426
+ {
427
+ "id": "ollama",
428
+ "name": "Ollama (本地)",
429
+ "models": ["llama3", "mixtral", "codellama"],
430
+ },
431
+ ]
432
+
433
+
434
+ def _parse_api_error(body: bytes, status_code: int) -> str:
435
+ """解析第三方 API 的错误响应,返回可读的错误消息。"""
436
+ try:
437
+ error_json = _json.loads(body)
438
+ # OpenAI 标准格式: {"error": {"message": "...", "type": "..."}}
439
+ if "error" in error_json:
440
+ err = error_json["error"]
441
+ if isinstance(err, dict):
442
+ msg = err.get("message", str(err))
443
+ err_type = err.get("type", "")
444
+ if err_type:
445
+ return f"API 错误 ({status_code}): {msg}({err_type})"
446
+ return f"API 错误 ({status_code}): {msg}"
447
+ return f"API 错误 ({status_code}): {err}"
448
+ # 其他格式
449
+ return f"API 错误 ({status_code}): {_json.dumps(error_json, ensure_ascii=False)[:200]}"
450
+ except Exception:
451
+ return f"API 错误 ({status_code}): {body.decode(errors='replace')[:200]}"
452
+
453
+
454
+ # Global LLM service instance
455
+ _llm_service: LiteLLMService | None = None
456
+
457
+
458
+ def get_llm_service() -> LiteLLMService:
459
+ """Get or create LLM service instance."""
460
+ global _llm_service
461
+ if _llm_service is None:
462
+ _llm_service = LiteLLMService()
463
+ return _llm_service
@@ -0,0 +1,64 @@
1
+ """
2
+ 内存日志收集器。
3
+ 记录 API 请求和 LLM 调用日志,供前端查询。
4
+ 最多保留 500 条,FIFO 淘汰。
5
+ """
6
+ import threading
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from typing import Optional
10
+
11
+ _logs: list[dict] = []
12
+ _lock = threading.Lock()
13
+ MAX_LOGS = 500
14
+
15
+
16
+ def add_log(
17
+ level: str,
18
+ category: str,
19
+ message: str,
20
+ detail: Optional[str] = None,
21
+ user_id: Optional[str] = None,
22
+ ) -> dict:
23
+ entry = {
24
+ "id": str(uuid.uuid4())[:8],
25
+ "timestamp": datetime.now(timezone.utc).isoformat(),
26
+ "source": "backend",
27
+ "level": level,
28
+ "category": category,
29
+ "message": message,
30
+ "detail": detail,
31
+ "user_id": user_id,
32
+ }
33
+ with _lock:
34
+ _logs.append(entry)
35
+ if len(_logs) > MAX_LOGS:
36
+ _logs[:] = _logs[-MAX_LOGS:]
37
+ return entry
38
+
39
+
40
+ def get_logs(
41
+ limit: int = 100,
42
+ level: Optional[str] = None,
43
+ user_id: Optional[str] = None,
44
+ ) -> list[dict]:
45
+ with _lock:
46
+ filtered = _logs[:]
47
+ if user_id:
48
+ filtered = [l for l in filtered if l.get("user_id") == user_id or l.get("user_id") is None]
49
+ if level:
50
+ filtered = [l for l in filtered if l["level"] == level]
51
+ filtered.sort(key=lambda x: x["timestamp"], reverse=True)
52
+ return filtered[:limit]
53
+
54
+
55
+ def clear_logs(user_id: Optional[str] = None) -> int:
56
+ with _lock:
57
+ if user_id:
58
+ before = len(_logs)
59
+ _logs[:] = [l for l in _logs if l.get("user_id") != user_id and l.get("user_id") is not None]
60
+ return before - len(_logs)
61
+ else:
62
+ count = len(_logs)
63
+ _logs.clear()
64
+ return count