CI commited on
Commit
64640e1
·
0 Parent(s):

deploy from 29315abadb924caea469367181002762d487b1b7

Browse files
Files changed (3) hide show
  1. Dockerfile +19 -0
  2. README.md +11 -0
  3. api.py +419 -0
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13
2
+
3
+ RUN useradd -m -u 1000 user
4
+
5
+ WORKDIR /app
6
+
7
+ RUN mkdir -p logs
8
+
9
+ RUN pip install uv
10
+
11
+ # RUN uv pip install --system git+https://github.com/mpilhlt/llamore.git[api]
12
+ RUN uv pip install --system "git+https://github.com/mpilhlt/llamore.git@feat/add_webservice_api#egg=llamore[api]"
13
+
14
+ COPY --chown=user api.py .
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
19
+
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Api
3
+ emoji: 👁
4
+ colorFrom: indigo
5
+ colorTo: pink
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: FastAPI for llamore
9
+ ---
10
+
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
api.py ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import secrets
4
+ import tempfile
5
+ import traceback
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ from typing import Annotated, Any, Dict, List, Literal, Optional
9
+
10
+ from fastapi import Depends, FastAPI, Form, HTTPException, Request, Security, UploadFile
11
+ from fastapi.responses import JSONResponse
12
+ from fastapi.security import APIKeyHeader
13
+
14
+ from llamore import (
15
+ GeminiExtractor,
16
+ LineByLinePrompter,
17
+ OpenaiExtractor,
18
+ References,
19
+ SchemaPrompter,
20
+ )
21
+ from pydantic import BaseModel, BeforeValidator, Field
22
+
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
26
+ handlers=[logging.StreamHandler()],
27
+ )
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # ===== Config =====
32
+
33
+ ALLOWED_API_KEY = os.getenv("ALLOWED_API_KEY")
34
+ if not ALLOWED_API_KEY:
35
+ raise ValueError("ALLOWED_API_KEY environment variable must be set")
36
+
37
+ MAX_PDF_SIZE_BYTES = int(os.getenv("MAX_PDF_SIZE_MB", "50")) * 1024 * 1024
38
+
39
+
40
+ # ===== Types =====
41
+
42
+ def _coerce_dict(v: Any) -> Optional[Dict[str, Any]]:
43
+ """Accept a dict, None, or empty string; reject anything else."""
44
+ if v is None or v == "":
45
+ return None
46
+ if isinstance(v, dict):
47
+ return v
48
+ raise ValueError(f"Expected a JSON object, got {type(v).__name__!r}")
49
+
50
+
51
+ OptionalDict = Annotated[Optional[Dict[str, Any]], BeforeValidator(_coerce_dict)]
52
+
53
+
54
+ # ===== Auth =====
55
+
56
+ api_key_header = APIKeyHeader(name="X-Llamore-API-Key", scheme_name="Llamore API Key", auto_error=False)
57
+ provider_key_header = APIKeyHeader(name="X-LLM-Provider-Key", scheme_name="LLM Provider Key", auto_error=False)
58
+
59
+
60
+ def api_error(detail: str, status_code: int = 400) -> HTTPException:
61
+ """Create an HTTPException with server-side logging."""
62
+ logger.error(detail)
63
+ return HTTPException(status_code=status_code, detail=detail)
64
+
65
+
66
+ async def verify_api_key(api_key: str = Security(api_key_header)):
67
+ if not api_key or not secrets.compare_digest(api_key, ALLOWED_API_KEY):
68
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
69
+ return api_key
70
+
71
+
72
+ async def verify_provider_key(provider_api_key: str = Security(provider_key_header)):
73
+ if not provider_api_key or not provider_api_key.strip():
74
+ raise HTTPException(status_code=401, detail="Missing or empty provider API key")
75
+ return provider_api_key
76
+
77
+
78
+ # ===== App =====
79
+
80
+ @asynccontextmanager
81
+ async def lifespan(app: FastAPI):
82
+ logger.info("Starting llamore FastAPI application")
83
+ yield
84
+ logger.info("Shutting down llamore FastAPI application")
85
+
86
+
87
+ app = FastAPI(
88
+ title="Llamore API",
89
+ description="API for extracting and processing scholarly references using llamore",
90
+ version="1.0.0",
91
+ lifespan=lifespan,
92
+ )
93
+
94
+
95
+ @app.exception_handler(Exception)
96
+ async def global_exception_handler(request: Request, exc: Exception):
97
+ if isinstance(exc, HTTPException):
98
+ raise exc
99
+ logger.error(
100
+ f"Unhandled exception in {request.method} {request.url.path}:\n{traceback.format_exc()}"
101
+ )
102
+ return JSONResponse(
103
+ status_code=500,
104
+ content={"detail": "An internal server error occurred."},
105
+ )
106
+
107
+
108
+ # ===== Schemas =====
109
+
110
+ class BaseExtractionConfig(BaseModel):
111
+ """Options shared across all providers and input types."""
112
+
113
+ prompter_type: Literal["schema", "line_by_line"] = Field(
114
+ "schema", description="Prompter type for extraction.",
115
+ )
116
+ step_by_step: bool = Field(
117
+ False, description="Enable step-by-step extraction (SchemaPrompter only).",
118
+ )
119
+ extra_api_kwargs: OptionalDict = Field(
120
+ None, description="Extra keyword arguments forwarded to the provider's generate call.",
121
+ )
122
+ return_xml: bool = Field(
123
+ False, description="If true, also return a TEI XML representation of the extracted references.",
124
+ )
125
+
126
+
127
+ class OpenaiExtractionConfig(BaseExtractionConfig):
128
+ """OpenAI-specific extraction options."""
129
+
130
+ model: str = Field("gpt-4o", description="OpenAI model name.")
131
+ endpoint: Literal["create", "parse"] = Field(
132
+ "create",
133
+ description=(
134
+ "'parse' uses beta.chat.completions.parse for native structured output "
135
+ "and requires a compatible model. "
136
+ "Cannot be combined with prompter_type='line_by_line'."
137
+ ),
138
+ )
139
+ client_kwargs: OptionalDict = Field(
140
+ None,
141
+ description=(
142
+ "Extra keyword arguments forwarded to the openai.OpenAI() constructor "
143
+ "(e.g. base_url for Ollama/vLLM/SGLang-compatible endpoints, "
144
+ "timeout, max_retries, default_headers)."
145
+ ),
146
+ )
147
+
148
+ @classmethod
149
+ def as_form(
150
+ cls,
151
+ model: str = Form("gpt-4o"),
152
+ prompter_type: Literal["schema", "line_by_line"] = Form("schema"),
153
+ step_by_step: bool = Form(False),
154
+ endpoint: Literal["create", "parse"] = Form("create"),
155
+ client_kwargs: OptionalDict = Form(None),
156
+ extra_api_kwargs: OptionalDict = Form(None),
157
+ return_xml: bool = Form(False),
158
+ ) -> "OpenaiExtractionConfig":
159
+ return cls(
160
+ model=model,
161
+ prompter_type=prompter_type,
162
+ step_by_step=step_by_step,
163
+ endpoint=endpoint,
164
+ client_kwargs=client_kwargs,
165
+ extra_api_kwargs=extra_api_kwargs,
166
+ return_xml=return_xml,
167
+ )
168
+
169
+
170
+ class GeminiExtractionConfig(BaseExtractionConfig):
171
+ """Gemini-specific extraction options."""
172
+
173
+ model: str = Field("gemini-2.5-flash", description="Gemini model name.")
174
+
175
+ @classmethod
176
+ def as_form(
177
+ cls,
178
+ model: str = Form("gemini-2.5-flash"),
179
+ prompter_type: Literal["schema", "line_by_line"] = Form("schema"),
180
+ step_by_step: bool = Form(False),
181
+ extra_api_kwargs: OptionalDict = Form(None),
182
+ return_xml: bool = Form(False),
183
+ ) -> "GeminiExtractionConfig":
184
+ return cls(
185
+ model=model,
186
+ prompter_type=prompter_type,
187
+ step_by_step=step_by_step,
188
+ extra_api_kwargs=extra_api_kwargs,
189
+ return_xml=return_xml,
190
+ )
191
+
192
+
193
+ class OpenaiExtractTextRequest(OpenaiExtractionConfig):
194
+ """Request body for OpenAI text extraction."""
195
+
196
+ text: str = Field(..., min_length=1, description="Raw text to extract references from.")
197
+
198
+
199
+
200
+ class GeminiExtractTextRequest(GeminiExtractionConfig):
201
+ """Request body for Gemini text extraction."""
202
+
203
+ text: str = Field(..., min_length=1, description="Raw text to extract references from.")
204
+
205
+
206
+
207
+ class ReferencesResponse(BaseModel):
208
+ """Response containing extracted references and optional TEI XML."""
209
+
210
+ references: List[Dict[str, Any]] = Field(
211
+ ..., description="List of extracted references as JSON objects.",
212
+ )
213
+ xml: Optional[str] = Field(
214
+ None, description="TEI XML representation of references (only present if return_xml=True).",
215
+ )
216
+
217
+
218
+ # ===== Factories =====
219
+
220
+ def _build_prompter(
221
+ prompter_type: Literal["schema", "line_by_line"],
222
+ step_by_step: bool,
223
+ endpoint: Literal["create", "parse"] = "create",
224
+ ):
225
+ if prompter_type == "line_by_line":
226
+ if endpoint == "parse":
227
+ raise api_error(
228
+ "The 'parse' endpoint is incompatible with the 'line_by_line' prompter."
229
+ )
230
+ return LineByLinePrompter()
231
+ elif prompter_type == "schema":
232
+ return SchemaPrompter(step_by_step=step_by_step)
233
+ else:
234
+ raise api_error(
235
+ f"Unsupported prompter_type '{prompter_type}'. Choose 'schema' or 'line_by_line'."
236
+ )
237
+
238
+
239
+ def create_openai_extractor(
240
+ provider_api_key: str,
241
+ config: OpenaiExtractionConfig,
242
+ ) -> OpenaiExtractor:
243
+ prompter = _build_prompter(config.prompter_type, config.step_by_step, config.endpoint)
244
+ return OpenaiExtractor(
245
+ api_key=provider_api_key,
246
+ model=config.model,
247
+ prompter=prompter,
248
+ endpoint=config.endpoint,
249
+ **(config.client_kwargs or {}),
250
+ )
251
+
252
+
253
+ def create_gemini_extractor(
254
+ provider_api_key: str,
255
+ config: GeminiExtractionConfig,
256
+ ) -> GeminiExtractor:
257
+ prompter = _build_prompter(config.prompter_type, config.step_by_step)
258
+ return GeminiExtractor(
259
+ api_key=provider_api_key,
260
+ model=config.model,
261
+ prompter=prompter,
262
+ )
263
+
264
+
265
+ def references_to_response(references: References, return_xml: bool) -> ReferencesResponse:
266
+ refs_dict = [ref.model_dump(exclude_none=True) for ref in references]
267
+ xml: Optional[str] = None
268
+ if return_xml and references:
269
+ try:
270
+ xml = references.to_xml(pretty_print=True)
271
+ except Exception:
272
+ logger.warning("Failed to convert references to TEI XML.", exc_info=True)
273
+ return ReferencesResponse(references=refs_dict, xml=xml)
274
+
275
+
276
+ async def _read_and_validate_pdf(file: UploadFile) -> bytes:
277
+ if not file.filename or not file.filename.lower().endswith(".pdf"):
278
+ raise api_error("A valid .pdf file is required.")
279
+ content = await file.read()
280
+ if not content:
281
+ raise api_error("Uploaded file is empty.")
282
+ if len(content) > MAX_PDF_SIZE_BYTES:
283
+ raise api_error(
284
+ f"PDF exceeds the maximum allowed size of {MAX_PDF_SIZE_BYTES // (1024 * 1024)} MB.",
285
+ status_code=413,
286
+ )
287
+ return content
288
+
289
+
290
+ async def _run_pdf_extraction(
291
+ extractor,
292
+ file: UploadFile,
293
+ extra_api_kwargs: OptionalDict,
294
+ return_xml: bool,
295
+ ) -> ReferencesResponse:
296
+ content = await _read_and_validate_pdf(file)
297
+ tmp_path: Optional[Path] = None
298
+ try:
299
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
300
+ tmp.write(content)
301
+ tmp_path = Path(tmp.name)
302
+ try:
303
+ references = extractor(pdf=tmp_path, **(extra_api_kwargs or {}))
304
+ except HTTPException:
305
+ raise
306
+ except Exception:
307
+ logger.error("PDF extraction failed for '%s'.", file.filename, exc_info=True)
308
+ raise api_error("Reference extraction failed. Check server logs for details.")
309
+ finally:
310
+ if tmp_path and tmp_path.exists():
311
+ try:
312
+ tmp_path.unlink()
313
+ except Exception:
314
+ logger.warning("Could not delete temporary file '%s'.", tmp_path, exc_info=True)
315
+
316
+ logger.info("Extracted %d references from '%s'.", len(references), file.filename)
317
+ return references_to_response(references, return_xml)
318
+
319
+
320
+ # ===== Endpoints =====
321
+
322
+ @app.get("/")
323
+ async def root():
324
+ return {
325
+ "message": "Llamore API",
326
+ "version": "1.0.0",
327
+ "endpoints": {
328
+ "extract_openai_text": "/extract/openai/text",
329
+ "extract_openai_pdf": "/extract/openai/pdf",
330
+ "extract_gemini_text": "/extract/gemini/text",
331
+ "extract_gemini_pdf": "/extract/gemini/pdf",
332
+ "health": "/health",
333
+ },
334
+ }
335
+
336
+
337
+ @app.get("/health")
338
+ async def health_check():
339
+ return {"status": "healthy", "service": "llamore-api"}
340
+
341
+
342
+ @app.post("/extract/openai/text", response_model=ReferencesResponse)
343
+ async def extract_openai_text(
344
+ request: OpenaiExtractTextRequest,
345
+ provider_api_key: str = Security(verify_provider_key),
346
+ _: str = Security(verify_api_key),
347
+ ):
348
+ """Extract references from plain text using OpenAI."""
349
+ if not request.text.strip():
350
+ raise api_error("Text cannot be empty.")
351
+ try:
352
+ extractor = create_openai_extractor(provider_api_key, request)
353
+ references = extractor(
354
+ text=request.text,
355
+ **(request.extra_api_kwargs or {}),
356
+ )
357
+ except HTTPException:
358
+ raise
359
+ except Exception:
360
+ logger.error("Text extraction failed.", exc_info=True)
361
+ raise api_error("Reference extraction failed. Check server logs for details.")
362
+
363
+ logger.info("Extracted %d references from text.", len(references))
364
+ return references_to_response(references, request.return_xml)
365
+
366
+
367
+ @app.post("/extract/openai/pdf", response_model=ReferencesResponse)
368
+ async def extract_openai_pdf(
369
+ file: UploadFile,
370
+ config: OpenaiExtractionConfig = Depends(OpenaiExtractionConfig.as_form),
371
+ provider_api_key: str = Security(verify_provider_key),
372
+ _: str = Security(verify_api_key),
373
+ ):
374
+ """Extract references from a PDF file using OpenAI."""
375
+ try:
376
+ extractor = create_openai_extractor(provider_api_key, config)
377
+ except HTTPException:
378
+ raise
379
+ return await _run_pdf_extraction(extractor, file, config.extra_api_kwargs, config.return_xml)
380
+
381
+
382
+ @app.post("/extract/gemini/text", response_model=ReferencesResponse)
383
+ async def extract_gemini_text(
384
+ request: GeminiExtractTextRequest,
385
+ provider_api_key: str = Security(verify_provider_key),
386
+ _: str = Security(verify_api_key),
387
+ ):
388
+ """Extract references from plain text using Gemini."""
389
+ if not request.text.strip():
390
+ raise api_error("Text cannot be empty.")
391
+ try:
392
+ extractor = create_gemini_extractor(provider_api_key, request)
393
+ references = extractor(
394
+ text=request.text,
395
+ **(request.extra_api_kwargs or {}),
396
+ )
397
+ except HTTPException:
398
+ raise
399
+ except Exception:
400
+ logger.error("Text extraction failed.", exc_info=True)
401
+ raise api_error("Reference extraction failed. Check server logs for details.")
402
+
403
+ logger.info("Extracted %d references from text.", len(references))
404
+ return references_to_response(references, request.return_xml)
405
+
406
+
407
+ @app.post("/extract/gemini/pdf", response_model=ReferencesResponse)
408
+ async def extract_gemini_pdf(
409
+ file: UploadFile,
410
+ config: GeminiExtractionConfig = Depends(GeminiExtractionConfig.as_form),
411
+ provider_api_key: str = Security(verify_provider_key),
412
+ _: str = Security(verify_api_key),
413
+ ):
414
+ """Extract references from a PDF file using Gemini."""
415
+ try:
416
+ extractor = create_gemini_extractor(provider_api_key, config)
417
+ except HTTPException:
418
+ raise
419
+ return await _run_pdf_extraction(extractor, file, config.extra_api_kwargs, config.return_xml)