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/cli.py
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from app.config import get_settings
|
|
16
|
+
from app.infrastructure.llm.lite_llm import LiteLLMService
|
|
17
|
+
from app.services.extract_service import (
|
|
18
|
+
_elements_from_plain_text,
|
|
19
|
+
_normalize_elements,
|
|
20
|
+
_parse_llm_json_response,
|
|
21
|
+
extract_text_from_content,
|
|
22
|
+
)
|
|
23
|
+
from app.services.prompt_builder import get_prompt_builder
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
APP_NAME = "Bid Master CLI"
|
|
27
|
+
MAX_CHARS = 200000
|
|
28
|
+
DEFAULT_API_URL = "https://bid-master-v2.vercel.app"
|
|
29
|
+
CREDENTIALS_PATH = Path.home() / ".bid-master" / "credentials.json"
|
|
30
|
+
SUPPORTED_FILE_SUFFIXES = {".pdf", ".doc", ".docx", ".md", ".txt", ".xlsx", ".xls", ".csv"}
|
|
31
|
+
QUOTE_FILE_SUFFIXES = {".xlsx", ".xls", ".csv"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CliError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normalize_argv(argv: list[str]) -> list[str]:
|
|
39
|
+
commands = {"auth", "tools", "extract", "analyze", "quote"}
|
|
40
|
+
if not argv or argv[0] in commands or argv[0] in {"-h", "--help", "-v", "--version"}:
|
|
41
|
+
return argv
|
|
42
|
+
return ["run", *argv]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BidMasterArgumentParser(argparse.ArgumentParser):
|
|
46
|
+
def format_help(self) -> str:
|
|
47
|
+
help_text = super().format_help()
|
|
48
|
+
help_text = help_text.replace("{run,auth,tools,extract,analyze,quote}", "{auth,tools,extract,analyze,quote}")
|
|
49
|
+
help_text = help_text.replace(" run ==SUPPRESS==\n", "")
|
|
50
|
+
help_text = help_text.replace(
|
|
51
|
+
"usage: bidmaster [-h] [-v] {auth,tools,extract,analyze,quote} ...",
|
|
52
|
+
"usage: bidmaster [-h] [-v] <文件或目录> [选项]\n bidmaster auth [login|status|logout]\n bidmaster tools [list|info]\n bidmaster extract|analyze|quote <文件> [选项]",
|
|
53
|
+
)
|
|
54
|
+
return help_text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> None:
|
|
58
|
+
argv = normalize_argv(sys.argv[1:])
|
|
59
|
+
parser = build_parser()
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
if not args.command:
|
|
63
|
+
parser.print_help()
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
asyncio.run(run_command(args))
|
|
68
|
+
except CliError as error:
|
|
69
|
+
print_error(str(error))
|
|
70
|
+
raise SystemExit(1) from error
|
|
71
|
+
except KeyboardInterrupt:
|
|
72
|
+
print("\n已取消")
|
|
73
|
+
raise SystemExit(130)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
77
|
+
parser = BidMasterArgumentParser(
|
|
78
|
+
prog="bidmaster",
|
|
79
|
+
description="招投标智能分析工具箱 CLI。默认用法:bidmaster <文件或目录>,自动调用网页端服务生成分析结果。",
|
|
80
|
+
epilog=(
|
|
81
|
+
"示例:\n"
|
|
82
|
+
" bidmaster tender.pdf --out summary.md\n"
|
|
83
|
+
" bidmaster opening.xlsx --type quote --out quote-report.md\n"
|
|
84
|
+
" bidmaster ./docs/ --out ./results -w 2 -y\n"
|
|
85
|
+
" bidmaster auth\n"
|
|
86
|
+
" bidmaster auth status\n"
|
|
87
|
+
" bidmaster tools list\n\n"
|
|
88
|
+
"主命令选项:\n"
|
|
89
|
+
" -o, --out PATH 输出文件或目录\n"
|
|
90
|
+
" -q, --ai 使用网页端 AI 增强分析模式\n"
|
|
91
|
+
" -w, --workers N 批量处理并发数\n"
|
|
92
|
+
" -y, --yes 跳过确认提示\n"
|
|
93
|
+
" --type TASK extract、analyze 或 quote\n"
|
|
94
|
+
" --format FORMAT md 或 json\n"
|
|
95
|
+
" --local 使用本地环境执行"
|
|
96
|
+
),
|
|
97
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument("-v", "--version", action="version", version="Bid Master CLI 1.0.0")
|
|
100
|
+
|
|
101
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
102
|
+
run_parser = subparsers.add_parser("run", help=argparse.SUPPRESS)
|
|
103
|
+
run_parser.add_argument("input", help="输入文件或目录。PDF/DOCX/MD/TXT 默认做要素提取,XLSX/CSV 默认做开标报价分析")
|
|
104
|
+
run_parser.add_argument("-o", "--out", default=None, help="输出文件或目录路径")
|
|
105
|
+
run_parser.add_argument("-q", "--ai", action="store_true", help="使用网页端 AI 增强分析模式")
|
|
106
|
+
run_parser.add_argument("-w", "--workers", type=int, default=1, help="批量处理并发数,默认 1")
|
|
107
|
+
run_parser.add_argument("-y", "--yes", action="store_true", help="跳过需要确认的提示,适合脚本自动化")
|
|
108
|
+
run_parser.add_argument("--type", choices=["extract", "analyze", "quote"], default=None, help="指定任务类型,默认按文件类型自动判断")
|
|
109
|
+
run_parser.add_argument("--format", choices=["md", "json"], default="md", help="输出格式,默认 md")
|
|
110
|
+
run_parser.add_argument("--provider", default=None, help="AI 供应商,默认读取网页端配置")
|
|
111
|
+
run_parser.add_argument("--model", default=None, help="模型名称")
|
|
112
|
+
run_parser.add_argument("--modules", default=None, help="开标分析模块,多个模块用英文逗号分隔")
|
|
113
|
+
run_parser.add_argument("--local", action="store_true", help="使用本地环境执行,不调用网页端服务")
|
|
114
|
+
|
|
115
|
+
auth_parser = subparsers.add_parser("auth", help="网页登录授权与本地凭证管理")
|
|
116
|
+
auth_parser.add_argument("--api-url", default=None, help="网页端地址,默认使用线上服务")
|
|
117
|
+
auth_parser.add_argument("--no-browser", action="store_true", help="只打印授权链接,不自动打开浏览器")
|
|
118
|
+
auth_subparsers = auth_parser.add_subparsers(dest="auth_command")
|
|
119
|
+
auth_login = auth_subparsers.add_parser("login", help="打开网页授权本机 CLI")
|
|
120
|
+
auth_login.add_argument("--api-url", default=None, help="网页端地址,默认使用线上服务")
|
|
121
|
+
auth_login.add_argument("--no-browser", action="store_true", help="只打印授权链接,不自动打开浏览器")
|
|
122
|
+
auth_subparsers.add_parser("status", help="查看当前 CLI 登录状态")
|
|
123
|
+
auth_subparsers.add_parser("logout", help="清除本地 CLI 登录凭证")
|
|
124
|
+
|
|
125
|
+
tools_parser = subparsers.add_parser("tools", help="辅助工具命令")
|
|
126
|
+
tools_subparsers = tools_parser.add_subparsers(dest="tool_command")
|
|
127
|
+
tools_subparsers.add_parser("list", help="列出可用工具")
|
|
128
|
+
info_parser = tools_subparsers.add_parser("info", help="查看工具说明")
|
|
129
|
+
info_parser.add_argument("name", help="工具名称")
|
|
130
|
+
|
|
131
|
+
extract_parser = subparsers.add_parser("extract", help="提取招标文件关键内容")
|
|
132
|
+
add_file_options(extract_parser)
|
|
133
|
+
extract_parser.add_argument("-f", "--format", choices=["md", "json"], default="md", help="输出格式,默认 md")
|
|
134
|
+
extract_parser.add_argument("--provider", default=None, help="AI 供应商,默认读取网页端配置或 AI_PROVIDER")
|
|
135
|
+
extract_parser.add_argument("--model", default=None, help="模型名称")
|
|
136
|
+
extract_parser.add_argument("--local", action="store_true", help="使用本地环境执行,不调用网页端服务")
|
|
137
|
+
|
|
138
|
+
analyze_parser = subparsers.add_parser("analyze", help="生成招标文件分析报告")
|
|
139
|
+
add_file_options(analyze_parser)
|
|
140
|
+
analyze_parser.add_argument("--provider", default=None, help="AI 供应商,默认读取 AI_PROVIDER")
|
|
141
|
+
analyze_parser.add_argument("--model", default=None, help="模型名称")
|
|
142
|
+
analyze_parser.add_argument("--local", action="store_true", help="使用本地环境执行,不调用网页端服务")
|
|
143
|
+
|
|
144
|
+
quote_parser = subparsers.add_parser("quote", help="分析开标报价文件")
|
|
145
|
+
add_file_options(quote_parser)
|
|
146
|
+
quote_parser.add_argument("-f", "--format", choices=["md", "json"], default="md", help="输出格式,默认 md")
|
|
147
|
+
quote_parser.add_argument("--modules", default=None, help="分析模块,多个模块用英文逗号分隔")
|
|
148
|
+
quote_parser.add_argument("--provider", default=None, help="AI 供应商,默认读取网页端配置")
|
|
149
|
+
quote_parser.add_argument("--model", default=None, help="模型名称")
|
|
150
|
+
quote_parser.add_argument("--local", action="store_true", help="使用本地环境执行,不调用网页端服务")
|
|
151
|
+
|
|
152
|
+
return parser
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def add_file_options(parser: argparse.ArgumentParser) -> None:
|
|
156
|
+
parser.add_argument("file", help="输入文件路径")
|
|
157
|
+
parser.add_argument("-o", "--out", default=None, help="输出文件路径")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def coerce_task_args(args: argparse.Namespace, task_type: str, file_path: Path | None = None, out: str | None = None) -> argparse.Namespace:
|
|
161
|
+
source = str(file_path) if file_path else getattr(args, "file", None) or getattr(args, "input", None)
|
|
162
|
+
return argparse.Namespace(
|
|
163
|
+
file=source,
|
|
164
|
+
out=out if out is not None else getattr(args, "out", None),
|
|
165
|
+
format=getattr(args, "format", "md"),
|
|
166
|
+
provider=getattr(args, "provider", None),
|
|
167
|
+
model=getattr(args, "model", None),
|
|
168
|
+
modules=getattr(args, "modules", None),
|
|
169
|
+
local=getattr(args, "local", False),
|
|
170
|
+
type=task_type,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def infer_task_type(input_path: Path, explicit_type: str | None = None) -> str:
|
|
175
|
+
if explicit_type:
|
|
176
|
+
return explicit_type
|
|
177
|
+
suffix = input_path.suffix.lower()
|
|
178
|
+
if suffix in QUOTE_FILE_SUFFIXES:
|
|
179
|
+
return "quote"
|
|
180
|
+
return "extract"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def collect_input_files(input_path: Path) -> list[Path]:
|
|
184
|
+
if input_path.is_file():
|
|
185
|
+
return [input_path]
|
|
186
|
+
if not input_path.is_dir():
|
|
187
|
+
raise CliError(f"输入路径不存在:{input_path}")
|
|
188
|
+
files = [path for path in sorted(input_path.rglob("*")) if path.is_file() and path.suffix.lower() in SUPPORTED_FILE_SUFFIXES]
|
|
189
|
+
if not files:
|
|
190
|
+
raise CliError(f"目录中没有可处理文件:{input_path}")
|
|
191
|
+
return files
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def resolve_batch_output(out: str | None, input_root: Path, file_path: Path, task_type: str, output_format: str) -> str | None:
|
|
195
|
+
if not out:
|
|
196
|
+
return None
|
|
197
|
+
out_path = Path(out).expanduser().resolve()
|
|
198
|
+
if input_root.is_file():
|
|
199
|
+
return str(out_path)
|
|
200
|
+
suffix = "quote-report" if task_type == "quote" else "analysis" if task_type == "analyze" else "summary"
|
|
201
|
+
return str(out_path / f"{file_path.stem}-{suffix}.{output_format}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def run_auto(args: argparse.Namespace) -> None:
|
|
205
|
+
input_root = Path(args.input).expanduser().resolve()
|
|
206
|
+
files = collect_input_files(input_root)
|
|
207
|
+
if len(files) > 1 and not args.yes:
|
|
208
|
+
print(f"将处理 {len(files)} 个文件,并发数:{max(args.workers, 1)}")
|
|
209
|
+
workers = max(args.workers, 1)
|
|
210
|
+
if workers == 1:
|
|
211
|
+
for file_path in files:
|
|
212
|
+
await run_auto_file(args, input_root, file_path)
|
|
213
|
+
return
|
|
214
|
+
semaphore = asyncio.Semaphore(workers)
|
|
215
|
+
async def _run(file_path: Path) -> None:
|
|
216
|
+
async with semaphore:
|
|
217
|
+
await run_auto_file(args, input_root, file_path)
|
|
218
|
+
await asyncio.gather(*[_run(file_path) for file_path in files])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def run_auto_file(args: argparse.Namespace, input_root: Path, file_path: Path) -> None:
|
|
222
|
+
task_type = infer_task_type(file_path, args.type)
|
|
223
|
+
output_format = args.format
|
|
224
|
+
out = resolve_batch_output(args.out, input_root, file_path, task_type, output_format)
|
|
225
|
+
task_args = coerce_task_args(args, task_type, file_path, out)
|
|
226
|
+
if task_type == "quote":
|
|
227
|
+
if args.local:
|
|
228
|
+
run_quote(task_args)
|
|
229
|
+
else:
|
|
230
|
+
await run_remote_quote(task_args)
|
|
231
|
+
elif task_type == "analyze":
|
|
232
|
+
if args.local:
|
|
233
|
+
await run_analyze(task_args)
|
|
234
|
+
else:
|
|
235
|
+
await run_remote_analyze(task_args)
|
|
236
|
+
else:
|
|
237
|
+
if args.local:
|
|
238
|
+
await run_extract(task_args)
|
|
239
|
+
else:
|
|
240
|
+
await run_remote_extract(task_args)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def run_tools(args: argparse.Namespace) -> None:
|
|
244
|
+
tools = {
|
|
245
|
+
"extract": "招标文件关键内容提取:bidmaster extract tender.pdf --out summary.md",
|
|
246
|
+
"analyze": "招标文件风险分析:bidmaster analyze tender.pdf --out analysis.md",
|
|
247
|
+
"quote": "开标报价分析:bidmaster quote opening.xlsx --out quote-report.md",
|
|
248
|
+
}
|
|
249
|
+
if args.tool_command == "list":
|
|
250
|
+
for name, description in tools.items():
|
|
251
|
+
print(f"{name}\t{description}")
|
|
252
|
+
return
|
|
253
|
+
if args.tool_command == "info":
|
|
254
|
+
description = tools.get(args.name)
|
|
255
|
+
if not description:
|
|
256
|
+
raise CliError(f"未知工具:{args.name}")
|
|
257
|
+
print(description)
|
|
258
|
+
return
|
|
259
|
+
raise CliError("请指定 tools 子命令:list 或 info")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def run_command(args: argparse.Namespace) -> None:
|
|
263
|
+
if args.command == "auth":
|
|
264
|
+
await run_auth(args)
|
|
265
|
+
elif args.command == "tools":
|
|
266
|
+
run_tools(args)
|
|
267
|
+
elif args.command == "extract":
|
|
268
|
+
if args.local:
|
|
269
|
+
await run_extract(args)
|
|
270
|
+
else:
|
|
271
|
+
await run_remote_extract(args)
|
|
272
|
+
elif args.command == "analyze":
|
|
273
|
+
if args.local:
|
|
274
|
+
await run_analyze(args)
|
|
275
|
+
else:
|
|
276
|
+
await run_remote_analyze(args)
|
|
277
|
+
elif args.command == "quote":
|
|
278
|
+
if args.local:
|
|
279
|
+
run_quote(args)
|
|
280
|
+
else:
|
|
281
|
+
await run_remote_quote(args)
|
|
282
|
+
elif args.command == "run" or args.input:
|
|
283
|
+
await run_auto(args)
|
|
284
|
+
else:
|
|
285
|
+
raise CliError(f"未知命令:{args.command}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def run_auth(args: argparse.Namespace) -> None:
|
|
289
|
+
if args.auth_command in {None, "login"}:
|
|
290
|
+
await run_auth_login(args)
|
|
291
|
+
elif args.auth_command == "status":
|
|
292
|
+
await run_auth_status()
|
|
293
|
+
elif args.auth_command == "logout":
|
|
294
|
+
run_auth_logout()
|
|
295
|
+
else:
|
|
296
|
+
raise CliError("请指定 auth 子命令:login、status 或 logout")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def run_auth_login(args: argparse.Namespace) -> None:
|
|
300
|
+
api_url = normalize_api_url(args.api_url)
|
|
301
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
302
|
+
try:
|
|
303
|
+
start_res = await client.post(f"{api_url}/api/cli-auth/start")
|
|
304
|
+
except httpx.HTTPError as error:
|
|
305
|
+
raise CliError(f"无法连接网页端服务:{error}") from error
|
|
306
|
+
if start_res.status_code != 200:
|
|
307
|
+
raise CliError(f"创建 CLI 授权请求失败:HTTP {start_res.status_code} {start_res.text}")
|
|
308
|
+
|
|
309
|
+
data = start_res.json()
|
|
310
|
+
verification_uri = data["verification_uri"]
|
|
311
|
+
device_code = data["device_code"]
|
|
312
|
+
interval = int(data.get("interval") or 2)
|
|
313
|
+
expires_in = int(data.get("expires_in") or 600)
|
|
314
|
+
|
|
315
|
+
print("请在浏览器中授权 Bid Master CLI:", flush=True)
|
|
316
|
+
print(verification_uri, flush=True)
|
|
317
|
+
print(flush=True)
|
|
318
|
+
if not args.no_browser:
|
|
319
|
+
webbrowser.open(verification_uri)
|
|
320
|
+
print("等待网页端授权...", flush=True)
|
|
321
|
+
|
|
322
|
+
deadline = time.monotonic() + expires_in
|
|
323
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
324
|
+
while time.monotonic() < deadline:
|
|
325
|
+
await asyncio.sleep(interval)
|
|
326
|
+
try:
|
|
327
|
+
poll_res = await client.post(
|
|
328
|
+
f"{api_url}/api/cli-auth/poll",
|
|
329
|
+
json={"device_code": device_code},
|
|
330
|
+
)
|
|
331
|
+
except httpx.HTTPError as error:
|
|
332
|
+
raise CliError(f"轮询授权状态失败:{error}") from error
|
|
333
|
+
|
|
334
|
+
if poll_res.status_code != 200:
|
|
335
|
+
raise CliError(f"轮询授权状态失败:HTTP {poll_res.status_code} {poll_res.text}")
|
|
336
|
+
payload = poll_res.json()
|
|
337
|
+
status = payload.get("status")
|
|
338
|
+
if status == "pending":
|
|
339
|
+
continue
|
|
340
|
+
if status == "expired":
|
|
341
|
+
raise CliError("授权请求已过期,请重新执行 bidmaster auth login")
|
|
342
|
+
if payload.get("access_token"):
|
|
343
|
+
save_credentials({
|
|
344
|
+
"api_url": api_url,
|
|
345
|
+
"access_token": payload["access_token"],
|
|
346
|
+
"user": payload.get("user") or {},
|
|
347
|
+
"token_type": payload.get("token_type") or "bearer",
|
|
348
|
+
})
|
|
349
|
+
user = payload.get("user") or {}
|
|
350
|
+
print(f"授权成功:{user.get('username') or user.get('email') or '当前用户'}")
|
|
351
|
+
return
|
|
352
|
+
raise CliError(f"授权未完成:{status or '未知状态'}")
|
|
353
|
+
|
|
354
|
+
raise CliError("授权等待超时,请重新执行 bidmaster auth login")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def run_auth_status() -> None:
|
|
358
|
+
credentials = load_credentials()
|
|
359
|
+
if not credentials:
|
|
360
|
+
print("未登录。请执行:bidmaster auth login")
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
api_url = credentials["api_url"]
|
|
364
|
+
token = credentials["access_token"]
|
|
365
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
366
|
+
try:
|
|
367
|
+
res = await client.get(
|
|
368
|
+
f"{api_url}/api/cli-auth/me",
|
|
369
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
370
|
+
)
|
|
371
|
+
except httpx.HTTPError as error:
|
|
372
|
+
raise CliError(f"无法验证登录状态:{error}") from error
|
|
373
|
+
if res.status_code == 200:
|
|
374
|
+
user = res.json().get("user") or {}
|
|
375
|
+
print(f"已登录:{user.get('username') or user.get('email') or user.get('id')}")
|
|
376
|
+
print(f"服务地址:{api_url}")
|
|
377
|
+
return
|
|
378
|
+
print("本地凭证已失效,请重新执行:bidmaster auth login")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def run_auth_logout() -> None:
|
|
382
|
+
if CREDENTIALS_PATH.exists():
|
|
383
|
+
CREDENTIALS_PATH.unlink()
|
|
384
|
+
print("已清除本地 CLI 登录凭证")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def normalize_api_url(api_url: str | None) -> str:
|
|
388
|
+
value = api_url or os.getenv("BID_MASTER_API_URL") or DEFAULT_API_URL
|
|
389
|
+
return value.rstrip("/")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def load_credentials() -> dict[str, Any] | None:
|
|
393
|
+
if not CREDENTIALS_PATH.exists():
|
|
394
|
+
return None
|
|
395
|
+
try:
|
|
396
|
+
data = json.loads(CREDENTIALS_PATH.read_text(encoding="utf-8"))
|
|
397
|
+
except (OSError, json.JSONDecodeError):
|
|
398
|
+
return None
|
|
399
|
+
if not data.get("api_url") or not data.get("access_token"):
|
|
400
|
+
return None
|
|
401
|
+
return data
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def save_credentials(credentials: dict[str, Any]) -> None:
|
|
405
|
+
CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
CREDENTIALS_PATH.write_text(json.dumps(credentials, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
407
|
+
CREDENTIALS_PATH.chmod(0o600)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def get_remote_credentials() -> dict[str, Any]:
|
|
411
|
+
credentials = load_credentials()
|
|
412
|
+
if not credentials:
|
|
413
|
+
raise CliError("未登录网页端。请先执行:bidmaster auth login")
|
|
414
|
+
return credentials
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def remote_headers(credentials: dict[str, Any]) -> dict[str, str]:
|
|
418
|
+
token_type = credentials.get("token_type") or "bearer"
|
|
419
|
+
token = credentials["access_token"]
|
|
420
|
+
return {"Authorization": f"{token_type.title()} {token}"}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def upload_remote_file(client: httpx.AsyncClient, credentials: dict[str, Any], input_path: Path, category: str) -> str:
|
|
424
|
+
print_step(1, 4, "正在上传文件到网页端...")
|
|
425
|
+
with input_path.open("rb") as file_obj:
|
|
426
|
+
response = await client.post(
|
|
427
|
+
f"{credentials['api_url']}/api/files/upload",
|
|
428
|
+
headers=remote_headers(credentials),
|
|
429
|
+
files={"file": (input_path.name, file_obj, "application/octet-stream")},
|
|
430
|
+
params={"category": category},
|
|
431
|
+
)
|
|
432
|
+
if response.status_code == 401:
|
|
433
|
+
raise CliError("网页端登录凭证已失效,请重新执行:bidmaster auth login")
|
|
434
|
+
if response.status_code != 200:
|
|
435
|
+
raise CliError(f"上传文件失败:HTTP {response.status_code} {response.text}")
|
|
436
|
+
payload = response.json()
|
|
437
|
+
file_id = (payload.get("data") or {}).get("id")
|
|
438
|
+
if not file_id:
|
|
439
|
+
raise CliError("网页端未返回文件 ID")
|
|
440
|
+
return file_id
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def parse_sse_line(line: str) -> dict[str, Any] | None:
|
|
444
|
+
if not line.startswith("data: "):
|
|
445
|
+
return None
|
|
446
|
+
try:
|
|
447
|
+
return json.loads(line[6:])
|
|
448
|
+
except json.JSONDecodeError:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def iter_sse_events(response: httpx.Response):
|
|
453
|
+
async for line in response.aiter_lines():
|
|
454
|
+
event = parse_sse_line(line)
|
|
455
|
+
if event is not None:
|
|
456
|
+
yield event
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
async def run_remote_extract(args: argparse.Namespace) -> None:
|
|
460
|
+
credentials = get_remote_credentials()
|
|
461
|
+
input_path = resolve_input_file(args.file)
|
|
462
|
+
output_path = resolve_output_file(args.out, input_path, "summary", args.format)
|
|
463
|
+
print_header(input_path, "网页端招标文件关键内容提取", output_path)
|
|
464
|
+
|
|
465
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
466
|
+
file_id = await upload_remote_file(client, credentials, input_path, "tender")
|
|
467
|
+
elements = await request_remote_extract(client, credentials, file_id, args.provider, args.model)
|
|
468
|
+
|
|
469
|
+
print_step(4, 4, "正在写入结果文件...")
|
|
470
|
+
if args.format == "json":
|
|
471
|
+
output = json.dumps({"elements": elements}, ensure_ascii=False, indent=2)
|
|
472
|
+
else:
|
|
473
|
+
output = render_extract_markdown(input_path, elements)
|
|
474
|
+
write_output(output_path, output)
|
|
475
|
+
print("\n提取完成\n")
|
|
476
|
+
print_extract_summary(elements)
|
|
477
|
+
print(f"\n结果已保存:{output_path}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def request_remote_extract(
|
|
481
|
+
client: httpx.AsyncClient,
|
|
482
|
+
credentials: dict[str, Any],
|
|
483
|
+
file_id: str,
|
|
484
|
+
provider: str | None,
|
|
485
|
+
model: str | None,
|
|
486
|
+
) -> list[dict[str, Any]]:
|
|
487
|
+
print_step(2, 4, "正在请求网页端开始提取...")
|
|
488
|
+
payload = {"fileId": file_id, "provider": provider or "deepseek", "model": model}
|
|
489
|
+
async with client.stream(
|
|
490
|
+
"POST",
|
|
491
|
+
f"{credentials['api_url']}/api/extract/element",
|
|
492
|
+
headers={**remote_headers(credentials), "Content-Type": "application/json"},
|
|
493
|
+
json=payload,
|
|
494
|
+
) as response:
|
|
495
|
+
if response.status_code == 401:
|
|
496
|
+
raise CliError("网页端登录凭证已失效,请重新执行:bidmaster auth login")
|
|
497
|
+
if response.status_code != 200:
|
|
498
|
+
body = await response.aread()
|
|
499
|
+
raise CliError(f"提取请求失败:HTTP {response.status_code} {body.decode('utf-8', errors='ignore')}")
|
|
500
|
+
print_step(3, 4, "网页端 AI 正在提取...")
|
|
501
|
+
elements: list[dict[str, Any]] = []
|
|
502
|
+
async for event in iter_sse_events(response):
|
|
503
|
+
if event.get("type") in {"progress", "llm_progress"} and event.get("message"):
|
|
504
|
+
print(str(event["message"]))
|
|
505
|
+
elif event.get("type") == "element":
|
|
506
|
+
elem = event.get("data") or event
|
|
507
|
+
elements.append({"name": elem.get("name") or "分析结果", "content": elem.get("content") or ""})
|
|
508
|
+
elif event.get("type") == "error":
|
|
509
|
+
data = event.get("data") or {}
|
|
510
|
+
raise CliError(data.get("message") or event.get("message") or "网页端提取失败")
|
|
511
|
+
elif event.get("type") == "done":
|
|
512
|
+
break
|
|
513
|
+
return elements
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
async def run_remote_analyze(args: argparse.Namespace) -> None:
|
|
517
|
+
credentials = get_remote_credentials()
|
|
518
|
+
input_path = resolve_input_file(args.file)
|
|
519
|
+
output_path = resolve_output_file(args.out, input_path, "analysis", "md")
|
|
520
|
+
print_header(input_path, "网页端招标文件智能分析", output_path)
|
|
521
|
+
|
|
522
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
523
|
+
file_id = await upload_remote_file(client, credentials, input_path, "tender")
|
|
524
|
+
elements = await request_remote_extract(client, credentials, file_id, args.provider, args.model)
|
|
525
|
+
|
|
526
|
+
report = render_remote_analyze_markdown(input_path, elements)
|
|
527
|
+
write_output(output_path, report)
|
|
528
|
+
print("\n分析完成\n")
|
|
529
|
+
print_analysis_summary(report)
|
|
530
|
+
print(f"\n结果已保存:{output_path}")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def render_remote_analyze_markdown(input_path: Path, elements: list[dict[str, Any]]) -> str:
|
|
534
|
+
lines = [
|
|
535
|
+
f"# {input_path.stem} 招标文件分析报告",
|
|
536
|
+
"",
|
|
537
|
+
f"- 来源文件:{input_path.name}",
|
|
538
|
+
f"- 识别要素:{len(elements)} 项",
|
|
539
|
+
"",
|
|
540
|
+
"## 关键要素",
|
|
541
|
+
"",
|
|
542
|
+
]
|
|
543
|
+
for element in elements:
|
|
544
|
+
name = str(element.get("name") or "分析结果").strip()
|
|
545
|
+
content = str(element.get("content") or "未识别").strip()
|
|
546
|
+
lines.extend([f"### {name}", "", content, ""])
|
|
547
|
+
lines.extend([
|
|
548
|
+
"## 投标响应建议",
|
|
549
|
+
"",
|
|
550
|
+
"请结合以上关键要素,重点核对资格条件、评分办法、投标文件格式、报价要求和递交截止时间。",
|
|
551
|
+
])
|
|
552
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
async def run_remote_quote(args: argparse.Namespace) -> None:
|
|
556
|
+
credentials = get_remote_credentials()
|
|
557
|
+
input_path = resolve_input_file(args.file)
|
|
558
|
+
output_path = resolve_output_file(args.out, input_path, "quote-report", args.format)
|
|
559
|
+
print_header(input_path, "网页端开标报价分析", output_path)
|
|
560
|
+
|
|
561
|
+
modules = [item.strip() for item in args.modules.split(",") if item.strip()] if args.modules else None
|
|
562
|
+
form_data = {}
|
|
563
|
+
if modules:
|
|
564
|
+
form_data["modules"] = json.dumps(modules, ensure_ascii=False)
|
|
565
|
+
if args.provider:
|
|
566
|
+
form_data["provider"] = args.provider
|
|
567
|
+
if args.model:
|
|
568
|
+
form_data["model"] = args.model
|
|
569
|
+
|
|
570
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
571
|
+
print_step(1, 4, "正在上传报价文件到网页端...")
|
|
572
|
+
with input_path.open("rb") as file_obj:
|
|
573
|
+
response = await client.post(
|
|
574
|
+
f"{credentials['api_url']}/api/statistics/analyze/upload",
|
|
575
|
+
headers=remote_headers(credentials),
|
|
576
|
+
files={"file": (input_path.name, file_obj, "application/octet-stream")},
|
|
577
|
+
data={"modules": form_data.get("modules", "")},
|
|
578
|
+
)
|
|
579
|
+
if response.status_code == 401:
|
|
580
|
+
raise CliError("网页端登录凭证已失效,请重新执行:bidmaster auth login")
|
|
581
|
+
if response.status_code != 200:
|
|
582
|
+
raise CliError(f"报价分析失败:HTTP {response.status_code} {response.text}")
|
|
583
|
+
payload = response.json()
|
|
584
|
+
if not payload.get("success"):
|
|
585
|
+
raise CliError(payload.get("detail") or payload.get("error") or "报价分析失败")
|
|
586
|
+
result = payload.get("data") or {}
|
|
587
|
+
|
|
588
|
+
print_step(2, 4, "正在启动网页端 AI 综合分析...")
|
|
589
|
+
task_id = None
|
|
590
|
+
with input_path.open("rb") as file_obj:
|
|
591
|
+
ai_response = await client.post(
|
|
592
|
+
f"{credentials['api_url']}/api/statistics/analyze/comprehensive/upload/start",
|
|
593
|
+
headers=remote_headers(credentials),
|
|
594
|
+
files={"file": (input_path.name, file_obj, "application/octet-stream")},
|
|
595
|
+
data=form_data,
|
|
596
|
+
)
|
|
597
|
+
if ai_response.status_code == 200:
|
|
598
|
+
task_id = ai_response.json().get("task_id")
|
|
599
|
+
else:
|
|
600
|
+
print(f"AI 综合分析启动失败,已保留基础报价分析:HTTP {ai_response.status_code}")
|
|
601
|
+
|
|
602
|
+
ai_analysis = ""
|
|
603
|
+
if task_id:
|
|
604
|
+
print_step(3, 4, "正在等待网页端 AI 综合分析完成...")
|
|
605
|
+
ai_analysis = await poll_remote_opening_task(client, credentials, task_id)
|
|
606
|
+
else:
|
|
607
|
+
print_step(3, 4, "跳过 AI 综合分析...")
|
|
608
|
+
|
|
609
|
+
print_step(4, 4, "正在写入结果文件...")
|
|
610
|
+
result_with_ai = {**result, "ai_analysis": ai_analysis}
|
|
611
|
+
if args.format == "json":
|
|
612
|
+
output = json.dumps(result_with_ai, ensure_ascii=False, indent=2)
|
|
613
|
+
else:
|
|
614
|
+
output = ai_analysis.strip() or render_remote_quote_markdown(result)
|
|
615
|
+
write_output(output_path, output)
|
|
616
|
+
print("\n分析完成\n")
|
|
617
|
+
print_quote_summary(result)
|
|
618
|
+
print(f"\n结果已保存:{output_path}")
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
async def poll_remote_opening_task(client: httpx.AsyncClient, credentials: dict[str, Any], task_id: str) -> str:
|
|
622
|
+
deadline = time.monotonic() + 600
|
|
623
|
+
while time.monotonic() < deadline:
|
|
624
|
+
response = await client.get(
|
|
625
|
+
f"{credentials['api_url']}/api/statistics/analysis-task/{task_id}",
|
|
626
|
+
headers=remote_headers(credentials),
|
|
627
|
+
)
|
|
628
|
+
if response.status_code == 401:
|
|
629
|
+
raise CliError("网页端登录凭证已失效,请重新执行:bidmaster auth login")
|
|
630
|
+
if response.status_code != 200:
|
|
631
|
+
raise CliError(f"获取 AI 分析任务失败:HTTP {response.status_code} {response.text}")
|
|
632
|
+
record = (response.json().get("data") or {})
|
|
633
|
+
status = record.get("status")
|
|
634
|
+
if status in {"completed", "partial"}:
|
|
635
|
+
return record.get("ai_analysis") or ""
|
|
636
|
+
if status == "error":
|
|
637
|
+
return record.get("ai_analysis") or ""
|
|
638
|
+
await asyncio.sleep(2)
|
|
639
|
+
raise CliError("AI 综合分析等待超时,请稍后在网页端查看结果")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def render_remote_quote_markdown(result: dict[str, Any]) -> str:
|
|
643
|
+
lines = ["# 开标报价分析报告", ""]
|
|
644
|
+
stats = result.get("bid_stats") or {}
|
|
645
|
+
ranking = result.get("bid_ranking") or []
|
|
646
|
+
lines.append(f"- 供应商数量:{result.get('bidder_count', 0)}")
|
|
647
|
+
if stats:
|
|
648
|
+
lines.extend([
|
|
649
|
+
f"- 最低报价:{stats.get('min', '未识别')}",
|
|
650
|
+
f"- 最高报价:{stats.get('max', '未识别')}",
|
|
651
|
+
f"- 平均报价:{stats.get('mean', '未识别')}",
|
|
652
|
+
])
|
|
653
|
+
if ranking:
|
|
654
|
+
lines.extend(["", "## 报价排名", ""])
|
|
655
|
+
for item in ranking:
|
|
656
|
+
lines.append(f"{item.get('rank', '-')}. {item.get('name', '未知供应商')}:{item.get('price', '未识别')}")
|
|
657
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
async def run_extract(args: argparse.Namespace) -> None:
|
|
661
|
+
input_path = resolve_input_file(args.file)
|
|
662
|
+
output_path = resolve_output_file(args.out, input_path, "summary", args.format)
|
|
663
|
+
provider = resolve_provider(args.provider)
|
|
664
|
+
|
|
665
|
+
print_header(input_path, "招标文件关键内容提取", output_path)
|
|
666
|
+
print_step(1, 4, "正在读取文件...")
|
|
667
|
+
content = input_path.read_bytes()
|
|
668
|
+
|
|
669
|
+
print_step(2, 4, "正在解析招标文件内容...")
|
|
670
|
+
text_content = extract_document_text(content)
|
|
671
|
+
|
|
672
|
+
print_step(3, 4, "正在提取项目关键信息...")
|
|
673
|
+
elements = await extract_elements(text_content, provider, args.model)
|
|
674
|
+
|
|
675
|
+
print_step(4, 4, "正在写入结果文件...")
|
|
676
|
+
if args.format == "json":
|
|
677
|
+
output = json.dumps({"elements": elements}, ensure_ascii=False, indent=2)
|
|
678
|
+
else:
|
|
679
|
+
output = render_extract_markdown(input_path, elements)
|
|
680
|
+
write_output(output_path, output)
|
|
681
|
+
|
|
682
|
+
print("\n提取完成\n")
|
|
683
|
+
print_extract_summary(elements)
|
|
684
|
+
print(f"\n结果已保存:{output_path}")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
async def run_analyze(args: argparse.Namespace) -> None:
|
|
688
|
+
input_path = resolve_input_file(args.file)
|
|
689
|
+
output_path = resolve_output_file(args.out, input_path, "analysis", "md")
|
|
690
|
+
provider = resolve_provider(args.provider)
|
|
691
|
+
|
|
692
|
+
print_header(input_path, "招标文件智能分析", output_path)
|
|
693
|
+
print_step(1, 5, "正在读取文件...")
|
|
694
|
+
content = input_path.read_bytes()
|
|
695
|
+
|
|
696
|
+
print_step(2, 5, "正在解析文档结构...")
|
|
697
|
+
text_content = extract_document_text(content)
|
|
698
|
+
|
|
699
|
+
print_step(3, 5, "正在提取评分规则与资格条件...")
|
|
700
|
+
truncated = truncate_text(text_content)
|
|
701
|
+
|
|
702
|
+
print_step(4, 5, "正在识别投标风险...")
|
|
703
|
+
report = await analyze_document(truncated, provider, args.model)
|
|
704
|
+
|
|
705
|
+
print_step(5, 5, "正在生成分析报告...")
|
|
706
|
+
write_output(output_path, report)
|
|
707
|
+
|
|
708
|
+
print("\n分析完成\n")
|
|
709
|
+
print_analysis_summary(report)
|
|
710
|
+
print(f"\n结果已保存:{output_path}")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def run_quote(args: argparse.Namespace) -> None:
|
|
714
|
+
from app.api.statistics import compute_all_dimensions, parse_opening_excel, _fallback_opening_analysis
|
|
715
|
+
|
|
716
|
+
input_path = resolve_input_file(args.file)
|
|
717
|
+
output_format = args.format
|
|
718
|
+
output_path = resolve_output_file(args.out, input_path, "quote-report", output_format)
|
|
719
|
+
modules = [item.strip() for item in args.modules.split(",") if item.strip()] if args.modules else None
|
|
720
|
+
|
|
721
|
+
print_header(input_path, "开标报价分析", output_path)
|
|
722
|
+
print_step(1, 4, "正在读取报价文件...")
|
|
723
|
+
content = input_path.read_bytes()
|
|
724
|
+
|
|
725
|
+
print_step(2, 4, "正在识别供应商报价...")
|
|
726
|
+
try:
|
|
727
|
+
parsed = parse_opening_excel(content, input_path.name)
|
|
728
|
+
except ValueError as error:
|
|
729
|
+
raise CliError(str(error)) from error
|
|
730
|
+
except Exception as error:
|
|
731
|
+
raise CliError(f"无法解析报价文件,请确认表格包含投标人和报价列:{error}") from error
|
|
732
|
+
|
|
733
|
+
print_step(3, 4, "正在计算报价区间...")
|
|
734
|
+
result = compute_all_dimensions(parsed["bidders"], parsed["meta"], modules)
|
|
735
|
+
|
|
736
|
+
print_step(4, 4, "正在生成报价分析报告...")
|
|
737
|
+
if output_format == "json":
|
|
738
|
+
output = json.dumps(result, ensure_ascii=False, indent=2)
|
|
739
|
+
else:
|
|
740
|
+
output = _fallback_opening_analysis(result)
|
|
741
|
+
write_output(output_path, output)
|
|
742
|
+
|
|
743
|
+
print("\n分析完成\n")
|
|
744
|
+
print_quote_summary(result)
|
|
745
|
+
print(f"\n结果已保存:{output_path}")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def resolve_input_file(file_path: str) -> Path:
|
|
749
|
+
path = Path(file_path).expanduser().resolve()
|
|
750
|
+
if not path.exists():
|
|
751
|
+
raise CliError(f"无法读取文件 {file_path}\n\n可能原因:\n- 文件路径不存在\n- 当前目录不是文件所在目录\n\n你可以尝试:\n bidmaster extract ./docs/tender.pdf --out tender-summary.md")
|
|
752
|
+
if not path.is_file():
|
|
753
|
+
raise CliError(f"输入路径不是文件:{file_path}")
|
|
754
|
+
return path
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def resolve_output_file(out: str | None, input_path: Path, suffix: str, output_format: str) -> Path:
|
|
758
|
+
if out:
|
|
759
|
+
return Path(out).expanduser().resolve()
|
|
760
|
+
return input_path.with_name(f"{input_path.stem}-{suffix}.{output_format}").resolve()
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def resolve_provider(provider: str | None) -> str:
|
|
764
|
+
return provider or get_settings().ai_provider or "deepseek"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def extract_document_text(content: bytes) -> str:
|
|
768
|
+
text_content, needs_ocr = extract_text_from_content(content)
|
|
769
|
+
if needs_ocr:
|
|
770
|
+
raise CliError("当前文件疑似扫描件 PDF,第一版 CLI 暂不支持本地 OCR。请先使用 Web 端 OCR,或上传可复制文本的 PDF/DOCX 文件。")
|
|
771
|
+
if not text_content.strip():
|
|
772
|
+
raise CliError("文档内容为空或无法解析,请确认文件格式为 PDF、DOCX、XLSX、CSV、Markdown 或 TXT。")
|
|
773
|
+
return text_content
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def truncate_text(text: str) -> str:
|
|
777
|
+
if len(text) <= MAX_CHARS:
|
|
778
|
+
return text
|
|
779
|
+
return text[:MAX_CHARS] + f"\n\n[文档已截断,共 {len(text)} 字符]"
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
async def extract_elements(text_content: str, provider: str, model: str | None) -> list[dict[str, Any]]:
|
|
783
|
+
builder = get_prompt_builder()
|
|
784
|
+
messages = [
|
|
785
|
+
{"role": "system", "content": builder.build_extract_system_prompt("brief")},
|
|
786
|
+
{"role": "user", "content": builder.build_extract_user_prompt(truncate_text(text_content)) + "\n\n请只输出 JSON,不要输出 Markdown 代码块、解释文字或额外前后缀。"},
|
|
787
|
+
]
|
|
788
|
+
response = await complete_text(provider, messages, model)
|
|
789
|
+
try:
|
|
790
|
+
_, elements = _parse_llm_json_response(response)
|
|
791
|
+
return _normalize_elements(elements, response)
|
|
792
|
+
except json.JSONDecodeError:
|
|
793
|
+
return _elements_from_plain_text(response)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
async def analyze_document(text_content: str, provider: str, model: str | None) -> str:
|
|
797
|
+
messages = [
|
|
798
|
+
{
|
|
799
|
+
"role": "system",
|
|
800
|
+
"content": "你是招投标文件分析专家。请输出面向招投标从业人员的 Markdown 报告,重点说明项目关键信息、资格条件、评分重点、投标风险和响应建议。",
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
"role": "user",
|
|
804
|
+
"content": f"请分析以下招标文件内容,并生成可直接保存的 Markdown 报告。\n\n---\n\n# 招标文件内容\n\n{text_content}",
|
|
805
|
+
},
|
|
806
|
+
]
|
|
807
|
+
report = await complete_text(provider, messages, model)
|
|
808
|
+
if not report.strip():
|
|
809
|
+
raise CliError("AI 未返回分析内容,请检查模型配置后重试。")
|
|
810
|
+
return report.strip() + "\n"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
async def complete_text(provider: str, messages: list[dict[str, str]], model: str | None) -> str:
|
|
814
|
+
service = LiteLLMService()
|
|
815
|
+
chunks: list[str] = []
|
|
816
|
+
try:
|
|
817
|
+
async for chunk in service.complete(provider, messages, model=model, stream=True):
|
|
818
|
+
chunks.append(chunk)
|
|
819
|
+
except Exception as error:
|
|
820
|
+
raise CliError(f"AI 分析失败:{error}\n\n你可以尝试:\n 1. 在 .env.local 或 src/backend/.env 中配置对应供应商 API Key\n 2. 使用 --provider 指定可用供应商\n 3. 本地演示时设置 DEMO_MODE=true") from error
|
|
821
|
+
return "".join(chunks).strip()
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def render_extract_markdown(input_path: Path, elements: list[dict[str, Any]]) -> str:
|
|
825
|
+
lines = [
|
|
826
|
+
f"# {input_path.stem} 关键内容提取",
|
|
827
|
+
"",
|
|
828
|
+
f"- 来源文件:{input_path.name}",
|
|
829
|
+
f"- 提取要素:{len(elements)} 项",
|
|
830
|
+
"",
|
|
831
|
+
]
|
|
832
|
+
for element in elements:
|
|
833
|
+
name = str(element.get("name") or "分析结果").strip()
|
|
834
|
+
content = str(element.get("content") or "未识别").strip()
|
|
835
|
+
lines.extend([f"## {name}", "", content, ""])
|
|
836
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def write_output(output_path: Path, content: str) -> None:
|
|
840
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
841
|
+
output_path.write_text(content, encoding="utf-8")
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def print_header(input_path: Path, task_type: str, output_path: Path) -> None:
|
|
845
|
+
print(APP_NAME)
|
|
846
|
+
print()
|
|
847
|
+
print(f"输入文件:{input_path}")
|
|
848
|
+
print(f"任务类型:{task_type}")
|
|
849
|
+
print(f"输出文件:{output_path}")
|
|
850
|
+
print()
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def print_step(current: int, total: int, message: str) -> None:
|
|
854
|
+
print(f"[{current}/{total}] {message}")
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def print_extract_summary(elements: list[dict[str, Any]]) -> None:
|
|
858
|
+
for element in elements[:6]:
|
|
859
|
+
name = str(element.get("name") or "分析结果").strip()
|
|
860
|
+
content = " ".join(str(element.get("content") or "").split())
|
|
861
|
+
preview = content[:80] + ("..." if len(content) > 80 else "")
|
|
862
|
+
print(f"{name}:{preview or '未识别'}")
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def print_analysis_summary(report: str) -> None:
|
|
866
|
+
lines = [line.strip("- ").strip() for line in report.splitlines() if line.strip().startswith("-")]
|
|
867
|
+
if not lines:
|
|
868
|
+
print("报告已生成,可打开 Markdown 文件查看完整内容。")
|
|
869
|
+
return
|
|
870
|
+
print("核心结论:")
|
|
871
|
+
for line in lines[:4]:
|
|
872
|
+
print(f"- {line}")
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def print_quote_summary(result: dict[str, Any]) -> None:
|
|
876
|
+
stats = result.get("bid_stats") or {}
|
|
877
|
+
ranking = result.get("bid_ranking") or []
|
|
878
|
+
print(f"供应商数量:{result.get('bidder_count', 0)}")
|
|
879
|
+
if stats:
|
|
880
|
+
print(f"最低报价:{stats.get('min', '未识别')}")
|
|
881
|
+
print(f"最高报价:{stats.get('max', '未识别')}")
|
|
882
|
+
print(f"平均报价:{stats.get('mean', '未识别')}")
|
|
883
|
+
if ranking:
|
|
884
|
+
print("\n报价排名:")
|
|
885
|
+
for item in ranking[:3]:
|
|
886
|
+
print(f"{item.get('rank')}. {item.get('name')} {item.get('price')}")
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def print_error(message: str) -> None:
|
|
890
|
+
print(f"错误:{message}")
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
if __name__ == "__main__":
|
|
894
|
+
main()
|