docs: архитектурный анализ + History + gitignore (2026-06-27)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
Binary file not shown.
Binary file not shown.
+163
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user