|
|
@@ -11,12 +11,12 @@
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
-import json # ✅ 最小修改:新增
|
|
|
+import json
|
|
|
import re
|
|
|
import asyncio
|
|
|
from typing import List
|
|
|
|
|
|
-from pydantic import BaseModel, Field, ValidationError # ✅ 最小修改:新增 ValidationError
|
|
|
+from pydantic import BaseModel, Field, ValidationError
|
|
|
from langchain_core.prompts import ChatPromptTemplate
|
|
|
from langchain_core.output_parsers import PydanticOutputParser
|
|
|
from foundation.ai.agent.generate.model_generate import generate_model_client
|
|
|
@@ -36,15 +36,17 @@ class BasisItems(BaseModel):
|
|
|
items: List[BasisItem]
|
|
|
|
|
|
|
|
|
-# --------- 2) Prompt(强约束:只抽《》条目,输出可解析结构)---------
|
|
|
+# --------- 2) Prompt(按章节区分提取规则)---------
|
|
|
SYSTEM = """
|
|
|
你是信息抽取助手。请从中文文本中抽取"编制依据/法律法规/规范标准"等条目。
|
|
|
规则:
|
|
|
-1) 只抽取包含书名号《 》的条目。
|
|
|
-2) 每条条目包括:title(《》内名称,去掉书名号)、suffix(《》后面的版本/日期/修订说明,可为空)、raw(该条目原文)。
|
|
|
-3) 忽略标题行、段落说明、无《》的行。
|
|
|
-4) **重要:title 和 raw 必须保留原文的所有空格和格式,不要修改或去除任何空格。**
|
|
|
+1) 提取所有被书名号《》包裹的条目,**即使格式有错误也要提取**。
|
|
|
+ 包含但不限于:《名称》(编号)、《名称》编号、(名称)(编号)、名称》(编号)、《名称(缺结束书名号)、《名称》[编号](方括号)等。
|
|
|
+2) 每条条目包括:title(书名号内名称,去掉书名号)、suffix(后面的编号/版本信息)、raw(该条目原文)。
|
|
|
+3) 忽略标题行、段落说明。
|
|
|
+4) **重要:title 和 raw 必须保留原文的所有空格和标点,不要做任何格式修复。即使原文格式有错误(缺书名号、括号不对、用错括号类型),raw 也必须原样输出。**
|
|
|
5) 输出必须严格符合格式要求,不要输出任何额外文字。
|
|
|
+{chapter_rules}
|
|
|
"""
|
|
|
HUMAN ="""
|
|
|
文本如下:
|
|
|
@@ -54,6 +56,15 @@ HUMAN ="""
|
|
|
/no_think
|
|
|
"""
|
|
|
|
|
|
+_BASIS_RULES = """
|
|
|
+6) **只提取标准、规范、法规、条例类条目**(含标准编号如GB/JGJ/JTG/JT/T/DL/T等,或法律法规名称如XX法/XX条例/XX规程/XX规定)。
|
|
|
+7) **不要提取项目文件类条目**,如施工图设计、施工组织设计、专项施工方案、项目合同等。这些不属于标准规范,不需要审查。
|
|
|
+"""
|
|
|
+
|
|
|
+_NON_BASIS_RULES = """
|
|
|
+6) **不要提取"详见""见""参见""参照""参考"后面紧跟的条目**,这些只是引用说明,不是正文中的标准规范引用。
|
|
|
+"""
|
|
|
+
|
|
|
prompt = ChatPromptTemplate.from_messages([
|
|
|
("system", SYSTEM),
|
|
|
("human", HUMAN)
|
|
|
@@ -80,17 +91,26 @@ def fallback_regex(text: str) -> BasisItems:
|
|
|
|
|
|
# 模式1:匹配《名称》(编号)格式
|
|
|
pattern1 = r'《([^《》]{1,60})》[\s]*[((]([^))]{1,30})[))]'
|
|
|
-
|
|
|
+
|
|
|
# 模式2:匹配《名称》编号 格式(编号无括号)
|
|
|
pattern2 = r'《([^《》]{1,60})》[\s]*([A-Za-z]{0,6}[/-]?[0-9]{2,6}(?:-[0-9]{4})?)'
|
|
|
-
|
|
|
+
|
|
|
# 模式3:匹配只有《名称》的格式(在列表结尾或分号前)
|
|
|
pattern3 = r'《([^《》]{1,60})》(?=[\s]*[;;]|\s*$|\s+[((]\d)'
|
|
|
-
|
|
|
+
|
|
|
+ # 模式4:《name — 缺少结束书名号
|
|
|
+ pattern4 = r'《([^《》]{1,60})(?=[\s;;、\n]|$)'
|
|
|
+ # 模式5:name》(number) — 缺少开始书名号
|
|
|
+ pattern5 = r'([^《》\s]{2,60})》[\s]*[((]([^))]{1,30})[))]'
|
|
|
+ # 模式6:(name)(number) — 用括号代替书名号
|
|
|
+ pattern6 = r'[((]([^))]{2,60})[))][\s]*[((]([^))]{1,30})[))]'
|
|
|
+ # 模式7:《name》[number] — 用方括号
|
|
|
+ pattern7 = r'《([^《》]{1,60})》[\s]*\[([^\]]{1,30})\]'
|
|
|
+
|
|
|
# 合并模式,按优先级匹配
|
|
|
matched_positions = set()
|
|
|
-
|
|
|
- for pattern in [pattern1, pattern2, pattern3]:
|
|
|
+
|
|
|
+ for pattern in [pattern1, pattern2, pattern3, pattern4, pattern5, pattern6, pattern7]:
|
|
|
for match in re.finditer(pattern, text):
|
|
|
# 检查是否已匹配过此位置
|
|
|
start_pos = match.start()
|
|
|
@@ -137,18 +157,88 @@ def fallback_regex(text: str) -> BasisItems:
|
|
|
# 记录已匹配的位置
|
|
|
matched_positions.add(start_pos)
|
|
|
|
|
|
+ # 兜底:扫描所有《号,捕获前轮未覆盖的不完整引用
|
|
|
+ for m in re.finditer(r'《([^《》]{1,60})', text):
|
|
|
+ start = m.start()
|
|
|
+ if start in matched_positions:
|
|
|
+ continue
|
|
|
+ # 取《号到下一个《号或100字符之间的文本
|
|
|
+ next_book = text.find('《', start + 1)
|
|
|
+ end = min(next_book, start + 100) if next_book != -1 else start + 100
|
|
|
+ raw = text[start:end].strip()
|
|
|
+ # 去掉末尾的分隔符
|
|
|
+ for delim in [';', ';', '、', '\n', '\r']:
|
|
|
+ idx = raw.find(delim, 1)
|
|
|
+ if idx != -1:
|
|
|
+ raw = raw[:idx].strip()
|
|
|
+ title = m.group(1).strip()
|
|
|
+ if title:
|
|
|
+ items.append(BasisItem(title=title, suffix='', raw=raw))
|
|
|
+ matched_positions.add(start)
|
|
|
+
|
|
|
logger.info(f"[编制依据提取] 兜底方案提取到 {len(items)} 条")
|
|
|
return BasisItems(items=items)
|
|
|
|
|
|
|
|
|
# --------- 3.5) 根据chapter_code过滤,避免LLM误抽 ---------
|
|
|
+# 标准编号前缀
|
|
|
+_STANDARD_PREFIXES = re.compile(
|
|
|
+ r'GB|JGJ|JTG|JTJ|CJJ|YB|SH|DL|GA|AQ|HG|SY|TB|NB|RB|MH|JG|JC|DB',
|
|
|
+ re.IGNORECASE
|
|
|
+)
|
|
|
+
|
|
|
+# 标准/法规类名称后缀
|
|
|
+_STANDARD_SUFFIXES = re.compile(
|
|
|
+ r'(条例|规程|规范|规定|办法|细则|通则|标准|规则|导则|指南)$'
|
|
|
+)
|
|
|
+
|
|
|
+# 法律类名称后缀(无标准编号,不做格式审查)
|
|
|
+_LAW_SUFFIXES = re.compile(r'(法|法律)$')
|
|
|
+
|
|
|
+
|
|
|
+def is_law_item(item: BasisItem) -> bool:
|
|
|
+ """判断条目是否为法律法规(无标准编号,不做格式审查)"""
|
|
|
+ title = (item.title or "").strip()
|
|
|
+ return bool(_LAW_SUFFIXES.search(title))
|
|
|
+
|
|
|
+# 项目文件关键词(非标准规范)
|
|
|
+_PROJECT_KEYWORDS = re.compile(
|
|
|
+ r'(施工图设计|施工组织设计|专项施工方案|工程可行性|两阶段设计|初步设计|招标文件|合同文件|标段)'
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def _is_standard_item(item: BasisItem) -> bool:
|
|
|
+ """判断条目是否属于标准规范/法律法规(而非项目文件)"""
|
|
|
+ title = item.title or ""
|
|
|
+ suffix = item.suffix or ""
|
|
|
+
|
|
|
+ # 有标准编号后缀 → 标准
|
|
|
+ if _STANDARD_PREFIXES.search(suffix):
|
|
|
+ return True
|
|
|
+
|
|
|
+ # 标题以法规/标准后缀结尾 → 标准/法规
|
|
|
+ if _STANDARD_SUFFIXES.search(title):
|
|
|
+ return True
|
|
|
+
|
|
|
+ # 标题以法律后缀结尾 → 法律
|
|
|
+ if _LAW_SUFFIXES.search(title):
|
|
|
+ return True
|
|
|
+
|
|
|
+ # 标题包含项目文件关键词 → 非标准
|
|
|
+ if _PROJECT_KEYWORDS.search(title):
|
|
|
+ return False
|
|
|
+
|
|
|
+ # 默认保留(宁多勿漏)
|
|
|
+ return True
|
|
|
+
|
|
|
+
|
|
|
def _filter_by_chapter_code(items: List[BasisItem], chapter_code: str) -> List[BasisItem]:
|
|
|
"""
|
|
|
根据章节代码过滤编制依据条目
|
|
|
|
|
|
规则:
|
|
|
- - 如果 chapter_code == "basis":编制依据章节,不过滤,返回所有条目
|
|
|
- - 如果 chapter_code != "basis":非编制依据章节,过滤掉raw中没有书名号的条目
|
|
|
+ - basis 章节:过滤空条目 + 过滤非标准规范的项目文件
|
|
|
+ - 非 basis 章节:只过滤空条目
|
|
|
|
|
|
Args:
|
|
|
items: 待过滤的条目列表
|
|
|
@@ -157,28 +247,22 @@ def _filter_by_chapter_code(items: List[BasisItem], chapter_code: str) -> List[B
|
|
|
Returns:
|
|
|
过滤后的条目列表
|
|
|
"""
|
|
|
- # 编制依据章节,不过滤
|
|
|
+ # 先过滤空条目
|
|
|
+ items = [item for item in items if item.raw.strip()]
|
|
|
+
|
|
|
if chapter_code == "basis":
|
|
|
- logger.debug(f"[编制依据提取] 当前为编制依据章节(basis),不过滤,共 {len(items)} 条")
|
|
|
- return items
|
|
|
-
|
|
|
- # 非编制依据章节,过滤掉没有书名号的条目(避免LLM误抽)
|
|
|
- before_count = len(items)
|
|
|
- filtered_items = [
|
|
|
- item for item in items
|
|
|
- if "《" in item.raw and "》" in item.raw
|
|
|
- ]
|
|
|
- after_count = len(filtered_items)
|
|
|
-
|
|
|
- # 如果有过滤,记录日志
|
|
|
- if before_count != after_count:
|
|
|
- logger.info(
|
|
|
- f"[编制依据提取] 非编制依据章节(chapter_code={chapter_code}),"
|
|
|
- f"过滤掉无书名号条目: {before_count} -> {after_count} "
|
|
|
- f"(过滤 {before_count - after_count} 条)"
|
|
|
- )
|
|
|
+ before_count = len(items)
|
|
|
+ items = [item for item in items if _is_standard_item(item)]
|
|
|
+ after_count = len(items)
|
|
|
+ if before_count != after_count:
|
|
|
+ logger.info(
|
|
|
+ f"[编制依据提取] basis章节过滤非标准条目: {before_count} -> {after_count} "
|
|
|
+ f"(过滤 {before_count - after_count} 条项目文件)"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ logger.debug(f"[编制依据提取] basis章节,共 {len(items)} 条标准规范")
|
|
|
|
|
|
- return filtered_items
|
|
|
+ return items
|
|
|
|
|
|
|
|
|
# --------- 4) 最小修改:只做一件事,提取第一个 JSON ---------
|
|
|
@@ -204,77 +288,37 @@ def extract_first_json(text: str) -> dict:
|
|
|
raise ValueError("JSON 花括号未闭合")
|
|
|
|
|
|
|
|
|
-# --------- 5) 主函数:使用 LangChain 抽取(最小改动版) ---------
|
|
|
-async def extract_basis_with_langchain_qwen(progress_manager,callback_task_id:str,text: str,chapter_code: str) -> BasisItems:
|
|
|
+# --------- 5) 主函数:非流式调用,底层自动处理 thinking ---------
|
|
|
+async def extract_basis(progress_manager, callback_task_id: str, text: str, chapter_code: str) -> BasisItems:
|
|
|
"""
|
|
|
- 使用 LangChain + LLM 提取编制依据信息(流式输出版本)
|
|
|
+ 使用 LLM 提取编制依据信息(非流式,thinking 由底层自动处理)
|
|
|
"""
|
|
|
- # 标准化文本(无论是否走LLM,都先做)
|
|
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
|
try:
|
|
|
- # 创建解析器(✅ 保留:仅用于 format_instructions)
|
|
|
parser = PydanticOutputParser(pydantic_object=BasisItems)
|
|
|
|
|
|
- # 构建消息
|
|
|
+ # 根据章节类型选择提取规则
|
|
|
+ chapter_rules = _BASIS_RULES if chapter_code == "basis" else _NON_BASIS_RULES
|
|
|
+
|
|
|
messages = prompt.format_messages(
|
|
|
input_text=text,
|
|
|
- format_instructions=parser.get_format_instructions()
|
|
|
+ format_instructions=parser.get_format_instructions(),
|
|
|
+ chapter_rules=chapter_rules
|
|
|
)
|
|
|
|
|
|
- logger.info(f"[编制依据提取] 开始使用 LLM 提取,文本长度: {len(text)}")
|
|
|
-
|
|
|
- # 推送开始消息(使用独立命名空间,避免与主流程进度冲突)
|
|
|
- if progress_manager and callback_task_id:
|
|
|
- try:
|
|
|
- await progress_manager.update_stage_progress(
|
|
|
- callback_task_id=callback_task_id,
|
|
|
- stage_name="编制依据提取-子任务", # 独立命名空间
|
|
|
- status="processing",
|
|
|
- message=f"开始编制依据提取",
|
|
|
- overall_task_status="processing",
|
|
|
- event_type="processing"
|
|
|
- # 不设置 current,避免覆盖主流程进度
|
|
|
- )
|
|
|
- except Exception as e:
|
|
|
- logger.error(f"SSE推送开始消息失败: {e}")
|
|
|
-
|
|
|
- # 流式调用模型,每检测到5个}符号推送一次进度
|
|
|
- raw_out = ""
|
|
|
- brace_count = 0
|
|
|
-
|
|
|
- # 使用通用模型底座的流式调用(通过 function_name 从 model_setting.yaml 加载配置)
|
|
|
- for chunk in generate_model_client.get_model_generate_stream(
|
|
|
+ logger.info(f"[编制依据提取] 开始 LLM 提取,文本长度: {len(text)}")
|
|
|
+
|
|
|
+ # 非流式调用:底层自动处理 thinking 模式,返回干净的 str
|
|
|
+ raw_out = await generate_model_client.get_model_generate_invoke(
|
|
|
trace_id=callback_task_id or "directory_extract",
|
|
|
messages=messages,
|
|
|
function_name="directory_extraction"
|
|
|
- ):
|
|
|
- raw_out += chunk
|
|
|
-
|
|
|
- # 统计}符号出现次数
|
|
|
- for char in chunk:
|
|
|
- if char == "}":
|
|
|
- brace_count += 1
|
|
|
-
|
|
|
- # 每5个}推送一次进度(使用独立命名空间)
|
|
|
- if brace_count % 5 == 0:
|
|
|
- if progress_manager and callback_task_id:
|
|
|
- try:
|
|
|
- await progress_manager.update_stage_progress(
|
|
|
- callback_task_id=callback_task_id,
|
|
|
- stage_name="编制依据提取-子任务", # 独立命名空间
|
|
|
- status="processing",
|
|
|
- message=f"编制依据提取中... (已处理 {brace_count} 个结构)",
|
|
|
- overall_task_status="processing",
|
|
|
- event_type="processing"
|
|
|
- # 不设置 current,避免覆盖主流程进度
|
|
|
- )
|
|
|
- except Exception as e:
|
|
|
- logger.error(f"SSE推送进度失败: {e}")
|
|
|
-
|
|
|
- logger.info(f"编制依据提取调试:{raw_out}")
|
|
|
-
|
|
|
- # ✅ 最小修改:不再 parser.parse / 不再 _parse_with_retry
|
|
|
+ )
|
|
|
+
|
|
|
+ logger.debug(f"[编制依据提取] LLM 返回: {raw_out[:500]}")
|
|
|
+
|
|
|
+ # 解析:模型返回应为纯 JSON,extract_first_json 兜底处理多余文本
|
|
|
data = extract_first_json(raw_out)
|
|
|
parsed = BasisItems.model_validate(data)
|
|
|
|
|
|
@@ -292,7 +336,6 @@ async def extract_basis_with_langchain_qwen(progress_manager,callback_task_id:st
|
|
|
|
|
|
cleaned.append(BasisItem(title=title, suffix=suffix, raw=raw))
|
|
|
|
|
|
- # ✅ 新增:根据chapter_code过滤,避免LLM在非编制依据章节误抽
|
|
|
cleaned = _filter_by_chapter_code(cleaned, chapter_code)
|
|
|
|
|
|
if cleaned:
|
|
|
@@ -300,16 +343,17 @@ async def extract_basis_with_langchain_qwen(progress_manager,callback_task_id:st
|
|
|
return BasisItems(items=cleaned)
|
|
|
|
|
|
logger.warning("[编制依据提取] LLM 未提取到内容,使用兜底方案")
|
|
|
- # ✅ 修改:兜底方案也要经过过滤
|
|
|
fallback_result = fallback_regex(text)
|
|
|
filtered_items = _filter_by_chapter_code(fallback_result.items, chapter_code)
|
|
|
return BasisItems(items=filtered_items)
|
|
|
|
|
|
except (json.JSONDecodeError, ValidationError, ValueError) as e:
|
|
|
+ logger.error(f"[编制依据提取] LLM 解析失败,降级兜底: {type(e).__name__}: {e}")
|
|
|
fallback_result = fallback_regex(text)
|
|
|
filtered_items = _filter_by_chapter_code(fallback_result.items, chapter_code)
|
|
|
return BasisItems(items=filtered_items)
|
|
|
except Exception as e:
|
|
|
+ logger.error(f"[编制依据提取] LLM 调用异常,降级兜底: {type(e).__name__}: {e}")
|
|
|
fallback_result = fallback_regex(text)
|
|
|
filtered_items = _filter_by_chapter_code(fallback_result.items, chapter_code)
|
|
|
return BasisItems(items=filtered_items)
|
|
|
@@ -357,7 +401,7 @@ if __name__ == "__main__":
|
|
|
"""
|
|
|
|
|
|
async def _demo_run():
|
|
|
- result = await extract_basis_with_langchain_qwen(None, None, demo)
|
|
|
+ result = await extract_basis(None, None, demo, "basis")
|
|
|
print(f"\n提取到 {len(result.items)} 条编制依据:")
|
|
|
for idx, item in enumerate(result.items, 1):
|
|
|
print(f"\n{idx}. {item.model_dump()}")
|