# -*- coding: utf-8 -*- """RoleRecallEngine — YAML 驱动的统一角色召回引擎(角色分类流程优化 Phase E)。 职责 ==== 把原 ``role.py`` 中三个各自内联正则、重复构建上下文的类合并为一个引擎: - ``RoleRulePredictor`` → :meth:`predict_role_rule`(角色/金额上下文召回) - ``RoleRuleFinalAdd`` → :meth:`predict_final_add`(文末/全文格式兜底召回) - ``TendereeRuleRecall`` → :meth:`predict_recall`(招标人 7 级上下文链 + 主语规则) 三个旧类名保留为 ``role.py`` 中的薄委托(保持 ``getPredictor`` 旧 key 与 ``from ... import RoleRulePredictor`` 等老 import 兼容),新代码直接用本引擎。 规则来源(source of truth,经 Phase B 逐字节校验 + Phase E 冒烟/双跑回归) ======================================================================== - ``dl/rules/patterns/role_context.yaml`` 角色三组模式(front/behind/whole,命名组后缀为概率权重)+ 金额召回模式 - ``dl/rules/patterns/role_fallback.yaml`` final_*(RoleRuleFinalAdd)与 recall_*(TendereeRuleRecall) 上下文复用(Phase A ``RoleContext``) ==================================== ``extract.py`` 在 prem 前构建一次 ``build_role_contexts``,本引擎通过 ``contexts`` 参数直接复用:实体-句子配对 O(1)、切片惰性缓存 (``span``=get_context 前后 20 字、``span_rule``=spanWindow 前后 40 词 use_text=False、``span_wide``=spanWindow 前后 40 词 use_text=True)。 ``contexts=None`` 时引擎内部自建(供 pipeline 等老调用方使用)。 行为等价说明 ============ - 移植保持原执行顺序与短路语义逐行等价(含 ser_role 首个 finditer 命中即返回、 entity_context_rule 各级"命中后不 break 继续标记同级实体"等隐式行为)。 - 与原实现的已知差异(均为缺陷修正,Phase E badcase 双跑回归验证不影响结果): 1. ``final_end_date_fallback``:原 role.py 文末兜底正则括号不平衡(缺 ``")?"``)一旦执行必抛 ``re.error``,属隐性 bug;YAML 中已修复, badcase 回归集未覆盖该路径。 2. 未配对到句子的实体:原实现靠句循环扫描自然跳过(prem 之后的链路中 实体均来自同一 ``build_role_contexts``,配对语义一致);金额实体在 原实现中若配对失败会沿用上一实体的 ``_span`` 残留(stale 状态), 本实现按"跳过"处理。 3. ``TendereeRuleRecall`` 原按 ``list_sentences[0][ent.sentence_index]`` 直接下标取句(假设句号连续),本实现按 (doc_id, sentence_index) 精确配对(Phase A 已接受的配对修正)。 依赖方向 ======== CORE(predictors)只读消费 ``dl/rules`` 的 patterns 运行时数据与 loader 框架(与 Phase C ``RoleRuleEngine`` 相同约定,ARCHITECTURE.md §4.3)。 """ from __future__ import absolute_import import re from BiddingKG.dl.common.context_utils import spanWindow, get_context from BiddingKG.dl.predictors._common import is_agency from BiddingKG.dl.predictors.role_context import build_role_contexts from BiddingKG.dl.rules.loader import RuleLoader __all__ = ["RoleRecallEngine"] # ---------------------------------------------------------------------------- # 规则 ID 与执行顺序(与原 RoleRulePredictor.__init__ 三组列表一一对应) # ---------------------------------------------------------------------------- _LEFT_ORDER = ( "tenderee_front_60", "tenderee_front_55", "tenderee_front_50", "agency_front", "second_front", "third_front", "winner_front_60", "winner_front_55", "winner_front_51", ) _WHOLE_ORDER = ("winner_whole_center", "tenderee_whole_center") _RIGHT_ORDER = ( "third_behind", "second_behind", "agency_behind", "tenderee_or_agency_behind", "tenderee_behind_50", "winner_behind", ) #: 不可能是中标人的实体集合(原 RoleRulePredictor.SET_NOT_TENDERER) SET_NOT_TENDERER = set([ "人民政府", "人民法院", "中华人民共和国", "人民检察院", "评标委员会", "中国政府", "中国海关", "中华人民共和国政府", ]) #: 机构类后缀(原 TendereeRuleRecall.entity_context_rule 内联正则,程序性判断) _ORG_SUFFIX = ("医院|学校|大学|中学|小学|幼儿园|政府|部|委员会|署|行|" "局|厅|处|室|科|股|站") #: 代理业务关键词(原 TendereeRuleRecall.entity_context_rule 内联正则) _AGENCY_BIZ = "(采购|招标|投标|交易|代理|拍卖|咨询|顾问|管理)" class RoleRecallEngine(object): """统一角色召回引擎(无实例可变状态,线程安全)。 用法(role.py 旧类委托):: engine = RoleRecallEngine() all_tenderer = engine.predict_role_rule(list_articles, list_sentences, list_entitys, list_codenames, channel_dic, title, contexts=ctxs) engine.predict_final_add(list_articles, list_sentences, list_entitys, list_codenames) engine.predict_recall(list_articles, list_sentences, list_entitys, list_codenames, contexts=ctxs) """ def __init__(self): # role_context.yaml:三组角色模式(编译后) self.p_left = [RuleLoader.get_pattern(pid) for pid in _LEFT_ORDER] self.p_whole = [RuleLoader.get_pattern(pid) for pid in _WHOLE_ORDER] self.p_right = [RuleLoader.get_pattern(pid) for pid in _RIGHT_ORDER] # predict 流程单独引用的模式 self.p_tenderee_front_50 = RuleLoader.get_pattern("tenderee_front_50") self.p_tenderee_front_55 = RuleLoader.get_pattern("tenderee_front_55") self.p_candidate_front = RuleLoader.get_pattern("candidate_front") self.p_tenderer_front = RuleLoader.get_pattern("tenderer_front") # 金额召回(role_context.yaml,Phase E 迁移) self.p_money_tenderee_front = RuleLoader.get_pattern("money_tenderee_front") self.p_money_tenderer_front = RuleLoader.get_pattern("money_tenderer_front") self.p_money_tenderer_whole = RuleLoader.get_pattern("money_tenderer_whole") self.p_money_other_fee = RuleLoader.get_pattern("money_other_fee") self.p_money_bank_tenderee_front = RuleLoader.get_pattern("money_bank_tenderee_front") self.p_money_bank_tenderee_behind = RuleLoader.get_pattern("money_bank_tenderee_behind") self.p_pack_signal = RuleLoader.get_pattern("pack_signal") # 文末/全文格式召回(role_fallback.yaml final_*) self.p_final_publisher = RuleLoader.get_pattern("final_publisher") self.p_final_buyer_info = RuleLoader.get_pattern("final_buyer_info") self.p_final_account_name = RuleLoader.get_pattern("final_account_name") self.p_final_contact_unit = RuleLoader.get_pattern("final_contact_unit") self.p_final_end_date = RuleLoader.get_pattern("final_end_date") self.p_final_end_date_fallback = RuleLoader.get_pattern("final_end_date_fallback") self.p_final_station_format = RuleLoader.get_pattern("final_station_format") # 招标人 7 级上下文链 + 主语(role_fallback.yaml recall_*) self.p_recall_left_1 = RuleLoader.get_pattern("recall_left_1") self.p_recall_left_2 = RuleLoader.get_pattern("recall_left_2") self.p_recall_left_3 = RuleLoader.get_pattern("recall_left_3") self.p_recall_left_4 = RuleLoader.get_pattern("recall_left_4") self.p_recall_left_5 = RuleLoader.get_pattern("recall_left_5") self.p_recall_behind = RuleLoader.get_pattern("recall_behind") self.p_recall_behind_2 = RuleLoader.get_pattern("recall_behind_2") self.p_recall_behind_3 = RuleLoader.get_pattern("recall_behind_3") self.p_recall_subject = RuleLoader.get_pattern("recall_subject") self.SET_NOT_TENDERER = SET_NOT_TENDERER # ------------------------------------------------------------------ # 原 RoleRulePredictor 公共方法 # ------------------------------------------------------------------ @staticmethod def _check_input(text, ignore=False): if not text: return [] if not isinstance(text, list): text = [text] null_index = [i for i, t in enumerate(text) if not t] if null_index and not ignore: raise Exception("null text in input ") return text def ser_role(self, pattern_list, text, entity_text): """命名组模式匹配:首个 finditer 命中即返回 (label, prob, group0)。""" for _pattern in pattern_list: for _iter in _pattern.finditer(text): for _group, _v_group in _iter.groupdict().items(): if _v_group is not None and _v_group != "": _role = _group.split("_")[0] if _role == "tendereeORagency": # 2022/3/9 新增不确定招标代理判断逻辑 if is_agency(entity_text): _role = 'tenderee' else: _role = "agency" prob = int(_group.split("_")[2]) / 100 if len(_group.split("_")) == 3 else 0.55 _label = {"tenderee": 0, "agency": 1, "winTenderer": 2, "secondTenderer": 3, "thirdTenderer": 4}.get(_role) return (_label, prob, _iter.group(0)) return (5, 0.5, '') def rule_predict(self, before, center, after, entity_text): """前文 → 整句 → 后文三级召回(原 RoleRulePredictor.rule_predict 逐行移植)。""" _label, _prob, keyword = self.ser_role(self.p_left, before, entity_text) # 前文匹配 keyword = "left_" + keyword if keyword != "" else keyword if _label == 2 and re.search( '各.{,5}供应商|尊敬的供应商|[^\w]候选供应商|业绩|拟招|(交易|采购|招标|建设)服务(单位|机构)|第[四五六七4567]|是否中标:否|序号:\d+,\w{,2}候选|(排名|排序|名次):([4-9]|\d{2,})|未(中[标选]|入围)|不得确定为|(响应|参[加与]报价|通过资格审查)的?供应商', # 135463002 拟招一家供应商为宜宾市第三人民医院、李庄同济医院院区提供消防维保服务 before) != None: _label = 5 elif _label == 2 and re.search('为$', before) and re.match('\w', after): # 排除错误 前文为结尾,后文不是标点符号结尾的,如 353824459 供应商为社会团体的, 供应商为玉田县中医医院提供安保服务 _label = 5 elif _label == 2 and re.search('评委|未中标', after[:5]): # 397194341 过滤掉错误召回中标人 _label = 5 elif _label == 2 and re.search('[^\w]供应商', before[-10:]) and re.search('^,?(投标报价|(资格性审查:|符合性审查:)?(不通过|不符合))', after[:12]) and re.search('中标|成交|中选|排名|排序|名次|第[一1]名', before[-10:]) is None: # 20240705 处理类似 493939047 错误 _label = 5 if _label == 5: _label, _prob, keyword = self.ser_role(self.p_whole, before + center + after, entity_text) # 前后文匹配 keyword = 'whole_' + keyword[:keyword.find(entity_text)] if keyword != "" else keyword if _label == 2 and re.search('以[^,。;]{10,30}为准', before + center + after) != None: _label = 5 if _label != 5 and self.ser_role(self.p_whole, before, entity_text)[0] != 5 or \ self.ser_role(self.p_whole, after, entity_text)[0] != 5: _label = 5 if _label == 5: _label, _prob, keyword = self.ser_role(self.p_right, after, entity_text) # 后文匹配 keyword = "right_" + keyword if keyword != "" else keyword if _label == 5 and re.search('(中标|中选|成交)?|谈判结果)(结果)?(公告|公示|通知书?),', before) and re.match(':', after): # 632523961 直接谈判结果通知书,广东金钥匙智能包装科技有限公司:经广州地铁传媒有限公司 _label = 2 _prob = 0.5 if _label == 2 and re.search('[^\w]供应商(名称)?:$', before) and re.search('^,(是否通过资格审查|资格审查情况|专家\d|报价)', after): # 修复 673143737 废标公告 附件评标信息作为中标 _label = 5 _flag = False if _label == 5 else True return (_label, _prob, _flag, keyword) # ------------------------------------------------------------------ # 原 RoleRulePredictor.predict # ------------------------------------------------------------------ def predict_role_rule(self, list_articles, list_sentences, list_entitys, list_codenames, channel_dic, title, on_value=0.5, all_winner=False, req_scope=[], deposit_project=False, contexts=None): """角色/金额上下文召回(原 RoleRulePredictor.predict 移植,RoleContext 复用)。 :param contexts: ``build_role_contexts`` 预计算上下文(extract.py 入口已构建, 与 prem 共享);None 时内部自建(保持老调用方行为)。 :return: all_tenderer 集合(所有召回的投标人实体文本) """ all_tenderer = set() # 保存所有投标人 if contexts is None: contexts = build_role_contexts(list_sentences, list_entitys) for article, list_entity, list_sentence, list_codename, list_ctx in zip( list_articles, list_entitys, list_sentences, list_codenames, contexts): list_sentence.sort(key=lambda x: x.sentence_index) # 2022/1/5 按句子顺序排序 ctx_map = {id(c.entity): c for c in list_ctx} list_name = [] # 2022/1/5 改为实体列表内所有项目名称 name_entitys = [] # 2023/6/30 保存项目名称实体,直接通过位置判断角色是否在项目名称里面 candidates = [] # 保存不能确定为第几的候选人 2023/04/14 notfound_tenderer = True # 未找到前三候选人 deposit_moneys = [] # 保存存款类项目采购内容中大于百万的其他金额实体 for entity in list_entity: if entity.entity_type == 'name': list_name.append(entity.entity_text) name_entitys.append(entity) list_name = self._check_input(list_name) + [article.title] for p_entity in list_entity: if p_entity.entity_type in ["org", "company"]: # 只解析角色为无的或者概率低于阈值的 if p_entity.label is None: continue ctx = ctx_map.get(id(p_entity)) # 将上下文包含标题的实体概率置为0.6,因为标题中的实体不一定是招标人 if str(p_entity.label) == "0": find_flag = False if ctx is not None: _span = ctx.span if self.p_tenderee_front_50.search(_span[0]) or self.p_tenderee_front_55.search(_span[0]): # 前面有关键词的实体不判断是否在项目名称中出现 find_flag = True elif re.search('(项目|工程|招标|采购(条目)?|合同|标项|标的|计划|询价|询价单|询价通知书|申购单|申购)(名称|标名|标题|主题):$', _span[0]): find_flag = True if re.search('(局|院|府|学|处|站|会|所|校|馆|队|厅|室|司|心|园|厂)$', p_entity.entity_text): p_entity.values[0] = 0.6 if p_entity.values[0] > 0.6 else 0.55 else: p_entity.values[0] = on_value # 项目名称里面实体修改为最低概率 if p_entity.entity_text in title: # 749013557 提高表头角色概率 p_entity.values[0] += 0.02 else: for _name in name_entitys: if _name.sentence_index == p_entity.sentence_index and p_entity.wordOffset_begin >= _name.wordOffset_begin and p_entity.wordOffset_end < _name.wordOffset_end: find_flag = True if re.search('(局|院|府|学|处|站|会|所|校|馆|队|厅|室|司|心|园|厂)$', p_entity.entity_text): p_entity.values[0] = 0.6 if p_entity.values[0] > 0.6 else 0.55 else: p_entity.values[0] = on_value # 项目名称里面实体修改为最低概率 if p_entity.entity_text in title: # 749013557 提高表头角色概率 p_entity.values[0] += 0.02 break if find_flag: continue # 正则从概率低于阈值或其他类别中召回角色 role_prob = float(p_entity.values[int(p_entity.label)]) if role_prob < on_value or str(p_entity.label) == "5": # 将标题中的实体置为招标人 _list_name = self._check_input(list_name, ignore=True) find_flag = False for _name in _list_name: # 2022/1/5修正只要项目名称出现过的角色,所有位置都标注为招标人 if str(_name).find(p_entity.entity_text) >= 0 and p_entity.sentence_index < 4: if ctx is not None: _span = ctx.span if _span[2].startswith(":"): # 实体后面为冒号的不作为招标人,避免项目名称出错中标变招标 368122675 陇西兴恒建建筑有限责任公司:线路安全保护区内环境治理专项整改(第二标段)项目 pass elif str(_span[0][-len(str(_name)):] + _span[1] + _span[2][:len(str(_name))]).find( _name) >= 0 or str(_name).startswith(p_entity.entity_text): # 20240621 补充公司开头的项目名称召回,避免name太长召回失败 例 367033697 if is_agency(p_entity.entity_text): # 2024/3/29 统一方法判断是否为代理 find_flag = True # 20250918 不在召回项目名称中的代理,避免某些站源批量错误 640739667 else: find_flag = True _label = 0 p_entity.label = _label p_entity.values[int(_label)] = on_value + p_entity.values[int(_label)] / 10 if 6 < len(p_entity.entity_text) < 20 and p_entity.entity_type == 'org': # 标题中角色长度在一定范围内的加分 优化类似367720967 标题中两个实体选择错误问题 p_entity.values[int(_label)] += 0.005 break if find_flag: break # 若是实体在标题中,默认为招标人,不进行以下的规则匹配 if find_flag: continue if ctx is not None: spans = ctx.span_rule # spanWindow 前后各 40 词,use_text=False # 添加中标通知书类型特殊处理(原 s_index == 0:实体所在句为排序后第一句) handled = False try: if ctx.sentence is list_sentence[0] and re.search( '中标通知书.{,30}[,:]%s:' % p_entity.entity_text.replace('(', '').replace(')', ''), ctx.sentence.sentence_text.replace('(', '').replace(')', '')[:100]): p_entity.label = 2 p_entity.values[2] = 0.5 notfound_tenderer = False handled = True except Exception as e: print('正则报错:', e) if not handled: before, center, after = spans[0], spans[1], spans[2] entity_text = p_entity.entity_text _label, _prob, _flag, kw = self.rule_predict(before, center, after, entity_text) if _label == 5 and re.search(':(1[.、])?$', before) and re.search('^[、;,&/。]', after) and re.search('(监督|管理)(机构|部门|单位):', before) is None and re.search( '(中标|成交|中选))?(人|单位|供应商|银行|合作伙伴)?(公示)?(信息|情况|结果|如下)(公[示告]如下)?:|(遴选|寻源|采购|招标|竞价|议价|比选|委托|询比?价|比价|评选|谈判|邀标|邀请|洽谈|约谈|选取|抽取)结果(如下)(公[示告]如下)?:|,中标人,', ctx.sentence.sentence_text[:p_entity.wordOffset_begin]): # 补充召回 例:514053647 标段1:中国建设银行西安南大街支行,标段2:中国农业银行股份有限公司西安分行, _flag = True _label = 2 _prob = 0.5 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)): if re.search('(中标|中选|成交|入围|入选)(人|单位|供应商|银行)(名称)?:$', before) and re.search('未(中标|中选|成交|入围|入选)', before[-15:]) is None: _flag = True _label = 2 _prob = 0.55 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]): _flag = True _label = 2 _prob = 0.51 elif re.search('(候选|投标|应答|响应)(人|单位|供应商|银行)(名称)?:$', before) and re.search('是否中标:否|未(中标|中选|成交|入围|入选)', before[-15:]) is None: _flag = True _label = 2 _prob = 0.5 # 得到结果 if _flag: if _label in [2, 3, 4]: notfound_tenderer = False all_tenderer.add(p_entity.entity_text) p_entity.label = _label p_entity.values[_label] = _prob + p_entity.values[int(_label)] / 10 # 提取投标人 if _label == 5 and self.p_tenderer_front.search(before[-10:]): all_tenderer.add(p_entity.entity_text) else: if self.p_candidate_front.search(before) and re.search('尊敬的|各', before[-10:]) is None: candidates.append(p_entity) elif channel_dic['docchannel']['docchannel'] in ['中标信息', '候选人公示', '合同公告'] and re.search(':$', before) and re.search('^[,。]', after) and re.search('候选人', before[-15:]): # 补充 577756336 候选人,三期A160、A166地块:中国建设银行成都第九支行, candidates.append(p_entity) elif str(p_entity.label) in ['2', '3', '4']: notfound_tenderer = False # 其他金额通过正则召回可能是招标或中投标的金额 if p_entity.entity_type in ["money"]: ctx_m = ctx_map.get(id(p_entity)) if str(p_entity.label) == "2" and ctx_m is not None: self._recall_money_label2(p_entity, ctx_m, deposit_project) if deposit_project and p_entity.label in [1, 2]: if req_scope and float(p_entity.entity_text) > 1000000 and (p_entity.sentence_index > req_scope[0][0] \ 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] \ or (p_entity.sentence_index == req_scope[1][0] and p_entity.wordOffset_end <= req_scope[1][1])): deposit_moneys.append(p_entity) if deposit_moneys: moneys = [float(p.entity_text) for p in deposit_moneys] for p in deposit_moneys: if float(p.entity_text) == max(moneys): p.values[0] = 0.55 p.label = 0 else: p.values[0] = 0.5 p.label = 0 if notfound_tenderer and len(set([ent.entity_text for ent in candidates])) == 1 and channel_dic['docchannel']['docchannel'] in ['中标信息', '候选人公示', '合同公告']: for p_entity in candidates: # 只有一个候选人的作为中标人 p_entity.label = 2 p_entity.values[2] = on_value # 增加招标金额扩展,招标金额+连续的未识别金额,并且都可以匹配到标段信息,则将为识别的金额设置为招标金额 list_p = [] state = 0 for p_entity in list_entity: for _sentence in list_sentence: if _sentence.sentence_index == p_entity.sentence_index: _span = get_context(_sentence.sentence_text, p_entity.wordOffset_begin, p_entity.wordOffset_end, size=30, center_include=True) if state == 2: for _p in list_p[1:]: if _p.label == 2: _p.values[0] = 0.5 + _p.values[0] / 10 _p.label = 0 state = 0 list_p = [] if state == 0: if p_entity.entity_type in ["money"]: if str(p_entity.label) == "0" and self.p_pack_signal.search( _span[0] + "-" + _span[2]) is not None: state = 1 list_p.append(p_entity) elif state == 1: if p_entity.entity_type in ["money"]: if str(p_entity.label) in ["0", "2"] and self.p_pack_signal.search( _span[0] + "-" + _span[2]) is not None and self.p_money_other_fee.search( _span[0] + "-" + _span[2]) is None and p_entity.sentence_index == list_p[0].sentence_index: list_p.append(p_entity) else: state = 2 if len(list_p) > 1: for _p in list_p[1:]: if _p.label == 2: _p.values[0] = 0.5 + _p.values[0] / 10 _p.label = 0 state = 0 list_p = [] for p_entity in list_entity: # 将属于集合中的不可能是中标人的标签置为无 if p_entity.entity_text in self.SET_NOT_TENDERER: p_entity.label = 5 return all_tenderer def _recall_money_label2(self, p_entity, ctx, deposit_project): """money 实体 label=2 的招标/中标金额召回(原 predict 468-522 行移植)。 原实现中 continue/break 均作用于句循环,效果为"结束该实体的金额处理", 此处以 return 等价表达。 """ _span = ctx.span # get_context 前后各 20 字 if re.search('(含|在|包括)(\d+)?$', _span[0]): return m_tenderee = self.p_money_tenderee_front.search(_span[0]) if m_tenderee is not None and self.p_money_other_fee.search(_span[0]) is None: front_text = _span[0][m_tenderee.end():] if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉 return p_entity.values[0] = 0.62 + p_entity.values[0] / 10 p_entity.label = 0 elif deposit_project: m_bank = self.p_money_bank_tenderee_front.search(_span[0]) if m_bank is not None and self.p_money_other_fee.search(_span[0]) is None: front_text = _span[0][m_bank.end():] if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉 return p_entity.values[0] = 0.6 + p_entity.values[0] / 10 p_entity.label = 0 elif self.p_money_bank_tenderee_behind.search(_span[2]): p_entity.values[0] = 0.55 + p_entity.values[0] / 10 p_entity.label = 0 elif (re.search('存款|总额度', _span[0]) or re.search('存[款放]|专项债资金', _span[2])): # 注:原实现此处 front 搜索未命中时以 _span[2] 匹配的 end() 切 _span[0],保留原行为 front_text = _span[0][(re.search('存款|总额度', _span[0]) or re.search('存[款放]|专项债资金', _span[2])).end():] if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉 return p_entity.values[0] = 0.55 p_entity.label = 0 m_tenderer = self.p_money_tenderer_front.search(_span[0]) if m_tenderer is not None: front_text = _span[0][m_tenderer.end():] if re.search('\d[万亿]?元|元)?:?\d', front_text): # 当前金额与关键词中间有金额的过滤掉 return elif re.search('合同价暂定为?$', _span[0]): # 20250310 修复 598504921 合同价暂定 为招标金额 return elif re.search('^(以[上下])?按[\d.%]+收取|^及?以[上下]|^[()]?[+×*-][\d.%]+|服务招标费率|招标代理服务收费', _span[2][:20]): return m_other = self.p_money_other_fee.search(_span[0]) if m_other is not None: if m_tenderer.span()[1] > m_other.span()[1]: p_entity.values[1] = 0.6 + p_entity.values[1] / 10 p_entity.label = 1 else: p_entity.values[1] = 0.6 + p_entity.values[1] / 10 p_entity.label = 1 if self.p_money_tenderer_whole.search("".join(_span)) and self.p_money_tenderer_whole.search(_span[0]) is None \ and self.p_money_tenderer_whole.search(_span[2]) is None and self.p_money_other_fee.search(_span[0]) is None: p_entity.values[1] = 0.6 + p_entity.values[1] / 10 p_entity.label = 1 elif re.search('(预算金额|最高(投标)?上?限[价额]?格?|招标控制价))?:?([\d.,]+万?元[,(]其中)?(第?[一二三四五0-9](标[段|包]|[分子]包):?[\d.,]+万?元,)*第?[一二三四五0-9](标[段|包]|[分子]包):?$', ctx.sentence.sentence_text[:p_entity.wordOffset_begin]): # 处理几个标段金额相邻情况 例子:191705231 p_entity.values[0] = 0.6 + p_entity.values[0] / 10 p_entity.label = 0 elif re.search('固定价格?:(人民币|¥)?$', _span[0]): # 20250423 修复 613808422 补充为招标金额 p_entity.values[0] = 0.5 p_entity.label = 0 # ------------------------------------------------------------------ # 原 RoleRuleFinalAdd.predict # ------------------------------------------------------------------ def predict_final_add(self, list_articles, list_sentences, list_entitys, list_codenames): """文末/全文格式兜底召回(原 RoleRuleFinalAdd.predict 移植,final_* YAML)。""" main_sentences = [sentence for sentence in list_sentences[0] if not sentence.in_attachment] if len(list_sentences[0]) > 0 and list_sentences[0][-1].in_attachment: main_sentences = list_sentences[0][-1:] + main_sentences[-2:] if len(main_sentences) == 0: return 0 sear_ent = None for sentence in main_sentences[-5:][::-1]: # 402073799 最后五句由后往前,匹配文末角色,日期 text_end = "".join(sentence.tokens) text_end = re.sub(r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", '', text_end) # 去除网址 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日, 这种情况 sear_ent = self.p_final_end_date.search(text_end) if sear_ent: b, e = sear_ent.span() if re.search('报价记录|竞价成交', text_end[max(b - 10, 0):b] + text_end[e:]): sear_ent = None break if sear_ent is None: text_end = list_articles[0].content[-100:] # 注:原 role.py 此处正则括号不平衡(隐性 bug),YAML final_end_date_fallback 已修复 sear_ent = self.p_final_end_date_fallback.search(text_end) if sear_ent: b, e = sear_ent.span() if re.search('报价记录|竞价成交', text_end[max(b - 10, 0):b] + text_end[e:]): sear_ent = None sear_ent1 = self.p_final_contact_unit.search(list_articles[0].content[:5000]) sear_ent2 = self.p_final_account_name.search(list_articles[0].content[:5000]) sear_ent3 = self.p_final_buyer_info.search(list_articles[0].content[:5000]) sear_ent4 = self.p_final_publisher.search(list_articles[0].content[:5000]) sear_ent5 = self.p_final_station_format.search(list_articles[0].content[:5000]) sear_list = [sear_ent4, sear_ent3, sear_ent2, sear_ent1, sear_ent, sear_ent5] tenderee_notfound = True agency_notfound = True tenderee_list = [] agency_list = [] ents = [] for ent in list_entitys[0]: if ent.entity_type in ['org', 'company']: if ent.label == 0 and ent.values[ent.label] > 0.55: if '公共资源交易中心' in ent.entity_text: # 公共资源交易中心不算招标或代理,只算平台 # 改为降低概率,不改类别,防止 382573066 明显招标人表达不提取 ent.values[ent.label] = 0.6 if ent.values[ent.label] > 0.6 else 0.5 continue tenderee_list.append(ent.entity_text) tenderee_notfound = False elif ent.label == 1 and ent.values[ent.label] > 0.55: agency_list.append(ent.entity_text) agency_notfound = False elif ent.label == 5: if '公共资源交易中心' in ent.entity_text: continue ents.append(ent) if sear_ent or sear_ent1 or sear_ent2 or sear_ent3 or sear_ent4 or sear_ent5: for _sear_ent in [_sear for _sear in sear_list if _sear]: ent_re = _sear_ent.group('entity') ent_re = ent_re.replace(',', '').replace("(", "(").replace(")", ")") if tenderee_notfound or agency_notfound: n = 0 for i in range(len(ents) - 1, -1, -1): if not ents[i].in_attachment: n += 1 if n > 3 and _sear_ent == sear_ent: # 文章末尾角色加日期这种只找后三个实体 break elif _sear_ent == sear_ent and ents[i].label != 5: # 后面有角色的实体的停止继续往前 break 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): if agency_notfound and is_agency(ents[i].entity_text) and ents[i].entity_text not in tenderee_list: ents[i].label = 1 ents[i].values[1] = 0.51 # 修改为比标题概率略高 agency_notfound = False elif tenderee_notfound and not is_agency(ents[i].entity_text) and ents[i].entity_text not in agency_list: ents[i].label = 0 ents[i].values[0] = 0.51 # 修改为比标题概率略高 tenderee_notfound = False break if not tenderee_notfound: break # ------------------------------------------------------------------ # 原 TendereeRuleRecall.predict / entity_context_rule / subject_rule # ------------------------------------------------------------------ def predict_recall(self, list_articles, list_sentences, list_entitys, list_codenames, contexts=None): """招标人兜底召回:7 级上下文链 + 公告主语规则(recall_* YAML)。""" if contexts is None: contexts = build_role_contexts(list_sentences, list_entitys) list_ctx = contexts[0] if contexts else [] ctx_map = {id(c.entity): c for c in list_ctx} get_tenderee = False ents = [] list_name = [] agency_set = set() for ent in list_entitys[0]: if ent.entity_type == 'name': list_name.append(ent.entity_text) if ent.entity_type in ['org', 'company']: if ent.label == 0 and ent.values[ent.label] >= 0.5: get_tenderee = True break elif ent.label == 1: if ent.values[ent.label] > 0.5: agency_set.add(ent.entity_text) elif ent.label == 5: if len(ent.entity_text) >= 4: ents.append(ent) if not get_tenderee: get_tenderee = self._recall_entity_context(ents, ctx_map, list_name, list_sentences, list(agency_set)) if not get_tenderee: get_tenderee = self._recall_subject(ents, ctx_map, list_articles, list_sentences) return get_tenderee def _recall_entity_context(self, entitys, ctx_map, list_name, list_sentences, list_agency): """7 级优先级链(left_1..left_5 → right → right2 → right3,命中后不 break, 同级所有命中实体均标记;级间短路)。返回是否召回。""" list_sentences[0].sort(key=lambda x: x.sentence_index) entity_data = [] for ent in entitys: ctx = ctx_map.get(id(ent)) if ctx is None: continue # 原 list_sentences[0][ent.sentence_index] 直接下标;未配对按跳过处理 entity_data.append((ent, ctx)) get_tenderee = False # ---- left_1 / left_2 / left_3:无条件标记 ---- for level_pattern in (self.p_recall_left_1, self.p_recall_left_2, self.p_recall_left_3): if get_tenderee: break for ent, ctx in entity_data: if level_pattern.search(ctx.span_wide[0]): ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 get_tenderee = True # ---- left_4:代理同名/机构后缀过滤(含 自行采购 特例)---- if not get_tenderee: for ent, ctx in entity_data: if self.p_recall_left_4.search(ctx.span_wide[0]): if len(list_agency) > 0: _same = False for agency in list_agency: if ent.entity_text in agency or agency in ent.entity_text: _same = True break if not _same: ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 get_tenderee = True else: if re.search(_ORG_SUFFIX, ent.entity_text) \ or not re.search(_AGENCY_BIZ, ent.entity_text) \ or re.search("自行.?采购", ctx.sentence.sentence_text): ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 get_tenderee = True # ---- left_5:代理同名/机构后缀过滤(无 自行采购 特例)---- if not get_tenderee: for ent, ctx in entity_data: if self.p_recall_left_5.search(ctx.span_wide[0]): if len(list_agency) > 0: _same = False for agency in list_agency: if ent.entity_text in agency or agency in ent.entity_text: _same = True break if not _same: ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 get_tenderee = True else: if re.search(_ORG_SUFFIX, ent.entity_text) or not re.search(_AGENCY_BIZ, ent.entity_text): ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 get_tenderee = True # ---- right / right2 ---- for level_pattern in (self.p_recall_behind, self.p_recall_behind_2): if get_tenderee: break for ent, ctx in entity_data: if level_pattern.search(ctx.span_wide[2]): ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 get_tenderee = True # ---- right3:后文项目名命中 name 实体(prob 固定 0.5)---- if not get_tenderee: if list_name: for ent, ctx in entity_data: pj_name = self.p_recall_behind_3.search(ctx.span_wide[2]) if pj_name: pj_name = pj_name.groupdict()["project"] for _name in list_name: if _name in pj_name: ent.label = 0 ent.values[0] = 0.5 get_tenderee = True break return get_tenderee def _recall_subject(self, entitys, ctx_map, list_articles, list_sentences): """公告主语规则:全文 我院/本校/我局 → 实体名含院/校/局 时召回(局 需上下文无监督/投诉)。 返回是否实际标记了实体(对应原 TendereeRuleRecall.get_tenderee)。""" content = list_articles[0].content.split('##attachment##')[0] m = self.p_recall_subject.search(content) if m is None: return False _subject = m.group() found = False for ent in entitys: if re.search("院", _subject) and re.search("医院|学院", ent.entity_text): ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 found = True elif re.search("校", _subject) and re.search("学校|学院|大学|高中|初中|中学|小学", ent.entity_text): ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 found = True elif re.search("局", _subject) and re.search("局", ent.entity_text): ctx = ctx_map.get(id(ent)) if ctx is None: continue _span = spanWindow(tokens=ctx.sentence.tokens, begin_index=ent.begin_index, end_index=ent.end_index, size=20, center_include=True, word_flag=True, use_text=True, text=re.sub(")", ")", re.sub("(", "(", ent.entity_text))) if not re.search("监督|投诉", _span[0][-10:]): ent.label = 0 ent.values[0] = 0.5 + ent.values[0] / 10 found = True return found