kingabzpro Codex commited on
Commit
f5bd1fa
·
1 Parent(s): 818c308

Fix Urdu analysis reliability

Browse files

Retry incomplete model JSON with a larger Urdu token budget, prevent trace failures from hiding successful assessments, show localized backend errors, and preserve LTR input direction for English messages in Urdu mode.

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

Files changed (5) hide show
  1. app.py +33 -8
  2. static/app.js +16 -1
  3. static/index.html +1 -1
  4. static/styles.css +5 -2
  5. tests/test_tracing.py +69 -0
app.py CHANGED
@@ -356,7 +356,15 @@ def call_model(
356
  {"role": "user", "content": content},
357
  ],
358
  temperature=0,
359
- max_tokens=500 if image_data_url else 350,
 
 
 
 
 
 
 
 
360
  response_format={
361
  "type": "json_schema",
362
  "json_schema": {
@@ -391,6 +399,10 @@ def call_model(
391
  if attempt == retries:
392
  raise
393
  time.sleep(retry_delay)
 
 
 
 
394
 
395
  raise RuntimeError("Model request ended without a response.")
396
 
@@ -415,13 +427,16 @@ def analyze_notice(
415
  ) -> dict[str, Any]:
416
  telemetry = telemetry or {}
417
  if save_trace:
418
- trace_id, queued = queue_trace(
419
- text=text,
420
- image_data_url=image_data_url,
421
- example_id=example_id,
422
- assessment=response.get("assessment"),
423
- )
424
- response["trace"] = {"trace_id": trace_id, "status": queued}
 
 
 
425
  else:
426
  response["trace"] = {"trace_id": "", "status": "disabled"}
427
  return response
@@ -456,6 +471,7 @@ def analyze_notice(
456
  "MODAL_PROXY_SECRET. Add them as environment variables or "
457
  "Hugging Face Space secrets."
458
  ),
 
459
  "status": status,
460
  },
461
  )
@@ -481,16 +497,25 @@ def analyze_notice(
481
  if exc.status_code in {401, 403}
482
  else f"The Modal model returned HTTP {exc.status_code}. Try again shortly."
483
  )
 
 
 
 
 
484
  except APITimeoutError:
485
  message = "The Modal model is unavailable or still starting. Try again shortly."
 
486
  except APIConnectionError:
487
  message = "The Modal model is unavailable or still starting. Try again shortly."
 
488
  except (ValueError, RuntimeError):
489
  message = "The model returned an invalid response. Please try again."
 
490
  return finish(
491
  {
492
  "ok": False,
493
  "error": message,
 
494
  "status": {**status, "connected": False, "label": "Modal model unavailable"},
495
  },
496
  telemetry=telemetry,
 
356
  {"role": "user", "content": content},
357
  ],
358
  temperature=0,
359
+ max_tokens=(
360
+ 700
361
+ if output_language == "ur" and image_data_url
362
+ else 550
363
+ if output_language == "ur"
364
+ else 500
365
+ if image_data_url
366
+ else 350
367
+ ),
368
  response_format={
369
  "type": "json_schema",
370
  "json_schema": {
 
399
  if attempt == retries:
400
  raise
401
  time.sleep(retry_delay)
402
+ except ValueError:
403
+ if attempt == retries:
404
+ raise
405
+ time.sleep(retry_delay)
406
 
407
  raise RuntimeError("Model request ended without a response.")
408
 
 
427
  ) -> dict[str, Any]:
428
  telemetry = telemetry or {}
429
  if save_trace:
430
+ try:
431
+ trace_id, queued = queue_trace(
432
+ text=text,
433
+ image_data_url=image_data_url,
434
+ example_id=example_id,
435
+ assessment=response.get("assessment"),
436
+ )
437
+ response["trace"] = {"trace_id": trace_id, "status": queued}
438
+ except Exception:
439
+ response["trace"] = {"trace_id": "", "status": "failed"}
440
  else:
441
  response["trace"] = {"trace_id": "", "status": "disabled"}
442
  return response
 
471
  "MODAL_PROXY_SECRET. Add them as environment variables or "
472
  "Hugging Face Space secrets."
473
  ),
474
+ "error_code": "modelCredentialsError",
475
  "status": status,
476
  },
477
  )
 
497
  if exc.status_code in {401, 403}
498
  else f"The Modal model returned HTTP {exc.status_code}. Try again shortly."
499
  )
500
+ error_code = (
501
+ "modelAuthError"
502
+ if exc.status_code in {401, 403}
503
+ else "modelServiceError"
504
+ )
505
  except APITimeoutError:
506
  message = "The Modal model is unavailable or still starting. Try again shortly."
507
+ error_code = "modelUnavailableError"
508
  except APIConnectionError:
509
  message = "The Modal model is unavailable or still starting. Try again shortly."
510
+ error_code = "modelUnavailableError"
511
  except (ValueError, RuntimeError):
512
  message = "The model returned an invalid response. Please try again."
513
+ error_code = "modelInvalidError"
514
  return finish(
515
  {
516
  "ok": False,
517
  "error": message,
518
+ "error_code": error_code,
519
  "status": {**status, "connected": False, "label": "Modal model unavailable"},
520
  },
521
  telemetry=telemetry,
static/app.js CHANGED
@@ -84,6 +84,11 @@ const translations = {
84
  requestFailedError: "The request could not be completed.",
85
  noResultError: "The app returned no result.",
86
  analyzeError: "Unable to analyze this input.",
 
 
 
 
 
87
  imageTypeError: "Use a PNG, JPG, or WebP image.",
88
  imageSizeError: "Please choose an image smaller than 8 MB.",
89
  exampleImageError: "Could not load the example image.",
@@ -159,6 +164,11 @@ const translations = {
159
  requestFailedError: "درخواست مکمل نہیں ہو سکی۔",
160
  noResultError: "کوئی نتیجہ موصول نہیں ہوا۔",
161
  analyzeError: "اس مواد کی جانچ نہیں ہو سکی۔",
 
 
 
 
 
162
  imageTypeError: "PNG، JPG یا WebP تصویر استعمال کریں۔",
163
  imageSizeError: "براہ کرم 8 MB سے چھوٹی تصویر منتخب کریں۔",
164
  exampleImageError: "مثالی تصویر لوڈ نہیں ہو سکی۔",
@@ -317,7 +327,12 @@ function renderList(selector, items) {
317
  }
318
 
319
  function renderResult(payload) {
320
- if (!payload.ok) throw new Error(t("analyzeError"));
 
 
 
 
 
321
  const result = payload.assessment;
322
  setStatus(payload.status);
323
  elements.risk.className = `risk-badge risk-${result.risk_label.toLowerCase().replaceAll(" ", "-")}`;
 
84
  requestFailedError: "The request could not be completed.",
85
  noResultError: "The app returned no result.",
86
  analyzeError: "Unable to analyze this input.",
87
+ modelCredentialsError: "Modal credentials are required.",
88
+ modelAuthError: "The model rejected the configured credentials.",
89
+ modelServiceError: "The model service returned an error. Please try again.",
90
+ modelUnavailableError: "The model is unavailable or still starting. Please try again.",
91
+ modelInvalidError: "The model returned an incomplete response. Please try again.",
92
  imageTypeError: "Use a PNG, JPG, or WebP image.",
93
  imageSizeError: "Please choose an image smaller than 8 MB.",
94
  exampleImageError: "Could not load the example image.",
 
164
  requestFailedError: "درخواست مکمل نہیں ہو سکی۔",
165
  noResultError: "کوئی نتیجہ موصول نہیں ہوا۔",
166
  analyzeError: "اس مواد کی جانچ نہیں ہو سکی۔",
167
+ modelCredentialsError: "ماڈل تک رسائی کے لیے لاگ اِن معلومات درکار ہیں۔",
168
+ modelAuthError: "ماڈل نے موجودہ لاگ اِن معلومات قبول نہیں کیں۔",
169
+ modelServiceError: "ماڈل سروس میں خرابی آئی ہے۔ براہ کرم دوبارہ کوشش کریں۔",
170
+ modelUnavailableError: "ماڈل دستیاب نہیں یا ابھی شروع ہو رہا ہے۔ براہ کرم دوبارہ کوشش کریں۔",
171
+ modelInvalidError: "ماڈل کا جواب مکمل نہیں تھا۔ براہ کرم دوبارہ کوشش کریں۔",
172
  imageTypeError: "PNG، JPG یا WebP تصویر استعمال کریں۔",
173
  imageSizeError: "براہ کرم 8 MB سے چھوٹی تصویر منتخب کریں۔",
174
  exampleImageError: "مثالی تصویر لوڈ نہیں ہو سکی۔",
 
327
  }
328
 
329
  function renderResult(payload) {
330
+ if (!payload.ok) {
331
+ const localizedError = payload.error_code
332
+ ? translations[currentLanguage][payload.error_code]
333
+ : "";
334
+ throw new Error(localizedError || payload.error || t("analyzeError"));
335
+ }
336
  const result = payload.assessment;
337
  setStatus(payload.status);
338
  elements.risk.className = `risk-badge risk-${result.risk_label.toLowerCase().replaceAll(" ", "-")}`;
static/index.html CHANGED
@@ -72,7 +72,7 @@
72
 
73
  <div class="field-card">
74
  <label class="field-label" for="noticeText"><span>2</span><span class="field-label-text" data-i18n="pasteLabel">Or paste the message</span></label>
75
- <textarea id="noticeText" maxlength="12000" placeholder="Paste the SMS, email, bill text, or notice here..." data-i18n-placeholder="textPlaceholder"></textarea>
76
  <div class="field-meta"><span data-i18n="languageSupport">English, Urdu, and Roman Urdu supported by compatible models</span><span id="charCount">0 / 12,000</span></div>
77
  <div id="textHint" class="mode-hint"><span class="hint-icon">✎</span><span data-i18n="textMode">Text mode active — image upload is locked</span></div>
78
  </div>
 
72
 
73
  <div class="field-card">
74
  <label class="field-label" for="noticeText"><span>2</span><span class="field-label-text" data-i18n="pasteLabel">Or paste the message</span></label>
75
+ <textarea id="noticeText" dir="auto" maxlength="12000" placeholder="Paste the SMS, email, bill text, or notice here..." data-i18n-placeholder="textPlaceholder"></textarea>
76
  <div class="field-meta"><span data-i18n="languageSupport">English, Urdu, and Roman Urdu supported by compatible models</span><span id="charCount">0 / 12,000</span></div>
77
  <div id="textHint" class="mode-hint"><span class="hint-icon">✎</span><span data-i18n="textMode">Text mode active — image upload is locked</span></div>
78
  </div>
static/styles.css CHANGED
@@ -328,12 +328,15 @@ html[lang="ur"] .drop-zone small {
328
  html[lang="ur"] textarea {
329
  min-height: 250px;
330
  padding: 19px 20px 26px;
331
- direction: rtl;
332
- text-align: right;
333
  font-size: 17px;
334
  line-height: 2.1;
335
  }
336
  html[lang="ur"] textarea::placeholder { line-height: 2.1; }
 
 
 
 
337
  html[lang="ur"] .field-meta {
338
  align-items: baseline;
339
  margin-top: 4px;
 
328
  html[lang="ur"] textarea {
329
  min-height: 250px;
330
  padding: 19px 20px 26px;
331
+ text-align: start;
 
332
  font-size: 17px;
333
  line-height: 2.1;
334
  }
335
  html[lang="ur"] textarea::placeholder { line-height: 2.1; }
336
+ html[lang="ur"] #charCount {
337
+ direction: ltr;
338
+ unicode-bidi: isolate;
339
+ }
340
  html[lang="ur"] .field-meta {
341
  align-items: baseline;
342
  margin-top: 4px;
tests/test_tracing.py CHANGED
@@ -298,6 +298,7 @@ class TraceTests(unittest.TestCase):
298
  result = app.analyze_notice("test message")
299
  self.assertFalse(result["ok"])
300
  model_mock.assert_not_called()
 
301
  self.assertNotIn("modal_called", queue_mock.call_args.kwargs)
302
 
303
  def test_success_uses_existing_model_call_once(self) -> None:
@@ -334,6 +335,27 @@ class TraceTests(unittest.TestCase):
334
  self.assertNotIn("modal_called", queue_mock.call_args.kwargs)
335
  self.assertNotIn("retry_count", queue_mock.call_args.kwargs)
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  def test_timeout_is_sanitized(self) -> None:
338
  timeout = APITimeoutError(request=httpx.Request("POST", "https://example.invalid"))
339
  with patch(
@@ -345,6 +367,7 @@ class TraceTests(unittest.TestCase):
345
  ) as queue_mock:
346
  result = app.analyze_notice("test message")
347
  self.assertFalse(result["ok"])
 
348
  self.assertNotIn("failure_category", queue_mock.call_args.kwargs)
349
 
350
  def test_http_failure_is_sanitized(self) -> None:
@@ -363,6 +386,7 @@ class TraceTests(unittest.TestCase):
363
  ) as queue_mock:
364
  result = app.analyze_notice("test message")
365
  self.assertFalse(result["ok"])
 
366
  self.assertNotIn("private", json.dumps(queue_mock.call_args.kwargs))
367
 
368
  def test_malformed_output_is_sanitized(self) -> None:
@@ -375,6 +399,7 @@ class TraceTests(unittest.TestCase):
375
  ) as queue_mock:
376
  result = app.analyze_notice("test message")
377
  self.assertFalse(result["ok"])
 
378
  self.assertNotIn("PRIVATE RAW OUTPUT", json.dumps(queue_mock.call_args.kwargs))
379
 
380
  def test_normalization_failure_uses_normalize_stage(self) -> None:
@@ -426,6 +451,50 @@ class TraceTests(unittest.TestCase):
426
  self.assertEqual(completions.calls, 2)
427
  self.assertEqual(telemetry["retry_count"], 1)
428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  def test_publisher_persists_batch(self) -> None:
430
  publisher = trace_runtime.TracePublisher()
431
  with tempfile.TemporaryDirectory() as directory, patch.object(
 
298
  result = app.analyze_notice("test message")
299
  self.assertFalse(result["ok"])
300
  model_mock.assert_not_called()
301
+ self.assertEqual(result["error_code"], "modelCredentialsError")
302
  self.assertNotIn("modal_called", queue_mock.call_args.kwargs)
303
 
304
  def test_success_uses_existing_model_call_once(self) -> None:
 
335
  self.assertNotIn("modal_called", queue_mock.call_args.kwargs)
336
  self.assertNotIn("retry_count", queue_mock.call_args.kwargs)
337
 
338
+ def test_trace_failure_does_not_hide_successful_assessment(self) -> None:
339
+ assessment = {
340
+ "risk_label": "Verify first",
341
+ "simple_explanation": "Check independently.",
342
+ "red_flags": ["Unverified sender"],
343
+ "safe_next_steps": ["Use an official channel."],
344
+ "reply_draft": "Please confirm through an official channel.",
345
+ }
346
+ with patch(
347
+ "app.model_status",
348
+ return_value={"connected": True, "label": "ready"},
349
+ ), patch("app.call_model", return_value=assessment), patch(
350
+ "app.queue_trace",
351
+ side_effect=RuntimeError("trace publisher failed"),
352
+ ):
353
+ result = app.analyze_notice("test message", save_trace=True)
354
+
355
+ self.assertTrue(result["ok"])
356
+ self.assertEqual(result["assessment"], assessment)
357
+ self.assertEqual(result["trace"]["status"], "failed")
358
+
359
  def test_timeout_is_sanitized(self) -> None:
360
  timeout = APITimeoutError(request=httpx.Request("POST", "https://example.invalid"))
361
  with patch(
 
367
  ) as queue_mock:
368
  result = app.analyze_notice("test message")
369
  self.assertFalse(result["ok"])
370
+ self.assertEqual(result["error_code"], "modelUnavailableError")
371
  self.assertNotIn("failure_category", queue_mock.call_args.kwargs)
372
 
373
  def test_http_failure_is_sanitized(self) -> None:
 
386
  ) as queue_mock:
387
  result = app.analyze_notice("test message")
388
  self.assertFalse(result["ok"])
389
+ self.assertEqual(result["error_code"], "modelServiceError")
390
  self.assertNotIn("private", json.dumps(queue_mock.call_args.kwargs))
391
 
392
  def test_malformed_output_is_sanitized(self) -> None:
 
399
  ) as queue_mock:
400
  result = app.analyze_notice("test message")
401
  self.assertFalse(result["ok"])
402
+ self.assertEqual(result["error_code"], "modelInvalidError")
403
  self.assertNotIn("PRIVATE RAW OUTPUT", json.dumps(queue_mock.call_args.kwargs))
404
 
405
  def test_normalization_failure_uses_normalize_stage(self) -> None:
 
451
  self.assertEqual(completions.calls, 2)
452
  self.assertEqual(telemetry["retry_count"], 1)
453
 
454
+ def test_invalid_model_json_is_retried_with_larger_urdu_budget(self) -> None:
455
+ valid = {
456
+ "risk_label": "Verify first",
457
+ "simple_explanation": "آزاد ذریعے سے تصدیق کریں۔",
458
+ "red_flags": ["بھیجنے والے کی تصدیق نہیں ہوئی۔"],
459
+ "safe_next_steps": ["سرکاری ذریعے سے رابطہ کریں۔"],
460
+ "reply_draft": "براہ کرم سرکاری ذریعے سے تصدیق کریں۔",
461
+ }
462
+
463
+ class Completions:
464
+ def __init__(self):
465
+ self.calls = 0
466
+ self.max_tokens: list[int] = []
467
+
468
+ def create(self, **kwargs):
469
+ self.calls += 1
470
+ self.max_tokens.append(kwargs["max_tokens"])
471
+ content = "{" if self.calls == 1 else json.dumps(valid)
472
+ message = type("Message", (), {"content": content})()
473
+ choice = type("Choice", (), {"message": message})()
474
+ return type("Completion", (), {"choices": [choice]})()
475
+
476
+ completions = Completions()
477
+ client = type(
478
+ "Client",
479
+ (),
480
+ {"chat": type("Chat", (), {"completions": completions})()},
481
+ )()
482
+ telemetry: dict = {}
483
+ with patch("app.create_model_client", return_value=(client, "model")), patch.dict(
484
+ "os.environ",
485
+ {"MODEL_MAX_ATTEMPTS": "2", "MODEL_RETRY_DELAY_SECONDS": "0"},
486
+ ):
487
+ result = app.call_model(
488
+ "test",
489
+ "",
490
+ telemetry,
491
+ output_language="ur",
492
+ )
493
+
494
+ self.assertEqual(result["risk_label"], "Verify first")
495
+ self.assertEqual(completions.calls, 2)
496
+ self.assertEqual(completions.max_tokens, [550, 550])
497
+
498
  def test_publisher_persists_batch(self) -> None:
499
  publisher = trace_runtime.TracePublisher()
500
  with tempfile.TemporaryDirectory() as directory, patch.object(