light-infer-chat commited on
Commit
ebb9029
·
1 Parent(s): ae0c2d4

feat: add qr extractor

Browse files
app/api/v1/json_extract.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException
7
+ from pydantic import BaseModel, Field
8
+
9
+ from app.api.deps import require_auth
10
+ from app.core.logger import get_logger
11
+ from app.services.json_service import extract_json
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ router = APIRouter()
16
+
17
+ MAX_CONTENT_LENGTH = 10_000_000
18
+
19
+
20
+ class ExtractJsonRequest(BaseModel):
21
+ content: str = Field(
22
+ ...,
23
+ description="Dirty string content potentially containing JSON wrapped in markdown, conversational text, etc.",
24
+ min_length=1,
25
+ )
26
+ limit: Optional[int] = Field(
27
+ default=None,
28
+ ge=1,
29
+ le=100,
30
+ description="Maximum number of JSON objects to extract. Omit for all.",
31
+ )
32
+ mode: str = Field(
33
+ default="all",
34
+ pattern=r"^(first|all)$",
35
+ description="'first' returns only the first JSON object; 'all' returns all extracted objects.",
36
+ )
37
+
38
+
39
+ class ExtractJsonResponse(BaseModel):
40
+ success: bool
41
+ time_ms: float
42
+ data: Any = None
43
+ count: int = 0
44
+ error_message: Optional[str] = None
45
+
46
+
47
+ @router.post(
48
+ "/json/extract",
49
+ response_model=ExtractJsonResponse,
50
+ summary="Extract JSON from dirty/markdown content",
51
+ description=(
52
+ "Accepts string content that may contain JSON embedded in markdown code fences "
53
+ "(```json), XML-style <json> tags, or mixed with conversational text. "
54
+ "Returns cleaned, parsed JSON objects. Handles malformed JSON via a repair pipeline "
55
+ "that fixes trailing commas, unquoted keys, single-quote strings, JS comments, etc."
56
+ ),
57
+ )
58
+ async def extract_json_endpoint(
59
+ body: ExtractJsonRequest,
60
+ token: str = Depends(require_auth),
61
+ ) -> ExtractJsonResponse:
62
+ start = time.perf_counter()
63
+
64
+ content_length = len(body.content)
65
+ if content_length > MAX_CONTENT_LENGTH:
66
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
67
+ raise HTTPException(
68
+ status_code=413,
69
+ detail=ExtractJsonResponse(
70
+ success=False,
71
+ time_ms=elapsed,
72
+ data=None,
73
+ count=0,
74
+ error_message=f"Content exceeds maximum length of {MAX_CONTENT_LENGTH:,} characters.",
75
+ ).model_dump(),
76
+ )
77
+
78
+ effective_limit = 1 if body.mode == "first" else body.limit
79
+
80
+ result = extract_json(body.content, limit=effective_limit)
81
+
82
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
83
+
84
+ if not result.success:
85
+ logger.warning(
86
+ "JSON extraction returned no results",
87
+ extra={
88
+ "input_length": content_length,
89
+ "mode": body.mode,
90
+ "time_ms": elapsed,
91
+ },
92
+ )
93
+ raise HTTPException(
94
+ status_code=422,
95
+ detail=ExtractJsonResponse(
96
+ success=False,
97
+ time_ms=elapsed,
98
+ data=None,
99
+ count=0,
100
+ error_message=result.error_message or "No JSON content could be extracted from the provided input.",
101
+ ).model_dump(),
102
+ )
103
+
104
+ response_data = result.data[0] if body.mode == "first" else result.data
105
+
106
+ logger.info(
107
+ "JSON extraction successful",
108
+ extra={
109
+ "count": result.total_extracted,
110
+ "method": result.extraction_method,
111
+ "input_length": content_length,
112
+ "time_ms": elapsed,
113
+ },
114
+ )
115
+
116
+ return ExtractJsonResponse(
117
+ success=True,
118
+ time_ms=elapsed,
119
+ data=response_data,
120
+ count=result.total_extracted,
121
+ error_message=None,
122
+ )
app/api/v1/qr_decoder.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
7
+ from pydantic import BaseModel, Field
8
+
9
+ from app.api.deps import require_auth
10
+ from app.core.logger import get_logger
11
+ from app.services.qr_decoder_service import QRDecoderService
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ router = APIRouter()
16
+
17
+ MAX_UPLOAD_BYTES = 20 * 1024 * 1024
18
+
19
+ _service = QRDecoderService()
20
+
21
+
22
+ class QRDecodeUrlRequest(BaseModel):
23
+ url: str = Field(
24
+ ...,
25
+ description="Public URL of the QR code image to decode",
26
+ )
27
+
28
+
29
+ class DecodedQR(BaseModel):
30
+ data: str
31
+ type: str = "QRCODE"
32
+ bounding_box: Optional[List[List[float]]] = None
33
+ decoder: Optional[str] = None
34
+
35
+
36
+ class QRDecodeResponse(BaseModel):
37
+ success: bool
38
+ time_ms: float
39
+ decoded: List[DecodedQR]
40
+ count: int
41
+ error_message: Optional[str] = None
42
+
43
+
44
+ @router.post(
45
+ "/qr-decode/file",
46
+ response_model=QRDecodeResponse,
47
+ summary="Decode QR code from an uploaded image file",
48
+ description="Upload an image file containing a QR code and get its decoded content.",
49
+ )
50
+ async def decode_qr_file(
51
+ file: UploadFile = File(..., description="Image file containing a QR code"),
52
+ token: str = Depends(require_auth),
53
+ ) -> QRDecodeResponse:
54
+ start = time.perf_counter()
55
+
56
+ if not file.content_type or not file.content_type.startswith("image/"):
57
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
58
+ raise HTTPException(
59
+ status_code=400,
60
+ detail=QRDecodeResponse(
61
+ success=False, time_ms=elapsed, decoded=[], count=0,
62
+ error_message="Uploaded file must be an image.",
63
+ ).model_dump(),
64
+ )
65
+
66
+ raw = await file.read()
67
+ if len(raw) == 0:
68
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
69
+ raise HTTPException(
70
+ status_code=400,
71
+ detail=QRDecodeResponse(
72
+ success=False, time_ms=elapsed, decoded=[], count=0,
73
+ error_message="Uploaded file is empty.",
74
+ ).model_dump(),
75
+ )
76
+ if len(raw) > MAX_UPLOAD_BYTES:
77
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
78
+ raise HTTPException(
79
+ status_code=413,
80
+ detail=QRDecodeResponse(
81
+ success=False, time_ms=elapsed, decoded=[], count=0,
82
+ error_message=f"File exceeds maximum size of {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
83
+ ).model_dump(),
84
+ )
85
+
86
+ result = await _service.decode_from_bytes(raw, source_label=file.filename or "upload")
87
+
88
+ if not result.success:
89
+ logger.warning("QR decode failed for uploaded file", extra={
90
+ "filename": file.filename, "error": result.error_message,
91
+ })
92
+ raise HTTPException(
93
+ status_code=422,
94
+ detail=QRDecodeResponse(
95
+ success=False, time_ms=result.processing_time_ms, decoded=[], count=0,
96
+ error_message=result.error_message,
97
+ ).model_dump(),
98
+ )
99
+
100
+ items = [DecodedQR(**item) for item in result.decoded_data]
101
+ logger.info("QR decode successful", extra={
102
+ "filename": file.filename, "count": len(items),
103
+ })
104
+ return QRDecodeResponse(
105
+ success=True, time_ms=result.processing_time_ms,
106
+ decoded=items, count=len(items), error_message=None,
107
+ )
108
+
109
+
110
+ @router.post(
111
+ "/qr-decode/url",
112
+ response_model=QRDecodeResponse,
113
+ summary="Decode QR code from an image URL",
114
+ description="Provide a public URL to an image containing a QR code and get its decoded content.",
115
+ )
116
+ async def decode_qr_url(
117
+ body: QRDecodeUrlRequest,
118
+ token: str = Depends(require_auth),
119
+ ) -> QRDecodeResponse:
120
+ start = time.perf_counter()
121
+
122
+ if not body.url.lower().startswith(("http://", "https://")):
123
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
124
+ raise HTTPException(
125
+ status_code=400,
126
+ detail=QRDecodeResponse(
127
+ success=False, time_ms=elapsed, decoded=[], count=0,
128
+ error_message="URL must start with http:// or https://.",
129
+ ).model_dump(),
130
+ )
131
+
132
+ result = await _service.decode_from_url(body.url)
133
+
134
+ if not result.success:
135
+ logger.warning("QR decode failed for URL", extra={
136
+ "url": body.url, "error": result.error_message,
137
+ })
138
+ raise HTTPException(
139
+ status_code=422,
140
+ detail=QRDecodeResponse(
141
+ success=False, time_ms=result.processing_time_ms, decoded=[], count=0,
142
+ error_message=result.error_message,
143
+ ).model_dump(),
144
+ )
145
+
146
+ items = [DecodedQR(**item) for item in result.decoded_data]
147
+ logger.info("QR decode successful", extra={
148
+ "url": body.url, "count": len(items),
149
+ })
150
+ return QRDecodeResponse(
151
+ success=True, time_ms=result.processing_time_ms,
152
+ decoded=items, count=len(items), error_message=None,
153
+ )
app/api/v1/router.py CHANGED
@@ -11,6 +11,8 @@ from app.api.v1 import (
11
  csv_analysis,
12
  database,
13
  embeddings,
 
 
14
  qr_generator,
15
  reconcile,
16
  scraper,
@@ -46,5 +48,7 @@ api_v1_router.include_router(chat.router, tags=["Chat"])
46
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
47
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
48
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
 
 
49
  api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
50
  api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
 
11
  csv_analysis,
12
  database,
13
  embeddings,
14
+ json_extract,
15
+ qr_decoder,
16
  qr_generator,
17
  reconcile,
18
  scraper,
 
48
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
49
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
50
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
51
+ api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
52
+ api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
53
  api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
54
  api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
app/services/json_service.py ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from app.core.logger import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ MAX_CONTENT_LENGTH = 10_000_000
13
+ MAX_REPAIR_PASSES = 6
14
+ MAX_RESULTS = 100
15
+
16
+
17
+ class ExtractionResult:
18
+ def __init__(
19
+ self,
20
+ success: bool,
21
+ data: List[Any],
22
+ time_ms: float,
23
+ error_message: Optional[str] = None,
24
+ extraction_method: Optional[str] = None,
25
+ total_extracted: int = 0,
26
+ input_length: int = 0,
27
+ ):
28
+ self.success = success
29
+ self.data = data
30
+ self.time_ms = time_ms
31
+ self.error_message = error_message
32
+ self.extraction_method = extraction_method
33
+ self.total_extracted = total_extracted
34
+ self.input_length = input_length
35
+
36
+ def to_dict(self) -> Dict[str, Any]:
37
+ return {
38
+ "success": self.success,
39
+ "data": self.data,
40
+ "time_ms": self.time_ms,
41
+ "error_message": self.error_message,
42
+ "extraction_method": self.extraction_method,
43
+ "total_extracted": self.total_extracted,
44
+ "input_length": self.input_length,
45
+ }
46
+
47
+
48
+ def _purge_nan_inf(obj: Any) -> Any:
49
+ if isinstance(obj, float):
50
+ if obj != obj or obj == float("inf") or obj == -float("inf"):
51
+ return None
52
+ return obj
53
+ if isinstance(obj, dict):
54
+ return {k: _purge_nan_inf(v) for k, v in obj.items()}
55
+ if isinstance(obj, list):
56
+ return [_purge_nan_inf(v) for v in obj]
57
+ return obj
58
+
59
+
60
+ def _safe_json_parse(raw: str) -> Optional[Any]:
61
+ if not raw or len(raw) == 0:
62
+ return None
63
+ try:
64
+ parsed = json.loads(raw)
65
+ return _purge_nan_inf(parsed)
66
+ except (json.JSONDecodeError, ValueError):
67
+ return None
68
+
69
+
70
+ def _is_meaningful(value: Any) -> bool:
71
+ if value is None:
72
+ return False
73
+ if isinstance(value, bool):
74
+ return True
75
+ if isinstance(value, (int, float)):
76
+ return True
77
+ if isinstance(value, str):
78
+ return len(value.strip()) > 0
79
+ if isinstance(value, (list, tuple)):
80
+ return len(value) > 0
81
+ if isinstance(value, dict):
82
+ return len(value) > 0
83
+ return False
84
+
85
+
86
+ def _is_trivial(value: Any) -> bool:
87
+ if isinstance(value, (list, tuple)):
88
+ return len(value) == 0
89
+ if isinstance(value, dict):
90
+ return len(value) == 0
91
+ return False
92
+
93
+
94
+ def _normalize_whitespace(raw: str) -> str:
95
+ result = raw
96
+ result = result.replace("\r\n", "\n")
97
+ result = result.replace("\r", "\n")
98
+ result = result.replace("\t", " ")
99
+ result = result.replace("\u00a0", " ")
100
+ result = re.sub(r"[\u200b-\u200d]", "", result)
101
+ result = result.replace("\ufeff", "")
102
+ return result
103
+
104
+
105
+ def _strip_bom(raw: str) -> str:
106
+ if raw and ord(raw[0]) == 0xFEFF:
107
+ return raw[1:]
108
+ return raw
109
+
110
+
111
+ def _repair_trailing_commas(raw: str) -> str:
112
+ result = raw
113
+ prev = None
114
+ passes = 0
115
+ while result != prev and passes < MAX_REPAIR_PASSES:
116
+ prev = result
117
+ result = re.sub(r",(\s*[}\]])", r"\1", result)
118
+ passes += 1
119
+ return result
120
+
121
+
122
+ def _repair_leading_commas(raw: str) -> str:
123
+ return re.sub(r"([\[{])\s*,", r"\1", raw)
124
+
125
+
126
+ def _repair_double_commas(raw: str) -> str:
127
+ return re.sub(r",(\s*),", r",\1", raw)
128
+
129
+
130
+ def _quote_unquoted_keys(raw: str) -> str:
131
+ return re.sub(r'([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:', r'\1"\2":', raw)
132
+
133
+
134
+ def _replace_single_quote_strings(raw: str) -> str:
135
+ def _replace(m: re.Match) -> str:
136
+ inner = m.group(1)
137
+ escaped = inner.replace('"', '\\"')
138
+ return f': "{escaped}"'
139
+ return re.sub(r":\s*'((?:[^'\\]|\\.)*)'", _replace, raw)
140
+
141
+
142
+ def _replace_single_quote_keys(raw: str) -> str:
143
+ def _replace(m: re.Match) -> str:
144
+ pre, key, post = m.group(1), m.group(2), m.group(3)
145
+ escaped = key.replace('"', '\\"')
146
+ return f'{pre}"{escaped}"{post}'
147
+ return re.sub(r"([{,]\s*)'((?:[^'\\]|\\.)*)'(\s*:)", _replace, raw)
148
+
149
+
150
+ def _fix_ellipsis_values(raw: str) -> str:
151
+ return re.sub(r":\s*\.\.\.", ": null", raw)
152
+
153
+
154
+ def _fix_undefined_values(raw: str) -> str:
155
+ return re.sub(r":\s*undefined\b", ": null", raw, flags=re.IGNORECASE)
156
+
157
+
158
+ def _fix_nan_values(raw: str) -> str:
159
+ return re.sub(r":\s*NaN\b", ": null", raw)
160
+
161
+
162
+ def _fix_infinity_values(raw: str) -> str:
163
+ return re.sub(r":\s*-?Infinity\b", ": null", raw)
164
+
165
+
166
+ def _fix_hex_numbers(raw: str) -> str:
167
+ def _replace(m: re.Match) -> str:
168
+ hex_val = m.group(1)
169
+ return f": {int(hex_val, 16)}"
170
+ return re.sub(r":\s*(0x[0-9a-fA-F]+)", _replace, raw)
171
+
172
+
173
+ def _strip_js_comments(raw: str) -> str:
174
+ result = re.sub(r"//[^\n]*", "", raw)
175
+ result = re.sub(r"/\*[\s\S]*?\*/", "", result)
176
+ return result
177
+
178
+
179
+ def _remove_bare_string_entries(raw: str) -> str:
180
+ lines = raw.split("\n")
181
+ cleaned: List[str] = []
182
+
183
+ for i, line in enumerate(lines):
184
+ trimmed = line.strip()
185
+ is_bare = bool(re.match(r'^"[^"]*",?\s*$', trimmed)) and ":" not in trimmed
186
+ if is_bare:
187
+ if cleaned:
188
+ cleaned[-1] = re.sub(r",\s*$", "", cleaned[-1])
189
+ continue
190
+ cleaned.append(line)
191
+
192
+ return "\n".join(cleaned)
193
+
194
+
195
+ def _fix_single_element_bare_objects(raw: str) -> str:
196
+ def _replace(m: re.Match) -> str:
197
+ content = m.group(1)
198
+ if ":" in content:
199
+ return m.group(0)
200
+ return "{}"
201
+ return re.sub(r'\{\s*"([^"]+)"\s*\}', _replace, raw)
202
+
203
+
204
+ def _fix_missing_commas(raw: str) -> str:
205
+ result = raw
206
+ result = re.sub(r'("\s*)\n(\s*")', r'\1,\n\2', result)
207
+ result = re.sub(r"(\d)\n(\s*\")", r'\1,\n\2', result)
208
+ result = re.sub(r'("\s*)\n(\s*\d)', r'\1,\n\2', result)
209
+ result = re.sub(r"(\})\n(\s*\{)", r'\1,\n\2', result)
210
+ result = re.sub(r"(\])\n(\s*\[)", r'\1,\n\2', result)
211
+ return result
212
+
213
+
214
+ def _apply_repair_pipeline(raw: str) -> str:
215
+ result = raw
216
+ result = _strip_js_comments(result)
217
+ result = _remove_bare_string_entries(result)
218
+ result = _fix_single_element_bare_objects(result)
219
+ result = _replace_single_quote_keys(result)
220
+ result = _replace_single_quote_strings(result)
221
+ result = _quote_unquoted_keys(result)
222
+ result = _fix_ellipsis_values(result)
223
+ result = _fix_undefined_values(result)
224
+ result = _fix_nan_values(result)
225
+ result = _fix_infinity_values(result)
226
+ result = _fix_hex_numbers(result)
227
+ result = _repair_leading_commas(result)
228
+ result = _repair_trailing_commas(result)
229
+ result = _repair_double_commas(result)
230
+ result = _fix_missing_commas(result)
231
+ return result
232
+
233
+
234
+ def _truncate_to_balanced(raw: str) -> str:
235
+ if not raw:
236
+ return raw
237
+ opener = raw[0]
238
+ if opener not in ("{", "["):
239
+ return raw
240
+ closer = "}" if opener == "{" else "]"
241
+
242
+ depth = 0
243
+ in_string = False
244
+ escape = False
245
+
246
+ for i, char in enumerate(raw):
247
+ if in_string:
248
+ if escape:
249
+ escape = False
250
+ elif char == "\\":
251
+ escape = True
252
+ elif char == '"':
253
+ in_string = False
254
+ continue
255
+
256
+ if char == '"':
257
+ in_string = True
258
+ continue
259
+
260
+ if char == opener:
261
+ depth += 1
262
+ elif char == closer:
263
+ depth -= 1
264
+ if depth == 0:
265
+ return raw[: i + 1]
266
+
267
+ return raw
268
+
269
+
270
+ def _close_unclosed_structures(raw: str) -> str:
271
+ stack: List[str] = []
272
+ in_string = False
273
+ escape = False
274
+
275
+ for char in raw:
276
+ if in_string:
277
+ if escape:
278
+ escape = False
279
+ elif char == "\\":
280
+ escape = True
281
+ elif char == '"':
282
+ in_string = False
283
+ continue
284
+
285
+ if char == '"':
286
+ in_string = True
287
+ elif char == "{":
288
+ stack.append("}")
289
+ elif char == "[":
290
+ stack.append("]")
291
+ elif char == "}" or char == "]":
292
+ if stack and stack[-1] == char:
293
+ stack.pop()
294
+
295
+ if not stack:
296
+ return raw
297
+
298
+ result = raw.rstrip()
299
+ result = re.sub(r",\s*$", "", result)
300
+
301
+ for closer in reversed(stack):
302
+ result += closer
303
+
304
+ return result
305
+
306
+
307
+ def _try_parse_with_repair(raw: str) -> Optional[Any]:
308
+ trimmed = raw.strip()
309
+ if not trimmed:
310
+ return None
311
+
312
+ direct = _safe_json_parse(trimmed)
313
+ if direct is not None:
314
+ return direct
315
+
316
+ repaired = _apply_repair_pipeline(trimmed)
317
+
318
+ after_repair = _safe_json_parse(repaired)
319
+ if after_repair is not None:
320
+ return after_repair
321
+
322
+ truncated = _truncate_to_balanced(repaired)
323
+ after_truncate = _safe_json_parse(truncated)
324
+ if after_truncate is not None:
325
+ return after_truncate
326
+
327
+ closed = _close_unclosed_structures(repaired)
328
+ after_close = _safe_json_parse(closed)
329
+ if after_close is not None:
330
+ return after_close
331
+
332
+ closed_truncated = _close_unclosed_structures(truncated)
333
+ return _safe_json_parse(closed_truncated)
334
+
335
+
336
+ def _find_balanced_closing(text: str, start: int) -> int:
337
+ opener = text[start]
338
+ if opener not in ("{", "["):
339
+ return -1
340
+ closer = "}" if opener == "{" else "]"
341
+
342
+ depth = 0
343
+ in_string = False
344
+ escape = False
345
+
346
+ for i in range(start, len(text)):
347
+ char = text[i]
348
+
349
+ if in_string:
350
+ if escape:
351
+ escape = False
352
+ elif char == "\\":
353
+ escape = True
354
+ elif char == '"':
355
+ in_string = False
356
+ continue
357
+
358
+ if char == '"':
359
+ in_string = True
360
+ continue
361
+
362
+ if char == opener:
363
+ depth += 1
364
+ elif char == closer:
365
+ depth -= 1
366
+ if depth == 0:
367
+ return i
368
+
369
+ return -1
370
+
371
+
372
+ def _extract_from_fenced_blocks(content: str) -> Tuple[List[Any], List[Tuple[int, int]]]:
373
+ results: List[Any] = []
374
+ covered_ranges: List[Tuple[int, int]] = []
375
+
376
+ patterns = [
377
+ re.compile(r"```json\s*\n?(.*?)```", re.DOTALL),
378
+ re.compile(r"```javascript\s*\n?(.*?)```", re.DOTALL),
379
+ re.compile(r"```js\s*\n?(.*?)```", re.DOTALL),
380
+ re.compile(r"```typescript\s*\n?(.*?)```", re.DOTALL),
381
+ re.compile(r"```ts\s*\n?(.*?)```", re.DOTALL),
382
+ re.compile(r"```(.*?)```", re.DOTALL),
383
+ re.compile(r"~~~json\s*\n?(.*?)~~~", re.DOTALL),
384
+ re.compile(r"~~~(.*?)~~~", re.DOTALL),
385
+ ]
386
+
387
+ seen_ranges: set = set()
388
+
389
+ for pattern in patterns:
390
+ for match in pattern.finditer(content):
391
+ range_key = (match.start(), match.end())
392
+ if range_key in seen_ranges:
393
+ continue
394
+ seen_ranges.add(range_key)
395
+
396
+ raw = match.group(1).strip() if match.lastindex else match.group(1).strip()
397
+ if not raw:
398
+ continue
399
+
400
+ parsed = _try_parse_with_repair(raw)
401
+ if parsed is not None and not _is_trivial(parsed):
402
+ results.append(parsed)
403
+ covered_ranges.append(range_key)
404
+
405
+ return results, covered_ranges
406
+
407
+
408
+ def _extract_from_json_tags(content: str) -> Tuple[List[Any], List[Tuple[int, int]]]:
409
+ results: List[Any] = []
410
+ covered_ranges: List[Tuple[int, int]] = []
411
+
412
+ pattern = re.compile(r"<json[^>]*>(.*?)</json>", re.DOTALL)
413
+
414
+ for match in pattern.finditer(content):
415
+ raw = match.group(1).strip()
416
+ if not raw:
417
+ continue
418
+
419
+ parsed = _try_parse_with_repair(raw)
420
+ if parsed is not None and not _is_trivial(parsed):
421
+ results.append(parsed)
422
+ covered_ranges.append((match.start(), match.end()))
423
+
424
+ return results, covered_ranges
425
+
426
+
427
+ def _is_inside_range(index: int, ranges: List[Tuple[int, int]]) -> bool:
428
+ for start, end in ranges:
429
+ if start <= index <= end:
430
+ return True
431
+ return False
432
+
433
+
434
+ def _extract_balanced_json(content: str, skip_ranges: List[Tuple[int, int]]) -> List[Any]:
435
+ results: List[Any] = []
436
+ cursor = 0
437
+
438
+ while cursor < len(content):
439
+ if _is_inside_range(cursor, skip_ranges):
440
+ cursor += 1
441
+ continue
442
+
443
+ char = content[cursor]
444
+ if char not in ("{", "["):
445
+ cursor += 1
446
+ continue
447
+
448
+ end = _find_balanced_closing(content, cursor)
449
+ if end != -1:
450
+ candidate = content[cursor : end + 1]
451
+ if len(candidate) >= 2:
452
+ parsed = _try_parse_with_repair(candidate)
453
+ if parsed is not None and not _is_trivial(parsed):
454
+ results.append(parsed)
455
+ cursor = end + 1
456
+ continue
457
+ else:
458
+ partial = content[cursor:]
459
+ if len(partial) >= 2:
460
+ parsed = _try_parse_with_repair(partial)
461
+ if parsed is not None and not _is_trivial(parsed):
462
+ results.append(parsed)
463
+ break
464
+
465
+ cursor += 1
466
+
467
+ return results
468
+
469
+
470
+ def _extract_json_lines(content: str, skip_ranges: List[Tuple[int, int]]) -> List[Any]:
471
+ results: List[Any] = []
472
+ offset = 0
473
+
474
+ for line in content.split("\n"):
475
+ line_start = offset
476
+ offset += len(line) + 1
477
+
478
+ if _is_inside_range(line_start, skip_ranges):
479
+ continue
480
+
481
+ trimmed = line.strip()
482
+ if not trimmed.startswith("{") and not trimmed.startswith("["):
483
+ continue
484
+
485
+ parsed = _safe_json_parse(trimmed)
486
+ if parsed is not None and not _is_trivial(parsed):
487
+ results.append(parsed)
488
+
489
+ return results
490
+
491
+
492
+ def _extract_entire_content(content: str) -> List[Any]:
493
+ trimmed = content.strip()
494
+ if not trimmed.startswith("{") and not trimmed.startswith("["):
495
+ return []
496
+
497
+ parsed = _try_parse_with_repair(trimmed)
498
+ if parsed is not None and not _is_trivial(parsed):
499
+ return [parsed]
500
+ return []
501
+
502
+
503
+ def _deduplicate(items: List[Any]) -> List[Any]:
504
+ seen: set = set()
505
+ result: List[Any] = []
506
+ for item in items:
507
+ key = json.dumps(item, sort_keys=True, default=str)
508
+ if key not in seen:
509
+ seen.add(key)
510
+ result.append(item)
511
+ return result
512
+
513
+
514
+ def _remove_contained_subsets(items: List[Any]) -> List[Any]:
515
+ serialized = [json.dumps(item, sort_keys=True, default=str) for item in items]
516
+ result: List[Any] = []
517
+
518
+ for i, current in enumerate(serialized):
519
+ if not current:
520
+ continue
521
+ is_contained = any(
522
+ j != i and other and len(other) > len(current) and current in other
523
+ for j, other in enumerate(serialized)
524
+ )
525
+ if not is_contained:
526
+ result.append(items[i])
527
+
528
+ return result
529
+
530
+
531
+ def extract_json_from_content(content: Any, limit: Optional[int] = None) -> List[Any]:
532
+ if not isinstance(content, str):
533
+ return []
534
+
535
+ normalized = _strip_bom(_normalize_whitespace(content))
536
+
537
+ if len(normalized) == 0:
538
+ return []
539
+ if len(normalized) > MAX_CONTENT_LENGTH:
540
+ logger.warning("Content exceeds maximum length of %d", MAX_CONTENT_LENGTH)
541
+ return []
542
+
543
+ entire = _extract_entire_content(normalized)
544
+ if entire:
545
+ filtered = [v for v in entire if _is_meaningful(v)]
546
+ return filtered[:limit] if limit else filtered
547
+
548
+ fenced_results, fenced_ranges = _extract_from_fenced_blocks(normalized)
549
+ tag_results, tag_ranges = _extract_from_json_tags(normalized)
550
+
551
+ all_skip_ranges = fenced_ranges + tag_ranges
552
+
553
+ balanced_results = _extract_balanced_json(normalized, all_skip_ranges)
554
+ line_results = _extract_json_lines(normalized, all_skip_ranges)
555
+
556
+ combined = fenced_results + tag_results + balanced_results + line_results
557
+
558
+ meaningful = [v for v in combined if _is_meaningful(v)]
559
+ deduplicated = _deduplicate(meaningful)
560
+ filtered = _remove_contained_subsets(deduplicated)
561
+
562
+ return filtered[:limit] if limit else filtered
563
+
564
+
565
+ def extract_first_json(content: Any) -> Optional[Any]:
566
+ results = extract_json_from_content(content, limit=1)
567
+ return results[0] if results else None
568
+
569
+
570
+ def extract_json(content: Any, limit: Optional[int] = None) -> ExtractionResult:
571
+ start = time.perf_counter()
572
+
573
+ try:
574
+ data = extract_json_from_content(content, limit)
575
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
576
+
577
+ method: Optional[str] = None
578
+ if data:
579
+ if isinstance(content, str):
580
+ trimmed = content.strip()
581
+ if trimmed.startswith("{") or trimmed.startswith("["):
582
+ method = "entire-content"
583
+ elif "```" in content or "~~~" in content:
584
+ method = "fenced-blocks"
585
+ elif "<json" in content:
586
+ method = "json-tags"
587
+ else:
588
+ method = "balanced-json"
589
+ else:
590
+ method = "unknown"
591
+
592
+ return ExtractionResult(
593
+ success=len(data) > 0,
594
+ data=data,
595
+ time_ms=elapsed,
596
+ error_message=None if data else "No JSON content could be extracted",
597
+ extraction_method=method,
598
+ total_extracted=len(data),
599
+ input_length=len(content) if isinstance(content, str) else 0,
600
+ )
601
+ except Exception as exc:
602
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
603
+ logger.exception("extract_json failed")
604
+ return ExtractionResult(
605
+ success=False,
606
+ data=[],
607
+ time_ms=elapsed,
608
+ error_message=str(exc),
609
+ extraction_method=None,
610
+ total_extracted=0,
611
+ input_length=len(content) if isinstance(content, str) else 0,
612
+ )
app/services/qr_decoder_service.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import ipaddress
5
+ import os
6
+ import socket
7
+ import time
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+ from urllib.parse import urlparse
10
+
11
+ import cv2
12
+ import httpx
13
+ import numpy as np
14
+ from PIL import Image, UnidentifiedImageError
15
+
16
+ from app.core.logger import get_logger
17
+
18
+ logger = get_logger(__name__)
19
+
20
+ try:
21
+ from pyzbar import pyzbar
22
+ PYZBAR_AVAILABLE = True
23
+ except Exception:
24
+ PYZBAR_AVAILABLE = False
25
+
26
+
27
+ class QRCodeExtractionError(Exception):
28
+ pass
29
+
30
+
31
+ class QRDecoderResult:
32
+ def __init__(
33
+ self,
34
+ success: bool,
35
+ decoded_data: List[Dict[str, Any]],
36
+ error_message: Optional[str] = None,
37
+ processing_time_ms: float = 0.0,
38
+ ):
39
+ self.success = success
40
+ self.decoded_data = decoded_data
41
+ self.error_message = error_message
42
+ self.processing_time_ms = processing_time_ms
43
+
44
+
45
+ class _PreprocessingPipeline:
46
+ """Collection of static preprocessing strategies for QR code images.
47
+
48
+ Each strategy returns (preprocessed_image, label) or None if not applicable.
49
+ Strategies are tried in order from fastest/least destructive to most aggressive.
50
+ """
51
+
52
+ @staticmethod
53
+ def original(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
54
+ return (image, "original")
55
+
56
+ @staticmethod
57
+ def grayscale(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
58
+ if image.shape[2] == 3:
59
+ return (cv2.cvtColor(image, cv2.COLOR_BGR2GRAY), "grayscale")
60
+ return (image, "grayscale")
61
+
62
+ @staticmethod
63
+ def otsu_threshold(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
64
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
65
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
66
+ _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
67
+ return (binary, "otsu")
68
+
69
+ @staticmethod
70
+ def adaptive_threshold(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
71
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
72
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
73
+ binary = cv2.adaptiveThreshold(
74
+ blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 51, 2
75
+ )
76
+ return (binary, "adaptive")
77
+
78
+ @staticmethod
79
+ def clahe(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
80
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
81
+ clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
82
+ enhanced = clahe.apply(gray)
83
+ return (enhanced, "clahe")
84
+
85
+ @staticmethod
86
+ def unsharp_mask(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
87
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
88
+ blurred = cv2.GaussianBlur(gray, (0, 0), 3.0)
89
+ sharpened = cv2.addWeighted(gray, 1.5, blurred, -0.5, 0)
90
+ return (sharpened, "unsharp")
91
+
92
+ @staticmethod
93
+ def inverted_otsu(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
94
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
95
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
96
+ _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
97
+ inverted = cv2.bitwise_not(binary)
98
+ return (inverted, "inverted_otsu")
99
+
100
+ @staticmethod
101
+ def morphological_clean(image: np.ndarray) -> Optional[Tuple[np.ndarray, str]]:
102
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
103
+ _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
104
+ kernel = np.ones((3, 3), np.uint8)
105
+ cleaned = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
106
+ cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_OPEN, kernel)
107
+ return (cleaned, "morphological")
108
+
109
+ @classmethod
110
+ def all_strategies(cls) -> List[Any]:
111
+ return [
112
+ cls.original,
113
+ cls.grayscale,
114
+ cls.otsu_threshold,
115
+ cls.adaptive_threshold,
116
+ cls.clahe,
117
+ cls.unsharp_mask,
118
+ cls.inverted_otsu,
119
+ cls.morphological_clean,
120
+ ]
121
+
122
+
123
+ class QRDecoderService:
124
+ def __init__(
125
+ self,
126
+ timeout: float = 30.0,
127
+ max_file_size_mb: float = 20.0,
128
+ allow_private_network_urls: bool = False,
129
+ ) -> None:
130
+ self._timeout = timeout
131
+ self._max_file_size = int(max_file_size_mb * 1024 * 1024)
132
+ self._allow_private_network_urls = allow_private_network_urls
133
+ self._detector = cv2.QRCodeDetector()
134
+ self._preprocessing = _PreprocessingPipeline()
135
+
136
+ # ------------------------------------------------------------------
137
+ # Public API (all public methods are async)
138
+ # ------------------------------------------------------------------
139
+
140
+ async def extract(self, source: str) -> QRDecoderResult:
141
+ if self._is_url(source):
142
+ return await self.decode_from_url(source)
143
+ return await self.decode_from_file(source)
144
+
145
+ async def decode_from_file(self, file_path: str) -> QRDecoderResult:
146
+ start = time.perf_counter()
147
+ try:
148
+ if not os.path.isfile(file_path):
149
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
150
+ return QRDecoderResult(False, [], f"File not found: {file_path}", elapsed)
151
+ with open(file_path, "rb") as f:
152
+ raw = f.read()
153
+ return await self.decode_from_bytes(raw, source_label=file_path)
154
+ except QRCodeExtractionError as exc:
155
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
156
+ return QRDecoderResult(False, [], str(exc), elapsed)
157
+ except Exception as exc:
158
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
159
+ logger.exception("Unexpected error decoding QR file")
160
+ return QRDecoderResult(False, [], f"Processing failed: {exc}", elapsed)
161
+
162
+ async def decode_from_bytes(
163
+ self, image_bytes: bytes, source_label: str = "<bytes>"
164
+ ) -> QRDecoderResult:
165
+ start = time.perf_counter()
166
+ try:
167
+ if len(image_bytes) > self._max_file_size:
168
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
169
+ return QRDecoderResult(
170
+ False, [],
171
+ f"Image exceeds maximum size of {self._max_file_size // (1024 * 1024)} MB.",
172
+ elapsed,
173
+ )
174
+
175
+ image = await self._load_cv_image_async(image_bytes, source_label)
176
+ result = await self._decode_async(image, source_label)
177
+ result.processing_time_ms = round((time.perf_counter() - start) * 1000, 3)
178
+ return result
179
+ except QRCodeExtractionError as exc:
180
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
181
+ return QRDecoderResult(False, [], str(exc), elapsed)
182
+ except Exception as exc:
183
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
184
+ logger.exception("Unexpected error decoding QR bytes")
185
+ return QRDecoderResult(False, [], f"Processing failed: {exc}", elapsed)
186
+
187
+ async def decode_from_url(self, image_url: str) -> QRDecoderResult:
188
+ start = time.perf_counter()
189
+ try:
190
+ if not self._is_url(image_url):
191
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
192
+ return QRDecoderResult(
193
+ False, [], f"Not a valid http(s) URL: {image_url}", elapsed
194
+ )
195
+
196
+ self._validate_url_is_safe(image_url)
197
+ raw = await self._download(image_url)
198
+ result = await self.decode_from_bytes(raw, source_label=image_url)
199
+ result.processing_time_ms = round((time.perf_counter() - start) * 1000, 3)
200
+ return result
201
+ except QRCodeExtractionError as exc:
202
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
203
+ return QRDecoderResult(False, [], str(exc), elapsed)
204
+ except Exception as exc:
205
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
206
+ logger.exception("Unexpected error decoding QR from URL")
207
+ return QRDecoderResult(False, [], f"Processing failed: {exc}", elapsed)
208
+
209
+ # ------------------------------------------------------------------
210
+ # Async wrappers for CPU-bound OpenCV/PIL operations
211
+ # ------------------------------------------------------------------
212
+
213
+ async def _load_cv_image_async(self, raw_bytes: bytes, origin: str) -> np.ndarray:
214
+ loop = asyncio_get_loop()
215
+ return await loop.run_in_executor(None, self._load_cv_image, raw_bytes, origin)
216
+
217
+ async def _decode_async(
218
+ self, image: np.ndarray, source: str
219
+ ) -> QRDecoderResult:
220
+ loop = asyncio_get_loop()
221
+ return await loop.run_in_executor(None, self._decode_sync, image, source)
222
+
223
+ # ------------------------------------------------------------------
224
+ # Synchronous CPU-bound implementations
225
+ # ------------------------------------------------------------------
226
+
227
+ @staticmethod
228
+ def _load_cv_image(raw_bytes: bytes, origin: str) -> np.ndarray:
229
+ try:
230
+ pil_image = Image.open(io.BytesIO(raw_bytes))
231
+ pil_image.load()
232
+ except UnidentifiedImageError as exc:
233
+ raise QRCodeExtractionError(
234
+ f"'{origin}' is not a readable image file"
235
+ ) from exc
236
+ except Exception as exc:
237
+ raise QRCodeExtractionError(
238
+ f"Could not open image '{origin}': {exc}"
239
+ ) from exc
240
+
241
+ rgb = pil_image.convert("RGB")
242
+ arr = np.array(rgb)
243
+ return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
244
+
245
+ def _decode_sync(self, image: np.ndarray, source: str) -> QRDecoderResult:
246
+ results = self._decode_with_multi_strategy(image)
247
+
248
+ if not results and PYZBAR_AVAILABLE:
249
+ results = self._decode_with_pyzbar(image)
250
+
251
+ if not results:
252
+ return QRDecoderResult(
253
+ success=False,
254
+ decoded_data=[],
255
+ error_message=(
256
+ "No QR code could be detected in the image. "
257
+ "Make sure the image is clear, in-frame, and not "
258
+ "excessively skewed or low-resolution."
259
+ ),
260
+ )
261
+
262
+ return QRDecoderResult(success=True, decoded_data=results)
263
+
264
+ def _decode_with_multi_strategy(
265
+ self, image: np.ndarray
266
+ ) -> List[Dict[str, Any]]:
267
+ tried_strategies: List[str] = []
268
+ seen_data: set = set()
269
+
270
+ for strategy in _PreprocessingPipeline.all_strategies():
271
+ processed = strategy(image)
272
+ if processed is None:
273
+ continue
274
+ preprocessed_img, label = processed
275
+ tried_strategies.append(label)
276
+
277
+ try:
278
+ ok, decoded_info, points, _ = (
279
+ self._detector.detectAndDecodeMulti(preprocessed_img)
280
+ )
281
+ except cv2.error:
282
+ ok, decoded_info, points = False, [], None
283
+
284
+ if ok:
285
+ results = []
286
+ for i, data in enumerate(decoded_info):
287
+ if data and data not in seen_data:
288
+ seen_data.add(data)
289
+ bbox = (
290
+ points[i].tolist() if points is not None else None
291
+ )
292
+ results.append({
293
+ "data": data,
294
+ "type": "QRCODE",
295
+ "bounding_box": bbox,
296
+ "decoder": f"opencv_{label}",
297
+ })
298
+ if results:
299
+ return results
300
+
301
+ try:
302
+ data, points, _ = self._detector.detectAndDecode(preprocessed_img)
303
+ except cv2.error:
304
+ data, points = "", None
305
+
306
+ if data and data not in seen_data:
307
+ seen_data.add(data)
308
+ bbox = points.tolist() if points is not None else None
309
+ return [{
310
+ "data": data,
311
+ "type": "QRCODE",
312
+ "bounding_box": bbox,
313
+ "decoder": f"opencv_{label}",
314
+ }]
315
+
316
+ logger.debug("All OpenCV strategies failed: %s", tried_strategies)
317
+ return []
318
+
319
+ @staticmethod
320
+ def _decode_with_pyzbar(image: np.ndarray) -> List[Dict[str, Any]]:
321
+ results: List[Dict[str, Any]] = []
322
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
323
+ for obj in pyzbar.decode(gray):
324
+ try:
325
+ data = obj.data.decode("utf-8")
326
+ except UnicodeDecodeError:
327
+ data = obj.data.decode("latin-1", errors="replace")
328
+ bbox = [[p.x, p.y] for p in obj.polygon] if obj.polygon else None
329
+ results.append({
330
+ "data": data,
331
+ "type": obj.type,
332
+ "bounding_box": bbox,
333
+ "decoder": "pyzbar",
334
+ })
335
+ return results
336
+
337
+ # ------------------------------------------------------------------
338
+ # URL handling
339
+ # ------------------------------------------------------------------
340
+
341
+ @staticmethod
342
+ def _is_url(source: str) -> bool:
343
+ try:
344
+ parsed = urlparse(source)
345
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
346
+ except Exception:
347
+ return False
348
+
349
+ def _validate_url_is_safe(self, url: str) -> None:
350
+ if self._allow_private_network_urls:
351
+ return
352
+
353
+ hostname = urlparse(url).hostname
354
+ if not hostname:
355
+ raise QRCodeExtractionError("URL has no hostname")
356
+
357
+ try:
358
+ resolved = socket.getaddrinfo(hostname, None)
359
+ except socket.gaierror as exc:
360
+ raise QRCodeExtractionError(
361
+ f"Could not resolve host '{hostname}': {exc}"
362
+ ) from exc
363
+
364
+ for family, _, _, _, sockaddr in resolved:
365
+ ip_str = sockaddr[0]
366
+ try:
367
+ ip_obj = ipaddress.ip_address(ip_str)
368
+ except ValueError:
369
+ continue
370
+ if (
371
+ ip_obj.is_private
372
+ or ip_obj.is_loopback
373
+ or ip_obj.is_link_local
374
+ or ip_obj.is_reserved
375
+ ):
376
+ raise QRCodeExtractionError(
377
+ f"Refusing to fetch URL: host resolves to a "
378
+ f"non-public address ({ip_str})"
379
+ )
380
+
381
+ async def _download(self, url: str) -> bytes:
382
+ try:
383
+ async with httpx.AsyncClient(
384
+ timeout=self._timeout, follow_redirects=True
385
+ ) as client:
386
+ async with client.stream("GET", url) as resp:
387
+ resp.raise_for_status()
388
+
389
+ content_length = resp.headers.get("Content-Length")
390
+ if content_length is not None:
391
+ try:
392
+ if int(content_length) > self._max_file_size:
393
+ raise QRCodeExtractionError(
394
+ f"Remote file too large "
395
+ f"(Content-Length={content_length} bytes)"
396
+ )
397
+ except ValueError:
398
+ pass
399
+
400
+ chunks = []
401
+ total = 0
402
+ async for chunk in resp.aiter_bytes(chunk_size=65536):
403
+ total += len(chunk)
404
+ if total > self._max_file_size:
405
+ raise QRCodeExtractionError(
406
+ f"Download exceeded max allowed size "
407
+ f"of {self._max_file_size} bytes"
408
+ )
409
+ chunks.append(chunk)
410
+
411
+ data = b"".join(chunks)
412
+ if not data:
413
+ raise QRCodeExtractionError(
414
+ "Downloaded content was empty"
415
+ )
416
+ return data
417
+ except httpx.HTTPStatusError as exc:
418
+ raise QRCodeExtractionError(
419
+ f"Failed to fetch image from URL: HTTP {exc.response.status_code}"
420
+ ) from exc
421
+ except httpx.RequestError as exc:
422
+ raise QRCodeExtractionError(
423
+ f"Failed to fetch image from URL: {exc}"
424
+ ) from exc
425
+
426
+
427
+ def asyncio_get_loop():
428
+ import asyncio
429
+ try:
430
+ return asyncio.get_running_loop()
431
+ except RuntimeError:
432
+ return asyncio.new_event_loop()
app/utils/json_utils.py CHANGED
@@ -1,53 +1,16 @@
1
  from __future__ import annotations
2
 
3
- import json
4
  import logging
5
- import re
6
  from typing import Any, List, Optional
7
 
8
  logger = logging.getLogger(__name__)
9
 
10
 
11
  def extract_json_blocks(text: str) -> List[Any]:
12
- blocks: List[Any] = []
13
-
14
- stripped = text.strip()
15
- try:
16
- blocks.append(json.loads(stripped))
17
- return blocks
18
- except json.JSONDecodeError:
19
- pass
20
-
21
- pattern = r"```json\s*\n?(.*?)```"
22
- for match in re.findall(pattern, text, re.DOTALL):
23
- stripped = match.strip()
24
- if stripped:
25
- try:
26
- blocks.append(json.loads(stripped))
27
- except json.JSONDecodeError:
28
- logger.warning("Failed to parse JSON block: %s", stripped[:100])
29
-
30
- if not blocks:
31
- for delim in (("{", "}"), ("[", "]")):
32
- start = text.find(delim[0])
33
- end = text.rfind(delim[1])
34
- if start != -1 and end != -1 and end > start:
35
- candidate = text[start : end + 1]
36
- try:
37
- blocks.append(json.loads(candidate))
38
- except json.JSONDecodeError:
39
- pass
40
-
41
- return blocks
42
 
43
 
44
  def extract_single_json(text: str) -> Optional[Any]:
45
- """Extract a single JSON value from text, optimized for structured output.
46
-
47
- Tries in order:
48
- 1. Parse entire text as JSON
49
- 2. Extract from ```json code fences
50
- 3. Find first {..} or [..] block
51
- """
52
- blocks = extract_json_blocks(text)
53
- return blocks[0] if blocks else None
 
1
  from __future__ import annotations
2
 
 
3
  import logging
 
4
  from typing import Any, List, Optional
5
 
6
  logger = logging.getLogger(__name__)
7
 
8
 
9
  def extract_json_blocks(text: str) -> List[Any]:
10
+ from app.services.json_service import extract_json_from_content as _extract
11
+ return _extract(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  def extract_single_json(text: str) -> Optional[Any]:
15
+ from app.services.json_service import extract_first_json as _extract
16
+ return _extract(text)
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -35,7 +35,7 @@ dependencies = [
35
  ]
36
 
37
  [project.optional-dependencies]
38
- dev = ["pytest>=8", "pytest-asyncio>=0.23"]
39
 
40
  [tool.pytest.ini_options]
41
  asyncio_mode = "auto"
 
35
  ]
36
 
37
  [project.optional-dependencies]
38
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "qrcode[pil]>=8.0"]
39
 
40
  [tool.pytest.ini_options]
41
  asyncio_mode = "auto"
requirements.txt CHANGED
@@ -10,6 +10,9 @@ numpy>=1.26.0
10
  rapidocr-onnxruntime>=1.4.4
11
  onnxruntime>=1.18.0
12
  pillow>=10.0.0
 
 
 
13
  pypdfium2>=4.30.0
14
  pandas>=2.0.0
15
  matplotlib>=3.8.0
@@ -58,3 +61,6 @@ Unidecode>=1.3.8
58
 
59
  # JSON Schema validation
60
  jsonschema>=4.21.0
 
 
 
 
10
  rapidocr-onnxruntime>=1.4.4
11
  onnxruntime>=1.18.0
12
  pillow>=10.0.0
13
+
14
+ # QR / image processing
15
+ opencv-python-headless>=4.9.0
16
  pypdfium2>=4.30.0
17
  pandas>=2.0.0
18
  matplotlib>=3.8.0
 
61
 
62
  # JSON Schema validation
63
  jsonschema>=4.21.0
64
+
65
+ # QR code generation (testing)
66
+ qrcode[pil]>=8.0