Skip to content

Commit c66d7fe

Browse files
committed
Finish follow-up tasks
1 parent 5494dc7 commit c66d7fe

33 files changed

Lines changed: 932 additions & 17 deletions

‎.github/workflows/build-release.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ jobs:
3939
deploy/helm/newsletter-maker/values-minikube.yaml >
4040
/tmp/newsletter-maker-chart.yaml
4141

42+
- name: Render staging overlay
43+
run: helm template newsletter-maker-staging deploy/helm/newsletter-maker -f
44+
deploy/helm/newsletter-maker/values-staging.yaml >
45+
/tmp/newsletter-maker-staging-chart.yaml
46+
4247
build-frontend:
4348
name: Build frontend
4449
runs-on: ubuntu-latest
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
dependencies = [
6+
("content", "0003_content_pipeline_state"),
7+
]
8+
9+
operations = [
10+
migrations.AddField(
11+
model_name="content",
12+
name="summary_text",
13+
field=models.TextField(blank=True, default=""),
14+
),
15+
]

‎content/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class Content(models.Model):
4444
published_date = models.DateTimeField()
4545
ingested_at = models.DateTimeField(auto_now_add=True)
4646
content_text = models.TextField()
47+
summary_text = models.TextField(blank=True, default="")
4748
relevance_score = models.FloatField(null=True, blank=True)
4849
authority_adjusted_score = models.FloatField(null=True, blank=True)
4950
embedding_id = models.CharField(max_length=64, blank=True)

‎content/serializers.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Meta:
2424
"published_date",
2525
"ingested_at",
2626
"content_text",
27+
"summary_text",
2728
"relevance_score",
2829
"authority_adjusted_score",
2930
"embedding_id",
@@ -41,6 +42,7 @@ class Meta:
4142
"project",
4243
"canonical_url",
4344
"ingested_at",
45+
"summary_text",
4446
"authority_adjusted_score",
4547
"embedding_id",
4648
"duplicate_of",

‎content/tests/test_api.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,25 @@ def test_content_create_uses_project_from_url(self):
166166
created_content = Content.objects.get(title="New Content")
167167
self.assertEqual(created_content.project, self.owner_project)
168168

169+
def test_content_detail_includes_summary_text(self):
170+
self.owner_content.summary_text = "A concise summary ready for editors."
171+
self.owner_content.save(update_fields=["summary_text"])
172+
173+
response = self.client.get(
174+
reverse(
175+
"v1:project-content-detail",
176+
kwargs={
177+
"project_id": _require_pk(self.owner_project),
178+
"pk": _require_pk(self.owner_content),
179+
},
180+
)
181+
)
182+
183+
self.assertEqual(response.status_code, status.HTTP_200_OK)
184+
self.assertEqual(
185+
response.json()["summary_text"], "A concise summary ready for editors."
186+
)
187+
169188
@patch("core.tasks.run_relevance_scoring_skill.delay")
170189
def test_content_skill_action_queues_relevance_scoring(
171190
self, run_relevance_scoring_delay_mock

‎core/api.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@
250250
"published_date": "2026-04-25T14:00:00Z",
251251
"ingested_at": "2026-04-26T12:05:00Z",
252252
"content_text": "A walkthrough of short-term and long-term memory patterns for production agents.",
253+
"summary_text": "The piece explains practical patterns for combining short-term context with durable memory in production agents.",
253254
"relevance_score": 0.92,
254255
"authority_adjusted_score": 0.95,
255256
"embedding_id": "emb_01jabcxyz",
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Bootstrap real RSS and Reddit sources for local development."""
2+
3+
from __future__ import annotations
4+
5+
from django.conf import settings
6+
from django.core.management.base import BaseCommand, CommandError
7+
8+
from ingestion.tasks import run_ingestion
9+
from projects.model_support import SourcePluginName
10+
from projects.models import Project, SourceConfig
11+
12+
13+
class Command(BaseCommand):
14+
help = (
15+
"Create or reactivate RSS and Reddit source configs for one project, "
16+
"optionally queueing ingestion immediately."
17+
)
18+
19+
def add_arguments(self, parser):
20+
parser.add_argument(
21+
"--project-id",
22+
type=int,
23+
help="Project ID that should own the bootstrapped source configs.",
24+
)
25+
parser.add_argument(
26+
"--project-name",
27+
help="Project name that should own the bootstrapped source configs.",
28+
)
29+
parser.add_argument(
30+
"--rss-feed",
31+
action="append",
32+
default=[],
33+
help=(
34+
"RSS feed URL to add. Repeat the flag or pass a comma-separated list."
35+
),
36+
)
37+
parser.add_argument(
38+
"--subreddit",
39+
action="append",
40+
default=[],
41+
help=("Subreddit to add. Repeat the flag or pass a comma-separated list."),
42+
)
43+
parser.add_argument(
44+
"--reddit-listing",
45+
default="both",
46+
choices=("new", "hot", "both"),
47+
help="Listing mode to use for bootstrapped Reddit sources.",
48+
)
49+
parser.add_argument(
50+
"--reddit-limit",
51+
type=int,
52+
default=25,
53+
help="Per-listing fetch limit for bootstrapped Reddit sources.",
54+
)
55+
parser.add_argument(
56+
"--run-now",
57+
action="store_true",
58+
help="Queue ingestion immediately for every source config touched.",
59+
)
60+
61+
def handle(self, *args, **options):
62+
project = self._get_project(options)
63+
rss_feeds = self._split_values(options["rss_feed"])
64+
subreddits = self._split_values(options["subreddit"])
65+
reddit_limit = int(options["reddit_limit"])
66+
67+
if reddit_limit <= 0:
68+
raise CommandError("--reddit-limit must be a positive integer.")
69+
if not rss_feeds and not subreddits:
70+
raise CommandError(
71+
"Provide at least one --rss-feed or --subreddit value to bootstrap."
72+
)
73+
74+
created_count = 0
75+
reactivated_count = 0
76+
updated_count = 0
77+
touched_source_ids: list[int] = []
78+
79+
for feed_url in rss_feeds:
80+
source_config, outcome = self._upsert_rss_source(project, feed_url)
81+
touched_source_ids.append(int(source_config.pk))
82+
if outcome == "created":
83+
created_count += 1
84+
elif outcome == "reactivated":
85+
reactivated_count += 1
86+
elif outcome == "updated":
87+
updated_count += 1
88+
89+
for subreddit in subreddits:
90+
source_config, outcome = self._upsert_reddit_source(
91+
project,
92+
subreddit,
93+
listing=options["reddit_listing"],
94+
limit=reddit_limit,
95+
)
96+
touched_source_ids.append(int(source_config.pk))
97+
if outcome == "created":
98+
created_count += 1
99+
elif outcome == "reactivated":
100+
reactivated_count += 1
101+
elif outcome == "updated":
102+
updated_count += 1
103+
104+
queued_count = 0
105+
if options["run_now"]:
106+
for source_config_id in touched_source_ids:
107+
if settings.CELERY_TASK_ALWAYS_EAGER:
108+
run_ingestion(source_config_id)
109+
else:
110+
run_ingestion.delay(source_config_id)
111+
queued_count += 1
112+
113+
self.stdout.write(
114+
self.style.SUCCESS(
115+
f"Bootstrapped {len(touched_source_ids)} source config(s) for project {project.name}."
116+
)
117+
)
118+
self.stdout.write(f"Created: {created_count}")
119+
self.stdout.write(f"Reactivated: {reactivated_count}")
120+
self.stdout.write(f"Updated: {updated_count}")
121+
if options["run_now"]:
122+
self.stdout.write(f"Queued ingestions: {queued_count}")
123+
124+
def _get_project(self, options) -> Project:
125+
"""Resolve the project from either ID or exact name."""
126+
127+
project_id = options.get("project_id")
128+
project_name = (options.get("project_name") or "").strip()
129+
if bool(project_id) == bool(project_name):
130+
raise CommandError("Pass exactly one of --project-id or --project-name.")
131+
132+
if project_id:
133+
try:
134+
return Project.objects.get(pk=project_id)
135+
except Project.DoesNotExist as exc:
136+
raise CommandError(
137+
f"Project with id {project_id} does not exist."
138+
) from exc
139+
140+
try:
141+
return Project.objects.get(name=project_name)
142+
except Project.DoesNotExist as exc:
143+
raise CommandError(
144+
f"Project named '{project_name}' does not exist."
145+
) from exc
146+
147+
@staticmethod
148+
def _split_values(raw_values: list[str]) -> list[str]:
149+
"""Split repeated or comma-delimited CLI values into a clean list."""
150+
151+
normalized_values: list[str] = []
152+
seen_values: set[str] = set()
153+
for raw_value in raw_values:
154+
for item in raw_value.split(","):
155+
normalized_item = item.strip()
156+
if not normalized_item or normalized_item in seen_values:
157+
continue
158+
seen_values.add(normalized_item)
159+
normalized_values.append(normalized_item)
160+
return normalized_values
161+
162+
def _upsert_rss_source(
163+
self,
164+
project: Project,
165+
feed_url: str,
166+
) -> tuple[SourceConfig, str]:
167+
"""Create or reactivate one RSS source config."""
168+
169+
source_config = SourceConfig.objects.filter(
170+
project=project,
171+
plugin_name=SourcePluginName.RSS,
172+
config__feed_url=feed_url,
173+
).first()
174+
if source_config is None:
175+
source_config = SourceConfig.objects.create(
176+
project=project,
177+
plugin_name=SourcePluginName.RSS,
178+
config={"feed_url": feed_url},
179+
is_active=True,
180+
)
181+
return source_config, "created"
182+
if not source_config.is_active:
183+
source_config.is_active = True
184+
source_config.save(update_fields=["is_active"])
185+
return source_config, "reactivated"
186+
return source_config, "unchanged"
187+
188+
def _upsert_reddit_source(
189+
self,
190+
project: Project,
191+
subreddit: str,
192+
*,
193+
listing: str,
194+
limit: int,
195+
) -> tuple[SourceConfig, str]:
196+
"""Create, reactivate, or refresh one Reddit source config."""
197+
198+
desired_config = {
199+
"subreddit": subreddit,
200+
"listing": listing,
201+
"limit": limit,
202+
}
203+
source_config = SourceConfig.objects.filter(
204+
project=project,
205+
plugin_name=SourcePluginName.REDDIT,
206+
config__subreddit=subreddit,
207+
).first()
208+
if source_config is None:
209+
source_config = SourceConfig.objects.create(
210+
project=project,
211+
plugin_name=SourcePluginName.REDDIT,
212+
config=desired_config,
213+
is_active=True,
214+
)
215+
return source_config, "created"
216+
217+
update_fields: list[str] = []
218+
outcome = "unchanged"
219+
if source_config.config != desired_config:
220+
source_config.config = desired_config
221+
update_fields.append("config")
222+
outcome = "updated"
223+
if not source_config.is_active:
224+
source_config.is_active = True
225+
update_fields.append("is_active")
226+
outcome = "reactivated" if outcome == "unchanged" else outcome
227+
if update_fields:
228+
source_config.save(update_fields=update_fields)
229+
return source_config, outcome

‎core/management/commands/seed_demo.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -690,14 +690,15 @@ def _seed_pipeline_state(
690690
)
691691
)
692692
if relevance_score >= settings.AI_RELEVANCE_SUMMARIZE_THRESHOLD:
693+
content.summary_text = self._summary_for_article(article)
693694
skill_results.append(
694695
SkillResult(
695696
content=content,
696697
project=project,
697698
skill_name=SUMMARIZATION_SKILL_NAME,
698699
status=SkillStatus.COMPLETED,
699700
result_data={
700-
"summary": self._summary_for_article(article),
701+
"summary": content.summary_text,
701702
},
702703
model_used=settings.AI_SUMMARIZATION_MODEL,
703704
latency_ms=640 + (index % 6) * 40,
@@ -730,7 +731,7 @@ def _seed_pipeline_state(
730731

731732
Content.objects.bulk_update(
732733
content_updates,
733-
["content_type", "relevance_score", "is_active"],
734+
["content_type", "relevance_score", "is_active", "summary_text"],
734735
)
735736
SkillResult.objects.bulk_create(skill_results)
736737
ReviewQueue.objects.bulk_create(review_items)

‎core/pipeline.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@ def _project_pk(content: Content) -> int:
117117
return _require_pk(content.project)
118118

119119

120+
def _persist_content_summary(
121+
content: Content,
122+
summary: dict[str, Any],
123+
*,
124+
extra_update_fields: list[str] | None = None,
125+
) -> None:
126+
"""Persist the latest summary text onto the content row."""
127+
128+
update_fields = ["summary_text"]
129+
content.summary_text = str(summary.get("summary", "")).strip()
130+
if extra_update_fields:
131+
update_fields.extend(extra_update_fields)
132+
content.save(update_fields=update_fields)
133+
134+
120135
def _content_id_from_state(state: PipelineState) -> int:
121136
"""Extract a required content id from pipeline state."""
122137

@@ -462,7 +477,11 @@ def summarize_node(state: PipelineState) -> PipelineState:
462477
"status": "awaiting_review",
463478
}
464479
content.pipeline_state = ContentPipelineState.COMPLETED
465-
content.save(update_fields=["pipeline_state"])
480+
_persist_content_summary(
481+
content,
482+
summary,
483+
extra_update_fields=["pipeline_state"],
484+
)
466485
return {
467486
"summary": summary,
468487
"retry_budget_remaining": retry_budget.remaining_retries,
@@ -1155,6 +1174,7 @@ def execute_background_skill_result(
11551174
)
11561175
if skill_name == SUMMARIZATION_SKILL_NAME:
11571176
summary = _run_ad_hoc_summarization(skill_result.content)
1177+
_persist_content_summary(skill_result.content, summary)
11581178
return _update_skill_result(
11591179
skill_result,
11601180
status=SkillStatus.COMPLETED,
@@ -1230,6 +1250,7 @@ def _execute_ad_hoc_summarization(content: Content) -> SkillResult:
12301250

12311251
try:
12321252
summary = _run_ad_hoc_summarization(content)
1253+
_persist_content_summary(content, summary)
12331254
return _create_skill_result(
12341255
content,
12351256
skill_name=SUMMARIZATION_SKILL_NAME,

0 commit comments

Comments
 (0)