feat(ingest+поиск): приём ZIP через интерфейс, регистронезависимый поиск, чистка очереди

- Загрузка архива (§4.1): POST /ingest распаковывает ZIP, дораспознаёт и
  догружает прайсы в базу (append). Виджет в админке. Кэш эмбеддингов справочника
  (dict_emb.npy) — догрузка считает только новый файл, эмбеддинги best-effort с
  откатом на fuzzy при сбое Gemini.
- Поиск по name_norm (lowercase): «УЗИ» и «узи» дают одинаковый результат —
  SQLite LIKE не сворачивает регистр для кириллицы.
- Фильтр названий: код-мнемоники («МОЗО.15») больше не попадают в очередь.
- /stats считается из базы — цифры на дашборде обновляются после загрузки.
- Деплой через Dockerfile (зависимости в образе, код монтируется томом).
- ruff: ядро и весь репозиторий проходят проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 11:35:12 +05:00
parent 89928511ba
commit 69a9884db5
25 changed files with 2217 additions and 182 deletions
+388 -95
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Генератор синтетических прайсов клиник (RU/KZ/EN) + эталонная нормализация + справочник канонов.
Выдаёт:
@@ -8,7 +7,13 @@
contracts/service_canon.json — справочник канонических услуг (seed для нормализации)
Детерминированно (seed=42).
"""
import csv, json, os, random, subprocess, sys
import csv
import json
import os
import random
import subprocess
import sys
ROOT = sys.argv[1] if len(sys.argv) > 1 else "."
RAW = os.path.join(ROOT, "fixtures/raw")
@@ -18,145 +23,433 @@ for d in (RAW, NORM, CONTRACTS):
os.makedirs(d, exist_ok=True)
random.seed(42)
def ensure(pkg, mod=None):
try: __import__(mod or pkg)
except ImportError: subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
try:
__import__(mod or pkg)
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
# Справочник канонических медуслуг. lang-тегированные синонимы — материал для нормализации.
CANONS = [
{"id":"cbc","ru":"Общий анализ крови","kz":"Қанның жалпы анализі","en":"Complete blood count (CBC)",
"category":"Лаборатория","code":"B03.016.003","unit":"1 иссл.","price":(1200,4500),
"syn":[("ОАК","ru"),("Общий анализ крови (развернутый)","ru"),("Клинический анализ крови","ru"),("КАК","ru"),("CBC","en"),("Қанның жалпы талдауы","kz")]},
{"id":"uzi_abd","ru":"УЗИ органов брюшной полости","kz":"Құрсақ қуысы ағзаларының УДЗ","en":"Abdominal ultrasound",
"category":"Диагностика/УЗИ","code":"A04.16.001","unit":"1 иссл.","price":(6000,13000),
"syn":[("УЗИ ОБП","ru"),("УЗИ брюшной полости","ru"),("Ультразвуковое исследование брюшной полости","ru"),("Құрсақ қуысы УДЗ","kz"),("Abdominal US","en")]},
{"id":"therapist","ru":"Приём врача-терапевта","kz":"Терапевт дәрігердің қабылдауы","en":"General practitioner consultation",
"category":"Приём специалистов","code":"B01.047.001","unit":"1 приём","price":(4000,9000),
"syn":[("Прием терапевта","ru"),("Консультация терапевта первичная","ru"),("Терапевт қабылдауы","kz"),("GP visit","en")]},
{"id":"ecg","ru":"Электрокардиография (ЭКГ)","kz":"Электрокардиография (ЭКГ)","en":"Electrocardiography (ECG)",
"category":"Диагностика","code":"A05.10.006","unit":"1 иссл.","price":(2000,5000),
"syn":[("ЭКГ","ru"),("ЭКГ с расшифровкой","ru"),("Электрокардиография","ru"),("ECG","en"),("ЭКГ + описание","ru")]},
{"id":"glucose","ru":"Глюкоза крови","kz":"Қандағы глюкоза","en":"Blood glucose",
"category":"Лаборатория","code":"B03.016.006","unit":"1 иссл.","price":(800,2200),
"syn":[("Глюкоза","ru"),("Сахар крови","ru"),("Қандағы глюкоза","kz"),("Glucose","en")]},
{"id":"cardiologist","ru":"Консультация кардиолога","kz":"Кардиолог кеңесі","en":"Cardiologist consultation",
"category":"Приём специалистов","code":"B01.015.001","unit":"1 приём","price":(5000,12000),
"syn":[("Прием кардиолога","ru"),("Кардиолог консультация первичная","ru"),("Кардиолог қабылдауы","kz")]},
{"id":"mri_brain","ru":"МРТ головного мозга","kz":"Бас миының МРТ","en":"Brain MRI",
"category":"Диагностика/МРТ","code":"A05.23.009","unit":"1 иссл.","price":(18000,35000),
"syn":[("МРТ г","ru"),("Магнитно-резонансная томография головного мозга","ru"),("Бас миы МРТ","kz"),("Brain MRI","en")]},
{"id":"biochem","ru":"Биохимический анализ крови","kz":"Қанның биохимиялық анализі","en":"Blood chemistry panel",
"category":"Лаборатория","code":"B03.016.004","unit":"1 иссл.","price":(3500,9000),
"syn":[("Биохимия крови","ru"),("Б/х крови","ru"),("Биохимический анализ крови (базовый)","ru"),("Қан биохимиясы","kz")]},
{"id":"tooth_ext","ru":"Удаление зуба","kz":"Тісті жұлу","en":"Tooth extraction",
"category":"Стоматология","code":"A16.07.001","unit":"1 зуб","price":(6000,15000),
"syn":[("Удаление зуба простое","ru"),("Экстракция зуба","ru"),("Тіс жұлу","kz"),("Tooth removal","en")]},
{"id":"xray_chest","ru":"Рентген грудной клетки","kz":"Кеуде клеткасының рентгені","en":"Chest X-ray",
"category":"Диагностика","code":"A06.09.007","unit":"1 иссл.","price":(2500,6000),
"syn":[("Рентгенография ОГК","ru"),("Флюорография","ru"),("Кеуде рентгені","kz"),("Chest X-ray","en")]},
{"id":"tsh","ru":"Тиреотропный гормон (ТТГ)","kz":"Тиреотропты гормон (ТТГ)","en":"Thyroid-stimulating hormone (TSH)",
"category":"Лаборатория","code":"B03.016.021","unit":"1 иссл.","price":(1500,4000),
"syn":[("ТТГ","ru"),("Тиреотропный гормон","ru"),("TSH","en"),("ТТГ гормоны","kz")]},
{"id":"urinalysis","ru":"Общий анализ мочи","kz":"Зәрдің жалпы анализі","en":"Urinalysis",
"category":"Лаборатория","code":"B03.016.010","unit":"1 иссл.","price":(900,2500),
"syn":[("ОАМ","ru"),("Общий анализ мочи","ru"),("Зәр анализі","kz"),("Urine test","en")]},
{"id":"gyn","ru":"Приём гинеколога","kz":"Гинеколог қабылдауы","en":"Gynecologist consultation",
"category":"Приём специалистов","code":"B01.001.001","unit":"1 приём","price":(5000,11000),
"syn":[("Прием гинеколога","ru"),("Консультация гинеколога первичная","ru"),("Гинеколог кеңесі","kz")]},
{"id":"covid_pcr","ru":"ПЦР на COVID-19","kz":"COVID-19 ПТР","en":"COVID-19 PCR test",
"category":"Лаборатория","code":"A26.08.027","unit":"1 иссл.","price":(7000,16000),
"syn":[("ПЦР COVID-19","ru"),("ПЦР-тест на коронавирус","ru"),("COVID PCR","en"),("COVID ПТР","kz")]},
{"id":"vit_d","ru":"Витамин D (25-OH)","kz":"D дәрумені (25-OH)","en":"Vitamin D (25-OH)",
"category":"Лаборатория","code":"B03.016.115","unit":"1 иссл.","price":(6000,12000),
"syn":[("25-OH витамин D","ru"),("Витамин Д","ru"),("Vitamin D","en"),("D витамині","kz")]},
{
"id": "cbc",
"ru": "Общий анализ крови",
"kz": "Қанның жалпы анализі",
"en": "Complete blood count (CBC)",
"category": "Лаборатория",
"code": "B03.016.003",
"unit": "1 иссл.",
"price": (1200, 4500),
"syn": [
("ОАК", "ru"),
("Общий анализ крови (развернутый)", "ru"),
("Клинический анализ крови", "ru"),
("КАК", "ru"),
("CBC", "en"),
("Қанның жалпы талдауы", "kz"),
],
},
{
"id": "uzi_abd",
"ru": "УЗИ органов брюшной полости",
"kz": "Құрсақ қуысы ағзаларының УДЗ",
"en": "Abdominal ultrasound",
"category": "Диагностика/УЗИ",
"code": "A04.16.001",
"unit": "1 иссл.",
"price": (6000, 13000),
"syn": [
("УЗИ ОБП", "ru"),
("УЗИ брюшной полости", "ru"),
("Ультразвуковое исследование брюшной полости", "ru"),
("Құрсақ қуысы УДЗ", "kz"),
("Abdominal US", "en"),
],
},
{
"id": "therapist",
"ru": "Приём врача-терапевта",
"kz": "Терапевт дәрігердің қабылдауы",
"en": "General practitioner consultation",
"category": "Приём специалистов",
"code": "B01.047.001",
"unit": "1 приём",
"price": (4000, 9000),
"syn": [
("Прием терапевта", "ru"),
("Консультация терапевта первичная", "ru"),
("Терапевт қабылдауы", "kz"),
("GP visit", "en"),
],
},
{
"id": "ecg",
"ru": "Электрокардиография (ЭКГ)",
"kz": "Электрокардиография (ЭКГ)",
"en": "Electrocardiography (ECG)",
"category": "Диагностика",
"code": "A05.10.006",
"unit": "1 иссл.",
"price": (2000, 5000),
"syn": [
("ЭКГ", "ru"),
("ЭКГ с расшифровкой", "ru"),
("Электрокардиография", "ru"),
("ECG", "en"),
("ЭКГ + описание", "ru"),
],
},
{
"id": "glucose",
"ru": "Глюкоза крови",
"kz": "Қандағы глюкоза",
"en": "Blood glucose",
"category": "Лаборатория",
"code": "B03.016.006",
"unit": "1 иссл.",
"price": (800, 2200),
"syn": [
("Глюкоза", "ru"),
("Сахар крови", "ru"),
("Қандағы глюкоза", "kz"),
("Glucose", "en"),
],
},
{
"id": "cardiologist",
"ru": "Консультация кардиолога",
"kz": "Кардиолог кеңесі",
"en": "Cardiologist consultation",
"category": "Приём специалистов",
"code": "B01.015.001",
"unit": "1 приём",
"price": (5000, 12000),
"syn": [
("Прием кардиолога", "ru"),
("Кардиолог консультация первичная", "ru"),
("Кардиолог қабылдауы", "kz"),
],
},
{
"id": "mri_brain",
"ru": "МРТ головного мозга",
"kz": "Бас миының МРТ",
"en": "Brain MRI",
"category": "Диагностика/МРТ",
"code": "A05.23.009",
"unit": "1 иссл.",
"price": (18000, 35000),
"syn": [
("МРТ г", "ru"),
("Магнитно-резонансная томография головного мозга", "ru"),
("Бас миы МРТ", "kz"),
("Brain MRI", "en"),
],
},
{
"id": "biochem",
"ru": "Биохимический анализ крови",
"kz": "Қанның биохимиялық анализі",
"en": "Blood chemistry panel",
"category": "Лаборатория",
"code": "B03.016.004",
"unit": "1 иссл.",
"price": (3500, 9000),
"syn": [
("Биохимия крови", "ru"),
("Б/х крови", "ru"),
("Биохимический анализ крови (базовый)", "ru"),
("Қан биохимиясы", "kz"),
],
},
{
"id": "tooth_ext",
"ru": "Удаление зуба",
"kz": "Тісті жұлу",
"en": "Tooth extraction",
"category": "Стоматология",
"code": "A16.07.001",
"unit": "1 зуб",
"price": (6000, 15000),
"syn": [
("Удаление зуба простое", "ru"),
("Экстракция зуба", "ru"),
("Тіс жұлу", "kz"),
("Tooth removal", "en"),
],
},
{
"id": "xray_chest",
"ru": "Рентген грудной клетки",
"kz": "Кеуде клеткасының рентгені",
"en": "Chest X-ray",
"category": "Диагностика",
"code": "A06.09.007",
"unit": "1 иссл.",
"price": (2500, 6000),
"syn": [
("Рентгенография ОГК", "ru"),
("Флюорография", "ru"),
("Кеуде рентгені", "kz"),
("Chest X-ray", "en"),
],
},
{
"id": "tsh",
"ru": "Тиреотропный гормон (ТТГ)",
"kz": "Тиреотропты гормон (ТТГ)",
"en": "Thyroid-stimulating hormone (TSH)",
"category": "Лаборатория",
"code": "B03.016.021",
"unit": "1 иссл.",
"price": (1500, 4000),
"syn": [("ТТГ", "ru"), ("Тиреотропный гормон", "ru"), ("TSH", "en"), ("ТТГ гормоны", "kz")],
},
{
"id": "urinalysis",
"ru": "Общий анализ мочи",
"kz": "Зәрдің жалпы анализі",
"en": "Urinalysis",
"category": "Лаборатория",
"code": "B03.016.010",
"unit": "1 иссл.",
"price": (900, 2500),
"syn": [
("ОАМ", "ru"),
("Общий анализ мочи", "ru"),
("Зәр анализі", "kz"),
("Urine test", "en"),
],
},
{
"id": "gyn",
"ru": "Приём гинеколога",
"kz": "Гинеколог қабылдауы",
"en": "Gynecologist consultation",
"category": "Приём специалистов",
"code": "B01.001.001",
"unit": "1 приём",
"price": (5000, 11000),
"syn": [
("Прием гинеколога", "ru"),
("Консультация гинеколога первичная", "ru"),
("Гинеколог кеңесі", "kz"),
],
},
{
"id": "covid_pcr",
"ru": "ПЦР на COVID-19",
"kz": "COVID-19 ПТР",
"en": "COVID-19 PCR test",
"category": "Лаборатория",
"code": "A26.08.027",
"unit": "1 иссл.",
"price": (7000, 16000),
"syn": [
("ПЦР COVID-19", "ru"),
("ПЦР-тест на коронавирус", "ru"),
("COVID PCR", "en"),
("COVID ПТР", "kz"),
],
},
{
"id": "vit_d",
"ru": "Витамин D (25-OH)",
"kz": "D дәрумені (25-OH)",
"en": "Vitamin D (25-OH)",
"category": "Лаборатория",
"code": "B03.016.115",
"unit": "1 иссл.",
"price": (6000, 12000),
"syn": [
("25-OH витамин D", "ru"),
("Витамин Д", "ru"),
("Vitamin D", "en"),
("D витамині", "kz"),
],
},
]
CLINICS = [
"Клиника «Аль-Шифа»",
"Медцентр «Сункар»",
"Diagnostic Lab Astana",
"Клиника «Береке»",
"Медцентр «Тенгри»",
]
CLINICS = ["Клиника «Аль-Шифа»","Медцентр «Сункар»","Diagnostic Lab Astana","Клиника «Береке»","Медцентр «Тенгри»"]
def price(c): return random.randint(c["price"][0]//100, c["price"][1]//100) * 100
def variant(c): return random.choice([(c["ru"],"ru")] + c["syn"])
def price(c):
return random.randint(c["price"][0] // 100, c["price"][1] // 100) * 100
def variant(c):
return random.choice([(c["ru"], "ru")] + c["syn"])
GOLDEN = []
def build(file_name, clinic, n):
rows = []
for c in random.sample(CANONS, k=min(n, len(CANONS))):
raw_name, lang = variant(c); p = price(c)
rows.append({"raw_name":raw_name,"price":p,"unit":c["unit"]})
GOLDEN.append({"source_file":file_name,"clinic":clinic,"raw_name":raw_name,"lang":lang,
"canon_id":c["id"],"canonical_ru":c["ru"],"canonical_kz":c["kz"],"canonical_en":c["en"],
"category":c["category"],"code":c["code"],"price":p,"currency":"KZT","unit":c["unit"]})
raw_name, lang = variant(c)
p = price(c)
rows.append({"raw_name": raw_name, "price": p, "unit": c["unit"]})
GOLDEN.append(
{
"source_file": file_name,
"clinic": clinic,
"raw_name": raw_name,
"lang": lang,
"canon_id": c["id"],
"canonical_ru": c["ru"],
"canonical_kz": c["kz"],
"canonical_en": c["en"],
"category": c["category"],
"code": c["code"],
"price": p,
"currency": "KZT",
"unit": c["unit"],
}
)
return rows
ensure("openpyxl"); from openpyxl import Workbook
ensure("openpyxl")
from openpyxl import Workbook
# 1) чистый Excel
rows = build("clinic_alpha_clean.xlsx", CLINICS[0], 9)
wb=Workbook(); ws=wb.active; ws.title="Прайс"; ws.append(["Наименование услуги","Цена, тг"])
for r in rows: ws.append([r["raw_name"], r["price"]])
wb = Workbook()
ws = wb.active
ws.title = "Прайс"
ws.append(["Наименование услуги", "Цена, тг"])
for r in rows:
ws.append([r["raw_name"], r["price"]])
wb.save(f"{RAW}/clinic_alpha_clean.xlsx")
# 2) грязный Excel
rows = build("clinic_beta_dirty.xlsx", CLINICS[1], 9)
wb=Workbook(); ws=wb.active; ws.title="PRICE 2026"
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"]); ws.append([])
ws.append(["","Услуга / Қызмет","Стоимость (тенге)","Ед."])
secs=["— Лаборатория / Зертхана —","— Диагностика —","— Приём специалистов —"]; i=1
for k,r in enumerate(rows):
if k%3==0 and secs: ws.append([secs.pop(0),None,None,None])
val=f"от {r['price']}" if random.random()<0.3 else r["price"]
ws.append([i, r["raw_name"], val, r["unit"]]); i+=1
wb = Workbook()
ws = wb.active
ws.title = "PRICE 2026"
ws.append(["Прайс-лист медцентра «Сункар» (с 01.06.2026)"])
ws.append([])
ws.append(["", "Услуга / Қызмет", "Стоимость (тенге)", "Ед."])
secs = ["— Лаборатория / Зертхана —", "— Диагностика —", "— Приём специалистов —"]
i = 1
for k, r in enumerate(rows):
if k % 3 == 0 and secs:
ws.append([secs.pop(0), None, None, None])
val = f"от {r['price']}" if random.random() < 0.3 else r["price"]
ws.append([i, r["raw_name"], val, r["unit"]])
i += 1
wb.save(f"{RAW}/clinic_beta_dirty.xlsx")
# 3) CSV ;
rows = build("clinic_gamma.csv", CLINICS[2], 8)
with open(f"{RAW}/clinic_gamma.csv","w",encoding="utf-8",newline="") as f:
w=csv.writer(f,delimiter=";"); w.writerow(["service","price_kzt","unit"])
for r in rows: w.writerow([r["raw_name"], r["price"], r["unit"]])
with open(f"{RAW}/clinic_gamma.csv", "w", encoding="utf-8", newline="") as f:
w = csv.writer(f, delimiter=";")
w.writerow(["service", "price_kzt", "unit"])
for r in rows:
w.writerow([r["raw_name"], r["price"], r["unit"]])
# 4) грязный текст
rows = build("clinic_delta_messy.txt", CLINICS[4], 7)
with open(f"{RAW}/clinic_delta_messy.txt","w",encoding="utf-8") as f:
with open(f"{RAW}/clinic_delta_messy.txt", "w", encoding="utf-8") as f:
f.write("МЕДЦЕНТР «ТЕНГРИ» — действующие цены\n\n")
for r in rows:
sep=random.choice([""," ... ",": "," "]); cur=random.choice(["тг","тенге","","KZT"])
sep = random.choice(["", " ... ", ": ", " "])
cur = random.choice(["тг", "тенге", "", "KZT"])
f.write(f"{r['raw_name']}{sep}{r['price']} {cur} ({r['unit']})\n")
f.write("\n* Цены справочные, уточняйте в регистратуре. Бағалар анықтамалық.\n")
# 5) PNG-скан
rows = build("clinic_epsilon_scan.png", CLINICS[3], 9)
ensure("Pillow","PIL"); from PIL import Image, ImageDraw, ImageFont
ensure("Pillow", "PIL")
from PIL import Image, ImageDraw, ImageFont
def font(sz):
for p in ["/System/Library/Fonts/Supplemental/Arial.ttf","/Library/Fonts/Arial.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
if os.path.exists(p): return ImageFont.truetype(p,sz)
for p in [
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]:
if os.path.exists(p):
return ImageFont.truetype(p, sz)
return ImageFont.load_default()
img=Image.new("RGB",(900,720),(250,248,244)); d=ImageDraw.Draw(img)
d.text((40,30),"Клиника «Береке» — прайс-лист",fill=(20,20,20),font=font(34)); d.line((40,85,860,85),fill=(180,180,180),width=2)
y=110
img = Image.new("RGB", (900, 720), (250, 248, 244))
d = ImageDraw.Draw(img)
d.text((40, 30), "Клиника «Береке» — прайс-лист", fill=(20, 20, 20), font=font(34))
d.line((40, 85, 860, 85), fill=(180, 180, 180), width=2)
y = 110
for r in rows:
d.text((50,y),r["raw_name"][:42],fill=(30,30,30),font=font(23)); d.text((720,y),f"{r['price']} тг",fill=(30,30,30),font=font(23)); y+=62
img=img.rotate(-1.4,expand=False,fillcolor=(250,248,244)); img.save(f"{RAW}/clinic_epsilon_scan.png")
d.text((50, y), r["raw_name"][:42], fill=(30, 30, 30), font=font(23))
d.text((720, y), f"{r['price']} тг", fill=(30, 30, 30), font=font(23))
y += 62
img = img.rotate(-1.4, expand=False, fillcolor=(250, 248, 244))
img.save(f"{RAW}/clinic_epsilon_scan.png")
# 6) PDF
rows = build("clinic_zeta_prices.pdf", CLINICS[2], 10)
pdf="skip"
pdf = "skip"
try:
ensure("reportlab")
from reportlab.lib.pagesizes import A4; from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics; from reportlab.pdfbase.ttfonts import TTFont
fp=next((p for p in ["/Library/Fonts/Arial.ttf","/System/Library/Fonts/Supplemental/Arial.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"] if os.path.exists(p)),None)
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
fp = next(
(
p
for p in [
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
if os.path.exists(p)
),
None,
)
if fp:
pdfmetrics.registerFont(TTFont("U",fp)); c=canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf",pagesize=A4)
c.setFont("U",18); c.drawString(60,790,"Diagnostic Lab Astana — Price list / Прайс"); c.setFont("U",12); y=750
for r in rows: c.drawString(60,y,r["raw_name"][:55]); c.drawRightString(540,y,f"{r['price']} KZT"); y-=28
c.save(); pdf="ok"
except Exception as e: pdf=f"skip ({e})"
pdfmetrics.registerFont(TTFont("U", fp))
c = canvas.Canvas(f"{RAW}/clinic_zeta_prices.pdf", pagesize=A4)
c.setFont("U", 18)
c.drawString(60, 790, "Diagnostic Lab Astana — Price list / Прайс")
c.setFont("U", 12)
y = 750
for r in rows:
c.drawString(60, y, r["raw_name"][:55])
c.drawRightString(540, y, f"{r['price']} KZT")
y -= 28
c.save()
pdf = "ok"
except Exception as e:
pdf = f"skip ({e})"
# эталон нормализации + справочник
with open(f"{NORM}/golden.json","w",encoding="utf-8") as f:
with open(f"{NORM}/golden.json", "w", encoding="utf-8") as f:
json.dump(GOLDEN, f, ensure_ascii=False, indent=2)
canon_out=[{"canon_id":c["id"],"canonical_ru":c["ru"],"canonical_kz":c["kz"],"canonical_en":c["en"],
"category":c["category"],"code":c["code"],"unit":c["unit"],
"synonyms":[{"text":t,"lang":l} for t,l in c["syn"]]} for c in CANONS]
with open(f"{CONTRACTS}/service_canon.json","w",encoding="utf-8") as f:
canon_out = [
{
"canon_id": c["id"],
"canonical_ru": c["ru"],
"canonical_kz": c["kz"],
"canonical_en": c["en"],
"category": c["category"],
"code": c["code"],
"unit": c["unit"],
"synonyms": [{"text": t, "lang": l} for t, l in c["syn"]],
}
for c in CANONS
]
with open(f"{CONTRACTS}/service_canon.json", "w", encoding="utf-8") as f:
json.dump(canon_out, f, ensure_ascii=False, indent=2)
print("PDF:", pdf)