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,117 @@
## 批注注入(comments.xml 机制)
ContractEditor 库**不支持批注**。批注需要直接操作 docx 的 OOXML 批注三件套。验证可用(2026-06-17 培训合同实战)。
### 批注的三个组成部分
1. **word/comments.xml**:批注内容本体(每条 `<w:comment>`,含 id/author/date/initials)。
2. **word/document.xml**:在被批注的段落里插入锚点:
- `<w:commentRangeStart w:id="X"/>` —— 放在段落第一个 run/ins 之前
- `<w:commentRangeEnd w:id="X"/>` —— 放在段落末尾
- 一个含 `<w:commentReference w:id="X"/>` 的 run —— 放在 rangeEnd 之后
3. **关系声明**
- `[Content_Types].xml` 加 Override(comments.xml 的 ContentType)
- `word/_rels/document.xml.rels` 加 Relationship(指向 comments.xml)
### 可复用代码(在 ContractEditor 修订并 save 之后,对成品 docx 注入批注)
```python
import zipfile, io
from lxml import etree
W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
Wq = '{' + W + '}'
# 批注清单:anchor 是"接受修订后能精确匹配(唯一)"的段落片段
comments = [
{"id": "201", "anchor": "甲方扣除相应服务费后", "text": "建议……"},
{"id": "202", "anchor": "向甲方住所地人民法院提起诉讼", "text": "建议……"},
]
DATE = "2026-06-17T10:00:00Z" # ISO格式
def build_comments_xml(comments):
parts = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>']
parts.append(f'<w:comments xmlns:w="{W}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">')
for c in comments:
parts.append(f'<w:comment w:id="{c["id"]}" w:author="WB" w:date="{DATE}" w:initials="WB">')
# 批注文字字号建议比正文小(sz=18=9pt),字体跟随文档(宋体+hint=eastAsia)
parts.append('<w:p><w:r><w:rPr><w:rFonts w:ascii="宋体" w:hAnsi="宋体" w:eastAsia="宋体" w:cs="宋体" w:hint="eastAsia"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>')
parts.append(f'<w:t xml:space="preserve">{c["text"]}</w:t></w:r></w:p>')
parts.append('</w:comment>')
parts.append('</w:comments>')
return ''.join(parts)
def inject_comments(in_path, out_path, comments):
comments_xml = build_comments_xml(comments)
with open(in_path, 'rb') as f:
data = f.read()
buf_in, buf_out = io.BytesIO(data), io.BytesIO()
inserted = {c["id"]: False for c in comments}
with zipfile.ZipFile(buf_in, 'r') as zin, zipfile.ZipFile(buf_out, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
raw = zin.read(item.filename)
if item.filename == 'word/document.xml':
tree = etree.fromstring(raw)
body = tree.find(f'{Wq}body')
for para in body.findall(f'.//{Wq}p'):
# 接受修订后文本(跳过 w:del 内的 t)做锚点匹配
ptext = ''
for t in para.findall(f'.//{Wq}t'):
if not any(a.tag == f'{Wq}del' for a in t.iterancestors()):
ptext += (t.text or '')
for c in comments:
if not inserted[c["id"]] and c["anchor"] in ptext:
cid = c["id"]
# 第一个挂靠点:段落第一个 r 或 ins
first_child = None
for child in para:
if child.tag in (f'{Wq}r', f'{Wq}ins'):
first_child = child; break
if first_child is None:
continue
crs = etree.Element(f'{Wq}commentRangeStart'); crs.set(f'{Wq}id', cid)
first_child.addprevious(crs)
cre = etree.Element(f'{Wq}commentRangeEnd'); cre.set(f'{Wq}id', cid)
para.append(cre)
rr = etree.SubElement(para, f'{Wq}r')
rrp = etree.SubElement(rr, f'{Wq}rPr')
rs = etree.SubElement(rrp, f'{Wq}rStyle'); rs.set(f'{Wq}val', 'CommentReference')
cref = etree.SubElement(rr, f'{Wq}commentReference'); cref.set(f'{Wq}id', cid)
inserted[cid] = True
raw = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
elif item.filename == '[Content_Types].xml':
ct = etree.fromstring(raw)
NS = 'http://schemas.openxmlformats.org/package/2006/content-types'
ov = etree.SubElement(ct, f'{{{NS}}}Override')
ov.set('PartName', '/word/comments.xml')
ov.set('ContentType', 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml')
raw = etree.tostring(ct, xml_declaration=True, encoding='UTF-8', standalone=True)
elif item.filename == 'word/_rels/document.xml.rels':
rt = etree.fromstring(raw)
RNS = 'http://schemas.openxmlformats.org/package/2006/relationships'
r = etree.SubElement(rt, f'{{{RNS}}}Relationship')
r.set('Id', 'rIdComments1')
r.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments')
r.set('Target', 'comments.xml')
raw = etree.tostring(rt, xml_declaration=True, encoding='UTF-8', standalone=True)
zout.writestr(item, raw)
zout.writestr('word/comments.xml', comments_xml.encode('utf-8'))
with open(out_path, 'wb') as f:
f.write(buf_out.getvalue())
return inserted # 检查是否全部 True
```
### 锚点选择要点
- anchor 必须是**接受修订后文本里唯一**的片段(先用脚本验证 hits==1,多处命中会挂错段)。
- 避免选被修订(w:del/w:ins)切割的文字做anchor——优先选未被改动的稳定片段。
- 若 anchor 落在被修订段,匹配用"接受修订后文本"(跳过 w:del),与上面代码一致。
### 验证(终审必做)
```python
# commentRangeStart/End/Reference 与 comments.xml 的 id 必须全部对应
crs = set(e.get(Wq+'id') for e in doc_root.iter(Wq+'commentRangeStart'))
cre = set(e.get(Wq+'id') for e in doc_root.iter(Wq+'commentRangeEnd'))
cref = set(e.get(Wq+'id') for e in doc_root.iter(Wq+'commentReference'))
com = set(c.get(Wq+'id') for c in com_root.iter(Wq+'comment'))
assert crs == cre == cref == com
```
末了用 python-docx `Document(out)` 能打开(XML合法)+ OnlyOffice 渲染确认批注气泡显示。