From c2e20fef48b7c09c02d53988652ab96f5f40b170 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Sun, 26 Apr 2026 19:19:20 +0300 Subject: [PATCH 1/3] Update favicon and logo --- celerybeat-schedule | Bin 12288 -> 12288 bytes core/static/core/favicon.ico | Bin 15086 -> 4286 bytes core/static/core/logo.png | Bin 0 -> 20331 bytes core/static/core/logo.svg | 1 - newsletter_maker/settings/base.py | 2 +- 5 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 core/static/core/logo.png delete mode 100644 core/static/core/logo.svg diff --git a/celerybeat-schedule b/celerybeat-schedule index 03a9299086e1d9e7632ced6f4971f0c8db0ff04e..4cdb08a04be8d00be5430823bbe94aa37f431087 100644 GIT binary patch delta 121 zcmZojXh_&#CCAiMJ=sQ1mcvZXc#57-Q`O`|IbqqRN@f9rlBx!lsq738(8C-W;ygvK zDZi<1@{9%qW}JCvCnw1VGO}*oBCjH#!C1}CEWlJ!#aPM1EX9jZ3 delta 121 zcmZojXh_&#CCAhlH`zu`mcvxfc#57-W9;NaIbqqx7-j*3lBx!lsq738(8C-W;ygvK zDZeRs@=I(aDlA7@CMU@UGO}#mBCjH#!EleAS%9geis23qvlLTmUP)1AYB6K7 V0J9QPX-RT?YF=VePHGCnT>uM#B5nWx diff --git a/core/static/core/favicon.ico b/core/static/core/favicon.ico index 02d0115b8038801968478798bf9347be7dcab627..6eabf35560e55fa6dbc188ed52b1e28506709c84 100644 GIT binary patch literal 4286 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 diff --git a/core/static/core/logo.png b/core/static/core/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..159c9fdfebcb613540c0769c1a84b2835c5a3b84 GIT binary patch literal 20331 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 literal 0 HcmV?d00001 diff --git a/core/static/core/logo.svg b/core/static/core/logo.svg deleted file mode 100644 index 0c20a100..00000000 --- a/core/static/core/logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/newsletter_maker/settings/base.py b/newsletter_maker/settings/base.py index 37280aaa..14e78c15 100644 --- a/newsletter_maker/settings/base.py +++ b/newsletter_maker/settings/base.py @@ -153,7 +153,7 @@ def env_list(name: str, default: str = "") -> list[str]: "href": lambda request: static("core/favicon.ico"), }, ], - "SITE_ICON": lambda request: static("core/logo.svg"), + "SITE_ICON": lambda request: static("core/logo.png"), "SITE_SYMBOL": "newsletter", } From d25d34263b67a50047fc362e5bd2600729b28377 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 27 Apr 2026 00:31:17 +0300 Subject: [PATCH 2/3] Add LangGraph pipeline and skills --- .env.example | 10 + .vscode/settings.json | 5 +- celerybeat-schedule | Bin 12288 -> 12288 bytes core/llm.py | 71 +++++ core/pipeline.py | 388 +++++++++++++++++++++++++ core/tasks.py | 10 + core/tests/test_pipeline.py | 174 +++++++++++ core/tests/test_tasks.py | 6 + newsletter_maker/settings/ai.py | 24 ++ requirements.txt | 11 + skills/content_classification/SKILL.md | 17 ++ skills/relevance_scoring/SKILL.md | 13 + skills/summarization/SKILL.md | 9 + 13 files changed, 737 insertions(+), 1 deletion(-) create mode 100644 core/llm.py create mode 100644 core/pipeline.py create mode 100644 core/tests/test_pipeline.py create mode 100644 skills/content_classification/SKILL.md create mode 100644 skills/relevance_scoring/SKILL.md create mode 100644 skills/summarization/SKILL.md diff --git a/.env.example b/.env.example index 97c0b6ab..991d1d85 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,16 @@ OPENROUTER_API_KEY= OPENROUTER_API_BASE=https://openrouter.ai/api/v1 OPENROUTER_APP_URL= OPENROUTER_APP_NAME=newsletter-maker +AI_CLASSIFICATION_MODEL=meta-llama/llama-3.1-70b-instruct +AI_RELEVANCE_MODEL=qwen/qwen-2.5-72b-instruct +AI_SUMMARIZATION_MODEL=google/gemma-3-27b-it +AI_CLASSIFICATION_REVIEW_THRESHOLD=0.6 +AI_RELEVANCE_LOW_THRESHOLD=0.5 +AI_RELEVANCE_HIGH_THRESHOLD=0.85 +AI_RELEVANCE_REVIEW_THRESHOLD=0.4 +AI_RELEVANCE_SUMMARIZE_THRESHOLD=0.7 +AI_MAX_NODE_RETRIES=2 +AI_REQUEST_TIMEOUT_SECONDS=60 EMBEDDING_PROVIDER=sentence-transformers EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 EMBEDDING_TRUST_REMOTE_CODE=false diff --git a/.vscode/settings.json b/.vscode/settings.json index e1b74788..55016634 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "noinput", "nomic", "OLLAMA", + "ormsgpack", "PRAW", "psycopg", "pylint", @@ -28,6 +29,8 @@ "readyz", "Referer", "upserted", - "upvote" + "upvote", + "uritemplate", + "xxhash" ] } diff --git a/celerybeat-schedule b/celerybeat-schedule index 4cdb08a04be8d00be5430823bbe94aa37f431087..1089d88f021aa70f4862db94d857744adca7fd25 100644 GIT binary patch delta 96 zcmZojXh_&#CCAhpG}%T@nW;H&a-y8DY;yp!fI&%B1Itu)1_3rjjbg3p~tHOsRP#MVYC^jF$zNm6%FPlH*hJ5|eULQy4D-03k;k_W%F@ delta 96 zcmZojXh_&#CCAiMJ=sQ1nW?F2a-y8DY*Qt(fI&%B1Itu)1_shOa(rrDVp2|O3S$)jFf OpenRouterJSONResponse: + if not settings.OPENROUTER_API_KEY: + raise RuntimeError("OPENROUTER_API_KEY must be configured for OpenRouter chat completions.") + + headers = { + "Authorization": f"Bearer {settings.OPENROUTER_API_KEY}", + "Content-Type": "application/json", + } + if settings.OPENROUTER_APP_URL: + headers["HTTP-Referer"] = settings.OPENROUTER_APP_URL + if settings.OPENROUTER_APP_NAME: + headers["X-OpenRouter-Title"] = settings.OPENROUTER_APP_NAME + + started_at = time.perf_counter() + response = httpx.post( + f"{settings.OPENROUTER_API_BASE.rstrip('/')}/chat/completions", + headers=headers, + json={ + "model": model, + "temperature": 0, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + }, + timeout=settings.AI_REQUEST_TIMEOUT_SECONDS, + ) + latency_ms = int((time.perf_counter() - started_at) * 1000) + response.raise_for_status() + + message_content = response.json()["choices"][0]["message"]["content"] + return OpenRouterJSONResponse( + payload=_extract_json_object(message_content), + model=model, + latency_ms=latency_ms, + ) + + +def _extract_json_object(message_content: str) -> dict[str, Any]: + try: + payload = json.loads(message_content) + except json.JSONDecodeError: + match = JSON_OBJECT_PATTERN.search(message_content) + if not match: + raise ValueError("Model response did not contain a JSON object.") + payload = json.loads(match.group(0)) + if not isinstance(payload, dict): + raise ValueError("Model response JSON must be an object.") + return payload \ No newline at end of file diff --git a/core/pipeline.py b/core/pipeline.py new file mode 100644 index 00000000..2057339b --- /dev/null +++ b/core/pipeline.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import logging +import re +from functools import lru_cache +from typing import Any, Literal, TypedDict + +from django.conf import settings +from langgraph.graph import END, StateGraph + +from core.embeddings import build_content_embedding_text, embed_text, get_reference_similarity +from core.llm import openrouter_chat_json +from core.models import Content, ReviewQueue, ReviewReason, SkillResult, SkillStatus + +logger = logging.getLogger(__name__) + +CLASSIFICATION_SKILL_NAME = "content_classification" +RELEVANCE_SKILL_NAME = "relevance_scoring" +SUMMARIZATION_SKILL_NAME = "summarization" + +CONTENT_TYPES = ( + "technical_article", + "tutorial", + "opinion", + "product_announcement", + "event", + "release_notes", + "other", +) + + +class PipelineState(TypedDict, total=False): + content_id: int + tenant_id: int + classification: dict[str, Any] | None + relevance: dict[str, Any] | None + summary: dict[str, Any] | None + status: str + + +@lru_cache(maxsize=1) +def get_ingestion_graph(): + graph = StateGraph(PipelineState) + graph.add_node("classify", classify_node) + graph.add_node("score_relevance", relevance_node) + graph.add_node("summarize", summarize_node) + graph.add_node("archive", archive_node) + graph.add_node("queue_review", queue_review_node) + graph.set_entry_point("classify") + graph.add_edge("classify", "score_relevance") + graph.add_conditional_edges( + "score_relevance", + route_by_relevance, + { + "relevant": "summarize", + "borderline": "queue_review", + "irrelevant": "archive", + }, + ) + graph.add_edge("summarize", END) + graph.add_edge("archive", END) + graph.add_edge("queue_review", END) + return graph.compile() + + +def process_content_pipeline(content_id: int) -> PipelineState: + content = Content.objects.select_related("tenant").get(pk=content_id) + initial_state: PipelineState = { + "content_id": content.id, + "tenant_id": content.tenant_id, + "status": "processing", + } + return get_ingestion_graph().invoke(initial_state) + + +def classify_node(state: PipelineState) -> PipelineState: + content = _get_content(state) + classification = _execute_with_retries(CLASSIFICATION_SKILL_NAME, lambda: run_content_classification(content)) + content.content_type = classification["content_type"] + content.save(update_fields=["content_type"]) + _create_skill_result( + content, + skill_name=CLASSIFICATION_SKILL_NAME, + status=SkillStatus.COMPLETED, + result_data=classification, + model_used=classification["model_used"], + latency_ms=classification["latency_ms"], + confidence=classification["confidence"], + ) + if classification["confidence"] < settings.AI_CLASSIFICATION_REVIEW_THRESHOLD: + _upsert_review_queue_item( + content, + reason=ReviewReason.LOW_CONFIDENCE_CLASSIFICATION, + confidence=float(classification["confidence"]), + ) + return {"classification": classification} + + +def relevance_node(state: PipelineState) -> PipelineState: + content = _get_content(state) + relevance = _execute_with_retries(RELEVANCE_SKILL_NAME, lambda: run_relevance_scoring(content)) + content.relevance_score = relevance["relevance_score"] + content.is_active = True + content.save(update_fields=["relevance_score", "is_active"]) + _create_skill_result( + content, + skill_name=RELEVANCE_SKILL_NAME, + status=SkillStatus.COMPLETED, + result_data=relevance, + model_used=relevance["model_used"], + latency_ms=relevance["latency_ms"], + confidence=relevance["relevance_score"], + ) + return {"relevance": relevance} + + +def summarize_node(state: PipelineState) -> PipelineState: + content = _get_content(state) + summary = _execute_with_retries(SUMMARIZATION_SKILL_NAME, lambda: run_summarization(content)) + _create_skill_result( + content, + skill_name=SUMMARIZATION_SKILL_NAME, + status=SkillStatus.COMPLETED, + result_data=summary, + model_used=summary["model_used"], + latency_ms=summary["latency_ms"], + ) + return {"summary": summary, "status": "completed"} + + +def archive_node(state: PipelineState) -> PipelineState: + content = _get_content(state) + content.is_active = False + content.save(update_fields=["is_active"]) + return {"status": "archived"} + + +def queue_review_node(state: PipelineState) -> PipelineState: + content = _get_content(state) + relevance = state.get("relevance") or {} + _upsert_review_queue_item( + content, + reason=ReviewReason.BORDERLINE_RELEVANCE, + confidence=float(relevance.get("relevance_score", settings.AI_RELEVANCE_REVIEW_THRESHOLD)), + ) + content.is_active = True + content.save(update_fields=["is_active"]) + return {"status": "review"} + + +def route_by_relevance(state: PipelineState) -> Literal["relevant", "borderline", "irrelevant"]: + relevance = state.get("relevance") or {} + score = float(relevance.get("relevance_score", 0.0)) + if score >= settings.AI_RELEVANCE_SUMMARIZE_THRESHOLD: + return "relevant" + if score < settings.AI_RELEVANCE_REVIEW_THRESHOLD: + return "irrelevant" + return "borderline" + + +def run_content_classification(content: Content) -> dict[str, Any]: + if settings.OPENROUTER_API_KEY: + try: + response = openrouter_chat_json( + model=settings.AI_CLASSIFICATION_MODEL, + system_prompt=( + "You classify newsletter content into one of these categories: " + "technical_article, tutorial, opinion, product_announcement, event, release_notes, other. " + "Return JSON with content_type, confidence, and explanation." + ), + user_prompt=f"Title: {content.title}\nURL: {content.url}\n\nContent:\n{content.content_text[:5000]}", + ) + payload = response.payload + content_type = str(payload.get("content_type", "other")) + if content_type not in CONTENT_TYPES: + content_type = "other" + confidence = _clamp_score(payload.get("confidence", 0.5)) + return { + "content_type": content_type, + "confidence": confidence, + "explanation": str(payload.get("explanation", "LLM-based classification.")), + "model_used": response.model, + "latency_ms": response.latency_ms, + } + except Exception: + logger.exception( + "Classification model call failed; falling back to heuristic classifier", + extra={"content_id": content.id}, + ) + return _heuristic_classification(content) + + +def run_relevance_scoring(content: Content) -> dict[str, Any]: + vector = embed_text(build_content_embedding_text(content)) + similarity = float(get_reference_similarity(content.tenant_id, vector)) + if similarity >= settings.AI_RELEVANCE_HIGH_THRESHOLD or similarity < settings.AI_RELEVANCE_LOW_THRESHOLD: + return { + "relevance_score": similarity, + "explanation": f"Reference corpus similarity score is {similarity:.2f}; no LLM adjudication was required.", + "used_llm": False, + "model_used": f"embedding:{settings.EMBEDDING_MODEL}", + "latency_ms": 0, + } + + if settings.OPENROUTER_API_KEY: + try: + response = openrouter_chat_json( + model=settings.AI_RELEVANCE_MODEL, + system_prompt=( + "You score how relevant a candidate article is for a newsletter topic. " + "Return JSON with relevance_score between 0 and 1, explanation, and used_llm=true." + ), + user_prompt=( + f"Newsletter topic: {content.tenant.topic_description}\n" + f"Reference similarity score: {similarity:.3f}\n" + f"Title: {content.title}\n" + f"Content:\n{content.content_text[:5000]}" + ), + ) + payload = response.payload + return { + "relevance_score": _clamp_score(payload.get("relevance_score", similarity)), + "explanation": str(payload.get("explanation", "LLM-based relevance adjudication.")), + "used_llm": True, + "model_used": response.model, + "latency_ms": response.latency_ms, + } + except Exception: + logger.exception( + "Relevance model call failed; falling back to heuristic relevance", + extra={"content_id": content.id}, + ) + + return { + "relevance_score": similarity, + "explanation": ( + f"Borderline reference similarity of {similarity:.2f} against the tenant baseline for " + f"'{content.tenant.topic_description}'." + ), + "used_llm": False, + "model_used": f"embedding:{settings.EMBEDDING_MODEL}", + "latency_ms": 0, + } + + +def run_summarization(content: Content) -> dict[str, Any]: + if settings.OPENROUTER_API_KEY: + try: + response = openrouter_chat_json( + model=settings.AI_SUMMARIZATION_MODEL, + system_prompt=( + "You write concise newsletter-ready summaries. Return JSON with a single key named summary." + ), + user_prompt=( + f"Newsletter topic: {content.tenant.topic_description}\n" + f"Title: {content.title}\n" + f"Content:\n{content.content_text[:5000]}" + ), + ) + return { + "summary": _normalize_summary(str(response.payload.get("summary", "")), content), + "model_used": response.model, + "latency_ms": response.latency_ms, + } + except Exception: + logger.exception( + "Summarization model call failed; falling back to heuristic summary", + extra={"content_id": content.id}, + ) + return { + "summary": _heuristic_summary(content), + "model_used": "heuristic", + "latency_ms": 0, + } + + +def _execute_with_retries(skill_name: str, fn): + last_exc: Exception | None = None + for attempt in range(settings.AI_MAX_NODE_RETRIES + 1): + try: + return fn() + except Exception as exc: # pragma: no cover + last_exc = exc + logger.exception( + "Skill execution failed", + extra={"skill_name": skill_name, "attempt": attempt + 1}, + ) + assert last_exc is not None + raise last_exc + + +def _heuristic_classification(content: Content) -> dict[str, Any]: + text = f"{content.title}\n{content.content_text}".lower() + keyword_sets = { + "release_notes": ("release notes", "changelog", "version", "released"), + "tutorial": ("tutorial", "how to", "guide", "walkthrough", "step-by-step"), + "product_announcement": ("announcing", "launch", "launched", "available now", "introducing"), + "event": ("conference", "summit", "meetup", "webinar", "event"), + "opinion": ("opinion", "why i", "lessons learned", "thoughts", "editorial"), + "technical_article": ("architecture", "engineering", "platform", "infrastructure", "devops", "kubernetes"), + } + best_type = "other" + best_score = 0 + for content_type, keywords in keyword_sets.items(): + score = sum(text.count(keyword) for keyword in keywords) + if score > best_score: + best_type = content_type + best_score = score + confidence = 0.45 if best_type == "other" else min(0.95, 0.55 + (best_score * 0.1)) + return { + "content_type": best_type, + "confidence": confidence, + "explanation": "Keyword heuristic based on title and body text.", + "model_used": "heuristic", + "latency_ms": 0, + } + + +def _heuristic_summary(content: Content) -> str: + sentences = [segment.strip() for segment in re.split(r"(?<=[.!?])\s+", content.content_text.strip()) if segment.strip()] + if not sentences: + return f"{content.title}: no summary was available from the source content." + summary = " ".join(sentences[:2]) + if len(summary) > 400: + summary = summary[:397].rstrip() + "..." + return _normalize_summary(summary, content) + + +def _normalize_summary(summary: str, content: Content) -> str: + normalized = summary.strip() + if normalized: + return normalized + return f"{content.title}: summary generation returned no content." + + +def _clamp_score(value: Any) -> float: + try: + score = float(value) + except (TypeError, ValueError): + score = 0.0 + return max(0.0, min(1.0, score)) + + +def _get_content(state: PipelineState) -> Content: + return Content.objects.select_related("tenant").get(pk=state["content_id"]) + + +def _upsert_review_queue_item(content: Content, *, reason: ReviewReason, confidence: float) -> ReviewQueue: + existing = ReviewQueue.objects.filter(content=content, reason=reason, resolved=False).first() + if existing: + existing.confidence = confidence + existing.save(update_fields=["confidence"]) + return existing + return ReviewQueue.objects.create( + tenant=content.tenant, + content=content, + reason=reason, + confidence=confidence, + ) + + +def _create_skill_result( + content: Content, + *, + skill_name: str, + status: SkillStatus, + result_data: dict[str, Any] | None = None, + error_message: str = "", + model_used: str = "", + latency_ms: int | None = None, + confidence: float | None = None, +) -> SkillResult: + previous = SkillResult.objects.filter(content=content, skill_name=skill_name, superseded_by__isnull=True).first() + skill_result = SkillResult.objects.create( + content=content, + tenant=content.tenant, + skill_name=skill_name, + status=status, + result_data=result_data, + error_message=error_message, + model_used=model_used, + latency_ms=latency_ms, + confidence=confidence, + ) + if previous: + previous.superseded_by = skill_result + previous.save(update_fields=["superseded_by"]) + return skill_result \ No newline at end of file diff --git a/core/tasks.py b/core/tasks.py index 6fb22e2a..5fc8e945 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -6,6 +6,7 @@ from core.embeddings import upsert_content_embedding from core.models import Content, IngestionRun, RunStatus, SourceConfig +from core.pipeline import process_content_pipeline from core.plugins import get_plugin_for_source_config logger = logging.getLogger(__name__) @@ -48,6 +49,11 @@ def run_all_ingestions(): return len(source_config_ids) +@shared_task(name="core.tasks.process_content") +def process_content(content_id: int): + return process_content_pipeline(content_id) + + def _ingest_source_config(source_config: SourceConfig) -> tuple[int, int]: plugin = get_plugin_for_source_config(source_config) fetched_items = plugin.fetch_new_content(source_config.last_fetched_at) @@ -66,6 +72,10 @@ def _ingest_source_config(source_config: SourceConfig) -> tuple[int, int]: content_text=item.content_text, ) upsert_content_embedding(content) + if settings.CELERY_TASK_ALWAYS_EAGER: + process_content(content.id) + else: + process_content.delay(content.id) ingested_count += 1 source_config.last_fetched_at = timezone.now() source_config.save(update_fields=["last_fetched_at"]) diff --git a/core/tests/test_pipeline.py b/core/tests/test_pipeline.py new file mode 100644 index 00000000..41dc7d08 --- /dev/null +++ b/core/tests/test_pipeline.py @@ -0,0 +1,174 @@ +from types import SimpleNamespace + +import pytest + +from core.models import Content, ReviewQueue, ReviewReason, SkillResult, Tenant +from core.pipeline import CLASSIFICATION_SKILL_NAME, RELEVANCE_SKILL_NAME, SUMMARIZATION_SKILL_NAME +from core.tasks import process_content + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def pipeline_context(django_user_model): + user = django_user_model.objects.create_user(username="pipeline-owner", password="testpass123") + tenant = Tenant.objects.create(name="Pipeline Tenant", user=user, topic_description="Platform engineering") + content = Content.objects.create( + tenant=tenant, + url="https://example.com/article", + title="Kubernetes Release Notes", + author="Editor", + source_plugin="rss", + published_date="2026-04-26T00:00:00Z", + content_text="This article covers a new Kubernetes release and what changed for platform teams.", + embedding_id="emb_123", + ) + return SimpleNamespace(user=user, tenant=tenant, content=content) + + +def test_process_content_runs_full_pipeline_for_relevant_content(pipeline_context, mocker): + mocker.patch( + "core.pipeline.run_content_classification", + return_value={ + "content_type": "release_notes", + "confidence": 0.9, + "explanation": "High confidence classification.", + "model_used": "heuristic", + "latency_ms": 0, + }, + ) + mocker.patch( + "core.pipeline.run_relevance_scoring", + return_value={ + "relevance_score": 0.92, + "explanation": "Very close to the tenant reference corpus.", + "used_llm": False, + "model_used": "embedding:test", + "latency_ms": 0, + }, + ) + mocker.patch( + "core.pipeline.run_summarization", + return_value={ + "summary": "A concise summary for the editor.", + "model_used": "heuristic", + "latency_ms": 0, + }, + ) + + result = process_content(pipeline_context.content.id) + + pipeline_context.content.refresh_from_db() + assert result["status"] == "completed" + assert pipeline_context.content.content_type == "release_notes" + assert pipeline_context.content.relevance_score == pytest.approx(0.92) + assert pipeline_context.content.is_active is True + assert SkillResult.objects.filter(content=pipeline_context.content, skill_name=CLASSIFICATION_SKILL_NAME).count() == 1 + assert SkillResult.objects.filter(content=pipeline_context.content, skill_name=RELEVANCE_SKILL_NAME).count() == 1 + assert SkillResult.objects.filter(content=pipeline_context.content, skill_name=SUMMARIZATION_SKILL_NAME).count() == 1 + assert ReviewQueue.objects.filter(content=pipeline_context.content).count() == 0 + + +def test_process_content_queues_borderline_items_for_review(pipeline_context, mocker): + mocker.patch( + "core.pipeline.run_content_classification", + return_value={ + "content_type": "technical_article", + "confidence": 0.9, + "explanation": "High confidence classification.", + "model_used": "heuristic", + "latency_ms": 0, + }, + ) + mocker.patch( + "core.pipeline.run_relevance_scoring", + return_value={ + "relevance_score": 0.55, + "explanation": "Borderline similarity to the tenant baseline.", + "used_llm": False, + "model_used": "embedding:test", + "latency_ms": 0, + }, + ) + summarize_mock = mocker.patch("core.pipeline.run_summarization") + + result = process_content(pipeline_context.content.id) + + pipeline_context.content.refresh_from_db() + assert result["status"] == "review" + assert pipeline_context.content.is_active is True + summarize_mock.assert_not_called() + review_item = ReviewQueue.objects.get(content=pipeline_context.content, reason=ReviewReason.BORDERLINE_RELEVANCE) + assert review_item.confidence == pytest.approx(0.55) + + +def test_process_content_archives_irrelevant_items(pipeline_context, mocker): + mocker.patch( + "core.pipeline.run_content_classification", + return_value={ + "content_type": "other", + "confidence": 0.7, + "explanation": "Low signal classification.", + "model_used": "heuristic", + "latency_ms": 0, + }, + ) + mocker.patch( + "core.pipeline.run_relevance_scoring", + return_value={ + "relevance_score": 0.2, + "explanation": "Far from the tenant reference corpus.", + "used_llm": False, + "model_used": "embedding:test", + "latency_ms": 0, + }, + ) + summarize_mock = mocker.patch("core.pipeline.run_summarization") + + result = process_content(pipeline_context.content.id) + + pipeline_context.content.refresh_from_db() + assert result["status"] == "archived" + assert pipeline_context.content.is_active is False + summarize_mock.assert_not_called() + assert ReviewQueue.objects.filter(content=pipeline_context.content, reason=ReviewReason.BORDERLINE_RELEVANCE).count() == 0 + + +def test_process_content_adds_review_item_for_low_confidence_classification(pipeline_context, mocker): + mocker.patch( + "core.pipeline.run_content_classification", + return_value={ + "content_type": "other", + "confidence": 0.3, + "explanation": "Ambiguous content.", + "model_used": "heuristic", + "latency_ms": 0, + }, + ) + mocker.patch( + "core.pipeline.run_relevance_scoring", + return_value={ + "relevance_score": 0.9, + "explanation": "Close to the tenant baseline.", + "used_llm": False, + "model_used": "embedding:test", + "latency_ms": 0, + }, + ) + mocker.patch( + "core.pipeline.run_summarization", + return_value={ + "summary": "Summary present even though classification confidence was low.", + "model_used": "heuristic", + "latency_ms": 0, + }, + ) + + result = process_content(pipeline_context.content.id) + + assert result["status"] == "completed" + review_item = ReviewQueue.objects.get( + content=pipeline_context.content, + reason=ReviewReason.LOW_CONFIDENCE_CLASSIFICATION, + ) + assert review_item.confidence == pytest.approx(0.3) \ No newline at end of file diff --git a/core/tests/test_tasks.py b/core/tests/test_tasks.py index df7a53ea..02de1c52 100644 --- a/core/tests/test_tasks.py +++ b/core/tests/test_tasks.py @@ -24,6 +24,7 @@ def source_plugin_context(django_user_model): def test_run_ingestion_creates_content_from_rss_entries(source_plugin_context, mocker): upsert_embedding_mock = mocker.patch("core.tasks.upsert_content_embedding") + process_content_delay_mock = mocker.patch("core.tasks.process_content.delay") parse_mock = mocker.patch("core.plugins.rss.feedparser.parse") source_config = SourceConfig.objects.create( tenant=source_plugin_context.tenant, @@ -50,12 +51,14 @@ def test_run_ingestion_creates_content_from_rss_entries(source_plugin_context, m assert content.tenant == source_plugin_context.tenant assert content.entity == source_plugin_context.entity upsert_embedding_mock.assert_called_once_with(content) + process_content_delay_mock.assert_called_once_with(content.id) assert SourceConfig.objects.get(pk=source_config.id).last_fetched_at is not None ingestion_run = IngestionRun.objects.get(tenant=source_plugin_context.tenant, plugin_name=SourcePluginName.RSS) assert ingestion_run.status == RunStatus.SUCCESS def test_run_ingestion_skips_duplicate_urls(source_plugin_context, mocker): upsert_embedding_mock = mocker.patch("core.tasks.upsert_content_embedding") + process_content_delay_mock = mocker.patch("core.tasks.process_content.delay") parse_mock = mocker.patch("core.plugins.rss.feedparser.parse") source_config = SourceConfig.objects.create( tenant=source_plugin_context.tenant, @@ -89,10 +92,12 @@ def test_run_ingestion_skips_duplicate_urls(source_plugin_context, mocker): assert result["items_fetched"] == 1 assert result["items_ingested"] == 0 upsert_embedding_mock.assert_not_called() + process_content_delay_mock.assert_not_called() assert Content.objects.filter(url="https://example.com/post-1").count() == 1 def test_run_ingestion_creates_content_from_reddit_posts(source_plugin_context, mocker): upsert_embedding_mock = mocker.patch("core.tasks.upsert_content_embedding") + process_content_delay_mock = mocker.patch("core.tasks.process_content.delay") reddit_mock = mocker.patch("core.plugins.reddit.praw.Reddit") source_config = SourceConfig.objects.create( tenant=source_plugin_context.tenant, @@ -117,6 +122,7 @@ def test_run_ingestion_creates_content_from_reddit_posts(source_plugin_context, assert result["items_ingested"] == 1 content = Content.objects.get(title="Reddit Post") upsert_embedding_mock.assert_called_once_with(content) + process_content_delay_mock.assert_called_once_with(content.id) assert content.source_plugin == SourcePluginName.REDDIT assert content.entity is None diff --git a/newsletter_maker/settings/ai.py b/newsletter_maker/settings/ai.py index 8a89beba..9d30ee98 100644 --- a/newsletter_maker/settings/ai.py +++ b/newsletter_maker/settings/ai.py @@ -2,11 +2,35 @@ from .base import env_bool + +def env_float(name: str, default: float) -> float: + value = os.getenv(name) + if value is None: + return default + return float(value) + + +def env_int(name: str, default: int) -> int: + value = os.getenv(name) + if value is None: + return default + return int(value) + QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333") OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") OPENROUTER_API_BASE = os.getenv("OPENROUTER_API_BASE", "https://openrouter.ai/api/v1") OPENROUTER_APP_URL = os.getenv("OPENROUTER_APP_URL", "") OPENROUTER_APP_NAME = os.getenv("OPENROUTER_APP_NAME", "newsletter-maker") +AI_CLASSIFICATION_MODEL = os.getenv("AI_CLASSIFICATION_MODEL", "meta-llama/llama-3.1-70b-instruct") +AI_RELEVANCE_MODEL = os.getenv("AI_RELEVANCE_MODEL", "qwen/qwen-2.5-72b-instruct") +AI_SUMMARIZATION_MODEL = os.getenv("AI_SUMMARIZATION_MODEL", "google/gemma-3-27b-it") +AI_CLASSIFICATION_REVIEW_THRESHOLD = env_float("AI_CLASSIFICATION_REVIEW_THRESHOLD", default=0.6) +AI_RELEVANCE_LOW_THRESHOLD = env_float("AI_RELEVANCE_LOW_THRESHOLD", default=0.5) +AI_RELEVANCE_HIGH_THRESHOLD = env_float("AI_RELEVANCE_HIGH_THRESHOLD", default=0.85) +AI_RELEVANCE_REVIEW_THRESHOLD = env_float("AI_RELEVANCE_REVIEW_THRESHOLD", default=0.4) +AI_RELEVANCE_SUMMARIZE_THRESHOLD = env_float("AI_RELEVANCE_SUMMARIZE_THRESHOLD", default=0.7) +AI_MAX_NODE_RETRIES = env_int("AI_MAX_NODE_RETRIES", default=2) +AI_REQUEST_TIMEOUT_SECONDS = env_float("AI_REQUEST_TIMEOUT_SECONDS", default=60.0) EMBEDDING_PROVIDER = os.getenv("EMBEDDING_PROVIDER", "sentence-transformers") EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2") EMBEDDING_TRUST_REMOTE_CODE = env_bool("EMBEDDING_TRUST_REMOTE_CODE", default=False) diff --git a/requirements.txt b/requirements.txt index 95bf1b90..6fe1d3ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,9 +19,17 @@ httpx==0.28.1 identify==2.6.19 inflection==0.5.1 jsbeautifier==1.15.4 +langchain-core==1.3.2 +langchain-protocol==0.0.12 +langgraph-checkpoint==4.0.2 +langgraph-prebuilt==1.0.11 +langgraph-sdk==0.3.13 +langgraph==1.1.9 +langsmith==0.7.36 librt==0.9.0 mypy==1.20.2 nodeenv==1.10.0 +ormsgpack==1.12.2 praw==7.8.1 pre-commit==4.6.0 psycopg[binary]==3.3.3 @@ -32,9 +40,12 @@ pytest-mock==3.15.1 pytest==9.0.3 python-dotenv==1.2.2 qdrant-client==1.17.1 +requests-toolbelt==1.0.0 ruff==0.15.12 sentence-transformers==5.4.1 structlog==25.5.0 types-pyyaml==6.0.12.20260408 uritemplate==4.2.0 +uuid-utils==0.14.1 watchdog==6.0.0 +xxhash==3.7.0 diff --git a/skills/content_classification/SKILL.md b/skills/content_classification/SKILL.md new file mode 100644 index 00000000..f3772600 --- /dev/null +++ b/skills/content_classification/SKILL.md @@ -0,0 +1,17 @@ +--- +name: content_classification +input: title, content_text, url +output: content_type, confidence, explanation +--- + +Classify newsletter content into one of these categories: + +- technical_article +- tutorial +- opinion +- product_announcement +- event +- release_notes +- other + +Return structured JSON with `content_type`, `confidence`, and `explanation`. \ No newline at end of file diff --git a/skills/relevance_scoring/SKILL.md b/skills/relevance_scoring/SKILL.md new file mode 100644 index 00000000..46ee027c --- /dev/null +++ b/skills/relevance_scoring/SKILL.md @@ -0,0 +1,13 @@ +--- +name: relevance_scoring +input: content_embedding, tenant_id +output: relevance_score, explanation, used_llm +--- + +Score how relevant a piece of content is for a tenant using reference-corpus similarity first. + +- Similarity >= 0.85: use the similarity score directly. +- Similarity < 0.5: use the similarity score directly. +- Similarity between 0.5 and 0.85: use an LLM for nuanced judgment when available. + +Return structured JSON with `relevance_score`, `explanation`, and `used_llm`. \ No newline at end of file diff --git a/skills/summarization/SKILL.md b/skills/summarization/SKILL.md new file mode 100644 index 00000000..da74150d --- /dev/null +++ b/skills/summarization/SKILL.md @@ -0,0 +1,9 @@ +--- +name: summarization +input: title, content_text, newsletter_topic +output: summary +--- + +Write a concise 2-3 sentence newsletter-ready summary for content that has already been judged relevant. + +Return structured JSON with a single `summary` field. \ No newline at end of file From e8522bc01a07e2524d113c09ac9450f8ae85f00a Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Mon, 27 Apr 2026 00:38:06 +0300 Subject: [PATCH 3/3] Fix lint errors --- core/llm.py | 2 +- core/pipeline.py | 2 +- core/tests/test_pipeline.py | 2 +- skills/content_classification/SKILL.md | 2 +- skills/relevance_scoring/SKILL.md | 2 +- skills/summarization/SKILL.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/llm.py b/core/llm.py index 7f60a608..2bf545f3 100644 --- a/core/llm.py +++ b/core/llm.py @@ -68,4 +68,4 @@ def _extract_json_object(message_content: str) -> dict[str, Any]: payload = json.loads(match.group(0)) if not isinstance(payload, dict): raise ValueError("Model response JSON must be an object.") - return payload \ No newline at end of file + return payload diff --git a/core/pipeline.py b/core/pipeline.py index 2057339b..521a94db 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -385,4 +385,4 @@ def _create_skill_result( if previous: previous.superseded_by = skill_result previous.save(update_fields=["superseded_by"]) - return skill_result \ No newline at end of file + return skill_result diff --git a/core/tests/test_pipeline.py b/core/tests/test_pipeline.py index 41dc7d08..196ccbb1 100644 --- a/core/tests/test_pipeline.py +++ b/core/tests/test_pipeline.py @@ -171,4 +171,4 @@ def test_process_content_adds_review_item_for_low_confidence_classification(pipe content=pipeline_context.content, reason=ReviewReason.LOW_CONFIDENCE_CLASSIFICATION, ) - assert review_item.confidence == pytest.approx(0.3) \ No newline at end of file + assert review_item.confidence == pytest.approx(0.3) diff --git a/skills/content_classification/SKILL.md b/skills/content_classification/SKILL.md index f3772600..3622de7f 100644 --- a/skills/content_classification/SKILL.md +++ b/skills/content_classification/SKILL.md @@ -14,4 +14,4 @@ Classify newsletter content into one of these categories: - release_notes - other -Return structured JSON with `content_type`, `confidence`, and `explanation`. \ No newline at end of file +Return structured JSON with `content_type`, `confidence`, and `explanation`. diff --git a/skills/relevance_scoring/SKILL.md b/skills/relevance_scoring/SKILL.md index 46ee027c..192f80e2 100644 --- a/skills/relevance_scoring/SKILL.md +++ b/skills/relevance_scoring/SKILL.md @@ -10,4 +10,4 @@ Score how relevant a piece of content is for a tenant using reference-corpus sim - Similarity < 0.5: use the similarity score directly. - Similarity between 0.5 and 0.85: use an LLM for nuanced judgment when available. -Return structured JSON with `relevance_score`, `explanation`, and `used_llm`. \ No newline at end of file +Return structured JSON with `relevance_score`, `explanation`, and `used_llm`. diff --git a/skills/summarization/SKILL.md b/skills/summarization/SKILL.md index da74150d..f8a117a7 100644 --- a/skills/summarization/SKILL.md +++ b/skills/summarization/SKILL.md @@ -6,4 +6,4 @@ output: summary Write a concise 2-3 sentence newsletter-ready summary for content that has already been judged relevant. -Return structured JSON with a single `summary` field. \ No newline at end of file +Return structured JSON with a single `summary` field.