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,316 @@
#!/usr/bin/env python3
"""
contract_preprocess.py — 合同预处理:检测并切割非审查图片内容
用途:在workflow审查前,检测合同末尾的纯图片附件(如招标公告截图、中标通知书等),
切割出来保存,审查完后再还原。
判断逻辑:
1. 扫描文件结构:文字段落数 vs 图片段落数
2. 全文/大部分是图片(扫描件合同)→ 不切割,标记需OCR
3. 正文文字+末尾图片附件 → 切割末尾图片区域
4. 切割点:从最后一个"纯文字附件"结束后,到第一个"纯图片附件"开始
输出:
- {basename}_stripped.docx — 去掉图片附件的版本(供workflow处理)
- {basename}_cutdata.json — 切割信息(供还原用)
"""
import zipfile, json, os, sys, re
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
R_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
A_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
def analyze_contract(docx_path):
"""Analyze contract structure, return analysis dict"""
with zipfile.ZipFile(docx_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
media_files = {n: z.getinfo(n).file_size for n in z.namelist() if n.startswith('word/media/')}
body = doc.find(f'{{{W}}}body')
paras = body.findall(f'{{{W}}}p')
paragraphs = []
total_text_chars = 0
total_img_paras = 0
for i, p in enumerate(paras):
texts = p.findall(f'.//{{{W}}}t')
text = ''.join(t.text or '' for t in texts).strip()
has_img = any('drawing' in (e.tag if isinstance(e.tag, str) else '') for e in p.iter())
blips = list(p.iter(f'{{{A_NS}}}blip'))
img_rids = [b.get(f'{{{R_NS}}}embed', '') for b in blips]
total_text_chars += len(text)
if has_img:
total_img_paras += 1
paragraphs.append({
'idx': i,
'text': text,
'text_len': len(text),
'has_img': has_img,
'img_rids': img_rids,
'is_appendix_heading': bool(re.match(r'^附件[一二三四五六七八九十\d]+[::、]', text)),
})
return {
'total_paras': len(paras),
'total_text_chars': total_text_chars,
'total_img_paras': total_img_paras,
'media_files': media_files,
'total_media_bytes': sum(media_files.values()),
'paragraphs': paragraphs,
}
def detect_cut_zone(analysis):
"""Detect if there's a tail image zone to cut."""
paras = analysis['paragraphs']
total = analysis['total_paras']
text_paras = sum(1 for p in paras if p['text_len'] > 0 and not p['has_img'])
img_paras = analysis['total_img_paras']
if text_paras == 0 and img_paras > 0:
return {'action': 'ocr', 'reason': '全文无文字段落,疑似扫描件合同'}
if img_paras == 0:
return None
img_ratio = img_paras / max(1, text_paras + img_paras)
if img_ratio > 0.5:
return {'action': 'ocr', 'reason': f'图片段落占比{img_ratio:.0%},疑似扫描件合同'}
# Find tail image zones
image_zones = []
i = 0
while i < total:
p = paras[i]
if p['is_appendix_heading']:
zone_start = i
zone_has_images = False
zone_has_text_content = False
j = i + 1
while j < total:
next_p = paras[j]
if next_p['is_appendix_heading']:
break
if next_p['has_img']:
zone_has_images = True
if next_p['text_len'] > 20 and not next_p['has_img']:
zone_has_text_content = True
j += 1
image_zones.append({
'start_idx': zone_start,
'end_idx': j - 1,
'heading': p['text'],
'has_images': zone_has_images,
'has_text': zone_has_text_content,
'is_image_only': zone_has_images and not zone_has_text_content,
})
i = j
else:
i += 1
# Find consecutive image-only appendices at the tail
tail_cut_zones = []
for zone in reversed(image_zones):
if zone['is_image_only']:
tail_cut_zones.insert(0, zone)
else:
break
if not tail_cut_zones:
return None
cut_start = tail_cut_zones[0]['start_idx']
cut_headings = [z['heading'] for z in tail_cut_zones]
return {
'action': 'cut',
'cut_start_idx': cut_start,
'cut_end_idx': total - 1,
'cut_headings': cut_headings,
'reason': f'末尾{len(tail_cut_zones)}个附件为纯图片:{", ".join(cut_headings)}',
}
def preprocess_contract(docx_path, output_dir=None):
"""Main entry: analyze and optionally strip tail images."""
if output_dir is None:
output_dir = os.path.dirname(docx_path) or '.'
basename = os.path.splitext(os.path.basename(docx_path))[0]
analysis = analyze_contract(docx_path)
cut_info = detect_cut_zone(analysis)
print(f"\n=== 合同预处理分析 ===")
print(f"文件: {os.path.basename(docx_path)}")
print(f"段落数: {analysis['total_paras']}")
print(f"文字字符: {analysis['total_text_chars']}")
print(f"图片段落: {analysis['total_img_paras']}")
print(f"媒体文件: {len(analysis['media_files'])} ({analysis['total_media_bytes']:,} bytes)")
if cut_info is None:
print(f"结论: 无需切割")
return {'action': 'none', 'analysis': analysis}
if cut_info['action'] == 'ocr':
print(f"结论: {cut_info['reason']},需OCR处理")
return {'action': 'ocr', 'reason': cut_info['reason'], 'analysis': analysis}
cut_start = cut_info['cut_start_idx']
print(f"结论: 需切割 — {cut_info['reason']}")
print(f"切割点: 段落 #{cut_start}")
with zipfile.ZipFile(docx_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
all_files = {}
for name in z.namelist():
all_files[name] = z.read(name)
body = doc.find(f'{{{W}}}body')
paras = body.findall(f'{{{W}}}p')
cut_paras_xml = []
for i in range(cut_start, len(paras)):
cut_paras_xml.append(etree.tostring(paras[i], encoding='unicode'))
for i in range(len(paras) - 1, cut_start - 1, -1):
body.remove(paras[i])
cut_rids = set()
for p_info in analysis['paragraphs'][cut_start:]:
cut_rids.update(p_info['img_rids'])
rels_xml = all_files.get('word/_rels/document.xml.rels', b'')
if isinstance(rels_xml, bytes):
rels_xml = rels_xml.decode()
rid_to_media = {}
for m in re.finditer(r'Id="(rId\d+)"[^/]*Target="(media/[^"]+)"', rels_xml):
rid_to_media[m.group(1)] = f'word/{m.group(2)}'
cut_media = {}
for rid in cut_rids:
media_path = rid_to_media.get(rid)
if media_path and media_path in all_files:
cut_media[media_path] = len(all_files[media_path])
stripped_path = os.path.join(output_dir, f'{basename}_stripped.docx')
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(stripped_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_files.items():
zout.writestr(name, data)
cutdata = {
'original_file': os.path.basename(docx_path),
'cut_start_idx': cut_start,
'total_paras_original': len(paras) + len(cut_paras_xml),
'cut_paragraphs_xml': cut_paras_xml,
'cut_headings': cut_info['cut_headings'],
'cut_media_files': list(cut_media.keys()),
'reason': cut_info['reason'],
}
cutdata_path = os.path.join(output_dir, f'{basename}_cutdata.json')
with open(cutdata_path, 'w', encoding='utf-8') as f:
json.dump(cutdata, f, ensure_ascii=False, indent=2)
stripped_size = os.path.getsize(stripped_path)
original_size = os.path.getsize(docx_path)
print(f"\n输出:")
print(f" stripped: {stripped_path} ({stripped_size:,} bytes)")
print(f" cutdata: {cutdata_path}")
print(f" 大小变化: {original_size:,}{stripped_size:,} bytes ({stripped_size/original_size:.0%})")
return {
'action': 'cut',
'stripped_path': stripped_path,
'cutdata_path': cutdata_path,
'cut_info': cut_info,
'analysis': analysis,
}
def restore_contract(reviewed_path, cutdata_path, output_path):
"""Restore cut content back into the reviewed file."""
with open(cutdata_path, 'r', encoding='utf-8') as f:
cutdata = json.load(f)
with zipfile.ZipFile(reviewed_path) as z:
doc = etree.fromstring(z.read('word/document.xml'))
all_files = {}
for name in z.namelist():
all_files[name] = z.read(name)
body = doc.find(f'{{{W}}}body')
sect_pr = body.find(f'{{{W}}}sectPr')
for para_xml in cutdata['cut_paragraphs_xml']:
para_elem = etree.fromstring(para_xml)
if sect_pr is not None:
sect_pr.addprevious(para_elem)
else:
body.append(para_elem)
original_dir = os.path.dirname(cutdata_path)
original_name = cutdata['original_file']
original_path = os.path.join(original_dir, original_name)
if os.path.exists(original_path):
with zipfile.ZipFile(original_path) as z_orig:
for media_file in cutdata.get('cut_media_files', []):
if media_file not in all_files and media_file in z_orig.namelist():
all_files[media_file] = z_orig.read(media_file)
print(f" 还原媒体文件: {media_file}")
all_files['word/document.xml'] = etree.tostring(doc, xml_declaration=True, encoding='UTF-8', standalone=True)
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, data in all_files.items():
zout.writestr(name, data)
restored_size = os.path.getsize(output_path)
print(f"\n=== 合同还原完成 ===")
print(f"还原文件: {output_path} ({restored_size:,} bytes)")
print(f"还原段落: {len(cutdata['cut_paragraphs_xml'])}")
print(f"还原附件: {', '.join(cutdata['cut_headings'])}")
return output_path
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage:")
print(" 预处理: python contract_preprocess.py preprocess <input.docx> [output_dir]")
print(" 还原: python contract_preprocess.py restore <reviewed.docx> <cutdata.json> <output.docx>")
sys.exit(1)
action = sys.argv[1]
if action == 'preprocess':
docx_path = sys.argv[2]
output_dir = sys.argv[3] if len(sys.argv) > 3 else None
result = preprocess_contract(docx_path, output_dir)
print(f"\nResult: {json.dumps({k: v for k, v in result.items() if k != 'analysis'}, ensure_ascii=False, indent=2)}")
elif action == 'restore':
reviewed_path = sys.argv[2]
cutdata_path = sys.argv[3]
output_path = sys.argv[4]
restore_contract(reviewed_path, cutdata_path, output_path)
else:
print(f"Unknown action: {action}")
sys.exit(1)