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
8 changes: 5 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ You are working in Newsletter Maker, a Django + DRF + Celery + Qdrant backend wi

- Backend runtime code is split across `core/`, `projects/`, `content/`, `entities/`, `ingestion/`, `newsletters/`, `pipeline/`, `trends/`, and `users/`.
- Django project settings and top-level URLs live in `newsletter_maker/`.
- Backend tests live primarily in `core/tests/`, app-local `tests/` packages, and `tests/`.
- Backend tests live in app-local `tests/` packages first (`users/tests/`, `projects/tests/`, `ingestion/tests/`, `newsletters/tests/`, `pipeline/tests/`), with `core/tests/` reserved for the remaining cross-cutting coverage.
- The repo-root `tests/` package is for integration coverage only. New unit and app-scoped tests should live in the owning app's `tests/` package.
- Frontend application code lives in `frontend/src/app/`, shared UI in `frontend/src/components/`, and shared API/types/helpers in `frontend/src/lib/`.
- Operational and architecture docs live in `docs/`.

Expand All @@ -20,11 +21,12 @@ You are working in Newsletter Maker, a Django + DRF + Celery + Qdrant backend wi
## Backend Conventions

- Project scoping is a core invariant. Most API resources are nested under `/api/v1/projects/{project_id}/...`.
- Reuse the established DRF patterns in `core/api.py`, `core/api_urls.py`, and `core/serializers.py`:
- Treat `core/` as the home for genuine cross-cutting concerns only. New app-owned runtime logic should live with its owning app rather than expanding `core/`.
- Reuse the established DRF patterns in `core/api.py`, `core/api_urls.py`, and `core/serializer_mixins.py`:
- `ProjectOwnedQuerysetMixin` for nested viewsets
- serializer context containing `project`
- explicit validation for cross-project foreign keys
- Keep viewsets and views thin. Put operational logic in `core/tasks.py`, `core/pipeline.py`, `core/newsletters.py`, `core/plugins/`, or nearby helpers.
- Keep viewsets and views thin. Put operational logic in `core/tasks.py`, `core/pipeline.py`, `ingestion/plugins/`, `newsletters/intake.py`, or nearby helpers owned by the feature's app.
- Preserve existing API field shapes. Backend serializers and frontend types currently use `snake_case`; do not introduce ad hoc `camelCase` transforms.
- When API behavior changes, update drf-spectacular schema metadata in `core/api.py`.
- When changing ingestion, newsletter intake, AI processing, or embeddings, preserve the handoff between database state, Celery tasks, and Qdrant state.
Expand Down
73 changes: 67 additions & 6 deletions content/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,73 @@
from unfold.admin import ModelAdmin

from content.models import Content, UserFeedback
from core.admin import (
DuplicateStateFilter,
HighValueFilter,
_score_color,
_score_to_percent,
)


def _score_to_percent(value):
"""Normalize score-like values for display as percentages."""

if value is None:
return None
numeric_value = float(value)
if -1.0 <= numeric_value <= 1.0:
return numeric_value * 100
return numeric_value


def _score_color(value) -> str:
"""Return the admin display color for a score-like value."""

percent_value = _score_to_percent(value)
if percent_value is None:
return "inherit"
if percent_value >= 75:
return "green"
if percent_value >= 40:
return "orange"
return "red"


class HighValueFilter(admin.SimpleListFilter):
"""Filter content down to high-value reference items."""

title = "Content Value"
parameter_name = "value_tier"

def lookups(self, request, model_admin):
"""Return the custom filter options displayed in the admin sidebar."""

return (("high_value", "🔥 High Value (Score > 80 & Reference)"),)

def queryset(self, request, queryset):
"""Apply the high-value filter when it is selected."""

if self.value() == "high_value":
return queryset.filter(relevance_score__gt=80, is_reference=True)
return queryset


class DuplicateStateFilter(admin.SimpleListFilter):
"""Filter content by duplicate retention and suppression state."""

title = "Duplicate State"
parameter_name = "duplicate_state"

def lookups(self, request, model_admin):
"""Return duplicate-state options displayed in the admin sidebar."""

return (
("canonical_with_duplicates", "Canonical rows with duplicate signals"),
("suppressed_duplicates", "Suppressed duplicate rows"),
)

def queryset(self, request, queryset):
"""Apply the selected duplicate-state filter."""

if self.value() == "canonical_with_duplicates":
return queryset.filter(duplicate_signal_count__gt=0)
if self.value() == "suppressed_duplicates":
return queryset.filter(duplicate_of__isnull=False)
return queryset


@admin.register(Content)
Expand Down
3 changes: 3 additions & 0 deletions content/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ class ContentConfig(AppConfig):

default_auto_field = "django.db.models.BigAutoField"
name = "content"

def ready(self) -> None:
import content.signals # noqa: F401
File renamed without changes.
25 changes: 3 additions & 22 deletions core/signals.py → content/signals.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,15 @@
"""Signal handlers for cross-cutting core behaviors."""
"""Signal handlers for content-owned behaviors."""

from __future__ import annotations

from typing import Any

from django.db.models.signals import post_save
from django.dispatch import receiver

from core.models import UserFeedback
from newsletters.signals import handle_anymail_inbound as _handle_anymail_inbound
from content.models import UserFeedback
from projects.models import ProjectConfig
from trends.tasks import queue_topic_centroid_recompute


def handle_anymail_inbound(
sender: Any,
event: Any,
esp_name: str,
**kwargs: Any,
) -> None:
"""Preserve the legacy core.signals import path for inbound handling."""

_handle_anymail_inbound(
sender=sender,
event=event,
esp_name=esp_name,
**kwargs,
)


@receiver(post_save, sender=UserFeedback)
def queue_topic_centroid_on_feedback_save(sender, instance, created, **kwargs):
"""Queue centroid recomputation when feedback changes and config allows it."""
Expand All @@ -38,4 +19,4 @@ def queue_topic_centroid_on_feedback_save(sender, instance, created, **kwargs):

config, _ = ProjectConfig.objects.get_or_create(project=instance.project)
if config.recompute_topic_centroid_on_feedback_save:
queue_topic_centroid_recompute(instance.project_id)
queue_topic_centroid_recompute(instance.project_id)
Loading
Loading