From dbe36d3323746672914a07923b3275f69527a08c Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Sat, 27 Jun 2026 19:42:21 +0500 Subject: [PATCH] =?UTF-8?q?fix(normalize):=20confidence-aware=20guard=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BE=D0=B1=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=E2=86=92=20=D1=87=D0=B5=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=2073,7%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прежний guard снимал эмбеддинг-матч, если услуга собрала в клинике больше 8 разных названий. Это наказывало легитимные частотные услуги — одна «МРТ головного мозга» реально встречается под десятками протоколов (с контрастом, 3Тл, предоперационная), и все они верно нормализуются к ней. Процент падал до 69%. Теперь снимаем только НЕуверенные матчи (<0,72) внутри концентрированных групп: так отсекается сиблинг-захват (напр. «Магний Mg (моча)» подтягивал Никель/Свинец/Ртуть в моче на пороге 0,70), а верные вариации остаются сопоставленными. Признак ошибки — низкая уверенность, а не число названий. Итог на тестовом архиве: 16 140 позиций извлечено, 73,7% нормализовано автоматически (цель ТЗ ≥70%), 273 неуверенных матча — в очередь верификации. Цифры выровнены в README, ARCHITECTURE, питч-деках, PPTX, quality_report. Плюс полировка: извлечение города клиники, регистронезависимый поиск. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 ++ README.md | 4 +- api/main.py | 8 ++- docs/ARCHITECTURE.md | 6 +-- docs/quality_report.json | 20 ++++---- etl/extractors/readers.py | 18 +++++++ etl/pipeline.py | 79 +++++++++++++++++++++++++++--- pitch/MedArchive_команда_107.pptx | Bin 1725045 -> 1725045 bytes pitch/day2/index.html | 6 +-- pitch/landing/index.html | 4 +- 10 files changed, 120 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 81e244b..5a42876 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Thumbs.db # Данные клиник: реальный архив и справочник в публичный репозиторий не коммитим data/ *.sqlite + +# Внутренние рабочие доки хакатона (план, спеки, дизайн) — не в публичный репо +_internal/ diff --git a/README.md b/README.md index abeb298..9c7ee52 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ XLSX/XLS), извлекает услуги и цены, нормализует ## Результат на тестовом архиве (8 клиник, 10 файлов) -- **15 086 позиций** извлечено: нативными парсерами + трудные PDF (битый текстовый слой, +- **16 140 позиций** извлечено: нативными парсерами + трудные PDF (битый текстовый слой, нестандартная вёрстка) — через Gemini Vision; обработаны все 8 клиник. -- **76,1% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%). +- **73,7% позиций нормализованы автоматически** (цель ТЗ — не ниже 70%). - Остальное уходит в очередь ручной разметки, как и предполагает ТЗ. Полный отчёт о качестве — `docs/quality_report.json`. diff --git a/api/main.py b/api/main.py index e50ec0b..a0e2258 100644 --- a/api/main.py +++ b/api/main.py @@ -413,11 +413,15 @@ def price_changes(threshold: float = 10.0, limit: int = 100): SELECT p.name AS partner_name, a.service_name_raw, s.name_ru AS service_ru, a.price_resident AS new_price, a.effective_date AS new_date, (SELECT b.price_resident FROM price_item b - WHERE b.partner_id = a.partner_id AND b.service_name_raw = a.service_name_raw + WHERE b.partner_id = a.partner_id + AND ((a.service_id IS NOT NULL AND b.service_id = a.service_id) + OR (a.service_id IS NULL AND b.service_name_raw = a.service_name_raw)) AND b.is_active = 0 AND b.price_resident IS NOT NULL ORDER BY b.effective_date DESC LIMIT 1) AS old_price, (SELECT b.effective_date FROM price_item b - WHERE b.partner_id = a.partner_id AND b.service_name_raw = a.service_name_raw + WHERE b.partner_id = a.partner_id + AND ((a.service_id IS NOT NULL AND b.service_id = a.service_id) + OR (a.service_id IS NULL AND b.service_name_raw = a.service_name_raw)) AND b.is_active = 0 AND b.price_resident IS NOT NULL ORDER BY b.effective_date DESC LIMIT 1) AS old_date FROM price_item a diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 822ffd5..2e6e622 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,9 +116,9 @@ HNSW-индексом, в остальном модель та же. пересчитывается, эмбеддится только новый файл. Подтверждение оператора пишет `learned_synonym` — следующий прогон сопоставит синоним автоматически (система дообучается). -**Качество на тестовом архиве** (8 клиник, 10 файлов): 15 086 позиций извлечено, **76,1% -нормализовано автоматически** (код 4397, точное 254, эмбеддинги 6717, нечёткое 110), остальное — -в очередь. +**Качество на тестовом архиве** (8 клиник, 10 файлов): 16 140 позиций извлечено, **73,7% +нормализовано автоматически** (код 4397, точное 291, эмбеддинги 7083, нечёткое 128), плюс 273 неуверенных +матча сняты guard'ом переобобщения в очередь. --- diff --git a/docs/quality_report.json b/docs/quality_report.json index 1c07a49..15b8695 100644 --- a/docs/quality_report.json +++ b/docs/quality_report.json @@ -1,15 +1,15 @@ { "documents": 10, "partners": 8, - "items": 8871, - "auto_matched": 6555, - "auto_matched_pct": 73.9, - "unmatched": 2316, + "items": 16140, + "auto_matched": 11899, + "auto_matched_pct": 73.7, + "unmatched": 4241, "by_method": { - "code": 1414, - "exact": 167, - "embedding": 4903, - "fuzzy": 71, - "null": 2316 + "code": 4397, + "exact": 291, + "embedding": 7083, + "fuzzy": 128, + "over_match": 273 } -} \ No newline at end of file +} diff --git a/etl/extractors/readers.py b/etl/extractors/readers.py index 40c9a77..0a06da9 100644 --- a/etl/extractors/readers.py +++ b/etl/extractors/readers.py @@ -28,6 +28,24 @@ def partner_from_name(name: str) -> str: return re.sub(r"\s+", " ", base).strip() +_KZ_CITIES = { + "алмат": "Алматы", "астан": "Астана", "нур-султан": "Астана", "шымкент": "Шымкент", + "караганд": "Караганда", "актобе": "Актобе", "павлодар": "Павлодар", "семей": "Семей", + "усть-камен": "Усть-Каменогорск", "костанай": "Костанай", "атырау": "Атырау", "тараз": "Тараз", + "уральск": "Уральск", "кокшетау": "Кокшетау", "петропавл": "Петропавловск", + "кызылорд": "Кызылорда", "талдыкорган": "Талдыкорган", "актау": "Актау", +} + + +def city_from_text(text: str | None) -> str | None: + """Город партнёра по тексту документа (БИН в казахстанских прайсах обычно не печатают).""" + low = (text or "").lower() + for stem, name in _KZ_CITIES.items(): + if stem in low: + return name + return None + + def detect_format(path: str) -> str: ext = Path(path).suffix.lower() return {".xlsx": "xlsx", ".xls": "xls", ".docx": "docx", ".pdf": "pdf"}.get( diff --git a/etl/pipeline.py b/etl/pipeline.py index 08e48ee..1b39852 100644 --- a/etl/pipeline.py +++ b/etl/pipeline.py @@ -24,7 +24,7 @@ import numpy as np sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from etl.dictionary import load_dictionary, normalize_name -from etl.extractors import extract +from etl.extractors import extract, readers from etl.normalize import Matcher from etl.validate import validate_item @@ -104,7 +104,7 @@ def _process_documents(conn, data_dir, matcher, partners, today, use_vision) -> partners[partner_norm] = partner_id conn.execute( "INSERT INTO partner VALUES (?,?,?,?)", - (partner_id, partner_name, partner_norm, None), + (partner_id, partner_name, partner_norm, readers.city_from_text(doc.raw_content)), ) report["new_partners"] += 1 @@ -161,11 +161,55 @@ def _process_documents(conn, data_dir, matcher, partners, today, use_vision) -> return report +_OVERMATCH_MAX_NAMES = 8 # «концентрация»: услуга собрала в клинике > стольких разных названий +_OVERMATCH_MIN_CONF = 0.72 # внутри концентрации снимаем только НЕуверенные матчи (сиблинг-захват) + + +def _guard_over_matching( + conn, max_names: int = _OVERMATCH_MAX_NAMES, min_conf: float = _OVERMATCH_MIN_CONF +) -> int: + """Снять ложные эмбеддинг-матчи переобобщения. Если услуга «поглотила» в клинике много + разных сырых названий И матч НЕуверенный — это сиблинг-захват: эмбеддинг подтянул соседнюю, + но другую услугу (напр. «Магний Mg (моча)» собирает Никель/Свинец/Ртуть в моче на пороге + ~0.70). Такие позиции возвращаем в очередь, сохранив кандидата оператору. + + Число поглощённых названий само по себе НЕ признак ошибки: у крупной клиники одна услуга + реально встречается под десятками формулировок («МРТ головного мозга с контрастом 3Тл», + «первичный приём гинеколога»). Эти уверенные вариации остаются сопоставленными — поэтому + решает порог уверенности, а не счётчик. + """ + cur = conn.execute( + """UPDATE price_item + SET suggested_service_id = service_id, suggested_score = map_confidence, + service_id = NULL, map_method = 'over_match' + WHERE map_method = 'embedding' AND service_id IS NOT NULL + AND map_confidence < ? + AND (partner_id, service_id) IN ( + SELECT partner_id, service_id FROM price_item + WHERE map_method = 'embedding' AND service_id IS NOT NULL + GROUP BY partner_id, service_id + HAVING COUNT(DISTINCT service_name_raw) > ?)""", + [min_conf, max_names], + ) + return cur.rowcount + + def _apply_versioning(conn) -> None: - """У партнёра по той же услуге более свежий прайс вытесняет старые (is_active = 0).""" + """Более свежий прайс клиники вытесняет старые (is_active = 0). Сопоставленные позиции + версионируются по услуге справочника (имена в разных прайсах могут отличаться — + напр. native 2026 vs Vision 2025), несопоставленные — по сырому названию. + """ conn.execute( """UPDATE price_item SET is_active = 0 - WHERE effective_date IS NOT NULL AND EXISTS ( + WHERE effective_date IS NOT NULL AND service_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM price_item b + WHERE b.partner_id = price_item.partner_id + AND b.service_id = price_item.service_id + AND b.effective_date > price_item.effective_date)""" + ) + conn.execute( + """UPDATE price_item SET is_active = 0 + WHERE effective_date IS NOT NULL AND service_id IS NULL AND EXISTS ( SELECT 1 FROM price_item b WHERE b.partner_id = price_item.partner_id AND b.service_name_raw = price_item.service_name_raw @@ -189,6 +233,27 @@ def _summary(report: Counter) -> dict: } +def _summary_db(conn) -> dict: + """Сводка по всей базе — считается ПОСЛЕ guard, чтобы процент отражал реальность.""" + total = conn.execute("SELECT COUNT(*) FROM price_item").fetchone()[0] + matched = conn.execute( + "SELECT COUNT(*) FROM price_item WHERE service_id IS NOT NULL" + ).fetchone()[0] + by_method = { + m: conn.execute("SELECT COUNT(*) FROM price_item WHERE map_method = ?", [m]).fetchone()[0] + for m in ("code", "exact", "embedding", "fuzzy", "over_match") + } + return { + "documents": conn.execute("SELECT COUNT(*) FROM price_document").fetchone()[0], + "partners": conn.execute("SELECT COUNT(*) FROM partner").fetchone()[0], + "items": total, + "auto_matched": matched, + "auto_matched_pct": round(100 * matched / total, 1) if total else 0.0, + "unmatched": total - matched, + "by_method": by_method, + } + + def run( data_dir: str, db_path: str, @@ -208,10 +273,11 @@ def run( conn = _open(db_path) _load_services(conn, services) partners: dict[str, str] = {} - report = _process_documents(conn, data_dir, matcher, partners, today, use_vision) + _process_documents(conn, data_dir, matcher, partners, today, use_vision) + _guard_over_matching(conn) # снять ложные эмбеддинг-матчи до подсчёта процента _apply_versioning(conn) conn.commit() - summary = _summary(report) + summary = _summary_db(conn) conn.close() return summary @@ -248,6 +314,7 @@ def ingest( for row in conn.execute("SELECT partner_id, name_norm FROM partner") } report = _process_documents(conn, data_dir, matcher, partners, today, use_vision) + _guard_over_matching(conn) # снять ложные эмбеддинг-матчи и у догрузки _apply_versioning(conn) conn.commit() summary = _summary(report) diff --git a/pitch/MedArchive_команда_107.pptx b/pitch/MedArchive_команда_107.pptx index 034aa48525873f1ad7da0c0415e303591433e87e..3f6de52681b3ef6d77660912f7d30ddb41d0f8af 100644 GIT binary patch delta 3018 zcmYjTc|6qlA0A_79OKM6U$+!F7ljHeUXS)7)UeEXae4h95`TX;J)60YCYJR3_Eapz1J?#knFuxjuut8xFFIz5`^m{RhAn%p=A79VPuLKLA=y6d2#6FlClxtaA9PL|(N4cuBGHPeE&d z<|)M2?2V%=Yum9Ta4?du`7yGo?RTq{ktAsS!`GZO*IA2cmW|aH&F*Upd~HI?bYMwd zj=bOM7o1{AI5*T+7dgwGS-;ckDeGc&_6e|71O(D;!n)W)@BS~TpTS8JenCJ%P6=3J zrAGxbz%b(C5E#~5gd4LsF-D$)2Yja4HG@-hQbF+{vZ*5guYZC6%*phTlJj&(i1;Be zWjuZx$!ZKSOA)c+fb2VPQ#Lz|Y%%+3^L7}63`G#)Z#bBSXmZ|#> z5JxErf`U8o%U!yQ|IKc2QMU4J87kEp1_QnDE(`q+j0Zm69~l!%qq|Uy62D3RAA}p@ z;s|w5{O5k}jo$I3IN0IqnB0l#tei{ZjUr9m&;oU|Hu7AxSX!bTUNnR%vZblQ-|V9* z{_|SmZfWk2gv9T1kG!4?oJf5Y+G!D#!~N`TQl_$usbhG^)i~iO4zZY)_qU|r+Lh;b zUy(U$wmcka1EZ`xh9cGBPt?kkE?@|hll+z5Yj9(UEUh|!bq}cbq*0M|ck*`pZ#GSq z$%A6}wK@J%_!A*W;9tA4&9hPm{T)s$o-V7v5ciNO*?+WRdv40fE)t*Wmj+HZ^u_(E ziaRB<;fJd5PY861=9s1|@L2W^_up1IQs``9bgkZ3_oRIa(yf~MQ##N3^_W+LRCHGh z^eRoS`FhBK*9S7@eWqG97i>;T#C}}#^$~hq$e7^jWo3*#Gg)3CF8_4Lem zJ13)#KgSd7VN+cQOh=%YhE8WyW_-0)TCm{^rk1+npHfZT`8B1XQ0uId#&7QimjdTh z-!$JxyWG|-n{RLRiq+SYIoK0Ku-dM*e&CfNb=6NQCKcutoVRrd=bk0QwYDNgdiic3 ztKR)+vubXxU(g#R;Z?Oi7cOXXHrKgZybQ`|0Me~|aV}=>Dgt?y?2vmw8#aoW=QW8l z=NeWb?oZCmrwLHErgq#HRiWDmm9Zpo$&ylPT2G2-LBuAkQ)85&g1s<72)F~?Q@*mR zkT&BWH_>4(;(3RtNU*}%zY-I@a(t)G9_|l6e$2m7nONn6dUOTj<)R{``NnMfjoF^rq*c->`d6%n<8sz%z3I1l>kfHiglBREGuiX7p~0Ze0_5DHXqLBq zWM+9=Spv3c?5FnH?XjDHaC4jMVx%|!cq#BppPHnp>+^Bjwbfxu{PZCM%zjzc=-$d? zbH-Tuikkzc+`ao-QuG7UGJM17JqtM|4Z_l^ zXEt3=bPw>20bwipg!hJG%i%lIJNQ-3muVKRzR`B@I-bzWk3RjwWA%AYs4gd}5S~C2 znKHfHPA_yM|Nhk10wDuLpt?gX{Y+f7Mq*bE;*)Y(0&HIN`M+<>-moWk&MEDu8zxeW zi2e-e3fX(O5uT>K6%k?(szRo-Ctfm+XTT&S+8Fn?*H>+cFjMlk)zvXB{Z_i59ukIp zhaXvnk1ltq-SCL}`%6l??8aXr=gk5g2a9S!NKW zo#_;yvLSLM3Urm5Ay3UZv#2Hpeu|ggitfDR$0+UV?#@t3Q)1p3 z(RiiCHWBb}LhGzN|1*w*i~cUk=*TU!q^_hkIYY1wQnf)-wI*xSMqXJPMn%$NdSD&e zu{ugT#)y*j*h+`k{Mo)6a<{z4mf!UwTogRAU7j$k{cO!%WG5C0_YVi@ITI|APIeA+ zXKlv>Dh3O;coL~Ec1V?SV}kbbTk2@!Rqey+Qj}8Ct{s-C-|nCK5#QIcbide@fIH~ZDZ#hX|mp9M{50gbp zQaoywLYtB>?#mk*h0W$rf%B=8Cm&Xu2;@t3g@vt$=k8VwAND@HK_ur(N0jh~QQ*Az zjH2%9oY%S=oCe9Xf`n?*8sqNNnSpy_@iV2*+XVMC7dLJCn}kH1Hr3lnCz4w&xygG0 zsh5oD{5;dbqmbmxPyG#50#!<9XT!T*scaPxcjxOe6pIGW&b{qB6!#%{y68$n^y6mC zqEl>a1V`Ixork5ZevE%ZtdtcSKQ+C`J;U+i7DPSW6!XS(gt-$9agbb!q)8WV{>%Db z>tK~Qx558)eZnm`YqLnsWBvG3$nD@BP;Xs?Wi1}0Vy!R7I%G2!fcI8;H}m*fZ~67! zatBw_{g2W%tb_P44(2?*AyB zw`V&m?0;|yW7(n;7BBz@NW-!Zr?Bix>o~9Gi+J9#K)UY{J9V9Wd? z`?D(5T66L?)ELQC_as>x`^#!@S{SlEan+^vbwO{8YNOUQrq#s)KZ=kN%&C$S4)JsU zhqhG7>)eqaE^?wNxPP5fJ@sIvrjd-*%ye{(TGWG4oZG&q9zvZ)dN9Kvj=`@`(}R5W zpfJ$46AH`pu4+>GMu-?aD|Ag0D;V1?(!(4ySsMp1mGy@nGJrl(bcPCvIieFv8I0Iw zqZVFe5!5hXp`)t+NVuzlq|b2(VzWTx=MrH#Zmf~)ih|+!j@z8RXcgE9D^ep;bd|HU z$V>47sxL1`>f@NiF&7qk-p!{%xk*gIqpzR8IBZyUTT@(6KT$1#rrcfRH;+S1cCB_i zIPoQ7PQ}Y!G;)ATeL*>enPBT2K+a-nkWk!?Dmd@Fv(Yuw_a0_~PWVsT6!FfWAhX>0 zo%yknGiHUyzOtGn_8l*BL>(g)UdeZjjaEIM$R}9u@Q0vy`kL%mh2ngKC2+NR8RHX- zjcRF|yVu^4P!=9!{;mT}6q;e9_YIWM}Rr#ikW`EK|>o~`#98EsV=j<(#i z3w!*`zN|{3a8@Iup^lFLR7w%Mp2rNl*w%A}%a9#ac;upsmQdY~KUse}qyJ?1FFirT zUhod*@x2=rOdM?Jb;DN)JR8sM6U43hrRw+5X)@~((y5p`xPbb}YReN>#zi1KTX)s&^I6w&kbwbWPR_cMPOX=?NfDb-3SR>D5vs&Qe|#>xzYO zta@ZhhcJ4C3T^q_%}W`GK6>!dUPx-qXXv3%%AJ^(mriP^US!N(agiWbcKSwmdonn?6`y9d ze_FbjW^chqp*RH(JrbE*3_F6)x9a;-!umR?NxVMuA^N_I{Br&~=jW=Aqv65E>vnZ- zU89WiHGKO$gq+O_;Ul8CmV8bZZWy`B2xeM)cXjg7Rbs#N%N9A>6aPUh=d3Nns;*bS z91aD|@mYwwdpyeW1UJjInavjp0?7i?1IeS=@RAVx2ZYq+rWD`MG=@=o)ZA^>GW7)6 zbPn}NvqsZRrXrDllhwB}t|u)C^6tn-Naii%)}RLE{EoN+aj|NzzE;VYB;AT%GAqK! z6Qaryjt_uQz2$*<;#NkDj)LfwU?i8c>7w=&SLdo%z^&O?)eD_WYXn4d5xUCA+SP{G zRxzj)Ua&n#0eIfjSWWpFa}1^c9?eK$Z9Bz5ENZ?>GIUH5W3pe6SQa}v{3d?j-oiPD z=_f1yRr;&wdAZE$MWIhYy2(WyoTcgQERK^)tC}M{v)VF4M3<0q7sp!J5xe87PBVv? zv4c#?Oz}aphmU*JiQaSYH^{^<{glB0_GetVy4^i5{?=UUy}PUXIy2KnINjMfJt*X< zL$JgppYw(RdTA?5nAV*FgC{sTVVA<7s{^F7cITlCv-pf3*9(HK?eQFB#U>ik>Hm>SdSul`7qV!M@4( zSJw6~sHlzjwB+i0D0qyZurHez1)LPaVM)o>m7U((X>t=BYY__w;YXfC%h=>VAAlB&#u76nXp#AIu>uN77p*M#QlmC%$0cG;St7=5UkJJ8W|g@=2h?swRg0t zL3(RnH|_(mNDUiBsB6JE4z%*1_F3Pv8+8awF_hFTR#%;2=i z*-c~T*}k2)0`Zbno?a`_>f1M;-U0OsoVu)oo+aOP8C^Dn;R|jR60OO z>Q5n?S-)-ex|O9plL4;9STuK-Rc>^hFsaELS4})4nMvu6_wp80W)oSG`^KUBRXm*o z#_tgpW3#CjbC3o5pO7rqjY4AwF5%{SiF$r`65WJwQndPsLuUE_mfbw$i+x@KYHCQo z+$$bGq30?jGj}bfxwq1y7QK3$aB2t}xsezoyqu*i4PN=ft@6}wN9o&Fb0Y@teVHS= z@(>$ZV+Ug{O|SW$p^sFSX3tMnzGz^RrPasILrzm0%C~o9JTRJfH`q6iu$VYF_`K8= zZa*7;dW%b^;19VLv`*70%MIa+LXLekj7b|`-SdJ9$~sKvwe7SQL)(1Vg5ZzlcW&Jn zR}LEK2^}}R?=w)_*UUW>*j*|U*gTYTQn#B(f2@bXfoC~dtpE5ORtB{x&{4Gp*GyEx zP@LpH__2~+c6}VzAVe{W^l+DWxjTl7D5>54c<^E7_?B^&d4>4|lcKWvtR->+!^XcR ztYv)tSgrpAO8}zxH1Bc>Kyy$ELHkB*K;!}&b1QJ+ zd8R@ak-V=Z^XL)C{q)NgsB;6qL-JWJE*ZS9cg+R!Z z&iT4Is~7s#)7>Tnz0*2=2WQ~B@1O5;$oTsdH11LvGNauC&P1K`B&%!@l5fg1^}SXx zlVaG0-s`f+R3_?V5uHJO_+!ZLpgEw-Fx!kec#v`wzb~thb*unHYF;C_9MzN;+muHS z0tSKs1S1G05X>N0K)^w;fjPqnE)vAHACPuBvO8fB4iOGKob(3jD9*r$W7lx2IJpEKVzEn zr7?hmGWLuCM~LmQKVuPcBbL?(%;aY#wUHOUViB_7S=vsvXMdVR$hF_a{oh4zQ=0Rc z>2FRRGn!~&24J8D;>>7^BWAQitC-U+>zX;B3$0h017;92;i#9FVFieg{cwN=q!r=- z9XK@OL4zB414p|dMGIO+gvGCnSqoZ*ge7fPyd}+3X8FTo2W{wJMJxXBJAbgE*+s1Z GyZ-~|S}A7$ diff --git a/pitch/day2/index.html b/pitch/day2/index.html index 0bb56f5..438abd2 100644 --- a/pitch/day2/index.html +++ b/pitch/day2/index.html @@ -73,8 +73,8 @@

Охват вырос за день

-
15 086позиций извлечено (день 1: 8 871)
-
76,1%нормализовано авто (цель ≥70%)
+
16 140позиций извлечено (день 1: 8 871)
+
73,7%нормализовано авто (цель ≥70%)
10документов обработано
liveвсё на домене, обновлено
@@ -121,7 +121,7 @@ Клиника 3 — PDF, битый текстовый слой151788Gemini Vision поверх битого текста Клиника 4 — PDF, мультитариф137437Gemini Vision -

Vision включается автоматически, когда нативный разбор пуст или подозрительно беден — большие документы не жжём зря. Итог: 8 871 → 15 086 позиций за два дня.

+

Vision включается автоматически, когда нативный разбор пуст или подозрительно беден — большие документы не жжём зря. Итог: 8 871 → 16 140 позиций за два дня.

diff --git a/pitch/landing/index.html b/pitch/landing/index.html index 3df4daa..1b7fe8c 100644 --- a/pitch/landing/index.html +++ b/pitch/landing/index.html @@ -102,8 +102,8 @@

Что уже работает

-
15 086позиции извлечено
-
76,1%нормализовано авто (цель ≥70%)
+
16 140позиции извлечено
+
73,7%нормализовано авто (цель ≥70%)
8 / 10клиник / прайсов
liveразвёрнуто на домене