Files
openclaw/skills/contract-audit/scripts/contract_audit.py
2026-03-01 17:44:19 +03:00

226 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import argparse
import json
import re
from datetime import datetime
from pathlib import Path
from zipfile import ZipFile, ZIP_DEFLATED
import xml.etree.ElementTree as ET
NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
ET.register_namespace('w', NS['w'])
def extract_docx_text(path: str) -> str:
with ZipFile(path, 'r') as z:
xml = z.read('word/document.xml').decode('utf-8', 'ignore')
chunks = re.findall(r'<w:t[^>]*>(.*?)</w:t>', xml)
text = ' '.join(chunks)
text = text.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')
text = re.sub(r'\s+', ' ', text).strip()
return text
def _p_text(p) -> str:
return ''.join((t.text or '') for t in p.findall('.//w:t', NS)).strip()
def _set_p_text(p, new_text: str):
ts = p.findall('.//w:t', NS)
if not ts:
return
ts[0].text = new_text
for t in ts[1:]:
t.text = ''
def autofix_docx(in_docx: str, out_docx: str):
src = Path(in_docx)
out = Path(out_docx)
with ZipFile(src, 'r') as zin:
xml = zin.read('word/document.xml')
root = ET.fromstring(xml)
changed = []
for p in root.findall('.//w:p', NS):
txt = _p_text(p)
if not txt:
continue
if 'Пушкино' in txt:
_set_p_text(p, txt.replace('Пушкино', 'Пушкина'))
changed.append('address_typo')
continue
if txt.startswith('1.1.'):
new = (
'1.1. Арендодатель передает, а Арендатор принимает во временное владение и пользование '
'недвижимое имущество — нежилые помещения, расположенные по адресу: Российская Федерация, '
'Республика Крым, г. Симферополь, ул. Пушкина, д. 24 (1 этаж): помещение с кадастровым номером '
'90:22:010301:552, площадью 80,6 кв.м; помещение с кадастровым номером 90:22:010301:7181, '
'площадью 84,2 кв.м. Общая площадь арендуемых помещений составляет 164,8 кв.м, '
'в том числе площадь зала для обслуживания посетителей — 48 кв.м.'
)
_set_p_text(p, new)
changed.append('clause_1_1')
elif txt.startswith('1.2.'):
new = '1.2. Срок аренды устанавливается с 01.01.2026 г. по 30.11.2026 г. (включительно), что составляет 11 месяцев.'
_set_p_text(p, new)
changed.append('clause_1_2')
elif txt.startswith('3.1.'):
new = (
'3.1. Арендная плата установлена за базовый месяц в размере 180 000 '
'(Сто восемьдесят тысяч) рублей. Уплата арендной платы производится до 7 (седьмого) '
'числа каждого месяца, следующего за отчетным, путем перечисления на расчетный счет '
'Арендодателя по реквизитам, указанным в разделе 14 настоящего договора. '
'Оплата начинается с 01.01.2026 г.'
)
_set_p_text(p, new)
changed.append('clause_3_1')
elif txt.startswith('3.2.'):
new = (
'3.2. Арендная плата выплачивается Арендатором Арендодателю ежемесячно не позднее '
'седьмого числа каждого месяца, следующего за отчетным. В случае задержки с оплатой '
'аренды более чем на 10 дней это считается невыполнением условий договора, дающим '
'право Арендодателю прекратить настоящий договор в одностороннем порядке.'
)
_set_p_text(p, new)
changed.append('clause_3_2')
elif 'последние 30 дней Аренды' in txt or 'Гарантированным платежом согласно первоначальному договору Аренды' in txt:
_set_p_text(p, 'Стороны подтверждают, что срок действия настоящего договора не превышает 11 месяцев.')
changed.append('clause_30days_conflict')
new_xml = ET.tostring(root, encoding='utf-8', xml_declaration=True)
with ZipFile(src, 'r') as zin, ZipFile(out, 'w', ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == 'word/document.xml':
data = new_xml
zout.writestr(item, data)
return sorted(set(changed))
def parse_date(s: str):
for fmt in ('%d.%m.%Y', '%d.%m.%y'):
try:
return datetime.strptime(s, fmt)
except ValueError:
pass
return None
def months_diff(a: datetime, b: datetime) -> int:
return (b.year - a.year) * 12 + (b.month - a.month) + (1 if b.day >= a.day else 0)
def find_all(pattern, text, flags=0):
return re.findall(pattern, text, flags)
def audit(doc_text: str, egrn_texts: list[str]):
findings = []
fixes = []
if 'Пушкино' in doc_text:
findings.append('Опечатка в адресе: найдено «Пушкино» вместо «Пушкина».')
fixes.append('Заменить во всех пунктах: «ул. Пушкино» -> «ул. Пушкина».')
if 'Арендная плата выплачивается Арендатору' in doc_text and 'Арендодателем' in doc_text:
findings.append('Перепутаны плательщик и получатель арендной платы.')
fixes.append('Исправить формулировку: «Арендная плата выплачивается Арендатором Арендодателю ...».')
if re.search(r'1\s*8\s*0000', doc_text):
findings.append('Некорректная запись суммы аренды (разрыв числа).')
fixes.append('Исправить сумму на единый формат: «180 000 (Сто восемьдесят тысяч) рублей».')
has_30days = 'последние 30 дней Аренды' in doc_text or 'Гарантированным платежом' in doc_text
m = re.search(r'Срок\s+Аренды\s+устанавливается\s+с\s+(\d{2}\.\d{2}\.\d{2,4})\s*г?\.?\s+по\s+(\d{2}\.\d{2}\.\d{2,4})', doc_text, re.I)
if m:
d1 = parse_date(m.group(1))
d2 = parse_date(m.group(2))
if d1 and d2:
months = months_diff(d1, d2)
if months > 11:
findings.append(f'Срок договора превышает 11 месяцев: ~{months} мес.')
fixes.append('Сократить срок до 11 месяцев максимум и явно указать период в п.1.2.')
if has_30days and d2.month == 11:
findings.append('Есть конфликт срока с блоком про «последние 30 дней» за пределами периода аренды.')
fixes.append('Удалить/переформулировать блок про «последние 30 дней», оставив условия в пределах срока договора.')
cadasters = sorted(set(find_all(r'90:22:010301:\d{3,5}', doc_text)))
areas = sorted(set(find_all(r'\b\d{2,3}[\.,]\d\b\s*кв\.?м', doc_text, re.I)))
if egrn_texts:
e_text = ' '.join(egrn_texts)
e_cadasters = sorted(set(find_all(r'90:22:010301:\d{3,5}', e_text)))
e_areas = sorted(set(find_all(r'\b\d{2,3}[\.,]\d\b', e_text)))
if e_cadasters and set(e_cadasters) - set(cadasters):
findings.append(f'В договоре отсутствуют кадастровые номера из ЕГРН: {", ".join(sorted(set(e_cadasters)-set(cadasters)))}.')
fixes.append('Добавить недостающие кадастровые номера в п.1.1.')
if e_areas and not any(a in doc_text for a in ('80,6', '84,2', '80.6', '84.2')):
findings.append('Площади помещений из ЕГРН не отражены явно в договоре.')
fixes.append('В п.1.1 указать площади каждого помещения и общую площадь.')
if not findings:
findings.append('Критичных несогласованностей по шаблонным проверкам не найдено.')
return {
'findings': findings,
'fixes': fixes,
'meta': {
'cadasters_in_doc': cadasters,
'areas_in_doc': areas,
},
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--docx', required=True)
ap.add_argument('--egrn-text', action='append', default=[])
ap.add_argument('--json', action='store_true')
ap.add_argument('--autofix', action='store_true')
ap.add_argument('--out', default='')
args = ap.parse_args()
target_docx = args.docx
applied = []
if args.autofix:
out = args.out or str(Path(args.docx).with_name(Path(args.docx).stem + '_fixed.docx'))
applied = autofix_docx(args.docx, out)
target_docx = out
doc_text = extract_docx_text(target_docx)
egrn_texts = []
for p in args.egrn_text:
with open(p, 'r', encoding='utf-8', errors='ignore') as f:
egrn_texts.append(f.read())
result = audit(doc_text, egrn_texts)
if applied:
result['autofix'] = {
'applied': applied,
'output_docx': target_docx,
}
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
if applied:
print(f'Автоправки применены: {", ".join(applied)}')
print(f'Файл: {target_docx}\n')
print('Найдено:')
for i, x in enumerate(result['findings'], 1):
print(f'{i}. {x}')
if result['fixes']:
print('\nРекомендованные правки:')
for i, x in enumerate(result['fixes'], 1):
print(f'{i}. {x}')
if __name__ == '__main__':
main()