From f4de6d9e2818dad13d236b7b922976ae75561291 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:10:56 +0100 Subject: [PATCH 1/9] feat(api): add api app skeleton with APIToken model --- api/__init__.py | 0 api/apps.py | 7 +++++ api/migrations/0001_initial.py | 35 ++++++++++++++++++++++++ api/migrations/__init__.py | 0 api/models.py | 25 +++++++++++++++++ api/urls.py | 5 ++++ score/settings.py | 1 + score/urls.py | 1 + tests/test_api_auth.py | 49 ++++++++++++++++++++++++++++++++++ 9 files changed, 123 insertions(+) create mode 100644 api/__init__.py create mode 100644 api/apps.py create mode 100644 api/migrations/0001_initial.py create mode 100644 api/migrations/__init__.py create mode 100644 api/models.py create mode 100644 api/urls.py create mode 100644 tests/test_api_auth.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..af7d23d --- /dev/null +++ b/api/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api" + verbose_name = "REST API" diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 0000000..ed3cce9 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.15 on 2026-03-26 10:09 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tenants', '0005_audit_log'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='APIToken', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('key_hash', models.CharField(db_index=True, max_length=64, unique=True)), + ('name', models.CharField(max_length=200)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tenants.project')), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tenants.tenant')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..d884050 --- /dev/null +++ b/api/models.py @@ -0,0 +1,25 @@ +import uuid + +from django.conf import settings +from django.db import models + + +class APIToken(models.Model): + """Bearer token for API authentication, scoped to a tenant and project.""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + key_hash = models.CharField(max_length=64, unique=True, db_index=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens" + ) + tenant = models.ForeignKey("tenants.Tenant", on_delete=models.CASCADE) + project = models.ForeignKey("tenants.Project", on_delete=models.CASCADE) + name = models.CharField(max_length=200) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.user.username})" diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..794f80f --- /dev/null +++ b/api/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +app_name = "api" + +urlpatterns = [] diff --git a/score/settings.py b/score/settings.py index 4f8e0c5..d5ca22e 100644 --- a/score/settings.py +++ b/score/settings.py @@ -69,6 +69,7 @@ "reports", "dashboard", "chat", + "api", ] SITE_ID = 1 diff --git a/score/urls.py b/score/urls.py index e47ead5..7a383df 100644 --- a/score/urls.py +++ b/score/urls.py @@ -15,6 +15,7 @@ path("reports/", include("reports.urls")), path("tenants/", include("tenants.urls")), path("chat/", include("chat.urls")), + path("api/v1/", include("api.urls")), # Root redirect path( "", lambda r: __import__("django.shortcuts", fromlist=["redirect"]).redirect("/dashboard/") diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py new file mode 100644 index 0000000..17871a1 --- /dev/null +++ b/tests/test_api_auth.py @@ -0,0 +1,49 @@ +import hashlib +import pytest +from django.contrib.auth.models import User +from api.models import APIToken +from tenants.models import Tenant, Project + + +@pytest.fixture +def api_tenant(db): + return Tenant.objects.create(name="API Tenant", slug="api-tenant") + + +@pytest.fixture +def api_project(api_tenant): + return Project.objects.create(tenant=api_tenant, name="API Project", slug="api-project") + + +@pytest.fixture +def api_user(db): + return User.objects.create_user(username="apiuser", password="pass") + + +class TestAPITokenModel: + def test_create_token(self, api_tenant, api_project, api_user): + raw_token = "score_test_token_abc123" + token = APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=api_user, + tenant=api_tenant, + project=api_project, + name="test-token", + ) + assert token.is_active is True + assert token.name == "test-token" + assert str(token) == "test-token (apiuser)" + + def test_lookup_by_hash(self, api_tenant, api_project, api_user): + raw_token = "score_lookup_token" + key_hash = hashlib.sha256(raw_token.encode()).hexdigest() + APIToken.objects.create( + key_hash=key_hash, + user=api_user, + tenant=api_tenant, + project=api_project, + name="lookup-token", + ) + found = APIToken.objects.filter(key_hash=key_hash, is_active=True).first() + assert found is not None + assert found.name == "lookup-token" From 2a1b9252b3a7964ae639fad5920401ca45494ff0 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:12:07 +0100 Subject: [PATCH 2/9] feat(api): add token authentication with require_api_token decorator --- api/auth.py | 47 ++++++++++++++++++++++++++++ tests/test_api_auth.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 api/auth.py diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..1eeb699 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,47 @@ +import hashlib +import functools + +from django.http import JsonResponse + +from api.models import APIToken + + +def authenticate_token(raw_token): + """Validate a raw token string. Returns dict with user/tenant/project or None.""" + key_hash = hashlib.sha256(raw_token.encode()).hexdigest() + try: + token = APIToken.objects.select_related("user", "tenant", "project").get( + key_hash=key_hash, is_active=True + ) + except APIToken.DoesNotExist: + return None + return { + "user": token.user, + "tenant": token.tenant, + "project": token.project, + "token": token, + } + + +def require_api_token(view_func): + """Decorator that enforces Bearer token auth on a view.""" + @functools.wraps(view_func) + def wrapper(request, *args, **kwargs): + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return JsonResponse( + {"error": "Missing or invalid Authorization header", "code": "AUTH_REQUIRED"}, + status=401, + ) + raw_token = auth_header[7:] + result = authenticate_token(raw_token) + if result is None: + return JsonResponse( + {"error": "Invalid or inactive token", "code": "INVALID_TOKEN"}, + status=401, + ) + request.api_user = result["user"] + request.api_tenant = result["tenant"] + request.api_project = result["project"] + return view_func(request, *args, **kwargs) + return wrapper diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 17871a1..396813f 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -47,3 +47,73 @@ def test_lookup_by_hash(self, api_tenant, api_project, api_user): found = APIToken.objects.filter(key_hash=key_hash, is_active=True).first() assert found is not None assert found.name == "lookup-token" + + +import json +from django.test import RequestFactory +from django.http import JsonResponse +from api.auth import authenticate_token, require_api_token + + +class TestTokenAuth: + def test_valid_token(self, api_tenant, api_project, api_user): + raw_token = "score_valid_token" + APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=api_user, + tenant=api_tenant, + project=api_project, + name="valid", + ) + result = authenticate_token(raw_token) + assert result is not None + assert result["user"] == api_user + assert result["tenant"] == api_tenant + assert result["project"] == api_project + + def test_invalid_token(self, db): + result = authenticate_token("nonexistent") + assert result is None + + def test_inactive_token(self, api_tenant, api_project, api_user): + raw_token = "score_inactive" + APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=api_user, + tenant=api_tenant, + project=api_project, + name="inactive", + is_active=False, + ) + result = authenticate_token(raw_token) + assert result is None + + +class TestRequireApiTokenDecorator: + def test_missing_header(self): + @require_api_token + def dummy_view(request): + return JsonResponse({"ok": True}) + + factory = RequestFactory() + request = factory.get("/api/v1/test/") + response = dummy_view(request) + assert response.status_code == 401 + + def test_valid_header(self, api_tenant, api_project, api_user): + @require_api_token + def dummy_view(request): + return JsonResponse({"user": request.api_user.username}) + + raw_token = "score_decorator_test" + APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=api_user, + tenant=api_tenant, + project=api_project, + name="decorator-test", + ) + factory = RequestFactory() + request = factory.get("/api/v1/test/", HTTP_AUTHORIZATION=f"Bearer {raw_token}") + response = dummy_view(request) + assert response.status_code == 200 From ae84241a38a4f8ac4bcd53fb12721852daea6d33 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:13:41 +0100 Subject: [PATCH 3/9] feat(api): add POST /api/v1/tokens/ endpoint for token creation --- api/urls.py | 6 +++++- api/views_tokens.py | 49 ++++++++++++++++++++++++++++++++++++++++++ tenants/middleware.py | 4 ++-- tests/test_api_auth.py | 26 ++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 api/views_tokens.py diff --git a/api/urls.py b/api/urls.py index 794f80f..b9a8848 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,5 +1,9 @@ from django.urls import path +from api import views_tokens + app_name = "api" -urlpatterns = [] +urlpatterns = [ + path("tokens/", views_tokens.create_token, name="create-token"), +] diff --git a/api/views_tokens.py b/api/views_tokens.py new file mode 100644 index 0000000..fb9a00a --- /dev/null +++ b/api/views_tokens.py @@ -0,0 +1,49 @@ +import hashlib +import json +import secrets + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from api.models import APIToken +from tenants.models import Project + + +@csrf_exempt +@require_POST +def create_token(request): + """Create a new API token. Requires superuser (session auth).""" + if not request.user.is_authenticated or not request.user.is_superuser: + return JsonResponse( + {"error": "Superuser access required", "code": "FORBIDDEN"}, status=403 + ) + + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({"error": "Invalid JSON body", "code": "BAD_REQUEST"}, status=400) + + name = body.get("name", "").strip() + project_id = body.get("project_id", "") + + if not name: + return JsonResponse({"error": "name is required", "code": "BAD_REQUEST"}, status=400) + + try: + project = Project.objects.select_related("tenant").get(id=project_id) + except (Project.DoesNotExist, ValueError): + return JsonResponse({"error": "Project not found", "code": "NOT_FOUND"}, status=404) + + raw_token = f"score_{secrets.token_urlsafe(32)}" + key_hash = hashlib.sha256(raw_token.encode()).hexdigest() + + APIToken.objects.create( + key_hash=key_hash, + user=request.user, + tenant=project.tenant, + project=project, + name=name, + ) + + return JsonResponse({"token": raw_token, "name": name}, status=201) diff --git a/tenants/middleware.py b/tenants/middleware.py index cf4bdb1..3284329 100644 --- a/tenants/middleware.py +++ b/tenants/middleware.py @@ -64,7 +64,7 @@ def __call__(self, request): # Resolve project self._resolve_project(request, membership.tenant) - elif not request.path.startswith(("/auth/", "/admin/", "/tenants/")): + elif not request.path.startswith(("/auth/", "/admin/", "/tenants/", "/api/")): return redirect(reverse("tenant-select")) return self.get_response(request) @@ -100,5 +100,5 @@ def _resolve_project(self, request, tenant): if project_membership: request.project = project_membership.project request.project_membership = project_membership - elif not request.path.startswith(("/auth/", "/admin/", "/tenants/")): + elif not request.path.startswith(("/auth/", "/admin/", "/tenants/", "/api/")): return redirect(reverse("project-list")) diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 396813f..9772e81 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -117,3 +117,29 @@ def dummy_view(request): request = factory.get("/api/v1/test/", HTTP_AUTHORIZATION=f"Bearer {raw_token}") response = dummy_view(request) assert response.status_code == 200 + + +class TestTokenCreateEndpoint: + def test_create_token_as_admin(self, client, api_tenant, api_project, api_user): + api_user.is_superuser = True + api_user.save() + client.force_login(api_user) + response = client.post( + "/api/v1/tokens/", + data=json.dumps({"name": "my-token", "project_id": str(api_project.id)}), + content_type="application/json", + ) + assert response.status_code == 201 + data = response.json() + assert "token" in data + assert data["token"].startswith("score_") + assert data["name"] == "my-token" + + def test_create_token_non_admin(self, client, api_user): + client.force_login(api_user) + response = client.post( + "/api/v1/tokens/", + data=json.dumps({"name": "my-token", "project_id": "fake"}), + content_type="application/json", + ) + assert response.status_code == 403 From c4242fd2e0ee734b5e8d15491bebdb606887dc32 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:17:03 +0100 Subject: [PATCH 4/9] feat(api): add document CRUD endpoints (POST/GET/DELETE /api/v1/documents/) --- api/urls.py | 4 +- api/views_documents.py | 186 ++++++++++++++++++++++++++++++++++++ tests/test_api_documents.py | 121 +++++++++++++++++++++++ 3 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 api/views_documents.py create mode 100644 tests/test_api_documents.py diff --git a/api/urls.py b/api/urls.py index b9a8848..5b89cad 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,9 +1,11 @@ from django.urls import path -from api import views_tokens +from api import views_tokens, views_documents app_name = "api" urlpatterns = [ path("tokens/", views_tokens.create_token, name="create-token"), + path("documents/", views_documents.document_list, name="document-list"), + path("documents//", views_documents.document_detail, name="document-detail"), ] diff --git a/api/views_documents.py b/api/views_documents.py new file mode 100644 index 0000000..2bd3c8c --- /dev/null +++ b/api/views_documents.py @@ -0,0 +1,186 @@ +import hashlib +import json +import uuid + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from api.auth import require_api_token +from connectors.models import ConnectorConfig +from ingestion.chunking import chunk_document +from ingestion.hashing import hash_content +from ingestion.models import Document, DocumentChunk +from llm.client import get_llm_client +from vectorstore.store import get_vector_store + + +def _get_or_create_api_connector(tenant, project): + connector, _ = ConnectorConfig.objects.get_or_create( + tenant=tenant, + project=project, + connector_type="generic", + name="API", + defaults={"config": {"source_type": "api"}, "enabled": True}, + ) + return connector + + +@csrf_exempt +@require_api_token +@require_http_methods(["GET", "POST"]) +def document_list(request): + if request.method == "GET": + return _list_documents(request) + return _create_document(request) + + +def _list_documents(request): + page = int(request.GET.get("page", 1)) + page_size = min(int(request.GET.get("page_size", 50)), 200) + offset = (page - 1) * page_size + + qs = Document.objects.filter( + tenant=request.api_tenant, project=request.api_project + ).exclude(status=Document.Status.DELETED) + + total = qs.count() + docs = qs[offset : offset + page_size] + + return JsonResponse({ + "documents": [ + { + "id": str(d.id), + "title": d.title, + "status": d.status, + "word_count": d.word_count, + "chunk_count": d.chunk_count, + "content_type": d.doc_type, + "created_at": d.created_at.isoformat(), + } + for d in docs + ], + "total": total, + "page": page, + "page_size": page_size, + }) + + +def _create_document(request): + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({"error": "Invalid JSON", "code": "BAD_REQUEST"}, status=400) + + title = body.get("title", "").strip() + content = body.get("content", "").strip() + content_type = body.get("content_type", "text/plain") + metadata = body.get("metadata", {}) + + if not title or not content: + return JsonResponse( + {"error": "title and content are required", "code": "BAD_REQUEST"}, status=400 + ) + + tenant = request.api_tenant + project = request.api_project + connector = _get_or_create_api_connector(tenant, project) + + source_id = f"api:{uuid.uuid4()}" + content_hash_val = hash_content(content) + + doc = Document.objects.create( + tenant=tenant, + project=project, + connector=connector, + source_id=source_id, + title=title, + content_hash=content_hash_val, + doc_type=content_type, + word_count=len(content.split()), + status=Document.Status.INGESTED, + author=metadata.get("author", ""), + path=metadata.get("path", ""), + ) + + chunks_data = chunk_document(content) + chunk_objects = [] + for i, chunk in enumerate(chunks_data): + chunk_objects.append( + DocumentChunk( + tenant=tenant, + document=doc, + chunk_index=i, + content=chunk.content, + token_count=chunk.token_count, + heading_path=chunk.heading_path or "", + content_hash=chunk.content_hash, + ) + ) + DocumentChunk.objects.bulk_create(chunk_objects) + doc.chunk_count = len(chunk_objects) + + # Try to embed, but don't fail if LLM is unavailable + try: + llm = get_llm_client() + vec_store = get_vector_store() + texts = [c.content for c in chunk_objects] + if texts: + embeddings = llm.embed(texts) + for chunk_obj, embedding in zip(chunk_objects, embeddings): + vec_store.upsert( + chunk_id=str(chunk_obj.id), + embedding=embedding, + tenant_id=tenant.slug, + document_id=str(doc.id), + doc_type=content_type, + source_type="api", + project_id=str(project.id), + ) + chunk_obj.has_embedding = True + DocumentChunk.objects.bulk_update(chunk_objects, ["has_embedding"]) + doc.status = Document.Status.READY + except Exception: + doc.status = Document.Status.INGESTED + + doc.save() + + return JsonResponse( + { + "id": str(doc.id), + "title": doc.title, + "status": doc.status, + "word_count": doc.word_count, + "chunk_count": doc.chunk_count, + }, + status=201, + ) + + +@csrf_exempt +@require_api_token +@require_http_methods(["GET", "DELETE"]) +def document_detail(request, doc_id): + try: + doc = Document.objects.get( + id=doc_id, tenant=request.api_tenant, project=request.api_project + ) + except Document.DoesNotExist: + return JsonResponse({"error": "Document not found", "code": "NOT_FOUND"}, status=404) + + if request.method == "DELETE": + vec_store = get_vector_store() + vec_store.delete_by_document(str(doc.id)) + doc.delete() + return JsonResponse({}, status=204) + + return JsonResponse({ + "id": str(doc.id), + "title": doc.title, + "status": doc.status, + "word_count": doc.word_count, + "chunk_count": doc.chunk_count, + "content_type": doc.doc_type, + "created_at": doc.created_at.isoformat(), + "updated_at": doc.updated_at.isoformat(), + }) diff --git a/tests/test_api_documents.py b/tests/test_api_documents.py new file mode 100644 index 0000000..c13d7e6 --- /dev/null +++ b/tests/test_api_documents.py @@ -0,0 +1,121 @@ +import hashlib +import json +from unittest.mock import patch, MagicMock + +import pytest +from django.contrib.auth.models import User + +from api.models import APIToken +from ingestion.models import Document +from tenants.models import Project, Tenant + + +@pytest.fixture +def api_setup(db): + tenant = Tenant.objects.create(name="DocTenant", slug="doc-tenant") + project = Project.objects.create(tenant=tenant, name="DocProject", slug="doc-project") + user = User.objects.create_user(username="docuser", password="pass") + raw_token = "score_doc_test_token" + APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=user, + tenant=tenant, + project=project, + name="doc-token", + ) + return {"tenant": tenant, "project": project, "user": user, "token": raw_token} + + +def _mock_llm_and_vecstore(): + """Return context managers that mock LLM embed and vector store.""" + mock_llm = MagicMock() + mock_llm.embed.return_value = [[0.1] * 768] + mock_vs = MagicMock() + return ( + patch("api.views_documents.get_llm_client", return_value=mock_llm), + patch("api.views_documents.get_vector_store", return_value=mock_vs), + ) + + +class TestDocumentCreate: + def test_create_document(self, client, api_setup): + p1, p2 = _mock_llm_and_vecstore() + with p1, p2: + response = client.post( + "/api/v1/documents/", + data=json.dumps({ + "title": "Test Doc", + "content": "This is a test document with enough words for chunking.", + "content_type": "text/plain", + }), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {api_setup['token']}", + ) + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Test Doc" + assert "id" in data + + def test_create_document_no_auth(self, client): + response = client.post( + "/api/v1/documents/", + data=json.dumps({"title": "X", "content": "Y"}), + content_type="application/json", + ) + assert response.status_code == 401 + + def test_create_document_missing_fields(self, client, api_setup): + response = client.post( + "/api/v1/documents/", + data=json.dumps({"title": "No content"}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {api_setup['token']}", + ) + assert response.status_code == 400 + + +class TestDocumentList: + def test_list_documents(self, client, api_setup): + p1, p2 = _mock_llm_and_vecstore() + with p1, p2: + client.post( + "/api/v1/documents/", + data=json.dumps({ + "title": "Listed Doc", + "content": "Content here for listing test.", + "content_type": "text/plain", + }), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {api_setup['token']}", + ) + response = client.get( + "/api/v1/documents/", + HTTP_AUTHORIZATION=f"Bearer {api_setup['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + assert len(data["documents"]) >= 1 + + +class TestDocumentDelete: + def test_delete_document(self, client, api_setup): + p1, p2 = _mock_llm_and_vecstore() + with p1, p2: + resp = client.post( + "/api/v1/documents/", + data=json.dumps({ + "title": "To Delete", + "content": "Will be deleted soon.", + "content_type": "text/plain", + }), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {api_setup['token']}", + ) + doc_id = resp.json()["id"] + with patch("api.views_documents.get_vector_store", return_value=MagicMock()): + response = client.delete( + f"/api/v1/documents/{doc_id}/", + HTTP_AUTHORIZATION=f"Bearer {api_setup['token']}", + ) + assert response.status_code == 204 From eb2eb4bcf60834c10726e1d3a434d053dc8a56a3 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:18:42 +0100 Subject: [PATCH 5/9] feat(api): add GET /api/v1/score/ and POST /api/v1/analysis/ endpoints --- api/urls.py | 5 ++- api/views_score.py | 73 +++++++++++++++++++++++++++++++++++++ tests/test_api_score.py | 80 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 api/views_score.py create mode 100644 tests/test_api_score.py diff --git a/api/urls.py b/api/urls.py index 5b89cad..f3059e4 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from api import views_tokens, views_documents +from api import views_tokens, views_documents, views_score app_name = "api" @@ -8,4 +8,7 @@ path("tokens/", views_tokens.create_token, name="create-token"), path("documents/", views_documents.document_list, name="document-list"), path("documents//", views_documents.document_detail, name="document-detail"), + path("score/", views_score.score_view, name="score"), + path("analysis/", views_score.analysis_trigger, name="analysis"), + path("analysis//", views_score.analysis_detail_view, name="analysis-detail"), ] diff --git a/api/views_score.py b/api/views_score.py new file mode 100644 index 0000000..30e2437 --- /dev/null +++ b/api/views_score.py @@ -0,0 +1,73 @@ +import json + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from analysis.models import AnalysisJob +from analysis.tasks import run_unified_pipeline +from api.auth import require_api_token +from score.scoring import compute_score + + +@require_api_token +@require_http_methods(["GET"]) +def score_view(request): + """GET /api/v1/score/ — current quality score.""" + result = compute_score(request.api_project) + breakdown = result.get("breakdown", {}) + return JsonResponse({ + "score": result.get("score", 0), + "grade": result.get("grade", "E"), + "dimensions": breakdown, + "has_analysis": result.get("has_analysis", False), + "has_docs": result.get("has_docs", False), + }) + + +@csrf_exempt +@require_api_token +@require_http_methods(["POST"]) +def analysis_trigger(request): + """POST /api/v1/analysis/ — trigger a new analysis.""" + job = AnalysisJob.objects.create( + tenant=request.api_tenant, + project=request.api_project, + status=AnalysisJob.Status.QUEUED, + ) + task = run_unified_pipeline.delay(str(job.id)) + job.celery_task_id = task.id + job.status = AnalysisJob.Status.RUNNING + job.save(update_fields=["celery_task_id", "status"]) + + return JsonResponse({"job_id": str(job.id), "status": "running"}, status=202) + + +@require_api_token +@require_http_methods(["GET"]) +def analysis_detail_view(request, job_id): + """GET /api/v1/analysis/{job_id}/ — poll analysis status.""" + try: + job = AnalysisJob.objects.get( + id=job_id, tenant=request.api_tenant, project=request.api_project + ) + except AnalysisJob.DoesNotExist: + return JsonResponse({"error": "Analysis job not found", "code": "NOT_FOUND"}, status=404) + + result = { + "job_id": str(job.id), + "status": job.status, + "current_phase": job.current_phase, + "progress_pct": job.progress_pct, + "error_message": job.error_message or None, + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + } + + if job.status == AnalysisJob.Status.COMPLETED: + score_result = compute_score(request.api_project) + result["score"] = score_result.get("score", 0) + result["grade"] = score_result.get("grade", "E") + result["dimensions"] = score_result.get("breakdown", {}) + + return JsonResponse(result) diff --git a/tests/test_api_score.py b/tests/test_api_score.py new file mode 100644 index 0000000..5b39142 --- /dev/null +++ b/tests/test_api_score.py @@ -0,0 +1,80 @@ +import hashlib +import json +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User + +from api.models import APIToken +from analysis.models import AnalysisJob +from tenants.models import Project, Tenant + + +@pytest.fixture +def score_setup(db): + tenant = Tenant.objects.create(name="ScoreTenant", slug="score-tenant") + project = Project.objects.create(tenant=tenant, name="ScoreProject", slug="score-project") + user = User.objects.create_user(username="scoreuser", password="pass") + raw_token = "score_score_test" + APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=user, + tenant=tenant, + project=project, + name="score-token", + ) + return {"tenant": tenant, "project": project, "token": raw_token} + + +class TestScoreEndpoint: + def test_get_score(self, client, score_setup): + response = client.get( + "/api/v1/score/", + HTTP_AUTHORIZATION=f"Bearer {score_setup['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert "score" in data + assert "grade" in data + assert "dimensions" in data + + def test_get_score_no_auth(self, client): + response = client.get("/api/v1/score/") + assert response.status_code == 401 + + +class TestAnalysisEndpoint: + def test_trigger_analysis(self, client, score_setup): + with patch("api.views_score.run_unified_pipeline") as mock_task: + mock_task.delay.return_value.id = "fake-celery-id" + response = client.post( + "/api/v1/analysis/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {score_setup['token']}", + ) + assert response.status_code == 202 + data = response.json() + assert "job_id" in data + assert data["status"] == "running" + + def test_get_analysis_status(self, client, score_setup): + job = AnalysisJob.objects.create( + tenant=score_setup["tenant"], + project=score_setup["project"], + status=AnalysisJob.Status.COMPLETED, + ) + response = client.get( + f"/api/v1/analysis/{job.id}/", + HTTP_AUTHORIZATION=f"Bearer {score_setup['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "completed" + + def test_get_analysis_not_found(self, client, score_setup): + import uuid + response = client.get( + f"/api/v1/analysis/{uuid.uuid4()}/", + HTTP_AUTHORIZATION=f"Bearer {score_setup['token']}", + ) + assert response.status_code == 404 From fd277030e7a480c233ec537f939be8beb13ea150 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:20:15 +0100 Subject: [PATCH 6/9] feat(api): add POST /api/v1/audit/ and GET /api/v1/audit/{id}/ endpoints --- api/urls.py | 4 +- api/views_audit.py | 60 ++++++++++++++++++++++++ tests/test_api_audit.py | 100 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 api/views_audit.py create mode 100644 tests/test_api_audit.py diff --git a/api/urls.py b/api/urls.py index f3059e4..f2fa58f 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from api import views_tokens, views_documents, views_score +from api import views_tokens, views_documents, views_score, views_audit app_name = "api" @@ -11,4 +11,6 @@ path("score/", views_score.score_view, name="score"), path("analysis/", views_score.analysis_trigger, name="analysis"), path("analysis//", views_score.analysis_detail_view, name="analysis-detail"), + path("audit/", views_audit.audit_trigger, name="audit-trigger"), + path("audit//", views_audit.audit_detail, name="audit-detail"), ] diff --git a/api/views_audit.py b/api/views_audit.py new file mode 100644 index 0000000..b5a4d4e --- /dev/null +++ b/api/views_audit.py @@ -0,0 +1,60 @@ +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from analysis.models import AnalysisJob, AuditJob, AuditAxisResult +from analysis.tasks import run_unified_pipeline +from api.auth import require_api_token + + +@csrf_exempt +@require_api_token +@require_http_methods(["POST"]) +def audit_trigger(request): + """POST /api/v1/audit/ -- trigger a new analysis with audit.""" + job = AnalysisJob.objects.create( + tenant=request.api_tenant, + project=request.api_project, + status=AnalysisJob.Status.QUEUED, + includes_audit=True, + ) + task = run_unified_pipeline.delay(str(job.id)) + job.celery_task_id = task.id + job.status = AnalysisJob.Status.RUNNING + job.save(update_fields=["celery_task_id", "status"]) + + return JsonResponse({"job_id": str(job.id), "status": "running"}, status=202) + + +@require_api_token +@require_http_methods(["GET"]) +def audit_detail(request, job_id): + """GET /api/v1/audit/{job_id}/ -- retrieve audit results with axis scores.""" + try: + audit_job = AuditJob.objects.get( + id=job_id, tenant=request.api_tenant, project=request.api_project + ) + except AuditJob.DoesNotExist: + return JsonResponse( + {"error": "Audit job not found", "code": "NOT_FOUND"}, status=404 + ) + + axes = [] + for result in audit_job.axis_results.all(): + axes.append({ + "axis": result.axis, + "score": result.score, + "metrics": result.metrics, + "chart_data": result.chart_data, + "details": result.details, + }) + + return JsonResponse({ + "job_id": str(audit_job.id), + "status": audit_job.status, + "overall_score": audit_job.overall_score, + "overall_grade": audit_job.overall_grade or None, + "axes": axes, + "created_at": audit_job.created_at.isoformat(), + "completed_at": audit_job.completed_at.isoformat() if audit_job.completed_at else None, + }) diff --git a/tests/test_api_audit.py b/tests/test_api_audit.py new file mode 100644 index 0000000..2d3ddac --- /dev/null +++ b/tests/test_api_audit.py @@ -0,0 +1,100 @@ +import hashlib +import uuid +from unittest.mock import patch + +import pytest +from django.contrib.auth.models import User + +from api.models import APIToken +from analysis.models import AnalysisJob, AuditJob, AuditAxisResult +from tenants.models import Project, Tenant + + +@pytest.fixture +def audit_setup(db): + tenant = Tenant.objects.create(name="AuditTenant", slug="audit-tenant") + project = Project.objects.create(tenant=tenant, name="AuditProject", slug="audit-project") + user = User.objects.create_user(username="audituser", password="pass") + raw_token = "audit_test_token" + APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=user, + tenant=tenant, + project=project, + name="audit-token", + ) + return {"tenant": tenant, "project": project, "token": raw_token} + + +class TestAuditDetail: + def test_get_audit_results_with_axis_scores(self, client, audit_setup): + audit_job = AuditJob.objects.create( + tenant=audit_setup["tenant"], + project=audit_setup["project"], + status=AuditJob.Status.COMPLETED, + overall_score=78.5, + overall_grade="B", + ) + AuditAxisResult.objects.create( + tenant=audit_setup["tenant"], + project=audit_setup["project"], + audit_job=audit_job, + axis="hygiene", + score=85.0, + metrics={"broken_links": 2}, + chart_data={"labels": ["OK", "KO"]}, + details={"items": []}, + ) + AuditAxisResult.objects.create( + tenant=audit_setup["tenant"], + project=audit_setup["project"], + audit_job=audit_job, + axis="structure", + score=72.0, + metrics={"avg_chunk_size": 512}, + chart_data={}, + details={}, + ) + + response = client.get( + f"/api/v1/audit/{audit_job.id}/", + HTTP_AUTHORIZATION=f"Bearer {audit_setup['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["job_id"] == str(audit_job.id) + assert data["status"] == "completed" + assert data["overall_score"] == 78.5 + assert data["overall_grade"] == "B" + assert len(data["axes"]) == 2 + axes_by_name = {a["axis"]: a for a in data["axes"]} + assert axes_by_name["hygiene"]["score"] == 85.0 + assert axes_by_name["structure"]["score"] == 72.0 + + def test_get_audit_not_found(self, client, audit_setup): + response = client.get( + f"/api/v1/audit/{uuid.uuid4()}/", + HTTP_AUTHORIZATION=f"Bearer {audit_setup['token']}", + ) + assert response.status_code == 404 + data = response.json() + assert data["code"] == "NOT_FOUND" + + +class TestAuditTrigger: + def test_post_trigger_audit(self, client, audit_setup): + with patch("api.views_audit.run_unified_pipeline") as mock_task: + mock_task.delay.return_value.id = "fake-celery-id" + response = client.post( + "/api/v1/audit/", + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {audit_setup['token']}", + ) + assert response.status_code == 202 + data = response.json() + assert "job_id" in data + assert data["status"] == "running" + # Verify the AnalysisJob was created with includes_audit=True + job = AnalysisJob.objects.get(id=data["job_id"]) + assert job.includes_audit is True + assert job.celery_task_id == "fake-celery-id" From 6fdddbfabd4c19768a9119e8d5292186fccbe8bc Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:22:34 +0100 Subject: [PATCH 7/9] feat(api): add analysis results endpoints (duplicates, contradictions, clusters, gaps) --- api/urls.py | 6 +- api/views_results.py | 151 +++++++++++++++++++ tests/test_api_results.py | 306 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 api/views_results.py create mode 100644 tests/test_api_results.py diff --git a/api/urls.py b/api/urls.py index f2fa58f..1f312d5 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from api import views_tokens, views_documents, views_score, views_audit +from api import views_tokens, views_documents, views_score, views_audit, views_results app_name = "api" @@ -13,4 +13,8 @@ path("analysis//", views_score.analysis_detail_view, name="analysis-detail"), path("audit/", views_audit.audit_trigger, name="audit-trigger"), path("audit//", views_audit.audit_detail, name="audit-detail"), + path("analysis//duplicates/", views_results.duplicates_view, name="duplicates"), + path("analysis//contradictions/", views_results.contradictions_view, name="contradictions"), + path("analysis//clusters/", views_results.clusters_view, name="clusters"), + path("analysis//gaps/", views_results.gaps_view, name="gaps"), ] diff --git a/api/views_results.py b/api/views_results.py new file mode 100644 index 0000000..865d858 --- /dev/null +++ b/api/views_results.py @@ -0,0 +1,151 @@ +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods + +from analysis.models import ( + AnalysisJob, + ContradictionPair, + DuplicateGroup, + GapReport, + TopicCluster, +) +from api.auth import require_api_token + + +def _get_job_or_404(request, job_id): + """Return AnalysisJob or a 404 JsonResponse.""" + try: + return AnalysisJob.objects.get( + id=job_id, tenant=request.api_tenant, project=request.api_project + ) + except AnalysisJob.DoesNotExist: + return None + + +@require_api_token +@require_http_methods(["GET"]) +def duplicates_view(request, job_id): + """GET /api/v1/analysis/{job_id}/duplicates/ — list duplicate groups with pairs.""" + job = _get_job_or_404(request, job_id) + if job is None: + return JsonResponse( + {"error": "Analysis job not found", "code": "NOT_FOUND"}, status=404 + ) + + groups = DuplicateGroup.objects.filter( + analysis_job=job + ).prefetch_related("pairs", "pairs__doc_a", "pairs__doc_b") + + results = [] + for group in groups: + pairs = [] + for pair in group.pairs.all(): + pairs.append({ + "id": str(pair.id), + "doc_a": {"id": str(pair.doc_a_id), "title": pair.doc_a.title}, + "doc_b": {"id": str(pair.doc_b_id), "title": pair.doc_b.title}, + "semantic_score": pair.semantic_score, + "lexical_score": pair.lexical_score, + "metadata_score": pair.metadata_score, + "combined_score": pair.combined_score, + "verified": pair.verified, + "verification_result": pair.verification_result or None, + }) + results.append({ + "id": str(group.id), + "recommended_action": group.recommended_action, + "rationale": group.rationale, + "pairs": pairs, + }) + + return JsonResponse({"total": len(results), "groups": results}) + + +@require_api_token +@require_http_methods(["GET"]) +def contradictions_view(request, job_id): + """GET /api/v1/analysis/{job_id}/contradictions/ — list contradiction pairs.""" + job = _get_job_or_404(request, job_id) + if job is None: + return JsonResponse( + {"error": "Analysis job not found", "code": "NOT_FOUND"}, status=404 + ) + + contradictions = ContradictionPair.objects.filter( + analysis_job=job + ).select_related("claim_a", "claim_b") + + results = [] + for c in contradictions: + results.append({ + "id": str(c.id), + "claim_a": { + "id": str(c.claim_a_id), + "text": c.claim_a.as_text, + "document_id": str(c.claim_a.document_id), + }, + "claim_b": { + "id": str(c.claim_b_id), + "text": c.claim_b.as_text, + "document_id": str(c.claim_b.document_id), + }, + "classification": c.classification, + "severity": c.severity, + "confidence": c.confidence, + "evidence": c.evidence, + }) + + return JsonResponse({"total": len(results), "contradictions": results}) + + +@require_api_token +@require_http_methods(["GET"]) +def clusters_view(request, job_id): + """GET /api/v1/analysis/{job_id}/clusters/ — list top-level clusters.""" + job = _get_job_or_404(request, job_id) + if job is None: + return JsonResponse( + {"error": "Analysis job not found", "code": "NOT_FOUND"}, status=404 + ) + + clusters = TopicCluster.objects.filter(analysis_job=job, parent__isnull=True) + + results = [] + for cluster in clusters: + results.append({ + "id": str(cluster.id), + "label": cluster.label, + "summary": cluster.summary, + "doc_count": cluster.doc_count, + "chunk_count": cluster.chunk_count, + "level": cluster.level, + "key_concepts": cluster.key_concepts, + }) + + return JsonResponse({"total": len(results), "clusters": results}) + + +@require_api_token +@require_http_methods(["GET"]) +def gaps_view(request, job_id): + """GET /api/v1/analysis/{job_id}/gaps/ — list gap reports.""" + job = _get_job_or_404(request, job_id) + if job is None: + return JsonResponse( + {"error": "Analysis job not found", "code": "NOT_FOUND"}, status=404 + ) + + gaps = GapReport.objects.filter(analysis_job=job) + + results = [] + for gap in gaps: + results.append({ + "id": str(gap.id), + "gap_type": gap.gap_type, + "title": gap.title, + "description": gap.description, + "severity": gap.severity, + "coverage_score": gap.coverage_score, + "evidence": gap.evidence, + }) + + return JsonResponse({"total": len(results), "gaps": results}) diff --git a/tests/test_api_results.py b/tests/test_api_results.py new file mode 100644 index 0000000..34ed0d5 --- /dev/null +++ b/tests/test_api_results.py @@ -0,0 +1,306 @@ +import hashlib +import uuid + +import pytest +from django.contrib.auth.models import User + +from analysis.models import ( + AnalysisJob, + Claim, + ContradictionPair, + DuplicateGroup, + DuplicatePair, + GapReport, + TopicCluster, +) +from api.models import APIToken +from connectors.models import ConnectorConfig +from ingestion.models import Document, DocumentChunk +from tenants.models import Project, Tenant + + +@pytest.fixture +def results_setup(db): + tenant = Tenant.objects.create(name="ResultsTenant", slug="results-tenant") + project = Project.objects.create(tenant=tenant, name="ResultsProject", slug="results-project") + user = User.objects.create_user(username="resultsuser", password="pass") + raw_token = "results_test_token" + APIToken.objects.create( + key_hash=hashlib.sha256(raw_token.encode()).hexdigest(), + user=user, + tenant=tenant, + project=project, + name="results-token", + ) + job = AnalysisJob.objects.create( + tenant=tenant, + project=project, + status=AnalysisJob.Status.COMPLETED, + ) + connector = ConnectorConfig.objects.create( + tenant=tenant, + project=project, + name="test-connector", + connector_type=ConnectorConfig.ConnectorType.GENERIC, + ) + doc_a = Document.objects.create( + tenant=tenant, + project=project, + connector=connector, + source_id="doc-a", + title="Document A", + content_hash="aaa", + ) + doc_b = Document.objects.create( + tenant=tenant, + project=project, + connector=connector, + source_id="doc-b", + title="Document B", + content_hash="bbb", + ) + chunk_a = DocumentChunk.objects.create( + tenant=tenant, + document=doc_a, + chunk_index=0, + content="Chunk A content", + ) + chunk_b = DocumentChunk.objects.create( + tenant=tenant, + document=doc_b, + chunk_index=0, + content="Chunk B content", + ) + return { + "tenant": tenant, + "project": project, + "token": raw_token, + "job": job, + "connector": connector, + "doc_a": doc_a, + "doc_b": doc_b, + "chunk_a": chunk_a, + "chunk_b": chunk_b, + } + + +class TestDuplicatesView: + def test_list_duplicate_groups_with_pairs(self, client, results_setup): + s = results_setup + group = DuplicateGroup.objects.create( + tenant=s["tenant"], + project=s["project"], + analysis_job=s["job"], + recommended_action=DuplicateGroup.Action.MERGE, + rationale="Very similar content", + ) + pair = DuplicatePair.objects.create( + tenant=s["tenant"], + project=s["project"], + group=group, + doc_a=s["doc_a"], + doc_b=s["doc_b"], + semantic_score=0.95, + lexical_score=0.80, + metadata_score=0.70, + combined_score=0.85, + ) + + response = client.get( + f"/api/v1/analysis/{s['job'].id}/duplicates/", + HTTP_AUTHORIZATION=f"Bearer {s['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["groups"]) == 1 + g = data["groups"][0] + assert g["id"] == str(group.id) + assert g["recommended_action"] == "merge" + assert g["rationale"] == "Very similar content" + assert len(g["pairs"]) == 1 + p = g["pairs"][0] + assert p["doc_a"]["title"] == "Document A" + assert p["doc_b"]["title"] == "Document B" + assert p["combined_score"] == 0.85 + assert p["verified"] is False + + def test_duplicates_not_found(self, client, results_setup): + response = client.get( + f"/api/v1/analysis/{uuid.uuid4()}/duplicates/", + HTTP_AUTHORIZATION=f"Bearer {results_setup['token']}", + ) + assert response.status_code == 404 + assert response.json()["code"] == "NOT_FOUND" + + def test_duplicates_empty(self, client, results_setup): + response = client.get( + f"/api/v1/analysis/{results_setup['job'].id}/duplicates/", + HTTP_AUTHORIZATION=f"Bearer {results_setup['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert data["groups"] == [] + + +class TestContradictionsView: + def test_list_contradictions(self, client, results_setup): + s = results_setup + claim_a = Claim.objects.create( + tenant=s["tenant"], + project=s["project"], + document=s["doc_a"], + chunk=s["chunk_a"], + subject="Policy X", + predicate="requires", + object_value="approval", + raw_text="Policy X requires approval", + ) + claim_b = Claim.objects.create( + tenant=s["tenant"], + project=s["project"], + document=s["doc_b"], + chunk=s["chunk_b"], + subject="Policy X", + predicate="does not require", + object_value="approval", + raw_text="Policy X does not require approval", + ) + contradiction = ContradictionPair.objects.create( + tenant=s["tenant"], + project=s["project"], + analysis_job=s["job"], + claim_a=claim_a, + claim_b=claim_b, + classification=ContradictionPair.Classification.CONTRADICTION, + severity=ContradictionPair.Severity.HIGH, + confidence=0.92, + evidence="Claims directly contradict each other.", + ) + + response = client.get( + f"/api/v1/analysis/{s['job'].id}/contradictions/", + HTTP_AUTHORIZATION=f"Bearer {s['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + c = data["contradictions"][0] + assert c["id"] == str(contradiction.id) + assert c["classification"] == "contradiction" + assert c["severity"] == "high" + assert c["confidence"] == 0.92 + assert c["claim_a"]["document_id"] == str(s["doc_a"].id) + assert c["claim_b"]["document_id"] == str(s["doc_b"].id) + + def test_contradictions_not_found(self, client, results_setup): + response = client.get( + f"/api/v1/analysis/{uuid.uuid4()}/contradictions/", + HTTP_AUTHORIZATION=f"Bearer {results_setup['token']}", + ) + assert response.status_code == 404 + + +class TestClustersView: + def test_list_top_level_clusters(self, client, results_setup): + s = results_setup + parent_cluster = TopicCluster.objects.create( + tenant=s["tenant"], + project=s["project"], + analysis_job=s["job"], + label="Security", + summary="Security-related documents", + doc_count=5, + chunk_count=20, + level=0, + key_concepts=["auth", "encryption"], + ) + # Child cluster should NOT appear in results + TopicCluster.objects.create( + tenant=s["tenant"], + project=s["project"], + analysis_job=s["job"], + parent=parent_cluster, + label="Authentication", + summary="Auth docs", + doc_count=2, + chunk_count=8, + level=1, + key_concepts=["login", "SSO"], + ) + + response = client.get( + f"/api/v1/analysis/{s['job'].id}/clusters/", + HTTP_AUTHORIZATION=f"Bearer {s['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + cl = data["clusters"][0] + assert cl["id"] == str(parent_cluster.id) + assert cl["label"] == "Security" + assert cl["doc_count"] == 5 + assert cl["key_concepts"] == ["auth", "encryption"] + + def test_clusters_not_found(self, client, results_setup): + response = client.get( + f"/api/v1/analysis/{uuid.uuid4()}/clusters/", + HTTP_AUTHORIZATION=f"Bearer {results_setup['token']}", + ) + assert response.status_code == 404 + + +class TestGapsView: + def test_list_gaps(self, client, results_setup): + s = results_setup + gap = GapReport.objects.create( + tenant=s["tenant"], + project=s["project"], + analysis_job=s["job"], + gap_type=GapReport.GapType.MISSING_TOPIC, + title="Missing disaster recovery docs", + description="No documentation covers disaster recovery procedures.", + severity="high", + coverage_score=0.1, + evidence={"unanswered_questions": ["What is the DR plan?"]}, + ) + + response = client.get( + f"/api/v1/analysis/{s['job'].id}/gaps/", + HTTP_AUTHORIZATION=f"Bearer {s['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + g = data["gaps"][0] + assert g["id"] == str(gap.id) + assert g["gap_type"] == "missing_topic" + assert g["title"] == "Missing disaster recovery docs" + assert g["severity"] == "high" + assert g["coverage_score"] == 0.1 + + def test_gaps_not_found(self, client, results_setup): + response = client.get( + f"/api/v1/analysis/{uuid.uuid4()}/gaps/", + HTTP_AUTHORIZATION=f"Bearer {results_setup['token']}", + ) + assert response.status_code == 404 + + def test_gaps_empty(self, client, results_setup): + response = client.get( + f"/api/v1/analysis/{results_setup['job'].id}/gaps/", + HTTP_AUTHORIZATION=f"Bearer {results_setup['token']}", + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert data["gaps"] == [] + + +class TestAuthRequired: + def test_no_token_returns_401(self, client, results_setup): + response = client.get( + f"/api/v1/analysis/{results_setup['job'].id}/duplicates/", + ) + assert response.status_code == 401 From 01905574e04f92fa0f3887b8b6edade5bf86e706 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:35:57 +0100 Subject: [PATCH 8/9] fix(api): correct vector store upsert signature and add embedding error logging --- api/views_documents.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/api/views_documents.py b/api/views_documents.py index 2bd3c8c..4328368 100644 --- a/api/views_documents.py +++ b/api/views_documents.py @@ -130,17 +130,21 @@ def _create_document(request): for chunk_obj, embedding in zip(chunk_objects, embeddings): vec_store.upsert( chunk_id=str(chunk_obj.id), - embedding=embedding, tenant_id=tenant.slug, - document_id=str(doc.id), - doc_type=content_type, - source_type="api", + vector=embedding, + metadata={ + "document_id": str(doc.id), + "doc_type": content_type, + "source_type": "api", + }, project_id=str(project.id), ) chunk_obj.has_embedding = True DocumentChunk.objects.bulk_update(chunk_objects, ["has_embedding"]) doc.status = Document.Status.READY - except Exception: + except Exception as exc: + import logging + logging.getLogger(__name__).warning("Embedding failed for doc %s: %s", doc.id, exc) doc.status = Document.Status.INGESTED doc.save() From 8d4b3343fd1ef5305b871705975f9a6f7302bd8b Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Thu, 26 Mar 2026 11:37:08 +0100 Subject: [PATCH 9/9] fix: handle None paragraph style in DOCX extraction --- ingestion/extraction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingestion/extraction.py b/ingestion/extraction.py index 3e9e5fc..9e23905 100644 --- a/ingestion/extraction.py +++ b/ingestion/extraction.py @@ -145,7 +145,7 @@ def _extract_docx(content: bytes) -> ExtractedText: if not text: continue - style_name = (para.style.name or "").lower() + style_name = ((para.style.name if para.style else "") or "").lower() if "heading" in style_name: # Extract heading level from style name (e.g., "Heading 2") level_match = re.search(r"(\d+)", style_name)