validops-east-1 commited on
Commit
cd6f706
·
1 Parent(s): 978b74e

feat: add local on-device extraction (/json/feature-extract)

Browse files
.env.example CHANGED
@@ -46,6 +46,14 @@ EMBEDDING_DIMENSION=384
46
  DEFAULT_TOP_K=10
47
  DATA_DIR=./data
48
 
 
 
 
 
 
 
 
 
49
  # --- Supabase ---
50
  SUPABASE_URL=https://your-project.supabase.co
51
  SUPABASE_ANON_KEY=
 
46
  DEFAULT_TOP_K=10
47
  DATA_DIR=./data
48
 
49
+ # --- Local on-device extraction (/json/feature-extract) ---
50
+ # All keys are optional; unset values fall back to the defaults shown.
51
+ # GLINER_MODEL=fastino/gliner2-base-v1
52
+ # GLINER_ENABLED=true
53
+ # GLINER_DEVICE=cpu
54
+ # GLINER_MAX_CONCURRENT=2
55
+ # GLINER_MAX_CONTENT_LENGTH=100000
56
+
57
  # --- Supabase ---
58
  SUPABASE_URL=https://your-project.supabase.co
59
  SUPABASE_ANON_KEY=
Dockerfile CHANGED
@@ -95,6 +95,11 @@ RUN chmod +x /app/whatsapp-service/server
95
 
96
  RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='ibm-granite/granite-embedding-small-english-r2', local_dir='/app/models/bge-384')" && chown -R appuser:appuser /app/models
97
 
 
 
 
 
 
98
  RUN mkdir -p /app/data /app/logs && \
99
  chown -R appuser:appuser /app/data /app/logs
100
 
@@ -105,6 +110,9 @@ USER appuser
105
  ENV PYTHONPATH=/app
106
  ENV PYTHONUNBUFFERED=1
107
 
 
 
 
108
  # Path to the embedded WhatsApp service binary (started by start.sh as a
109
  # sibling process). Set to an empty value to disable the WhatsApp gateway.
110
  # The Go service reads all runtime settings (SUPABASE_URL, SUPABASE_DB_URL,
 
95
 
96
  RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='ibm-granite/granite-embedding-small-english-r2', local_dir='/app/models/bge-384')" && chown -R appuser:appuser /app/models
97
 
98
+ # Pre-download the local on-device extraction model (used by /json/feature-extract)
99
+ # so first boot does not hit Hugging Face. The GLINER_MODEL env below points the
100
+ # service at this local copy.
101
+ RUN mkdir -p /app/models && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='fastino/gliner2-base-v1', local_dir='/app/models/gliner2-base-v1')" && chown -R appuser:appuser /app/models
102
+
103
  RUN mkdir -p /app/data /app/logs && \
104
  chown -R appuser:appuser /app/data /app/logs
105
 
 
110
  ENV PYTHONPATH=/app
111
  ENV PYTHONUNBUFFERED=1
112
 
113
+ # Local copy of the on-device extraction model baked in at build time.
114
+ ENV GLINER_MODEL=/app/models/gliner2-base-v1
115
+
116
  # Path to the embedded WhatsApp service binary (started by start.sh as a
117
  # sibling process). Set to an empty value to disable the WhatsApp gateway.
118
  # The Go service reads all runtime settings (SUPABASE_URL, SUPABASE_DB_URL,
app/api/server.py CHANGED
@@ -30,6 +30,7 @@ from app.core.logger import get_logger
30
  from app.core.redis_client import close_redis, create_redis_client
31
  from app.core.scripts import load_scripts
32
  from app.services.embeddings_service import EmbeddingService
 
33
  from app.services.scheduler_service import SchedulerService
34
  from app.services.vector_store_service import VectorStoreService
35
  from app.utils.http_utils import SharedAsyncClient
@@ -143,6 +144,18 @@ async def lifespan(app: FastAPI):
143
  except Exception as exc:
144
  _logger.warning("OCR engine warm-up failed (will lazy-init on first use): %s", exc)
145
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
147
  scripts = await load_scripts(redis) if redis else {}
148
  app.state.redis = redis
@@ -170,6 +183,7 @@ async def lifespan(app: FastAPI):
170
  yield
171
  _logger.info("Shutting down...")
172
  await _scheduler_service.shutdown()
 
173
  await close_redis(redis)
174
  await _vector_store_service.close_all()
175
  await pool_manager.close_all()
 
30
  from app.core.redis_client import close_redis, create_redis_client
31
  from app.core.scripts import load_scripts
32
  from app.services.embeddings_service import EmbeddingService
33
+ from app.services.gliner_service import gliner_service
34
  from app.services.scheduler_service import SchedulerService
35
  from app.services.vector_store_service import VectorStoreService
36
  from app.utils.http_utils import SharedAsyncClient
 
144
  except Exception as exc:
145
  _logger.warning("OCR engine warm-up failed (will lazy-init on first use): %s", exc)
146
 
147
+ # Warm the local GLiNER2 model so the /json/no-ai route never pays the
148
+ # one-time ~5-15s load cost on first request. Loads on the shared thread
149
+ # pool; failure is non-fatal (the route lazy-loads on first use).
150
+ if _settings.gliner_enabled and is_service_enabled("json_extract"):
151
+ try:
152
+ await run_in_executor(gliner_service.load_model)
153
+ _logger.info("GLiNER2 model loaded and cached at startup (no-ai JSON extractor)")
154
+ except Exception as exc:
155
+ _logger.warning("GLiNER2 model warm-up failed (will lazy-load on first use): %s", exc)
156
+ else:
157
+ _logger.info("GLiNER2 no-ai JSON extractor is disabled, skipping model warm-up")
158
+
159
  redis = create_redis_client(_settings.redis_url) if _settings.redis_url else None
160
  scripts = await load_scripts(redis) if redis else {}
161
  app.state.redis = redis
 
183
  yield
184
  _logger.info("Shutting down...")
185
  await _scheduler_service.shutdown()
186
+ gliner_service.unload()
187
  await close_redis(redis)
188
  await _vector_store_service.close_all()
189
  await pool_manager.close_all()
app/api/v1/json_extract.py CHANGED
@@ -1,13 +1,16 @@
1
  from __future__ import annotations
2
 
 
3
  import time
4
- from typing import Any, Optional
5
 
6
  from fastapi import APIRouter, HTTPException
7
  from pydantic import BaseModel, Field
8
 
 
9
  from app.core.logger import get_logger
10
  from app.core.thread_pool import run_in_executor
 
11
  from app.services.json_service import extract_json
12
 
13
  logger = get_logger(__name__)
@@ -119,3 +122,205 @@ async def extract_json_endpoint(
119
  count=result.total_extracted,
120
  error_message=None,
121
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import asyncio
4
  import time
5
+ from typing import Annotated, Any, Dict, List, Optional
6
 
7
  from fastapi import APIRouter, HTTPException
8
  from pydantic import BaseModel, Field
9
 
10
+ from app.config import get_settings
11
  from app.core.logger import get_logger
12
  from app.core.thread_pool import run_in_executor
13
+ from app.services.gliner_service import gliner_service
14
  from app.services.json_service import extract_json
15
 
16
  logger = get_logger(__name__)
 
122
  count=result.total_extracted,
123
  error_message=None,
124
  )
125
+
126
+
127
+ class NoAiExtractRequest(BaseModel):
128
+ content: str = Field(
129
+ ...,
130
+ description=(
131
+ "Raw text to extract from (invoice text, OCR output, emails, etc.). "
132
+ "No external AI/LLM API is called."
133
+ ),
134
+ min_length=1,
135
+ )
136
+ mode: str = Field(
137
+ default="json",
138
+ pattern=r"^(json|entities)$",
139
+ description=(
140
+ "'json' extracts structured fields using a GLiNER2 `structure` schema; "
141
+ "'entities' extracts zero-shot entities using a `labels` list."
142
+ ),
143
+ )
144
+ structure: Optional[Dict[str, Any]] = Field(
145
+ default=None,
146
+ description=(
147
+ "Required for mode='json'. GLiNER2 structure schema mapping a parent "
148
+ "key to field specs, e.g. "
149
+ '{"invoice": ["number::str::Invoice number", "total::str::Total amount"]}. '
150
+ "Field spec: name::dtype::choices::description."
151
+ ),
152
+ )
153
+ labels: Optional[List[str]] = Field(
154
+ default=None,
155
+ description="Required for mode='entities'. Entity types to detect, e.g. ['person', 'company', 'location'].",
156
+ )
157
+ threshold: float = Field(
158
+ default=0.5,
159
+ ge=0.0,
160
+ le=1.0,
161
+ description="Confidence threshold (0.0-1.0). Lower includes more candidates.",
162
+ )
163
+
164
+
165
+ class NoAiExtractResponse(BaseModel):
166
+ success: bool
167
+ time_ms: float
168
+ mode: str
169
+ data: Any = None
170
+ count: int = 0
171
+ error_message: Optional[str] = None
172
+
173
+
174
+ def _count_extracted(result: Any) -> int:
175
+ if not isinstance(result, dict):
176
+ return 0
177
+ total = 0
178
+ for parent, items in result.items():
179
+ if isinstance(items, list):
180
+ total += len(items)
181
+ elif isinstance(items, dict):
182
+ total += 1
183
+ return total
184
+
185
+
186
+ @router.post(
187
+ "/json/feature-extract",
188
+ response_model=List[NoAiExtractResponse],
189
+ summary="Batch-extract structured JSON / entities with a local on-device model (no external AI)",
190
+ description=(
191
+ "Send up to 5 requests as a JSON array (1-5 items). All items are "
192
+ "processed concurrently on the shared thread pool, bounded by the local "
193
+ "model's concurrency limit. No external AI/LLM API is contacted -- ideal "
194
+ "for private or invoice/OCR data. mode='json' uses a structure schema to "
195
+ "pull named fields; mode='entities' detects a flat list of entity types. "
196
+ "Each item reports its own success/error. "
197
+ "Returns HTTP 503 if the model failed to load or is disabled."
198
+ ),
199
+ )
200
+ async def no_ai_extract_endpoint(
201
+ body: Annotated[
202
+ List[NoAiExtractRequest],
203
+ Field(
204
+ min_length=1,
205
+ max_length=5,
206
+ description="Array of up to 5 extraction requests, processed concurrently.",
207
+ ),
208
+ ],
209
+ ) -> List[NoAiExtractResponse]:
210
+ settings = get_settings()
211
+ total_start = time.perf_counter()
212
+
213
+ if not settings.gliner_enabled:
214
+ raise HTTPException(
215
+ status_code=503,
216
+ detail=NoAiExtractResponse(
217
+ success=False,
218
+ time_ms=0.0,
219
+ mode="",
220
+ data=None,
221
+ count=0,
222
+ error_message="The local extraction service is disabled.",
223
+ ).model_dump(),
224
+ )
225
+
226
+ if not gliner_service.is_loaded():
227
+ try:
228
+ await run_in_executor(gliner_service.load_model)
229
+ except Exception:
230
+ logger.exception("Lazy GLiNER2 model load failed on request")
231
+ raise HTTPException(
232
+ status_code=503,
233
+ detail=NoAiExtractResponse(
234
+ success=False,
235
+ time_ms=round((time.perf_counter() - total_start) * 1000, 3),
236
+ mode="",
237
+ data=None,
238
+ count=0,
239
+ error_message="The local extraction model is unavailable. Please try again later.",
240
+ ).model_dump(),
241
+ )
242
+
243
+ async def _process_one(index: int, item: NoAiExtractRequest) -> NoAiExtractResponse:
244
+ start = time.perf_counter()
245
+
246
+ if item.mode == "json" and not item.structure:
247
+ return NoAiExtractResponse(
248
+ success=False,
249
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
250
+ mode=item.mode,
251
+ data=None,
252
+ count=0,
253
+ error_message="mode='json' requires a non-empty 'structure' schema.",
254
+ )
255
+ if item.mode == "entities" and not item.labels:
256
+ return NoAiExtractResponse(
257
+ success=False,
258
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
259
+ mode=item.mode,
260
+ data=None,
261
+ count=0,
262
+ error_message="mode='entities' requires a non-empty 'labels' list.",
263
+ )
264
+ if len(item.content) > gliner_service.max_content_length:
265
+ return NoAiExtractResponse(
266
+ success=False,
267
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
268
+ mode=item.mode,
269
+ data=None,
270
+ count=0,
271
+ error_message=(
272
+ f"Content exceeds maximum length of {gliner_service.max_content_length:,} characters."
273
+ ),
274
+ )
275
+
276
+ try:
277
+ if item.mode == "json":
278
+ result = await run_in_executor(
279
+ gliner_service.extract_json, item.content, item.structure, item.threshold
280
+ )
281
+ else:
282
+ result = await run_in_executor(
283
+ gliner_service.extract_entities, item.content, item.labels, item.threshold
284
+ )
285
+ except Exception:
286
+ logger.exception("GLiNER2 inference failed for item %s", index)
287
+ return NoAiExtractResponse(
288
+ success=False,
289
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
290
+ mode=item.mode,
291
+ data=None,
292
+ count=0,
293
+ error_message="Extraction failed. Please try again later.",
294
+ )
295
+
296
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
297
+ logger.info(
298
+ "Feature-extract item extracted",
299
+ extra={
300
+ "index": index,
301
+ "mode": item.mode,
302
+ "count": _count_extracted(result),
303
+ "time_ms": elapsed,
304
+ },
305
+ )
306
+ return NoAiExtractResponse(
307
+ success=True,
308
+ time_ms=elapsed,
309
+ mode=item.mode,
310
+ data=result,
311
+ count=_count_extracted(result),
312
+ error_message=None,
313
+ )
314
+
315
+ results = await asyncio.gather(
316
+ *[_process_one(index, item) for index, item in enumerate(body)]
317
+ )
318
+
319
+ logger.info(
320
+ "Feature-extract batch processed",
321
+ extra={
322
+ "items": len(body),
323
+ "time_ms": round((time.perf_counter() - total_start) * 1000, 3),
324
+ },
325
+ )
326
+ return list(results)
app/config.py CHANGED
@@ -219,6 +219,16 @@ class Settings(BaseSettings):
219
  scheduler_misfire_grace_time: int = Field(default=300, alias="SCHEDULER_MISFIRE_GRACE_TIME")
220
  scheduler_coordinator_prefix: str = Field(default="scheduler:", alias="SCHEDULER_COORDINATOR_PREFIX")
221
 
 
 
 
 
 
 
 
 
 
 
222
  @property
223
  def max_upload_mb(self) -> int:
224
  return self.max_upload_bytes // (1024 * 1024)
 
219
  scheduler_misfire_grace_time: int = Field(default=300, alias="SCHEDULER_MISFIRE_GRACE_TIME")
220
  scheduler_coordinator_prefix: str = Field(default="scheduler:", alias="SCHEDULER_COORDINATOR_PREFIX")
221
 
222
+ # Local GLiNER2 model for the /json/feature-extract extractor. Runs fully
223
+ # on-device (no external AI/LLM API). The model is loaded once and cached at
224
+ # startup; disable to skip loading and return 503 from the route. Every key
225
+ # is optional -- if not set, the default value below is used.
226
+ gliner_model: str = Field(default="fastino/gliner2-base-v1", alias="GLINER_MODEL")
227
+ gliner_enabled: bool = Field(default=True, alias="GLINER_ENABLED")
228
+ gliner_device: str = Field(default="cpu", alias="GLINER_DEVICE")
229
+ gliner_max_concurrent: int = Field(default=2, alias="GLINER_MAX_CONCURRENT")
230
+ gliner_max_content_length: int = Field(default=100_000, alias="GLINER_MAX_CONTENT_LENGTH")
231
+
232
  @property
233
  def max_upload_mb(self) -> int:
234
  return self.max_upload_bytes // (1024 * 1024)
app/services/gliner_service.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local GLiNER2 extraction service for the ``/json/no-ai`` route.
2
+
3
+ GLiNER2 performs zero-shot entity / structured-JSON extraction entirely
4
+ on-device -- no external AI/LLM API is called, hence the "no-ai" route name.
5
+
6
+ The model is loaded ONCE and cached for the life of the process (warmed at
7
+ application startup via the lifespan hook in ``app/api/server.py`` and lazily
8
+ loaded on first use if startup warm-up failed). Inference is CPU-bound and is
9
+ dispatched on the shared thread pool by the route layer; a bounded semaphore
10
+ serializes concurrent forwards on the shared model instance so a burst of
11
+ requests cannot oversubscribe the CPU or the model's memory buffers.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import io
18
+ import threading
19
+ import time
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from app.config import get_settings
23
+ from app.core.logger import get_logger
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ DEFAULT_MODEL_ID = "fastino/gliner2-base-v1"
28
+ DEFAULT_DEVICE = "cpu"
29
+ DEFAULT_MAX_CONCURRENT = 2
30
+ DEFAULT_MAX_CONTENT_LENGTH = 100_000
31
+
32
+
33
+ class GLiNERService:
34
+ """Cached, concurrency-bounded wrapper around a GLiNER2 model instance."""
35
+
36
+ def __init__(
37
+ self,
38
+ model_id: str = DEFAULT_MODEL_ID,
39
+ device: str = DEFAULT_DEVICE,
40
+ max_concurrent: int = DEFAULT_MAX_CONCURRENT,
41
+ max_content_length: int = DEFAULT_MAX_CONTENT_LENGTH,
42
+ ) -> None:
43
+ self._model_id = model_id
44
+ self._device = device
45
+ self._max_content_length = max_content_length
46
+ self._max_concurrent = max(1, max_concurrent)
47
+ self._model: Any = None
48
+ self._lock = threading.Lock()
49
+ self._semaphore = threading.BoundedSemaphore(self._max_concurrent)
50
+ self._load_error: Optional[str] = None
51
+
52
+ # ------------------------------------------------------------------ #
53
+ # Lifecycle
54
+ # ------------------------------------------------------------------ #
55
+
56
+ def load_model(self) -> None:
57
+ """Load and cache the GLiNER2 model. Idempotent and thread-safe."""
58
+ if self.is_loaded():
59
+ return
60
+ with self._lock:
61
+ if self.is_loaded():
62
+ return
63
+ t0 = time.perf_counter()
64
+ try:
65
+ from gliner2 import GLiNER2
66
+
67
+ # GLiNER2 prints an emoji config banner to stdout on load, which
68
+ # crashes consoles with a non-UTF-8 encoding (e.g. Windows cp1252).
69
+ # Swallow it so loading works everywhere.
70
+ with contextlib.redirect_stdout(io.StringIO()):
71
+ self._model = GLiNER2.from_pretrained(self._model_id, map_location=self._device)
72
+ self._load_error = None
73
+ logger.info(
74
+ "GLiNER2 model loaded and cached (%s, device=%s) in %.2fs",
75
+ self._model_id,
76
+ self._device,
77
+ time.perf_counter() - t0,
78
+ )
79
+ except Exception as exc: # noqa: BLE001
80
+ self._load_error = str(exc)
81
+ logger.exception("GLiNER2 model load failed")
82
+ raise
83
+
84
+ def unload(self) -> None:
85
+ """Release the cached model (frees ~1.3 GB). Used at shutdown."""
86
+ with self._lock:
87
+ self._model = None
88
+ self._load_error = None
89
+
90
+ def is_loaded(self) -> bool:
91
+ return self._model is not None
92
+
93
+ def load_error(self) -> Optional[str]:
94
+ return self._load_error
95
+
96
+ @property
97
+ def model_id(self) -> str:
98
+ return self._model_id
99
+
100
+ @property
101
+ def device(self) -> str:
102
+ return self._device
103
+
104
+ @property
105
+ def max_content_length(self) -> int:
106
+ return self._max_content_length
107
+
108
+ # ------------------------------------------------------------------ #
109
+ # Inference (called on the shared thread pool)
110
+ # ------------------------------------------------------------------ #
111
+
112
+ def extract_json(self, text: str, structure: Dict[str, Any], threshold: float = 0.5) -> Dict[str, Any]:
113
+ """Structured JSON extraction with the cached local model."""
114
+ self._ensure_loaded()
115
+ with self._semaphore:
116
+ return self._model.extract_json(text, structure, threshold=threshold)
117
+
118
+ def extract_entities(self, text: str, labels: List[str], threshold: float = 0.5) -> Dict[str, Any]:
119
+ """Zero-shot entity extraction with the cached local model."""
120
+ self._ensure_loaded()
121
+ with self._semaphore:
122
+ return self._model.extract_entities(text, labels, threshold=threshold)
123
+
124
+ def _ensure_loaded(self) -> None:
125
+ if not self.is_loaded():
126
+ self.load_model()
127
+
128
+
129
+ _settings = get_settings()
130
+
131
+ gliner_service = GLiNERService(
132
+ model_id=_settings.gliner_model,
133
+ device=_settings.gliner_device,
134
+ max_concurrent=_settings.gliner_max_concurrent,
135
+ max_content_length=_settings.gliner_max_content_length,
136
+ )
requirements.txt CHANGED
@@ -42,6 +42,10 @@ xlrd==2.0.1
42
 
43
  # --- ML / embeddings / OCR models ---
44
  zvec==0.4.0
 
 
 
 
45
  sentence-transformers==3.4.1
46
  transformers==4.50.2
47
  torch==2.5.1
 
42
 
43
  # --- ML / embeddings / OCR models ---
44
  zvec==0.4.0
45
+ # Semantic routing (aurelio-labs) used by the /semantic-router/route endpoint
46
+ semantic-router==0.1.16
47
+ # Local on-device model powering the /json/feature-extract route (no external AI).
48
+ gliner2==1.3.2
49
  sentence-transformers==3.4.1
50
  transformers==4.50.2
51
  torch==2.5.1