role_recall_engine.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. # -*- coding: utf-8 -*-
  2. """RoleRecallEngine — YAML 驱动的统一角色召回引擎(角色分类流程优化 Phase E)。
  3. 职责
  4. ====
  5. 把原 ``role.py`` 中三个各自内联正则、重复构建上下文的类合并为一个引擎:
  6. - ``RoleRulePredictor`` → :meth:`predict_role_rule`(角色/金额上下文召回)
  7. - ``RoleRuleFinalAdd`` → :meth:`predict_final_add`(文末/全文格式兜底召回)
  8. - ``TendereeRuleRecall`` → :meth:`predict_recall`(招标人 7 级上下文链 + 主语规则)
  9. 三个旧类名保留为 ``role.py`` 中的薄委托(保持 ``getPredictor`` 旧 key 与
  10. ``from ... import RoleRulePredictor`` 等老 import 兼容),新代码直接用本引擎。
  11. 规则来源(source of truth,经 Phase B 逐字节校验 + Phase E 冒烟/双跑回归)
  12. ========================================================================
  13. - ``dl/rules/patterns/role_context.yaml``
  14. 角色三组模式(front/behind/whole,命名组后缀为概率权重)+ 金额召回模式
  15. - ``dl/rules/patterns/role_fallback.yaml``
  16. final_*(RoleRuleFinalAdd)与 recall_*(TendereeRuleRecall)
  17. 上下文复用(Phase A ``RoleContext``)
  18. ====================================
  19. ``extract.py`` 在 prem 前构建一次 ``build_role_contexts``,本引擎通过
  20. ``contexts`` 参数直接复用:实体-句子配对 O(1)、切片惰性缓存
  21. (``span``=get_context 前后 20 字、``span_rule``=spanWindow 前后 40 词
  22. use_text=False、``span_wide``=spanWindow 前后 40 词 use_text=True)。
  23. ``contexts=None`` 时引擎内部自建(供 pipeline 等老调用方使用)。
  24. 行为等价说明
  25. ============
  26. - 移植保持原执行顺序与短路语义逐行等价(含 ser_role 首个 finditer 命中即返回、
  27. entity_context_rule 各级"命中后不 break 继续标记同级实体"等隐式行为)。
  28. - 与原实现的已知差异(均为缺陷修正,Phase E badcase 双跑回归验证不影响结果):
  29. 1. ``final_end_date_fallback``:原 role.py 文末兜底正则括号不平衡(缺
  30. ``")?"``)一旦执行必抛 ``re.error``,属隐性 bug;YAML 中已修复,
  31. badcase 回归集未覆盖该路径。
  32. 2. 未配对到句子的实体:原实现靠句循环扫描自然跳过(prem 之后的链路中
  33. 实体均来自同一 ``build_role_contexts``,配对语义一致);金额实体在
  34. 原实现中若配对失败会沿用上一实体的 ``_span`` 残留(stale 状态),
  35. 本实现按"跳过"处理。
  36. 3. ``TendereeRuleRecall`` 原按 ``list_sentences[0][ent.sentence_index]``
  37. 直接下标取句(假设句号连续),本实现按 (doc_id, sentence_index)
  38. 精确配对(Phase A 已接受的配对修正)。
  39. 依赖方向
  40. ========
  41. CORE(predictors)只读消费 ``dl/rules`` 的 patterns 运行时数据与 loader
  42. 框架(与 Phase C ``RoleRuleEngine`` 相同约定,ARCHITECTURE.md §4.3)。
  43. """
  44. from __future__ import absolute_import
  45. import re
  46. from BiddingKG.dl.common.context_utils import spanWindow, get_context
  47. from BiddingKG.dl.predictors._common import is_agency
  48. from BiddingKG.dl.predictors.role_context import build_role_contexts
  49. from BiddingKG.dl.rules.loader import RuleLoader
  50. __all__ = ["RoleRecallEngine"]
  51. # ----------------------------------------------------------------------------
  52. # 规则 ID 与执行顺序(与原 RoleRulePredictor.__init__ 三组列表一一对应)
  53. # ----------------------------------------------------------------------------
  54. _LEFT_ORDER = (
  55. "tenderee_front_60", "tenderee_front_55", "tenderee_front_50",
  56. "agency_front", "second_front", "third_front",
  57. "winner_front_60", "winner_front_55", "winner_front_51",
  58. )
  59. _WHOLE_ORDER = ("winner_whole_center", "tenderee_whole_center")
  60. _RIGHT_ORDER = (
  61. "third_behind", "second_behind", "agency_behind",
  62. "tenderee_or_agency_behind", "tenderee_behind_50", "winner_behind",
  63. )
  64. #: 不可能是中标人的实体集合(原 RoleRulePredictor.SET_NOT_TENDERER)
  65. SET_NOT_TENDERER = set([
  66. "人民政府", "人民法院", "中华人民共和国", "人民检察院",
  67. "评标委员会", "中国政府", "中国海关", "中华人民共和国政府",
  68. ])
  69. #: 机构类后缀(原 TendereeRuleRecall.entity_context_rule 内联正则,程序性判断)
  70. _ORG_SUFFIX = ("医院|学校|大学|中学|小学|幼儿园|政府|部|委员会|署|行|"
  71. "局|厅|处|室|科|股|站")
  72. #: 代理业务关键词(原 TendereeRuleRecall.entity_context_rule 内联正则)
  73. _AGENCY_BIZ = "(采购|招标|投标|交易|代理|拍卖|咨询|顾问|管理)"
  74. class RoleRecallEngine(object):
  75. """统一角色召回引擎(无实例可变状态,线程安全)。
  76. 用法(role.py 旧类委托)::
  77. engine = RoleRecallEngine()
  78. all_tenderer = engine.predict_role_rule(list_articles, list_sentences,
  79. list_entitys, list_codenames,
  80. channel_dic, title, contexts=ctxs)
  81. engine.predict_final_add(list_articles, list_sentences, list_entitys, list_codenames)
  82. engine.predict_recall(list_articles, list_sentences, list_entitys, list_codenames, contexts=ctxs)
  83. """
  84. def __init__(self):
  85. # role_context.yaml:三组角色模式(编译后)
  86. self.p_left = [RuleLoader.get_pattern(pid) for pid in _LEFT_ORDER]
  87. self.p_whole = [RuleLoader.get_pattern(pid) for pid in _WHOLE_ORDER]
  88. self.p_right = [RuleLoader.get_pattern(pid) for pid in _RIGHT_ORDER]
  89. # predict 流程单独引用的模式
  90. self.p_tenderee_front_50 = RuleLoader.get_pattern("tenderee_front_50")
  91. self.p_tenderee_front_55 = RuleLoader.get_pattern("tenderee_front_55")
  92. self.p_candidate_front = RuleLoader.get_pattern("candidate_front")
  93. self.p_tenderer_front = RuleLoader.get_pattern("tenderer_front")
  94. # 金额召回(role_context.yaml,Phase E 迁移)
  95. self.p_money_tenderee_front = RuleLoader.get_pattern("money_tenderee_front")
  96. self.p_money_tenderer_front = RuleLoader.get_pattern("money_tenderer_front")
  97. self.p_money_tenderer_whole = RuleLoader.get_pattern("money_tenderer_whole")
  98. self.p_money_other_fee = RuleLoader.get_pattern("money_other_fee")
  99. self.p_money_bank_tenderee_front = RuleLoader.get_pattern("money_bank_tenderee_front")
  100. self.p_money_bank_tenderee_behind = RuleLoader.get_pattern("money_bank_tenderee_behind")
  101. self.p_pack_signal = RuleLoader.get_pattern("pack_signal")
  102. # 文末/全文格式召回(role_fallback.yaml final_*)
  103. self.p_final_publisher = RuleLoader.get_pattern("final_publisher")
  104. self.p_final_buyer_info = RuleLoader.get_pattern("final_buyer_info")
  105. self.p_final_account_name = RuleLoader.get_pattern("final_account_name")
  106. self.p_final_contact_unit = RuleLoader.get_pattern("final_contact_unit")
  107. self.p_final_end_date = RuleLoader.get_pattern("final_end_date")
  108. self.p_final_end_date_fallback = RuleLoader.get_pattern("final_end_date_fallback")
  109. self.p_final_station_format = RuleLoader.get_pattern("final_station_format")
  110. # 招标人 7 级上下文链 + 主语(role_fallback.yaml recall_*)
  111. self.p_recall_left_1 = RuleLoader.get_pattern("recall_left_1")
  112. self.p_recall_left_2 = RuleLoader.get_pattern("recall_left_2")
  113. self.p_recall_left_3 = RuleLoader.get_pattern("recall_left_3")
  114. self.p_recall_left_4 = RuleLoader.get_pattern("recall_left_4")
  115. self.p_recall_left_5 = RuleLoader.get_pattern("recall_left_5")
  116. self.p_recall_behind = RuleLoader.get_pattern("recall_behind")
  117. self.p_recall_behind_2 = RuleLoader.get_pattern("recall_behind_2")
  118. self.p_recall_behind_3 = RuleLoader.get_pattern("recall_behind_3")
  119. self.p_recall_subject = RuleLoader.get_pattern("recall_subject")
  120. self.SET_NOT_TENDERER = SET_NOT_TENDERER
  121. # ------------------------------------------------------------------
  122. # 原 RoleRulePredictor 公共方法
  123. # ------------------------------------------------------------------
  124. @staticmethod
  125. def _check_input(text, ignore=False):
  126. if not text:
  127. return []
  128. if not isinstance(text, list):
  129. text = [text]
  130. null_index = [i for i, t in enumerate(text) if not t]
  131. if null_index and not ignore:
  132. raise Exception("null text in input ")
  133. return text
  134. def ser_role(self, pattern_list, text, entity_text):
  135. """命名组模式匹配:首个 finditer 命中即返回 (label, prob, group0)。"""
  136. for _pattern in pattern_list:
  137. for _iter in _pattern.finditer(text):
  138. for _group, _v_group in _iter.groupdict().items():
  139. if _v_group is not None and _v_group != "":
  140. _role = _group.split("_")[0]
  141. if _role == "tendereeORagency": # 2022/3/9 新增不确定招标代理判断逻辑
  142. if is_agency(entity_text):
  143. _role = 'tenderee'
  144. else:
  145. _role = "agency"
  146. prob = int(_group.split("_")[2]) / 100 if len(_group.split("_")) == 3 else 0.55
  147. _label = {"tenderee": 0, "agency": 1, "winTenderer": 2,
  148. "secondTenderer": 3, "thirdTenderer": 4}.get(_role)
  149. return (_label, prob, _iter.group(0))
  150. return (5, 0.5, '')
  151. def rule_predict(self, before, center, after, entity_text):
  152. """前文 → 整句 → 后文三级召回(原 RoleRulePredictor.rule_predict 逐行移植)。"""
  153. _label, _prob, keyword = self.ser_role(self.p_left, before, entity_text) # 前文匹配
  154. keyword = "left_" + keyword if keyword != "" else keyword
  155. if _label == 2 and re.search(
  156. '各.{,5}供应商|尊敬的供应商|[^\w]候选供应商|业绩|拟招|(交易|采购|招标|建设)服务(单位|机构)|第[四五六七4567]|是否中标:否|序号:\d+,\w{,2}候选|(排名|排序|名次):([4-9]|\d{2,})|未(中[标选]|入围)|不得确定为|(响应|参[加与]报价|通过资格审查)的?供应商',
  157. # 135463002 拟招一家供应商为宜宾市第三人民医院、李庄同济医院院区提供消防维保服务
  158. before) != None:
  159. _label = 5
  160. elif _label == 2 and re.search('为$', before) and re.match('\w', after): # 排除错误 前文为结尾,后文不是标点符号结尾的,如 353824459 供应商为社会团体的, 供应商为玉田县中医医院提供安保服务
  161. _label = 5
  162. elif _label == 2 and re.search('评委|未中标', after[:5]): # 397194341 过滤掉错误召回中标人
  163. _label = 5
  164. elif _label == 2 and re.search('[^\w]供应商', before[-10:]) and re.search('^,?(投标报价|(资格性审查:|符合性审查:)?(不通过|不符合))', after[:12]) and re.search('中标|成交|中选|排名|排序|名次|第[一1]名', before[-10:]) is None: # 20240705 处理类似 493939047 错误
  165. _label = 5
  166. if _label == 5:
  167. _label, _prob, keyword = self.ser_role(self.p_whole, before + center + after, entity_text) # 前后文匹配
  168. keyword = 'whole_' + keyword[:keyword.find(entity_text)] if keyword != "" else keyword
  169. if _label == 2 and re.search('以[^,。;]{10,30}为准', before + center + after) != None:
  170. _label = 5
  171. if _label != 5 and self.ser_role(self.p_whole, before, entity_text)[0] != 5 or \
  172. self.ser_role(self.p_whole, after, entity_text)[0] != 5:
  173. _label = 5
  174. if _label == 5:
  175. _label, _prob, keyword = self.ser_role(self.p_right, after, entity_text) # 后文匹配
  176. keyword = "right_" + keyword if keyword != "" else keyword
  177. if _label == 5 and re.search('(中标|中选|成交)?|谈判结果)(结果)?(公告|公示|通知书?),', before) and re.match(':', after): # 632523961 直接谈判结果通知书,广东金钥匙智能包装科技有限公司:经广州地铁传媒有限公司
  178. _label = 2
  179. _prob = 0.5
  180. if _label == 2 and re.search('[^\w]供应商(名称)?:$', before) and re.search('^,(是否通过资格审查|资格审查情况|专家\d|报价)', after): # 修复 673143737 废标公告 附件评标信息作为中标
  181. _label = 5
  182. _flag = False if _label == 5 else True
  183. return (_label, _prob, _flag, keyword)
  184. # ------------------------------------------------------------------
  185. # 原 RoleRulePredictor.predict
  186. # ------------------------------------------------------------------
  187. def predict_role_rule(self, list_articles, list_sentences, list_entitys, list_codenames,
  188. channel_dic, title, on_value=0.5, all_winner=False,
  189. req_scope=[], deposit_project=False, contexts=None):
  190. """角色/金额上下文召回(原 RoleRulePredictor.predict 移植,RoleContext 复用)。
  191. :param contexts: ``build_role_contexts`` 预计算上下文(extract.py 入口已构建,
  192. 与 prem 共享);None 时内部自建(保持老调用方行为)。
  193. :return: all_tenderer 集合(所有召回的投标人实体文本)
  194. """
  195. all_tenderer = set() # 保存所有投标人
  196. if contexts is None:
  197. contexts = build_role_contexts(list_sentences, list_entitys)
  198. for article, list_entity, list_sentence, list_codename, list_ctx in zip(
  199. list_articles, list_entitys, list_sentences, list_codenames, contexts):
  200. list_sentence.sort(key=lambda x: x.sentence_index) # 2022/1/5 按句子顺序排序
  201. ctx_map = {id(c.entity): c for c in list_ctx}
  202. list_name = [] # 2022/1/5 改为实体列表内所有项目名称
  203. name_entitys = [] # 2023/6/30 保存项目名称实体,直接通过位置判断角色是否在项目名称里面
  204. candidates = [] # 保存不能确定为第几的候选人 2023/04/14
  205. notfound_tenderer = True # 未找到前三候选人
  206. deposit_moneys = [] # 保存存款类项目采购内容中大于百万的其他金额实体
  207. for entity in list_entity:
  208. if entity.entity_type == 'name':
  209. list_name.append(entity.entity_text)
  210. name_entitys.append(entity)
  211. list_name = self._check_input(list_name) + [article.title]
  212. for p_entity in list_entity:
  213. if p_entity.entity_type in ["org", "company"]:
  214. # 只解析角色为无的或者概率低于阈值的
  215. if p_entity.label is None:
  216. continue
  217. ctx = ctx_map.get(id(p_entity))
  218. # 将上下文包含标题的实体概率置为0.6,因为标题中的实体不一定是招标人
  219. if str(p_entity.label) == "0":
  220. find_flag = False
  221. if ctx is not None:
  222. _span = ctx.span
  223. if self.p_tenderee_front_50.search(_span[0]) or self.p_tenderee_front_55.search(_span[0]): # 前面有关键词的实体不判断是否在项目名称中出现
  224. find_flag = True
  225. elif re.search('(项目|工程|招标|采购(条目)?|合同|标项|标的|计划|询价|询价单|询价通知书|申购单|申购)(名称|标名|标题|主题):$', _span[0]):
  226. find_flag = True
  227. if re.search('(局|院|府|学|处|站|会|所|校|馆|队|厅|室|司|心|园|厂)$', p_entity.entity_text):
  228. p_entity.values[0] = 0.6 if p_entity.values[0] > 0.6 else 0.55
  229. else:
  230. p_entity.values[0] = on_value # 项目名称里面实体修改为最低概率
  231. if p_entity.entity_text in title: # 749013557 提高表头角色概率
  232. p_entity.values[0] += 0.02
  233. else:
  234. for _name in name_entitys:
  235. if _name.sentence_index == p_entity.sentence_index and p_entity.wordOffset_begin >= _name.wordOffset_begin and p_entity.wordOffset_end < _name.wordOffset_end:
  236. find_flag = True
  237. if re.search('(局|院|府|学|处|站|会|所|校|馆|队|厅|室|司|心|园|厂)$', p_entity.entity_text):
  238. p_entity.values[0] = 0.6 if p_entity.values[0] > 0.6 else 0.55
  239. else:
  240. p_entity.values[0] = on_value # 项目名称里面实体修改为最低概率
  241. if p_entity.entity_text in title: # 749013557 提高表头角色概率
  242. p_entity.values[0] += 0.02
  243. break
  244. if find_flag:
  245. continue
  246. # 正则从概率低于阈值或其他类别中召回角色
  247. role_prob = float(p_entity.values[int(p_entity.label)])
  248. if role_prob < on_value or str(p_entity.label) == "5":
  249. # 将标题中的实体置为招标人
  250. _list_name = self._check_input(list_name, ignore=True)
  251. find_flag = False
  252. for _name in _list_name: # 2022/1/5修正只要项目名称出现过的角色,所有位置都标注为招标人
  253. if str(_name).find(p_entity.entity_text) >= 0 and p_entity.sentence_index < 4:
  254. if ctx is not None:
  255. _span = ctx.span
  256. if _span[2].startswith(":"): # 实体后面为冒号的不作为招标人,避免项目名称出错中标变招标 368122675 陇西兴恒建建筑有限责任公司:线路安全保护区内环境治理专项整改(第二标段)项目
  257. pass
  258. elif str(_span[0][-len(str(_name)):] + _span[1] + _span[2][:len(str(_name))]).find(
  259. _name) >= 0 or str(_name).startswith(p_entity.entity_text): # 20240621 补充公司开头的项目名称召回,避免name太长召回失败 例 367033697
  260. if is_agency(p_entity.entity_text): # 2024/3/29 统一方法判断是否为代理
  261. find_flag = True
  262. # 20250918 不在召回项目名称中的代理,避免某些站源批量错误 640739667
  263. else:
  264. find_flag = True
  265. _label = 0
  266. p_entity.label = _label
  267. p_entity.values[int(_label)] = on_value + p_entity.values[int(_label)] / 10
  268. if 6 < len(p_entity.entity_text) < 20 and p_entity.entity_type == 'org': # 标题中角色长度在一定范围内的加分 优化类似367720967 标题中两个实体选择错误问题
  269. p_entity.values[int(_label)] += 0.005
  270. break
  271. if find_flag:
  272. break
  273. # 若是实体在标题中,默认为招标人,不进行以下的规则匹配
  274. if find_flag:
  275. continue
  276. if ctx is not None:
  277. spans = ctx.span_rule # spanWindow 前后各 40 词,use_text=False
  278. # 添加中标通知书类型特殊处理(原 s_index == 0:实体所在句为排序后第一句)
  279. handled = False
  280. try:
  281. if ctx.sentence is list_sentence[0] and re.search(
  282. '中标通知书.{,30}[,:]%s:' % p_entity.entity_text.replace('(', '').replace(')', ''),
  283. ctx.sentence.sentence_text.replace('(', '').replace(')', '')[:100]):
  284. p_entity.label = 2
  285. p_entity.values[2] = 0.5
  286. notfound_tenderer = False
  287. handled = True
  288. except Exception as e:
  289. print('正则报错:', e)
  290. if not handled:
  291. before, center, after = spans[0], spans[1], spans[2]
  292. entity_text = p_entity.entity_text
  293. _label, _prob, _flag, kw = self.rule_predict(before, center, after, entity_text)
  294. if _label == 5 and re.search(':(1[.、])?$', before) and re.search('^[、;,&/。]', after) and re.search('(监督|管理)(机构|部门|单位):', before) is None and re.search(
  295. '(中标|成交|中选))?(人|单位|供应商|银行|合作伙伴)?(公示)?(信息|情况|结果|如下)(公[示告]如下)?:|(遴选|寻源|采购|招标|竞价|议价|比选|委托|询比?价|比价|评选|谈判|邀标|邀请|洽谈|约谈|选取|抽取)结果(如下)(公[示告]如下)?:|,中标人,', ctx.sentence.sentence_text[:p_entity.wordOffset_begin]): # 补充召回 例:514053647 标段1:中国建设银行西安南大街支行,标段2:中国农业银行股份有限公司西安分行,
  296. _flag = True
  297. _label = 2
  298. _prob = 0.5
  299. elif _label == 5 and channel_dic['docchannel']['docchannel'] in ['中标信息', '候选人公示', '合同公告'] and (all_winner == 1 or (all_winner == 2 and re.search('(排[名序]|名次|顺序|第):?[0-9一二三四五六七八九十]+', before) is None)):
  300. if re.search('(中标|中选|成交|入围|入选)(人|单位|供应商|银行)(名称)?:$', before) and re.search('未(中标|中选|成交|入围|入选)', before[-15:]) is None:
  301. _flag = True
  302. _label = 2
  303. _prob = 0.55
  304. elif re.search('(:|[::,]\d{1,2}[.、])$', before) and re.search('^[、;,&/。]', after) and re.search('(监督|管理)(机构|部门|单位):', before) is None and re.search('(入围|合格)(人|单位|供应商|银行|候选人|合作伙伴)?(信息|情况|结果|如下)(公[示告]如下)?(:|,?((入围)?排名不分先后))', ctx.sentence.sentence_text[:p_entity.wordOffset_begin]):
  305. _flag = True
  306. _label = 2
  307. _prob = 0.51
  308. elif re.search('(候选|投标|应答|响应)(人|单位|供应商|银行)(名称)?:$', before) and re.search('是否中标:否|未(中标|中选|成交|入围|入选)', before[-15:]) is None:
  309. _flag = True
  310. _label = 2
  311. _prob = 0.5
  312. # 得到结果
  313. if _flag:
  314. if _label in [2, 3, 4]:
  315. notfound_tenderer = False
  316. all_tenderer.add(p_entity.entity_text)
  317. p_entity.label = _label
  318. p_entity.values[_label] = _prob + p_entity.values[int(_label)] / 10
  319. # 提取投标人
  320. if _label == 5 and self.p_tenderer_front.search(before[-10:]):
  321. all_tenderer.add(p_entity.entity_text)
  322. else:
  323. if self.p_candidate_front.search(before) and re.search('尊敬的|各', before[-10:]) is None:
  324. candidates.append(p_entity)
  325. elif channel_dic['docchannel']['docchannel'] in ['中标信息', '候选人公示', '合同公告'] and re.search(':$', before) and re.search('^[,。]', after) and re.search('候选人', before[-15:]): # 补充 577756336 候选人,三期A160、A166地块:中国建设银行成都第九支行,
  326. candidates.append(p_entity)
  327. elif str(p_entity.label) in ['2', '3', '4']:
  328. notfound_tenderer = False
  329. # 其他金额通过正则召回可能是招标或中投标的金额
  330. if p_entity.entity_type in ["money"]:
  331. ctx_m = ctx_map.get(id(p_entity))
  332. if str(p_entity.label) == "2" and ctx_m is not None:
  333. self._recall_money_label2(p_entity, ctx_m, deposit_project)
  334. if deposit_project and p_entity.label in [1, 2]:
  335. if req_scope and float(p_entity.entity_text) > 1000000 and (p_entity.sentence_index > req_scope[0][0] \
  336. or (p_entity.sentence_index == req_scope[0][0] and p_entity.wordOffset_begin > req_scope[0][1])) and (p_entity.sentence_index < req_scope[1][0] \
  337. or (p_entity.sentence_index == req_scope[1][0] and p_entity.wordOffset_end <= req_scope[1][1])):
  338. deposit_moneys.append(p_entity)
  339. if deposit_moneys:
  340. moneys = [float(p.entity_text) for p in deposit_moneys]
  341. for p in deposit_moneys:
  342. if float(p.entity_text) == max(moneys):
  343. p.values[0] = 0.55
  344. p.label = 0
  345. else:
  346. p.values[0] = 0.5
  347. p.label = 0
  348. if notfound_tenderer and len(set([ent.entity_text for ent in candidates])) == 1 and channel_dic['docchannel']['docchannel'] in ['中标信息', '候选人公示', '合同公告']:
  349. for p_entity in candidates:
  350. # 只有一个候选人的作为中标人
  351. p_entity.label = 2
  352. p_entity.values[2] = on_value
  353. # 增加招标金额扩展,招标金额+连续的未识别金额,并且都可以匹配到标段信息,则将为识别的金额设置为招标金额
  354. list_p = []
  355. state = 0
  356. for p_entity in list_entity:
  357. for _sentence in list_sentence:
  358. if _sentence.sentence_index == p_entity.sentence_index:
  359. _span = get_context(_sentence.sentence_text, p_entity.wordOffset_begin, p_entity.wordOffset_end,
  360. size=30, center_include=True)
  361. if state == 2:
  362. for _p in list_p[1:]:
  363. if _p.label == 2:
  364. _p.values[0] = 0.5 + _p.values[0] / 10
  365. _p.label = 0
  366. state = 0
  367. list_p = []
  368. if state == 0:
  369. if p_entity.entity_type in ["money"]:
  370. if str(p_entity.label) == "0" and self.p_pack_signal.search(
  371. _span[0] + "-" + _span[2]) is not None:
  372. state = 1
  373. list_p.append(p_entity)
  374. elif state == 1:
  375. if p_entity.entity_type in ["money"]:
  376. if str(p_entity.label) in ["0", "2"] and self.p_pack_signal.search(
  377. _span[0] + "-" + _span[2]) is not None and self.p_money_other_fee.search(
  378. _span[0] + "-" + _span[2]) is None and p_entity.sentence_index == list_p[0].sentence_index:
  379. list_p.append(p_entity)
  380. else:
  381. state = 2
  382. if len(list_p) > 1:
  383. for _p in list_p[1:]:
  384. if _p.label == 2:
  385. _p.values[0] = 0.5 + _p.values[0] / 10
  386. _p.label = 0
  387. state = 0
  388. list_p = []
  389. for p_entity in list_entity:
  390. # 将属于集合中的不可能是中标人的标签置为无
  391. if p_entity.entity_text in self.SET_NOT_TENDERER:
  392. p_entity.label = 5
  393. return all_tenderer
  394. def _recall_money_label2(self, p_entity, ctx, deposit_project):
  395. """money 实体 label=2 的招标/中标金额召回(原 predict 468-522 行移植)。
  396. 原实现中 continue/break 均作用于句循环,效果为"结束该实体的金额处理",
  397. 此处以 return 等价表达。
  398. """
  399. _span = ctx.span # get_context 前后各 20 字
  400. if re.search('(含|在|包括)(\d+)?$', _span[0]):
  401. return
  402. m_tenderee = self.p_money_tenderee_front.search(_span[0])
  403. if m_tenderee is not None and self.p_money_other_fee.search(_span[0]) is None:
  404. front_text = _span[0][m_tenderee.end():]
  405. if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉
  406. return
  407. p_entity.values[0] = 0.62 + p_entity.values[0] / 10
  408. p_entity.label = 0
  409. elif deposit_project:
  410. m_bank = self.p_money_bank_tenderee_front.search(_span[0])
  411. if m_bank is not None and self.p_money_other_fee.search(_span[0]) is None:
  412. front_text = _span[0][m_bank.end():]
  413. if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉
  414. return
  415. p_entity.values[0] = 0.6 + p_entity.values[0] / 10
  416. p_entity.label = 0
  417. elif self.p_money_bank_tenderee_behind.search(_span[2]):
  418. p_entity.values[0] = 0.55 + p_entity.values[0] / 10
  419. p_entity.label = 0
  420. elif (re.search('存款|总额度', _span[0]) or re.search('存[款放]|专项债资金', _span[2])):
  421. # 注:原实现此处 front 搜索未命中时以 _span[2] 匹配的 end() 切 _span[0],保留原行为
  422. front_text = _span[0][(re.search('存款|总额度', _span[0]) or re.search('存[款放]|专项债资金', _span[2])).end():]
  423. if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉
  424. return
  425. p_entity.values[0] = 0.55
  426. p_entity.label = 0
  427. m_tenderer = self.p_money_tenderer_front.search(_span[0])
  428. if m_tenderer is not None:
  429. front_text = _span[0][m_tenderer.end():]
  430. if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉
  431. return
  432. elif re.search('合同价暂定为?$', _span[0]): # 20250310 修复 598504921 合同价暂定 为招标金额
  433. return
  434. elif re.search('^(以[上下])?按[\d.%]+收取|^及?以[上下]|^[()]?[+×*-][\d.%]+|服务招标费率|招标代理服务收费', _span[2][:20]):
  435. return
  436. m_other = self.p_money_other_fee.search(_span[0])
  437. if m_other is not None:
  438. if m_tenderer.span()[1] > m_other.span()[1]:
  439. p_entity.values[1] = 0.6 + p_entity.values[1] / 10
  440. p_entity.label = 1
  441. else:
  442. p_entity.values[1] = 0.6 + p_entity.values[1] / 10
  443. p_entity.label = 1
  444. if self.p_money_tenderer_whole.search("".join(_span)) and self.p_money_tenderer_whole.search(_span[0]) is None \
  445. and self.p_money_tenderer_whole.search(_span[2]) is None and self.p_money_other_fee.search(_span[0]) is None:
  446. p_entity.values[1] = 0.6 + p_entity.values[1] / 10
  447. p_entity.label = 1
  448. elif re.search('(预算金额|最高(投标)?上?限[价额]?格?|招标控制价))?:?([\d.,]+万?元[,(]其中)?(第?[一二三四五0-9](标[段|包]|[分子]包):?[\d.,]+万?元,)*第?[一二三四五0-9](标[段|包]|[分子]包):?$',
  449. ctx.sentence.sentence_text[:p_entity.wordOffset_begin]): # 处理几个标段金额相邻情况 例子:191705231
  450. p_entity.values[0] = 0.6 + p_entity.values[0] / 10
  451. p_entity.label = 0
  452. elif re.search('固定价格?:(人民币|¥)?$', _span[0]): # 20250423 修复 613808422 补充为招标金额
  453. p_entity.values[0] = 0.5
  454. p_entity.label = 0
  455. # ------------------------------------------------------------------
  456. # 原 RoleRuleFinalAdd.predict
  457. # ------------------------------------------------------------------
  458. def predict_final_add(self, list_articles, list_sentences, list_entitys, list_codenames):
  459. """文末/全文格式兜底召回(原 RoleRuleFinalAdd.predict 移植,final_* YAML)。"""
  460. main_sentences = [sentence for sentence in list_sentences[0] if not sentence.in_attachment]
  461. if len(list_sentences[0]) > 0 and list_sentences[0][-1].in_attachment:
  462. main_sentences = list_sentences[0][-1:] + main_sentences[-2:]
  463. if len(main_sentences) == 0:
  464. return 0
  465. sear_ent = None
  466. for sentence in main_sentences[-5:][::-1]: # 402073799 最后五句由后往前,匹配文末角色,日期
  467. text_end = "".join(sentence.tokens)
  468. text_end = re.sub(r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", '', text_end) # 去除网址
  469. text_end = re.sub(',?(招标办|招投标管理中心|国有资产管理处|采办共享中心|采购与招标管理办公室|附件\d*:[^附件,。]{5,100}\.(docx|doc|rar|xlsx|xls|jpg|pdf)|附件\d*:\w{,100})', '', text_end)[-200:] # 处理 类似 285264698 传真:0512-62690315,苏州卫生职业技术学院,国有资产管理处,2022年11月24日, 这种情况
  470. sear_ent = self.p_final_end_date.search(text_end)
  471. if sear_ent:
  472. b, e = sear_ent.span()
  473. if re.search('报价记录|竞价成交', text_end[max(b - 10, 0):b] + text_end[e:]):
  474. sear_ent = None
  475. break
  476. if sear_ent is None:
  477. text_end = list_articles[0].content[-100:]
  478. # 注:原 role.py 此处正则括号不平衡(隐性 bug),YAML final_end_date_fallback 已修复
  479. sear_ent = self.p_final_end_date_fallback.search(text_end)
  480. if sear_ent:
  481. b, e = sear_ent.span()
  482. if re.search('报价记录|竞价成交', text_end[max(b - 10, 0):b] + text_end[e:]):
  483. sear_ent = None
  484. sear_ent1 = self.p_final_contact_unit.search(list_articles[0].content[:5000])
  485. sear_ent2 = self.p_final_account_name.search(list_articles[0].content[:5000])
  486. sear_ent3 = self.p_final_buyer_info.search(list_articles[0].content[:5000])
  487. sear_ent4 = self.p_final_publisher.search(list_articles[0].content[:5000])
  488. sear_ent5 = self.p_final_station_format.search(list_articles[0].content[:5000])
  489. sear_list = [sear_ent4, sear_ent3, sear_ent2, sear_ent1, sear_ent, sear_ent5]
  490. tenderee_notfound = True
  491. agency_notfound = True
  492. tenderee_list = []
  493. agency_list = []
  494. ents = []
  495. for ent in list_entitys[0]:
  496. if ent.entity_type in ['org', 'company']:
  497. if ent.label == 0 and ent.values[ent.label] > 0.55:
  498. if '公共资源交易中心' in ent.entity_text: # 公共资源交易中心不算招标或代理,只算平台
  499. # 改为降低概率,不改类别,防止 382573066 明显招标人表达不提取
  500. ent.values[ent.label] = 0.6 if ent.values[ent.label] > 0.6 else 0.5
  501. continue
  502. tenderee_list.append(ent.entity_text)
  503. tenderee_notfound = False
  504. elif ent.label == 1 and ent.values[ent.label] > 0.55:
  505. agency_list.append(ent.entity_text)
  506. agency_notfound = False
  507. elif ent.label == 5:
  508. if '公共资源交易中心' in ent.entity_text:
  509. continue
  510. ents.append(ent)
  511. if sear_ent or sear_ent1 or sear_ent2 or sear_ent3 or sear_ent4 or sear_ent5:
  512. for _sear_ent in [_sear for _sear in sear_list if _sear]:
  513. ent_re = _sear_ent.group('entity')
  514. ent_re = ent_re.replace(',', '').replace("(", "(").replace(")", ")")
  515. if tenderee_notfound or agency_notfound:
  516. n = 0
  517. for i in range(len(ents) - 1, -1, -1):
  518. if not ents[i].in_attachment:
  519. n += 1
  520. if n > 3 and _sear_ent == sear_ent: # 文章末尾角色加日期这种只找后三个实体
  521. break
  522. elif _sear_ent == sear_ent and ents[i].label != 5: # 后面有角色的实体的停止继续往前
  523. break
  524. if ents[i].entity_text == ent_re or (ents[i].entity_text in ent_re and re.search('(大学|中学|小学|幼儿园|医院)$', ents[i].entity_text)) or (ents[i].entity_text in ent_re and len(ents[i].entity_text) / len(ent_re) > 0.6):
  525. if agency_notfound and is_agency(ents[i].entity_text) and ents[i].entity_text not in tenderee_list:
  526. ents[i].label = 1
  527. ents[i].values[1] = 0.51 # 修改为比标题概率略高
  528. agency_notfound = False
  529. elif tenderee_notfound and not is_agency(ents[i].entity_text) and ents[i].entity_text not in agency_list:
  530. ents[i].label = 0
  531. ents[i].values[0] = 0.51 # 修改为比标题概率略高
  532. tenderee_notfound = False
  533. break
  534. if not tenderee_notfound:
  535. break
  536. # ------------------------------------------------------------------
  537. # 原 TendereeRuleRecall.predict / entity_context_rule / subject_rule
  538. # ------------------------------------------------------------------
  539. def predict_recall(self, list_articles, list_sentences, list_entitys, list_codenames, contexts=None):
  540. """招标人兜底召回:7 级上下文链 + 公告主语规则(recall_* YAML)。"""
  541. if contexts is None:
  542. contexts = build_role_contexts(list_sentences, list_entitys)
  543. list_ctx = contexts[0] if contexts else []
  544. ctx_map = {id(c.entity): c for c in list_ctx}
  545. get_tenderee = False
  546. ents = []
  547. list_name = []
  548. agency_set = set()
  549. for ent in list_entitys[0]:
  550. if ent.entity_type == 'name':
  551. list_name.append(ent.entity_text)
  552. if ent.entity_type in ['org', 'company']:
  553. if ent.label == 0 and ent.values[ent.label] >= 0.5:
  554. get_tenderee = True
  555. break
  556. elif ent.label == 1:
  557. if ent.values[ent.label] > 0.5:
  558. agency_set.add(ent.entity_text)
  559. elif ent.label == 5:
  560. if len(ent.entity_text) >= 4:
  561. ents.append(ent)
  562. if not get_tenderee:
  563. get_tenderee = self._recall_entity_context(ents, ctx_map, list_name, list_sentences, list(agency_set))
  564. if not get_tenderee:
  565. get_tenderee = self._recall_subject(ents, ctx_map, list_articles, list_sentences)
  566. return get_tenderee
  567. def _recall_entity_context(self, entitys, ctx_map, list_name, list_sentences, list_agency):
  568. """7 级优先级链(left_1..left_5 → right → right2 → right3,命中后不 break,
  569. 同级所有命中实体均标记;级间短路)。返回是否召回。"""
  570. list_sentences[0].sort(key=lambda x: x.sentence_index)
  571. entity_data = []
  572. for ent in entitys:
  573. ctx = ctx_map.get(id(ent))
  574. if ctx is None:
  575. continue # 原 list_sentences[0][ent.sentence_index] 直接下标;未配对按跳过处理
  576. entity_data.append((ent, ctx))
  577. get_tenderee = False
  578. # ---- left_1 / left_2 / left_3:无条件标记 ----
  579. for level_pattern in (self.p_recall_left_1, self.p_recall_left_2, self.p_recall_left_3):
  580. if get_tenderee:
  581. break
  582. for ent, ctx in entity_data:
  583. if level_pattern.search(ctx.span_wide[0]):
  584. ent.label = 0
  585. ent.values[0] = 0.5 + ent.values[0] / 10
  586. get_tenderee = True
  587. # ---- left_4:代理同名/机构后缀过滤(含 自行采购 特例)----
  588. if not get_tenderee:
  589. for ent, ctx in entity_data:
  590. if self.p_recall_left_4.search(ctx.span_wide[0]):
  591. if len(list_agency) > 0:
  592. _same = False
  593. for agency in list_agency:
  594. if ent.entity_text in agency or agency in ent.entity_text:
  595. _same = True
  596. break
  597. if not _same:
  598. ent.label = 0
  599. ent.values[0] = 0.5 + ent.values[0] / 10
  600. get_tenderee = True
  601. else:
  602. if re.search(_ORG_SUFFIX, ent.entity_text) \
  603. or not re.search(_AGENCY_BIZ, ent.entity_text) \
  604. or re.search("自行.?采购", ctx.sentence.sentence_text):
  605. ent.label = 0
  606. ent.values[0] = 0.5 + ent.values[0] / 10
  607. get_tenderee = True
  608. # ---- left_5:代理同名/机构后缀过滤(无 自行采购 特例)----
  609. if not get_tenderee:
  610. for ent, ctx in entity_data:
  611. if self.p_recall_left_5.search(ctx.span_wide[0]):
  612. if len(list_agency) > 0:
  613. _same = False
  614. for agency in list_agency:
  615. if ent.entity_text in agency or agency in ent.entity_text:
  616. _same = True
  617. break
  618. if not _same:
  619. ent.label = 0
  620. ent.values[0] = 0.5 + ent.values[0] / 10
  621. get_tenderee = True
  622. else:
  623. if re.search(_ORG_SUFFIX, ent.entity_text) or not re.search(_AGENCY_BIZ, ent.entity_text):
  624. ent.label = 0
  625. ent.values[0] = 0.5 + ent.values[0] / 10
  626. get_tenderee = True
  627. # ---- right / right2 ----
  628. for level_pattern in (self.p_recall_behind, self.p_recall_behind_2):
  629. if get_tenderee:
  630. break
  631. for ent, ctx in entity_data:
  632. if level_pattern.search(ctx.span_wide[2]):
  633. ent.label = 0
  634. ent.values[0] = 0.5 + ent.values[0] / 10
  635. get_tenderee = True
  636. # ---- right3:后文项目名命中 name 实体(prob 固定 0.5)----
  637. if not get_tenderee:
  638. if list_name:
  639. for ent, ctx in entity_data:
  640. pj_name = self.p_recall_behind_3.search(ctx.span_wide[2])
  641. if pj_name:
  642. pj_name = pj_name.groupdict()["project"]
  643. for _name in list_name:
  644. if _name in pj_name:
  645. ent.label = 0
  646. ent.values[0] = 0.5
  647. get_tenderee = True
  648. break
  649. return get_tenderee
  650. def _recall_subject(self, entitys, ctx_map, list_articles, list_sentences):
  651. """公告主语规则:全文 我院/本校/我局 → 实体名含院/校/局 时召回(局 需上下文无监督/投诉)。
  652. 返回是否实际标记了实体(对应原 TendereeRuleRecall.get_tenderee)。"""
  653. content = list_articles[0].content.split('##attachment##')[0]
  654. m = self.p_recall_subject.search(content)
  655. if m is None:
  656. return False
  657. _subject = m.group()
  658. found = False
  659. for ent in entitys:
  660. if re.search("院", _subject) and re.search("医院|学院", ent.entity_text):
  661. ent.label = 0
  662. ent.values[0] = 0.5 + ent.values[0] / 10
  663. found = True
  664. elif re.search("校", _subject) and re.search("学校|学院|大学|高中|初中|中学|小学", ent.entity_text):
  665. ent.label = 0
  666. ent.values[0] = 0.5 + ent.values[0] / 10
  667. found = True
  668. elif re.search("局", _subject) and re.search("局", ent.entity_text):
  669. ctx = ctx_map.get(id(ent))
  670. if ctx is None:
  671. continue
  672. _span = spanWindow(tokens=ctx.sentence.tokens, begin_index=ent.begin_index,
  673. end_index=ent.end_index, size=20, center_include=True,
  674. word_flag=True, use_text=True,
  675. text=re.sub(")", ")", re.sub("(", "(", ent.entity_text)))
  676. if not re.search("监督|投诉", _span[0][-10:]):
  677. ent.label = 0
  678. ent.values[0] = 0.5 + ent.values[0] / 10
  679. found = True
  680. return found