Commit 8371d9cd by ccran

feat: fix bugs for bookmark;

parent 1152e468
......@@ -18,6 +18,12 @@ class LLMConfig:
api_key: str = "none"
model: str = "Qwen2-72B-Instruct"
# @dataclass
# class LLMConfig:
# base_url: str = "http://192.168.252.71:9002/v1"
# api_key: str = "none"
# model: str = "Qwen2-72B-Instruct"
# 最大分片数量
min_single_chunk_size = 2000
max_single_chunk_size = 100000
......
No preview for this file type
File added
from PIL import Image
import pytesseract
img = Image.open("ocr.png")
text = pytesseract.image_to_string(img,lang="chi_sim+eng")
print(text)
\ No newline at end of file
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
import argparse
import asyncio
import difflib
import json
from itertools import zip_longest
from pathlib import Path
from typing import Any
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Alignment, Font
from core.config import LLMConfig
from utils.common_util import extract_json
from utils.openai_util import OpenAITool
WORD_SUFFIXES = {".doc", ".docx", ".wps"}
EXCEL_SUFFIXES = {".xlsx", ".xlsm", ".xls"}
EXCEL_HEADER_NAMES = {"修改前", "修订前", "变更前", "before", "before_revision"}
RULE_EXTRACT_SYSTEM_PROMPT = """你是资深合同审查规则分析助手。
你的任务是根据单条合同条款的“修订前/修订后”对比文本,反推该条修订体现出的审查项和审查规则。
输出要求:
1. 只输出 JSON,不要输出 Markdown、解释或多余文本。
2. JSON 格式必须是对象,包含:
- rule_title:审查项名称,简短准确,例如“纠纷解决审查”
- rule_detail:审查规则明细,用中文分点表述,例如“1)我司只接受……\\n2)我司只接受……”
3. 只提取能从该条修订前后对比中合理归纳出的规则,不要编造无法支撑的规则。
4. 如果该条差异无法归纳出审查规则,输出 {"rule_title": "", "rule_detail": ""}。
"""
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Compare Word revisions or two-column Excel diff rows and write results to Excel.")
parser.add_argument("--input_file",
default=r'/home/ccran/lufa-contract/demo/test/5、[修订后复审版]麓谷发展集团2026-2027年质量_修订对比_2col.xlsx',
help="Path to the Word document with revisions or a two-column Excel file.")
parser.add_argument(
"-o",
"--output",
help="Path to the output Excel file. Defaults to '<input_stem>_修订对比.xlsx'.",
)
parser.add_argument(
"--show-text",
action="store_true",
help="Print full text before and after accepting revisions.",
)
parser.add_argument(
"--no-diff-print",
action="store_true",
help="Do not print unified diff to the console.",
)
parser.add_argument(
"--no-rule-extract",
action="store_true",
help="Do not call LLM to extract review rules from revision diff rows.",
)
parser.add_argument(
"--batch-size",
type=int,
default=10,
help="Number of single-row prompts to run concurrently in one batch. Defaults to 10.",
)
return parser.parse_args()
def get_default_output_file(input_file: Path) -> Path:
return input_file.with_name(f"{input_file.stem}_修订对比.xlsx")
def read_revision_texts(input_file: Path) -> tuple[str, str]:
from spire.doc import Document
doc = Document()
try:
doc.LoadFromFile(str(input_file))
before_text = doc.GetText()
if doc.HasChanges:
doc.AcceptChanges()
after_text = doc.GetText()
return before_text, after_text
finally:
doc.Close()
def cell_to_text(value: Any) -> str:
if value != value:
return ""
return "" if value is None else str(value).strip()
def is_excel_header_row(before_text: str, after_text: str) -> bool:
before_normalized = before_text.strip().lower()
after_normalized = after_text.strip().lower()
return before_normalized in EXCEL_HEADER_NAMES and after_normalized in {
"修改后",
"修订后",
"变更后",
"after",
"after_revision",
}
def read_excel_diff_rows(input_file: Path) -> list[tuple[str, str]]:
suffix = input_file.suffix.lower()
if suffix in {".xlsx", ".xlsm"}:
wb = load_workbook(input_file, read_only=True, data_only=True)
try:
ws = wb.active
rows: list[tuple[str, str]] = []
for row_index, row in enumerate(ws.iter_rows(min_row=1, values_only=True), start=1):
before_line = cell_to_text(row[0] if len(row) > 0 else None)
after_line = cell_to_text(row[1] if len(row) > 1 else None)
if row_index == 1 and is_excel_header_row(before_line, after_line):
continue
if not before_line and not after_line:
continue
rows.append((before_line, after_line))
return rows
finally:
wb.close()
if suffix == ".xls":
try:
import pandas as pd
except ImportError as exc:
raise RuntimeError("读取 .xls 需要安装 pandas/xlrd,建议另存为 .xlsx 后再运行。") from exc
try:
df = pd.read_excel(input_file, header=None, usecols=[0, 1], dtype=str)
except Exception as exc:
raise RuntimeError("读取 .xls 失败,建议将文件另存为 .xlsx 后再运行。") from exc
rows = []
for row_index, row in df.iterrows():
before_line = cell_to_text(row.iloc[0])
after_line = cell_to_text(row.iloc[1])
if row_index == 0 and is_excel_header_row(before_line, after_line):
continue
if not before_line and not after_line:
continue
rows.append((before_line, after_line))
return rows
raise ValueError(f"unsupported Excel file type: {suffix}")
def build_unified_diff(before_lines: list[str], after_lines: list[str]) -> list[str]:
return list(
difflib.unified_diff(
before_lines,
after_lines,
fromfile="before_revision",
tofile="after_revision",
lineterm="",
)
)
def build_diff_rows(before_lines: list[str], after_lines: list[str]) -> list[tuple[str, str]]:
rows: list[tuple[str, str]] = []
matcher = difflib.SequenceMatcher(None, before_lines, after_lines)
for tag, before_start, before_end, after_start, after_end in matcher.get_opcodes():
if tag == "equal":
continue
before_part = before_lines[before_start:before_end]
after_part = after_lines[after_start:after_end]
if tag == "replace":
rows.extend(
(before_line or "", after_line or "")
for before_line, after_line in zip_longest(before_part, after_part)
)
elif tag == "delete":
rows.extend((before_line, "") for before_line in before_part)
elif tag == "insert":
rows.extend(("", after_line) for after_line in after_part)
return rows
def write_diff_rows_to_excel(
diff_rows: list[tuple[str, str]],
output_file: Path,
rule_results: list[dict[str, str]] | None = None,
) -> None:
wb = Workbook()
ws = wb.active
ws.title = "revision_compare"
ws.append(["修订前", "修订后", "审查项", "审查规则"])
for cell in ws[1]:
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
for index, (before_line, after_line) in enumerate(diff_rows):
rule = rule_results[index] if rule_results and index < len(rule_results) else {}
ws.append(
[
before_line,
after_line,
rule.get("rule_title", ""),
rule.get("rule_detail", ""),
]
)
for row in ws.iter_rows(min_row=2):
for cell in row:
cell.alignment = Alignment(wrap_text=True, vertical="top")
if row[1].value:
row[1].font = Font(color="FF0000")
ws.column_dimensions["A"].width = 80
ws.column_dimensions["B"].width = 80
ws.column_dimensions["C"].width = 24
ws.column_dimensions["D"].width = 80
ws.freeze_panes = "A2"
wb.save(output_file)
def chunk_list(items: list[tuple[str, str]], batch_size: int) -> list[list[tuple[int, str, str]]]:
if batch_size <= 0:
raise ValueError("--batch-size must be greater than 0")
indexed_items = [
(index, before_line, after_line)
for index, (before_line, after_line) in enumerate(items, start=1)
]
return [
indexed_items[start : start + batch_size]
for start in range(0, len(indexed_items), batch_size)
]
def build_rule_extract_user_prompt(before_line: str, after_line: str) -> str:
return (
"请根据以下单条合同修订前后对比文本,提取该条修订体现出的审查项和审查规则。\n"
"注意:修订后的文本通常代表更符合我司要求的表达,请结合修订前被替换或删除的内容归纳规则。\n"
f"{json.dumps({'修订前': before_line, '修订后': after_line}, ensure_ascii=False, indent=2)}"
)
def normalize_single_rule_response(value: Any) -> dict[str, str]:
if isinstance(value, list):
rules = [item for item in value if isinstance(item, dict)]
elif isinstance(value, dict) and isinstance(value.get("rules"), list):
rules = [item for item in value["rules"] if isinstance(item, dict)]
elif isinstance(value, dict):
rules = [value]
else:
rules = []
normalized_rules = [
{
"rule_title": str(item.get("rule_title", "")).strip(),
"rule_detail": str(item.get("rule_detail", "")).strip(),
}
for item in rules
if str(item.get("rule_title", "")).strip() or str(item.get("rule_detail", "")).strip()
]
if not normalized_rules:
return {"rule_title": "", "rule_detail": ""}
return {
"rule_title": "\n".join(rule["rule_title"] for rule in normalized_rules if rule["rule_title"]),
"rule_detail": "\n\n".join(rule["rule_detail"] for rule in normalized_rules if rule["rule_detail"]),
}
def build_rule_extract_messages(before_line: str, after_line: str) -> list[dict[str, str]]:
return [
{"role": "system", "content": RULE_EXTRACT_SYSTEM_PROMPT},
{"role": "user", "content": build_rule_extract_user_prompt(before_line, after_line)},
]
def parse_rule_response(response: str) -> dict[str, str]:
parsed_items = extract_json(response)
parsed = parsed_items[0] if parsed_items else {}
return normalize_single_rule_response(parsed)
async def extract_review_rules_async(
diff_rows: list[tuple[str, str]],
batch_size: int,
) -> list[dict[str, str]]:
llm = OpenAITool(LLMConfig())
results_by_index: dict[int, dict[str, str]] = {}
batches = chunk_list(diff_rows, batch_size)
for batch_index, batch_rows in enumerate(batches, start=1):
start_index = batch_rows[0][0]
end_index = batch_rows[-1][0]
print(f"正在并发提取第 {batch_index}/{len(batches)} 批审查规则,行 {start_index}-{end_index}...")
messages_list = [
build_rule_extract_messages(before_line, after_line)
for _, before_line, after_line in batch_rows
]
responses = await llm.mul_chat(messages_list)
for (row_index, _, _), response in zip(batch_rows, responses):
results_by_index[row_index] = parse_rule_response(response)
results: list[dict[str, str]] = []
for index in range(1, len(diff_rows) + 1):
results.append(results_by_index.get(index, {"rule_title": "", "rule_detail": ""}))
return results
def extract_review_rules(diff_rows: list[tuple[str, str]], batch_size: int) -> list[dict[str, str]]:
return asyncio.run(extract_review_rules_async(diff_rows, batch_size))
def build_diff_rows_from_word(
input_file: Path,
show_text: bool,
print_diff: bool,
) -> list[tuple[str, str]]:
before_text, after_text = read_revision_texts(input_file)
if show_text:
print("===== 修订前文本 =====")
print(before_text)
print("===== 修订后文本 =====")
print(after_text)
if before_text == after_text:
return []
before_lines = before_text.splitlines()
after_lines = after_text.splitlines()
diff_lines = build_unified_diff(before_lines, after_lines)
diff_rows = build_diff_rows(before_lines, after_lines)
if print_diff and diff_rows:
print("===== Diff 对比结果 =====")
print("\n".join(diff_lines))
return diff_rows
def load_diff_rows(input_file: Path, show_text: bool, print_diff: bool) -> list[tuple[str, str]]:
suffix = input_file.suffix.lower()
if suffix in EXCEL_SUFFIXES:
diff_rows = read_excel_diff_rows(input_file)
print(f"从 Excel 读取到 {len(diff_rows)} 条前后对比。")
return diff_rows
if suffix in WORD_SUFFIXES:
return build_diff_rows_from_word(input_file, show_text, print_diff)
raise ValueError(f"不支持的输入文件类型: {suffix}")
def compare_revisions(
input_file: Path,
output_file: Path,
show_text: bool,
print_diff: bool,
extract_rules: bool,
batch_size: int,
) -> bool:
diff_rows = load_diff_rows(input_file, show_text, print_diff)
if not diff_rows:
print("没有检测到前后对比内容,不写入 Excel。")
return False
rule_results = extract_review_rules(diff_rows, batch_size) if extract_rules else None
write_diff_rows_to_excel(diff_rows, output_file, rule_results)
print(f"Excel Diff 对比结果已写入: {output_file}")
return True
def main() -> None:
args = parse_args()
input_file = Path(args.input_file)
output_file = Path(args.output) if args.output else get_default_output_file(input_file)
compare_revisions(
input_file=input_file,
output_file=output_file,
show_text=args.show_text,
print_diff=not args.no_diff_print,
extract_rules=not args.no_rule_extract,
batch_size=args.batch_size,
)
if __name__ == "__main__":
main()
import argparse
import difflib
from itertools import zip_longest
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Compare Word revision text and write diff rows to Excel.")
parser.add_argument("--input_file", default=r'D:\VsProject\lufa-contract\demo\5、[修订后复审版]麓谷发展集团2026-2027年质量.docx',
help="Path to the Word document with revisions.")
parser.add_argument(
"-o",
"--output",
help="Path to the output Excel file. Defaults to '<input_stem>_修订对比.xlsx'.",
)
parser.add_argument(
"--show-text",
action="store_true",
help="Print full text before and after accepting revisions.",
)
parser.add_argument(
"--no-diff-print",
action="store_true",
help="Do not print unified diff to the console.",
)
return parser.parse_args()
def get_default_output_file(input_file: Path) -> Path:
return input_file.with_name(f"{input_file.stem}_修订对比.xlsx")
def read_revision_texts(input_file: Path) -> tuple[str, str]:
from spire.doc import Document
doc = Document()
try:
doc.LoadFromFile(str(input_file))
before_text = doc.GetText()
if doc.HasChanges:
doc.AcceptChanges()
after_text = doc.GetText()
return before_text, after_text
finally:
doc.Close()
def build_unified_diff(before_lines: list[str], after_lines: list[str]) -> list[str]:
return list(
difflib.unified_diff(
before_lines,
after_lines,
fromfile="before_revision",
tofile="after_revision",
lineterm="",
)
)
def build_diff_rows(before_lines: list[str], after_lines: list[str]) -> list[tuple[str, str]]:
rows: list[tuple[str, str]] = []
matcher = difflib.SequenceMatcher(None, before_lines, after_lines)
for tag, before_start, before_end, after_start, after_end in matcher.get_opcodes():
if tag == "equal":
continue
before_part = before_lines[before_start:before_end]
after_part = after_lines[after_start:after_end]
if tag == "replace":
rows.extend(
(before_line or "", after_line or "")
for before_line, after_line in zip_longest(before_part, after_part)
)
elif tag == "delete":
rows.extend((before_line, "") for before_line in before_part)
elif tag == "insert":
rows.extend(("", after_line) for after_line in after_part)
return rows
def write_diff_rows_to_excel(diff_rows: list[tuple[str, str]], output_file: Path) -> None:
wb = Workbook()
ws = wb.active
ws.title = "revision_compare"
ws.append(["修订前", "修订后"])
for cell in ws[1]:
cell.font = Font(bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
for before_line, after_line in diff_rows:
ws.append([before_line, after_line])
for row in ws.iter_rows(min_row=2):
for cell in row:
cell.alignment = Alignment(wrap_text=True, vertical="top")
if row[1].value:
row[1].font = Font(color="FF0000")
ws.column_dimensions["A"].width = 80
ws.column_dimensions["B"].width = 80
ws.freeze_panes = "A2"
wb.save(output_file)
def compare_revisions(input_file: Path, output_file: Path, show_text: bool, print_diff: bool) -> bool:
before_text, after_text = read_revision_texts(input_file)
if show_text:
print("===== 修订前文本 =====")
print(before_text)
print("===== 修订后文本 =====")
print(after_text)
if before_text == after_text:
print("修订前后文本一致,无差异,不写入 Excel。")
return False
before_lines = before_text.splitlines()
after_lines = after_text.splitlines()
diff_lines = build_unified_diff(before_lines, after_lines)
diff_rows = build_diff_rows(before_lines, after_lines)
if not diff_rows:
print("没有检测到真实差异,不写入 Excel。")
return False
if print_diff:
print("===== Diff 对比结果 =====")
print("\n".join(diff_lines))
write_diff_rows_to_excel(diff_rows, output_file)
print(f"Excel Diff 对比结果已写入: {output_file}")
return True
def main() -> None:
args = parse_args()
input_file = Path(args.input_file)
output_file = Path(args.output) if args.output else get_default_output_file(input_file)
compare_revisions(
input_file=input_file,
output_file=output_file,
show_text=args.show_text,
print_diff=not args.no_diff_print,
)
if __name__ == "__main__":
main()
File added
---
name: review-flow-skill
description: 合同审查编排 Skill。用于把 URL 或本地合同文件下载/解析为 txt,逐条覆盖 rules.xlsx 中的审查规则,调用 review-llm-skill 完成路由、审查、反思、合并,并将结果文件上传。
---
# Review Flow Skill
## 定位
`review-flow-skill` 是合同审查的流程编排层。它不直接实现 HTTP、文档解析、Excel 读写或 LLM 提示词,而是按固定顺序组合以下 Skill:
- `http-skill`:URL 输入时下载源文件;审查完成后上传结果文件。
- `doc-excel-skill`:把 Word/PDF/Excel/CSV 解析成 UTF-8 txt;读取 `assets/rules.xlsx`;把 JSON 结果写入 Excel。
- `review-llm-skill`:执行规则路由、具体审查、反思复核、结果合并。
## 标准流程
1. 输入识别
- 如果输入是 `http://``https://` URL,必须先调用 `http-skill` 下载到本地工作目录。
- 如果输入是本地路径,直接进入解析步骤。
2. 文档解析
- 优先把待审文件解析为 `.txt`,减少后续上下文占用。
- `.txt` 直接复用。
- `.doc``.docx``.wps``.pdf` 使用 `doc-excel-skill/scripts/doc_tool.py` 转 txt。
- `.xlsx``.xls``.csv``.tsv` 使用 `doc-excel-skill/scripts/excel_tool.py` 读取各 sheet 后序列化为 txt。
3. 规则加载
- 默认从 `skills/review-flow-skill/assets/rules.xlsx` 读取审查规则。
- 未指定 sheet 时读取所有 sheet。
- 必须覆盖到每一条非空规则。流程不得因为路由未命中而跳过“规则覆盖记录”。
4. 状态记录
- 每次运行在工作目录生成 `state.json`
- 状态至少记录:源文件、txt 文件、规则文件、规则总数、当前规则序号、已完成规则、输出文件路径。
- 每完成一条规则立即写回状态,用于断点续审。
5. 逐条规则审查
- 按规则顺序逐条推进。
- 对每条规则,先对 txt 分块调用 `review-llm-skill``router` 动作。
- 只有路由命中的分块才调用 `review` 动作。
- 即使所有分块均未命中,该规则也必须标记为已覆盖,并记录路由未命中。
6. 单规则反思
- 每条规则的分块审查完成后,将该规则的原始 findings 放入 `context.findings`,调用 `reflect` 动作复核。
- 反思结果写入中间结果文件。
7. 全局合并
- 所有规则审查完毕后,对反思后的 findings 按 `rule_name``result``original_text` 聚合。
- 同组多条 findings 调用 `merge` 动作融合 `issue``suggestion`
- 输出最终 JSON 和 Excel 汇总文件。
8. 结果上传
- 默认使用 `http-skill` 上传最终 Excel 文件。
- 如调用方只需要本地文件,可传 `--no-upload`
## 工具文件
- `scripts/review_flow.py`:审查流程编排 CLI。
- `assets/rules.xlsx`:默认审查规则库。
## 输出文件
运行目录默认位于 `skills/review-flow-skill/outputs/run-YYYYmmdd-HHMMSS/`,包含:
- `input.txt`:解析后的合同文本。
- `state.json`:断点续审状态。
- `rule_progress.json`:每条规则的覆盖、路由、审查统计。
- `findings_raw.json`:分块审查原始结果。
- `findings_reflected.json`:按规则反思后的结果。
- `final_findings.json`:全局合并后的最终 JSON 结果。
- `review_result.xlsx`:最终 Excel 汇总文件。
- `upload_result.json`:上传结果,仅在上传成功后生成。
## 使用示例
查看帮助:
```bash
python skills/review-flow-skill/scripts/review_flow.py --help
```
审查 URL 文件并上传结果:
```bash
python skills/review-flow-skill/scripts/review_flow.py \
https://example.com/contract.docx
```
审查本地文件,不上传:
```bash
python skills/review-flow-skill/scripts/review_flow.py \
demo/example.docx \
--no-upload
```
只审查指定 sheet:
```bash
python skills/review-flow-skill/scripts/review_flow.py \
demo/example.pdf \
--sheet-name 通用 \
--sheet-name 借款
```
从已有运行目录继续:
```bash
python skills/review-flow-skill/scripts/review_flow.py \
--resume skills/review-flow-skill/outputs/run-20260612-173000
```
调试时限制本次最多推进 3 条规则:
```bash
python skills/review-flow-skill/scripts/review_flow.py \
demo/example.docx \
--max-rules 3 \
--no-upload
```
## 约束
- 不在该 Skill 中重写下载、上传、文档解析、Excel 操作或 LLM 提示词能力。
- 审查过程必须逐条规则推进并持久化状态。
- 规则覆盖记录和最终结果必须落盘,避免只保存在上下文中。
- 默认使用 txt 分块输入 LLM,避免一次性塞入完整合同造成上下文过大。
#!/usr/bin/env python3
"""Orchestrate URL/local-file contract review across every rule."""
from __future__ import annotations
import argparse
import json
import math
import shutil
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
SKILL_DIR = SCRIPT_DIR.parent
REPO_ROOT = SKILL_DIR.parents[1]
DEFAULT_RULES_FILE = SKILL_DIR / "assets" / "rules.xlsx"
DEFAULT_OUTPUT_ROOT = SKILL_DIR / "outputs"
HTTP_SCRIPT_DIR = REPO_ROOT / "skills" / "http-skill" / "scripts"
DOC_EXCEL_SCRIPT_DIR = REPO_ROOT / "skills" / "doc-excel-skill" / "scripts"
REVIEW_LLM_SCRIPT_DIR = REPO_ROOT / "skills" / "review-llm-skill" / "scripts"
for path in (HTTP_SCRIPT_DIR, DOC_EXCEL_SCRIPT_DIR, REVIEW_LLM_SCRIPT_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
import excel_tool # type: ignore # noqa: E402
import http_util # type: ignore # noqa: E402
from doc_tool import doc_to_txt # type: ignore # noqa: E402
from segment_llm_action import run_segment_llm_action # type: ignore # noqa: E402
WORD_SUFFIXES = {".doc", ".docx", ".wps"}
PDF_SUFFIXES = {".pdf"}
TEXT_SUFFIXES = {".txt", ".md"}
TABLE_SUFFIXES = {".xlsx", ".xls", ".csv", ".tsv"}
TITLE_KEYS = ("审查项", "审查项名称", "规则名称", "规则标题", "title", "name")
def jdump(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2)
def now_text() -> str:
return datetime.now().isoformat(timespec="seconds")
def is_url(value: str) -> bool:
return value.startswith(("http://", "https://"))
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(jdump(value) + "\n", encoding="utf-8")
def read_json(path: Path, default: Any) -> Any:
if not path.exists() or path.stat().st_size == 0:
return default
return json.loads(path.read_text(encoding="utf-8"))
def make_run_dir(output_root: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
run_dir = output_root / f"run-{stamp}"
index = 1
while run_dir.exists():
run_dir = output_root / f"run-{stamp}-{index}"
index += 1
run_dir.mkdir(parents=True)
return run_dir
def configure_http(args: argparse.Namespace) -> None:
http_util._configure_urls( # noqa: SLF001
http_util._strip(args.base_fastgpt_url), # noqa: SLF001
http_util._strip(args.base_backend_url), # noqa: SLF001
http_util._strip(args.outer_backend_url), # noqa: SLF001
)
http_util._configure_login(args.username, args.password) # noqa: SLF001
def resolve_source(source: str, run_dir: Path) -> Path:
if is_url(source):
downloaded = http_util.download_file(source, run_dir / "download")
if not downloaded:
raise RuntimeError(f"download failed: {source}")
return Path(downloaded)
path = Path(source).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
if not path.exists():
raise FileNotFoundError(path)
return path
def excel_to_txt(path: Path, output: Path) -> Path:
chunks: list[str] = []
sheets = excel_tool.list_sheets(str(path)) if path.suffix.lower() in {".xlsx", ".xls"} else [None]
for sheet in sheets:
rows = excel_tool.load_excel(str(path), sheet=sheet, header=True)
title = sheet or path.name
chunks.append(f"# Sheet: {title}\n{jdump(rows)}")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text("\n\n".join(chunks), encoding="utf-8")
return output
def parse_to_txt(source_file: Path, run_dir: Path) -> Path:
suffix = source_file.suffix.lower()
output = run_dir / "input.txt"
if suffix in TEXT_SUFFIXES:
if source_file.resolve() != output.resolve():
shutil.copyfile(source_file, output)
return output
if suffix in WORD_SUFFIXES or suffix in PDF_SUFFIXES:
return Path(doc_to_txt(str(source_file), str(output)))
if suffix in TABLE_SUFFIXES:
return excel_to_txt(source_file, output)
raise ValueError(f"unsupported input file type: {suffix}")
def compact_rule(row: dict[str, Any], sheet_name: str, row_index: int) -> dict[str, Any]:
cleaned = {str(k).strip(): v for k, v in row.items() if str(k).strip() and v not in (None, "")}
title = next((str(cleaned[key]).strip() for key in TITLE_KEYS if cleaned.get(key)), "")
if not title:
title = next((str(value).strip() for value in cleaned.values() if value not in (None, "")), "")
cleaned["_sheet_name"] = sheet_name
cleaned["_row_index"] = row_index
cleaned["_rule_id"] = f"{sheet_name}:{row_index}:{title or 'untitled'}"
cleaned["_title"] = title or cleaned["_rule_id"]
return cleaned
def load_rules(rules_file: Path, sheet_names: list[str] | None) -> list[dict[str, Any]]:
selected_sheets = sheet_names or excel_tool.list_sheets(str(rules_file))
rules: list[dict[str, Any]] = []
for sheet_name in selected_sheets:
rows = excel_tool.search_rows(str(rules_file), sheet_name, {})
for index, row in enumerate(rows, start=2):
if not isinstance(row, dict):
continue
rule = compact_rule(row, sheet_name, index)
if any(not str(key).startswith("_") for key in rule):
rules.append(rule)
return rules
def split_text(text: str, chunk_size: int) -> list[dict[str, Any]]:
if chunk_size <= 0:
raise ValueError("--chunk-size must be > 0")
total = max(1, math.ceil(len(text) / chunk_size))
return [
{
"chunk_index": index,
"chunk_total": total,
"start": index * chunk_size,
"end": min((index + 1) * chunk_size, len(text)),
"text": text[index * chunk_size : (index + 1) * chunk_size],
}
for index in range(total)
]
def selected_by_router(router_result: Any, rule: dict[str, Any]) -> bool:
if isinstance(router_result, list):
return bool(router_result)
if not isinstance(router_result, dict):
return False
selected = router_result.get("selected_items") or router_result.get("selected_rules") or []
if not isinstance(selected, list):
return False
if not selected:
return False
title = str(rule.get("_title", ""))
rule_id = str(rule.get("_rule_id", ""))
for item in selected:
if not isinstance(item, dict):
return True
text = jdump(item)
if title and title in text:
return True
if rule_id and rule_id in text:
return True
return True
def normalize_findings(value: Any) -> list[dict[str, Any]]:
if isinstance(value, dict):
if isinstance(value.get("findings"), list):
value = value["findings"]
else:
value = [value]
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, dict)]
def call_llm(action: str, rule: dict[str, Any], text: str) -> Any:
return run_segment_llm_action(action=action, rule=rule, text=text)
def review_rule(rule: dict[str, Any], chunks: list[dict[str, Any]]) -> dict[str, Any]:
routed_chunks: list[int] = []
router_results: list[dict[str, Any]] = []
raw_findings: list[dict[str, Any]] = []
router_rule = {
"title": rule.get("_title"),
"rule_id": rule.get("_rule_id"),
"candidate_rules": [rule],
}
for chunk in chunks:
router_result = call_llm("router", router_rule, chunk["text"])
hit = selected_by_router(router_result, rule)
router_results.append(
{
"chunk_index": chunk["chunk_index"],
"selected": hit,
"router_result": router_result,
}
)
if not hit:
continue
routed_chunks.append(chunk["chunk_index"])
review_rule_payload = {
**rule,
"context": {
"chunk_index": chunk["chunk_index"],
"chunk_total": chunk["chunk_total"],
},
}
review_result = call_llm("review", review_rule_payload, chunk["text"])
for finding in normalize_findings(review_result):
finding.setdefault("rule_name", rule.get("_title"))
finding["_rule_id"] = rule.get("_rule_id")
finding["_sheet_name"] = rule.get("_sheet_name")
finding["_row_index"] = rule.get("_row_index")
finding["_chunk_index"] = chunk["chunk_index"]
raw_findings.append(finding)
reflected_findings: list[dict[str, Any]] = []
if raw_findings:
reflect_payload = {
**rule,
"context": {
"findings": raw_findings,
"routed_chunks": routed_chunks,
},
}
evidence_text = "\n\n".join(
str(item.get("original_text", "")).strip()
for item in raw_findings
if item.get("original_text")
)
reflect_result = call_llm("reflect", reflect_payload, evidence_text)
reflected_findings = normalize_findings(reflect_result)
for finding in reflected_findings:
finding.setdefault("rule_name", rule.get("_title"))
finding["_rule_id"] = rule.get("_rule_id")
finding["_sheet_name"] = rule.get("_sheet_name")
finding["_row_index"] = rule.get("_row_index")
return {
"rule": rule,
"router_results": router_results,
"routed_chunks": routed_chunks,
"raw_findings": raw_findings,
"reflected_findings": reflected_findings,
}
def merge_findings(findings: list[dict[str, Any]]) -> list[dict[str, Any]]:
grouped: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
for finding in findings:
key = (
str(finding.get("rule_name", "")),
str(finding.get("result", "")),
str(finding.get("original_text", "")),
)
grouped[key].append(finding)
merged: list[dict[str, Any]] = []
for (rule_name, result, original_text), items in grouped.items():
if len(items) == 1:
merged.append(items[0])
continue
merge_payload = {
"rule_name": rule_name,
"result": result,
"original_text": original_text,
"findings": [
{
"issue": item.get("issue", ""),
"suggestion": item.get("suggestion", ""),
"original_text": item.get("original_text", ""),
}
for item in items
],
}
merged_result = call_llm("merge", merge_payload, "")
base = dict(items[0])
if isinstance(merged_result, dict):
base["issue"] = merged_result.get("issue", base.get("issue", ""))
base["suggestion"] = merged_result.get("suggestion", base.get("suggestion", ""))
merged.append(base)
return merged
def save_outputs(run_dir: Path, progress: list[dict[str, Any]], raw: list[dict[str, Any]], reflected: list[dict[str, Any]], final: list[dict[str, Any]] | None = None) -> None:
write_json(run_dir / "rule_progress.json", progress)
write_json(run_dir / "findings_raw.json", raw)
write_json(run_dir / "findings_reflected.json", reflected)
if final is not None:
write_json(run_dir / "final_findings.json", final)
excel_tool.json_to_sheet(final, str(run_dir / "review_result.xlsx"), "审查结果")
def update_state(run_dir: Path, state: dict[str, Any]) -> None:
state["updated_at"] = now_text()
write_json(run_dir / "state.json", state)
def initial_state(args: argparse.Namespace, run_dir: Path, source_file: Path, text_file: Path, rules: list[dict[str, Any]]) -> dict[str, Any]:
return {
"created_at": now_text(),
"updated_at": now_text(),
"source": args.source,
"source_file": str(source_file),
"text_file": str(text_file),
"rules_file": str(Path(args.rules_file).resolve()),
"sheet_names": args.sheet_name or "ALL",
"chunk_size": args.chunk_size,
"current_rule_index": 0,
"total_rules": len(rules),
"completed_rules": [],
"outputs": {
"progress": str(run_dir / "rule_progress.json"),
"raw": str(run_dir / "findings_raw.json"),
"reflected": str(run_dir / "findings_reflected.json"),
"final": str(run_dir / "final_findings.json"),
"excel": str(run_dir / "review_result.xlsx"),
},
}
def run_new(args: argparse.Namespace) -> tuple[Path, dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
run_dir = make_run_dir(Path(args.output_root))
configure_http(args)
source_file = resolve_source(args.source, run_dir)
text_file = parse_to_txt(source_file, run_dir)
rules = load_rules(Path(args.rules_file), args.sheet_name)
state = initial_state(args, run_dir, source_file, text_file, rules)
write_json(run_dir / "rules.loaded.json", rules)
update_state(run_dir, state)
return run_dir, state, rules, split_text(text_file.read_text(encoding="utf-8"), args.chunk_size)
def run_resume(args: argparse.Namespace) -> tuple[Path, dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]:
run_dir = Path(args.resume).expanduser()
if not run_dir.is_absolute():
run_dir = Path.cwd() / run_dir
state = read_json(run_dir / "state.json", {})
if not state:
raise FileNotFoundError(run_dir / "state.json")
rules = read_json(run_dir / "rules.loaded.json", [])
if not rules:
rules = load_rules(Path(state["rules_file"]), None if state.get("sheet_names") == "ALL" else state.get("sheet_names"))
text = Path(state["text_file"]).read_text(encoding="utf-8")
return run_dir, state, rules, split_text(text, int(state["chunk_size"]))
def upload_result(args: argparse.Namespace, run_dir: Path) -> None:
if args.no_upload:
return
configure_http(args)
result = http_util.upload_file(run_dir / "review_result.xlsx")
write_json(run_dir / "upload_result.json", {"uploaded": result})
def run_review(args: argparse.Namespace) -> Path:
run_dir, state, rules, chunks = run_resume(args) if args.resume else run_new(args)
progress = read_json(run_dir / "rule_progress.json", [])
raw_findings = read_json(run_dir / "findings_raw.json", [])
reflected_findings = read_json(run_dir / "findings_reflected.json", [])
start = int(state.get("current_rule_index", 0))
stop = len(rules)
if args.max_rules is not None:
stop = min(stop, start + args.max_rules)
for rule_index in range(start, stop):
rule_result = review_rule(rules[rule_index], chunks)
progress.append(
{
"rule_index": rule_index,
"rule_id": rules[rule_index].get("_rule_id"),
"title": rules[rule_index].get("_title"),
"routed_chunks": rule_result["routed_chunks"],
"covered": True,
"raw_count": len(rule_result["raw_findings"]),
"reflected_count": len(rule_result["reflected_findings"]),
"completed_at": now_text(),
"router_results": rule_result["router_results"],
}
)
raw_findings.extend(rule_result["raw_findings"])
reflected_findings.extend(rule_result["reflected_findings"])
state["current_rule_index"] = rule_index + 1
state["completed_rules"].append(rules[rule_index].get("_rule_id"))
save_outputs(run_dir, progress, raw_findings, reflected_findings)
update_state(run_dir, state)
if int(state.get("current_rule_index", 0)) >= len(rules):
final_findings = merge_findings(reflected_findings)
save_outputs(run_dir, progress, raw_findings, reflected_findings, final_findings)
state["finalized_at"] = now_text()
update_state(run_dir, state)
upload_result(args, run_dir)
return run_dir
def parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Run contract review flow over every rule.")
p.add_argument("source", nargs="?", help="URL or local contract file. Required unless --resume is used.")
p.add_argument("--resume", help="Resume from an existing run directory.")
p.add_argument("--rules-file", default=str(DEFAULT_RULES_FILE))
p.add_argument("--sheet-name", action="append", help="Rule sheet to load. May be repeated. Defaults to all sheets.")
p.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT))
p.add_argument("--chunk-size", type=int, default=6000)
p.add_argument("--max-rules", type=int, help="Only advance this many rules in the current invocation.")
p.add_argument("--no-upload", action="store_true")
p.add_argument("--base-fastgpt-url", default=http_util.DEFAULT_BASE_FASTGPT_URL)
p.add_argument("--base-backend-url", default=http_util.DEFAULT_BASE_BACKEND_URL)
p.add_argument("--outer-backend-url", default=http_util.DEFAULT_OUTER_BACKEND_URL)
p.add_argument("--username", default=http_util.DEFAULT_BACKEND_ADMIN_USERNAME)
p.add_argument("--password", default=http_util.DEFAULT_BACKEND_ADMIN_PASSWORD)
return p
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
if not args.resume and not args.source:
raise SystemExit("source is required unless --resume is used")
run_dir = run_review(args)
print(run_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Backward-compatible entry point for segment_llm_action.py."""
from __future__ import annotations
from segment_llm_action import * # noqa: F403
from segment_llm_action import main
if __name__ == "__main__":
raise SystemExit(main())
......@@ -19,7 +19,6 @@ try:
REFLECT_SYSTEM_PROMPT,
REVIEW_SYSTEM_PROMPT,
ROUTER_SYSTEM_PROMPT,
SUMMARY_ROUTER_SYSTEM_PROMPT,
SUMMARY_SYSTEM_PROMPT,
)
except ImportError:
......@@ -28,7 +27,6 @@ except ImportError:
REFLECT_SYSTEM_PROMPT,
REVIEW_SYSTEM_PROMPT,
ROUTER_SYSTEM_PROMPT,
SUMMARY_ROUTER_SYSTEM_PROMPT,
SUMMARY_SYSTEM_PROMPT,
)
......
......@@ -470,6 +470,7 @@ class SpireWordDoc(DocBase):
current_child_text = self._resolve_table(table)
# 跳过其他非文本子对象
else:
print(child_obj)
continue
# 添加新对象
if (
......@@ -794,6 +795,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) 先按作者标识查重,命中则更新内容;
......@@ -936,6 +953,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):
......
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