requirement_analyzer.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. """
  2. requirement_analyzer.py - 招标要求分析模块
  3. 使用 OpenAI API 分析招标文件内容,识别并分类其中的各项要求。
  4. """
  5. import json
  6. import logging
  7. import time
  8. from typing import Optional
  9. from .config import get_config
  10. from .models import AnalysisResult, BidDocument, Requirement
  11. from .utils import chunk_text, table_to_markdown
  12. logger = logging.getLogger(__name__)
  13. class AnalysisError(Exception):
  14. """AI 分析失败异常"""
  15. pass
  16. class RequirementAnalyzer:
  17. """招标文件需求分析器。
  18. 使用 OpenAI 分析招标文件中的各项要求,包括评分标准、服务要求、
  19. 资质要求等,返回结构化的分析结果。
  20. """
  21. CATEGORY_MAP = {
  22. "evaluation": "评审标准",
  23. "service": "服务要求",
  24. "qualification": "资质要求",
  25. "other": "其他",
  26. }
  27. REVERSE_CATEGORY_MAP = {v: k for k, v in CATEGORY_MAP.items()}
  28. def __init__(self, api_key: Optional[str] = None, model: str = "gpt-4o"):
  29. """初始化分析器。
  30. Args:
  31. api_key: OpenAI API Key,默认从环境变量读取
  32. model: 使用的模型名称
  33. """
  34. try:
  35. from openai import OpenAI
  36. except ImportError:
  37. raise ImportError("请安装 openai: pip install openai")
  38. key = api_key or get_config().openai_api_key
  39. if not key:
  40. raise ValueError(
  41. "未提供 OpenAI API Key。请通过参数传入或设置 OPENAI_API_KEY 环境变量。"
  42. )
  43. self.client = OpenAI(api_key=key)
  44. self.model = model
  45. def analyze(self, doc: BidDocument) -> AnalysisResult:
  46. """对招标文件进行 AI 分析,提取所有要求。
  47. Args:
  48. doc: 招标文件结构化内容
  49. Returns:
  50. AnalysisResult: 分析结果(结构化需求列表 + 摘要)
  51. Raises:
  52. AnalysisError: AI 分析失败
  53. """
  54. full_text = doc.full_text.strip()
  55. if not full_text:
  56. raise AnalysisError("招标文件内容为空,无法进行分析")
  57. logger.info("开始分析招标文件: %s (%d 字符)", doc.pdf_path, len(full_text))
  58. config = get_config()
  59. max_length = config.max_text_length
  60. if len(full_text) <= max_length:
  61. prompt = self._build_prompt(doc)
  62. result = self._call_api(prompt)
  63. logger.info(
  64. "分析完成: 评审标准 %d 项, 服务要求 %d 项, 资质要求 %d 项, 其他 %d 项",
  65. len(result.evaluation_criteria),
  66. len(result.service_requirements),
  67. len(result.qualification_requirements),
  68. len(result.other_requirements),
  69. )
  70. return result
  71. # 文本过长,分块处理
  72. logger.info("文本过长 (%d 字符,超过 %d),进行分块分析", len(full_text), max_length)
  73. return self._analyze_by_chunks(doc, full_text, max_length)
  74. def _analyze_by_chunks(
  75. self, doc: BidDocument, full_text: str, max_length: int
  76. ) -> AnalysisResult:
  77. """将长文本分块后分别分析,再合并结果。"""
  78. text_chunks = chunk_text(full_text, max_length)
  79. logger.info("文本已切分为 %d 个块", len(text_chunks))
  80. all_reqs: list[Requirement] = []
  81. summaries: list[str] = []
  82. project_names: list[str] = []
  83. for i, chunk in enumerate(text_chunks):
  84. logger.info("分析第 %d/%d 块...", i + 1, len(text_chunks))
  85. partial_prompt = self._build_chunk_prompt(chunk, i + 1, len(text_chunks))
  86. try:
  87. result = self._call_api(partial_prompt)
  88. all_reqs.extend(result.all_requirements)
  89. summaries.append(result.summary)
  90. if result.project_name:
  91. project_names.append(result.project_name)
  92. except AnalysisError as e:
  93. logger.warning("第 %d 块分析失败: %s,跳过该块", i + 1, e)
  94. # 合并结果,按类别分类
  95. combined = AnalysisResult(
  96. summary="\n".join(summaries),
  97. project_name=project_names[0] if project_names else "",
  98. evaluation_criteria=[r for r in all_reqs if r.category == "evaluation"],
  99. service_requirements=[r for r in all_reqs if r.category == "service"],
  100. qualification_requirements=[r for r in all_reqs if r.category == "qualification"],
  101. other_requirements=[r for r in all_reqs if r.category == "other"],
  102. )
  103. logger.info(
  104. "分块分析完成: %d 项要求",
  105. combined.total_requirements,
  106. )
  107. return combined
  108. def _build_chunk_prompt(self, chunk: str, chunk_num: int, total_chunks: int) -> str:
  109. """为单个文本块构建分析 prompt。"""
  110. return f"""=== 招标文件内容(第 {chunk_num}/{total_chunks} 部分)===
  111. {chunk}
  112. ---
  113. 请分析以上招标文件内容片段,提取其中包含的所有要求。"""
  114. def _build_prompt(self, doc: BidDocument) -> str:
  115. """构建包含招标文件完整内容的 prompt。
  116. 将文本和表格按页组织,方便 AI 理解内容结构。
  117. """
  118. sections: list[str] = ["=== 招标文件内容 ==="]
  119. # 构建表格索引:页码 → 表格索引
  120. table_idx_by_page: dict[int, list[int]] = {}
  121. for ti, table in enumerate(doc.tables):
  122. page_num = getattr(table, 'page_num', 0) + 1 # page_num是0-indexed
  123. table_idx_by_page.setdefault(page_num, []).append(ti)
  124. for page in doc.pages:
  125. page_num = page.page_num
  126. sections.append(f"\n--- 第 {page_num} 页 ---")
  127. if page.text:
  128. sections.append(page.text)
  129. else:
  130. sections.append("(本页无文本内容)")
  131. # 添加本页中的表格
  132. page_table_indices = table_idx_by_page.get(page_num, [])
  133. for ti in page_table_indices:
  134. if ti < len(doc.tables):
  135. table = doc.tables[ti]
  136. md_table = table_to_markdown(table)
  137. if md_table and md_table != "(空表格)":
  138. sections.append(f"\n本页中的表格:\n{md_table}")
  139. return "\n".join(sections)
  140. def _call_api(self, user_prompt: str) -> AnalysisResult:
  141. """调用 OpenAI API 进行需求分析,使用结构化输出。"""
  142. system_prompt = """你是一个专业的招标文件分析专家。请仔细阅读招标文件内容,提取并分类其中的所有要求。
  143. 请识别以下类别的要求:
  144. 1. **evaluation(评审标准)**:评审因素、评分项、分值分配、评审标准等
  145. 2. **service(服务要求)**:服务范围、服务内容、服务标准、人员配置等
  146. 3. **qualification(资质要求)**:投标人资格条件、资质证书、注册资金、业绩要求等
  147. 4. **other(其他)**:商务条款、技术规范、预算金额、付款方式、服务期限等
  148. 对于每一项要求,尽可能提取其标题(title)、详细描述(description)、分值(score)和评分标准详情(detail)。"""
  149. max_retries = 2
  150. last_error: Optional[Exception] = None
  151. for attempt in range(max_retries + 1):
  152. try:
  153. response = self.client.chat.completions.create(
  154. model=self.model,
  155. messages=[
  156. {"role": "system", "content": system_prompt},
  157. {"role": "user", "content": user_prompt},
  158. ],
  159. temperature=get_config().openai_temperature,
  160. max_tokens=get_config().openai_max_tokens,
  161. timeout=get_config().openai_timeout,
  162. response_format={
  163. "type": "json_schema",
  164. "json_schema": {
  165. "name": "analysis_result",
  166. "strict": True,
  167. "schema": {
  168. "type": "object",
  169. "properties": {
  170. "summary": {
  171. "type": "string",
  172. "description": "招标文件概要总结,不超过300字",
  173. },
  174. "project_name": {
  175. "type": "string",
  176. "description": "项目名称",
  177. },
  178. "requirements": {
  179. "type": "array",
  180. "items": {
  181. "type": "object",
  182. "properties": {
  183. "category": {
  184. "type": "string",
  185. "enum": ["evaluation", "service", "qualification", "other"],
  186. "description": "要求类别",
  187. },
  188. "title": {
  189. "type": "string",
  190. "description": "要求标题",
  191. },
  192. "description": {
  193. "type": "string",
  194. "description": "详细描述",
  195. },
  196. "score": {
  197. "type": ["number", "null"],
  198. "description": "分值(如明确有分值)",
  199. },
  200. "detail": {
  201. "type": "string",
  202. "description": "评分标准详细说明",
  203. },
  204. },
  205. "required": ["category", "title", "description", "score", "detail"],
  206. "additionalProperties": False,
  207. },
  208. },
  209. },
  210. "required": ["summary", "project_name", "requirements"],
  211. "additionalProperties": False,
  212. },
  213. },
  214. },
  215. )
  216. content = response.choices[0].message.content
  217. if not content:
  218. raise AnalysisError("API 返回了空响应")
  219. data = json.loads(content)
  220. # 按类别分类构建 Requirement
  221. all_reqs: list[Requirement] = []
  222. for req in data.get("requirements", []):
  223. category = req.get("category", "other")
  224. # 校验category合法性
  225. if category not in ("evaluation", "service", "qualification", "other"):
  226. category = "other"
  227. all_reqs.append(Requirement(
  228. category=category,
  229. title=req.get("title", ""),
  230. description=req.get("description", ""),
  231. score=req.get("score"),
  232. detail=req.get("detail", ""),
  233. ))
  234. result = AnalysisResult(
  235. summary=data.get("summary", ""),
  236. project_name=data.get("project_name", ""),
  237. evaluation_criteria=[r for r in all_reqs if r.category == "evaluation"],
  238. service_requirements=[r for r in all_reqs if r.category == "service"],
  239. qualification_requirements=[r for r in all_reqs if r.category == "qualification"],
  240. other_requirements=[r for r in all_reqs if r.category == "other"],
  241. )
  242. logger.debug("API 分析成功: %d 项要求", result.total_requirements)
  243. return result
  244. except Exception as e:
  245. last_error = e
  246. if attempt < max_retries:
  247. wait = 2 ** (attempt + 1)
  248. logger.warning(
  249. "API 调用失败(第 %d 次),%ds 后重试: %s",
  250. attempt + 1, wait, e,
  251. )
  252. time.sleep(wait)
  253. else:
  254. logger.error("API 调用最终失败: %s", e)
  255. raise AnalysisError(f"AI 分析失败,已重试 {max_retries} 次: {last_error}")