Commit 05df4d3c by ccran

feat: update lufa prompt;

parent 58af8ced
......@@ -8,22 +8,24 @@ from core.config import pdf_support_formats
MAX_CACHE = 128
def _normalize_file_ext(file_ext: str) -> str:
if not file_ext:
raise ValueError("file_ext is required")
ext = file_ext.strip().lower()
if not ext.startswith("."):
ext = f".{ext}"
return ext
if not file_ext:
raise ValueError("file_ext is required")
ext = file_ext.strip().lower()
if not ext.startswith("."):
ext = f".{ext}"
return ext
@lru_cache(maxsize=MAX_CACHE)
def get_cached_doc_tool(conversation_id: str, file_ext: str) -> Tuple[DocBase, str]:
ext = _normalize_file_ext(file_ext)
if ext in pdf_support_formats:
return SpirePdfDoc(), ext
return SpireWordDoc(), ext
ext = _normalize_file_ext(file_ext)
if ext in pdf_support_formats:
return SpirePdfDoc(), ext
return SpireWordDoc(), ext
@lru_cache(maxsize=MAX_CACHE)
def get_cached_memory(conversation_id: str) -> MemoryStore:
return MemoryStore(f'memory_store_{conversation_id}.json')
\ No newline at end of file
return MemoryStore(f"memory_store_{conversation_id}.json")
......@@ -5,6 +5,13 @@ from dataclasses import dataclass
use_docker = False
# @dataclass
# class LLMConfig:
# base_url: str = "https://api.deepseek.com/v1"
# api_key: str = "sk-3df81e63afe44ca39cbd7108d59bc91a"
# model: str = "deepseek-v4-pro"
@dataclass
class LLMConfig:
base_url: str = "http://192.168.252.71:9002/v1"
......@@ -17,10 +24,25 @@ MERGE_RULE_PROMPT = False
MAX_SINGLE_CHUNK_SIZE = 5000
META_KEY = "META"
DEFAULT_RULESET_ID = "通用"
ALL_RULESET_IDS = ["通用", "借款", "担保", "财务口", "金盘", "金盘简化", "麓发测试"]
ALL_RULESET_IDS = [
"通用",
"借款",
"担保",
"财务口",
"金盘",
"金盘简化",
"麓发测试",
"麓发标准",
]
MAX_WORKERS = 10
FILE_SUFFIX = "-审核批注"
## 关键参数**
use_non_fastgpt_llm = False
use_lufa = True
use_jp_machine = True
use_lufa = False
## 关键参数**
if use_lufa:
outer_backend_url = "http://znkf.lgfzgroup.com:48081"
base_fastgpt_url = "http://192.168.252.71:18089"
......@@ -32,9 +54,14 @@ if use_lufa:
"fastgpt-ao3al2vgfnArt9qi2bTpPeRHouCO7qngUZiQsIM1E2x91u22z65J"
)
else:
outer_backend_url = "http://218.77.58.8:48080"
base_fastgpt_url = "http://192.168.252.71:18088"
base_backend_url = "http://192.168.252.71:48080"
if not use_jp_machine:
outer_backend_url = "http://218.77.58.8:48080"
base_fastgpt_url = "http://192.168.252.71:18088"
base_backend_url = "http://192.168.252.71:48080"
else:
outer_backend_url = "http://172.21.107.45:48080"
base_fastgpt_url = "http://172.21.107.45:38080"
base_backend_url = "http://172.21.107.45:48080"
segment_review_api_key = (
"fastgpt-vLu2JHAfqwEq5FUQhvATFDK0yDS6fs804v7KwWBMyU4sRrHzh4UGl89Zpa"
)
......@@ -55,11 +82,19 @@ if use_docker:
root_path = "/app"
LLM = {
"base_tool_llm": LLMConfig(),
"fastgpt_segment_review": LLMConfig(
base_url=f"{base_fastgpt_url}/api/v1", api_key=segment_review_api_key
"fastgpt_segment_review": (
LLMConfig()
if use_non_fastgpt_llm
else LLMConfig(
base_url=f"{base_fastgpt_url}/api/v1", api_key=segment_review_api_key
)
),
"fastgpt_reflect_retry": LLMConfig(
base_url=f"{base_fastgpt_url}/api/v1", api_key=reflect_retry_api_key
"fastgpt_reflect_retry": (
LLMConfig()
if use_non_fastgpt_llm
else LLMConfig(
base_url=f"{base_fastgpt_url}/api/v1", api_key=reflect_retry_api_key
)
),
}
doc_support_formats = [".docx", ".doc", ".wps"]
......
......@@ -11,13 +11,12 @@ from uuid import uuid4
from utils.http_util import upload_file
from utils.doc_util import DocBase
from core.config import META_KEY
from core.config import META_KEY, FILE_SUFFIX, use_lufa
logger = logging.getLogger(__name__)
_ALLOWED_RISK_LEVELS = {"H", "M", "L", ""}
_ALLOWED_RISK_LEVELS = {"H", "M", "L", "H,M", ""}
FINDING_KEY_REVIEW = "review"
FINDING_KEY_REFLECT = "reflect"
FINDING_KEY_MERGE = "merge"
......@@ -290,9 +289,10 @@ class MemoryStore:
raise ImportError(
"openpyxl is required for export_to_excel; install via 'pip install openpyxl'"
) from exc
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
name = file_name or f"memory_export_{ts}.xlsx"
file_suffix = FILE_SUFFIX + datetime.now().strftime("%Y%m%d_%H%M%S")
name = file_name or f"memory_export.xlsx"
name = Path(name).stem + file_suffix + ".xlsx"
# print(f"Exporting to Excel with file name: {name}")
output_path = Path(__file__).resolve().parent.parent / "tmp" / name
with self._lock:
......@@ -381,13 +381,11 @@ class MemoryStore:
"""Add all findings as comments to a document, upload, then delete the local file."""
if doc_obj is None:
raise ValueError("doc_obj is required")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
doc_name = getattr(doc_obj, "_doc_name", "") or ""
suffix = Path(doc_name).suffix or ".docx"
name = file_name or f"findings_{ts}{suffix}"
if not Path(name).suffix:
name = f"{name}{suffix}"
# build suffix
file_suffix = FILE_SUFFIX + datetime.now().strftime("%Y%m%d_%H%M%S")
# derive file name
name = file_name or getattr(doc_obj, "_doc_name", "") or "memory_export.docx"
name = Path(name).stem + file_suffix + (Path(name).suffix or ".docx")
output_path = Path(__file__).resolve().parent.parent / "tmp" / name
target_key = self._normalize_finding_key(finding_key)
......@@ -398,14 +396,17 @@ class MemoryStore:
for idx, f in enumerate(target_findings, start=1):
segment_id = int(f.segment_id or 0)
chunk_id = max(segment_id, 0)
suggest_parts = []
if f.risk_level:
suggest_parts.append(f"风险等级:{f.risk_level}")
if f.issue:
suggest_parts.append(f"问题:{f.issue}")
if f.suggestion:
suggest_parts.append(f"建议:{f.suggestion}")
suggest_text = "\n".join(suggest_parts).strip()
if use_lufa:
suggest_parts = []
if f.risk_level:
suggest_parts.append(f"风险等级:{f.risk_level}")
if f.issue:
suggest_parts.append(f"问题:{f.issue}")
if f.suggestion:
suggest_parts.append(f"建议:{f.suggestion}")
suggest_text = "\n".join(suggest_parts).strip()
else:
suggest_text = f"建议:{f.suggestion}".strip()
comments.append(
{
"id": str(idx),
......@@ -517,9 +518,11 @@ def test_memory_and_export_excel():
# print("Findings search:")
# for f in hits:
# print(json.dumps(asdict(f), ensure_ascii=False, indent=2))
print(store.export_to_excel())
print(store.export_to_excel("测试"))
if __name__ == "__main__":
# test_export_findings_to_doc_comments("/home/ccran/lufa-contract/tmp/股份转让协议.docx")
test_memory_and_export_excel()
test_export_findings_to_doc_comments(
"/home/ccran/lufa-contract/tmp/1_金盘箱变采购合同.docx"
)
# test_memory_and_export_excel()
......@@ -5,8 +5,319 @@ from typing import Dict, List, Optional, Any
from core.tool import tool, tool_func
from core.tools.segment_llm import LLMTool
from core.config import use_lufa
REFLECT_SYSTEM_PROMPT = '''
REFLECT_SYSTEM_PROMPT_LF = """
你是合同审查反思智能体(ReviewReflection)。
你的任务不是重新审查合同。
你只能基于:
- 已有 findings
- 当前审查规则
- 合同摘要事实记忆
对 findings 进行复核、校正、去重、拆分、合并和定稿,输出 final_findings。
1. 基本原则
1.1 不得新增全新的审查维度。
1.2 不得凭空创造合同中不存在的事实。
1.3 不得假设合同摘要事实记忆中不存在的合同内容。
1.4 若 finding 无法被 original_text 或合同摘要事实记忆直接支持,则删除。
1.5 若合同摘要事实记忆已经对某风险进行了补充、限制、例外说明或纠正,则应修订或删除对应 finding,不得机械保留。
2. 去重与拆分规则
2.1 若多个 findings 实质指向同一问题,则合并。
2.2 若一个 finding 包含多个独立问题,则拆分。
2.3 拆分后必须满足:
- 每个 final finding 只对应一个独立问题;
- original_text 只包含一个最小充分证据片段;
- 不得在一个 finding 中保留多个证据来源。
3. final finding 要求
3.1 每个 final finding 必须:
- issue 与 result 能被 original_text 或合同摘要事实记忆直接支持;
- result 只能为:
- "合格"
- "不合格"
3.2 若 result="合格":
- suggestion="无需修改"
- risk_level="L"
3.3 若 result="不合格":
suggestion 必须:
- 具体;
- 可执行;
- 明确偏向我方利益;
- 尽量给出可直接落地的修改方案。
3.4 suggestion 不得:
- 建议双方协商;
- 建议平衡双方权利义务;
- 削弱我方权益;
- 增加我方责任;
- 减轻对方责任。
4. 风险等级规则
4.1 risk_level 只能为:
- "H"
- "M"
- "L"
4.2 H 风险仅用于“全局关键缺失风险”。
即:
当前审查规则明确要求某关键条款、条件或必要要素,
但:
- 合同摘要事实记忆中完全未出现相关约定;
- 已有 findings 中也不存在对应有效 finding;
- 无法引用任何合同原文直接证明该事项已经被约定或明确;
说明该关键事项在合同整体层面可能完全缺失。
例如:
规则要求服务期限,
但合同整体均未体现服务期限、履行期限或起止时间。
此类问题属于最高优先级风险。
4.3 只有同时满足以上条件时,才允许:
- risk_level="H"
- original_text=""
4.4 若 original_text 不为空,则不得标记为 H 风险。
4.5 若合同中已经存在相关条款,
只是:
- 内容对我方不利;
- 表述不清;
- 范围过宽;
- 条件不合理;
- 限制不足;
- 风险分配不合理;
则属于内容质量问题,而非全局关键缺失问题。
此时:
- 必须正常引用 original_text;
- risk_level 应为 "M"。
4.6 不得将“存在但内容不好”的问题错误标记为 H 风险。
4.7 若当前审查规则明确要求某关键事项,
且:
- 合同摘要事实记忆中完全不存在;
- 已有 findings 中也不存在;
则应新增一条 H 风险 final finding。
新增的 H 风险 finding 必须满足:
- result="不合格"
- risk_level="H"
- original_text=""
- issue 明确说明缺失了什么关键要素以及可能导致的风险;
- suggestion 明确说明应补充什么关键条款或必要要素。
4.8 此类新增仅限于:
- 当前审查规则已经明确要求的事项;
- 不得扩展新的审查维度。
5. final_findings 要求
5.1 final_findings 不得:
- 重复;
- 冲突;
- 保留证据不足的 finding;
- 遗漏已有 findings 中本来成立的有效问题。
5.2 若某关键事项在合同整体层面完全缺失,
即使已有 findings 未明确指出,
也必须在 final_findings 中新增对应 H 风险 finding。
"""
REFLECT_USER_PROMPT_LF = """
【当前审查规则】
{rule}
【规则专属反思思路】
{rule_reflection_prompt}
【已有 findings】
{findings_json}
【合同摘要事实记忆】
{facts_json}
【合同立场】
站在 {party_role} 的立场进行反思审查。
【任务】
请基于当前审查规则、规则专属反思思路、已有 findings 和合同摘要事实记忆,
对已有 findings 进行复核、校正、去重、合并、拆分、风险等级修正与最终定稿,
输出 final_findings。
【核心原则】
- 你不是从零重新审查合同;
- 你只能在当前审查规则范围内工作;
- 不得扩展新的审查维度;
- 不得凭空创造合同中不存在的事实;
- 不得假设 facts 中不存在的合同内容;
【反思与校正规则】
- 若某 finding 无法被 original_text 或 facts 直接支持,则删除;
- 若某风险已被 facts 补充、限制、例外说明或纠正,则删除或修订对应 finding;
- 若多个 findings 实际指向同一问题,则合并;
- 若一个 finding 包含多个独立问题,则必须拆分;
【拆分规则】
拆分后必须满足:
- 每个 final finding 只能对应一个独立问题;
- original_text 只能包含一个最小充分证据片段;
- 不得在一个 finding 中保留多个证据来源;
- issue、risk_level、suggestion 必须仅对应当前这个独立问题;
【final finding 要求】
每条 final finding 必须:
- 能被 original_text 或 facts 直接支持;
- result 只能为:
- "合格"
- "不合格"
【合格规则】
若 result="合格":
- suggestion="无需修改"
- risk_level="L"
【不合格规则】
若 result="不合格":
- suggestion 必须具体、可执行;
- 应尽量给出可直接落地的修改方案;
- 必须明确偏向我方利益;
【禁止类 suggestion】
suggestion 不得:
- 建议双方协商;
- 建议平衡双方权利义务;
- 削弱我方权益;
- 增加我方责任;
- 减轻对方责任;
【H 风险规则(非常重要)】
H 风险仅用于“合同整体层面的关键事项缺失”。
即:
当前审查规则明确要求某关键条款、条件或必要要素,
但:
- 合同摘要事实记忆中完全未出现相关约定;
- 已有 findings 中也不存在对应有效 finding;
- 无法引用任何合同原文直接证明该事项已经被约定或明确;
说明该关键事项在合同整体层面可能完全缺失。
例如:
规则要求服务期限,
但合同整体均未体现:
- 服务期限;
- 履行期限;
- 起止时间;
- 服务周期;
等相关约定。
此类问题属于最高优先级风险。
【H 风险强制要求】
只有同时满足以上条件时,才允许:
- risk_level="H"
- original_text=""
若 original_text 不为空,则不得标记为 H 风险。
【M 风险规则】
若合同中已经存在相关条款,
只是:
- 内容对我方不利;
- 范围过宽;
- 责任失衡;
- 表述不清;
- 条件不合理;
- 限制不足;
- 风险分配不合理;
则属于内容质量问题,而非全局关键缺失问题。
此时:
- 必须正常引用 original_text;
- risk_level 应为 "M"。
不得将“存在但内容不好”的问题错误标记为 H 风险。
【新增 H 风险规则】
若当前审查规则明确要求某关键条款、条件或必要要素,
且:
- facts 中完全不存在;
- 已有 findings 中也不存在;
- 无法引用任何合同原文证明其存在;
则即使已有 findings 未提及,
也必须新增一条 H 风险 final finding。
新增的 H 风险 finding 必须满足:
- result="不合格"
- risk_level="H"
- original_text=""
- issue 必须明确说明:
- 缺失了什么关键事项;
- 为什么会导致我方风险;
- suggestion 必须明确说明:
- 应补充什么关键条款;
- 应明确哪些必要要素;
【final_findings 完整性要求】
final_findings 不得:
- 重复;
- 冲突;
- 保留证据不足的 finding;
- 遗漏已有 findings 中仍成立的独立问题;
- 遗漏合同整体层面完全缺失的关键事项;
【输出要求】
- 若无成立 findings:
返回 {{"final_findings":[]}}
- 仅输出 JSON。
"""
OUTPUT_FORMAT_SCHEMA_LF = """
```json
{
"final_findings": [
{
"segment_id":"合同原文片段所在的段落ID",
"issue": "详细且准确的风险描述,为什么该问题构成风险,需基于规则和文本解释",
"risk_level": "对应审查规则的风险等级,如 H/M/L",
"original_text": "合同原文片段的直接引用",
"suggestion": "可直接替换原文、新增条款措辞,或明确的修改方向",
"result": "合格 或 不合格",
}
]
}
"""
REFLECT_SYSTEM_PROMPT = """
你是一个合同审查反思智能体(ReviewReflection)。
你的任务不是从零重新审查合同,也不是简单删减 findings,
......@@ -111,9 +422,8 @@ Step 7:再输出最终 JSON。
7. suggestion 是否具体、可执行、与 result 一致?
8. 若 result=合格,suggestion 是否为“无需修改”?
9. 删除、合并、拆分后,是否遗漏了已有 findings 中本来成立的有效问题?
'''
REFLECT_USER_PROMPT = '''
"""
REFLECT_USER_PROMPT = """
【当前审查规则】
{rule}
......@@ -147,9 +457,8 @@ REFLECT_USER_PROMPT = '''
- final_findings 应准确、去重、完整,不得无故少于已有 findings 中实际成立的独立问题数量;
- 若无成立 findings,返回 {{"final_findings": []}};
- 仅输出 JSON。
'''
OUTPUT_FORMAT_SCHEMA = '''
"""
OUTPUT_FORMAT_SCHEMA = """
```json
{
"final_findings": [
......@@ -163,31 +472,31 @@ OUTPUT_FORMAT_SCHEMA = '''
]
}
```
'''
"""
@tool("reflect_retry", "反思重试质量闸")
class ReflectRetryTool(LLMTool):
def __init__(self) -> None:
super().__init__(REFLECT_SYSTEM_PROMPT,'fastgpt_reflect_retry')
@tool_func(
{
"type": "object",
"properties": {
"party_role": {"type": "string"},
"rule": {"type": "object"},
"facts": {"type": "array"},
"findings": {"type": "array"},
},
"required": ["party_role", "rule", "facts", "findings"],
}
)
def _stringify_rule(self, rule:Dict) -> str:
res = ''
def __init__(self) -> None:
if use_lufa:
super().__init__(REFLECT_SYSTEM_PROMPT_LF, "fastgpt_reflect_retry")
else:
super().__init__(REFLECT_SYSTEM_PROMPT, "fastgpt_reflect_retry")
@tool_func(
{
"type": "object",
"properties": {
"party_role": {"type": "string"},
"rule": {"type": "object"},
"facts": {"type": "array"},
"findings": {"type": "array"},
},
"required": ["party_role", "rule", "facts", "findings"],
}
)
def _stringify_rule(self, rule: Dict) -> str:
res = ""
res += f"## 审查项标题\n{rule.get('title','')}\n"
res += f"## 审查规则\n{rule.get('rule','')}\n"
res += f"## 风险等级\n{rule.get('level','')}\n"
......@@ -195,72 +504,83 @@ class ReflectRetryTool(LLMTool):
res += f"## 参考案例\n{rule.get('case','')}\n"
return res
def run(self, party_role: str, rule: Dict, facts: Optional[List[Dict]] = None, findings: Optional[List[Dict]] = None) -> List[Dict]:
base_findings = self._build_findings_with_ids(findings or [])
if len(base_findings) == 0:
return []
user_content = REFLECT_USER_PROMPT.format(
rule=self._stringify_rule(rule),
findings_json=json.dumps(base_findings, ensure_ascii=False),
facts_json=json.dumps(facts or [], ensure_ascii=False),
party_role=party_role,
# TODO 不同规则可能有不同的反思思路提示,目前先不区分,后续可根据需要增加规则专属反思思路提示,如针对付款条款审查的反思思路提示、针对担保条款审查的反思思路提示等 --- IGNORE ---
rule_reflection_prompt=None,
) + OUTPUT_FORMAT_SCHEMA
messages = self.build_messages(user_content)
try:
resp = self.run_with_loop(self.chat_async(messages))
data = self.parse_first_json(resp)
except Exception:
data = {}
final_findings = data.get("final_findings", []) or []
for finding in final_findings:
def run(
self,
party_role: str,
rule: Dict,
facts: Optional[List[Dict]] = None,
findings: Optional[List[Dict]] = None,
) -> List[Dict]:
base_findings = self._build_findings_with_ids(findings or [])
# if len(base_findings) == 0:
# return []
user_content = (
REFLECT_USER_PROMPT_LF if use_lufa else REFLECT_USER_PROMPT
).format(
rule=self._stringify_rule(rule),
findings_json=json.dumps(base_findings, ensure_ascii=False),
facts_json=json.dumps(facts or [], ensure_ascii=False),
party_role=party_role,
# TODO 不同规则可能有不同的反思思路提示,目前先不区分,后续可根据需要增加规则专属反思思路提示,如针对付款条款审查的反思思路提示、针对担保条款审查的反思思路提示等 --- IGNORE ---
rule_reflection_prompt=None,
) + (
OUTPUT_FORMAT_SCHEMA_LF if use_lufa else OUTPUT_FORMAT_SCHEMA
)
messages = self.build_messages(user_content)
try:
finding['segment_id'] = int(finding.get('segment_id', 0))
resp = self.run_with_loop(self.chat_async(messages))
data = self.parse_first_json(resp)
except Exception:
finding['segment_id'] = 0
finding['rule_title'] = rule.get('title','')
return final_findings
def _build_findings_with_ids(self, findings: List[Dict]) -> List[Dict[str, Any]]:
res: List[Dict[str, Any]] = []
for idx, f in enumerate(findings):
fid = f.get("id") or f.get("finding_id") or f.get("_id") or f"f_{idx+1}"
item = dict(f)
item["id"] = str(fid)
res.append(item)
return res
data = {}
final_findings = data.get("final_findings", []) or []
for finding in final_findings:
try:
finding["segment_id"] = int(finding.get("segment_id", 0))
except Exception:
finding["segment_id"] = 0
finding["rule_title"] = rule.get("title", "")
return final_findings
def _build_findings_with_ids(self, findings: List[Dict]) -> List[Dict[str, Any]]:
res: List[Dict[str, Any]] = []
for idx, f in enumerate(findings):
fid = f.get("id") or f.get("finding_id") or f.get("_id") or f"f_{idx+1}"
item = dict(f)
item["id"] = str(fid)
res.append(item)
return res
if __name__ == "__main__":
tool = ReflectRetryTool()
res = tool.run(
party_role="甲方",
rule={"title":"主体审查","rule":"判断双方主体是否存在"},
facts=[
{"segment_id": 1, "主体": {"甲方": "麓发集团", "乙方": "湖南大学"}},
],
findings=[
{
"rule_title": "主体审查",
"segment_id": 1,
"original_text": "甲方为麓发集团,乙方为湖南大学。",
"issue": "已经约定双方主体,无问题",
"risk_level": "M",
"result":"合格",
"suggestion": "无需建议"
},
{
"rule_title": "主体审查",
"segment_id": 3,
"original_text": "",
"issue": "未约定双方主体",
"risk_level": "M",
"result":"不合格",
"suggestion": "补充双方主体信息"
},
]
)
print(res)
\ No newline at end of file
tool = ReflectRetryTool()
res = tool.run(
party_role="甲方",
rule={"title": "主体审查", "rule": "判断双方主体是否存在"},
facts=[
{"segment_id": 1, "主体": {"甲方": "麓发集团", "乙方": "湖南大学"}},
],
findings=[
{
"rule_title": "主体审查",
"segment_id": 1,
"original_text": "甲方为麓发集团,乙方为湖南大学。",
"issue": "已经约定双方主体,无问题",
"risk_level": "M",
"result": "合格",
"suggestion": "无需建议",
},
{
"rule_title": "主体审查",
"segment_id": 3,
"original_text": "",
"issue": "未约定双方主体",
"risk_level": "M",
"result": "不合格",
"suggestion": "补充双方主体信息",
},
],
)
print(res)
from __future__ import annotations
from typing import Any, Dict, List
from core.tool import tool, tool_func
@tool("rule_filter", "规则过滤")
class RuleFilterTool:
@tool_func(
{
"type": "object",
"properties": {
"payload": {"type": "object"},
},
"required": ["payload"],
}
)
def run(self, payload: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError("Subclasses must implement run")
@tool("lufa_party_rule_filter_tool", "LUFA 当事人与支付主体规则过滤")
class LufaPartyRuleFilterTool(RuleFilterTool):
@tool_func(
{
"type": "object",
"properties": {
"payload": {"type": "object"},
},
"required": ["payload"],
}
)
def run(self, payload: Dict[str, Any]) -> Dict[str, Any]:
# rules = payload.get("rules") or []
# segment_idx = int(payload.get("segment_idx", 0))
# total_segments = int(payload.get("total_segments", 0))
# if not rules or total_segments <= 0:
# payload["rules"] = rules
# return payload
# # 奇数分段时将中间段归入前半段
# first_half_count = (total_segments + 1) // 2
# if segment_idx < first_half_count:
# filtered_rules: List[Dict[str, Any]] = [
# r for r in rules if "支付主体审查" not in str(r.get("title", ""))
# ]
# else:
# filtered_rules = [
# r for r in rules if "当事人审查" not in str(r.get("title", ""))
# ]
# payload["rules"] = filtered_rules
return payload
from __future__ import annotations
import difflib
import json
import re
import unicodedata
......@@ -11,7 +10,6 @@ from core.tools.segment_llm import LLMTool
from loguru import logger
import traceback
MERGER_SYSTEM_PROMPT = """
你将收到同一组 findings 的 issue 与 suggestion 列表,请做信息融合而非机械拼接。
......@@ -107,17 +105,17 @@ def _merge_text_union(base: str, other: str) -> str:
return f"{left}\n{right}"
def _has_substring_overlap(a: str, b: str, min_common_len: int = 8) -> bool:
def _has_substring_overlap(a: str, b: str, min_common_len: int = 5) -> bool:
left = _normalize_text_for_match(str(a or ""))
right = _normalize_text_for_match(str(b or ""))
if not left or not right:
return False
if left in right or right in left:
return True
match = difflib.SequenceMatcher(None, left, right).find_longest_match(
0, len(left), 0, len(right)
# Only treat edge overlap as related: suffix(left)->prefix(right) or reverse.
overlap_len = max(
_max_suffix_prefix_overlap(left, right),
_max_suffix_prefix_overlap(right, left),
)
return match.size >= min_common_len
return overlap_len >= min_common_len
def _normalize_text_for_match(text: str) -> str:
......@@ -225,7 +223,13 @@ def _rule_based_merge(
groups.append([findings[idx] for idx in group_idx])
return [_merge_group(group, field_merger=field_merger) for group in groups]
merged_findings: List[Dict[str, Any]] = []
for group in groups:
if _should_skip_group_merge(group):
merged_findings.extend([_normalize_finding(item) for item in group])
continue
merged_findings.append(_merge_group(group, field_merger=field_merger))
return merged_findings
def _deterministic_field_merge(group: List[Dict[str, Any]]) -> Dict[str, str]:
......@@ -237,6 +241,15 @@ def _deterministic_field_merge(group: List[Dict[str, Any]]) -> Dict[str, str]:
}
def _should_skip_group_merge(group: List[Dict[str, Any]]) -> bool:
if len(group) <= 1:
return True
for item in group:
if not str(item.get("original_text", "") or "").strip():
return True
return False
@tool("segment_merger", "同证据 findings 合并")
class SegmentMergerTool(LLMTool):
def __init__(self) -> None:
......@@ -321,30 +334,30 @@ if __name__ == "__main__":
tool = SegmentMergerTool()
sample = [
{
"rule_title": "支付时间审查",
"rule_title": "预付款审查",
"segment_id": 0,
"original_text": "本协议约定的服务内容全部履行完毕经甲方认可,在乙方提交检测数据后15个工作日内,乙方须向甲方提供相应数额的正规发票,甲方一次性支付合同总金额的100%给乙方。",
"issue": "付款条件缺乏实质把控。条款将付款绑定于提交数据,未明确'经甲方认可'的验收标准、期限及异议机制,且未设置质保金,存在验收流于形式即需全额付款的风险。",
"original_text": "丙方提交的下列单据经甲方、乙方审核无误后 20 个工作日内,支付该批次设备合同价格 70% 的到款项,合同生效后,丙方提供下列材料,甲方、乙方审核无误后20个工作日内支付给丙方本合同总价的10%的款项(¥458,000元,人民币大写肆拾伍万捌仟元整)作为预付款",
"issue": "根据审查规则,预付款比例应大于等于合同总价款的20%,或约定发货前付清全款。合同第3.2.1条约定预付款比例为合同总价的10%(458,000元),未达到公司规定的20%最低比例要求,且未约定发货前付清全款,存在资金占用风险。",
"risk_level": "H",
"suggestion": "修改为:'乙方提交报告后,甲方在X个工作日内验收。验收合格且收到发票后15个工作日内支付95%;剩余5%作为质保金,满X个月无异议后无息支付。若验收不合格,甲方有权拒付并要求整改。'",
"suggestion": "将预付款比例修改为合同总价的20%。建议修改为:“合同生效后,丙方提供下列材料,甲方、乙方审核无误后20个工作日内支付给丙方本合同总价的20%的款项(¥916,000元,人民币大写玖拾壹万陆仟元整)作为预付款”。",
"result": "不合格",
},
{
"rule_title": "发票审查",
"rule_title": "付款时间审查",
"segment_id": 0,
"original_text": "在乙方提交检测数据后15个工作日内,乙方须向甲方提供相应数额的正规发票,甲方一次性支付合同总金额的100%给乙方。甲方指定由其全资子公司长沙高新控股集团有限公司(简称高新控股)承担并支付本合同约定的检查服务费,乙方向高新控股开具相应金额的增值税专用发票。",
"issue": "缺失发票税率约定。条款明确了发票类型和开具时间,但未约定适用税率,违反审查规则,可能导致后续开票金额争议或税务合规风险。",
"original_text": "丙方按合同约定和交货通知单的要求交付合同设备后,现场经清点无误并验收合格,丙方提交的下列单据经甲方、乙方审核无误后 20 个工作日内,支付该批次设备合同价格 70% 的到款项",
"issue": "根据审查规则,发货后的付款项(如到货款)若未约定发货前全额付款,必须以“到货 XX 天/月”或相似表述作为充分条件之一,且若有多个条件需提及“先到为准”。当前条款约定支付“到货款”的条件仅为“现场经清点无误并验收合格”,属于以验收结果作为唯一触发条件,未设置“到货 XX 天”的闭口时间限制。若买方拖延验收,将导致卖方收款时间无限期延后,不符合规则要求。",
"risk_level": "H",
"suggestion": "补充税率约定。建议在'乙方向高新控股开具相应金额的增值税专用发票'后补充:'(税率:6%)'或根据实际业务类型补充具体税率数值。",
"suggestion": "修改为:丙方按合同约定和交货通知单的要求交付合同设备后,现场经清点无误并验收合格,或自货物到达现场之日起【30】日内(以先到者为准),丙方提交的下列单据经甲方、乙方审核无误后 20 个工作日内,支付该批次设备合同价格 70% 的到款项。",
"result": "不合格",
},
{
"rule_title": "主体审查",
"segment_id": 0,
"original_text": "委托方(甲方): 湖南麓谷发展集团有限公司... 甲方指定由其全资子公司长沙高新控股集团有限公司(简称高新控股)承担并支付本合同约定的检查服务费... 签章处:甲方:长沙高新控股集团有限公司",
"issue": "签约主体不一致。首部甲方为'湖南麓谷发展集团有限公司',但签章处及付款义务主体变更为'长沙高新控股集团有限公司',且未明确授权委托或变更确认条款,存在主体混同及履约风险。",
"original_text": "3.2.2 ...支付该批次设备合同价格70%的到款项; ... B.乙方开具给甲方、丙方开具给乙方的金额为该批次合同价格100%的增值税专用发票;",
"issue": "第3.2.2条约定到货款支付比例为70%,但条款要求开具金额为该批次合同价格100%的增值税专用发票。此时发票比例(100%)高于付款比例(70%)。根据审查规则,此类情况仅在合同中明确表述“货到”或“发货完成”时才为合格。虽然3.2.2条提到“交付合同设备后...验收合格”,但开票义务在3.3.1条中表述为无条件义务,未将“货到/发货完成”作为开具全额发票的强制前置条件,导致卖方在仅收到70%款项时需开具100%发票,不符合卖方利益。",
"risk_level": "H",
"suggestion": "统一合同主体名称。若确由子公司履约,应将首部及正文甲方统一修改为'长沙高新控股集团有限公司';若由母公司签约,应在签章处由母公司盖章,并补充'指定子公司代为履行付款义务'条款。",
"suggestion": '明确开票节点与付款节点的对应关系,修改为:"丙方应在合同设备发货后、买方支付到货款前,向乙方开具该批次设备金额100%的增值税专用发票。"',
"result": "不合格",
},
]
......
......@@ -11,123 +11,126 @@ import re
from loguru import logger
REVIEW_SYSTEM_PROMPT_LF = """
你是一个专业的合同分段审查智能体(SegmentReview)
你是合同分段审查智能体
你的任务是:基于给定审查规则,对“当前分段”进行审查,识别其中与规则相关且证据充分的条款,并判断其结果为“合格”或“不合格”,输出审查结论及必要的修改建议
基于【审查规则】审查【当前分段】
【审查范围】
你只能审查当前分段自身已经明确体现的内容。
你只能识别以下两类结果:
1. 合格条款:当前分段中存在与审查规则相关的明确表述,且该表述符合规则要求;
2. 不合格条款:当前分段中存在与审查规则相关的明确表述,且该表述不符合规则要求,例如:对我方不利、表述不清、逻辑冲突、责任失衡、触发条件不明确、关键限制缺失等。
仅审查当前分段原文,不得使用上下文或自行推断。
【审查原则】
- 严格基于给定的审查规则进行审查,不得脱离规则自行扩展审查标准。
- 只审查当前分段原文,不得使用上下文信息补充、修正或推断当前分段含义。
- 优先识别“确定成立”的合格或不合格结论,不输出模糊怀疑类表述。
你必须站在“我方利益最大化”立场审查。
【立场约束规则(强制)】
你必须严格站在“我方利益最大化”的立场进行审查与建议生成,遵循以下规则:
一、禁止削弱我方权益(硬性约束)
在任何情况下,suggestion 不得包含或导致以下结果:
- 将“我方完成后付款”修改为分期付款或预付款
- 新增或加重我方违约金、赔偿责任、利息等负担
- 新增我方履约义务、配合义务或缩短我方时限
- 削弱我方验收权(如“默示验收”“逾期视为验收”)
- 新增对方权利或免除对方责任
- 以“平衡双方权利义务”为理由削弱我方优势
- 给予对方奖励、补偿或额外利益
- 将原本由对方承担的风险转移至我方
若原条款已存在上述问题,应判定为“不合格”,并提出纠偏建议。
二、建议方向约束(必须遵守)
当 result="不合格" 时,suggestion 必须优先朝以下方向优化:
- 强化我方权利(如单方决定权、解释权、验收权、解除权)
- 降低我方责任(限制责任范围、金额、触发条件)
- 增加对方义务与违约责任
- 延长我方期限、缩短对方期限
- 增加对方违约成本(违约金/赔偿/利息)
- 明确触发条件,避免我方被动承担风险
三、禁止中立化建议(非常重要)
- 不得提出“双方协商一致”“友好协商解决”等中性建议
- 不得提出“建议双方平衡”“建议公平调整”等弱化我方立场的建议
- 所有 suggestion 必须体现明确的偏向我方的修改方向
1. 审查原则
【完整性要求(非常重要)】
你必须对当前分段进行“穷举式审查”,不得只输出部分结果。
1.1 只输出与审查规则直接相关且证据充分的 findings。
执行方式:
- 应逐句扫描当前分段
- 对每一句或关键子句,判断其是否与审查规则相关
- 只要存在证据充分的问题或合格表述,必须全部列出,不得遗漏
1.2 每个 finding 必须对应一个独立判断点。
特别要求:
- 不得因为已找到1条或少量finding而提前停止
- 若一个段落中存在多处问题,必须分别输出多个 findings
- findings 数量应与段落中实际存在的问题数量大致一致,不得明显偏少
1.3 每个 finding 只能引用一个最小充分证据片段。
错误示例(禁止):
- 一个段落有多个风险点,但只输出1条
1.4 不得遗漏当前分段中的其他独立风险点或合规点。
正确行为:
- 覆盖所有可以独立成立的审查点
1.5 result 只能为:
- "合格"
- "不合格"
【结果判定规则】
- result 只能取以下两个值之一:
- "合格":当前分段存在与规则相关的明确内容,且符合该规则要求;
- "不合格":当前分段存在与规则相关的明确内容,且不符合该规则要求。
- 如果当前分段与某条审查规则无关,或虽疑似相关但证据不足,则不得生成 finding。
1.6 当条款存在,但存在以下问题时:
- 对我方不利;
- 表述不清;
- 责任失衡;
- 条件不合理;
- 限制不足;
- 风险分配不合理;
则:
result="不合格"
【证据要求】
每个 findings 都必须包含 original_text,且必须是合同原文的直接引用。
1.7 当条款符合审查规则,且对我方风险可控时:
result="合格"
【单一证据约束(非常重要)】
每一个 finding 必须只对应一个“独立判断点”和一个“最小证据句”。
2. 风险等级规则
2.1 risk_level 只能为:
- "H"
- "M"
- "L"
具体要求:
- 一个 finding 只能基于一个关键句或一个最小语义单元;
- 若多个句子分别支持不同问题,必须拆分为多个 findings;
- 严禁将多个不同问题合并为一个 finding;
- 严禁在 original_text 中拼接多个不连续句子作为证据;
- 若 original_text 涉及跨句或跨段内容,必须拆分为多个 findings。
3. suggestion 要求
判断标准:
- 如果去掉 original_text 中的一部分,仍能形成一个独立判断 → 说明应该拆分
3.1 当 result="不合格" 时:
【issue 要求】
- issue 必须说明:该条款为什么合格或为什么不合格。
- 当 result="合格" 时,issue 应说明该表述满足了什么规则要求、为什么可认定为合格。
- 当 result="不合格" 时,issue 应说明该表述违反了什么规则要求、为什么构成风险或缺陷。
- issue 必须紧扣规则和原文,不得空泛评价。
suggestion 必须:
- 强化我方权利;
- 降低我方责任;
- 增加对方责任;
- 明确触发条件;
- 降低我方风险。
3.2 suggestion 不得:
- 平衡双方权利义务;
- 建议协商;
- 削弱我方优势;
- 增加我方责任;
- 减轻对方责任。
3.3 当 result="合格" 时:
suggestion="无需修改"
"""
REVIEW_USER_PROMPT_LF = """
【当前分段文本】
{segment_text}
【合同立场】
站在 {party_role} 的立场进行审查。
【审查规则】
{ruleset_text}
【任务】
请基于审查规则,仅针对当前分段文本进行审查,提取证据充分的合格条款和不合格条款,并输出 findings。
【特别要求】
- 仅基于当前分段原文进行判断,不得参考上下文、摘要或记忆信息;
- 不得扩展新的审查维度;
- 若一个段落中存在多处独立问题,必须拆分为多个 findings;
- findings 中每一项都必须包含 result;
- result 只能为:
- "合格"
- "不合格"
- original_text 必须:
- 为合同原文直接引用;
- 为最小充分证据片段;
- 不得拼接多个不连续句子;
【建议要求】
- suggestion 必须具体、可执行。
- 当 result="不合格" 时:
- 若能在当前分段内直接修正,请给出可直接替换或新增的条款措辞;
- 若无法直接改写,请给出明确修改方向和应补充的关键要素;
- 严禁提出削弱我方权益或中立化的建议;
- 当 result="合格" 时:
- suggestion 应简洁填写,可写“无需修改”;
- 不得为了凑内容而提出与审查结论无关的修改建议。
suggestion="无需修改"
【输出约束】
- 严格按照指定 JSON Schema 输出。
- 不得输出任何 JSON 之外的解释性文字。
- 若未发现证据充分的合格或不合格条款,返回 {"findings": []}。
- 当 result="不合格" 时:
suggestion 必须具体、可执行,并尽量提供可直接落地的修改文本;
若无法安全直接改写,则明确说明应补充的关键要素;
在生成最终 JSON 之前,你必须执行以下内部步骤(不输出):
- 若 original_text 不为空,则不得认定为缺失类不合格;
Step A:将当前分段拆分为若干句子或语义单元
Step B:逐句判断该句是否涉及任一审查规则
Step C:若涉及规则,判断其为合格或不合格
Step D:为每一个成立的判断生成一个 finding
- 若合同原文已经存在相关表述,但只是内容对我方不利、范围过宽、限制不足或表述不清,则不属于缺失类不合格,必须正常引用 original_text;
只有完成上述穷举后,才允许输出最终结果
【输出要求】
- 若无相关或无证据充分的结论:
{{"findings":[]}}
提示:在合同审查中,一个分段通常可能包含多个独立风险点或合规点,findings 数量通常大于1,除非该段确实只涉及单一事项。
- 仅输出 JSON。
"""
REVIEW_OUTPUT_SCHEMA_LF = """
```json
{
"findings": [
{
"rule_title": "对应审查规则标题",
"result": "合格 或 不合格",
"risk_level": "对应审查规则的风险等级,如 H/M/L",
"issue": "对该条款为何合格或为何不合格的详细说明,需基于规则与案例文本解释",
"original_text": "合同原文片段的直接引用",
"suggestion": "若 result=合格 则填写“无需修改”;若 result=不合格 则填写可直接替换原文或新增的条款措辞"
}
]
}
"""
REVIEW_SYSTEM_PROMPT_JP = """
......@@ -219,7 +222,7 @@ Step D:为每一个成立的判断生成一个 finding
提示:在合同审查中,一个分段通常可能包含多个独立风险点或合规点,findings 数量通常大于1,除非该段确实只涉及单一事项。
"""
REVIEW_USER_PROMPT = """
REVIEW_USER_PROMPT_JP = """
【当前分段文本】
{segment_text}
......@@ -244,7 +247,7 @@ REVIEW_USER_PROMPT = """
【输出要求】
- 仅输出 JSON。
"""
REVIEW_OUTPUT_SCHEMA = """
REVIEW_OUTPUT_SCHEMA_JP = """
```json
{
"findings": [
......@@ -346,15 +349,16 @@ class SegmentReviewTool(LLMTool):
context_memories: Optional[List[Dict]],
) -> List[Dict[str, str]]:
user_content = (
REVIEW_USER_PROMPT.format(
segment_id=segment_id,
segment_text=segment_text,
party_role=party_role,
# context_facts_json=json.dumps(context_summaries or [], ensure_ascii=False),
# context_memories_json=json.dumps(context_memories or [], ensure_ascii=False),
ruleset_text=self._stringify_rule(rule),
)
+ REVIEW_OUTPUT_SCHEMA
REVIEW_USER_PROMPT_JP if not use_lufa else REVIEW_USER_PROMPT_LF
).format(
segment_id=segment_id,
segment_text=segment_text,
party_role=party_role,
# context_facts_json=json.dumps(context_summaries or [], ensure_ascii=False),
# context_memories_json=json.dumps(context_memories or [], ensure_ascii=False),
ruleset_text=self._stringify_rule(rule),
) + (
REVIEW_OUTPUT_SCHEMA_JP if not use_lufa else REVIEW_OUTPUT_SCHEMA_LF
)
return self.build_messages(user_content)
......@@ -368,21 +372,16 @@ class SegmentReviewTool(LLMTool):
context_memories: Optional[List[Dict]],
) -> List[Dict[str, str]]:
ruleset_text = "\n\n".join([self._stringify_rule(rule) for rule in rules])
user_content = (
REVIEW_USER_PROMPT.format(
segment_id=segment_id,
segment_text=segment_text,
party_role=party_role,
context_facts_json=json.dumps(
context_summaries or [], ensure_ascii=False
),
context_memories_json=json.dumps(
context_memories or [], ensure_ascii=False
),
ruleset_text=ruleset_text,
)
+ REVIEW_OUTPUT_SCHEMA
)
user_content = REVIEW_USER_PROMPT.format(
segment_id=segment_id,
segment_text=segment_text,
party_role=party_role,
context_facts_json=json.dumps(context_summaries or [], ensure_ascii=False),
context_memories_json=json.dumps(
context_memories or [], ensure_ascii=False
),
ruleset_text=ruleset_text,
) + (REVIEW_OUTPUT_SCHEMA_JP if not use_lufa else REVIEW_OUTPUT_SCHEMA_LF)
return self.build_messages(user_content)
async def _evaluate_rules_async(
......@@ -424,7 +423,9 @@ class SegmentReviewTool(LLMTool):
try:
responses = await self.chat_batch_async(msgs)
except Exception as e:
logger.error(f"Error in segment review for segment {segment_id}: {e}")
logger.error(
f"segment_review Error in segment review for segment {segment_id}: {e}"
)
return {"findings": []}
all_findings: List[Dict] = []
......@@ -440,13 +441,13 @@ class SegmentReviewTool(LLMTool):
else:
for idx, resp in enumerate(responses):
data = self.parse_first_json(resp)
rule_title = rules[idx].get("title", "")
rule_level = rules[idx].get("level", "")
findings = data.get("findings", []) or []
# 为每条 finding 添加对应的 rule_title 和 risk_level
for f in findings:
f["rule_title"] = rule_title
f["risk_level"] = rule_level
if "rule_title" not in f or not f["rule_title"]:
f["rule_title"] = rules[idx].get("title", "")
if "risk_level" not in f or not f["risk_level"]:
f["risk_level"] = rules[idx].get("level", "")
all_findings.extend(findings)
return {
......@@ -493,42 +494,24 @@ class SegmentReviewTool(LLMTool):
if __name__ == "__main__":
tool = SegmentReviewTool()
segment_text = """
val: 买方需在卖方产品验收通过后24个月内,买方在收到卖方提交的下列全部单据并经审核无误后60日内,向卖方支付合同价格的10%
val: (1)银行开具的质保金保函,保函期限不少于质量保证期。
val: 3.2.1.5质保金:采购合同内设备验收投运满24个月后,买方验收无质量问题及索赔事项,经买方同意后,卖方可向买方提交质保金保函等额替代质保金,买方向卖方支付合同价格的10%作为质保金。
val: 8.2(补充为)如在质量保证期内发现合同设备部件出现缺陷但不影响合同设备的正常运行,经维修或更换后的部件的质量保证期重新计算。在质量保证期内,由于卖方责任导致合同设备停运时,该台合同设备的质量保证期自卖方消除该缺陷后重新计算。
"""
answer: 1.1“甲方(买方)”是指【冕宁县穗发新能源有限公司 】,包括其指定继承人(其指定继承人将全面继承需方在本合同的权利、义务和责任)。
answer: 14.11由于买方与卖方的合同分包商和外购设备供货商没有直接的合同关系,故本合同设备的卖方的分包和外购设备的付款由卖方负责。但如果发生由于个别原因(包括但不限于买方虽按时向卖方付款而卖方没有按时向其分包商或外购设备供货商付款等情形)导致卖方的分包和外购设备有可能无法按时交货以至于影响施工进度的情况,买方有权暂时中止向卖方付款。在卖方向其分包商或外购设备供货商支付相关款项后,买方将继续向卖方付款,同时买方还将追究卖方延误工期的责任。如果卖方仍未向其分包商或外购设备供货商付款,买方将出于保障工程进度的目的,有权直接向其分包商或外购设备供货商付款。但在此情况下,卖方必须协助买方同卖方的分包商或外购设备供货商另行签订转付款协议书,同时该协议书中此转付款连同买方发生的贷款利息将从下一笔买方向卖方的应付款中扣除。
answer: 14.12若买方认为卖方因财务或其他问题未能履行本合同内的义务,买方有权自行或另请其他方履行本合同余下的义务。卖方保证分包合同中将载有规定,在卖方无法继续经营或履行分包合同的情况下,卖方在各分包合同下的权利自动转让给买方或买方指定的其他方。
answer: 20.8.2 卖方资质出现失效、未通过年审(年检)或被主管部门注销的,买方有权单方面解除合同并将合同未完成事项转由有资质的单位承接,已完成的事项按实结算。如因上述情形导致买方损失的,卖方应予完全赔偿。 """
result = tool.run(
segment_id=1,
segment_text=segment_text,
rules=[
{
"title": "质保期审查",
"title": "第三方审查",
"rule": """
1)质保期(而非寿命期)必须提及“到货/交付/运行XX天/月”条件作为起算时间,如果有多个条件必须提及“先到为准”
2)质保期(而非寿命期)期限/时长不超过(小于等于)“到货24个月/2年”或“交付后18个月/1年半”或"运行后12个月/1年",最长不超过2年
3)没有明确提及质保期的起算点和时长,审查合格
""",
"suggestion_template": """
质保期为交付之日起18个月或运行之日起12个月或到货后24个月,先到为准
""",
"case": """
## 案例1:
原文:质保期期限为产品交付之日起48个月,或产品运行之日起40个月,两者以先到时间为准。
结论:质保期的起算时间提及了交付XX月,以及先到为准,满足条件;原文的交付后48个月超过了24个月,运行后40个月超过了12个月,不满足时长要求,因此审查不合格。
## 案例2:
原文:质保期期限为产品交付之日起两年。
结论:质保期时长最长时间交付后1年半,原文要求2年,超过质保期时长要求,因此审查不合格。
## 案例3:
原文:质保期期限为通电验收起两年。
结论:质保期起算时间必须提及“到货/交付/运行XX天/月”,而非通电验收,因此审查不合格。
## 案例4:
原文:质保期出现问题需要赔偿违约金。
结论:没有明确提及质保期的起算时间和时长,因此审查合格。
1)货款支付不能涉及第三方(业主或委托付款方)
2)不能明确提及甲方将履行义务转移给第三方(业主或委托付款方)
3)买方转移债务到第三方(业主或委托付款方),由卖方直接向第三方(业主或委托付款方)行使债权,审查不合格
""",
}
],
party_role="金盘(卖方、供方、乙方)",
party_role="麓谷发展",
)
print(json.dumps(result, ensure_ascii=False, indent=2))
......
......@@ -14,6 +14,7 @@ from utils.common_util import random_str
from utils.http_util import upload_file, fastgpt_openai_chat, download_file
use_lufa = False
batch_size = 5
if not use_lufa:
SUFFIX = "_麓发迁移"
......@@ -26,13 +27,13 @@ if not use_lufa:
# 人机交互测试(测试环境)
# token = 'fastgpt-p189K5zoTX5wjp0dBybFCwsbWm3juIwlJxt2wTGyiaOWOANI5Y10pKEZzyt'
# 人机交互测试(生产环境)
# token = 'fastgpt-ry4jIjgNwmNgufMr5jR0ncvJVmSS4GZl4bx2ItsNPoncdQzW9Na3IP1Xrankr'
# token = "fastgpt-ry4jIjgNwmNgufMr5jR0ncvJVmSS4GZl4bx2ItsNPoncdQzW9Na3IP1Xrankr"
# 提取后审查测试
# token = 'fastgpt-n74gGX5ZqLT6o1ysMBSGUTjIciswYOWDRfQ75krMkE5gDVDkpzsbz8u'
else:
SUFFIX = "_麓发"
batch_input_dir_path = "lufa-input"
batch_output_dir_path = "lufa-output-standard"
batch_input_dir_path = "4.24测财务合同审核"
batch_output_dir_path = "4.24测财务合同审核-batch"
# 麓发fastgpt接口
url = "http://192.168.252.71:18089/api/v1/chat/completions"
# 麓发合同审查生产token
......@@ -41,9 +42,6 @@ else:
token = "fastgpt-mg5tQUgreJeF7peoOr5zqP0NR4EIrfS2bEVXge6FUL94Suu1TvEMR1sGNRSiV"
batch_size = 5
def extract_url(text):
# \s * ([ ^ "\s]+?\.(?:docx?|pdf|xlsx))
excel_p, doc_p = (
......
......@@ -328,7 +328,7 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument(
"--val-dir",
type=Path,
default=base / "batch_output_0121_val",
default=base / "jp-output-rj-base",
help="Directory containing extracted val xlsx files.",
)
parser.add_argument(
......
......@@ -10,11 +10,11 @@ from spire.doc import Document
from compare_annotation import compare_with_log
# Map raw comment authors to unified review item names.
COMMENT_AUTHOR_MAPPING: dict[str, str] = {
"三方货款审查": "第三方审查",
"履行义务审查": "第三方审查",
"债务转移审查": "第三方审查",
"违约条款审查": "违约与延期审查",
"延期审查": "违约与延期审查",
}
......@@ -121,7 +121,7 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument(
"--datasets-dir",
type=Path,
default=base / "results" / "jp-output-lufa-20260416-235546",
default=base / "results" / "jp-output-lufa-20260511-101828",
help="Directory containing Word files with annotations.",
)
parser.add_argument(
......
No preview for this file type
from spire.doc import *
from spire.doc.common import *
# 创建一个 Document 类对象并加载一个 Word 文档
doc = Document()
doc.LoadFromFile(
"/home/ccran/lufa-contract/demo/湖南麓谷发展集团“主数据管理系统与合同管理系统开发”项目合同协议书-审核批注20260511_153215.docx"
)
# 移除第二个注释
# doc.Comments.RemoveAt(1)
# 移除所有注释
doc.Comments.Clear()
# 保存文档
doc.SaveToFile(
"/home/ccran/lufa-contract/demo/湖南麓谷发展集团“主数据管理系统与合同管理系统开发”项目合同协议书-审核批注20260511_153215-无批注.docx"
)
doc.Close()
......@@ -14,10 +14,16 @@ from loguru import logger
from utils.common_util import extract_url_file, format_now
from utils.http_util import download_file
from core.cache import get_cached_doc_tool, get_cached_memory
from core.config import doc_support_formats, pdf_support_formats, MERGE_RULE_PROMPT
from core.config import (
doc_support_formats,
pdf_support_formats,
MERGE_RULE_PROMPT,
use_lufa,
)
from core.tools.segment_summary import SegmentSummaryTool
from core.tools.segment_review import SegmentReviewTool
from core.tools.segment_rule_router import SegmentRuleRouterTool
from core.tools.rule_filter import LufaPartyRuleFilterTool
from core.tools.retrieve_reference import RetrieveReferenceTool
from core.tools.reflect_retry import ReflectRetryTool
from core.tools.segment_merger import SegmentMergerTool
......@@ -30,6 +36,7 @@ TMP_DIR.mkdir(parents=True, exist_ok=True)
summary_tool = SegmentSummaryTool()
review_tool = SegmentReviewTool()
rule_router_tool = SegmentRuleRouterTool()
lufa_party_rule_filter_tool = LufaPartyRuleFilterTool()
reference_tool = RetrieveReferenceTool()
reflect_tool = ReflectRetryTool()
merger_tool = SegmentMergerTool()
......@@ -59,6 +66,7 @@ class DocumentParseResponse(BaseModel):
ruleset_items: List[str]
text: Optional[str] = None
file_ext: Optional[str] = None
file_name: Optional[str] = None
@app.post("/documents/parse", response_model=DocumentParseResponse)
......@@ -66,18 +74,13 @@ async def parse_document(payload: DocumentParseRequest) -> DocumentParseResponse
if not payload.urls:
raise HTTPException(status_code=400, detail="No URLs provided")
try:
support_formats = list(dict.fromkeys(doc_support_formats + pdf_support_formats))
filename = extract_url_file(payload.urls[0], support_formats)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Failed to parse url: {exc}")
file_path = str(TMP_DIR / filename)
try:
download_file(payload.urls[0], file_path)
file_path = download_file(payload.urls[0], TMP_DIR)
if not file_path:
raise RuntimeError("download returned empty path")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Download failed: {exc}")
# get doc tool
file_ext = payload.file_ext or Path(filename).suffix
file_ext = payload.file_ext or Path(file_path).suffix
try:
doc_obj, _ = get_cached_doc_tool(payload.conversation_id, file_ext)
except Exception as exc:
......@@ -105,6 +108,7 @@ async def parse_document(payload: DocumentParseRequest) -> DocumentParseResponse
segment_ids=segment_ids,
ruleset_items=ruleset_review_items,
file_ext=file_ext,
file_name=Path(file_path).name,
)
......@@ -285,6 +289,22 @@ def route_segment_rules(payload: SegmentReviewRequest) -> SegmentRuleRouterRespo
ruleset_id = payload.ruleset_id or reference_tool.default_ruleset_id
rules = reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
if use_lufa and rules:
try:
total_segments = len(doc_obj.get_chunk_id_list() or [])
except Exception:
total_segments = 0
filtered_payload = lufa_party_rule_filter_tool.run(
{
"rules": rules,
"segment_idx": segment_idx,
"total_segments": total_segments,
}
)
rules = filtered_payload.get("rules", rules)
result = rule_router_tool.run(
segment_id=segment_idx,
segment_text=segment_text,
......@@ -508,7 +528,9 @@ def export_memory(payload: MemoryExportRequest) -> MemoryExportResponse:
try:
doc_res = store.export_findings_to_doc_comments(
doc_obj, finding_key=payload.finding_key or FINDING_KEY_REVIEW
doc_obj,
file_name=payload.file_name,
finding_key=payload.finding_key or FINDING_KEY_REVIEW,
)
except Exception as exc:
traceback.print_exc()
......
from main import FactsRetrieveRequest, retrieve_facts
from core.cache import get_cached_memory
import json
def test_retrieve_facts_direct() -> None:
conversation_id = "fa86563cb6c649d59e32e7def16ea6b2"
payload = FactsRetrieveRequest(
conversation_id=conversation_id,
keywords=["当事人"],
)
res = retrieve_facts(payload)
print(json.dumps(res.facts,ensure_ascii=False, indent=4))
if __name__ == "__main__":
test_retrieve_facts_direct()
......@@ -9,7 +9,7 @@ from core.config import max_chunk_page, min_single_chunk_size, max_single_chunk_
def random_str(l=5):
return ''.join(random.sample('abcdefghijklmnopqrstuvwxyz', l))
return "".join(random.sample("abcdefghijklmnopqrstuvwxyz", l))
def format_now():
......@@ -19,12 +19,14 @@ def format_now():
# 从url中提取文件名称
def extract_url_file(url, support_formats):
pattern = '|'.join([r'[\u4e00-\u9fa5()()0-9\w-]+' + format for format in support_formats])
pattern = "|".join(
[r"[\u4e00-\u9fa5()()0-9\w-]+" + format for format in support_formats]
)
search_result = re.search(pattern, url)
if search_result:
return search_result.group()
else:
raise Exception(f'{support_formats} not found in url:{url}')
raise Exception(f"{support_formats} not found in url:{url}")
# 调整单个页面数量
......@@ -34,7 +36,7 @@ def adjust_single_chunk_size(all_text_len):
# 从JSON字符串提取JSON对象
def extract_json(json_str:str) -> List[Dict]:
def extract_json(json_str: str) -> List[Dict]:
"""从字符串中提取 JSON 对象列表。
优先提取 ```json ... ``` 代码块;若不存在,尝试:
......@@ -43,12 +45,13 @@ def extract_json(json_str:str) -> List[Dict]:
- 从任意包含花括号/方括号的片段尝试解析
返回解析成功的 JSON 对象列表(数组会被展开)。
"""
def _try_parse_to_list(candidate: str, out_list: list) -> bool:
s = (candidate or '').strip()
s = (candidate or "").strip()
if not s:
return False
# 清理控制字符
s = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', s)
s = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", s)
try:
obj = json_repair.loads(s, strict=False)
if isinstance(obj, list):
......@@ -63,26 +66,26 @@ def extract_json(json_str:str) -> List[Dict]:
results = []
# 1. 提取 ```json ... ``` 代码块
fenced_json_pattern = r'```json([\s\S]*?)```'
for match in re.findall(fenced_json_pattern, json_str or '', re.DOTALL):
fenced_json_pattern = r"```json([\s\S]*?)```"
for match in re.findall(fenced_json_pattern, json_str or "", re.DOTALL):
_try_parse_to_list(match, results)
if results:
return results
# 2. 尝试将全文解析为 JSON
if _try_parse_to_list(json_str or '', results):
if _try_parse_to_list(json_str or "", results):
return results
# 3. 提取普通 ``` ... ``` 代码块,尝试解析
fenced_any_pattern = r'```([\s\S]*?)```'
for match in re.findall(fenced_any_pattern, json_str or '', re.DOTALL):
fenced_any_pattern = r"```([\s\S]*?)```"
for match in re.findall(fenced_any_pattern, json_str or "", re.DOTALL):
if _try_parse_to_list(match, results):
return results
# 4. 从包含花括号/方括号的片段尝试解析(启发式,尽力而为)
bracket_pattern = r'(\{[\s\S]*?\}|\[[\s\S]*?\])'
for match in re.findall(bracket_pattern, json_str or '', re.DOTALL):
bracket_pattern = r"(\{[\s\S]*?\}|\[[\s\S]*?\])"
for match in re.findall(bracket_pattern, json_str or "", re.DOTALL):
_try_parse_to_list(match, results)
return results
......@@ -90,11 +93,7 @@ def extract_json(json_str:str) -> List[Dict]:
def remove_duplicates_by_key(data_list, key):
# 先按字符串长度从长到短排序
sorted_list = sorted(
data_list,
key=lambda x: len(x.get(key, "")),
reverse=True
)
sorted_list = sorted(data_list, key=lambda x: len(x.get(key, "")), reverse=True)
result = []
seen_strings = []
......@@ -109,12 +108,14 @@ def remove_duplicates_by_key(data_list, key):
def extract_drop_json_part(json_str):
json_pattern = r'```json([\s\S]*?)```'
non_json_content = re.sub(json_pattern, '', json_str, re.DOTALL)
json_pattern = r"```json([\s\S]*?)```"
non_json_content = re.sub(json_pattern, "", json_str, re.DOTALL)
return non_json_content.strip()
def group_chunk_by_len(chunk_list: List[Dict], key: str, chunk_len: int) -> List[List[Dict]]:
def group_chunk_by_len(
chunk_list: List[Dict], key: str, chunk_len: int
) -> List[List[Dict]]:
ret_chunk_list = []
sub_chunk_list = []
current_acc_len = 0 # 用于记录当前 sub_chunk 的累积长度
......@@ -144,7 +145,10 @@ def group_chunk_by_len(chunk_list: List[Dict], key: str, chunk_len: int) -> List
return ret_chunk_list
if __name__ == '__main__':
if __name__ == "__main__":
json_str = '```json{"segment_id": "seg-001"}```'
print(extract_json(json_str))
url = "/api/common/file/read/今麦郎合同审核.docx?token=eyJhbGciOiJ.kpXVCJ9.1xfdsa"
print(extract_url_file(url, [".docx", ".doc", ".wps"]))
pass
import json
import os
import re
from pathlib import Path
from urllib.parse import unquote, urlparse
import requests
from loguru import logger
......@@ -82,6 +85,26 @@ def upload_file(path, input_url_to_inner=True, output_url_to_inner=False) -> str
raise Exception(f"上传{path}失败 Response text: {response.text}")
def _resolve_download_filename(url: str, response: requests.Response) -> str:
content_disposition = response.headers.get("content-disposition", "")
if content_disposition:
match = re.search(
r"filename\*=(?:UTF-8''|utf-8'')?([^;]+)", content_disposition
)
if match:
return unquote(match.group(1).strip().strip('"'))
match = re.search(r'filename="?([^";]+)"?', content_disposition)
if match:
return unquote(match.group(1).strip().strip('"'))
url_filename = Path(urlparse(url).path).name
if url_filename:
return url_filename
return "downloaded_file"
# 下载url到本地path
def download_file(url, path, input_url_to_inner=True):
if not url.startswith("http:"):
......@@ -92,13 +115,21 @@ def download_file(url, path, input_url_to_inner=True):
response = requests.get(url)
# 确保请求成功
if response.status_code == 200:
target_path = Path(path)
if target_path.exists() and target_path.is_dir():
target_path = target_path / _resolve_download_filename(url, response)
target_path.parent.mkdir(parents=True, exist_ok=True)
# 打开本地文件,准备写入数据
with open(path, "wb") as f:
with open(target_path, "wb") as f:
# 写入响应的内容
f.write(response.content)
logger.info(f"{url}文件下载成功,保存到{path}")
logger.info(f"{url}文件下载成功,保存到{target_path}")
return str(target_path)
else:
logger.error(f"{url}文件下载失败. HTTP Status Code: {response.status_code}")
return None
def url_replace_fastgpt(origin: str):
......
......@@ -4,14 +4,7 @@ from openai import AsyncOpenAI
from dataclasses import dataclass
from tenacity import retry, stop_after_attempt, stop_after_delay, wait_fixed
import asyncio
from core.config import MAX_WORKERS
@dataclass
class LLMConfig:
base_url: str
api_key: str
model: str
from core.config import MAX_WORKERS, LLMConfig, use_non_fastgpt_llm
class OpenAITool:
......@@ -25,17 +18,25 @@ class OpenAITool:
@retry(stop=stop_after_delay(600) | stop_after_attempt(3), wait=wait_fixed(1))
async def chat(self, msg, tools=None):
if tools is None:
extra_body = None
if msg[0]["role"] == "system":
extra_body = {"variables": {"system": msg[0]["content"]}}
msg = msg[1:]
response = await self.client.chat.completions.create(
model=self.llm_config.model, messages=msg, extra_body=extra_body
)
content = response.choices[0].message.content
reasoning_content = response.choices[0].message.model_extra.get(
"reasoning_content", ""
)
extra_body = {}
# fastgpt专用:如果第一个消息是system角色,则将其内容放入extra_body.variables.system,并从消息列表中移除
if not use_non_fastgpt_llm:
if msg[0]["role"] == "system":
extra_body = {"variables": {"system": msg[0]["content"]}}
msg = msg[1:]
# deepseek专用关闭思考
extra_body["thinking"] = {"type": "disabled"}
try:
response = await self.client.chat.completions.create(
model=self.llm_config.model, messages=msg, extra_body=extra_body
)
content = response.choices[0].message.content
reasoning_content = response.choices[0].message.model_extra.get(
"reasoning_content", ""
)
except Exception as e:
logger.error(f"LLM调用失败: {e} | response: {response}")
raise e
return content
else:
response = await self.client.chat.completions.create(
......@@ -54,3 +55,19 @@ class OpenAITool:
return await self.chat(m, tools)
return await asyncio.gather(*[_wrapped(m) for m in msgs])
if __name__ == "__main__":
import json
llm_config = LLMConfig()
tool = OpenAITool(llm_config)
messages = [
{
"role": "system",
"content": "你是我的人工智能助手,协助我分析问题并提供建议。",
},
{"role": "user", "content": "请分析以下问题:为什么天空是蓝色的?"},
]
response = asyncio.run(tool.chat(messages))
print("LLM Response:", response)
......@@ -4,6 +4,7 @@ import re
from thefuzz import fuzz
from utils.doc_util import DocBase
from utils.common_util import adjust_single_chunk_size
from core.config import use_lufa
import os
......@@ -713,6 +714,40 @@ class SpireWordDoc(DocBase):
text_range = text_sel.GetAsOneRange()
return self._insert_comment_by_text_range(text_range, author, comment_content)
def add_comment_to_first_paragraph(
self, comment_text, author="审阅助手", target_text=None
):
"""
将批注直接添加到第一节第一个段落。
保留 target_text 参数仅为兼容旧调用。
"""
self._ensure_loaded()
if self._doc.Sections.Count == 0:
logger.error("文档中未找到任何节,无法添加批注")
return False
section = self._doc.Sections.get_Item(0)
if section.Paragraphs.Count == 0:
logger.error("第一节未找到段落,无法添加批注")
return False
paragraph = section.Paragraphs.get_Item(0)
comment = Comment(self._doc)
comment.Body.AddParagraph().Text = comment_text
comment.Format.Author = author
paragraph.ChildObjects.Add(comment)
comment_start = CommentMark(self._doc, CommentMarkType.CommentStart)
comment_end = CommentMark(self._doc, CommentMarkType.CommentEnd)
comment_start.CommentId = comment.Format.CommentId
comment_end.CommentId = comment.Format.CommentId
paragraph.ChildObjects.Insert(0, comment_start)
paragraph.ChildObjects.Add(comment_end)
return True
# 设置chunk批注
def add_table_comment(
self, table, target_text, comment_text, author="审阅助手", initials="AI"
......@@ -778,6 +813,25 @@ class SpireWordDoc(DocBase):
)
author = self.format_comment_author(comment)
suggest = comment.get("suggest", "")
original_text = (comment.get("original_text") or "").strip()
# original_text 为空时,直接落在文档首个可用段落。
if not original_text:
existing_comment_idx = self.find_comment(author)
if existing_comment_idx is not None:
self._update_comment_content(existing_comment_idx, suggest)
continue
first_para_author = self._decorate_author_with_match_type(
author, "exact"
)
matched = self.add_comment_to_first_paragraph(
suggest, first_para_author
)
if not matched:
logger.error("original_text 为空,且未能在首段落添加批注")
continue
find_key = comment["original_text"].strip() or comment["key_points"]
# 先检查是否已有同一“规则ID|要点”的批注,避免重复插入。
......@@ -868,6 +922,18 @@ class SpireWordDoc(DocBase):
def to_file(self, path, remove_prefix=False):
self._ensure_loaded()
# watermark_text = (
# "Evaluation Warning: The document was created with Spire.Doc for Python."
# )
# if self._doc.Sections.Count > 0:
# section = self._doc.Sections.get_Item(0)
# if section.Paragraphs.Count > 0:
# first_paragraph = section.Paragraphs.get_Item(0)
# first_text = (first_paragraph.Text or "").strip()
# if first_text == watermark_text:
# section.Paragraphs.RemoveAt(0)
if remove_prefix:
self.remove_comment_prefix()
self._doc.SaveToFile(path)
......@@ -886,8 +952,9 @@ class SpireWordDoc(DocBase):
if __name__ == "__main__":
doc = SpireWordDoc()
doc.load(r"/home/ccran/lufa-contract/demo/今麦郎合同审核.docx")
print(doc._doc_name)
print("附件2《技术协议》" in doc.get_all_text())
# print(doc._doc_name)
# print("附件2《技术协议》" in doc.get_all_text())
# doc.add_chunk_comment(
# 0,
# [
......@@ -902,4 +969,6 @@ if __name__ == "__main__":
# }
# ],
# )
# doc.to_file("/home/ccran/lufa-contract/demo/今麦郎合同审核_test.docx", True)
doc.add_comment_to_first_paragraph("这是第一段的批注", "审阅助手")
doc.to_file("/home/ccran/lufa-contract/demo/今麦郎合同审核_test.docx", True)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment