Spaces:
Sleeping
Sleeping
feat(ui): preview sample documents before ingesting them
Browse filesКнопка «Load demo card» спершу показує сирі документи (і синтетичний бланк
для фото-шляху), і лише потім вантажить їх у картку — щоб витяг можна було
перевірити, а не приймати на віру.
- GET /samples, GET /samples/image
- модалка перегляду (UA/EN)
- .gitattributes +2 -35
- livemedcard/api.py +16 -0
- livemedcard/static/index.html +104 -5
- livemedcard/static/sample_lab_report.jpg +3 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,2 @@
|
|
| 1 |
-
*.
|
| 2 |
-
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
livemedcard/static/sample_lab_report.jpg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
livemedcard/api.py
CHANGED
|
@@ -264,6 +264,22 @@ def ingest_image(req: ImageIngestRequest, state: _State = Depends(_get_state)):
|
|
| 264 |
|
| 265 |
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
@app.post("/ingest/samples")
|
| 268 |
def ingest_samples(lang: str = "uk", state: _State = Depends(_get_state)):
|
| 269 |
"""Демо-документи мовою `lang` (`uk`|`en`) — набори дзеркальні за значеннями."""
|
|
|
|
| 264 |
|
| 265 |
|
| 266 |
|
| 267 |
+
@app.get("/samples")
|
| 268 |
+
def samples(lang: str = "uk"):
|
| 269 |
+
"""Демо-документи як вхідні дані — БЕЗ завантаження в картку.
|
| 270 |
+
|
| 271 |
+
Дає журі побачити сирий документ до того, як картка з нього щось витягне:
|
| 272 |
+
спершу вхід, потім результат. Сесія не потрібна — нічого не змінюємо.
|
| 273 |
+
"""
|
| 274 |
+
return {"documents": load_sample_docs(lang)}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
@app.get("/samples/image")
|
| 278 |
+
def sample_image() -> FileResponse:
|
| 279 |
+
"""Синтетичний бланк лабораторії — фото-шлях (OCR) для того самого порівняння."""
|
| 280 |
+
return FileResponse(_STATIC / "sample_lab_report.jpg", media_type="image/jpeg")
|
| 281 |
+
|
| 282 |
+
|
| 283 |
@app.post("/ingest/samples")
|
| 284 |
def ingest_samples(lang: str = "uk", state: _State = Depends(_get_state)):
|
| 285 |
"""Демо-документи мовою `lang` (`uk`|`en`) — набори дзеркальні за значеннями."""
|
livemedcard/static/index.html
CHANGED
|
@@ -345,6 +345,16 @@
|
|
| 345 |
.gwrap.sel .node.on text{fill:var(--ink);font-weight:700}
|
| 346 |
.drawer .body{padding:14px 18px 20px;overflow:auto;white-space:pre-wrap;
|
| 347 |
font-size:13.5px;line-height:1.75;color:var(--muted)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
/* ── cloud consent (photo leaves the device — say so before it does) ── */
|
| 349 |
.consent{position:fixed;left:50%;top:50%;transform:translate(-50%,-46%) scale(.98);
|
| 350 |
width:min(470px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
|
@@ -502,6 +512,28 @@
|
|
| 502 |
</div>
|
| 503 |
</aside>
|
| 504 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
<div class="drawer-back" id="help-back"></div>
|
| 506 |
<aside class="help" id="help" role="dialog" aria-modal="true" aria-labelledby="help-title">
|
| 507 |
<div class="head">
|
|
@@ -587,6 +619,13 @@ const I18N = {
|
|
| 587 |
escalate:'escalated to human (L4)', auto:'automatic processing',
|
| 588 |
sevLabel:{critical:'critical',major:'high',moderate:'moderate',info:'info'},
|
| 589 |
toastSamples:'Demo card loaded', toastReset:'Card reset',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
toastExport:'FHIR bundle downloaded', toastNeedInput:'Enter text or choose a photo',
|
| 591 |
ocrReading:'Reading (OCR)…', processing:'Processing…',
|
| 592 |
toastOcrWait:'Reading the photo on the cloud OCR lab — up to ~90 s',
|
|
@@ -684,6 +723,13 @@ const I18N = {
|
|
| 684 |
escalate:'ескалація до людини (L4)', auto:'автоматична обробка',
|
| 685 |
sevLabel:{critical:'критичний',major:'високий',moderate:'помірний',info:'інфо'},
|
| 686 |
toastSamples:'Демо-картку завантажено', toastReset:'Картку скинуто',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
toastExport:'FHIR Bundle завантажено', toastNeedInput:'Введіть текст або виберіть фото',
|
| 688 |
ocrReading:'Зчитування (OCR)…', processing:'Опрацювання…',
|
| 689 |
toastOcrWait:'Фото читає хмарний OCR-лаб — до ~90 с',
|
|
@@ -1045,7 +1091,7 @@ async function openDoc(docId, span){
|
|
| 1045 |
function closeDoc(){$('#drawer').classList.remove('show');$('#drawer-back').classList.remove('show');}
|
| 1046 |
$('#drawer-close').onclick=closeDoc;
|
| 1047 |
$('#drawer-back').onclick=closeDoc;
|
| 1048 |
-
addEventListener('keydown',e=>{if(e.key==='Escape'){closeDoc();closeHelp();closeGraph();}});
|
| 1049 |
|
| 1050 |
// ── довідка ───────────────────────────────────────────────
|
| 1051 |
function renderHelp(){
|
|
@@ -1159,10 +1205,63 @@ async function ask(q){
|
|
| 1159 |
}catch(e){toast(I18N[LANG].errorPrefix+e.message);}
|
| 1160 |
}
|
| 1161 |
|
| 1162 |
-
/
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1166 |
};
|
| 1167 |
$('#btn-reset').onclick=async()=>{await api('/reset',{method:'POST'});$('#log').innerHTML='';await refresh();toast(I18N[LANG].toastReset);};
|
| 1168 |
$('#btn-export').onclick=async()=>{
|
|
|
|
| 345 |
.gwrap.sel .node.on text{fill:var(--ink);font-weight:700}
|
| 346 |
.drawer .body{padding:14px 18px 20px;overflow:auto;white-space:pre-wrap;
|
| 347 |
font-size:13.5px;line-height:1.75;color:var(--muted)}
|
| 348 |
+
/* ── зразки документів: спершу вхід, потім результат ── */
|
| 349 |
+
.sdoc{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:10px;background:var(--panel-2)}
|
| 350 |
+
.sdoc .sh{display:flex;gap:9px;align-items:baseline;margin-bottom:7px}
|
| 351 |
+
.sdoc .sk{font-size:12px;font-weight:600;color:var(--ink)}
|
| 352 |
+
.sdoc .sd{font-family:var(--mono);font-size:11px;color:var(--muted)}
|
| 353 |
+
.sdoc .st{font-family:var(--mono);font-size:12px;line-height:1.65;color:var(--muted);white-space:pre-wrap;word-break:break-word;margin:0}
|
| 354 |
+
.sphoto{border:1px dashed var(--line);border-radius:10px;padding:12px 14px;margin-top:14px}
|
| 355 |
+
.sphoto img{width:100%;max-width:420px;border-radius:8px;border:1px solid var(--line);display:block;margin:9px 0}
|
| 356 |
+
.sfoot{display:flex;gap:9px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--line)}
|
| 357 |
+
|
| 358 |
/* ── cloud consent (photo leaves the device — say so before it does) ── */
|
| 359 |
.consent{position:fixed;left:50%;top:50%;transform:translate(-50%,-46%) scale(.98);
|
| 360 |
width:min(470px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
|
|
|
| 512 |
</div>
|
| 513 |
</aside>
|
| 514 |
|
| 515 |
+
<div class="drawer-back" id="samples-back"></div>
|
| 516 |
+
<aside class="help" id="samplesm" role="dialog" aria-modal="true" aria-labelledby="samples-title">
|
| 517 |
+
<div class="head">
|
| 518 |
+
<h3 id="samples-title"></h3>
|
| 519 |
+
<button class="close" id="samples-close" aria-label="Close">×</button>
|
| 520 |
+
</div>
|
| 521 |
+
<div class="body">
|
| 522 |
+
<p class="gcap" id="samples-cap"></p>
|
| 523 |
+
<div id="samples-list"></div>
|
| 524 |
+
<div class="sphoto">
|
| 525 |
+
<div class="sk" id="samples-photo-head"></div>
|
| 526 |
+
<p class="st" id="samples-photo-cap" style="margin:6px 0 0"></p>
|
| 527 |
+
<img id="samples-photo" alt="">
|
| 528 |
+
<button class="ghost" id="samples-photo-btn"></button>
|
| 529 |
+
</div>
|
| 530 |
+
</div>
|
| 531 |
+
<div class="sfoot">
|
| 532 |
+
<button class="ghost" id="samples-cancel"></button>
|
| 533 |
+
<button class="primary" id="samples-load"></button>
|
| 534 |
+
</div>
|
| 535 |
+
</aside>
|
| 536 |
+
|
| 537 |
<div class="drawer-back" id="help-back"></div>
|
| 538 |
<aside class="help" id="help" role="dialog" aria-modal="true" aria-labelledby="help-title">
|
| 539 |
<div class="head">
|
|
|
|
| 619 |
escalate:'escalated to human (L4)', auto:'automatic processing',
|
| 620 |
sevLabel:{critical:'critical',major:'high',moderate:'moderate',info:'info'},
|
| 621 |
toastSamples:'Demo card loaded', toastReset:'Card reset',
|
| 622 |
+
samplesTitle:'Sample documents — what goes in',
|
| 623 |
+
samplesCap:'These are the raw inputs, exactly as the card receives them. Read them first, then load them and check what was extracted — the timeline, the medications, the safety signals. Nothing here is hidden from you.',
|
| 624 |
+
samplesHint:'Note the two dangerous medications sit in <b>different</b> documents, two visits apart. That combination is what the deterministic layer catches.',
|
| 625 |
+
samplesLoad:'Load into the card', samplesCancel:'Cancel',
|
| 626 |
+
samplesPhotoHead:'Or a photo of a lab form',
|
| 627 |
+
samplesPhotoCap:'Same comparison, harder path: this synthetic form is read by a real OCR model, not by a script with the answers.',
|
| 628 |
+
samplesPhotoBtn:'Read this photo (OCR)',
|
| 629 |
toastExport:'FHIR bundle downloaded', toastNeedInput:'Enter text or choose a photo',
|
| 630 |
ocrReading:'Reading (OCR)…', processing:'Processing…',
|
| 631 |
toastOcrWait:'Reading the photo on the cloud OCR lab — up to ~90 s',
|
|
|
|
| 723 |
escalate:'ескалація до людини (L4)', auto:'автоматична обробка',
|
| 724 |
sevLabel:{critical:'критичний',major:'високий',moderate:'помірний',info:'інфо'},
|
| 725 |
toastSamples:'Демо-картку завантажено', toastReset:'Картку скинуто',
|
| 726 |
+
samplesTitle:'Зразки документів — що подається на вхід',
|
| 727 |
+
samplesCap:'Це сирі документи, рівно в тому вигляді, в якому їх отримує картка. Спершу прочитайте їх, потім завантажте — і перевірте, що з них витягнуто: таймлайн, ліки, сигнали безпеки. Нічого не приховано.',
|
| 728 |
+
samplesHint:'Зверніть увагу: два небезпечні препарати лежать у <b>різних</b> документах, між ними — рік. Саме таку комбінацію й ловить детермінований шар.',
|
| 729 |
+
samplesLoad:'Завантажити в картку', samplesCancel:'Скасувати',
|
| 730 |
+
samplesPhotoHead:'Або фото лабораторного бланка',
|
| 731 |
+
samplesPhotoCap:'Те саме порівняння, складніший шлях: цей синтетичний бланк читає справжня OCR-модель, а н�� скрипт із готовими відповідями.',
|
| 732 |
+
samplesPhotoBtn:'Прочитати це фото (OCR)',
|
| 733 |
toastExport:'FHIR Bundle завантажено', toastNeedInput:'Введіть текст або виберіть фото',
|
| 734 |
ocrReading:'Зчитування (OCR)…', processing:'Опрацювання…',
|
| 735 |
toastOcrWait:'Фото читає хмарний OCR-лаб — до ~90 с',
|
|
|
|
| 1091 |
function closeDoc(){$('#drawer').classList.remove('show');$('#drawer-back').classList.remove('show');}
|
| 1092 |
$('#drawer-close').onclick=closeDoc;
|
| 1093 |
$('#drawer-back').onclick=closeDoc;
|
| 1094 |
+
addEventListener('keydown',e=>{if(e.key==='Escape'){closeDoc();closeHelp();closeGraph();closeSamples();}});
|
| 1095 |
|
| 1096 |
// ── довідка ───────────────────────────────────────────────
|
| 1097 |
function renderHelp(){
|
|
|
|
| 1205 |
}catch(e){toast(I18N[LANG].errorPrefix+e.message);}
|
| 1206 |
}
|
| 1207 |
|
| 1208 |
+
/* Демо-документи — мовою інтерфейсу (набори дзеркальні: ті самі значення, ті самі
|
| 1209 |
+
сигнали). Спершу показуємо ВХІД, і лише потім вантажимо: інакше витягнутим фактам
|
| 1210 |
+
немає з чим порівнювати, і журі лишається тільки вірити нам на слово. */
|
| 1211 |
+
const kindLabels=()=>{const t=I18N[LANG];
|
| 1212 |
+
return {discharge_summary:t.optDischarge,lab_report:t.optLab,prescription:t.optRx,note:t.optNote};};
|
| 1213 |
+
|
| 1214 |
+
async function openSamples(){
|
| 1215 |
+
const t=I18N[LANG];
|
| 1216 |
+
$('#samples-title').textContent=t.samplesTitle;
|
| 1217 |
+
$('#samples-cap').innerHTML=t.samplesCap+' '+t.samplesHint;
|
| 1218 |
+
$('#samples-load').textContent=t.samplesLoad;
|
| 1219 |
+
$('#samples-cancel').textContent=t.samplesCancel;
|
| 1220 |
+
$('#samples-photo-head').textContent=t.samplesPhotoHead;
|
| 1221 |
+
$('#samples-photo-cap').textContent=t.samplesPhotoCap;
|
| 1222 |
+
$('#samples-photo-btn').textContent=t.samplesPhotoBtn;
|
| 1223 |
+
$('#samples-photo').src='/samples/image';
|
| 1224 |
+
try{
|
| 1225 |
+
const d=await api('/samples?lang='+(LANG==='ua'?'uk':'en'));
|
| 1226 |
+
const K=kindLabels();
|
| 1227 |
+
$('#samples-list').innerHTML=d.documents.map(doc=>`<div class="sdoc">
|
| 1228 |
+
<div class="sh"><span class="sk">${K[doc.kind]||doc.kind}</span><span class="sd">${escHtml((doc.received_at||'').slice(0,10))}</span></div>
|
| 1229 |
+
<p class="st">${escHtml(doc.text)}</p></div>`).join('');
|
| 1230 |
+
}catch(e){toast(t.errorPrefix+e.message);return;}
|
| 1231 |
+
$('#samplesm').classList.add('show');$('#samples-back').classList.add('show');
|
| 1232 |
+
$('#samples-close').focus();
|
| 1233 |
+
}
|
| 1234 |
+
function closeSamples(){$('#samplesm').classList.remove('show');$('#samples-back').classList.remove('show');}
|
| 1235 |
+
$('#btn-samples').onclick=openSamples;
|
| 1236 |
+
$('#samples-close').onclick=closeSamples;
|
| 1237 |
+
$('#samples-cancel').onclick=closeSamples;
|
| 1238 |
+
$('#samples-back').onclick=closeSamples;
|
| 1239 |
+
|
| 1240 |
+
$('#samples-load').onclick=async()=>{
|
| 1241 |
+
const t=I18N[LANG], b=$('#samples-load'), orig=b.textContent; b.disabled=true;
|
| 1242 |
+
try{
|
| 1243 |
+
await api(`/ingest/samples?lang=${LANG==='ua'?'uk':'en'}`,{method:'POST'});
|
| 1244 |
+
closeSamples(); await refresh(); toast(t.toastSamples);
|
| 1245 |
+
}catch(e){toast(t.errorPrefix+e.message);}
|
| 1246 |
+
finally{b.disabled=false;b.textContent=orig;}
|
| 1247 |
+
};
|
| 1248 |
+
|
| 1249 |
+
// Той самий бланк, що в кліпі: журі бачить його очима, потім віддає справжньому OCR.
|
| 1250 |
+
$('#samples-photo-btn').onclick=async()=>{
|
| 1251 |
+
const t=I18N[LANG], b=$('#samples-photo-btn'), orig=b.textContent;
|
| 1252 |
+
if(!await askCloudConsent()) return;
|
| 1253 |
+
b.disabled=true; b.textContent=t.ocrReading; toast(t.toastOcrWait);
|
| 1254 |
+
try{
|
| 1255 |
+
const blob=await (await fetch('/samples/image')).blob();
|
| 1256 |
+
const base64=await new Promise((res,rej)=>{
|
| 1257 |
+
const rd=new FileReader(); rd.onload=()=>res(rd.result); rd.onerror=rej; rd.readAsDataURL(blob);});
|
| 1258 |
+
const rep=await api('/ingest/image',{method:'POST',headers:{'Content-Type':'application/json'},
|
| 1259 |
+
body:JSON.stringify({image_base64:base64,kind:'lab_report'})});
|
| 1260 |
+
closeSamples(); await refresh();
|
| 1261 |
+
const got=(rep.extraction&&rep.extraction.observations||[]).length;
|
| 1262 |
+
toast(got?(rep.decision.escalate?t.toastEscalated:t.toastAuto):t.toastNoFacts);
|
| 1263 |
+
}catch(e){toast(t.errorPrefix+e.message);}
|
| 1264 |
+
finally{b.disabled=false;b.textContent=orig;}
|
| 1265 |
};
|
| 1266 |
$('#btn-reset').onclick=async()=>{await api('/reset',{method:'POST'});$('#log').innerHTML='';await refresh();toast(I18N[LANG].toastReset);};
|
| 1267 |
$('#btn-export').onclick=async()=>{
|
livemedcard/static/sample_lab_report.jpg
ADDED
|
Git LFS Details
|