Add shared ai-service container as AI provider intermediary

All feature containers now POST messages to ai-service (port 8010) instead
of calling AI providers directly. ai-service routes to LM Studio, Ollama,
or Anthropic based on /config/ai_service_config.json. doc-service AI
providers removed; replaced by httpx ai_client.py. Backend settings
restructured to /api/settings/ai. Frontend gets dedicated AIAdminSettingsPage
and AI Service card in AppsPage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-14 12:30:45 +02:00
parent 52a2967f61
commit 88c1ea297e
47 changed files with 1354 additions and 497 deletions
+7 -6
View File
@@ -75,12 +75,13 @@ MOCK_AI_RESULT = {
@pytest.fixture
def mock_ai():
"""Patch the AI classify_document call to return MOCK_AI_RESULT."""
provider_mock = AsyncMock()
provider_mock.classify_document = AsyncMock(return_value=MOCK_AI_RESULT)
with patch("app.routers.documents.get_provider", return_value=provider_mock):
yield provider_mock
def mock_ai_service():
"""Patch classify_document to return MOCK_AI_RESULT without hitting ai-service."""
with patch(
"app.services.ai_client.classify_document",
new=AsyncMock(return_value=MOCK_AI_RESULT),
) as mock:
yield mock
# ── HTTP client ────────────────────────────────────────────────────────────────
+28 -2
View File
@@ -189,7 +189,7 @@ async def test_cannot_assign_other_users_category(client, other_client, minimal_
# ── AI processing integration (with mock AI) ──────────────────────────────────
async def test_processing_sets_extracted_data(client, invoice_pdf, mock_ai):
async def test_processing_sets_extracted_data(client, invoice_pdf, mock_ai_service):
"""Upload + wait for background processing; verify extracted_data is populated."""
r = await client.post("/documents/upload", files=_pdf_upload("invoice.pdf", invoice_pdf))
assert r.status_code == 202
@@ -217,9 +217,35 @@ async def test_processing_sets_extracted_data(client, invoice_pdf, mock_ai):
assert len(extracted["suggested_categories"]) > 0
# ── Graceful degradation when ai-service is unavailable ──────────────────────
async def test_processing_fails_gracefully_when_ai_service_502(client, invoice_pdf):
"""When ai-service returns an error, document status should be 'failed', not crash."""
from app.services.ai_client import AIServiceError
with patch(
"app.services.ai_client.classify_document",
side_effect=AIServiceError("ai-service returned 502"),
):
r = await client.post("/documents/upload", files=_pdf_upload("fail.pdf", invoice_pdf))
assert r.status_code == 202
doc_id = r.json()["id"]
import asyncio
for _ in range(20):
status_r = await client.get(f"/documents/{doc_id}/status")
if status_r.json()["status"] in ("done", "failed"):
break
await asyncio.sleep(0.1)
doc = (await client.get(f"/documents/{doc_id}")).json()
assert doc["status"] == "failed"
assert "ai-service" in (doc.get("error_message") or "").lower()
# ── Live tests (require real PDFs in tests/pdfs/) ─────────────────────────────
async def test_live_upload_real_pdf(client, real_pdfs, mock_ai):
async def test_live_upload_real_pdf(client, real_pdfs, mock_ai_service):
"""Upload each real PDF from tests/pdfs/ and verify it reaches 'done'."""
import asyncio
for pdf_path in real_pdfs: