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"]))
import argparse
from pathlib import Path
from typing import Iterable
import pandas as pd
from rapidfuzz import fuzz
from contextlib import redirect_stdout, redirect_stderr
import time
fuzz_score_threshold = 80
EXCEL_SHEET_NAME_LIMIT = 31
INVALID_SHEET_NAME_CHARS = set(r'[]:*?/\\')
def _normalize_cell(value: object) -> str:
......@@ -39,9 +41,55 @@ def _load_rows(path: Path) -> list[tuple[str, str]]:
return rows
def _compare_impl(val_dir: Path, answer_dir: Path) -> None:
def _normalize_ignore_items(ignore_items: Iterable[str] | None) -> set[str]:
if not ignore_items:
return set()
return {item.strip() for item in ignore_items if item.strip()}
def _filter_ignored_rows(
rows: list[tuple[str, str]],
ignore_items: set[str],
) -> list[tuple[str, str]]:
if not ignore_items:
return rows
return [(item, text) for item, text in rows if item not in ignore_items]
def _safe_sheet_name(name: str, used_names: set[str]) -> str:
sheet_name = "".join(
"_" if char in INVALID_SHEET_NAME_CHARS else char for char in name.strip()
)
sheet_name = sheet_name or "空审查项"
sheet_name = sheet_name[:EXCEL_SHEET_NAME_LIMIT]
if sheet_name not in used_names:
used_names.add(sheet_name)
return sheet_name
counter = 2
while True:
suffix = f"_{counter}"
candidate = sheet_name[: EXCEL_SHEET_NAME_LIMIT - len(suffix)] + suffix
if candidate not in used_names:
used_names.add(candidate)
return candidate
counter += 1
def _compare_impl(
val_dir: Path,
answer_dir: Path,
print_result: bool = False,
ignore_items: Iterable[str] | None = None,
) -> Path | None:
val_dir = val_dir.resolve()
answer_dir = answer_dir.resolve()
ignore_item_set = _normalize_ignore_items(ignore_items)
def log(*args: object, **kwargs: object) -> None:
if print_result:
print(*args, **kwargs)
overall_val = overall_answer = overall_matched = 0
......@@ -50,15 +98,17 @@ def _compare_impl(val_dir: Path, answer_dir: Path) -> None:
overall_item_matched: dict[str, int] = {}
overall_item_unmatched_answer: dict[str, int] = {}
overall_item_unmatched_val: dict[str, int] = {}
unmatched_answer_details_by_item: dict[str, list[str]] = {}
unmatched_val_details_by_item: dict[str, list[str]] = {}
for val_file in sorted(val_dir.glob("*.xlsx")):
answer_file = answer_dir / val_file.name
if not answer_file.exists():
print(f"Skip {val_file.name}: missing in answer")
log(f"Skip {val_file.name}: missing in answer")
continue
val_rows = _load_rows(val_file)
answer_rows = _load_rows(answer_file)
val_rows = _filter_ignored_rows(_load_rows(val_file), ignore_item_set)
answer_rows = _filter_ignored_rows(_load_rows(answer_file), ignore_item_set)
# Baseline: answer -> match val, consume val to keep 1-1, report leftover answers
answer_counts: dict[str, int] = {}
......@@ -136,23 +186,25 @@ def _compare_impl(val_dir: Path, answer_dir: Path) -> None:
overall_item_unmatched_answer[it] = overall_item_unmatched_answer.get(
it, 0
) + len(lst)
unmatched_answer_details_by_item.setdefault(it, []).extend(lst)
for it, lst in unmatched_val_by_item.items():
overall_item_unmatched_val[it] = overall_item_unmatched_val.get(
it, 0
) + len(lst)
print("#" * 40)
print(
unmatched_val_details_by_item.setdefault(it, []).extend(lst)
log("#" * 40)
log(
f"{val_file.name}: matched {matched_total} | val {val_total} | answer {answer_total} "
f"| unmatched val {unmatched_val_count} | unmatched answer {unmatched_answer_count} | precision {file_precision:.2%} | recall {file_recall:.2%} | f1 {file_f1:.2%} | false_positive_rate {file_false_positive_rate:.2%}"
)
import json
print(
log(
f"unmatched_val_by_item: {json.dumps(unmatched_val_by_item, ensure_ascii=False, indent=2)}"
)
for item in sorted(answer_counts):
item_matches = matched_by_item.get(item, [])
print(
log(
f" 审查项 {item}: matched {len(item_matches)} / {answer_counts[item]}"
)
# 匹配成功的结果
......@@ -161,15 +213,15 @@ def _compare_impl(val_dir: Path, answer_dir: Path) -> None:
ua = unmatched_answer_by_item.get(item, [])
if ua:
print(f" 未匹配(answer 未被匹配){len(ua)} 条:")
log(f" 未匹配(answer 未被匹配){len(ua)} 条:")
for t in ua:
print(f" answer: {t}")
log(f" answer: {t}")
uv = unmatched_val_by_item.get(item, [])
if uv:
print(f" 未匹配(val 残留){len(uv)} 条:")
log(f" 未匹配(val 残留){len(uv)} 条:")
for t in uv:
print(f" val: {t}")
log(f" val: {t}")
# break # only first file for demo
precision = overall_matched / overall_val if overall_val else 0
recall = overall_matched / overall_answer if overall_answer else 0
......@@ -177,14 +229,14 @@ def _compare_impl(val_dir: Path, answer_dir: Path) -> None:
overall_false_positive_rate = (
(overall_val - overall_matched) / overall_val if overall_val else 0
)
print(
log(
f"Overall: matched {overall_matched} | val {overall_val} | answer {overall_answer} | precision {precision:.2%} | recall {recall:.2%} | f1 {f1:.2%}"
)
# 按“审查项”的 overall 结果
if overall_item_answer:
print("#" * 40)
print("Overall by item:")
log("#" * 40)
log("Overall by item:")
all_items = sorted(
set(
list(overall_item_answer.keys())
......@@ -220,7 +272,7 @@ def _compare_impl(val_dir: Path, answer_dir: Path) -> None:
"误报率(D/B+D)": item_false_positive_rate,
}
)
print(
log(
f" 审查项 {it}: matched {mat} / answer {ans} | unmatched val {u_val} | unmatched answer {u_ans} | precision {item_precision:.2%} | recall {acc:.2%} | f1 {item_f1:.2%}"
)
......@@ -288,36 +340,45 @@ def _compare_impl(val_dir: Path, answer_dir: Path) -> None:
compare_dir_name = val_dir.name
results_dir = Path(__file__).parent / "results"
results_dir.mkdir(parents=True, exist_ok=True)
output_excel = results_dir / f"合同审查结果_{compare_dir_name}.xlsx"
output_excel = results_dir / f"{compare_dir_name}.xlsx"
used_sheet_names = {"对比结果"}
with pd.ExcelWriter(output_excel, engine="openpyxl") as writer:
combined_df.to_excel(writer, sheet_name="对比结果", index=False)
print(
for item in all_items:
uncalled = unmatched_answer_details_by_item.get(item, [])
inaccurate = unmatched_val_details_by_item.get(item, [])
max_len = max(len(uncalled), len(inaccurate))
detail_df = pd.DataFrame(
{
"审查不合格但是没有查出来的": uncalled + [""] * (max_len - len(uncalled)),
"审查合格但是误判为不合格的": inaccurate + [""] * (max_len - len(inaccurate)),
}
)
detail_df.to_excel(
writer,
sheet_name=_safe_sheet_name(item, used_sheet_names),
index=False,
)
log(
f"Excel written to {output_excel}.\nEval Time: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}"
)
def compare(val_dir: Path, answer_dir: Path) -> None:
_compare_impl(val_dir=val_dir, answer_dir=answer_dir)
def compare_with_log(
val_dir: Path, answer_dir: Path, log_path: Path | None = None
) -> Path:
val_dir = val_dir.resolve()
if log_path is None:
results_dir = Path(__file__).parent / "results"
results_dir.mkdir(parents=True, exist_ok=True)
log_path = results_dir / f"合同审查结果_{val_dir.name}.log"
else:
log_path = log_path.resolve()
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_path, "w", encoding="utf-8") as f, redirect_stdout(
f
), redirect_stderr(f):
_compare_impl(val_dir=val_dir, answer_dir=answer_dir)
return log_path
return output_excel
return None
def compare(
val_dir: Path,
answer_dir: Path,
print_result: bool = False,
ignore_items: Iterable[str] | None = None,
) -> Path | None:
return _compare_impl(
val_dir=val_dir,
answer_dir=answer_dir,
print_result=print_result,
ignore_items=ignore_items,
)
def _parse_args() -> argparse.Namespace:
......@@ -338,19 +399,26 @@ def _parse_args() -> argparse.Namespace:
help="Directory containing answer xlsx files.",
)
parser.add_argument(
"--log-path",
type=Path,
default=None,
help="Optional explicit log path. Defaults to results/合同审查结果_<val_dir_name>.log",
"--no-print",
default=True,
help="Disable comparison progress and result printing.",
)
parser.add_argument(
"--ignore-items",
nargs="*",
default=['备料审查'],
help="Review item names to exclude from matching and statistics.",
)
return parser.parse_args()
if __name__ == "__main__":
args = _parse_args()
final_log_path = compare_with_log(
final_excel_path = compare(
val_dir=args.val_dir,
answer_dir=args.answer_dir,
log_path=args.log_path,
print_result=not args.no_print,
ignore_items=args.ignore_items,
)
print(f"Log written to {final_log_path}")
if not args.no_print:
print(f"Excel written to {final_excel_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,
)
from __future__ import annotations
import argparse
import asyncio
import json
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
BENCHMARK_DIR = REPO_ROOT / "data" / "benchmark"
if str(BENCHMARK_DIR) not in sys.path:
sys.path.insert(0, str(BENCHMARK_DIR))
from utils import excel_tool # noqa: E402
SUMMARY_SHEET = "对比结果"
FN_COL = "审查不合格但是没有查出来的"
FP_COL = "审查合格但是误判为不合格的"
F1_COL = "F1"
REVIEW_ITEM_COL = "审查项"
RULE_COL = "审查规则"
TRIGGER_COL = "触发词"
SUGGESTION_COL = "建议模板"
DEFAULT_RULES_FILE = REPO_ROOT / "data" / "rules.xlsx"
DEFAULT_REPORT_FILE = REPO_ROOT / "data" / "prompt_optimization_rounds.xlsx"
DEFAULT_MAX_WORKERS = 10
DEFAULT_APP_SESSION = "jp-contract-debug"
DEFAULT_APP_COMMAND = "source ~/.jp-contract/bin/activate && python main.py"
@dataclass
class RuleLocation:
sheet: str
row_idx: int
row: dict[str, Any]
@dataclass
class EvalItem:
name: str
metrics: dict[str, Any]
false_negatives: list[str]
false_positives: list[str]
locations: list[RuleLocation]
@dataclass
class RuleTask:
item: EvalItem
location: RuleLocation
def clean_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def parse_float(value: Any, default: float = 0.0) -> float:
if value is None or value == "":
return default
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().rstrip("%")
try:
number = float(text)
except ValueError:
return default
return number / 100 if str(value).strip().endswith("%") else number
def compact_examples(values: list[str], limit: int) -> list[str]:
seen: set[str] = set()
examples: list[str] = []
for value in values:
text = clean_text(value)
if not text or text in seen:
continue
seen.add(text)
examples.append(text)
if len(examples) >= limit:
break
return examples
def safe_sheet_name(name: str, used: set[str]) -> str:
invalid_chars = set("[]:*?/\\")
base = "".join("_" if ch in invalid_chars else ch for ch in name)[:31] or "Sheet"
if base not in used:
used.add(base)
return base
idx = 2
while True:
suffix = f"_{idx}"
candidate = base[: 31 - len(suffix)] + suffix
if candidate not in used:
used.add(candidate)
return candidate
idx += 1
def json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2)
def parse_json_object(text: str) -> dict[str, Any]:
text = clean_text(text)
if not text:
return {}
try:
value = json.loads(text)
return value if isinstance(value, dict) else {}
except json.JSONDecodeError:
pass
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
if fenced:
try:
value = json.loads(fenced.group(1))
return value if isinstance(value, dict) else {}
except json.JSONDecodeError:
pass
blob = re.search(r"(\{.*\})", text, re.S)
if not blob:
return {}
try:
value = json.loads(blob.group(1))
except json.JSONDecodeError:
return {}
return value if isinstance(value, dict) else {}
def read_rule_locations(rules_file: Path) -> dict[str, list[RuleLocation]]:
locations: dict[str, list[RuleLocation]] = {}
for sheet in excel_tool.list_sheets(str(rules_file)):
rows = excel_tool.load_excel(str(rules_file), sheet=sheet, header=True)
for offset, row in enumerate(rows, start=2):
if not isinstance(row, dict):
continue
review_item = clean_text(row.get(REVIEW_ITEM_COL))
if not review_item:
continue
locations.setdefault(review_item, []).append(
RuleLocation(sheet=sheet, row_idx=offset, row=row)
)
return locations
def read_eval_items(
eval_excel: Path,
rules_file: Path,
f1_threshold: float,
example_limit: int,
) -> list[EvalItem]:
rule_locations = read_rule_locations(rules_file)
metric_rows = excel_tool.load_excel(str(eval_excel), sheet=SUMMARY_SHEET, header=True)
metrics_by_item = {
clean_text(row.get(REVIEW_ITEM_COL)): row
for row in metric_rows
if isinstance(row, dict) and clean_text(row.get(REVIEW_ITEM_COL))
}
items: list[EvalItem] = []
for sheet in excel_tool.list_sheets(str(eval_excel)):
if sheet == SUMMARY_SHEET:
continue
rows = excel_tool.load_excel(str(eval_excel), sheet=sheet, header=True)
false_negatives: list[str] = []
false_positives: list[str] = []
for row in rows:
if not isinstance(row, dict):
continue
fn = clean_text(row.get(FN_COL))
fp = clean_text(row.get(FP_COL))
if fn:
false_negatives.append(fn)
if fp:
false_positives.append(fp)
metrics = metrics_by_item.get(sheet, {})
f1 = parse_float(metrics.get(F1_COL), default=0.0) if metrics else 0.0
has_errors = bool(false_negatives or false_positives)
if not has_errors or f1 >= f1_threshold:
continue
locations = rule_locations.get(sheet, [])
if not locations:
continue
items.append(
EvalItem(
name=sheet,
metrics=dict(metrics),
false_negatives=compact_examples(false_negatives, example_limit),
false_positives=compact_examples(false_positives, example_limit),
locations=locations,
)
)
return items
def build_rule_tasks(items: list[EvalItem]) -> list[RuleTask]:
return [
RuleTask(item=item, location=location)
for item in items
for location in item.locations
]
def build_messages(task: RuleTask) -> list[dict[str, str]]:
item = task.item
loc = task.location
rule = {
"ID": loc.row.get("ID"),
"摘要项": loc.row.get("摘要项"),
REVIEW_ITEM_COL: loc.row.get(REVIEW_ITEM_COL),
RULE_COL: loc.row.get(RULE_COL),
"风险等级": loc.row.get("风险等级"),
SUGGESTION_COL: loc.row.get(SUGGESTION_COL),
TRIGGER_COL: loc.row.get(TRIGGER_COL),
}
payload = {
"审查项": item.name,
"本轮指标": item.metrics,
"当前规则": rule,
"漏检样例_应判不合格但未查出": item.false_negatives,
"误报样例_合格但误判不合格": item.false_positives,
"任务": (
"请优化该审查项的规则。漏检样例用于增强召回,误报样例用于补充合格边界或排除条件。"
"不要过拟合样例原文,要抽象为可泛化的合同审查规则。"
),
}
system_prompt = """
你是合同审查规则优化专家。你只输出 JSON,不输出解释性文字。
目标:
1. 基于漏检样例补充不合格判定条件,提高查全率。
2. 基于误报样例补充合格边界、排除条件和适用前提,提高查准率。
3. 保留原规则中仍然有效的核心约束,不要为了消除误报删除应检风险。
输出 JSON Schema:
{
"should_optimize": true,
"updated_rule": "优化后的审查规则完整文本",
"reason": "简要说明本次优化依据"
}
如果样例不足以支持修改,返回 should_optimize=false,并保持 updated_rule 和 reason 为空字符串。
""".strip()
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json_dumps(payload)},
]
async def optimize_rule_tasks(tasks: list[RuleTask], max_workers: int) -> list[dict[str, Any]]:
if not tasks:
return []
from core.config import LLM
from utils.openai_util import OpenAITool
tool = OpenAITool(LLM["base_tool_llm"], max_workers=max_workers)
responses = await tool.mul_chat([build_messages(task) for task in tasks])
results: list[dict[str, Any]] = []
for task, response in zip(tasks, responses):
parsed = parse_json_object(response)
results.append(
{
"task": task,
"raw_response": response,
"parsed": parsed,
}
)
return results
def backup_rules(rules_file: Path, output_dir: Path, round_idx: int) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
backup = output_dir / f"{rules_file.stem}.round{round_idx}.{time.strftime('%Y%m%d-%H%M%S')}{rules_file.suffix}"
shutil.copy2(rules_file, backup)
return backup
def update_rules_file(rules_file: Path, optimizations: list[dict[str, Any]]) -> list[dict[str, Any]]:
from openpyxl import load_workbook
wb = load_workbook(rules_file)
applied: list[dict[str, Any]] = []
for optimization in optimizations:
task: RuleTask = optimization["task"]
item = task.item
loc = task.location
parsed = optimization["parsed"]
if not parsed.get("should_optimize"):
continue
updated_rule = clean_text(parsed.get("updated_rule"))
if not updated_rule:
continue
ws = wb[loc.sheet]
headers = [clean_text(cell.value) for cell in ws[1]]
header_map = {header: idx + 1 for idx, header in enumerate(headers) if header}
if RULE_COL not in header_map:
continue
old_rule = clean_text(ws.cell(loc.row_idx, header_map[RULE_COL]).value)
ws.cell(loc.row_idx, header_map[RULE_COL], updated_rule)
applied.append(
{
"审查项": item.name,
"规则sheet": loc.sheet,
"规则行": loc.row_idx,
"旧审查规则": old_rule,
"新审查规则": updated_rule,
"优化理由": clean_text(parsed.get("reason")),
}
)
if applied:
wb.save(rules_file)
wb.close()
return applied
def run_batch() -> Path:
from data.batch import batch as batch_module
params = batch_module.get_params()
batch_module.execute_batch(params)
return Path(params["batch_output_dir_path"]).resolve()
def restart_app(session: str, command: str) -> None:
subprocess.run(
["tmux", "kill-session", "-t", session],
cwd=REPO_ROOT,
check=False,
)
tmux_command = f"cd {shell_quote(str(REPO_ROOT))} && {command}"
subprocess.run(
["tmux", "new-session", "-d", "-s", session, tmux_command],
cwd=REPO_ROOT,
check=True,
)
time.sleep(8)
def shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def run_eval(batch_output_dir: Path) -> Path:
from data.benchmark import eval as eval_module
val_dir = batch_output_dir.with_name(f"{batch_output_dir.name}-测评结果")
eval_module.eval(
datasets_dir=batch_output_dir,
answer_dir=BENCHMARK_DIR / "审查答案",
val_dir=val_dir,
strip_suffixes=["_麓发改进", "_人机交互", "_麓发迁移"],
)
return BENCHMARK_DIR / "results" / f"{val_dir.name}.xlsx"
def append_round_report(
report_file: Path,
round_idx: int,
batch_output_dir: Path,
eval_excel: Path,
backup_file: Path | None,
eval_items: list[EvalItem],
optimizations: list[dict[str, Any]],
applied: list[dict[str, Any]],
f1_threshold: float,
) -> None:
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font
report_file.parent.mkdir(parents=True, exist_ok=True)
wb = load_workbook(report_file) if report_file.exists() else Workbook()
if wb.active.title == "Sheet" and wb.active.max_row == 1 and wb.active["A1"].value is None:
wb.remove(wb.active)
used = set(wb.sheetnames)
sheet_name = safe_sheet_name(f"round_{round_idx}", used)
ws = wb.create_sheet(sheet_name)
headers = [
"轮次",
"时间",
"F1阈值",
"batch输出目录",
"eval Excel",
"规则备份",
"审查项",
"F1",
"查准率(B/B+D)",
"查全率(B/C)",
"误报率(D/B+D)",
"漏检样例数",
"误报样例数",
"是否优化",
"是否写回",
"规则位置",
"旧审查规则",
"新审查规则",
"优化理由",
"模型原始输出",
]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True)
applied_by_item = {(row["审查项"], row["规则sheet"], row["规则行"]): row for row in applied}
optimization_by_location = {
(opt["task"].item.name, opt["task"].location.sheet, opt["task"].location.row_idx): opt
for opt in optimizations
}
if not eval_items:
ws.append(
[
round_idx,
time.strftime("%Y-%m-%d %H:%M:%S"),
f1_threshold,
str(batch_output_dir),
str(eval_excel),
str(backup_file or ""),
"",
"",
"",
"",
"",
0,
0,
False,
False,
"",
"",
"",
"所有审查项均达到阈值或无可优化错误",
"",
]
)
for item in eval_items:
for loc in item.locations:
opt = optimization_by_location.get((item.name, loc.sheet, loc.row_idx), {})
parsed = opt.get("parsed", {})
raw_response = clean_text(opt.get("raw_response"))
applied_row = applied_by_item.get((item.name, loc.sheet, loc.row_idx), {})
ws.append(
[
round_idx,
time.strftime("%Y-%m-%d %H:%M:%S"),
f1_threshold,
str(batch_output_dir),
str(eval_excel),
str(backup_file or ""),
item.name,
item.metrics.get(F1_COL),
item.metrics.get("查准率(B/B+D)"),
item.metrics.get("查全率(B/C)"),
item.metrics.get("误报率(D/B+D)"),
len(item.false_negatives),
len(item.false_positives),
bool(parsed.get("should_optimize")),
bool(applied_row),
f"{loc.sheet}!{loc.row_idx}",
applied_row.get("旧审查规则", loc.row.get(RULE_COL)),
applied_row.get("新审查规则", parsed.get("updated_rule", "")),
applied_row.get("优化理由", parsed.get("reason", "")),
raw_response,
]
)
for row in ws.iter_rows():
for cell in row:
cell.alignment = Alignment(vertical="top", wrap_text=True)
wb.save(report_file)
wb.close()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Loop batch/eval and optimize data/rules.xlsx with LLM.")
parser.add_argument("--rules-file", type=Path, default=DEFAULT_RULES_FILE)
parser.add_argument("--report-file", type=Path, default=DEFAULT_REPORT_FILE)
parser.add_argument("--rounds", type=int, default=5)
parser.add_argument("--f1-threshold", type=float, default=0.9)
parser.add_argument("--example-limit", type=int, default=20)
parser.add_argument("--max-workers", type=int, default=DEFAULT_MAX_WORKERS)
parser.add_argument("--app-session", default=DEFAULT_APP_SESSION)
parser.add_argument("--app-command", default=DEFAULT_APP_COMMAND)
parser.add_argument(
"--skip-app-restart",
action="store_true",
help="Do not restart tmux app session before running batch.py.",
)
parser.add_argument(
"--batch-output",
type=Path,
# default='/data/home/htsc/jp-contract/data/benchmark/results/jp-output-20260618-163339-976082',
help="Use an existing batch output dir for the first round instead of running batch.py.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Run eval and LLM optimization but do not write updates to rules.xlsx.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
rules_file = args.rules_file.resolve()
report_file = args.report_file.resolve()
for round_idx in range(1, args.rounds + 1):
# 1. 重启程序 跑批量
if round_idx == 1 and args.batch_output:
batch_output_dir = args.batch_output.resolve()
else:
if not args.skip_app_restart:
restart_app(args.app_session, args.app_command)
batch_output_dir = run_batch()
print(f'round={round_idx} 完成批处理任务={batch_output_dir}', flush=True)
# 2. 测评结果分析,生成优化建议,写回规则文件,记录优化报告
eval_excel = run_eval(batch_output_dir)
eval_items = read_eval_items(
eval_excel=eval_excel,
rules_file=rules_file,
f1_threshold=args.f1_threshold,
example_limit=args.example_limit,
)
print(f'round={round_idx} 解析评测结果,待优化审查项数量={len(eval_items)}', flush=True)
rule_tasks = build_rule_tasks(eval_items)
optimizations = asyncio.run(optimize_rule_tasks(rule_tasks, max_workers=args.max_workers))
print(f'round={round_idx} 完成LLM优化建议生成,优化建议数量={len(optimizations)}', flush=True)
backup_file: Path | None = None
applied: list[dict[str, Any]] = []
if optimizations and not args.dry_run:
backup_file = backup_rules(rules_file, report_file.parent / "rule_backups", round_idx)
applied = update_rules_file(rules_file, optimizations)
#
append_round_report(
report_file=report_file,
round_idx=round_idx,
batch_output_dir=batch_output_dir,
eval_excel=eval_excel,
backup_file=backup_file,
eval_items=eval_items,
optimizations=optimizations,
applied=applied,
f1_threshold=args.f1_threshold,
)
print('*' * 80, flush=True)
if not eval_items:
break
return 0
if __name__ == "__main__":
raise SystemExit(main())
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
......@@ -18,19 +18,22 @@ from core.config import (
doc_support_formats,
pdf_support_formats,
MERGE_RULE_PROMPT,
FULL_TEXT_SEGMENT_ID,
use_lufa,
max_model_len
max_model_len,
debug_mode,
)
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.rules_retrieve_reference import RulesRetrieveReferenceTool
from core.tools.reflect_retry import ReflectRetryTool
from core.tools.segment_merger import SegmentMergerTool
from core.tools.fact_merger import FactMergerTool
from core.tools.ruleset_router import RulesetRouterTool
from core.tools.party_role import PartyRoleTool
from core.tools.segment_llm import LLMTool
from core.memory import Finding
from core.memory import FINDING_KEY_MERGE, FINDING_KEY_REFLECT, FINDING_KEY_REVIEW
......@@ -41,12 +44,13 @@ summary_tool = SegmentSummaryTool()
review_tool = SegmentReviewTool()
rule_router_tool = SegmentRuleRouterTool()
lufa_party_rule_filter_tool = LufaPartyRuleFilterTool()
reference_tool = RetrieveReferenceTool()
rules_reference_tool = RulesRetrieveReferenceTool()
reflect_tool = ReflectRetryTool()
merger_tool = SegmentMergerTool()
fact_merger_tool = FactMergerTool()
ruleset_router_tool = RulesetRouterTool()
party_role_tool = PartyRoleTool()
nanobot_llm_tool = LLMTool(llm_key="nanobot_llm")
@app.post("/sleep")
......@@ -57,6 +61,31 @@ def sleep(t: int):
return {"res": f"sleep over for {t} seconds."}
class NanobotLLMChatRequest(BaseModel):
session_id: str
user_message: str
class NanobotLLMChatResponse(BaseModel):
result: str
@app.post("/nanobot_llm_chat", response_model=NanobotLLMChatResponse)
async def nanobot_llm_chat(payload: NanobotLLMChatRequest) -> NanobotLLMChatResponse:
session_id = (payload.session_id or "").strip()
user_message = (payload.user_message or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id cannot be empty")
if not user_message:
raise HTTPException(status_code=400, detail="user_message cannot be empty")
result = await nanobot_llm_tool.chat_async(
nanobot_llm_tool.build_messages(user_message),
session_id=session_id,
)
return NanobotLLMChatResponse(result=result)
########################################################################################################################
......@@ -73,6 +102,7 @@ class DocumentParseResponse(BaseModel):
ruleset_items: List[str]
summary_names: List[str]
text: Optional[str] = None
text_file_path: Optional[str] = None
file_ext: Optional[str] = None
file_name: Optional[str] = None
......@@ -154,12 +184,14 @@ async def parse_document(payload: DocumentParseRequest) -> DocumentParseResponse
# ocr
await doc_obj.get_from_ocr()
text = doc_obj.get_all_text()
text_file_path = Path(file_path).with_suffix(".txt").resolve()
text_file_path.write_text(text or "", encoding="utf-8")
segment_ids = doc_obj.get_chunk_id_list()
# TODO: FastGPT BUG segment_ids必须从1开始,0开始会缺少第一段文本,后续需要修复
segment_ids = [idx + 1 for idx in segment_ids]
# get ruleset items
ruleset_id = payload.ruleset_id or reference_tool.default_ruleset_id
ruleset_items = reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
ruleset_id = payload.ruleset_id or rules_reference_tool.default_ruleset_id
ruleset_items = rules_reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
ruleset_review_items = [
t
for t in (r.get("title") for r in ruleset_items)
......@@ -168,13 +200,14 @@ async def parse_document(payload: DocumentParseRequest) -> DocumentParseResponse
summary_names = list(
dict.fromkeys(
s.strip()
for s in (r.get("summary") for r in ruleset_items)
for s in (r.get("summa y") for r in ruleset_items)
if isinstance(s, str) and s.strip()
)
)
return DocumentParseResponse(
conversation_id=payload.conversation_id,
text=text,
text_file_path=str(text_file_path),
segment_ids=segment_ids,
ruleset_items=ruleset_review_items,
summary_names=summary_names,
......@@ -222,21 +255,21 @@ def summarize_facts(payload: SegmentSummaryRequest) -> SegmentSummaryResponse:
detail=f"Segment text not found for id {payload.segment_id}: {exc}. Please parse document first.",
)
ruleset_id = payload.ruleset_id or reference_tool.default_ruleset_id
ruleset_id = payload.ruleset_id or rules_reference_tool.default_ruleset_id
if payload.routed_summary_names is not None:
summary_names = {
name.strip()
for name in payload.routed_summary_names
if isinstance(name, str) and name.strip()
}
all_rules = reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
all_rules = rules_reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
rules = [
rule
for rule in all_rules
if str(rule.get("summary", "")).strip() in summary_names
]
else:
rules = reference_tool.run(
rules = rules_reference_tool.run(
ruleset_id=ruleset_id,
routed_rule_titles=payload.routed_rule_titles,
).get("rules", [])
......@@ -261,11 +294,18 @@ def summarize_facts(payload: SegmentSummaryRequest) -> SegmentSummaryResponse:
class SegmentReviewRequest(BaseModel):
conversation_id: str
segment_id: int
segment_id: int = Field(
default=FULL_TEXT_SEGMENT_ID,
description="分段ID;为 FULL_TEXT_SEGMENT_ID 时使用 segment_text 审查全文",
)
segment_text: Optional[str] = Field(
default=None,
description="segment_id 为 FULL_TEXT_SEGMENT_ID 时用于审查的全文文本",
)
party_role: Optional[str] = ""
ruleset_id: Optional[str] = "通用"
routed_rule_titles: Optional[List[str]] = None
file_ext: str
file_ext: Optional[str] = None
context_memories: Optional[List[Dict]] = None
route_by: Literal["rule", "summary"] = Field(
default="rule", description="路由依据:rule=审查规则项,summary=摘要项"
......@@ -289,9 +329,27 @@ class SegmentRuleRouterResponse(BaseModel):
routed_rules: List[Dict]
@app.post("/segments/review/findings", response_model=SegmentReviewResponse)
def review_segment(payload: SegmentReviewRequest) -> SegmentReviewResponse:
store = get_cached_memory(payload.conversation_id)
def _resolve_review_segment(payload: SegmentReviewRequest):
if payload.segment_id == FULL_TEXT_SEGMENT_ID:
segment_text = (payload.segment_text or "").strip()
if not segment_text:
raise HTTPException(
status_code=400,
detail=f"segment_text cannot be empty when segment_id is {FULL_TEXT_SEGMENT_ID}",
)
return FULL_TEXT_SEGMENT_ID, segment_text, None
if payload.segment_id < FULL_TEXT_SEGMENT_ID:
raise HTTPException(
status_code=400,
detail=f"segment_id must be {FULL_TEXT_SEGMENT_ID} or greater",
)
if not payload.file_ext:
raise HTTPException(
status_code=400,
detail=f"file_ext is required when segment_id is not {FULL_TEXT_SEGMENT_ID}",
)
try:
doc_obj, _ = get_cached_doc_tool(payload.conversation_id, payload.file_ext)
except Exception as exc:
......@@ -307,14 +365,21 @@ def review_segment(payload: SegmentReviewRequest) -> SegmentReviewResponse:
status_code=404,
detail=f"Segment text not found for id {payload.segment_id}: {exc}. Please parse document first.",
)
return segment_idx, segment_text, doc_obj
ruleset_id = payload.ruleset_id or reference_tool.default_ruleset_id
rules = reference_tool.run(
@app.post("/segments/review/findings", response_model=SegmentReviewResponse)
def review_segment(payload: SegmentReviewRequest) -> SegmentReviewResponse:
store = get_cached_memory(payload.conversation_id)
segment_idx, segment_text, _ = _resolve_review_segment(payload)
ruleset_id = payload.ruleset_id or rules_reference_tool.default_ruleset_id
rules = rules_reference_tool.run(
ruleset_id=ruleset_id,
routed_rule_titles=payload.routed_rule_titles,
).get("rules", [])
# 暂时不添加摘要看下结果
# summary_keywords = reference_tool.summary_keywords(rules)
# summary_keywords = rules_reference_tool.summary_keywords(rules)
# context_summaries = store.search_facts(summary_keywords)
result = review_tool.run(
......@@ -361,24 +426,10 @@ def review_segment(payload: SegmentReviewRequest) -> SegmentReviewResponse:
@app.post("/segments/review/rule-router", response_model=SegmentRuleRouterResponse)
def route_segment_rules(payload: SegmentReviewRequest) -> SegmentRuleRouterResponse:
try:
doc_obj, _ = get_cached_doc_tool(payload.conversation_id, payload.file_ext)
except Exception as exc:
raise HTTPException(
status_code=400, detail=f"Document tool not available: {exc}"
)
segment_idx = payload.segment_id - 1
try:
segment_text = doc_obj.get_chunk_item(segment_idx)
except Exception as exc:
raise HTTPException(
status_code=404,
detail=f"Segment text not found for id {payload.segment_id}: {exc}. Please parse document first.",
)
segment_idx, segment_text, doc_obj = _resolve_review_segment(payload)
ruleset_id = payload.ruleset_id or reference_tool.default_ruleset_id
rules = reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
ruleset_id = payload.ruleset_id or rules_reference_tool.default_ruleset_id
rules = rules_reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
if use_lufa and rules:
try:
......@@ -423,12 +474,23 @@ class ReflectReviewRequest(BaseModel):
rule_title: str
class RuleStringifyRequest(BaseModel):
ruleset_id: Optional[str] = "通用"
rule_title: str
class ReflectReviewResponse(BaseModel):
conversation_id: str
rule_title: str
findings: List[Dict]
class RuleStringifyResponse(BaseModel):
ruleset_id: str
rule_title: str
rule_string: str
class MergerRequest(BaseModel):
conversation_id: str
segment_id: int
......@@ -454,11 +516,30 @@ class FactsMergerResponse(BaseModel):
merge_facts: Dict
@app.post("/rules/stringify", response_model=RuleStringifyResponse)
def stringify_rule(payload: RuleStringifyRequest) -> RuleStringifyResponse:
ruleset_id = payload.ruleset_id or rules_reference_tool.default_ruleset_id
ruleset_items = rules_reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
rule = next(
(r for r in ruleset_items if r.get("title") == payload.rule_title), None
)
if not rule:
raise HTTPException(
status_code=404, detail=f"Rule not found: {payload.rule_title}"
)
return RuleStringifyResponse(
ruleset_id=ruleset_id,
rule_title=payload.rule_title,
rule_string=reflect_tool._stringify_rule(rule),
)
@app.post("/segments/review/reflect", response_model=ReflectReviewResponse)
def reflect_review(payload: ReflectReviewRequest) -> ReflectReviewResponse:
store = get_cached_memory(payload.conversation_id)
ruleset_id = payload.ruleset_id or reference_tool.default_ruleset_id
ruleset_items = reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
ruleset_id = payload.ruleset_id or rules_reference_tool.default_ruleset_id
ruleset_items = rules_reference_tool.run(ruleset_id=ruleset_id).get("rules", [])
rule = next(
(r for r in ruleset_items if r.get("title") == payload.rule_title), None
)
......@@ -466,7 +547,7 @@ def reflect_review(payload: ReflectReviewRequest) -> ReflectReviewResponse:
raise HTTPException(
status_code=404, detail=f"Rule not found: {payload.rule_title}"
)
summary_keywords = reference_tool.summary_keywords([rule])
summary_keywords = rules_reference_tool.summary_keywords([rule])
context_summaries_facts = store.search_facts(summary_keywords)
# 查找审查规则对应的 findings
findings = [
......@@ -710,4 +791,6 @@ if __name__ == "__main__":
port = 18168
else:
port = 18169
if debug_mode:
port += 10
uvicorn.run("main:app", host="0.0.0.0", port=port, log_level="info", reload=False)
......@@ -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())
#!/usr/bin/env python3
"""Standalone Excel CLI for table reads, row edits, and JSON sheet writes."""
from __future__ import annotations
import argparse
import csv
import json
import string
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "rel": "http://schemas.openxmlformats.org/package/2006/relationships"}
class ExcelLoadError(Exception):
pass
def _json(v: Any) -> None:
print(json.dumps(v, ensure_ascii=False, indent=2))
def _load_json(v: str) -> Any:
return json.loads(Path(v[1:]).read_text(encoding="utf-8") if v.startswith("@") else v)
def _col_idx(ref: str) -> int:
n = 0
for ch in "".join(c for c in ref if c in string.ascii_letters).upper():
n = n * 26 + ord(ch) - 64
return max(n - 1, 0)
def _rows_to_result(rows: list, header: bool) -> list:
if not rows:
return []
if not header:
return [list(r) for r in rows]
heads = [str(h).strip() if h is not None else "" for h in rows[0]]
return [{heads[i] if i < len(heads) else f"col{i}": row[i] for i in range(len(row))} for row in rows[1:]]
def _sheet_map(zf: zipfile.ZipFile) -> list[tuple[str, str]]:
wb = ET.fromstring(zf.read("xl/workbook.xml"))
rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
rel_map = {r.attrib["Id"]: r.attrib["Target"] for r in rels.findall("rel:Relationship", NS)}
out = []
for s in wb.findall(".//a:sheets/a:sheet", NS):
target = rel_map.get(s.attrib.get(f"{{{NS['r']}}}id", ""), "")
out.append((s.attrib.get("name", ""), "xl/" + target.lstrip("/") if not target.startswith("xl/") else target))
return out
def _shared(zf: zipfile.ZipFile) -> list[str]:
try:
root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
except KeyError:
return []
return ["".join(t.text or "" for t in item.findall(".//a:t", NS)) for item in root.findall(".//a:si", NS)]
def _load_std_xlsx(path: Path, sheet: str | None, header: bool) -> list:
with zipfile.ZipFile(path) as zf:
shared, sheets = _shared(zf), _sheet_map(zf)
if not sheets:
return []
sheet_path = next((p for n, p in sheets if n == sheet), sheets[0][1])
root = ET.fromstring(zf.read(sheet_path))
rows = []
for r in root.findall(".//a:sheetData/a:row", NS):
values = []
for c in r.findall("a:c", NS):
while len(values) < _col_idx(c.attrib.get("r", "")):
values.append(None)
raw = (c.find("a:v", NS).text if c.find("a:v", NS) is not None else None)
values.append(shared[int(raw)] if c.attrib.get("t") == "s" and raw is not None and int(raw) < len(shared) else raw)
rows.append(values)
return _rows_to_result(rows, header)
def load_excel(path: str, sheet: str | None = None, header: bool = True) -> list:
p = Path(path)
if p.suffix.lower() in {".csv", ".tsv"}:
with p.open(newline="", encoding="utf-8-sig", errors="replace") as f:
return _rows_to_result(list(csv.reader(f, delimiter="\t" if p.suffix.lower() == ".tsv" else ",")), header)
try:
import openpyxl # type: ignore
wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
ws = wb[sheet] if sheet else wb.active
return _rows_to_result(list(ws.iter_rows(values_only=True)), header)
except ImportError:
if p.suffix.lower() != ".xlsx":
raise ExcelLoadError("openpyxl is required for non-xlsx files")
return _load_std_xlsx(p, sheet, header)
def list_sheets(path: str) -> list[str]:
try:
import openpyxl # type: ignore
return openpyxl.load_workbook(path, read_only=True).sheetnames
except ImportError:
with zipfile.ZipFile(path) as zf:
return [n for n, _ in _sheet_map(zf)]
def _load_workbook_for_write(path: str):
try:
import openpyxl # type: ignore
except ImportError as exc:
raise ExcelLoadError("openpyxl is required for write operations") from exc
return openpyxl.load_workbook(path)
def _get_sheet(wb: Any, sheet: str | None):
return wb[sheet] if sheet else wb.active
def _headers(ws: Any) -> list[str]:
return [str(cell.value).strip() if cell.value is not None else "" for cell in ws[1]]
def _header_map(ws: Any) -> dict[str, int]:
return {header: idx for idx, header in enumerate(_headers(ws), start=1) if header}
def _ensure_header_columns(ws: Any, keys: list[str]) -> dict[str, int]:
header_map = _header_map(ws)
next_col = ws.max_column + 1
for key in keys:
if key in header_map:
continue
ws.cell(row=1, column=next_col, value=key)
header_map[key] = next_col
next_col += 1
return header_map
def _row_dict_from_ws(ws: Any, row_idx: int, headers: list[str]) -> dict[str, Any]:
return {
header: ws.cell(row=row_idx, column=col_idx).value
for col_idx, header in enumerate(headers, start=1)
if header
}
def rows_as_dicts(path: str, sheet: str | None = None) -> list[dict[str, Any]]:
return search_rows(path, sheet, {})
def _require_dict(value: Any, name: str = "row") -> dict[str, Any]:
if not isinstance(value, dict):
raise ExcelLoadError(f"{name} must be a JSON object")
return value
def append_row(path: str, sheet: str | None, row_data: dict[str, Any]) -> dict[str, Any]:
wb = _load_workbook_for_write(path)
ws = _get_sheet(wb, sheet)
header_map = _ensure_header_columns(ws, [str(key) for key in row_data.keys()])
row_idx = ws.max_row + 1
for key, value in row_data.items():
ws.cell(row=row_idx, column=header_map[str(key)], value=value)
wb.save(path)
return {"file": path, "sheet": ws.title, "row": row_idx, "inserted": row_data}
def _matched_row_indices(ws: Any, criteria: dict[str, Any]) -> list[int]:
header_map = _header_map(ws)
missing_keys = [key for key in criteria if key not in header_map]
if missing_keys:
raise ExcelLoadError(f"criteria keys not found in header: {', '.join(missing_keys)}")
matched: list[int] = []
for row_idx in range(2, ws.max_row + 1):
if all(ws.cell(row=row_idx, column=header_map[key]).value == value for key, value in criteria.items()):
matched.append(row_idx)
return matched
def search_rows(path: str, sheet: str | None, criteria: dict[str, Any]) -> list[dict[str, Any]]:
rows = [row for row in load_excel(path, sheet=sheet, header=True) if isinstance(row, dict)]
if not criteria:
return rows
if rows:
missing_keys = [key for key in criteria if key not in rows[0]]
if missing_keys:
raise ExcelLoadError(f"criteria keys not found in header: {', '.join(missing_keys)}")
return [row for row in rows if all(row.get(key) == value for key, value in criteria.items())]
def update_rows(path: str, sheet: str | None, criteria: dict[str, Any], row_data: dict[str, Any]) -> dict[str, Any]:
if not criteria:
raise ExcelLoadError("criteria must not be empty for update_rows")
wb = _load_workbook_for_write(path)
ws = _get_sheet(wb, sheet)
row_indices = _matched_row_indices(ws, criteria)
if not row_indices:
return {"file": path, "sheet": ws.title, "updated": False, "rows": []}
update_keys = [str(key) for key in row_data.keys()]
header_map = _ensure_header_columns(ws, update_keys)
for row_idx in row_indices:
for key, value in row_data.items():
key = str(key)
ws.cell(row=row_idx, column=header_map[key], value=value)
wb.save(path)
return {"file": path, "sheet": ws.title, "updated": True, "rows": row_indices, "count": len(row_indices)}
def delete_rows(path: str, sheet: str | None, criteria: dict[str, Any]) -> dict[str, Any]:
if not criteria:
raise ExcelLoadError("criteria must not be empty for delete_rows")
wb = _load_workbook_for_write(path)
ws = _get_sheet(wb, sheet)
row_indices = _matched_row_indices(ws, criteria)
if not row_indices:
return {"file": path, "sheet": ws.title, "deleted": False, "rows": []}
for row_idx in sorted(row_indices, reverse=True):
ws.delete_rows(row_idx, 1)
wb.save(path)
return {"file": path, "sheet": ws.title, "deleted": True, "rows": row_indices, "count": len(row_indices)}
def _cell(v: Any) -> str:
return json.dumps(v, ensure_ascii=False, indent=2) if isinstance(v, (dict, list)) else ("" if v is None else str(v))
def _json_rows(data: Any) -> tuple[list[str], list[list[Any]]]:
if isinstance(data, dict):
headers = [str(key) for key in data.keys()]
return headers, [[data[key] for key in data.keys()]]
if not isinstance(data, list):
return ["value"], [[data]]
if not data:
return [], []
if all(isinstance(item, dict) for item in data):
headers: list[str] = []
for item in data:
for key in item.keys():
key = str(key)
if key not in headers:
headers.append(key)
rows = [[item.get(header) for header in headers] for item in data]
return headers, rows
if all(isinstance(item, (list, tuple)) for item in data):
max_len = max(len(item) for item in data)
headers = [f"col{idx + 1}" for idx in range(max_len)]
rows = [list(item) + [None] * (max_len - len(item)) for item in data]
return headers, rows
return ["value"], [[item] for item in data]
def json_to_sheet(data: Any, out: str, sheet: str = "Sheet1") -> str:
try:
from openpyxl import Workbook, load_workbook # type: ignore
from openpyxl.styles import Alignment, Font # type: ignore
except ImportError as exc:
raise ExcelLoadError("openpyxl is required for json-to-sheet") from exc
output_path = Path(out)
wb = load_workbook(output_path) if output_path.exists() else Workbook()
if sheet in wb.sheetnames:
old_sheet = wb[sheet]
old_index = wb.sheetnames.index(sheet)
wb.remove(old_sheet)
ws = wb.create_sheet(sheet, old_index)
else:
ws = wb.active if wb.active.title == "Sheet" and wb.active.max_row == 1 and wb.active.max_column == 1 and wb.active["A1"].value is None else wb.create_sheet(sheet)
ws.title = sheet
headers, rows = _json_rows(data)
for col_idx, header in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = Font(bold=True)
for row_idx, row in enumerate(rows, start=2):
for col_idx, value in enumerate(row, start=1):
ws.cell(row=row_idx, column=col_idx, value=_cell(value))
if headers or rows:
for row in ws.iter_rows(
min_row=1,
max_row=max(1, len(rows) + 1),
max_col=max(1, len(headers)),
):
for cell in row:
cell.alignment = Alignment(vertical="top", wrap_text=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
wb.save(output_path)
return str(output_path)
def main() -> int:
p = argparse.ArgumentParser(description="Standalone Excel CLI"); sub = p.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("load-excel"); a.add_argument("file"); a.add_argument("--sheet-name"); a.add_argument("--no-header", action="store_true")
a = sub.add_parser("list-sheets"); a.add_argument("file")
a = sub.add_parser("search_rows"); a.add_argument("file"); a.add_argument("criteria", nargs="?", default="{}"); a.add_argument("--sheet-name")
a = sub.add_parser("append-row"); a.add_argument("file"); a.add_argument("row"); a.add_argument("--sheet-name")
a = sub.add_parser("update-rows"); a.add_argument("file"); a.add_argument("criteria"); a.add_argument("row"); a.add_argument("--sheet-name")
a = sub.add_parser("delete-rows"); a.add_argument("file"); a.add_argument("criteria"); a.add_argument("--sheet-name")
a = sub.add_parser("find-value"); a.add_argument("file"); a.add_argument("key_column"); a.add_argument("key_value"); a.add_argument("value_column"); a.add_argument("--sheet-name")
a = sub.add_parser("map-rows"); a.add_argument("file"); a.add_argument("column_map"); a.add_argument("--sheet-name")
a = sub.add_parser("json-to-sheet"); a.add_argument("json_data"); a.add_argument("output"); a.add_argument("--sheet-name", default="Sheet1")
x = p.parse_args()
if x.cmd == "load-excel": _json(load_excel(x.file, x.sheet_name, not x.no_header))
elif x.cmd == "list-sheets": _json(list_sheets(x.file))
elif x.cmd == "search_rows": _json(search_rows(x.file, x.sheet_name, _require_dict(_load_json(x.criteria), "criteria")))
elif x.cmd == "append-row": _json(append_row(x.file, x.sheet_name, _require_dict(_load_json(x.row))))
elif x.cmd == "update-rows": _json(update_rows(x.file, x.sheet_name, _require_dict(_load_json(x.criteria), "criteria"), _require_dict(_load_json(x.row))))
elif x.cmd == "delete-rows": _json(delete_rows(x.file, x.sheet_name, _require_dict(_load_json(x.criteria), "criteria")))
elif x.cmd == "find-value": _json(next((r.get(x.value_column) for r in load_excel(x.file, x.sheet_name) if isinstance(r, dict) and r.get(x.key_column) == x.key_value), None))
elif x.cmd == "map-rows": _json([{k: r.get(v) for k, v in json.loads(x.column_map).items()} for r in load_excel(x.file, x.sheet_name) if isinstance(r, dict)])
elif x.cmd == "json-to-sheet": print(json_to_sheet(_load_json(x.json_data), x.output, x.sheet_name))
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
"""System prompts for review-llm-skill."""
from __future__ import annotations
REVIEW_SYSTEM_PROMPT = """
你是一个专业的合同分段审查智能体(SegmentReview)。
你的任务是:基于给定审查规则,对“当前分段”进行审查,识别其中与规则相关且证据充分的条款,并判断其结果为“合格”或“不合格”,输出审查结论及必要的修改建议。
【审查范围】
你只能审查当前分段自身已经明确体现的内容。
你只能识别以下两类结果:
1. 合格条款:当前分段中存在与审查规则相关的明确表述,且该表述符合规则要求;
2. 不合格条款:当前分段中存在与审查规则相关的明确表述,且该表述不符合规则要求,例如:对我方不利、表述不清、逻辑冲突、责任失衡、触发条件不明确、关键限制缺失等。
【审查原则】
- 严格基于给定的审查规则进行审查,不得脱离规则自行扩展审查标准。
- 只审查当前分段原文,不得使用上下文信息补充、修正或推断当前分段含义。
- 优先识别“确定成立”的合格或不合格结论,不输出模糊怀疑类表述。
【完整性要求(非常重要)】
你必须对当前分段进行“穷举式审查”,不得只输出部分结果。
执行方式:
- 应逐句扫描当前分段
- 对每一句或关键子句,判断其是否与审查规则相关
- 只要存在证据充分的问题或合格表述,必须全部列出,不得遗漏
特别要求:
- 不得因为已找到1条或少量finding而提前停止
- 若一个段落中存在多处问题,必须分别输出多个 findings
- findings 数量应与段落中实际存在的问题数量大致一致,不得明显偏少
错误示例(禁止):
- 一个段落有多个风险点,但只输出1条
正确行为:
- 覆盖所有可以独立成立的审查点
【结果判定规则】
- result 只能取以下两个值之一:
- "合格":当前分段存在与规则相关的明确内容,且符合该规则要求;
- "不合格":当前分段存在与规则相关的明确内容,且不符合该规则要求。
- 如果当前分段与某条审查规则无关,或虽疑似相关但证据不足,则不得生成 finding。
【证据要求】
每个 findings 都必须包含 original_text,且必须是合同原文的直接引用。
【单一证据约束(非常重要)】
每一个 finding 必须只对应一个“独立判断点”和一个“最小证据句”。
具体要求:
- 一个 finding 只能基于一个关键句或一个最小语义单元;
- 若多个句子分别支持不同问题,必须拆分为多个 findings;
- 严禁将多个不同问题合并为一个 finding;
- 严禁在 original_text 中拼接多个不连续句子作为证据;
- 若 original_text 涉及跨句或跨段内容,必须拆分为多个 findings。
判断标准:
- 如果去掉 original_text 中的一部分,仍能形成一个独立判断 → 说明应该拆分
【issue 要求】
- issue 必须说明:该条款为什么合格或为什么不合格。
- 当 result="合格" 时,issue 应说明该表述满足了什么规则要求、为什么可认定为合格。
- 当 result="不合格" 时,issue 应说明该表述违反了什么规则要求、为什么构成风险或缺陷。
- issue 必须紧扣规则和原文,不得空泛评价。
【建议要求】
- suggestion 必须具体、可执行。
- 当 result="不合格" 时:
- 若能在当前分段内直接修正,请给出可直接替换或新增的条款措辞;
- 若无法直接改写,请给出明确修改方向和应补充的关键要素;
- 不得只写“建议协商”“建议完善”等空泛表述。
- 当 result="合格" 时:
- suggestion 应简洁填写,可写“无需修改”;
- 不得为了凑内容而提出与审查结论无关的修改建议。
【输出约束】
- 严格按照指定 JSON Schema 输出。
- 不得输出任何 JSON 之外的解释性文字。
- 若未发现证据充分的合格或不合格条款,返回 {"findings": []}。
在生成最终 JSON 之前,你必须执行以下内部步骤(不输出):
Step A:将当前分段拆分为若干句子或语义单元
Step B:逐句判断该句是否涉及任一审查规则
Step C:若涉及规则,判断其为合格或不合格
Step D:为每一个成立的判断生成一个 finding
只有完成上述穷举后,才允许输出最终结果
提示:在合同审查中,一个分段通常可能包含多个独立风险点或合规点,findings 数量通常大于1,除非该段确实只涉及单一事项。
【输出格式】
[
{
"rule_name": "审查项名称",
"result": "合格 或 不合格",
"issue": "基于规则和原文说明为什么合格或不合格",
"original_text": "待处理文本中的最小证据原文",
"suggestion": "合格时填写“无需修改”;不合格时填写具体、可执行的修改建议"
}
]
"""
REFLECT_SYSTEM_PROMPT = """
你是一个合同审查反思智能体(ReviewReflection)。
你的任务不是从零重新审查合同,也不是简单删减 findings,
而是基于“已有 findings、当前审查规则、合同全文、合同摘要事实记忆”,
对 findings 进行规则内复核、去重、校正、拆分、合并与定稿,输出最终 final_findings。
【你的角色定位】
你是“终审校准器”,不是“初审生成器”。
你的目标是让 final_findings 同时满足以下要求:
1. 与当前审查规则严格相关;
2. 能被合同全文直接支持;
3. 不重复、不冲突;
4. 表述准确、建议可执行;
5. 对已有 findings 中已经涉及的规则问题做到完整定稿,而不是机械保留或机械删除。
【允许执行的操作】
你只能在“已有 findings 已涉及的规则范围内”做以下处理:
1. 删除重复 findings;
2. 删除证据不足、引用不当、不能由合同原文直接支持的 findings;
3. 删除超出当前审查规则范围的 findings;
4. 修订 issue、result、original_text 或 suggestion 不准确的 findings;
5. 合并多个指向同一原文实质问题的 findings;
6. 拆分一个同时包含多个独立问题的 finding,将其改写为多个 final findings;
当出现以下情况时,必须拆分:
- original_text 包含多个句子或多个不连续片段;
- 一个 finding 的 issue 实际对应多个独立风险点;
- 不同句子分别支撑不同判断;
拆分要求:
- 每个拆分后的 finding 只保留一个独立问题;
- 每个 finding 的 original_text 只引用一个最小充分证据句;
- 不得在一个 finding 中保留多个证据来源;
7. 基于合同全文对已有 findings 做必要校正;
8. 在不扩展新审查维度的前提下,对已有 findings 中已经涉及但表达混杂、粒度过粗、遗漏独立结论的内容进行重组和细化。
【结构违规检测(必须执行)】
你必须检查每一个已有 finding 是否违反以下结构规则:
1. original_text 是否超过一个句子?
2. 是否包含多个不连续文本片段?
3. issue 是否描述了多个问题?
4. suggestion 是否同时针对多个问题?
如果任一为“是”,则该 finding 必须被拆分为多个 final findings。
【禁止事项】
你不得:
- 脱离当前审查规则新增全新的审查维度;
- 凭空创造合同中不存在的事实;
- 仅因措辞保守就删除一个本来成立的 finding;
- 仅因已有 findings 数量较多就刻意压缩结果数量;
- 输出无法由合同原文直接支持的结论;
- 输出模糊、空泛、不可执行的 suggestion。
【核心判定原则】
- findings 只是候选结论,不当然等于最终结论;
- final result 必须以“当前审查规则 + 合同全文 + 合同立场”为准;
- 每条 final finding 必须能被合同原文直接支持;
- original_text 必须是能够直接支撑该 finding 的最小充分证据片段;
- result 只能为“合格”或“不合格”;
- 若 result 为“合格”,suggestion 必须填写“无需修改”;
- 若 result 为“不合格”,suggestion 必须具体、可执行,优先给出可直接替换或新增的条款表述;若无法安全直接改写,则明确指出应补充的关键要素。
【全文校正规则(非常重要)】
你必须结合合同全文检查每条已有 finding 是否存在以下情况:
1. 该问题在合同其他部分已有明确补充、限制、例外或纠正;
2. 该 finding 对原文存在断章取义;
3. 该 finding 忽略了适用条件、前提、例外或定义;
4. 该 finding 的 original_text 不能直接支撑其 issue 或 result;
5. 该 finding 与合同立场下的风险判断不一致;
6. 两条 findings 看似不同,但实质上指向同一风险;
7. 一条 finding 看似一条,实际上包含多个独立成立的判断,应拆分。
若合同全文已经对某一已有风险作出充分补正或限制,导致该 finding 不再成立,则应删除或修订,而不是机械保留。
【完整性要求】
反思的目标不是尽量减少 findings,而是输出“准确、去重、完整”的 final_findings。
如果已有 findings 中实际上包含多个独立成立的问题,必须在 final_findings 中完整呈现,不得因为反思阶段而无故收缩为1条。
【内部执行步骤(不得输出)】
在输出最终 JSON 前,你必须完成以下内部步骤:
Step 1:逐条审阅已有 findings,判断其是否仍成立;
Step 2:检查每条 finding 是否与当前规则相关,是否有合同原文直接支持;
Step 3:结合合同全文核验该 finding 是否被其他条款补充、限制、修正或否定;
Step 4:识别重复项、交叉项、包含多个问题的混合项;
Step 5:对 findings 进行删除、修订、合并或拆分;
Step 6:确保 final_findings 中每一条都可独立成立,且合并后不遗漏已有 findings 所涉及的有效问题;
Step 7:再输出最终 JSON。
【输出约束】
- 严格输出 JSON;
- 不得输出任何解释性文字;
- 若反思后无成立 findings,返回 {"final_findings": []}。
在输出 final_findings 前,你必须逐条自检(不输出):
1. 这条 finding 是否仍在当前审查规则范围内?
2. original_text 是否真的能直接支持 issue 和 result?
3. issue 是否准确说明了为什么合格/不合格?
4. 是否被合同全文其他条款补正、限制或推翻?
5. 是否与其他 finding 重复?
6. 是否其实包含多个独立问题,需要拆分?
7. suggestion 是否具体、可执行、与 result 一致?
8. 若 result=合格,suggestion 是否为“无需修改”?
9. 删除、合并、拆分后,是否遗漏了已有 findings 中本来成立的有效问题?
【输出格式】
[
{
"rule_name": "审查项名称",
"result": "合格 或 不合格",
"issue": "复核后的准确风险或合格说明",
"original_text": "合同原文中的最小证据片段",
"suggestion": "可直接替换原文、新增条款措辞,或明确的修改方向"
}
]
"""
SUMMARY_SYSTEM_PROMPT = """
你是合同事实提取智能体(SegmentSummary)。
你的任务是:**基于给定的审查规则,从当前合同分段中提取“与该规则直接相关的客观事实”,并结构化输出。**
【核心原则】
你必须严格围绕“规则所需信息”进行提取。
---
【事实定义】
事实必须满足:
1. 可以在当前分段原文中直接找到对应表述;
2. 不得对原文进行抽象、概括或推断;
3. 不得补充未出现的主体、条件或数值;
4. 允许对原文做最小结构化拆分(例如金额、比例、期限)。
---
【规则驱动提取要求(关键)】
- 仅提取“该审查规则执行所需要的信息字段”
- 不得提取与该规则无关的信息(即使这些信息在文本中存在)
- 若规则未涉及某类信息,则不得输出对应字段
- 若规则涉及某字段但文本未出现,需显式标记为 "未明确"
---
【输出结构】
- 输出字段:facts
- facts 是一个对象
- 键必须来自【规则字段定义(rule_fields)】
- 不得使用预设通用维度(如“支付/违约责任”等)
---
【字段填充规则】
- 每个字段值必须是对象或对象列表
- 不得输出字符串作为字段值
- 字段内容必须为原文的最小结构化表达
- 不得改写原文含义
---
【缺失信息处理(非常重要)】
- 若规则要求的字段在当前分段未出现:
→ 必须输出该字段,并标记为:
"未明确"
(用于后续审查判断)
---
【约束】
- 严禁编造信息
- 严禁推断未出现的内容
- 不得输出风险判断或解释
- 严格输出 JSON
【输出格式示例】
```json
{
"facts": {
"支付审查": {"方式": "银行转账", "时间": "验收后30日内"},
"违约责任审查": {"违约金比例": "合同总金额的5%"}
}
}
```
"""
ROUTER_SYSTEM_PROMPT = """
你是合同分段规则路由智能体(SegmentRuleRouter)。
你的任务是:基于“当前分段文本”,从候选审查规则中选出“应执行审查”的规则项。
【路由目标】
- 仅做规则适配判断,不输出风险结论、不输出审查建议。
- 高召回优先:只要当前分段与规则存在明确相关性,就应路由命中。
- 若候选规则明显无关,则不要命中。
【判断依据】
- 以当前分段文本为主。
- 可参考上下文记忆辅助理解术语,但不得脱离当前分段文本做臆断。
【输出约束】
- 严格输出 JSON。
- 每个命中规则需给出简短 reason,说明该分段为何与规则相关。
- 若确实没有任何相关规则,返回 {"selected_items": []}。
【输出格式示例】
```json
{
"selected_items": [
{
"title": "规则标题",
"reason": "命中原因(简短)"
}
]
}
```
"""
MERGE_SYSTEM_PROMPT = """
你将收到同一组 findings 的 issue 与 suggestion 列表,请做信息融合而非机械拼接。
要求:
1. 输入中已经包含同组条款原文`original_text`,请仅将其作为分析依据。
2. `issue`:提炼并合并组内风险点,去重、保留关键信息,语言精炼。
3. `suggestion`:合并为一条可执行建议,必须基于输入原文的具体表述来给出,避免空泛、泛化或与原文脱节,必要时按“先补充条款、再明确标准”这类逻辑组织。
4. 禁止输出与输入无关的信息。
【输出格式示例】
```json
{
"issue": "提炼合并后的风险点",
"suggestion": "提炼合并后的建议"
}
```
"""
\ No newline at end of file
#!/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 不被关闭。
#!/usr/bin/env python3
"""Standalone Excel CLI for table reads, row edits, and JSON sheet writes."""
from __future__ import annotations
import argparse
import csv
import json
import string
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "rel": "http://schemas.openxmlformats.org/package/2006/relationships"}
class ExcelLoadError(Exception):
pass
def _json(v: Any) -> None:
print(json.dumps(v, ensure_ascii=False, indent=2))
def _load_json(v: str) -> Any:
return json.loads(Path(v[1:]).read_text(encoding="utf-8") if v.startswith("@") else v)
def _col_idx(ref: str) -> int:
n = 0
for ch in "".join(c for c in ref if c in string.ascii_letters).upper():
n = n * 26 + ord(ch) - 64
return max(n - 1, 0)
def _rows_to_result(rows: list, header: bool) -> list:
if not rows:
return []
if not header:
return [list(r) for r in rows]
heads = [str(h).strip() if h is not None else "" for h in rows[0]]
return [{heads[i] if i < len(heads) else f"col{i}": row[i] for i in range(len(row))} for row in rows[1:]]
def _sheet_map(zf: zipfile.ZipFile) -> list[tuple[str, str]]:
wb = ET.fromstring(zf.read("xl/workbook.xml"))
rels = ET.fromstring(zf.read("xl/_rels/workbook.xml.rels"))
rel_map = {r.attrib["Id"]: r.attrib["Target"] for r in rels.findall("rel:Relationship", NS)}
out = []
for s in wb.findall(".//a:sheets/a:sheet", NS):
target = rel_map.get(s.attrib.get(f"{{{NS['r']}}}id", ""), "")
out.append((s.attrib.get("name", ""), "xl/" + target.lstrip("/") if not target.startswith("xl/") else target))
return out
def _shared(zf: zipfile.ZipFile) -> list[str]:
try:
root = ET.fromstring(zf.read("xl/sharedStrings.xml"))
except KeyError:
return []
return ["".join(t.text or "" for t in item.findall(".//a:t", NS)) for item in root.findall(".//a:si", NS)]
def _load_std_xlsx(path: Path, sheet: str | None, header: bool) -> list:
with zipfile.ZipFile(path) as zf:
shared, sheets = _shared(zf), _sheet_map(zf)
if not sheets:
return []
sheet_path = next((p for n, p in sheets if n == sheet), sheets[0][1])
root = ET.fromstring(zf.read(sheet_path))
rows = []
for r in root.findall(".//a:sheetData/a:row", NS):
values = []
for c in r.findall("a:c", NS):
while len(values) < _col_idx(c.attrib.get("r", "")):
values.append(None)
raw = (c.find("a:v", NS).text if c.find("a:v", NS) is not None else None)
values.append(shared[int(raw)] if c.attrib.get("t") == "s" and raw is not None and int(raw) < len(shared) else raw)
rows.append(values)
return _rows_to_result(rows, header)
def load_excel(path: str, sheet: str | None = None, header: bool = True) -> list:
p = Path(path)
if p.suffix.lower() in {".csv", ".tsv"}:
with p.open(newline="", encoding="utf-8-sig", errors="replace") as f:
return _rows_to_result(list(csv.reader(f, delimiter="\t" if p.suffix.lower() == ".tsv" else ",")), header)
try:
import openpyxl # type: ignore
wb = openpyxl.load_workbook(p, data_only=True, read_only=True)
ws = wb[sheet] if sheet else wb.active
return _rows_to_result(list(ws.iter_rows(values_only=True)), header)
except ImportError:
if p.suffix.lower() != ".xlsx":
raise ExcelLoadError("openpyxl is required for non-xlsx files")
return _load_std_xlsx(p, sheet, header)
def list_sheets(path: str) -> list[str]:
try:
import openpyxl # type: ignore
return openpyxl.load_workbook(path, read_only=True).sheetnames
except ImportError:
with zipfile.ZipFile(path) as zf:
return [n for n, _ in _sheet_map(zf)]
def _load_workbook_for_write(path: str):
try:
import openpyxl # type: ignore
except ImportError as exc:
raise ExcelLoadError("openpyxl is required for write operations") from exc
return openpyxl.load_workbook(path)
def _get_sheet(wb: Any, sheet: str | None):
return wb[sheet] if sheet else wb.active
def _headers(ws: Any) -> list[str]:
return [str(cell.value).strip() if cell.value is not None else "" for cell in ws[1]]
def _header_map(ws: Any) -> dict[str, int]:
return {header: idx for idx, header in enumerate(_headers(ws), start=1) if header}
def _ensure_header_columns(ws: Any, keys: list[str]) -> dict[str, int]:
header_map = _header_map(ws)
next_col = ws.max_column + 1
for key in keys:
if key in header_map:
continue
ws.cell(row=1, column=next_col, value=key)
header_map[key] = next_col
next_col += 1
return header_map
def _row_dict_from_ws(ws: Any, row_idx: int, headers: list[str]) -> dict[str, Any]:
return {
header: ws.cell(row=row_idx, column=col_idx).value
for col_idx, header in enumerate(headers, start=1)
if header
}
def rows_as_dicts(path: str, sheet: str | None = None) -> list[dict[str, Any]]:
return search_rows(path, sheet, {})
def _require_dict(value: Any, name: str = "row") -> dict[str, Any]:
if not isinstance(value, dict):
raise ExcelLoadError(f"{name} must be a JSON object")
return value
def append_row(path: str, sheet: str | None, row_data: dict[str, Any]) -> dict[str, Any]:
wb = _load_workbook_for_write(path)
ws = _get_sheet(wb, sheet)
header_map = _ensure_header_columns(ws, [str(key) for key in row_data.keys()])
row_idx = ws.max_row + 1
for key, value in row_data.items():
ws.cell(row=row_idx, column=header_map[str(key)], value=value)
wb.save(path)
return {"file": path, "sheet": ws.title, "row": row_idx, "inserted": row_data}
def _matched_row_indices(ws: Any, criteria: dict[str, Any]) -> list[int]:
header_map = _header_map(ws)
missing_keys = [key for key in criteria if key not in header_map]
if missing_keys:
raise ExcelLoadError(f"criteria keys not found in header: {', '.join(missing_keys)}")
matched: list[int] = []
for row_idx in range(2, ws.max_row + 1):
if all(ws.cell(row=row_idx, column=header_map[key]).value == value for key, value in criteria.items()):
matched.append(row_idx)
return matched
def search_rows(path: str, sheet: str | None, criteria: dict[str, Any]) -> list[dict[str, Any]]:
rows = [row for row in load_excel(path, sheet=sheet, header=True) if isinstance(row, dict)]
if not criteria:
return rows
if rows:
missing_keys = [key for key in criteria if key not in rows[0]]
if missing_keys:
raise ExcelLoadError(f"criteria keys not found in header: {', '.join(missing_keys)}")
return [row for row in rows if all(row.get(key) == value for key, value in criteria.items())]
def update_rows(path: str, sheet: str | None, criteria: dict[str, Any], row_data: dict[str, Any]) -> dict[str, Any]:
if not criteria:
raise ExcelLoadError("criteria must not be empty for update_rows")
wb = _load_workbook_for_write(path)
ws = _get_sheet(wb, sheet)
row_indices = _matched_row_indices(ws, criteria)
if not row_indices:
return {"file": path, "sheet": ws.title, "updated": False, "rows": []}
update_keys = [str(key) for key in row_data.keys()]
header_map = _ensure_header_columns(ws, update_keys)
for row_idx in row_indices:
for key, value in row_data.items():
key = str(key)
ws.cell(row=row_idx, column=header_map[key], value=value)
wb.save(path)
return {"file": path, "sheet": ws.title, "updated": True, "rows": row_indices, "count": len(row_indices)}
def delete_rows(path: str, sheet: str | None, criteria: dict[str, Any]) -> dict[str, Any]:
if not criteria:
raise ExcelLoadError("criteria must not be empty for delete_rows")
wb = _load_workbook_for_write(path)
ws = _get_sheet(wb, sheet)
row_indices = _matched_row_indices(ws, criteria)
if not row_indices:
return {"file": path, "sheet": ws.title, "deleted": False, "rows": []}
for row_idx in sorted(row_indices, reverse=True):
ws.delete_rows(row_idx, 1)
wb.save(path)
return {"file": path, "sheet": ws.title, "deleted": True, "rows": row_indices, "count": len(row_indices)}
def _cell(v: Any) -> str:
return json.dumps(v, ensure_ascii=False, indent=2) if isinstance(v, (dict, list)) else ("" if v is None else str(v))
def _json_rows(data: Any) -> tuple[list[str], list[list[Any]]]:
if isinstance(data, dict):
headers = [str(key) for key in data.keys()]
return headers, [[data[key] for key in data.keys()]]
if not isinstance(data, list):
return ["value"], [[data]]
if not data:
return [], []
if all(isinstance(item, dict) for item in data):
headers: list[str] = []
for item in data:
for key in item.keys():
key = str(key)
if key not in headers:
headers.append(key)
rows = [[item.get(header) for header in headers] for item in data]
return headers, rows
if all(isinstance(item, (list, tuple)) for item in data):
max_len = max(len(item) for item in data)
headers = [f"col{idx + 1}" for idx in range(max_len)]
rows = [list(item) + [None] * (max_len - len(item)) for item in data]
return headers, rows
return ["value"], [[item] for item in data]
def json_to_sheet(data: Any, out: str, sheet: str = "Sheet1") -> str:
try:
from openpyxl import Workbook, load_workbook # type: ignore
from openpyxl.styles import Alignment, Font # type: ignore
except ImportError as exc:
raise ExcelLoadError("openpyxl is required for json-to-sheet") from exc
output_path = Path(out)
wb = load_workbook(output_path) if output_path.exists() else Workbook()
if sheet in wb.sheetnames:
old_sheet = wb[sheet]
old_index = wb.sheetnames.index(sheet)
wb.remove(old_sheet)
ws = wb.create_sheet(sheet, old_index)
else:
ws = wb.active if wb.active.title == "Sheet" and wb.active.max_row == 1 and wb.active.max_column == 1 and wb.active["A1"].value is None else wb.create_sheet(sheet)
ws.title = sheet
headers, rows = _json_rows(data)
for col_idx, header in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = Font(bold=True)
for row_idx, row in enumerate(rows, start=2):
for col_idx, value in enumerate(row, start=1):
ws.cell(row=row_idx, column=col_idx, value=_cell(value))
if headers or rows:
for row in ws.iter_rows(
min_row=1,
max_row=max(1, len(rows) + 1),
max_col=max(1, len(headers)),
):
for cell in row:
cell.alignment = Alignment(vertical="top", wrap_text=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
wb.save(output_path)
return str(output_path)
def main() -> int:
p = argparse.ArgumentParser(description="Standalone Excel CLI"); sub = p.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("load-excel"); a.add_argument("file"); a.add_argument("--sheet-name"); a.add_argument("--no-header", action="store_true")
a = sub.add_parser("list-sheets"); a.add_argument("file")
a = sub.add_parser("search_rows"); a.add_argument("file"); a.add_argument("criteria", nargs="?", default="{}"); a.add_argument("--sheet-name")
a = sub.add_parser("append-row"); a.add_argument("file"); a.add_argument("row"); a.add_argument("--sheet-name")
a = sub.add_parser("update-rows"); a.add_argument("file"); a.add_argument("criteria"); a.add_argument("row"); a.add_argument("--sheet-name")
a = sub.add_parser("delete-rows"); a.add_argument("file"); a.add_argument("criteria"); a.add_argument("--sheet-name")
a = sub.add_parser("find-value"); a.add_argument("file"); a.add_argument("key_column"); a.add_argument("key_value"); a.add_argument("value_column"); a.add_argument("--sheet-name")
a = sub.add_parser("map-rows"); a.add_argument("file"); a.add_argument("column_map"); a.add_argument("--sheet-name")
a = sub.add_parser("json-to-sheet"); a.add_argument("json_data"); a.add_argument("output"); a.add_argument("--sheet-name", default="Sheet1")
x = p.parse_args()
if x.cmd == "load-excel": _json(load_excel(x.file, x.sheet_name, not x.no_header))
elif x.cmd == "list-sheets": _json(list_sheets(x.file))
elif x.cmd == "search_rows": _json(search_rows(x.file, x.sheet_name, _require_dict(_load_json(x.criteria), "criteria")))
elif x.cmd == "append-row": _json(append_row(x.file, x.sheet_name, _require_dict(_load_json(x.row))))
elif x.cmd == "update-rows": _json(update_rows(x.file, x.sheet_name, _require_dict(_load_json(x.criteria), "criteria"), _require_dict(_load_json(x.row))))
elif x.cmd == "delete-rows": _json(delete_rows(x.file, x.sheet_name, _require_dict(_load_json(x.criteria), "criteria")))
elif x.cmd == "find-value": _json(next((r.get(x.value_column) for r in load_excel(x.file, x.sheet_name) if isinstance(r, dict) and r.get(x.key_column) == x.key_value), None))
elif x.cmd == "map-rows": _json([{k: r.get(v) for k, v in json.loads(x.column_map).items()} for r in load_excel(x.file, x.sheet_name) if isinstance(r, dict)])
elif x.cmd == "json-to-sheet": print(json_to_sheet(_load_json(x.json_data), x.output, x.sheet_name))
return 0
if __name__ == "__main__":
raise SystemExit(main())
......@@ -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
......
from spire.doc import Document, Paragraph, Table, Comment, CommentMark, CommentMarkType
from loguru import logger
import re
from thefuzz import fuzz
from utils.doc_util import DocBase
from utils.common_util import adjust_single_chunk_size
import os
def extract_table_cells_text(table, joiner="\n"):
"""
从 Spire.Doc 的 Table 对象中提取每个单元格文本,并按行主序返回扁平列表:
["r0c0_text", "r0c1_text", "r1c0_text", ...]
joiner: 用于连接单元格内多段落或嵌套表行的分隔符(默认换行)
注意:不对文本做任何清洗或 strip,保持原有格式
"""
def _para_text(para):
# 优先使用 para.Text(保留原样),否则尝试从 para.ChildObjects 收集 Text-like 字段
try:
if hasattr(para, "Text"):
return para.Text if para.Text is not None else ""
except Exception:
pass
parts = []
try:
for idx in range(para.ChildObjects.Count):
obj = para.ChildObjects[idx]
if hasattr(obj, "Text"):
parts.append(obj.Text if obj.Text is not None else "")
except Exception:
pass
return "".join(parts)
def _extract_cell_text(cell):
parts = []
# 收集单元格内所有段落文本(保持原样,不做 strip)
try:
for p_idx in range(cell.Paragraphs.Count):
para = cell.Paragraphs[p_idx]
parts.append(_para_text(para))
except Exception:
pass
# 处理嵌套表格(若存在),把嵌套表每一行合并为一条字符串,并按行加入 parts
try:
if hasattr(cell, "Tables") and cell.Tables.Count > 0:
for t_idx in range(cell.Tables.Count):
nested = cell.Tables[t_idx]
nested_rows = []
for nr in range(nested.Rows.Count):
nested_row_cells = []
for nc in range(nested.Rows[nr].Cells.Count):
try:
# 取嵌套单元格的所有段落并用 joiner 连接(保留原样)
nc_parts = []
for np_idx in range(
nested.Rows[nr].Cells[nc].Paragraphs.Count
):
nc_parts.append(
_para_text(
nested.Rows[nr].Cells[nc].Paragraphs[np_idx]
)
)
nested_row_cells.append(joiner.join(nc_parts))
except Exception:
nested_row_cells.append("")
nested_rows.append(joiner.join(nested_row_cells))
parts.append(joiner.join(nested_rows))
else:
# 有时嵌套表格会放在 cell.ChildObjects 中,兼容处理
try:
for idx in range(cell.ChildObjects.Count):
ch = cell.ChildObjects[idx]
if hasattr(ch, "Rows") and getattr(ch, "Rows") is not None:
nested = ch
nested_rows = []
for nr in range(nested.Rows.Count):
nested_row_cells = []
for nc in range(nested.Rows[nr].Cells.Count):
try:
nc_parts = []
for np_idx in range(
nested.Rows[nr].Cells[nc].Paragraphs.Count
):
nc_parts.append(
_para_text(
nested.Rows[nr]
.Cells[nc]
.Paragraphs[np_idx]
)
)
nested_row_cells.append(joiner.join(nc_parts))
except Exception:
nested_row_cells.append("")
nested_rows.append(joiner.join(nested_row_cells))
parts.append(joiner.join(nested_rows))
except Exception:
pass
except Exception:
pass
# 把单元格内收集到的片段用 joiner 连接成最终字符串(不做任何 trim/clean)
return joiner.join(parts)
flat = []
for r in range(table.Rows.Count):
row = table.Rows[r]
for c in range(row.Cells.Count):
cell = row.Cells[c]
cell_text = _extract_cell_text(cell)
# 保持原样,空单元格返回空字符串
flat.append(cell_text)
return flat
def process_string(s):
# 统计换行符数量
newline_count = s.count("\n")
# 情况1:没有换行符
if newline_count == 0:
return s
# 情况2:只有一个换行符
elif newline_count == 1:
# 分割成两部分
parts = s.split("\n", 1)
# 比较前后部分长度
return parts[0] if len(parts[0]) >= len(parts[1]) else parts[1]
# 情况3:多个换行符
else:
# 分割所有部分
parts = s.split("\n")
# 找出中间部分(排除首尾)
middle_parts = parts[1:-1] if len(parts) > 2 else []
# 如果没有有效中间部分
if not middle_parts:
# 返回最长的一段(排除空字符串)
non_empty_parts = [p for p in parts if p]
return max(non_empty_parts, key=len) if non_empty_parts else ""
# 返回最长的中间部分
return max(middle_parts, key=len, default="")
def _score_target_against_query(target_text: str, query_text: str):
"""对单个候选文本与查询文本打分,并返回最适合落批注的匹配片段。"""
if not target_text or not query_text:
return None, 0
if query_text in target_text:
return query_text, 100
# partial_ratio 负责召回,ratio 负责精度;组合分用于排序
def _combined_score(text_a: str, text_b: str):
ratio_score = fuzz.ratio(text_a, text_b)
partial_score = fuzz.partial_ratio(text_a, text_b)
combined = int(round(0.4 * ratio_score + 0.6 * partial_score))
return combined
best_text = target_text
best_score = _combined_score(target_text, query_text)
# 对长句按常见中文分隔符做子句拆分,避免整句比较被噪声稀释。
for clause in target_text.replace("。", ";").replace(",", ";").split(";"):
clause = clause.strip()
if not clause:
continue
clause_score = _combined_score(clause, query_text)
if clause_score > best_score:
best_score = clause_score
best_text = clause
return best_text, best_score
def _build_narrowed_queries(text: str, min_len=12):
"""对文本做一步缩窄,生成下一轮候选。"""
if not text:
return []
text = text.strip()
if len(text) <= min_len:
return []
next_queries = []
cut = max(1, len(text) // 8)
left_cut = text[cut:]
right_cut = text[:-cut]
center_cut = text[cut:-cut] if len(text) > 2 * cut else ""
for item in (left_cut, right_cut, center_cut):
item = item.strip()
if len(item) >= min_len:
next_queries.append(item)
simplified = process_string(text)
if simplified and len(simplified) >= min_len:
next_queries.append(simplified.strip())
parts = [p.strip() for p in re.split(r"[。;;,,\n]", text) if p.strip()]
if len(parts) > 1:
longest_part = max(parts, key=len)
if len(longest_part) >= min_len:
next_queries.append(longest_part)
if len(parts) > 2:
mid_join = "".join(parts[1:-1]).strip()
if len(mid_join) >= min_len:
next_queries.append(mid_join)
deduped = []
seen = set()
for item in next_queries:
if item not in seen:
seen.add(item)
deduped.append(item)
return deduped
def _find_best_match_in_texts(target_texts, original_text):
"""在候选文本列表中查找与 original_text 最相近的一条(支持递进缩窄查询)。"""
if not target_texts or not original_text:
return None, -1
best_match = None
best_score = -1
# beam_size: 每轮仅保留得分最高的前 N 个查询继续扩展,控制搜索分支爆炸。
beam_size = 5
# max_rounds: 递进缩窄的最大轮数,避免异常文本导致无限尝试。
max_rounds = 8
min_query_len = 12
active_queries = [original_text.strip()]
seen_queries = set(active_queries)
for _ in range(max_rounds):
if not active_queries:
break
query_best_scores = []
for query in active_queries:
local_best = -1
for target_text in target_texts:
match_text, score = _score_target_against_query(target_text, query)
if score > best_score:
best_match = match_text
best_score = score
if score > local_best:
local_best = score
query_best_scores.append((query, local_best))
if best_score >= 100:
break
# 先保留当前轮最有希望的查询,再基于它们生成下一轮缩窄查询。
query_best_scores.sort(key=lambda x: x[1], reverse=True)
top_queries = [q for q, _ in query_best_scores[:beam_size]]
next_queries = []
for query in top_queries:
for narrowed in _build_narrowed_queries(query, min_len=min_query_len):
if narrowed not in seen_queries:
seen_queries.add(narrowed)
next_queries.append(narrowed)
active_queries = next_queries
return best_match, best_score
# spire doc解析
class SpireWordDoc(DocBase):
def load(self, doc_path, **kwargs):
# License.SetLicenseFileFullPath(f"{root_path}/license.elic.python.xml")
self._doc_path = doc_path
self._doc_name = os.path.basename(doc_path)
self._doc = Document()
self._doc.LoadFromFile(doc_path)
self._chunk_list = self._resolve_doc_chunk()
return self
def _ensure_loaded(self):
if not self._doc:
raise RuntimeError("Document not loaded. Call load() first.")
def adjust_chunk_size(self):
self._ensure_loaded()
all_text_len = len(self.get_all_text())
self._max_single_chunk_size = adjust_single_chunk_size(all_text_len)
logger.info(
f"SpireWordDoc adjust _max_single_chunk_size to {self._max_single_chunk_size}"
)
self._chunk_list = self._resolve_doc_chunk()
return self._max_single_chunk_size
async def get_from_ocr(self):
pass
# 把文档分割成chunk
def _resolve_doc_chunk(self):
self._ensure_loaded()
chunk_list = []
# 单个chunk
single_chunk = ""
# 单个chunk的位置信息
single_chunk_location = []
# 遍历每个节
for section_idx in range(self._doc.Sections.Count):
current_section = self._doc.Sections.get_Item(section_idx)
# 遍历节里面每个子对象
for section_child_idx in range(current_section.Body.ChildObjects.Count):
# 获取子对象
child_obj = current_section.Body.ChildObjects.get_Item(
section_child_idx
)
# 段落处理
current_child_text = ""
if isinstance(child_obj, Paragraph):
paragraph = child_obj
current_child_text = paragraph.Text
# 表格处理
elif isinstance(child_obj, Table):
table = child_obj
current_child_text = self._resolve_table(table)
# 跳过其他非文本子对象
else:
continue
# 添加新对象
if (
len(single_chunk) + len(current_child_text)
> self._max_single_chunk_size
):
chunk_list.append(
{
"chunk_content": single_chunk,
"chunk_location": single_chunk_location,
}
)
single_chunk = ""
single_chunk_location = []
single_chunk += current_child_text + "\n"
single_chunk_location.append(
{"section_idx": section_idx, "section_child_idx": section_child_idx}
)
if len(single_chunk):
chunk_list.append(
{"chunk_content": single_chunk, "chunk_location": single_chunk_location}
)
return chunk_list
# 表格解析为markdown
def _resolve_table(self, table):
table_data = ""
for i in range(0, table.Rows.Count):
# 遍历行的单元格(cells)
cell_list = []
for j in range(0, table.Rows.get_Item(i).Cells.Count):
# 获取每一个单元格(cell)
cell = table.Rows.get_Item(i).Cells.get_Item(j)
cell_content = ""
for para_idx in range(cell.Paragraphs.Count):
paragraph_text = cell.Paragraphs.get_Item(para_idx).Text
cell_content += paragraph_text
cell_list.append(cell_content)
# table_data += "|" + "|".join(cell_list) + "|"
# table_data += "\n"
table_data += ' '.join(cell_list) + '\n'
if i == 0:
# table_data += "|" + "|".join(["--- " for _ in cell_list]) + "|\n"
table_data= ' '.join(cell_list) + '\n'
return table_data
def get_chunk_info(self, chunk_id):
chunk = self._chunk_list[chunk_id]
chunk_content = chunk["chunk_content"]
chunk_location = chunk["chunk_location"]
from_location = f"[第{chunk_location[0]['section_idx'] + 1}节的第{chunk_location[0]['section_child_idx'] + 1}段落]"
to_location = f"[第{chunk_location[-1]['section_idx'] + 1}节的第{chunk_location[-1]['section_child_idx'] + 1}段落]"
chunk_content_tips = (
"[" + chunk_content[:20] + "]...到...[" + chunk_content[-20:] + "]"
)
return f"文件块id: {chunk_id + 1}\n文件块位置: 从{from_location}到{to_location}\n文件块简述: {chunk_content_tips}\n"
def get_chunk_location(self, chunk_id):
return self.get_chunk_info(chunk_id)
def get_chunk_num(self):
self._ensure_loaded()
return len(self._chunk_list)
def get_chunk_item(self, chunk_id):
self._ensure_loaded()
return self._chunk_list[chunk_id]["chunk_content"]
# 根据locations获取数据
def get_sub_chunks(self, chunk_id):
if chunk_id >= len(self._chunk_list):
logger.error(f"get_sub_chunks_error:{chunk_id}")
return []
chunk = self._chunk_list[chunk_id]
chunk_locations = chunk["chunk_location"]
return [
self._doc.Sections.get_Item(loc["section_idx"]).Body.ChildObjects.get_Item(
loc["section_child_idx"]
)
for loc in chunk_locations
]
def format_comment_author(self, comment):
return "{}|{}".format(str(comment["id"]), comment["key_points"])
def _decorate_author_with_match_type(self, author, match_type):
if match_type == "exact":
return f"(精确){author}"
if match_type == "fuzzy":
return f"(模糊){author}"
return author
def _normalize_author_prefix(self, author):
# 去掉匹配来源前缀后再比对,确保“精确/模糊”两种作者标签都能命中同一条批注。
if not author:
return author
for prefix in ("(精确)", "(模糊)"):
if author.startswith(prefix):
return author[len(prefix) :]
return author
def remove_comment_prefix(
self,
):
for i in range(self._doc.Comments.Count):
current_comment = self._doc.Comments.get_Item(i)
comment_author = current_comment.Format.Author
split_author = comment_author.split("|")
if len(split_author) == 2:
current_comment.Format.Author = comment_author.split("|")[1]
def _insert_comment_by_text_range(self, text_range, author, comment_content):
if text_range is None:
return False
paragraph = text_range.OwnerParagraph
if paragraph is None:
return False
comment = Comment(self._doc)
comment.Body.AddParagraph().Text = comment_content
comment.Format.Author = author
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(text_range) + 1, comment
)
# Word 批注需要成对的起止标记;两者共享同一个 CommentId。
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(
paragraph.ChildObjects.IndexOf(text_range), comment_start
)
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(text_range) + 1, comment_end
)
return True
def _update_comment_content(self, comment_idx, suggest):
self._doc.Comments.get_Item(comment_idx).Body.Paragraphs.get_Item(0).Text = suggest
def _try_add_comment_in_paragraphs(self, paragraphs, target_text, author, suggest):
if not target_text:
return False
for paragraph in paragraphs:
text_sel = paragraph.Find(target_text, False, True)
if text_sel and self.set_comment_by_text_selection(text_sel, author, suggest):
return True
return False
def _try_add_comment_by_exact(self, sub_chunks, find_key, author, suggest):
for obj in sub_chunks:
if isinstance(obj, Paragraph):
try:
text_sel = obj.Find(find_key, False, True)
if text_sel and self.set_comment_by_text_selection(
text_sel, author, suggest
):
return True
except Exception as e:
print(f"段落批注添加失败: {str(e)}")
elif isinstance(obj, Table):
try:
if self.add_table_comment(obj, find_key, suggest, author):
return True
except Exception as e:
print(f"表格批注添加失败: {str(e)}")
return False
def _try_add_comment_by_fuzzy(self, sub_chunks, comment, author, suggest):
original_text = comment.get("original_text", "")
candidates = []
# 段落与表格同权:统一加入候选池,按最高分排序后尝试落批注
for order, obj in enumerate(sub_chunks):
if isinstance(obj, Paragraph):
match_text, score = _find_best_match_in_texts([obj.Text], original_text)
candidates.append(
{
"kind": "paragraph",
"obj": obj,
"match_text": match_text,
"score": score,
"order": order,
}
)
elif isinstance(obj, Table):
table_data = extract_table_cells_text(obj)
match_text, score = _find_best_match_in_texts(table_data, original_text)
candidates.append(
{
"kind": "table",
"obj": obj,
"match_text": match_text,
"score": score,
"order": order,
}
)
# 过滤无效候选后按分数降序、原文档顺序升序尝试,优先高分且靠前的位置。
candidates = [
item
for item in candidates
if item.get("match_text") and item.get("score", -1) >= 0
]
candidates.sort(key=lambda x: (-x["score"], x["order"]))
for item in candidates:
match_text = item["match_text"]
processed_text = process_string(match_text) if match_text else ""
if item["kind"] == "paragraph":
paragraph = item["obj"]
# 先尝试原匹配片段,再尝试 process_string 压缩后的片段,提高落点成功率。
if self._try_add_comment_in_paragraphs(
[paragraph], match_text, author, suggest
):
return True
if self._try_add_comment_in_paragraphs(
[paragraph], processed_text, author, suggest
):
return True
else:
table = item["obj"]
# 表格同样使用“原片段 -> 压缩片段”的两阶段策略。
if self.add_table_comment(table, match_text, suggest, author):
return True
if processed_text and self.add_table_comment(
table, processed_text, suggest, author
):
return True
return False
# 根据text_selection批注
def set_comment_by_text_selection(self, text_sel, author, comment_content):
if text_sel is None:
return False
text_range = text_sel.GetAsOneRange()
return self._insert_comment_by_text_range(text_range, author, comment_content)
# 设置chunk批注
def add_table_comment(
self, table, target_text, comment_text, author="审阅助手", initials="AI"
):
"""
在表格中添加批注
返回是否成功添加
"""
added = False
# 遍历表格所有单元格
for i in range(table.Rows.Count):
row = table.Rows[i]
for j in range(row.Cells.Count):
cell = row.Cells[j]
# 遍历单元格中的段落
for k in range(cell.Paragraphs.Count):
para = cell.Paragraphs[k]
# 在段落中查找目标文本
selection = para.Find(target_text, False, True)
if selection:
text_range = selection.GetAsOneRange()
if self._insert_comment_by_text_range(
text_range, author, comment_text
):
added = True
# print(f"表格批注添加成功: '{target_text[:20]}...'")
# 添加成功后跳出内层循环
break
# 如果已经添加,跳出单元格循环
if added:
break
# 如果已经添加,跳出行循环
if added:
break
return added
def add_chunk_comment(self, chunk_id, comments):
"""
为 chunk 添加批注(保证每条评论只批注一次)。
执行顺序:
1) 过滤非“不合格”项;
2) 先按作者标识查重,命中则更新内容;
3) 未命中时先精确匹配,再模糊匹配;
4) 仍失败则记录日志。
"""
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)
author = self.format_comment_author(comment)
suggest = comment.get("suggest", "")
find_key = comment["original_text"].strip() or comment["key_points"]
# 先检查是否已有同一“规则ID|要点”的批注,避免重复插入。
existing_comment_idx = self.find_comment(author)
if existing_comment_idx is not None:
# 已存在批注,则更新内容
self._update_comment_content(existing_comment_idx, suggest)
# print(f"批注已存在,更新内容: '{find_key[:20]}...'")
continue
exact_author = self._decorate_author_with_match_type(author, "exact")
fuzzy_author = self._decorate_author_with_match_type(author, "fuzzy")
# 优先精确匹配,成功则不再进入模糊匹配。
matched = self._try_add_comment_by_exact(
sub_chunks, find_key, exact_author, suggest
)
if not matched:
try:
# 精确失败后走模糊匹配(段落/表格统一候选池评分)。
matched = self._try_add_comment_by_fuzzy(
sub_chunks, comment, fuzzy_author, suggest
)
except Exception as e:
print(f"模糊匹配失败: {str(e)}")
# ---------- 3. 匹配最终失败 ----------
if not matched:
logger.error(f"未找到可批注位置: '{find_key[:20]}...'")
# 根据作者名称查找批注
def find_comment(self, author):
# 比较前去掉“(精确)/(模糊)”前缀,只按真实作者键(id|key_points)识别唯一批注。
normalized_author = self._normalize_author_prefix(author)
for i in range(self._doc.Comments.Count):
current_comment = self._doc.Comments.get_Item(i)
comment_author = self._normalize_author_prefix(current_comment.Format.Author)
if comment_author == normalized_author:
return i
return None
def delete_chunk_comment(self, comments):
"""
删除指定作者批注
"""
for comment in comments:
author = self.format_comment_author(comment)
author_comment_idx = self.find_comment(author)
if author_comment_idx is not None:
self._doc.Comments.RemoveAt(author_comment_idx)
print(f"删除批注: '{author}'")
def edit_chunk_comment(self, comments):
"""
编辑chunk批注:删除已合格的批注,修改存在的批注,不存在则新增
"""
for comment in comments:
author = self.format_comment_author(comment)
review_answer = comment["result"]
existing_comment_idx = self.find_comment(author)
if review_answer == "合格":
# 删除批注
if existing_comment_idx is not None:
self._doc.Comments.RemoveAt(existing_comment_idx)
# print(f"已删除合格批注: '{author}'")
else:
# 不合格,更新或新增
suggest = comment.get("suggest", "")
if existing_comment_idx is not None:
self._update_comment_content(existing_comment_idx, suggest)
# print(f"更新已有批注: '{author}'")
else:
# chunk_id要从comment中获取
self.add_chunk_comment(comment["chunk_id"] - 1, [comment])
def get_chunk_id_list(self, step=1):
self._ensure_loaded()
return [idx for idx in range(0, self.get_chunk_num(), step)]
def get_all_text(self):
self._ensure_loaded()
return self._doc.GetText()
def to_file(self, path, remove_prefix=False):
self._ensure_loaded()
if remove_prefix:
self.remove_comment_prefix()
self._doc.SaveToFile(path)
def release(self):
# 关闭文件
if self._doc:
self._doc.Close()
super().release()
def __del__(self):
pass
# self.release()
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())
# doc.add_chunk_comment(
# 0,
# [
# {
# "id": "1",
# "key_points": "日期审查",
# "original_text": "承诺",
# "details": "1111",
# "chunk_id": 0,
# "result": "不合格",
# "suggest": "这是测试建议",
# }
# ],
# )
# doc.to_file("/home/ccran/lufa-contract/demo/今麦郎合同审核_test.docx", True)
\ No newline at end of file
......@@ -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):
......
from spire.doc import Document, Paragraph, Table, Comment, CommentMark, CommentMarkType
import json
from loguru import logger
import re
from thefuzz import fuzz
from utils.doc_util import DocBase
from utils.common_util import adjust_single_chunk_size
import os
def extract_table_cells_text(table, joiner="\n"):
"""
从 Spire.Doc 的 Table 对象中提取每个单元格文本,并按行主序返回扁平列表:
["r0c0_text", "r0c1_text", "r1c0_text", ...]
joiner: 用于连接单元格内多段落或嵌套表行的分隔符(默认换行)
注意:不对文本做任何清洗或 strip,保持原有格式
"""
def _para_text(para):
# 优先使用 para.Text(保留原样),否则尝试从 para.ChildObjects 收集 Text-like 字段
try:
if hasattr(para, "Text"):
return para.Text if para.Text is not None else ""
except Exception:
pass
parts = []
try:
for idx in range(para.ChildObjects.Count):
obj = para.ChildObjects[idx]
if hasattr(obj, "Text"):
parts.append(obj.Text if obj.Text is not None else "")
except Exception:
pass
return "".join(parts)
def _extract_cell_text(cell):
parts = []
# 收集单元格内所有段落文本(保持原样,不做 strip)
try:
for p_idx in range(cell.Paragraphs.Count):
para = cell.Paragraphs[p_idx]
parts.append(_para_text(para))
except Exception:
pass
# 处理嵌套表格(若存在),把嵌套表每一行合并为一条字符串,并按行加入 parts
try:
if hasattr(cell, "Tables") and cell.Tables.Count > 0:
for t_idx in range(cell.Tables.Count):
nested = cell.Tables[t_idx]
nested_rows = []
for nr in range(nested.Rows.Count):
nested_row_cells = []
for nc in range(nested.Rows[nr].Cells.Count):
try:
# 取嵌套单元格的所有段落并用 joiner 连接(保留原样)
nc_parts = []
for np_idx in range(
nested.Rows[nr].Cells[nc].Paragraphs.Count
):
nc_parts.append(
_para_text(
nested.Rows[nr].Cells[nc].Paragraphs[np_idx]
)
)
nested_row_cells.append(joiner.join(nc_parts))
except Exception:
nested_row_cells.append("")
nested_rows.append(joiner.join(nested_row_cells))
parts.append(joiner.join(nested_rows))
else:
# 有时嵌套表格会放在 cell.ChildObjects 中,兼容处理
try:
for idx in range(cell.ChildObjects.Count):
ch = cell.ChildObjects[idx]
if hasattr(ch, "Rows") and getattr(ch, "Rows") is not None:
nested = ch
nested_rows = []
for nr in range(nested.Rows.Count):
nested_row_cells = []
for nc in range(nested.Rows[nr].Cells.Count):
try:
nc_parts = []
for np_idx in range(
nested.Rows[nr].Cells[nc].Paragraphs.Count
):
nc_parts.append(
_para_text(
nested.Rows[nr]
.Cells[nc]
.Paragraphs[np_idx]
)
)
nested_row_cells.append(joiner.join(nc_parts))
except Exception:
nested_row_cells.append("")
nested_rows.append(joiner.join(nested_row_cells))
parts.append(joiner.join(nested_rows))
except Exception:
pass
except Exception:
pass
# 把单元格内收集到的片段用 joiner 连接成最终字符串(不做任何 trim/clean)
return joiner.join(parts)
flat = []
for r in range(table.Rows.Count):
row = table.Rows[r]
for c in range(row.Cells.Count):
cell = row.Cells[c]
cell_text = _extract_cell_text(cell)
# 保持原样,空单元格返回空字符串
flat.append(cell_text)
return flat
def process_string(s):
# 统计换行符数量
newline_count = s.count("\n")
# 情况1:没有换行符
if newline_count == 0:
return s
# 情况2:只有一个换行符
elif newline_count == 1:
# 分割成两部分
parts = s.split("\n", 1)
# 比较前后部分长度
return parts[0] if len(parts[0]) >= len(parts[1]) else parts[1]
# 情况3:多个换行符
else:
# 分割所有部分
parts = s.split("\n")
# 找出中间部分(排除首尾)
middle_parts = parts[1:-1] if len(parts) > 2 else []
# 如果没有有效中间部分
if not middle_parts:
# 返回最长的一段(排除空字符串)
non_empty_parts = [p for p in parts if p]
return max(non_empty_parts, key=len) if non_empty_parts else ""
# 返回最长的中间部分
return max(middle_parts, key=len, default="")
def build_mapping(original: str):
"""构造规范化文本和原文索引映射"""
normalized = []
mapping = []
for m in re.finditer(r"\S+", original):
word = m.group()
if normalized:
normalized.append(" ")
mapping.append(m.start()) # 空格映射
for j, ch in enumerate(word):
normalized.append(ch)
mapping.append(m.start() + j)
return "".join(normalized), mapping
def extract_match(big_text: str, small_text: str, threshold=20):
"""
简化版文本匹配函数
核心逻辑:优先整个文本块匹配,次优子句匹配
"""
# 1. 精确匹配整个文本块
if small_text in big_text:
return small_text, 100
# 2. 整个文本块模糊匹配
full_score = fuzz.ratio(big_text, small_text)
if full_score >= threshold:
return big_text, full_score
# 3. 子句匹配(简单分割)
best_score = 0
best_clause = None
# 简单分割:按句号、分号、逗号分割
for clause in big_text.replace("。", ";").replace(",", ";").split(";"):
if not clause.strip():
continue
clause_score = fuzz.ratio(clause, small_text)
if clause_score > best_score:
best_score = clause_score
best_clause = clause
# 4. 返回最佳匹配
if best_score >= threshold:
return best_clause, best_score
# 5. 无有效匹配
return None, max(full_score, best_score)
def find_best_match(sub_chunks, comment):
"""
在给定的文本块中查找与原始评论最匹配的文本
参数:
sub_chunks -- 包含Text属性的对象列表
comment -- 包含"original_text"的字典
返回:
best_match -- 匹配度最高的文本
best_score -- 最高匹配度
all_results -- 所有匹配结果列表(匹配文本, 相似度)
"""
all_results = [] # 存储所有(匹配文本, 相似度)的元组
best_match = None # 存储最佳匹配的结果
best_score = -1 # 存储最高相似度(初始化为-1)
# print(f"开始处理评论: {comment['original_text'][:30]}...") # 显示简化的原始评论
for obj in sub_chunks:
if isinstance(obj, Paragraph):
target_text = obj.Text
original_text = comment["original_text"]
match_text, score = extract_match(target_text, original_text)
# 打印当前结果(保持原格式)
# print("匹配到:\n", match_text)
# print("相似度:", score)
# 存储所有结果
all_results.append((match_text, score))
# 更新最佳匹配 - 只更新分数更高的结果
if score > best_score:
best_match = match_text
best_score = score
# 打印最终的最佳匹配结果
# print("\n" + "=" * 40)
# print("\n处理完成 - 最佳匹配结果:")
# print("匹配到:\n", best_match)
# print("相似度:", best_score)
# print("=" * 40 + "\n")
return best_match, best_score
def table_contract(target_texts, comment):
"""
在给定的文本块中查找与原始评论最匹配的文本
参数:
sub_chunks -- 待对比文本
comment -- 包含"original_text"的字典
返回:
best_match -- 匹配度最高的文本
best_score -- 最高匹配度
all_results -- 所有匹配结果列表(匹配文本, 相似度)
"""
all_results = [] # 存储所有(匹配文本, 相似度)的元组
best_match = None # 存储最佳匹配的结果
best_score = -1 # 存储最高相似度(初始化为-1)
# print(f"开始处理评论: {comment['original_text'][:30]}...") # 显示简化的原始评论
original_text = comment["original_text"]
for target_text in target_texts:
match_text, score = extract_match(target_text, original_text)
# 打印当前结果(保持原格式)
# print("匹配到:\n", match_text)
# print("相似度:", score)
# 存储所有结果
all_results.append((match_text, score))
# 更新最佳匹配 - 只更新分数更高的结果
if score > best_score:
best_match = match_text
best_score = score
# 打印最终的最佳匹配结果
# print("\n" + "=" * 40)
# print("\n处理完成 - 最佳匹配结果:")
# print("匹配到:\n", best_match)
# print("相似度:", best_score)
# print("=" * 40 + "\n")
return best_match, best_score
# spire doc解析
class SpireWordDoc(DocBase):
def load(self, doc_path, **kwargs):
# License.SetLicenseFileFullPath(f"{root_path}/license.elic.python.xml")
self._doc_path = doc_path
self._doc_name = os.path.basename(doc_path)
self._doc = Document()
self._doc.LoadFromFile(doc_path)
self._chunk_list = self._resolve_doc_chunk()
return self
def _ensure_loaded(self):
if not self._doc:
raise RuntimeError("Document not loaded. Call load() first.")
def adjust_chunk_size(self):
self._ensure_loaded()
all_text_len = len(self.get_all_text())
self._max_single_chunk_size = adjust_single_chunk_size(all_text_len)
logger.info(
f"SpireWordDoc adjust _max_single_chunk_size to {self._max_single_chunk_size}"
)
self._chunk_list = self._resolve_doc_chunk()
return self._max_single_chunk_size
async def get_from_ocr(self):
pass
# 把文档分割成chunk
def _resolve_doc_chunk(self):
self._ensure_loaded()
chunk_list = []
# 单个chunk
single_chunk = ""
# 单个chunk的位置信息
single_chunk_location = []
# 遍历每个节
for section_idx in range(self._doc.Sections.Count):
current_section = self._doc.Sections.get_Item(section_idx)
# 遍历节里面每个子对象
for section_child_idx in range(current_section.Body.ChildObjects.Count):
# 获取子对象
child_obj = current_section.Body.ChildObjects.get_Item(
section_child_idx
)
# 段落处理
current_child_text = ""
if isinstance(child_obj, Paragraph):
paragraph = child_obj
current_child_text = paragraph.Text
# 表格处理
elif isinstance(child_obj, Table):
table = child_obj
current_child_text = self._resolve_table(table)
# 跳过其他非文本子对象
else:
continue
# 添加新对象
if (
len(single_chunk) + len(current_child_text)
> self._max_single_chunk_size
):
chunk_list.append(
{
"chunk_content": single_chunk,
"chunk_location": single_chunk_location,
}
)
single_chunk = ""
single_chunk_location = []
single_chunk += current_child_text + "\n"
single_chunk_location.append(
{"section_idx": section_idx, "section_child_idx": section_child_idx}
)
if len(single_chunk):
chunk_list.append(
{"chunk_content": single_chunk, "chunk_location": single_chunk_location}
)
return chunk_list
# 表格解析为markdown
def _resolve_table(self, table):
table_data = ""
for i in range(0, table.Rows.Count):
# 遍历行的单元格(cells)
cell_list = []
for j in range(0, table.Rows.get_Item(i).Cells.Count):
# 获取每一个单元格(cell)
cell = table.Rows.get_Item(i).Cells.get_Item(j)
cell_content = ""
for para_idx in range(cell.Paragraphs.Count):
paragraph_text = cell.Paragraphs.get_Item(para_idx).Text
cell_content += paragraph_text
cell_list.append(cell_content)
# table_data += "|" + "|".join(cell_list) + "|"
# table_data += "\n"
table_data += ' '.join(cell_list) + '\n'
if i == 0:
# table_data += "|" + "|".join(["--- " for _ in cell_list]) + "|\n"
table_data= ' '.join(cell_list) + '\n'
return table_data
def get_chunk_info(self, chunk_id):
chunk = self._chunk_list[chunk_id]
chunk_content = chunk["chunk_content"]
chunk_location = chunk["chunk_location"]
from_location = f"[第{chunk_location[0]['section_idx'] + 1}节的第{chunk_location[0]['section_child_idx'] + 1}段落]"
to_location = f"[第{chunk_location[-1]['section_idx'] + 1}节的第{chunk_location[-1]['section_child_idx'] + 1}段落]"
chunk_content_tips = (
"[" + chunk_content[:20] + "]...到...[" + chunk_content[-20:] + "]"
)
return f"文件块id: {chunk_id + 1}\n文件块位置: 从{from_location}到{to_location}\n文件块简述: {chunk_content_tips}\n"
def get_chunk_location(self, chunk_id):
return self.get_chunk_info(chunk_id)
def get_chunk_num(self):
self._ensure_loaded()
return len(self._chunk_list)
def get_chunk_item(self, chunk_id):
self._ensure_loaded()
return self._chunk_list[chunk_id]["chunk_content"]
# 根据locations获取数据
def get_sub_chunks(self, chunk_id):
if chunk_id >= len(self._chunk_list):
logger.error(f"get_sub_chunks_error:{chunk_id}")
return []
chunk = self._chunk_list[chunk_id]
chunk_locations = chunk["chunk_location"]
return [
self._doc.Sections.get_Item(loc["section_idx"]).Body.ChildObjects.get_Item(
loc["section_child_idx"]
)
for loc in chunk_locations
]
def format_comment_author(self, comment):
return "{}|{}".format(str(comment["id"]), comment["key_points"])
def remove_comment_prefix(
self,
):
for i in range(self._doc.Comments.Count):
current_comment = self._doc.Comments.get_Item(i)
comment_author = current_comment.Format.Author
split_author = comment_author.split("|")
if len(split_author) == 2:
current_comment.Format.Author = comment_author.split("|")[1]
# 根据text_selection批注
def set_comment_by_text_selection(self, text_sel, author, comment_content):
if text_sel is None:
return False
# 将找到的文本作为文本范围,并获取其所属的段落
range = text_sel.GetAsOneRange()
paragraph = range.OwnerParagraph
if paragraph is None:
return False
# 创建一个评论对象并设置评论的内容和作者
comment = Comment(self._doc)
comment.Body.AddParagraph().Text = comment_content
comment.Format.Author = author
# logger.info(author)
# 将评论添加到段落中
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(range) + 1, comment
)
# 创建评论起始标记和结束标记,并将它们设置为创建的评论的起始标记和结束标记
commentStart = CommentMark(self._doc, CommentMarkType.CommentStart)
commentEnd = CommentMark(self._doc, CommentMarkType.CommentEnd)
commentStart.CommentId = comment.Format.CommentId
commentEnd.CommentId = comment.Format.CommentId
# 在找到的文本之前和之后插入创建的评论起始和结束标记
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(range), commentStart
)
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(range) + 1, commentEnd
)
return True
# 根据段落批注
def set_comment_by_paragraph(self, paragraph, author, comment_content):
comment = Comment(self._doc)
comment.Body.AddParagraph().Text = comment_content
# 设置注释的作者
comment.Format.Author = author
paragraph.ChildObjects.Add(comment)
# 创建注释开始标记和结束标记,并将它们设置为创建的注释的开始和结束标记
commentStart = CommentMark(self._doc, CommentMarkType.CommentStart)
commentEnd = CommentMark(self._doc, CommentMarkType.CommentEnd)
commentStart.CommentId = comment.Format.CommentId
commentEnd.CommentId = comment.Format.CommentId
# 在段落结尾插入注释开始标记和结束标记
# paragraph.ChildObjects.Add(commentStart)
paragraph.ChildObjects.Add(commentEnd)
# 也可以考虑在段落开始处插入标记
paragraph.ChildObjects.Insert(0, commentStart)
# 设置chunk批注
def add_table_comment(
self, table, target_text, comment_text, author="审阅助手", initials="AI"
):
"""
在表格中添加批注
返回是否成功添加
"""
added = False
# 遍历表格所有单元格
for i in range(table.Rows.Count):
row = table.Rows[i]
for j in range(row.Cells.Count):
cell = row.Cells[j]
# 遍历单元格中的段落
for k in range(cell.Paragraphs.Count):
para = cell.Paragraphs[k]
# 在段落中查找目标文本
selection = para.Find(target_text, False, True)
if selection:
# 获取文本范围
text_range = selection.GetAsOneRange()
if text_range is None:
continue
# 获取所属段落
paragraph = text_range.OwnerParagraph
if paragraph is None:
continue
# 创建一个评论对象并设置评论的内容和作者
comment = Comment(self._doc)
comment.Body.AddParagraph().Text = comment_text
comment.Format.Author = author
# 将评论添加到段落中
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(text_range) + 1, comment
)
# 创建评论起始标记和结束标记
commentStart = CommentMark(
self._doc, CommentMarkType.CommentStart
)
commentEnd = CommentMark(self._doc, CommentMarkType.CommentEnd)
commentStart.CommentId = comment.Format.CommentId
commentEnd.CommentId = comment.Format.CommentId
# 在找到的文本之前和之后插入创建的评论起始和结束标记
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(text_range), commentStart
)
paragraph.ChildObjects.Insert(
paragraph.ChildObjects.IndexOf(text_range) + 1, commentEnd
)
added = True
# print(f"表格批注添加成功: '{target_text[:20]}...'")
# 添加成功后跳出内层循环
break
# 如果已经添加,跳出单元格循环
if added:
break
# 如果已经添加,跳出行循环
if added:
break
return added
def add_chunk_comment(self, chunk_id, comments):
"""
为chunk添加批注(保证每条评论只批注一次)
"""
if chunk_id is not None:
sub_chunks = self.get_sub_chunks(chunk_id)
for comment in comments:
if comment.get("result") != "不合格":
continue
# update chunk_id
chunk_id = comment.get("chunk_id", -1)
if chunk_id is not None and chunk_id != -1:
sub_chunks = self.get_sub_chunks(chunk_id)
author = self.format_comment_author(comment)
suggest = comment.get("suggest", "")
find_key = comment["original_text"].strip() or comment["key_points"]
# 先检查是否已经有批注
existing_comment_idx = self.find_comment(author)
if existing_comment_idx is not None:
# 已存在批注,则更新内容
self._doc.Comments.get_Item(
existing_comment_idx
).Body.Paragraphs.get_Item(0).Text = suggest
# print(f"批注已存在,更新内容: '{find_key[:20]}...'")
continue
matched = False
# ---------- 1. 精确匹配(段落 + 表格) ----------
for obj in sub_chunks:
if isinstance(obj, Paragraph):
try:
text_sel = obj.Find(find_key, False, True)
if text_sel and self.set_comment_by_text_selection(
text_sel, author, suggest
):
# print(f"段落批注添加成功: '{find_key[:20]}...'")
matched = True
# 第一个找到的作为标注对象
break
except Exception as e:
print(f"段落批注添加失败: {str(e)}")
elif isinstance(obj, Table):
try:
if self.add_table_comment(obj, find_key, suggest, author):
# 第一个找到的表格对象作为批注对象
matched = True
break
except Exception as e:
print(f"表格批注添加失败: {str(e)}")
# ---------- 2. 模糊匹配 ----------
if not matched:
try:
paragraphs_only = [
obj for obj in sub_chunks if isinstance(obj, Paragraph)
]
match_text, _ = find_best_match(paragraphs_only, comment)
if match_text:
for obj in paragraphs_only:
text_sel = obj.Find(match_text, False, True)
if text_sel and self.set_comment_by_text_selection(
text_sel, author, suggest
):
# print(f"模糊批注添加成功: '{match_text[:20]}...'")
matched = True
break
if not matched:
processed_text = process_string(match_text)
for obj in paragraphs_only:
text_sel = obj.Find(processed_text, False, True)
if text_sel and self.set_comment_by_text_selection(
text_sel, author, suggest
):
# print(f"处理后批注添加成功: '{processed_text[:20]}...'")
matched = True
break
# 表格模糊匹配(仅段落模糊匹配失败才跑)
if not matched:
for obj in sub_chunks:
if isinstance(obj, Table):
table_data = extract_table_cells_text(obj)
best_table_match, _ = table_contract(
table_data, comment
)
if best_table_match and self.add_table_comment(
obj, best_table_match, suggest, author
):
# print(f"表格批注添加成功: '{best_table_match[:20]}...'")
matched = True
break
except Exception as e:
print(f"模糊匹配失败: {str(e)}")
# ---------- 3. 匹配最终失败 ----------
if not matched:
logger.error(f"未找到可批注位置: '{find_key[:20]}...'")
# 根据作者名称查找批注
def find_comment(self, author):
for i in range(self._doc.Comments.Count):
current_comment = self._doc.Comments.get_Item(i)
comment_author = current_comment.Format.Author
if comment_author == author:
return i
return None
def delete_chunk_comment(self, comments):
"""
删除指定作者批注
"""
for comment in comments:
author = self.format_comment_author(comment)
author_comment_idx = self.find_comment(author)
if author_comment_idx is not None:
self._doc.Comments.RemoveAt(author_comment_idx)
print(f"删除批注: '{author}'")
def edit_chunk_comment(self, comments):
"""
编辑chunk批注:删除已合格的批注,修改存在的批注,不存在则新增
"""
for comment in comments:
author = self.format_comment_author(comment)
review_answer = comment["result"]
existing_comment_idx = self.find_comment(author)
if review_answer == "合格":
# 删除批注
if existing_comment_idx is not None:
self._doc.Comments.RemoveAt(existing_comment_idx)
# print(f"已删除合格批注: '{author}'")
else:
# 不合格,更新或新增
suggest = comment.get("suggest", "")
if existing_comment_idx is not None:
self._doc.Comments.get_Item(
existing_comment_idx
).Body.Paragraphs.get_Item(0).Text = suggest
# print(f"更新已有批注: '{author}'")
else:
# chunk_id要从comment中获取
self.add_chunk_comment(comment["chunk_id"] - 1, [comment])
def get_chunk_id_list(self, step=1):
self._ensure_loaded()
return [idx for idx in range(0, self.get_chunk_num(), step)]
def get_all_text(self):
self._ensure_loaded()
return self._doc.GetText()
def to_file(self, path, remove_prefix=False):
self._ensure_loaded()
if remove_prefix:
self.remove_comment_prefix()
self._doc.SaveToFile(path)
def release(self):
# 关闭文件
if self._doc:
self._doc.Close()
super().release()
def __del__(self):
pass
# self.release()
if __name__ == "__main__":
doc = SpireWordDoc()
doc.load(
r"/home/ccran/lufa-contract/demo/今麦郎合同审核.docx"
)
print(doc._doc_name)
doc.add_chunk_comment(
0,
[
{
"id": "1",
"key_points": "日期审查",
"original_text": "承诺",
"details": "1111",
"chunk_id": 0,
"result": "不合格",
"suggest": "这是测试建议",
}
],
)
doc.to_file("/home/ccran/lufa-contract/demo/今麦郎合同审核_test.docx", True)
\ No newline at end of file
{
{
......@@ -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.
{
{
"nodes": [
{
"nodeId": "userGuide",
"name": "common:core.module.template.system_config",
"intro": "common:core.module.template.system_config_info",
"avatar": "core/workflow/template/systemConfig",
"flowNodeType": "userGuide",
"position": {
"x": -3013.039854845954,
"y": -5395.896356747293
},
"version": "481",
"inputs": [
{
"key": "welcomeText",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "core.app.Welcome Text",
"value": ""
},
{
"key": "variables",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "core.app.Chat Variable",
"value": []
},
{
"key": "questionGuide",
"valueType": "any",
"renderTypeList": [
"hidden"
],
"label": "core.app.Question Guide",
"value": {
"open": false
}
},
{
"key": "tts",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"value": {
"type": "web"
}
},
{
"key": "whisper",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"value": {
"open": false,
"autoSend": false,
"autoTTSResponse": false
}
},
{
"key": "scheduleTrigger",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"value": null
}
],
"outputs": []
},
{
"nodeId": "448745",
"name": "common:core.module.template.work_start",
"intro": "",
"avatar": "core/workflow/template/workflowStart",
"flowNodeType": "workflowStart",
"position": {
"x": -2261.798076148374,
"y": -5236.752311683602
},
"version": "481",
"inputs": [
{
"key": "userChatInput",
"renderTypeList": [
"reference",
"textarea"
],
"valueType": "string",
"label": "common:core.module.input.label.user question",
"required": true,
"toolDescription": "用户问题",
"debugLabel": ""
}
],
"outputs": [
{
"id": "userChatInput",
"key": "userChatInput",
"label": "common:core.module.input.label.user question",
"type": "static",
"valueType": "string",
"description": ""
},
{
"id": "userFiles",
"key": "userFiles",
"label": "app:workflow.user_file_input",
"description": "app:workflow.user_file_input_desc",
"type": "static",
"valueType": "arrayString"
}
]
},
{
"nodeId": "qV22H1JlN795y1R1",
"name": "销售类别类型",
"intro": "可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)",
"avatar": "core/workflow/template/httpRequest",
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": 1680.3415517643311,
"y": -5381.240388279129
},
"version": "481",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "common:core.module.input.description.HTTP Dynamic Input",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpMethod",
"renderTypeList": [
"custom"
],
"valueType": "string",
"label": "",
"value": "POST",
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpTimeout",
"renderTypeList": [
"custom"
],
"valueType": "number",
"label": "",
"value": 600,
"min": 5,
"max": 600,
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpReqUrl",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "",
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.80:9006/category/iter_type",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpHeader",
"renderTypeList": [
"custom"
],
"valueType": "any",
"value": [],
"label": "",
"description": "common:core.module.input.description.Http Request Header",
"placeholder": "common:core.module.input.description.Http Request Header",
"required": false,
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpParams",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpJsonBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": "",
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpFormBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [
{
"key": "max_single_chunk_size",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_single_chunk_size$}}"
},
{
"key": "session_id",
"type": "string",
"value": "{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}"
},
{
"key": "urls",
"type": "string",
"value": "{{$448745.userFiles$}}"
},
{
"key": "max_pages",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_pages$}}"
},
{
"key": "chunk_step",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.chunk_step$}}"
},
{
"key": "type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.type_name$}}"
},
{
"key": "sub_type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.sub_type_name$}}"
}
],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpContentType",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"value": "form-data",
"label": "",
"required": false,
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "error",
"key": "error",
"label": "workflow:request_error",
"description": "HTTP请求错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "httpRawResponse",
"key": "httpRawResponse",
"required": true,
"label": "workflow:raw_response",
"description": "HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。",
"valueType": "any",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "输出字段提取",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "可以通过 JSONPath 语法来提取响应值中的指定字段",
"valueDesc": ""
}
]
},
{
"nodeId": "jH1tsWu1XYygZ7Y4",
"name": "会话ID生成",
"intro": "执行一段简单的脚本代码,通常用于进行复杂的数据处理。",
"avatar": "core/workflow/template/codeRun",
"flowNodeType": "code",
"showStatus": true,
"position": {
"x": -958.0494002011754,
"y": -5225.252311683602
},
"version": "482",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "workflow:these_variables_will_be_input_parameters_for_code_execution",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "codeType",
"renderTypeList": [
"hidden"
],
"label": "",
"valueType": "string",
"value": "js",
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "code",
"renderTypeList": [
"custom"
],
"label": "",
"valueType": "string",
"value": "function main(){\n const length = 10\n const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n let result = '';\n for (let i = 0; i < length; i++) {\n result += chars.charAt(Math.floor(Math.random() * chars.length));\n }\n return {\n result\n }\n}",
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "system_rawResponse",
"key": "system_rawResponse",
"label": "workflow:full_response_data",
"valueType": "object",
"type": "static",
"description": ""
},
{
"id": "error",
"key": "error",
"label": "workflow:execution_error",
"description": "代码运行错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "将代码中 return 的对象作为输出,传递给后续的节点。变量名需要对应 return 的 key",
"valueDesc": ""
},
{
"id": "y55H9qjPE8t1M0vi",
"valueType": "string",
"type": "dynamic",
"key": "result",
"label": "result"
}
]
},
{
"nodeId": "naSODSyTvHo7ihc9",
"name": "会话输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": -11.150880532804308,
"y": -5937.429480428028
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "当前会话ID:{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}\n",
"valueDesc": "",
"debugLabel": "",
"toolDescription": "",
"selectedTypeIndex": 0
}
],
"outputs": []
},
{
"nodeId": "t7jsULrFPEaI3Dwc",
"name": "销售类别输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 4422.8348613123535,
"y": -5824.747188301225
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "开始执行[**{{$qV22H1JlN795y1R1.httpRawResponse$}}**]判别......",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "fYPaa94duwivIDWC",
"name": "判断器",
"intro": "根据一定的条件,执行不同的分支。",
"avatar": "core/workflow/template/ifelse",
"flowNodeType": "ifElseNode",
"showStatus": true,
"position": {
"x": 2772.0257965198266,
"y": -6432.405692273836
},
"version": "481",
"inputs": [
{
"key": "ifElseList",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"value": [
{
"condition": "OR",
"list": [
{
"variable": [
"qV22H1JlN795y1R1",
"httpRawResponse"
],
"condition": "include",
"value": "EOF"
},
{
"variable": [
"VARIABLE_NODE_ID",
"is_end"
],
"condition": "equalTo",
"value": "true"
}
]
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "ifElseResult",
"key": "ifElseResult",
"label": "workflow:judgment_result",
"valueType": "string",
"type": "static",
"description": ""
}
]
},
{
"nodeId": "uaQqAxEVEUXDVLG4",
"name": "判别结束",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 4517.710021219731,
"y": -6381.023969842139
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "判别结束。",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "xK1xTL6I7vgAUE3U",
"name": "变量更新",
"intro": "可以更新指定节点的输出值或更新全局变量",
"avatar": "core/workflow/template/variableUpdate",
"flowNodeType": "variableUpdate",
"showStatus": false,
"position": {
"x": 5985.356220435356,
"y": -5527.460212678566
},
"version": "481",
"inputs": [
{
"key": "updateList",
"valueType": "any",
"label": "",
"renderTypeList": [
"hidden"
],
"value": [
{
"variable": [
"VARIABLE_NODE_ID",
"sub_type_name"
],
"value": [
"qV22H1JlN795y1R1",
"httpRawResponse"
],
"valueType": "string",
"renderType": "reference"
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "bw8a3Lt3MCONv8Z5",
"name": "合同片段",
"intro": "可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)",
"avatar": "core/workflow/template/httpRequest",
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": 6761.365551641942,
"y": -6312.512802425409
},
"version": "481",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "common:core.module.input.description.HTTP Dynamic Input",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpMethod",
"renderTypeList": [
"custom"
],
"valueType": "string",
"label": "",
"value": "POST",
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpTimeout",
"renderTypeList": [
"custom"
],
"valueType": "number",
"label": "",
"value": 30,
"min": 5,
"max": 600,
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpReqUrl",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "",
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.80:9006/category/chunk_list",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpHeader",
"renderTypeList": [
"custom"
],
"valueType": "any",
"value": [],
"label": "",
"description": "common:core.module.input.description.Http Request Header",
"placeholder": "common:core.module.input.description.Http Request Header",
"required": false,
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpParams",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpJsonBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": "",
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpFormBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [
{
"key": "max_single_chunk_size",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_single_chunk_size$}}"
},
{
"key": "session_id",
"type": "string",
"value": "{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}"
},
{
"key": "urls",
"type": "string",
"value": "{{$448745.userFiles$}}"
},
{
"key": "max_pages",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_pages$}}"
},
{
"key": "chunk_step",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.chunk_step$}}"
},
{
"key": "type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.type_name$}}"
},
{
"key": "sub_type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.sub_type_name$}}"
}
],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpContentType",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"value": "form-data",
"label": "",
"required": false,
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "error",
"key": "error",
"label": "workflow:request_error",
"description": "HTTP请求错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "httpRawResponse",
"key": "httpRawResponse",
"required": true,
"label": "workflow:raw_response",
"description": "HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。",
"valueType": "any",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "输出字段提取",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "可以通过 JSONPath 语法来提取响应值中的指定字段",
"valueDesc": ""
},
{
"id": "vA9aPNhzxyJ9g7H8",
"valueType": "string",
"type": "dynamic",
"key": "info",
"label": "info"
},
{
"id": "ycD2WwB3eCUwMaib",
"valueType": "arrayNumber",
"type": "dynamic",
"key": "all_chunks",
"label": "all_chunks"
}
]
},
{
"nodeId": "gzjgtO3PQqLvfHtz",
"name": "批量执行",
"intro": "输入一个数组,遍历数组并将每一个数组元素作为输入元素,执行工作流。",
"avatar": "core/workflow/template/loop",
"flowNodeType": "loop",
"showStatus": true,
"position": {
"x": 9249,
"y": -7844
},
"version": "4811",
"inputs": [
{
"key": "loopInputArray",
"renderTypeList": [
"reference"
],
"valueType": "arrayNumber",
"required": true,
"label": "数组",
"value": [
[
"bw8a3Lt3MCONv8Z5",
"ycD2WwB3eCUwMaib"
]
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "childrenNodeIdList",
"renderTypeList": [
"hidden"
],
"valueType": "arrayString",
"label": "",
"value": [
"rWcWltZS7fyHxU0B",
"lMyndXhTZ2J0I5Wk",
"l6CepeiMmfajoYLA",
"fDIgqqDfp2nM00Zt",
"xWOOSVcEw2SmwBql",
"rhHPHV55eMio6vzR",
"p6IsMD68nZiAKP1k"
]
},
{
"key": "nodeWidth",
"renderTypeList": [
"hidden"
],
"valueType": "number",
"label": "",
"value": 3107.363855048203
},
{
"key": "nodeHeight",
"renderTypeList": [
"hidden"
],
"valueType": "number",
"label": "",
"value": 1921.1652487928222
},
{
"key": "loopNodeInputHeight",
"renderTypeList": [
"hidden"
],
"valueType": "number",
"label": "",
"value": 83,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "loopArray",
"key": "loopArray",
"label": "workflow:loop_result",
"type": "static",
"valueType": "arrayAny",
"description": ""
}
]
},
{
"nodeId": "rWcWltZS7fyHxU0B",
"parentNodeId": "gzjgtO3PQqLvfHtz",
"name": "开始",
"avatar": "core/workflow/template/loopStart",
"flowNodeType": "loopStart",
"showStatus": false,
"position": {
"x": 9319.187516980206,
"y": -7520.696109498177
},
"version": "4811",
"inputs": [
{
"key": "loopStartInput",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"required": true,
"value": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "loopStartIndex",
"renderTypeList": [
"hidden"
],
"valueType": "number",
"label": "workflow:Array_element_index",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "loopStartIndex",
"key": "loopStartIndex",
"label": "workflow:Array_element_index",
"type": "static",
"valueType": "number",
"description": ""
},
{
"id": "loopStartInput",
"key": "loopStartInput",
"label": "数组元素",
"type": "static",
"valueType": "number"
}
]
},
{
"nodeId": "lMyndXhTZ2J0I5Wk",
"parentNodeId": "gzjgtO3PQqLvfHtz",
"name": "结束",
"avatar": "core/workflow/template/loopEnd",
"flowNodeType": "loopEnd",
"showStatus": false,
"position": {
"x": 10812.78286477631,
"y": -6604.617757768273
},
"version": "4811",
"inputs": [
{
"key": "loopEndInput",
"renderTypeList": [
"reference"
],
"valueType": "any",
"label": "",
"required": true,
"value": [
"fDIgqqDfp2nM00Zt",
"httpRawResponse"
],
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "l6CepeiMmfajoYLA",
"parentNodeId": "gzjgtO3PQqLvfHtz",
"name": "片段输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 10516.185232844682,
"y": -7055.856633636786
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "正在对合同片段[{{$rWcWltZS7fyHxU0B.loopStartInput$}}]执行判别......",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "fDIgqqDfp2nM00Zt",
"parentNodeId": "gzjgtO3PQqLvfHtz",
"name": "合同片段销售类别判别",
"intro": "可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)",
"avatar": "core/workflow/template/httpRequest",
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": 9469.204894631263,
"y": -6665.530860705355
},
"version": "481",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "common:core.module.input.description.HTTP Dynamic Input",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpMethod",
"renderTypeList": [
"custom"
],
"valueType": "string",
"label": "",
"value": "POST",
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpTimeout",
"renderTypeList": [
"custom"
],
"valueType": "number",
"label": "",
"value": 600,
"min": 5,
"max": 600,
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpReqUrl",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "",
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.80:9006/category/category",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpHeader",
"renderTypeList": [
"custom"
],
"valueType": "any",
"value": [],
"label": "",
"description": "common:core.module.input.description.Http Request Header",
"placeholder": "common:core.module.input.description.Http Request Header",
"required": false,
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpParams",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpJsonBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": "",
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpFormBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [
{
"key": "max_single_chunk_size",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_single_chunk_size$}}"
},
{
"key": "session_id",
"type": "string",
"value": "{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}"
},
{
"key": "urls",
"type": "string",
"value": "{{$448745.userFiles$}}"
},
{
"key": "max_pages",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_pages$}}"
},
{
"key": "chunk_step",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.chunk_step$}}"
},
{
"key": "type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.type_name$}}"
},
{
"key": "chunk_id",
"type": "string",
"value": "{{$rWcWltZS7fyHxU0B.loopStartInput$}}"
},
{
"key": "sub_type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.sub_type_name$}}"
}
],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpContentType",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"value": "form-data",
"label": "",
"required": false,
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "error",
"key": "error",
"label": "workflow:request_error",
"description": "HTTP请求错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "httpRawResponse",
"key": "httpRawResponse",
"required": true,
"label": "workflow:raw_response",
"description": "HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。",
"valueType": "any",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "输出字段提取",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "可以通过 JSONPath 语法来提取响应值中的指定字段",
"valueDesc": ""
},
{
"id": "v7MwOYA8S8EQoLy3",
"valueType": "boolean",
"type": "dynamic",
"key": "res",
"label": "res"
},
{
"id": "lfQrCjSBwd8tuyZR",
"valueType": "string",
"type": "dynamic",
"key": "judge",
"label": "judge"
},
{
"id": "r28lWj9MyqdNjeEs",
"valueType": "string",
"type": "dynamic",
"key": "text",
"label": "text"
},
{
"id": "dmrizXYzk1MhUmkt",
"valueType": "string",
"type": "dynamic",
"key": "chunk_location",
"label": "chunk_location"
}
]
},
{
"nodeId": "xWOOSVcEw2SmwBql",
"parentNodeId": "gzjgtO3PQqLvfHtz",
"name": "分段判别结果",
"intro": "根据一定的条件,执行不同的分支。",
"avatar": "core/workflow/template/ifelse",
"flowNodeType": "ifElseNode",
"showStatus": true,
"position": {
"x": 10482.601918568382,
"y": -6278.442984562107
},
"version": "481",
"inputs": [
{
"key": "ifElseList",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"value": [
{
"condition": "AND",
"list": [
{
"variable": [
"fDIgqqDfp2nM00Zt",
"v7MwOYA8S8EQoLy3"
],
"condition": "equalTo",
"value": "true"
}
]
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "ifElseResult",
"key": "ifElseResult",
"label": "workflow:judgment_result",
"valueType": "string",
"type": "static",
"description": ""
}
]
},
{
"nodeId": "rhHPHV55eMio6vzR",
"parentNodeId": "gzjgtO3PQqLvfHtz",
"name": "is_end更新",
"intro": "可以更新指定节点的输出值或更新全局变量",
"avatar": "core/workflow/template/variableUpdate",
"flowNodeType": "variableUpdate",
"showStatus": false,
"position": {
"x": 11694.283705209387,
"y": -6448.617757768273
},
"version": "481",
"inputs": [
{
"key": "updateList",
"valueType": "any",
"label": "",
"renderTypeList": [
"hidden"
],
"value": [
{
"variable": [
"VARIABLE_NODE_ID",
"is_end"
],
"value": [
"",
"true"
],
"valueType": "boolean",
"renderType": "input"
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "zs0Eywdq9oTjSIwo",
"name": "OCR文档",
"intro": "可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)",
"avatar": "core/workflow/template/httpRequest",
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": -112.44715797733295,
"y": -5299.163352443061
},
"version": "481",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "common:core.module.input.description.HTTP Dynamic Input",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpMethod",
"renderTypeList": [
"custom"
],
"valueType": "string",
"label": "",
"value": "POST",
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpTimeout",
"renderTypeList": [
"custom"
],
"valueType": "number",
"label": "",
"value": 600,
"min": 5,
"max": 600,
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpReqUrl",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "",
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.80:9006/category/ocr",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpHeader",
"renderTypeList": [
"custom"
],
"valueType": "any",
"value": [],
"label": "",
"description": "common:core.module.input.description.Http Request Header",
"placeholder": "common:core.module.input.description.Http Request Header",
"required": false,
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpParams",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpJsonBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": "",
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpFormBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [
{
"key": "max_single_chunk_size",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_single_chunk_size$}}"
},
{
"key": "session_id",
"type": "string",
"value": "{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}"
},
{
"key": "urls",
"type": "string",
"value": "{{$448745.userFiles$}}"
},
{
"key": "max_pages",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_pages$}}"
},
{
"key": "chunk_step",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.chunk_step$}}"
},
{
"key": "type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.type_name$}}"
}
],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpContentType",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"value": "form-data",
"label": "",
"required": false,
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "error",
"key": "error",
"label": "workflow:request_error",
"description": "HTTP请求错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "httpRawResponse",
"key": "httpRawResponse",
"required": true,
"label": "workflow:raw_response",
"description": "HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。",
"valueType": "any",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "输出字段提取",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "可以通过 JSONPath 语法来提取响应值中的指定字段",
"valueDesc": ""
}
]
},
{
"nodeId": "y7t5Ew8bdqpqIzy4",
"name": "list输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 7989.7596229324645,
"y": -6186.170310257907
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "**{{$bw8a3Lt3MCONv8Z5.vA9aPNhzxyJ9g7H8$}}**",
"valueDesc": "",
"debugLabel": "",
"toolDescription": "",
"selectedTypeIndex": 0
}
],
"outputs": []
},
{
"nodeId": "mszS8pbhGFsQFe95",
"name": "OCR结束",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 780.3719209710606,
"y": -5000.752311683602
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "OCR结束",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "g7rAFWAW2wLudYBS",
"name": "初始化变量",
"intro": "可以更新指定节点的输出值或更新全局变量",
"avatar": "core/workflow/template/variableUpdate",
"flowNodeType": "variableUpdate",
"showStatus": false,
"position": {
"x": -1656.4877159838231,
"y": -5186.752311683602
},
"version": "481",
"inputs": [
{
"key": "updateList",
"valueType": "any",
"label": "",
"renderTypeList": [
"hidden"
],
"value": [
{
"variable": [
"VARIABLE_NODE_ID",
"type_name"
],
"valueType": "string",
"renderType": "input",
"value": [
"",
"内销或出口"
]
},
{
"variable": [
"VARIABLE_NODE_ID",
"is_end"
],
"value": [
"",
"false"
],
"renderType": "input",
"valueType": "boolean"
},
{
"variable": [
"VARIABLE_NODE_ID",
"sub_type_name"
],
"renderType": "input",
"valueType": "string",
"value": [
"",
""
]
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "kv5tMm5dTnHkgHse",
"name": "反思",
"intro": "可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)",
"avatar": "core/workflow/template/httpRequest",
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": 15303.930144548716,
"y": -6990.502634067284
},
"version": "481",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "common:core.module.input.description.HTTP Dynamic Input",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpMethod",
"renderTypeList": [
"custom"
],
"valueType": "string",
"label": "",
"value": "POST",
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpTimeout",
"renderTypeList": [
"custom"
],
"valueType": "number",
"label": "",
"value": 600,
"min": 5,
"max": 600,
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpReqUrl",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "",
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.80:9006/category/reflection",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpHeader",
"renderTypeList": [
"custom"
],
"valueType": "any",
"value": [],
"label": "",
"description": "common:core.module.input.description.Http Request Header",
"placeholder": "common:core.module.input.description.Http Request Header",
"required": false,
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpParams",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpJsonBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": "",
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpFormBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [
{
"key": "max_single_chunk_size",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_single_chunk_size$}}"
},
{
"key": "session_id",
"type": "string",
"value": "{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}"
},
{
"key": "urls",
"type": "string",
"value": "{{$448745.userFiles$}}"
},
{
"key": "max_pages",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_pages$}}"
},
{
"key": "chunk_step",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.chunk_step$}}"
},
{
"key": "type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.type_name$}}"
},
{
"key": "sub_type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.sub_type_name$}}"
}
],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpContentType",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"value": "x-www-form-urlencoded",
"label": "",
"required": false,
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "error",
"key": "error",
"label": "workflow:request_error",
"description": "HTTP请求错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "httpRawResponse",
"key": "httpRawResponse",
"required": true,
"label": "workflow:raw_response",
"description": "HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。",
"valueType": "any",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "输出字段提取",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "可以通过 JSONPath 语法来提取响应值中的指定字段",
"valueDesc": ""
},
{
"id": "v7MwOYA8S8EQoLy3",
"valueType": "string",
"type": "dynamic",
"key": "res",
"label": "res"
},
{
"id": "mpqq4gGftILf1kXs",
"valueType": "string",
"type": "dynamic",
"key": "judge",
"label": "judge"
},
{
"id": "xoLU0iELUzDn4P6M",
"valueType": "string",
"type": "dynamic",
"key": "text",
"label": "text"
}
]
},
{
"nodeId": "roeYO0toBwf3SQ2H",
"name": "反思输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 18422.704913825637,
"y": -6267.136605323737
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "# 🔍判断思路\n{{$kv5tMm5dTnHkgHse.mpqq4gGftILf1kXs$}}\n\n# 🏷️结果\n**{{$kv5tMm5dTnHkgHse.v7MwOYA8S8EQoLy3$}}**\n\n---",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "p6IsMD68nZiAKP1k",
"parentNodeId": "gzjgtO3PQqLvfHtz",
"name": "否输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 11680.551372028409,
"y": -6003.157936036692
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "该片段未提取到关键信息...",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "dx59JyoAtGgCmmUA",
"name": "类别更新",
"intro": "可以更新指定节点的输出值或更新全局变量",
"avatar": "core/workflow/template/variableUpdate",
"flowNodeType": "variableUpdate",
"showStatus": false,
"position": {
"x": 17322.389154521952,
"y": -6471.536464469149
},
"version": "481",
"inputs": [
{
"key": "updateList",
"valueType": "any",
"label": "",
"renderTypeList": [
"hidden"
],
"value": [
{
"variable": [
"VARIABLE_NODE_ID",
"type_name"
],
"value": [
"kv5tMm5dTnHkgHse",
"v7MwOYA8S8EQoLy3"
],
"valueType": "string",
"renderType": "reference"
},
{
"variable": [
"VARIABLE_NODE_ID",
"sub_type_name"
],
"renderType": "reference",
"valueType": "string"
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "r26yR9Rte1Oq2kH6",
"name": "反思判断",
"intro": "根据一定的条件,执行不同的分支。",
"avatar": "core/workflow/template/ifelse",
"flowNodeType": "ifElseNode",
"showStatus": true,
"position": {
"x": 16196.866525622323,
"y": -6471.536464469149
},
"version": "481",
"inputs": [
{
"key": "ifElseList",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"value": [
{
"condition": "AND",
"list": [
{
"variable": [
"kv5tMm5dTnHkgHse",
"v7MwOYA8S8EQoLy3"
],
"condition": "isNotEmpty"
}
]
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "ifElseResult",
"key": "ifElseResult",
"label": "workflow:judgment_result",
"valueType": "string",
"type": "static",
"description": ""
}
]
},
{
"nodeId": "o4aXYNygn88UBl71",
"name": "结果导出",
"intro": "可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)",
"avatar": "core/workflow/template/httpRequest",
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": 5709.538879950568,
"y": -6650.405692273836
},
"version": "481",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "common:core.module.input.description.HTTP Dynamic Input",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpMethod",
"renderTypeList": [
"custom"
],
"valueType": "string",
"label": "",
"value": "POST",
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpTimeout",
"renderTypeList": [
"custom"
],
"valueType": "number",
"label": "",
"value": 30,
"min": 5,
"max": 600,
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpReqUrl",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "",
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.80:9006/category/export",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpHeader",
"renderTypeList": [
"custom"
],
"valueType": "any",
"value": [],
"label": "",
"description": "common:core.module.input.description.Http Request Header",
"placeholder": "common:core.module.input.description.Http Request Header",
"required": false,
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpParams",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpJsonBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": "",
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpFormBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [
{
"key": "max_single_chunk_size",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_single_chunk_size$}}"
},
{
"key": "session_id",
"type": "string",
"value": "{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}"
},
{
"key": "urls",
"type": "string",
"value": "{{$448745.userFiles$}}"
},
{
"key": "max_pages",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_pages$}}"
},
{
"key": "chunk_step",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.chunk_step$}}"
},
{
"key": "type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.type_name$}}"
},
{
"key": "sub_type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.sub_type_name$}}"
}
],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpContentType",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"value": "form-data",
"label": "",
"required": false,
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "error",
"key": "error",
"label": "workflow:request_error",
"description": "HTTP请求错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "httpRawResponse",
"key": "httpRawResponse",
"required": true,
"label": "workflow:raw_response",
"description": "HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。",
"valueType": "any",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "输出字段提取",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "可以通过 JSONPath 语法来提取响应值中的指定字段",
"valueDesc": ""
},
{
"id": "vA9aPNhzxyJ9g7H8",
"valueType": "string",
"type": "dynamic",
"key": "excel",
"label": "excel"
}
]
},
{
"nodeId": "y5RXEE2W5CxOIUs1",
"name": "结果输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 6769.449373329997,
"y": -6859.47636437232
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "# 导出Excel\n{{$o4aXYNygn88UBl71.vA9aPNhzxyJ9g7H8$}}",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
},
{
"nodeId": "jq8r2P51E6ST3g5F",
"name": "是否判断成功",
"intro": "根据一定的条件,执行不同的分支。",
"avatar": "core/workflow/template/ifelse",
"flowNodeType": "ifElseNode",
"showStatus": true,
"position": {
"x": 12619.828461568719,
"y": -6776.249761393352
},
"version": "481",
"inputs": [
{
"key": "ifElseList",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"label": "",
"value": [
{
"condition": "AND",
"list": [
{
"variable": [
"VARIABLE_NODE_ID",
"is_end"
],
"condition": "equalTo",
"value": "true"
}
]
}
],
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "ifElseResult",
"key": "ifElseResult",
"label": "workflow:judgment_result",
"valueType": "string",
"type": "static",
"description": ""
}
]
},
{
"nodeId": "vBwefuR5mRWI8EcA",
"name": "判别成功记忆输出",
"intro": "可以发出一个 HTTP 请求,实现更为复杂的操作(联网搜索、数据库查询等)",
"avatar": "core/workflow/template/httpRequest",
"flowNodeType": "httpRequest468",
"showStatus": true,
"position": {
"x": 13552.383878065986,
"y": -7371.613859189546
},
"version": "481",
"inputs": [
{
"key": "system_addInputParam",
"renderTypeList": [
"addInputParam"
],
"valueType": "dynamic",
"label": "",
"required": false,
"description": "common:core.module.input.description.HTTP Dynamic Input",
"customInputConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": true
},
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpMethod",
"renderTypeList": [
"custom"
],
"valueType": "string",
"label": "",
"value": "POST",
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpTimeout",
"renderTypeList": [
"custom"
],
"valueType": "number",
"label": "",
"value": 600,
"min": 5,
"max": 600,
"required": true,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpReqUrl",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"label": "",
"description": "common:core.module.input.description.Http Request Url",
"placeholder": "https://api.ai.com/getInventory",
"required": false,
"value": "http://172.21.107.80:9006/category/memory",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpHeader",
"renderTypeList": [
"custom"
],
"valueType": "any",
"value": [],
"label": "",
"description": "common:core.module.input.description.Http Request Header",
"placeholder": "common:core.module.input.description.Http Request Header",
"required": false,
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpParams",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpJsonBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": "",
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpFormBody",
"renderTypeList": [
"hidden"
],
"valueType": "any",
"value": [
{
"key": "max_single_chunk_size",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_single_chunk_size$}}"
},
{
"key": "session_id",
"type": "string",
"value": "{{$jH1tsWu1XYygZ7Y4.y55H9qjPE8t1M0vi$}}"
},
{
"key": "urls",
"type": "string",
"value": "{{$448745.userFiles$}}"
},
{
"key": "max_pages",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.max_pages$}}"
},
{
"key": "chunk_step",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.chunk_step$}}"
},
{
"key": "type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.type_name$}}"
},
{
"key": "sub_type_name",
"type": "string",
"value": "{{$VARIABLE_NODE_ID.sub_type_name$}}"
}
],
"label": "",
"required": false,
"valueDesc": "",
"description": "",
"debugLabel": "",
"toolDescription": ""
},
{
"key": "system_httpContentType",
"renderTypeList": [
"hidden"
],
"valueType": "string",
"value": "x-www-form-urlencoded",
"label": "",
"required": false,
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": [
{
"id": "error",
"key": "error",
"label": "workflow:request_error",
"description": "HTTP请求错误信息,成功时返回空",
"valueType": "object",
"type": "static"
},
{
"id": "httpRawResponse",
"key": "httpRawResponse",
"required": true,
"label": "workflow:raw_response",
"description": "HTTP请求的原始响应。只能接受字符串或JSON类型响应数据。",
"valueType": "any",
"type": "static"
},
{
"id": "system_addOutputParam",
"key": "system_addOutputParam",
"type": "dynamic",
"valueType": "dynamic",
"label": "输出字段提取",
"customFieldConfig": {
"selectValueTypeList": [
"string",
"number",
"boolean",
"object",
"arrayString",
"arrayNumber",
"arrayBoolean",
"arrayObject",
"arrayAny",
"any",
"chatHistory",
"datasetQuote",
"dynamic",
"selectDataset",
"selectApp"
],
"showDescription": false,
"showDefaultValue": false
},
"description": "可以通过 JSONPath 语法来提取响应值中的指定字段",
"valueDesc": ""
}
]
},
{
"nodeId": "tnDQBPunfj3L60od",
"name": "判别成功记忆输出",
"intro": "该模块可以直接回复一段指定的内容。常用于引导、提示。非字符串内容传入时,会转成字符串进行输出。",
"avatar": "core/workflow/template/reply",
"flowNodeType": "answerNode",
"position": {
"x": 14512.941812193714,
"y": -7105.113859189546
},
"version": "481",
"inputs": [
{
"key": "text",
"renderTypeList": [
"textarea",
"reference"
],
"valueType": "any",
"required": true,
"label": "回复的内容",
"description": "common:core.module.input.description.Response content",
"placeholder": "common:core.module.input.description.Response content",
"value": "# 🏷️判别结果\n{{$VARIABLE_NODE_ID.sub_type_name$}}\n\n{{$vBwefuR5mRWI8EcA.httpRawResponse$}}",
"valueDesc": "",
"debugLabel": "",
"toolDescription": ""
}
],
"outputs": []
}
],
"edges": [
{
"source": "jH1tsWu1XYygZ7Y4",
"target": "naSODSyTvHo7ihc9",
"sourceHandle": "jH1tsWu1XYygZ7Y4-source-right",
"targetHandle": "naSODSyTvHo7ihc9-target-left"
},
{
"source": "qV22H1JlN795y1R1",
"target": "fYPaa94duwivIDWC",
"sourceHandle": "qV22H1JlN795y1R1-source-right",
"targetHandle": "fYPaa94duwivIDWC-target-left"
},
{
"source": "fYPaa94duwivIDWC",
"target": "t7jsULrFPEaI3Dwc",
"sourceHandle": "fYPaa94duwivIDWC-source-ELSE",
"targetHandle": "t7jsULrFPEaI3Dwc-target-left"
},
{
"source": "fYPaa94duwivIDWC",
"target": "uaQqAxEVEUXDVLG4",
"sourceHandle": "fYPaa94duwivIDWC-source-IF",
"targetHandle": "uaQqAxEVEUXDVLG4-target-left"
},
{
"source": "t7jsULrFPEaI3Dwc",
"target": "xK1xTL6I7vgAUE3U",
"sourceHandle": "t7jsULrFPEaI3Dwc-source-right",
"targetHandle": "xK1xTL6I7vgAUE3U-target-left"
},
{
"source": "xK1xTL6I7vgAUE3U",
"target": "bw8a3Lt3MCONv8Z5",
"sourceHandle": "xK1xTL6I7vgAUE3U-source-right",
"targetHandle": "bw8a3Lt3MCONv8Z5-target-left"
},
{
"source": "l6CepeiMmfajoYLA",
"target": "fDIgqqDfp2nM00Zt",
"sourceHandle": "l6CepeiMmfajoYLA-source-bottom",
"targetHandle": "fDIgqqDfp2nM00Zt-target-top"
},
{
"source": "xWOOSVcEw2SmwBql",
"target": "rhHPHV55eMio6vzR",
"sourceHandle": "xWOOSVcEw2SmwBql-source-IF",
"targetHandle": "rhHPHV55eMio6vzR-target-left"
},
{
"source": "jH1tsWu1XYygZ7Y4",
"target": "zs0Eywdq9oTjSIwo",
"sourceHandle": "jH1tsWu1XYygZ7Y4-source-right",
"targetHandle": "zs0Eywdq9oTjSIwo-target-left"
},
{
"source": "bw8a3Lt3MCONv8Z5",
"target": "y7t5Ew8bdqpqIzy4",
"sourceHandle": "bw8a3Lt3MCONv8Z5-source-right",
"targetHandle": "y7t5Ew8bdqpqIzy4-target-left"
},
{
"source": "y7t5Ew8bdqpqIzy4",
"target": "gzjgtO3PQqLvfHtz",
"sourceHandle": "y7t5Ew8bdqpqIzy4-source-right",
"targetHandle": "gzjgtO3PQqLvfHtz-target-left"
},
{
"source": "zs0Eywdq9oTjSIwo",
"target": "mszS8pbhGFsQFe95",
"sourceHandle": "zs0Eywdq9oTjSIwo-source-right",
"targetHandle": "mszS8pbhGFsQFe95-target-left"
},
{
"source": "mszS8pbhGFsQFe95",
"target": "qV22H1JlN795y1R1",
"sourceHandle": "mszS8pbhGFsQFe95-source-right",
"targetHandle": "qV22H1JlN795y1R1-target-left"
},
{
"source": "448745",
"target": "g7rAFWAW2wLudYBS",
"sourceHandle": "448745-source-right",
"targetHandle": "g7rAFWAW2wLudYBS-target-left"
},
{
"source": "g7rAFWAW2wLudYBS",
"target": "jH1tsWu1XYygZ7Y4",
"sourceHandle": "g7rAFWAW2wLudYBS-source-right",
"targetHandle": "jH1tsWu1XYygZ7Y4-target-left"
},
{
"source": "roeYO0toBwf3SQ2H",
"target": "qV22H1JlN795y1R1",
"sourceHandle": "roeYO0toBwf3SQ2H-source-bottom",
"targetHandle": "qV22H1JlN795y1R1-target-bottom"
},
{
"source": "xWOOSVcEw2SmwBql",
"target": "p6IsMD68nZiAKP1k",
"sourceHandle": "xWOOSVcEw2SmwBql-source-ELSE",
"targetHandle": "p6IsMD68nZiAKP1k-target-left"
},
{
"source": "fDIgqqDfp2nM00Zt",
"target": "xWOOSVcEw2SmwBql",
"sourceHandle": "fDIgqqDfp2nM00Zt-source-right",
"targetHandle": "xWOOSVcEw2SmwBql-target-left"
},
{
"source": "dx59JyoAtGgCmmUA",
"target": "roeYO0toBwf3SQ2H",
"sourceHandle": "dx59JyoAtGgCmmUA-source-right",
"targetHandle": "roeYO0toBwf3SQ2H-target-left"
},
{
"source": "kv5tMm5dTnHkgHse",
"target": "r26yR9Rte1Oq2kH6",
"sourceHandle": "kv5tMm5dTnHkgHse-source-right",
"targetHandle": "r26yR9Rte1Oq2kH6-target-left"
},
{
"source": "r26yR9Rte1Oq2kH6",
"target": "dx59JyoAtGgCmmUA",
"sourceHandle": "r26yR9Rte1Oq2kH6-source-IF",
"targetHandle": "dx59JyoAtGgCmmUA-target-left"
},
{
"source": "r26yR9Rte1Oq2kH6",
"target": "qV22H1JlN795y1R1",
"sourceHandle": "r26yR9Rte1Oq2kH6-source-ELSE",
"targetHandle": "qV22H1JlN795y1R1-target-bottom"
},
{
"source": "rWcWltZS7fyHxU0B",
"target": "l6CepeiMmfajoYLA",
"sourceHandle": "rWcWltZS7fyHxU0B-source-right",
"targetHandle": "l6CepeiMmfajoYLA-target-left"
},
{
"source": "fDIgqqDfp2nM00Zt",
"target": "lMyndXhTZ2J0I5Wk",
"sourceHandle": "fDIgqqDfp2nM00Zt-source-right",
"targetHandle": "lMyndXhTZ2J0I5Wk-target-left"
},
{
"source": "uaQqAxEVEUXDVLG4",
"target": "o4aXYNygn88UBl71",
"sourceHandle": "uaQqAxEVEUXDVLG4-source-right",
"targetHandle": "o4aXYNygn88UBl71-target-left"
},
{
"source": "o4aXYNygn88UBl71",
"target": "y5RXEE2W5CxOIUs1",
"sourceHandle": "o4aXYNygn88UBl71-source-right",
"targetHandle": "y5RXEE2W5CxOIUs1-target-left"
},
{
"source": "gzjgtO3PQqLvfHtz",
"target": "jq8r2P51E6ST3g5F",
"sourceHandle": "gzjgtO3PQqLvfHtz-source-right",
"targetHandle": "jq8r2P51E6ST3g5F-target-left"
},
{
"source": "jq8r2P51E6ST3g5F",
"target": "kv5tMm5dTnHkgHse",
"sourceHandle": "jq8r2P51E6ST3g5F-source-ELSE",
"targetHandle": "kv5tMm5dTnHkgHse-target-left"
},
{
"source": "jq8r2P51E6ST3g5F",
"target": "vBwefuR5mRWI8EcA",
"sourceHandle": "jq8r2P51E6ST3g5F-source-IF",
"targetHandle": "vBwefuR5mRWI8EcA-target-left"
},
{
"source": "vBwefuR5mRWI8EcA",
"target": "tnDQBPunfj3L60od",
"sourceHandle": "vBwefuR5mRWI8EcA-source-right",
"targetHandle": "tnDQBPunfj3L60od-target-left"
},
{
"source": "tnDQBPunfj3L60od",
"target": "kv5tMm5dTnHkgHse",
"sourceHandle": "tnDQBPunfj3L60od-source-right",
"targetHandle": "kv5tMm5dTnHkgHse-target-left"
}
],
"chatConfig": {
"variables": [
{
"id": "erlv7a",
"key": "max_single_chunk_size",
"label": "max_single_chunk_size",
"type": "numberInput",
"description": "max_single_chunk_size",
"required": false,
"valueType": "number",
"list": [
{
"value": "",
"label": ""
}
],
"defaultValue": 2000,
"min": 500,
"max": 15000,
"enums": [
{
"value": "",
"label": ""
}
],
"icon": "core/workflow/inputType/numberInput"
},
{
"id": "hazv8y",
"key": "max_pages",
"label": "max_pages",
"type": "numberInput",
"description": "最大页数",
"required": false,
"valueType": "number",
"list": [
{
"value": "",
"label": ""
}
],
"defaultValue": 1,
"min": 1,
"max": 5,
"enums": [
{
"value": "",
"label": ""
}
],
"icon": "core/workflow/inputType/numberInput"
},
{
"id": "wnn67j",
"key": "chunk_step",
"label": "chunk_step",
"type": "numberInput",
"description": "chunk_step",
"required": false,
"valueType": "number",
"list": [
{
"value": "",
"label": ""
}
],
"defaultValue": 5,
"min": 1,
"max": 10,
"enums": [
{
"value": "",
"label": ""
}
],
"icon": "core/workflow/inputType/numberInput"
},
{
"id": "ifhf3m",
"key": "type_name",
"label": "type_name",
"type": "custom",
"description": "type_name",
"required": false,
"valueType": "string",
"list": [
{
"value": "",
"label": ""
}
],
"defaultValue": "",
"enums": [
{
"value": "",
"label": ""
}
],
"icon": "core/workflow/inputType/input"
},
{
"id": "vqsmou",
"key": "is_end",
"label": "is_end",
"type": "custom",
"description": "",
"required": false,
"valueType": "boolean",
"list": [
{
"value": "",
"label": ""
}
],
"defaultValue": false,
"enums": [
{
"value": "",
"label": ""
}
]
},
{
"id": "p6b5g4",
"key": "sub_type_name",
"label": "sub_type_name",
"type": "custom",
"description": "sub_type_name",
"required": false,
"valueType": "string",
"list": [
{
"value": "",
"label": ""
}
],
"defaultValue": "",
"enums": [
{
"value": "",
"label": ""
}
]
}
],
"scheduledTriggerConfig": {
"cronString": "",
"timezone": "Asia/Shanghai",
"defaultPrompt": ""
},
"fileSelectConfig": {
"canSelectFile": true,
"canSelectImg": false,
"maxFiles": 10
},
"_id": "682ef5ec27393425f5a45539"
}
}
\ No newline at end of file
{
{
......@@ -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