Merge branch 'main' into copilot/add-self-hosted-ocr-support

This commit is contained in:
Christian Krakau-Louis
2026-02-23 23:51:43 +01:00
committed by GitHub
18 changed files with 1187 additions and 112 deletions
+133 -3
View File
@@ -1,14 +1,20 @@
"""
AI provider and OpenAI API endpoints.
Exposes two endpoints:
- GET /api/ai/test tests the currently configured AI provider (generic, provider-agnostic)
- GET /api/openai/test backward-compatible alias that tests the OpenAI API specifically
Exposes three endpoints:
- GET /api/ai/test tests the currently configured AI provider (generic, provider-agnostic)
- GET /api/openai/test backward-compatible alias that tests the OpenAI API specifically
- POST /api/ai/test-extraction runs the metadata-extraction prompt against the configured AI provider
with caller-supplied plaintext and returns the raw response, parsed JSON,
and extracted tags so operators can evaluate model quality.
"""
import json
import logging
import re
from fastapi import APIRouter, Request
from pydantic import BaseModel, Field
from app.auth import require_login
from app.config import settings
@@ -18,6 +24,10 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# Maximum number of characters accepted for a test-extraction request.
# Keeps individual requests reasonable without blocking any real-world document.
_MAX_EXTRACTION_TEXT_LEN = 50_000
def _get_exception_chain_detail(exc: Exception) -> str:
"""
@@ -202,3 +212,123 @@ async def test_ai_provider_connection(request: Request):
"message": f"Connection failed: {detail}",
"provider": provider_name,
}
class ExtractionTestRequest(BaseModel):
"""Request body for the AI extraction test endpoint."""
text: str = Field(..., min_length=1, max_length=_MAX_EXTRACTION_TEXT_LEN, description="Plain-text document content")
def _build_extraction_prompt(text: str) -> str:
"""Return the metadata-extraction prompt used in the standard processing pipeline."""
return (
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
"Extract and return the following fields:\n"
"1. **filename**: Machine-readable filename "
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
'3. **absender**: The sender, or "Unknown" if not found.\n'
"4. **correspondent**: The entity or company that issued the document "
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
"Private_Korrespondenz, Sonstige_Informationen].\n"
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
"10. **title**: A human-readable title summarizing the document content.\n"
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
"of the extracted metadata.\n"
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
"### Important Rules:\n"
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
"- **Output Language**: Maintain the document's original language.\n\n"
f"Extracted text:\n{text}\n\n"
"Return only valid JSON with no additional commentary.\n"
)
def _extract_json_from_text(text: str):
"""Try to extract a JSON object from the LLM response text."""
pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
match = re.search(pattern, text, re.DOTALL)
if match:
return match.group(1)
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return None
@router.post("/ai/test-extraction")
@require_login
async def test_ai_extraction(request: Request, body: ExtractionTestRequest):
"""
Run the metadata-extraction prompt against the configured AI provider.
Accepts plain-text document content, sends it through the same prompt used
by the background processing pipeline, and returns:
- ``raw_response``: verbatim LLM output
- ``parsed_json``: the extracted JSON object (null when parsing fails)
- ``tags``: the ``tags`` list from the parsed JSON (empty list on failure)
- ``provider`` / ``model``: which provider / model was used
"""
from app.utils.ai_provider import get_ai_provider
provider_name = settings.ai_provider
model = settings.ai_model or settings.openai_model
logger.info(f"AI extraction test requested: provider={provider_name}, model={model}")
try:
provider = get_ai_provider()
prompt = _build_extraction_prompt(body.text)
raw_response = provider.chat_completion(
messages=[
{"role": "system", "content": "You are an intelligent document classifier."},
{"role": "user", "content": prompt},
],
model=model,
temperature=0,
)
except ValueError as e:
logger.warning(f"AI extraction test configuration error: {e}")
return {"status": "error", "message": str(e), "provider": provider_name}
except Exception as e:
detail = _get_exception_chain_detail(e)
logger.error(f"AI extraction test failed for provider '{provider_name}': {detail}", exc_info=True)
return {"status": "error", "message": f"AI call failed: {detail}", "provider": provider_name}
# Attempt to parse JSON from the response
parsed_json = None
tags: list = []
parse_error = None
json_text = _extract_json_from_text(raw_response)
if json_text:
try:
parsed_json = json.loads(json_text)
tags = parsed_json.get("tags", [])
except json.JSONDecodeError as exc:
parse_error = str(exc)
logger.warning(f"AI extraction test: JSON parse error: {exc}")
else:
parse_error = "No JSON object found in response"
return {
"status": "success",
"provider": provider_name,
"model": model,
"raw_response": raw_response,
"parsed_json": parsed_json,
"tags": tags,
"parse_error": parse_error,
}
+91 -49
View File
@@ -11,6 +11,7 @@ See the Configuration Guide for full details on each provider's settings.
"""
import logging
import re
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
@@ -19,6 +20,50 @@ from app.config import settings
logger = logging.getLogger(__name__)
def _resolve_temperature(model: str, requested: float) -> Optional[float]:
"""Return a temperature value compatible with the given model, or ``None`` to omit it.
Certain model families have restrictions on the ``temperature`` parameter:
* **o-series reasoning models** (``o1``, ``o3``, ``o4``, …) do not accept
a ``temperature`` argument at all. Return ``None`` so callers can skip the
parameter entirely.
* **gpt-5 family** (``gpt-5``, ``gpt-5-nano``, ``gpt-5-codex``, …) only
``temperature=1`` is accepted; passing ``0`` raises a 400 error. Return
``1`` and emit a debug log so the caller is aware of the coercion.
* All other models return the requested value unchanged.
The model string may include a provider prefix (e.g. ``openai/gpt-4o``);
only the part after the last ``/`` is examined.
Args:
model: Model identifier (may include a provider prefix).
requested: The temperature the caller wants to use.
Returns:
A compatible temperature float, or ``None`` if temperature should be
omitted from the API call.
"""
bare = model.lower().split("/")[-1]
# o-series reasoning models (o1, o3, o4 …) do not support temperature
if re.match(r"^o\d+(-|$)", bare):
logger.debug("Dropping temperature parameter for reasoning model '%s' (not supported)", model)
return None
# gpt-5 family only supports temperature=1
if bare.startswith("gpt-5"):
if requested != 1.0:
logger.debug(
"Coercing temperature from %s to 1 for model '%s' (only temperature=1 is supported)",
requested,
model,
)
return 1.0
return requested
def _require_text_content(content: Optional[str]) -> str:
"""Raise a clear error if the AI response contains no text content.
@@ -101,12 +146,12 @@ class OpenAIProvider(AIProvider):
temperature: float = 0,
**kwargs: Any,
) -> str:
completion = self._client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
**kwargs,
)
call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
call_kwargs["temperature"] = safe_temp
call_kwargs.update(kwargs)
completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content
return _require_text_content(_content)
@@ -130,12 +175,12 @@ class AzureOpenAIProvider(AIProvider):
temperature: float = 0,
**kwargs: Any,
) -> str:
completion = self._client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
**kwargs,
)
call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
call_kwargs["temperature"] = safe_temp
call_kwargs.update(kwargs)
completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content
return _require_text_content(_content)
@@ -161,13 +206,12 @@ class AnthropicProvider(AIProvider):
import litellm
model_name = model if model.startswith("anthropic/") else f"anthropic/{model}"
response = litellm.completion(
model=model_name,
messages=messages,
temperature=temperature,
api_key=self._api_key,
**kwargs,
)
call_kwargs: Dict[str, Any] = {"model": model_name, "messages": messages, "api_key": self._api_key}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
call_kwargs["temperature"] = safe_temp
call_kwargs.update(kwargs)
response = litellm.completion(**call_kwargs)
_content = response.choices[0].message.content
return _require_text_content(_content)
@@ -193,13 +237,12 @@ class GeminiProvider(AIProvider):
import litellm
model_name = model if model.startswith("gemini/") else f"gemini/{model}"
response = litellm.completion(
model=model_name,
messages=messages,
temperature=temperature,
api_key=self._api_key,
**kwargs,
)
call_kwargs: Dict[str, Any] = {"model": model_name, "messages": messages, "api_key": self._api_key}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
call_kwargs["temperature"] = safe_temp
call_kwargs.update(kwargs)
response = litellm.completion(**call_kwargs)
_content = response.choices[0].message.content
return _require_text_content(_content)
@@ -235,12 +278,12 @@ class OllamaProvider(AIProvider):
temperature: float = 0,
**kwargs: Any,
) -> str:
completion = self._client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
**kwargs,
)
call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
call_kwargs["temperature"] = safe_temp
call_kwargs.update(kwargs)
completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content
return _require_text_content(_content)
@@ -269,12 +312,12 @@ class OpenRouterProvider(AIProvider):
temperature: float = 0,
**kwargs: Any,
) -> str:
completion = self._client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
**kwargs,
)
call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
call_kwargs["temperature"] = safe_temp
call_kwargs.update(kwargs)
completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content
return _require_text_content(_content)
@@ -335,12 +378,12 @@ class PortkeyProvider(AIProvider):
temperature: float = 0,
**kwargs: Any,
) -> str:
completion = self._client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
**kwargs,
)
call_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
call_kwargs["temperature"] = safe_temp
call_kwargs.update(kwargs)
completion = self._client.chat.completions.create(**call_kwargs)
_content = completion.choices[0].message.content
return _require_text_content(_content)
@@ -371,11 +414,10 @@ class LiteLLMProvider(AIProvider):
) -> str:
import litellm
completion_kwargs: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
}
completion_kwargs: Dict[str, Any] = {"model": model, "messages": messages}
safe_temp = _resolve_temperature(model, temperature)
if safe_temp is not None:
completion_kwargs["temperature"] = safe_temp
if self._api_key:
completion_kwargs["api_key"] = self._api_key
if self._api_base:
+48 -2
View File
@@ -154,10 +154,33 @@ SETTING_METADATA = {
"openai_model": {
"category": "AI Services",
"description": "Fallback model name used when AI_MODEL is not set (e.g. gpt-4o-mini)",
"type": "string",
"type": "model_picker",
"sensitive": False,
"required": False,
"restart_required": False,
"suggested_models": [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
"o1",
"o1-mini",
"o3",
"o3-mini",
"gpt-5",
"gpt-5-nano",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-2.0-flash-exp",
"llama3.2",
"qwen2.5:7b",
"phi3",
"mistral",
],
},
"ai_provider": {
"category": "AI Services",
@@ -171,10 +194,33 @@ SETTING_METADATA = {
"ai_model": {
"category": "AI Services",
"description": "Model name for the selected provider (overrides OPENAI_MODEL). E.g. gpt-4o, claude-3-5-sonnet-20241022, gemini-1.5-pro, llama3.2",
"type": "string",
"type": "model_picker",
"sensitive": False,
"required": False,
"restart_required": False,
"suggested_models": [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
"o1",
"o1-mini",
"o3",
"o3-mini",
"gpt-5",
"gpt-5-nano",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"gemini-1.5-pro",
"gemini-1.5-flash",
"gemini-2.0-flash-exp",
"llama3.2",
"qwen2.5:7b",
"phi3",
"mistral",
],
},
"anthropic_api_key": {
"category": "AI Services",