aleks-gotsa commited on
Commit
fb462b5
·
1 Parent(s): 72e3eab

Paper Decoder v1: base model end-to-end (G1)

Browse files
Files changed (5) hide show
  1. Dockerfile +46 -0
  2. README.md +49 -5
  3. app.py +399 -0
  4. requirements.txt +4 -0
  5. start.sh +25 -0
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Paper Decoder — HF Space (Docker SDK)
2
+ # llama.cpp server (official image binaries) + Tesseract OCR (ukr/rus) + Gradio 6
3
+
4
+ # Stage 1: take llama-server + shared libs from the official CPU server image.
5
+ # Layout verified 2026-06-10 against .devops/cpu.Dockerfile: binary and all
6
+ # *.so* live flat in /app; runtime needs libgomp1 and curl.
7
+ FROM ghcr.io/ggml-org/llama.cpp:server AS llama
8
+
9
+ FROM python:3.11-slim-bookworm
10
+
11
+ # System deps: OCR (Ukrainian + Russian traineddata), llama.cpp runtime libs
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ tesseract-ocr \
14
+ tesseract-ocr-ukr \
15
+ tesseract-ocr-rus \
16
+ libgomp1 \
17
+ curl \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ COPY --from=llama /app /opt/llama
21
+ ENV LD_LIBRARY_PATH=/opt/llama
22
+
23
+ # Bake the model into the image at build time: faster Space restarts,
24
+ # no runtime download. Exact file pin recorded after first build.
25
+ RUN pip install --no-cache-dir "huggingface_hub[cli]>=0.26,<2.0" && \
26
+ huggingface-cli download unsloth/Qwen3-4B-Instruct-2507-GGUF \
27
+ --include "*Q4_K_M*.gguf" --local-dir /opt/models && \
28
+ find /opt/models -name "*.gguf" -exec ls -lh {} \;
29
+
30
+ COPY requirements.txt /tmp/requirements.txt
31
+ RUN pip install --no-cache-dir -r /tmp/requirements.txt
32
+
33
+ # HF Spaces convention: run as non-root user with UID 1000
34
+ RUN useradd -m -u 1000 user
35
+ USER user
36
+ ENV HOME=/home/user \
37
+ PATH=/home/user/.local/bin:$PATH \
38
+ GRADIO_SERVER_NAME=0.0.0.0 \
39
+ GRADIO_SERVER_PORT=7860
40
+
41
+ WORKDIR /home/user/app
42
+ COPY --chown=user app.py start.sh ./
43
+ RUN chmod +x start.sh
44
+
45
+ EXPOSE 7860
46
+ CMD ["./start.sh"]
README.md CHANGED
@@ -1,12 +1,56 @@
1
  ---
2
- title: Paper Decoder
3
- emoji: 📉
4
- colorFrom: blue
5
  colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
  license: apache-2.0
9
- short_description: Ukrainian official-letter decoder for my parents.
 
 
 
 
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Paper Decoder — Розшифровувач паперів
3
+ emoji: 📄
4
+ colorFrom: yellow
5
  colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  license: apache-2.0
10
+ short_description: Ukrainian official-letter decoder for my parents. Local-only.
11
+ tags:
12
+ - backyard-ai
13
+ - off-the-grid
14
+ - llama-cpp
15
+ - tiny-titan
16
  ---
17
 
18
+ # Paper Decoder — Розшифровувач паперів
19
+
20
+ My parents in Uzhhorod receive official Ukrainian letters — utility debt
21
+ notices, bank letters, tax demands — and "official-looking" scam messages.
22
+ They can read every word and still not know what the letter wants from them,
23
+ or whether it's real.
24
+
25
+ Paper Decoder takes a phone photo or pasted text of a letter and returns:
26
+
27
+ 1. a plain-Ukrainian summary (3–4 sentences, no bureaucratese),
28
+ 2. a "what you must do" action list,
29
+ 3. every date, deadline and amount, extracted,
30
+ 4. scam-pattern flags with the matched evidence quoted.
31
+
32
+ ## Why a small local model
33
+
34
+ These letters contain names, addresses, account numbers, debts. Sending them
35
+ to a cloud API is exactly what I'd tell my parents never to do. Everything
36
+ runs on this CPU Space: Tesseract OCR (ukr+rus) for the photo path,
37
+ Qwen3-4B-Instruct-2507 at Q4_K_M served by llama.cpp. No external calls at
38
+ inference time.
39
+
40
+ ## Model selection was gated, not assumed
41
+
42
+ Before building, three candidate models ran a 10-prompt Ukrainian spot-check
43
+ on real letter types (scored 0–2 per prompt, blind rubric):
44
+
45
+ | Model | Score | Verdict |
46
+ |---|---|---|
47
+ | MiniCPM5-1B (Q4_K_M) | 0/20 | Russian bleed, script corruption, empty outputs |
48
+ | Qwen3-1.7B (Q4_K_M) | 11/20 | Fluent Ukrainian, zero scam awareness |
49
+ | Qwen3-4B-Instruct-2507 (Q4_K_M) | 14/20 | Pass — shipped |
50
+
51
+ A fine-tuned LoRA targeting the remaining failure modes (protective actions
52
+ on scam letters, date fidelity in summaries) is in progress; the before/after
53
+ eval will be published with the model.
54
+
55
+ Built solo by [aleks-gotsa](https://huggingface.co/aleks-gotsa) for the
56
+ Build Small Hackathon 2026, Backyard AI track.
app.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paper Decoder — Розшифровувач паперів.
2
+
3
+ Ukrainian official-letter decoder. Photo or pasted text in; plain-language
4
+ summary, required actions, deadlines, and scam flags out. Fully local:
5
+ Tesseract OCR + Qwen3-4B-Instruct-2507 (Q4_K_M) via llama.cpp. No cloud calls.
6
+
7
+ Build Small Hackathon 2026 · Backyard AI track · built for my parents.
8
+ """
9
+
10
+ import html
11
+ import json
12
+ import os
13
+ import time
14
+
15
+ import pytesseract
16
+ import requests
17
+ from PIL import Image, ImageOps
18
+
19
+ import gradio as gr
20
+
21
+ LLAMA_URL = os.environ.get("LLAMA_URL", "http://127.0.0.1:8081")
22
+ MAX_LETTER_CHARS = 6000 # ~1.5-2k tokens; keeps prefill sane on CPU
23
+
24
+ SYSTEM_PROMPT = """Ти — «Розшифровувач паперів», помічник для літніх людей в Україні. Тобі дають текст офіційного листа (іноді розпізнаний з фотографії, з помилками розпізнавання).
25
+
26
+ Відповідай ЛИШЕ валідним JSON без жодного іншого тексту, з полями:
27
+ - "summary": 3–4 короткі речення простою українською, без канцеляризмів. Пояснюй так, ніби говориш із власною бабусею.
28
+ - "actions": список конкретних дій, які має зробити людина, у порядку виконання.
29
+ - "deadlines": список об'єктів {"date": "...", "what": "...", "amount": "..."}; якщо суми немає — null.
30
+ - "scam_flags": список об'єктів {"pattern": "...", "evidence": "точна цитата з листа"}; якщо ознак немає — порожній список [].
31
+
32
+ У полі "pattern" ДОЗВОЛЕНІ ЛИШЕ ці значення, нічого іншого:
33
+ 1. "несправжнє посилання" — домен лише схожий на офіційний (зайві слова, дефіси, інша зона).
34
+ 2. "оплата на картку фізособи" — вимога переказати гроші на 16-значний номер картки.
35
+ 3. "вимога ПІН-коду, CVV або кодів з SMS".
36
+ 4. "знижка за швидку оплату" — менша сума, якщо заплатити за години.
37
+ 5. "строк у годинах" — вимога зробити щось за 12/24/48 годин. Справжні установи дають конкретні дати або дні, не години.
38
+
39
+ Якщо жодна з цих п'яти ознак не присутня ДОСЛІВНО в тексті — "scam_flags" має бути []. Попередження про відключення послуг, пеню, суд або стягнення у листах від комунальних підприємств, банків, податкової чи виконавчої служби — звичайна законна практика, це НЕ шахрайство і НЕ потрапляє у "scam_flags".
40
+
41
+ ПРАВИЛО БЕЗПЕКИ: якщо "scam_flags" непорожній, у "actions" ЗАБОРОНЕНО радити переходити за посиланням, платити, телефонувати за номерами з листа або вводити дані. Натомість дії мають бути захисними: не переходити за посиланням і не платити; зателефонувати до банку чи установи за офіційним номером (з банківської картки або офіційного сайту); показати лист рідним.
42
+
43
+ ПРАВИЛА ДАТ І СУМ: використовуй лише дати та суми, які є в листі, і копіюй їх точно. Відносні строки («протягом 10 календарних днів») залишай відносними — не перетворюй їх на конкретні дати. Дати у "summary" мають дослівно збігатися з датами у "deadlines". Нічого не вигадуй."""
44
+
45
+ # ---------------------------------------------------------------- OCR path
46
+
47
+
48
+ def ocr_image(filepath: str | None) -> str:
49
+ if not filepath:
50
+ return ""
51
+ img = Image.open(filepath)
52
+ img = ImageOps.exif_transpose(img) # phone photos carry rotation in EXIF
53
+ img = ImageOps.grayscale(img)
54
+ img = ImageOps.autocontrast(img)
55
+ if img.width < 1400: # Tesseract wants ~300dpi-ish input
56
+ scale = 1400 / img.width
57
+ img = img.resize((1400, int(img.height * scale)), Image.LANCZOS)
58
+ text = pytesseract.image_to_string(img, lang="ukr+rus")
59
+ return text.strip()
60
+
61
+
62
+ # ------------------------------------------------------------- model call
63
+
64
+
65
+ def stream_completion(letter_text: str):
66
+ """Yield (accumulated_text, completion_tokens, elapsed_s) while streaming."""
67
+ payload = {
68
+ "model": "paper-decoder",
69
+ "temperature": 0.2,
70
+ "seed": 42,
71
+ "max_tokens": 900,
72
+ "cache_prompt": True, # system prompt is cached across requests
73
+ "response_format": {"type": "json_object"},
74
+ "stream": True,
75
+ "stream_options": {"include_usage": True},
76
+ "messages": [
77
+ {"role": "system", "content": SYSTEM_PROMPT},
78
+ {"role": "user", "content": letter_text},
79
+ ],
80
+ }
81
+ acc, completion_tokens = "", 0
82
+ t0 = time.monotonic()
83
+ with requests.post(
84
+ f"{LLAMA_URL}/v1/chat/completions", json=payload, stream=True, timeout=600
85
+ ) as r:
86
+ r.raise_for_status()
87
+ r.encoding = "utf-8"
88
+ for raw in r.iter_lines(decode_unicode=True):
89
+ if not raw or not raw.startswith("data: "):
90
+ continue
91
+ data = raw[len("data: "):]
92
+ if data.strip() == "[DONE]":
93
+ break
94
+ try:
95
+ chunk = json.loads(data)
96
+ except json.JSONDecodeError:
97
+ continue
98
+ usage = chunk.get("usage")
99
+ if usage:
100
+ completion_tokens = usage.get("completion_tokens", completion_tokens)
101
+ choices = chunk.get("choices") or []
102
+ if choices:
103
+ delta = choices[0].get("delta") or {}
104
+ acc += delta.get("content") or ""
105
+ yield acc, completion_tokens, time.monotonic() - t0
106
+
107
+
108
+ def parse_model_json(text: str) -> dict | None:
109
+ candidate = text.strip()
110
+ if candidate.startswith("```"):
111
+ candidate = candidate.strip("`")
112
+ if candidate.startswith("json"):
113
+ candidate = candidate[4:]
114
+ candidate = candidate.strip()
115
+ for attempt in (candidate,):
116
+ try:
117
+ obj = json.loads(attempt)
118
+ if isinstance(obj, dict):
119
+ return obj
120
+ except (json.JSONDecodeError, ValueError):
121
+ pass
122
+ start, end = candidate.find("{"), candidate.rfind("}")
123
+ if start != -1 and end > start:
124
+ try:
125
+ obj = json.loads(candidate[start : end + 1])
126
+ if isinstance(obj, dict):
127
+ return obj
128
+ except (json.JSONDecodeError, ValueError):
129
+ pass
130
+ return None
131
+
132
+
133
+ # -------------------------------------------------------------- rendering
134
+
135
+
136
+ def esc(x) -> str:
137
+ return html.escape(str(x)) if x is not None else ""
138
+
139
+
140
+ def render_status(message: str, detail: str = "") -> str:
141
+ d = f'<div class="pd-status-detail">{esc(detail)}</div>' if detail else ""
142
+ return (
143
+ '<div class="pd-status"><span class="pd-pulse"></span>'
144
+ f"<div><div>{esc(message)}</div>{d}</div></div>"
145
+ )
146
+
147
+
148
+ def render_error(message: str) -> str:
149
+ return f'<div class="pd-error">{esc(message)}</div>'
150
+
151
+
152
+ def render_result(data: dict, tok_s: float | None) -> str:
153
+ flags = data.get("scam_flags") or []
154
+ is_scam = len(flags) > 0
155
+
156
+ if is_scam:
157
+ stamp = '<div class="pd-stamp pd-stamp-danger">Схоже на шахрайство</div>'
158
+ else:
159
+ stamp = '<div class="pd-stamp pd-stamp-ok">Виглядає як справжній лист</div>'
160
+
161
+ parts = [f'<div class="pd-stamp-row">{stamp}</div>']
162
+
163
+ summary = data.get("summary")
164
+ if summary:
165
+ parts.append(
166
+ '<section class="pd-card"><h3>Про що цей лист</h3>'
167
+ f'<p class="pd-summary">{esc(summary)}</p></section>'
168
+ )
169
+
170
+ if is_scam:
171
+ rows = "".join(
172
+ '<li><span class="pd-flag-pattern">' + esc(f.get("pattern", "")) + "</span>"
173
+ '<span class="pd-flag-evidence">«' + esc(f.get("evidence", "")) + "»</span></li>"
174
+ for f in flags
175
+ if isinstance(f, dict)
176
+ )
177
+ parts.append(
178
+ '<section class="pd-card pd-card-danger"><h3>Чому це підозріло</h3>'
179
+ f'<ul class="pd-flags">{rows}</ul></section>'
180
+ )
181
+
182
+ actions = data.get("actions") or []
183
+ if actions:
184
+ rows = "".join(f"<li>{esc(a)}</li>" for a in actions)
185
+ parts.append(
186
+ '<section class="pd-card"><h3>Що робити</h3>'
187
+ f'<ol class="pd-actions">{rows}</ol></section>'
188
+ )
189
+
190
+ deadlines = data.get("deadlines") or []
191
+ if deadlines:
192
+ rows = ""
193
+ for d in deadlines:
194
+ if not isinstance(d, dict):
195
+ continue
196
+ amount = d.get("amount")
197
+ amount_html = f'<td class="pd-mono pd-amount">{esc(amount)}</td>' if amount else "<td>—</td>"
198
+ rows += (
199
+ f'<tr><td class="pd-mono pd-date">{esc(d.get("date", ""))}</td>'
200
+ f'<td>{esc(d.get("what", ""))}</td>{amount_html}</tr>'
201
+ )
202
+ parts.append(
203
+ '<section class="pd-card"><h3>Дати та суми</h3>'
204
+ '<table class="pd-deadlines"><thead><tr>'
205
+ "<th>Коли</th><th>Що</th><th>Сума</th></tr></thead>"
206
+ f"<tbody>{rows}</tbody></table></section>"
207
+ )
208
+
209
+ speed = f" · {tok_s:.1f} ток/с" if tok_s else ""
210
+ parts.append(
211
+ f'<div class="pd-meta">Оброблено локально, без інтернет-сервісів{speed}</div>'
212
+ )
213
+ return '<div class="pd-result">' + "".join(parts) + "</div>"
214
+
215
+
216
+ # ------------------------------------------------------------------ logic
217
+
218
+
219
+ def do_ocr(image_path):
220
+ if not image_path:
221
+ return gr.update()
222
+ text = ocr_image(image_path)
223
+ if not text:
224
+ return "(Не вдалося розпізнати текст. Спробуйте чіткіше фото або вставте текст вручну.)"
225
+ return text
226
+
227
+
228
+ def decode(letter_text: str):
229
+ letter_text = (letter_text or "").strip()
230
+ if not letter_text or letter_text.startswith("(Не вдалося"):
231
+ yield render_error(
232
+ "Спочатку додайте лист: сфотографуйте його або вставте текст у поле зліва."
233
+ )
234
+ return
235
+ letter_text = letter_text[:MAX_LETTER_CHARS]
236
+
237
+ yield render_status("Читаю лист…", "Перший запит після запуску може тривати довше.")
238
+
239
+ acc, completion_tokens, elapsed = "", 0, 0.0
240
+ try:
241
+ last_yield = 0.0
242
+ for acc, completion_tokens, elapsed in stream_completion(letter_text):
243
+ if elapsed - last_yield >= 1.0:
244
+ last_yield = elapsed
245
+ yield render_status(
246
+ "Розшифровую…", f"{len(acc)} символів за {elapsed:.0f} с"
247
+ )
248
+ except requests.RequestException as e:
249
+ yield render_error(f"Помилка зв'язку з моделлю: {e}")
250
+ return
251
+
252
+ data = parse_model_json(acc)
253
+ if data is None:
254
+ yield (
255
+ render_error("Модель повернула некоректну відповідь. Спробуйте ще раз.")
256
+ + f'<pre class="pd-raw">{esc(acc[:2000])}</pre>'
257
+ )
258
+ return
259
+
260
+ tok_s = (completion_tokens / elapsed) if completion_tokens and elapsed > 0 else None
261
+ yield render_result(data, tok_s)
262
+
263
+
264
+ # --------------------------------------------------------------------- UI
265
+
266
+ CSS = """
267
+ @import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@500;600&display=swap');
268
+
269
+ :root, .dark {
270
+ --pd-bg: #14120F; --pd-panel: #1D1A15; --pd-panel2: #232017;
271
+ --pd-ink: #F2EDE3; --pd-dim: #A89F8D; --pd-line: #322E26;
272
+ --pd-amber: #E8B33A; --pd-red: #D4543F; --pd-green: #7CA86A;
273
+ --body-background-fill: var(--pd-bg);
274
+ --background-fill-primary: var(--pd-panel);
275
+ --background-fill-secondary: var(--pd-panel2);
276
+ --block-background-fill: var(--pd-panel);
277
+ --border-color-primary: var(--pd-line);
278
+ --body-text-color: var(--pd-ink);
279
+ --block-label-text-color: var(--pd-dim);
280
+ --input-background-fill: var(--pd-panel2);
281
+ }
282
+ body, gradio-app { background: var(--pd-bg) !important; }
283
+ * { font-family: 'IBM Plex Sans', sans-serif; }
284
+
285
+ .pd-header { padding: 8px 0 2px; }
286
+ .pd-wordmark {
287
+ font-family: 'Unbounded', sans-serif; font-weight: 700;
288
+ font-size: 1.7rem; color: var(--pd-ink); letter-spacing: -0.01em;
289
+ }
290
+ .pd-wordmark .accent { color: var(--pd-amber); }
291
+ .pd-tagline { color: var(--pd-dim); font-size: 1.02rem; margin-top: 4px; }
292
+
293
+ .pd-status { display:flex; gap:12px; align-items:center; padding:18px;
294
+ background:var(--pd-panel); border:1px solid var(--pd-line); border-radius:10px;
295
+ font-size:1.1rem; color:var(--pd-ink); }
296
+ .pd-status-detail { color: var(--pd-dim); font-size:0.92rem; margin-top:2px; }
297
+ .pd-pulse { width:12px; height:12px; border-radius:50%; background:var(--pd-amber);
298
+ animation:pdpulse 1.1s ease-in-out infinite; flex:none; }
299
+ @keyframes pdpulse { 0%,100%{opacity:.35} 50%{opacity:1} }
300
+ @media (prefers-reduced-motion: reduce) { .pd-pulse { animation:none; } }
301
+
302
+ .pd-error { padding:16px 18px; border:1px solid var(--pd-red); border-radius:10px;
303
+ color:var(--pd-ink); background:rgba(212,84,63,.08); font-size:1.05rem; }
304
+ .pd-raw { color:var(--pd-dim); font-size:.8rem; white-space:pre-wrap; margin-top:10px; }
305
+
306
+ .pd-stamp-row { padding: 6px 0 14px; }
307
+ .pd-stamp {
308
+ display:inline-block; transform:rotate(-2deg);
309
+ font-family:'IBM Plex Mono', monospace; font-weight:600;
310
+ font-size:1.12rem; letter-spacing:.08em; text-transform:uppercase;
311
+ padding:10px 18px; border:3px double currentColor; border-radius:6px;
312
+ }
313
+ .pd-stamp-danger { color: var(--pd-red); }
314
+ .pd-stamp-ok { color: var(--pd-green); }
315
+
316
+ .pd-card { background:var(--pd-panel); border:1px solid var(--pd-line);
317
+ border-radius:12px; padding:18px 20px; margin-bottom:14px; }
318
+ .pd-card-danger { border-color: var(--pd-red); }
319
+ .pd-card h3 { margin:0 0 10px; color:var(--pd-amber); font-size:.85rem;
320
+ font-weight:600; letter-spacing:.12em; text-transform:uppercase; }
321
+ .pd-card-danger h3 { color: var(--pd-red); }
322
+
323
+ .pd-summary { font-size:1.22rem; line-height:1.65; margin:0; color:var(--pd-ink); }
324
+
325
+ .pd-actions { margin:0; padding-left:1.4em; font-size:1.14rem; line-height:1.6; }
326
+ .pd-actions li { margin-bottom:10px; }
327
+ .pd-actions li::marker { color:var(--pd-amber); font-weight:600; }
328
+
329
+ .pd-flags { margin:0; padding-left:1.2em; font-size:1.05rem; line-height:1.55; }
330
+ .pd-flags li { margin-bottom:10px; }
331
+ .pd-flag-pattern { display:block; font-weight:600; color:var(--pd-ink); }
332
+ .pd-flag-evidence { display:block; color:var(--pd-dim); font-style:italic; }
333
+
334
+ .pd-deadlines { width:100%; border-collapse:collapse; font-size:1.08rem; }
335
+ .pd-deadlines th { text-align:left; color:var(--pd-dim); font-weight:500;
336
+ font-size:.85rem; padding:4px 12px 8px 0; }
337
+ .pd-deadlines td { padding:9px 12px 9px 0; border-top:1px solid var(--pd-line);
338
+ vertical-align:top; }
339
+ .pd-mono { font-family:'IBM Plex Mono', monospace; font-weight:600; }
340
+ .pd-date { color:var(--pd-amber); white-space:nowrap; }
341
+ .pd-amount { white-space:nowrap; }
342
+
343
+ .pd-meta { color:var(--pd-dim); font-size:.88rem; padding:4px 2px 0; }
344
+ footer { display:none !important; }
345
+ """
346
+
347
+ EXAMPLE_LEGIT = """ПрАТ «Закарпаттяобленерго»
348
+ Вих. № 1123/05 від 03.06.2026
349
+
350
+ Шановний споживачу!
351
+ За особовим рахунком № 4471102 станом на 01.06.2026 обліковується заборгованість за спожиту електричну енергію в сумі 1847,50 грн. Просимо погасити заборгованість у повному обсязі до 25.06.2026. У разі непогашення заборгованості у зазначений термін постачання електричної енергії може бути припинено відповідно до п. 7 Правил роздрібного ринку електричної енергії.
352
+ Довідки за телефоном: 0800-50-60-22."""
353
+
354
+ EXAMPLE_SCAM = """UA-BANK ПОВІДОМЛЕННЯ
355
+
356
+ Шановний клієнт! Ваша банківська картка ЗАБЛОКОВАНА у зв'язку з підозрілою операцією. Для розблокування картки терміново перейдіть за посиланням та підтвердіть свої дані протягом 12 годин: http://privat24-secure.com-ua-card.info/unlock
357
+ У разі непідтвердження даних кошти на рахунку будуть утримані.
358
+ Служба безпеки банку."""
359
+
360
+ with gr.Blocks(title="Paper Decoder — Розшифровувач паперів") as demo:
361
+ gr.HTML(
362
+ '<div class="pd-header">'
363
+ '<div class="pd-wordmark">Розшифровувач <span class="accent">паперів</span></div>'
364
+ '<div class="pd-tagline">Сфотографуйте офіційний лист — отримаєте просте пояснення, '
365
+ "список дій, усі дати й суми та перевірку на шахрайство. "
366
+ "Працює повністю на цьому сервері, лист нікуди не надсилається.</div></div>"
367
+ )
368
+ with gr.Row():
369
+ with gr.Column(scale=5):
370
+ image = gr.Image(
371
+ type="filepath",
372
+ sources=["upload"],
373
+ label="Фото листа / Photo of the letter",
374
+ height=260,
375
+ )
376
+ letter = gr.Textbox(
377
+ label="Текст листа / Letter text",
378
+ placeholder="…або вставте текст листа сюди",
379
+ lines=10,
380
+ )
381
+ with gr.Row():
382
+ decode_btn = gr.Button("Розшифрувати лист", variant="primary", size="lg")
383
+ clear_btn = gr.ClearButton([image, letter], value="Очистити")
384
+ gr.Examples(
385
+ examples=[[EXAMPLE_LEGIT], [EXAMPLE_SCAM]],
386
+ inputs=[letter],
387
+ label="Приклади / Examples",
388
+ )
389
+ with gr.Column(scale=6):
390
+ result = gr.HTML(
391
+ '<div class="pd-meta">Тут з\'явиться розшифровка листа.</div>'
392
+ )
393
+
394
+ image.upload(do_ocr, inputs=[image], outputs=[letter])
395
+ decode_btn.click(decode, inputs=[letter], outputs=[result])
396
+ letter.submit(decode, inputs=[letter], outputs=[result])
397
+
398
+ if __name__ == "__main__":
399
+ demo.launch(css=CSS)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio==6.17.3
2
+ pytesseract==0.3.13
3
+ pillow==12.2.0
4
+ requests==2.34.2
start.sh ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ MODEL="$(find /opt/models -name '*.gguf' | head -n1)"
5
+ if [ -z "$MODEL" ]; then
6
+ echo "FATAL: no .gguf found in /opt/models" >&2
7
+ exit 1
8
+ fi
9
+ echo ">> starting llama-server with $MODEL"
10
+
11
+ /opt/llama/llama-server \
12
+ -m "$MODEL" \
13
+ --host 127.0.0.1 \
14
+ --port 8081 \
15
+ -c 4096 \
16
+ -t "$(nproc)" \
17
+ --jinja &
18
+
19
+ echo ">> waiting for llama-server health ..."
20
+ until curl -sf http://127.0.0.1:8081/health > /dev/null; do
21
+ sleep 2
22
+ done
23
+ echo ">> llama-server is up, starting Gradio"
24
+
25
+ exec python3 app.py