analyze_similarity.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. """
  2. Phase 1: 历史招投标相似度分析
  3. 对比新招标文件与各历史招标文件的相似度,
  4. 决定每组历史数据对最终投标文件的影响权重。
  5. 用法:
  6. from phase1_parsing.analyze_similarity import calculate_similarity
  7. results = calculate_similarity(new_tender_text, historical_groups)
  8. """
  9. import sys
  10. import os
  11. import json
  12. import re
  13. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  14. from llm.llm_utils import LLM
  15. def extract_tender_features(text: str) -> dict:
  16. """从招标文本中提取关键特征用于相似度比较"""
  17. features = {}
  18. # 项目类型判断
  19. type_keywords = {
  20. "物业管理": ["物业", "保洁", "保安", "绿化", "维修", "会务", "客服"],
  21. "工程装修": ["装修", "改造", "修缮", "施工", "工程"],
  22. "IT服务": ["软件", "系统", "信息化", "开发", "运维", "网络安全"],
  23. "物资采购": ["采购", "设备", "货物", "供应", "物资"],
  24. "咨询服务": ["咨询", "评估", "审计", "监理", "设计"],
  25. "餐饮服务": ["餐饮", "食堂", "供餐", "配餐"],
  26. }
  27. matched_types = []
  28. for ttype, kws in type_keywords.items():
  29. if any(kw in text[:8000] for kw in kws):
  30. matched_types.append(ttype)
  31. features["project_types"] = matched_types
  32. # 采购人(同一采购人很可能项目相似)
  33. m = re.search(r"采购人[::]\s*(.{2,30})", text[:5000])
  34. if m:
  35. features["buyer"] = m.group(1).strip()
  36. # 预算规模
  37. m = re.search(r"预算[^\n]{0,10}[::]\s*([\d,.]+\s*万)", text[:10000])
  38. if m:
  39. budget_str = m.group(1).strip().replace(",", "")
  40. try:
  41. budget_val = float(budget_str.replace("万", ""))
  42. features["budget"] = budget_val
  43. if budget_val > 1000:
  44. features["budget_level"] = "大型"
  45. elif budget_val > 200:
  46. features["budget_level"] = "中型"
  47. else:
  48. features["budget_level"] = "小型"
  49. except ValueError:
  50. pass
  51. # 服务期限
  52. m = re.search(r"服务期限[::](.{0,40})", text[:5000])
  53. if m:
  54. features["service_period"] = m.group(1).strip()
  55. # 地域
  56. m = re.search(r"([一-鿿]{2,8}(?:区|市|县))", text[:3000])
  57. if m:
  58. features["region"] = m.group(1).strip()
  59. # 关键需求关键词密度
  60. demand_kw = ["服务方案", "质量标准", "人员配置", "管理制度", "应急预案",
  61. "考核", "培训", "设备", "安全", "合同"]
  62. found_kw = [kw for kw in demand_kw if kw in text]
  63. features["demand_keywords"] = found_kw
  64. return features
  65. def calculate_similarity(
  66. new_tender_text: str,
  67. historical_groups: list,
  68. use_llm: bool = True,
  69. ) -> list:
  70. """
  71. 计算新招标文件与每组历史招标文件的相似度。
  72. Args:
  73. new_tender_text: 新招标文件全文
  74. historical_groups: [
  75. {
  76. "group_id": "project_A",
  77. "name": "项目名称",
  78. "tender_text": "历史招标文本",
  79. "bid_text": "对应投标文本",
  80. "tender_file": "原始PDF路径",
  81. "bid_file": "原始PDF路径",
  82. },
  83. ...
  84. ]
  85. use_llm: 是否用 LLM 做语义级评分(否则只用关键词匹配)
  86. Returns:
  87. [
  88. {
  89. "group_id": "project_A",
  90. "name": "项目名称",
  91. "similarity_score": 0.85, # 0~1
  92. "similarity_level": "高", # 高/中/低
  93. "similarity_reason": "同为政府机关物业管理...",
  94. "new_tender_features": {...},
  95. "history_tender_features": {...},
  96. "bid_structure": {...}, # 对应投标的目录结构
  97. "weight": 0.6, # 归一化后的权重
  98. },
  99. ...
  100. ]
  101. """
  102. new_features = extract_tender_features(new_tender_text)
  103. results = []
  104. for group in historical_groups:
  105. hist_features = extract_tender_features(group.get("tender_text", ""))
  106. if use_llm:
  107. score, reason = _llm_similarity_score(
  108. new_tender_text[:8000],
  109. group.get("tender_text", "")[:8000],
  110. )
  111. else:
  112. score, reason = _keyword_similarity(new_features, hist_features)
  113. # 提取投标目录
  114. from phase1_parsing.extract_bid_toc import extract_toc
  115. bid_toc = extract_toc(group.get("bid_text", ""))
  116. # 判断相似等级
  117. if score >= 0.7:
  118. level = "高"
  119. elif score >= 0.4:
  120. level = "中"
  121. else:
  122. level = "低"
  123. results.append({
  124. "group_id": group["group_id"],
  125. "name": group.get("name", group["group_id"]),
  126. "similarity_score": round(score, 4),
  127. "similarity_level": level,
  128. "similarity_reason": reason,
  129. "new_tender_features": new_features,
  130. "history_tender_features": hist_features,
  131. "bid_toc": bid_toc,
  132. "bid_text_len": len(group.get("bid_text", "")),
  133. "tender_text_len": len(group.get("tender_text", "")),
  134. })
  135. # 归一化权重:相似度越高权重越大
  136. scores = [r["similarity_score"] for r in results]
  137. total = sum(scores) or 1
  138. for r in results:
  139. r["weight"] = round(r["similarity_score"] / total, 4)
  140. results.sort(key=lambda x: x["similarity_score"], reverse=True)
  141. return results
  142. def _keyword_similarity(new_feat: dict, hist_feat: dict) -> tuple:
  143. """基于关键词匹配的相似度计算(快速但粗糙)"""
  144. score = 0.0
  145. reasons = []
  146. # 项目类型匹配
  147. new_types = set(new_feat.get("project_types", []))
  148. hist_types = set(hist_feat.get("project_types", []))
  149. if new_types and hist_types:
  150. overlap = new_types & hist_types
  151. if overlap:
  152. score += 0.3
  153. reasons.append(f"类型匹配: {overlap}")
  154. # 采购人匹配
  155. if (new_feat.get("buyer") and hist_feat.get("buyer")
  156. and new_feat["buyer"] == hist_feat["buyer"]):
  157. score += 0.25
  158. reasons.append("同一采购人")
  159. # 预算规模匹配
  160. if new_feat.get("budget_level") and hist_feat.get("budget_level"):
  161. if new_feat["budget_level"] == hist_feat["budget_level"]:
  162. score += 0.15
  163. reasons.append(f"预算规模相当({new_feat['budget_level']})")
  164. # 需求关键词重叠
  165. new_kw = set(new_feat.get("demand_keywords", []))
  166. hist_kw = set(hist_feat.get("demand_keywords", []))
  167. if new_kw and hist_kw:
  168. overlap = new_kw & hist_kw
  169. if overlap:
  170. kw_score = len(overlap) / max(len(new_kw), 1) * 0.2
  171. score += kw_score
  172. reasons.append(f"需求关键词重叠: {overlap}")
  173. # 地域匹配
  174. if (new_feat.get("region") and hist_feat.get("region")
  175. and new_feat["region"] == hist_feat["region"]):
  176. score += 0.1
  177. reasons.append("同区域")
  178. score = min(score, 1.0)
  179. reason = ";".join(reasons) if reasons else "无明显相似特征"
  180. return score, reason
  181. def _llm_similarity_score(new_excerpt: str, hist_excerpt: str) -> tuple:
  182. """用 LLM 评估两个招标文件的相似度(语义级)"""
  183. try:
  184. llm = LLM()
  185. system = (
  186. "你是一个招标文件比对专家。比较两份招标文件,输出相似度评分(0~1)和理由。\n"
  187. "输出 JSON: {\"score\": 0.85, \"reason\": \"同类型项目,服务内容重叠度高\"}"
  188. )
  189. user = (
  190. "请比较以下两份招标文件的相似度,考虑项目类型、服务内容、预算规模、采购人等因素:\n\n"
  191. f"【新招标文件】\n{new_excerpt[:4000]}\n\n"
  192. f"【历史招标文件】\n{hist_excerpt[:4000]}"
  193. )
  194. result = llm._call(system, user, max_tokens=1024, temperature=0.2,
  195. response_format={"type": "json_object"})
  196. parsed = json.loads(result)
  197. return float(parsed.get("score", 0.5)), parsed.get("reason", "")
  198. except Exception as e:
  199. return 0.5, f"LLM评分异常: {e}"
  200. if __name__ == "__main__":
  201. import sys
  202. sys.stdout.reconfigure(encoding="utf-8")
  203. # 简单测试
  204. text1 = "项目名称:松江区机关事务管理局物业管理服务 预算金额:1739.51万元 服务内容:保洁、保安、绿化、会务"
  205. text2 = "项目名称:嘉定区机关事务管理局物业管理服务 预算金额:1200万元 服务内容:保洁、保安、绿化"
  206. groups = [{
  207. "group_id": "test",
  208. "name": "测试项目",
  209. "tender_text": text2,
  210. "bid_text": "第一章 投标人资格...",
  211. }]
  212. results = calculate_similarity(text1, groups, use_llm=False)
  213. for r in results:
  214. print(f"{r['name']}: 相似度 {r['similarity_score']} ({r['similarity_level']})")
  215. print(f" 理由: {r['similarity_reason']}")
  216. print(f" 权重: {r['weight']}")