docs: архитектурный анализ + History + gitignore (2026-06-27)

This commit is contained in:
“Naeel”
2026-06-27 13:00:18 +04:00
parent 4a21d77f51
commit 82c5c075f1
154 changed files with 4789 additions and 1443 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
Binary file not shown.
Binary file not shown.
+163
View File
@@ -0,0 +1,163 @@
"""
actions.py — API-обёртки для симулятора.
Все запросы к contracts.kube5s.ru с таймаутами.
Эмулирует действия пользователя через UI (загрузка, classify, groups, compare).
"""
import requests, json, time, sys, os, uuid
BASE = "https://contracts.kube5s.ru"
TIMEOUT = 120
# Убрать warnings
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def _post(url, **kw):
return requests.post(url, timeout=TIMEOUT, verify=False, **kw)
def _get(url, **kw):
return requests.get(url, timeout=TIMEOUT, verify=False, **kw)
# ═══════════════════════════════════════════════════════════════
# Действия
# ═══════════════════════════════════════════════════════════════
class Session:
"""Состояние сессии: batch_id, contract_id, files."""
def __init__(self):
self.batch_id = str(uuid.uuid4())
self.contract_id = None
self.files = [] # {name, doc_id, status}
def upload_file(self, path):
"""Загрузить один файл. Возвращает ответ API."""
fname = os.path.basename(path)
with open(path, 'rb') as f:
data = {'batch_id': self.batch_id}
if self.contract_id:
data['contract_id'] = self.contract_id
r = _post(f"{BASE}/upload", files={'files': (fname, f)}, data=data)
result = r.json()
if result.get('ok'):
doc_id = result.get('doc_id')
parsed = result.get('parsed', {})
self.files.append({
'name': fname, 'doc_id': doc_id,
'status': parsed.get('status', 'uploaded'),
'element_count': parsed.get('element_count', 0)
})
if result.get('contract_id'):
self.contract_id = result['contract_id']
return result
def upload_files(self, paths, delay=0.2):
"""Загрузить несколько файлов последовательно."""
for p in paths:
r = self.upload_file(p)
status = r.get('parsed', {}).get('status', r.get('ok', '?'))
print(f"{os.path.basename(p)}: {status}")
time.sleep(delay)
def classify(self):
"""Запустить классификацию, ждать завершения."""
r = _post(f"{BASE}/api/classify-batch", json={'batch_id': self.batch_id})
result = r.json()
if not result.get('ok'):
print(f" ✗ classify failed: {result}")
return result
# Поллить прогресс
total = result.get('total', 0)
print(f" ⏳ classify {total} файлов...")
for _ in range(300): # макс 10 минут (300 × 2с)
time.sleep(2)
pr = _get(f"{BASE}/api/batch-progress?batch={self.batch_id}").json()
done = pr.get('counts', {}).get('classified', 0) + pr.get('counts', {}).get('failed', 0)
if done >= total and total > 0:
print(f" ✓ classify done: {pr.get('counts')}")
return pr
return {"ok": False, "error": "timeout"}
def get_groups(self):
"""Получить группы после классификации."""
r = _get(f"{BASE}/api/groups?batch={self.batch_id}")
data = r.json()
groups = data.get('groups', [])
real = [g for g in groups if g.get('contract_number') != '__unresolved__']
unres = [g for g in groups if g.get('contract_number') == '__unresolved__']
print(f" 📋 группы: {len(real)} (+ {len(unres)} нераспознано)")
for g in real:
print(f"{g.get('contract_number')}{g.get('counterparty','?')} ({len(g.get('documents',[]))} док.)")
return data
def apply_and_compare(self, group_index=0):
"""Применить группу и запустить сравнение через SSE."""
r = _get(f"{BASE}/api/groups?batch={self.batch_id}")
groups = r.json().get('groups', [])
real = [g for g in groups if g.get('contract_number') != '__unresolved__']
if not real:
print(" ✗ нет групп для сравнения")
return None
group = real[group_index]
print(f" ⚖ compare группы №{group.get('contract_number')}...")
# Применить группу
ar = _post(f"{BASE}/api/apply-groups", json={'batch_id': self.batch_id, 'groups': [group]})
ad = ar.json()
if not ad.get('ok') or not ad.get('contract_ids'):
print(f" ✗ apply failed: {ad}")
return None
cid = ad['contract_ids'][0]
# SSE compare
import sseclient # pip install sseclient-py
url = f"{BASE}/process-v2?contract_id={cid}"
response = requests.get(url, stream=True, timeout=300, verify=False)
client = sseclient.SSEClient(response)
sections = {}
total_ops = 0
for event in client.events():
d = json.loads(event.data)
t = d.get('type')
if t == 'extract_start':
sections[d['supplement_id']] = d['filename']
elif t == 'llm_done':
print(f"{sections.get(d['supplement_id'],'?')}: {d.get('ops_count',0)} оп. {d.get('mode','?')} ({d.get('time_s',0)}с)")
elif t == 'applied':
ops = d.get('ops', [])
total_ops += len(ops)
s = d.get('summary', {})
print(f" 📊 +{s.get('added',0)} ~{s.get('updated',0)} -{s.get('deleted',0)}")
elif t == 'done':
print(f" ✓ compare done: {d.get('total_time_s',0)}с, всего {total_ops} оп.")
return d
elif t == 'error':
print(f" ✗ compare error: {d.get('message','?')}")
return None
return None
def cleanup(self):
"""Очистить БД после теста."""
try:
_post(f"{BASE}/api/cleanup")
except Exception:
pass
def upload_files_from_dir(session, dir_path, pattern="*", delay=0.2):
"""Загрузить все файлы из папки."""
import glob
files = sorted(glob.glob(os.path.join(dir_path, pattern)))
files = [f for f in files if f.endswith(('.docx', '.doc', '.pdf', '.zip'))]
if not files:
print(f" (нет файлов в {dir_path})")
return
print(f" Загрузка {len(files)} файлов из {dir_path}...")
session.upload_files(files, delay)
+35
View File
@@ -0,0 +1,35 @@
"""
run.py — Точка входа симулятора.
Запуск: python3 sim/run.py [сценарий]
без аргументов — все сценарии A–F
A — хэппи-путь
B — зигзаг
...
"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from scenarios import run_all, Session, OUT
import scenarios
SCENARIOS = {
'A': scenarios.scenario_A_happy,
'B': scenarios.scenario_B_zigzag,
'C': scenarios.scenario_C_bulk,
'D': scenarios.scenario_D_stupid,
'E': scenarios.scenario_E_zips,
'F': scenarios.scenario_F_stress,
}
if __name__ == "__main__":
if len(sys.argv) > 1:
name = sys.argv[1].upper()
if name in SCENARIOS:
s = Session()
SCENARIOS[name](s)
s.cleanup()
else:
print(f"Неизвестный сценарий: {name}. Доступны: {', '.join(SCENARIOS)}")
else:
run_all()
+134
View File
@@ -0,0 +1,134 @@
"""
scenarios.py — Сценарии симуляции (A–F по схеме Опуса).
Каждый сценарий — функция, принимающая Session и пути к файлам.
"""
import time, os, glob, random
from actions import Session, upload_files_from_dir
OUT = os.path.join(os.path.dirname(__file__), "..", "testgen", "out")
def scenario_A_happy(session):
"""Хэппи-путь: загрузить контракт → classify → группы → compare."""
print("\n=== A: Хэппи-путь ===")
valid = os.path.join(OUT, "valid")
files = sorted(glob.glob(os.path.join(valid, "*XXX001*")))[:4] # договор + 2 спеки + 1 допник
session.upload_files(files)
session.classify()
session.get_groups()
session.apply_and_compare(0)
def scenario_B_zigzag(session):
"""Зигзаг: загрузил → удалил → добавил → classify."""
print("\n=== B: Зигзаг ===")
valid = os.path.join(OUT, "valid")
all_f = sorted(glob.glob(os.path.join(valid, "*.docx")))
# Загрузить 3
session.upload_files(all_f[:3])
# Удалить 1 (через sync — отправляем keep_ids без одного)
# Эмулируем: sync с текущим списком без первого
keep = [f['doc_id'] for f in session.files[1:]]
import requests
requests.post("https://contracts.kube5s.ru/api/sync", json={'keep_ids': keep}, timeout=10, verify=False)
session.files = session.files[1:]
print(f" ✕ удалён 1 файл, осталось {len(session.files)}")
# Добавить ещё 2
session.upload_files(all_f[3:5])
session.classify()
session.get_groups()
def scenario_C_bulk(session):
"""Bulk: загрузить все валидные разом."""
print("\n=== C: Bulk (все валидные) ===")
valid = os.path.join(OUT, "valid")
all_f = sorted(glob.glob(os.path.join(valid, "*.docx")))
session.upload_files(all_f, delay=0.1)
session.classify()
session.get_groups()
def scenario_D_stupid(session):
"""Тупые действия: дубликаты, удалить всё, мусор."""
print("\n=== D: Тупые действия ===")
valid = os.path.join(OUT, "valid")
errors = os.path.join(OUT, "errors")
# Загрузить файл дважды (эмуляция — API не спросит confirm)
f0 = sorted(glob.glob(os.path.join(valid, "*.docx")))[0]
session.upload_file(f0)
session.upload_file(f0) # дубликат — API перезапишет
print(f" дубликат: {len(session.files)} файлов (должен быть 1)")
# Мусор
trash = sorted(glob.glob(os.path.join(errors, "trash_*.docx")))[:3]
session.upload_files(trash)
# Удалить всё (sync с пустым списком)
import requests
requests.post("https://contracts.kube5s.ru/api/sync", json={'keep_ids': []}, timeout=10, verify=False)
session.files = []
session.contract_id = None
print(f" удалено всё: {len(session.files)} файлов")
# Загрузить заново
session.upload_files(sorted(glob.glob(os.path.join(valid, "*XXX005*"))))
session.classify()
def scenario_E_zips(session):
"""ZIP: загрузить архивы."""
print("\n=== E: ZIP-архивы ===")
zips = os.path.join(OUT, "zips")
all_z = sorted(glob.glob(os.path.join(zips, "*.zip")))
session.upload_files(all_z)
def scenario_F_stress(session):
"""Стресс: загрузить ошибки + мусор + валидные вперемешку."""
print("\n=== F: Стресс-микс ===")
valid = os.path.join(OUT, "valid")
errors = os.path.join(OUT, "errors")
mixed = []
mixed += sorted(glob.glob(os.path.join(valid, "*.docx")))[:5]
mixed += sorted(glob.glob(os.path.join(errors, "trash_*.docx")))[:5]
mixed += sorted(glob.glob(os.path.join(errors, "corrupt_xml.docx")))
mixed += sorted(glob.glob(os.path.join(errors, "orphan_*.docx")))[:2]
random.shuffle(mixed)
session.upload_files(mixed, delay=0.05)
session.classify()
session.get_groups()
def run_all():
"""Прогнать все сценарии."""
print("=" * 60)
print("СИМУЛЯТОР — сценарии A–F")
print("=" * 60)
scenarios = [
("A", scenario_A_happy),
("B", scenario_B_zigzag),
("C", scenario_C_bulk),
("D", scenario_D_stupid),
("E", scenario_E_zips),
("F", scenario_F_stress),
]
for name, fn in scenarios:
s = Session()
try:
fn(s)
print(f" ✅ сценарий {name} OK")
except Exception as e:
print(f" ❌ сценарий {name} FAIL: {e}")
finally:
s.cleanup()
time.sleep(1)
print("\n" + "=" * 60)
print("СИМУЛЯЦИЯ ЗАВЕРШЕНА")
print("=" * 60)
if __name__ == "__main__":
run_all()