1648 lines
59 KiB
Python
1648 lines
59 KiB
Python
"""Agno specialist agents and report guardrails."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import json
|
|
import logging
|
|
import re
|
|
from collections.abc import Awaitable, Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from agno.agent import Agent
|
|
|
|
from engram_memory import MemoryRecord
|
|
from llm import build_model, make_agent, parse_json_object, run_agent
|
|
from models import (
|
|
CompanyContext,
|
|
ContentGap,
|
|
ContentIdea,
|
|
DEVArticle,
|
|
DEVSupplySignal,
|
|
HNDeveloperSignal,
|
|
HNItem,
|
|
IdeationReport,
|
|
SearchQueryPlan,
|
|
TrendDigestItem,
|
|
AgentRunResult,
|
|
)
|
|
from config import Settings
|
|
from engram_memory import MemoryStore
|
|
from sources import search_dev_articles, search_hn
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
StageCallback = Callable[[str], Awaitable[None] | None]
|
|
|
|
|
|
def _preview_items(items: list[str], limit: int = 3) -> str:
|
|
cleaned = [item.strip() for item in items if item.strip()]
|
|
if not cleaned:
|
|
return "general signals"
|
|
preview = ", ".join(cleaned[:limit])
|
|
if len(cleaned) > limit:
|
|
preview += f" (+{len(cleaned) - limit} more)"
|
|
return preview
|
|
|
|
|
|
async def _notify(callback: StageCallback | None, message: str) -> None:
|
|
if not callback:
|
|
return
|
|
result = callback(message)
|
|
if asyncio.iscoroutine(result):
|
|
await result
|
|
|
|
|
|
QUERY_PLANNER_PROMPT = """
|
|
You are the Developer Trend & DevRel Ideation Agent query planner.
|
|
Create search queries that reveal real developer demand on Hacker News and current article supply on DEV.to.
|
|
Prefer high-intent implementation, debugging, migration, comparison, architecture, integration, tutorial, and production-readiness phrases.
|
|
For DEV.to, include both specific article queries and broader public tags likely to return recent articles; avoid over-narrow quoted phrases.
|
|
Return concise query lists and tags. Avoid broad tags like programming unless no better tag exists.
|
|
Return only JSON with keys: hn_queries, dev_queries, dev_tags, rationale.
|
|
"""
|
|
|
|
|
|
IDEATION_WRITER_PROMPT = """
|
|
You write DevRel talk and blog ideation for developer-tool companies.
|
|
Do not write full articles. Produce a trend digest plus ranked talk/blog/tutorial ideas with synthesized titles (not raw HN headlines).
|
|
Every idea needs HN evidence links, DEV supply gap notes, angle, outline bullets, and confidence.
|
|
The user may provide any developer product. Classify the product/category from the prompt and evidence yourself; do not assume a fixed taxonomy.
|
|
Choose DEV links per idea only when the article title, tags, description, or body excerpt actually matches that idea.
|
|
If DEV coverage is weak or unrelated, leave dev_links empty and explain the supply gap instead of reusing generic links.
|
|
Return only valid JSON matching the report shape: company, summary, trend_digest, content_ideas, memory_notes.
|
|
Use short strings. Avoid quotation marks inside text values; use apostrophes or parentheses instead.
|
|
Do not include markdown, comments, trailing commas, or prose outside the JSON object.
|
|
"""
|
|
|
|
|
|
JSON_REPAIR_PROMPT = """
|
|
You repair malformed JSON from a report-writing agent.
|
|
Return only one valid JSON object. Preserve the report content as much as possible.
|
|
Do not add commentary, markdown fences, comments, or trailing commas.
|
|
If a string contains unescaped quotation marks, replace the inner quotation marks with apostrophes.
|
|
The JSON object must use keys: company, summary, trend_digest, content_ideas, memory_notes.
|
|
"""
|
|
|
|
|
|
def _coerce_list(value: object) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, str):
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
if isinstance(value, list):
|
|
return [str(item).strip() for item in value if str(item).strip()]
|
|
return [str(value).strip()] if str(value).strip() else []
|
|
|
|
|
|
async def plan_queries_with_llm(context: CompanyContext, settings: Settings) -> SearchQueryPlan:
|
|
"""Ask the model to decide source searches from the active user context."""
|
|
model = build_model(settings)
|
|
agent = make_agent(model, QUERY_PLANNER_PROMPT, "query_planner")
|
|
request = {
|
|
"context": context.to_dict(),
|
|
"requirements": [
|
|
"Choose Hacker News queries for developer demand.",
|
|
"Choose DEV.to article queries and tags for supply/saturation checks.",
|
|
"Use the company, product/category, audience, and seed keywords from context.",
|
|
"Return only source-search planning JSON; do not write the report.",
|
|
],
|
|
}
|
|
payload = parse_json_object(await run_agent(agent, json.dumps(request, ensure_ascii=False)))
|
|
plan = SearchQueryPlan(
|
|
rationale=str(payload.get("rationale") or "LLM-selected source search plan."),
|
|
hn_queries=payload.get("hn_queries", []),
|
|
dev_queries=payload.get("dev_queries", []),
|
|
dev_tags=payload.get("dev_tags", []),
|
|
)
|
|
if not plan.hn_queries:
|
|
raise RuntimeError("The LLM query planner did not choose Hacker News queries.")
|
|
if not plan.dev_queries and not plan.dev_tags:
|
|
raise RuntimeError("The LLM query planner did not choose DEV.to queries or tags.")
|
|
return plan
|
|
|
|
|
|
def memory_user_id(context: CompanyContext) -> str:
|
|
slug = "".join(
|
|
char.lower() if char.isalnum() else "-"
|
|
for char in context.company_name.strip()
|
|
).strip("-")
|
|
return slug or "engineering-content-agent"
|
|
|
|
|
|
def fallback_hn_signals(items: list[HNItem]) -> list[HNDeveloperSignal]:
|
|
signals: list[HNDeveloperSignal] = []
|
|
signal_by_topic: dict[str, HNDeveloperSignal] = {}
|
|
for item in items[:16]:
|
|
title = item.title.strip()
|
|
text = (item.text or title).strip()
|
|
topic = _topic_from_hn_item(item)
|
|
if not topic:
|
|
continue
|
|
score = min(100, max(25, (item.points or 0) // 2 + (item.num_comments or 0)))
|
|
existing = signal_by_topic.get(topic.lower())
|
|
if existing is not None:
|
|
existing.intensity_score = max(existing.intensity_score, score)
|
|
if item.url not in existing.source_urls:
|
|
existing.source_urls.append(item.url)
|
|
continue
|
|
signal = HNDeveloperSignal(
|
|
topic=topic,
|
|
pain_point=text[:220],
|
|
developer_question=f"What should developers know about {topic[:80]}?",
|
|
evidence_summary=(
|
|
f"HN {item.source_type} discusses {topic} with "
|
|
f"{item.points or 0} points and {item.num_comments or 0} comments."
|
|
),
|
|
intensity_score=score,
|
|
source_urls=[item.url],
|
|
)
|
|
signal_by_topic[topic.lower()] = signal
|
|
signals.append(signal)
|
|
if len(signals) >= 8:
|
|
break
|
|
return signals
|
|
|
|
|
|
def _topic_from_hn_item(item: HNItem) -> str | None:
|
|
haystack = f"{item.title} {item.text or ''}".lower()
|
|
topic_rules = [
|
|
(
|
|
("core web vitals", "pagespeed", "page speed", "lcp", "inp", "cls", "webperf"),
|
|
"Core Web Vitals debugging and performance monitoring",
|
|
),
|
|
(
|
|
("real-user monitoring", "rum", "user monitoring", "browser monitoring", "session replay"),
|
|
"Real-user monitoring for production web apps",
|
|
),
|
|
(
|
|
("api latency", "slow api", "gateway", "packet", "request latency", "timeout"),
|
|
"Debugging API latency from user sessions",
|
|
),
|
|
(
|
|
("network debugging", "network latency", "networking", "isp", "packet loss", "gateway"),
|
|
"Network debugging for frontend and backend teams",
|
|
),
|
|
(
|
|
("error rate", "error rates", "crash", "exception", "incident", "outage"),
|
|
"Production error-rate monitoring and incident debugging",
|
|
),
|
|
(
|
|
("web analytics", "post analytics", "custom events", "utm", "conversion", "funnel"),
|
|
"Developer-friendly web analytics and event tracking",
|
|
),
|
|
(
|
|
("observability", "monitoring", "tracing", "logs", "metrics"),
|
|
"Observability workflows for developer teams",
|
|
),
|
|
]
|
|
for needles, topic in topic_rules:
|
|
if any(needle in haystack for needle in needles):
|
|
return topic
|
|
return _synth_topic_from_text(item.title, item.text or "")
|
|
|
|
|
|
def _clean_topic_fragment(text: str) -> str:
|
|
cleaned = re.sub(r"<[^>]+>", " ", text)
|
|
cleaned = re.sub(r"https?://\\S+", " ", cleaned)
|
|
cleaned = re.sub(r"[^a-zA-Z0-9+.#/ -]+", " ", cleaned)
|
|
words = [
|
|
word.strip(" -_/")
|
|
for word in cleaned.split()
|
|
if word.strip(" -_/") and word.lower().strip(" -_/") not in TOPIC_STOPWORDS
|
|
]
|
|
fragment = " ".join(words[:8]).strip()
|
|
return fragment[:90].strip()
|
|
|
|
|
|
def _synth_topic_from_text(title: str, text: str = "") -> str | None:
|
|
fragment = _clean_topic_fragment(title)
|
|
if not fragment:
|
|
fragment = _clean_topic_fragment(text)
|
|
if not fragment:
|
|
return None
|
|
lowered = f"{title} {text}".lower()
|
|
if any(term in lowered for term in ("debug", "latency", "slow", "error", "failure", "incident")):
|
|
return f"Debugging {fragment}"
|
|
if any(term in lowered for term in ("migrate", "migration", "upgrade")):
|
|
return f"Migration lessons for {fragment}"
|
|
if any(term in lowered for term in ("architecture", "design", "scale", "scaling")):
|
|
return f"Architecture tradeoffs for {fragment}"
|
|
if any(term in lowered for term in ("benchmark", "cost", "performance", "throughput", "evaluation")):
|
|
return f"Evaluating {fragment}"
|
|
return fragment
|
|
|
|
|
|
def fallback_dev_signals(articles: list[DEVArticle], product: str) -> list[DEVSupplySignal]:
|
|
if not articles:
|
|
return [
|
|
DEVSupplySignal(
|
|
topic=product,
|
|
common_angles=[],
|
|
saturated_angles=["Introductory overviews without implementation evidence"],
|
|
missing_angles=["Production architecture", "debugging workflows", "comparison criteria"],
|
|
supply_quality_score=25,
|
|
source_urls=[],
|
|
)
|
|
]
|
|
|
|
grouped = articles[:10]
|
|
common = [article.title for article in grouped[:5]]
|
|
urls = [article.url for article in grouped if article.url][:5]
|
|
return [
|
|
DEVSupplySignal(
|
|
topic=product,
|
|
common_angles=common,
|
|
saturated_angles=[
|
|
"Beginner tutorials",
|
|
"High-level explainers",
|
|
"Tool announcements without tradeoffs",
|
|
],
|
|
missing_angles=[
|
|
"Operational debugging details",
|
|
"Architecture tradeoffs",
|
|
"Evaluation criteria and failure modes",
|
|
],
|
|
supply_quality_score=min(
|
|
100,
|
|
35 + sum(article.positive_reactions_count or 0 for article in grouped) // 20,
|
|
),
|
|
source_urls=urls,
|
|
)
|
|
]
|
|
|
|
|
|
def memory_novelty_score(topic: str, memories: list[MemoryRecord]) -> int:
|
|
topic_terms = {term for term in topic.lower().split() if len(term) > 3}
|
|
if not topic_terms or not memories:
|
|
return 100
|
|
best_overlap = 0
|
|
for memory in memories:
|
|
memory_terms = set(memory.content.lower().split())
|
|
best_overlap = max(best_overlap, len(topic_terms & memory_terms))
|
|
return max(20, 100 - best_overlap * 18)
|
|
|
|
|
|
def _bounded_score(value: int | float) -> int:
|
|
return max(0, min(100, round(value)))
|
|
|
|
|
|
def _asset_type_for_topic(topic: str) -> str:
|
|
lowered = topic.lower()
|
|
if any(term in lowered for term in ("conference", "keynote", "meetup", "talk", "panel")):
|
|
return "talk"
|
|
if any(term in lowered for term in (" vs ", "versus", "alternative", "compare", "comparison", "architecture", "architect", "design", "coordination", "system")):
|
|
return "blog"
|
|
if any(term in lowered for term in ("debug", "error", "failure", "fix", "troubleshoot", "migrate", "migration", "upgrade", "integrate", "integration", "api", "webhook", "sdk", "tutorial", "how to", "build", "implement", "example")):
|
|
return "tutorial"
|
|
if any(term in lowered for term in ("landing", "pain", "problem", "evaluate")):
|
|
return "blog"
|
|
return "blog"
|
|
|
|
|
|
def _search_intent_for_asset(asset_type: str) -> str:
|
|
if asset_type == "talk":
|
|
return "conference narrative"
|
|
if asset_type == "tutorial":
|
|
return "implementation"
|
|
return "technical research"
|
|
|
|
|
|
def _business_intent_score(topic: str, asset_type: str) -> int:
|
|
lowered = f"{topic} {asset_type}".lower()
|
|
score = 55
|
|
if any(term in lowered for term in ("vs", "versus", "alternative", "compare", "pricing", "evaluate")):
|
|
score += 25
|
|
if any(term in lowered for term in ("debug", "error", "failure", "migrate", "integration", "production")):
|
|
score += 15
|
|
if any(term in lowered for term in ("tutorial", "guide", "article")):
|
|
score += 5
|
|
return _bounded_score(score)
|
|
|
|
|
|
_ASSET_PREFIX = {
|
|
"tutorial": "How to",
|
|
"blog": "Understanding",
|
|
"talk": "Talk:",
|
|
}
|
|
|
|
|
|
def _brief_page_title(topic: str, asset_type: str) -> str:
|
|
normalized = " ".join(topic.split())
|
|
lowered = normalized.lower()
|
|
if lowered == "production vector search with postgres and pgvector":
|
|
return "How to run production vector search with Postgres and pgvector"
|
|
if lowered == "debugging rag retrieval quality and relevance":
|
|
return "How to debug RAG retrieval quality and relevance"
|
|
if lowered == "indexing and embedding performance for vector search":
|
|
return "How to tune indexing and embedding performance for vector search"
|
|
if lowered == "hybrid search and reranking architecture":
|
|
return "How to design hybrid search and reranking architecture"
|
|
if lowered == "debugging vector query latency in production":
|
|
return "How to debug vector query latency in production"
|
|
if lowered == "speculative decoding and draft-model evaluation for llm serving":
|
|
return "How to evaluate draft models for speculative decoding"
|
|
if lowered == "production llm serving latency and throughput tuning":
|
|
return "How to tune latency and throughput for production LLM serving"
|
|
if lowered == "batching and kv-cache tuning for production inference":
|
|
return "How to tune batching and KV cache for production inference"
|
|
if lowered == "inference cost and throughput benchmarking":
|
|
return "How to benchmark inference cost and throughput"
|
|
if lowered == "production deployment and evaluation for faster llm serving":
|
|
return "Production deployment checklist for faster LLM serving"
|
|
if lowered == "migration playbooks for production ai retrieval systems":
|
|
return "Migration playbooks for production AI retrieval systems"
|
|
if lowered == "production debugging for ai retrieval systems":
|
|
return "Production debugging for AI retrieval systems"
|
|
if lowered == "production error-rate monitoring and incident debugging":
|
|
return "How to monitor production error rates and debug incidents"
|
|
if lowered == "core web vitals debugging and performance monitoring":
|
|
return "How to debug Core Web Vitals with production monitoring"
|
|
if lowered == "debugging api latency from user sessions":
|
|
return "How to debug API latency from user sessions"
|
|
if lowered == "network debugging for frontend and backend teams":
|
|
return "How frontend and backend teams debug network issues"
|
|
if lowered == "real-user monitoring for production web apps":
|
|
return "How to use real-user monitoring in production web apps"
|
|
if lowered == "developer-friendly web analytics and event tracking":
|
|
return "Developer-friendly web analytics and event tracking"
|
|
if lowered == "observability workflows for developer teams":
|
|
return "Understanding observability workflows for developer teams"
|
|
|
|
if asset_type == "tutorial":
|
|
gerund_rewrites = {
|
|
"debugging ": "debug ",
|
|
"monitoring ": "monitor ",
|
|
"building ": "build ",
|
|
"using ": "use ",
|
|
"implementing ": "implement ",
|
|
"integrating ": "integrate ",
|
|
}
|
|
for prefix, replacement in gerund_rewrites.items():
|
|
if lowered.startswith(prefix):
|
|
return f"How to {replacement}{normalized[len(prefix):]}"
|
|
prefix = _ASSET_PREFIX.get(asset_type, "")
|
|
return f"{prefix} {normalized}".strip() if prefix else normalized
|
|
|
|
|
|
def _short_audience(audience: str) -> str:
|
|
"""Extract just the role/noun from an audience string, truncating at qualifiers."""
|
|
for sep in (" who ", " that ", " caring ", " interested "):
|
|
idx = audience.lower().find(sep)
|
|
if idx > 0:
|
|
return audience[:idx].strip()
|
|
words = audience.split()
|
|
if len(words) > 5:
|
|
return " ".join(words[:5])
|
|
return audience
|
|
|
|
|
|
TOPIC_STOPWORDS = {
|
|
"about",
|
|
"after",
|
|
"angle",
|
|
"article",
|
|
"backend",
|
|
"blog",
|
|
"build",
|
|
"core",
|
|
"care",
|
|
"debug",
|
|
"debugging",
|
|
"developer",
|
|
"developers",
|
|
"engineer",
|
|
"frontend",
|
|
"from",
|
|
"guide",
|
|
"monitor",
|
|
"monitoring",
|
|
"production",
|
|
"should",
|
|
"service",
|
|
"services",
|
|
"teams",
|
|
"technical",
|
|
"tool",
|
|
"tools",
|
|
"tutorial",
|
|
"understanding",
|
|
"user",
|
|
"users",
|
|
"what",
|
|
"with",
|
|
}
|
|
|
|
FOREIGN_TOPIC_CLUSTERS = (
|
|
{"pgvector", "postgres", "postgresql", "vector", "vectors", "rag", "retrieval", "embedding", "embeddings", "hybrid", "reranking"},
|
|
{"vitals", "lcp", "inp", "cls"},
|
|
{"kubernetes", "k8s", "container", "containers"},
|
|
)
|
|
|
|
|
|
def _topic_terms(text: str) -> set[str]:
|
|
terms: set[str] = set()
|
|
for term in re.findall(r"[a-z0-9]+", text.lower()):
|
|
if len(term) <= 3 or term in TOPIC_STOPWORDS:
|
|
continue
|
|
terms.add(term)
|
|
if term.endswith("s") and len(term) > 4:
|
|
terms.add(term[:-1])
|
|
return terms
|
|
|
|
|
|
def _topic_phrases(text: str) -> set[str]:
|
|
words = [
|
|
term
|
|
for term in re.findall(r"[a-z0-9]+", text.lower())
|
|
if len(term) > 2 and term not in TOPIC_STOPWORDS
|
|
]
|
|
phrases = set()
|
|
for width in (2, 3):
|
|
for index in range(max(0, len(words) - width + 1)):
|
|
phrases.add(" ".join(words[index : index + width]))
|
|
return phrases
|
|
|
|
|
|
def _dev_article_relevance(topic: str, article: DEVArticle) -> int:
|
|
terms = _topic_terms(topic)
|
|
phrases = _topic_phrases(topic)
|
|
if not terms and not phrases:
|
|
return 0
|
|
title = article.title.lower()
|
|
description = (article.description or "").lower()
|
|
body = (article.body_excerpt or "").lower()
|
|
tags = " ".join(article.tags).lower()
|
|
haystack = f"{title} {description} {body} {tags}"
|
|
|
|
score = 0
|
|
for phrase in phrases:
|
|
if phrase in title:
|
|
score += 8
|
|
elif phrase in description:
|
|
score += 5
|
|
elif phrase in body:
|
|
score += 3
|
|
for term in terms:
|
|
if term in title:
|
|
score += 5
|
|
if term in tags:
|
|
score += 4
|
|
if term in description:
|
|
score += 2
|
|
if term in body:
|
|
score += 1
|
|
if score and any(term in haystack for term in ("beginner", "intro", "getting started")):
|
|
score -= 1
|
|
return max(score, 0)
|
|
|
|
|
|
def _topic_dev_links(
|
|
topic: str,
|
|
articles: list[DEVArticle],
|
|
*,
|
|
used_urls: set[str] | None = None,
|
|
context_terms: set[str] | None = None,
|
|
limit: int = 4,
|
|
) -> list[str]:
|
|
used_urls = used_urls or set()
|
|
scored = [
|
|
(_dev_article_relevance(topic, article), article)
|
|
for article in articles
|
|
if article.url
|
|
and article.url not in used_urls
|
|
and (
|
|
not context_terms
|
|
or (_topic_terms(f"{article.title} {article.description or ''} {' '.join(article.tags)} {article.body_excerpt or ''}") & context_terms)
|
|
)
|
|
]
|
|
matched = [
|
|
article
|
|
for score, article in sorted(scored, key=lambda item: item[0], reverse=True)
|
|
if score >= 5
|
|
]
|
|
return [article.url for article in matched[:limit]]
|
|
|
|
|
|
def _hn_item_relevance(topic: str, item: HNItem) -> int:
|
|
terms = _topic_terms(topic)
|
|
phrases = _topic_phrases(topic)
|
|
title = item.title.lower()
|
|
text = (item.text or "").lower()
|
|
return _text_relevance_score(terms, phrases, f"{title} {text}")
|
|
|
|
|
|
def _text_relevance_score(terms: set[str], phrases: set[str], text: str) -> int:
|
|
score = 0
|
|
for phrase in phrases:
|
|
if phrase in text:
|
|
score += 8
|
|
for term in terms:
|
|
if term in text:
|
|
score += 5
|
|
return score
|
|
|
|
|
|
def _hn_item_matches_topic(topic: str, item: HNItem, context_terms: set[str] | None) -> bool:
|
|
terms = _topic_terms(topic)
|
|
phrases = _topic_phrases(topic)
|
|
if not terms and not phrases:
|
|
return False
|
|
|
|
title_terms = _topic_terms(item.title)
|
|
combined_terms = _topic_terms(f"{item.title} {item.text or ''}")
|
|
if context_terms and not (combined_terms & context_terms):
|
|
return False
|
|
|
|
title_score = _text_relevance_score(terms, phrases, item.title.lower())
|
|
text_score = _text_relevance_score(terms, phrases, (item.text or "").lower())
|
|
title_lower = item.title.lower()
|
|
is_discussion = title_lower.startswith(("ask hn:", "tell hn:"))
|
|
if title_score >= 8:
|
|
return True
|
|
if is_discussion and text_score >= 12:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _topic_hn_links(
|
|
topic: str,
|
|
hn_items: list[HNItem],
|
|
*,
|
|
context_terms: set[str] | None = None,
|
|
limit: int = 4,
|
|
) -> list[str]:
|
|
scored = [
|
|
(_hn_item_relevance(topic, item), item)
|
|
for item in hn_items
|
|
if item.url
|
|
and _hn_item_matches_topic(topic, item, context_terms)
|
|
]
|
|
matched = [
|
|
item
|
|
for score, item in sorted(scored, key=lambda item: item[0], reverse=True)
|
|
if score >= (8 if context_terms else 5)
|
|
]
|
|
return [item.url for item in matched[:limit]]
|
|
|
|
|
|
def _repair_report_links(
|
|
report: IdeationReport,
|
|
hn_items: list[HNItem],
|
|
dev_articles: list[DEVArticle],
|
|
context: CompanyContext | None = None,
|
|
) -> None:
|
|
context_terms = _context_relevance_terms(context) if context else set()
|
|
for idea in report.content_ideas:
|
|
idea.hn_evidence = _topic_hn_links(idea.title, hn_items, context_terms=context_terms)
|
|
for trend in report.trend_digest:
|
|
trend.hn_links = _topic_hn_links(
|
|
trend.topic,
|
|
hn_items,
|
|
context_terms=context_terms,
|
|
)
|
|
|
|
_repair_report_dev_links(report, dev_articles, context_terms=context_terms)
|
|
|
|
|
|
def _repair_report_dev_links(
|
|
report: IdeationReport,
|
|
dev_articles: list[DEVArticle],
|
|
*,
|
|
context_terms: set[str] | None = None,
|
|
) -> None:
|
|
if not dev_articles:
|
|
return
|
|
used_urls: set[str] = set()
|
|
for idea in report.content_ideas:
|
|
topic = f"{idea.title} {idea.angle} {idea.dev_gap} {' '.join(idea.outline[:3])}"
|
|
links = _topic_dev_links(
|
|
topic,
|
|
dev_articles,
|
|
used_urls=used_urls,
|
|
context_terms=context_terms,
|
|
limit=3,
|
|
)
|
|
if links:
|
|
idea.dev_links = links
|
|
used_urls.update(links)
|
|
else:
|
|
idea.dev_links = []
|
|
if not idea.dev_gap or "operational debugging details" in idea.dev_gap.lower():
|
|
idea.dev_gap = "No specific DEV.to article matched this idea closely; treat this as a potential supply gap to validate."
|
|
|
|
for trend in report.trend_digest:
|
|
links = _topic_dev_links(
|
|
f"{trend.topic} {trend.why_trending} {trend.dev_saturation}",
|
|
dev_articles,
|
|
context_terms=context_terms,
|
|
limit=3,
|
|
)
|
|
trend.dev_links = links
|
|
if not links:
|
|
trend.dev_saturation = (
|
|
trend.dev_saturation
|
|
or "No specific DEV.to article matched this trend closely."
|
|
)
|
|
|
|
|
|
def _dev_links_for_gap(
|
|
gap: ContentGap,
|
|
dev_signals: list[DEVSupplySignal],
|
|
dev_articles: list[DEVArticle] | None = None,
|
|
used_urls: set[str] | None = None,
|
|
) -> list[str]:
|
|
if dev_articles:
|
|
article_links = _topic_dev_links(
|
|
f"{gap.topic} {gap.supply_gap} {gap.why_it_matters}",
|
|
dev_articles,
|
|
used_urls=used_urls,
|
|
)
|
|
if article_links:
|
|
return article_links
|
|
topic_terms = {term for term in gap.topic.lower().split() if len(term) > 3}
|
|
for signal in dev_signals:
|
|
signal_terms = set(signal.topic.lower().split())
|
|
if topic_terms & signal_terms and signal.source_urls:
|
|
return signal.source_urls[:4]
|
|
return []
|
|
|
|
|
|
def fallback_content_gaps(
|
|
hn_signals: list[HNDeveloperSignal],
|
|
dev_signals: list[DEVSupplySignal],
|
|
memories: list[MemoryRecord],
|
|
seed_keywords: list[str] | None = None,
|
|
) -> list[ContentGap]:
|
|
supply = dev_signals[0] if dev_signals else None
|
|
gaps: list[ContentGap] = []
|
|
for signal in hn_signals[:8]:
|
|
asset_type = _asset_type_for_topic(signal.topic)
|
|
novelty = memory_novelty_score(signal.topic, memories)
|
|
supply_quality = supply.supply_quality_score if supply else 35
|
|
gap_score = max(10, 100 - supply_quality)
|
|
business_score = _business_intent_score(signal.topic, asset_type)
|
|
total = (
|
|
signal.intensity_score * 0.35
|
|
+ gap_score * 0.30
|
|
+ novelty * 0.15
|
|
+ business_score * 0.20
|
|
)
|
|
gaps.append(
|
|
ContentGap(
|
|
topic=signal.topic,
|
|
demand_score=signal.intensity_score,
|
|
supply_gap_score=gap_score,
|
|
memory_novelty_score=novelty,
|
|
business_intent_score=business_score,
|
|
total_score=_bounded_score(total),
|
|
why_it_matters=signal.evidence_summary,
|
|
demand_evidence=signal.source_urls,
|
|
supply_gap=(
|
|
"; ".join(supply.missing_angles)
|
|
if supply and supply.missing_angles
|
|
else "Existing DEV.to articles do not cover the operational angle in depth."
|
|
),
|
|
recommended_asset_type=asset_type,
|
|
search_intent=_search_intent_for_asset(asset_type),
|
|
)
|
|
)
|
|
|
|
if seed_keywords and len(gaps) < 5:
|
|
supply_quality = supply.supply_quality_score if supply else 35
|
|
supply_gap_text = (
|
|
"; ".join(supply.missing_angles)
|
|
if supply and supply.missing_angles
|
|
else "Existing DEV.to articles do not cover the operational angle in depth."
|
|
)
|
|
existing_topics = {gap.topic.lower() for gap in gaps}
|
|
for keyword in seed_keywords:
|
|
synthetic_topic = _topic_from_seed_keyword(keyword)
|
|
if synthetic_topic.lower() in existing_topics:
|
|
continue
|
|
asset_type = _asset_type_for_topic(synthetic_topic)
|
|
novelty = memory_novelty_score(synthetic_topic, memories)
|
|
gap_score = max(10, 100 - supply_quality)
|
|
business_score = _business_intent_score(synthetic_topic, asset_type)
|
|
total = (
|
|
50 * 0.35
|
|
+ gap_score * 0.30
|
|
+ novelty * 0.15
|
|
+ business_score * 0.20
|
|
)
|
|
gaps.append(
|
|
ContentGap(
|
|
topic=synthetic_topic,
|
|
demand_score=50,
|
|
supply_gap_score=gap_score,
|
|
memory_novelty_score=novelty,
|
|
business_intent_score=business_score,
|
|
total_score=_bounded_score(total),
|
|
why_it_matters=f"Seed keyword '{keyword}' indicates audience interest; DEV.to supply has gaps.",
|
|
demand_evidence=[],
|
|
supply_gap=supply_gap_text,
|
|
recommended_asset_type=asset_type,
|
|
search_intent=_search_intent_for_asset(asset_type),
|
|
)
|
|
)
|
|
existing_topics.add(synthetic_topic.lower())
|
|
if len(gaps) >= 8:
|
|
break
|
|
|
|
return sorted(gaps, key=lambda gap: gap.total_score, reverse=True)
|
|
|
|
|
|
def _topic_from_seed_keyword(keyword: str) -> str:
|
|
lowered = keyword.lower().strip()
|
|
cleaned = _clean_topic_fragment(lowered)
|
|
if not cleaned:
|
|
return "Production implementation guide"
|
|
if any(term in lowered for term in ("debug", "latency", "error", "failure", "incident", "timeout")):
|
|
return f"Debugging {cleaned} in production"
|
|
if any(term in lowered for term in ("migration", "migrate", "upgrade")):
|
|
return f"Migration playbook for {cleaned}"
|
|
if any(term in lowered for term in ("compare", "comparison", "alternative", "versus", " vs ")):
|
|
return f"Evaluating {cleaned} alternatives"
|
|
if any(term in lowered for term in ("architecture", "integration", "deploy", "scaling", "scale")):
|
|
return f"Production {cleaned} architecture"
|
|
if any(term in lowered for term in ("cost", "benchmark", "evaluation", "eval", "tradeoff", "throughput")):
|
|
return f"Evaluating {cleaned} tradeoffs"
|
|
return f"Production {cleaned} guide"
|
|
|
|
|
|
def _dev_saturation_text(dev_signals: list[DEVSupplySignal]) -> str:
|
|
if not dev_signals:
|
|
return "DEV.to supply unclear in this run."
|
|
supply = dev_signals[0]
|
|
if supply.saturated_angles:
|
|
return "; ".join(supply.saturated_angles[:3])
|
|
if supply.common_angles:
|
|
return f"Common angles: {', '.join(supply.common_angles[:3])}"
|
|
return "Limited DEV.to coverage found."
|
|
|
|
|
|
def fallback_ideation_report(
|
|
context: CompanyContext,
|
|
hn_signals: list[HNDeveloperSignal],
|
|
gaps: list[ContentGap],
|
|
dev_signals: list[DEVSupplySignal],
|
|
dev_articles: list[DEVArticle] | None = None,
|
|
) -> IdeationReport:
|
|
saturation = _dev_saturation_text(dev_signals)
|
|
dev_articles = dev_articles or []
|
|
context_terms = _context_relevance_terms(context)
|
|
|
|
trend_digest: list[TrendDigestItem] = []
|
|
seen_trends: set[str] = set()
|
|
for signal in hn_signals:
|
|
topic = signal.topic
|
|
if not _is_context_relevant(topic, context_terms):
|
|
continue
|
|
trend_key = topic.strip().lower()
|
|
if trend_key in seen_trends:
|
|
continue
|
|
seen_trends.add(trend_key)
|
|
trend_gap = ContentGap(
|
|
topic=topic,
|
|
demand_score=signal.intensity_score,
|
|
supply_gap_score=50,
|
|
memory_novelty_score=50,
|
|
total_score=signal.intensity_score,
|
|
why_it_matters=signal.evidence_summary,
|
|
supply_gap=saturation,
|
|
recommended_asset_type=_asset_type_for_topic(topic),
|
|
)
|
|
trend_digest.append(
|
|
TrendDigestItem(
|
|
topic=topic,
|
|
why_trending=signal.evidence_summary or signal.pain_point,
|
|
intensity_score=signal.intensity_score,
|
|
dev_saturation=saturation,
|
|
hn_links=signal.source_urls[:4],
|
|
dev_links=_dev_links_for_gap(trend_gap, dev_signals, dev_articles)
|
|
)
|
|
)
|
|
if len(trend_digest) >= 8:
|
|
break
|
|
|
|
content_ideas: list[ContentIdea] = []
|
|
seen_ideas: set[str] = set()
|
|
used_dev_urls: set[str] = set()
|
|
for gap in gaps:
|
|
topic = gap.topic
|
|
if not _is_context_relevant(topic, context_terms):
|
|
continue
|
|
idea_format = gap.recommended_asset_type or _asset_type_for_topic(topic)
|
|
if idea_format not in {"talk", "blog", "tutorial"}:
|
|
idea_format = _asset_type_for_topic(topic)
|
|
title = _brief_page_title(topic, idea_format)
|
|
idea_key = title.strip().lower()
|
|
if idea_key in seen_ideas:
|
|
continue
|
|
seen_ideas.add(idea_key)
|
|
content_ideas.append(
|
|
ContentIdea(
|
|
title=title,
|
|
format=idea_format,
|
|
angle=(
|
|
f"Lead with a realistic {context.product} implementation and show tradeoffs, "
|
|
"failure modes, and decision criteria instead of a beginner overview."
|
|
),
|
|
outline=[
|
|
"Problem and developer intent",
|
|
"Current approaches and tradeoffs",
|
|
f"Implementation walkthrough with {context.product}",
|
|
"Failure modes and debugging checklist",
|
|
"Decision checklist and next steps",
|
|
],
|
|
score=gap.total_score,
|
|
confidence="High" if gap.total_score >= 70 else "Medium",
|
|
hn_evidence=gap.demand_evidence[:4],
|
|
dev_gap=(
|
|
gap.supply_gap
|
|
if _dev_links_for_gap(gap, dev_signals, dev_articles, used_dev_urls)
|
|
else "No specific DEV.to article matched this idea closely; treat this as a potential supply gap to validate."
|
|
),
|
|
dev_links=[],
|
|
)
|
|
)
|
|
content_ideas[-1].dev_links = _dev_links_for_gap(
|
|
gap,
|
|
dev_signals,
|
|
dev_articles,
|
|
used_dev_urls,
|
|
)
|
|
used_dev_urls.update(content_ideas[-1].dev_links)
|
|
if len(content_ideas) >= 5:
|
|
break
|
|
|
|
if not content_ideas:
|
|
short_audience = _short_audience(context.audience)
|
|
title = f"How {short_audience} should evaluate {context.product}"
|
|
content_ideas.append(
|
|
ContentIdea(
|
|
title=title,
|
|
format="blog",
|
|
angle="Use concrete evaluation criteria and a minimal runnable setup instead of product claims.",
|
|
outline=[
|
|
"Evaluation criteria",
|
|
"Demo setup",
|
|
"Tradeoffs and failure modes",
|
|
],
|
|
score=55,
|
|
confidence="Low",
|
|
hn_evidence=[],
|
|
dev_gap="Supply is unclear; validate with more recent DEV.to searches.",
|
|
dev_links=[],
|
|
)
|
|
)
|
|
|
|
if not trend_digest and hn_signals:
|
|
signal = next(
|
|
(
|
|
candidate
|
|
for candidate in hn_signals
|
|
if _is_context_relevant(candidate.topic, context_terms)
|
|
),
|
|
hn_signals[0],
|
|
)
|
|
trend_digest.append(
|
|
TrendDigestItem(
|
|
topic=signal.topic,
|
|
why_trending=signal.evidence_summary,
|
|
intensity_score=signal.intensity_score,
|
|
dev_saturation=saturation,
|
|
hn_links=signal.source_urls[:4],
|
|
dev_links=_topic_dev_links(signal.topic, dev_articles),
|
|
)
|
|
)
|
|
|
|
strongest = content_ideas[0]
|
|
return IdeationReport(
|
|
company=context.company_name,
|
|
summary=(
|
|
f"The strongest DevRel opportunity is '{strongest.title}' because it combines "
|
|
f"recent developer demand with a DEV.to supply gap around {context.product}."
|
|
),
|
|
trend_digest=trend_digest[:8],
|
|
content_ideas=content_ideas[:5],
|
|
memory_notes=[],
|
|
)
|
|
|
|
|
|
def _context_relevance_terms(context: CompanyContext) -> set[str]:
|
|
terms = _topic_terms(
|
|
" ".join(
|
|
[
|
|
context.company_name,
|
|
context.product,
|
|
context.audience,
|
|
*context.seed_keywords,
|
|
*context.competitors,
|
|
*context.existing_topics,
|
|
]
|
|
)
|
|
)
|
|
return {
|
|
term
|
|
for term in terms
|
|
if term
|
|
not in {
|
|
"backend",
|
|
"engineers",
|
|
"platform",
|
|
"teams",
|
|
"systems",
|
|
"production",
|
|
"engineer",
|
|
"care",
|
|
"service",
|
|
"services",
|
|
}
|
|
}
|
|
|
|
|
|
def _is_context_relevant(topic: str, context_terms: set[str]) -> bool:
|
|
if not context_terms:
|
|
return True
|
|
topic_terms = _topic_terms(topic)
|
|
if _has_foreign_topic_cluster(topic_terms, context_terms):
|
|
return False
|
|
return bool(topic_terms & context_terms)
|
|
|
|
|
|
def _has_foreign_topic_cluster(topic_terms: set[str], context_terms: set[str]) -> bool:
|
|
for cluster in FOREIGN_TOPIC_CLUSTERS:
|
|
if topic_terms & cluster and not context_terms & cluster:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _idea_context_text(idea: ContentIdea) -> str:
|
|
return " ".join([idea.title, idea.angle, idea.dev_gap, *idea.outline])
|
|
|
|
|
|
def _sanitize_report_for_context(
|
|
report: IdeationReport,
|
|
context: CompanyContext,
|
|
) -> IdeationReport:
|
|
context_terms = _context_relevance_terms(context)
|
|
if not context_terms:
|
|
return report
|
|
|
|
report.content_ideas = [
|
|
idea
|
|
for idea in report.content_ideas
|
|
if _is_context_relevant(_idea_context_text(idea), context_terms)
|
|
]
|
|
report.trend_digest = [
|
|
trend
|
|
for trend in report.trend_digest
|
|
if _is_context_relevant(f"{trend.topic} {trend.why_trending}", context_terms)
|
|
]
|
|
if not report.content_ideas or not report.trend_digest:
|
|
return report
|
|
return report
|
|
|
|
|
|
def _augment_report_missing_ideas(
|
|
report: IdeationReport,
|
|
context: CompanyContext,
|
|
content_gaps: list[ContentGap],
|
|
dev_signals: list[DEVSupplySignal],
|
|
dev_articles: list[DEVArticle],
|
|
target_count: int = 5,
|
|
) -> IdeationReport:
|
|
report = _sanitize_report_for_context(report, context)
|
|
if len(report.content_ideas) >= target_count:
|
|
_repair_report_dev_links(
|
|
report,
|
|
dev_articles,
|
|
context_terms=_context_relevance_terms(context),
|
|
)
|
|
return report
|
|
|
|
seed_gaps = fallback_content_gaps(
|
|
[],
|
|
dev_signals,
|
|
[],
|
|
seed_keywords=context.seed_keywords,
|
|
)
|
|
seen_gap_topics = {gap.topic.strip().lower() for gap in content_gaps}
|
|
merged_gaps = list(content_gaps)
|
|
for gap in seed_gaps:
|
|
key = gap.topic.strip().lower()
|
|
if key in seen_gap_topics:
|
|
continue
|
|
merged_gaps.append(gap)
|
|
seen_gap_topics.add(key)
|
|
|
|
fallback = fallback_ideation_report(
|
|
context,
|
|
[],
|
|
merged_gaps,
|
|
dev_signals,
|
|
dev_articles,
|
|
)
|
|
seen = {idea.title.strip().lower() for idea in report.content_ideas}
|
|
for idea in fallback.content_ideas:
|
|
key = idea.title.strip().lower()
|
|
if key in seen:
|
|
continue
|
|
report.content_ideas.append(idea)
|
|
seen.add(key)
|
|
if len(report.content_ideas) >= target_count:
|
|
break
|
|
|
|
_repair_report_dev_links(
|
|
report,
|
|
dev_articles,
|
|
context_terms=_context_relevance_terms(context),
|
|
)
|
|
return report
|
|
|
|
|
|
def _coerce_report_payload(report_raw: object) -> dict[str, Any]:
|
|
if report_raw is None:
|
|
return {}
|
|
if isinstance(report_raw, dict):
|
|
return report_raw
|
|
if hasattr(report_raw, "model_dump"):
|
|
dumped = report_raw.model_dump()
|
|
if isinstance(dumped, dict):
|
|
return dumped
|
|
if hasattr(report_raw, "to_dict"):
|
|
dumped = report_raw.to_dict()
|
|
if isinstance(dumped, dict):
|
|
return dumped
|
|
return {}
|
|
|
|
|
|
def _filter_dataclass_fields(cls, payload: object) -> dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
return {}
|
|
allowed = {field.name for field in dataclasses.fields(cls)}
|
|
return {key: value for key, value in payload.items() if key in allowed}
|
|
|
|
|
|
def _normalize_report_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
if isinstance(payload.get("report"), dict):
|
|
payload = payload["report"]
|
|
normalized = dict(payload)
|
|
trend_items = (
|
|
normalized.get("trend_digest")
|
|
or normalized.get("trends")
|
|
or normalized.get("developer_trends")
|
|
or normalized.get("trend_digest_items")
|
|
or []
|
|
)
|
|
idea_items = (
|
|
normalized.get("content_ideas")
|
|
or normalized.get("ideas")
|
|
or normalized.get("recommendations")
|
|
or normalized.get("talk_blog_ideas")
|
|
or normalized.get("ranked_ideas")
|
|
or []
|
|
)
|
|
normalized["trend_digest"] = [
|
|
{
|
|
"topic": str(item.get("topic") or item.get("title") or "Developer trend").strip(),
|
|
"why_trending": str(item.get("why_trending") or item.get("summary") or item.get("reason") or "").strip(),
|
|
"intensity_score": item.get("intensity_score") or item.get("score") or 50,
|
|
"dev_saturation": str(item.get("dev_saturation") or item.get("dev_gap") or "").strip(),
|
|
"hn_links": item.get("hn_links") or item.get("hn_evidence") or item.get("source_urls") or [],
|
|
"dev_links": item.get("dev_links") or [],
|
|
}
|
|
for item in trend_items
|
|
if isinstance(item, dict)
|
|
]
|
|
normalized["content_ideas"] = [
|
|
{
|
|
"title": str(item.get("title") or item.get("idea") or item.get("topic") or "Untitled idea").strip(),
|
|
"format": str(item.get("format") or item.get("asset_type") or "blog").strip(),
|
|
"angle": str(item.get("angle") or item.get("why_now") or item.get("rationale") or "").strip(),
|
|
"outline": item.get("outline") or item.get("sections") or ["Problem and developer intent", "Technical walkthrough", "Decision criteria"],
|
|
"score": item.get("score") or item.get("priority_score") or 50,
|
|
"confidence": str(item.get("confidence") or "Medium").strip(),
|
|
"hn_evidence": item.get("hn_evidence") or item.get("hn_links") or item.get("source_urls") or [],
|
|
"dev_gap": str(item.get("dev_gap") or item.get("dev_supply_gap") or "").strip(),
|
|
"dev_links": item.get("dev_links") or item.get("dev_supply_links") or [],
|
|
}
|
|
for item in idea_items
|
|
if isinstance(item, dict)
|
|
]
|
|
if not normalized["trend_digest"] and normalized["content_ideas"]:
|
|
normalized["trend_digest"] = [
|
|
{
|
|
"topic": idea["title"],
|
|
"why_trending": idea["angle"] or "Derived from the model's ranked recommendation.",
|
|
"intensity_score": idea["score"],
|
|
"dev_saturation": idea["dev_gap"],
|
|
"hn_links": idea["hn_evidence"],
|
|
"dev_links": idea["dev_links"],
|
|
}
|
|
for idea in normalized["content_ideas"][:3]
|
|
]
|
|
return _filter_dataclass_fields(IdeationReport, normalized)
|
|
|
|
|
|
def _brief_format(asset_type: str) -> str:
|
|
lowered = asset_type.lower()
|
|
if "talk" in lowered or "conference" in lowered or "keynote" in lowered:
|
|
return "talk"
|
|
if "tutorial" in lowered or "guide" in lowered or "how to" in lowered:
|
|
return "tutorial"
|
|
return "blog"
|
|
|
|
|
|
def _legacy_briefs_to_ideation_report(
|
|
payload: dict[str, Any],
|
|
context: CompanyContext,
|
|
hn_signals: list[HNDeveloperSignal],
|
|
dev_signals: list[DEVSupplySignal],
|
|
) -> IdeationReport | None:
|
|
briefs = payload.get("publishing_briefs")
|
|
if not isinstance(briefs, list) or not briefs:
|
|
return None
|
|
|
|
saturation = _dev_saturation_text(dev_signals)
|
|
trend_digest: list[TrendDigestItem] = []
|
|
for signal in hn_signals[:8]:
|
|
trend_digest.append(
|
|
TrendDigestItem(
|
|
topic=signal.topic,
|
|
why_trending=signal.evidence_summary or signal.pain_point,
|
|
intensity_score=signal.intensity_score,
|
|
dev_saturation=saturation,
|
|
hn_links=signal.source_urls[:4],
|
|
dev_links=signal.source_urls[:4],
|
|
)
|
|
)
|
|
|
|
content_ideas: list[ContentIdea] = []
|
|
for brief in briefs[:5]:
|
|
if not isinstance(brief, dict):
|
|
continue
|
|
asset_type = str(brief.get("asset_type") or "blog")
|
|
idea_format = _brief_format(asset_type)
|
|
title = str(brief.get("page_title") or brief.get("suggested_h1") or "Untitled idea").strip()
|
|
outline: list[str] = []
|
|
for section in brief.get("outline") or []:
|
|
if isinstance(section, dict):
|
|
section_title = str(section.get("title") or "").strip()
|
|
if section_title:
|
|
outline.append(section_title)
|
|
elif isinstance(section, str) and section.strip():
|
|
outline.append(section.strip())
|
|
content_ideas.append(
|
|
ContentIdea(
|
|
title=title,
|
|
format=idea_format,
|
|
angle=str(
|
|
brief.get("differentiation_angle")
|
|
or brief.get("priority_rationale")
|
|
or ""
|
|
).strip(),
|
|
outline=outline or ["Problem framing", "Implementation", "Takeaways"],
|
|
score=int(brief.get("score") or 50),
|
|
confidence=str(brief.get("confidence") or "Medium"),
|
|
hn_evidence=_coerce_list(brief.get("hn_demand_evidence")),
|
|
dev_gap=str(brief.get("dev_supply_gap") or ""),
|
|
dev_links=_coerce_list(brief.get("dev_supply_links")),
|
|
)
|
|
)
|
|
|
|
if not content_ideas:
|
|
return None
|
|
|
|
company = str(payload.get("company") or context.company_name or context.product).strip()
|
|
summary = str(payload.get("summary") or "").strip()
|
|
if not summary:
|
|
summary = (
|
|
f"Migrated legacy publishing briefs into {len(content_ideas)} DevRel content ideas "
|
|
f"for {company or context.product}."
|
|
)
|
|
return IdeationReport(
|
|
company=company,
|
|
summary=summary,
|
|
trend_digest=trend_digest,
|
|
content_ideas=content_ideas,
|
|
memory_notes=_coerce_list(payload.get("memory_notes")),
|
|
)
|
|
|
|
|
|
def _parse_ideation_report(
|
|
report_raw: object,
|
|
context: CompanyContext,
|
|
hn_signals: list[HNDeveloperSignal],
|
|
content_gaps: list[ContentGap],
|
|
dev_signals: list[DEVSupplySignal],
|
|
dev_articles: list[DEVArticle] | None = None,
|
|
) -> IdeationReport:
|
|
dev_articles = dev_articles or []
|
|
payload = _coerce_report_payload(report_raw)
|
|
if payload.get("publishing_briefs"):
|
|
migrated = _legacy_briefs_to_ideation_report(payload, context, hn_signals, dev_signals)
|
|
if migrated is not None:
|
|
if not migrated.trend_digest:
|
|
migrated.trend_digest = fallback_ideation_report(
|
|
context, hn_signals, content_gaps, dev_signals, dev_articles
|
|
).trend_digest
|
|
_sanitize_report_for_context(migrated, context)
|
|
_repair_report_dev_links(
|
|
migrated,
|
|
dev_articles,
|
|
context_terms=_context_relevance_terms(context),
|
|
)
|
|
return migrated
|
|
|
|
if isinstance(report_raw, IdeationReport):
|
|
report = report_raw
|
|
else:
|
|
try:
|
|
report = IdeationReport(**_normalize_report_payload(payload))
|
|
except Exception:
|
|
report = fallback_ideation_report(
|
|
context, hn_signals, content_gaps, dev_signals, dev_articles
|
|
)
|
|
|
|
if not report.content_ideas or not report.trend_digest:
|
|
report = fallback_ideation_report(
|
|
context, hn_signals, content_gaps, dev_signals, dev_articles
|
|
)
|
|
if report_needs_repair(report, context):
|
|
report = fallback_ideation_report(
|
|
context, hn_signals, content_gaps, dev_signals, dev_articles
|
|
)
|
|
_sanitize_report_for_context(report, context)
|
|
_repair_report_dev_links(
|
|
report,
|
|
dev_articles,
|
|
context_terms=_context_relevance_terms(context),
|
|
)
|
|
return report
|
|
|
|
|
|
def _parse_llm_report_strict(
|
|
report_raw: object,
|
|
context: CompanyContext,
|
|
dev_articles: list[DEVArticle],
|
|
) -> IdeationReport:
|
|
payload = _coerce_report_payload(report_raw)
|
|
if isinstance(report_raw, IdeationReport):
|
|
report = report_raw
|
|
else:
|
|
try:
|
|
report = IdeationReport(**_normalize_report_payload(payload))
|
|
except Exception as exc:
|
|
raise RuntimeError("The Agno team did not return a valid structured report.") from exc
|
|
|
|
if not report.content_ideas:
|
|
raise RuntimeError("The Agno team returned no content ideas.")
|
|
if not report.trend_digest:
|
|
raise RuntimeError("The Agno team returned no trend digest.")
|
|
if report_needs_repair(report, context):
|
|
raise RuntimeError(
|
|
"The Agno team report failed context or format validation, so I did not replace it with a deterministic fallback."
|
|
)
|
|
_sanitize_report_for_context(report, context)
|
|
if not report.content_ideas or not report.trend_digest:
|
|
raise RuntimeError("The Agno team report did not match the active product context.")
|
|
_repair_report_dev_links(
|
|
report,
|
|
dev_articles,
|
|
context_terms=_context_relevance_terms(context),
|
|
)
|
|
return report
|
|
|
|
|
|
async def write_report_with_llm(
|
|
context: CompanyContext,
|
|
query_plan: SearchQueryPlan,
|
|
hn_items: list[HNItem],
|
|
dev_articles: list[DEVArticle],
|
|
memories: list[MemoryRecord],
|
|
settings: Settings,
|
|
) -> IdeationReport:
|
|
"""Use the model to write the final report from gathered facts when team output is malformed."""
|
|
model = build_model(settings)
|
|
agent = make_agent(
|
|
model,
|
|
IDEATION_WRITER_PROMPT
|
|
+ "\nReturn one JSON object with keys: company, summary, trend_digest, content_ideas, memory_notes.",
|
|
"final_report_writer",
|
|
)
|
|
request = {
|
|
"context": context.to_dict(),
|
|
"query_plan": query_plan.to_dict(),
|
|
"hn_items": [item.to_dict() for item in hn_items[:20]],
|
|
"dev_articles": [article.to_dict() for article in dev_articles[:20]],
|
|
"memory_summaries": [memory.__dict__ for memory in memories[:8]],
|
|
"requirements": [
|
|
"Write exactly 5 ranked talk/blog/tutorial ideas when evidence permits.",
|
|
"Base every idea on the provided HN/DEV/Engram facts.",
|
|
"Do not write a full article.",
|
|
"Do not mention internal agent failures or parsing.",
|
|
],
|
|
}
|
|
raw_report = await run_agent(agent, json.dumps(request, ensure_ascii=False))
|
|
try:
|
|
payload = parse_json_object(raw_report)
|
|
except Exception as parse_exc:
|
|
logger.warning("Final report JSON parse failed; asking GLM to repair JSON: %s", parse_exc)
|
|
repair_agent = make_agent(model, JSON_REPAIR_PROMPT, "json_repair")
|
|
repaired = await run_agent(
|
|
repair_agent,
|
|
json.dumps(
|
|
{
|
|
"error": str(parse_exc),
|
|
"malformed_json": raw_report,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
)
|
|
payload = parse_json_object(repaired)
|
|
report = IdeationReport(**_normalize_report_payload(payload))
|
|
if not report.content_ideas or not report.trend_digest:
|
|
raise RuntimeError("The GLM report writer did not produce enough usable report sections.")
|
|
for idea in report.content_ideas:
|
|
if idea.format not in {"talk", "blog", "tutorial"}:
|
|
idea.format = _brief_format(idea.format)
|
|
if _looks_like_raw_source_fragment(idea.title):
|
|
raise RuntimeError("The GLM report writer returned a raw source fragment as an idea title.")
|
|
for trend in report.trend_digest:
|
|
if _looks_like_raw_source_fragment(trend.topic):
|
|
raise RuntimeError("The GLM report writer returned a raw source fragment as a trend topic.")
|
|
report.content_ideas = report.content_ideas[:5]
|
|
report.trend_digest = report.trend_digest[:8]
|
|
_repair_report_links(
|
|
report,
|
|
hn_items,
|
|
dev_articles,
|
|
context,
|
|
)
|
|
return report
|
|
|
|
|
|
def report_needs_repair(report: IdeationReport, context: CompanyContext) -> bool:
|
|
if not report.content_ideas:
|
|
return True
|
|
context_terms = _context_relevance_terms(context)
|
|
if any(_looks_like_raw_source_fragment(trend.topic) for trend in report.trend_digest[:5]):
|
|
return True
|
|
for idea in report.content_ideas[:5]:
|
|
title = idea.title.strip()
|
|
if _looks_like_raw_source_fragment(title):
|
|
return True
|
|
if context_terms and not _is_context_relevant(_idea_context_text(idea), context_terms):
|
|
return True
|
|
if idea.format not in {"talk", "blog", "tutorial"}:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _looks_like_raw_source_fragment(text: str) -> bool:
|
|
cleaned = text.strip()
|
|
lowered = cleaned.lower()
|
|
if any(marker in lowered for marker in ("<p>", ">", "<", "<a ", "</", "pissing in the ocean")):
|
|
return True
|
|
if cleaned.startswith((">", '"')):
|
|
return True
|
|
if len(cleaned) > 120:
|
|
return True
|
|
return False
|
|
|
|
|
|
async def run_autonomous_content_team(
|
|
context: CompanyContext,
|
|
settings: Settings,
|
|
memory_store: MemoryStore,
|
|
limit: int = 10,
|
|
on_stage: StageCallback | None = None,
|
|
engram_user_id: str | None = None,
|
|
conversation_id: str | None = None,
|
|
) -> AgentRunResult:
|
|
user_id = engram_user_id or memory_user_id(context)
|
|
session_id = conversation_id or user_id
|
|
|
|
await _notify(on_stage, "Query Planner: choosing HN/DEV searches with GLM")
|
|
query_plan = await plan_queries_with_llm(context, settings)
|
|
await _notify(
|
|
on_stage,
|
|
f"Query Planner: selected HN ({_preview_items(query_plan.hn_queries)}) and DEV ({_preview_items(query_plan.dev_queries or query_plan.dev_tags)})",
|
|
)
|
|
|
|
await _notify(
|
|
on_stage,
|
|
"HN, DEV.to, and Engram agents: gathering facts in parallel from GLM-selected searches",
|
|
)
|
|
hn_task = search_hn(query_plan.hn_queries, limit=limit)
|
|
dev_task = search_dev_articles(
|
|
query_plan.dev_queries,
|
|
query_plan.dev_tags,
|
|
limit=limit,
|
|
api_key=settings.dev_api_key,
|
|
)
|
|
memory_task = memory_store.search(
|
|
query=f"{context.company_name} {context.product} {' '.join(context.seed_keywords)}",
|
|
user_id=user_id,
|
|
limit=8,
|
|
)
|
|
hn_items, dev_articles, memories = await asyncio.gather(
|
|
hn_task,
|
|
dev_task,
|
|
memory_task,
|
|
)
|
|
await _notify(on_stage, f"HN Demand Agent: collected {len(hn_items)} recent HN stories/comments")
|
|
await _notify(on_stage, f"DEV Supply Agent: collected {len(dev_articles)} recent DEV.to articles")
|
|
await _notify(on_stage, f"Engram Memory Agent: returned {len(memories)} related memories")
|
|
|
|
await _notify(
|
|
on_stage,
|
|
"DevRel Ideation Writer: reasoning over gathered facts and producing the report",
|
|
)
|
|
report = await write_report_with_llm(
|
|
context,
|
|
query_plan,
|
|
hn_items,
|
|
dev_articles,
|
|
memories,
|
|
settings,
|
|
)
|
|
_repair_report_links(report, hn_items, dev_articles, context)
|
|
hn_signals: list[HNDeveloperSignal] = []
|
|
dev_signals: list[DEVSupplySignal] = []
|
|
content_gaps: list[ContentGap] = []
|
|
team_member_responses = [
|
|
"Query Planner selected HN/DEV searches with GLM.",
|
|
"HN, DEV.to, and Engram evidence were gathered in parallel.",
|
|
"DevRel Ideation Writer produced the final report from gathered facts.",
|
|
]
|
|
|
|
await _notify(on_stage, "Storing research summary in Engram Memory")
|
|
memory_notes = await memory_store.store_research_summary(
|
|
context=context,
|
|
report=report,
|
|
content_gaps=content_gaps,
|
|
user_id=user_id,
|
|
)
|
|
report.memory_notes = memory_notes
|
|
|
|
markdown = render_markdown(report, build_source_title_lookup(hn_items, dev_articles))
|
|
result = AgentRunResult(
|
|
context=context,
|
|
query_plan=query_plan,
|
|
hn_items=hn_items,
|
|
dev_articles=dev_articles,
|
|
hn_signals=hn_signals,
|
|
dev_signals=dev_signals,
|
|
content_gaps=content_gaps,
|
|
report=report,
|
|
markdown=markdown,
|
|
memory_notes=memory_notes,
|
|
team_member_responses=team_member_responses,
|
|
judge_score=None,
|
|
judge_notes=[],
|
|
)
|
|
save_latest_result(result)
|
|
return result
|
|
|
|
|
|
def save_latest_result(result: AgentRunResult) -> Path:
|
|
output_dir = Path(__file__).resolve().parent / "outputs"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
output_path = output_dir / "latest_ideation_report.json"
|
|
output_path.write_text(result.to_json(indent=2), encoding="utf-8")
|
|
return output_path
|
|
|
|
|
|
def build_source_title_lookup(
|
|
hn_items: list[HNItem],
|
|
dev_articles: list[DEVArticle],
|
|
) -> dict[str, str]:
|
|
lookup: dict[str, str] = {}
|
|
for item in hn_items:
|
|
if item.url and item.title:
|
|
lookup[item.url] = item.title
|
|
for article in dev_articles:
|
|
if article.url and article.title:
|
|
lookup[article.url] = article.title
|
|
return lookup
|
|
|
|
|
|
def _format_source_link(url: str, source_titles: dict[str, str] | None = None) -> str:
|
|
source_titles = source_titles or {}
|
|
title = source_titles.get(url, "").strip()
|
|
if not title:
|
|
return url
|
|
safe_title = title.replace("[", "(").replace("]", ")")
|
|
return f"[{safe_title}]({url})"
|
|
|
|
|
|
def render_markdown(
|
|
report: IdeationReport,
|
|
source_titles: dict[str, str] | None = None,
|
|
) -> str:
|
|
lines = [
|
|
f"# Developer Trend & DevRel Ideation Report for {report.company}",
|
|
"",
|
|
"## Summary",
|
|
"",
|
|
report.summary,
|
|
"",
|
|
"## Developer Trend Digest",
|
|
"",
|
|
]
|
|
|
|
if report.trend_digest:
|
|
for index, trend in enumerate(report.trend_digest, 1):
|
|
lines.extend([
|
|
f"### {index}. {trend.topic}",
|
|
"",
|
|
f"Intensity: {trend.intensity_score} | DEV saturation: {trend.dev_saturation}",
|
|
"",
|
|
trend.why_trending,
|
|
"",
|
|
"HN links:",
|
|
"",
|
|
])
|
|
if trend.hn_links:
|
|
lines.extend(
|
|
f"* {_format_source_link(url, source_titles)}"
|
|
for url in trend.hn_links[:4]
|
|
)
|
|
else:
|
|
lines.append("* No strong HN evidence found in this run.")
|
|
lines.append("")
|
|
if trend.dev_links:
|
|
lines.append("DEV.to reference articles:")
|
|
lines.append("")
|
|
lines.extend(
|
|
f"* {_format_source_link(url, source_titles)}"
|
|
for url in trend.dev_links[:4]
|
|
)
|
|
lines.append("")
|
|
else:
|
|
lines.append("DEV.to reference articles:")
|
|
lines.append("")
|
|
lines.append("* No specific DEV.to article matched this trend closely.")
|
|
lines.append("")
|
|
else:
|
|
lines.append("No trend digest items were captured in this run.")
|
|
lines.append("")
|
|
|
|
lines.extend([
|
|
"## Recommended Talk & Blog Ideas",
|
|
"",
|
|
])
|
|
|
|
for index, idea in enumerate(report.content_ideas, 1):
|
|
lines.extend([
|
|
f"### {index}. {idea.title}",
|
|
"",
|
|
(
|
|
f"Score: {idea.score} | Format: {idea.format} "
|
|
f"| Confidence: {idea.confidence}"
|
|
),
|
|
"",
|
|
idea.angle,
|
|
"",
|
|
"HN evidence:",
|
|
"",
|
|
])
|
|
if idea.hn_evidence:
|
|
lines.extend(
|
|
f"* {_format_source_link(url, source_titles)}"
|
|
for url in idea.hn_evidence[:4]
|
|
)
|
|
else:
|
|
lines.append("* No strong HN evidence found in this run.")
|
|
lines.append("")
|
|
if idea.dev_links:
|
|
lines.append("DEV.to reference articles:")
|
|
lines.append("")
|
|
lines.extend(
|
|
f"* {_format_source_link(url, source_titles)}"
|
|
for url in idea.dev_links[:4]
|
|
)
|
|
lines.append("")
|
|
else:
|
|
lines.append("DEV.to reference articles:")
|
|
lines.append("")
|
|
lines.append("* No specific DEV.to article matched this idea closely.")
|
|
lines.append("")
|
|
lines.extend([
|
|
f"DEV supply gap: {idea.dev_gap}",
|
|
"",
|
|
])
|
|
if idea.outline:
|
|
lines.append("Outline:")
|
|
lines.append("")
|
|
lines.extend(f"* {item}" for item in idea.outline[:6])
|
|
lines.append("")
|
|
|
|
return "\n".join(lines).strip() + "\n"
|