Merge branch 'main' into copilot/add-self-hosted-ocr-support
This commit is contained in:
+133
-3
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user