Spaces:
Runtime error
Runtime error
Rajeev Pandey commited on
Commit ·
ca4cce7
0
Parent(s):
Build FalconScan customs document assistant
Browse files- .dockerignore +6 -0
- .gitignore +5 -0
- Dockerfile +12 -0
- README.md +211 -0
- TASK_SHEET.md +167 -0
- app.py +179 -0
- backend/__init__.py +1 -0
- backend/ai_definition_service.py +27 -0
- backend/document_service.py +148 -0
- backend/feedback_service.py +71 -0
- backend/glossary_service.py +191 -0
- backend/image_utils.py +42 -0
- backend/insight_service.py +62 -0
- backend/ocr_service.py +136 -0
- backend/progress_service.py +22 -0
- backend/vlm_service.py +35 -0
- data/feedback_history.json +1 -0
- data/glossary.json +123 -0
- data/ocr_cache.json +1 -0
- data/project_progress.json +11 -0
- data/sme_approved_definitions.json +1 -0
- data/user_corrections.json +1 -0
- examples/sample_documents/README.md +3 -0
- frontend/camera.js +310 -0
- frontend/feedback.js +1 -0
- frontend/index.html +74 -0
- frontend/insights.js +67 -0
- frontend/overlay.js +202 -0
- frontend/styles.css +228 -0
- pytest.ini +3 -0
- requirements-dev.txt +3 -0
- requirements-lite.txt +8 -0
- requirements.txt +11 -0
- tests/test_services.py +127 -0
.dockerignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
__pycache__
|
| 4 |
+
.pytest_cache
|
| 5 |
+
*.pyc
|
| 6 |
+
tests
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 FALCONSCAN_OCR_ENABLED=true
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends libgl1 libglib2.0-0 libgomp1 && rm -rf /var/lib/apt/lists/*
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install -r requirements.txt
|
| 8 |
+
COPY . .
|
| 9 |
+
RUN useradd -m -u 1000 user && chown -R user:user /app
|
| 10 |
+
USER user
|
| 11 |
+
EXPOSE 7860
|
| 12 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FalconScan
|
| 3 |
+
emoji: 🦅
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# FalconScan
|
| 13 |
+
|
| 14 |
+
FalconScan is a bilingual, AI-assisted camera companion for customs documents. It lets a non-specialist point a phone or webcam at a manifest, bill of lading, airway bill, declaration, invoice, packing list, delivery order, or port-clearance form and tap contextual dots for plain-language definitions.
|
| 15 |
+
|
| 16 |
+
The product is deliberately **glossary-first and CPU-first**. The browser provides continuous camera feedback and detects stable, sharp, well-lit frames. Only a stable frame—or an explicit “Scan now”—is sent to the backend. PaddleOCR handles English and Arabic text; local services then match customs terminology. A VLM is an optional, separately hosted enhancement, never a requirement for the core app.
|
| 17 |
+
|
| 18 |
+
## 1. Project overview
|
| 19 |
+
|
| 20 |
+
FalconScan is an end-to-end, camera-based customs terminology assistant for Dubai Customs, freight-forwarding desks, free-zone operators, logistics teams, and trade-compliance staff. Phase 1 delivers the CPU-friendly scanning loop; Phase 2 adds governed knowledge and feedback; Phase 3 defines optional AI/VLM enhancement without making it a core dependency.
|
| 21 |
+
|
| 22 |
+
## 2. Problem statement
|
| 23 |
+
|
| 24 |
+
Customs paperwork assumes specialist vocabulary. Staff often lose context by switching to a search tool or relying on an unverified explanation. FalconScan keeps the explanation attached to the physical term in the camera view.
|
| 25 |
+
|
| 26 |
+
## 3. Functional requirements
|
| 27 |
+
|
| 28 |
+
Requirements implemented:
|
| 29 |
+
|
| 30 |
+
- Camera permission, live preview, local motion/blur/lighting signals, stable-frame auto-capture, and manual capture.
|
| 31 |
+
- English and Arabic PaddleOCR with word/line bounding boxes.
|
| 32 |
+
- Exact, case-insensitive, acronym, alias, Arabic, multi-word phrase, and fuzzy OCR matching.
|
| 33 |
+
- Clickable dot overlays, bilingual definition cards, confidence, source labels, related terms, and RTL display.
|
| 34 |
+
- Thumbs-up/down feedback, correction entry, local audit history, pending review, and SME approve/reject UI.
|
| 35 |
+
- Definition priority: SME-approved → pending user correction → verified glossary → optional knowledge/RAG layer → cautious AI fallback. SME approval intentionally overrides pending user text because it is the governed official answer.
|
| 36 |
+
- Optional VLM gate for low confidence, unknown terms, complex layouts, or explicit full-document analysis.
|
| 37 |
+
|
| 38 |
+
## 4. Non-functional requirements
|
| 39 |
+
|
| 40 |
+
- **Mobile-first responsive design:** phone portrait is the primary interaction target. Camera controls, scanning status, definition sheets, and feedback use touch-friendly dimensions and safe-area spacing. Tablet and desktop layouts progressively enhance the same interface without removing features.
|
| 41 |
+
- CPU usability, bounded OCR cache, lazy model loading, and graceful operation without VLM.
|
| 42 |
+
- No saved document images by default, clear provenance, and governed corrections.
|
| 43 |
+
- Simple JSON persistence for the MVP and an API shape ready for later database/RAG migration.
|
| 44 |
+
- Accessible semantics, readable contrast, RTL definitions, reduced-motion support, and 320px minimum viewport support.
|
| 45 |
+
|
| 46 |
+
## 5. Architecture
|
| 47 |
+
|
| 48 |
+
The phone performs the continuous, latency-sensitive work. The backend only receives a stable or explicitly captured frame, performs cache/OCR/knowledge lookup, and returns terms with coordinates. Feedback feeds a human-governed approval loop. Optional VLM analysis is isolated from the routine path.
|
| 49 |
+
|
| 50 |
+
```mermaid
|
| 51 |
+
flowchart TD
|
| 52 |
+
A["Mobile browser camera"] --> B["On-device stability, blur and lighting checks"]
|
| 53 |
+
B -->|"Stable frame or Scan now"| C["FastAPI image utilities"]
|
| 54 |
+
C --> D{"Similar frame cached?"}
|
| 55 |
+
D -->|"Yes"| H["Definition priority layer"]
|
| 56 |
+
D -->|"No"| E["PaddleOCR English + Arabic"]
|
| 57 |
+
E --> F["Text and bounding boxes"]
|
| 58 |
+
F --> G["Exact, alias, phrase and fuzzy matching"]
|
| 59 |
+
G --> H
|
| 60 |
+
H --> I["Clickable dots and definition sheet"]
|
| 61 |
+
I --> J["User feedback and corrections"]
|
| 62 |
+
J --> K["SME review"]
|
| 63 |
+
K --> H
|
| 64 |
+
E -. "Low confidence, unknown term or complex layout" .-> L["Optional external AI or VLM"]
|
| 65 |
+
L -. "Unverified result with source label" .-> H
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
The definition layer applies governed precedence: SME-approved definition, pending user correction, verified glossary, future RAG retrieval, then explicitly unverified AI fallback. Full document images are not persisted.
|
| 69 |
+
|
| 70 |
+
## 6. Folder structure
|
| 71 |
+
|
| 72 |
+
```text
|
| 73 |
+
Browser camera
|
| 74 |
+
→ local stability / blur / light checks
|
| 75 |
+
→ one JPEG on stable or manual capture
|
| 76 |
+
→ FastAPI → perceptual cache → PaddleOCR (EN + AR)
|
| 77 |
+
→ corrections / SME / glossary / optional RAG / AI priority
|
| 78 |
+
→ bounding boxes + definitions → clickable overlay
|
| 79 |
+
→ feedback audit → SME approval
|
| 80 |
+
↘ optional external VLM only when gated
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
```text
|
| 84 |
+
app.py FastAPI routes and orchestration
|
| 85 |
+
backend/ocr_service.py Lazy bilingual PaddleOCR adapter
|
| 86 |
+
backend/glossary_service.py Matching and definition priority
|
| 87 |
+
backend/feedback_service.py Corrections, audit, SME workflow
|
| 88 |
+
backend/ai_definition_service.py Conservative AI fallback boundary
|
| 89 |
+
backend/vlm_service.py Phase 3 trigger and endpoint gate
|
| 90 |
+
backend/image_utils.py Decode, quality, frame fingerprint
|
| 91 |
+
backend/progress_service.py Persistent project checkpoint
|
| 92 |
+
frontend/ Camera UI, overlay, feedback, styles
|
| 93 |
+
data/ Glossary, corrections, approvals, cache
|
| 94 |
+
tests/ Service and API tests
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## 7. Data model
|
| 98 |
+
|
| 99 |
+
`data/glossary.json` is RAG-ready rather than a flat string map. Each canonical term can have aliases, full form, English and Arabic definitions, category, related terms, document types, and provenance. Twenty seed concepts include B/L, MAWB, HAWB, HS Code, CIF, FOB, COO, TIR, FZE, customs duty, consignee, shipper, manifest, and key trade documents.
|
| 100 |
+
|
| 101 |
+
Corrections are separated into pending user suggestions and SME-approved definitions. Feedback history is append-only. For a multi-instance production deployment, replace the JSON repositories with SQLite/Postgres or a Hugging Face Dataset while keeping the service interfaces.
|
| 102 |
+
|
| 103 |
+
## 8. Glossary examples
|
| 104 |
+
|
| 105 |
+
The seeded bilingual knowledge includes B/L, MAWB, HAWB, HS Code, CIF, FOB, COO, TIR, FZE, Bonded Warehouse, Customs Duty, Consignee, Shipper, Manifest, Declaration Number, Incoterms, Delivery Order, Packing List, and Commercial Invoice. Each record can carry aliases, Arabic equivalents, category, document types, related terms, definition provenance, and confidence.
|
| 106 |
+
|
| 107 |
+
## 9. APIs
|
| 108 |
+
|
| 109 |
+
- `POST /analyze-frame` — base64 image, dimensions, language preference; returns detected terms, bounding boxes, OCR confidence, quality, cache state, unknown text, and VLM suggestion.
|
| 110 |
+
- `POST /analyze-document` — analyzes uploaded JPG, PNG, WebP, PDF, or DOCX; renders a private preview and returns positioned terms with definitions.
|
| 111 |
+
- `POST /explain-selection` — turns selected OCR text or a highlighted paragraph into a sourced summary and business meaning.
|
| 112 |
+
- `GET /definition/{term}?language=en` — best governed definition or a labeled verification fallback.
|
| 113 |
+
- `POST /submit-feedback` — records positive feedback or a correction.
|
| 114 |
+
- `GET /admin/corrections` — pending review queue.
|
| 115 |
+
- `POST /admin/review` — approve or reject a suggestion.
|
| 116 |
+
- `POST /analyze-document-vlm` — explicit Phase 3 gate; remains graceful when unconfigured.
|
| 117 |
+
- `GET /health` and `GET /progress` — runtime and project state.
|
| 118 |
+
|
| 119 |
+
Interactive schemas are available at `/docs`.
|
| 120 |
+
|
| 121 |
+
## 10. Frontend camera workflow
|
| 122 |
+
|
| 123 |
+
The camera samples a tiny 96×72 canvas locally. Average pixel change estimates motion, vertical luminance differences provide a cheap focus signal, and brightness detects darkness/glare. Twelve good samples trigger one capture, with an eight-second cooldown. Phone portrait is the baseline UI: the camera comes first, controls are touch-sized, status remains readable over video, and definitions open as bottom sheets. At 900px and above, the scanner and control panel become a two-column desktop workspace.
|
| 124 |
+
|
| 125 |
+
## 11. Backend OCR workflow
|
| 126 |
+
|
| 127 |
+
The backend decodes one stable JPEG, computes an image-quality signal and visual fingerprint, then checks the bounded cache. A cache miss invokes lazy English and Arabic PaddleOCR. The backend hashes a 16×16 luminance thumbnail and reuses one of at most 100 cached OCR results for visually similar frames. Original images are not written to disk.
|
| 128 |
+
|
| 129 |
+
## 12. Matching logic
|
| 130 |
+
|
| 131 |
+
OCR detections retain source coordinates. The browser maps them through the contained-video scale and offsets. Definitions are selected by canonical/alias normalization, longest phrase containment, then a 0.78 fuzzy threshold. OCR and match confidence are combined and always displayed as a confidence signal—not as legal certainty.
|
| 132 |
+
|
| 133 |
+
## 13. Feedback correction logic
|
| 134 |
+
|
| 135 |
+
A thumbs-down correction immediately becomes the preferred pending user explanation. Feedback events remain in the audit history. The header badge shows the actual pending-review count and stays hidden when the count is zero or unavailable.
|
| 136 |
+
|
| 137 |
+
## 14. SME approval workflow
|
| 138 |
+
|
| 139 |
+
The SME queue shows current and suggested text, author, timestamp, and actions. Approval copies the definition into the governed store; rejection removes it from the active priority path while preserving the audit record. An approved correction becomes the official answer and overrides pending user text.
|
| 140 |
+
|
| 141 |
+
## 15. AI fallback logic
|
| 142 |
+
|
| 143 |
+
Unknown-term AI output is intentionally conservative and labeled `ai_generated_unverified`. Set `FALCONSCAN_AI_ENDPOINT` and `HF_TOKEN` only after implementing the chosen provider adapter. If no endpoint exists, FalconScan says that the term needs customs-expert verification rather than guessing.
|
| 144 |
+
|
| 145 |
+
## 16. VLM enhancement workflow
|
| 146 |
+
|
| 147 |
+
`FALCONSCAN_VLM_ENDPOINT` configures the optional Phase 3 boundary. A VLM should run outside the CPU web process and only for an explicit request, low OCR confidence, unknown content, stamps/tables, or complex layout. It must not replace word localization or the governed glossary.
|
| 148 |
+
|
| 149 |
+
## 17. Run and deploy
|
| 150 |
+
|
| 151 |
+
Lightweight local UI/API development (no OCR model):
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
python -m venv .venv
|
| 155 |
+
source .venv/bin/activate
|
| 156 |
+
pip install -r requirements-lite.txt
|
| 157 |
+
FALCONSCAN_OCR_ENABLED=false uvicorn app:app --reload
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
Full local OCR:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
pip install -r requirements.txt
|
| 164 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
Open `http://localhost:7860`. Camera access requires localhost or HTTPS. The first English/Arabic scan downloads PaddleOCR models and therefore has a cold start.
|
| 168 |
+
|
| 169 |
+
For Hugging Face Spaces:
|
| 170 |
+
|
| 171 |
+
1. Create a **Docker Space** using a free CPU hardware tier.
|
| 172 |
+
2. Push this repository. The README metadata and Dockerfile expose port 7860.
|
| 173 |
+
3. Allow the initial image build and model download. If build size is tight, preselect only the languages you need or host model files in a Dataset.
|
| 174 |
+
4. Add `HF_TOKEN`, `FALCONSCAN_AI_ENDPOINT`, or `FALCONSCAN_VLM_ENDPOINT` as secrets only if optional remote services are implemented.
|
| 175 |
+
5. Treat local JSON as demo persistence: free Spaces may restart. Use persistent storage, a Dataset, or an external database before operational use.
|
| 176 |
+
6. Test on HTTPS with a real phone and representative, anonymized documents.
|
| 177 |
+
|
| 178 |
+
## 18. Testing plan
|
| 179 |
+
|
| 180 |
+
Run `pip install -r requirements-dev.txt && pytest -q`. Automated tests cover health, exact/Arabic/fuzzy definitions, invalid images, VLM-disabled behavior, and SME priority. Manual acceptance cases:
|
| 181 |
+
|
| 182 |
+
1. B/L on a bill of lading produces a dot and definition.
|
| 183 |
+
2. MAWB on an airway bill is recognized.
|
| 184 |
+
3. `رمز النظام المنسق` can display Arabic or English.
|
| 185 |
+
4. Thumbs down saves a correction; the next lookup uses it.
|
| 186 |
+
5. SME approval overrides pending user text.
|
| 187 |
+
6. Poor capture suggests full-document analysis.
|
| 188 |
+
7. Unknown terms get an explicitly unverified verification message.
|
| 189 |
+
8. Admin can approve/reject with an audit timestamp.
|
| 190 |
+
9. The app stays useful when VLM is disabled.
|
| 191 |
+
10. Images never appear in `data/` after scans.
|
| 192 |
+
|
| 193 |
+
## 19. Performance, security, and governance
|
| 194 |
+
|
| 195 |
+
- Never OCR each frame; keep continuous work in the browser and send stable/manual frames only.
|
| 196 |
+
- Resize before upload later if real documents show 1280px is sufficient; current capture preserves camera resolution for OCR quality.
|
| 197 |
+
- Keep OCR lazy, cache by visual fingerprint, cap the cache, and avoid loading a VLM in-process.
|
| 198 |
+
- Do not store full images by default. Persist only terms, definitions, feedback, confidence, status, and timestamps.
|
| 199 |
+
- Put admin routes behind authentication before real deployment. Add rate limits, request-size limits, encryption, retention rules, and named SME identities.
|
| 200 |
+
- Treat all definitions as assistance, not a customs ruling. Only governed SME content should become official knowledge.
|
| 201 |
+
|
| 202 |
+
## 20. Progress checkpoint
|
| 203 |
+
|
| 204 |
+
PROGRESS CHECKPOINT:
|
| 205 |
+
- Completed: End-to-end camera UI, stability capture, bilingual OCR adapter, bounding boxes, glossary/fuzzy matching, dot overlays, definitions, feedback, SME workflow, guarded AI/VLM paths, data, tests, and Docker Space configuration.
|
| 206 |
+
- In progress: Real-device and production Space validation.
|
| 207 |
+
- Pending: Install/warm PaddleOCR models; connect optional remote AI/VLM provider; replace demo JSON for durable multi-user storage; add production authentication.
|
| 208 |
+
- Current file being worked on: `README.md` (handoff complete).
|
| 209 |
+
- Next exact step: Deploy to a free CPU Docker Space and test the ten manual scenarios with anonymized English/Arabic documents.
|
| 210 |
+
- Important decisions made: Browser stability creates real-time feel; glossary remains primary; images are never persisted; SME-approved text outranks pending user corrections; VLM runs only behind an explicit external gate.
|
| 211 |
+
- Known issues: Default Space disk is ephemeral; OCR cold start can be slow; PaddleOCR footprint is substantial; AI/VLM provider adapters are intentionally unimplemented until an endpoint contract is selected.
|
TASK_SHEET.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FalconScan task sheet
|
| 2 |
+
|
| 3 |
+
This sheet is the implementation baseline. A requirement is marked complete only when it exists in the code and has a verification path.
|
| 4 |
+
|
| 5 |
+
| # | Requirement | Implementation | Status | Verification |
|
| 6 |
+
|---:|---|---|---|---|
|
| 7 |
+
| 1 | Browser camera view | Live `getUserMedia` preview | Complete | Start camera on localhost or HTTPS |
|
| 8 |
+
| 2 | Browser-side frame stability | Motion, blur/focus, and lighting checks on a 96×72 canvas | Complete | Observe live status metrics |
|
| 9 |
+
| 3 | Stable/manual frame capture | 12 stable samples or Scan now | Complete | Hold document still or tap button |
|
| 10 |
+
| 4 | English and Arabic OCR | Lazy PaddleOCR service with boxes/confidence | Complete | Install full requirements and scan bilingual samples |
|
| 11 |
+
| 5 | Customs glossary matching | Exact, acronym, alias, phrase, Arabic, and fuzzy matching | Complete | Automated service tests |
|
| 12 |
+
| 6 | Clickable dot overlays | OCR coordinates mapped into contained camera video | Complete | Tap a detected dot |
|
| 13 |
+
| 7 | Bilingual definition sheet | English/Arabic, RTL, source, confidence, related terms | Complete | Switch header language and open a term |
|
| 14 |
+
| 8 | Feedback correction | Thumbs up/down, correction form, JSON persistence | Complete | Submit correction and repeat lookup |
|
| 15 |
+
| 9 | SME review workflow | Pending queue, accurate header count, approve/reject | Complete | Header badge equals `/admin/corrections` item count |
|
| 16 |
+
| 10 | Governed definition priority | SME → user correction → glossary → RAG → AI | Complete | Automated priority test |
|
| 17 |
+
| 11 | Optional AI/VLM | Explicitly gated and disabled safely when unconfigured | Complete boundary | Configure provider adapter for live inference |
|
| 18 |
+
| 12 | No image persistence | Only bounded OCR results and feedback are saved | Complete | Inspect `data/` after scan |
|
| 19 |
+
| 13 | Free CPU Space deployment | Docker Space metadata and Dockerfile | Complete | Deploy and run smoke tests |
|
| 20 |
+
| 14 | Architecture diagram + explanation | Mermaid diagram and textual explanation in project documentation; intentionally excluded from the operational scanning UI | Complete | View README architecture section |
|
| 21 |
+
| 15 | Mobile-first responsive design | Phone portrait baseline; touch targets, safe areas, bottom-sheet dialogs; tablet/desktop progressive layouts | Complete | Test 320, 390, 768, 900, and 1440px widths |
|
| 22 |
+
| 16 | Contextual Scan Status panel | Closed before scanning, manually toggleable, automatically opened after scan, responsive drawer behavior | Complete | Load app, scan, close and reopen details |
|
| 23 |
+
| 17 | Trust-building privacy note | Prominent note explains that frames are analyzed but captured images are not stored | Complete | View note above scanner and inspect storage behavior |
|
| 24 |
+
| 18 | Polished product UI | Restrained Apple-inspired hierarchy, translucent surfaces, system typography, purposeful motion, and reduced visual noise | Complete | Visual QA on phone and desktop |
|
| 25 |
+
| 19 | Document upload | Scan Details accepts JPG, PNG, WebP, PDF, and Word DOCX; renders a private preview and uses OCR or native text extraction | Complete | Upload each supported type under 15 MB |
|
| 26 |
+
| 20 | Readable detection callouts | Dots expand into one rectangular insight containing dot, divider, term, confidence, definition, and full-meaning action | Complete | Tap several dots and inspect inline insight readability |
|
| 27 |
+
| 21 | Adaptive detection dots | Page area and OCR complexity determine a readable marker budget; every matched term remains accessible in Scan Details | Complete | Upload simple and dense documents |
|
| 28 |
+
| 22 | Select/highlight insight | Select OCR text, a phrase, or paragraph to receive Summary and Business Meaning with governed source/confidence | Complete | Highlight text on an uploaded document preview |
|
| 29 |
+
|
| 30 |
+
## Newly fixed requirements
|
| 31 |
+
|
| 32 |
+
### Accurate header counting
|
| 33 |
+
|
| 34 |
+
- The SME review badge is sourced from `GET /admin/corrections`.
|
| 35 |
+
- Zero is not presented as a pending notification; the badge is hidden.
|
| 36 |
+
- Singular/plural accessible labels reflect the real count.
|
| 37 |
+
- A failed count request hides the badge instead of showing stale data.
|
| 38 |
+
|
| 39 |
+
### Mobile-first responsive behavior
|
| 40 |
+
|
| 41 |
+
- Base CSS targets 320px+ phone portrait screens.
|
| 42 |
+
- Camera content is ordered first and uses small-viewport height units.
|
| 43 |
+
- Controls meet touch-friendly sizing and use device safe-area insets.
|
| 44 |
+
- Definition/admin dialogs are mobile bottom sheets and centered dialogs on larger screens.
|
| 45 |
+
- Tablet enhances spacing and actions; desktop switches to a two-column scanner workspace.
|
| 46 |
+
- Technical architecture stays in the README so the scanning experience remains task-focused.
|
| 47 |
+
|
| 48 |
+
### Scan Status disclosure
|
| 49 |
+
|
| 50 |
+
- Scan Status starts closed so the camera remains the primary task surface.
|
| 51 |
+
- Users can open or close details at any time.
|
| 52 |
+
- A completed scan opens the details automatically and shows result count, quality, confidence, and actions.
|
| 53 |
+
- On phones the panel expands below the camera; on desktop it reveals as a right-side panel.
|
| 54 |
+
|
| 55 |
+
### Trust and visual polish
|
| 56 |
+
|
| 57 |
+
- Privacy is presented as a concise trust note beside the core workflow rather than as marketing decoration.
|
| 58 |
+
- Architecture remains in technical documentation and is not shown in the scanning UI.
|
| 59 |
+
- The visual system uses native system typography, calm neutral surfaces, selective translucency, large radii, and subtle motion.
|
| 60 |
+
|
| 61 |
+
### Upload and detection readability
|
| 62 |
+
|
| 63 |
+
- Scan Details includes private upload for JPG, PNG, WebP, PDF, and Word DOCX files up to 15 MB. Legacy binary `.doc` is rejected with a clear message; save it as DOCX first.
|
| 64 |
+
- PDFs use positioned page text when available and OCR for scanned first pages. DOCX paragraphs are rendered into a positioned preview. Images use PaddleOCR or the installed portable CPU fallback.
|
| 65 |
+
- Uploaded documents are previewed in the scanner and analyzed without being persisted.
|
| 66 |
+
- Expanded detection callouts contain the status dot, divider line, term, confidence, definition, and full-meaning action.
|
| 67 |
+
- Markers are collision-adjusted. Dense documents show the highest-confidence dots on the image while keeping every matched term in the details list.
|
| 68 |
+
- Dots now appear by default. Tapping one expands an inline term, confidence, and definition card; the full meaning remains one tap away.
|
| 69 |
+
- OCR/PDF/DOCX text regions form a selectable layer. Highlighting a word or paragraph opens a live Summary and Business Meaning insight.
|
| 70 |
+
|
| 71 |
+
## Current checkpoint
|
| 72 |
+
|
| 73 |
+
- Completed: Header count fix, consecutively numbered walkthrough, architecture documentation, mobile-first layout, contextual Scan Status drawer, trust note, and polished product UI.
|
| 74 |
+
- In progress: Representative-device validation with live camera permissions.
|
| 75 |
+
- Next: Enable full PaddleOCR and run English/Arabic document acceptance tests on a phone over HTTPS.
|
| 76 |
+
- Known external issue: The supplied screenshot is a proxy authentication prompt at `91.207.173.102:4433`; it is outside FalconScan. Use the localhost URL for local testing and do not enter credentials into an unknown proxy prompt.
|
| 77 |
+
|
| 78 |
+
## Mandatory release-gate checklist
|
| 79 |
+
|
| 80 |
+
Every item below must pass before FalconScan is marked ready for GitHub or Hugging Face deployment.
|
| 81 |
+
|
| 82 |
+
### Detection and overlay
|
| 83 |
+
|
| 84 |
+
- [x] A simple document with a known customs term shows at least one dot automatically.
|
| 85 |
+
- [x] A dense document uses an adaptive marker budget based on page area and OCR complexity.
|
| 86 |
+
- [x] Markers do not overlap; displaced or hidden terms remain available in Scan Details.
|
| 87 |
+
- [x] Multiple known terms on the same OCR line create separate detections.
|
| 88 |
+
- [x] Tapping a dot expands an inline card containing term, confidence, definition, dot, and divider.
|
| 89 |
+
- [x] Only one expanded inline card is shown at a time to preserve readability.
|
| 90 |
+
- [x] “Open full meaning” opens the complete bilingual definition and source information.
|
| 91 |
+
- [x] Resizing the viewport clears stale overlay coordinates before the next render.
|
| 92 |
+
- [x] When OCR succeeds but no governed glossary term matches, contextual dots appear with an explicit unverified label instead of a misleading empty result.
|
| 93 |
+
- [x] No more than three highest-confidence dots are shown on the page at once.
|
| 94 |
+
- [x] Dots use the reduced 18px control size and can be dragged to uncover document text.
|
| 95 |
+
- [x] A readable single-word OCR region can receive a contextual dot when it is not already represented by a verified term.
|
| 96 |
+
|
| 97 |
+
### Selection, highlight, and business insight
|
| 98 |
+
|
| 99 |
+
- [x] OCR/PDF/DOCX text regions are selectable without blocking detection dots.
|
| 100 |
+
- [x] Selecting a known word, phrase, or paragraph opens the Live Document Insight panel.
|
| 101 |
+
- [x] The panel returns both a Summary and Business Meaning.
|
| 102 |
+
- [x] Recognized concepts are shown as clickable term chips.
|
| 103 |
+
- [x] Known selections display verified glossary provenance and confidence.
|
| 104 |
+
- [x] Unknown selections are clearly labeled as needing expert verification.
|
| 105 |
+
- [x] Empty or collapsed selections do not trigger requests.
|
| 106 |
+
- [x] Selection supports multi-word phrases and paragraphs without maintaining a selection history in Scan Details.
|
| 107 |
+
- [x] A post-scan Info control summarizes the full recognized page using the same sourced Summary and Business Meaning panel.
|
| 108 |
+
|
| 109 |
+
### Supported documents
|
| 110 |
+
|
| 111 |
+
- [x] JPG upload is accepted and analyzed with positioned OCR results.
|
| 112 |
+
- [x] PNG upload is accepted and analyzed with positioned OCR results.
|
| 113 |
+
- [x] WebP upload is accepted and analyzed with positioned OCR results.
|
| 114 |
+
- [x] Text PDF first pages are rendered and analyzed using positioned PDF text.
|
| 115 |
+
- [x] Scanned PDF first pages fall back to OCR.
|
| 116 |
+
- [x] Word DOCX paragraphs are extracted, rendered, and mapped to positioned regions.
|
| 117 |
+
- [x] Unsupported legacy `.doc` files return a clear instruction to save as DOCX.
|
| 118 |
+
- [x] Files over 15 MB are rejected before analysis.
|
| 119 |
+
- [x] Uploaded document images and files are not persisted by default.
|
| 120 |
+
- [x] Uploaded document previews use a dedicated scroll container with visible native scrollbars.
|
| 121 |
+
|
| 122 |
+
### Camera and performance
|
| 123 |
+
|
| 124 |
+
- [x] Camera OCR runs only after a stable frame or explicit Scan now action.
|
| 125 |
+
- [x] Blur, motion, and lighting checks run locally in the browser.
|
| 126 |
+
- [x] Similar frames use the bounded OCR cache.
|
| 127 |
+
- [x] CPU OCR fallback works when PaddleOCR is unavailable.
|
| 128 |
+
- [x] VLM remains optional and does not block the glossary-first workflow.
|
| 129 |
+
- [x] Large uploaded images are resized to a maximum 1600px edge in the browser before transfer.
|
| 130 |
+
- [x] Small images avoid unnecessary recompression.
|
| 131 |
+
- [x] Portable CPU OCR is warmed in the background to reduce first-scan latency.
|
| 132 |
+
|
| 133 |
+
### Responsive UI and accessibility
|
| 134 |
+
|
| 135 |
+
- [x] Scan Status starts closed and opens automatically after analysis.
|
| 136 |
+
- [x] Scan Status can be opened and closed manually.
|
| 137 |
+
- [x] Phone portrait is the primary layout at 320px and 390px widths.
|
| 138 |
+
- [x] Tablet and desktop layouts adapt without removing functionality.
|
| 139 |
+
- [x] Touch targets remain usable and safe-area insets are respected.
|
| 140 |
+
- [x] Arabic definitions render RTL.
|
| 141 |
+
- [x] Reduced-motion preferences are respected.
|
| 142 |
+
- [x] Pending SME count is accurate and hidden when zero or unavailable.
|
| 143 |
+
- [x] Scan Status uses concise state-specific instructions rather than exposing raw stability, lighting, OCR, or zero-count diagnostics.
|
| 144 |
+
- [x] Term-count badges are omitted from Scan Status; the result list itself communicates available concepts.
|
| 145 |
+
- [x] Scan Details does not retain or display a history of selected terms.
|
| 146 |
+
|
| 147 |
+
### Governance and trust
|
| 148 |
+
|
| 149 |
+
- [x] The privacy note states that frames are analyzed but captured images are not stored.
|
| 150 |
+
- [x] Every definition includes source type and confidence.
|
| 151 |
+
- [x] SME-approved definitions override pending user corrections.
|
| 152 |
+
- [x] Feedback and review actions preserve an audit history.
|
| 153 |
+
- [x] AI-generated or unmatched explanations are marked unverified.
|
| 154 |
+
|
| 155 |
+
### Automated verification recorded
|
| 156 |
+
|
| 157 |
+
- [x] Python/API suite: 12 tests passing.
|
| 158 |
+
- [x] JavaScript syntax checks: camera, overlay, feedback, and insights scripts passing.
|
| 159 |
+
- [x] DOM integration: two terms produced two dots.
|
| 160 |
+
- [x] DOM integration: dot click expanded the inline definition.
|
| 161 |
+
- [x] DOM integration: full-meaning action opened the governed definition.
|
| 162 |
+
- [x] DOM integration: text selection triggered the insight flow.
|
| 163 |
+
- [x] Live API: selection returned recognized terms, summary, business meaning, source, and confidence.
|
| 164 |
+
- [ ] Physical iPhone Safari camera and selection acceptance test.
|
| 165 |
+
- [ ] Physical Android Chrome camera and selection acceptance test.
|
| 166 |
+
- [ ] Deployed Hugging Face Space smoke test after remote synchronization.
|
| 167 |
+
- [ ] GitHub CI test run after repository push.
|
app.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from threading import Thread
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, HTTPException
|
| 10 |
+
from fastapi.responses import FileResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
from backend.ai_definition_service import AIDefinitionService
|
| 15 |
+
from backend.document_service import DocumentService
|
| 16 |
+
from backend.feedback_service import FeedbackService
|
| 17 |
+
from backend.glossary_service import GlossaryService
|
| 18 |
+
from backend.image_utils import decode_data_url, image_fingerprint, image_quality
|
| 19 |
+
from backend.insight_service import InsightService
|
| 20 |
+
from backend.ocr_service import OCRService
|
| 21 |
+
from backend.progress_service import ProgressService
|
| 22 |
+
from backend.vlm_service import VLMService
|
| 23 |
+
|
| 24 |
+
ROOT = Path(__file__).parent
|
| 25 |
+
DATA = ROOT / "data"
|
| 26 |
+
logging.basicConfig(level=logging.INFO)
|
| 27 |
+
|
| 28 |
+
app = FastAPI(title="FalconScan", version="1.0.0", description="CPU-first customs terminology camera assistant")
|
| 29 |
+
app.mount("/static", StaticFiles(directory=ROOT / "frontend"), name="static")
|
| 30 |
+
|
| 31 |
+
ocr = OCRService()
|
| 32 |
+
Thread(target=ocr.warmup, daemon=True, name="falconscan-ocr-warmup").start()
|
| 33 |
+
glossary = GlossaryService(DATA / "glossary.json", DATA / "user_corrections.json", DATA / "sme_approved_definitions.json")
|
| 34 |
+
feedback = FeedbackService(DATA / "user_corrections.json", DATA / "feedback_history.json", DATA / "sme_approved_definitions.json")
|
| 35 |
+
ai = AIDefinitionService()
|
| 36 |
+
vlm = VLMService()
|
| 37 |
+
progress = ProgressService(DATA / "project_progress.json")
|
| 38 |
+
documents = DocumentService(ocr, glossary)
|
| 39 |
+
insights = InsightService(glossary)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class FrameRequest(BaseModel):
|
| 43 |
+
image_base64: str
|
| 44 |
+
frame_width: int = Field(gt=0, le=10000)
|
| 45 |
+
frame_height: int = Field(gt=0, le=10000)
|
| 46 |
+
language_preference: str = Field(default="en", pattern="^(en|ar)$")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class FeedbackRequest(BaseModel):
|
| 50 |
+
term: str = Field(min_length=1, max_length=200)
|
| 51 |
+
old_definition: str = Field(default="", max_length=2000)
|
| 52 |
+
corrected_definition: Optional[str] = Field(default=None, max_length=2000)
|
| 53 |
+
feedback_type: str = Field(pattern="^(thumbs_up|thumbs_down)$")
|
| 54 |
+
suggested_by: str = Field(default="anonymous", max_length=100)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ReviewRequest(BaseModel):
|
| 58 |
+
term: str
|
| 59 |
+
action: str = Field(pattern="^(approve|reject)$")
|
| 60 |
+
reviewer: str = "SME"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class VLMRequest(BaseModel):
|
| 64 |
+
image_base64: Optional[str] = None
|
| 65 |
+
user_requested: bool = True
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class DocumentRequest(BaseModel):
|
| 69 |
+
file_base64: str
|
| 70 |
+
filename: str = Field(min_length=1, max_length=255)
|
| 71 |
+
language_preference: str = Field(default="en", pattern="^(en|ar)$")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SelectionRequest(BaseModel):
|
| 75 |
+
text: str = Field(min_length=1, max_length=5000)
|
| 76 |
+
language_preference: str = Field(default="en", pattern="^(en|ar)$")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@app.get("/", include_in_schema=False)
|
| 80 |
+
def index():
|
| 81 |
+
return FileResponse(ROOT / "frontend" / "index.html")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@app.get("/health")
|
| 85 |
+
def health():
|
| 86 |
+
return {"status": "ok", "ocr": ocr.status(), "ai_enabled": ai.enabled,
|
| 87 |
+
"vlm_enabled": vlm.enabled, "storage": "local_json"}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@app.post("/analyze-frame")
|
| 91 |
+
def analyze_frame(request: FrameRequest):
|
| 92 |
+
try:
|
| 93 |
+
image = decode_data_url(request.image_base64)
|
| 94 |
+
except ValueError as exc:
|
| 95 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 96 |
+
fingerprint = image_fingerprint(image)
|
| 97 |
+
cache_path = DATA / "ocr_cache.json"
|
| 98 |
+
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
| 99 |
+
cached = cache.get(fingerprint)
|
| 100 |
+
if cached:
|
| 101 |
+
return {**cached, "cached": True}
|
| 102 |
+
quality = image_quality(image)
|
| 103 |
+
try:
|
| 104 |
+
detections = ocr.extract(image, request.language_preference)
|
| 105 |
+
except RuntimeError as exc:
|
| 106 |
+
# Honest degraded mode: camera works and reports setup rather than fabricating OCR.
|
| 107 |
+
return {"detected_terms": [], "unknown_terms": [], "ocr_items": [], "cached": False,
|
| 108 |
+
"quality": quality, "ocr_available": False, "message": str(exc),
|
| 109 |
+
"suggest_vlm": False, "vlm_reasons": []}
|
| 110 |
+
terms = glossary.match_regions(detections)
|
| 111 |
+
if len(terms) < 3:
|
| 112 |
+
terms.extend(glossary.contextual_fallbacks(detections, 3 - len(terms), terms))
|
| 113 |
+
_, unknown = glossary.match_ocr(detections)
|
| 114 |
+
mean_confidence = sum(x["confidence"] for x in detections) / len(detections) if detections else 0.0
|
| 115 |
+
suggest_vlm, reasons = vlm.should_suggest(mean_confidence, len(unknown))
|
| 116 |
+
result = {"detected_terms": terms, "unknown_terms": unknown[:10], "ocr_items": detections,
|
| 117 |
+
"cached": False, "ocr_available": True, "quality": quality,
|
| 118 |
+
"mean_ocr_confidence": round(mean_confidence, 3), "suggest_vlm": suggest_vlm,
|
| 119 |
+
"vlm_reasons": reasons}
|
| 120 |
+
cache[fingerprint] = result
|
| 121 |
+
# Bounded local cache: images are never persisted.
|
| 122 |
+
if len(cache) > 100:
|
| 123 |
+
cache.pop(next(iter(cache)))
|
| 124 |
+
cache_path.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 125 |
+
return result
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.post("/analyze-document")
|
| 129 |
+
def analyze_document(request: DocumentRequest):
|
| 130 |
+
try:
|
| 131 |
+
return documents.analyze(request.file_base64, request.filename, request.language_preference)
|
| 132 |
+
except ValueError as exc:
|
| 133 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 134 |
+
except RuntimeError as exc:
|
| 135 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.post("/explain-selection")
|
| 139 |
+
def explain_selection(request: SelectionRequest):
|
| 140 |
+
try:
|
| 141 |
+
return insights.explain(request.text, request.language_preference)
|
| 142 |
+
except ValueError as exc:
|
| 143 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@app.get("/definition/{term:path}")
|
| 147 |
+
def get_definition(term: str, language: str = "en"):
|
| 148 |
+
result = glossary.definition(term)
|
| 149 |
+
return result or ai.define(term, language=language)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@app.post("/submit-feedback")
|
| 153 |
+
def submit_feedback(request: FeedbackRequest):
|
| 154 |
+
if request.feedback_type == "thumbs_down" and not request.corrected_definition:
|
| 155 |
+
raise HTTPException(status_code=400, detail="A corrected definition is required for thumbs down")
|
| 156 |
+
return feedback.submit(**request.model_dump())
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@app.get("/admin/corrections")
|
| 160 |
+
def pending_corrections():
|
| 161 |
+
return {"items": feedback.pending()}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@app.post("/admin/review")
|
| 165 |
+
def review_correction(request: ReviewRequest):
|
| 166 |
+
try:
|
| 167 |
+
return feedback.review(request.term, request.action, request.reviewer)
|
| 168 |
+
except KeyError as exc:
|
| 169 |
+
raise HTTPException(status_code=404, detail="Correction not found") from exc
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@app.post("/analyze-document-vlm")
|
| 173 |
+
def analyze_document_vlm(request: VLMRequest):
|
| 174 |
+
return vlm.analyze(request.user_requested)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@app.get("/progress")
|
| 178 |
+
def project_progress():
|
| 179 |
+
return progress.get()
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""FalconScan backend services."""
|
backend/ai_definition_service.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class AIDefinitionService:
|
| 5 |
+
"""Conservative optional text-model fallback; disabled unless explicitly configured."""
|
| 6 |
+
|
| 7 |
+
def __init__(self):
|
| 8 |
+
self.endpoint = os.getenv("FALCONSCAN_AI_ENDPOINT", "")
|
| 9 |
+
self.token = os.getenv("HF_TOKEN", "")
|
| 10 |
+
|
| 11 |
+
@property
|
| 12 |
+
def enabled(self) -> bool:
|
| 13 |
+
return bool(self.endpoint and self.token)
|
| 14 |
+
|
| 15 |
+
def define(self, term: str, nearby_text: str = "", language: str = "en") -> dict:
|
| 16 |
+
# A remote call is deliberately not implicit on free Spaces. Integrators may place a
|
| 17 |
+
# compatible inference endpoint behind this interface without changing API behavior.
|
| 18 |
+
if not self.enabled:
|
| 19 |
+
message = ("هذا المصطلح يحتاج إلى تحقق من خبير جمركي."
|
| 20 |
+
if language == "ar" else "This term needs customs expert verification.")
|
| 21 |
+
return {"term": term, "definition": message, "source": "ai_generated_unverified",
|
| 22 |
+
"source_label": "AI-generated · unverified", "confidence": 0.2,
|
| 23 |
+
"ai_available": False}
|
| 24 |
+
# Keep a safe placeholder rather than guessing if an endpoint contract is unknown.
|
| 25 |
+
return {"term": term, "definition": "This term needs customs expert verification.",
|
| 26 |
+
"source": "ai_generated_unverified", "source_label": "AI-generated · unverified",
|
| 27 |
+
"confidence": 0.2, "ai_available": True}
|
backend/document_service.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import io
|
| 3 |
+
import textwrap
|
| 4 |
+
import zipfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from xml.etree import ElementTree
|
| 7 |
+
|
| 8 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 9 |
+
|
| 10 |
+
from backend.glossary_service import GlossaryService
|
| 11 |
+
from backend.ocr_service import OCRService
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class DocumentService:
|
| 15 |
+
MAX_BYTES = 15 * 1024 * 1024
|
| 16 |
+
|
| 17 |
+
def __init__(self, ocr: OCRService, glossary: GlossaryService):
|
| 18 |
+
self.ocr = ocr
|
| 19 |
+
self.glossary = glossary
|
| 20 |
+
|
| 21 |
+
def analyze(self, encoded: str, filename: str, language: str) -> dict:
|
| 22 |
+
try:
|
| 23 |
+
raw = base64.b64decode(encoded, validate=True)
|
| 24 |
+
except Exception as exc:
|
| 25 |
+
raise ValueError("Invalid uploaded document") from exc
|
| 26 |
+
if not raw or len(raw) > self.MAX_BYTES:
|
| 27 |
+
raise ValueError("Document must be between 1 byte and 15 MB")
|
| 28 |
+
extension = Path(filename).suffix.lower()
|
| 29 |
+
if extension in {".jpg", ".jpeg", ".png", ".webp"}:
|
| 30 |
+
return self._image(raw, language)
|
| 31 |
+
if extension == ".pdf":
|
| 32 |
+
return self._pdf(raw)
|
| 33 |
+
if extension == ".docx":
|
| 34 |
+
return self._docx(raw)
|
| 35 |
+
raise ValueError("Supported formats are JPG, PNG, WebP, PDF, and DOCX")
|
| 36 |
+
|
| 37 |
+
def _image(self, raw: bytes, language: str) -> dict:
|
| 38 |
+
try:
|
| 39 |
+
image = Image.open(io.BytesIO(raw)).convert("RGB")
|
| 40 |
+
image.load()
|
| 41 |
+
except Exception as exc:
|
| 42 |
+
raise ValueError("The uploaded image could not be opened") from exc
|
| 43 |
+
detections = self.ocr.extract(image, language)
|
| 44 |
+
terms = self.glossary.match_regions(detections)
|
| 45 |
+
if len(terms) < 3:
|
| 46 |
+
terms.extend(self.glossary.contextual_fallbacks(detections, 3 - len(terms), terms))
|
| 47 |
+
_, unknown = self.glossary.match_ocr(detections)
|
| 48 |
+
return self._result(image, terms, detections, unknown, "image_ocr")
|
| 49 |
+
|
| 50 |
+
def _pdf(self, raw: bytes) -> dict:
|
| 51 |
+
try:
|
| 52 |
+
import fitz
|
| 53 |
+
except ImportError as exc:
|
| 54 |
+
raise RuntimeError("PDF support is not installed") from exc
|
| 55 |
+
try:
|
| 56 |
+
document = fitz.open(stream=raw, filetype="pdf")
|
| 57 |
+
if document.page_count < 1:
|
| 58 |
+
raise ValueError("The PDF has no pages")
|
| 59 |
+
page = document[0]
|
| 60 |
+
zoom = min(2.0, 1800 / max(page.rect.width, page.rect.height))
|
| 61 |
+
matrix = fitz.Matrix(zoom, zoom)
|
| 62 |
+
pixmap = page.get_pixmap(matrix=matrix, alpha=False)
|
| 63 |
+
image = Image.frombytes("RGB", [pixmap.width, pixmap.height], pixmap.samples)
|
| 64 |
+
regions = []
|
| 65 |
+
for block in page.get_text("blocks"):
|
| 66 |
+
x1, y1, x2, y2, text = block[:5]
|
| 67 |
+
if text.strip():
|
| 68 |
+
regions.append({"text": text, "bbox": [x1 * zoom, y1 * zoom, x2 * zoom, y2 * zoom],
|
| 69 |
+
"confidence": 1.0, "language": self._language(text)})
|
| 70 |
+
terms = self.glossary.match_regions(regions)
|
| 71 |
+
if len(terms) < 3:
|
| 72 |
+
terms.extend(self.glossary.contextual_fallbacks(regions, 3 - len(terms), terms))
|
| 73 |
+
if not regions:
|
| 74 |
+
detections = self.ocr.extract(image, "en")
|
| 75 |
+
terms = self.glossary.match_regions(detections)
|
| 76 |
+
if len(terms) < 3:
|
| 77 |
+
terms.extend(self.glossary.contextual_fallbacks(detections, 3 - len(terms), terms))
|
| 78 |
+
_, unknown = self.glossary.match_ocr(detections)
|
| 79 |
+
return self._result(image, terms, detections, unknown, "scanned_pdf_ocr")
|
| 80 |
+
return self._result(image, terms, regions, [], "pdf_text")
|
| 81 |
+
except (ValueError, RuntimeError):
|
| 82 |
+
raise
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
raise ValueError("The PDF could not be processed") from exc
|
| 85 |
+
|
| 86 |
+
def _docx(self, raw: bytes) -> dict:
|
| 87 |
+
try:
|
| 88 |
+
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
| 89 |
+
xml = archive.read("word/document.xml")
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
raise ValueError("The Word document could not be opened") from exc
|
| 92 |
+
root = ElementTree.fromstring(xml)
|
| 93 |
+
paragraphs = []
|
| 94 |
+
for paragraph in root.iter("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p"):
|
| 95 |
+
text = "".join(node.text or "" for node in paragraph.iter("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t"))
|
| 96 |
+
if text.strip():
|
| 97 |
+
paragraphs.append(text.strip())
|
| 98 |
+
if not paragraphs:
|
| 99 |
+
raise ValueError("The Word document contains no readable text")
|
| 100 |
+
return self._render_text(paragraphs)
|
| 101 |
+
|
| 102 |
+
def _render_text(self, paragraphs: list[str]) -> dict:
|
| 103 |
+
width, margin, line_height = 1400, 100, 42
|
| 104 |
+
wrapped = []
|
| 105 |
+
for paragraph in paragraphs:
|
| 106 |
+
wrapped.extend(textwrap.wrap(paragraph, width=85, break_long_words=False) or [""])
|
| 107 |
+
wrapped.append("")
|
| 108 |
+
height = min(2200, max(900, margin * 2 + len(wrapped) * line_height))
|
| 109 |
+
image = Image.new("RGB", (width, height), "#ffffff")
|
| 110 |
+
draw = ImageDraw.Draw(image)
|
| 111 |
+
try:
|
| 112 |
+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
|
| 113 |
+
except OSError:
|
| 114 |
+
font = ImageFont.load_default()
|
| 115 |
+
regions = []
|
| 116 |
+
y = margin
|
| 117 |
+
for line in wrapped:
|
| 118 |
+
if y + line_height > height - margin:
|
| 119 |
+
break
|
| 120 |
+
if line:
|
| 121 |
+
draw.text((margin, y), line, fill="#17211e", font=font)
|
| 122 |
+
regions.append({"text": line, "bbox": [margin, y, width - margin, y + line_height],
|
| 123 |
+
"confidence": 1.0, "language": self._language(line)})
|
| 124 |
+
y += line_height
|
| 125 |
+
terms = self.glossary.match_regions(regions)
|
| 126 |
+
if len(terms) < 3:
|
| 127 |
+
terms.extend(self.glossary.contextual_fallbacks(regions, 3 - len(terms), terms))
|
| 128 |
+
return self._result(image, terms, regions, [], "docx_text")
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def _language(text: str) -> str:
|
| 132 |
+
return "ar" if any("\u0600" <= character <= "\u06ff" for character in text) else "en"
|
| 133 |
+
|
| 134 |
+
@staticmethod
|
| 135 |
+
def _result(image: Image.Image, terms: list, detections: list, unknown: list, method: str) -> dict:
|
| 136 |
+
output = io.BytesIO()
|
| 137 |
+
image.save(output, format="JPEG", quality=88, optimize=True)
|
| 138 |
+
return {
|
| 139 |
+
"detected_terms": terms,
|
| 140 |
+
"ocr_items": detections,
|
| 141 |
+
"unknown_terms": unknown[:10],
|
| 142 |
+
"frame_width": image.width,
|
| 143 |
+
"frame_height": image.height,
|
| 144 |
+
"preview_base64": "data:image/jpeg;base64," + base64.b64encode(output.getvalue()).decode(),
|
| 145 |
+
"ocr_available": True,
|
| 146 |
+
"analysis_method": method,
|
| 147 |
+
"mean_ocr_confidence": round(sum(float(item.get("confidence", 1)) for item in detections) / len(detections), 3) if detections else 0,
|
| 148 |
+
}
|
backend/feedback_service.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from threading import RLock
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def now() -> str:
|
| 11 |
+
return datetime.now(timezone.utc).isoformat()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class FeedbackService:
|
| 15 |
+
def __init__(self, corrections_path: Path, history_path: Path, approved_path: Path):
|
| 16 |
+
self.corrections_path = corrections_path
|
| 17 |
+
self.history_path = history_path
|
| 18 |
+
self.approved_path = approved_path
|
| 19 |
+
self.lock = RLock()
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def _read(path: Path):
|
| 23 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 24 |
+
|
| 25 |
+
@staticmethod
|
| 26 |
+
def _write(path: Path, value):
|
| 27 |
+
path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 28 |
+
|
| 29 |
+
def submit(self, term: str, old_definition: str, corrected_definition: str | None,
|
| 30 |
+
feedback_type: str, suggested_by: str = "anonymous") -> dict:
|
| 31 |
+
with self.lock:
|
| 32 |
+
event_id = str(uuid4())
|
| 33 |
+
event = {"id": event_id, "term": term, "old_definition": old_definition,
|
| 34 |
+
"corrected_definition": corrected_definition, "feedback_type": feedback_type,
|
| 35 |
+
"suggested_by": suggested_by, "status": "pending_sme_review" if corrected_definition else "recorded",
|
| 36 |
+
"created_at": now()}
|
| 37 |
+
history = self._read(self.history_path)
|
| 38 |
+
history.append(event)
|
| 39 |
+
self._write(self.history_path, history)
|
| 40 |
+
if corrected_definition:
|
| 41 |
+
corrections = self._read(self.corrections_path)
|
| 42 |
+
corrections[term] = {**event, "source": "user_corrected",
|
| 43 |
+
"previous_definition": old_definition}
|
| 44 |
+
self._write(self.corrections_path, corrections)
|
| 45 |
+
return {"status": "saved", "message": "Correction saved and will be used next time." if corrected_definition else "Feedback recorded.",
|
| 46 |
+
"source": "user_corrected" if corrected_definition else "feedback", "id": event_id}
|
| 47 |
+
|
| 48 |
+
def pending(self) -> list[dict]:
|
| 49 |
+
corrections = self._read(self.corrections_path)
|
| 50 |
+
return [value for value in corrections.values() if value.get("status") == "pending_sme_review"]
|
| 51 |
+
|
| 52 |
+
def review(self, term: str, action: str, reviewer: str = "SME") -> dict:
|
| 53 |
+
with self.lock:
|
| 54 |
+
corrections = self._read(self.corrections_path)
|
| 55 |
+
if term not in corrections:
|
| 56 |
+
raise KeyError(term)
|
| 57 |
+
correction = corrections[term]
|
| 58 |
+
correction.update({"status": "approved" if action == "approve" else "rejected",
|
| 59 |
+
"reviewed_by": reviewer, "reviewed_at": now()})
|
| 60 |
+
corrections[term] = correction
|
| 61 |
+
self._write(self.corrections_path, corrections)
|
| 62 |
+
if action == "approve":
|
| 63 |
+
approved = self._read(self.approved_path)
|
| 64 |
+
approved[term] = {
|
| 65 |
+
"term": term, "definition": correction["corrected_definition"],
|
| 66 |
+
"previous_definition": correction.get("previous_definition"),
|
| 67 |
+
"source": "sme_approved", "approved_by": reviewer,
|
| 68 |
+
"approved_at": correction["reviewed_at"],
|
| 69 |
+
}
|
| 70 |
+
self._write(self.approved_path, approved)
|
| 71 |
+
return {"status": correction["status"], "term": term}
|
backend/glossary_service.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from difflib import SequenceMatcher
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from threading import RLock
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
SOURCE_LABELS = {
|
| 11 |
+
"user_corrected": "User correction (pending SME review)",
|
| 12 |
+
"sme_approved": "SME-approved definition",
|
| 13 |
+
"verified_glossary": "Verified Customs Glossary",
|
| 14 |
+
"rag_retrieved": "Knowledge Base",
|
| 15 |
+
"ai_generated_unverified": "AI-generated · unverified",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def normalize(value: str) -> str:
|
| 20 |
+
return re.sub(r"[^\w\u0600-\u06ff]+", " ", value.casefold()).strip()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class GlossaryService:
|
| 24 |
+
def __init__(self, glossary_path: Path, corrections_path: Path, approved_path: Path):
|
| 25 |
+
self.glossary_path = glossary_path
|
| 26 |
+
self.corrections_path = corrections_path
|
| 27 |
+
self.approved_path = approved_path
|
| 28 |
+
self.lock = RLock()
|
| 29 |
+
|
| 30 |
+
@staticmethod
|
| 31 |
+
def _read(path: Path) -> dict:
|
| 32 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 33 |
+
|
| 34 |
+
def _index(self) -> tuple[dict, dict]:
|
| 35 |
+
glossary = self._read(self.glossary_path)
|
| 36 |
+
aliases: dict[str, str] = {}
|
| 37 |
+
for canonical, entry in glossary.items():
|
| 38 |
+
for alias in [canonical, *entry.get("aliases", [])]:
|
| 39 |
+
aliases[normalize(alias)] = canonical
|
| 40 |
+
return glossary, aliases
|
| 41 |
+
|
| 42 |
+
def find_match(self, text: str, threshold: float = 0.78) -> dict | None:
|
| 43 |
+
glossary, aliases = self._index()
|
| 44 |
+
target = normalize(text)
|
| 45 |
+
if not target:
|
| 46 |
+
return None
|
| 47 |
+
# Prefer longest contained phrases, then fuzzy matching for OCR errors.
|
| 48 |
+
contained = [(alias, canonical) for alias, canonical in aliases.items()
|
| 49 |
+
if alias and re.search(rf"(?<!\w){re.escape(alias)}(?!\w)", target)]
|
| 50 |
+
match_type, score = "exact", 1.0
|
| 51 |
+
if contained:
|
| 52 |
+
alias, canonical = max(contained, key=lambda item: len(item[0]))
|
| 53 |
+
match_type = "exact" if alias == target else "phrase"
|
| 54 |
+
else:
|
| 55 |
+
alias, canonical, score = "", "", 0.0
|
| 56 |
+
for candidate, name in aliases.items():
|
| 57 |
+
candidate_score = SequenceMatcher(None, target, candidate).ratio()
|
| 58 |
+
if candidate_score > score:
|
| 59 |
+
alias, canonical, score = candidate, name, candidate_score
|
| 60 |
+
if score < threshold:
|
| 61 |
+
return None
|
| 62 |
+
match_type = "fuzzy"
|
| 63 |
+
return {"canonical": canonical, "entry": glossary[canonical],
|
| 64 |
+
"match_type": match_type, "match_confidence": round(score, 3)}
|
| 65 |
+
|
| 66 |
+
def definition(self, term: str) -> dict | None:
|
| 67 |
+
with self.lock:
|
| 68 |
+
matched = self.find_match(term)
|
| 69 |
+
canonical = matched["canonical"] if matched else term
|
| 70 |
+
norm = normalize(canonical)
|
| 71 |
+
approved = self._read(self.approved_path)
|
| 72 |
+
corrections = self._read(self.corrections_path)
|
| 73 |
+
# SME approval is official and intentionally overrides pending user correction.
|
| 74 |
+
selected = next((v for k, v in approved.items() if normalize(k) == norm), None)
|
| 75 |
+
if selected:
|
| 76 |
+
return self._response(canonical, selected, "sme_approved", 1.0, matched)
|
| 77 |
+
selected = next((v for k, v in corrections.items()
|
| 78 |
+
if normalize(k) == norm and v.get("status") == "pending_sme_review"), None)
|
| 79 |
+
if selected:
|
| 80 |
+
item = dict(selected)
|
| 81 |
+
item["definition"] = item.get("corrected_definition", item.get("definition", ""))
|
| 82 |
+
return self._response(canonical, item, "user_corrected", 0.9, matched)
|
| 83 |
+
if matched:
|
| 84 |
+
return self._response(canonical, matched["entry"],
|
| 85 |
+
matched["entry"].get("source", "verified_glossary"),
|
| 86 |
+
matched["match_confidence"], matched)
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
@staticmethod
|
| 90 |
+
def _response(term: str, entry: dict, source: str, confidence: float, matched: dict | None) -> dict:
|
| 91 |
+
return {
|
| 92 |
+
"term": term,
|
| 93 |
+
"full_form": entry.get("full_form"),
|
| 94 |
+
"definition": entry.get("definition", ""),
|
| 95 |
+
"definition_ar": entry.get("definition_ar"),
|
| 96 |
+
"source": source,
|
| 97 |
+
"source_label": SOURCE_LABELS.get(source, source),
|
| 98 |
+
"confidence": round(float(confidence), 3),
|
| 99 |
+
"category": entry.get("category"),
|
| 100 |
+
"related_terms": entry.get("related_terms", []),
|
| 101 |
+
"document_types": entry.get("document_types", []),
|
| 102 |
+
"match_type": matched.get("match_type") if matched else "direct",
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
def match_ocr(self, detections: list[dict]) -> tuple[list[dict], list[dict]]:
|
| 106 |
+
found, unknown = [], []
|
| 107 |
+
for item in detections:
|
| 108 |
+
definition = self.definition(item["text"])
|
| 109 |
+
if definition:
|
| 110 |
+
definition.update({"bbox": item["bbox"], "ocr_text": item["text"],
|
| 111 |
+
"ocr_confidence": item["confidence"], "language": item["language"]})
|
| 112 |
+
definition["confidence"] = round(definition["confidence"] * item["confidence"], 3)
|
| 113 |
+
found.append(definition)
|
| 114 |
+
elif len(item["text"]) >= 3:
|
| 115 |
+
unknown.append(item)
|
| 116 |
+
return found, unknown
|
| 117 |
+
|
| 118 |
+
def match_regions(self, regions: list[dict]) -> list[dict]:
|
| 119 |
+
"""Find every known canonical term inside positioned text regions."""
|
| 120 |
+
_, aliases = self._index()
|
| 121 |
+
found: list[dict] = []
|
| 122 |
+
seen: set[tuple[str, tuple[int, ...]]] = set()
|
| 123 |
+
for region in regions:
|
| 124 |
+
normalized_text = normalize(region.get("text", ""))
|
| 125 |
+
if not normalized_text:
|
| 126 |
+
continue
|
| 127 |
+
matches: dict[str, str] = {}
|
| 128 |
+
for alias, canonical in aliases.items():
|
| 129 |
+
if alias and re.search(rf"(?<!\w){re.escape(alias)}(?!\w)", normalized_text):
|
| 130 |
+
previous = matches.get(canonical, "")
|
| 131 |
+
if len(alias) > len(previous):
|
| 132 |
+
matches[canonical] = alias
|
| 133 |
+
for canonical in matches:
|
| 134 |
+
bbox = [round(value) for value in region["bbox"]]
|
| 135 |
+
key = (canonical, tuple(bbox))
|
| 136 |
+
if key in seen:
|
| 137 |
+
continue
|
| 138 |
+
seen.add(key)
|
| 139 |
+
definition = self.definition(canonical)
|
| 140 |
+
if definition:
|
| 141 |
+
definition.update({
|
| 142 |
+
"bbox": bbox,
|
| 143 |
+
"ocr_text": region.get("text", canonical),
|
| 144 |
+
"ocr_confidence": region.get("confidence", 1.0),
|
| 145 |
+
"language": region.get("language", "en"),
|
| 146 |
+
})
|
| 147 |
+
definition["confidence"] = round(
|
| 148 |
+
definition["confidence"] * float(region.get("confidence", 1.0)), 3
|
| 149 |
+
)
|
| 150 |
+
found.append(definition)
|
| 151 |
+
return found
|
| 152 |
+
|
| 153 |
+
def contextual_fallbacks(self, regions: list[dict], limit: int = 6,
|
| 154 |
+
existing: list[dict] | None = None) -> list[dict]:
|
| 155 |
+
"""Return clearly unverified markers when OCR succeeds but the governed glossary has no match."""
|
| 156 |
+
candidates = []
|
| 157 |
+
occupied = {tuple(item.get("bbox", [])) for item in (existing or [])}
|
| 158 |
+
seen = set()
|
| 159 |
+
for region in sorted(regions, key=lambda item: float(item.get("confidence", 0)), reverse=True):
|
| 160 |
+
text = " ".join(str(region.get("text", "")).split())
|
| 161 |
+
region_box = tuple(round(value) for value in region.get("bbox", []))
|
| 162 |
+
if region_box in occupied:
|
| 163 |
+
continue
|
| 164 |
+
if not 2 <= len(text) <= 120 or float(region.get("confidence", 1)) < 0.55:
|
| 165 |
+
continue
|
| 166 |
+
label = text if len(text) <= 58 else text[:55].rstrip() + "…"
|
| 167 |
+
key = normalize(label)
|
| 168 |
+
if not key or key in seen:
|
| 169 |
+
continue
|
| 170 |
+
seen.add(key)
|
| 171 |
+
confidence = round(min(0.62, float(region.get("confidence", 1)) * 0.62), 3)
|
| 172 |
+
candidates.append({
|
| 173 |
+
"term": label,
|
| 174 |
+
"full_form": None,
|
| 175 |
+
"definition": "This document phrase is not yet in the verified customs glossary. Select or highlight it for a contextual summary and business meaning.",
|
| 176 |
+
"definition_ar": None,
|
| 177 |
+
"source": "ai_generated_unverified",
|
| 178 |
+
"source_label": "Contextual marker · needs verification",
|
| 179 |
+
"confidence": confidence,
|
| 180 |
+
"category": "Document Context",
|
| 181 |
+
"related_terms": [],
|
| 182 |
+
"document_types": [],
|
| 183 |
+
"match_type": "contextual_fallback",
|
| 184 |
+
"bbox": list(region_box),
|
| 185 |
+
"ocr_text": text,
|
| 186 |
+
"ocr_confidence": region.get("confidence", 1.0),
|
| 187 |
+
"language": region.get("language", "en"),
|
| 188 |
+
})
|
| 189 |
+
if len(candidates) >= limit:
|
| 190 |
+
break
|
| 191 |
+
return candidates
|
backend/image_utils.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import hashlib
|
| 3 |
+
import io
|
| 4 |
+
from typing import Tuple
|
| 5 |
+
|
| 6 |
+
from PIL import Image, ImageStat
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def decode_data_url(value: str) -> Image.Image:
|
| 10 |
+
"""Decode a base64 data URL (or raw base64) into a normalized RGB image."""
|
| 11 |
+
if not value:
|
| 12 |
+
raise ValueError("image_base64 is required")
|
| 13 |
+
payload = value.split(",", 1)[1] if "," in value else value
|
| 14 |
+
try:
|
| 15 |
+
raw = base64.b64decode(payload, validate=True)
|
| 16 |
+
image = Image.open(io.BytesIO(raw))
|
| 17 |
+
image.load()
|
| 18 |
+
return image.convert("RGB")
|
| 19 |
+
except Exception as exc:
|
| 20 |
+
raise ValueError("Invalid base64 image") from exc
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def image_fingerprint(image: Image.Image, size: Tuple[int, int] = (16, 16)) -> str:
|
| 24 |
+
"""Perceptual-ish hash suitable for avoiding repeat OCR on nearly identical frames."""
|
| 25 |
+
thumb = image.convert("L").resize(size)
|
| 26 |
+
pixels = list(thumb.getdata())
|
| 27 |
+
mean = sum(pixels) / len(pixels)
|
| 28 |
+
bits = "".join("1" if pixel >= mean else "0" for pixel in pixels)
|
| 29 |
+
return hashlib.sha256(bits.encode()).hexdigest()[:24]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def image_quality(image: Image.Image) -> dict:
|
| 33 |
+
gray = image.convert("L")
|
| 34 |
+
stat = ImageStat.Stat(gray)
|
| 35 |
+
brightness = stat.mean[0]
|
| 36 |
+
# Variance is a cheap CPU-only sharpness signal; browser performs the primary check.
|
| 37 |
+
variance = stat.var[0]
|
| 38 |
+
return {
|
| 39 |
+
"brightness": round(brightness, 2),
|
| 40 |
+
"contrast": round(variance ** 0.5, 2),
|
| 41 |
+
"acceptable": 35 <= brightness <= 225 and variance >= 20,
|
| 42 |
+
}
|
backend/insight_service.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from backend.glossary_service import GlossaryService
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
CATEGORY_MEANING = {
|
| 5 |
+
"Air Cargo": "This affects how air cargo is documented, consolidated, tracked, and released.",
|
| 6 |
+
"Customs Classification": "This can affect duty rates, permits, restrictions, reporting, and customs clearance.",
|
| 7 |
+
"Customs Charges": "This can affect the landed cost and payment required before goods are released.",
|
| 8 |
+
"Shipping Documents": "This is part of the evidence customs and carriers use to identify and release a shipment.",
|
| 9 |
+
"Trade Documents": "This supports customs valuation, verification, and consistency checks across the shipment file.",
|
| 10 |
+
"Incoterms": "This defines which party carries transport cost, operational responsibility, and risk at each stage.",
|
| 11 |
+
"Shipment Parties": "This identifies who sends, receives, or takes responsibility for the shipment.",
|
| 12 |
+
"Cargo Release": "This can determine whether a carrier or terminal is authorized to release the cargo.",
|
| 13 |
+
"Customs Clearance": "This is used to trace the declaration and progress the shipment through customs controls.",
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class InsightService:
|
| 18 |
+
def __init__(self, glossary: GlossaryService):
|
| 19 |
+
self.glossary = glossary
|
| 20 |
+
|
| 21 |
+
def explain(self, text: str, language: str = "en") -> dict:
|
| 22 |
+
clean = " ".join(text.split())[:5000]
|
| 23 |
+
if not clean:
|
| 24 |
+
raise ValueError("Select a word, phrase, or paragraph first")
|
| 25 |
+
terms = self.glossary.match_regions([{
|
| 26 |
+
"text": clean, "bbox": [0, 0, 1, 1], "confidence": 1.0, "language": language,
|
| 27 |
+
}])
|
| 28 |
+
unique = []
|
| 29 |
+
seen = set()
|
| 30 |
+
for term in terms:
|
| 31 |
+
if term["term"] not in seen:
|
| 32 |
+
seen.add(term["term"])
|
| 33 |
+
unique.append(term)
|
| 34 |
+
if unique:
|
| 35 |
+
names = ", ".join(item["term"] for item in unique[:4])
|
| 36 |
+
definitions = " ".join(item["definition"] for item in unique[:3])
|
| 37 |
+
summary = f"This selection contains {len(unique)} recognized trade concept{'s' if len(unique) != 1 else ''}: {names}. {definitions}"
|
| 38 |
+
meanings = []
|
| 39 |
+
for item in unique:
|
| 40 |
+
meaning = CATEGORY_MEANING.get(item.get("category"))
|
| 41 |
+
if meaning and meaning not in meanings:
|
| 42 |
+
meanings.append(meaning)
|
| 43 |
+
business_meaning = " ".join(meanings[:3]) or "Use these terms to cross-check the shipment documents and confirm the responsible parties before clearance."
|
| 44 |
+
confidence = round(sum(item["confidence"] for item in unique) / len(unique), 3)
|
| 45 |
+
source = "verified_glossary"
|
| 46 |
+
source_label = "Verified Customs Glossary"
|
| 47 |
+
else:
|
| 48 |
+
excerpt = clean if len(clean) <= 220 else clean[:217] + "…"
|
| 49 |
+
summary = f"Selected document text: {excerpt}"
|
| 50 |
+
business_meaning = "No governed customs term was found in this selection. Review it in the surrounding document context or request customs-expert verification before acting on it."
|
| 51 |
+
confidence = 0.35
|
| 52 |
+
source = "ai_generated_unverified"
|
| 53 |
+
source_label = "Context summary · needs expert verification"
|
| 54 |
+
return {
|
| 55 |
+
"selected_text": clean,
|
| 56 |
+
"summary": summary,
|
| 57 |
+
"business_meaning": business_meaning,
|
| 58 |
+
"recognized_terms": unique,
|
| 59 |
+
"source": source,
|
| 60 |
+
"source_label": source_label,
|
| 61 |
+
"confidence": confidence,
|
| 62 |
+
}
|
backend/ocr_service.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class OCRService:
|
| 13 |
+
"""Lazy PaddleOCR adapter. Models load only on the first real scan."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.enabled = os.getenv("FALCONSCAN_OCR_ENABLED", "true").lower() == "true"
|
| 17 |
+
self._engines: dict[str, Any] = {}
|
| 18 |
+
self._rapid = None
|
| 19 |
+
self.warming = False
|
| 20 |
+
self.ready = False
|
| 21 |
+
|
| 22 |
+
def _engine(self, language: str):
|
| 23 |
+
key = "ar" if language == "ar" else "en"
|
| 24 |
+
if key not in self._engines:
|
| 25 |
+
if not self.enabled:
|
| 26 |
+
raise RuntimeError("OCR is disabled")
|
| 27 |
+
try:
|
| 28 |
+
from paddleocr import PaddleOCR
|
| 29 |
+
except ImportError as exc:
|
| 30 |
+
raise RuntimeError("PaddleOCR is not installed") from exc
|
| 31 |
+
# PaddleOCR uses Arabic model code 'ar'. Angle classification helps camera captures.
|
| 32 |
+
self._engines[key] = PaddleOCR(use_angle_cls=True, lang=key, show_log=False)
|
| 33 |
+
return self._engines[key]
|
| 34 |
+
|
| 35 |
+
@staticmethod
|
| 36 |
+
def _box(points: list) -> list[int]:
|
| 37 |
+
xs = [point[0] for point in points]
|
| 38 |
+
ys = [point[1] for point in points]
|
| 39 |
+
return [round(min(xs)), round(min(ys)), round(max(xs)), round(max(ys))]
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def _language(text: str) -> str:
|
| 43 |
+
return "ar" if re.search(r"[\u0600-\u06ff]", text) else "en"
|
| 44 |
+
|
| 45 |
+
def extract(self, image: Image.Image, language_preference: str = "en") -> list[dict]:
|
| 46 |
+
if not self.enabled:
|
| 47 |
+
raise RuntimeError("OCR is disabled")
|
| 48 |
+
try:
|
| 49 |
+
import paddleocr # noqa: F401
|
| 50 |
+
paddle_available = True
|
| 51 |
+
except ImportError:
|
| 52 |
+
paddle_available = False
|
| 53 |
+
if not paddle_available:
|
| 54 |
+
return self._extract_rapid(image)
|
| 55 |
+
|
| 56 |
+
languages = ["ar", "en"] if language_preference == "ar" else ["en", "ar"]
|
| 57 |
+
detections: list[dict] = []
|
| 58 |
+
seen: set[tuple[str, tuple[int, ...]]] = set()
|
| 59 |
+
for language in languages:
|
| 60 |
+
try:
|
| 61 |
+
result = self._engine(language).ocr(np.asarray(image), cls=True) or []
|
| 62 |
+
except RuntimeError:
|
| 63 |
+
raise
|
| 64 |
+
except Exception as exc:
|
| 65 |
+
logger.warning("%s OCR failed: %s", language, exc)
|
| 66 |
+
continue
|
| 67 |
+
for page in result:
|
| 68 |
+
for line in page or []:
|
| 69 |
+
points, value = line
|
| 70 |
+
text, confidence = value
|
| 71 |
+
box = self._box(points)
|
| 72 |
+
key = (text.strip().casefold(), tuple(box))
|
| 73 |
+
if text.strip() and key not in seen:
|
| 74 |
+
seen.add(key)
|
| 75 |
+
detections.append({
|
| 76 |
+
"text": text.strip(), "bbox": box,
|
| 77 |
+
"confidence": round(float(confidence), 4),
|
| 78 |
+
"language": self._language(text),
|
| 79 |
+
})
|
| 80 |
+
return detections
|
| 81 |
+
|
| 82 |
+
def _extract_rapid(self, image: Image.Image) -> list[dict]:
|
| 83 |
+
"""Portable CPU fallback for environments where Paddle wheels are unavailable."""
|
| 84 |
+
try:
|
| 85 |
+
from rapidocr_onnxruntime import RapidOCR
|
| 86 |
+
except ImportError as exc:
|
| 87 |
+
raise RuntimeError("Neither PaddleOCR nor the CPU fallback is installed") from exc
|
| 88 |
+
if self._rapid is None:
|
| 89 |
+
self._rapid = RapidOCR()
|
| 90 |
+
result, _ = self._rapid(np.asarray(image))
|
| 91 |
+
self.ready = True
|
| 92 |
+
detections = []
|
| 93 |
+
for line in result or []:
|
| 94 |
+
points, text, confidence = line
|
| 95 |
+
if text and text.strip():
|
| 96 |
+
detections.append({
|
| 97 |
+
"text": text.strip(),
|
| 98 |
+
"bbox": self._box(points),
|
| 99 |
+
"confidence": round(float(confidence), 4),
|
| 100 |
+
"language": self._language(text),
|
| 101 |
+
})
|
| 102 |
+
return detections
|
| 103 |
+
|
| 104 |
+
def warmup(self) -> None:
|
| 105 |
+
"""Load the portable OCR sessions before the user's first scan."""
|
| 106 |
+
if not self.enabled or self.ready or self.warming:
|
| 107 |
+
return
|
| 108 |
+
self.warming = True
|
| 109 |
+
try:
|
| 110 |
+
try:
|
| 111 |
+
import paddleocr # noqa: F401
|
| 112 |
+
# Paddle remains lazy because two multilingual models are substantially larger.
|
| 113 |
+
return
|
| 114 |
+
except ImportError:
|
| 115 |
+
pass
|
| 116 |
+
self._extract_rapid(Image.new("RGB", (320, 128), "white"))
|
| 117 |
+
except Exception as exc:
|
| 118 |
+
logger.warning("OCR warm-up failed: %s", exc)
|
| 119 |
+
finally:
|
| 120 |
+
self.warming = False
|
| 121 |
+
|
| 122 |
+
def status(self) -> dict:
|
| 123 |
+
try:
|
| 124 |
+
import paddleocr # noqa: F401
|
| 125 |
+
installed = True
|
| 126 |
+
except ImportError:
|
| 127 |
+
installed = False
|
| 128 |
+
try:
|
| 129 |
+
import rapidocr_onnxruntime # noqa: F401
|
| 130 |
+
fallback_installed = True
|
| 131 |
+
except ImportError:
|
| 132 |
+
fallback_installed = False
|
| 133 |
+
return {"enabled": self.enabled, "installed": installed,
|
| 134 |
+
"fallback_installed": fallback_installed,
|
| 135 |
+
"loaded_languages": list(self._engines),
|
| 136 |
+
"warming": self.warming, "ready": self.ready}
|
backend/progress_service.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from threading import RLock
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ProgressService:
|
| 8 |
+
def __init__(self, path: Path):
|
| 9 |
+
self.path = path
|
| 10 |
+
self.lock = RLock()
|
| 11 |
+
|
| 12 |
+
def get(self) -> dict:
|
| 13 |
+
with self.lock:
|
| 14 |
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
| 15 |
+
|
| 16 |
+
def update(self, **changes) -> dict:
|
| 17 |
+
with self.lock:
|
| 18 |
+
data = self.get()
|
| 19 |
+
data.update(changes)
|
| 20 |
+
data["last_updated"] = datetime.now(timezone.utc).isoformat()
|
| 21 |
+
self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 22 |
+
return data
|
backend/vlm_service.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class VLMService:
|
| 7 |
+
"""Phase 3 gate. Never loads a VLM in the CPU process."""
|
| 8 |
+
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self.endpoint = os.getenv("FALCONSCAN_VLM_ENDPOINT", "")
|
| 11 |
+
self.token = os.getenv("HF_TOKEN", "")
|
| 12 |
+
|
| 13 |
+
@property
|
| 14 |
+
def enabled(self) -> bool:
|
| 15 |
+
return bool(self.endpoint and self.token)
|
| 16 |
+
|
| 17 |
+
def should_suggest(self, ocr_confidence: float, unknown_count: int,
|
| 18 |
+
user_requested: bool = False, complex_layout: bool = False) -> tuple[bool, list[str]]:
|
| 19 |
+
reasons = []
|
| 20 |
+
if ocr_confidence < 0.7:
|
| 21 |
+
reasons.append("low_ocr_confidence")
|
| 22 |
+
if unknown_count:
|
| 23 |
+
reasons.append("unknown_terms")
|
| 24 |
+
if user_requested:
|
| 25 |
+
reasons.append("user_requested")
|
| 26 |
+
if complex_layout:
|
| 27 |
+
reasons.append("complex_layout")
|
| 28 |
+
return bool(reasons), reasons
|
| 29 |
+
|
| 30 |
+
def analyze(self, user_requested: bool) -> dict:
|
| 31 |
+
if not user_requested:
|
| 32 |
+
return {"status": "not_run", "message": "VLM analysis requires an explicit trigger."}
|
| 33 |
+
if not self.enabled:
|
| 34 |
+
return {"status": "unavailable", "message": "Optional VLM endpoint is not configured. Glossary-first OCR remains available."}
|
| 35 |
+
return {"status": "configured", "message": "VLM endpoint is configured; provider adapter can be enabled for the selected model."}
|
data/feedback_history.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/glossary.json
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"B/L": {
|
| 3 |
+
"aliases": ["BL", "Bill of Lading", "بوليصة شحن"],
|
| 4 |
+
"full_form": "Bill of Lading",
|
| 5 |
+
"definition": "A carrier-issued document that records the goods shipped, transport terms, and who may receive the cargo.",
|
| 6 |
+
"definition_ar": "مستند يصدره الناقل يوضح البضائع المشحونة وشروط النقل والجهة التي يحق لها استلامها.",
|
| 7 |
+
"category": "Shipping Documents", "related_terms": ["Shipper", "Consignee", "Manifest"],
|
| 8 |
+
"document_types": ["Bill of Lading", "Manifest"], "source": "verified_glossary"
|
| 9 |
+
},
|
| 10 |
+
"MAWB": {
|
| 11 |
+
"aliases": ["Master Air Waybill", "Master AWB", "بوليصة الشحن الجوي الرئيسية"],
|
| 12 |
+
"full_form": "Master Air Waybill",
|
| 13 |
+
"definition": "The main air cargo transport document issued by an airline or carrier for a consolidated shipment.",
|
| 14 |
+
"definition_ar": "مستند النقل الجوي الرئيسي الذي تصدره شركة الطيران أو الناقل لشحنة مجمعة.",
|
| 15 |
+
"category": "Air Cargo", "related_terms": ["HAWB", "Carrier", "Consignee", "Shipper"],
|
| 16 |
+
"document_types": ["Air Waybill", "Manifest"], "source": "verified_glossary"
|
| 17 |
+
},
|
| 18 |
+
"HAWB": {
|
| 19 |
+
"aliases": ["House Air Waybill", "House AWB", "بوليصة شحن جوي فرعية"],
|
| 20 |
+
"full_form": "House Air Waybill",
|
| 21 |
+
"definition": "An air cargo document issued by a freight forwarder for an individual shipment within a consolidated load.",
|
| 22 |
+
"definition_ar": "مستند شحن جوي يصدره وكيل الشحن لشحنة فردية ضمن شحنة مجمعة.",
|
| 23 |
+
"category": "Air Cargo", "related_terms": ["MAWB", "Freight Forwarder"], "document_types": ["Air Waybill"], "source": "verified_glossary"
|
| 24 |
+
},
|
| 25 |
+
"HS Code": {
|
| 26 |
+
"aliases": ["HS", "Harmonized System Code", "Harmonised System Code", "رمز النظام المنسق", "الرمز المنسق"],
|
| 27 |
+
"full_form": "Harmonized System Code",
|
| 28 |
+
"definition": "A standard code used to classify traded goods for customs duty and import or export rules.",
|
| 29 |
+
"definition_ar": "رمز معياري لتصنيف السلع المتداولة وتحديد الرسوم الجمركية وقواعد الاستيراد أو التصدير.",
|
| 30 |
+
"category": "Customs Classification", "related_terms": ["Customs Duty", "Tariff", "Commodity"],
|
| 31 |
+
"document_types": ["Customs Declaration", "Commercial Invoice"], "source": "verified_glossary"
|
| 32 |
+
},
|
| 33 |
+
"CIF": {
|
| 34 |
+
"aliases": ["Cost Insurance and Freight", "التكلفة والتأمين وأجرة الشحن"], "full_form": "Cost, Insurance and Freight",
|
| 35 |
+
"definition": "An Incoterm where the seller pays cost, marine insurance, and freight to the named destination port.",
|
| 36 |
+
"definition_ar": "شرط تجاري يدفع فيه البائع التكلفة والتأمين البحري والشحن حتى ميناء الوجهة المحدد.",
|
| 37 |
+
"category": "Incoterms", "related_terms": ["FOB", "Incoterms"], "document_types": ["Commercial Invoice"], "source": "verified_glossary"
|
| 38 |
+
},
|
| 39 |
+
"FOB": {
|
| 40 |
+
"aliases": ["Free On Board", "التسليم على ظهر السفينة"], "full_form": "Free On Board",
|
| 41 |
+
"definition": "An Incoterm where risk transfers to the buyer once goods are loaded aboard the vessel at the named port.",
|
| 42 |
+
"definition_ar": "شرط تجاري تنتقل فيه المخاطر إلى المشتري بعد تحميل البضاعة على السفينة في الميناء المحدد.",
|
| 43 |
+
"category": "Incoterms", "related_terms": ["CIF", "Incoterms"], "document_types": ["Commercial Invoice"], "source": "verified_glossary"
|
| 44 |
+
},
|
| 45 |
+
"COO": {
|
| 46 |
+
"aliases": ["Certificate of Origin", "شهادة المنشأ"], "full_form": "Certificate of Origin",
|
| 47 |
+
"definition": "A document certifying the country where goods were produced or manufactured.",
|
| 48 |
+
"definition_ar": "مستند يثبت الدولة التي أُنتجت أو صُنعت فيها البضائع.",
|
| 49 |
+
"category": "Trade Documents", "related_terms": ["Commercial Invoice", "Exporter"], "document_types": ["Certificate of Origin"], "source": "verified_glossary"
|
| 50 |
+
},
|
| 51 |
+
"TIR": {
|
| 52 |
+
"aliases": ["Transports Internationaux Routiers", "النقل البري الدولي"], "full_form": "Transports Internationaux Routiers",
|
| 53 |
+
"definition": "An international customs transit system that lets sealed road cargo cross participating borders with simplified checks.",
|
| 54 |
+
"definition_ar": "نظام عبور جمركي دولي يتيح للبضائع البرية المختومة عبور حدود الدول المشاركة بإجراءات مبسطة.",
|
| 55 |
+
"category": "Customs Transit", "related_terms": ["Bonded Warehouse", "Manifest"], "document_types": ["TIR Carnet"], "source": "verified_glossary"
|
| 56 |
+
},
|
| 57 |
+
"FZE": {
|
| 58 |
+
"aliases": ["Free Zone Establishment", "مؤسسة منطقة حرة"], "full_form": "Free Zone Establishment",
|
| 59 |
+
"definition": "A legal entity registered in a UAE free zone, typically owned by one shareholder.",
|
| 60 |
+
"definition_ar": "كيان قانوني مسجل في منطقة حرة بدولة الإمارات ويكون عادةً مملوكاً لمساهم واحد.",
|
| 61 |
+
"category": "Free Zones", "related_terms": ["Free Zone", "Importer"], "document_types": ["Trade License"], "source": "verified_glossary"
|
| 62 |
+
},
|
| 63 |
+
"Bonded Warehouse": {
|
| 64 |
+
"aliases": ["Customs Bonded Warehouse", "مستودع جمركي", "مستودع مرخص"],
|
| 65 |
+
"definition": "An authorized facility where imported goods can be stored before customs duty is paid or the goods are re-exported.",
|
| 66 |
+
"definition_ar": "منشأة مرخصة تُخزن فيها البضائع المستوردة قبل دفع الرسوم الجمركية أو إعادة تصديرها.",
|
| 67 |
+
"category": "Customs Facilities", "related_terms": ["Customs Duty", "TIR"], "document_types": ["Warehouse Entry"], "source": "verified_glossary"
|
| 68 |
+
},
|
| 69 |
+
"Customs Duty": {
|
| 70 |
+
"aliases": ["Import Duty", "Duty", "الرسوم الجمركية"],
|
| 71 |
+
"definition": "A tax charged by customs on imported or, in some cases, exported goods.",
|
| 72 |
+
"definition_ar": "ضريبة تفرضها الجمارك على البضائع المستوردة، وفي بعض الحالات المصدرة.",
|
| 73 |
+
"category": "Customs Charges", "related_terms": ["HS Code", "CIF"], "document_types": ["Customs Declaration"], "source": "verified_glossary"
|
| 74 |
+
},
|
| 75 |
+
"Consignee": {
|
| 76 |
+
"aliases": ["Receiver", "المرسل إليه", "المستلم"],
|
| 77 |
+
"definition": "The person or company named to receive the shipped goods.",
|
| 78 |
+
"definition_ar": "الشخص أو الشركة المذكورة لاستلام البضائع المشحونة.",
|
| 79 |
+
"category": "Shipment Parties", "related_terms": ["Shipper", "Carrier"], "document_types": ["Bill of Lading", "Air Waybill"], "source": "verified_glossary"
|
| 80 |
+
},
|
| 81 |
+
"Shipper": {
|
| 82 |
+
"aliases": ["Consignor", "المرسل", "الشاحن"],
|
| 83 |
+
"definition": "The person or company that sends the goods and is named on the transport document.",
|
| 84 |
+
"definition_ar": "الشخص أو الشركة التي ترسل البضائع ويظهر اسمها في مستند النقل.",
|
| 85 |
+
"category": "Shipment Parties", "related_terms": ["Consignee", "Carrier"], "document_types": ["Bill of Lading", "Air Waybill"], "source": "verified_glossary"
|
| 86 |
+
},
|
| 87 |
+
"Manifest": {
|
| 88 |
+
"aliases": ["Cargo Manifest", "بيان الشحنة", "قائمة الشحن"],
|
| 89 |
+
"definition": "A consolidated list of cargo, passengers, or containers carried by a vessel, aircraft, or vehicle.",
|
| 90 |
+
"definition_ar": "قائمة مجمعة بالبضائع أو الركاب أو الحاويات التي تنقلها سفينة أو طائرة أو مركبة.",
|
| 91 |
+
"category": "Shipping Documents", "related_terms": ["B/L", "MAWB"], "document_types": ["Manifest"], "source": "verified_glossary"
|
| 92 |
+
},
|
| 93 |
+
"Declaration Number": {
|
| 94 |
+
"aliases": ["Customs Declaration Number", "رقم البيان", "رقم البيان الجمركي"],
|
| 95 |
+
"definition": "The unique reference assigned to a customs declaration for tracking and clearance.",
|
| 96 |
+
"definition_ar": "المرجع الفريد المخصص للبيان الجمركي لأغراض التتبع والتخليص.",
|
| 97 |
+
"category": "Customs Clearance", "related_terms": ["Customs Declaration"], "document_types": ["Customs Declaration"], "source": "verified_glossary"
|
| 98 |
+
},
|
| 99 |
+
"Incoterms": {
|
| 100 |
+
"aliases": ["International Commercial Terms", "شروط التجارة الدولية"],
|
| 101 |
+
"definition": "Standard trade rules defining how delivery costs, tasks, and risks are split between seller and buyer.",
|
| 102 |
+
"definition_ar": "قواعد تجارية معيارية تحدد توزيع تكاليف ومهام ومخاطر التسليم بين البائع والمشتري.",
|
| 103 |
+
"category": "Trade Terms", "related_terms": ["CIF", "FOB"], "document_types": ["Commercial Invoice", "Sales Contract"], "source": "verified_glossary"
|
| 104 |
+
},
|
| 105 |
+
"Delivery Order": {
|
| 106 |
+
"aliases": ["D/O", "DO", "أمر تسليم", "إذن التسليم"],
|
| 107 |
+
"definition": "An instruction authorizing a terminal or carrier to release cargo to the named recipient.",
|
| 108 |
+
"definition_ar": "تعليمات تجيز للمحطة أو الناقل الإفراج عن البضاعة للمستلم المحدد.",
|
| 109 |
+
"category": "Cargo Release", "related_terms": ["Consignee", "B/L"], "document_types": ["Delivery Order"], "source": "verified_glossary"
|
| 110 |
+
},
|
| 111 |
+
"Packing List": {
|
| 112 |
+
"aliases": ["قائمة التعبئة", "بيان التعبئة"],
|
| 113 |
+
"definition": "A detailed list of package contents, quantities, weights, and dimensions in a shipment.",
|
| 114 |
+
"definition_ar": "قائمة تفصيلية بمحتويات الطرود والكميات والأوزان والأبعاد في الشحنة.",
|
| 115 |
+
"category": "Trade Documents", "related_terms": ["Commercial Invoice"], "document_types": ["Packing List"], "source": "verified_glossary"
|
| 116 |
+
},
|
| 117 |
+
"Commercial Invoice": {
|
| 118 |
+
"aliases": ["Trade Invoice", "فاتورة تجارية"],
|
| 119 |
+
"definition": "The seller's bill showing the goods, parties, prices, currency, and trade terms used for customs valuation.",
|
| 120 |
+
"definition_ar": "فاتورة البائع التي توضح البضائع والأطراف والأسعار والعملة والشروط التجارية المستخدمة للتقييم الجمركي.",
|
| 121 |
+
"category": "Trade Documents", "related_terms": ["Packing List", "HS Code", "COO"], "document_types": ["Commercial Invoice"], "source": "verified_glossary"
|
| 122 |
+
}
|
| 123 |
+
}
|
data/ocr_cache.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
data/project_progress.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"project": "FalconScan",
|
| 3 |
+
"current_phase": "Phases 1–3 designed; Phase 1 implemented",
|
| 4 |
+
"completed_modules": ["Problem Statement", "Requirements", "Architecture Documentation", "Glossary Design", "OCR Service", "Glossary Matching", "Mobile-first Camera UI", "Responsive Desktop Layout", "Collapsible Scan Status", "Document Image Upload", "Adaptive Dot Overlay", "Selectable OCR Text Layer", "Summary and Business Meaning Insights", "Privacy Trust Note", "Polished Product UI", "Stable Frame Capture", "Feedback Correction", "Accurate Header Count", "SME Review", "AI Fallback Gate", "VLM Enhancement Gate", "HF Deployment", "Automated Tests"],
|
| 5 |
+
"current_module": "Representative device validation",
|
| 6 |
+
"pending_modules": ["Install production OCR models", "Configure optional hosted AI endpoint", "Configure optional VLM endpoint", "Persistent production database"],
|
| 7 |
+
"open_issues": ["Local JSON storage is single-instance and ephemeral on default free Spaces", "PaddleOCR model download adds cold-start time", "Remote AI and VLM adapters require provider-specific endpoint contracts"],
|
| 8 |
+
"last_completed_step": "Added scrollable document stage, three small draggable dots, full-page Info summary, faster multi-word selection, and removed right-panel selection history",
|
| 9 |
+
"next_recommended_step": "Run the remaining physical iPhone/Android acceptance checks, then execute GitHub CI and Hugging Face smoke tests after remotes are configured",
|
| 10 |
+
"last_updated": "2026-06-20T12:00:00Z"
|
| 11 |
+
}
|
data/sme_approved_definitions.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
data/user_corrections.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
examples/sample_documents/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sample documents
|
| 2 |
+
|
| 3 |
+
Place synthetic or properly anonymized manifests, bills of lading, airway bills, invoices, packing lists, delivery orders, and customs declarations here for manual testing. Do not commit real customer documents or personal data.
|
frontend/camera.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
const $ = (selector) => document.querySelector(selector);
|
| 3 |
+
let stream = null;
|
| 4 |
+
let processing = false;
|
| 5 |
+
let lastAutoScan = 0;
|
| 6 |
+
let previous = null;
|
| 7 |
+
let stableFrames = 0;
|
| 8 |
+
let currentDocumentText = "";
|
| 9 |
+
|
| 10 |
+
const sample = document.createElement("canvas");
|
| 11 |
+
const context = sample.getContext("2d", { willReadFrequently: true });
|
| 12 |
+
sample.width = 96;
|
| 13 |
+
sample.height = 72;
|
| 14 |
+
|
| 15 |
+
function setDetails(open) {
|
| 16 |
+
const shell = $("#scannerShell");
|
| 17 |
+
shell.classList.toggle("details-open", open);
|
| 18 |
+
shell.classList.toggle("details-closed", !open);
|
| 19 |
+
$("#scanDetails").setAttribute("aria-hidden", String(!open));
|
| 20 |
+
$("#detailsToggle").setAttribute("aria-expanded", String(open));
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async function start() {
|
| 24 |
+
try {
|
| 25 |
+
stream = await navigator.mediaDevices.getUserMedia({
|
| 26 |
+
video: {
|
| 27 |
+
facingMode: { ideal: "environment" },
|
| 28 |
+
width: { ideal: 1280 },
|
| 29 |
+
height: { ideal: 720 },
|
| 30 |
+
},
|
| 31 |
+
audio: false,
|
| 32 |
+
});
|
| 33 |
+
$("#video").srcObject = stream;
|
| 34 |
+
await $("#video").play();
|
| 35 |
+
$("#video").hidden = false;
|
| 36 |
+
$("#documentScroller").hidden = true;
|
| 37 |
+
$("#viewport").appendChild($("#overlay"));
|
| 38 |
+
$("#documentInfo").hidden = true;
|
| 39 |
+
$("#emptyState").classList.add("hidden");
|
| 40 |
+
$("#viewport").classList.add("active");
|
| 41 |
+
$("#statusPill").classList.add("live");
|
| 42 |
+
$("#statusPill span").textContent = "Live · private preview";
|
| 43 |
+
$("#captureButton").disabled = false;
|
| 44 |
+
setStatus("Camera ready", "Position the document inside the frame and hold still. Capture starts automatically when the page is clear.");
|
| 45 |
+
requestAnimationFrame(monitor);
|
| 46 |
+
} catch (error) {
|
| 47 |
+
setStatus(
|
| 48 |
+
"Camera unavailable",
|
| 49 |
+
error.name === "NotAllowedError"
|
| 50 |
+
? "Camera permission was denied. Allow access and try again."
|
| 51 |
+
: "Use HTTPS or localhost and make sure a camera is connected.",
|
| 52 |
+
);
|
| 53 |
+
setDetails(true);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function monitor() {
|
| 58 |
+
if (!stream) return;
|
| 59 |
+
const video = $("#video");
|
| 60 |
+
if (video.readyState >= 2) {
|
| 61 |
+
context.drawImage(video, 0, 0, sample.width, sample.height);
|
| 62 |
+
const data = context.getImageData(0, 0, sample.width, sample.height).data;
|
| 63 |
+
let brightness = 0;
|
| 64 |
+
let motion = 0;
|
| 65 |
+
let edges = 0;
|
| 66 |
+
const gray = new Uint8Array(sample.width * sample.height);
|
| 67 |
+
|
| 68 |
+
for (let source = 0, target = 0; source < data.length; source += 4, target += 1) {
|
| 69 |
+
gray[target] = data[source] * 0.299 + data[source + 1] * 0.587 + data[source + 2] * 0.114;
|
| 70 |
+
brightness += gray[target];
|
| 71 |
+
if (previous) motion += Math.abs(gray[target] - previous[target]);
|
| 72 |
+
if (target > sample.width) edges += Math.abs(gray[target] - gray[target - sample.width]);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
brightness /= gray.length;
|
| 76 |
+
motion = previous ? motion / gray.length : 99;
|
| 77 |
+
edges /= gray.length;
|
| 78 |
+
previous = gray;
|
| 79 |
+
|
| 80 |
+
const lightOkay = brightness > 42 && brightness < 220;
|
| 81 |
+
const sharp = edges > 5.2;
|
| 82 |
+
const still = motion < 3.8;
|
| 83 |
+
stableFrames = lightOkay && sharp && still ? stableFrames + 1 : 0;
|
| 84 |
+
|
| 85 |
+
$("#qualityPill").textContent = !lightOkay
|
| 86 |
+
? brightness < 42 ? "More light needed" : "Reduce glare"
|
| 87 |
+
: !sharp ? "Move closer / focus"
|
| 88 |
+
: !still ? "Hold steady"
|
| 89 |
+
: stableFrames < 12 ? "Almost stable…" : "Frame stable";
|
| 90 |
+
|
| 91 |
+
if (stableFrames >= 12 && !processing && Date.now() - lastAutoScan > 8000) {
|
| 92 |
+
lastAutoScan = Date.now();
|
| 93 |
+
analyze();
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
requestAnimationFrame(monitor);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
async function analyze() {
|
| 100 |
+
if (processing || !stream) return;
|
| 101 |
+
const video = $("#video");
|
| 102 |
+
const canvas = $("#captureCanvas");
|
| 103 |
+
canvas.width = video.videoWidth;
|
| 104 |
+
canvas.height = video.videoHeight;
|
| 105 |
+
canvas.getContext("2d").drawImage(video, 0, 0);
|
| 106 |
+
await analyzeCanvas(canvas, "camera");
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
async function analyzeCanvas(canvas, source) {
|
| 110 |
+
if (processing) return;
|
| 111 |
+
processing = true;
|
| 112 |
+
stableFrames = 0;
|
| 113 |
+
setStatus("Finding document terms", source === "upload" ? "Reading the uploaded page securely." : "Reading one clear camera frame.");
|
| 114 |
+
showProgress("Recognizing text and matching business terminology…");
|
| 115 |
+
const payload = {
|
| 116 |
+
image_base64: canvas.toDataURL("image/jpeg", 0.86),
|
| 117 |
+
frame_width: canvas.width,
|
| 118 |
+
frame_height: canvas.height,
|
| 119 |
+
language_preference: $("#language").value,
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
try {
|
| 123 |
+
const response = await fetch("/analyze-frame", {
|
| 124 |
+
method: "POST",
|
| 125 |
+
headers: { "Content-Type": "application/json" },
|
| 126 |
+
body: JSON.stringify(payload),
|
| 127 |
+
});
|
| 128 |
+
const data = await response.json();
|
| 129 |
+
if (!response.ok) throw new Error(data.detail || "Scan failed");
|
| 130 |
+
|
| 131 |
+
if (!data.ocr_available) {
|
| 132 |
+
setStatus("Recognition unavailable", `${data.message}. Install the full requirements to enable document recognition.`);
|
| 133 |
+
toast("The interface is ready. OCR needs the PaddleOCR dependency.");
|
| 134 |
+
} else {
|
| 135 |
+
renderAnalysis(data, canvas.width, canvas.height);
|
| 136 |
+
}
|
| 137 |
+
} catch (error) {
|
| 138 |
+
setStatus("Couldn’t scan this page", error.message);
|
| 139 |
+
} finally {
|
| 140 |
+
processing = false;
|
| 141 |
+
setDetails(true);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
async function handleUpload(event) {
|
| 146 |
+
const file = event.target.files?.[0];
|
| 147 |
+
event.target.value = "";
|
| 148 |
+
if (!file) return;
|
| 149 |
+
const extension = file.name.split(".").pop()?.toLowerCase();
|
| 150 |
+
const supported = ["jpg", "jpeg", "png", "webp", "pdf", "docx"];
|
| 151 |
+
if (!supported.includes(extension)) {
|
| 152 |
+
toast("Please choose JPG, PNG, WebP, PDF, or Word DOCX.");
|
| 153 |
+
return;
|
| 154 |
+
}
|
| 155 |
+
if (file.size > 15 * 1024 * 1024) {
|
| 156 |
+
toast("Choose a document smaller than 15 MB.");
|
| 157 |
+
return;
|
| 158 |
+
}
|
| 159 |
+
processing = true;
|
| 160 |
+
setStatus("Preparing document", "Creating a private preview and finding useful business terms.");
|
| 161 |
+
showProgress("Opening and reading the document…");
|
| 162 |
+
setDetails(true);
|
| 163 |
+
try {
|
| 164 |
+
const prepared = await prepareUpload(file);
|
| 165 |
+
const response = await fetch("/analyze-document", {
|
| 166 |
+
method: "POST",
|
| 167 |
+
headers: { "Content-Type": "application/json" },
|
| 168 |
+
body: JSON.stringify({
|
| 169 |
+
file_base64: prepared.base64,
|
| 170 |
+
filename: prepared.filename,
|
| 171 |
+
language_preference: $("#language").value,
|
| 172 |
+
}),
|
| 173 |
+
});
|
| 174 |
+
const data = await response.json();
|
| 175 |
+
if (!response.ok) throw new Error(data.detail || "Document analysis failed");
|
| 176 |
+
const preview = $("#uploadedPreview");
|
| 177 |
+
await loadPreview(preview, data.preview_base64);
|
| 178 |
+
$("#documentStage").appendChild($("#overlay"));
|
| 179 |
+
$("#documentScroller").hidden = false;
|
| 180 |
+
$("#documentScroller").scrollTo({ top: 0, left: 0, behavior: "instant" });
|
| 181 |
+
$("#video").hidden = true;
|
| 182 |
+
$("#emptyState").classList.add("hidden");
|
| 183 |
+
$("#viewport").classList.remove("active");
|
| 184 |
+
$("#statusPill").classList.add("live");
|
| 185 |
+
$("#statusPill span").textContent = "Uploaded · not stored";
|
| 186 |
+
$("#qualityPill").textContent = file.name;
|
| 187 |
+
$("#captureButton").disabled = true;
|
| 188 |
+
renderAnalysis(data, data.frame_width, data.frame_height);
|
| 189 |
+
} catch (error) {
|
| 190 |
+
setStatus("Couldn’t read this document", error.message);
|
| 191 |
+
toast(error.message);
|
| 192 |
+
} finally {
|
| 193 |
+
processing = false;
|
| 194 |
+
setDetails(true);
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function renderAnalysis(data, frameWidth, frameHeight) {
|
| 199 |
+
const terms = data.detected_terms || [];
|
| 200 |
+
$("#resultTitle").textContent = terms.length ? "Document ready" : "No useful terms found";
|
| 201 |
+
$("#statusMessage").textContent = terms.length
|
| 202 |
+
? "Tap a dot on the document to open its live definition. Drag a dot if it covers important text."
|
| 203 |
+
: data.unknown_terms?.length
|
| 204 |
+
? "Text was found, but no glossary terms matched. Try a clearer document."
|
| 205 |
+
: "Try a sharper image or a page containing customs, freight, or shipping terminology.";
|
| 206 |
+
window.FalconOverlay.render(terms, frameWidth, frameHeight, data.ocr_items || []);
|
| 207 |
+
currentDocumentText = (data.ocr_items || []).map((item) => item.text || "").filter(Boolean).join(" ").slice(0, 5000);
|
| 208 |
+
$("#documentInfo").hidden = !currentDocumentText;
|
| 209 |
+
$("#selectionHint").hidden = !(data.ocr_items || []).length;
|
| 210 |
+
$("#vlmButton").disabled = !data.suggest_vlm;
|
| 211 |
+
$("#vlmButton").title = data.vlm_reasons?.join(", ") || "";
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
async function prepareUpload(file) {
|
| 215 |
+
if (!file.type.startsWith("image/")) {
|
| 216 |
+
return { base64: await readBase64(file), filename: file.name };
|
| 217 |
+
}
|
| 218 |
+
const objectUrl = URL.createObjectURL(file);
|
| 219 |
+
try {
|
| 220 |
+
const image = await loadImage(objectUrl);
|
| 221 |
+
const maxEdge = 1600;
|
| 222 |
+
const scale = Math.min(1, maxEdge / Math.max(image.naturalWidth, image.naturalHeight));
|
| 223 |
+
if (scale === 1 && file.size < 2.5 * 1024 * 1024) {
|
| 224 |
+
return { base64: await readBase64(file), filename: file.name };
|
| 225 |
+
}
|
| 226 |
+
const canvas = document.createElement("canvas");
|
| 227 |
+
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
|
| 228 |
+
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
|
| 229 |
+
canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height);
|
| 230 |
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.84));
|
| 231 |
+
return { base64: await readBase64(blob), filename: "optimized-upload.jpg" };
|
| 232 |
+
} finally {
|
| 233 |
+
URL.revokeObjectURL(objectUrl);
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function readBase64(file) {
|
| 238 |
+
return new Promise((resolve, reject) => {
|
| 239 |
+
const reader = new FileReader();
|
| 240 |
+
reader.onload = () => resolve(String(reader.result).split(",", 2)[1]);
|
| 241 |
+
reader.onerror = () => reject(new Error("The document could not be read"));
|
| 242 |
+
reader.readAsDataURL(file);
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function loadImage(source) {
|
| 247 |
+
return new Promise((resolve, reject) => {
|
| 248 |
+
const image = new Image();
|
| 249 |
+
image.onload = () => resolve(image);
|
| 250 |
+
image.onerror = () => reject(new Error("The image could not be prepared"));
|
| 251 |
+
image.src = source;
|
| 252 |
+
});
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function showProgress(message) {
|
| 256 |
+
$("#termsList").innerHTML = `<div class="analysis-progress"><i aria-hidden="true"></i><span>${message}</span></div>`;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
function loadPreview(element, source) {
|
| 260 |
+
return new Promise((resolve, reject) => {
|
| 261 |
+
element.onload = resolve;
|
| 262 |
+
element.onerror = () => reject(new Error("The document preview could not be rendered"));
|
| 263 |
+
element.src = source;
|
| 264 |
+
});
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
async function analyzeVlm() {
|
| 268 |
+
$("#vlmButton").disabled = true;
|
| 269 |
+
const data = await fetch("/analyze-document-vlm", {
|
| 270 |
+
method: "POST",
|
| 271 |
+
headers: { "Content-Type": "application/json" },
|
| 272 |
+
body: JSON.stringify({ user_requested: true }),
|
| 273 |
+
}).then((response) => response.json());
|
| 274 |
+
toast(data.message);
|
| 275 |
+
setTimeout(() => { $("#vlmButton").disabled = false; }, 1200);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function setStatus(title, message) {
|
| 279 |
+
$("#resultTitle").textContent = title;
|
| 280 |
+
$("#statusMessage").textContent = message;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function toast(message) {
|
| 284 |
+
const element = $("#toast");
|
| 285 |
+
element.textContent = message;
|
| 286 |
+
element.classList.add("show");
|
| 287 |
+
setTimeout(() => element.classList.remove("show"), 3500);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
document.addEventListener("DOMContentLoaded", () => {
|
| 291 |
+
window.FalconOverlay.init();
|
| 292 |
+
window.FalconFeedback.init();
|
| 293 |
+
window.FalconInsights.init();
|
| 294 |
+
setDetails(false);
|
| 295 |
+
$("#startCamera").onclick = start;
|
| 296 |
+
$("#captureButton").onclick = analyze;
|
| 297 |
+
$("#vlmButton").onclick = analyzeVlm;
|
| 298 |
+
$("#detailsToggle").onclick = () => setDetails(!$("#scannerShell").classList.contains("details-open"));
|
| 299 |
+
$("#detailsClose").onclick = () => setDetails(false);
|
| 300 |
+
$("#uploadButton").onclick = () => $("#documentUpload").click();
|
| 301 |
+
$("#documentUpload").onchange = handleUpload;
|
| 302 |
+
$("#documentInfo").onclick = () => {
|
| 303 |
+
if (currentDocumentText) window.FalconInsights.explain(currentDocumentText);
|
| 304 |
+
};
|
| 305 |
+
$("#language").onchange = () => { document.documentElement.lang = $("#language").value; };
|
| 306 |
+
window.addEventListener("resize", () => window.FalconOverlay.clear());
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
window.addEventListener("beforeunload", () => stream?.getTracks().forEach((track) => track.stop()));
|
| 310 |
+
})();
|
frontend/feedback.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
window.FalconFeedback=(()=>{let current=null;const $=s=>document.querySelector(s);function init(){document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>close(b.dataset.close));$('#thumbUp').onclick=()=>submit('thumbs_up');$('#thumbDown').onclick=()=>{$('#correctionForm').hidden=false;$('#correctionText').focus()};$('#correctionForm').onsubmit=e=>{e.preventDefault();submit('thumbs_down',$('#correctionText').value)};$('#adminButton').onclick=openAdmin;loadCount()}function open(item){current=item;const lang=$('#language').value;const arabic=lang==='ar'&&item.definition_ar;$('#definitionModal').classList.add('open');$('#definitionModal').setAttribute('aria-hidden','false');$('.definition-card').dir=arabic?'rtl':'ltr';$('#definitionCategory').textContent=item.category||'CUSTOMS TERM';$('#definitionTerm').textContent=item.term;$('#definitionFullForm').textContent=item.full_form||'';$('#definitionText').textContent=arabic?item.definition_ar:item.definition;$('#definitionSource').textContent=item.source_label;$('#definitionConfidence').textContent=`${Math.round(item.confidence*100)}% confidence`;$('#relatedTerms').textContent=item.related_terms?.length?`Related: ${item.related_terms.join(' · ')}`:'';$('#correctionForm').hidden=true;$('#correctionText').value='';$('#feedbackMessage').textContent=''}function close(id){$('#'+id).classList.remove('open');$('#'+id).setAttribute('aria-hidden','true')}async function submit(type,correction=null){if(!current)return;const response=await fetch('/submit-feedback',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({term:current.term,old_definition:current.definition,corrected_definition:correction,feedback_type:type})});const data=await response.json();$('#feedbackMessage').textContent=response.ok?data.message:(data.detail||'Could not save feedback');if(response.ok&&correction){current.definition=correction;current.source='user_corrected';current.source_label='User correction (pending SME review)';loadCount();setTimeout(()=>close('definitionModal'),1000)}}async function loadCount(){const badge=$('#reviewCount'),button=$('#adminButton');try{const data=await fetch('/admin/corrections').then(r=>{if(!r.ok)throw new Error('Review queue unavailable');return r.json()});const count=Array.isArray(data.items)?data.items.length:0;badge.textContent=String(count);badge.hidden=count===0;button.setAttribute('aria-label',count?`Open SME review queue, ${count} pending ${count===1?'correction':'corrections'}`:'Open SME review queue')}catch{badge.hidden=true;button.setAttribute('aria-label','Open SME review queue; count unavailable')}}async function openAdmin(){$('#adminModal').classList.add('open');const list=$('#adminList');list.innerHTML='<p class="muted">Loading…</p>';const data=await fetch('/admin/corrections').then(r=>r.json());list.innerHTML=data.items.length?data.items.map(item=>`<article class="admin-item"><h3>${escapeHtml(item.term)}</h3><p class="muted">Current: ${escapeHtml(item.old_definition||item.previous_definition||'—')}</p><p><strong>Suggestion:</strong> ${escapeHtml(item.corrected_definition)}</p><small>Suggested by ${escapeHtml(item.suggested_by)} · ${new Date(item.created_at).toLocaleString()}</small><div class="admin-actions"><button class="approve" data-review="approve" data-term="${escapeHtml(item.term)}">Approve</button><button class="reject" data-review="reject" data-term="${escapeHtml(item.term)}">Reject</button></div></article>`).join(''):'<p class="muted">No corrections are waiting for review.</p>';list.querySelectorAll('[data-review]').forEach(b=>b.onclick=()=>review(b.dataset.term,b.dataset.review))}async function review(term,action){await fetch('/admin/review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({term,action,reviewer:'FalconScan SME'})});openAdmin();loadCount()}function escapeHtml(s){return String(s||'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]))}return{init,open}})();
|
frontend/index.html
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 6 |
+
<meta name="theme-color" content="#071a18">
|
| 7 |
+
<title>FalconScan — Customs terms, made clear</title>
|
| 8 |
+
<link rel="stylesheet" href="/static/styles.css">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<header>
|
| 12 |
+
<a class="brand" href="#"><span class="mark">F</span><span>FalconScan<small>Customs intelligence</small></span></a>
|
| 13 |
+
<div class="header-actions">
|
| 14 |
+
<select id="language" aria-label="Definition language"><option value="en">English</option><option value="ar">العربية</option></select>
|
| 15 |
+
<button class="ghost" id="adminButton" aria-label="Open SME review queue">SME review <span id="reviewCount" class="count" hidden>0</span></button>
|
| 16 |
+
</div>
|
| 17 |
+
</header>
|
| 18 |
+
|
| 19 |
+
<main>
|
| 20 |
+
<section class="intro">
|
| 21 |
+
<div><p class="eyebrow">LIVE DOCUMENT ASSISTANT</p><h1>Point. Pause. Understand.</h1>
|
| 22 |
+
<p>FalconScan finds customs and freight terminology on physical documents, then explains it without taking you away from the camera.</p></div>
|
| 23 |
+
</section>
|
| 24 |
+
|
| 25 |
+
<aside class="trust-note" aria-label="Privacy note">
|
| 26 |
+
<span class="trust-icon" aria-hidden="true">✓</span>
|
| 27 |
+
<div><strong>Private by design</strong><p>Document frames are analyzed only to identify terminology. FalconScan does not store the captured image.</p></div>
|
| 28 |
+
<span class="trust-label">ON-DEVICE FIRST</span>
|
| 29 |
+
</aside>
|
| 30 |
+
|
| 31 |
+
<section id="scannerShell" class="scanner-shell details-closed">
|
| 32 |
+
<div id="viewport" class="viewport">
|
| 33 |
+
<video id="video" autoplay muted playsinline></video>
|
| 34 |
+
<div id="documentScroller" class="document-scroller" hidden>
|
| 35 |
+
<div id="documentStage" class="document-stage">
|
| 36 |
+
<img id="uploadedPreview" class="uploaded-preview" alt="Uploaded customs document preview">
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
<canvas id="captureCanvas" hidden></canvas>
|
| 40 |
+
<div id="overlay" class="overlay"></div>
|
| 41 |
+
<div class="corner tl"></div><div class="corner tr"></div><div class="corner bl"></div><div class="corner br"></div>
|
| 42 |
+
<div id="emptyState" class="empty-state"><div class="lens">◎</div><strong>Camera is ready when you are</strong><span>Position a customs document inside the frame</span><button id="startCamera" class="primary">Start camera</button></div>
|
| 43 |
+
<div id="scanLine" class="scan-line"></div>
|
| 44 |
+
<div id="statusPill" class="status-pill"><i></i><span>Camera off</span></div>
|
| 45 |
+
<div id="qualityPill" class="quality-pill">Waiting for camera</div>
|
| 46 |
+
<div id="selectionHint" class="selection-hint" hidden>Select or highlight document text for a live business insight</div>
|
| 47 |
+
<button id="documentInfo" class="document-info" type="button" aria-label="Summarize this document" hidden><span aria-hidden="true">i</span><b>Document info</b></button>
|
| 48 |
+
<button id="detailsToggle" class="details-toggle" aria-expanded="false" aria-controls="scanDetails"><span>Scan details</span><i aria-hidden="true">⌃</i></button>
|
| 49 |
+
</div>
|
| 50 |
+
<aside id="scanDetails" class="control-panel" aria-hidden="true">
|
| 51 |
+
<button id="detailsClose" class="details-close" aria-label="Close scan details">×</button>
|
| 52 |
+
<div class="panel-heading"><div><p class="eyebrow">SCAN STATUS</p><h2 id="resultTitle">Ready when you are</h2></div></div>
|
| 53 |
+
<div id="statusMessage" class="status-message">Use the camera or upload a document. Hold camera documents steady for automatic capture.</div>
|
| 54 |
+
<div id="termsList" class="terms-list"><div class="instruction-card"><strong>How it works</strong><p>Scan or upload, then tap any dot on the document for a clear business explanation.</p></div></div>
|
| 55 |
+
<div class="upload-card">
|
| 56 |
+
<div><strong>Have a digital document?</strong><span>Upload JPG, PNG, WebP, PDF, or Word DOCX.</span></div>
|
| 57 |
+
<button id="uploadButton" type="button">Upload document</button>
|
| 58 |
+
<input id="documentUpload" type="file" accept="image/jpeg,image/png,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.docx" hidden>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="panel-actions"><button id="captureButton" disabled>Scan now</button><button id="vlmButton" class="secondary" disabled>Analyze full document</button></div>
|
| 61 |
+
</aside>
|
| 62 |
+
</section>
|
| 63 |
+
|
| 64 |
+
<section class="trust-row"><div><b>EN + AR</b><span>Bilingual OCR</span></div><div><b>LOCAL FIRST</b><span>Glossary lookup</span></div><div><b>HUMAN LED</b><span>SME-approved knowledge</span></div><div><b>CPU READY</b><span>Built for free Spaces</span></div></section>
|
| 65 |
+
</main>
|
| 66 |
+
|
| 67 |
+
<div id="definitionModal" class="modal" aria-hidden="true"><div class="definition-card" role="dialog" aria-modal="true"><button class="close" data-close="definitionModal">×</button><p class="eyebrow" id="definitionCategory">CUSTOMS TERM</p><h2 id="definitionTerm"></h2><p id="definitionFullForm" class="full-form"></p><p id="definitionText" class="definition-text"></p><div class="definition-meta"><span id="definitionSource"></span><span id="definitionConfidence"></span></div><div class="related" id="relatedTerms"></div><div class="feedback-row"><span>Was this useful?</span><button id="thumbUp" aria-label="Helpful">↑</button><button id="thumbDown" aria-label="Incorrect">↓</button></div><form id="correctionForm" hidden><label>Suggest a clearer, correct definition<textarea id="correctionText" required maxlength="2000"></textarea></label><button class="primary" type="submit">Save for review</button></form><p id="feedbackMessage" class="feedback-message"></p></div></div>
|
| 68 |
+
|
| 69 |
+
<div id="adminModal" class="modal" aria-hidden="true"><div class="admin-card" role="dialog" aria-modal="true"><button class="close" data-close="adminModal">×</button><p class="eyebrow">KNOWLEDGE GOVERNANCE</p><h2>SME correction review</h2><p class="muted">Approve suggestions to make them the official definition.</p><div id="adminList" class="admin-list"></div></div></div>
|
| 70 |
+
<div id="insightModal" class="modal" aria-hidden="true"><div class="insight-card" role="dialog" aria-modal="true" aria-labelledby="insightTitle"><button id="insightClose" class="close" aria-label="Close insight">×</button><p class="eyebrow">LIVE DOCUMENT INSIGHT</p><h2 id="insightTitle">Selection insight</h2><blockquote id="insightSelection"></blockquote><section><small>SUMMARY</small><p id="insightSummary"></p></section><section><small>BUSINESS MEANING</small><p id="insightBusiness"></p></section><div id="insightTerms" class="insight-terms"></div><div class="definition-meta"><span id="insightSource"></span><span id="insightConfidence"></span></div></div></div>
|
| 71 |
+
<div id="toast" class="toast"></div>
|
| 72 |
+
<script src="/static/insights.js"></script><script src="/static/overlay.js"></script><script src="/static/feedback.js"></script><script src="/static/camera.js"></script>
|
| 73 |
+
</body>
|
| 74 |
+
</html>
|
frontend/insights.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
window.FalconInsights = (() => {
|
| 2 |
+
const $ = (selector) => document.querySelector(selector);
|
| 3 |
+
let pending = false;
|
| 4 |
+
|
| 5 |
+
function init() {
|
| 6 |
+
$("#insightClose").onclick = close;
|
| 7 |
+
$("#insightModal").addEventListener("click", (event) => {
|
| 8 |
+
if (event.target === $("#insightModal")) close();
|
| 9 |
+
});
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
async function explain(text) {
|
| 13 |
+
const selected = String(text || "").trim();
|
| 14 |
+
if (!selected || pending) return;
|
| 15 |
+
pending = true;
|
| 16 |
+
openLoading(selected);
|
| 17 |
+
try {
|
| 18 |
+
const response = await fetch("/explain-selection", {
|
| 19 |
+
method: "POST",
|
| 20 |
+
headers: { "Content-Type": "application/json" },
|
| 21 |
+
body: JSON.stringify({ text: selected, language_preference: $("#language").value }),
|
| 22 |
+
});
|
| 23 |
+
const data = await response.json();
|
| 24 |
+
if (!response.ok) throw new Error(data.detail || "Insight unavailable");
|
| 25 |
+
$("#insightSummary").textContent = data.summary;
|
| 26 |
+
$("#insightBusiness").textContent = data.business_meaning;
|
| 27 |
+
$("#insightSource").textContent = data.source_label;
|
| 28 |
+
$("#insightConfidence").textContent = `${Math.round(data.confidence * 100)}% confidence`;
|
| 29 |
+
$("#insightTerms").innerHTML = (data.recognized_terms || []).map((item) => `<button type="button" data-term="${escapeHtml(item.term)}">${escapeHtml(item.term)}</button>`).join("");
|
| 30 |
+
$("#insightTerms").querySelectorAll("button").forEach((button) => {
|
| 31 |
+
button.onclick = () => {
|
| 32 |
+
const item = data.recognized_terms.find((term) => term.term === button.dataset.term);
|
| 33 |
+
if (item) window.FalconFeedback.open(item);
|
| 34 |
+
};
|
| 35 |
+
});
|
| 36 |
+
} catch (error) {
|
| 37 |
+
$("#insightSummary").textContent = error.message;
|
| 38 |
+
$("#insightBusiness").textContent = "Try selecting a shorter phrase or a clearly recognized paragraph.";
|
| 39 |
+
} finally {
|
| 40 |
+
pending = false;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function openLoading(text) {
|
| 45 |
+
$("#insightSelection").textContent = text;
|
| 46 |
+
$("#insightSummary").textContent = "Understanding your selection…";
|
| 47 |
+
$("#insightBusiness").textContent = "Finding the relevant trade and clearance context.";
|
| 48 |
+
$("#insightTerms").innerHTML = "";
|
| 49 |
+
$("#insightSource").textContent = "Analyzing";
|
| 50 |
+
$("#insightConfidence").textContent = "";
|
| 51 |
+
$("#insightModal").classList.add("open");
|
| 52 |
+
$("#insightModal").setAttribute("aria-hidden", "false");
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function close() {
|
| 56 |
+
$("#insightModal").classList.remove("open");
|
| 57 |
+
$("#insightModal").setAttribute("aria-hidden", "true");
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function escapeHtml(value) {
|
| 61 |
+
return String(value).replace(/[&<>'"]/g, (character) => ({
|
| 62 |
+
"&": "&", "<": "<", ">": ">", "'": "'", '"': """,
|
| 63 |
+
})[character]);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return { init, explain };
|
| 67 |
+
})();
|
frontend/overlay.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
window.FalconOverlay = (() => {
|
| 2 |
+
let terms = [];
|
| 3 |
+
let viewport;
|
| 4 |
+
let overlay;
|
| 5 |
+
let video;
|
| 6 |
+
let uploadedPreview;
|
| 7 |
+
let documentScroller;
|
| 8 |
+
let activePopover = null;
|
| 9 |
+
|
| 10 |
+
function init() {
|
| 11 |
+
viewport = document.querySelector("#viewport");
|
| 12 |
+
overlay = document.querySelector("#overlay");
|
| 13 |
+
video = document.querySelector("#video");
|
| 14 |
+
uploadedPreview = document.querySelector("#uploadedPreview");
|
| 15 |
+
documentScroller = document.querySelector("#documentScroller");
|
| 16 |
+
document.addEventListener("pointerdown", (event) => {
|
| 17 |
+
if (activePopover && !activePopover.contains(event.target) && !event.target.closest(".term-marker")) closePopover();
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function geometry(frameWidth, frameHeight) {
|
| 22 |
+
const documentMode = documentScroller && !documentScroller.hidden;
|
| 23 |
+
const media = documentMode ? uploadedPreview : video;
|
| 24 |
+
const mediaRect = media.getBoundingClientRect();
|
| 25 |
+
const viewRect = documentMode ? overlay.getBoundingClientRect() : viewport.getBoundingClientRect();
|
| 26 |
+
const scale = Math.min(mediaRect.width / frameWidth, mediaRect.height / frameHeight);
|
| 27 |
+
const drawnWidth = frameWidth * scale;
|
| 28 |
+
const drawnHeight = frameHeight * scale;
|
| 29 |
+
return {
|
| 30 |
+
viewRect,
|
| 31 |
+
scale,
|
| 32 |
+
offsetX: documentMode ? 0 : (viewRect.width - drawnWidth) / 2,
|
| 33 |
+
offsetY: documentMode ? 0 : (viewRect.height - drawnHeight) / 2,
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function render(items, frameWidth, frameHeight, textRegions = []) {
|
| 38 |
+
terms = items;
|
| 39 |
+
overlay.innerHTML = "";
|
| 40 |
+
activePopover = null;
|
| 41 |
+
const layout = geometry(frameWidth, frameHeight);
|
| 42 |
+
renderSelectionLayer(textRegions, layout);
|
| 43 |
+
renderMarkers(items, layout);
|
| 44 |
+
renderList(items);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function renderMarkers(items, layout) {
|
| 48 |
+
if (!items.length) return;
|
| 49 |
+
const visible = [...items].sort((a, b) => b.confidence - a.confidence).slice(0, 3);
|
| 50 |
+
const occupied = [];
|
| 51 |
+
|
| 52 |
+
visible.forEach((item, index) => {
|
| 53 |
+
const [x1, y1, x2, y2] = item.bbox;
|
| 54 |
+
let left = layout.offsetX + x2 * layout.scale + 7;
|
| 55 |
+
let top = layout.offsetY + ((y1 + y2) / 2) * layout.scale - 12;
|
| 56 |
+
if (left > layout.viewRect.width - 34) left = layout.offsetX + x1 * layout.scale - 31;
|
| 57 |
+
left = clamp(left, 7, layout.viewRect.width - 31);
|
| 58 |
+
top = clamp(top, 46, layout.viewRect.height - 38);
|
| 59 |
+
let attempts = 0;
|
| 60 |
+
while (occupied.some((point) => Math.hypot(point.left - left, point.top - top) < 31) && attempts < 12) {
|
| 61 |
+
top = clamp(top + 32, 46, layout.viewRect.height - 38);
|
| 62 |
+
if (top >= layout.viewRect.height - 39) left = clamp(left - 34, 7, layout.viewRect.width - 31);
|
| 63 |
+
attempts += 1;
|
| 64 |
+
}
|
| 65 |
+
occupied.push({ left, top });
|
| 66 |
+
|
| 67 |
+
const marker = document.createElement("button");
|
| 68 |
+
marker.className = "term-marker";
|
| 69 |
+
marker.type = "button";
|
| 70 |
+
marker.style.left = `${left}px`;
|
| 71 |
+
marker.style.top = `${top}px`;
|
| 72 |
+
marker.style.setProperty("--marker-delay", `${Math.min(index * 35, 350)}ms`);
|
| 73 |
+
marker.setAttribute("aria-label", `Explain ${item.term}`);
|
| 74 |
+
marker.innerHTML = `<span></span>`;
|
| 75 |
+
let dragged = false;
|
| 76 |
+
enableDrag(marker, layout, () => { dragged = true; });
|
| 77 |
+
marker.onclick = (event) => {
|
| 78 |
+
event.stopPropagation();
|
| 79 |
+
if (dragged) {
|
| 80 |
+
dragged = false;
|
| 81 |
+
return;
|
| 82 |
+
}
|
| 83 |
+
openPopover(item, marker, layout);
|
| 84 |
+
};
|
| 85 |
+
overlay.appendChild(marker);
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function enableDrag(marker, layout, onDrag) {
|
| 90 |
+
let start = null;
|
| 91 |
+
marker.addEventListener("pointerdown", (event) => {
|
| 92 |
+
start = {
|
| 93 |
+
x: event.clientX,
|
| 94 |
+
y: event.clientY,
|
| 95 |
+
left: parseFloat(marker.style.left),
|
| 96 |
+
top: parseFloat(marker.style.top),
|
| 97 |
+
moved: false,
|
| 98 |
+
};
|
| 99 |
+
marker.setPointerCapture?.(event.pointerId);
|
| 100 |
+
});
|
| 101 |
+
marker.addEventListener("pointermove", (event) => {
|
| 102 |
+
if (!start) return;
|
| 103 |
+
const dx = event.clientX - start.x;
|
| 104 |
+
const dy = event.clientY - start.y;
|
| 105 |
+
if (Math.hypot(dx, dy) > 3) {
|
| 106 |
+
start.moved = true;
|
| 107 |
+
onDrag();
|
| 108 |
+
}
|
| 109 |
+
marker.style.left = `${clamp(start.left + dx, 5, layout.viewRect.width - 23)}px`;
|
| 110 |
+
marker.style.top = `${clamp(start.top + dy, 5, layout.viewRect.height - 23)}px`;
|
| 111 |
+
});
|
| 112 |
+
const finish = () => { start = null; };
|
| 113 |
+
marker.addEventListener("pointerup", finish);
|
| 114 |
+
marker.addEventListener("pointercancel", finish);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function openPopover(item, marker, layout) {
|
| 118 |
+
closePopover();
|
| 119 |
+
marker.classList.add("active");
|
| 120 |
+
const popover = document.createElement("article");
|
| 121 |
+
popover.className = "term-popover";
|
| 122 |
+
const markerLeft = parseFloat(marker.style.left);
|
| 123 |
+
const markerTop = parseFloat(marker.style.top);
|
| 124 |
+
const width = Math.min(286, layout.viewRect.width - 24);
|
| 125 |
+
let left = markerLeft + 34;
|
| 126 |
+
if (left + width > layout.viewRect.width - 8) left = markerLeft - width - 8;
|
| 127 |
+
left = clamp(left, 8, layout.viewRect.width - width - 8);
|
| 128 |
+
const top = clamp(markerTop - 18, 50, layout.viewRect.height - 170);
|
| 129 |
+
popover.style.left = `${left}px`;
|
| 130 |
+
popover.style.top = `${top}px`;
|
| 131 |
+
popover.style.width = `${width}px`;
|
| 132 |
+
popover.innerHTML = `<div class="popover-heading"><span class="callout-dot"></span><span class="callout-line"></span><strong>${escapeHtml(item.term)}</strong><small>${Math.round(item.confidence * 100)}%</small></div><p>${escapeHtml(item.definition)}</p><button type="button">Open full meaning</button>`;
|
| 133 |
+
popover.querySelector("button").onclick = (event) => {
|
| 134 |
+
event.stopPropagation();
|
| 135 |
+
window.FalconFeedback.open(item);
|
| 136 |
+
};
|
| 137 |
+
popover.onclick = (event) => event.stopPropagation();
|
| 138 |
+
overlay.appendChild(popover);
|
| 139 |
+
activePopover = popover;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function closePopover() {
|
| 143 |
+
if (activePopover) activePopover.remove();
|
| 144 |
+
activePopover = null;
|
| 145 |
+
overlay?.querySelectorAll(".term-marker.active").forEach((marker) => marker.classList.remove("active"));
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function renderSelectionLayer(regions, layout) {
|
| 149 |
+
if (!regions.length) return;
|
| 150 |
+
const layer = document.createElement("div");
|
| 151 |
+
layer.className = "selection-layer";
|
| 152 |
+
regions.slice(0, 80).forEach((region) => {
|
| 153 |
+
if (!region.text || !region.bbox) return;
|
| 154 |
+
const [x1, y1, x2, y2] = region.bbox;
|
| 155 |
+
const span = document.createElement("span");
|
| 156 |
+
span.className = "selectable-region";
|
| 157 |
+
span.textContent = region.text;
|
| 158 |
+
span.style.left = `${layout.offsetX + x1 * layout.scale}px`;
|
| 159 |
+
span.style.top = `${layout.offsetY + y1 * layout.scale}px`;
|
| 160 |
+
span.style.width = `${Math.max(8, (x2 - x1) * layout.scale)}px`;
|
| 161 |
+
span.style.height = `${Math.max(12, (y2 - y1) * layout.scale)}px`;
|
| 162 |
+
span.style.fontSize = `${clamp((y2 - y1) * layout.scale * 0.72, 8, 24)}px`;
|
| 163 |
+
layer.appendChild(span);
|
| 164 |
+
});
|
| 165 |
+
const explainSelection = () => {
|
| 166 |
+
requestAnimationFrame(() => {
|
| 167 |
+
const selection = window.getSelection();
|
| 168 |
+
if (!selection || selection.isCollapsed || !layer.contains(selection.anchorNode)) return;
|
| 169 |
+
const text = selection.toString().trim();
|
| 170 |
+
if (text) window.FalconInsights.explain(text);
|
| 171 |
+
});
|
| 172 |
+
};
|
| 173 |
+
layer.addEventListener("mouseup", explainSelection);
|
| 174 |
+
layer.addEventListener("touchend", explainSelection);
|
| 175 |
+
overlay.appendChild(layer);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function renderList(items) {
|
| 179 |
+
const list = document.querySelector("#termsList");
|
| 180 |
+
list.innerHTML = items.length
|
| 181 |
+
? '<div class="instruction-card"><strong>Insights are on the document</strong><p>Tap a dot for meaning, drag it to reveal text, or select multiple words for business context.</p></div>'
|
| 182 |
+
: '<div class="empty-result">No readable text was detected. Try a sharper image or another page.</div>';
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function clear() {
|
| 186 |
+
terms = [];
|
| 187 |
+
activePopover = null;
|
| 188 |
+
if (overlay) overlay.innerHTML = "";
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function clamp(value, minimum, maximum) {
|
| 192 |
+
return Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function escapeHtml(value) {
|
| 196 |
+
return String(value).replace(/[&<>'"]/g, (character) => ({
|
| 197 |
+
"&": "&", "<": "<", ">": ">", "'": "'", '"': """,
|
| 198 |
+
})[character]);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
return { init, render, clear };
|
| 202 |
+
})();
|
frontend/styles.css
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--ink: #14201d;
|
| 3 |
+
--muted: #64706c;
|
| 4 |
+
--paper: #f5f5f2;
|
| 5 |
+
--white: #fff;
|
| 6 |
+
--green: #087a5b;
|
| 7 |
+
--lime: #c9f257;
|
| 8 |
+
--line: #dfe3df;
|
| 9 |
+
--dark: #081c18;
|
| 10 |
+
--glass: rgba(255, 255, 255, .78);
|
| 11 |
+
--shadow: 0 22px 70px rgba(20, 38, 33, .12);
|
| 12 |
+
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
* { box-sizing: border-box; }
|
| 16 |
+
html { scroll-behavior: smooth; }
|
| 17 |
+
body { margin: 0; min-width: 320px; background: radial-gradient(circle at 50% -10%, #fff 0, var(--paper) 48%); color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
|
| 18 |
+
button, select, textarea { font: inherit; }
|
| 19 |
+
button { cursor: pointer; }
|
| 20 |
+
|
| 21 |
+
/* Mobile is the base layout. Larger screens progressively enhance it. */
|
| 22 |
+
header {
|
| 23 |
+
position: sticky; top: 0; z-index: 10;
|
| 24 |
+
min-height: 64px; padding: 10px 14px;
|
| 25 |
+
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
| 26 |
+
border-bottom: 1px solid rgba(20, 35, 31, .08); background: rgba(247, 247, 244, .82);
|
| 27 |
+
backdrop-filter: saturate(180%) blur(20px);
|
| 28 |
+
}
|
| 29 |
+
.brand { display: flex; align-items: center; gap: 9px; min-width: 0; text-decoration: none; color: var(--ink); font-weight: 800; font-size: 16px; }
|
| 30 |
+
.brand small { display: none; font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); margin-top: 2px; }
|
| 31 |
+
.mark { display: grid; place-items: center; flex: 0 0 auto; width: 36px; height: 36px; background: var(--dark); color: var(--lime); border-radius: 10px; font-family: Georgia, serif; font-style: italic; font-size: 24px; }
|
| 32 |
+
.header-actions { display: flex; align-items: center; gap: 6px; }
|
| 33 |
+
.header-actions select, .ghost { min-height: 38px; border: 1px solid rgba(20, 35, 31, .1); background: rgba(255,255,255,.64); border-radius: 11px; padding: 7px 9px; color: var(--ink); }
|
| 34 |
+
.ghost { width: 42px; overflow: hidden; white-space: nowrap; font-size: 0; }
|
| 35 |
+
.ghost::before { content: "SME"; font-size: 10px; font-weight: 800; }
|
| 36 |
+
.count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; margin-left: 2px; padding: 0 5px; background: var(--lime); border-radius: 99px; font-size: 10px; color: var(--dark); }
|
| 37 |
+
.count[hidden] { display: none; }
|
| 38 |
+
|
| 39 |
+
main { width: 100%; max-width: 1320px; margin: auto; padding: 30px 14px calc(64px + var(--safe-bottom)); }
|
| 40 |
+
.intro { margin-bottom: 18px; }
|
| 41 |
+
.intro h1 { max-width: 10ch; margin: 2px 0 12px; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, sans-serif; font-weight: 680; font-size: clamp(39px, 12vw, 64px); line-height: .98; letter-spacing: -.055em; }
|
| 42 |
+
.intro > div > p:last-child { max-width: 680px; margin: 0; color: var(--muted); line-height: 1.55; font-size: 14px; }
|
| 43 |
+
.eyebrow { margin: 0 0 8px; color: var(--green); font-size: 10px; letter-spacing: .18em; font-weight: 800; }
|
| 44 |
+
.trust-note { display: grid; grid-template-columns: 34px 1fr; gap: 11px; align-items: center; margin: 18px 0; padding: 13px 14px; border: 1px solid rgba(8,122,91,.12); background: rgba(232,247,240,.66); border-radius: 15px; }
|
| 45 |
+
.trust-icon { display: grid; place-items: center; width: 34px; height: 34px; background: #d7f4e8; color: var(--green); border-radius: 50%; font-weight: 800; }
|
| 46 |
+
.trust-note strong { display: block; font-size: 13px; }
|
| 47 |
+
.trust-note p { margin: 2px 0 0; color: #53645f; font-size: 11px; line-height: 1.45; }
|
| 48 |
+
.trust-label { display: none; color: var(--green); font-size: 9px; font-weight: 800; letter-spacing: .13em; }
|
| 49 |
+
|
| 50 |
+
.scanner-shell { display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: var(--white); border: 1px solid rgba(20,35,31,.06); border-radius: 20px; box-shadow: var(--shadow); transition: grid-template-columns .42s cubic-bezier(.22,1,.36,1); }
|
| 51 |
+
.viewport { position: relative; min-height: min(62svh, 560px); background: radial-gradient(circle at center, #23423a, #071a18 72%); overflow: hidden; }
|
| 52 |
+
.viewport video { position: absolute; width: 100%; height: 100%; object-fit: contain; }
|
| 53 |
+
.document-scroller { position: absolute; inset: 0; z-index: 1; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-color: #71827c #162a25; scrollbar-width: auto; background: #152622; }
|
| 54 |
+
.document-scroller[hidden] { display: none; }
|
| 55 |
+
.document-scroller::-webkit-scrollbar { width: 11px; height: 11px; }
|
| 56 |
+
.document-scroller::-webkit-scrollbar-track { background: #162a25; }
|
| 57 |
+
.document-scroller::-webkit-scrollbar-thumb { border: 3px solid #162a25; background: #71827c; border-radius: 99px; }
|
| 58 |
+
.document-stage { position: relative; width: 100%; min-height: 100%; background: #fff; }
|
| 59 |
+
.uploaded-preview { display: block; width: 100%; height: auto; min-height: 100%; object-fit: contain; object-position: top center; background: #fff; }
|
| 60 |
+
.document-stage > .overlay { position: absolute; inset: 0; width: 100%; height: 100%; }
|
| 61 |
+
.overlay { position: absolute; inset: 0; pointer-events: none; }
|
| 62 |
+
.term-marker { position: absolute; z-index: 4; display: grid; place-items: center; width: 18px; height: 18px; border: 1.5px solid rgba(255,255,255,.9); padding: 0; background: rgba(8,28,24,.9); border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,.26); pointer-events: auto; cursor: grab; touch-action: none; animation: marker-in .35s var(--marker-delay, 0ms) both cubic-bezier(.22,1,.36,1); }
|
| 63 |
+
.term-marker:active { cursor: grabbing; }
|
| 64 |
+
.term-marker span { width: 6px; height: 6px; background: var(--lime); border-radius: 50%; box-shadow: 0 0 0 3px rgba(201,242,87,.16); }
|
| 65 |
+
.term-marker:hover, .term-marker:focus-visible, .term-marker.active { border-color: var(--lime); transform: scale(1.1); }
|
| 66 |
+
@keyframes marker-in { from { opacity: 0; transform: scale(.4); } }
|
| 67 |
+
.term-popover { position: absolute; z-index: 6; border: 1px solid rgba(255,255,255,.16); padding: 13px; background: rgba(8,28,24,.94); color: white; border-radius: 14px; box-shadow: 0 18px 48px rgba(0,0,0,.38); backdrop-filter: saturate(160%) blur(18px); pointer-events: auto; animation: popover-in .22s cubic-bezier(.22,1,.36,1); }
|
| 68 |
+
@keyframes popover-in { from { opacity: 0; transform: translateY(7px) scale(.97); } }
|
| 69 |
+
.popover-heading { display: grid; grid-template-columns: 9px 1px minmax(0,1fr) auto; align-items: center; gap: 8px; }
|
| 70 |
+
.callout-dot { width: 9px; height: 9px; background: var(--lime); border-radius: 50%; box-shadow: 0 0 0 3px rgba(201,242,87,.16); }
|
| 71 |
+
.callout-line { align-self: stretch; width: 1px; min-height: 20px; background: rgba(255,255,255,.22); }
|
| 72 |
+
.popover-heading strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
| 73 |
+
.popover-heading small { color: #c4cfcb; font-size: 9px; font-variant-numeric: tabular-nums; }
|
| 74 |
+
.term-popover p { margin: 11px 0; color: #d8e1de; font-size: 11px; line-height: 1.5; }
|
| 75 |
+
.term-popover button { width: 100%; min-height: 36px; border: 0; background: var(--lime); color: var(--dark); border-radius: 8px; font-size: 10px; font-weight: 750; }
|
| 76 |
+
.selection-hint { position: absolute; z-index: 3; left: 50%; bottom: 66px; transform: translateX(-50%); max-width: calc(100% - 32px); padding: 7px 10px; background: rgba(255,255,255,.88); color: var(--dark); border-radius: 99px; backdrop-filter: blur(12px); font-size: 9px; font-weight: 650; text-align: center; white-space: nowrap; }
|
| 77 |
+
.selection-hint[hidden] { display: none; }
|
| 78 |
+
.document-info { position: absolute; z-index: 5; left: 14px; bottom: 14px; display: flex; align-items: center; gap: 7px; min-height: 40px; border: 1px solid #ffffff24; padding: 7px 11px 7px 7px; background: rgba(8,28,24,.82); color: white; border-radius: 99px; backdrop-filter: blur(16px); pointer-events: auto; }
|
| 79 |
+
.document-info[hidden] { display: none; }
|
| 80 |
+
.document-info span { display: grid; place-items: center; width: 25px; height: 25px; background: var(--lime); color: var(--dark); border-radius: 50%; font-family: Georgia, serif; font-weight: 800; }
|
| 81 |
+
.document-info b { display: none; font-size: 10px; }
|
| 82 |
+
.selection-layer { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
|
| 83 |
+
.selectable-region { position: absolute; display: block; overflow: hidden; color: transparent; line-height: 1; white-space: pre-wrap; user-select: text; -webkit-user-select: text; pointer-events: auto; cursor: text; }
|
| 84 |
+
.selectable-region::selection { background: rgba(201,242,87,.72); color: rgba(8,28,24,.9); }
|
| 85 |
+
@keyframes pop { from { scale: .2; opacity: 0; } }
|
| 86 |
+
.corner { position: absolute; width: 34px; height: 34px; border-color: var(--lime); border-style: solid; opacity: .8; }
|
| 87 |
+
.tl { top: 28px; left: 20px; border-width: 2px 0 0 2px; }
|
| 88 |
+
.tr { top: 28px; right: 20px; border-width: 2px 2px 0 0; }
|
| 89 |
+
.bl { bottom: 28px; left: 20px; border-width: 0 0 2px 2px; }
|
| 90 |
+
.br { right: 20px; bottom: 28px; border-width: 0 2px 2px 0; }
|
| 91 |
+
.empty-state { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; color: white; text-align: center; background: linear-gradient(130deg, #102c27dd, #071a18ee); }
|
| 92 |
+
.empty-state.hidden { display: none; }
|
| 93 |
+
.empty-state span { max-width: 250px; color: #a8b7b2; font-size: 14px; }
|
| 94 |
+
.lens { margin-bottom: 8px; color: var(--lime); font-size: 50px; }
|
| 95 |
+
.primary, .panel-actions button { min-height: 48px; border: 0; border-radius: 12px; padding: 12px 18px; background: var(--lime); color: var(--dark); font-weight: 720; box-shadow: 0 8px 20px #8aaa3030; transition: transform .18s ease, filter .18s ease; }
|
| 96 |
+
.primary:hover, .panel-actions button:hover:not(:disabled) { filter: brightness(1.025); transform: translateY(-1px); }
|
| 97 |
+
.empty-state .primary { margin-top: 14px; }
|
| 98 |
+
.scan-line { display: none; position: absolute; left: 7%; right: 7%; top: 50%; height: 1px; background: var(--lime); box-shadow: 0 0 20px var(--lime); animation: scan 2.4s ease-in-out infinite; }
|
| 99 |
+
.viewport.active .scan-line { display: block; }
|
| 100 |
+
@keyframes scan { 0%, 100% { top: 12%; opacity: .1; } 50% { top: 88%; opacity: .9; } }
|
| 101 |
+
.status-pill, .quality-pill { position: absolute; top: 14px; max-width: 46%; overflow: hidden; border: 1px solid #ffffff22; background: #071a18bb; color: #fff; backdrop-filter: blur(10px); border-radius: 99px; padding: 7px 10px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
| 102 |
+
.status-pill { left: 12px; }
|
| 103 |
+
.quality-pill { right: 12px; }
|
| 104 |
+
.status-pill i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; background: #80908b; border-radius: 50%; }
|
| 105 |
+
.status-pill.live i { background: var(--lime); box-shadow: 0 0 8px var(--lime); }
|
| 106 |
+
|
| 107 |
+
.details-toggle { position: absolute; z-index: 3; left: 50%; bottom: 14px; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; min-height: 42px; border: 1px solid #ffffff24; padding: 8px 11px 8px 14px; background: rgba(8,28,24,.76); color: white; border-radius: 99px; backdrop-filter: saturate(160%) blur(16px); box-shadow: 0 8px 30px #0003; font-size: 11px; font-weight: 650; }
|
| 108 |
+
.details-toggle i { font-style: normal; transition: transform .3s ease; }
|
| 109 |
+
.details-open .details-toggle i { transform: rotate(180deg); }
|
| 110 |
+
|
| 111 |
+
.control-panel { position: relative; display: flex; flex-direction: column; max-height: 0; overflow: hidden; padding: 0 18px; opacity: 0; transform: translateY(18px); transition: max-height .45s cubic-bezier(.22,1,.36,1), opacity .25s ease, transform .4s cubic-bezier(.22,1,.36,1), padding .4s ease; }
|
| 112 |
+
.details-open .control-panel { max-height: 740px; padding: 26px 18px calc(20px + var(--safe-bottom)); opacity: 1; transform: translateY(0); }
|
| 113 |
+
.details-close { position: absolute; top: 15px; right: 14px; z-index: 2; display: grid; place-items: center; width: 32px; height: 32px; border: 0; background: #f0f2ef; color: var(--muted); border-radius: 50%; font-size: 21px; line-height: 1; }
|
| 114 |
+
.panel-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); padding: 0 38px 14px 0; }
|
| 115 |
+
.panel-heading h2 { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, sans-serif; font-size: 24px; font-weight: 680; letter-spacing: -.035em; }
|
| 116 |
+
.panel-heading > span { flex: 0 0 auto; padding: 6px 9px; background: #eef0e8; border-radius: 99px; font-size: 11px; }
|
| 117 |
+
.status-message { padding: 14px 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
| 118 |
+
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
|
| 119 |
+
.metrics > div { min-width: 0; background: #f3f4ef; padding: 10px 8px; border-radius: 9px; }
|
| 120 |
+
.metrics small, .metrics strong { display: block; overflow: hidden; text-overflow: ellipsis; }
|
| 121 |
+
.metrics small { color: var(--muted); font-size: 8px; text-transform: uppercase; letter-spacing: .08em; }
|
| 122 |
+
.metrics strong { margin-top: 5px; font-size: 11px; white-space: nowrap; }
|
| 123 |
+
.terms-list { max-height: 230px; overflow: auto; padding: 14px 0; }
|
| 124 |
+
.hint-card { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 13px; }
|
| 125 |
+
.hint-card span { color: var(--green); font-family: Georgia, serif; font-size: 22px; }
|
| 126 |
+
.instruction-card { padding: 13px 14px; background: #f3f5f1; border-radius: 12px; }
|
| 127 |
+
.instruction-card strong { font-size: 12px; }
|
| 128 |
+
.instruction-card p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
| 129 |
+
.analysis-progress { display: flex; align-items: center; gap: 10px; padding: 14px; background: #f3f5f1; border-radius: 12px; color: var(--muted); font-size: 11px; }
|
| 130 |
+
.analysis-progress i { width: 16px; height: 16px; border: 2px solid #d9dfdb; border-top-color: var(--green); border-radius: 50%; animation: progress-spin .8s linear infinite; }
|
| 131 |
+
@keyframes progress-spin { to { transform: rotate(360deg); } }
|
| 132 |
+
.empty-result { padding: 14px; border: 1px dashed #d5dbd7; color: var(--muted); border-radius: 12px; font-size: 11px; line-height: 1.5; }
|
| 133 |
+
.upload-card { display: grid; gap: 10px; margin: 4px 0 14px; padding: 13px; border: 1px solid var(--line); background: #f6f7f4; border-radius: 12px; }
|
| 134 |
+
.upload-card strong, .upload-card span { display: block; }
|
| 135 |
+
.upload-card strong { font-size: 12px; }
|
| 136 |
+
.upload-card span { margin-top: 3px; color: var(--muted); font-size: 10px; line-height: 1.4; }
|
| 137 |
+
.upload-card button { min-height: 40px; border: 1px solid #ced5d1; background: white; color: var(--ink); border-radius: 9px; font-size: 11px; font-weight: 700; }
|
| 138 |
+
.upload-card button:hover { border-color: var(--green); color: var(--green); }
|
| 139 |
+
.panel-actions { display: grid; gap: 8px; }
|
| 140 |
+
.panel-actions button:disabled { opacity: .4; cursor: not-allowed; }
|
| 141 |
+
.panel-actions .secondary { background: var(--dark); color: white; }
|
| 142 |
+
|
| 143 |
+
.trust-row { display: grid; grid-template-columns: 1fr 1fr; margin-top: 18px; overflow: hidden; border: 1px solid rgba(20,35,31,.08); background: var(--glass); border-radius: 16px; box-shadow: 0 12px 40px rgba(20,38,33,.05); }
|
| 144 |
+
.trust-row div { display: flex; flex-direction: column; padding: 15px; border-right: 1px solid #d8d8cf; border-bottom: 1px solid #d8d8cf; }
|
| 145 |
+
.trust-row div:nth-child(2n) { border-right: 0; }
|
| 146 |
+
.trust-row div:nth-last-child(-n+2) { border-bottom: 0; }
|
| 147 |
+
.trust-row b { color: var(--green); font-size: 9px; letter-spacing: .1em; }
|
| 148 |
+
.trust-row span { margin-top: 4px; color: var(--muted); font-size: 11px; }
|
| 149 |
+
|
| 150 |
+
.modal { position: fixed; inset: 0; z-index: 20; display: none; place-items: end center; padding: 12px 12px calc(12px + var(--safe-bottom)); background: #071a18aa; backdrop-filter: blur(5px); }
|
| 151 |
+
.modal.open { display: grid; }
|
| 152 |
+
.definition-card, .admin-card, .insight-card { position: relative; width: 100%; max-height: 88svh; overflow: auto; background: white; border-radius: 20px 20px 14px 14px; padding: 28px 20px; box-shadow: 0 30px 100px #0006; }
|
| 153 |
+
.close { position: absolute; top: 10px; right: 14px; border: 0; background: transparent; color: var(--muted); font-size: 27px; }
|
| 154 |
+
.definition-card h2, .admin-card h2, .insight-card h2 { margin: 0 0 2px; font-family: Georgia, serif; font-size: 32px; }
|
| 155 |
+
.full-form { margin: 0; color: var(--green); font-weight: 700; }
|
| 156 |
+
.definition-text { font-family: Georgia, serif; font-size: 20px; line-height: 1.45; }
|
| 157 |
+
.definition-meta { display: grid; gap: 5px; padding: 10px; background: #f0f3ec; border-radius: 9px; font-size: 11px; }
|
| 158 |
+
.related { padding: 12px 0; color: var(--muted); font-size: 11px; }
|
| 159 |
+
.feedback-row { display: flex; align-items: center; gap: 7px; border-top: 1px solid var(--line); padding-top: 16px; }
|
| 160 |
+
.feedback-row span { margin-right: auto; font-size: 12px; }
|
| 161 |
+
.feedback-row button { width: 42px; height: 40px; border: 1px solid var(--line); border-radius: 8px; background: white; }
|
| 162 |
+
textarea { width: 100%; min-height: 90px; margin: 7px 0 9px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; }
|
| 163 |
+
form label { font-size: 12px; font-weight: 700; }
|
| 164 |
+
#correctionForm { margin-top: 15px; }
|
| 165 |
+
.feedback-message { color: var(--green); font-size: 12px; }
|
| 166 |
+
.insight-card blockquote { margin: 16px 0; padding: 12px 14px; background: #f2f4f0; color: var(--muted); border-left: 3px solid var(--lime); border-radius: 4px 10px 10px 4px; font-size: 12px; line-height: 1.45; }
|
| 167 |
+
.insight-card section { padding: 13px 0; border-top: 1px solid var(--line); }
|
| 168 |
+
.insight-card section small { color: var(--green); font-size: 9px; font-weight: 800; letter-spacing: .14em; }
|
| 169 |
+
.insight-card section p { margin: 7px 0 0; color: var(--ink); font-size: 14px; line-height: 1.6; }
|
| 170 |
+
.insight-terms { display: flex; flex-wrap: wrap; gap: 6px; margin: 5px 0 16px; }
|
| 171 |
+
.insight-terms button { border: 1px solid #ced8d3; padding: 6px 9px; background: #f7faf7; color: var(--green); border-radius: 99px; font-size: 10px; font-weight: 700; }
|
| 172 |
+
.admin-item { margin-top: 12px; padding: 16px; border: 1px solid var(--line); border-radius: 12px; }
|
| 173 |
+
.admin-item h3 { margin: 0; }
|
| 174 |
+
.admin-item p { font-size: 13px; }
|
| 175 |
+
.admin-actions { display: flex; gap: 8px; margin-top: 12px; }
|
| 176 |
+
.admin-actions button { min-height: 40px; border: 0; border-radius: 7px; padding: 8px 12px; }
|
| 177 |
+
.approve { background: var(--lime); }
|
| 178 |
+
.reject { background: #f1dddd; }
|
| 179 |
+
.muted { color: var(--muted); }
|
| 180 |
+
.toast { position: fixed; left: 14px; right: 14px; bottom: calc(16px + var(--safe-bottom)); z-index: 30; transform: translateY(20px); opacity: 0; background: var(--dark); color: white; padding: 11px 16px; border-radius: 9px; transition: .2s; text-align: center; }
|
| 181 |
+
.toast.show { opacity: 1; transform: translateY(0); }
|
| 182 |
+
[dir=rtl] .definition-card { text-align: right; }
|
| 183 |
+
|
| 184 |
+
@media (min-width: 600px) {
|
| 185 |
+
header { min-height: 72px; padding: 10px 24px; }
|
| 186 |
+
.brand { font-size: 18px; }
|
| 187 |
+
.brand small { display: block; }
|
| 188 |
+
.ghost { width: auto; font-size: 12px; }
|
| 189 |
+
.ghost::before { content: none; }
|
| 190 |
+
main { padding: 36px 24px 72px; }
|
| 191 |
+
.viewport { min-height: 560px; }
|
| 192 |
+
.control-panel { padding: 26px; }
|
| 193 |
+
.panel-actions { grid-template-columns: 1fr 1fr; }
|
| 194 |
+
.modal { place-items: center; padding: 20px; }
|
| 195 |
+
.definition-card { width: min(500px, 100%); border-radius: 18px; padding: 30px; }
|
| 196 |
+
.admin-card { width: min(800px, 100%); border-radius: 18px; padding: 30px; }
|
| 197 |
+
.insight-card { width: min(600px, 100%); border-radius: 18px; padding: 30px; }
|
| 198 |
+
.definition-meta { display: flex; justify-content: space-between; }
|
| 199 |
+
.document-info b { display: block; }
|
| 200 |
+
.toast { left: 50%; right: auto; width: max-content; max-width: 90vw; transform: translate(-50%, 20px); }
|
| 201 |
+
.toast.show { transform: translate(-50%, 0); }
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
@media (min-width: 900px) {
|
| 205 |
+
header { position: static; height: 76px; padding-inline: max(24px, calc((100vw - 1320px) / 2)); }
|
| 206 |
+
.intro { display: flex; justify-content: space-between; align-items: end; margin-bottom: 26px; }
|
| 207 |
+
.intro h1 { max-width: none; }
|
| 208 |
+
.intro > div > p:last-child { font-size: 16px; }
|
| 209 |
+
.trust-note { grid-template-columns: 38px 1fr auto; padding: 14px 17px; }
|
| 210 |
+
.trust-label { display: block; }
|
| 211 |
+
.scanner-shell { display: grid; grid-template-columns: minmax(0, 1fr) 0; min-height: 590px; border-radius: 24px; }
|
| 212 |
+
.scanner-shell.details-open { grid-template-columns: minmax(0, 1.65fr) minmax(330px, .7fr); }
|
| 213 |
+
.viewport { min-height: 590px; }
|
| 214 |
+
.control-panel { max-height: none; min-width: 0; height: 590px; padding: 28px; opacity: 1; transform: translateX(24px); transition: opacity .22s ease, transform .42s cubic-bezier(.22,1,.36,1); }
|
| 215 |
+
.details-closed .control-panel { visibility: hidden; padding-inline: 0; opacity: 0; pointer-events: none; }
|
| 216 |
+
.details-open .control-panel { max-height: none; padding: 28px; opacity: 1; transform: translateX(0); }
|
| 217 |
+
.details-toggle { left: auto; right: 18px; bottom: 18px; transform: none; }
|
| 218 |
+
.details-open .details-toggle { right: 18px; }
|
| 219 |
+
.panel-actions { grid-template-columns: 1fr; }
|
| 220 |
+
.terms-list { flex: 1; max-height: 300px; }
|
| 221 |
+
.trust-row { grid-template-columns: repeat(4, 1fr); margin-top: 20px; }
|
| 222 |
+
.trust-row div, .trust-row div:nth-child(2n) { border-right: 1px solid #d8d8cf; border-bottom: 0; padding: 18px 24px; }
|
| 223 |
+
.trust-row div:last-child { border-right: 0; }
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
@media (prefers-reduced-motion: reduce) {
|
| 227 |
+
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
| 228 |
+
}
|
pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
pythonpath = .
|
| 3 |
+
testpaths = tests
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements-lite.txt
|
| 2 |
+
pytest==8.4.0
|
| 3 |
+
httpx==0.28.1
|
requirements-lite.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.12
|
| 2 |
+
uvicorn[standard]==0.34.3
|
| 3 |
+
pydantic==2.11.7
|
| 4 |
+
pillow==11.2.1
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
PyMuPDF==1.25.5
|
| 7 |
+
opencv-python==4.10.0.84
|
| 8 |
+
rapidocr-onnxruntime==1.4.4
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.12
|
| 2 |
+
uvicorn[standard]==0.34.3
|
| 3 |
+
pydantic==2.11.7
|
| 4 |
+
pillow==11.2.1
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
paddlepaddle==3.0.0
|
| 7 |
+
paddleocr==2.10.0
|
| 8 |
+
python-multipart==0.0.20
|
| 9 |
+
PyMuPDF==1.25.5
|
| 10 |
+
opencv-python==4.10.0.84
|
| 11 |
+
rapidocr-onnxruntime==1.4.4
|
tests/test_services.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import base64
|
| 3 |
+
import io
|
| 4 |
+
import zipfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
from app import app
|
| 10 |
+
from backend.glossary_service import GlossaryService
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).parents[1]
|
| 14 |
+
client = TestClient(app)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_health():
|
| 18 |
+
response = client.get("/health")
|
| 19 |
+
assert response.status_code == 200
|
| 20 |
+
assert response.json()["status"] == "ok"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_definition_exact_and_arabic_alias():
|
| 24 |
+
response = client.get("/definition/MAWB")
|
| 25 |
+
assert response.status_code == 200
|
| 26 |
+
assert response.json()["source"] == "verified_glossary"
|
| 27 |
+
response = client.get("/definition/رمز النظام المنسق")
|
| 28 |
+
assert response.json()["term"] == "HS Code"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_fuzzy_ocr_match():
|
| 32 |
+
service = GlossaryService(ROOT / "data/glossary.json", ROOT / "data/user_corrections.json", ROOT / "data/sme_approved_definitions.json")
|
| 33 |
+
match = service.find_match("Commercia1 Invoice")
|
| 34 |
+
assert match and match["canonical"] == "Commercial Invoice"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_invalid_image_rejected():
|
| 38 |
+
response = client.post("/analyze-frame", json={"image_base64": "bad", "frame_width": 100, "frame_height": 100})
|
| 39 |
+
assert response.status_code == 400
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_vlm_disabled_is_graceful():
|
| 43 |
+
response = client.post("/analyze-document-vlm", json={"user_requested": True})
|
| 44 |
+
assert response.status_code == 200
|
| 45 |
+
assert response.json()["status"] in {"unavailable", "configured"}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_feedback_and_sme_priority(tmp_path):
|
| 49 |
+
glossary_path = tmp_path / "glossary.json"
|
| 50 |
+
corrections_path = tmp_path / "corrections.json"
|
| 51 |
+
approved_path = tmp_path / "approved.json"
|
| 52 |
+
glossary_path.write_text(json.dumps({"MAWB": {"definition": "verified", "source": "verified_glossary"}}))
|
| 53 |
+
corrections_path.write_text(json.dumps({"MAWB": {"corrected_definition": "user", "status": "pending_sme_review"}}))
|
| 54 |
+
approved_path.write_text(json.dumps({"MAWB": {"definition": "approved"}}))
|
| 55 |
+
service = GlossaryService(glossary_path, corrections_path, approved_path)
|
| 56 |
+
result = service.definition("MAWB")
|
| 57 |
+
assert result["definition"] == "approved"
|
| 58 |
+
assert result["source"] == "sme_approved"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_docx_upload_returns_clickable_term_data():
|
| 62 |
+
xml = '''<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>MAWB with Packing List</w:t></w:r></w:p></w:body></w:document>'''
|
| 63 |
+
buffer = io.BytesIO()
|
| 64 |
+
with zipfile.ZipFile(buffer, "w") as archive:
|
| 65 |
+
archive.writestr("word/document.xml", xml)
|
| 66 |
+
response = client.post("/analyze-document", json={
|
| 67 |
+
"file_base64": base64.b64encode(buffer.getvalue()).decode(),
|
| 68 |
+
"filename": "air-cargo.docx",
|
| 69 |
+
"language_preference": "en",
|
| 70 |
+
})
|
| 71 |
+
assert response.status_code == 200
|
| 72 |
+
terms = response.json()["detected_terms"]
|
| 73 |
+
assert {item["term"] for item in terms} >= {"MAWB", "Packing List"}
|
| 74 |
+
assert all(item["bbox"] and item["definition"] for item in terms)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_unsupported_legacy_word_is_clear():
|
| 78 |
+
response = client.post("/analyze-document", json={
|
| 79 |
+
"file_base64": base64.b64encode(b"legacy").decode(),
|
| 80 |
+
"filename": "legacy.doc",
|
| 81 |
+
"language_preference": "en",
|
| 82 |
+
})
|
| 83 |
+
assert response.status_code == 400
|
| 84 |
+
assert "DOCX" in response.json()["detail"]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_selection_insight_has_summary_and_business_meaning():
|
| 88 |
+
response = client.post("/explain-selection", json={
|
| 89 |
+
"text": "Commercial Invoice with HS Code and Customs Duty",
|
| 90 |
+
"language_preference": "en",
|
| 91 |
+
})
|
| 92 |
+
assert response.status_code == 200
|
| 93 |
+
data = response.json()
|
| 94 |
+
assert data["summary"]
|
| 95 |
+
assert data["business_meaning"]
|
| 96 |
+
assert {item["term"] for item in data["recognized_terms"]} >= {
|
| 97 |
+
"Commercial Invoice", "HS Code", "Customs Duty"
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_unknown_selection_is_clearly_unverified():
|
| 102 |
+
response = client.post("/explain-selection", json={
|
| 103 |
+
"text": "Internal reference XYZ-998",
|
| 104 |
+
"language_preference": "en",
|
| 105 |
+
})
|
| 106 |
+
assert response.status_code == 200
|
| 107 |
+
assert response.json()["source"] == "ai_generated_unverified"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_contextual_fallback_prevents_empty_dot_state():
|
| 111 |
+
service = GlossaryService(ROOT / "data/glossary.json", ROOT / "data/user_corrections.json", ROOT / "data/sme_approved_definitions.json")
|
| 112 |
+
terms = service.contextual_fallbacks([{
|
| 113 |
+
"text": "Port Reference ZX-2048", "bbox": [20, 30, 280, 60],
|
| 114 |
+
"confidence": 0.91, "language": "en",
|
| 115 |
+
}])
|
| 116 |
+
assert len(terms) == 1
|
| 117 |
+
assert terms[0]["bbox"] == [20, 30, 280, 60]
|
| 118 |
+
assert terms[0]["source"] == "ai_generated_unverified"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_scanner_serves_scroll_info_and_clean_status_controls():
|
| 122 |
+
html = client.get("/").text
|
| 123 |
+
assert 'id="documentScroller"' in html
|
| 124 |
+
assert 'id="documentInfo"' in html
|
| 125 |
+
assert 'id="stabilityMetric"' not in html
|
| 126 |
+
assert 'id="lightMetric"' not in html
|
| 127 |
+
assert 'id="ocrMetric"' not in html
|