Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 213 additions & 2 deletions core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
"""

import json
from urllib.parse import urlencode

from django import forms
from django.contrib import admin, messages
from django.db.models import Avg
from django.db.models import Avg, Max, QuerySet
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.safestring import mark_safe
Expand All @@ -35,6 +38,7 @@
ReviewQueue,
SkillResult,
SourceConfig,
TopicCentroidSnapshot,
UserFeedback,
)
from core.plugins import get_plugin_for_source_config, validate_plugin_config
Expand Down Expand Up @@ -64,6 +68,94 @@ def _score_color(value) -> str:
return "red"


def _drift_card_color(value) -> str:
"""Return an admin card severity for centroid drift percentages."""

if value is None:
return "info"
numeric_value = float(value)
if numeric_value <= 0.15:
return "success"
if numeric_value <= 0.35:
return "warning"
return "danger"


def _format_snapshot_freshness(computed_at) -> str:
"""Return a compact human-readable age for the latest snapshot."""

if computed_at is None:
return "-"
age = timezone.now() - computed_at
total_hours = max(0, int(age.total_seconds() // 3600))
if total_hours < 24:
return f"{total_hours}h ago"
return f"{max(1, total_hours // 24)}d ago"


def _freshness_card_color(computed_at) -> str:
"""Return an admin card severity based on snapshot recency."""

if computed_at is None:
return "warning"
age = timezone.now() - computed_at
age_hours = age.total_seconds() / 3600
if age_hours <= 24:
return "success"
if age_hours <= 72:
return "warning"
return "danger"


def _build_topic_centroid_project_drilldowns(queryset, changelist_url: str):
"""Build one filtered-history drilldown row per project.

The changelist widget needs stable project links even on SQLite, so this keeps
the grouping logic in Python instead of relying on database-specific distinct-on
behavior.
"""

latest_by_project: dict[int, TopicCentroidSnapshot] = {}
snapshot_counts: dict[int, int] = {}
ordered_snapshots = queryset.select_related("project").order_by(
"project_id", "-computed_at"
)

for snapshot in ordered_snapshots:
project_id = snapshot.project_id
snapshot_counts[project_id] = snapshot_counts.get(project_id, 0) + 1
latest_by_project.setdefault(project_id, snapshot)

project_drilldowns = []
for snapshot in sorted(
latest_by_project.values(),
key=lambda value: value.project.name.lower(),
):
project_drilldowns.append(
{
"project_id": snapshot.project_id,
"project_name": snapshot.project.name,
"snapshot_count": snapshot_counts[snapshot.project_id],
"centroid_active": snapshot.centroid_active,
"feedback_count": snapshot.feedback_count,
"latest_snapshot": _format_snapshot_freshness(snapshot.computed_at),
"drift_from_previous": (
f"{_score_to_percent(snapshot.drift_from_previous):.1f}%"
if snapshot.drift_from_previous is not None
else "n/a"
),
"drift_from_week_ago": (
f"{_score_to_percent(snapshot.drift_from_week_ago):.1f}%"
if snapshot.drift_from_week_ago is not None
else "n/a"
),
"href": f"{changelist_url}?{urlencode({'project__id__exact': snapshot.project_id})}",
}
)

return project_drilldowns


class BlueskyCredentialsAdminForm(forms.ModelForm):
"""Admin form that accepts a plaintext Bluesky app credential input."""

Expand Down Expand Up @@ -223,6 +315,15 @@ class ProjectConfigAdmin(admin.ModelAdmin):
"upvote_authority_weight",
"downvote_authority_weight",
"authority_decay_rate",
"recompute_topic_centroid_on_feedback_save",
)
list_filter = ("recompute_topic_centroid_on_feedback_save",)
fields = (
"project",
"upvote_authority_weight",
"downvote_authority_weight",
"authority_decay_rate",
"recompute_topic_centroid_on_feedback_save",
)


Expand Down Expand Up @@ -325,6 +426,112 @@ def display_components(self, obj):
)


@admin.register(TopicCentroidSnapshot)
class TopicCentroidSnapshotAdmin(admin.ModelAdmin):
"""Admin view for persisted topic-centroid history and drift."""

list_before_template = "admin/topic_centroid_snapshot_changelist_widget.html"
list_display = (
"project",
"centroid_active",
"feedback_count",
"display_drift_from_previous",
"display_drift_from_week_ago",
"computed_at",
)
list_filter = (
"centroid_active",
("project", admin.RelatedOnlyFieldListFilter),
"computed_at",
)
search_fields = ("project__name",)
autocomplete_fields = ("project",)

@admin.display(description="Drift vs Previous", ordering="drift_from_previous")
def display_drift_from_previous(self, obj):
"""Render cosine-distance drift from the previous active snapshot."""

if obj.drift_from_previous is None:
return "n/a"
return f"{_score_to_percent(obj.drift_from_previous):.1f}%"

@admin.display(description="Drift vs 7d", ordering="drift_from_week_ago")
def display_drift_from_week_ago(self, obj):
"""Render cosine-distance drift from the nearest week-old snapshot."""

if obj.drift_from_week_ago is None:
return "n/a"
return f"{_score_to_percent(obj.drift_from_week_ago):.1f}%"

def changelist_view(self, request, extra_context=None):
"""Augment the changelist with centroid freshness and drift summary cards."""

queryset = self.get_queryset(request)
changelist_url = reverse(
f"{self.admin_site.name}:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist"
)
metrics = queryset.aggregate(
avg_drift_from_previous=Avg("drift_from_previous"),
avg_drift_from_week_ago=Avg("drift_from_week_ago"),
latest_snapshot_at=Max("computed_at"),
)
project_count = queryset.values("project_id").distinct().count()
active_project_count = (
queryset.filter(centroid_active=True)
.values("project_id")
.distinct()
.count()
)

extra_context = extra_context or {}
extra_context["dashboard_stats"] = [
{
"title": "Active Centroids",
"value": (
f"{active_project_count} / {project_count}"
if project_count
else "0 / 0"
),
"icon": "hub",
"color": (
"success"
if active_project_count == project_count and project_count
else "warning"
),
},
{
"title": "Avg Drift vs Previous",
"value": (
f"{_score_to_percent(metrics['avg_drift_from_previous']):.1f}%"
if metrics["avg_drift_from_previous"] is not None
else "-"
),
"icon": "show_chart",
"color": _drift_card_color(metrics["avg_drift_from_previous"]),
},
{
"title": "Avg Drift vs 7d",
"value": (
f"{_score_to_percent(metrics['avg_drift_from_week_ago']):.1f}%"
if metrics["avg_drift_from_week_ago"] is not None
else "-"
),
"icon": "timeline",
"color": _drift_card_color(metrics["avg_drift_from_week_ago"]),
},
{
"title": "Latest Snapshot",
"value": _format_snapshot_freshness(metrics["latest_snapshot_at"]),
"icon": "schedule",
"color": _freshness_card_color(metrics["latest_snapshot_at"]),
},
]
extra_context["centroid_project_drilldowns"] = (
_build_topic_centroid_project_drilldowns(queryset, changelist_url)
)
return super().changelist_view(request, extra_context=extra_context)


@admin.register(EntityMention)
class EntityMentionAdmin(admin.ModelAdmin):
"""Admin view for extracted tracked-entity mentions."""
Expand Down Expand Up @@ -404,7 +611,11 @@ def reject_selected_candidates(self, request, queryset):
)

@admin.action(description="Merge selected candidates into existing entities")
def merge_into_existing_entities(self, request, queryset):
def merge_into_existing_entities(
self,
request: HttpRequest,
queryset: QuerySet[EntityCandidate],
) -> None:
"""Merge candidates when a same-name entity already exists in the project."""

merged_count = 0
Expand Down
62 changes: 61 additions & 1 deletion core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import logging
from typing import Any

from django.db.models import Count, Prefetch
from django.db.models import Avg, Count, Prefetch, Q
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
Expand Down Expand Up @@ -40,6 +40,7 @@
ReviewQueue,
SkillResult,
SourceConfig,
TopicCentroidSnapshot,
UserFeedback,
)
from core.serializers import (
Expand All @@ -55,6 +56,8 @@
ReviewQueueSerializer,
SkillResultSerializer,
SourceConfigSerializer,
TopicCentroidObservabilitySummarySerializer,
TopicCentroidSnapshotSerializer,
UserFeedbackSerializer,
)

Expand Down Expand Up @@ -1036,6 +1039,63 @@ class SourceConfigViewSet(ProjectOwnedQuerysetMixin, viewsets.ModelViewSet):
queryset = SourceConfig.objects.select_related("project")


@document_project_owned_viewset(
resource_plural="topic centroid snapshots",
resource_singular="topic centroid snapshot",
create_description="Topic centroid snapshots are pipeline-managed history rows and are exposed read-only for observability.",
tag="Observability",
action_overrides=build_crud_action_overrides(
TopicCentroidSnapshotSerializer,
resource_plural="topic centroid snapshots for the selected project",
resource_singular="topic centroid snapshot",
),
)
class TopicCentroidSnapshotViewSet(
ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelViewSet
):
"""Inspect persisted centroid history and aggregate drift for a project."""

serializer_class = TopicCentroidSnapshotSerializer
queryset = TopicCentroidSnapshot.objects.select_related("project")

@extend_schema(
summary="Get topic centroid summary",
description=(
"Return aggregate centroid observability metrics for the selected project, "
"including average drift and the latest persisted snapshot."
),
request=None,
responses={
200: TopicCentroidObservabilitySummarySerializer,
403: AUTHENTICATION_REQUIRED_RESPONSE,
},
tags=["Observability"],
)
@action(detail=False, methods=["get"], url_path="summary")
def summary(self, request, *args, **kwargs):
"""Return centroid observability summary metrics for the current project."""

queryset = self.get_queryset()
metrics = queryset.aggregate(
snapshot_count=Count("id"),
active_snapshot_count=Count("id", filter=Q(centroid_active=True)),
avg_drift_from_previous=Avg("drift_from_previous"),
avg_drift_from_week_ago=Avg("drift_from_week_ago"),
)
serializer = TopicCentroidObservabilitySummarySerializer(
{
"project": self.get_project().id,
"snapshot_count": metrics["snapshot_count"],
"active_snapshot_count": metrics["active_snapshot_count"],
"avg_drift_from_previous": metrics["avg_drift_from_previous"],
"avg_drift_from_week_ago": metrics["avg_drift_from_week_ago"],
"latest_snapshot": queryset.order_by("-computed_at").first(),
},
context=self.get_serializer_context(),
)
return Response(serializer.data)


@document_project_owned_viewset(
resource_plural="review queue entries",
resource_singular="review queue entry",
Expand Down
6 changes: 6 additions & 0 deletions core/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ReviewQueueViewSet,
SkillResultViewSet,
SourceConfigViewSet,
TopicCentroidSnapshotViewSet,
UserFeedbackViewSet,
)

Expand Down Expand Up @@ -40,6 +41,11 @@
project_router.register(
r"source-configs", SourceConfigViewSet, basename="project-source-config"
)
project_router.register(
r"topic-centroid-snapshots",
TopicCentroidSnapshotViewSet,
basename="project-topic-centroid-snapshot",
)
project_router.register(
r"review-queue", ReviewQueueViewSet, basename="project-review-queue"
)
Expand Down
Loading
Loading