@@ -25,108 +25,71 @@ jobs:
2525 ref : ${{ github.event.pull_request.head.ref }}
2626 fetch-depth : 0
2727
28- - name : Apply exact fixes and behavioral tests
28+ - name : Apply fixes to actual checkout
2929 shell : bash
3030 run : |
3131 set -euo pipefail
3232 python - <<'PY'
3333 from pathlib import Path
3434 import re
3535
36- # 1) Medical OCR separator fix
36+ # OCR: operate only inside the named dictionary/function, never by global text guessing.
3737 p = Path('hf-space/app_core.py')
3838 s = p.read_text(encoding='utf-8')
39- if '"ترامادول ": "ترامادول"' in s:
40- old_dict = '''OCR_CORRECTIONS = {
41- "باراسيتبمول": "باراسيتامول", "ايبوروفين": "ايبوبروفين",
42- "اموكسيستلين": "اموكسيسيلين", "اموكسيسلين": "اموكسيسيلين",
43- "ازيثروميسين": "ازيثرومايسين", "ميتروندازول": "ميترونيدازول",
44- "ديكلوفيناك ": "ديكلوفيناك", "اوجمينتين": "اوجمنتين",
45- "اوميبرازول ": "اوميبرازول", "سيليبريكس ": "سيليبريكس",
46- "ترامادول ": "ترامادول", "كاتافلام ": "كاتافلام",
47- "نوفافين ": "نوفافين", "فلاميكس ": "فلاميكس",
48- "بنادول ": "بنادول", "ادفيل ": "ادفيل",
49- }'''
50- new_dict = '''OCR_CORRECTIONS = {
51- "باراسيتبمول": "باراسيتامول", "ايبوروفين": "ايبوبروفين",
52- "اموكسيستلين": "اموكسيسيلين", "اموكسيسلين": "اموكسيسيلين",
53- "ازيثروميسين": "ازيثرومايسين", "ميتروندازول": "ميترونيدازول",
54- "ديكلوفيناك": "ديكلوفيناك", "اوجمينتين": "اوجمنتين",
55- "اوميبرازول": "اوميبرازول", "سيليبريكس": "سيليبريكس",
56- "ترامادول": "ترامادول", "كاتافلام": "كاتافلام",
57- "نوفافين": "نوفافين", "فلاميكس": "فلاميكس",
58- "بنادول": "بنادول", "ادفيل": "ادفيل",
59- }'''
60- if old_dict not in s:
61- raise SystemExit('Expected original OCR_CORRECTIONS block not found')
62- s = s.replace(old_dict, new_dict, 1)
63-
64- old_func = '''def _auto_correct_ocr(text: str) -> Tuple[str, List[Dict]]:
65- """Apply OCR corrections + spell checker. Returns (corrected, changes)."""
66- changes = []
67- corrected = text
68- for wrong, right in OCR_CORRECTIONS.items():
69- if wrong in corrected:
70- count = corrected.count(wrong)
71- corrected = corrected.replace(wrong, right)
72- changes.append({"type": "ocr_fix", "from": wrong, "to": right, "count": count})
73- corrected = re.sub(r'[ \\t]+', ' ', corrected)
74- corrected = re.sub(r'\\n{3,}', '\\n\\n', corrected).strip()
75- return corrected, changes'''
76- new_func = '''def _auto_correct_ocr(text: str) -> Tuple[str, List[Dict]]:
77- """Apply OCR corrections while preserving token boundaries and whitespace."""
78- changes = []
79- corrected = text
80- for wrong, right in OCR_CORRECTIONS.items():
81- if wrong == right:
82- continue
83- pattern = r'(?<!\\w)' + re.escape(wrong) + r'(?!\\w)'
84- count = len(re.findall(pattern, corrected))
85- if count:
86- corrected = re.sub(pattern, right, corrected)
87- changes.append({"type": "ocr_fix", "from": wrong, "to": right, "count": count})
88- corrected = re.sub(r'[ \\t]+', ' ', corrected)
89- corrected = re.sub(r'\\n{3,}', '\\n\\n', corrected).strip()
90- return corrected, changes'''
91- if old_func not in s:
92- raise SystemExit('Expected original _auto_correct_ocr function not found')
93- s = s.replace(old_func, new_func, 1)
94- p.write_text(s, encoding='utf-8')
39+ m = re.search(r'OCR_CORRECTIONS = \{.*?\n\}', s, flags=re.S)
40+ if not m:
41+ raise SystemExit('OCR_CORRECTIONS block not found')
42+ block = m.group(0)
43+ block = re.sub(r'("[^"\n]*?)\s+":', r'\1":', block)
44+ s = s[:m.start()] + block + s[m.end():]
45+
46+ old_start = s.index('def _auto_correct_ocr(')
47+ old_end = s.index('\ndef _extract_ner', old_start)
48+ new_func = '''def _auto_correct_ocr(text: str) -> Tuple[str, List[Dict]]:\n """Apply OCR corrections while preserving token boundaries and whitespace."""\n changes = []\n corrected = text\n for wrong, right in OCR_CORRECTIONS.items():\n if wrong == right:\n continue\n pattern = r'(?<!\\w)' + re.escape(wrong) + r'(?!\\w)'\n count = len(re.findall(pattern, corrected))\n if count:\n corrected = re.sub(pattern, right, corrected)\n changes.append({"type": "ocr_fix", "from": wrong, "to": right, "count": count})\n corrected = re.sub(r'[ \\t]+', ' ', corrected)\n corrected = re.sub(r'\\n{3,}', '\\n\\n', corrected).strip()\n return corrected, changes\n'''
49+ s = s[:old_start] + new_func + s[old_end:]
50+ p.write_text(s, encoding='utf-8')
9551
96- # 2) Authenticate admin status endpoint
52+ # Admin endpoint.
9753 p = Path('app/routers/admin.py')
9854 s = p.read_text(encoding='utf-8')
9955 if 'Depends(get_current_user)' not in s:
100- s = s.replace('from fastapi import APIRouter\n', 'from fastapi import APIRouter, Depends\nfrom app.core.security import get_current_user\nfrom app.db.models.auth import User\n', 1)
56+ s = s.replace('from fastapi import APIRouter', 'from fastapi import APIRouter, Depends', 1)
57+ s = s.replace('from fastapi import APIRouter, Depends\n', 'from fastapi import APIRouter, Depends\nfrom app.core.security import get_current_user\nfrom app.db.models.auth import User\n', 1)
10158 s = s.replace('async def system_status():', 'async def system_status(current_user: User = Depends(get_current_user)):', 1)
102- p.write_text(s, encoding='utf-8')
59+ p.write_text(s, encoding='utf-8')
10360
104- # 3) Make full-stack deployment explicit and non-destructive
61+ # Deployment topology.
10562 p = Path('deploy/deploy.sh')
10663 s = p.read_text(encoding='utf-8')
107- s = s.replace('deploy/docker-compose.prod.yml', 'deploy/docker-compose.full.yml')
108- s = re.sub(r'^\s*cp\s+[^\n]*docker-compose\.full\.yml[^\n]*\n', '', s, flags=re.M)
64+ s = s.replace('docker-compose.prod.yml', 'docker-compose.full.yml')
65+ s = re.sub(r'^\s*cp\s+"?\$\{DEPLOY_DIR\}/docker-compose\.full\.yml"?\s+"?\$\{PROJECT_DIR\}/docker-compose\.prod\.yml"?\s*\n', '', s, flags=re.M)
66+ s = re.sub(r'^if \[\[ -f "\$\{DEPLOY_DIR\}/docker-compose\.full\.yml" \]\]; then\n\s*cp .*?\nfi\n', '', s, flags=re.M)
67+ if 'COMPOSE_FILE=' not in s:
68+ s = s.replace('DEPLOY_DIR="$(dirname "$0")"\n', 'DEPLOY_DIR="$(dirname "$0")"\nCOMPOSE_FILE="${DEPLOY_DIR}/docker-compose.full.yml"\n', 1)
69+ s = s.replace('docker compose -f docker-compose.full.yml', 'docker compose --project-directory "$PROJECT_DIR" -f "$COMPOSE_FILE"')
10970 p.write_text(s, encoding='utf-8')
110- old = Path('deploy/docker-compose.prod.yml')
111- full = Path('deploy/docker-compose.full.yml')
112- if old.exists() and not full.exists():
113- old.rename(full)
114- elif old.exists() and full.exists():
115- raise SystemExit('Both compose files exist; refusing to choose silently')
116- elif not full.exists():
117- raise SystemExit('Full compose file missing')
11871
119- # 4) Behavioral tests against the actual source implementation
120- test = Path('tests/security/test_medical_behavior.py')
121- test.parent.mkdir(parents=True, exist_ok=True)
122- test.write_text('''import re\nfrom pathlib import Path\n\ndef _load_ocr_function():\n source = Path("hf-space/app_core.py").read_text(encoding="utf-8")\n block = source[source.index("OCR_CORRECTIONS = {"):source.index("# ── Lazy-loaded modules")]\n ns = {"re": re}\n exec(block + "\\n" + source[source.index("def _auto_correct_ocr"):source.index("\\ndef _extract_ner")], ns)\n return ns["_auto_correct_ocr"]\n\ndef test_separator_preserved():\n assert _load_ocr_function()("ترامادول 0.5 mg")[0] == "ترامادول 0.5 mg"\n\ndef test_decimal_tokens_unchanged():\n fn = _load_ocr_function()\n for text in ("0.5", "1.25", "0.75", "٠٫٥", "١٫٢٥"):\n assert fn(text)[0] == text\n\ndef test_negations_unchanged():\n fn = _load_ocr_function()\n for text in ("لا", "ليس", "لم", "لن", "غير", "بدون", "لا يوجد سكري"):\n assert fn(text)[0] == text\n\ndef test_intended_ocr_correction():\n assert _load_ocr_function()("باراسيتبمول 500 mg")[0] == "باراسيتامول 500 mg"\n''', encoding='utf-8')
72+ # Rename alternate compose if needed.
73+ old = Path('deploy/docker-compose.prod.yml')
74+ new = Path('deploy/docker-compose.full.yml')
75+ if old.exists() and not new.exists():
76+ old.rename(new)
77+ if not new.exists():
78+ raise SystemExit('deploy/docker-compose.full.yml missing')
79+
80+ # Real behavioral regression tests: execute the extracted production function source.
81+ t = Path('tests/security/test_medical_behavior.py')
82+ t.parent.mkdir(parents=True, exist_ok=True)
83+ t.write_text('''import re\nfrom pathlib import Path\n\ndef _fn():\n s = Path("hf-space/app_core.py").read_text(encoding="utf-8")\n b = s[s.index("OCR_CORRECTIONS = {"):s.index("# ── Lazy-loaded modules")]\n f = s[s.index("def _auto_correct_ocr"):s.index("\\ndef _extract_ner")]\n ns = {"re": re}\n exec(b + "\\n" + f, ns)\n return ns["_auto_correct_ocr"]\n\ndef test_separator():\n assert _fn()("ترامادول 0.5 mg")[0] == "ترامادول 0.5 mg"\n\ndef test_decimals():\n fn = _fn()\n for x in ("0.5", "1.25", "0.75", "٠٫٥", "١٫٢٥"):\n assert fn(x)[0] == x\n\ndef test_negations():\n fn = _fn()\n for x in ("لا", "ليس", "لم", "لن", "غير", "بدون", "لا يوجد سكري"):\n assert fn(x)[0] == x\n\ndef test_intended_fix():\n assert _fn()("باراسيتبمول 500 mg")[0] == "باراسيتامول 500 mg"\n''', encoding='utf-8')
12384 PY
85+
12486 python -m pytest -q tests/security/test_medical_behavior.py
12587 grep -q '"ترامادول": "ترامادول"' hf-space/app_core.py
12688 ! grep -q '"ترامادول ": "ترامادول"' hf-space/app_core.py
12789 grep -q 'Depends(get_current_user)' app/routers/admin.py
12890 test -f deploy/docker-compose.full.yml
12991 ! test -f deploy/docker-compose.prod.yml
92+ grep -q 'docker-compose.full.yml' deploy/deploy.sh
13093
13194 - name : Commit applied fixes
13295 shell : bash
@@ -136,34 +99,24 @@ jobs:
13699 git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
137100 git add hf-space/app_core.py app/routers/admin.py deploy/deploy.sh deploy/docker-compose.full.yml tests/security/test_medical_behavior.py
138101 git add -u deploy/docker-compose.prod.yml
139- if git diff --cached --quiet; then
140- echo 'Fixes already applied; nothing to commit.'
141- else
142- git commit -m 'fix: apply verified security hardening'
143- git push origin HEAD:${{ github.event.pull_request.head.ref }}
144- fi
102+ if git diff --cached --quiet; then exit 0; fi
103+ git commit -m 'fix: apply verified security hardening'
104+ git push origin HEAD:${{ github.event.pull_request.head.ref }}
145105
146106 lint :
147107 name : Code Quality
148108 runs-on : ubuntu-latest
149109 steps :
150110 - uses : actions/checkout@v4
151-
152111 - name : Set up Python
153112 uses : actions/setup-python@v5
154- with :
155- python-version : ' 3.11'
156-
113+ with : {python-version: '3.11'}
157114 - name : Install lint tools
158- run : |
159- pip install flake8 black isort mypy
160-
115+ run : pip install flake8 black isort mypy
161116 - name : Run flake8
162117 run : flake8 desktop/ hf-space/ app/ --count --statistics || true
163-
164118 - name : Run black check
165119 run : black --check desktop/ hf-space/ app/ || true
166-
167120 - name : Run isort check
168121 run : isort --check-only desktop/ hf-space/ app/ || true
169122
@@ -172,26 +125,18 @@ jobs:
172125 runs-on : ubuntu-latest
173126 steps :
174127 - uses : actions/checkout@v4
175-
176128 - name : Set up Python
177129 uses : actions/setup-python@v5
178- with :
179- python-version : ' 3.11'
180-
130+ with : {python-version: '3.11'}
181131 - name : Install system dependencies (Tesseract OCR)
182- run : |
183- sudo apt-get update -qq
184- sudo apt-get install -y -qq tesseract-ocr tesseract-ocr-ara tesseract-ocr-eng libtesseract-dev 2>/dev/null || true
185-
132+ run : sudo apt-get update -qq && sudo apt-get install -y -qq tesseract-ocr tesseract-ocr-ara tesseract-ocr-eng libtesseract-dev 2>/dev/null || true
186133 - name : Install dependencies
187134 run : |
188135 pip install pytest pytest-cov
189136 pip install opencv-python Pillow numpy
190137 pip install "huggingface-hub<1.0.0" "pydantic<2.11.0"
191-
192138 - name : Run tests
193139 run : pytest tests/ -v --cov=. --cov-report=xml || echo "No tests yet"
194-
195140 - name : Upload coverage
196141 uses : codecov/codecov-action@v3
197142 if : always()
0 commit comments