Skip to content
Open
Empty file added api/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions api/apps.py
Original file line number Diff line number Diff line change
@@ -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"
47 changes: 47 additions & 0 deletions api/auth.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions api/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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'],
},
),
]
Empty file added api/migrations/__init__.py
Empty file.
25 changes: 25 additions & 0 deletions api/models.py
Original file line number Diff line number Diff line change
@@ -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})"
20 changes: 20 additions & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.urls import path

from api import views_tokens, views_documents, views_score, views_audit, views_results

app_name = "api"

urlpatterns = [
path("tokens/", views_tokens.create_token, name="create-token"),
path("documents/", views_documents.document_list, name="document-list"),
path("documents/<uuid:doc_id>/", 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/<uuid:job_id>/", views_score.analysis_detail_view, name="analysis-detail"),
path("audit/", views_audit.audit_trigger, name="audit-trigger"),
path("audit/<uuid:job_id>/", views_audit.audit_detail, name="audit-detail"),
path("analysis/<uuid:job_id>/duplicates/", views_results.duplicates_view, name="duplicates"),
path("analysis/<uuid:job_id>/contradictions/", views_results.contradictions_view, name="contradictions"),
path("analysis/<uuid:job_id>/clusters/", views_results.clusters_view, name="clusters"),
path("analysis/<uuid:job_id>/gaps/", views_results.gaps_view, name="gaps"),
]
60 changes: 60 additions & 0 deletions api/views_audit.py
Original file line number Diff line number Diff line change
@@ -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,
})
190 changes: 190 additions & 0 deletions api/views_documents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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),
tenant_id=tenant.slug,
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 as exc:
import logging
logging.getLogger(__name__).warning("Embedding failed for doc %s: %s", doc.id, exc)
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(),
})
Loading
Loading