diff --git a/SESSION.md b/SESSION.md index 3977f3f3..2d2aecdc 100644 --- a/SESSION.md +++ b/SESSION.md @@ -1 +1,73 @@ # Session Restore Point + +## Current focus + +Phase 3 WP5: original content idea generation in `trends/`. + +## What landed today + +- Added `OriginalContentIdeaStatus` and `OriginalContentIdea` to `trends/models.py`. +- Added migration `trends/migrations/0005_original_content_idea.py`. +- Added new prompt skill folder `skills/original_content_ideation/` with: + - `SKILL.md` + - `resources/gap_detect.md` + - `resources/generate.md` + - `resources/critique.md` +- Added partial WP5 task implementation in `trends/tasks.py`: + - `run_all_original_content_idea_generations` + - `generate_original_content_ideas` + - workflow helpers for accept / dismiss / mark written + - heuristic gap detection + fallback generation + heuristic critique + - optional OpenRouter-backed prompt-resource calls for gap detect / generate / critique +- Added partial task coverage in `trends/tests/test_tasks.py` for: + - pending-idea creation + - weekly cap + - mark-written workflow + +## What was validated + +- `python manage.py check` +- `python manage.py makemigrations --check --dry-run trends` + +Both passed after naming the new model index `core_idea_project_7f21_idx`. + +## Known incomplete state + +- WP5 is not finished. +- No serializer/API route/test work has been added yet for `OriginalContentIdea`. +- `docs/IMPLEMENTATION_PHASE_3.md` still needs a current implementation note for WP5 once the backend slice is complete. +- The focused task validation was interrupted: + - first `pytest trends/tests/test_tasks.py -q` failed on a duplicate `_build_theme_cluster_context` definition introduced during editing + - that duplicate definition was removed + - the rerun was cancelled because we paused for shutdown +- After that, additional external edits were reported in: + - `docs/IMPLEMENTATION_PHASE_3.md` + - `trends/tasks.py` + - `trends/tests/test_tasks.py` + Re-read those files before making new edits tomorrow. + +## First steps tomorrow + +1. Confirm the active branch. The repo attachment reported `maintenance/finish-core-refactor`, but an earlier terminal action created `feature/original-content-idea-generation`, so verify before continuing. +2. Re-read: + - `trends/tasks.py` + - `trends/tests/test_tasks.py` + - `docs/IMPLEMENTATION_PHASE_3.md` +3. Run the focused validation that was interrupted: + - `pytest trends/tests/test_tasks.py -q` +4. If task tests pass, finish the remaining WP5 slice: + - `trends/serializers.py` + - `trends/api.py` + - `trends/api_urls.py` + - `trends/tests/test_api.py` + - optional admin only if needed + - add WP5 implementation note to `docs/IMPLEMENTATION_PHASE_3.md` +5. After API work, run focused validation again: + - `pytest trends/tests/test_tasks.py trends/tests/test_api.py -q` + - `python3 -m mypy trends/tasks.py` + +## Likely follow-up checks + +- Watch for unused imports in `trends/tasks.py` (`build_skill_user_prompt` / `get_skill_definition` may no longer be needed for WP5 helpers). +- Confirm the new heuristic tests still pass after any formatter changes. +- Keep WP5 scoped to backend + docs for now; frontend `/ideas` work belongs to WP6. diff --git a/skills/original_content_ideation/SKILL.md b/skills/original_content_ideation/SKILL.md new file mode 100644 index 00000000..e5b733a5 --- /dev/null +++ b/skills/original_content_ideation/SKILL.md @@ -0,0 +1,15 @@ +--- +name: original-content-ideation +description: Generate grounded original article ideas from project trend gaps. +input: project_topic_description, cluster_context, supporting_contents, recent_themes_accepted, recent_themes_dismissed +output: angle_title, summary, suggested_outline, why_now, self_critique_score +--- + +This skill generates editor-facing original content ideas from project-scoped trend gaps. + +The runtime flow is split into three prompt resources under `resources/`: +- `gap_detect.md` +- `generate.md` +- `critique.md` + +Each step must stay grounded in real project context, accepted and dismissed theme history, and the supplied supporting content. diff --git a/skills/original_content_ideation/resources/critique.md b/skills/original_content_ideation/resources/critique.md new file mode 100644 index 00000000..523c99fb --- /dev/null +++ b/skills/original_content_ideation/resources/critique.md @@ -0,0 +1,7 @@ +You critique a generated original content idea before it reaches an editor. + +Score the idea for topic alignment, redundancy with recent project content, redundancy with theme history, and plausibility of the `why_now` rationale. + +Return structured JSON with these fields: +- `self_critique_score`: a number between 0 and 1, where 1 is a strong editor-ready idea +- `critique_summary`: one short sentence summarizing the critique diff --git a/skills/original_content_ideation/resources/gap_detect.md b/skills/original_content_ideation/resources/gap_detect.md new file mode 100644 index 00000000..07ebf9d9 --- /dev/null +++ b/skills/original_content_ideation/resources/gap_detect.md @@ -0,0 +1,9 @@ +You identify promising editorial gaps around one high-velocity project topic cluster. + +Use the supplied project topic, cluster context, supporting contents, and recent theme history. + +Prioritize clusters that are accelerating, still adjacent to the project's topic centroid, and not yet dominated by high-authority voices. + +Return structured JSON with these fields: +- `gap_description`: one concise paragraph describing the undercovered opportunity +- `gap_score`: a number between 0 and 1, where 1 is a very strong original-content opportunity diff --git a/skills/original_content_ideation/resources/generate.md b/skills/original_content_ideation/resources/generate.md new file mode 100644 index 00000000..84932761 --- /dev/null +++ b/skills/original_content_ideation/resources/generate.md @@ -0,0 +1,9 @@ +You generate one editor-facing original content idea from a validated project gap. + +Use only the supplied project topic, cluster context, gap analysis, and supporting contents. Keep the idea specific enough that an editor could plausibly assign or write it now. + +Return structured JSON with these fields: +- `angle_title`: a short editorial angle title +- `summary`: a concise explanation of the proposed article +- `suggested_outline`: a short outline the editor could follow +- `why_now`: one short paragraph explaining why the angle is timely diff --git a/trends/api.py b/trends/api.py index d7aea4f1..d246011d 100644 --- a/trends/api.py +++ b/trends/api.py @@ -2,9 +2,10 @@ from typing import Any +from django.conf import settings from django.db.models import Avg, Count, OuterRef, Prefetch, Q, Subquery -from drf_spectacular.utils import OpenApiParameter, extend_schema -from rest_framework import serializers, viewsets +from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer +from rest_framework import serializers, status, viewsets from rest_framework.decorators import action from rest_framework.filters import OrderingFilter from rest_framework.response import Response @@ -19,6 +20,7 @@ from core.permissions import IsProjectContributor, IsProjectMember from trends.models import ( ContentClusterMembership, + OriginalContentIdea, SourceDiversitySnapshot, ThemeSuggestion, ThemeSuggestionStatus, @@ -27,6 +29,8 @@ TopicVelocitySnapshot, ) from trends.serializers import ( + OriginalContentIdeaDismissSerializer, + OriginalContentIdeaSerializer, SourceDiversityObservabilitySummarySerializer, SourceDiversitySnapshotSerializer, ThemeSuggestionDismissSerializer, @@ -38,6 +42,12 @@ TopicVelocitySnapshotSerializer, ) from trends.tasks import accept_theme_suggestion, dismiss_theme_suggestion +from trends.tasks import ( + accept_original_content_idea, + dismiss_original_content_idea, + generate_original_content_ideas, + mark_original_content_idea_written, +) def _require_pk(instance: Any) -> int: @@ -278,6 +288,192 @@ def dismiss(self, request, *args, **kwargs): return Response(response_serializer.data) +@document_project_owned_viewset( + resource_plural="original content ideas", + resource_singular="original content idea", + create_description="Original content ideas are pipeline-managed rows and are exposed read-only aside from editorial workflow actions.", + tag="Trend Analysis", + action_overrides=build_crud_action_overrides( + OriginalContentIdeaSerializer, + resource_plural="original content ideas for the selected project", + resource_singular="original content idea", + ), +) +class OriginalContentIdeaViewSet( + ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelViewSet +): + """Inspect and resolve project-scoped original-content ideas.""" + + serializer_class = OriginalContentIdeaSerializer + filter_backends = [OrderingFilter] + ordering_fields = ["created_at", "self_critique_score", "status"] + ordering = ["status", "-self_critique_score", "-created_at"] + queryset = OriginalContentIdea.objects.select_related( + "project", "related_cluster", "decided_by" + ) + + def get_queryset(self): + """Prefetch supporting contents for original-content idea responses.""" + + return ( + super() + .get_queryset() + .select_related("related_cluster__dominant_entity") + .prefetch_related( + Prefetch( + "supporting_contents", + queryset=Content.objects.order_by("-published_date", "-id"), + ) + ) + ) + + def get_permissions(self): + """Allow members to read ideas and contributors to resolve them.""" + + if self.action in {"accept", "dismiss", "mark_written", "generate"}: + return [IsProjectContributor()] + return [IsProjectMember()] + + @extend_schema( + summary="Generate original content ideas", + description=( + "Trigger original-content idea generation for the selected project. " + "When Celery runs eagerly, ideas are generated before the response is returned. " + "Otherwise, the generation task is queued for background execution." + ), + request=None, + responses={ + 200: inline_serializer( + name="OriginalContentIdeaGenerateCompletedResponse", + fields={ + "status": serializers.CharField(), + "project_id": serializers.IntegerField(), + "result": inline_serializer( + name="OriginalContentIdeaGenerateResult", + fields={ + "project_id": serializers.IntegerField(), + "clusters_considered": serializers.IntegerField(), + "created": serializers.IntegerField(), + "skipped": serializers.IntegerField(), + }, + ), + }, + ), + 202: inline_serializer( + name="OriginalContentIdeaGenerateQueuedResponse", + fields={ + "status": serializers.CharField(), + "project_id": serializers.IntegerField(), + }, + ), + 403: AUTHENTICATION_REQUIRED_RESPONSE, + }, + tags=["Trend Analysis"], + ) + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request, *args, **kwargs): + """Trigger original-content idea generation for the current project.""" + + project = self.get_project() + project_id = _require_pk(project) + if settings.CELERY_TASK_ALWAYS_EAGER: + result = generate_original_content_ideas(project_id) + return Response( + { + "status": "completed", + "project_id": project_id, + "result": result, + } + ) + generate_original_content_ideas.delay(project_id) + return Response( + {"status": "queued", "project_id": project_id}, + status=status.HTTP_202_ACCEPTED, + ) + + @extend_schema( + summary="Accept original content idea", + description="Mark a pending original content idea as accepted by the current editor.", + request=None, + responses={ + 200: OriginalContentIdeaSerializer, + 403: AUTHENTICATION_REQUIRED_RESPONSE, + }, + tags=["Trend Analysis"], + ) + @action(detail=True, methods=["post"], url_path="accept") + def accept(self, request, *args, **kwargs): + """Accept the selected pending original-content idea.""" + + idea = self.get_object() + try: + accept_original_content_idea(idea, user_id=request.user.id) + except ValueError as exc: + raise serializers.ValidationError( + {"status": "Unable to accept this original content idea."} + ) from exc + response_serializer = self.get_serializer(idea) + return Response(response_serializer.data) + + @extend_schema( + summary="Dismiss original content idea", + description="Dismiss a pending original content idea and persist the editor's reason.", + request=OriginalContentIdeaDismissSerializer, + responses={ + 200: OriginalContentIdeaSerializer, + 400: OriginalContentIdeaDismissSerializer, + 403: AUTHENTICATION_REQUIRED_RESPONSE, + }, + tags=["Trend Analysis"], + ) + @action(detail=True, methods=["post"], url_path="dismiss") + def dismiss(self, request, *args, **kwargs): + """Dismiss the selected pending original-content idea.""" + + idea = self.get_object() + serializer = OriginalContentIdeaDismissSerializer( + data=request.data, + context=self.get_serializer_context(), + ) + serializer.is_valid(raise_exception=True) + try: + dismiss_original_content_idea( + idea, + user_id=request.user.id, + reason=serializer.validated_data["reason"], + ) + except ValueError as exc: + raise serializers.ValidationError( + {"status": "Unable to dismiss this original content idea."} + ) from exc + response_serializer = self.get_serializer(idea) + return Response(response_serializer.data) + + @extend_schema( + summary="Mark original content idea written", + description="Mark an accepted original content idea as written by the current editor.", + request=None, + responses={ + 200: OriginalContentIdeaSerializer, + 403: AUTHENTICATION_REQUIRED_RESPONSE, + }, + tags=["Trend Analysis"], + ) + @action(detail=True, methods=["post"], url_path="mark_written") + def mark_written(self, request, *args, **kwargs): + """Mark the selected accepted original-content idea as written.""" + + idea = self.get_object() + try: + mark_original_content_idea_written(idea, user_id=request.user.id) + except ValueError as exc: + raise serializers.ValidationError( + {"status": "Unable to mark this original content idea as written."} + ) from exc + response_serializer = self.get_serializer(idea) + return Response(response_serializer.data) + + @document_project_owned_viewset( resource_plural="topic centroid snapshots", resource_singular="topic centroid snapshot", diff --git a/trends/api_urls.py b/trends/api_urls.py index 54f50b00..5e2dec61 100644 --- a/trends/api_urls.py +++ b/trends/api_urls.py @@ -3,6 +3,7 @@ from rest_framework_nested.routers import NestedSimpleRouter from trends.api import ( + OriginalContentIdeaViewSet, SourceDiversitySnapshotViewSet, ThemeSuggestionViewSet, TopicCentroidSnapshotViewSet, @@ -23,6 +24,11 @@ def register_project_routes(project_router: NestedSimpleRouter) -> None: ThemeSuggestionViewSet, basename="project-theme-suggestion", ) + project_router.register( + r"ideas", + OriginalContentIdeaViewSet, + basename="project-original-content-idea", + ) project_router.register( r"topic-centroid-snapshots", TopicCentroidSnapshotViewSet, diff --git a/trends/migrations/0005_original_content_idea.py b/trends/migrations/0005_original_content_idea.py new file mode 100644 index 00000000..8c54993d --- /dev/null +++ b/trends/migrations/0005_original_content_idea.py @@ -0,0 +1,98 @@ +import django.db.models.deletion + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("content", "0001_initial"), + ("trends", "0004_source_diversity_snapshot"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="OriginalContentIdea", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("angle_title", models.CharField(max_length=255)), + ("summary", models.TextField()), + ("suggested_outline", models.TextField()), + ("why_now", models.TextField()), + ("generated_by_model", models.CharField(max_length=128)), + ("self_critique_score", models.FloatField()), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("accepted", "Accepted"), + ("dismissed", "Dismissed"), + ("written", "Written"), + ], + default="pending", + max_length=16, + ), + ), + ("dismissal_reason", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("decided_at", models.DateTimeField(blank=True, null=True)), + ( + "decided_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="decided_original_content_ideas", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="original_content_ideas", + to="projects.project", + ), + ), + ( + "related_cluster", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="original_content_ideas", + to="trends.topiccluster", + ), + ), + ], + options={ + "ordering": ["-created_at", "id"], + "db_table": "core_originalcontentidea", + "indexes": [ + models.Index( + fields=["project", "status", "-created_at"], + name="core_idea_project_7f21_idx", + ), + ], + }, + ), + migrations.AddField( + model_name="originalcontentidea", + name="supporting_contents", + field=models.ManyToManyField( + blank=True, + related_name="supporting_ideas", + to="content.content", + ), + ), + ] diff --git a/trends/models.py b/trends/models.py index 95cdf45d..a646b0e7 100644 --- a/trends/models.py +++ b/trends/models.py @@ -207,6 +207,71 @@ def __str__(self) -> str: return self.title +class OriginalContentIdeaStatus(models.TextChoices): + """Workflow states for generated original content ideas.""" + + PENDING = "pending", "Pending" + ACCEPTED = "accepted", "Accepted" + DISMISSED = "dismissed", "Dismissed" + WRITTEN = "written", "Written" + + +class OriginalContentIdea(models.Model): + """Persist one editor-facing original content idea grounded in cluster gaps.""" + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="original_content_ideas", + ) + angle_title = models.CharField(max_length=255) + summary = models.TextField() + suggested_outline = models.TextField() + why_now = models.TextField() + supporting_contents = models.ManyToManyField( + "content.Content", + related_name="supporting_ideas", + blank=True, + ) + related_cluster = models.ForeignKey( + TopicCluster, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="original_content_ideas", + ) + generated_by_model = models.CharField(max_length=128) + self_critique_score = models.FloatField() + status = models.CharField( + max_length=16, + choices=OriginalContentIdeaStatus.choices, + default=OriginalContentIdeaStatus.PENDING, + ) + dismissal_reason = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + decided_at = models.DateTimeField(null=True, blank=True) + decided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="decided_original_content_ideas", + ) + + class Meta: + ordering = ["-created_at", "id"] + db_table = "core_originalcontentidea" + indexes = [ + models.Index( + fields=["project", "status", "-created_at"], + name="core_idea_project_7f21_idx", + ), + ] + + def __str__(self) -> str: + return self.angle_title + + class SourceDiversitySnapshot(models.Model): """Capture one project-level source diversity reading for a rolling window.""" diff --git a/trends/serializers.py b/trends/serializers.py index 36a1cf1f..59b00c20 100644 --- a/trends/serializers.py +++ b/trends/serializers.py @@ -6,6 +6,8 @@ from core.serializer_mixins import ProjectScopedSerializerMixin from trends.models import ( ContentClusterMembership, + OriginalContentIdea, + OriginalContentIdeaStatus, SourceDiversitySnapshot, ThemeSuggestion, ThemeSuggestionStatus, @@ -186,6 +188,76 @@ def validate_reason(self, value: str) -> str: return normalized +class OriginalContentIdeaClusterSummarySerializer(serializers.Serializer): + """Serialize the related cluster summary embedded in original-content ideas.""" + + id = serializers.IntegerField() + label = serializers.CharField(allow_blank=True) + member_count = serializers.IntegerField() + + +class OriginalContentIdeaSupportingContentSerializer(serializers.ModelSerializer): + """Serialize one supporting content row linked to an original-content idea.""" + + class Meta: + model = Content + fields = ["id", "url", "title", "published_date", "source_plugin"] + read_only_fields = fields + + +class OriginalContentIdeaSerializer(serializers.ModelSerializer): + """Serialize one editor-facing original-content idea.""" + + related_cluster = OriginalContentIdeaClusterSummarySerializer(read_only=True) + supporting_contents = OriginalContentIdeaSupportingContentSerializer( + many=True, + read_only=True, + ) + decided_by_username = serializers.CharField( + source="decided_by.username", + read_only=True, + allow_null=True, + ) + + class Meta: + model = OriginalContentIdea + fields = [ + "id", + "project", + "angle_title", + "summary", + "suggested_outline", + "why_now", + "supporting_contents", + "related_cluster", + "generated_by_model", + "self_critique_score", + "status", + "dismissal_reason", + "created_at", + "decided_at", + "decided_by", + "decided_by_username", + ] + read_only_fields = fields + + +class OriginalContentIdeaDismissSerializer( + ProjectScopedSerializerMixin, serializers.Serializer +): + """Validate dismissal requests for pending original-content ideas.""" + + reason = serializers.CharField(max_length=500) + + def validate_reason(self, value: str) -> str: + """Reject blank dismissal reasons.""" + + normalized = value.strip() + if not normalized: + raise serializers.ValidationError("Dismissal reason cannot be blank.") + return normalized + + class TopicCentroidSnapshotSerializer(serializers.ModelSerializer): """Serialize one persisted topic-centroid recomputation for a project.""" diff --git a/trends/tasks.py b/trends/tasks.py index 592e7ef7..7fc196c8 100644 --- a/trends/tasks.py +++ b/trends/tasks.py @@ -3,6 +3,8 @@ from collections import Counter import math from datetime import datetime, timedelta +from functools import lru_cache +from pathlib import Path from typing import Any, Protocol, cast from celery import shared_task @@ -17,6 +19,7 @@ build_content_embedding_text, delete_topic_centroid, embed_text, + get_topic_centroid_similarity, upsert_topic_centroid, ) from core.llm import build_skill_user_prompt, get_skill_definition, openrouter_chat_json @@ -26,6 +29,8 @@ from .models import ( ContentClusterMembership, + OriginalContentIdea, + OriginalContentIdeaStatus, SourceDiversitySnapshot, ThemeSuggestion, ThemeSuggestionStatus, @@ -52,6 +57,15 @@ THEME_NOVELTY_LOOKBACK_DAYS = 30 THEME_NOVELTY_MIN_SCORE = 0.6 THEME_CLUSTER_CONTEXT_LIMIT = 5 +ORIGINAL_CONTENT_IDEATION_SKILL_NAME = "original_content_ideation" +ORIGINAL_CONTENT_IDEA_WEEKLY_CAP = 3 +ORIGINAL_CONTENT_IDEA_LOOKBACK_DAYS = 90 +ORIGINAL_CONTENT_IDEA_MIN_SUPPORTING_CONTENTS = 2 +ORIGINAL_CONTENT_IDEA_SUPPORTING_LIMIT = 3 +ORIGINAL_CONTENT_IDEA_MIN_GAP_SCORE = 0.55 +ORIGINAL_CONTENT_IDEA_MIN_SCORE = 0.6 +ORIGINAL_CONTENT_IDEA_CENTROID_SIMILARITY_MIN = 0.65 +ORIGINAL_CONTENT_IDEA_CENTROID_SIMILARITY_MAX = 0.8 class DelayedTask(Protocol): @@ -592,6 +606,169 @@ def generate_theme_suggestions(project_id: int) -> dict[str, int]: } +@shared_task(name="core.tasks.run_all_original_content_idea_generations") +def run_all_original_content_idea_generations() -> int: + """Queue original-content ideation generation for every project.""" + + project_ids = list(Project.objects.values_list("id", flat=True)) + for project_id in project_ids: + if settings.CELERY_TASK_ALWAYS_EAGER: + generate_original_content_ideas(project_id) + else: + _enqueue_task(generate_original_content_ideas, project_id) + return len(project_ids) + + +@shared_task(name="core.tasks.generate_original_content_ideas") +def generate_original_content_ideas(project_id: int) -> dict[str, int]: + """Generate weekly original-content ideas grounded in project trend gaps.""" + + now = timezone.now() + project = Project.objects.only("id", "name", "topic_description").get(pk=project_id) + recent_accepted_themes = list( + ThemeSuggestion.objects.filter( + project_id=project_id, + status=ThemeSuggestionStatus.ACCEPTED, + created_at__gte=now - timedelta(days=ORIGINAL_CONTENT_IDEA_LOOKBACK_DAYS), + ) + .only("title", "pitch", "why_it_matters") + .order_by("-created_at") + ) + recent_dismissed_themes = list( + ThemeSuggestion.objects.filter( + project_id=project_id, + status=ThemeSuggestionStatus.DISMISSED, + created_at__gte=now - timedelta(days=ORIGINAL_CONTENT_IDEA_LOOKBACK_DAYS), + ) + .only("title", "pitch", "dismissal_reason") + .order_by("-created_at") + ) + recent_project_content = list( + Content.objects.filter( + project_id=project_id, + published_date__gte=now + - timedelta(days=ORIGINAL_CONTENT_IDEA_LOOKBACK_DAYS), + ) + .only("title", "content_text") + .order_by("-published_date", "-id")[:50] + ) + weekly_created_count = OriginalContentIdea.objects.filter( + project_id=project_id, + created_at__gte=now - timedelta(days=7), + ).count() + remaining_slots = max(0, ORIGINAL_CONTENT_IDEA_WEEKLY_CAP - weekly_created_count) + if remaining_slots <= 0: + return { + "project_id": project_id, + "clusters_considered": 0, + "created": 0, + "skipped": 0, + } + + existing_pending_cluster_ids = set( + OriginalContentIdea.objects.filter( + project_id=project_id, + status=OriginalContentIdeaStatus.PENDING, + related_cluster__isnull=False, + ).values_list("related_cluster_id", flat=True) + ) + recent_ideas = list( + OriginalContentIdea.objects.filter( + project_id=project_id, + created_at__gte=now - timedelta(days=ORIGINAL_CONTENT_IDEA_LOOKBACK_DAYS), + ) + .only("angle_title", "summary") + .order_by("-created_at") + ) + vector_cache: dict[int, list[float]] = {} + created_count = 0 + skipped_count = 0 + clusters = list(_clusters_with_latest_velocity(project_id)) + + for cluster in clusters: + if remaining_slots <= 0: + break + cluster_id = _require_pk(cluster) + if cluster_id in existing_pending_cluster_ids: + skipped_count += 1 + continue + + cluster_context = _build_theme_cluster_context(cluster) + supporting_memberships = list( + ContentClusterMembership.objects.filter(cluster=cluster) + .select_related("content", "content__entity") + .order_by("-similarity", "-assigned_at")[ + :ORIGINAL_CONTENT_IDEA_SUPPORTING_LIMIT + ] + ) + if len(supporting_memberships) < ORIGINAL_CONTENT_IDEA_MIN_SUPPORTING_CONTENTS: + skipped_count += 1 + continue + + gap_analysis = _detect_original_content_gap( + project=project, + cluster=cluster, + cluster_context=cluster_context, + supporting_memberships=supporting_memberships, + recent_accepted_themes=recent_accepted_themes, + recent_dismissed_themes=recent_dismissed_themes, + vector_cache=vector_cache, + ) + if ( + float(gap_analysis.get("gap_score", 0.0) or 0.0) + < ORIGINAL_CONTENT_IDEA_MIN_GAP_SCORE + ): + skipped_count += 1 + continue + + idea_payload, generated_by_model = _synthesize_original_content_payload( + project=project, + cluster=cluster, + cluster_context=cluster_context, + gap_analysis=gap_analysis, + supporting_memberships=supporting_memberships, + recent_accepted_themes=recent_accepted_themes, + recent_dismissed_themes=recent_dismissed_themes, + ) + self_critique_score = _score_original_content_idea( + project=project, + cluster=cluster, + idea_payload=idea_payload, + gap_analysis=gap_analysis, + recent_project_content=recent_project_content, + recent_themes=recent_accepted_themes + recent_dismissed_themes, + recent_ideas=recent_ideas, + ) + if self_critique_score < ORIGINAL_CONTENT_IDEA_MIN_SCORE: + skipped_count += 1 + continue + + idea = OriginalContentIdea.objects.create( + project_id=project_id, + angle_title=str(idea_payload["angle_title"]), + summary=str(idea_payload["summary"]), + suggested_outline=str(idea_payload["suggested_outline"]), + why_now=str(idea_payload["why_now"]), + related_cluster=cluster, + generated_by_model=generated_by_model, + self_critique_score=self_critique_score, + ) + idea.supporting_contents.set( + [_require_pk(membership.content) for membership in supporting_memberships] + ) + existing_pending_cluster_ids.add(cluster_id) + recent_ideas.insert(0, idea) + created_count += 1 + remaining_slots -= 1 + + return { + "project_id": project_id, + "clusters_considered": len(clusters), + "created": created_count, + "skipped": skipped_count, + } + + @shared_task(name="core.tasks.assign_content_to_topic_cluster") def assign_content_to_topic_cluster(content_id: int) -> dict[str, object]: """Assign one content item to the nearest active cluster when it fits.""" @@ -1073,6 +1250,59 @@ def dismiss_theme_suggestion( return theme_suggestion +def accept_original_content_idea( + original_content_idea: OriginalContentIdea, *, user_id: int +) -> OriginalContentIdea: + """Mark a pending original-content idea as accepted.""" + + if original_content_idea.status != OriginalContentIdeaStatus.PENDING: + raise ValueError("Only pending original content ideas can be accepted.") + original_content_idea.status = OriginalContentIdeaStatus.ACCEPTED + original_content_idea.decided_at = timezone.now() + original_content_idea.decided_by = get_user_model()._default_manager.get(pk=user_id) + original_content_idea.dismissal_reason = "" + original_content_idea.save( + update_fields=["status", "decided_at", "decided_by", "dismissal_reason"] + ) + return original_content_idea + + +def dismiss_original_content_idea( + original_content_idea: OriginalContentIdea, + *, + user_id: int, + reason: str, +) -> OriginalContentIdea: + """Mark a pending original-content idea as dismissed with editorial feedback.""" + + if original_content_idea.status != OriginalContentIdeaStatus.PENDING: + raise ValueError("Only pending original content ideas can be dismissed.") + original_content_idea.status = OriginalContentIdeaStatus.DISMISSED + original_content_idea.decided_at = timezone.now() + original_content_idea.decided_by = get_user_model()._default_manager.get(pk=user_id) + original_content_idea.dismissal_reason = reason.strip() + original_content_idea.save( + update_fields=["status", "decided_at", "decided_by", "dismissal_reason"] + ) + return original_content_idea + + +def mark_original_content_idea_written( + original_content_idea: OriginalContentIdea, + *, + user_id: int, +) -> OriginalContentIdea: + """Mark an accepted original-content idea as written.""" + + if original_content_idea.status != OriginalContentIdeaStatus.ACCEPTED: + raise ValueError("Only accepted original content ideas can be marked written.") + original_content_idea.status = OriginalContentIdeaStatus.WRITTEN + original_content_idea.decided_at = timezone.now() + original_content_idea.decided_by = get_user_model()._default_manager.get(pk=user_id) + original_content_idea.save(update_fields=["status", "decided_at", "decided_by"]) + return original_content_idea + + def _author_entities_for_contents(contents: list[Content]) -> dict[int, int | None]: """Resolve one best-effort author entity bucket per content row.""" @@ -1287,6 +1517,106 @@ def _clusters_with_latest_velocity(project_id: int): ) +def _detect_original_content_gap( + *, + project: Project, + cluster: TopicCluster, + cluster_context: dict[str, Any], + supporting_memberships: list[ContentClusterMembership], + recent_accepted_themes: list[ThemeSuggestion], + recent_dismissed_themes: list[ThemeSuggestion], + vector_cache: dict[int, list[float]], +) -> dict[str, Any]: + """Describe one promising original-content gap around a high-velocity cluster.""" + + supporting_contents = [membership.content for membership in supporting_memberships] + authoritative_scores = [ + float(content.entity.authority_score) + for content in supporting_contents + if content.entity is not None + ] + authoritative_coverage = ( + sum(1.0 for score in authoritative_scores if score >= 0.7) + / len(authoritative_scores) + if authoritative_scores + else 0.0 + ) + authority_gap_score = 1.0 - authoritative_coverage + centroid_vector = _cluster_centroid_for_contents(supporting_contents, vector_cache) + centroid_similarity = ( + get_topic_centroid_similarity(_require_pk(project), centroid_vector) + if centroid_vector + else 0.0 + ) + centroid_window_score = _similarity_window_score( + centroid_similarity, + minimum=ORIGINAL_CONTENT_IDEA_CENTROID_SIMILARITY_MIN, + maximum=ORIGINAL_CONTENT_IDEA_CENTROID_SIMILARITY_MAX, + ) + overlap_penalty = _theme_overlap_penalty( + cluster_context=cluster_context, + recent_themes=recent_accepted_themes + recent_dismissed_themes, + ) + velocity_score = float(cluster_context.get("velocity_score", 0.0) or 0.0) + heuristic_gap_score = max( + 0.0, + min( + 1.0, + 0.4 * velocity_score + + 0.3 * authority_gap_score + + 0.2 * centroid_window_score + + 0.1 * (1.0 - overlap_penalty), + ), + ) + fallback_gap = { + "gap_description": _fallback_original_content_gap_description( + cluster=cluster, + cluster_context=cluster_context, + authoritative_coverage=authoritative_coverage, + centroid_similarity=centroid_similarity, + ), + "gap_score": heuristic_gap_score, + "centroid_similarity": centroid_similarity, + "authority_gap_score": authority_gap_score, + "authoritative_coverage": authoritative_coverage, + } + + if settings.OPENROUTER_API_KEY: + try: + response = openrouter_chat_json( + model=settings.AI_RELEVANCE_MODEL, + system_prompt=_original_content_prompt_resource("gap_detect"), + user_prompt=_build_original_content_step_prompt( + project=project, + cluster_context=cluster_context, + supporting_memberships=supporting_memberships, + recent_accepted_themes=recent_accepted_themes, + recent_dismissed_themes=recent_dismissed_themes, + extra_payload={ + "heuristic_gap": fallback_gap, + }, + ), + ) + payload = response.payload + return { + **fallback_gap, + "gap_description": str( + payload.get("gap_description", fallback_gap["gap_description"]) + ).strip(), + "gap_score": max( + 0.0, + min( + 1.0, + float(payload.get("gap_score", fallback_gap["gap_score"])), + ), + ), + "generated_by_model": response.model, + } + except Exception: + pass + return fallback_gap + + def _build_theme_cluster_context(cluster: TopicCluster) -> dict[str, Any]: """Serialize the most relevant cluster context for theme generation.""" @@ -1379,6 +1709,121 @@ def _synthesize_theme_payload( } +def _synthesize_original_content_payload( + *, + project: Project, + cluster: TopicCluster, + cluster_context: dict[str, Any], + gap_analysis: dict[str, Any], + supporting_memberships: list[ContentClusterMembership], + recent_accepted_themes: list[ThemeSuggestion], + recent_dismissed_themes: list[ThemeSuggestion], +) -> tuple[dict[str, Any], str]: + """Generate one grounded original-content idea payload.""" + + fallback_payload = _fallback_original_content_payload( + project=project, + cluster=cluster, + cluster_context=cluster_context, + gap_analysis=gap_analysis, + supporting_memberships=supporting_memberships, + ) + if settings.OPENROUTER_API_KEY: + try: + response = openrouter_chat_json( + model=settings.AI_SUMMARIZATION_MODEL, + system_prompt=_original_content_prompt_resource("generate"), + user_prompt=_build_original_content_step_prompt( + project=project, + cluster_context=cluster_context, + supporting_memberships=supporting_memberships, + recent_accepted_themes=recent_accepted_themes, + recent_dismissed_themes=recent_dismissed_themes, + extra_payload={ + "gap_analysis": gap_analysis, + "fallback_payload": fallback_payload, + }, + ), + ) + payload = response.payload + return ( + { + "angle_title": str( + payload.get("angle_title", fallback_payload["angle_title"]) + ).strip() + or fallback_payload["angle_title"], + "summary": str( + payload.get("summary", fallback_payload["summary"]) + ).strip() + or fallback_payload["summary"], + "suggested_outline": str( + payload.get( + "suggested_outline", + fallback_payload["suggested_outline"], + ) + ).strip() + or fallback_payload["suggested_outline"], + "why_now": str( + payload.get("why_now", fallback_payload["why_now"]) + ).strip() + or fallback_payload["why_now"], + }, + response.model, + ) + except Exception: + pass + return fallback_payload, "heuristic-original-content-ideation" + + +def _score_original_content_idea( + *, + project: Project, + cluster: TopicCluster, + idea_payload: dict[str, Any], + gap_analysis: dict[str, Any], + recent_project_content: list[Content], + recent_themes: list[ThemeSuggestion], + recent_ideas: list[OriginalContentIdea], +) -> float: + """Estimate whether a generated idea is aligned, novel, and plausible.""" + + heuristic_score = _heuristic_original_content_idea_score( + project=project, + cluster=cluster, + idea_payload=idea_payload, + gap_analysis=gap_analysis, + recent_project_content=recent_project_content, + recent_themes=recent_themes, + recent_ideas=recent_ideas, + ) + if settings.OPENROUTER_API_KEY: + try: + response = openrouter_chat_json( + model=settings.AI_RELEVANCE_MODEL, + system_prompt=_original_content_prompt_resource("critique"), + user_prompt=( + f"project_topic_description:\n{project.topic_description}\n\n" + f"cluster_label:\n{cluster.label}\n\n" + f"idea_payload:\n{idea_payload}\n\n" + f"gap_analysis:\n{gap_analysis}\n\n" + f"recent_content_titles:\n{[content.title for content in recent_project_content[:20]]}\n\n" + f"recent_themes:\n{[{'title': theme.title, 'pitch': theme.pitch} for theme in recent_themes[:10]]}\n\n" + f"recent_ideas:\n{[{'angle_title': idea.angle_title, 'summary': idea.summary} for idea in recent_ideas[:10]]}\n\n" + "Return only a JSON object with fields self_critique_score and critique_summary." + ), + ) + return max( + 0.0, + min( + 1.0, + float(response.payload.get("self_critique_score", heuristic_score)), + ), + ) + except Exception: + pass + return heuristic_score + + def _score_theme_novelty( *, project: Project, @@ -1498,6 +1943,222 @@ def _heuristic_theme_novelty_score( return max(0.0, min(1.0, 1.0 - max_overlap)) +def _fallback_original_content_gap_description( + *, + cluster: TopicCluster, + cluster_context: dict[str, Any], + authoritative_coverage: float, + centroid_similarity: float, +) -> str: + """Describe the editorial gap around one cluster without LLM support.""" + + dominant_entity = cluster_context.get("dominant_entity") or {} + entity_name = dominant_entity.get("name") or "the current cluster" + return ( + f"Coverage around {entity_name} is accelerating with a velocity score of " + f"{cluster_context.get('velocity_score', 0.0):.2f}, but only " + f"{authoritative_coverage:.0%} of the strongest supporting items come from " + f"high-authority entities. The cluster remains near the project's centroid " + f"at {centroid_similarity:.2f}, which makes it timely but not yet saturated." + ) + + +def _fallback_original_content_payload( + *, + project: Project, + cluster: TopicCluster, + cluster_context: dict[str, Any], + gap_analysis: dict[str, Any], + supporting_memberships: list[ContentClusterMembership], +) -> dict[str, Any]: + """Build a deterministic original-content idea when LLM calls are unavailable.""" + + dominant_entity = cluster_context.get("dominant_entity") or {} + entity_name = str(dominant_entity.get("name", "this trend")).strip() + leading_title = ( + supporting_memberships[0].content.title if supporting_memberships else "" + ) + cluster_label = cluster.label or "emerging cluster" + angle_title = ( + f"The angle authoritative voices are missing on {entity_name}" + if entity_name and entity_name != "this trend" + else f"The overlooked opportunity inside {cluster_label}" + ) + summary = ( + f"Write a project-owned piece that explains why {cluster_label} is accelerating, " + f"using '{leading_title}' and the surrounding signal as evidence for an angle the project has not fully covered yet." + ) + suggested_outline = "\n".join( + [ + "1. State the shift the cluster is capturing and why it matters to the project topic.", + "2. Compare what the current coverage says versus what high-authority sources have not yet explained.", + "3. Close with a concrete editorial thesis or recommendation the project can own.", + ] + ) + why_now = ( + f"This cluster is moving at {cluster_context.get('velocity_score', 0.0):.2f} velocity, " + f"and the current evidence suggests the project can publish a sharper take before the topic fully saturates." + ) + return { + "angle_title": angle_title[:255], + "summary": summary, + "suggested_outline": suggested_outline, + "why_now": why_now, + "gap_description": gap_analysis.get("gap_description", ""), + "project_topic": project.topic_description, + } + + +def _heuristic_original_content_idea_score( + *, + project: Project, + cluster: TopicCluster, + idea_payload: dict[str, Any], + gap_analysis: dict[str, Any], + recent_project_content: list[Content], + recent_themes: list[ThemeSuggestion], + recent_ideas: list[OriginalContentIdea], +) -> float: + """Score originality and fit using overlap heuristics.""" + + candidate_tokens = _normalized_theme_tokens( + f"{idea_payload.get('angle_title', '')} {idea_payload.get('summary', '')}" + ) + if not candidate_tokens: + return 0.0 + project_tokens = _normalized_theme_tokens(project.topic_description) + cluster_tokens = _normalized_theme_tokens(cluster.label) + alignment_score = ( + 1.0 if candidate_tokens & (project_tokens | cluster_tokens) else 0.6 + ) + content_overlap = _max_token_overlap( + candidate_tokens, + [ + _normalized_theme_tokens(f"{content.title} {content.content_text[:200]}") + for content in recent_project_content + ], + ) + theme_overlap = _max_token_overlap( + candidate_tokens, + [ + _normalized_theme_tokens(f"{theme.title} {theme.pitch}") + for theme in recent_themes + ], + ) + idea_overlap = _max_token_overlap( + candidate_tokens, + [ + _normalized_theme_tokens(f"{idea.angle_title} {idea.summary}") + for idea in recent_ideas + ], + ) + why_now_tokens = _normalized_theme_tokens(str(idea_payload.get("why_now", ""))) + plausibility_score = 1.0 if len(why_now_tokens) >= 8 else 0.5 + gap_score = float(gap_analysis.get("gap_score", 0.0) or 0.0) + return max( + 0.0, + min( + 1.0, + 0.25 * alignment_score + + 0.25 * (1.0 - content_overlap) + + 0.15 * (1.0 - theme_overlap) + + 0.15 * (1.0 - idea_overlap) + + 0.2 * ((plausibility_score + gap_score) / 2), + ), + ) + + +def _theme_overlap_penalty( + *, + cluster_context: dict[str, Any], + recent_themes: list[ThemeSuggestion], +) -> float: + """Estimate overlap between a cluster and prior theme history.""" + + dominant_entity = cluster_context.get("dominant_entity") or {} + candidate_tokens = _normalized_theme_tokens( + f"{cluster_context.get('cluster_id', '')} {dominant_entity.get('name', '')}" + ) + if not candidate_tokens: + return 0.0 + return _max_token_overlap( + candidate_tokens, + [ + _normalized_theme_tokens(f"{theme.title} {theme.pitch}") + for theme in recent_themes + ], + ) + + +def _max_token_overlap( + candidate_tokens: set[str], + prior_token_sets: list[set[str]], +) -> float: + """Return the largest Jaccard overlap against prior token sets.""" + + max_overlap = 0.0 + for prior_tokens in prior_token_sets: + if not prior_tokens: + continue + overlap = len(candidate_tokens & prior_tokens) / len( + candidate_tokens | prior_tokens + ) + max_overlap = max(max_overlap, overlap) + return max_overlap + + +def _similarity_window_score( + similarity: float, *, minimum: float, maximum: float +) -> float: + """Return a peak score for similarities that land inside the target window.""" + + if minimum <= similarity <= maximum: + return 1.0 + if similarity < minimum: + if minimum <= 0: + return 0.0 + return max(0.0, similarity / minimum) + if maximum >= 1.0: + return 0.0 + return max(0.0, 1.0 - ((similarity - maximum) / (1.0 - maximum))) + + +@lru_cache(maxsize=8) +def _original_content_prompt_resource(resource_name: str) -> str: + """Load one original-content ideation prompt resource from disk.""" + + resource_path = ( + Path(__file__).resolve().parent.parent + / "skills" + / ORIGINAL_CONTENT_IDEATION_SKILL_NAME + / "resources" + / f"{resource_name}.md" + ) + return resource_path.read_text(encoding="utf-8").strip() + + +def _build_original_content_step_prompt( + *, + project: Project, + cluster_context: dict[str, Any], + supporting_memberships: list[ContentClusterMembership], + recent_accepted_themes: list[ThemeSuggestion], + recent_dismissed_themes: list[ThemeSuggestion], + extra_payload: dict[str, Any], +) -> str: + """Serialize ideation context into a stable prompt body.""" + + return ( + f"project_topic_description:\n{project.topic_description}\n\n" + f"cluster_context:\n{cluster_context}\n\n" + f"supporting_contents:\n{[{'id': _require_pk(membership.content), 'title': membership.content.title, 'url': membership.content.url} for membership in supporting_memberships]}\n\n" + f"recent_themes_accepted:\n{[{'title': theme.title, 'pitch': theme.pitch, 'why_it_matters': theme.why_it_matters} for theme in recent_accepted_themes[:10]]}\n\n" + f"recent_themes_dismissed:\n{[{'title': theme.title, 'pitch': theme.pitch, 'dismissal_reason': theme.dismissal_reason} for theme in recent_dismissed_themes[:10]]}\n\n" + f"extra_payload:\n{extra_payload}\n\n" + "Return only a JSON object using the fields requested by the system prompt." + ) + + def _normalized_theme_tokens(text: str) -> set[str]: """Normalize free text into a small token set for novelty heuristics.""" diff --git a/trends/tests/test_api.py b/trends/tests/test_api.py index e6f3a05a..57e55b38 100644 --- a/trends/tests/test_api.py +++ b/trends/tests/test_api.py @@ -12,6 +12,8 @@ from projects.models import Project, ProjectConfig, ProjectMembership, ProjectRole from trends.models import ( ContentClusterMembership, + OriginalContentIdea, + OriginalContentIdeaStatus, SourceDiversitySnapshot, ThemeSuggestion, ThemeSuggestionStatus, @@ -394,6 +396,210 @@ def test_theme_suggestion_accept_and_dismiss_actions_update_workflow_fields(self self.assertEqual(dismiss_suggestion.dismissal_reason, "already covered") self.assertEqual(dismiss_suggestion.decided_by, self.owner) + def test_original_content_idea_list_is_scoped_to_project(self): + cluster = TopicCluster.objects.create( + project=self.owner_project, + first_seen_at="2026-04-22T00:00:00Z", + last_seen_at="2026-04-24T00:00:00Z", + is_active=True, + member_count=3, + dominant_entity=self.owner_entity, + label="Owner Cluster", + ) + idea = OriginalContentIdea.objects.create( + project=self.owner_project, + related_cluster=cluster, + angle_title="Owner idea", + summary="Owner summary", + suggested_outline="Owner outline", + why_now="Owner why now", + generated_by_model="heuristic", + self_critique_score=0.82, + ) + idea.supporting_contents.add(self.owner_content) + + other_cluster = TopicCluster.objects.create( + project=self.other_project, + first_seen_at="2026-04-22T00:00:00Z", + last_seen_at="2026-04-24T00:00:00Z", + is_active=True, + member_count=2, + dominant_entity=self.other_entity, + label="Other Cluster", + ) + other_idea = OriginalContentIdea.objects.create( + project=self.other_project, + related_cluster=other_cluster, + angle_title="Other idea", + summary="Other summary", + suggested_outline="Other outline", + why_now="Other why now", + generated_by_model="heuristic", + self_critique_score=0.7, + ) + other_idea.supporting_contents.add(self.other_content) + + response = self.client.get( + reverse( + "v1:project-original-content-idea-list", + kwargs={"project_id": _require_pk(self.owner_project)}, + ) + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["id"], _require_pk(idea)) + self.assertEqual( + response.json()[0]["status"], OriginalContentIdeaStatus.PENDING + ) + self.assertEqual( + response.json()[0]["related_cluster"]["id"], _require_pk(cluster) + ) + self.assertEqual(len(response.json()[0]["supporting_contents"]), 1) + self.assertEqual( + response.json()[0]["supporting_contents"][0]["id"], + _require_pk(self.owner_content), + ) + + def test_original_content_idea_workflow_actions_update_status_fields(self): + cluster = TopicCluster.objects.create( + project=self.owner_project, + first_seen_at="2026-04-22T00:00:00Z", + last_seen_at="2026-04-24T00:00:00Z", + is_active=True, + member_count=3, + dominant_entity=self.owner_entity, + label="Idea Cluster", + ) + accepted_then_written_idea = OriginalContentIdea.objects.create( + project=self.owner_project, + related_cluster=cluster, + angle_title="Accept me", + summary="Summary", + suggested_outline="Outline", + why_now="Why now", + generated_by_model="heuristic", + self_critique_score=0.8, + ) + accepted_then_written_idea.supporting_contents.add(self.owner_content) + dismissed_idea = OriginalContentIdea.objects.create( + project=self.owner_project, + related_cluster=cluster, + angle_title="Dismiss me", + summary="Summary", + suggested_outline="Outline", + why_now="Why now", + generated_by_model="heuristic", + self_critique_score=0.75, + ) + + accept_response = self.client.post( + reverse( + "v1:project-original-content-idea-accept", + kwargs={ + "project_id": _require_pk(self.owner_project), + "pk": _require_pk(accepted_then_written_idea), + }, + ), + format="json", + ) + dismiss_response = self.client.post( + reverse( + "v1:project-original-content-idea-dismiss", + kwargs={ + "project_id": _require_pk(self.owner_project), + "pk": _require_pk(dismissed_idea), + }, + ), + {"reason": "already assigned"}, + format="json", + ) + mark_written_response = self.client.post( + reverse( + "v1:project-original-content-idea-mark-written", + kwargs={ + "project_id": _require_pk(self.owner_project), + "pk": _require_pk(accepted_then_written_idea), + }, + ), + format="json", + ) + + accepted_then_written_idea.refresh_from_db() + dismissed_idea.refresh_from_db() + + self.assertEqual(accept_response.status_code, status.HTTP_200_OK) + self.assertEqual(accept_response.json()["status"], "accepted") + self.assertEqual( + mark_written_response.status_code, + status.HTTP_200_OK, + ) + self.assertEqual(mark_written_response.json()["status"], "written") + self.assertEqual( + accepted_then_written_idea.status, + OriginalContentIdeaStatus.WRITTEN, + ) + self.assertEqual(accepted_then_written_idea.decided_by, self.owner) + self.assertIsNotNone(accepted_then_written_idea.decided_at) + + self.assertEqual(dismiss_response.status_code, status.HTTP_200_OK) + self.assertEqual( + dismissed_idea.status, + OriginalContentIdeaStatus.DISMISSED, + ) + self.assertEqual(dismissed_idea.dismissal_reason, "already assigned") + self.assertEqual(dismissed_idea.decided_by, self.owner) + + def test_original_content_idea_generate_action_runs_immediately_in_eager_mode( + self, + ): + with self.settings(CELERY_TASK_ALWAYS_EAGER=True): + with self.subTest("generate now returns completed result"): + from unittest.mock import patch + + with patch( + "trends.api.generate_original_content_ideas", + return_value={ + "project_id": _require_pk(self.owner_project), + "clusters_considered": 4, + "created": 2, + "skipped": 1, + }, + ) as generate_mock: + response = self.client.post( + reverse( + "v1:project-original-content-idea-generate", + kwargs={"project_id": _require_pk(self.owner_project)}, + ), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["status"], "completed") + self.assertEqual(response.json()["project_id"], _require_pk(self.owner_project)) + self.assertEqual(response.json()["result"]["created"], 2) + generate_mock.assert_called_once_with(_require_pk(self.owner_project)) + + def test_original_content_idea_generate_action_queues_in_background_mode(self): + with self.settings(CELERY_TASK_ALWAYS_EAGER=False): + from unittest.mock import patch + + with patch( + "trends.api.generate_original_content_ideas.delay" + ) as delay_mock: + response = self.client.post( + reverse( + "v1:project-original-content-idea-generate", + kwargs={"project_id": _require_pk(self.owner_project)}, + ), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.json()["status"], "queued") + self.assertEqual(response.json()["project_id"], _require_pk(self.owner_project)) + delay_mock.assert_called_once_with(_require_pk(self.owner_project)) + def test_source_diversity_snapshot_list_and_summary_are_scoped_to_project(self): owner_snapshot = SourceDiversitySnapshot.objects.create( project=self.owner_project, diff --git a/trends/tests/test_tasks.py b/trends/tests/test_tasks.py index ee9f71ce..6f338ad2 100644 --- a/trends/tests/test_tasks.py +++ b/trends/tests/test_tasks.py @@ -10,6 +10,8 @@ from projects.models import Project, SourceConfig from trends.models import ( ContentClusterMembership, + OriginalContentIdea, + OriginalContentIdeaStatus, SourceDiversitySnapshot, ThemeSuggestion, ThemeSuggestionStatus, @@ -18,10 +20,13 @@ TopicVelocitySnapshot, ) from trends.tasks import ( + ORIGINAL_CONTENT_IDEA_WEEKLY_CAP, TOPIC_CENTROID_MIN_UPVOTES, accept_theme_suggestion, assign_content_to_topic_cluster, + generate_original_content_ideas, generate_theme_suggestions, + mark_original_content_idea_written, queue_topic_centroid_recompute, recompute_source_diversity, recompute_topic_centroid, @@ -389,6 +394,107 @@ def test_recompute_source_diversity_persists_entropy_breakdown_and_alerts( ] +def test_generate_original_content_ideas_creates_grounded_pending_idea( + source_plugin_context, +): + project = source_plugin_context.project + source_plugin_context.entity.authority_score = 0.3 + source_plugin_context.entity.save(update_fields=["authority_score"]) + cluster = TopicCluster.objects.create( + project=project, + first_seen_at="2026-04-20T00:00:00Z", + last_seen_at="2026-04-24T00:00:00Z", + is_active=True, + member_count=3, + dominant_entity=source_plugin_context.entity, + label="Authoritative Gap", + ) + TopicVelocitySnapshot.objects.create( + cluster=cluster, + project=project, + window_count=5, + trailing_mean=1.0, + trailing_stddev=0.5, + z_score=2.1, + velocity_score=0.88, + ) + contents = [] + for index in range(3): + content = Content.objects.create( + project=project, + entity=source_plugin_context.entity, + url=f"https://example.com/idea-{index}", + title=f"Idea source {index}", + author="Author", + source_plugin=SourcePluginName.RSS, + published_date="2026-04-24T12:00:00Z", + content_text="Clusterable trend content with room for analysis.", + ) + contents.append(content) + ContentClusterMembership.objects.create( + content=content, + cluster=cluster, + project=project, + similarity=0.95 - (index * 0.01), + ) + + result = generate_original_content_ideas(_require_pk(project)) + idea = OriginalContentIdea.objects.get(project=project) + + assert result["created"] == 1 + assert idea.status == OriginalContentIdeaStatus.PENDING + assert idea.related_cluster == cluster + assert idea.generated_by_model == "heuristic-original-content-ideation" + assert idea.self_critique_score >= 0.6 + assert list( + idea.supporting_contents.order_by("id").values_list("id", flat=True) + ) == [_require_pk(content) for content in contents] + assert "Authoritative Gap" in idea.summary + assert "velocity" in idea.why_now.lower() + + +def test_generate_original_content_ideas_enforces_weekly_cap_and_written_workflow( + source_plugin_context, + django_user_model, +): + project = source_plugin_context.project + editor = django_user_model.objects.create_user( + username="idea-editor", + password="testpass123", + ) + for index in range(ORIGINAL_CONTENT_IDEA_WEEKLY_CAP): + OriginalContentIdea.objects.create( + project=project, + angle_title=f"Existing idea {index}", + summary="Summary", + suggested_outline="Outline", + why_now="Why now", + generated_by_model="heuristic", + self_critique_score=0.7, + ) + + capped_result = generate_original_content_ideas(_require_pk(project)) + + accepted_idea = OriginalContentIdea.objects.create( + project=project, + angle_title="Accepted idea", + summary="Summary", + suggested_outline="Outline", + why_now="Why now", + generated_by_model="heuristic", + self_critique_score=0.8, + status=OriginalContentIdeaStatus.ACCEPTED, + ) + mark_original_content_idea_written(accepted_idea, user_id=_require_pk(editor)) + accepted_idea.refresh_from_db() + + assert capped_result["created"] == 0 + assert capped_result["clusters_considered"] == 0 + assert accepted_idea.status == OriginalContentIdeaStatus.WRITTEN + assert accepted_idea.decided_by == editor + assert accepted_idea.decided_at is not None + + def test_queue_topic_centroid_recompute_enqueues_background_task( source_plugin_context, mocker ):