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:
Christian Krakau-Louis
2026-02-23 22:22:22 +01:00
committed by GitHub
3 changed files with 724 additions and 3 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(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,
}
+231
View File
@@ -181,6 +181,12 @@
data-provider="ai_provider">
Test Connection
</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 %}
{% elif name == "Azure AI" %}
{% if provider.configured %}
@@ -273,6 +279,85 @@
</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>
{% 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>
{% endblock %}
+360
View File
@@ -309,3 +309,363 @@ class TestOpenAIConnectionErrors:
assert data["status"] == "error"
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