v1.0.178: генератор тестовых файлов (схема Опуса). 61 файл: 10 контрактов × (договор+спека v1+v2+допники) + ошибки + ZIP + дубликаты. Комментарий в каждом файле.

This commit is contained in:
“Naeel”
2026-06-25 11:03:41 +04:00
parent c022133de0
commit 3e548ad2a4
78 changed files with 2433 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+188
View File
@@ -0,0 +1,188 @@
"""
corrupt.py — Порча docx/pdf, конвертация в .doc.
Схема Опуса: 17 типов ошибочных кейсов.
"""
import zipfile, io, os, shutil, tempfile, subprocess
def corrupt_xml(docx_path, out_path):
"""
Битый XML внутри docx: обрезать тег в word/document.xml.
Парсер должен вернуть ✗, pipeline не падает.
"""
with zipfile.ZipFile(docx_path, 'r') as zin:
data = {name: zin.read(name) for name in zin.namelist()}
# Найти document.xml и обрезать последние 200 байт
doc_key = 'word/document.xml'
if doc_key in data:
xml = data[doc_key]
# Обрезать закрывающие теги
cut = max(len(xml) - 500, len(xml) // 2)
data[doc_key] = xml[:cut]
with zipfile.ZipFile(out_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for name, content in data.items():
zout.writestr(name, content)
def make_empty_docx(out_path):
"""Пустой docx — без текста, без таблиц."""
from docx import Document
doc = Document()
doc.save(out_path)
def make_empty_file(out_path):
"""Файл нулевого размера."""
with open(out_path, 'wb') as f:
pass
def make_text_only_docx(out_path, title="Соглашение об услугах"):
"""Docx только с текстом, без таблиц. Крайний случай для парсера."""
from docx import Document
doc = Document()
p = doc.add_paragraph()
p.add_run(title).bold = True
doc.add_paragraph("Настоящий документ является соглашением об оказании услуг.")
doc.add_paragraph("Стороны: Заказчик и Исполнитель.")
doc.add_paragraph("Стоимость услуг: 100 000 рублей.")
doc.add_paragraph("Дата: 01.01.2026")
doc.save(out_path)
def make_table_only_docx(out_path):
"""Docx только с таблицей, без параграфов. Крайний случай для парсера."""
from docx import Document
doc = Document()
table = doc.add_table(rows=3, cols=6)
table.style = 'Table Grid'
headers = ["", "Наименование", "Цена", "Объем", "Сумма", "Дата"]
for i, h in enumerate(headers):
table.rows[0].cells[i].text = h
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "Тестовая услуга"
table.rows[1].cells[2].text = "1000,00"
table.rows[1].cells[3].text = "2"
table.rows[1].cells[4].text = "2000,00"
table.rows[1].cells[5].text = "01.06.2026"
doc.save(out_path)
def make_no_number_docx(out_path, template_builder, ctx):
"""
Документ БЕЗ номера договора в тексте.
classify должен вернуть ошибку или 'other'.
"""
doc = template_builder(ctx)
# Удаляем параграф с номером (второй параграф после заголовка)
# Простой подход: заменяем текст номера на пробел
for p in doc.paragraphs:
if ctx["number"] in p.text:
p.text = p.text.replace(f"{ctx['number']}", "")
break
doc.save(out_path)
def make_nonstandard_title_docx(out_path):
"""
Документ с нестандартным заголовком — classify должен классифицировать
по содержимому, а не по заголовку.
"""
from docx import Document
doc = Document()
p = doc.add_paragraph()
p.add_run("Соглашение о предоставлении технологических услуг").bold = True
doc.add_paragraph("№ 99999")
doc.add_paragraph('ООО "Нестандарт" и ООО "НУБЕС" заключили настоящее соглашение.')
doc.add_paragraph("Предмет: оказание услуг ЦОД.")
# Таблица услуг
table = doc.add_table(rows=2, cols=6)
table.style = 'Table Grid'
for i, h in enumerate(["", "Наименование", "Цена", "Объем", "Сумма", "Дата"]):
table.rows[0].cells[i].text = h
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "Аренда стойко-места"
table.rows[1].cells[2].text = "200 000,00"
table.rows[1].cells[3].text = "2"
table.rows[1].cells[4].text = "400 000,00"
table.rows[1].cells[5].text = "01.06.2026"
doc.save(out_path)
def make_giant_docx(out_path, rows=500):
"""Документ с гигантской таблицей — тест производительности."""
from docx import Document
doc = Document()
p = doc.add_paragraph()
p.add_run("Договор № GIANT").bold = True
doc.add_paragraph("Спецификация с большим количеством позиций.")
table = doc.add_table(rows=1, cols=6)
table.style = 'Table Grid'
for i, h in enumerate(["", "Наименование", "Цена", "Объем", "Сумма", "Дата"]):
table.rows[0].cells[i].text = h
for r in range(rows):
row = table.add_row()
row.cells[0].text = str(r + 1)
row.cells[1].text = f"Услуга {r+1}"
row.cells[2].text = f"{1000 + r * 10},00"
row.cells[3].text = "1"
row.cells[4].text = f"{1000 + r * 10},00"
row.cells[5].text = "01.06.2026"
doc.save(out_path)
def make_trash_docx(out_path, label="Счёт-фактура"):
"""Мусорный документ — не договор. Негативная классификация."""
from docx import Document
doc = Document()
p = doc.add_paragraph()
p.add_run(label).bold = True
doc.add_paragraph("№ СФ-2026-001 от 15.06.2026")
doc.add_paragraph("Поставщик: ООО Рога и Копыта")
doc.add_paragraph("Покупатель: ЗАО XXX001")
doc.add_paragraph("Сумма: 150 000,00 руб.")
doc.save(out_path)
def convert_to_doc(docx_path, out_path):
"""Конвертировать docx → doc через LibreOffice."""
out_dir = os.path.dirname(out_path) or "."
out_name = os.path.basename(out_path)
tmp = tempfile.mkdtemp()
shutil.copy(docx_path, os.path.join(tmp, "temp.docx"))
try:
subprocess.run(
["libreoffice", "--headless", "--convert-to", "doc", "--outdir", tmp,
os.path.join(tmp, "temp.docx")],
timeout=30, capture_output=True
)
src = os.path.join(tmp, "temp.doc")
if os.path.exists(src):
shutil.move(src, out_path)
return True
except Exception:
pass
finally:
shutil.rmtree(tmp, ignore_errors=True)
return False
def make_broken_pdf(docx_path, out_path):
"""Сгенерировать PDF из docx и обрезать байты — битый PDF."""
# Сначала генерируем нормальный PDF через LibreOffice
out_dir = os.path.dirname(out_path) or "."
tmp = tempfile.mkdtemp()
shutil.copy(docx_path, os.path.join(tmp, "temp.docx"))
try:
subprocess.run(
["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmp,
os.path.join(tmp, "temp.docx")],
timeout=30, capture_output=True
)
src = os.path.join(tmp, "temp.pdf")
if os.path.exists(src):
with open(src, 'rb') as f:
data = f.read()
# Обрезать последние 30% байт
cut = int(len(data) * 0.7)
with open(out_path, 'wb') as f:
f.write(data[:cut])
return True
except Exception:
pass
finally:
shutil.rmtree(tmp, ignore_errors=True)
return False
+471
View File
@@ -0,0 +1,471 @@
"""
generate.py — Оркестратор генерации ~100 тестовых файлов.
Схема Опуса: 10 контрактов × (договор + спека v1 + спека v2 + 1-3 допника)
+ ошибочные, форматы, ZIP, дубликаты, мусор.
Вывод: out/ с подпапками + manifest.json (ground truth).
"""
import os, json, random, shutil, zipfile
import pools
import templates
import corrupt
OUT = os.path.join(os.path.dirname(__file__), "out")
MANIFEST = {}
def ensure_dir(path):
os.makedirs(path, exist_ok=True)
def save_doc(doc, path):
doc.save(path)
print(f"{os.path.basename(path)}")
# ═══════════════════════════════════════════════════════════════
# Генерация нормальных контрактов
# ═══════════════════════════════════════════════════════════════
def generate_valid():
"""10 контрактов: договор + спека v1 + спека v2 + 1-3 допника."""
out = os.path.join(OUT, "valid")
ensure_dir(out)
for ci, company in enumerate(pools.COMPANIES[:10]):
num = pools.contract_number(ci)
date = pools.random_date(2025, 12, 7)
prefix = company["prefix"]
# ── Договор ──
ctx_contract = {
"company": company, "number": num, "date": date,
"company_idx": ci,
"comment": (
f"Базовый договор контракта {prefix}-{num}. "
f"К нему привязаны спецификации и допсоглашения через НОМЕР={num}."
)
}
fname = f"договор-{prefix}-{num}.docx"
fpath = os.path.join(out, fname)
save_doc(templates.build_contract(ctx_contract), fpath)
MANIFEST[fname] = {
"type": "contract", "company": prefix, "number": num,
"date": date, "status": "valid",
"groups": [{"contract_number": num, "counterparty": company["prefix"]}],
}
# ── Спецификация v1 ──
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(random.randint(5, 9))
# Присвоить даты из пула
for s in svcs_v1:
s["date_start"] = pools.random_date(2026, 3, 3)
ctx_spec_v1 = {
"company": company, "number": num, "date": date,
"contract_date": date, "version": 1,
"services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1,
"label": f"Абонентские услуги с {date}",
"comment": (
f"Спецификация v1 (базовая) к договору {num}. "
f"{len(svcs_v1)} услуг, итого {tot_str_v1} руб."
)
}
fname_v1 = f"спецификация-{prefix}-{num}.docx"
fpath_v1 = os.path.join(out, fname_v1)
save_doc(templates.build_spec(ctx_spec_v1), fpath_v1)
MANIFEST[fname_v1] = {
"type": "spec", "company": prefix, "number": num,
"version": 1, "date": date, "status": "valid",
"services": [{"name": s["name"], "price": s["price"], "qty": s["qty"], "sum": s["sum"]} for s in svcs_v1],
}
# ── Спецификация v2 (ревизия с diff-ами) ──
svcs_v2, changes = _make_v2(svcs_v1)
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
tot_str_v2 = pools.format_price(tot_v2)
# Определить diff
diffs = _compute_diff(svcs_v1, svcs_v2)
# Добавить changes в описание
if changes:
diffs["description"] = ", ".join(changes)
ctx_spec_v2 = {
"company": company, "number": num, "date": date,
"contract_date": date, "version": 2,
"services": svcs_v2, "total": tot_v2, "total_str": tot_str_v2,
"label": f"Абонентские услуги с {pools.random_date(2026, 4, 3)}",
"comment": (
f"Спецификация v2 (ревизия) к договору {num}. "
f"Изменения относительно v1: {diffs['description']}. "
f"{len(svcs_v2)} услуг, итого {tot_str_v2} руб."
)
}
fname_v2 = f"спецификация-{prefix}-{num}_v2.docx"
fpath_v2 = os.path.join(out, fname_v2)
save_doc(templates.build_spec(ctx_spec_v2), fpath_v2)
MANIFEST[fname_v2] = {
"type": "spec", "company": prefix, "number": num,
"version": 2, "date": date, "status": "valid",
"services": [{"name": s["name"], "price": s["price"], "qty": s["qty"], "sum": s["sum"]} for s in svcs_v2],
"diff": diffs,
}
# ── Допники (1-3) ──
n_add = random.randint(1, 3)
for ai in range(1, n_add + 1):
inst, itot, itot_s = pools.pick_services(random.randint(1, 3))
mon, mtot, mtot_s = pools.pick_services(random.randint(2, 5))
adate = pools.random_date(2026, 1, 6)
ctx_add = {
"company": company, "number": num, "date": adate,
"contract_date": date, "addendum_num": ai,
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
"comment": (
f"Допсоглашение №{ai} к договору {num}. "
f"Инсталляц.: {len(inst)} стр., {itot_s} руб. "
f"Абонентск.: {len(mon)} стр., {mtot_s} руб."
)
}
fname_add = f"допник-{ai}-{prefix}-{num}.docx"
fpath_add = os.path.join(out, fname_add)
save_doc(templates.build_addendum(ctx_add), fpath_add)
MANIFEST[fname_add] = {
"type": "addendum", "company": prefix, "number": num,
"addendum_num": ai, "date": adate, "status": "valid",
}
def _make_v2(svcs_v1):
"""
Создать v2 из v1 с преднамеренными diff-ами:
- Изменить цену у 1-2 услуг (±15-30%)
- Изменить объём у 1 услуги
- Удалить 1 услугу (если > 3)
- Добавить 1 новую услугу
"""
import copy
svcs = copy.deepcopy(svcs_v1)
changes = []
# Изменить цену
if len(svcs) >= 1:
idx = random.randint(0, len(svcs) - 1)
old_price = svcs[idx]["price"]
new_price = round(old_price * random.uniform(0.70, 1.30), 2)
svcs[idx]["price"] = new_price
svcs[idx]["price_str"] = pools.format_price(new_price)
svcs[idx]["sum"] = round(new_price * svcs[idx]["qty"], 2)
svcs[idx]["sum_str"] = pools.format_price(svcs[idx]["sum"])
changes.append(f"цена {svcs[idx]['name'][:40]} {old_price}{new_price}")
# Изменить объём
if len(svcs) >= 2:
idx = random.randint(0, len(svcs) - 1)
old_qty = svcs[idx]["qty"]
new_qty = old_qty + random.choice([-2, -1, 1, 2, 3])
if new_qty >= 1:
svcs[idx]["qty"] = new_qty
svcs[idx]["sum"] = round(svcs[idx]["price"] * new_qty, 2)
svcs[idx]["sum_str"] = pools.format_price(svcs[idx]["sum"])
changes.append(f"объём {svcs[idx]['name'][:40]} {old_qty}{new_qty}")
# Удалить услугу
if len(svcs) > 3:
idx = random.randint(0, len(svcs) - 1)
removed = svcs.pop(idx)
changes.append(f"удалена: {removed['name'][:40]}")
# Добавить новую услугу
new_svc_pool = [s for s in pools.SERVICES if not any(s[0] == x["name"] for x in svcs)]
if new_svc_pool:
name, base_price, min_q, max_q, cat = random.choice(new_svc_pool)
price = pools.vary_price(base_price)
qty = pools.vary_qty(min_q, max_q)
s = round(price * qty, 2)
svcs.append({
"num": len(svcs) + 1,
"name": name, "price": price, "price_str": pools.format_price(price),
"qty": qty, "sum": s, "sum_str": pools.format_price(s), "cat": cat,
"date_start": pools.random_date(2026, 4, 3),
})
changes.append(f"добавлена: {name[:40]}")
# Пересчитать номера
for i, s in enumerate(svcs, 1):
s["num"] = i
return svcs, changes
def _compute_diff(v1, v2):
"""Вычислить разницу между v1 и v2 для manifest.json."""
names_v1 = {s["name"] for s in v1}
names_v2 = {s["name"] for s in v2}
added = [s["name"][:60] for s in v2 if s["name"] not in names_v1]
removed = [s["name"][:60] for s in v1 if s["name"] not in names_v2]
changed = []
for s1 in v1:
for s2 in v2:
if s1["name"] == s2["name"]:
if s1["price"] != s2["price"]:
changed.append(f"цена {s1['name'][:40]}: {s1['price']}{s2['price']}")
if s1["qty"] != s2["qty"]:
changed.append(f"объём {s1['name'][:40]}: {s1['qty']}{s2['qty']}")
desc_parts = []
if added: desc_parts.append(f"+{len(added)} услуг")
if removed: desc_parts.append(f"-{len(removed)} услуг")
if changed: desc_parts.append(f"~{len(changed)} изменений")
description = ", ".join(desc_parts) if desc_parts else "без изменений"
return {
"added": added, "removed": removed, "changed": changed,
"description": description,
}
# ═══════════════════════════════════════════════════════════════
# Ошибочные кейсы
# ═══════════════════════════════════════════════════════════════
def generate_errors():
out = os.path.join(OUT, "errors")
ensure_dir(out)
ctx_tmp = {
"company": pools.COMPANIES[0], "number": "ERR01",
"date": "01.06.2026", "company_idx": 0,
"comment": "Ошибочный кейс."
}
# Битый XML
tmp = os.path.join(out, "_tmp_valid.docx")
templates.build_contract(ctx_tmp).save(tmp)
corrupt.corrupt_xml(tmp, os.path.join(out, "corrupt_xml.docx"))
MANIFEST["corrupt_xml.docx"] = {"type": "error", "error": "corrupt_xml", "status": "expected_fail"}
# Пустой
corrupt.make_empty_docx(os.path.join(out, "empty.docx"))
MANIFEST["empty.docx"] = {"type": "error", "error": "empty_docx", "status": "expected_fail"}
corrupt.make_empty_file(os.path.join(out, "empty_zero.docx"))
MANIFEST["empty_zero.docx"] = {"type": "error", "error": "zero_bytes", "status": "expected_fail"}
# Только текст
corrupt.make_text_only_docx(os.path.join(out, "text_only.docx"))
MANIFEST["text_only.docx"] = {"type": "error", "error": "text_only", "status": "expected_parse_ok_no_tables"}
# Только таблицы
corrupt.make_table_only_docx(os.path.join(out, "table_only.docx"))
MANIFEST["table_only.docx"] = {"type": "error", "error": "table_only", "status": "expected_parse_ok"}
# Без номера
corrupt.make_no_number_docx(os.path.join(out, "no_number.docx"), templates.build_contract, ctx_tmp)
MANIFEST["no_number.docx"] = {"type": "error", "error": "no_number", "status": "expected_classify_fail"}
# Нестандартный заголовок
corrupt.make_nonstandard_title_docx(os.path.join(out, "nonstandard_title.docx"))
MANIFEST["nonstandard_title.docx"] = {"type": "error", "error": "nonstandard_title", "status": "expected_classify_ok"}
# Гигант
corrupt.make_giant_docx(os.path.join(out, "giant_500.docx"), 500)
MANIFEST["giant_500.docx"] = {"type": "error", "error": "giant", "status": "expected_slow"}
# Мусор (3 шт.)
trash_types = ["Счёт-фактура", "Письмо-запрос", "Коммерческое предложение"]
for i, tt in enumerate(trash_types):
fname = f"trash_{i+1}.docx"
corrupt.make_trash_docx(os.path.join(out, fname), tt)
MANIFEST[fname] = {"type": "trash", "label": tt, "status": "expected_classify_other"}
# Сирота — спецификация с номером, для которого нет договора
ctx_orphan = {
"company": pools.COMPANIES[0], "number": "ORPHAN",
"date": "01.06.2026", "contract_date": "01.01.2026", "version": 1,
"services": pools.pick_services(3)[0],
"total": 0, "total_str": "0",
"label": "Абонентские услуги",
"comment": "Спецификация-сирота: номер ORPHAN не принадлежит ни одному договору."
}
ctx_orphan["total"] = round(sum(s["sum"] for s in ctx_orphan["services"]), 2)
ctx_orphan["total_str"] = pools.format_price(ctx_orphan["total"])
save_doc(templates.build_spec(ctx_orphan), os.path.join(out, "orphan_spec.docx"))
MANIFEST["orphan_spec.docx"] = {"type": "error", "error": "orphan", "status": "expected_group_unresolved"}
print(f" Сгенерировано ошибочных: 13")
# ═══════════════════════════════════════════════════════════════
# .doc и .pdf форматы
# ═══════════════════════════════════════════════════════════════
def generate_formats():
out = os.path.join(OUT, "formats")
ensure_dir(out)
# Проверить наличие LibreOffice
import subprocess as sp
has_lo = False
try:
sp.run(["libreoffice", "--version"], capture_output=True, timeout=5)
has_lo = True
except (FileNotFoundError, sp.TimeoutExpired):
pass
if not has_lo:
print(" ⚠ LibreOffice не найден — .doc/.pdf пропущены")
return
# Взять один валидный контракт как основу
ci = 0; c = pools.COMPANIES[ci]; num = pools.contract_number(ci); date = pools.random_date()
ctx = {"company": c, "number": num, "date": date, "company_idx": ci,
"comment": "Конвертирован в .doc / .pdf для теста."}
docx_path = os.path.join(out, "_base.docx")
templates.build_contract(ctx).save(docx_path)
# .doc
doc_path = os.path.join(out, f"договор-{c['prefix']}-{num}.doc")
ok = corrupt.convert_to_doc(docx_path, doc_path)
if ok:
print(f" ✓ .doc конвертирован: {os.path.basename(doc_path)}")
MANIFEST[os.path.basename(doc_path)] = {"type": "contract", "format": "doc", "status": "valid"}
else:
print(f" ⚠ .doc конвертация не удалась (нет LibreOffice?)")
# Нормальный PDF
pdf_path = os.path.join(out, f"договор-{c['prefix']}-{num}.pdf")
corrupt.make_broken_pdf(docx_path, pdf_path) # эта функция делает битый — используем её, но с полным файлом
# На самом деле make_broken_pdf обрезает. Сделаем нормальный отдельно.
# Перезапишем полным — make_broken_pdf уже обрезал, сделаем заново с 100%
import tempfile, subprocess as sp
tmp = tempfile.mkdtemp()
shutil.copy(docx_path, os.path.join(tmp, "t.docx"))
sp.run(["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmp, os.path.join(tmp, "t.docx")],
timeout=30, capture_output=True)
src = os.path.join(tmp, "t.pdf")
if os.path.exists(src):
shutil.move(src, pdf_path)
print(f" ✓ .pdf: {os.path.basename(pdf_path)}")
MANIFEST[os.path.basename(pdf_path)] = {"type": "contract", "format": "pdf", "status": "valid"}
shutil.rmtree(tmp, ignore_errors=True)
# Битый PDF
broken_pdf = os.path.join(out, f"broken.pdf")
corrupt.make_broken_pdf(docx_path, broken_pdf)
if os.path.exists(broken_pdf):
print(f" ✓ битый .pdf: {os.path.basename(broken_pdf)}")
MANIFEST[os.path.basename(broken_pdf)] = {"type": "error", "error": "broken_pdf", "status": "expected_fail"}
os.remove(docx_path) # убрать временный
# ═══════════════════════════════════════════════════════════════
# ZIP-архивы
# ═══════════════════════════════════════════════════════════════
def generate_zips():
out = os.path.join(OUT, "zips")
ensure_dir(out)
valid_dir = os.path.join(OUT, "valid")
if not os.path.exists(valid_dir):
print(" ⚠ valid/ не найден, ZIP-ы не сгенерированы")
return
# Набор файлов одного контракта
files_for_zip = [f for f in os.listdir(valid_dir) if f.startswith("договор-XXX001")]
if files_for_zip:
zip_path = os.path.join(out, "contract_XXX001.zip")
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in files_for_zip:
zf.write(os.path.join(valid_dir, f), f)
print(f" ✓ ZIP: {os.path.basename(zip_path)} ({len(files_for_zip)} файлов)")
MANIFEST[os.path.basename(zip_path)] = {"type": "zip", "files": files_for_zip, "status": "valid"}
# ZIP с дубликатами имён внутри
if len(files_for_zip) >= 2:
zip_path2 = os.path.join(out, "duplicates_inside.zip")
with zipfile.ZipFile(zip_path2, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0])
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0]) # дубликат!
print(f" ✓ ZIP с дубликатами: {os.path.basename(zip_path2)}")
MANIFEST[os.path.basename(zip_path2)] = {"type": "zip", "duplicates": True, "status": "valid"}
# ZIP с битым файлом внутри
err_dir = os.path.join(OUT, "errors")
if os.path.exists(err_dir) and files_for_zip:
zip_path3 = os.path.join(out, "mixed_with_error.zip")
with zipfile.ZipFile(zip_path3, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(os.path.join(valid_dir, files_for_zip[0]), files_for_zip[0])
zf.write(os.path.join(err_dir, "corrupt_xml.docx"), "corrupt_xml.docx")
print(f" ✓ ZIP с битым: {os.path.basename(zip_path3)}")
MANIFEST[os.path.basename(zip_path3)] = {"type": "zip", "has_error": True, "status": "valid"}
# ═══════════════════════════════════════════════════════════════
# Дубликаты
# ═══════════════════════════════════════════════════════════════
def generate_duplicates():
out = os.path.join(OUT, "duplicates")
ensure_dir(out)
valid_dir = os.path.join(OUT, "valid")
if not os.path.exists(valid_dir):
return
files = [f for f in os.listdir(valid_dir) if f.endswith('.docx')]
if len(files) >= 3:
# Точная копия (другое имя)
src = os.path.join(valid_dir, files[0])
shutil.copy(src, os.path.join(out, "copy_of_" + files[0]))
MANIFEST["copy_of_" + files[0]] = {"type": "duplicate", "original": files[0], "kind": "content_copy"}
# Одинаковое имя, разный контент
shutil.copy(os.path.join(valid_dir, files[1]), os.path.join(out, files[0]))
MANIFEST[files[0]] = {"type": "duplicate", "original": files[0], "kind": "name_collision"}
print(f" ✓ Дубликаты: 2 шт.")
# ═══════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════
def main():
print("=" * 60)
print("Генератор тестовых документов (схема Опуса)")
print("=" * 60)
print("\n── Валидные контракты ──")
generate_valid()
print("\n── Ошибочные кейсы ──")
generate_errors()
print("\n── Форматы (.doc/.pdf) ──")
generate_formats()
print("\n── ZIP-архивы ──")
generate_zips()
print("\n── Дубликаты ──")
generate_duplicates()
# Сохранить manifest
manifest_path = os.path.join(OUT, "manifest.json")
with open(manifest_path, 'w', encoding='utf-8') as f:
json.dump(MANIFEST, f, ensure_ascii=False, indent=2)
total = len(MANIFEST)
print(f"\n{'=' * 60}")
print(f"Всего файлов в manifest.json: {total}")
print(f"Вывод: {OUT}/")
print(f"Манифест: {manifest_path}")
print(f"{'=' * 60}")
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+113
View File
@@ -0,0 +1,113 @@
"""
pools.py — Пулы данных для генератора тестовых документов.
Схема Опуса: 10 компаний, пул из ~12 услуг, цены ±20% от базовых,
вариативность дат, номеров, названий.
Все данные основаны на реальных файлах из примеры_договоров_для_ИИ/.
"""
import random
# ── Компании ─────────────────────────────────────────────────
COMPANIES = [
{"prefix": "XXX001", "legal": 'ЗАО "XXX001"', "dir": "Генерального директора"},
{"prefix": "XXX002", "legal": 'ООО "XXX002"', "dir": "Генерального директора"},
{"prefix": "XXX003", "legal": 'АО "XXX003"', "dir": "Директора"},
{"prefix": "XXX004", "legal": 'ПАО "XXX004"', "dir": "Генерального директора"},
{"prefix": "XXX005", "legal": 'ООО "XXX005"', "dir": "Директора"},
{"prefix": "XXX006", "legal": 'ЗАО "XXX006"', "dir": "Генерального директора"},
{"prefix": "XXX007", "legal": 'АО "XXX007"', "dir": "Директора"},
{"prefix": "XXX008", "legal": 'ООО "XXX008"', "dir": "Генерального директора"},
{"prefix": "XXX009", "legal": 'ИП "XXX009"', "dir": "Индивидуального предпринимателя"},
{"prefix": "XXX010", "legal": 'ПАО "XXX010"', "dir": "Генерального директора"},
# Кириллический edge (уже есть в примерах)
{"prefix": "ХХХ002", "legal": 'ООО "ХХХ002"', "dir": "Генерального директора"},
]
PROVIDER = 'ООО "НУБЕС"'
PROVIDER_DIR = "Генерального директора Степаненко В.И."
CITY = "г. Москва"
ADDRESS = "115404, г. Москва, 1-я Стекольная 7с5"
# ── Номера договоров ─────────────────────────────────────────
# Базовые номера из примеров + вариации
BASE_NUMBERS = ["03700", "01200", "01300", "04100", "05200", "06800", "07400", "08100", "09900", "10500"]
def contract_number(company_idx, variant=0):
"""Сгенерировать номер договора: БАЗА[_суффикс]."""
base = BASE_NUMBERS[company_idx % len(BASE_NUMBERS)]
if variant > 0:
return f"{base}_{variant}"
return base
# ── Даты ─────────────────────────────────────────────────────
def random_date(base_year=2025, base_month=12, spread_months=7):
"""Случайная дата ±spread_months от базовой. Возвращает DD.MM.YYYY."""
import datetime
base = datetime.date(base_year, base_month, 1)
offset = random.randint(0, spread_months * 30)
d = base + datetime.timedelta(days=offset)
return d.strftime("%d.%m.%Y")
# ── Пул услуг и базовых цен (из реальных файлов) ────────────
# (наименование, базовая_цена, мин_объём, макс_объём, категория)
# категория: 'rack' | 'power' | 'network' | 'cloud' | 'ip' | 'cross'
SERVICES = [
("Аренда стойко-места", 213905.44, 1, 5, "rack"),
("Аренда PDU: 3Ф 32А вертикальный", 2511.17, 1, 5, "power"),
("Организация L2 канала без резерва, в составе:\n Скорость порта: 1 Гбит/с", 11016.36, 1, 3, "network"),
("Организация L2 канала без резерва, в составе:\n Скорость порта: 10 Гбит/с", 28300.00, 1, 2, "network"),
("Next Generation Cloud (Intel 3.0 ГГц), в составе:\n Количество vCPU: 8, RAM: 32 ГБ", 76704.00, 1, 3, "cloud"),
("Next Generation Cloud (AMD 4.0 ГГц), в составе:\n Количество vCPU: 4, RAM: 16 ГБ", 123823.00, 1, 2, "cloud"),
("Аренда IPv4 адреса /32", 157.28, 1, 16, "ip"),
("Аренда IPv4 подсети /30", 629.10, 1, 4, "ip"),
("Организация cross-connect: ВОЛС, 2×SMF", 4192.40, 1, 5, "cross"),
("Организация cross-connect: UTP", 840.94, 1, 5, "cross"),
("Интернет без защиты от DDoS, в составе:\n Полоса Интернет: 100 Мбит/с", 7015.00, 1, 3, "network"),
("Аренда порта без резерва, в составе:\n Скорость порта: 1 Гбит/с", 1525.00, 1, 3, "network"),
("Аренда публичной подсети (Длина префикса: /30 — 1 IPv4 адрес)", 589.66, 1, 4, "ip"),
]
# ── Функции вариативности ────────────────────────────────────
def vary_price(base, pct=0.20):
"""Случайное изменение цены ±pct, округление до копеек."""
factor = 1.0 + random.uniform(-pct, pct)
return round(base * factor, 2)
def vary_qty(min_q, max_q):
return random.randint(min_q, max_q)
def format_price(p):
"""Формат цены с пробелами-разделителями: 213 905,44."""
s = f"{p:,.2f}"
# 213,905.44 → 213 905,44
return s.replace(",", " ").replace(".", ",").replace(" ", " ")
def pick_services(count=5, with_prices=True):
"""Выбрать случайный набор услуг. Возвращает [(name, price, qty, sum), ...]."""
selected = random.sample(SERVICES, min(count, len(SERVICES)))
result = []
total = 0.0
for i, (name, base_price, min_q, max_q, cat) in enumerate(selected, 1):
price = vary_price(base_price) if with_prices else base_price
qty = vary_qty(min_q, max_q)
s = round(price * qty, 2)
total += s
result.append({
"num": i,
"name": name,
"price": price,
"price_str": format_price(price),
"qty": qty,
"sum": s,
"sum_str": format_price(s),
"cat": cat,
})
return result, round(total, 2), format_price(round(total, 2))
+267
View File
@@ -0,0 +1,267 @@
"""
templates.py — Шаблоны документов (python-docx).
build_contract(ctx) — Договор на оказание технологических услуг
build_spec(ctx) — Спецификация и стоимость услуг
build_addendum(ctx) — Дополнительное соглашение
Каждый документ начинается с параграфа-комментария:
«Данный файл: [тип], компания [X], номер [N], версия [V].
Содержит: [краткое описание]. Сгенерирован автоматически для тестирования.»
"""
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
import pools
# ═══════════════════════════════════════════════════════════════
# Хелперы
# ═══════════════════════════════════════════════════════════════
def _comment_para(doc, text):
"""Добавить параграф-комментарий в начало документа (серый, мелкий)."""
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = p.add_run(text)
run.font.size = Pt(8)
run.font.color.rgb = RGBColor(0x80, 0x80, 0x80)
run.font.italic = True
def _add_title(doc, text):
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(text)
run.bold = True
run.font.size = Pt(14)
def _add_heading(doc, text):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = True
run.font.size = Pt(12)
def _add_para(doc, text):
p = doc.add_paragraph(text)
return p
def _add_parties_table(doc, customer, provider, provider_dir, city, date):
"""Таблица реквизитов сторон (1×2): Заказчик | Исполнитель."""
table = doc.add_table(rows=1, cols=2)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
c0 = table.rows[0].cells[0]
c1 = table.rows[0].cells[1]
cust_info = f'{customer["legal"]}\nЮридический адрес:\n\n{customer.get("dir","")}\n_______________/ФИО/\n М.П.'
prov_info = f'{provider}\nЮридический адрес:\n{pools.ADDRESS}\n{provider_dir}\n_______________/Степаненко В.И./\n М.П.'
c0.text = f"Заказчик\n{cust_info}"
c1.text = f"Исполнитель\n{prov_info}"
def _add_services_table(doc, services, total_str, with_end_date=False, label="Абонентские услуги"):
"""Таблица услуг: №, Наименование, Цена, Объем, Сумма, Дата начала [+ Дата окончания] + Итого."""
_add_para(doc, f"{label}:")
cols = 7 if with_end_date else 6
header = ["№ п/п", "Наименование услуг", "Цена, руб. с НДС", "Объем услуг", "Сумма, руб. с НДС", "Планируемая дата начала оказания услуг"]
if with_end_date:
header.append("Дата окончания оказания услуг")
table = doc.add_table(rows=1, cols=cols)
table.style = 'Table Grid'
# Заголовок
for i, h in enumerate(header):
table.rows[0].cells[i].text = h
# Строки услуг
for s in services:
row = table.add_row()
cells = row.cells
cells[0].text = str(s["num"])
cells[1].text = s["name"]
cells[2].text = s["price_str"]
cells[3].text = str(s["qty"])
cells[4].text = s["sum_str"]
cells[5].text = s.get("date_start", pools.random_date(2026, 3, 3))
if with_end_date:
cells[6].text = s.get("date_end", "")
# Итого
row = table.add_row()
row.cells[0].text = ""
row.cells[1].text = f"Итого {label.lower()} в руб с учетом НДС"
row.cells[2].text = ""
row.cells[3].text = ""
row.cells[4].text = total_str
row.cells[5].text = "-"
if with_end_date:
row.cells[6].text = "-"
# ═══════════════════════════════════════════════════════════════
# Билдеры
# ═══════════════════════════════════════════════════════════════
def build_contract(ctx):
"""
ctx = {
company, number, date, company_idx,
services[], total, total_str,
comment (описание для тестов)
}
"""
doc = Document()
c = ctx["company"]
num = ctx["number"]
date = ctx["date"]
# ── Комментарий для тестов ──
_comment_para(doc, (
f"Данный файл: ДОГОВОР, компания {c['prefix']}, № {num} от {date}.\n"
f"Стороны: {c['legal']} (Заказчик) + {pools.PROVIDER} (Исполнитель).\n"
f"Сгенерирован автоматически для тестирования сервиса contracts.\n"
f"{ctx.get('comment','')}"
))
_add_title(doc, "Договор на оказание технологических услуг")
_add_para(doc, f"{num}")
_add_para(doc, f'{c["legal"]} именуемое в дальнейшем "Заказчик", в лице {c["dir"]} ФИО, действующего на основании Устава, '
f'и {pools.PROVIDER}, именуемое в дальнейшем "Исполнитель", в лице {pools.PROVIDER_DIR}, '
f'действующего на основании Устава, заключили настоящий Договор о нижеследующем.')
# Термины
_add_heading(doc, "ТЕРМИНЫ И ОПРЕДЕЛЕНИЯ")
terms = [
"Услуги – оказываемые Исполнителем Заказчику технологические услуги в области компьютерных и телекоммуникационных технологий.",
"Абонентские услуги – Услуги, указанные в Спецификации, предоставляемые ежемесячно.",
"Инсталляционные услуги – Услуги по установке и настройке оборудования Заказчика.",
"Технологическая площадка – специализированное помещение (ЦОД) по адресу: г. Москва, 1-я Стекольная 7с5.",
"Отчетный период – календарный месяц.",
]
for t in terms:
_add_para(doc, t)
# Предмет
_add_heading(doc, "ПРЕДМЕТ ДОГОВОРА")
_add_para(doc, f"Исполнитель обязуется оказывать Заказчику технологические Услуги, указанные в Спецификации, "
f"а Заказчик обязуется принимать и оплачивать Услуги. Место оказания: {pools.CITY}, {pools.ADDRESS}.")
# Стоимость и оплата
_add_heading(doc, "СТОИМОСТЬ УСЛУГ И ПОРЯДОК ОПЛАТЫ")
_add_para(doc, f"Стоимость Услуг указана в Спецификации (Приложение №1). "
f"Оплата ежемесячная, постоплатой 100%, на основании УПД и счета. "
f"Срок оплаты — 10 рабочих дней с даты выставления счета.")
# Прочие условия (сокращённо)
_add_heading(doc, "ОСОБЫЕ УСЛОВИЯ")
_add_para(doc, "Исполнитель вправе приостановить оказание Услуг при просрочке платежей более 10 рабочих дней. "
"Заказчик обязан соблюдать правила Технологической площадки.")
_add_heading(doc, "СРОК ДЕЙСТВИЯ")
_add_para(doc, "Договор вступает в силу с момента подписания. Начальный период — 12 месяцев. "
"Автоматическая пролонгация, если ни одна из Сторон не уведомит о прекращении за 30 дней.")
# Реквизиты
_add_heading(doc, "ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН")
_add_para(doc, f"{pools.CITY} {date}г.")
_add_parties_table(doc, c, pools.PROVIDER, pools.PROVIDER_DIR, pools.CITY, date)
return doc
def build_spec(ctx):
"""
ctx = {
company, number, date, contract_date,
services[], total, total_str,
version (1 или 2),
label (название секции),
comment
}
Для v2 — slight differences в services (цены/объём/состав).
"""
doc = Document()
c = ctx["company"]
num = ctx["number"]
date = ctx["date"]
contract_date = ctx.get("contract_date", date)
ver = ctx.get("version", 1)
_comment_para(doc, (
f"Данный файл: СПЕЦИФИКАЦИЯ v{ver}, компания {c['prefix']}, к договору № {num} от {contract_date}.\n"
f"Версия {ver}: {'базовая' if ver == 1 else 'ревизия с изменениями'}.\n"
f"Содержит: {len(ctx['services'])} строк(и) услуг, итого {ctx['total_str']} руб.\n"
f"{ctx.get('comment','')}"
))
_add_title(doc, f"Приложение № 1")
_add_para(doc, f"к Договору на оказание технологических услуг № {num} от {contract_date} г.")
_add_para(doc, "")
_add_heading(doc, "Спецификация и стоимость услуг")
_add_para(doc, "Состав и стоимость Услуг")
label = ctx.get("label", f"Абонентские услуги с {date}")
_add_para(doc, label)
_add_para(doc, f"Общая стоимость Ежемесячных услуг составляет {ctx['total_str']} руб.")
_add_services_table(doc, ctx["services"], ctx["total_str"], with_end_date=True, label=label)
# Дата и город
_add_para(doc, "")
_add_para(doc, f"{pools.CITY} {date}г.")
return doc
def build_addendum(ctx):
"""
ctx = {
company, number, date, contract_date, addendum_num,
services_install[], install_total, install_total_str,
services_monthly[], monthly_total, monthly_total_str,
comment
}
"""
doc = Document()
c = ctx["company"]
num = ctx["number"]
date = ctx["date"]
contract_date = ctx.get("contract_date", date)
anum = ctx.get("addendum_num", 1)
_comment_para(doc, (
f"Данный файл: ДОПОЛНИТЕЛЬНОЕ СОГЛАШЕНИЕ №{anum}, компания {c['prefix']}, к договору № {num} от {contract_date}.\n"
f"Содержит: инсталляционные услуги ({len(ctx.get('services_install',[]))} стр.), "
f"абонентские услуги ({len(ctx.get('services_monthly',[]))} стр.).\n"
f"{ctx.get('comment','')}"
))
_add_title(doc, f"Дополнительное Соглашение №{anum}")
_add_para(doc, f"к Договору № {num} от {contract_date}")
_add_para(doc, f"{pools.CITY} «{date.split('.')[0]}» {_month_name(int(date.split('.')[1]))} {date.split('.')[2]}")
_add_para(doc, f'{c["legal"]} именуемое в дальнейшем "Заказчик", в лице {c["dir"]} ФИО, '
f'и {pools.PROVIDER}, именуемое в дальнейшем "Исполнитель", в лице {pools.PROVIDER_DIR}, '
f'заключили настоящее Соглашение о нижеследующем:')
_add_heading(doc, "1. ПРЕДМЕТ ДОПОЛНИТЕЛЬНОГО СОГЛАШЕНИЯ")
_add_para(doc, "Изменить Спецификацию и стоимость Услуг. Утвердить новую Спецификацию в соответствии с Приложением №1 к настоящему Соглашению.")
if ctx.get("services_install"):
_add_para(doc, "")
_add_para(doc, f"Общая стоимость Инсталляционных услуг составляет {ctx['install_total_str']} руб.")
_add_services_table(doc, ctx["services_install"], ctx["install_total_str"], with_end_date=False, label="Разовые услуги")
if ctx.get("services_monthly"):
_add_para(doc, "")
_add_para(doc, f"Общая стоимость Ежемесячных услуг составляет {ctx['monthly_total_str']} руб.")
_add_services_table(doc, ctx["services_monthly"], ctx["monthly_total_str"], with_end_date=True, label="Абонентские услуги")
_add_heading(doc, "ПРОЧИЕ УСЛОВИЯ")
_add_para(doc, "Во всём остальном, что не урегулировано настоящим Соглашением, действуют положения Договора.")
_add_heading(doc, "ЮРИДИЧЕСКИЕ АДРЕСА И РЕКВИЗИТЫ СТОРОН")
_add_parties_table(doc, c, pools.PROVIDER, pools.PROVIDER_DIR, pools.CITY, date)
return doc
def _month_name(m):
months = ["", "января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря"]
return months[m] if 1 <= m <= 12 else str(m)