feat: export core Hermes skills

This commit is contained in:
2026-07-15 02:45:56 +00:00
parent a028b63eda
commit 54711fee2a
308 changed files with 41310 additions and 1 deletions
@@ -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()