feat: export core Hermes skills
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
南通新东方租赁梳理 — 批量 Excel Builder
|
||||
========================================
|
||||
从所有已完成的 nantong-lease-audit workflow threads 中提取数据,
|
||||
按校区分 sheet,合并到一个工作簿。
|
||||
|
||||
用法:
|
||||
python3 batch-excel-builder.py <输出xlsx路径>
|
||||
|
||||
例:
|
||||
python3 batch-excel-builder.py /tmp/南通-workflow-batch.xlsx
|
||||
|
||||
前置:所有thread已跑完(status=end)。
|
||||
"""
|
||||
import subprocess, json, re, sys, os
|
||||
|
||||
UWF = "/home/maggie/.hermes/node/bin/uwf"
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return r.stdout
|
||||
|
||||
def get_thread_read(thread_id):
|
||||
"""获取thread完整markdown输出(需要大quota)"""
|
||||
return run([UWF, "thread", "read", thread_id, "--quota", "200000", "--start"])
|
||||
|
||||
def get_all_nantong_threads():
|
||||
"""Get all completed nantong-lease-audit threads"""
|
||||
r = subprocess.run([UWF, "workflow", "list"], capture_output=True, text=True)
|
||||
# Find nantong-lease-audit hash(es)
|
||||
wf_hashes = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
if 'nantong-lease' in line.lower():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
wf_hashes.append(parts[1])
|
||||
|
||||
if not wf_hashes:
|
||||
print("No nantong-lease-audit workflow found")
|
||||
return []
|
||||
|
||||
r = subprocess.run([UWF, "thread", "list", "--all"], capture_output=True, text=True)
|
||||
threads = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
for h in wf_hashes:
|
||||
if h in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3 and parts[2] == 'end':
|
||||
threads.append(parts[0])
|
||||
return threads
|
||||
|
||||
def get_thread_info(thread_id):
|
||||
"""Get campus and filename from thread prompt"""
|
||||
text = run([UWF, "thread", "read", thread_id, "--quota", "500"])
|
||||
campus_m = re.search(r'校区:(\S+)', text)
|
||||
file_m = re.search(r'合同文件:([^|]+)', text)
|
||||
ocr_m = re.search(r'OCR文本路径:(\S+)', text)
|
||||
return {
|
||||
'campus': campus_m.group(1) if campus_m else '',
|
||||
'filename': file_m.group(1).strip() if file_m else '',
|
||||
'ocr_path': ocr_m.group(1) if ocr_m else '',
|
||||
}
|
||||
|
||||
def extract_step_output(thread_text, role_name):
|
||||
"""Extract <output> content from a specific role step"""
|
||||
pattern = rf'## Step \d+: {role_name}.*?\n<output>\n(.*?)\n</output>'
|
||||
m = re.search(pattern, thread_text, re.DOTALL)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def extract_frontmatter(output_text):
|
||||
"""Extract YAML frontmatter fields from step output"""
|
||||
m = re.search(r'^---\n(.*?)\n---', output_text, re.DOTALL)
|
||||
if not m:
|
||||
return {}, output_text
|
||||
fm_text = m.group(1)
|
||||
body = output_text[m.end():].strip()
|
||||
result = {}
|
||||
current_key = None
|
||||
current_val = []
|
||||
is_multiline = False
|
||||
for line in fm_text.split('\n'):
|
||||
if not line.strip():
|
||||
if is_multiline: current_val.append('')
|
||||
continue
|
||||
km = re.match(r'^(\w[\w_]*):\s*(.*)', line)
|
||||
if km and not line.startswith(' '):
|
||||
if current_key: result[current_key] = '\n'.join(current_val).strip()
|
||||
current_key = km.group(1)
|
||||
val = km.group(2).strip()
|
||||
if val == '|' or val == '':
|
||||
is_multiline = True
|
||||
current_val = []
|
||||
else:
|
||||
is_multiline = False
|
||||
current_val = [val]
|
||||
elif is_multiline and current_key:
|
||||
current_val.append(line.strip())
|
||||
if current_key: result[current_key] = '\n'.join(current_val).strip()
|
||||
return result, body
|
||||
|
||||
def build_campus_sheet(wb, campus, rows):
|
||||
"""Build a campus sheet with all contract rows"""
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
F = Font(name='微软雅黑', size=10)
|
||||
FB = Font(name='微软雅黑', size=10, bold=True)
|
||||
TITLE_FONT = Font(name='微软雅黑', size=14, bold=True, color='FFFFFFFF')
|
||||
fill_title = PatternFill(start_color='FF8B1A2B', fill_type='solid')
|
||||
fill_sec = PatternFill(start_color='FFC0504D', fill_type='solid')
|
||||
fill_hdr = PatternFill(start_color='FFE2EFDA', fill_type='solid')
|
||||
fill_sub = PatternFill(start_color='FFF2F2F2', fill_type='solid')
|
||||
thin = Side(style='thin')
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
AL = Alignment(horizontal='left', vertical='top', wrap_text=True)
|
||||
AC = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
|
||||
ws = wb.create_sheet(campus[:31])
|
||||
widths = dict(A=5, B=20, C=17, D=27, E=23, F=9, G=21, H=36.33, I=34, J=8, K=50, L=35)
|
||||
for c, w in widths.items():
|
||||
ws.column_dimensions[c].width = w
|
||||
|
||||
def merge_row(r, text, font, fill, h=None, al=AC):
|
||||
ws.merge_cells(f'A{r}:L{r}')
|
||||
cell = ws.cell(r, 1, text); cell.font = font; cell.fill = fill; cell.alignment = al
|
||||
for col in range(1, 13):
|
||||
ws.cell(r, col).fill = fill; ws.cell(r, col).border = border
|
||||
if h: ws.row_dimensions[r].height = h
|
||||
|
||||
r = 1
|
||||
merge_row(r, f'{campus}校区 — 租赁合同梳理', TITLE_FONT, fill_title, 30); r += 1
|
||||
merge_row(r, f'共{len(rows)}份合同',
|
||||
Font(name='微软雅黑', size=10, bold=True, color='FF404040'), fill_sub, 76, AL); r += 1
|
||||
|
||||
HDR = ['序号','文件名称','合同类型','合同当事人','租赁标的/服务范围','面积(㎡)',
|
||||
'合同期限','金额/费用','核心内容','当前状态','法律风险(站乙方立场)','与07标准模版差异']
|
||||
for ci, h in enumerate(HDR, 1):
|
||||
c = ws.cell(r, ci, h); c.font = FB; c.fill = fill_hdr; c.alignment = AC; c.border = border
|
||||
ws.row_dimensions[r].height = 30; r += 1
|
||||
|
||||
for idx, row in enumerate(rows, 1):
|
||||
ws.cell(r, 1, str(idx)).font = F; ws.cell(r, 1).alignment = AC; ws.cell(r, 1).border = border
|
||||
for ci, col in enumerate('BCDEFGHIJKL', 2):
|
||||
c = ws.cell(r, ci, row.get(col, '')); c.font = F; c.alignment = AL; c.border = border
|
||||
ws.row_dimensions[r].height = 300; r += 1
|
||||
|
||||
ws.freeze_panes = 'A4'
|
||||
return ws
|
||||
|
||||
def main():
|
||||
out_path = sys.argv[1] if len(sys.argv) > 1 else '/tmp/南通-workflow-batch.xlsx'
|
||||
|
||||
print("=== 获取所有已完成的nantong-lease-audit threads ===")
|
||||
threads = get_all_nantong_threads()
|
||||
print(f"找到 {len(threads)} 个已完成的thread")
|
||||
|
||||
if not threads:
|
||||
print("No completed threads found. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
campus_data = {}
|
||||
for tid in threads:
|
||||
info = get_thread_info(tid)
|
||||
campus = info['campus']
|
||||
if not campus:
|
||||
print(f" SKIP {tid}: no campus info")
|
||||
continue
|
||||
|
||||
print(f" 处理 {tid}: {campus} / {info['filename']}")
|
||||
thread_text = get_thread_read(tid)
|
||||
|
||||
cls_output = extract_step_output(thread_text, 'classifier')
|
||||
cls_fm, _ = extract_frontmatter(cls_output)
|
||||
td_output = extract_step_output(thread_text, 'template-d')
|
||||
td_fm, td_body = extract_frontmatter(td_output)
|
||||
ra_output = extract_step_output(thread_text, 'rule-analy')
|
||||
ra_fm, ra_body = extract_frontmatter(ra_output)
|
||||
de_output = extract_step_output(thread_text, 'data-extra')
|
||||
de_fm, de_body = extract_frontmatter(de_output)
|
||||
|
||||
row = {}
|
||||
row['B'] = info['filename'] + '.pdf' if info['filename'] else cls_fm.get('contract_title', '')
|
||||
row['C'] = cls_fm.get('contract_type', '')
|
||||
row['D'] = f"甲方:{cls_fm.get('party_a', '')}\n乙方:{cls_fm.get('party_b', '')}"
|
||||
row['E'] = cls_fm.get('property_address', '')
|
||||
row['F'] = cls_fm.get('area_sqm', '')
|
||||
start = cls_fm.get('term_start', '')
|
||||
end = cls_fm.get('term_end', '')
|
||||
free = cls_fm.get('rent_free_period', '')
|
||||
row['G'] = f"{start}至{end}\n免租期:{free}" if start else ''
|
||||
|
||||
for col in 'HIJ':
|
||||
pattern = rf'{col}\.\s+[^::]+[::]\s*(.*?)(?=\n[A-L]\.\s|\nh_column|\Z)'
|
||||
m = re.search(pattern, de_body, re.DOTALL)
|
||||
row[col] = m.group(1).strip() if m else ''
|
||||
h_pattern = r'H\.\s+金额/费用[::]\s*(.*?)(?=\nI\.\s|\Z)'
|
||||
hm = re.search(h_pattern, de_body, re.DOTALL)
|
||||
if hm: row['H'] = hm.group(1).strip()
|
||||
|
||||
risk = ra_fm.get('risk_detail', ra_body[:5000])
|
||||
term = ra_fm.get('termination_analysis', '')
|
||||
row['K'] = risk + ('\n\n' + term if term else '')
|
||||
row['L'] = td_fm.get('diff_detail', td_body[:3000])
|
||||
|
||||
if campus not in campus_data:
|
||||
campus_data[campus] = []
|
||||
campus_data[campus].append(row)
|
||||
|
||||
import openpyxl
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active)
|
||||
|
||||
for campus, rows in campus_data.items():
|
||||
print(f" {campus}: {len(rows)} rows")
|
||||
build_campus_sheet(wb, campus, rows)
|
||||
|
||||
os.makedirs(os.path.dirname(out_path) or '.', exist_ok=True)
|
||||
wb.save(out_path)
|
||||
print(f"\n✅ 已保存: {out_path}")
|
||||
|
||||
print(f"\n=== 汇总 ===")
|
||||
for campus, rows in campus_data.items():
|
||||
print(f" {campus}: {len(rows)}份合同")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
单校区开工闸门 / Campus Workflow Gate
|
||||
=====================================
|
||||
南通新东方租赁合同梳理——每开一个新校区,动手前必须先跑这个脚本。
|
||||
|
||||
为什么存在:防止"开新校区时凭上个校区的印象乱跑/跳步"。
|
||||
这是物理闸门——脚本把【从头到尾的完整流程】+【单校区独立闭环纪律】打印出来,
|
||||
逼自己逐条确认,不跑不准动手填表。
|
||||
|
||||
🔴 通读强制纪律(悦拾光0703教训,详见 references/fulltext-reading-discipline-0703.md):
|
||||
Step2动作A法律审查必须 read_file 从第1行读到最后一行(每批500行),不得 grep 代替。
|
||||
I列按21/15固定类目逐项摘录原文(格式:· [类目] 原文(条款号))。
|
||||
建完表后跑 scripts/i-column-coverage-check.py 报警核查覆盖率。
|
||||
|
||||
用法:
|
||||
python3 campus-workflow-gate.py <校区名>
|
||||
例:
|
||||
python3 campus-workflow-gate.py 人民中路
|
||||
|
||||
它会:
|
||||
1. 打印「单校区独立闭环纪律」——本校区从 Step0 从头做,不受其他校区影响
|
||||
2. 打印完整 Step 0→7 workflow + 三角色 + 交付物板块的强制清单
|
||||
3. 定位该校区在 Nextcloud 的源文件夹 + 汇总表应存放的位置(=校区自己的文件夹)
|
||||
4. 生成一份待打勾的 todo 文本,贴进 todo 工具
|
||||
"""
|
||||
import sys, os, subprocess
|
||||
|
||||
NC_CONTAINER = "nextcloud-nextcloud-1"
|
||||
NC_BASE = "/var/www/html/data/admin/files/小Maggie协作区/南通新东方/履约期内非集采合同-综办/房租物业合同"
|
||||
NC_DAV = "小Maggie协作区/南通新东方/履约期内非集采合同-综办/房租物业合同"
|
||||
|
||||
def campus_dir_exists(campus):
|
||||
p = f"{NC_BASE}/{campus}"
|
||||
r = subprocess.run(["docker","exec",NC_CONTAINER,"test","-d",p],
|
||||
capture_output=True)
|
||||
return r.returncode == 0
|
||||
|
||||
def list_campus_files(campus):
|
||||
p = f"{NC_BASE}/{campus}"
|
||||
r = subprocess.run(["docker","exec",NC_CONTAINER,"bash","-c",f'ls -la "{p}"'],
|
||||
capture_output=True, text=True)
|
||||
return r.stdout
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 campus-workflow-gate.py <校区名>")
|
||||
print("例: python3 campus-workflow-gate.py 人民中路")
|
||||
sys.exit(1)
|
||||
campus = sys.argv[1].strip()
|
||||
|
||||
bar = "=" * 70
|
||||
print(bar)
|
||||
print(f" 单校区开工闸门 · 校区 = 【{campus}】")
|
||||
print(bar)
|
||||
|
||||
# ---- 第一道:独立闭环纪律 + 地基四铁律 ----
|
||||
print("""
|
||||
🔴🔴 单校区独立闭环纪律(Maggie 2026-06-23 立,开工前默念)🔴🔴
|
||||
1. 本校区从 Step 0 从头做到 Step 6 独立跑一遍(Step 7 总览是全部校区定稿后的全局收尾)。
|
||||
2. 绝不受其他校区影响——不拿"上个校区做过/世茂悦拾光是这样"的印象代替
|
||||
本校区的逐字通读、逐条审查、回 07 原件比对。每个校区都是第一次。
|
||||
3. 别的校区的结论、定级、措辞,统统不假设适用于本校区;一切回本校区原文。
|
||||
4. 这是一次"全新合同全面审",不是"套上一份的模子"。
|
||||
|
||||
🔴🔴 地基四铁律(Maggie 2026-06-26 重申,优先级最高)🔴🔴
|
||||
1. 逐字逐句:亲自读完整篇OCR,一字不跳。OCR乱码停→vision核实,不准跳过猜值。
|
||||
🔴 Step1完成后必跑 ocr-garble-detect.py 扫全文乱码,高危行逐条vision消灭(教训:
|
||||
凤凰文化10.2乱码未核实→整段"初年年租金20%违约金"丢失→审查结论反转→返工)
|
||||
2. 整体理解:通读全文后再逐条审,先建立全文结构认知。
|
||||
3. 上下文联系:每读一条问"这条被别处限定/修改了吗?"
|
||||
4. 逻辑分析:数字、比例、日期、主体用逻辑推一遍——合理吗?自洽吗?
|
||||
""")
|
||||
|
||||
# ---- 第二道:完整流程强制清单 ----
|
||||
print("""———— 完整 WORKFLOW(缺一步不交付)————
|
||||
Step 0 文件盘点归类:按文件夹结构(房租/扩租/物业),含空目录,不跨夹重排
|
||||
Step 1 取 PDF + OCR→.md(大文件后台跑;纯扫描件文字层=0 必 OCR)
|
||||
Step 2 承办(四眼分离前半)【并行,不串行】:
|
||||
⚡ 开工第一动作 = 立刻 delegate_task 发动作B 到后台,发出的同一秒自己开读动作A
|
||||
· 动作B 提取分析 = subagent(后台先发):OCR要素+模版比对+退租敞口+填表初稿
|
||||
· 动作A 法律审查 = 小Maggie本人主审(B发出后立即开读),亲自 read_file 逐字通读全文,
|
||||
八维框架,当全新合同审(独立法律审查框架)—— 两件事同时跑,绝不先做完A再做B
|
||||
· 🔴 模版比对必须回 07-房屋租赁合同.docx 原件逐条核(subagent context 带原件路径)
|
||||
Step 3 写 Excel:12 列(K=法律风险/L=模版差异,物理分列);
|
||||
按文件夹分板块;末尾必有「整体风险分析与建议」段
|
||||
· 各列规则详见 references/column-rules-0701.md(Maggie校准版,优先级最高)
|
||||
· H列四检:①条款号 ②月租换算 ③付款推算 ④❗标注。缺一不过,不过不交付。
|
||||
· 需客户核实内容整条标红(富文本红是最后一步→WPS另存/sharedStrings XML层)
|
||||
Step 4 三角色校对:法律校对 ‖ 格式校对(六维清单)→ 闭环复核
|
||||
Step 5 小Maggie终审:合并法律风险栏、回07原件复核L列、确认问题闭环、最后把关
|
||||
· 🔴 K/L 分工自检(必跑):K列 grep "模版|07模版|07-房屋"=0(独立审查不引模版,
|
||||
匹配"07模版"而非裸07防误命中金额数字);L列 grep "风险|建议|不利|详见"=0(纯客观不下判断)。
|
||||
任一非0即回去拆分。判据=遮模版测试:把"模版怎么写"遮住,K列风险论述仍完整成立才算独立。
|
||||
Step 6 交付前自查+存档:x2t渲染PDF + pdftotext拍平grep验文字 + vision验视觉(图先压<400KB)
|
||||
→ 汇总表存本校区文件夹 + files:scan + 清OO缓存 → 发Maggie核
|
||||
〔全部校区定稿后〕Step 7 整合总览sheet(全局收尾,非单校区步骤)
|
||||
———— 交付物必含板块(验收必查)————
|
||||
逐条风险(🔴🟡🟢分级,标条款号) + 整体风险分析与建议段 + L列模版差异(回07原件)
|
||||
""")
|
||||
|
||||
# ---- 第三道:存放纪律 + 定位 ----
|
||||
print(bar)
|
||||
print(" 📁 汇总表存放纪律(Maggie 2026-06-23 立)")
|
||||
print(bar)
|
||||
exists = campus_dir_exists(campus)
|
||||
if exists:
|
||||
print(f"✅ 校区源文件夹已定位:")
|
||||
print(f" 容器路径: {NC_BASE}/{campus}/")
|
||||
print(f" WebDAV : {NC_DAV}/{campus}/")
|
||||
print(f"\n 本校区文件清单:")
|
||||
for line in list_campus_files(campus).splitlines():
|
||||
if line.strip() and not line.startswith("total"):
|
||||
print(f" {line}")
|
||||
else:
|
||||
print(f"⚠️ 未在标准路径找到校区目录【{campus}】。请先核对校区名,或确认目录是否在别处:")
|
||||
print(f" 预期: {NC_BASE}/{campus}/")
|
||||
print(f" (17 个校区均在 房租物业合同/ 下,标准名单:")
|
||||
print(f" 万达、世茂、人民中路、凤凰文化、北翼玖玖、南通大厦、小石桥晏园、悦拾光、")
|
||||
print(f" 星月、桃坞路、解放中路、跃龙路、通大、通大附、通州金鹰、金飞达、龙信)")
|
||||
print(f"""
|
||||
🔴 本校区汇总表【必须】存到本校区自己的文件夹下,不放别处、不放公共目录:
|
||||
存放路径: {NC_DAV}/{campus}/
|
||||
命名规则: {campus}-梳理-MJ-YYYYMMDD.xlsx (当事人/项目名+文件名+修改人+日期)
|
||||
—— 每个校区的汇总表归到各自校区文件夹,与该校区合同放一起,便于客户对照查阅。
|
||||
""")
|
||||
|
||||
# ---- 第四道:吐出 todo 文本 ----
|
||||
print(bar)
|
||||
print(" ⬇️ 把下面这份 todo 贴进 todo 工具,逐项打勾(缺一项不交付)")
|
||||
print(bar)
|
||||
todos = [
|
||||
f"[{campus}] Step0 文件盘点归类(含空目录,不跨夹重排;多租赁物先按租赁物分类再按签约时间排列;详细K/L/I放最早签约那份行里后续写同上)",
|
||||
f"[{campus}] Step1 取PDF+OCR→.md(纯扫描件必OCR)+保存.md到同目录 → 跑 ocr-garble-detect.py 扫乱码 → 高危乱码每处vision核实消灭 → 全灭才进Step2",
|
||||
f"[{campus}] Step2 承办【并行,不串行】⚡开工第一动作=立刻 delegate_task 发动作B(模版比对回07原件,仅租赁合同需比对,物业等其他合同不需要)到后台 → 发出的同一秒自己开读动作A(本人逐字通读全文+八维当全新合同审)。两件事同时跑,绝不先做完A再做B",
|
||||
f"[{campus}] Step3 写12列Excel(K法律风险/L模版差异分列)+H列四检+整体风险分析段+标红。🔴必须用 templates/single-campus-builder.py 照抄改值,禁裸写 openpyxl。🔴建L列前必read_file subagent比对文件,从里面逐条摘差异。🔴各列规则见 references/column-rules-0701.md(多合同时详细K/L放最早签约那份行里,后续行写同上)",
|
||||
f"[{campus}] Step4 三角色校对(法律‖格式)闭环复核",
|
||||
f"[{campus}] Step5 终审:合并法律风险+回07原件复核L列+K/L分工自检(K列grep'模版|07'=0,L列grep'风险|建议|不利|详见'=0)+确认闭环",
|
||||
f"[{campus}] Step6 交付自查(x2t渲染+pdftotext验文字+vision验视觉)+存本校区文件夹",
|
||||
f"[{campus}] 存档:汇总表存到本校区文件夹 {campus}/ + files:scan + 清OO缓存",
|
||||
]
|
||||
for i, t in enumerate(todos, 1):
|
||||
print(f" {i}. {t}")
|
||||
print()
|
||||
|
||||
# ---- 🔴 第五道:物理卡口(防跳步)----
|
||||
print(bar)
|
||||
print(" 🔴🔴 物理卡口(以下两条不做到 = 返工,不交付)🔴🔴")
|
||||
print(bar)
|
||||
print("""
|
||||
卡口① 格式强制:Step3 写表必须用模板脚本
|
||||
→ 路径:~/.hermes/skills/legal/contract-portfolio-analysis/templates/single-campus-builder.py
|
||||
→ 方法:cp 到工作目录,改 TODO 标记的值(标题、项目信息、D5..L5、D8..L8、整体段、输出路径)
|
||||
→ 禁止:自己 openpyxl 裸写样式、自创颜色、改列头措辞
|
||||
→ 自检:交付前打开文件,和人民中路定稿并排对比——标题酒红底白字?段标题红底?行2灰底?
|
||||
K列头="法律风险(站乙方立场)"?L列头="与07标准模版差异"?冻结A5?行高30/76/409.5?
|
||||
|
||||
卡口② 模版比对强制:Step2 动作B 必须 delegate_task subagent
|
||||
→ 不能:自己读07模版后手写L列差异(会漏、会简略、会凭印象)
|
||||
→ 做法:OCR完成后立即 delegate_task,context 含 07模版路径 + 两份合同OCR路径
|
||||
→ 🔴 建表前必做:read_file 读 subagent 比对文件全文,L列从里面逐条摘、不从脑子里摘
|
||||
→ 自检①:subagent 返回的差异清单是否 ≥ 20 条?
|
||||
→ 自检②:L列每条差异是否都能在 subagent 比对文件里找到原文对应?
|
||||
"甲方制式格式,与07模版不同"这种一句话概括 = 没读 subagent 文件 = 返工
|
||||
subagent 抓到的差异(如举报邮箱新增、供电功率矛盾、首期期间变更)
|
||||
你在 L 列里没写 = 没读 subagent 文件 = 返工
|
||||
|
||||
卡口③ 交付前格式自检(跑脚本,不凭眼):
|
||||
→ python3 scripts/kl-separation-check.py <out.xlsx> # K列零模版、L列零判断词
|
||||
→ python3 -c "import openpyxl; wb=openpyxl.load_workbook('<out.xlsx>');
|
||||
ws=wb[wb.sheetnames[0]];
|
||||
assert ws.cell(1,1).fill.start_color.rgb=='FF8B1A2B','标题不是酒红底';
|
||||
assert ws.cell(3,1).fill.start_color.rgb=='FFC0504D','段标题不是红底';
|
||||
assert ws.cell(4,11).value=='法律风险(站乙方立场)','K列头不对';
|
||||
assert ws.cell(4,12).value=='与07标准模版差异','L列头不对';
|
||||
print('OK')"
|
||||
""")
|
||||
print("提醒:开工前默念独立闭环纪律——本校区从头做,不受其他校区影响。")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
交付前闸门 / Delivery Gate
|
||||
=========================
|
||||
每次交付南通新东方梳理表前,必须跑这个脚本。
|
||||
不通过 = 不发文件。不通过 = 回去补 workflow。
|
||||
|
||||
用法:
|
||||
python3 delivery-gate.py <xlsx路径> <工作目录> <校区名>
|
||||
|
||||
例:
|
||||
python3 delivery-gate.py /tmp/金飞达/金飞达-梳理-MJ-20260627.xlsx /tmp/金飞达 金飞达
|
||||
|
||||
检查项:
|
||||
G1. 模版比对 subagent 是否执行过(工作目录是否有 模版比对-*.md)
|
||||
G2. K/L 分工自检是否通过
|
||||
G3. 格式自检是否通过(标题酒红底、段标题红底、列头正确)
|
||||
G4. 整体风险分析段是否存在
|
||||
G5. 数据行数是否合理(至少 文件数 行)
|
||||
G6. 文件是否已上传 Nextcloud
|
||||
G7. OCR占位符残留检测(【…】/〔待核PDF〕/OCR模糊 等,任一残留=禁止交付)
|
||||
"""
|
||||
import sys, os, subprocess, re
|
||||
import openpyxl
|
||||
|
||||
def fail(msg):
|
||||
print(f"❌ {msg}")
|
||||
return False
|
||||
|
||||
def ok(msg):
|
||||
print(f"✅ {msg}")
|
||||
return True
|
||||
|
||||
def check_g1(workdir):
|
||||
"""G1: 模版比对 subagent 是否执行过"""
|
||||
import glob
|
||||
files = glob.glob(os.path.join(workdir, "模版比对-*.md"))
|
||||
if not files:
|
||||
return fail("G1 模版比对:未找到模版比对输出文件。Step2 动作B 必须 delegate_task subagent 做模版比对。")
|
||||
f = files[0]
|
||||
size = os.path.getsize(f)
|
||||
if size < 1000:
|
||||
return fail(f"G1 模版比对:{os.path.basename(f)} 仅 {size} 字节,内容过短,可能未完整执行。")
|
||||
return ok(f"G1 模版比对:{os.path.basename(f)} ({size:,} 字节)")
|
||||
|
||||
def check_g2(xlsx_path):
|
||||
"""G2: K/L 分工自检"""
|
||||
# 直接用 kl-separation-check.py
|
||||
script = os.path.expanduser("~/.hermes/skills/legal/contract-portfolio-analysis/scripts/kl-separation-check.py")
|
||||
r = subprocess.run(["python3", script, xlsx_path], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
return fail(f"G2 K/L自检:未通过\n{r.stdout}")
|
||||
return ok("G2 K/L自检:通过")
|
||||
|
||||
def check_g3(xlsx_path):
|
||||
"""G3: 格式自检"""
|
||||
try:
|
||||
wb = openpyxl.load_workbook(xlsx_path)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
# 找到第一个列头行(含"序号"的)
|
||||
hdr_row = None
|
||||
for r in range(1, 30):
|
||||
if ws.cell(r, 1).value == '序号':
|
||||
hdr_row = r
|
||||
break
|
||||
if not hdr_row:
|
||||
return fail("G3 格式:未找到列头行")
|
||||
checks = [
|
||||
("标题酒红底", ws.cell(1,1).fill.start_color.rgb == 'FF8B1A2B'),
|
||||
("段标题(K列头)", ws.cell(hdr_row, 11).value == '法律风险(站乙方立场)'),
|
||||
("段标题(L列头)", ws.cell(hdr_row, 12).value == '与07标准模版差异'),
|
||||
]
|
||||
for name, passed in checks:
|
||||
if not passed:
|
||||
return fail(f"G3 格式:{name} 不正确")
|
||||
return ok("G3 格式自检:通过")
|
||||
except Exception as e:
|
||||
return fail(f"G3 格式:打开失败 - {e}")
|
||||
|
||||
def check_g4(xlsx_path):
|
||||
"""G4: 整体风险分析段"""
|
||||
try:
|
||||
wb = openpyxl.load_workbook(xlsx_path)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
for r in range(ws.max_row, 1, -1):
|
||||
v = ws.cell(r, 1).value
|
||||
if v and '整体风险分析' in str(v):
|
||||
return ok(f"G4 整体风险分析段:存在(行{r})")
|
||||
return fail("G4 整体风险分析段:未找到。每校区必须包含「整体风险分析与建议」段。")
|
||||
except Exception as e:
|
||||
return fail(f"G4 整体风险分析段:打开失败 - {e}")
|
||||
|
||||
def check_g5(xlsx_path, min_rows=3):
|
||||
"""G5: 数据行数"""
|
||||
try:
|
||||
wb = openpyxl.load_workbook(xlsx_path)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
data_rows = 0
|
||||
for r in range(5, ws.max_row + 1):
|
||||
v = ws.cell(r, 1).value
|
||||
if v and re.match(r'^\d+$', str(v).strip()):
|
||||
data_rows += 1
|
||||
if data_rows < min_rows:
|
||||
return fail(f"G5 数据行数:仅 {data_rows} 行,需 ≥ {min_rows}")
|
||||
return ok(f"G5 数据行数:{data_rows} 行")
|
||||
except Exception as e:
|
||||
return fail(f"G5 数据行数:打开失败 - {e}")
|
||||
|
||||
def check_g6(xlsx_path, campus):
|
||||
"""G6: 文件是否已上传 Nextcloud"""
|
||||
fname = os.path.basename(xlsx_path)
|
||||
nc_path = f"/var/www/html/data/admin/files/小Maggie协作区/南通新东方/履约期内非集采合同-综办/房租物业合同/{campus}/{fname}"
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "nextcloud-nextcloud-1", "test", "-f", nc_path],
|
||||
capture_output=True
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return ok(f"G6 Nextcloud:已上传 {campus}/{fname}")
|
||||
else:
|
||||
return fail(f"G6 Nextcloud:未上传到 {campus}/{fname}。请先 docker cp + files:scan。")
|
||||
|
||||
def check_g7(xlsx_path):
|
||||
"""G7: OCR占位符残留检测(【…】、〔待核PDF〕、OCR模糊 等)"""
|
||||
patterns = [
|
||||
r'【\.{1,5}】', # 【…】、【...】
|
||||
r'【…】', # 中文省略号
|
||||
r'〔待核PDF〕', # 待核标记
|
||||
r'〔待核〕',
|
||||
r'OCR模糊', # OCR模糊标记
|
||||
r'OCR无法识别',
|
||||
r'OCR乱码',
|
||||
r'待补全',
|
||||
]
|
||||
try:
|
||||
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
|
||||
hits = []
|
||||
for ws in wb.worksheets:
|
||||
for r in range(1, ws.max_row + 1):
|
||||
for c in range(1, ws.max_column + 1):
|
||||
v = ws.cell(r, c).value
|
||||
if not v:
|
||||
continue
|
||||
sv = str(v)
|
||||
for pat in patterns:
|
||||
if re.search(pat, sv):
|
||||
col_letter = openpyxl.utils.get_column_letter(c)
|
||||
m = re.search(pat, sv)
|
||||
start = max(0, m.start() - 10)
|
||||
end = min(len(sv), m.end() + 10)
|
||||
ctx = sv[start:end].replace('\n', ' ')
|
||||
hits.append(f" [{ws.title}] {col_letter}{r}: ...{ctx}...")
|
||||
if hits:
|
||||
msg = f"G7 OCR占位符残留:发现 {len(hits)} 处未补全的OCR标记\n" + "\n".join(hits[:5])
|
||||
if len(hits) > 5:
|
||||
msg += f"\n ...(共 {len(hits)} 处,仅显示前5处)"
|
||||
msg += "\n 铁律:OCR读不出的必须用vision看原图补全,不能用占位符交付。"
|
||||
return fail(msg)
|
||||
return ok("G7 OCR占位符:无残留(全表扫描通过)")
|
||||
except Exception as e:
|
||||
return fail(f"G7 OCR占位符:检查失败 - {e}")
|
||||
|
||||
def check_g8(workdir):
|
||||
"""G8: OCR完整性checkpoint(物理依赖链第一环)"""
|
||||
checkpoint = os.path.join(workdir, 'step1.verified')
|
||||
if not os.path.exists(checkpoint):
|
||||
return fail(f"G8 OCR依赖链:checkpoint不存在\n 路径: {checkpoint}\n 说明: Step1 OCR完成后必须运行 ocr-integrity-check.py 生成checkpoint\n 动作: python3 scripts/ocr-integrity-check.py {workdir}")
|
||||
# 读取checkpoint内容确认
|
||||
with open(checkpoint, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if 'OCR完整性检查通过' not in content:
|
||||
return fail(f"G8 OCR依赖链:checkpoint内容异常\n {checkpoint} 未包含'OCR完整性检查通过'")
|
||||
return ok(f"G8 OCR依赖链:checkpoint存在且有效")
|
||||
|
||||
def check_g9(workdir):
|
||||
"""G9: 模版比对checkpoint(物理依赖链第二环)"""
|
||||
checkpoint = os.path.join(workdir, 'step2b.verified')
|
||||
if not os.path.exists(checkpoint):
|
||||
return fail(f"G9 模版比对依赖链:checkpoint不存在\n 路径: {checkpoint}\n 说明: Step2 动作B必须delegate subagent做模版比对,然后运行验证脚本\n 动作: python3 scripts/template-diff-verify.py {workdir}")
|
||||
# 读取checkpoint内容确认
|
||||
with open(checkpoint, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if '模版比对验证通过' not in content:
|
||||
return fail(f"G9 模版比对依赖链:checkpoint内容异常\n {checkpoint} 未包含'模版比对验证通过'")
|
||||
return ok(f"G9 模版比对依赖链:checkpoint存在且有效")
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4:
|
||||
print("用法: python3 delivery-gate.py <xlsx路径> <工作目录> <校区名>")
|
||||
print("例: python3 delivery-gate.py /tmp/金飞达/金飞达-梳理-MJ-20260627.xlsx /tmp/金飞达 金飞达")
|
||||
sys.exit(1)
|
||||
|
||||
xlsx = sys.argv[1]
|
||||
workdir = sys.argv[2]
|
||||
campus = sys.argv[3]
|
||||
|
||||
if not os.path.exists(xlsx):
|
||||
print(f"❌ 文件不存在: {xlsx}")
|
||||
sys.exit(1)
|
||||
|
||||
bar = "=" * 60
|
||||
print(bar)
|
||||
print(f" 交付闸门 · {campus}")
|
||||
print(bar)
|
||||
print()
|
||||
|
||||
results = [
|
||||
check_g1(workdir),
|
||||
check_g2(xlsx),
|
||||
check_g3(xlsx),
|
||||
check_g4(xlsx),
|
||||
check_g5(xlsx, min_rows=3),
|
||||
check_g6(xlsx, campus),
|
||||
check_g7(xlsx),
|
||||
check_g8(workdir),
|
||||
check_g9(workdir),
|
||||
]
|
||||
|
||||
print()
|
||||
print(bar)
|
||||
passed = sum(1 for r in results if r)
|
||||
total = len(results)
|
||||
if all(results):
|
||||
print(f" ✅ 全部通过 ({passed}/{total}) —— 可以交付")
|
||||
print(bar)
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f" ❌ {total - passed}/{total} 项未通过 —— 禁止交付,回去补 workflow")
|
||||
print(bar)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
edit-redmarked-xlsx.py — 安全编辑「已标红(WPS规范化)」的 xlsx,红色 run 一个字不碰。
|
||||
|
||||
背景(2026-06-18 世茂实证):
|
||||
WPS 另存后的好 xlsx 用 sharedStrings.xml + 富文本红 <r> run 存储「某条标红」。
|
||||
对它 openpyxl.load_workbook→改→save 会把整表打回 inlineStr、红色 run 全部归零、
|
||||
sharedStrings.xml 消失,Excel 重新报「需要修复」——成果一键作废。
|
||||
正解:在 sharedStrings.xml 的 XML 层做外科手术,只改目标 <t> 文字,红 <r> run 原样保留。
|
||||
|
||||
本脚本提供:
|
||||
1) --verify 只读·五查(本地验不了 Excel,这是能自动做的最强保证)
|
||||
2) 可 import 的工具函数 surgical_edit_si() / delete_run_and_renumber() / repack()
|
||||
—— 已知正确的实现,未来 session 直接 import 或照抄,别重新踩坑。
|
||||
|
||||
⚠️ 红色 = rgb="FFFF0000"。重打包必须 [Content_Types].xml 在 zip 第一项。
|
||||
⚠️ 改前先把 WPS 好版本另存 _bak_ 备份;改完五查全过再发 Maggie 用 Excel 终判。
|
||||
|
||||
用法:
|
||||
# 五查(改后必跑)
|
||||
python3 edit-redmarked-xlsx.py --verify 改后.xlsx --baseline WPS好基线.xlsx
|
||||
# 列出每个格子用的 sharedString 索引(定位「要改哪个格→改第几条 si」)
|
||||
python3 edit-redmarked-xlsx.py --map 文件.xlsx [--sheet sheet1.xml]
|
||||
# 打印某条 si 的 run 结构(看哪些 <r> 是红色、文字开头)
|
||||
python3 edit-redmarked-xlsx.py --show-si 文件.xlsx 47
|
||||
"""
|
||||
import sys, os, re, zipfile, shutil, argparse, tempfile
|
||||
from lxml import etree
|
||||
|
||||
NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
|
||||
RED = "FFFF0000"
|
||||
|
||||
|
||||
# ---------- 只读探针 ----------
|
||||
def red_run_count(xlsx):
|
||||
"""数红色富文本 run 数(精确匹配 rgb="FFFF0000")。
|
||||
⚠️ 2026-06-22 教训:务必用精确 rgb="FFFF0000" 匹配,绝不能用
|
||||
`'FF0000' in etree.tostring(rpr)` 子串匹配——黑色 FF000000 里也含 'F0000',
|
||||
会把黑字 run 误判成红字,导致 K列长单元格被误报"整格泛红"。
|
||||
"""
|
||||
z = zipfile.ZipFile(xlsx)
|
||||
if "xl/sharedStrings.xml" not in z.namelist():
|
||||
# 退化成 inlineStr 了——红色多半已丢,去 sheet 里数
|
||||
total = 0
|
||||
for n in z.namelist():
|
||||
if re.match(r"xl/worksheets/sheet\d+\.xml", n):
|
||||
total += len(re.findall(rf'rgb="{RED}"', z.read(n).decode()))
|
||||
return total, False # False = 没有 sharedStrings(危险信号)
|
||||
ss = z.read("xl/sharedStrings.xml").decode()
|
||||
return len(re.findall(rf'rgb="{RED}"', ss)), True
|
||||
|
||||
|
||||
def cell_si_map(xlsx, sheet="xl/worksheets/sheet1.xml"):
|
||||
"""返回 {单元格坐标: sharedString索引},用于把「改哪个格」翻成「改第几条 si」。"""
|
||||
z = zipfile.ZipFile(xlsx)
|
||||
sx = z.read(sheet).decode()
|
||||
out = {}
|
||||
for m in re.finditer(r'<c r="([A-Z]+\d+)"[^>]*\bt="s"[^>]*>\s*<v>(\d+)</v>', sx):
|
||||
out[m.group(1)] = int(m.group(2))
|
||||
return out
|
||||
|
||||
|
||||
def show_si(xlsx, idx):
|
||||
"""打印第 idx 条 si 的 run 结构(红/黑 + 文字开头),定位要改/要删哪个 run。"""
|
||||
z = zipfile.ZipFile(xlsx)
|
||||
root = etree.fromstring(z.read("xl/sharedStrings.xml"))
|
||||
si = root.findall(f"{NS}si")[idx]
|
||||
print(f"=== si[{idx}] ===")
|
||||
for i, ch in enumerate(si):
|
||||
tag = ch.tag.replace(NS, "")
|
||||
if tag == "r":
|
||||
rpr = ch.find(f"{NS}rPr")
|
||||
t = ch.find(f"{NS}t")
|
||||
is_red = rpr is not None and RED in etree.tostring(rpr, encoding="unicode")
|
||||
txt = (t.text or "")[:55] if t is not None else ""
|
||||
print(f" [{i}] {'🔴红' if is_red else ' 黑'} | {txt!r}")
|
||||
elif tag == "t":
|
||||
print(f" [{i}] 纯t | {(ch.text or '')[:55]!r}")
|
||||
|
||||
|
||||
def verify(xlsx, baseline=None, expect_red=None):
|
||||
"""五查:sharedStrings在 / 红色数 / zip+XML完整 / (可选)与基线同构。返回 True/False。"""
|
||||
ok = True
|
||||
z = zipfile.ZipFile(xlsx)
|
||||
names = z.namelist()
|
||||
|
||||
# ① sharedStrings 仍在
|
||||
has_ss = "xl/sharedStrings.xml" in names
|
||||
print(f"① sharedStrings.xml: {'✅在' if has_ss else '❌丢失(openpyxl毁了富文本!)'}")
|
||||
ok &= has_ss
|
||||
|
||||
# ② 红色 run 数
|
||||
n_red, _ = red_run_count(xlsx)
|
||||
tail = f"(预期 {expect_red})" if expect_red is not None else ""
|
||||
match = (expect_red is None) or (n_red == expect_red)
|
||||
print(f"② 红色run数: {n_red}{tail} {'✅' if match else '❌'}")
|
||||
ok &= match
|
||||
|
||||
# ③ zip 完整 + 所有 XML 部件可解析
|
||||
bad = z.testzip()
|
||||
parts_ok, parts_bad = 0, []
|
||||
for n in names:
|
||||
if n.endswith(".xml") or n.endswith(".rels"):
|
||||
try:
|
||||
etree.fromstring(z.read(n)); parts_ok += 1
|
||||
except Exception as e:
|
||||
parts_bad.append((n, str(e)[:50]))
|
||||
print(f"③ zip完整={'✅' if bad is None else '❌'+str(bad)}; "
|
||||
f"XML部件 {parts_ok}个OK"
|
||||
+ (f" ❌{parts_bad}" if parts_bad else " ✅"))
|
||||
ok &= (bad is None) and not parts_bad
|
||||
|
||||
# ④ Content_Types 必须第一项(严格解析器要求)
|
||||
first = names[0] if names else ""
|
||||
ct_first = first == "[Content_Types].xml"
|
||||
print(f"④ [Content_Types].xml 在首位: {'✅' if ct_first else '⚠️ 实为 '+first}")
|
||||
# 不计入硬失败(Excel/WPS 宽容),仅告警
|
||||
if not ct_first:
|
||||
print(" ↳ 严格解析器(LibreOffice)会报 source file could not be loaded;建议重打包置首。")
|
||||
|
||||
# ⑤ 与基线同构(部件清单)
|
||||
if baseline:
|
||||
zb = set(zipfile.ZipFile(baseline).namelist())
|
||||
zo = set(names)
|
||||
only_mine = {x for x in zo - zb if not x.endswith("/")}
|
||||
only_base = {x for x in zb - zo if not x.endswith("/")}
|
||||
iso = not only_mine and not only_base
|
||||
print(f"⑤ 与基线部件同构: {'✅' if iso else '❌'} "
|
||||
+ (f"我多:{only_mine} 基线多:{only_base}" if not iso else "(差异仅空目录条目可接受)"))
|
||||
ok &= iso
|
||||
|
||||
print(f"\n{'✅ 五查通过——可发 Maggie 用 Excel 终判' if ok else '❌ 有项未过——先修再发'}")
|
||||
return ok
|
||||
|
||||
|
||||
# ---------- 编辑工具(import 用;已验证正确,照抄别重写) ----------
|
||||
def surgical_edit_si(work_dir, edits: dict):
|
||||
"""
|
||||
在解压目录 work_dir 的 xl/sharedStrings.xml 上,按 {si索引: 新纯文本} 改纯文本格。
|
||||
仅适用于「纯文本 si」(无红 run)。含红 run 的格用下面 delete_run_and_renumber 或手写。
|
||||
"""
|
||||
ssp = os.path.join(work_dir, "xl", "sharedStrings.xml")
|
||||
tree = etree.parse(ssp)
|
||||
sis = tree.getroot().findall(f"{NS}si")
|
||||
for idx, new_text in edits.items():
|
||||
si = sis[idx]
|
||||
for c in list(si):
|
||||
si.remove(c)
|
||||
t = etree.SubElement(si, f"{NS}t")
|
||||
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
||||
t.text = new_text
|
||||
tree.write(ssp, xml_declaration=True, encoding="UTF-8", standalone=True)
|
||||
|
||||
|
||||
def delete_run_and_renumber(work_dir, si_idx, match_prefix, renum: dict):
|
||||
"""
|
||||
含红 run 的格:删掉文字以 match_prefix 开头的那个 <r>(连同其红色),
|
||||
再按 renum {旧前缀: 新前缀} 顺移后续编号。红 run 之外的一律不动。
|
||||
例:删 "9. " 那条红,renum={"10. ":"9. ","11. ":"10. ","12. ":"11. "}
|
||||
"""
|
||||
ssp = os.path.join(work_dir, "xl", "sharedStrings.xml")
|
||||
tree = etree.parse(ssp)
|
||||
si = tree.getroot().findall(f"{NS}si")[si_idx]
|
||||
target = None
|
||||
for r in si.findall(f"{NS}r"):
|
||||
t = r.find(f"{NS}t")
|
||||
if t is not None and t.text and t.text.startswith(match_prefix):
|
||||
target = r; break
|
||||
if target is None:
|
||||
raise ValueError(f"si[{si_idx}] 没找到以 {match_prefix!r} 开头的 run")
|
||||
si.remove(target)
|
||||
for r in si.findall(f"{NS}r"):
|
||||
t = r.find(f"{NS}t")
|
||||
if t is not None and t.text:
|
||||
for old, new in renum.items():
|
||||
if t.text.startswith(old):
|
||||
t.text = new + t.text[len(old):]; break
|
||||
tree.write(ssp, xml_declaration=True, encoding="UTF-8", standalone=True)
|
||||
|
||||
|
||||
def repack(work_dir, out_xlsx):
|
||||
"""规范重打包:[Content_Types].xml 第一,_rels/ 次之,其余原序。ZIP_DEFLATED。"""
|
||||
if os.path.exists(out_xlsx):
|
||||
os.remove(out_xlsx)
|
||||
files = []
|
||||
for folder, _, fs in os.walk(work_dir):
|
||||
for fn in fs:
|
||||
full = os.path.join(folder, fn)
|
||||
arc = os.path.relpath(full, work_dir).replace(os.sep, "/")
|
||||
files.append((arc, full))
|
||||
|
||||
def key(it):
|
||||
a = it[0]
|
||||
if a == "[Content_Types].xml": return (0, a)
|
||||
if a.startswith("_rels/"): return (1, a)
|
||||
return (2, a)
|
||||
|
||||
files.sort(key=key)
|
||||
with zipfile.ZipFile(out_xlsx, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for arc, full in files:
|
||||
zf.write(full, arc)
|
||||
return out_xlsx
|
||||
|
||||
|
||||
def extract(xlsx):
|
||||
"""解压到新临时目录,返回目录路径。"""
|
||||
d = tempfile.mkdtemp(prefix="redxlsx_")
|
||||
with zipfile.ZipFile(xlsx) as z:
|
||||
z.extractall(d)
|
||||
return d
|
||||
|
||||
|
||||
# ---------- CLI ----------
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--verify", metavar="XLSX", help="五查(只读)")
|
||||
ap.add_argument("--baseline", metavar="GOOD", help="WPS好基线,用于同构对比")
|
||||
ap.add_argument("--expect-red", type=int, help="改后预期红色run数(删1条红=基线-1)")
|
||||
ap.add_argument("--map", metavar="XLSX", help="列出单元格→sharedString索引")
|
||||
ap.add_argument("--sheet", default="xl/worksheets/sheet1.xml")
|
||||
ap.add_argument("--show-si", nargs=2, metavar=("XLSX", "IDX"), help="打印某条 si 的 run 结构")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.verify:
|
||||
sys.exit(0 if verify(args.verify, args.baseline, args.expect_red) else 1)
|
||||
if args.map:
|
||||
for coord, idx in sorted(cell_si_map(args.map, args.sheet).items(),
|
||||
key=lambda kv: (kv[0][0], int(re.sub(r"\D", "", kv[0])))):
|
||||
print(f" {coord:>5} -> si[{idx}]")
|
||||
return
|
||||
if args.show_si:
|
||||
show_si(args.show_si[0], int(args.show_si[1]))
|
||||
return
|
||||
ap.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fix-richtext-rpr-order.py — 把 openpyxl CellRichText 写出的 <rPr> 子元素顺序
|
||||
改成 OOXML 合规顺序 (rFont -> sz -> color)。
|
||||
|
||||
⚠️⚠️ 重要警告(2026-06-18 世茂实证):
|
||||
本脚本只修 rPr 顺序,但实测 **修了顺序 Microsoft Excel 仍报"需要修复"**。
|
||||
openpyxl 生成的富文本表在 Excel 严格校验下还有其他不合规处(去掉富文本只留
|
||||
纯文本都仍报错)。所以本脚本 **不保证解决 Excel 报错**,仅作"试过什么"的记录。
|
||||
|
||||
正解:不要用 CellRichText 做局部标红/加粗,改用纯文本前缀(【需客户核实】…)。
|
||||
详见 ../references/openpyxl-excel-richtext-pitfall.md
|
||||
|
||||
用法:
|
||||
python fix-richtext-rpr-order.py <文件.xlsx> # 原地修复
|
||||
python fix-richtext-rpr-order.py <文件.xlsx> --check # 只检查,不改
|
||||
python fix-richtext-rpr-order.py <文件.xlsx> --sheet sheet2.xml # 指定 sheet
|
||||
"""
|
||||
import sys, re, zipfile, os
|
||||
|
||||
|
||||
def analyze(xlsx, sheet_name="xl/worksheets/sheet1.xml"):
|
||||
z = zipfile.ZipFile(xlsx)
|
||||
if sheet_name not in z.namelist():
|
||||
sheets = [n for n in z.namelist() if re.match(r"xl/worksheets/sheet\d+\.xml$", n)]
|
||||
print(f"指定 sheet 不存在;可用:{sheets}")
|
||||
return None, None
|
||||
xml = z.read(sheet_name).decode("utf-8")
|
||||
rprs = re.findall(r"<rPr>(.*?)</rPr>", xml, flags=re.S)
|
||||
bad = 0
|
||||
for inner in rprs:
|
||||
p_sz = inner.find("<sz")
|
||||
p_color = inner.find("<color")
|
||||
if p_sz != -1 and p_color != -1 and p_sz > p_color:
|
||||
bad += 1 # color 在 sz 之前 = 错误顺序
|
||||
return xml, (len(rprs), bad)
|
||||
|
||||
|
||||
def fix(xlsx, sheet_name="xl/worksheets/sheet1.xml"):
|
||||
xml, stats = analyze(xlsx, sheet_name)
|
||||
if xml is None:
|
||||
return
|
||||
total, bad = stats
|
||||
print(f"rPr 总数 {total},错误顺序 {bad}")
|
||||
if bad == 0:
|
||||
print("无需修复(顺序已合规)。注意:顺序合规 ≠ Excel 不报错,见脚本顶部警告。")
|
||||
return
|
||||
|
||||
def _fix(m):
|
||||
inner = m.group(1)
|
||||
rf = re.search(r"<rFont[^/]*/>", inner)
|
||||
cs = re.search(r"<charset[^/]*/>", inner)
|
||||
fam = re.search(r"<family[^/]*/>", inner)
|
||||
b = re.search(r"<b[^/]*/>", inner)
|
||||
i = re.search(r"<i[^/]*/>", inner)
|
||||
sz = re.search(r"<sz[^/]*/>", inner)
|
||||
co = re.search(r"<color[^/]*/>", inner)
|
||||
# OOXML 顺序: rFont, charset, family, b, i, ..., sz, color
|
||||
parts = [x.group(0) for x in (rf, cs, fam, b, i, sz, co) if x]
|
||||
return "<rPr>" + "".join(parts) + "</rPr>"
|
||||
|
||||
fixed = re.sub(r"<rPr>(.*?)</rPr>", _fix, xml, flags=re.S)
|
||||
tmp = xlsx + ".tmp"
|
||||
with zipfile.ZipFile(xlsx, "r") as zin, zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zo:
|
||||
for it in zin.namelist():
|
||||
zo.writestr(it, fixed.encode("utf-8") if it == sheet_name else zin.read(it))
|
||||
os.replace(tmp, xlsx)
|
||||
print(f"已修复并写回 {xlsx}")
|
||||
print("⚠️ 仍需用 Microsoft Excel 实际打开验证——本脚本不保证消除 Excel 报错。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
path = sys.argv[1]
|
||||
sheet = "xl/worksheets/sheet1.xml"
|
||||
if "--sheet" in sys.argv:
|
||||
sheet = "xl/worksheets/" + sys.argv[sys.argv.index("--sheet") + 1]
|
||||
if "--check" in sys.argv:
|
||||
_, stats = analyze(path, sheet)
|
||||
if stats:
|
||||
print(f"rPr 总数 {stats[0]},错误顺序 {stats[1]}")
|
||||
else:
|
||||
fix(path, sheet)
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
I列类目覆盖检查器 — 建表后跑,报警但不阻断。
|
||||
检查I列是否覆盖了足够多的固定类目,不够就报警提示人工核查。
|
||||
|
||||
用法: python3 i-column-coverage-check.py <xlsx_file>
|
||||
|
||||
规则:
|
||||
租赁合同行:21个固定类目,≥15个不报警,<15个报警提示核查
|
||||
物业合同行:15个固定类目,≥10个不报警,<10个报警提示核查
|
||||
能耗协议/其他:不检查
|
||||
|
||||
报警≠阻断:有些合同确实没这么多类目,报警只是提醒逐一确认"是真没有还是漏了"。
|
||||
"""
|
||||
import sys, re
|
||||
import openpyxl
|
||||
|
||||
LEASE_CATEGORIES = [
|
||||
'用途', '转租', '装修改造', '广告标识', '非竞争', '维修责任', '保险要求',
|
||||
'物业服务联动', '配套设施', '出租方变更', '解除权机制', '违约金机制',
|
||||
'不可抗力', '征收拆迁', '房屋抵押查封', '政策变化', '到期处理',
|
||||
'恢复原状', '优先权', '管辖', '备案'
|
||||
]
|
||||
|
||||
PROPERTY_CATEGORIES = [
|
||||
'物业服务内容', '服务标准', '公共能耗费', '特约服务', '共用设施管理',
|
||||
'装修管理', '安保措施', '消防安全', '保险要求', '联动终止',
|
||||
'违约责任', '退出交接', '免责条款', '不可抗力', '管辖'
|
||||
]
|
||||
|
||||
LEASE_THRESHOLD = 15
|
||||
PROPERTY_THRESHOLD = 10
|
||||
|
||||
|
||||
def check_row(row_num, cell_value, contract_type):
|
||||
"""检查一行I列的类目覆盖情况"""
|
||||
if not cell_value:
|
||||
return None
|
||||
|
||||
text = str(cell_value)
|
||||
|
||||
if contract_type == 'lease':
|
||||
categories = LEASE_CATEGORIES
|
||||
threshold = LEASE_THRESHOLD
|
||||
type_name = '租赁合同'
|
||||
elif contract_type == 'property':
|
||||
categories = PROPERTY_CATEGORIES
|
||||
threshold = PROPERTY_THRESHOLD
|
||||
type_name = '物业合同'
|
||||
else:
|
||||
return None
|
||||
|
||||
found = []
|
||||
missing = []
|
||||
for cat in categories:
|
||||
# 检查类目是否出现在文本中(允许【】或[]包裹)
|
||||
if re.search(rf'[·\-\[\【]{cat}[\]\】]?', text) or cat in text:
|
||||
found.append(cat)
|
||||
else:
|
||||
missing.append(cat)
|
||||
|
||||
coverage = len(found)
|
||||
total = len(categories)
|
||||
|
||||
result = {
|
||||
'row': row_num,
|
||||
'type': type_name,
|
||||
'coverage': coverage,
|
||||
'total': total,
|
||||
'threshold': threshold,
|
||||
'found': found,
|
||||
'missing': missing,
|
||||
'alert': coverage < threshold
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 i-column-coverage-check.py <xlsx_file>")
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
wb = openpyxl.load_workbook(filepath)
|
||||
ws = wb.active
|
||||
|
||||
results = []
|
||||
for row in range(1, ws.max_row + 1):
|
||||
# 判断合同类型(C列)
|
||||
c_val = str(ws.cell(row, 3).value or '').strip()
|
||||
i_val = ws.cell(row, 9).value
|
||||
|
||||
if not i_val or not c_val:
|
||||
continue
|
||||
|
||||
if '租赁' in c_val and '物业' not in c_val:
|
||||
contract_type = 'lease'
|
||||
elif '物业' in c_val:
|
||||
contract_type = 'property'
|
||||
else:
|
||||
continue # 能耗协议等不检查
|
||||
|
||||
result = check_row(row, i_val, contract_type)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
if not results:
|
||||
print("⚠️ 未找到租赁/物业合同行,请确认文件结构。")
|
||||
sys.exit(0)
|
||||
|
||||
all_pass = True
|
||||
for r in results:
|
||||
status = '✅' if not r['alert'] else '⚠️'
|
||||
if r['alert']:
|
||||
all_pass = False
|
||||
print(f"{status} Row {r['row']} [{r['type']}]: {r['coverage']}/{r['total']} 类目"
|
||||
f"(阈值{r['threshold']})")
|
||||
if r['alert']:
|
||||
print(f" 缺失类目: {', '.join(r['missing'])}")
|
||||
print(f" → 请逐一核查:是合同确实没有,还是提取时漏了?")
|
||||
print()
|
||||
|
||||
if all_pass:
|
||||
print(f"\n✅ I列类目覆盖检查通过({len(results)}行全部达标)。")
|
||||
else:
|
||||
alert_count = sum(1 for r in results if r['alert'])
|
||||
print(f"\n⚠️ {alert_count}行I列类目覆盖不足,请核查确认。")
|
||||
print(" 说明:报警≠错误。有些合同确实没这么多类目,核查确认即可。")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
K/L 分工自检 / K-L Column Separation Check
|
||||
==========================================
|
||||
校区梳理表 Step5 终审必跑:验证 K 列(法律风险) 与 L 列(模版差异) 职责分离。
|
||||
|
||||
· K 列(第 11 列)= 独立法律审查,从合同条款本身起笔,**绝不引模版**
|
||||
→ 不得出现 "模版 / 07模版 / 07-房屋 / 07标准 / 相对模版 / 被放宽 / 被删除 / 被改为"
|
||||
· L 列(第 12 列)= 纯客观文本对比,只陈述 "模版表述为X;本合同表述为Y"
|
||||
→ 不得出现 "风险 / 建议 / 不利 / 详见"(判断词与向 K 列导流的话)
|
||||
|
||||
⚠️ K 列匹配 "07模版 / 07-房屋 / 07标准" 而非裸 "07"——裸 07 会误命中金额数字
|
||||
(如租金 377,507.80 里的 "507"),2026-06-24 跃龙路实证误报,已修正。
|
||||
|
||||
判据·遮模版测试:把"模版怎么写"整个遮住,K 列那条风险论述仍完整成立才算独立审查;
|
||||
遮住就垮 = L 列逻辑混进了 K 列。
|
||||
|
||||
用法:
|
||||
python3 kl-separation-check.py <校区梳理表.xlsx>
|
||||
退出码:0 = 通过;1 = 有违规(需回去拆分 K/L);2 = 用法错误
|
||||
|
||||
注:本脚本只查 K(11)/L(12) 两列的数据行。整体段(合并 A:L,在第 1 列)里
|
||||
"【与07标准模版的文本差异】" 小节合法含"模版/07",不在本脚本检查范围——
|
||||
那是整体段按设计单独成段的客观对比,不是 K 列。
|
||||
"""
|
||||
import sys
|
||||
import re
|
||||
import openpyxl
|
||||
|
||||
K_FORBIDDEN_LITERAL = ["模版", "相对模版", "被放宽", "被删除", "被改为"]
|
||||
K_FORBIDDEN_REGEX = r"07模版|07-?房屋|07标准"
|
||||
L_FORBIDDEN_LITERAL = ["风险", "建议", "不利", "详见"]
|
||||
|
||||
|
||||
def cell_text(cell):
|
||||
"""取单元格纯文本,兼容 CellRichText(可迭代) 与标量。"""
|
||||
v = cell.value
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
try:
|
||||
return "".join(getattr(t, "text", str(t)) for t in v)
|
||||
except TypeError:
|
||||
return str(v)
|
||||
|
||||
|
||||
def is_header(text):
|
||||
"""表头行启发式:K 表头='法律风险…'/'风险点…';L 表头='与…模版差异'。"""
|
||||
head = text[:10]
|
||||
if ("法律风险" in head or "风险点" in head) and len(text) < 40:
|
||||
return True
|
||||
if text.startswith("与") and "模版差异" in text and len(text) < 40:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check(path):
|
||||
wb = openpyxl.load_workbook(path)
|
||||
violations = []
|
||||
for ws in wb.worksheets:
|
||||
for r in range(1, ws.max_row + 1):
|
||||
k = cell_text(ws.cell(r, 11))
|
||||
l = cell_text(ws.cell(r, 12))
|
||||
if k and not is_header(k):
|
||||
kb = [w for w in K_FORBIDDEN_LITERAL if w in k]
|
||||
kb += re.findall(K_FORBIDDEN_REGEX, k)
|
||||
if kb:
|
||||
violations.append((ws.title, f"K{r}", "独立审查不得引模版", kb))
|
||||
if l and not is_header(l):
|
||||
lb = [w for w in L_FORBIDDEN_LITERAL if w in l]
|
||||
if lb:
|
||||
violations.append((ws.title, f"L{r}", "纯客观对比不得下判断", lb))
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 kl-separation-check.py <校区梳理表.xlsx>")
|
||||
sys.exit(2)
|
||||
v = check(sys.argv[1])
|
||||
if not v:
|
||||
print("✅ K/L 分工自检通过:K 列零模版引用、L 列零判断词。")
|
||||
sys.exit(0)
|
||||
print("❌ K/L 分工自检发现违规(需回去拆分):")
|
||||
for sheet, cell, why, hits in v:
|
||||
print(f" [{sheet}] {cell}: {why} — 命中 {hits}")
|
||||
print("\n判据·遮模版测试:把'模版怎么写'遮住,K 列风险论述仍完整成立才算独立。")
|
||||
print("L 列只陈述'模版表述为X;本合同表述为Y',判断与建议归 K 列。")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
南通新东方租赁梳理 — Excel Builder v2
|
||||
=====================================
|
||||
从nantong-lease-audit workflow的thread输出中提取结构化数据,
|
||||
生成12列Excel汇总表。K列和L列直接从rule-analyzer和template-diff步骤提取。
|
||||
|
||||
用法:
|
||||
python3 nantong-excel-builder.py <thread-id> <校区名> <输出xlsx路径> [文件名]
|
||||
"""
|
||||
import sys, os, subprocess, json, re
|
||||
|
||||
UWF = "/home/maggie/.hermes/node/bin/uwf"
|
||||
|
||||
def run(cmd):
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return r.stdout
|
||||
|
||||
def get_thread_read(thread_id):
|
||||
"""获取thread的完整markdown输出"""
|
||||
return run([UWF, "thread", "read", thread_id, "--quota", "200000", "--start"])
|
||||
|
||||
def extract_step_output(thread_text, role_name):
|
||||
"""从thread read的markdown中提取某role的<output>内容"""
|
||||
pattern = rf'## Step \d+: {role_name}.*?\n<output>\n(.*?)\n</output>'
|
||||
m = re.search(pattern, thread_text, re.DOTALL)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def extract_frontmatter(output_text):
|
||||
"""从step output中提取YAML frontmatter为dict"""
|
||||
# YAML frontmatter is between the first --- and second ---
|
||||
m = re.search(r'^---\n(.*?)\n---', output_text, re.DOTALL)
|
||||
if not m:
|
||||
# Try without leading ---
|
||||
m = re.search(r'^(.*?)\n---', output_text, re.DOTALL)
|
||||
if not m:
|
||||
return {}, output_text
|
||||
|
||||
fm_text = m.group(1)
|
||||
body = output_text[m.end():].strip()
|
||||
|
||||
result = {}
|
||||
current_key = None
|
||||
current_val = []
|
||||
is_multiline = False
|
||||
|
||||
for line in fm_text.split('\n'):
|
||||
if not line.strip():
|
||||
if is_multiline:
|
||||
current_val.append('')
|
||||
continue
|
||||
|
||||
# Check for new key: value
|
||||
km = re.match(r'^(\w[\w_]*):\s*(.*)', line)
|
||||
if km and not line.startswith(' '):
|
||||
# Save previous key
|
||||
if current_key:
|
||||
result[current_key] = '\n'.join(current_val).strip()
|
||||
current_key = km.group(1)
|
||||
val = km.group(2).strip()
|
||||
if val == '|' or val == '':
|
||||
is_multiline = True
|
||||
current_val = []
|
||||
else:
|
||||
is_multiline = False
|
||||
current_val = [val]
|
||||
elif is_multiline and current_key:
|
||||
current_val.append(line.strip())
|
||||
|
||||
if current_key:
|
||||
result[current_key] = '\n'.join(current_val).strip()
|
||||
|
||||
return result, body
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 4:
|
||||
print("用法: python3 nantong-excel-builder.py <thread-id> <校区名> <输出xlsx路径> [文件名]")
|
||||
sys.exit(1)
|
||||
|
||||
thread_id = sys.argv[1]
|
||||
campus = sys.argv[2]
|
||||
output_path = sys.argv[3]
|
||||
filename = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||
|
||||
# 1. 获取thread完整输出
|
||||
print(f"读取thread {thread_id} ...")
|
||||
thread_text = get_thread_read(thread_id)
|
||||
|
||||
# 2. 解析classifier
|
||||
cls_output = extract_step_output(thread_text, 'classifier')
|
||||
cls_fm, cls_body = extract_frontmatter(cls_output)
|
||||
print(f"Classifier: campus={cls_fm.get('campus','?')}, template={cls_fm.get('template_type','?')}")
|
||||
|
||||
# 3. 解析template-diff → L列
|
||||
td_output = extract_step_output(thread_text, 'template-d')
|
||||
td_fm, td_body = extract_frontmatter(td_output)
|
||||
diff_detail = td_fm.get('diff_detail', td_body)
|
||||
print(f"Template-diff: {td_fm.get('total_diffs', '?')}处差异")
|
||||
|
||||
# 4. 解析rule-analyzer → K列
|
||||
ra_output = extract_step_output(thread_text, 'rule-analy')
|
||||
ra_fm, ra_body = extract_frontmatter(ra_output)
|
||||
risk_detail = ra_fm.get('risk_detail', ra_body)
|
||||
term_analysis = ra_fm.get('termination_analysis', '')
|
||||
if term_analysis:
|
||||
risk_detail += '\n\n' + term_analysis
|
||||
print(f"Rule-analyzer: {ra_fm.get('risk_count', '?')}项风险")
|
||||
|
||||
# 5. 解析data-extractor → B-J列
|
||||
de_output = extract_step_output(thread_text, 'data-extra')
|
||||
de_fm, de_body = extract_frontmatter(de_output)
|
||||
|
||||
# 从data-extractor的output中提取B-J列
|
||||
columns = {}
|
||||
for col_letter in 'BCDEFGHIJ':
|
||||
# Try pattern "B. 文件名称:xxx" or just the value after the column letter
|
||||
pattern = rf'{col_letter}\.\s+[^::]+[::]\s*(.*?)(?=\n[A-L]\.\s|\nh_column|\Z)'
|
||||
m = re.search(pattern, de_body, re.DOTALL)
|
||||
if m:
|
||||
columns[col_letter] = m.group(1).strip()
|
||||
else:
|
||||
columns[col_letter] = ''
|
||||
|
||||
# 文件名
|
||||
if filename:
|
||||
columns['B'] = filename
|
||||
elif not columns.get('B'):
|
||||
columns['B'] = cls_fm.get('ocr_text_path', '').split('/')[-1].replace('.md', '.pdf')
|
||||
|
||||
# K列 = 法律风险(从rule-analyzer提取)
|
||||
columns['K'] = risk_detail[:8000] if risk_detail else '(法律风险分析待补充)'
|
||||
|
||||
# L列 = 模版差异(从template-diff提取)
|
||||
columns['L'] = diff_detail[:5000] if diff_detail else '(模版差异分析待补充)'
|
||||
|
||||
print(f"Columns: B={columns.get('B','?')[:30]} | K={len(columns.get('K',''))}chars | L={len(columns.get('L',''))}chars")
|
||||
|
||||
# 6. 生成Excel
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
F = Font(name='微软雅黑', size=10)
|
||||
FB = Font(name='微软雅黑', size=10, bold=True)
|
||||
TITLE_FONT = Font(name='微软雅黑', size=14, bold=True, color='FFFFFFFF')
|
||||
fill_title = PatternFill(start_color='FF8B1A2B', fill_type='solid')
|
||||
fill_sec = PatternFill(start_color='FFC0504D', fill_type='solid')
|
||||
fill_hdr = PatternFill(start_color='FFE2EFDA', fill_type='solid')
|
||||
fill_sub = PatternFill(start_color='FFF2F2F2', fill_type='solid')
|
||||
thin = Side(style='thin')
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
AL = Alignment(horizontal='left', vertical='top', wrap_text=True)
|
||||
AC = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = campus[:31] # sheet name max 31 chars
|
||||
|
||||
widths = dict(A=5, B=20, C=17, D=27, E=23, F=9, G=21, H=36.33, I=34, J=8, K=50, L=35)
|
||||
for c, w in widths.items():
|
||||
ws.column_dimensions[c].width = w
|
||||
|
||||
def merge_row(r, text, font, fill, h=None, al=AC):
|
||||
ws.merge_cells(f'A{r}:L{r}')
|
||||
cell = ws.cell(r, 1, text); cell.font = font; cell.fill = fill; cell.alignment = al
|
||||
for col in range(1, 13):
|
||||
ws.cell(r, col).fill = fill; ws.cell(r, col).border = border
|
||||
if h: ws.row_dimensions[r].height = h
|
||||
|
||||
r = 1
|
||||
merge_row(r, f'{campus} — 租赁合同梳理', TITLE_FONT, fill_title, 30); r += 1
|
||||
|
||||
proj = (f"物业项目:{cls_fm.get('property_address', '未知')}\n"
|
||||
f"甲方:{cls_fm.get('party_a', '未知')}\n"
|
||||
f"乙方:{cls_fm.get('party_b', '未知')}")
|
||||
merge_row(r, proj, Font(name='微软雅黑', size=10, bold=True, color='FF404040'), fill_sub, 76, AL); r += 1
|
||||
|
||||
HDR = ['序号','文件名称','合同类型','合同当事人','租赁标的/服务范围','面积(㎡)',
|
||||
'合同期限','金额/费用','核心内容','当前状态','法律风险(站乙方立场)','与07标准模版差异']
|
||||
for ci, h in enumerate(HDR, 1):
|
||||
c = ws.cell(r, ci, h); c.font = FB; c.fill = fill_hdr; c.alignment = AC; c.border = border
|
||||
ws.row_dimensions[r].height = 30; r += 1
|
||||
|
||||
# 数据行
|
||||
row = ['1']
|
||||
for col in 'BCDEFGHIJKL':
|
||||
row.append(columns.get(col, ''))
|
||||
|
||||
for ci, v in enumerate(row, 1):
|
||||
c = ws.cell(r, ci, v); c.font = F
|
||||
c.alignment = AC if ci in (1,3,6,10) else AL; c.border = border
|
||||
ws.row_dimensions[r].height = 409.5
|
||||
|
||||
ws.freeze_panes = f'A{r+1}'
|
||||
|
||||
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
|
||||
wb.save(output_path)
|
||||
print(f"\n✅ 已保存: {output_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OCR乱码检测器 — Step1 OCR完成后必跑。
|
||||
扫描OCR文本中所有疑似乱码行,输出vision核实清单。
|
||||
每一处乱码都必须vision对应PDF页面核实后才能进Step2。
|
||||
|
||||
用法: python3 ocr-garble-detect.py <ocr_file.md>
|
||||
|
||||
判定规则:
|
||||
1. 多个无意义英文片段(连续3+字母非常见词)
|
||||
2. 中文占比过低(正常合同行>50%,低于30%标红)
|
||||
3. 中英混杂碎片(典型OCR乱码模式)
|
||||
4. 异常符号密集(>20%非正常字符)
|
||||
|
||||
误报处理:
|
||||
- 纯数字表格行(日期+金额)→ 正常,核对数字即可
|
||||
- 邮箱/网址/银行账号 → 正常
|
||||
- 英文缩写(USD/RMB/PDF)→ 正常
|
||||
真正需要vision的是:含中文碎片+英文乱码的混合行(如"oe方,同Se")
|
||||
|
||||
教训(凤凰文化0701):
|
||||
Line 204 "Se【2024】年【10】月【15】日" 被跳过未核实,
|
||||
导致整段违约金条款丢失(实际是"初年年租金20%作为违约金")。
|
||||
代价=审查结论反转("无违约金"→"有20%违约金")。
|
||||
"""
|
||||
import re, sys
|
||||
|
||||
def is_garbled(line):
|
||||
"""判断一行是否疑似乱码,返回原因或None"""
|
||||
stripped = line.strip()
|
||||
if not stripped or len(stripped) < 5:
|
||||
return None
|
||||
|
||||
# 1. 连续3+个无意义英文字母组合(非常见英文词)
|
||||
nonsense_en = re.findall(r'[a-zA-Z]{3,}', stripped)
|
||||
common_words = {'pdf','ocr','usd','rmb','the','and','for','with','from',
|
||||
'www','com','xdf','jpg','png','doc','docx','xlsx','occ',
|
||||
'vision','step','check','null','true','false'}
|
||||
real_nonsense = [w for w in nonsense_en
|
||||
if w.lower() not in common_words
|
||||
and not re.match(r'^[A-Z]{1,4}$', w)]
|
||||
if len(real_nonsense) >= 2:
|
||||
return f"多个无意义英文片段: {real_nonsense[:3]}"
|
||||
|
||||
# 2. 单行中文字符占比过低(正常合同行中文应>50%)
|
||||
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', stripped))
|
||||
total_chars = len(re.findall(r'\S', stripped))
|
||||
if total_chars > 10 and chinese_chars / total_chars < 0.3:
|
||||
# 排除纯数字表格行(日期+金额)和邮箱/账号行
|
||||
if re.match(r'^[\d\.\-\s\|/,]+$', stripped):
|
||||
return None # 纯数字表格行
|
||||
if '@' in stripped or re.match(r'^[A-Z]{2,4}:', stripped):
|
||||
return None # 邮箱或字段标签
|
||||
return f"中文占比过低({chinese_chars}/{total_chars}={chinese_chars/total_chars:.0%})"
|
||||
|
||||
# 3. 常见OCR乱码模式:小写英文碎片+中文混杂
|
||||
if re.search(r'[a-z]{2,}\s+[a-z]{2,}\s+[a-z]{2,}', stripped) and chinese_chars > 0:
|
||||
return "中英混杂碎片(典型OCR乱码)"
|
||||
|
||||
# 3b. 日期区域英文字母污染(凤凰文化10.2教训:Se【2024】年【10】月→整段丢失)
|
||||
if re.search(r'[A-Za-z]{2,}\s*【\d{4}】', stripped) and chinese_chars > 0:
|
||||
return "日期区域字母污染(高风险:可能整段条款被OCR吃掉)"
|
||||
|
||||
# 4. 特殊符号异常密集
|
||||
special = len(re.findall(r'[^\w\u4e00-\u9fff\s,。、;:""''()【】《》\-\+\.\%\/\|]', stripped))
|
||||
if special > 5 and total_chars > 0 and special / total_chars > 0.2:
|
||||
return f"异常符号密集({special}个)"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 ocr-garble-detect.py <ocr_file.md>")
|
||||
sys.exit(1)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
with open(filepath, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
garbled = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
reason = is_garbled(line)
|
||||
if reason:
|
||||
garbled.append((i, line.strip()[:80], reason))
|
||||
|
||||
if not garbled:
|
||||
print("✅ 未检测到明显乱码行。可以进入Step 2。")
|
||||
else:
|
||||
# 区分真乱码 vs 可能误报(纯数字/邮箱等)
|
||||
real_garble = [g for g in garbled if '无意义英文' in g[2] or '中英混杂' in g[2]]
|
||||
maybe_garble = [g for g in garbled if g not in real_garble]
|
||||
|
||||
print(f"⚠️ 检测到 {len(garbled)} 处疑似乱码(其中 {len(real_garble)} 处高危)\n")
|
||||
|
||||
if real_garble:
|
||||
print("🔴 高危乱码(必须vision核实,不核实不进Step2):")
|
||||
for lineno, text, reason in real_garble:
|
||||
print(f" Line {lineno:3d} | {reason}")
|
||||
print(f" | {text}")
|
||||
print()
|
||||
|
||||
if maybe_garble:
|
||||
print("🟡 疑似乱码(核对数字/格式是否正确):")
|
||||
for lineno, text, reason in maybe_garble:
|
||||
print(f" Line {lineno:3d} | {reason}")
|
||||
print(f" | {text}")
|
||||
print()
|
||||
|
||||
print(f"--- 高危 {len(real_garble)} 处必须vision核实 + 疑似 {len(maybe_garble)} 处核对数值 ---")
|
||||
print("操作:对每处高危乱码,定位PDF页码 → vision精读 → 记录修正内容")
|
||||
print("全部消灭后才能进入 Step 2。")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OCR完整性检查 / OCR Integrity Check
|
||||
==================================
|
||||
物理依赖链第一环:OCR → 建表
|
||||
|
||||
检查OCR后的.md文件是否有未补全的乱码/占位符。
|
||||
如果有,拒绝生成checkpoint,建表脚本将因此中止。
|
||||
|
||||
用法:
|
||||
python3 ocr-integrity-check.py <工作目录>
|
||||
|
||||
检查模式:
|
||||
1. 公司名乱码:连续3+英文字母出现在中文语境中(如"HAT"代替公司名)
|
||||
2. 地址残缺:【…】、〔…〕、"了 B}"等占位符模式
|
||||
3. 关键数字乱码:字母代替数字(如"5 1 te"代替"号1幢")
|
||||
4. OCR标记残留:OCR模糊、OCR无法识别、待补全等
|
||||
|
||||
通过 → 生成 step1.verified checkpoint(含签名)
|
||||
失败 → 列出所有需要vision补全的字段,不生成checkpoint
|
||||
|
||||
退出码:0=通过,1=有问题需补全
|
||||
|
||||
🔴 安全机制:checkpoint文件包含HMAC签名,防止手动创建
|
||||
"""
|
||||
import sys, os, re, json, glob, hmac, hashlib
|
||||
from datetime import datetime
|
||||
|
||||
# checkpoint签名密钥(固定值,脚本内置)
|
||||
CHECKPOINT_SECRET = b'ocr-integrity-check-v1-2026-06-29'
|
||||
|
||||
def generate_checkpoint_signature(workdir, md_files, timestamp):
|
||||
"""生成checkpoint签名"""
|
||||
# 签名内容:工作目录+文件列表+时间戳
|
||||
sign_content = f"{workdir}|{','.join(sorted(md_files))}|{timestamp}"
|
||||
sig = hmac.new(CHECKPOINT_SECRET, sign_content.encode('utf-8'), hashlib.sha256).hexdigest()
|
||||
return sig[:16] # 取前16位
|
||||
|
||||
def check_ocr_file(md_path):
|
||||
"""检查单个.md文件的OCR完整性"""
|
||||
issues = []
|
||||
|
||||
with open(md_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
|
||||
# 模式1:公司名乱码 - 连续3+大写字母或混合大小写英文(中文语境中)
|
||||
# 例如:"HAT CP RRA)" 应该是公司名
|
||||
company_garble = re.finditer(r'[A-Z]{3,}(?:\s+[A-Z]{2,}){0,2}', content)
|
||||
for m in company_garble:
|
||||
# 排除常见的合理英文(如OCR、PDF、API等)
|
||||
if m.group() not in ['OCR', 'PDF', 'API', 'URL', 'HTTP', 'HTTPS', 'JSON', 'XML', 'LOGO', 'EMS', 'WPS',
|
||||
'PAGE', 'REMARK', 'NOTE', 'TODO', 'FIXME', 'HACK', 'XXX', 'EOF']:
|
||||
# 排除统一社会信用代码中的字母部分(前后有数字的字母序列)
|
||||
pre = content[max(0, m.start()-2):m.start()]
|
||||
post = content[m.end():min(len(content), m.end()+2)]
|
||||
if (pre and pre[-1:].isdigit()) or (post and post[:1].isdigit()):
|
||||
continue # 信用代码中的字母,跳过
|
||||
line_num = content[:m.start()].count('\n') + 1
|
||||
context_start = max(0, m.start() - 20)
|
||||
context_end = min(len(content), m.end() + 20)
|
||||
context = content[context_start:context_end].replace('\n', ' ')
|
||||
issues.append({
|
||||
'type': '公司名乱码',
|
||||
'line': line_num,
|
||||
'pattern': m.group(),
|
||||
'context': f"...{context}...",
|
||||
'fix': '用vision看原图,补全公司全称'
|
||||
})
|
||||
|
||||
# 模式2:地址残缺占位符
|
||||
placeholder_patterns = [
|
||||
(r'【\.{1,5}】', '中文省略号占位符'),
|
||||
(r'【…】', '中文省略号占位符'),
|
||||
(r'〔\.{1,5}〕', '方括号省略号占位符'),
|
||||
(r'了\s*[A-Za-z0-9]}', '地址残缺模式(如"了 B}")'),
|
||||
]
|
||||
for pat, desc in placeholder_patterns:
|
||||
for m in re.finditer(pat, content):
|
||||
line_num = content[:m.start()].count('\n') + 1
|
||||
context_start = max(0, m.start() - 20)
|
||||
context_end = min(len(content), m.end() + 20)
|
||||
context = content[context_start:context_end].replace('\n', ' ')
|
||||
issues.append({
|
||||
'type': desc,
|
||||
'line': line_num,
|
||||
'pattern': m.group(),
|
||||
'context': f"...{context}...",
|
||||
'fix': '用vision看原图,补全完整地址'
|
||||
})
|
||||
|
||||
# 模式3:OCR标记残留
|
||||
ocr_markers = [
|
||||
r'OCR模糊',
|
||||
r'OCR无法识别',
|
||||
r'OCR乱码',
|
||||
r'OCR识别失败',
|
||||
r'待补全',
|
||||
r'〔待核PDF〕',
|
||||
r'〔待核〕',
|
||||
]
|
||||
for marker in ocr_markers:
|
||||
for m in re.finditer(marker, content):
|
||||
line_num = content[:m.start()].count('\n') + 1
|
||||
context_start = max(0, m.start() - 15)
|
||||
context_end = min(len(content), m.end() + 15)
|
||||
context = content[context_start:context_end].replace('\n', ' ')
|
||||
issues.append({
|
||||
'type': 'OCR标记残留',
|
||||
'line': line_num,
|
||||
'pattern': m.group(),
|
||||
'context': f"...{context}...",
|
||||
'fix': '用vision看原图补全,删除标记'
|
||||
})
|
||||
|
||||
# 模式4:关键字段中的字母代替数字
|
||||
# 例如:"5 1 te 202" 应该是 "号1幢202"
|
||||
# 检测:中文+空格+单个字母+空格+数字 的模式
|
||||
digit_garble = re.finditer(r'[\u4e00-\u9fff]\s+[a-zA-Z]\s+\d{2,4}', content)
|
||||
for m in digit_garble:
|
||||
line_num = content[:m.start()].count('\n') + 1
|
||||
context_start = max(0, m.start() - 10)
|
||||
context_end = min(len(content), m.end() + 10)
|
||||
context = content[context_start:context_end].replace('\n', ' ')
|
||||
issues.append({
|
||||
'type': '数字乱码(字母代替)',
|
||||
'line': line_num,
|
||||
'pattern': m.group(),
|
||||
'context': f"...{context}...",
|
||||
'fix': '用vision看原图,确认正确数字'
|
||||
})
|
||||
|
||||
# 模式5:严重乱码段落(连续3+英文无义词,如"peMet iy ecsapae"、"Fak"开头)
|
||||
# 表明该区域OCR完全失败,不可用于模版比对
|
||||
garble_patterns = re.finditer(r'(?:[a-zA-Z]{3,}\s+){2,}[a-zA-Z]{2,}', content)
|
||||
for m in garble_patterns:
|
||||
# 排除已知英文短语
|
||||
text = m.group().strip()
|
||||
if any(kw in text.lower() for kw in ['page', 'total', 'remark', 'note']):
|
||||
continue
|
||||
line_num = content[:m.start()].count('\n') + 1
|
||||
context_start = max(0, m.start() - 15)
|
||||
context_end = min(len(content), m.end() + 15)
|
||||
context = content[context_start:context_end].replace('\n', ' ')
|
||||
issues.append({
|
||||
'type': '严重乱码段落(OCR完全失败)',
|
||||
'line': line_num,
|
||||
'pattern': text[:40],
|
||||
'context': f"...{context[:60]}...",
|
||||
'fix': '该区域OCR不可读,必须vision核实原文后才能写入比对报告'
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 ocr-integrity-check.py <工作目录>")
|
||||
print("例: python3 ocr-integrity-check.py /tmp/人民中路")
|
||||
sys.exit(1)
|
||||
|
||||
workdir = sys.argv[1]
|
||||
if not os.path.isdir(workdir):
|
||||
print(f"❌ 工作目录不存在: {workdir}")
|
||||
sys.exit(1)
|
||||
|
||||
# 查找所有OCR产出的.md文件
|
||||
md_files = glob.glob(os.path.join(workdir, '*.md'))
|
||||
md_files += glob.glob(os.path.join(workdir, 'md', '*.md'))
|
||||
md_files += glob.glob(os.path.join(workdir, 'ocr', '*.md'))
|
||||
|
||||
if not md_files:
|
||||
print(f"❌ 未找到OCR产出的.md文件: {workdir}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"检查 {len(md_files)} 个OCR文件的完整性...\n")
|
||||
|
||||
all_issues = []
|
||||
for md_file in md_files:
|
||||
fname = os.path.basename(md_file)
|
||||
issues = check_ocr_file(md_file)
|
||||
if issues:
|
||||
all_issues.append((fname, issues))
|
||||
|
||||
checkpoint_path = os.path.join(workdir, 'step1.verified')
|
||||
|
||||
if all_issues:
|
||||
print("❌ OCR完整性检查失败\n")
|
||||
for fname, issues in all_issues:
|
||||
print(f" {fname}: {len(issues)} 个问题")
|
||||
for i, issue in enumerate(issues[:5], 1): # 只显示前5个
|
||||
print(f" {i}. [{issue['type']}] 第{issue['line']}行")
|
||||
print(f" {issue['context']}")
|
||||
print(f" → {issue['fix']}")
|
||||
if len(issues) > 5:
|
||||
print(f" ... 还有 {len(issues) - 5} 个问题")
|
||||
print(f"\n⛔ 未生成checkpoint: {checkpoint_path}")
|
||||
print(" 建表脚本将拒绝执行,直到所有问题用vision补全。")
|
||||
|
||||
# 写入问题清单供后续处理
|
||||
issues_log = os.path.join(workdir, 'step1.issues.json')
|
||||
with open(issues_log, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'issues': {fname: issues for fname, issues in all_issues}
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
print(f" 问题清单已保存: {issues_log}")
|
||||
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ OCR完整性检查通过\n")
|
||||
for md_file in md_files:
|
||||
print(f" ✓ {os.path.basename(md_file)}")
|
||||
|
||||
# 生成checkpoint(含签名)
|
||||
timestamp = datetime.now().isoformat()
|
||||
signature = generate_checkpoint_signature(workdir, md_files, timestamp)
|
||||
|
||||
with open(checkpoint_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"OCR完整性检查通过\n")
|
||||
f.write(f"时间: {timestamp}\n")
|
||||
f.write(f"文件数: {len(md_files)}\n")
|
||||
for md_file in md_files:
|
||||
f.write(f" - {os.path.basename(md_file)}\n")
|
||||
f.write(f"签名: {signature}\n")
|
||||
|
||||
print(f"\n✓ checkpoint已生成: {checkpoint_path}")
|
||||
print(f" 签名: {signature}")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
output-style-check.py — 检查data-extractor输出的H/K/L列是否符合统一风格规范。
|
||||
用法:python3 output-style-check.py <campus>-row-data.json
|
||||
返回:exit 0 = 通过,exit 1 = 风格不符(列出具体问题)
|
||||
|
||||
This is Layer 3 of the output consistency defense (see references/output-consistency.md).
|
||||
Run AFTER data extraction, BEFORE xlsx generation. If it fails, fix the specific
|
||||
issues in the JSON before proceeding.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
def check_h_column(text):
|
||||
"""检查H列(金额/费用)格式"""
|
||||
issues = []
|
||||
|
||||
# 禁止bullet符号
|
||||
if re.search(r'[•\-\*]\s', text):
|
||||
issues.append("H列含bullet符号(•/-/*),应使用段落式叙述")
|
||||
|
||||
# 禁止"大类·条款号"合并标题
|
||||
if re.search(r'【[^】]*·第[一二三四五六七八九十\d]+条', text):
|
||||
issues.append("H列含'【大类·条款号】'合并标题,应分开写")
|
||||
|
||||
# 必须有【】大类标注
|
||||
if '【' not in text:
|
||||
issues.append("H列缺少【】大类标注(如【租金】【付款推算】等)")
|
||||
|
||||
return issues
|
||||
|
||||
def check_k_column(text):
|
||||
"""检查K列(法律风险)格式"""
|
||||
issues = []
|
||||
|
||||
# 禁止统计式开头
|
||||
if re.search(r'\d+项风险(\d+高', text):
|
||||
issues.append("K列用统计式开头(如'10项风险(3高/5中/2低)'),应以【整体评价】开头")
|
||||
|
||||
# 禁止"序号·等级·条款号"标签
|
||||
if re.search(r'\d+\.\s*【[高中低][中高]?·', text):
|
||||
issues.append("K列用'序号·等级·条款号'标签格式(如'1.【高·第十条】'),应用叙述式")
|
||||
|
||||
# 禁止markdown表格
|
||||
if '| #' in text or '|---|' in text:
|
||||
issues.append("K列含markdown表格,应用叙述式段落")
|
||||
|
||||
# 必须有【整体评价】
|
||||
if '【整体评价' not in text:
|
||||
issues.append("K列缺少【整体评价】段落")
|
||||
|
||||
return issues
|
||||
|
||||
def check_l_column(text):
|
||||
"""检查L列(模版差异)格式"""
|
||||
issues = []
|
||||
|
||||
# 禁止统计式开头
|
||||
if re.search(r'\d+处差异(\d+缺失', text):
|
||||
issues.append("L列用统计式开头(如'34处差异(16缺失/15修改/3新增)'),应叙述式说明")
|
||||
|
||||
# 禁止分类小标题
|
||||
if '【核心缺失' in text or '【核心修改' in text:
|
||||
issues.append("L列用'【核心缺失/修改】'分类小标题,应使用编号列表")
|
||||
|
||||
# 禁止"vs"分隔
|
||||
if ' vs ' in text:
|
||||
issues.append("L列用'vs'分隔模版和合同,应用中文叙述(如'模版为XX;本合同为XX')")
|
||||
|
||||
return issues
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 output-style-check.py <campus>-row-data.json")
|
||||
sys.exit(2)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"❌ 无法读取文件: {e}")
|
||||
sys.exit(2)
|
||||
|
||||
all_issues = []
|
||||
|
||||
# Check H column (fees)
|
||||
if 'fees' in data and data['fees']:
|
||||
h_issues = check_h_column(data['fees'])
|
||||
all_issues.extend(h_issues)
|
||||
|
||||
# Check K column (risk_detail)
|
||||
if 'risk_detail' in data and data['risk_detail']:
|
||||
k_issues = check_k_column(data['risk_detail'])
|
||||
all_issues.extend(k_issues)
|
||||
|
||||
# Check L column (diff_detail)
|
||||
if 'diff_detail' in data and data['diff_detail']:
|
||||
l_issues = check_l_column(data['diff_detail'])
|
||||
all_issues.extend(l_issues)
|
||||
|
||||
# Check J column (status) - should be just "履行中", no extra text
|
||||
if 'status' in data and data['status']:
|
||||
status = data['status'].strip()
|
||||
if status not in ['履行中', '已到期', '已解除']:
|
||||
all_issues.append(f"J列状态值不规范: '{status}',应为'履行中'/'已到期'/'已解除'(不加括号说明)")
|
||||
|
||||
if all_issues:
|
||||
print(f"❌ 风格检查未通过({len(all_issues)}个问题):")
|
||||
for i, issue in enumerate(all_issues, 1):
|
||||
print(f" {i}. {issue}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("✅ 风格检查通过:H/K/L/J列格式符合统一规范")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
模版比对验证 / Template Diff Verify
|
||||
===================================
|
||||
物理依赖链第二环:动作B → 建表
|
||||
|
||||
检查subagent的模版比对输出是否存在且内容充实。
|
||||
如果有问题,拒绝生成checkpoint,建表脚本将因此中止。
|
||||
|
||||
用法:
|
||||
python3 template-diff-verify.py <工作目录>
|
||||
|
||||
检查项:
|
||||
1. 工作目录是否有 模版比对-*.md 文件
|
||||
2. 文件大小是否 > 2000 字节(排除空文件或极简概括)
|
||||
3. 是否包含条款号(如"第X条"、"X.X条")
|
||||
4. 是否包含差异描述(如"模版表述为"、"本合同表述为")
|
||||
|
||||
通过 → 生成 step2b.verified checkpoint
|
||||
失败 → 拒绝生成checkpoint
|
||||
|
||||
退出码:0=通过,1=有问题
|
||||
"""
|
||||
import sys, os, re, glob
|
||||
from datetime import datetime
|
||||
|
||||
def check_template_diff(workdir):
|
||||
"""检查模版比对输出文件"""
|
||||
issues = []
|
||||
|
||||
# 查找模版比对输出文件
|
||||
diff_files = glob.glob(os.path.join(workdir, '模版比对-*.md'))
|
||||
diff_files += glob.glob(os.path.join(workdir, 'template-diff-*.md'))
|
||||
|
||||
if not diff_files:
|
||||
issues.append({
|
||||
'type': '文件缺失',
|
||||
'detail': f'工作目录 {workdir} 未找到模版比对输出文件(模版比对-*.md 或 template-diff-*.md)',
|
||||
'fix': 'Step2 动作B 必须 delegate_task subagent 做模版比对'
|
||||
})
|
||||
return issues
|
||||
|
||||
for diff_file in diff_files:
|
||||
fname = os.path.basename(diff_file)
|
||||
size = os.path.getsize(diff_file)
|
||||
|
||||
# 检查1:文件大小
|
||||
if size < 2000:
|
||||
issues.append({
|
||||
'type': '内容过短',
|
||||
'detail': f'{fname} 仅 {size} 字节,疑似极简概括而非逐条比对',
|
||||
'fix': 'subagent 应产出详细的逐条比对报告,不是"甲方制式格式,与07模版不同"一句话'
|
||||
})
|
||||
continue
|
||||
|
||||
# 读取文件内容
|
||||
with open(diff_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 检查2:是否包含条款号
|
||||
clause_pattern = r'第[一二三四五六七八九十\d]+条|[一二三四五六七八九十\d]+\.\d+'
|
||||
clause_matches = re.findall(clause_pattern, content)
|
||||
if len(clause_matches) < 10:
|
||||
issues.append({
|
||||
'type': '条款号不足',
|
||||
'detail': f'{fname} 仅找到 {len(clause_matches)} 个条款号引用,疑似未逐条比对',
|
||||
'fix': '模版比对必须回 07 原件逐条核对,不能概括性描述'
|
||||
})
|
||||
|
||||
# 检查3:是否包含差异描述关键词
|
||||
diff_keywords = [
|
||||
r'模版表述[为::]',
|
||||
r'本合同表述[为::]',
|
||||
r'模版.*本合同',
|
||||
r'差异',
|
||||
r'缺失',
|
||||
r'新增',
|
||||
r'修改为',
|
||||
r'变更为',
|
||||
r'无此条款',
|
||||
r'不存在',
|
||||
]
|
||||
keyword_count = sum(1 for kw in diff_keywords if re.search(kw, content))
|
||||
if keyword_count < 3:
|
||||
issues.append({
|
||||
'type': '差异描述不足',
|
||||
'detail': f'{fname} 差异描述关键词仅 {keyword_count} 个,疑似未详细比对',
|
||||
'fix': '比对报告应包含"模版表述为X;本合同表述为Y"的具体差异描述'
|
||||
})
|
||||
|
||||
# 检查4:行数(逐条比对应该有足够行数)
|
||||
lines = content.split('\n')
|
||||
if len(lines) < 30:
|
||||
issues.append({
|
||||
'type': '行数不足',
|
||||
'detail': f'{fname} 仅 {len(lines)} 行,疑似未逐条展开',
|
||||
'fix': '逐条比对报告应有足够行数覆盖所有条款差异'
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python3 template-diff-verify.py <工作目录>")
|
||||
print("例: python3 template-diff-verify.py /tmp/人民中路")
|
||||
sys.exit(1)
|
||||
|
||||
workdir = sys.argv[1]
|
||||
if not os.path.isdir(workdir):
|
||||
print(f"❌ 工作目录不存在: {workdir}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"检查模版比对输出...\n")
|
||||
|
||||
issues = check_template_diff(workdir)
|
||||
checkpoint_path = os.path.join(workdir, 'step2b.verified')
|
||||
|
||||
if issues:
|
||||
print("❌ 模版比对验证失败\n")
|
||||
for i, issue in enumerate(issues, 1):
|
||||
print(f" {i}. [{issue['type']}]")
|
||||
print(f" {issue['detail']}")
|
||||
print(f" → {issue['fix']}\n")
|
||||
|
||||
print(f"⛔ 未生成checkpoint: {checkpoint_path}")
|
||||
print(" 建表脚本将拒绝执行,直到模版比对完成。")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 找到通过的文件
|
||||
diff_files = glob.glob(os.path.join(workdir, '模版比对-*.md'))
|
||||
diff_files += glob.glob(os.path.join(workdir, 'template-diff-*.md'))
|
||||
|
||||
print("✅ 模版比对验证通过\n")
|
||||
for df in diff_files:
|
||||
fname = os.path.basename(df)
|
||||
size = os.path.getsize(df)
|
||||
print(f" ✓ {fname} ({size:,} 字节)")
|
||||
|
||||
# 生成checkpoint
|
||||
with open(checkpoint_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"模版比对验证通过\n")
|
||||
f.write(f"时间: {datetime.now().isoformat()}\n")
|
||||
f.write(f"文件数: {len(diff_files)}\n")
|
||||
for df in diff_files:
|
||||
fname = os.path.basename(df)
|
||||
size = os.path.getsize(df)
|
||||
f.write(f" - {fname} ({size} 字节)\n")
|
||||
|
||||
print(f"\n✓ checkpoint已生成: {checkpoint_path}")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""只读分析 xlsx 每个 sheet 的长内容单元格:按合并宽度+字号估算所需视觉行数与建议行高,
|
||||
与当前行高对比,标出可能截断的行。不修改文件。
|
||||
|
||||
用法: python3 xlsx-rowheight-analyze.py <文件.xlsx> [最小字符阈值,默认80]
|
||||
|
||||
背景见 references/onlyoffice-xlsx-rowheight-rendering.md:
|
||||
- 409.5/409.6pt 是 OnlyOffice 网页编辑器的 clamp 值,不是 xlsx 格式天花板
|
||||
- openpyxl 从文件层可写 >409.5 且 x2t 引擎不 clamp
|
||||
经验系数:中文每字≈2.1 宽度单位(西文≈1.05),每视觉行≈15.5pt(10pt字)。
|
||||
"""
|
||||
import sys, math
|
||||
import openpyxl
|
||||
from openpyxl.utils import get_column_letter, range_boundaries
|
||||
|
||||
DEFAULT_WIDTH = 8.43
|
||||
|
||||
def col_width(ws, col_letter):
|
||||
dim = ws.column_dimensions.get(col_letter)
|
||||
return dim.width if (dim and dim.width) else DEFAULT_WIDTH
|
||||
|
||||
def merged_info(ws, coord):
|
||||
for m in ws.merged_cells.ranges:
|
||||
if coord in m:
|
||||
c0, r0, c1, r1 = range_boundaries(str(m))
|
||||
total = sum(col_width(ws, get_column_letter(c)) for c in range(c0, c1 + 1))
|
||||
return total, str(m)
|
||||
col = ''.join(filter(str.isalpha, coord))
|
||||
return col_width(ws, col), None
|
||||
|
||||
def estimate_height(text, total_width, font_sz):
|
||||
cap = max(total_width, 1)
|
||||
visual_rows = 0
|
||||
for line in text.split("\n"):
|
||||
w = sum((2.1 if ord(ch) > 0x2000 else 1.05) for ch in line)
|
||||
visual_rows += max(1, math.ceil(w / cap))
|
||||
per_row = 15.5 if (font_sz or 11) <= 11 else (font_sz * 1.4)
|
||||
return visual_rows, math.ceil(visual_rows * per_row + 8)
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); sys.exit(1)
|
||||
path = sys.argv[1]
|
||||
threshold = int(sys.argv[2]) if len(sys.argv) > 2 else 80
|
||||
wb = openpyxl.load_workbook(path)
|
||||
print(f"FILE: {path}\nSHEETS: {wb.sheetnames}\n")
|
||||
for ws in wb.worksheets:
|
||||
heights = {r: round(d.height, 1) for r, d in ws.row_dimensions.items() if d.height}
|
||||
flagged = []
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value and isinstance(cell.value, str) and len(cell.value) > threshold:
|
||||
tw, mrange = merged_info(ws, cell.coordinate)
|
||||
fsz = cell.font.sz or 11
|
||||
vr, sug = estimate_height(cell.value, tw, fsz)
|
||||
cur = heights.get(cell.row)
|
||||
short = cur is not None and cur < sug
|
||||
flagged.append((cell.coordinate, mrange, len(cell.value),
|
||||
cell.value.count(chr(10)) + 1, round(tw, 1), vr, sug, cur, short))
|
||||
if not flagged:
|
||||
continue
|
||||
print(f"===== {ws.title} (max_row={ws.max_row}) =====")
|
||||
for co, mr, ln, ll, tw, vr, sug, cur, short in flagged:
|
||||
mark = " ⚠️可能截断" if short else ""
|
||||
print(f" {co} merge={mr} chars={ln} lines={ll} w={tw} "
|
||||
f"=> est_rows={vr} SUGGEST={sug}pt current={cur}{mark}")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user