| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738 |
- # -*- 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
|