Abid Ali Awan Codex commited on
Commit
edecb11
·
1 Parent(s): d33de76

Add notice image guidance and update runtime docs

Browse files

Show a clear warning when an uploaded image has no readable notice text, and document the English-only ZeroGPU Transformers deployment.

Co-authored-by: Codex <codex@openai.com>

README.md CHANGED
@@ -32,8 +32,13 @@ short_description: Review suspicious Pakistani messages before you act.
32
 
33
  # NoticeCheck
34
 
35
- NoticeCheck is a local-first safety assistant for suspicious Pakistani messages,
36
- bills, bank alerts, challans, courier notices, and screenshots. It returns:
 
 
 
 
 
37
 
38
  - a risk label
39
  - a short explanation based on visible evidence
@@ -61,14 +66,17 @@ MiniCPM5-1B through Transformers on ZeroGPU
61
  Structured risk assessment
62
  ```
63
 
64
- - **Space reasoning:** `openbmb/MiniCPM5-1B` through Transformers
65
  - **OCR:** `nvidia/NVIDIA-Nemotron-Parse-v1.2` through Transformers
66
- - **Hosting:** Hugging Face Spaces ZeroGPU
67
- - **Interface:** custom English HTML, CSS, and JavaScript
 
68
 
69
  The application does not use a remote model API and has no heuristic assessment
70
  fallback. Model and OCR failures are returned explicitly.
71
 
 
 
72
  ## Repository Layout
73
 
74
  ```text
@@ -104,13 +112,17 @@ python -m unittest
104
  node --check static/app.js
105
  ```
106
 
107
- ## Language Limits
108
 
109
- Nemotron-Parse v1.2 supports document text extraction across multiple
110
- languages. Urdu-script screenshots are best effort.
 
 
 
111
 
112
- MiniCPM5-1B is officially evaluated in English and Chinese. Urdu and Roman Urdu
113
- responses remain best effort and require task-specific evaluation.
 
114
 
115
  ## Privacy-Safe Traces
116
 
 
32
 
33
  # NoticeCheck
34
 
35
+ This repository is the local version of the
36
+ [Pakistan Notice Helper Hugging Face Space](https://huggingface.co/spaces/build-small-hackathon/pakistan-notice-helper).
37
+ It keeps the same notice-checking purpose, but uses a redesigned interface and
38
+ uses the Hugging Face ZeroGPU runtime.
39
+
40
+ NoticeCheck is a safety assistant for suspicious Pakistani messages, bills,
41
+ bank alerts, challans, courier notices, and screenshots. It returns:
42
 
43
  - a risk label
44
  - a short explanation based on visible evidence
 
66
  Structured risk assessment
67
  ```
68
 
69
+ - **Reasoning:** `openbmb/MiniCPM5-1B` through Transformers
70
  - **OCR:** `nvidia/NVIDIA-Nemotron-Parse-v1.2` through Transformers
71
+ - **Compute:** Hugging Face Spaces ZeroGPU
72
+ - **Interface:** redesigned custom HTML, CSS, and JavaScript
73
+ - **Language:** English only
74
 
75
  The application does not use a remote model API and has no heuristic assessment
76
  fallback. Model and OCR failures are returned explicitly.
77
 
78
+ Both models run through Transformers on the Hugging Face ZeroGPU deployment.
79
+
80
  ## Repository Layout
81
 
82
  ```text
 
112
  node --check static/app.js
113
  ```
114
 
115
+ ## English-Only Interface
116
 
117
+ This version intentionally uses an English-only interface and requests English
118
+ analysis from the model. Most notices and scam messages targeted by the project
119
+ contain English or English mixed with common local terms. The local model also
120
+ understands the task instructions and produces structured English results more
121
+ reliably than Urdu output.
122
 
123
+ Screenshot OCR may detect text from other languages, but the generated
124
+ assessment is intended to be in English. Urdu-language output is not currently
125
+ supported.
126
 
127
  ## Privacy-Safe Traces
128
 
app/model_endpoint.py CHANGED
@@ -16,7 +16,7 @@ import spaces
16
  from huggingface_hub import hf_hub_download
17
 
18
  from app.config import ModelConfig, model_config
19
- from app.ocr import OCRRuntimeError, extract_text, ocr_installed
20
  from app.prompts import SYSTEM_PROMPT
21
  from app.schema import OUTPUT_SCHEMA, normalize_assessment
22
 
@@ -34,6 +34,10 @@ class ModelRuntimeError(RuntimeError):
34
  """A sanitized local model failure safe to expose through the API."""
35
 
36
 
 
 
 
 
37
  def model_status() -> dict[str, Any]:
38
  config = model_config()
39
  on_space = bool(os.getenv("SPACE_ID"))
@@ -297,6 +301,8 @@ def call_model(
297
  if image_data_url:
298
  try:
299
  ocr_text = extract_text(image_data_url)
 
 
300
  except OCRRuntimeError as exc:
301
  raise ModelRuntimeError(str(exc)) from exc
302
  input_text = (
 
16
  from huggingface_hub import hf_hub_download
17
 
18
  from app.config import ModelConfig, model_config
19
+ from app.ocr import NoReadableTextError, OCRRuntimeError, extract_text, ocr_installed
20
  from app.prompts import SYSTEM_PROMPT
21
  from app.schema import OUTPUT_SCHEMA, normalize_assessment
22
 
 
34
  """A sanitized local model failure safe to expose through the API."""
35
 
36
 
37
+ class NoticeImageInputError(ModelRuntimeError):
38
+ """The uploaded image is not a readable notice or message."""
39
+
40
+
41
  def model_status() -> dict[str, Any]:
42
  config = model_config()
43
  on_space = bool(os.getenv("SPACE_ID"))
 
301
  if image_data_url:
302
  try:
303
  ocr_text = extract_text(image_data_url)
304
+ except NoReadableTextError as exc:
305
+ raise NoticeImageInputError(str(exc)) from exc
306
  except OCRRuntimeError as exc:
307
  raise ModelRuntimeError(str(exc)) from exc
308
  input_text = (
app/ocr.py CHANGED
@@ -32,6 +32,17 @@ class OCRRuntimeError(RuntimeError):
32
  """A sanitized OCR failure safe to expose through the API."""
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
35
  def ocr_installed() -> bool:
36
  try:
37
  from transformers import AutoModel, AutoProcessor # noqa: F401
@@ -144,8 +155,10 @@ def extract_text(image_data_url: str) -> str:
144
  else:
145
  text = generated_text.strip()
146
 
147
- if not text:
148
- raise OCRRuntimeError("No readable text was found in the screenshot.")
 
 
149
  return text
150
  except OCRRuntimeError:
151
  raise
 
32
  """A sanitized OCR failure safe to expose through the API."""
33
 
34
 
35
+ class NoReadableTextError(OCRRuntimeError):
36
+ """The image was valid, but it did not contain useful notice text."""
37
+
38
+
39
+ def _has_readable_text(text: str) -> bool:
40
+ """Reject empty OCR output and model markup without visible notice text."""
41
+ visible_text = re.sub(r"<[^>]+>", " ", text)
42
+ alphanumeric = [char for char in visible_text if char.isalnum()]
43
+ return len(alphanumeric) >= 4 and any(char.isalpha() for char in alphanumeric)
44
+
45
+
46
  def ocr_installed() -> bool:
47
  try:
48
  from transformers import AutoModel, AutoProcessor # noqa: F401
 
155
  else:
156
  text = generated_text.strip()
157
 
158
+ if not _has_readable_text(text):
159
+ raise NoReadableTextError(
160
+ "No readable notice text was found in the screenshot."
161
+ )
162
  return text
163
  except OCRRuntimeError:
164
  raise
app/service.py CHANGED
@@ -176,13 +176,23 @@ def analyze_notice(
176
  "source": "local_model",
177
  }
178
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  except model_endpoint.ModelRuntimeError as exc:
180
  if image_data_url and "Nemotron-Parse" in str(exc):
181
  message = str(exc)
182
  error_code = "ocrUnavailableError"
183
- elif image_data_url and "No readable text" in str(exc):
184
- message = str(exc)
185
- error_code = "ocrNoTextError"
186
  else:
187
  message = "The local model is unavailable or could not be loaded."
188
  error_code = "modelUnavailableError"
 
176
  "source": "local_model",
177
  }
178
  )
179
+ except model_endpoint.NoticeImageInputError:
180
+ return finish(
181
+ {
182
+ "ok": False,
183
+ "warning": True,
184
+ "error": (
185
+ "This image does not contain readable notice text. "
186
+ "Upload a clear screenshot of the full notice or message."
187
+ ),
188
+ "error_code": "noticeImageRequiredWarning",
189
+ "status": status,
190
+ }
191
+ )
192
  except model_endpoint.ModelRuntimeError as exc:
193
  if image_data_url and "Nemotron-Parse" in str(exc):
194
  message = str(exc)
195
  error_code = "ocrUnavailableError"
 
 
 
196
  else:
197
  message = "The local model is unavailable or could not be loaded."
198
  error_code = "modelUnavailableError"
requirements-local.txt DELETED
@@ -1,7 +0,0 @@
1
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
2
-
3
- gradio==6.17.3
4
- huggingface-hub>=0.34,<2
5
- llama-cpp-python==0.3.28
6
- numpy>=1.26
7
- pillow>=12
 
 
 
 
 
 
 
 
static/app.js CHANGED
@@ -92,7 +92,7 @@ const translations = {
92
  modelInvalidError: "The model returned an incomplete response. Please try again.",
93
  gpuQuotaError: "GPU quota exceeded. Please try again later or authenticate with a Hugging Face token for more quota.",
94
  ocrUnavailableError: "Nemotron-Parse is unavailable. Paste the notice text instead.",
95
- ocrNoTextError: "No readable text was found in the screenshot.",
96
  ocrLanguageError: "This language may not be fully supported. Results may vary.",
97
  imageTypeError: "Use a PNG, JPG, or WebP image.",
98
  imageSizeError: "Please choose an image smaller than 8 MB.",
@@ -177,7 +177,7 @@ const translations = {
177
  modelInvalidError: "ماڈل کا جواب مکمل نہیں تھا۔ براہ کرم دوبارہ کوشش کریں۔",
178
  gpuQuotaError: "GPU کوٹہ ختم ہو گیا۔ براہ کرم بعد میں دوبارہ کوشش کریں یا مزید کوٹہ کے لیے Hugging Face ٹوکن سے تصدیق کریں۔",
179
  ocrUnavailableError: "Nemotron-Parse دستیاب نہیں۔ نوٹس کا متن پیسٹ کریں۔",
180
- ocrNoTextError: "اسکرین شاٹ میں پڑھنے کے قابل متن نہیں ملا۔",
181
  ocrLanguageError: "یہ زبان مکمل طور پر سپورٹ نہیں ہو سکتی۔ نتائج مختلف ہو سکتے ہیں۔",
182
  imageTypeError: "PNG، JPG یا WebP تصویر استعمال کریں۔",
183
  imageSizeError: "براہ کرم 8 MB سے چھوٹی تصویر منتخب کریں۔",
@@ -329,9 +329,10 @@ async function loadStatus() {
329
  }
330
  }
331
 
332
- function showError(message = "") {
333
  elements.error.textContent = message;
334
  elements.error.classList.toggle("visible", Boolean(message));
 
335
  }
336
 
337
  function setMode(mode) {
@@ -369,6 +370,12 @@ function renderResult(payload) {
369
  const localizedError = payload.error_code
370
  ? translations[currentLanguage][payload.error_code]
371
  : "";
 
 
 
 
 
 
372
  throw new Error(localizedError || payload.error || t("analyzeError"));
373
  }
374
  const result = payload.assessment;
 
92
  modelInvalidError: "The model returned an incomplete response. Please try again.",
93
  gpuQuotaError: "GPU quota exceeded. Please try again later or authenticate with a Hugging Face token for more quota.",
94
  ocrUnavailableError: "Nemotron-Parse is unavailable. Paste the notice text instead.",
95
+ noticeImageRequiredWarning: "This image does not contain readable notice text. Upload a clear screenshot of the full notice or message.",
96
  ocrLanguageError: "This language may not be fully supported. Results may vary.",
97
  imageTypeError: "Use a PNG, JPG, or WebP image.",
98
  imageSizeError: "Please choose an image smaller than 8 MB.",
 
177
  modelInvalidError: "ماڈل کا جواب مکمل نہیں تھا۔ براہ کرم دوبارہ کوشش کریں۔",
178
  gpuQuotaError: "GPU کوٹہ ختم ہو گیا۔ براہ کرم بعد میں دوبارہ کوشش کریں یا مزید کوٹہ کے لیے Hugging Face ٹوکن سے تصدیق کریں۔",
179
  ocrUnavailableError: "Nemotron-Parse دستیاب نہیں۔ نوٹس کا متن پیسٹ کریں۔",
180
+ noticeImageRequiredWarning: "اس تصویر میں نوٹس کا واضح متن موجود نہیں ہے۔ مکمل نوٹس یا پیغام کا صاف اسکرین شاٹ اپ لوڈ کریں۔",
181
  ocrLanguageError: "یہ زبان مکمل طور پر سپورٹ نہیں ہو سکتی۔ نتائج مختلف ہو سکتے ہیں۔",
182
  imageTypeError: "PNG، JPG یا WebP تصویر استعمال کریں۔",
183
  imageSizeError: "براہ کرم 8 MB سے چھوٹی تصویر منتخب کریں۔",
 
329
  }
330
  }
331
 
332
+ function showError(message = "", tone = "error") {
333
  elements.error.textContent = message;
334
  elements.error.classList.toggle("visible", Boolean(message));
335
+ elements.error.classList.toggle("warning", Boolean(message) && tone === "warning");
336
  }
337
 
338
  function setMode(mode) {
 
370
  const localizedError = payload.error_code
371
  ? translations[currentLanguage][payload.error_code]
372
  : "";
373
+ if (payload.warning) {
374
+ elements.results.hidden = true;
375
+ setStatus(payload.status);
376
+ showError(localizedError || payload.error || t("analyzeError"), "warning");
377
+ return;
378
+ }
379
  throw new Error(localizedError || payload.error || t("analyzeError"));
380
  }
381
  const result = payload.assessment;
static/styles.css CHANGED
@@ -196,6 +196,7 @@ textarea:disabled { opacity: .45; background: #f5f3ff; cursor: not-allowed; }
196
  .form-actions { display: flex; justify-content: center; align-items: center; gap: 14px; margin-top: 26px; }
197
  .form-error { display: none; margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #fff0ef; color: #8d2722; font-size: 13px; }
198
  .form-error.visible { display: block; }
 
199
  .trace-consent {
200
  margin-top: 18px; padding: 13px 15px; display: flex; align-items: flex-start; gap: 11px;
201
  border: 1px solid var(--line); border-radius: 14px; background: #faf9ff; cursor: pointer;
 
196
  .form-actions { display: flex; justify-content: center; align-items: center; gap: 14px; margin-top: 26px; }
197
  .form-error { display: none; margin-top: 16px; padding: 12px 14px; border-radius: 12px; background: #fff0ef; color: #8d2722; font-size: 13px; }
198
  .form-error.visible { display: block; }
199
+ .form-error.warning { background: #fff8df; color: #72520b; border: 1px solid #ead58d; }
200
  .trace-consent {
201
  margin-top: 18px; padding: 13px 15px; display: flex; align-items: flex-start; gap: 11px;
202
  border: 1px solid var(--line); border-radius: 14px; background: #faf9ff; cursor: pointer;
tests/test_tracing.py CHANGED
@@ -400,6 +400,26 @@ class TraceTests(unittest.TestCase):
400
  self.assertEqual(result["error_code"], "modelInvalidError")
401
  self.assertNotIn("PRIVATE RAW OUTPUT", json.dumps(queue_mock.call_args.kwargs))
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  def test_normalization_failure_uses_normalize_stage(self) -> None:
404
  telemetry: dict = {}
405
  with self.assertRaises(ValueError):
@@ -467,6 +487,28 @@ class TraceTests(unittest.TestCase):
467
  self.assertEqual(text, "PAKISTAN POST\n\nPay Rs. 85 now")
468
  fake_pipeline.assert_called_once()
469
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  def test_image_ocr_text_is_passed_to_minicpm(self) -> None:
471
  config = model_endpoint.model_config()
472
  fake_model = object()
 
400
  self.assertEqual(result["error_code"], "modelInvalidError")
401
  self.assertNotIn("PRIVATE RAW OUTPUT", json.dumps(queue_mock.call_args.kwargs))
402
 
403
+ def test_image_without_notice_text_returns_input_warning(self) -> None:
404
+ with patch(
405
+ "app.model_endpoint.model_status",
406
+ return_value={"connected": True, "label": "ready"},
407
+ ), patch(
408
+ "app.model_endpoint.call_model",
409
+ side_effect=model_endpoint.NoticeImageInputError(
410
+ "No readable notice text was found in the screenshot."
411
+ ),
412
+ ):
413
+ result = app.analyze_notice(
414
+ image_data_url="data:image/png;base64,AAAA",
415
+ save_trace=False,
416
+ )
417
+
418
+ self.assertFalse(result["ok"])
419
+ self.assertTrue(result["warning"])
420
+ self.assertEqual(result["error_code"], "noticeImageRequiredWarning")
421
+ self.assertTrue(result["status"]["connected"])
422
+
423
  def test_normalization_failure_uses_normalize_stage(self) -> None:
424
  telemetry: dict = {}
425
  with self.assertRaises(ValueError):
 
487
  self.assertEqual(text, "PAKISTAN POST\n\nPay Rs. 85 now")
488
  fake_pipeline.assert_called_once()
489
 
490
+ def test_ocr_readability_rejects_parser_markup_without_notice_text(self) -> None:
491
+ self.assertFalse(
492
+ ocr._has_readable_text(
493
+ "<picture><x_10><y_20><x_900><y_700></picture>"
494
+ )
495
+ )
496
+ self.assertTrue(ocr._has_readable_text("Pay Rs. 85 now"))
497
+ self.assertTrue(ocr._has_readable_text("آپ کا بل 500 روپے ہے"))
498
+
499
+ def test_no_text_ocr_error_becomes_notice_image_input_error(self) -> None:
500
+ with patch(
501
+ "app.model_endpoint.extract_text",
502
+ side_effect=ocr.NoReadableTextError(
503
+ "No readable notice text was found in the screenshot."
504
+ ),
505
+ ):
506
+ with self.assertRaises(model_endpoint.NoticeImageInputError):
507
+ model_endpoint.call_model(
508
+ "",
509
+ "data:image/png;base64,AAAA",
510
+ )
511
+
512
  def test_image_ocr_text_is_passed_to_minicpm(self) -> None:
513
  config = model_endpoint.model_config()
514
  fake_model = object()