Merge pull request #377 from christianlouis/copilot/add-status-page-ai-check-button
feat(status): Add AI extraction test tool to /status page
This commit is contained in:
+133
-3
@@ -1,14 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
AI provider and OpenAI API endpoints.
|
AI provider and OpenAI API endpoints.
|
||||||
|
|
||||||
Exposes two endpoints:
|
Exposes three endpoints:
|
||||||
- GET /api/ai/test – tests the currently configured AI provider (generic, provider-agnostic)
|
- 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
|
- 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 logging
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -18,6 +24,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
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:
|
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}",
|
"message": f"Connection failed: {detail}",
|
||||||
"provider": provider_name,
|
"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(body: ExtractionTestRequest, request: Request):
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,6 +181,12 @@
|
|||||||
data-provider="ai_provider">
|
data-provider="ai_provider">
|
||||||
Test Connection
|
Test Connection
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="testAiExtractionBtn"
|
||||||
|
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
<i class="fa-solid fa-flask mr-1"></i>
|
||||||
|
Test Extraction
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif name == "Azure AI" %}
|
{% elif name == "Azure AI" %}
|
||||||
{% if provider.configured %}
|
{% if provider.configured %}
|
||||||
@@ -273,6 +279,85 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Extraction Test Modal -->
|
||||||
|
<div id="aiExtractionModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50" aria-modal="true" role="dialog">
|
||||||
|
<div class="relative top-10 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-2/3 xl:w-1/2 shadow-lg rounded-md bg-white">
|
||||||
|
<div class="absolute top-0 right-0 pt-4 pr-4">
|
||||||
|
<button type="button" id="closeAiExtractionModal" class="text-gray-400 hover:text-gray-500">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<i class="fa-solid fa-xmark h-6 w-6"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-1">
|
||||||
|
<i class="fa-solid fa-flask mr-2 text-indigo-600"></i>AI Extraction Test
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">
|
||||||
|
Paste the plain-text content of a document below and run it through the configured AI provider
|
||||||
|
to inspect the raw response, extracted JSON, and tags.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Input area -->
|
||||||
|
<div id="aiExtractionInput">
|
||||||
|
<label for="aiExtractionText" class="block text-sm font-medium text-gray-700 mb-1">Document Text</label>
|
||||||
|
<textarea
|
||||||
|
id="aiExtractionText"
|
||||||
|
rows="10"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-mono focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
placeholder="Paste the plain-text content of your document here…"></textarea>
|
||||||
|
<div class="mt-3 flex justify-end space-x-2">
|
||||||
|
<button type="button" id="cancelAiExtractionBtn"
|
||||||
|
class="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" id="runAiExtractionBtn"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
<i class="fa-solid fa-play mr-2"></i>Run Extraction
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results area (hidden until extraction completes) -->
|
||||||
|
<div id="aiExtractionResults" class="hidden mt-4 space-y-4">
|
||||||
|
<!-- Provider / model badge -->
|
||||||
|
<div id="aiExtractionMeta" class="text-xs text-gray-500"></div>
|
||||||
|
|
||||||
|
<!-- Parse warning -->
|
||||||
|
<div id="aiParseWarning" class="hidden bg-yellow-50 border-l-4 border-yellow-400 p-3 text-sm text-yellow-800"></div>
|
||||||
|
|
||||||
|
<!-- Raw response -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-1">Raw LLM Response</h4>
|
||||||
|
<pre id="aiRawResponse" class="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-auto max-h-48 whitespace-pre-wrap break-words"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Parsed JSON -->
|
||||||
|
<div id="aiParsedJsonSection">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-1">Parsed JSON</h4>
|
||||||
|
<pre id="aiParsedJson" class="bg-gray-50 border border-gray-200 rounded p-3 text-xs overflow-auto max-h-64 whitespace-pre-wrap break-words"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div id="aiTagsSection">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-1">Extracted Tags</h4>
|
||||||
|
<div id="aiTags" class="flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-2 pt-2">
|
||||||
|
<button type="button" id="aiExtractionBackBtn"
|
||||||
|
class="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
<i class="fa-solid fa-arrow-left mr-1"></i>Back
|
||||||
|
</button>
|
||||||
|
<button type="button" id="aiExtractionCloseBtn"
|
||||||
|
class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-600">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -573,6 +658,152 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── AI Extraction Test Modal ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const aiExtractionModal = document.getElementById('aiExtractionModal');
|
||||||
|
const aiExtractionText = document.getElementById('aiExtractionText');
|
||||||
|
const aiExtractionInput = document.getElementById('aiExtractionInput');
|
||||||
|
const aiExtractionResults = document.getElementById('aiExtractionResults');
|
||||||
|
const aiExtractionMeta = document.getElementById('aiExtractionMeta');
|
||||||
|
const aiRawResponse = document.getElementById('aiRawResponse');
|
||||||
|
const aiParsedJson = document.getElementById('aiParsedJson');
|
||||||
|
const aiParsedJsonSection = document.getElementById('aiParsedJsonSection');
|
||||||
|
const aiTags = document.getElementById('aiTags');
|
||||||
|
const aiTagsSection = document.getElementById('aiTagsSection');
|
||||||
|
const aiParseWarning = document.getElementById('aiParseWarning');
|
||||||
|
const testAiExtractionBtn = document.getElementById('testAiExtractionBtn');
|
||||||
|
const runAiExtractionBtn = document.getElementById('runAiExtractionBtn');
|
||||||
|
const cancelAiExtractionBtn= document.getElementById('cancelAiExtractionBtn');
|
||||||
|
const aiExtractionBackBtn = document.getElementById('aiExtractionBackBtn');
|
||||||
|
const aiExtractionCloseBtn = document.getElementById('aiExtractionCloseBtn');
|
||||||
|
const closeAiExtractionModal = document.getElementById('closeAiExtractionModal');
|
||||||
|
|
||||||
|
function openAiExtractionModal() {
|
||||||
|
// Reset to input view
|
||||||
|
aiExtractionText.value = '';
|
||||||
|
aiExtractionInput.classList.remove('hidden');
|
||||||
|
aiExtractionResults.classList.add('hidden');
|
||||||
|
aiExtractionModal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAiExtractionModalFn() {
|
||||||
|
aiExtractionModal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAiExtractionResults(data) {
|
||||||
|
// Provider / model meta
|
||||||
|
aiExtractionMeta.textContent = `Provider: ${data.provider || '—'} | Model: ${data.model || '—'}`;
|
||||||
|
|
||||||
|
// Raw response
|
||||||
|
aiRawResponse.textContent = data.raw_response || '(empty)';
|
||||||
|
|
||||||
|
// Parse warning
|
||||||
|
if (data.parse_error) {
|
||||||
|
aiParseWarning.textContent = `JSON parse issue: ${data.parse_error}`;
|
||||||
|
aiParseWarning.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
aiParseWarning.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsed JSON
|
||||||
|
if (data.parsed_json) {
|
||||||
|
aiParsedJson.textContent = JSON.stringify(data.parsed_json, null, 2);
|
||||||
|
aiParsedJsonSection.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
aiParsedJsonSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
aiTags.innerHTML = '';
|
||||||
|
const tags = Array.isArray(data.tags) ? data.tags : [];
|
||||||
|
if (tags.length > 0) {
|
||||||
|
tags.forEach(tag => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800';
|
||||||
|
span.textContent = tag;
|
||||||
|
aiTags.appendChild(span);
|
||||||
|
});
|
||||||
|
aiTagsSection.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
aiTagsSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch view
|
||||||
|
aiExtractionInput.classList.add('hidden');
|
||||||
|
aiExtractionResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testAiExtractionBtn) {
|
||||||
|
testAiExtractionBtn.addEventListener('click', openAiExtractionModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeAiExtractionModal) {
|
||||||
|
closeAiExtractionModal.addEventListener('click', closeAiExtractionModalFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelAiExtractionBtn) {
|
||||||
|
cancelAiExtractionBtn.addEventListener('click', closeAiExtractionModalFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiExtractionCloseBtn) {
|
||||||
|
aiExtractionCloseBtn.addEventListener('click', closeAiExtractionModalFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aiExtractionBackBtn) {
|
||||||
|
aiExtractionBackBtn.addEventListener('click', function() {
|
||||||
|
aiExtractionResults.classList.add('hidden');
|
||||||
|
aiExtractionInput.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close when clicking the backdrop
|
||||||
|
if (aiExtractionModal) {
|
||||||
|
aiExtractionModal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === aiExtractionModal) {
|
||||||
|
closeAiExtractionModalFn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runAiExtractionBtn) {
|
||||||
|
runAiExtractionBtn.addEventListener('click', function() {
|
||||||
|
const text = aiExtractionText.value.trim();
|
||||||
|
if (!text) {
|
||||||
|
aiExtractionText.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalHTML = runAiExtractionBtn.innerHTML;
|
||||||
|
runAiExtractionBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-2"></i>Running…';
|
||||||
|
runAiExtractionBtn.disabled = true;
|
||||||
|
cancelAiExtractionBtn.disabled = true;
|
||||||
|
|
||||||
|
fetch('/api/ai/test-extraction', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: text }),
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
showAiExtractionResults(data);
|
||||||
|
} else {
|
||||||
|
closeAiExtractionModalFn();
|
||||||
|
showModal('error', 'AI Extraction Failed', data.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
closeAiExtractionModalFn();
|
||||||
|
showModal('error', 'Connection Error', 'Error running extraction: ' + error.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
runAiExtractionBtn.innerHTML = originalHTML;
|
||||||
|
runAiExtractionBtn.disabled = false;
|
||||||
|
cancelAiExtractionBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -309,3 +309,363 @@ class TestOpenAIConnectionErrors:
|
|||||||
|
|
||||||
assert data["status"] == "error"
|
assert data["status"] == "error"
|
||||||
assert data.get("is_auth_error") is False
|
assert data.get("is_auth_error") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAiExtractionEndpoint:
|
||||||
|
"""Tests for POST /api/ai/test-extraction endpoint."""
|
||||||
|
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_missing_text_returns_422(self, mock_settings, client):
|
||||||
|
"""Test that missing text body returns 422 validation error."""
|
||||||
|
response = client.post("/api/ai/test-extraction", json={})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_empty_text_returns_422(self, mock_settings, client):
|
||||||
|
"""Test that empty string text returns 422 validation error."""
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": ""})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_returns_raw_response_and_parsed_json(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test successful extraction returns raw response, parsed JSON, and tags."""
|
||||||
|
import json as json_module
|
||||||
|
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
expected_json = {
|
||||||
|
"filename": "2024-01-01_Invoice",
|
||||||
|
"tags": ["invoice", "payment"],
|
||||||
|
"title": "January Invoice",
|
||||||
|
"document_type": "Invoice",
|
||||||
|
}
|
||||||
|
raw = "```json\n" + json_module.dumps(expected_json) + "\n```"
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = raw
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": "Invoice content here"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["raw_response"] == raw
|
||||||
|
assert data["parsed_json"]["filename"] == "2024-01-01_Invoice"
|
||||||
|
assert data["tags"] == ["invoice", "payment"]
|
||||||
|
assert data["parse_error"] is None
|
||||||
|
assert data["provider"] == "openai"
|
||||||
|
assert data["model"] == "gpt-4o-mini"
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_handles_invalid_json_in_response(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test that invalid JSON in LLM response is reported via parse_error."""
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = "Sorry, I cannot help with that."
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": "Some document text"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["parsed_json"] is None
|
||||||
|
assert data["tags"] == []
|
||||||
|
assert data["parse_error"] is not None
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_provider_config_error(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test that configuration errors (missing keys) are returned as error status."""
|
||||||
|
mock_settings.ai_provider = "anthropic"
|
||||||
|
mock_settings.ai_model = "claude-3"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
mock_get_provider.side_effect = ValueError("ANTHROPIC_API_KEY must be set")
|
||||||
|
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": "Some document text"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "error"
|
||||||
|
assert "ANTHROPIC_API_KEY" in data["message"]
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_provider_runtime_error(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test that runtime errors during AI call are returned as error status."""
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.side_effect = Exception("Connection refused")
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": "Some document text"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "error"
|
||||||
|
assert "Connection refused" in data["message"]
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_plain_json_without_code_fences(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test that JSON returned without code fences is still parsed correctly."""
|
||||||
|
import json as json_module
|
||||||
|
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
expected_json = {"tags": ["contract"], "title": "Service Agreement"}
|
||||||
|
raw = json_module.dumps(expected_json)
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = raw
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": "Contract content"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["tags"] == ["contract"]
|
||||||
|
assert data["parse_error"] is None
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_json_found_but_invalid_reports_parse_error(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test JSONDecodeError branch: response contains '{...}' but is not valid JSON."""
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
# Looks like JSON (has { and }) but is NOT parseable
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = "{this is not: valid json!!}"
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": "Some document text"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["parsed_json"] is None
|
||||||
|
assert data["tags"] == []
|
||||||
|
assert data["parse_error"] is not None
|
||||||
|
assert "raw_response" in data
|
||||||
|
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_text_too_long_returns_422(self, mock_settings, client):
|
||||||
|
"""Test that text exceeding max length returns 422 validation error."""
|
||||||
|
from app.api.openai import _MAX_EXTRACTION_TEXT_LEN
|
||||||
|
|
||||||
|
oversized_text = "x" * (_MAX_EXTRACTION_TEXT_LEN + 1)
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": oversized_text})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_extraction_response_with_no_tags_key(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test extraction where parsed JSON has no 'tags' key returns empty tags list."""
|
||||||
|
import json as json_module
|
||||||
|
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
# Valid JSON but no 'tags' key
|
||||||
|
payload = {"title": "Report", "document_type": "Report"}
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = json_module.dumps(payload)
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.post("/api/ai/test-extraction", json={"text": "Report content"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["tags"] == []
|
||||||
|
assert data["parsed_json"]["title"] == "Report"
|
||||||
|
assert data["parse_error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAiProviderTestEndpoint:
|
||||||
|
"""Tests for GET /api/ai/test endpoint."""
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_ai_test_success(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test successful AI provider connection."""
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = "ok"
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.get("/api/ai/test")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert "reachable" in data["message"].lower()
|
||||||
|
assert data["provider"] == "openai"
|
||||||
|
assert data["model"] == "gpt-4o-mini"
|
||||||
|
assert "response_preview" in data
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_ai_test_value_error(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test GET /api/ai/test returns error on provider configuration ValueError."""
|
||||||
|
mock_settings.ai_provider = "anthropic"
|
||||||
|
mock_settings.ai_model = None
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
mock_get_provider.side_effect = ValueError("ANTHROPIC_API_KEY must be set")
|
||||||
|
|
||||||
|
response = client.get("/api/ai/test")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "error"
|
||||||
|
assert "ANTHROPIC_API_KEY" in data["message"]
|
||||||
|
assert data["provider"] == "anthropic"
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_ai_test_connection_exception(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test GET /api/ai/test returns error on provider runtime exception."""
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = "gpt-4o-mini"
|
||||||
|
mock_settings.openai_model = "gpt-4o-mini"
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.side_effect = Exception("Connection refused")
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.get("/api/ai/test")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "error"
|
||||||
|
assert "Connection refused" in data["message"]
|
||||||
|
assert data["provider"] == "openai"
|
||||||
|
|
||||||
|
@patch("app.utils.ai_provider.get_ai_provider")
|
||||||
|
@patch("app.api.openai.settings")
|
||||||
|
def test_ai_test_uses_openai_model_fallback(self, mock_settings, mock_get_provider, client):
|
||||||
|
"""Test that ai_model=None falls back to openai_model."""
|
||||||
|
mock_settings.ai_provider = "openai"
|
||||||
|
mock_settings.ai_model = None
|
||||||
|
mock_settings.openai_model = "gpt-3.5-turbo"
|
||||||
|
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = "ok"
|
||||||
|
mock_get_provider.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.get("/api/ai/test")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["status"] == "success"
|
||||||
|
assert data["model"] == "gpt-3.5-turbo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExceptionChainDetail:
|
||||||
|
"""Unit tests for the _get_exception_chain_detail helper."""
|
||||||
|
|
||||||
|
def test_single_exception_no_chain(self):
|
||||||
|
"""Test with a simple exception that has no cause."""
|
||||||
|
from app.api.openai import _get_exception_chain_detail
|
||||||
|
|
||||||
|
exc = ValueError("root cause")
|
||||||
|
result = _get_exception_chain_detail(exc)
|
||||||
|
assert result == "root cause"
|
||||||
|
|
||||||
|
def test_exception_with_cause(self):
|
||||||
|
"""Test that chained exceptions are surfaced in the message."""
|
||||||
|
from app.api.openai import _get_exception_chain_detail
|
||||||
|
|
||||||
|
inner = OSError("DNS resolution failed")
|
||||||
|
outer = ConnectionError("Connection failed")
|
||||||
|
outer.__cause__ = inner
|
||||||
|
|
||||||
|
result = _get_exception_chain_detail(outer)
|
||||||
|
assert "Connection failed" in result
|
||||||
|
assert "DNS resolution failed" in result
|
||||||
|
assert "caused by" in result
|
||||||
|
|
||||||
|
def test_empty_cause_string_skipped(self):
|
||||||
|
"""Test that a cause with empty string representation is not appended."""
|
||||||
|
from app.api.openai import _get_exception_chain_detail
|
||||||
|
|
||||||
|
inner = Exception("") # str() returns ""
|
||||||
|
outer = RuntimeError("outer error")
|
||||||
|
outer.__cause__ = inner
|
||||||
|
|
||||||
|
result = _get_exception_chain_detail(outer)
|
||||||
|
# The empty-string cause should be skipped (branch 47->49)
|
||||||
|
assert result == "outer error"
|
||||||
|
|
||||||
|
def test_duplicate_cause_string_skipped(self):
|
||||||
|
"""Test that a cause whose str() is already in parts is not duplicated."""
|
||||||
|
from app.api.openai import _get_exception_chain_detail
|
||||||
|
|
||||||
|
outer = RuntimeError("same message")
|
||||||
|
inner = RuntimeError("same message") # same text as outer
|
||||||
|
outer.__cause__ = inner
|
||||||
|
|
||||||
|
result = _get_exception_chain_detail(outer)
|
||||||
|
# "same message" should appear only once (branch: cause_str already in parts)
|
||||||
|
assert result.count("same message") == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExtractJsonFromText:
|
||||||
|
"""Unit tests for the _extract_json_from_text helper."""
|
||||||
|
|
||||||
|
def test_json_in_code_fence(self):
|
||||||
|
"""Test extraction from markdown code fence."""
|
||||||
|
from app.api.openai import _extract_json_from_text
|
||||||
|
|
||||||
|
text = '```json\n{"key": "value"}\n```'
|
||||||
|
result = _extract_json_from_text(text)
|
||||||
|
assert result == '{"key": "value"}'
|
||||||
|
|
||||||
|
def test_json_in_plain_code_fence(self):
|
||||||
|
"""Test extraction from plain (non-json) code fence."""
|
||||||
|
from app.api.openai import _extract_json_from_text
|
||||||
|
|
||||||
|
text = '```\n{"key": "value"}\n```'
|
||||||
|
result = _extract_json_from_text(text)
|
||||||
|
assert result == '{"key": "value"}'
|
||||||
|
|
||||||
|
def test_bare_json_object(self):
|
||||||
|
"""Test extraction of a bare JSON object without code fence."""
|
||||||
|
from app.api.openai import _extract_json_from_text
|
||||||
|
|
||||||
|
text = 'Here is the result: {"title": "Invoice"} done.'
|
||||||
|
result = _extract_json_from_text(text)
|
||||||
|
assert result == '{"title": "Invoice"}'
|
||||||
|
|
||||||
|
def test_no_json_returns_none(self):
|
||||||
|
"""Test that text with no JSON object returns None."""
|
||||||
|
from app.api.openai import _extract_json_from_text
|
||||||
|
|
||||||
|
result = _extract_json_from_text("No JSON here at all.")
|
||||||
|
assert result is None
|
||||||
|
|||||||
Reference in New Issue
Block a user