Skip to content

Commit c9194ae

Browse files
committed
Content idea generation
1 parent 9fb666c commit c9194ae

13 files changed

Lines changed: 1524 additions & 2 deletions

File tree

SESSION.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,73 @@
11
# Session Restore Point
2+
3+
## Current focus
4+
5+
Phase 3 WP5: original content idea generation in `trends/`.
6+
7+
## What landed today
8+
9+
- Added `OriginalContentIdeaStatus` and `OriginalContentIdea` to `trends/models.py`.
10+
- Added migration `trends/migrations/0005_original_content_idea.py`.
11+
- Added new prompt skill folder `skills/original_content_ideation/` with:
12+
- `SKILL.md`
13+
- `resources/gap_detect.md`
14+
- `resources/generate.md`
15+
- `resources/critique.md`
16+
- Added partial WP5 task implementation in `trends/tasks.py`:
17+
- `run_all_original_content_idea_generations`
18+
- `generate_original_content_ideas`
19+
- workflow helpers for accept / dismiss / mark written
20+
- heuristic gap detection + fallback generation + heuristic critique
21+
- optional OpenRouter-backed prompt-resource calls for gap detect / generate / critique
22+
- Added partial task coverage in `trends/tests/test_tasks.py` for:
23+
- pending-idea creation
24+
- weekly cap
25+
- mark-written workflow
26+
27+
## What was validated
28+
29+
- `python manage.py check`
30+
- `python manage.py makemigrations --check --dry-run trends`
31+
32+
Both passed after naming the new model index `core_idea_project_7f21_idx`.
33+
34+
## Known incomplete state
35+
36+
- WP5 is not finished.
37+
- No serializer/API route/test work has been added yet for `OriginalContentIdea`.
38+
- `docs/IMPLEMENTATION_PHASE_3.md` still needs a current implementation note for WP5 once the backend slice is complete.
39+
- The focused task validation was interrupted:
40+
- first `pytest trends/tests/test_tasks.py -q` failed on a duplicate `_build_theme_cluster_context` definition introduced during editing
41+
- that duplicate definition was removed
42+
- the rerun was cancelled because we paused for shutdown
43+
- After that, additional external edits were reported in:
44+
- `docs/IMPLEMENTATION_PHASE_3.md`
45+
- `trends/tasks.py`
46+
- `trends/tests/test_tasks.py`
47+
Re-read those files before making new edits tomorrow.
48+
49+
## First steps tomorrow
50+
51+
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.
52+
2. Re-read:
53+
- `trends/tasks.py`
54+
- `trends/tests/test_tasks.py`
55+
- `docs/IMPLEMENTATION_PHASE_3.md`
56+
3. Run the focused validation that was interrupted:
57+
- `pytest trends/tests/test_tasks.py -q`
58+
4. If task tests pass, finish the remaining WP5 slice:
59+
- `trends/serializers.py`
60+
- `trends/api.py`
61+
- `trends/api_urls.py`
62+
- `trends/tests/test_api.py`
63+
- optional admin only if needed
64+
- add WP5 implementation note to `docs/IMPLEMENTATION_PHASE_3.md`
65+
5. After API work, run focused validation again:
66+
- `pytest trends/tests/test_tasks.py trends/tests/test_api.py -q`
67+
- `python3 -m mypy trends/tasks.py`
68+
69+
## Likely follow-up checks
70+
71+
- Watch for unused imports in `trends/tasks.py` (`build_skill_user_prompt` / `get_skill_definition` may no longer be needed for WP5 helpers).
72+
- Confirm the new heuristic tests still pass after any formatter changes.
73+
- Keep WP5 scoped to backend + docs for now; frontend `/ideas` work belongs to WP6.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
name: original-content-ideation
3+
description: Generate grounded original article ideas from project trend gaps.
4+
input: project_topic_description, cluster_context, supporting_contents, recent_themes_accepted, recent_themes_dismissed
5+
output: angle_title, summary, suggested_outline, why_now, self_critique_score
6+
---
7+
8+
This skill generates editor-facing original content ideas from project-scoped trend gaps.
9+
10+
The runtime flow is split into three prompt resources under `resources/`:
11+
- `gap_detect.md`
12+
- `generate.md`
13+
- `critique.md`
14+
15+
Each step must stay grounded in real project context, accepted and dismissed theme history, and the supplied supporting content.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
You critique a generated original content idea before it reaches an editor.
2+
3+
Score the idea for topic alignment, redundancy with recent project content, redundancy with theme history, and plausibility of the `why_now` rationale.
4+
5+
Return structured JSON with these fields:
6+
- `self_critique_score`: a number between 0 and 1, where 1 is a strong editor-ready idea
7+
- `critique_summary`: one short sentence summarizing the critique
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
You identify promising editorial gaps around one high-velocity project topic cluster.
2+
3+
Use the supplied project topic, cluster context, supporting contents, and recent theme history.
4+
5+
Prioritize clusters that are accelerating, still adjacent to the project's topic centroid, and not yet dominated by high-authority voices.
6+
7+
Return structured JSON with these fields:
8+
- `gap_description`: one concise paragraph describing the undercovered opportunity
9+
- `gap_score`: a number between 0 and 1, where 1 is a very strong original-content opportunity
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
You generate one editor-facing original content idea from a validated project gap.
2+
3+
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.
4+
5+
Return structured JSON with these fields:
6+
- `angle_title`: a short editorial angle title
7+
- `summary`: a concise explanation of the proposed article
8+
- `suggested_outline`: a short outline the editor could follow
9+
- `why_now`: one short paragraph explaining why the angle is timely

trends/api.py

Lines changed: 198 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
from typing import Any
44

5+
from django.conf import settings
56
from django.db.models import Avg, Count, OuterRef, Prefetch, Q, Subquery
6-
from drf_spectacular.utils import OpenApiParameter, extend_schema
7-
from rest_framework import serializers, viewsets
7+
from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer
8+
from rest_framework import serializers, status, viewsets
89
from rest_framework.decorators import action
910
from rest_framework.filters import OrderingFilter
1011
from rest_framework.response import Response
@@ -19,6 +20,7 @@
1920
from core.permissions import IsProjectContributor, IsProjectMember
2021
from trends.models import (
2122
ContentClusterMembership,
23+
OriginalContentIdea,
2224
SourceDiversitySnapshot,
2325
ThemeSuggestion,
2426
ThemeSuggestionStatus,
@@ -27,6 +29,8 @@
2729
TopicVelocitySnapshot,
2830
)
2931
from trends.serializers import (
32+
OriginalContentIdeaDismissSerializer,
33+
OriginalContentIdeaSerializer,
3034
SourceDiversityObservabilitySummarySerializer,
3135
SourceDiversitySnapshotSerializer,
3236
ThemeSuggestionDismissSerializer,
@@ -38,6 +42,12 @@
3842
TopicVelocitySnapshotSerializer,
3943
)
4044
from trends.tasks import accept_theme_suggestion, dismiss_theme_suggestion
45+
from trends.tasks import (
46+
accept_original_content_idea,
47+
dismiss_original_content_idea,
48+
generate_original_content_ideas,
49+
mark_original_content_idea_written,
50+
)
4151

4252

4353
def _require_pk(instance: Any) -> int:
@@ -278,6 +288,192 @@ def dismiss(self, request, *args, **kwargs):
278288
return Response(response_serializer.data)
279289

280290

291+
@document_project_owned_viewset(
292+
resource_plural="original content ideas",
293+
resource_singular="original content idea",
294+
create_description="Original content ideas are pipeline-managed rows and are exposed read-only aside from editorial workflow actions.",
295+
tag="Trend Analysis",
296+
action_overrides=build_crud_action_overrides(
297+
OriginalContentIdeaSerializer,
298+
resource_plural="original content ideas for the selected project",
299+
resource_singular="original content idea",
300+
),
301+
)
302+
class OriginalContentIdeaViewSet(
303+
ProjectOwnedQuerysetMixin, viewsets.ReadOnlyModelViewSet
304+
):
305+
"""Inspect and resolve project-scoped original-content ideas."""
306+
307+
serializer_class = OriginalContentIdeaSerializer
308+
filter_backends = [OrderingFilter]
309+
ordering_fields = ["created_at", "self_critique_score", "status"]
310+
ordering = ["status", "-self_critique_score", "-created_at"]
311+
queryset = OriginalContentIdea.objects.select_related(
312+
"project", "related_cluster", "decided_by"
313+
)
314+
315+
def get_queryset(self):
316+
"""Prefetch supporting contents for original-content idea responses."""
317+
318+
return (
319+
super()
320+
.get_queryset()
321+
.select_related("related_cluster__dominant_entity")
322+
.prefetch_related(
323+
Prefetch(
324+
"supporting_contents",
325+
queryset=Content.objects.order_by("-published_date", "-id"),
326+
)
327+
)
328+
)
329+
330+
def get_permissions(self):
331+
"""Allow members to read ideas and contributors to resolve them."""
332+
333+
if self.action in {"accept", "dismiss", "mark_written", "generate"}:
334+
return [IsProjectContributor()]
335+
return [IsProjectMember()]
336+
337+
@extend_schema(
338+
summary="Generate original content ideas",
339+
description=(
340+
"Trigger original-content idea generation for the selected project. "
341+
"When Celery runs eagerly, ideas are generated before the response is returned. "
342+
"Otherwise, the generation task is queued for background execution."
343+
),
344+
request=None,
345+
responses={
346+
200: inline_serializer(
347+
name="OriginalContentIdeaGenerateCompletedResponse",
348+
fields={
349+
"status": serializers.CharField(),
350+
"project_id": serializers.IntegerField(),
351+
"result": inline_serializer(
352+
name="OriginalContentIdeaGenerateResult",
353+
fields={
354+
"project_id": serializers.IntegerField(),
355+
"clusters_considered": serializers.IntegerField(),
356+
"created": serializers.IntegerField(),
357+
"skipped": serializers.IntegerField(),
358+
},
359+
),
360+
},
361+
),
362+
202: inline_serializer(
363+
name="OriginalContentIdeaGenerateQueuedResponse",
364+
fields={
365+
"status": serializers.CharField(),
366+
"project_id": serializers.IntegerField(),
367+
},
368+
),
369+
403: AUTHENTICATION_REQUIRED_RESPONSE,
370+
},
371+
tags=["Trend Analysis"],
372+
)
373+
@action(detail=False, methods=["post"], url_path="generate")
374+
def generate(self, request, *args, **kwargs):
375+
"""Trigger original-content idea generation for the current project."""
376+
377+
project = self.get_project()
378+
project_id = _require_pk(project)
379+
if settings.CELERY_TASK_ALWAYS_EAGER:
380+
result = generate_original_content_ideas(project_id)
381+
return Response(
382+
{
383+
"status": "completed",
384+
"project_id": project_id,
385+
"result": result,
386+
}
387+
)
388+
generate_original_content_ideas.delay(project_id)
389+
return Response(
390+
{"status": "queued", "project_id": project_id},
391+
status=status.HTTP_202_ACCEPTED,
392+
)
393+
394+
@extend_schema(
395+
summary="Accept original content idea",
396+
description="Mark a pending original content idea as accepted by the current editor.",
397+
request=None,
398+
responses={
399+
200: OriginalContentIdeaSerializer,
400+
403: AUTHENTICATION_REQUIRED_RESPONSE,
401+
},
402+
tags=["Trend Analysis"],
403+
)
404+
@action(detail=True, methods=["post"], url_path="accept")
405+
def accept(self, request, *args, **kwargs):
406+
"""Accept the selected pending original-content idea."""
407+
408+
idea = self.get_object()
409+
try:
410+
accept_original_content_idea(idea, user_id=request.user.id)
411+
except ValueError as exc:
412+
raise serializers.ValidationError(
413+
{"status": "Unable to accept this original content idea."}
414+
) from exc
415+
response_serializer = self.get_serializer(idea)
416+
return Response(response_serializer.data)
417+
418+
@extend_schema(
419+
summary="Dismiss original content idea",
420+
description="Dismiss a pending original content idea and persist the editor's reason.",
421+
request=OriginalContentIdeaDismissSerializer,
422+
responses={
423+
200: OriginalContentIdeaSerializer,
424+
400: OriginalContentIdeaDismissSerializer,
425+
403: AUTHENTICATION_REQUIRED_RESPONSE,
426+
},
427+
tags=["Trend Analysis"],
428+
)
429+
@action(detail=True, methods=["post"], url_path="dismiss")
430+
def dismiss(self, request, *args, **kwargs):
431+
"""Dismiss the selected pending original-content idea."""
432+
433+
idea = self.get_object()
434+
serializer = OriginalContentIdeaDismissSerializer(
435+
data=request.data,
436+
context=self.get_serializer_context(),
437+
)
438+
serializer.is_valid(raise_exception=True)
439+
try:
440+
dismiss_original_content_idea(
441+
idea,
442+
user_id=request.user.id,
443+
reason=serializer.validated_data["reason"],
444+
)
445+
except ValueError as exc:
446+
raise serializers.ValidationError(
447+
{"status": "Unable to dismiss this original content idea."}
448+
) from exc
449+
response_serializer = self.get_serializer(idea)
450+
return Response(response_serializer.data)
451+
452+
@extend_schema(
453+
summary="Mark original content idea written",
454+
description="Mark an accepted original content idea as written by the current editor.",
455+
request=None,
456+
responses={
457+
200: OriginalContentIdeaSerializer,
458+
403: AUTHENTICATION_REQUIRED_RESPONSE,
459+
},
460+
tags=["Trend Analysis"],
461+
)
462+
@action(detail=True, methods=["post"], url_path="mark_written")
463+
def mark_written(self, request, *args, **kwargs):
464+
"""Mark the selected accepted original-content idea as written."""
465+
466+
idea = self.get_object()
467+
try:
468+
mark_original_content_idea_written(idea, user_id=request.user.id)
469+
except ValueError as exc:
470+
raise serializers.ValidationError(
471+
{"status": "Unable to mark this original content idea as written."}
472+
) from exc
473+
response_serializer = self.get_serializer(idea)
474+
return Response(response_serializer.data)
475+
476+
281477
@document_project_owned_viewset(
282478
resource_plural="topic centroid snapshots",
283479
resource_singular="topic centroid snapshot",

trends/api_urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from rest_framework_nested.routers import NestedSimpleRouter
44

55
from trends.api import (
6+
OriginalContentIdeaViewSet,
67
SourceDiversitySnapshotViewSet,
78
ThemeSuggestionViewSet,
89
TopicCentroidSnapshotViewSet,
@@ -23,6 +24,11 @@ def register_project_routes(project_router: NestedSimpleRouter) -> None:
2324
ThemeSuggestionViewSet,
2425
basename="project-theme-suggestion",
2526
)
27+
project_router.register(
28+
r"ideas",
29+
OriginalContentIdeaViewSet,
30+
basename="project-original-content-idea",
31+
)
2632
project_router.register(
2733
r"topic-centroid-snapshots",
2834
TopicCentroidSnapshotViewSet,

0 commit comments

Comments
 (0)