Commit 51eaa283 by ccran

Merge branch 'master' of git.hnluchuan.com:ccran/lufa-contract

parents 70149414 4766f9df
......@@ -16,6 +16,7 @@
!data/**.xlsx
!README.md
!data/*.xlsx
# Keep this file tracked
!.gitignore
......
......@@ -18,6 +18,12 @@ use_docker = False
# api_key: str = "none"
# model: str = "Qwen2-72B-Instruct"
# @dataclass
# class LLMConfig:
# base_url: str = "http://172.21.107.80:9002/v1"
# api_key: str = "none"
# model: str = "Qwen2-72B-Instruct"
@dataclass
class LLMConfig:
base_url: str = "http://192.168.252.71:9002/v1"
......@@ -35,6 +41,7 @@ MAX_SINGLE_CHUNK_SIZE = 5000
MERGE_RULE_PROMPT = False
META_KEY = "META"
DEFAULT_RULESET_ID = "通用"
FULL_TEXT_SEGMENT_ID = -1
## 规则集ID列表,需与rules.xlsx中的sheet名称保持一致!!!
ALL_RULESET_IDS = [
"通用",
......@@ -57,7 +64,7 @@ FILE_SUFFIX = "-审核批注"
## 关键参数**
use_non_fastgpt_llm = True
use_lufa = False
use_lufa = True
use_jp_machine = True
debug_mode = False
......@@ -118,6 +125,9 @@ LLM = {
base_url=f"{base_fastgpt_url}/api/v1", api_key=reflect_retry_api_key
)
),
"nanobot_llm": LLMConfig(
base_url="http://172.21.107.80:19090/v1", api_key='none', model="Qwen3.5-122B-A10B-AWQ"
),
}
doc_support_formats = [".docx", ".doc", ".wps"]
pdf_support_formats = [".txt", ".md", ".pdf"]
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from core.memory import Finding
from core.tools.segment_llm import LLMTool
logger = logging.getLogger(__name__)
FINDING_PREPROCESSOR_SYSTEM_PROMPT = """你是合同审查建议的文本微调助手。
必须以原始建议为底稿,只对处理要求明确涉及的局部文字做细微调整。
不得重写整段建议,不得补充、删减、扩写、总结或解释其他内容。
合同原文是判断现有条款内容的唯一依据。必须区分建议中引用的原文和建议修改后的目标文本:
1. “将A修改为B”“把A替换为B”“删除A”等表述中的 A 是原文定位文本,必须是合同原文中实际存在的连续文本;
2. 如果 A 与合同原文不一致,只能依据合同原文将 A 校正为对应的原文文本,不得虚构;
3. 处理要求只作用于 B 等修改后的目标文本,不得错误修改作为定位依据的 A;
4. 建议直接给出拟新增或修改后的条款时,处理要求作用于该拟定文本。
suggestion 的值必须是连续的纯文本,不得包含标题、项目符号、编号列表、加粗、
引用、代码块、链接等任何 Markdown 格式。
只返回 JSON 对象,格式为:{"suggestion": "微调后的纯文本建议"}。"""
FINDING_PREPROCESSOR_USER_PROMPT = """请处理以下合同审查建议:
审查项:{rule_title}
处理要求:{processing_requirement}
合同原文(仅供理解上下文,不得修改或输出):{original_text}
原始建议:{suggestion}
除处理要求涉及的局部文字外,字词、标点和语序均须保持不变。
请仅返回 JSON 对象,suggestion 中不得使用任何 Markdown 格式。"""
class FindingPreprocessor:
"""在 finding 入库前按审查项执行文本处理。"""
_LLM_PROCESSING_REQUIREMENTS = {
"安装调试与指导审查": (
"拟新增或修改后的目标文本不得使用“指导”,应改用“支持”。"
"如果建议采用“将A修改为B”等表达,A 必须保持为合同原文中实际存在的文本,"
"只将 B 中的“指导”调整为“支持”;其他内容保持不变。"
),
}
_SUGGESTION_REPLACEMENTS = {
"安装调试与指导审查": {
"指导": "支持",
},
}
def __init__(self, llm_tool: LLMTool | None = None) -> None:
self._llm_tool = llm_tool
def process(self, finding: Finding, use_rule: bool = False) -> Finding:
"""优先使用 LLM 处理,调用失败时回退到确定性规则。"""
if use_rule:
return self.process_with_rules(finding)
return self.process_with_llm(finding)
def process_with_llm(self, finding: Finding) -> Finding:
if finding.result != "不合格":
return finding
rule_title = (finding.rule_title or "").strip()
requirement = self._LLM_PROCESSING_REQUIREMENTS.get(rule_title)
if not requirement or not finding.suggestion:
return finding
user_content = FINDING_PREPROCESSOR_USER_PROMPT.format(
rule_title=rule_title,
processing_requirement=requirement,
original_text=json.dumps(finding.original_text or "", ensure_ascii=False),
suggestion=json.dumps(finding.suggestion, ensure_ascii=False),
)
try:
llm_tool = self._get_llm_tool()
messages = llm_tool.build_messages(
user_content,
system_content=FINDING_PREPROCESSOR_SYSTEM_PROMPT,
)
response = llm_tool.run_with_loop(llm_tool.chat_async(messages))
data = llm_tool.parse_first_json(response)
suggestion = str(data.get("suggestion", "") or "").strip()
if not suggestion:
raise ValueError("LLM returned an empty suggestion")
finding.suggestion = suggestion
return finding
except Exception as exc:
logger.warning(
"LLM finding preprocessing failed, fallback to rule processing: %s",
exc,
)
return self.process_with_rules(finding)
def process_with_rules(self, finding: Finding) -> Finding:
if finding.result != "不合格":
return finding
replacements = self._SUGGESTION_REPLACEMENTS.get(
(finding.rule_title or "").strip(), {}
)
for source, target in replacements.items():
finding.suggestion = (finding.suggestion or "").replace(source, target)
return finding
def _get_llm_tool(self) -> LLMTool:
if self._llm_tool is None:
from core.tools.segment_llm import LLMTool
self._llm_tool = LLMTool(llm_key="base_tool_llm")
return self._llm_tool
......@@ -12,6 +12,7 @@ from uuid import uuid4
from utils.http_util import upload_file
from utils.doc_util import DocBase
from core.config import META_KEY, FILE_SUFFIX, use_lufa
from core.finding_preprocessor import FindingPreprocessor
logger = logging.getLogger(__name__)
......@@ -65,7 +66,7 @@ class Finding:
def __repr__(self):
return (
f"Finding(id={self.id!r}, rule_title={self.rule_title!r}, segment_id={self.segment_id}, "
f"issue={self.issue!r}, risk_level={self.risk_level!r}, result={self.result!r})"
f"issue={self.issue!r}, risk_level={self.risk_level!r}, result={self.result!r}),suggestion={self.suggestion!r}"
)
......@@ -82,6 +83,7 @@ class MemoryStore:
self.facts: List[Dict[str, Any]] = []
self.merge_facts: List[Dict[str, Any]] = []
self.findings: Dict[str, List[Finding]] = {}
self._finding_preprocessor = FindingPreprocessor()
self._load()
# ---------------------- facts ----------------------
......@@ -179,6 +181,7 @@ class MemoryStore:
def _add_finding(self, key: str, finding: Finding) -> Finding:
with self._lock:
finding = self._finding_preprocessor.process(finding)
finding_key = self._normalize_finding_key(key)
if not finding.id:
finding.id = uuid4().hex
......@@ -635,12 +638,12 @@ def test_memory_and_export_excel():
)
# print( store.search_facts(['支付']))
finding1 = Finding(
rule_title="违约责任",
rule_title="安装调试审查",
segment_id=1,
original_text="违约方应赔偿全部损失",
issue="未约定违约金上限,可能导致赔偿范围过大",
original_text="安装调试审查测试原文",
issue="安装调试审查中不能有指导",
risk_level="H",
suggestion="建议增加‘赔偿金额不超过合同总额的30%",
suggestion="修改为安装调试指导。",
)
finding2 = Finding(
rule_title="违约责任",
......@@ -659,11 +662,12 @@ 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/1_金盘箱变采购合同.docx"
)
# test_memory_and_export_excel()
# test_export_findings_to_doc_comments(
# "/home/ccran/lufa-contract/tmp/1_金盘箱变采购合同.docx"
# )
test_memory_and_export_excel()
pass
......@@ -9,8 +9,8 @@ from core.tool import ToolBase, tool, tool_func
from utils.excel_util import ExcelUtil
@tool("retrieve_reference", "审查参考检索")
class RetrieveReferenceTool(ToolBase):
@tool("rules_retrieve_reference", "审查参考检索")
class RulesRetrieveReferenceTool(ToolBase):
def __init__(self) -> None:
self.default_ruleset_id = DEFAULT_RULESET_ID
self.column_map = {
......@@ -72,7 +72,7 @@ class RetrieveReferenceTool(ToolBase):
if __name__ == "__main__":
tool = RetrieveReferenceTool()
tool = RulesRetrieveReferenceTool()
result = tool.run(ruleset_id="金盘", routed_rule_titles=None)
for rule in result.get("rules", []):
print(f"Rule Title: {rule.get('title')}")
......
......@@ -13,20 +13,23 @@ class LLMTool(ToolBase):
"""LLM-backed processor: builds prompts, calls LLM, parses JSON."""
def __init__(
self, system_prompt: str, llm_key: str = "fastgpt_segment_review"
self, system_prompt: str = None, llm_key: str = "fastgpt_segment_review"
) -> None:
super().__init__()
self.system_prompt = system_prompt
self.llm = OpenAITool(LLM[llm_key], max_workers=MAX_WORKERS)
def build_messages(self, user_content: str, system_content: str = None) -> List[Dict[str, str]]:
if system_content or self.system_prompt:
return [
{"role": "system", "content": system_content or self.system_prompt},
{"role": "user", "content": user_content},
]
else:
return [{"role": "user", "content": user_content}]
async def chat_async(self, messages: List[Dict[str, str]]):
return await self.llm.chat(messages)
async def chat_async(self, messages: List[Dict[str, str]],**extra) -> str:
return await self.llm.chat(messages, **extra)
async def chat_batch_async(self, messages_list: List[List[Dict[str, str]]]):
return await self.llm.mul_chat(messages_list)
......@@ -47,3 +50,10 @@ class LLMTool(ToolBase):
return data[0] if data else {}
except Exception:
return {}
if __name__ == "__main__":
tool = LLMTool(llm_key="nanobot_llm")
results = asyncio.run(tool.chat_async(tool.build_messages("列出工作目录"),**{
"session_id": "test_session",
}))
print(results)
\ No newline at end of file
......@@ -309,7 +309,7 @@ class SegmentReviewTool(LLMTool):
party_role: str,
context_summaries: Optional[List[Dict]] = None,
context_memories: Optional[List[Dict]] = None,
merge_rules_prompt: bool = True,
merge_rules_prompt: bool = False,
) -> Dict:
rules = rules or []
result = self._evaluate_rules(
......@@ -372,7 +372,9 @@ 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(
user_content = (
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,
......@@ -494,24 +496,27 @@ class SegmentReviewTool(LLMTool):
if __name__ == "__main__":
tool = SegmentReviewTool()
segment_text = """
answer: 1.1“甲方(买方)”是指【冕宁县穗发新能源有限公司 】,包括其指定继承人(其指定继承人将全面继承需方在本合同的权利、义务和责任)。
answer: 14.11由于买方与卖方的合同分包商和外购设备供货商没有直接的合同关系,故本合同设备的卖方的分包和外购设备的付款由卖方负责。但如果发生由于个别原因(包括但不限于买方虽按时向卖方付款而卖方没有按时向其分包商或外购设备供货商付款等情形)导致卖方的分包和外购设备有可能无法按时交货以至于影响施工进度的情况,买方有权暂时中止向卖方付款。在卖方向其分包商或外购设备供货商支付相关款项后,买方将继续向卖方付款,同时买方还将追究卖方延误工期的责任。如果卖方仍未向其分包商或外购设备供货商付款,买方将出于保障工程进度的目的,有权直接向其分包商或外购设备供货商付款。但在此情况下,卖方必须协助买方同卖方的分包商或外购设备供货商另行签订转付款协议书,同时该协议书中此转付款连同买方发生的贷款利息将从下一笔买方向卖方的应付款中扣除。
answer: 14.12若买方认为卖方因财务或其他问题未能履行本合同内的义务,买方有权自行或另请其他方履行本合同余下的义务。卖方保证分包合同中将载有规定,在卖方无法继续经营或履行分包合同的情况下,卖方在各分包合同下的权利自动转让给买方或买方指定的其他方。
answer: 20.8.2 卖方资质出现失效、未通过年审(年检)或被主管部门注销的,买方有权单方面解除合同并将合同未完成事项转由有资质的单位承接,已完成的事项按实结算。如因上述情形导致买方损失的,卖方应予完全赔偿。 """
买方取消订单时,卖方有证据证明其已经安排生产的,如是定制产品,买方应当按取消订单产品价款的80%向卖方赔偿损失;如是常规产品,买方应当按照取消订单部分产品价款的50%向卖方赔偿损失。
"""
result = tool.run(
segment_id=1,
segment_text=segment_text,
rules=[
{
"title": "第三方审查",
"title": "变更取消责任审查",
"rule": """
1)货款支付不能涉及第三方(业主或委托付款方)
2)不能明确提及甲方将履行义务转移给第三方(业主或委托付款方)
3)买方转移债务到第三方(业主或委托付款方),由卖方直接向第三方(业主或委托付款方)行使债权,审查不合格
1)我司不能接受,买方单方面变更或取消合同,同时又未明确责任或者责任过轻不足以弥补损失
2)我司不能接受,甲方合同发生变更同时又未明确甲方违约金额
3)我司不能接受,甲方中途退货没有规定甲方违约责任或甲方违约金额低于80%(定制产品)/50%(常规产品)
""",
"case":"""
## 案例1:
原文:买方取消订单时,卖方有证据证明其已经安排生产的,如是定制产品,买方应当按取消订单产品价款的80%向卖方赔偿损失;如是常规产品,买方应当按照取消订单部分产品价款的50%向卖方赔偿损失。
结论:审查合格,定制产品为80%的额度,常规产品为50%的额度
"""
}
],
party_role="麓谷发展",
party_role="甲方",
)
print(json.dumps(result, ensure_ascii=False, indent=2))
......
......@@ -16,30 +16,40 @@ from utils.http_util import upload_file, fastgpt_openai_chat, download_file
use_lufa = False
batch_size = 5
if not use_lufa:
SUFFIX = "_麓发迁移"
batch_input_dir_path = "jp-input"
batch_output_dir_path = f"/home/ccran/lufa-contract/data/benchmark/results/jp-output-lufa-{time.strftime('%Y%m%d-%H%M%S', time.localtime())}"
def get_params():
output_suffix = f"{time.strftime('%Y%m%d-%H%M%S')}-{time.time_ns() % 1_000_000:06d}"
if not use_lufa:
return {
"suffix": "_麓发迁移",
"batch_size": batch_size,
"batch_input_dir_path": "/data/home/htsc/jp-contract/data/batch/jp-temp",
"batch_output_dir_path": f"/data/home/htsc/jp-contract/data/benchmark/results/jp-output-{output_suffix}",
# 金盘fastgpt接口
url = "http://192.168.252.71:18088/api/v1/chat/completions"
"url": "http://172.21.107.45:3002/api/v1/chat/completions",
# 金盘迁移麓发合同审查测试token
token = "fastgpt-vykT6qs07g7hR4tL2MNJE6DdNCIxaQjEu3Cxw9nuTBFg8MAG3CkByvnXKxSNEyMK7"
# "token": "fastgpt-vykT6qs07g7hR4tL2MNJE6DdNCIxaQjEu3Cxw9nuTBFg8MAG3CkByvnXKxSNEyMK7",
"token": "fastgpt-pYh0DgMVPDh9DptmznbSz7fRAqS41Z7gUWWIPM0112APpMlbb8mc1iztJi",
# 人机交互测试(测试环境)
# token = 'fastgpt-p189K5zoTX5wjp0dBybFCwsbWm3juIwlJxt2wTGyiaOWOANI5Y10pKEZzyt'
# "token": "fastgpt-p189K5zoTX5wjp0dBybFCwsbWm3juIwlJxt2wTGyiaOWOANI5Y10pKEZzyt",
# 人机交互测试(生产环境)
# token = "fastgpt-ry4jIjgNwmNgufMr5jR0ncvJVmSS4GZl4bx2ItsNPoncdQzW9Na3IP1Xrankr"
# "token": "fastgpt-ry4jIjgNwmNgufMr5jR0ncvJVmSS4GZl4bx2ItsNPoncdQzW9Na3IP1Xrankr",
# 提取后审查测试
# token = 'fastgpt-n74gGX5ZqLT6o1ysMBSGUTjIciswYOWDRfQ75krMkE5gDVDkpzsbz8u'
else:
SUFFIX = "_麓发"
batch_input_dir_path = "4.24测财务合同审核"
batch_output_dir_path = "4.24测财务合同审核-batch"
# "token": "fastgpt-n74gGX5ZqLT6o1ysMBSGUTjIciswYOWDRfQ75krMkE5gDVDkpzsbz8u",
}
return {
"suffix": "_麓发",
"batch_size": batch_size,
"batch_input_dir_path": "4.24测财务合同审核",
"batch_output_dir_path": f"4.24测财务合同审核-batch-{output_suffix}",
# 麓发fastgpt接口
url = "http://192.168.252.71:18089/api/v1/chat/completions"
"url": "http://192.168.252.71:18089/api/v1/chat/completions",
# 麓发合同审查生产token
# token = "fastgpt-ek3Z6PxI6sXgYc0jxzZ5bVGqrxwM6aVyfSmA6JVErJYBMr2KmYxrHwEUOIMSYz"
# "token": "fastgpt-ek3Z6PxI6sXgYc0jxzZ5bVGqrxwM6aVyfSmA6JVErJYBMr2KmYxrHwEUOIMSYz",
# 麓发合同审查生产token-标准化
token = "fastgpt-mg5tQUgreJeF7peoOr5zqP0NR4EIrfS2bEVXge6FUL94Suu1TvEMR1sGNRSiV"
"token": "fastgpt-mg5tQUgreJeF7peoOr5zqP0NR4EIrfS2bEVXge6FUL94Suu1TvEMR1sGNRSiV",
}
def extract_url(text):
......@@ -58,7 +68,7 @@ def extract_url(text):
def process_single_file(
file, batch_input_dir_path, batch_output_dir_path, counter, start_file
file, params, counter, start_file
):
"""
单文件处理逻辑,可被线程池并发调用
......@@ -67,14 +77,20 @@ def process_single_file(
if start_file > counter:
return
batch_input_dir_path = params["batch_input_dir_path"]
batch_output_dir_path = params["batch_output_dir_path"]
suffix = params["suffix"]
url = params["url"]
token = params["token"]
# 提取文件前缀
file_name = file[: file.rfind(".")]
ext_name = file[file.rfind(".") :]
# 源目标处理
original_file = f"{batch_input_dir_path}/{file}"
des_check_file = f"{batch_output_dir_path}/{file_name}.md"
des_excel_file = f"{batch_output_dir_path}/{file_name}{SUFFIX}.xlsx"
des_doc_file = f"{batch_output_dir_path}/{file_name}{SUFFIX}{ext_name}"
des_excel_file = f"{batch_output_dir_path}/{file_name}{suffix}.xlsx"
des_doc_file = f"{batch_output_dir_path}/{file_name}{suffix}{ext_name}"
try:
# 处理原文件
......@@ -83,7 +99,7 @@ def process_single_file(
)
model = "Qwen2-72B-Instruct"
# 合同审核Excel工作流处理
logger.info(" 第{}个文件,处理文件: {}".format(counter, original_file))
# logger.info(" 第{}个文件,处理文件: {}".format(counter, original_file))
result = fastgpt_openai_chat(
url,
......@@ -108,15 +124,19 @@ def process_single_file(
),
des_doc_file,
)
logger.info(
f"第{counter}个文件下载:{excel_url}到{des_excel_file} {des_doc_file}"
)
# logger.info(
# f"第{counter}个文件下载:{excel_url}到{des_excel_file} {des_doc_file}"
# )
except Exception as e:
logger.error(f"{original_file} 处理异常 第{counter}个文件: {e}")
logger.error(traceback.print_exc())
def execute_batch(max_workers: int = 4):
def execute_batch(params=None):
params = params or get_params()
max_workers = params["batch_size"]
batch_input_dir_path = params["batch_input_dir_path"]
batch_output_dir_path = params["batch_output_dir_path"]
start_file = 1
dirs = os.listdir(batch_input_dir_path)
os.makedirs(batch_output_dir_path, exist_ok=True)
......@@ -126,8 +146,7 @@ def execute_batch(max_workers: int = 4):
executor.submit(
process_single_file,
file,
batch_input_dir_path,
batch_output_dir_path,
params,
counter,
start_file,
)
......@@ -141,6 +160,7 @@ def execute_batch(max_workers: int = 4):
if __name__ == "__main__":
import os
execute_batch(batch_size)
params = get_params()
execute_batch(params)
print("all done!")
print("文件保存在: ", os.path.abspath(batch_output_dir_path))
print("文件保存在: ", os.path.abspath(params["batch_output_dir_path"]))
......@@ -2,13 +2,14 @@ from __future__ import annotations
import argparse
import re
from contextlib import redirect_stdout
from pathlib import Path
from typing import Iterable
import pandas as pd
from spire.doc import Document
from compare_annotation import compare_with_log
from compare_annotation import compare
# Map raw comment authors to unified review item names.
COMMENT_AUTHOR_MAPPING: dict[str, str] = {
......@@ -86,8 +87,17 @@ def extract_annotaion(
def compare_annotaion(val_dir: Path, answer_dir: Path) -> None:
"""Run benchmark comparison on extracted annotations."""
log_path = compare_with_log(val_dir=val_dir, answer_dir=answer_dir)
print(f"Compare log written to: {log_path}")
log_file = val_dir.with_suffix(".log")
log_file.parent.mkdir(parents=True, exist_ok=True)
with log_file.open("w", encoding="utf-8") as log_output:
with redirect_stdout(log_output):
output_excel = compare(
val_dir=val_dir,
answer_dir=answer_dir,
print_result=True,
)
print(f"Compare result written to: {output_excel}")
print(f"Compare log written to: {log_file}")
def _strip_suffix_once(stem: str, suffixes: Iterable[str]) -> str:
......@@ -121,7 +131,7 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument(
"--datasets-dir",
type=Path,
default=base / "results" / "jp-output-lufa-20260511-101828",
default=base / "results" / "jp-output-20260701-144647-280297",
help="Directory containing Word files with annotations.",
)
parser.add_argument(
......@@ -143,7 +153,7 @@ if __name__ == "__main__":
datasets_dir=args.datasets_dir,
answer_dir=base / "审查答案",
val_dir=args.datasets_dir.with_name(
f"{args.datasets_dir.name}-extract-comment"
f"{args.datasets_dir.name}-测评结果"
),
strip_suffixes=args.strip_suffixes,
)
File added
from utils.spire_word_util import SpireWordDoc
if __name__ == "__main__":
doc = SpireWordDoc()
doc.load('demo/限制版-模板-采购合同-2025-11.doc')
doc.add_chunk_comment(0,[
{
"id": 0, # 必填,规则/评论唯一标识,和 key_points 共同组成批注作者键
"key_points": "审核要点1", # 必填,审核要点,和 id 共同用于去重、更新、删除批注
"result": "不合格", # 必填,仅 result == "不合格" 的评论会被添加/更新批注
"suggest": "这是第一条批注", # 可选,批注正文;缺省为空字符串
"original_text": "③分期付款:(适用于货物直接发到买方指定现场的),发货前支付 %预付款 元;到货款自买方签署到货签收单后 个工作日内支付 %到货款 元; 验收款自买方(项目经理或工程人员)签署验收单后 个工作日内支付 %验收款 元; %作为质保金,合计 元,合同产品质保期届满后 个工作日支付。", # 可选,优先用于定位原文;为空时批注落在文档首个可用段落
"chunk_id": 0, # 可选,0 基 chunk 下标;有效时优先于入参 chunk_id
}
])
doc.to_file('demo/限制版-模板-采购合同-2025-11-添加批注.doc')
\ No newline at end of file
File added
......@@ -42,6 +42,7 @@ openpyxl==3.1.5
packaging==26.0
pandas==3.0.0
pathlib==1.0.1
Pillow==12.0.0
plum-dispatch==1.7.4
propcache==0.4.1
prov==2.1.1
......@@ -52,6 +53,7 @@ pydantic_core==2.41.5
pydot==4.0.1
PyMuPDF==1.26.7
pyparsing==3.3.2
pytesseract==0.3.13
python-dateutil==2.9.0.post0
pyxnat==1.6.4
RapidFuzz==3.14.3
......@@ -63,8 +65,6 @@ setuptools==80.9.0
simplejson==3.20.2
six==1.17.0
sniffio==1.3.1
spire-doc==14.1.0
spire-pdf==12.1.3
starlette==0.50.0
tenacity==9.1.2
thefuzz==0.22.1
......
---
name: doc-excel-skill
description: 文档/表格工具 Skill。用于将 Word/PDF 文档解析为 txt,并读取、修改 Excel,以及把 JSON 写入 Excel sheet。
---
# 文档与 Excel Skill
## 定位
`doc-excel-skill` 负责文件解析和表格读写。它把 Word/PDF 文件转换为 UTF-8 `.txt`,把 Excel sheet 读取为结构化 JSON,也可以把 JSON 数据写入 Excel sheet。
该 Skill 不直接调用 LLM,也不实现业务审查逻辑。它提供的是稳定的文件 I/O 能力,供 `review-llm-skill``contract-review-flow-skill` 组合使用。
## 适用场景
-`.docx``.doc``.wps``.pdf` 合同文件解析为 `.txt`
- 读取 `data/rules.xlsx` 中的规则表。
- 列出 Excel sheets,并按条件 dict 搜索 sheet 行数据。
- 按表头 key 追加 Excel 行,或按条件 dict 更新、删除 Excel 行。
- 按列查找 Excel 单元格,或将 Excel 行映射为指定字段。
- 将 JSON 转换为 Excel 的某个 sheet。
## 工具文件
- `scripts/doc_tool.py`:基于 Spire 的 Word/PDF 转 txt CLI。
- `scripts/excel_tool.py`:Excel 读取、sheet 查询、行增删改、JSON 写入 sheet。
## 依赖说明
- Word / PDF 文本解析依赖 Spire:PDF 使用 `PdfDocument``PdfTextExtractOptions``PdfTextExtractor`;Word 使用 `Document.GetText()`
- 如果安装了 `openpyxl`,Excel 读取和写入会优先使用它。
- 如果没有 `openpyxl`,部分 `.xlsx` 读取会退回标准库实现,但复杂写入能力会受限。
- 当前 `doc_tool.py` 只做可提取文本解析,不做 OCR、分块和批注写入。
## 主要命令
- `doc_tool.py <file> [output]`:将 Word/PDF 解析为 txt;未传 `output` 时默认输出到同名 `.txt`
- `doc_tool.py doc-to-txt <file> [output]`:兼容旧调用形式,行为同上。
- `load-excel`:读取 Excel sheet 为 JSON。
- `list-sheets`:列出工作簿中的 sheet。
- `search_rows`:读取某个 sheet,按首行表头作为 key、每一行作为 dict;传入条件 dict 时返回所有匹配行,传 `{}` 时返回全部行。
- `append-row`:传入一个 dict,按 key 匹配表头列,并追加到指定 sheet 末尾。
- `update-rows`:传入条件 dict 和更新 dict,更新所有匹配行。
- `delete-rows`:传入条件 dict,删除所有匹配行。
- `find-value`:按某列匹配值,再返回另一列的值。
- `map-rows`:按字段映射读取 Excel 行。
- `json-to-sheet`:把 JSON 写入 Excel 的指定 sheet;dict key 会直接写入第一行作为表头。
## 输入输出
- 文档解析命令输入本地 Word/PDF 文件路径,输出 UTF-8 `.txt` 文件路径。
- Excel 读取类命令输入 `.xlsx` 路径和 sheet/列参数,输出 JSON。
- Excel 写入类命令输入 `.xlsx` 路径、sheet 名和 JSON dict,直接保存原文件。
- 行搜索、更新和删除的条件 dict 支持多个字段,所有字段都相等时才算匹配。
- `json-to-sheet` 输入 JSON 和输出 `.xlsx` 路径;如果目标 sheet 已存在,会替换该 sheet;第一行固定写表头,第二行开始写数据。
- 支持使用 `@file.json` 形式读取较大的 JSON 参数。
## 使用示例
查看帮助:
```bash
python skills/doc-excel-skill/scripts/doc_tool.py --help
python skills/doc-excel-skill/scripts/excel_tool.py --help
```
解析 Word/PDF 合同为同名 txt:
```bash
python skills/doc-excel-skill/scripts/doc_tool.py demo/example.docx
python skills/doc-excel-skill/scripts/doc_tool.py demo/example.pdf
```
解析 Word/PDF 合同到指定 txt:
```bash
python skills/doc-excel-skill/scripts/doc_tool.py demo/example.docx outputs/example.txt
python skills/doc-excel-skill/scripts/doc_tool.py doc-to-txt demo/example.pdf outputs/example.txt
```
读取规则 Excel:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py load-excel data/rules.xlsx \
--sheet-name 通用
```
列出所有 sheet:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py list-sheets data/rules.xlsx
```
按条件搜索指定 sheet 的行,传 `{}` 返回全部行:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py search_rows data/rules.xlsx \
'{"审查项":"当事人审查"}' \
--sheet-name 通用
```
追加一行:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py append-row data/rules.xlsx \
'{"审查项":"测试","风险等级":"中"}' \
--sheet-name 通用
```
更新一行:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py update-rows data/rules.xlsx \
'{"审查项":"测试","风险等级":"中"}' \
'{"风险等级":"高"}' \
--sheet-name 通用
```
删除一行:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py delete-rows data/rules.xlsx \
'{"审查项":"测试","风险等级":"高"}' \
--sheet-name 通用
```
将 JSON 写入 Excel sheet:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py json-to-sheet \
'[{"name":"张三","amount":100},{"name":"李四","amount":200}]' \
outputs/result.xlsx \
--sheet-name 明细
```
从 JSON 文件写入 Excel sheet:
```bash
python skills/doc-excel-skill/scripts/excel_tool.py json-to-sheet \
@data.json \
outputs/result.xlsx \
--sheet-name 数据
```
## 在合同审查流程中的位置
该 Skill 主要对应文档解析和 Excel 数据读写部分。它可以在处理前把 Word/PDF 转换为 txt,也可以把 Excel sheet 和 JSON 数据在两种结构之间转换。
#!/usr/bin/env python3
"""Convert Word/PDF documents to UTF-8 txt files with Spire."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
WORD_SUFFIXES = {".doc", ".docx", ".wps"}
PDF_SUFFIXES = {".pdf"}
def _extract_pdf_text(path: str) -> str:
from spire.pdf import PdfDocument, PdfTextExtractOptions, PdfTextExtractor
pdf = PdfDocument()
try:
pdf.LoadFromFile(path)
extract_options = PdfTextExtractOptions()
extract_options.IsExtractAllText = True
pages: list[str] = []
for page_idx in range(0, pdf.Pages.Count):
page = pdf.Pages[page_idx]
pages.append(PdfTextExtractor(page).ExtractText(extract_options))
return "\n".join(pages)
finally:
try:
pdf.Close()
except Exception:
pass
def _extract_word_text(path: str) -> str:
from spire.doc import Document
doc = Document()
try:
doc.LoadFromFile(path)
return doc.GetText()
finally:
try:
doc.Close()
except Exception:
pass
def extract_text(path: str) -> str:
suffix = Path(path).suffix.lower()
if suffix in PDF_SUFFIXES:
return _extract_pdf_text(path)
if suffix in WORD_SUFFIXES:
return _extract_word_text(path)
raise ValueError(f"unsupported file type: {suffix}")
def doc_to_txt(path: str, output: str | None = None) -> str:
text = extract_text(path)
output_path = Path(output) if output else Path(path).with_suffix(".txt")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(text, encoding="utf-8")
return str(output_path)
def main() -> int:
parser = argparse.ArgumentParser(description="Convert Word/PDF documents to txt")
parser.add_argument("file")
parser.add_argument("output", nargs="?")
argv = sys.argv[1:]
if argv and argv[0] == "doc-to-txt":
argv = argv[1:]
args = parser.parse_args(argv)
print(doc_to_txt(args.file, args.output))
return 0
if __name__ == "__main__":
raise SystemExit(main())
---
name: http-skill
description: HTTP 文件处理 Skill。用于下载远程文件和上传本地文件。上传下载优先使用此技能。
---
# HTTP 文件处理 Skill
## 定位
`http-skill` 负责合同审查流程中的网络文件搬运。它可以把接口传入的远程合同 URL 下载到本地,也可以把审查结果文件上传到后端服务。
该 Skill 使用 Python 标准库实现,不依赖 `requests``loguru``requests_toolbelt`,也不依赖仓库中的 `utils/``core/` 模块。
## 适用场景
- 从接口 URL 下载合同、PDF、Excel 或中间文件。
- 将本地生成的 Excel、docx 批注文件上传到后端文件服务。
- 在离线 CLI 流程中模拟 `main.py` 的文件下载和导出上传环节。
## 工具文件
- `scripts/http_util.py`:HTTP 文件处理 CLI。
## 主要命令
- `download`:下载 URL 到本地文件或目录。
- `upload`:上传本地文件到后端文件服务。
## 通用参数
- `--base-fastgpt-url`:FastGPT 内网基础地址,默认 `http://192.168.252.71:3030`
- `--base-backend-url`:后端内网基础地址,默认 `http://192.168.252.71:1122`
- `--outer-backend-url`:后端外网地址,默认 `https://218.77.58.8:48080`
- `--username`:后端管理员用户名,仅 `upload` 使用,默认 `admin`
- `--password`:后端管理员密码,仅 `upload` 使用,默认 `admin@jpai.com`
## 输入输出
- `download` 输入 URL 和可选目标路径;未传目标路径时默认下载到 `scripts/http_util.py` 同级目录的 `download/` 文件夹。
- `download` 的目标路径是目录时,会自动推断文件名。
- `upload` 输入本地文件路径和后端账号配置;输出后端接口响应。
- `upload` 输入相对路径时,会优先从 `scripts/http_util.py` 同级目录的 `download/` 文件夹查找,找不到再按当前工作目录查找。
## 使用示例
查看帮助:
```bash
python skills/http-skill/scripts/http_util.py --help
```
查看子命令帮助:
```bash
python skills/http-skill/scripts/http_util.py upload --help
python skills/http-skill/scripts/http_util.py download --help
```
上传本地文件:
```bash
python skills/http-skill/scripts/http_util.py upload demo/example.pdf
```
上传本地文件,并覆盖后端地址和账号密码:
```bash
python skills/http-skill/scripts/http_util.py upload \
--base-backend-url http://192.168.252.71:48081 \
--username admin \
--password 'admin@jpai.com' \
demo/example.pdf
```
下载相对路径到默认 `download/` 目录:
```bash
python skills/http-skill/scripts/http_util.py download /api/file/example.pdf
```
下载相对路径到指定目录:
```bash
python skills/http-skill/scripts/http_util.py download \
/api/file/example.pdf \
downloads
```
下载完整 URL,并替换外网后端地址:
```bash
python skills/http-skill/scripts/http_util.py download \
--outer-backend-url https://172.21.107.45:48080 \
--base-backend-url http://172.21.107.45:1122 \
https://172.21.107.45:48080/admin-api/infra/file/get/123 \
downloads/example.pdf
```
## 在合同审查流程中的位置
该 Skill 通常位于流程入口和出口:入口负责把远程合同下载成本地文件,出口负责把审查结果上传并生成可返回给调用方的文件地址。它不解析文档、不调用 LLM,也不保存审查记忆。
#!/usr/bin/env python3
"""Standalone HTTP upload/download CLI."""
from __future__ import annotations
import argparse, json, mimetypes, random, re, string, sys, time, urllib.error, urllib.request
from pathlib import Path
from urllib.parse import unquote, urlparse
# DEFAULT_OUTER_BACKEND_URL = "https://172.21.107.45:48080"
# DEFAULT_BASE_FASTGPT_URL = "http://172.21.107.45:3030"
# DEFAULT_BASE_BACKEND_URL = "http://172.21.107.45:1122"
DEFAULT_OUTER_BACKEND_URL = "https://218.77.58.8:48080"
DEFAULT_BASE_FASTGPT_URL = "http://192.168.252.71:3030"
DEFAULT_BASE_BACKEND_URL = "http://192.168.252.71:1122"
DEFAULT_BACKEND_ADMIN_USERNAME = "admin"
DEFAULT_BACKEND_ADMIN_PASSWORD = "admin@jpai.com"
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DOWNLOAD_DIR = SCRIPT_DIR / "download"
base_fastgpt_url, base_backend_url, outer_backend_url = DEFAULT_BASE_FASTGPT_URL, DEFAULT_BASE_BACKEND_URL, DEFAULT_OUTER_BACKEND_URL
backend_admin_username, backend_admin_password = DEFAULT_BACKEND_ADMIN_USERNAME, DEFAULT_BACKEND_ADMIN_PASSWORD
def _configure_urls(fastgpt_url: str | None = None, backend_url: str | None = None, outer_url: str | None = None) -> None:
global base_fastgpt_url, base_backend_url, outer_backend_url
base_fastgpt_url = fastgpt_url or base_fastgpt_url
base_backend_url = backend_url or base_backend_url
outer_backend_url = outer_url or outer_backend_url
def _configure_login(username: str | None = None, password: str | None = None) -> None:
global backend_admin_username, backend_admin_password
backend_admin_username = username or backend_admin_username
backend_admin_password = password or backend_admin_password
def _strip(url: str | None) -> str | None:
return url.rstrip("/") if url else url
def _random_str(n: int = 8) -> str:
return "".join(random.choice(string.ascii_lowercase) for _ in range(n))
def _post_json(url: str, data: dict, timeout: int = 120) -> str:
req = urllib.request.Request(url, data=json.dumps(data, ensure_ascii=False).encode(), headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read().decode("utf-8", errors="replace")
def _multipart_body(path: str, field: str = "file") -> tuple[bytes, str]:
p = Path(path); boundary = f"----http-skill-{int(time.time() * 1000)}-{_random_str()}"
ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
body = bytearray()
body.extend(f"--{boundary}\r\n".encode())
body.extend(f'Content-Disposition: form-data; name="{field}"; filename="{p.name}"\r\nContent-Type: {ctype}\r\n\r\n'.encode())
body.extend(p.read_bytes()); body.extend(f"\r\n--{boundary}--\r\n".encode())
return bytes(body), boundary
def _resolve_upload_path(path: str | Path) -> Path:
p = Path(path).expanduser()
if p.is_absolute():
return p
download_path = DEFAULT_DOWNLOAD_DIR / p
return download_path if download_path.exists() else p
def upload_file(path) -> str:
path = _resolve_upload_path(path)
login = _post_json(f"{base_backend_url}/admin-api/system/auth/login", {"username": backend_admin_username, "password": backend_admin_password})
token = (json.loads(login).get("data") or {}).get("accessToken")
if not token:
raise RuntimeError(f"后端登录异常:{login}")
body, boundary = _multipart_body(path)
req = urllib.request.Request(f"{base_backend_url}/admin-api/infra/file/upload", data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}", "Authorization": token}, method="POST")
with urllib.request.urlopen(req, timeout=120) as resp:
text = resp.read().decode("utf-8", errors="replace")
res = json.loads(text).get("data")
if not res:
raise RuntimeError(f"上传{path}失败 Response text: {text}")
return res
def _basename(name: str) -> str:
return Path(unquote(name.strip().strip('"')).replace("\\", "/")).name or "downloaded_file"
def _resolve_name(url: str, headers) -> str:
cd = headers.get("content-disposition", "") or headers.get("Content-Disposition", "")
for pat in [r"filename\*=(?:UTF-8''|utf-8'')?([^;]+)", r'filename="?([^";]+)"?']:
m = re.search(pat, cd)
if m:
return _basename(m.group(1))
return _basename(urlparse(url).path)
def download_file(url, path=None):
if not url.startswith(("http:", "https:")):
url = base_fastgpt_url + url
url = url.replace(outer_backend_url, base_backend_url)
try:
with urllib.request.urlopen(urllib.request.Request(url, method="GET"), timeout=120) as resp:
target = Path(path).expanduser() if path else DEFAULT_DOWNLOAD_DIR / _resolve_name(url, resp.headers)
if target.exists() and target.is_dir():
target = target / _resolve_name(url, resp.headers)
target.parent.mkdir(parents=True, exist_ok=True); target.write_bytes(resp.read())
return str(target)
except urllib.error.HTTPError as exc:
print(f"{url}文件下载失败. HTTP Status Code: {exc.code}", file=sys.stderr)
return None
def _add_url_args(p: argparse.ArgumentParser) -> None:
p.add_argument("--base-fastgpt-url", default=DEFAULT_BASE_FASTGPT_URL)
p.add_argument("--base-backend-url", default=DEFAULT_BASE_BACKEND_URL)
p.add_argument("--outer-backend-url", default=DEFAULT_OUTER_BACKEND_URL)
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="上传或下载文件。")
sub = p.add_subparsers(dest="command", required=True)
u = sub.add_parser("upload"); _add_url_args(u); u.add_argument("--username", default=DEFAULT_BACKEND_ADMIN_USERNAME); u.add_argument("--password", default=DEFAULT_BACKEND_ADMIN_PASSWORD); u.add_argument("path")
d = sub.add_parser("download"); _add_url_args(d); d.add_argument("url"); d.add_argument("path", nargs="?")
return p
def main(argv: list[str] | None = None) -> int:
p = _build_arg_parser(); a = p.parse_args(argv)
_configure_urls(_strip(a.base_fastgpt_url), _strip(a.base_backend_url), _strip(a.outer_backend_url))
if a.command == "upload":
_configure_login(a.username, a.password); print(upload_file(a.path)); return 0
if a.command == "download":
saved = download_file(a.url, a.path)
if saved is None:
return 1
print(saved); return 0
p.error(f"unsupported command: {a.command}"); return 2
if __name__ == "__main__":
sys.exit(main())
---
name: ocr-skill
description: OCR Skill。提供基于本地 Tesseract 的图片和 PDF 文本识别 CLI,以及可在 Python 中复用的 `TesseractOCRUtil` 类。
---
# OCR Skill
## 定位
`ocr-skill` 负责处理扫描件、图片和图片型 PDF 的文字识别。它适合在普通文本解析失败、PDF 文本乱码、合同是扫描版或截图版时使用。
该 Skill 只提供 OCR 能力,不负责合同审查、规则匹配、facts 提取或结果导出。识别出的文本可以继续交给 `doc-excel-skill``review-llm-skill` 或上层流程使用。
## 适用场景
- 识别合同截图或图片中的文字。
- 识别扫描版 PDF 每一页的文字。
- 在 PDF 直接解析结果为空或乱码时作为兜底方案。
- 在 Python 代码中直接调用 `TesseractOCRUtil` 做本地 OCR。
## 工具文件
- `scripts/ocr_tool.py`:OCR CLI 和 `TesseractOCRUtil` 类。
## 运行要求
- 本机需要安装 `tesseract` 可执行文件,并确保它在 `PATH` 中。
- 中文识别需要安装对应语言包,例如 `chi_sim`
- PDF 转图片依赖 `PyMuPDF`,包名为 `PyMuPDF`,导入名为 `fitz`
- OCR 质量受扫描清晰度、页眉页脚、表格线、印章和图片压缩影响。
## 主要命令
- `image`:识别单张图片,输出纯文本。
- `pdf`:把 PDF 每页转为图片后 OCR,输出每页识别结果 JSON。
## 输入输出
- 图片 OCR 输入图片路径,输出识别文本。
- PDF OCR 输入 PDF 路径,输出包含页码和文本的 JSON。
- 默认语言和 tesseract 可执行路径可通过命令参数覆盖,具体参数以 `--help` 为准。
## 使用示例
查看帮助:
```bash
python skills/ocr-skill/scripts/ocr_tool.py --help
```
识别图片:
```bash
python skills/ocr-skill/scripts/ocr_tool.py image demo/ocr.png
```
识别 PDF:
```bash
python skills/ocr-skill/scripts/ocr_tool.py pdf skills/ocr-skill/example/example.pdf
```
Python 中直接使用:
```python
from pathlib import Path
import sys
sys.path.append(str(Path("skills/ocr-skill/scripts").resolve()))
from ocr_tool import TesseractOCRUtil
util = TesseractOCRUtil(lang="chi_sim+eng", executable="tesseract")
text = util.ocr_image("/path/to/image.png")
print(text)
texts = util.ocr_result_pdf("/path/to/document.pdf")
print(texts)
```
## 在合同审查流程中的位置
该 Skill 通常作为文档解析阶段的兜底能力。当 `doc-excel-skill` 无法直接读取有效文本时,可以先用 OCR 得到页面文字,再进入分段、摘要、审查和导出流程。
#!/usr/bin/env python3
"""Minimal OCR module exposing only `TesseractOCRUtil`.
This file was trimmed to keep just the Tesseract utility requested by the
user. It intentionally omits CLI, PaddleOCR, remote OCR helpers, and other
utilities.
"""
from __future__ import annotations
import asyncio
import argparse
import json
import os
import re
import subprocess
from typing import List
class TesseractOCRUtil:
"""Minimal, self-contained Tesseract OCR utility.
Methods:
- `ocr_image(file_path) -> str`: run tesseract on an image and return text.
- `ocr_image_async(path_list) -> List[str]`: async wrapper over `ocr_image`.
- `pdf_2_img(pdf_path) -> List[str]`: convert PDF to PNG pages (requires PyMuPDF).
- `ocr_result_pdf(pdf_path) -> List[str]`: OCR all pages from a PDF and clean up.
"""
def __init__(self, lang: str = "chi_sim+eng", executable: str = "tesseract"):
self.lang = lang
self.executable = executable
def ocr_image(self, file_path: str) -> str:
result = subprocess.run(
[self.executable, file_path, "stdout", "-l", self.lang],
check=True,
capture_output=True,
text=True,
)
return result.stdout
async def ocr_image_async(self, path_list: List[str]) -> List[str]:
tasks = [asyncio.to_thread(self.ocr_image, file_path) for file_path in path_list]
responses = await asyncio.gather(*tasks)
return list(responses)
def set_pdf_2_img_page(self, path: str, page_idx: int) -> str:
return f"{path}_{page_idx + 1}.png"
@staticmethod
def _page_num_from_png_path(path: str) -> int:
match = re.search(r"_(\d+)\.png$", path)
if not match:
raise ValueError(f"Invalid pdf page image path: {path}")
return int(match.group(1))
def get_pdf_2_img_page_num(self, path: str) -> str:
return str(self._page_num_from_png_path(path))
def pdf_2_img(self, path: str, zoom_x: float = 2, zoom_y: float = 2) -> List[str]:
try:
import fitz # type: ignore
except ImportError as exc:
raise RuntimeError("pdf_to_img needs PyMuPDF installed.") from exc
pdf = fitz.open(path)
pdf_list: List[str] = []
try:
for page_index in range(pdf.page_count):
page = pdf[page_index]
matrix = fitz.Matrix(zoom_x, zoom_y)
pixmap = page.get_pixmap(matrix=matrix, alpha=False)
dest_png = f"{path}_{page_index + 1}.png"
pixmap.save(dest_png)
pdf_list.append(dest_png)
finally:
pdf.close()
return pdf_list
async def ocr_result_pdf(self, dest_path: str, zoom_x: float = 2, zoom_y: float = 2) -> List[str]:
pdf_list = self.pdf_2_img(dest_path, zoom_x, zoom_y)
try:
return await self.ocr_image_async(pdf_list)
finally:
for pdf in pdf_list:
if os.path.exists(pdf):
os.remove(pdf)
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Standalone Tesseract OCR CLI")
parser.add_argument("--lang", default="chi_sim+eng", help="Tesseract language, default: chi_sim+eng")
parser.add_argument("--executable", default="tesseract", help="Tesseract executable path")
sub = parser.add_subparsers(dest="cmd", required=True)
image = sub.add_parser("image", help="OCR a single image and print text")
image.add_argument("file")
pdf = sub.add_parser("pdf", help="Convert a PDF to images, OCR each page, and print JSON")
pdf.add_argument("file")
pdf.add_argument("--zoom-x", type=float, default=2)
pdf.add_argument("--zoom-y", type=float, default=2)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(argv)
util = TesseractOCRUtil(lang=args.lang, executable=args.executable)
if args.cmd == "image":
print(util.ocr_image(args.file))
return 0
if args.cmd == "pdf":
texts = asyncio.run(util.ocr_result_pdf(args.file, zoom_x=args.zoom_x, zoom_y=args.zoom_y))
print(json.dumps(texts, ensure_ascii=False, indent=2))
return 0
parser.error(f"unsupported command: {args.cmd}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
---
name: review-flow-skill
description: 合同审查编排说明 Skill。仅提供合同审查的流程、规则覆盖、状态落盘和结果产出要求;LLM 需要根据这些编排规则自行选择并调用合适的 Skill 完成审查。
---
# Review Flow Skill
## 定位
`review-flow-skill` 是合同审查的编排说明层。它本身不执行下载、解析、Excel 读写、LLM 审查、上传,也不要求调用固定入口脚本。
使用本 Skill 时,LLM 必须阅读并遵循下方编排规则,自行根据当前任务、输入类型和可用能力选择合适的 Skill 或工具完成审查。常见协作 Skill 包括:
- `http-skill`:URL 输入时下载源文件;审查完成后上传结果文件。
- `doc-excel-skill`:把 Word/PDF/Excel/CSV 解析成 UTF-8 txt;读取 `assets/审查规则.xlsx`;把 JSON 结果写入 Excel。
- `review-llm-skill`:执行规则路由、具体审查、反思复核、结果合并。
如果某个协作 Skill、脚本或工具不可用,LLM 应在不破坏编排约束的前提下选择等价能力完成相同步骤,并在结果中说明替代方式。
## 标准流程
1. 输入识别
- 如果输入是 `http://``https://` URL,应先使用可用的 HTTP 下载能力下载到本地工作目录。
- 如果输入是本地路径,直接进入解析步骤。
2. 文档解析
- 优先把待审文件解析为 `.txt`,减少后续上下文占用。
- `.txt` 直接复用。
- `.doc``.docx``.wps``.pdf` 应使用可用的文档解析能力转 txt。
- `.xlsx``.xls``.csv``.tsv` 应使用可用的表格解析能力读取各 sheet 后序列化为 txt。
3. 规则加载
- 默认从 `skills/review-flow-skill/assets/审查规则.xlsx` 读取审查规则。
- 未指定 sheet 时读取所有 sheet。
- 必须覆盖到每一条非空规则。流程不得因为路由未命中而跳过“规则覆盖记录”。
4. 状态记录
- 每次运行在工作目录生成 `state.json`
- 状态至少记录:源文件、txt 文件、规则文件、规则总数、当前规则序号、已完成规则、输出文件路径。
- 每完成一条规则立即写回状态,用于断点续审。
5. 逐条规则审查
- 按规则顺序逐条推进。
- 对每条规则,先对 txt 分块执行路由判断。
- 只有路由命中的分块才执行具体审查。
- 即使所有分块均未命中,该规则也必须标记为已覆盖,并记录路由未命中。
6. 单规则反思
- 每条规则的分块审查完成后,将该规则的原始 findings 放入反思上下文,执行复核。
- 反思结果写入中间结果文件。
7. 全局合并
- 所有规则审查完毕后,对反思后的 findings 按 `rule_name``result``original_text` 聚合。
- 同组多条 findings 需要融合 `issue``suggestion`
- 输出最终 JSON 和 Excel 汇总文件。
8. 结果上传
- 如调用方需要上传结果,应使用可用的上传能力上传最终 Excel 文件。
- 如调用方只需要本地文件,可仅保留本地输出。
## 编排资源
- `assets/审查规则.xlsx`:默认审查规则库。
## 输出文件
每次审查应创建独立运行目录,例如 `skills/review-flow-skill/outputs/{输入文件名}/`,包含:
- `input.txt`:解析后的合同文本。
- `state.json`:断点续审状态。
- `rule_progress.json`:每条规则的覆盖、路由、审查统计。
- `findings_raw.json`:分块审查原始结果。
- `findings_reflected.json`:按规则反思后的结果。
- `final_findings.json`:全局合并后的最终 JSON 结果。
- `review_result.xlsx`:最终 Excel 汇总文件。
## 约束
- 不在该 Skill 中重写下载、上传、文档解析、Excel 操作或 LLM 提示词能力。
- 不把该 Skill 当作可直接执行的审查程序;它只定义编排规则。
- LLM 必须自行根据编排规则选择、组合并调用可用 Skill 或工具完成审查。
- 审查过程必须逐条规则推进并持久化状态。
- 规则覆盖记录和最终结果必须落盘,避免只保存在上下文中。
- 默认使用 txt 分块输入 LLM,避免一次性塞入完整合同造成上下文过大。
---
name: review-llm-skill
description: LLM 动作执行模块。按动作选择系统提示词,并把传入的 rule dict 作为用户提示词发送给模型。
---
# Review LLM Skill
## 定位
`review-llm-skill` 是一个可单独执行的 LLM 动作模块。
它只做两件事:
- 根据 `action` 选择对应的系统提示词。
- 将调用方传入的 `rule` dict 和待处理文本拼成 user prompt。
本模块不负责读取规则、不选择规则、不编排流程、不保存状态。
## 支持动作
- `summary` / `segment_summary` / `摘要` / `总结`
- `router` / `segment_rule_router` / `路由`
- `review` / `审查`
- `reflect` / `反思` / `复核`
- `merge` / `merger` / `segment_merger` / `合并`
## 工具文件
- `scripts/segment_llm_action.py`:主入口,负责动作调度和 LLM 调用。
- `scripts/prompts.py`:系统提示词。
- `scripts/llm_tool.py`:OpenAI 兼容 LLM 调用与 JSON 解析。
- `scripts/config.py`:LLM 配置。
## 输入
- `action`:要执行的动作。
- `--rule`:任意字段的 JSON dict,支持 `@file.json`
- `--text`:直接输入待处理文本。
- `--input-file` + `--chunk-size` + `--chunk-index`:从文本文件中按字符数切片读取待处理文本。
- `--output`:输出目标;默认 `-` 表示直接打印,传入文件路径则追加到 JSON 数组文件。
## Python 接口
```python
from segment_llm_action import run_segment_llm_action
res = run_segment_llm_action(
action="review",
rule={
"title": "付款审查",
"rule": "检查付款期限是否明确",
"context": {"party_role": "甲方"},
},
text="甲方应于合同签订之日起30日内付款。",
)
print(res)
```
## CLI 示例
```bash
python skills/review-llm-skill/scripts/segment_llm_action.py review \
--rule '{"title":"付款审查","rule":"检查付款期限是否明确","context":{"party_role":"甲方"}}' \
--text '甲方应于合同签订之日起30日内付款。'
```
从文件读取指定分段:
```bash
python skills/review-llm-skill/scripts/segment_llm_action.py review \
--rule '{"title":"付款审查","rule":"检查付款期限是否明确"}' \
--input-file skills/review-llm-skill/example/downloaded_file.txt \
--chunk-size 2000 \
--chunk-index 0
```
追加输出到 JSON 文件:
```bash
python skills/review-llm-skill/scripts/segment_llm_action.py review \
--rule '{"title":"付款审查","rule":"检查付款期限是否明确"}' \
--input-file skills/review-llm-skill/example/downloaded_file.txt \
--chunk-size 2000 \
--chunk-index 0 \
--output outputs/review-results.json
```
只打印 messages,不调用模型:
```bash
python skills/review-llm-skill/scripts/segment_llm_action.py review \
--rule '{"title":"付款审查","rule":"检查付款期限是否明确"}' \
--text '甲方应于合同签订之日起30日内付款。' \
--print-messages
```
购销合同
供方:海南金盘智能科技股份有限公司 签订地点: 太原市
需方:山西长缘电力工程有限公司 签订时间: 2026年06月10日
一、货物(服务)名称、商标、型号、厂家、数量、金额 价格单位:(元)
货物名称
规格型号
生产厂家
单位
数量
单价(元)
总金额(元)
变压器
ZLSCLB-1000/10(6)
海南金盘
1
103100
103100
合计人民币金额: 大写 壹拾万零叁仟壹佰元整 小写:¥103100元
含:国标变压器本体、温控、IP20钢板外壳(标准色为RAL7035)、包装运输及13%增值税票等。
图号:DK1457.01.12GZ
技术参数:连接组别:Dyn11; 阻抗:6%;分接范围:±2×5% (变压器外壳与太重挖掘机全焊接抗震性相同)
二、交(提)货时间、地点:合同签订且方案或技术协议签订后 45 日内发到指定地点。
三、质量要求、技术标准:按国家及行业规范,产品交付之日起十八个月,或产品运行之日起十二个月,两者以先到时间为准。在保修(质保)期内如出现产品质量问题由卖方负责免费“三包”;操作、使用或保养不当等造成损坏的或不属产品质量问题的不在“三包”服务之列。
四、运输方式及到达站港和费用负担:由供方负担。
五、合理损耗及计算方法: 无损耗。
六、包装标准、包装物的供应与回收和费用负担:按国家及行业规范包装,包装物不回收。
七、验收标准、方法:按国家及行业规定。
八、异议期限及处理方法:需方收货后 3个月内或在货物安装使用后 6个月内发现货物存在质量问题,提出书面异议,双方协商解决。
九、随机备品、配件工具数量及供应方法:无备品配件,随货带装置说明书。
十、结算方式及期限:1、电汇或一线银行开具的6个月以内银行承兑汇票 2、合同签订后,发货前付清全款,供方开具税率为13%的增值税专用发票。
十一、违约责任:按中国法律。本合同双方签字盖章的扫描件具备与纸质版同等的法律效力。
十二、解决合同纠纷的方式:由双方友好协商;若协商不成则由卖方所在地法院管辖。
需 方
供 方
买受人(章)
山西长缘电力工程有限公司
出卖人(章)
海南金盘智能科技股份有限公司
地址:
山西省太原市小店区平阳路14号26幢20层2001、2002、2003号(太原首信商务秘书有限公司-1144号)集群登记
地址:
海南省海口市南海大道168-39号
法定代表人:
马林俊
法定代表人:
李辉
委托代理人:(签章)
委托代理人:(签章)
电话:
电话:
0898-66811301
开户银行:
中国农业银行太原平阳南路支行
开户银行:
交通银行海口南海支行
帐号:
04138201040004607
帐号:
461602303018010043627
税务登记号:
91140105MAENF9FL7F
税务登记号:
9146010062006446XN
邮政编码:
邮政编码:
"""Compact prompt templates kept for compatibility."""
import os
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "Qwen3.5-122B-A10B-AWQ")
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL","http://192.168.252.71:9002/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY","none")
USE_FASTGPT_SYSTEM_VARIABLE = False
DISABLE_LLM_THINKING = True
\ No newline at end of file
import re
import json
from typing import Any, List, Dict
from openai import OpenAI
from tenacity import retry, stop_after_attempt, stop_after_delay, wait_fixed
try:
from .config import (
DISABLE_LLM_THINKING,
OPENAI_API_KEY,
OPENAI_BASE_URL,
OPENAI_MODEL,
USE_FASTGPT_SYSTEM_VARIABLE,
)
except ImportError:
from config import (
DISABLE_LLM_THINKING,
OPENAI_API_KEY,
OPENAI_BASE_URL,
OPENAI_MODEL,
USE_FASTGPT_SYSTEM_VARIABLE,
)
class LLMTool:
def __init__(self, system_prompt: str = ""):
self.system_prompt = system_prompt or ""
self.model = OPENAI_MODEL
self.base_url = OPENAI_BASE_URL
self.api_key = OPENAI_API_KEY
self.client = OpenAI(base_url=self.base_url, api_key=self.api_key) if self.api_key else None
self.use_fastgpt_system_variable = USE_FASTGPT_SYSTEM_VARIABLE
self.disable_thinking = DISABLE_LLM_THINKING
def build_messages(self, user_content: str, system_content: str | None = None) -> List[Dict[str, str]]:
msgs = []
if system_content:
msgs.append({"role": "system", "content": system_content})
msgs.append({"role": "user", "content": user_content})
return msgs
def _prepare_request(
self, messages: List[Dict[str, str]]
) -> tuple[List[Dict[str, str]], Dict[str, Any]]:
request_messages = list(messages)
extra_body: Dict[str, Any] = {}
if self.use_fastgpt_system_variable and request_messages and request_messages[0].get("role") == "system":
extra_body["variables"] = {"system": request_messages[0].get("content", "")}
request_messages = request_messages[1:]
if self.disable_thinking:
extra_body["thinking"] = {"type": "disabled"}
extra_body["chat_template_kwargs"] = {"enable_thinking": False}
return request_messages, extra_body
@retry(stop=stop_after_delay(600) | stop_after_attempt(3), wait=wait_fixed(1))
def run(self, messages: List[Dict[str, str]]) -> str:
if not self.client:
raise RuntimeError("OPENAI_API_KEY is required")
request_messages, extra_body = self._prepare_request(messages)
kwargs: Dict[str, Any] = {
"model": self.model,
"messages": request_messages,
}
if extra_body:
kwargs["extra_body"] = extra_body
response = self.client.chat.completions.create(**kwargs)
return response.choices[0].message.content or ""
def chat_async(self, messages: List[Dict[str, str]]) -> str:
return self.run(messages)
def run_with_loop(self, chat_response: str) -> str:
return chat_response
def parse_first_json(self, text: str) -> Any:
if not text:
return None
try:
return json.loads(text)
except Exception:
pass
m = re.search(r"(\{.*\}|\[.*\])", text, re.S)
if not m:
return None
blob = m.group(1)
try:
return json.loads(blob)
except Exception:
return None
#!/usr/bin/env python3
"""Run an LLM action with a rule payload."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
try:
from .prompts import (
MERGE_SYSTEM_PROMPT,
REFLECT_SYSTEM_PROMPT,
REVIEW_SYSTEM_PROMPT,
ROUTER_SYSTEM_PROMPT,
SUMMARY_SYSTEM_PROMPT,
)
except ImportError:
from prompts import (
MERGE_SYSTEM_PROMPT,
REFLECT_SYSTEM_PROMPT,
REVIEW_SYSTEM_PROMPT,
ROUTER_SYSTEM_PROMPT,
SUMMARY_SYSTEM_PROMPT,
)
Action = str
def jdump(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2)
def load_json_arg(value: str | None, default: Any) -> Any:
if value is None:
return default
if value.startswith("@"):
return json.loads(Path(value[1:]).read_text(encoding="utf-8"))
return json.loads(value)
def pick_text_chunk(text: str, chunk_size: int | None, chunk_index: int) -> str:
if chunk_size is None:
return text
if chunk_size <= 0:
raise ValueError("--chunk-size must be > 0")
if chunk_index < 0:
raise ValueError("--chunk-index must be >= 0")
start = chunk_index * chunk_size
return text[start : start + chunk_size]
def load_review_text(text: str, input_file: str | None, chunk_size: int | None, chunk_index: int) -> str:
if input_file:
file_text = Path(input_file).read_text(encoding="utf-8")
return pick_text_chunk(file_text, chunk_size, chunk_index)
return text
def system_prompt_for(action: Action) -> str:
return {
"review": REVIEW_SYSTEM_PROMPT,
"reflect": REFLECT_SYSTEM_PROMPT,
"summary": SUMMARY_SYSTEM_PROMPT,
"router": ROUTER_SYSTEM_PROMPT,
"merge": MERGE_SYSTEM_PROMPT,
}[action]
def ensure_rule_dict(rule: Any) -> dict[str, Any]:
if not isinstance(rule, dict):
raise ValueError("--rule must be a JSON object")
return rule
def build_user_prompt(rule: dict[str, Any], text: str) -> str:
return jdump({"rule": ensure_rule_dict(rule), "text": text})
def build_messages(action: Action, rule: dict[str, Any], text: str = "") -> list[dict[str, str]]:
normalized_action = normalize_action(action)
return [
{"role": "system", "content": system_prompt_for(normalized_action)},
{"role": "user", "content": build_user_prompt(rule, text)},
]
def append_json_output(output_file: str, value: Any) -> None:
path = Path(output_file)
if path.exists() and path.stat().st_size > 0:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, list):
raise ValueError(f"--output file must contain a JSON array: {output_file}")
else:
data = []
if isinstance(value, list):
data.extend(value)
else:
data.append(value)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(jdump(data) + "\n", encoding="utf-8")
def write_output(output: str, value: Any) -> None:
if output == "-":
print(jdump(value))
return
append_json_output(output, value)
def create_llm_tool(system_prompt: str):
try:
from .llm_tool import LLMTool
except ImportError:
from llm_tool import LLMTool
return LLMTool(system_prompt)
def normalize_action(action: str) -> Action:
value = (action or "review").strip().lower()
aliases = {
"review": "review",
"审查": "review",
"reflect": "reflect",
"reflection": "reflect",
"反思": "reflect",
"复核": "reflect",
"summary": "summary",
"segment_summary": "summary",
"summarize": "summary",
"摘要": "summary",
"总结": "summary",
"router": "router",
"route": "router",
"segment_rule_router": "router",
"路由": "router",
"merge": "merge",
"merger": "merge",
"segment_merger": "merge",
"合并": "merge",
}
if value not in aliases:
raise ValueError(f"unknown action: {action}")
return aliases[value]
def run_segment_llm_action(action: Action, rule: dict[str, Any], text: str = "") -> Any:
messages = build_messages(action, rule, text)
llm = create_llm_tool(messages[0]["content"])
raw = llm.run(messages)
return llm.parse_first_json(raw) or {"raw": raw}
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Run an LLM action with a rule payload")
p.add_argument("action", choices=["review", "reflect", "summary", "segment_summary", "router", "segment_rule_router", "merge", "merger", "segment_merger", "审查", "反思", "复核", "摘要", "总结", "路由", "摘要路由", "摘要项路由", "合并"])
p.add_argument("--rule", required=True, help="任意字段的 JSON dict;支持 @file.json")
p.add_argument("--text", "--segment-text", dest="text", default="", help="直接输入待审查文本")
p.add_argument("--input-file", help="从文本文件读取待审查文本")
p.add_argument("--chunk-size", type=int, help="从文件读取时的分段大小")
p.add_argument("--chunk-index", type=int, default=0, help="从文件读取时的分段序号,从 0 开始")
p.add_argument("--output", default="-", help="输出目标;'-' 直接打印,其他路径则追加到 JSON 数组文件")
p.add_argument("--print-messages", action="store_true", help="只打印构造后的 messages,不调用模型")
return p
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
rule = ensure_rule_dict(load_json_arg(args.rule, {}))
text = load_review_text(args.text, args.input_file, args.chunk_size, args.chunk_index)
messages = build_messages(args.action, rule, text)
if args.print_messages:
print(jdump(messages))
return 0
llm = create_llm_tool(messages[0]["content"])
raw = llm.run(messages)
write_output(args.output, llm.parse_first_json(raw) or {"raw": raw})
return 0
if __name__ == "__main__":
raise SystemExit(main())
---
name: review-prompt-optimization-skill
description: 审查提示词/规则优化编排 Skill。通过 batch 批处理、benchmark eval、测评 Excel 归因,循环更新 data/rules.xlsx 中的审查规则。
---
# Review Prompt Optimization Skill
## 定位
`review-prompt-optimization-skill` 用于编排合同审查提示词和规则库优化闭环。
本 Skill 负责定义流程、判读方法、规则更新约束和循环停止条件。使用时,LLM 需要结合可用终端、Excel 工具和代码编辑能力执行流程;不要只给建议。
默认优化目标文件:
- 规则库:`data/rules.xlsx`
- 批处理脚本:`data/batch/batch.py`
- 测评脚本:`data/benchmark/eval.py`
- Excel 工具:`skills/doc-excel-skill/scripts/excel_tool.py`
- 编排辅助脚本:`data/optimize_review_prompt_flow.py`
## 标准闭环
1. 启动批处理
- 优先进入或使用 tmux session:`tmux attach -t batch`
- 在该 session 中运行:`python3 data/batch/batch.py`
- 也可直接使用 `data/optimize_review_prompt_flow.py` 串联执行。
2. 等待 batch 完成
- 每 5 分钟检查一次 tmux 输出。
- 完成标志是 `batch.py` 输出批处理结果路径,格式通常为:`文件保存在: /abs/path/to/output`
- 记录该路径为 `batch_output_dir`
3. 运行测评
- 执行:
```bash
python3 data/benchmark/eval.py --datasets-dir "$batch_output_dir"
```
- `eval.py` 会先抽取批注,再对比 `data/benchmark/审查答案`,并输出测评 Excel。
- 测评 Excel 通常位于:`data/benchmark/results/{batch输出目录名}-测评结果.xlsx`
4. 读取测评 Excel
- 先读取全部 sheet:
```bash
python3 skills/doc-excel-skill/scripts/excel_tool.py list-sheets "$eval_excel"
```
- 跳过汇总 sheet `对比结果`,遍历其余每个审查项 sheet。
- 每个审查项 sheet 有两类错误:
- 第一列 `审查不合格但是没有查出来的`:漏检,应增强召回。
- 第二列 `审查合格但是误判为不合格的`:误报,应收紧边界。
5. 更新规则库
- 更新目标是 `data/rules.xlsx` 中与 sheet 名或列 `审查项` 对应的规则行。
- 更新前必须备份,例如:
```bash
cp data/rules.xlsx "data/rules.xlsx.bak.$(date +%Y%m%d-%H%M%S).xlsx"
```
- 对漏检样例:
-`审查规则` 中补充该类风险的明确判定条件。
- 必要时补充 `触发词`,提高路由和规则命中。
- 如果样例反映的是规则缺项,而非单条规则表述不足,可新增或拆分审查项。
- 对误报样例:
-`审查规则` 中补充排除条件、合格边界、适用前提或反例。
- 必要时在 `建议模板` 中约束“满足某条件时无需修改”。
- 不要为了消除误报而删除应检风险的核心判定。
- 对同一审查项同时存在漏检和误报时:
- 先抽象共同原因,再修改规则。
- 避免简单堆砌样例,优先写可泛化的规则边界。
6. 循环验证
- 保存 `data/rules.xlsx` 后,再回到步骤 1 执行下一轮 batch/eval。
- 每轮记录:
- batch 输出目录
- eval Excel 路径
- 修改过的 sheet/审查项
- 漏检数量、误报数量、F1、查全率、查准率变化
- 当总体 F1 或目标审查项指标不再明显提升,或剩余错误已无法通过规则库优化稳定解决时停止。
## 辅助脚本
可使用辅助脚本完成“batch + eval + 规则优化 + 每轮结果落 Excel”:
```bash
python data/optimize_review_prompt_flow.py --rounds 1 --f1-threshold 0.9
```
默认行为:
- 调用 `data/batch/batch.py` 生成批处理输出目录。
- 调用 `data/benchmark/eval.py` 生成测评 Excel。
- 使用 `utils/excel_tool.py` 读取 `data/rules.xlsx` 和测评 Excel。
- 对 F1 低于阈值且存在漏检/误报的审查项,调用 `utils/openai_util.py` 批量优化规则。
- 写回 `data/rules.xlsx` 前自动备份。
- 每轮结果保存到 `data/benchmark/results/prompt_optimization_rounds.xlsx` 的新 sheet。
如果 batch 已经跑完,可跳过 tmux:
```bash
python data/optimize_review_prompt_flow.py \
--batch-output /abs/path/to/batch-output
```
常用参数:
- `--rounds 3`
- `--f1-threshold 0.9`
- `--example-limit 20`
- `--max-workers 10`
- `--dry-run`
## Excel 判读规则
### `对比结果`
用于确定优化优先级:
- 优先处理 `大模型未匹配上的不合格项(C-B)` 高的审查项,提高召回。
- 其次处理 `大模型其他不合格项``误报率(D/B+D)` 高的审查项,提高准确性。
- 同等情况下优先处理合同风险更高、出现频率更高的审查项。
### 审查项明细 sheet
每个明细 sheet 的 sheet 名即审查项名称。
- 第一列非空样例是“应该判不合格,但没有输出”的证据文本。
- 第二列非空样例是“实际合格,但被模型判不合格”的证据文本。
- 修改规则时,必须把样例上升为抽象规则,不要只复制样例原文。
## 更新 `data/rules.xlsx` 的约束
- 保留原有 sheet、列名、ID 和风险等级,除非确实需要新增规则。
- 优先更新与测评 sheet 同名或语义对应的 `审查项` 行。
- 只能改与本轮错误归因直接相关的规则,不做无关重构。
- 修改应同时兼顾召回和准确性,避免单向拉高导致另一类错误恶化。
- 每次修改后都要保存 Excel,并在最终回复中说明改了哪些审查项。
- 如果工具无法安全写入 Excel,应先导出修改建议 JSON/Markdown,不得破坏原文件。
## 推荐规则改写格式
`审查规则` 中优先使用以下结构:
```text
检查……。不合格情形包括:1)……;2)……。
合格/不判定为不合格的情形包括:1)……;2)……。
仅当合同原文明确体现……时输出不合格;不得因……直接推断为不合格。
```
`触发词` 中使用分号分隔:
```text
付款;支付;验收;尾款;发票
```
`建议模板` 中写可执行建议:
```text
如存在该风险,建议补充……;若合同已明确……则无需修改。
```
## 输出要求
每轮优化结束后,LLM 应输出:
- batch 输出目录
- eval Excel 路径
- 本轮修改的 `data/rules.xlsx` 备份路径
- 修改过的审查项列表
- 每个审查项的漏检/误报归因摘要
- 是否建议继续下一轮
## 注意事项
- 不要把 `对比结果` 当成规则明细 sheet。
- 不要把“审查合格但是误判为不合格的”样例加入不合格判定条件;它们用于写排除条件。
- 不要因单个样例过拟合规则,应提炼可泛化的合同审查边界。
- 批处理和测评耗时较长,轮询期间保持 tmux session 不被关闭。
......@@ -49,6 +49,9 @@ class ExcelUtil:
except Exception as exc:
raise ExcelLoadError(f"Failed to open Excel file: {exc}") from exc
if sheet_name and sheet_name not in wb.sheetnames:
return [] # Return empty if specified sheet is not found
ws = wb[sheet_name] if sheet_name else wb.active
rows = list(ws.iter_rows(values_only=True))
......
......@@ -113,10 +113,10 @@ def _resolve_download_filename(url: str, response: requests.Response) -> str:
# 下载url到本地path
def download_file(url, path, input_url_to_inner=True):
if not url.startswith("http:"):
if not url.startswith("http:") and not url.startswith("https:"):
url = base_fastgpt_url + url
url = url.replace(outer_backend_url, base_backend_url)
logger.info(f"url准备下载:{url}")
# logger.info(f"url准备下载:{url}")
# 发送一个HTTP请求到URL
response = requests.get(url)
# 确保请求成功
......@@ -131,10 +131,10 @@ def download_file(url, path, input_url_to_inner=True):
with open(target_path, "wb") as f:
# 写入响应的内容
f.write(response.content)
logger.info(f"{url}文件下载成功,保存到{target_path}")
# logger.info(f"{url}文件下载成功,保存到{target_path}")
return str(target_path)
else:
logger.error(f"{url}文件下载失败. HTTP Status Code: {response.status_code}")
# logger.error(f"{url}文件下载失败. HTTP Status Code: {response.status_code}")
return None
......@@ -146,5 +146,5 @@ def url_replace_fastgpt(origin: str):
if __name__ == "__main__":
# d = '/home/ccran/file.docx'
d = "/home/ccran/lufa-contract/tmp/default.json"
d = "/Users/chenran/VsProject/lufa-contract/demo/2020100593中建大成建筑(B类).pdf"
print(upload_file(d))
......@@ -16,7 +16,7 @@ class OpenAITool:
)
@retry(stop=stop_after_delay(600) | stop_after_attempt(3), wait=wait_fixed(1))
async def chat(self, msg, tools=None):
async def chat(self, msg, tools=None,**extra):
if tools is None:
extra_body = {}
# fastgpt专用:如果第一个消息是system角色,则将其内容放入extra_body.variables.system,并从消息列表中移除
......@@ -26,6 +26,9 @@ class OpenAITool:
msg = msg[1:]
# deepseek专用关闭思考
extra_body["thinking"] = {"type": "disabled"}
extra_body["chat_template_kwargs"] = {"enable_thinking": False}
if extra:
extra_body.update(extra)
try:
response = await self.client.chat.completions.create(
model=self.llm_config.model, messages=msg, extra_body=extra_body
......
import asyncio
import codecs
import json
import re
from urllib import parse
from urllib.parse import urlparse
import aiohttp
from aiohttp import ClientSession
from loguru import logger
from utils.common_util import random_str
from utils.http_util import download_file, url_replace_fastgpt
class PaddleOCRUtil:
def __init__(self, ocr_url='http://192.168.252.71:56100/ocr/pdf-robust'):
self.ocr_url = ocr_url
@staticmethod
def _decode_text(text):
if text is None:
return ''
if not isinstance(text, str):
text = str(text)
text = text.strip()
if not text:
return ''
# json.loads normally decodes "\u4e2d" into Chinese. Some services
# return the text field double-escaped, so decode only when needed.
if re.search(r'\\u[0-9a-fA-F]{4}', text):
try:
text = codecs.decode(text, 'unicode_escape')
except UnicodeDecodeError:
logger.warning('paddle ocr text unicode_escape decode failed, use raw text.')
return text
def _parse_response_text(self, response_text):
try:
rsp_json = json.loads(response_text)
except json.JSONDecodeError as exc:
raise ValueError(f'Invalid paddle ocr response json: {response_text[:500]}') from exc
if not rsp_json.get('ok') or rsp_json.get('code') != 0:
raise ValueError(f'Paddle ocr failed: {rsp_json}')
data = rsp_json.get('data') or {}
return self._decode_text(data.get('text', ''))
async def ocr_requests_async(self, session, file_path):
logger.info(f'paddle ocr pdf request:{file_path}')
with open(file_path, 'rb') as pdf_file:
form = aiohttp.FormData()
form.add_field(
'file',
pdf_file,
filename=file_path.split('/')[-1],
content_type='application/pdf',
)
async with session.post(self.ocr_url, data=form) as response:
response_text = await response.text()
response.raise_for_status()
return response_text
async def ocr_result_pdf(self, dest_path):
timeout = aiohttp.ClientTimeout(total=1200)
async with ClientSession(timeout=timeout) as session:
response_text = await self.ocr_requests_async(session, dest_path)
text = self._parse_response_text(response_text)
logger.info(f'paddle ocr pdf finish. text chars:{len(text)}')
return [text]
def ocr_download_path(self, url):
logger.info(f'paddle ocr url:{url}')
url = url_replace_fastgpt(url)
url_parsed = urlparse(url)
query_dict = parse.parse_qs(url_parsed.query)
if 'filename' in query_dict:
filename = query_dict.get('filename')[0]
else:
filename = f'{random_str()}.pdf'
dest_path = f'ocr/{filename}'
download_file(url, dest_path)
return dest_path
if __name__ == '__main__':
ocr_util = PaddleOCRUtil()
result = asyncio.run(ocr_util.ocr_result_pdf('demo/2020100593中建大成建筑(B类).pdf'))
print(f'len(result):{len(result)}')
print(result)
......@@ -6,7 +6,7 @@ from spire.pdf import PdfDocument, PdfTextExtractOptions, PdfTextExtractor, Lice
PdfPopupIcon
from loguru import logger
from rapidfuzz import process
from utils.ocr_util import OCRUtil
from utils.tesseract_ocr_util import TesseractOCRUtil
import copy
from utils.common_util import adjust_single_chunk_size
import re
......@@ -174,7 +174,7 @@ class SpirePdfDoc(DocBase):
def __init__(self, **kwargs):
super(SpirePdfDoc, self).__init__(**kwargs)
self.ocr_util = OCRUtil()
self.ocr_util = TesseractOCRUtil()
def load(self,doc_path):
self._doc_path = doc_path
......
......@@ -4,7 +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
from core.config import FULL_TEXT_SEGMENT_ID, use_lufa
import os
......@@ -470,6 +470,7 @@ class SpireWordDoc(DocBase):
current_child_text = self._resolve_table(table)
# 跳过其他非文本子对象
else:
print(child_obj)
continue
# 添加新对象
if (
......@@ -552,6 +553,22 @@ class SpireWordDoc(DocBase):
for loc in chunk_locations
]
def get_all_sub_chunks(self):
self._ensure_loaded()
sub_chunks = []
for chunk_id in range(len(self._chunk_list)):
sub_chunks.extend(self.get_sub_chunks(chunk_id))
return sub_chunks
def _get_comment_sub_chunks(self, chunk_id):
self._ensure_loaded()
if chunk_id == FULL_TEXT_SEGMENT_ID:
return self.get_all_sub_chunks()
if isinstance(chunk_id, int) and 0 <= chunk_id < self.get_chunk_num():
return self.get_sub_chunks(chunk_id)
logger.error(f"invalid chunk_id for comment: {chunk_id}")
return []
def format_comment_author(self, comment):
return "{}|{}".format(str(comment["id"]), comment["key_points"])
......@@ -794,6 +811,22 @@ class SpireWordDoc(DocBase):
def add_chunk_comment(self, chunk_id, comments):
"""
为 chunk 添加批注(保证每条评论只批注一次)。
comments 为评论字典列表,每个元素结构如下:
{
"id": str | int, # 必填,规则/评论唯一标识,和 key_points 共同组成批注作者键
"key_points": str, # 必填,审核要点,和 id 共同用于去重、更新、删除批注
"result": "不合格" | str, # 必填,仅 result == "不合格" 的评论会被添加/更新批注
"suggest": str, # 可选,批注正文;缺省为空字符串
"original_text": str, # 可选,优先用于定位原文;为空时批注落在文档首个可用段落
"chunk_id": int, # 可选,0 基 chunk 下标;有效时优先于入参 chunk_id
}
说明:
- 批注作者会格式化为 "{id}|{key_points}",用于识别同一条评论并避免重复插入。
- original_text 非空时,先在目标 chunk 内精确匹配,失败后再做模糊匹配。
- comment["chunk_id"] 缺失或无效时,使用入参 chunk_id。
执行顺序:
1) 过滤非“不合格”项;
2) 先按作者标识查重,命中则更新内容;
......@@ -803,14 +836,15 @@ class SpireWordDoc(DocBase):
for comment in comments:
if comment.get("result") != "不合格":
continue
# update chunk_id
comment_chunk_id = comment.get("chunk_id", -1)
# 优先使用comments里提供的chunk_id,如果没有或无效则使用外部传入的chunk_id,如果都没有则异常处理
sub_chunks = (
self.get_sub_chunks(comment_chunk_id)
if comment_chunk_id != -1 and comment_chunk_id < self.get_chunk_num()
else self.get_sub_chunks(chunk_id)
)
comment_chunk_id = comment.get("chunk_id")
# 优先使用 comment 中的 chunk_id;-1 表示全文;缺失或无效时回退到入参 chunk_id。
if isinstance(comment_chunk_id, int) and (
comment_chunk_id == FULL_TEXT_SEGMENT_ID
or 0 <= comment_chunk_id < self.get_chunk_num()
):
sub_chunks = self._get_comment_sub_chunks(comment_chunk_id)
else:
sub_chunks = self._get_comment_sub_chunks(chunk_id)
author = self.format_comment_author(comment)
suggest = comment.get("suggest", "")
original_text = (comment.get("original_text") or "").strip()
......@@ -936,6 +970,9 @@ class SpireWordDoc(DocBase):
if remove_prefix:
self.remove_comment_prefix()
self._doc.Replace('!Undefined Bookmark,','',False,True)
self._doc.Replace('!Undefined Book','',False,True)
self._doc.Replace('!Undefined B','',False,True)
self._doc.SaveToFile(path)
def release(self):
......
{
{
......@@ -7,8 +7,8 @@
"avatar": "core/workflow/template/systemConfig",
"flowNodeType": "userGuide",
"position": {
"x": -1830.6752840119775,
"y": -773.8134339439673
"x": -3052.9050978053997,
"y": -775.6849266713408
},
"version": "481",
"inputs": [
......@@ -93,8 +93,8 @@
"avatar": "core/workflow/template/workflowStart",
"flowNodeType": "workflowStart",
"position": {
"x": -1341.9918261676994,
"y": -720.3035540606256
"x": -2406.2705093256454,
"y": -675.1304350810863
},
"version": "481",
"inputs": [
......@@ -212,7 +212,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/conversations/new",
"value": "http://172.21.107.80:18169/conversations/new",
"debugLabel": "",
"toolDescription": ""
},
......@@ -528,7 +528,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/documents/parse",
"value": "http://172.21.107.80:18169/documents/parse",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1010,7 +1010,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/memory/facts/export",
"value": "http://172.21.107.80:18169/memory/facts/export",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1255,7 +1255,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/summary/facts",
"value": "http://172.21.107.80:18169/segments/summary/facts",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1538,7 +1538,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/rule-router",
"value": "http://172.21.107.80:18169/segments/review/rule-router",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1852,7 +1852,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/summary/facts/merger",
"value": "http://172.21.107.80:18169/segments/summary/facts/merger",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2053,8 +2053,8 @@
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": -804.8826015635359,
"y": -701.6010115448964
"x": -878.9665166899803,
"y": -775.6849266713408
},
"version": "481",
"inputs": [
......@@ -2127,7 +2127,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/rulesets/route",
"value": "http://172.21.107.80:18169/rulesets/route",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2163,7 +2163,7 @@
"hidden"
],
"valueType": "any",
"value": "{\n\"question\":\"{{$448745.userChatInput$}}\"\n}",
"value": "{\n\"question\":\"{{$anKdljJd5pCGBRhB.system_text$}}\"\n}",
"label": "",
"required": false,
"debugLabel": "",
......@@ -2359,6 +2359,45 @@
}
],
"outputs": []
},
{
"nodeId": "anKdljJd5pCGBRhB",
"name": "增加合同提取前缀",
"intro": "可对固定或传入的文本进行加工后输出,非字符串类型数据最终会转成字符串类型。",
"avatar": "core/workflow/template/textConcat",
"flowNodeType": "textEditor",
"position": {
"x": -1848.542944082165,
"y": -652.5650477390293
},
"version": "4813",
"inputs": [
{
"key": "system_textareaInput",
"renderTypeList": [
"textarea"
],
"valueType": "string",
"required": true,
"label": "拼接文本",
"placeholder": "workflow:input_variable_list",
"value": "帮我做合同信息提取;\n{{$448745.userChatInput$}}",
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "system_text",
"key": "system_text",
"label": "workflow:concatenation_result",
"type": "static",
"valueType": "string",
"description": ""
}
]
}
],
"edges": [
......@@ -2447,12 +2486,6 @@
"targetHandle": "v7v3dWPFEySgk5Wk-target-left"
},
{
"source": "448745",
"target": "yR4fA3XTHyjYMN3j",
"sourceHandle": "448745-source-right",
"targetHandle": "yR4fA3XTHyjYMN3j-target-left"
},
{
"source": "yR4fA3XTHyjYMN3j",
"target": "rNHAaQ4A2NNI0PCE",
"sourceHandle": "yR4fA3XTHyjYMN3j-source-right",
......@@ -2469,6 +2502,18 @@
"target": "o7b0axI8mmI9pA2A",
"sourceHandle": "te1XPVi9S4GFi6Jx-source-right",
"targetHandle": "o7b0axI8mmI9pA2A-target-left"
},
{
"source": "448745",
"target": "anKdljJd5pCGBRhB",
"sourceHandle": "448745-source-right",
"targetHandle": "anKdljJd5pCGBRhB-target-left"
},
{
"source": "anKdljJd5pCGBRhB",
"target": "yR4fA3XTHyjYMN3j",
"sourceHandle": "anKdljJd5pCGBRhB-source-right",
"targetHandle": "yR4fA3XTHyjYMN3j-target-left"
}
],
"chatConfig": {
......
{
{
......@@ -212,7 +212,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/conversations/new",
"value": "http://172.21.107.80:18169/conversations/new",
"debugLabel": "",
"toolDescription": ""
},
......@@ -528,7 +528,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/documents/parse",
"value": "http://172.21.107.80:18169/documents/parse",
"debugLabel": "",
"toolDescription": ""
},
......@@ -976,7 +976,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/findings",
"value": "http://172.21.107.80:18169/segments/review/findings",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1288,7 +1288,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/memory/export",
"value": "http://172.21.107.80:18169/memory/export",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1540,7 +1540,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/summary/facts",
"value": "http://172.21.107.80:18169/segments/summary/facts",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2024,7 +2024,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/reflect",
"value": "http://172.21.107.80:18169/segments/review/reflect",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2306,7 +2306,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/rule-router",
"value": "http://172.21.107.80:18169/segments/review/rule-router",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2791,7 +2791,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/merger",
"value": "http://172.21.107.80:18169/segments/review/merger",
"debugLabel": "",
"toolDescription": ""
},
......
{
{
......@@ -212,7 +212,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/conversations/new",
"value": "http://172.21.107.80:18169/conversations/new",
"debugLabel": "",
"toolDescription": ""
},
......@@ -528,7 +528,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/documents/parse",
"value": "http://172.21.107.80:18169/documents/parse",
"debugLabel": "",
"toolDescription": ""
},
......@@ -976,7 +976,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/findings",
"value": "http://172.21.107.80:18169/segments/review/findings",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1288,7 +1288,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/memory/export",
"value": "http://172.21.107.80:18169/memory/export",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1540,7 +1540,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/summary/facts",
"value": "http://172.21.107.80:18169/segments/summary/facts",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2024,7 +2024,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/reflect",
"value": "http://172.21.107.80:18169/segments/review/reflect",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2306,7 +2306,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/rule-router",
"value": "http://172.21.107.80:18169/segments/review/rule-router",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2791,7 +2791,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/merger",
"value": "http://172.21.107.80:18169/segments/review/merger",
"debugLabel": "",
"toolDescription": ""
},
......
This source diff could not be displayed because it is too large. You can view the blob instead.
{
{
......@@ -7,8 +7,8 @@
"avatar": "core/workflow/template/systemConfig",
"flowNodeType": "userGuide",
"position": {
"x": -1830.6752840119775,
"y": -773.8134339439673
"x": -2626.6230836832283,
"y": -718.7305346936864
},
"version": "481",
"inputs": [
......@@ -93,8 +93,8 @@
"avatar": "core/workflow/template/workflowStart",
"flowNodeType": "workflowStart",
"position": {
"x": -1341.9918261676994,
"y": -720.3035540606256
"x": -2076.59187052832,
"y": -648.1183530689627
},
"version": "481",
"inputs": [
......@@ -212,7 +212,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/conversations/new",
"value": "http://172.21.107.80:18169/conversations/new",
"debugLabel": "",
"toolDescription": ""
},
......@@ -528,7 +528,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/documents/parse",
"value": "http://172.21.107.80:18169/documents/parse",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1010,7 +1010,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/memory/facts/export",
"value": "http://172.21.107.80:18169/memory/facts/export",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1255,7 +1255,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/summary/facts",
"value": "http://172.21.107.80:18169/segments/summary/facts",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1538,7 +1538,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/review/rule-router",
"value": "http://172.21.107.80:18169/segments/review/rule-router",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1852,7 +1852,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/segments/summary/facts/merger",
"value": "http://172.21.107.80:18169/segments/summary/facts/merger",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2127,7 +2127,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.45:18169/rulesets/route",
"value": "http://172.21.107.80:18169/rulesets/route",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2163,7 +2163,7 @@
"hidden"
],
"valueType": "any",
"value": "{\n\"question\":\"{{$448745.userChatInput$}}\"\n}",
"value": "{\n\"question\":\"{{$txeKu3Wg4PktWwrs.system_text$}}\"\n}",
"label": "",
"required": false,
"debugLabel": "",
......@@ -2359,6 +2359,45 @@
}
],
"outputs": []
},
{
"nodeId": "txeKu3Wg4PktWwrs",
"name": "文本拼接",
"intro": "可对固定或传入的文本进行加工后输出,非字符串类型数据最终会转成字符串类型。",
"avatar": "core/workflow/template/textConcat",
"flowNodeType": "textEditor",
"position": {
"x": -1591.7611113168862,
"y": -566.1422308841376
},
"version": "4813",
"inputs": [
{
"key": "system_textareaInput",
"renderTypeList": [
"textarea"
],
"valueType": "string",
"required": true,
"label": "拼接文本",
"placeholder": "workflow:input_variable_list",
"value": "帮我进行技术协议提取;\n{{$448745.userChatInput$}}",
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "system_text",
"key": "system_text",
"label": "workflow:concatenation_result",
"type": "static",
"valueType": "string",
"description": ""
}
]
}
],
"edges": [
......@@ -2447,12 +2486,6 @@
"targetHandle": "v7v3dWPFEySgk5Wk-target-left"
},
{
"source": "448745",
"target": "yR4fA3XTHyjYMN3j",
"sourceHandle": "448745-source-right",
"targetHandle": "yR4fA3XTHyjYMN3j-target-left"
},
{
"source": "yR4fA3XTHyjYMN3j",
"target": "rNHAaQ4A2NNI0PCE",
"sourceHandle": "yR4fA3XTHyjYMN3j-source-right",
......@@ -2469,6 +2502,18 @@
"target": "o7b0axI8mmI9pA2A",
"sourceHandle": "te1XPVi9S4GFi6Jx-source-right",
"targetHandle": "o7b0axI8mmI9pA2A-target-left"
},
{
"source": "448745",
"target": "txeKu3Wg4PktWwrs",
"sourceHandle": "448745-source-right",
"targetHandle": "txeKu3Wg4PktWwrs-target-left"
},
{
"source": "txeKu3Wg4PktWwrs",
"target": "yR4fA3XTHyjYMN3j",
"sourceHandle": "txeKu3Wg4PktWwrs-source-right",
"targetHandle": "yR4fA3XTHyjYMN3j-target-left"
}
],
"chatConfig": {
......
{
{
......@@ -212,7 +212,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/conversations/new",
"value": "http://172.21.107.80:18169/conversations/new",
"debugLabel": "",
"toolDescription": ""
},
......@@ -528,7 +528,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/documents/parse",
"value": "http://172.21.107.80:18169/documents/parse",
"debugLabel": "",
"toolDescription": ""
},
......@@ -969,7 +969,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/segments/review/findings",
"value": "http://172.21.107.80:18169/segments/review/findings",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1281,7 +1281,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/memory/export",
"value": "http://172.21.107.80:18169/memory/export",
"debugLabel": "",
"toolDescription": ""
},
......@@ -1533,7 +1533,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/segments/summary/facts",
"value": "http://172.21.107.80:18169/segments/summary/facts",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2017,7 +2017,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/segments/review/reflect",
"value": "http://172.21.107.80:18169/segments/review/reflect",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2299,7 +2299,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/segments/review/rule-router",
"value": "http://172.21.107.80:18169/segments/review/rule-router",
"debugLabel": "",
"toolDescription": ""
},
......@@ -2784,7 +2784,7 @@
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://192.168.252.71:18169/segments/review/merger",
"value": "http://172.21.107.80:18169/segments/review/merger",
"debugLabel": "",
"toolDescription": ""
},
......
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