Ver Fonte

fix(sgsc-时效性审查模型-lpl): 修复括号识别问题

suhua31 há 2 semanas atrás
pai
commit
d4b3db4b1b

+ 1 - 1
core/construction_review/component/reviewers/timeliness_basis_reviewer.py

@@ -192,7 +192,7 @@ class BasisReviewService:
         self,
         basis_items: List[str],
         collection_name: str = "first_bfp_collection_status",
-        top_k_each: int = 3,
+        top_k_each: int = 10,  # 增加召回数量,提高精确匹配机会
     ) -> List[Dict[str, Any]]:
         """异步批次审查(通常3条)"""
         basis_items = [x for x in (basis_items or []) if isinstance(x, str) and x.strip()]

+ 1 - 1
core/construction_review/component/reviewers/timeliness_content_reviewer.py

@@ -398,7 +398,7 @@ class ContentTimelinessReviewer:
         self,
         standard_number: str,
         collection_name: str,
-        top_k: int = 3
+        top_k: int = 10  # 增加召回数量,提高精确匹配机会
     ) -> List[dict]:
         """异步搜索单个规范"""
         try:

+ 170 - 4
core/construction_review/component/reviewers/utils/reference_matcher.py

@@ -67,6 +67,8 @@ HUMAN = """
 
 3. **has_exact_match**(是否有名称编号都相同的文件)
    - 参考文件中的编号和文件名与审查规范完全匹配,返回 true
+   - **重要**:比较时忽略括号格式差异(半角()和全角()视为相同)
+   - 例如:《规范》(GB 1234-2020)与《规范》(GB 1234-2020)视为完全匹配
    - 否则返回 false
 
 4. **exact_match_info**(名称编号相同的文件及状态)
@@ -163,6 +165,98 @@ def _extract_regulation_info(text: str) -> Tuple[str, Optional[str]]:
     return name, number
 
 
+def _normalize_text(text: str) -> str:
+    """
+    标准化文本,统一括号格式用于比较
+    将全角括号转换为半角括号,去除多余空格
+    """
+    if not text:
+        return text
+    # 全角括号转为半角括号
+    text = text.replace('(', '(').replace(')', ')')
+    # 统一书名号(中文书名号保持不变,但统一全角半角)
+    text = text.replace('『', '《').replace('』', '》')
+    text = text.replace('﹄', '《').replace('﹃', '》')
+    # 去除多余空格
+    text = ' '.join(text.split())
+    return text.strip()
+
+
+def _extract_core_number(number: str) -> str:
+    """
+    提取规范编号的核心部分(去掉年份)
+    例如:JGJ 65-2013 -> JGJ65, GB/T 50010-2010 -> GB/T50010
+    
+    Args:
+        number: 规范编号,如 "JGJ 65-2013"
+        
+    Returns:
+        核心编号,如 "JGJ65"
+    """
+    if not number:
+        return ""
+    
+    # 标准化:转大写、去空格
+    normalized = number.upper().replace(' ', '')
+    
+    # 去掉年份部分(-YYYY 或 —YYYY)
+    # 匹配末尾的年份 -4位数字 或 —4位数字 或 - 4位数字
+    normalized = re.sub(r'[-—]\s*\d{4}$', '', normalized)
+    
+    return normalized
+
+
+def _is_same_regulation_family(original_number: str, generated_number: str, threshold: int = 100) -> bool:
+    """
+    判断两个编号是否属于同一规范家族(核心部分相同或高度相似)
+    
+    Args:
+        original_number: 原始编号
+        generated_number: 生成的编号
+        threshold: 数字差异阈值,默认100
+        
+    Returns:
+        bool: 是否属于同一规范家族
+    """
+    original_core = _extract_core_number(original_number)
+    generated_core = _extract_core_number(generated_number)
+    
+    if not original_core or not generated_core:
+        return False
+    
+    # 如果核心部分完全相同,肯定是同一规范
+    if original_core == generated_core:
+        return True
+    
+    # 提取前缀(如 JGJ、GB/T 等)和数字部分
+    def _split_core(core: str) -> tuple:
+        """将核心编号拆分为前缀和数字部分"""
+        match = re.match(r'^([A-Z]+(?:/[A-Z])?)(\d+(?:\.\d+)?)$', core)
+        if match:
+            return match.group(1), match.group(2)
+        return core, ""
+    
+    orig_prefix, orig_num = _split_core(original_core)
+    gen_prefix, gen_num = _split_core(generated_core)
+    
+    # 如果前缀相同但数字不同,可能是同一系列的不同规范
+    # 例如 JGJ65 和 JGJ300 都是 JGJ 系列,但是完全不同的规范
+    # 我们认为:如果前缀相同且数字相似(差值在一定范围内),才算同一规范家族
+    if orig_prefix == gen_prefix and orig_num and gen_num:
+        try:
+            orig_val = float(orig_num)
+            gen_val = float(gen_num)
+            # 【关键阈值】如果数字差异达到或超过阈值,认为是完全不同的规范
+            if abs(orig_val - gen_val) >= threshold:
+                return False
+            return True
+        except ValueError:
+            # 无法转换为数字,直接比较字符串
+            pass
+    
+    return False
+
+
 # ===== 9) 新流程:验证并生成正确编号 =====
 async def validate_and_generate_number(
     review_item: str,
@@ -189,6 +283,21 @@ async def validate_and_generate_number(
     if existing_number:
         logger.info(f"[时效性验证] 验证编号: 《{regulation_name}》 {existing_number}")
         
+        # 先进行本地标准化比较:检查参考候选中是否有编号完全匹配(忽略括号差异)的
+        normalized_existing = _normalize_text(existing_number)
+        for candidate in reference_candidates:
+            # 从候选中提取编号
+            _, candidate_number = _extract_regulation_info(candidate)
+            if candidate_number and _normalize_text(candidate_number) == normalized_existing:
+                logger.info(f"[时效性验证] 本地验证通过(编号匹配): 《{regulation_name}》 {existing_number}")
+                return ValidationMatchResult(
+                    review_item=review_item,
+                    reference_candidates=reference_candidates,
+                    is_valid=True,
+                    validated_number=existing_number,
+                    status="验证通过"
+                )
+        
         # 调用3模型验证
         validation = await validate_reference_number(
             regulation_name=regulation_name,
@@ -323,7 +432,44 @@ async def match_reference_files(reference_text: str, review_text: str) -> str:
         exact_info = raw_item.get("exact_match_info", "")
         same_name_current = raw_item.get("same_name_current", "")
         
-        # 如果有精确匹配,直接接受
+        # 【校正逻辑】如果LLM判断has_exact_match=false,但本地比较发现编号相同(忽略括号差异),则校正为true
+        if not has_exact and exact_info:
+            _, review_number = _extract_regulation_info(review_item)
+            _, exact_number = _extract_regulation_info(exact_info)
+            if review_number and exact_number and _normalize_text(review_number) == _normalize_text(exact_number):
+                logger.info(f"[规范匹配校正] review_item='{review_item}' 编号实质相同,校正has_exact_match为true")
+                has_exact = True
+        
+        # 【第一步】先检查向量搜索候选中是否有精确匹配(编号完全相同)
+        # ref_candidates 是 List[List[str]],需要获取当前项对应的候选列表
+        current_candidates = ref_candidates[i] if i < len(ref_candidates) else []
+        _, review_number = _extract_regulation_info(review_item)
+        
+        if review_number and current_candidates:
+            normalized_review_number = _normalize_text(review_number)
+            exact_match_found = False
+            
+            for candidate in current_candidates:
+                if isinstance(candidate, str):
+                    _, candidate_number = _extract_regulation_info(candidate)
+                    if candidate_number and _normalize_text(candidate_number) == normalized_review_number:
+                        # 向量库中找到精确匹配,直接使用,不需要AI投票
+                        logger.info(f"[规范匹配] 向量库中找到精确匹配: '{review_item}' -> '{candidate}'")
+                        final_results.append({
+                            "review_item": review_item,
+                            "has_related_file": True,
+                            "has_exact_match": True,
+                            "exact_match_info": candidate,
+                            "same_name_current": candidate
+                        })
+                        exact_match_found = True
+                        break
+            
+            # 如果找到了精确匹配,跳过本次循环
+            if exact_match_found:
+                continue
+        
+        # 如果有精确匹配(由LLM判断),直接接受
         if has_exact and exact_info:
             final_results.append({
                 "review_item": review_item,
@@ -334,15 +480,35 @@ async def match_reference_files(reference_text: str, review_text: str) -> str:
             })
             continue
         
-        # 如果没有精确匹配,但有相关文件,进行验证/生成
-        if has_related or ref_candidates:
+        # 【第二步】如果没有精确匹配,但有相关文件,进行验证/生成
+        # 使用当前项的候选列表(不是整个二维列表)
+        if has_related or current_candidates:
             try:
                 validation_result = await validate_and_generate_number(
                     review_item=review_item,
-                    reference_candidates=ref_candidates
+                    reference_candidates=current_candidates
                 )
                 
                 if validation_result.validated_number:
+                    # 【关键逻辑】检查生成的编号与原始编号是否属于同一规范家族
+                    is_same_family = _is_same_regulation_family(
+                        review_number or "", 
+                        validation_result.validated_number
+                    )
+                    
+                    if not is_same_family:
+                        # 生成的编号与原始编号完全不同,说明参考库中找到的文件实际上不相关
+                        logger.info(f"[规范匹配] '{review_item}' 生成的编号({validation_result.validated_number})"
+                                  f"与原始编号({review_number})不属于同一规范家族,判定为无相关文件")
+                        final_results.append({
+                            "review_item": review_item,
+                            "has_related_file": False,  # 【关键】标记为无相关文件
+                            "has_exact_match": False,
+                            "exact_match_info": "",
+                            "same_name_current": ""
+                        })
+                        continue
+                    
                     if validation_result.is_valid:
                         # 验证通过,原始编号正确
                         final_results.append({

+ 74 - 3
core/construction_review/component/reviewers/utils/timeliness_determiner.py

@@ -48,6 +48,8 @@ HUMAN = """
 
 【判定规则(按优先级从高到低)】
 
+**重要提示**:比较规范编号时,忽略括号格式差异(半角()和全角()视为相同)。例如 "GB/T 5224-2014" 和 "GB/T 5224-2014" 是相同的编号。
+
 1. **无参考规范**(无风险)
    - 条件:has_related_file = false
    - 原因:在参考规范库中完全找不到相关文件
@@ -55,7 +57,7 @@ HUMAN = """
 
 2. **规范编号错误**(高风险)
    - 条件:has_related_file = true 且 has_exact_match = false
-   - 原因:与参考文件XXX编号不一致
+   - 原因:与参考文件XXX编号不一致(注意:仅当编号实质性不同时才算不一致,忽略括号格式差异)
    - 建议:建议核实并更正为参考库中的正确编号XXX
 
 3. **规范编号正确**(无风险)
@@ -70,8 +72,12 @@ HUMAN = """
 
 5. **引用已被替代的规范**(高风险)
    - 条件:has_exact_match = true 且 exact_match_info 中状态为"废止" 且 same_name_current 不为空
-   - 原因:参考文件显示XXX已废止,但存在XXX现行版本
-   - 建议:建议更新为现行替代标准
+   - 原因:参考文件显示《规范名称》(原编号)已废止,存在现行版本《规范名称》(新编号)
+   - 建议:建议更新为现行版本《规范名称》(新编号),并核实其适用性
+   - **重要**:
+     - 必须从 same_name_current 字段中提取具体的现行版本编号
+     - 例如 same_name_current="《预应力混凝土用钢绞线》(GB/T 5224-2023)状态为现行",则建议应为"建议更新为现行版本《预应力混凝土用钢绞线》(GB/T 5224-2023),并核实其适用性"
+     - 严禁在建议中出现"XXX"字样,必须替换为实际的规范名称和编号
 
 【规范匹配结果】
 {match_results}
@@ -114,6 +120,23 @@ def extract_first_json(text: str) -> dict:
     raise ValueError("JSON 花括号未闭合")
 
 
+# ===== 辅助函数:标准化文本 =====
+def _normalize_text(text: str) -> str:
+    """标准化文本,统一括号格式用于比较"""
+    if not text:
+        return text
+    text = text.replace('(', '(').replace(')', ')')
+    text = ' '.join(text.split())
+    return text.strip()
+
+
+def _extract_number_from_location(location: str) -> str:
+    """从location字段提取规范编号"""
+    import re
+    match = re.search(r'[((]([^))]+)[))]', location)
+    return match.group(1).strip() if match else ""
+
+
 # ===== 7) 核心方法 =====
 async def determine_timeliness_issue(match_results: str) -> str:
     """
@@ -146,6 +169,10 @@ async def determine_timeliness_issue(match_results: str) -> str:
             data = extract_first_json(raw)
             findings = TimelinessResults.model_validate(data)
             result = [x.model_dump() for x in findings.items]
+            
+            # 【强制校正】处理LLM误判:如果判定为"规范编号错误"但编号实质相同,则校正为"规范编号正确"
+            result = _correct_misjudgment(result, match_results)
+            
             return json.dumps(result, ensure_ascii=False, indent=2)
         except (Exception, ValidationError, json.JSONDecodeError) as e:
             last_err = e
@@ -153,6 +180,50 @@ async def determine_timeliness_issue(match_results: str) -> str:
     raise RuntimeError(f"时效性判定失败:{last_err}") from last_err
 
 
+def _correct_misjudgment(results: list, match_results: str) -> list:
+    """
+    校正LLM的误判:检查"规范编号错误"是否实际为编号相同(仅括号格式不同)
+    """
+    import json
+    import re
+    
+    try:
+        match_data = json.loads(match_results)
+        match_items = match_data if isinstance(match_data, list) else match_data.get('items', [])
+        
+        for i, item in enumerate(results):
+            issue_point = item.get('issue_point', '')
+            location = item.get('location', '')
+            reason = item.get('reason', '')
+            
+            # 只处理"规范编号错误"的情况
+            if '规范编号错误' not in issue_point:
+                continue
+                
+            # 从location提取审查项编号
+            review_number = _extract_number_from_location(location)
+            if not review_number:
+                continue
+            
+            # 从reason或match_items中提取参考文件编号
+            ref_number = ''
+            reason_match = re.search(r'(([^)]+))', reason)
+            if reason_match:
+                ref_number = reason_match.group(1).strip()
+            
+            # 如果编号实质相同(忽略括号差异),校正为"规范编号正确"
+            if review_number and ref_number and _normalize_text(review_number) == _normalize_text(ref_number):
+                print(f"[校正] 误判检测: '{location}' 编号实质相同,校正为'规范编号正确'")
+                item['issue_point'] = '规范编号正确'
+                item['suggestion'] = '引用规范为现行有效版本,无需调整'
+                item['reason'] = f'与参考文件{location}名称编号一致,且文件状态为现行'
+                item['risk_level'] = '无风险'
+    except Exception as e:
+        print(f"[校正] 校正过程出错: {e}")
+    
+    return results
+
+
 # ===== 8) 示例 =====
 if __name__ == "__main__":
     import asyncio