""" Developer Trend & DevRel Ideation Agent Chat-first Streamlit app for developer trend digest and DevRel talk/blog ideation from Hacker News, DEV.to, Engram Memory, and an Agno multi-agent team. """ from __future__ import annotations import asyncio import base64 import hashlib import html import json import logging import os import re import time import uuid from pathlib import Path import streamlit as st from agents import run_autonomous_content_team from chat import ( apply_prompt_context, conversational_response, followup_response, has_required_context, missing_fields, render_context_summary, wants_brief_run, wants_current_report_followup, wants_explicit_research_run, wants_memory_lookup, ) from config import DEFAULT_MODEL, get_settings, load_project_env from engram_memory import MemoryRecord, build_research_summary_preview, create_memory_store from llm import build_model, make_agent, parse_json_object, run_agent from models import CompanyContext APP_DIR = Path(__file__).resolve().parent load_project_env() CONTEXT_EXTRACTOR_PROMPT = """ You extract and infer context for a chat-first developer trend and DevRel ideation agent. Return only JSON with keys: company_name, product, audience, seed_keywords, competitors, existing_topics. Use the user's natural language and current context. If the user asks for content ideas, talk/blog ideas, trend research, or DevRel opportunities for a named product/domain, infer reasonable missing product/category, audience, and search angles rather than demanding labels. Do not invent a company if no company, product, or domain is mentioned. """ ROUTER_PROMPT = """ You route messages for a chat-first developer trend and DevRel ideation agent. Return only JSON with keys: intent, requires_fresh_sources, reason. Choose one intent: - memory_lookup: asks what was discussed, researched, generated, remembered, or done before/in the past/lately, or asks for recommendations based on existing/past research. - research_run: asks for fresh/new source research from HN, DEV.to, developer conversations, article supply, trends, topics, or DevRel opportunities. - followup: asks about the current/latest report, evidence, summary, or refinements from the current report. - chat: asks general questions or gives context without requesting research or memory lookup. Important routing rules: - If the user says "based on our research", "last few products", "so far", "what have we researched", or "what should we work on from previous research", choose memory_lookup even if they say suggest/recommend/articles. - If the user asks "show evidence for idea 2", "make these more technical", "summarize the report", or "what did we find", choose followup when has_latest_report is true. - Choose research_run only when the user wants fresh research, new HN/DEV/source collection, or a new trend digest/report. - Do not choose research_run for memory or current-report follow-ups. - requires_fresh_sources must be true only when HN/DEV/source APIs should be called now. It must be false for memory_lookup, followup, and chat. Examples: - "Based on our research of last few products, what could be the top 3 articles we should work on?" -> {"intent":"memory_lookup","requires_fresh_sources":false,"reason":"Uses existing research across past products."} - "What products research have we done so far?" -> {"intent":"memory_lookup","requires_fresh_sources":false,"reason":"Asks about past research."} - "Show evidence for idea 2" -> {"intent":"followup","requires_fresh_sources":false,"reason":"Asks about latest report evidence."} - "Research HN and DEV and suggest blog ideas for this product" -> {"intent":"research_run","requires_fresh_sources":true,"reason":"Asks for fresh source research."} """ MEMORY_SUMMARIZER_PROMPT = """ You answer questions from Engram memories for a developer trend and DevRel ideation agent. Return a concise natural-language answer, not JSON. Rules: - Answer the user's question directly. - Speak like the app assistant, not like a memory debugger. - Do not mention Engram, memory records, "what's known", "what's uncertain", confidence, or retrieval. - Use first-person phrasing like "Yes, we have..." or "I don't see that we've...". - Do not repeat the same sentence or paragraph. - Extract concrete products, companies, trend topics, talk/blog ideas, or reports from memories. - Ignore assistant meta chatter like "the user asked", missing-context reminders, or UI status. - Ignore irrelevant memories that do not answer the question. - Do not call older stored memories "latest", "current", or "recent"; say "previously researched" instead. - If memories are noisy or weak, answer briefly and say what you can infer without sounding forensic. """ APP_NAME = "Developer Trend & DevRel Ideation Agent" APP_WELCOME_VERSION = "devrel-ideation-v2" ROUTER_TIMEOUT_SECONDS = 18 PIPELINE_STEPS: list[tuple[str, str]] = [ ("planner", "Query Planner"), ("research_hn", "HN demand research"), ("research_dev", "DEV.to supply research"), ("research_engram", "Engram memory lookup"), ("writer", "DevRel Ideation Writer"), ("assembly", "Report assembly"), ] STEP_ICON = { "pending": "○", "active": "◉", "done": "✓", "skipped": "–", } st.set_page_config( page_title=APP_NAME, layout="wide", initial_sidebar_state="expanded", ) def _inline_png(path: Path, height: int) -> str: try: encoded = base64.b64encode(path.read_bytes()).decode() except Exception: return "" return ( f"{path.stem}" ) def _png_data_uri(path: Path) -> str: try: encoded = base64.b64encode(path.read_bytes()).decode() except Exception: return "" return f"data:image/png;base64,{encoded}" def _inline_svg(path: Path, height: int) -> str: try: encoded = base64.b64encode(path.read_bytes()).decode() except Exception: return "" return ( f"{path.stem}" ) st.markdown( """ """, unsafe_allow_html=True, ) hackernews_img_inline = _inline_svg(APP_DIR / "assets" / "hacker_news.svg", 40) devto_img_inline = _inline_svg(APP_DIR / "assets" / "devto.svg", 40) title_html = f"""

{APP_NAME} with {hackernews_img_inline}Hacker News and {devto_img_inline}DEV.to

""" st.markdown(title_html, unsafe_allow_html=True) st.caption( "Agno multi-agent research from Hacker News trends, DEV.to supply gaps, " "Engram memory, and GLM report writing." ) def init_state() -> None: settings = get_settings(require_nebius=False) defaults = { "messages": [], "context": { "company_name": "", "product": "", "audience": "", "seed_keywords": [], "competitors": [], "existing_topics": [], }, "limit": 8, "latest_result": None, "stage_messages": [], "pipeline_steps": {step_id: "pending" for step_id, _ in PIPELINE_STEPS}, "memory_store": None, "is_running": False, "conversation_id": f"eca-{uuid.uuid4().hex[:10]}", "engram_user_id": settings.engram_user_id, "stored_context_fingerprint": "", "welcome_version": APP_WELCOME_VERSION, } for key, value in defaults.items(): if key not in st.session_state: st.session_state[key] = value st.session_state.engram_user_id = settings.engram_user_id if st.session_state.get("welcome_version") != APP_WELCOME_VERSION: st.session_state.messages = [] st.session_state.latest_result = None st.session_state.welcome_version = APP_WELCOME_VERSION def reset_pipeline_steps() -> None: st.session_state.pipeline_steps = {step_id: "pending" for step_id, _ in PIPELINE_STEPS} def update_pipeline_from_message(message: str) -> None: lowered = message.lower() steps = st.session_state.pipeline_steps def activate(step_id: str) -> None: for sid, _ in PIPELINE_STEPS: if sid == step_id: steps[sid] = "active" elif steps.get(sid) == "active": steps[sid] = "done" def complete(step_id: str) -> None: steps[step_id] = "done" if "query planner" in lowered: activate("planner") if "selected hn" in lowered: complete("planner") if "hn demand" in lowered or "search_hacker_news" in lowered or "hn fallback" in lowered or "completing hn" in lowered: activate("research_hn") if "returned" in lowered or "collected" in lowered or "finished search_hacker_news" in lowered: complete("research_hn") if "dev supply" in lowered or "search_dev_to" in lowered or "dev.to fallback" in lowered or "completing dev" in lowered: activate("research_dev") if "returned" in lowered or "collected" in lowered or "finished search_dev_to" in lowered: complete("research_dev") if "engram memory" in lowered: activate("research_engram") if "returned" in lowered or "found" in lowered or "no prior memories" in lowered: complete("research_engram") if "devrel ideation writer" in lowered or "ideation writer" in lowered: activate("writer") if "producing the report" in lowered: complete("writer") if "assembling report" in lowered or "storing research summary" in lowered or "research complete" in lowered: activate("assembly") complete("assembly") complete("planner") complete("writer") def render_pipeline_stepper() -> str: lines = ["**Pipeline**"] for step_id, label in PIPELINE_STEPS: state = st.session_state.pipeline_steps.get(step_id, "pending") lines.append(f"{STEP_ICON.get(state, '○')} {label}") return "\n".join(lines) def truncate_judge_note(note: str, limit: int = 320) -> str: cleaned = " ".join(note.split()) if len(cleaned) <= limit: return cleaned return cleaned[: limit - 3].rstrip() + "..." def assistant_prompt_for_missing() -> str: missing = missing_fields(st.session_state.context) if not missing: return ( "I’ve got enough to work with. Ask me to find content ideas or a trend digest, and I’ll run the Agno team to " "research HN and DEV in parallel, check Engram memory, and return ranked trends plus talk/blog ideas." ) if len(missing) == 1: return f"One thing I still need before I can run it: {missing[0]}." return "I’m close. I still need " + ", ".join(missing[:-1]) + f", and {missing[-1]}." def persist_session_env(values: dict[str, str]) -> None: env_path = APP_DIR / ".env" existing: dict[str, str] = {} if env_path.exists(): for raw_line in env_path.read_text(encoding="utf-8").splitlines(): if "=" not in raw_line or raw_line.strip().startswith("#"): continue key, value = raw_line.split("=", 1) existing[key] = value existing.update({key: value for key, value in values.items() if value}) lines = [f"{key}={value}" for key, value in existing.items()] env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") async def run_brief( context: CompanyContext, limit: int, on_progress=None, ): settings = get_settings(require_nebius=True) if st.session_state.memory_store is None: st.session_state.memory_store = create_memory_store( settings.engram_api_key, settings.engram_namespace, settings.engram_conversation_id, ) def on_stage(message: str) -> None: st.session_state.stage_messages.append(message) update_pipeline_from_message(message) if on_progress is not None: on_progress(message) return await run_autonomous_content_team( context=context, settings=settings, memory_store=st.session_state.memory_store, limit=limit, on_stage=on_stage, engram_user_id=st.session_state.engram_user_id, conversation_id=settings.engram_conversation_id or st.session_state.conversation_id, ) def render_progress_log(messages: list[str], max_lines: int = 14) -> str: recent = messages[-max_lines:] return "\n".join(f"- {message}" for message in recent) def _merge_context_update(update: dict) -> bool: changed = False for field in ( "company_name", "product", "audience", "seed_keywords", "competitors", "existing_topics", ): value = update.get(field) if value in (None, "", []): continue if field in {"seed_keywords", "competitors", "existing_topics"}: if isinstance(value, str): items = [item.strip() for item in value.split(",") if item.strip()] elif isinstance(value, list): items = [str(item).strip() for item in value if str(item).strip()] else: items = [str(value).strip()] if items and not st.session_state.context[field]: st.session_state.context[field] = items[:10] changed = True elif not st.session_state.context[field].strip(): st.session_state.context[field] = str(value).strip() changed = True return changed async def enrich_context_with_llm(prompt: str) -> bool: settings = get_settings(require_nebius=False) if not settings.nebius_api_key: return False model = build_model(settings) agent = make_agent(model, CONTEXT_EXTRACTOR_PROMPT, "context_extractor") request = { "user_message": prompt, "current_context": st.session_state.context, "recent_chat": st.session_state.messages[-6:], } try: payload = parse_json_object(await run_agent(agent, json.dumps(request, ensure_ascii=False))) except Exception: return False return _merge_context_update(payload) async def route_prompt_with_llm(prompt: str) -> str: settings = get_settings(require_nebius=False) fallback_intent = "chat" if wants_current_report_followup(prompt) and st.session_state.latest_result is not None: fallback_intent = "followup" elif wants_memory_lookup(prompt): fallback_intent = "memory_lookup" elif wants_brief_run(prompt, st.session_state.context): fallback_intent = "research_run" elif st.session_state.latest_result is not None: fallback_intent = "followup" if not settings.nebius_api_key: return fallback_intent model = build_model(settings) agent = make_agent(model, ROUTER_PROMPT, "message_router") request = { "user_message": prompt, "current_context": st.session_state.context, "has_latest_report": st.session_state.latest_result is not None, "missing_required_context": missing_fields(st.session_state.context), "recent_chat": st.session_state.messages[-6:], } try: payload = parse_json_object( await asyncio.wait_for( run_agent(agent, json.dumps(request, ensure_ascii=False)), timeout=ROUTER_TIMEOUT_SECONDS, ) ) except Exception: return fallback_intent intent = str(payload.get("intent", "")).strip().lower() requires_fresh_sources = payload.get("requires_fresh_sources") if intent == "research_run" and requires_fresh_sources is False: return fallback_intent if fallback_intent != "chat" else "memory_lookup" if intent in {"memory_lookup", "research_run", "followup", "chat"}: return intent return fallback_intent async def store_product_context_memory(context: CompanyContext) -> list[str]: fingerprint = product_context_fingerprint(context) if fingerprint == st.session_state.get("stored_context_fingerprint", ""): return [] if st.session_state.memory_store is None: settings = get_settings(require_nebius=False) st.session_state.memory_store = create_memory_store( settings.engram_api_key, settings.engram_namespace, settings.engram_conversation_id, ) notes = await st.session_state.memory_store.store_product_context( context=context, user_id=st.session_state.engram_user_id, ) if any(note.startswith("Stored product context") for note in notes): st.session_state.stored_context_fingerprint = fingerprint return notes def product_context_fingerprint(context: CompanyContext) -> str: payload = { "company_name": context.company_name, "product": context.product, "audience": context.audience, "seed_keywords": context.seed_keywords, "competitors": context.competitors, "existing_topics": context.existing_topics, } serialized = json.dumps(payload, sort_keys=True, ensure_ascii=False) return hashlib.sha256(serialized.encode("utf-8")).hexdigest() async def search_memory_answer(prompt: str) -> str: settings = get_settings(require_nebius=False) if st.session_state.memory_store is None: st.session_state.memory_store = create_memory_store( settings.engram_api_key, settings.engram_namespace, settings.engram_conversation_id, ) search_queries = _memory_lookup_queries(prompt) search_results = await asyncio.gather( *[ st.session_state.memory_store.search( query=query, user_id=st.session_state.engram_user_id, limit=8 if index == 0 else 4, ) for index, query in enumerate(search_queries) ] ) memories = _merge_memory_records(search_results) if not memories: return current_research_context_answer(prompt) or "I don’t see prior useful research for that yet." cleaned_memories = [ " ".join(memory.content.split()) for memory in memories[:8] if _memory_content_is_useful(memory.content) ] if not cleaned_memories: return current_research_context_answer(prompt) or "I don’t see that we’ve researched a concrete product or topic for that yet." if settings.nebius_api_key: model = build_model(settings) agent = make_agent(model, MEMORY_SUMMARIZER_PROMPT, "memory_summarizer") request = { "user_question": prompt, "memories": cleaned_memories, } try: answer = await run_agent(agent, json.dumps(request, ensure_ascii=False)) cleaned = answer.strip() if cleaned and "unknown model error" not in cleaned.lower(): memory_answer = _clean_memory_answer(cleaned) current_answer = current_research_context_answer(prompt) if current_answer and current_answer.lower() not in memory_answer.lower(): memory_answer = _remove_current_context_conflicts(memory_answer) return f"{current_answer}\n\nFrom older stored context: {memory_answer}" return memory_answer except Exception as exc: logging.warning("Memory summarizer LLM call failed: %s", exc) first = cleaned_memories[0][:240] current_answer = current_research_context_answer(prompt) if current_answer: return f"{current_answer}\n\nFrom older stored context, the clearest thing I found is: {first}" return f"Yes, we have some previous context. The clearest thing I found is: {first}" MEMORY_QUERY_STOPWORDS = { "what", "which", "have", "has", "were", "was", "we", "you", "researched", "research", "before", "past", "previously", "previous", "recently", "lately", "include", "available", "products", "product", "topics", "topic", "ideas", "idea", "reports", "report", "and", "or", "if", "the", "for", "about", "related", } def _extract_memory_product_hints(prompt: str) -> list[str]: hints: list[str] = [] for match in re.finditer(r"\b[a-zA-Z0-9][a-zA-Z0-9._-]{1,32}\b", prompt): token = match.group(0).strip(".,?!:;()[]{}") lowered = token.lower() if lowered in MEMORY_QUERY_STOPWORDS: continue if len(token) < 3 and not any(char.isdigit() for char in token): continue looks_product_like = ( any(char.isdigit() for char in token) or "." in token or "-" in token or token[:1].isupper() ) if looks_product_like and lowered not in {item.lower() for item in hints}: hints.append(token) return hints[:5] def _memory_lookup_queries(prompt: str) -> list[str]: queries = [prompt] for hint in _extract_memory_product_hints(prompt): queries.extend( [ f"Research summary for {hint}", f"Product context for {hint}", hint, ] ) deduped: list[str] = [] seen: set[str] = set() for query in queries: normalized = " ".join(query.split()).lower() if not normalized or normalized in seen: continue seen.add(normalized) deduped.append(query) return deduped[:12] def _merge_memory_records(search_results: list[list[MemoryRecord]]) -> list[MemoryRecord]: merged: list[MemoryRecord] = [] seen: set[str] = set() for result_set in search_results: for memory in result_set: normalized = " ".join(memory.content.split()).lower() if not normalized or normalized in seen: continue seen.add(normalized) merged.append(memory) return merged def current_research_context_answer(prompt: str) -> str | None: result = st.session_state.get("latest_result") if result is None: return None lowered = prompt.lower() if not any(term in lowered for term in ("product", "topic", "idea", "report", "researched", "suggested")): return None context = result.context company = context.company_name or result.report.company or context.product product = context.product or company if not company and not product: return None ideas = ", ".join(idea.title for idea in result.report.content_ideas[:3]) if ideas: return f"In this session, we researched {company or product} ({product}) and suggested ideas like {ideas}." return f"In this session, we researched {company or product} ({product})." def _clean_memory_answer(answer: str) -> str: cleaned = answer.strip() cleaned = cleaned.replace("Engram Memory", "prior research") cleaned = cleaned.replace("Engram memory", "prior research") cleaned = cleaned.replace("Engram", "prior research") cleaned = cleaned.replace("our latest one", "previously researched") cleaned = cleaned.replace("the latest one", "previously researched") cleaned = cleaned.replace("latest one", "previously researched") paragraphs: list[str] = [] seen: set[str] = set() for paragraph in cleaned.split("\n\n"): normalized = " ".join(paragraph.split()).lower() if not normalized or normalized in seen: continue seen.add(normalized) paragraphs.append(paragraph.strip()) return "\n\n".join(paragraphs).strip() def _remove_current_context_conflicts(answer: str) -> str: result = st.session_state.get("latest_result") if result is None: return answer context = result.context terms = [ context.company_name, context.product.split(",", 1)[0], result.report.company, ] for name in (context.company_name, result.report.company): first_word = (name or "").strip().split(" ", 1)[0] if len(first_word) > 2: terms.append(first_word) terms = [term.lower().strip() for term in terms if term and len(term.strip()) > 2] if not terms: return answer stale_markers = ( "don't see", "do not see", "haven't researched", "have not researched", "not researched", "no evidence", "not yet", ) pieces = re.split(r"(?<=[.!?])\s+", answer) filtered = [ piece for piece in pieces if not ( any(term in piece.lower() for term in terms) and any(marker in piece.lower() for marker in stale_markers) ) ] return " ".join(piece.strip() for piece in filtered if piece.strip()).strip() def _memory_content_is_useful(content: str) -> bool: lowered = content.lower() noisy_patterns = ( "i still need", "the user asked", "user requested assistance", "ask me to find", "researching recent", "generated the report below", "engram memory queued", "quality judge", "detailed reasoning", "publishing briefs", "publishing opportunities", "seo pages", "seo gap", "technical seo", ) if any(pattern in lowered for pattern in noisy_patterns): return False useful_patterns = ( "product context for", "research summary for", "top developer trends", "top talk/blog ideas", "key demand and supply gaps", "product/category", "seed keywords", "audience", ) return any(pattern in lowered for pattern in useful_patterns) def source_title_lookup(result) -> dict[str, str]: lookup: dict[str, str] = {} for item in result.hn_items: if item.url and item.title: lookup[item.url] = item.title for article in result.dev_articles: if article.url and article.title: lookup[article.url] = article.title return lookup def markdown_source_links( urls: list[str], lookup: dict[str, str], empty_text: str, limit: int = 4, ) -> str: if not urls: return f"- {empty_text}" lines = [] for url in urls[:limit]: title = lookup.get(url, url) safe_title = title.replace("[", "(").replace("]", ")") lines.append(f"- [{safe_title}]({url})" if title != url else f"- {url}") return "\n".join(lines) def render_metric_row(items: list[tuple[str, str | int]]) -> None: chips = "".join( f"{html.escape(str(label))}: {html.escape(str(value))}" for label, value in items ) st.markdown(f"
{chips}
", unsafe_allow_html=True) def render_structured_report(result) -> None: lookup = source_title_lookup(result) report = result.report st.subheader("What to publish next") st.markdown(f"

{html.escape(report.summary)}

", unsafe_allow_html=True) for index, idea in enumerate(report.content_ideas, 1): with st.container(border=True): st.markdown(f"### {index}. {idea.title}") render_metric_row( [ ("Score", idea.score), ("Format", idea.format), ("Confidence", idea.confidence), ] ) st.markdown(f"**Audience fit:** {idea.angle}") st.markdown(f"**DEV supply gap:** {idea.dev_gap}") col_hn, col_dev = st.columns(2) with col_hn: st.markdown("**HN demand**") st.markdown( markdown_source_links( idea.hn_evidence, lookup, "No strong HN evidence found in this run.", ) ) with col_dev: st.markdown("**DEV.to supply**") st.markdown( markdown_source_links( idea.dev_links, lookup, "No specific DEV.to article matched this idea closely.", ) ) if idea.outline: st.markdown("**Suggested outline**") st.markdown("\n".join(f"- {item}" for item in idea.outline[:6])) st.subheader("Trend digest") for index, trend in enumerate(report.trend_digest, 1): with st.expander(f"{index}. {trend.topic} · intensity {trend.intensity_score}"): st.markdown(trend.why_trending) st.markdown(f"**DEV saturation:** {trend.dev_saturation}") col_hn, col_dev = st.columns(2) with col_hn: st.markdown("**HN links**") st.markdown( markdown_source_links( trend.hn_links, lookup, "No strong HN evidence found in this run.", ) ) with col_dev: st.markdown("**DEV.to links**") st.markdown( markdown_source_links( trend.dev_links, lookup, "No specific DEV.to article matched this trend closely.", ) ) def render_grouped_evidence(result) -> None: lookup = source_title_lookup(result) st.markdown("Grouped by recommendation, so evidence can be checked against the exact idea it supports.") for index, idea in enumerate(result.report.content_ideas, 1): with st.expander(f"Idea {index}: {idea.title}", expanded=index == 1): col_hn, col_dev = st.columns(2) with col_hn: st.markdown("**HN demand evidence**") st.markdown( markdown_source_links( idea.hn_evidence, lookup, "No strong HN evidence found in this run.", ) ) with col_dev: st.markdown("**DEV.to supply check**") st.markdown( markdown_source_links( idea.dev_links, lookup, "No specific DEV.to article matched this idea closely.", ) ) st.caption(idea.dev_gap) with st.expander("All collected HN items"): for item in result.hn_items: meta = " · ".join( part for part in [ item.created_at, f"{item.points} points" if item.points is not None else "", f"{item.num_comments} comments" if item.num_comments is not None else "", ] if part ) st.markdown(f"- [{item.title}]({item.url})" + (f" — {meta}" if meta else "")) with st.expander("All collected DEV.to articles"): for article in result.dev_articles: meta = " · ".join( part for part in [ article.published_at, ", ".join(article.tags[:4]) if article.tags else "", ] if part ) st.markdown(f"- [{article.title}]({article.url})" + (f" — {meta}" if meta else "")) if article.body_excerpt: st.caption(article.body_excerpt[:220]) init_state() with st.sidebar: st.subheader("Credentials") st.markdown( f"""
""", unsafe_allow_html=True, ) load_project_env() nebius_key = st.text_input( "Nebius API Key", value=os.getenv("NEBIUS_API_KEY", ""), type="password", help="Required for Agno agents via Nebius Token Factory.", ) st.markdown( f"""
""", unsafe_allow_html=True, ) engram_key = st.text_input( "Engram API Key", value=os.getenv("ENGRAM_API_KEY", ""), type="password", help="Optional. Enables persistent Weaviate Engram Memory.", ) if st.button("Save API Keys", use_container_width=True): if nebius_key: os.environ["NEBIUS_API_KEY"] = nebius_key if engram_key: os.environ["ENGRAM_API_KEY"] = engram_key persist_session_env( { "NEBIUS_API_KEY": nebius_key, "NEBIUS_MODEL": os.getenv("NEBIUS_MODEL", DEFAULT_MODEL), "ENGRAM_API_KEY": engram_key, } ) st.session_state.memory_store = None st.success("Saved for this session.") st.caption(f"Model: {os.getenv('NEBIUS_MODEL', DEFAULT_MODEL)}") st.caption("LLM API: Nebius Token Factory") st.caption("HN: no key") st.caption("DEV: public API") st.caption("Nebius detected" if os.getenv("NEBIUS_API_KEY") else "Nebius key missing") st.caption( "Engram Memory enabled" if os.getenv("ENGRAM_API_KEY") else "Engram missing; memory disabled" ) if not st.session_state.messages: welcome = ( "Tell me about your product and audience, then ask for a **trend digest** and **talk/blog ideas**. " "The Agno agents will choose searches, gather Hacker News demand, DEV.to supply gaps, and Engram memory, then write a grounded report.\n\n" "Example:\n\n" "*I run raah.dev, a web analytics and network observability tool. My audience is backend engineers " "who care about latency, error rates, and user-side ISP behavior. " "Research what developers are discussing on HN, check DEV.to saturation, and suggest talk and blog ideas " "around debugging production services.*\n\n" "Follow-ups you can try after a report: *What did we find?* or *Show evidence for idea 1*." ) st.session_state.messages.append({"role": "assistant", "content": welcome}) for message in st.session_state.messages: with st.chat_message(message["role"]): st.markdown(message["content"]) chat_placeholder = ( "Research is running. Please wait for the current report..." if st.session_state.is_running else "Describe your product… or ask for a trend digest and talk/blog ideas…" ) prompt = st.chat_input(chat_placeholder, disabled=st.session_state.is_running) if prompt: st.session_state.messages.append({"role": "user", "content": prompt}) with st.chat_message("user"): st.markdown(prompt) if st.session_state.is_running: response = "I’m still running the current report. I’ll be ready for follow-up questions as soon as it finishes." with st.chat_message("assistant"): st.markdown(response) st.session_state.messages.append({"role": "assistant", "content": response}) st.stop() parsed_context = apply_prompt_context(prompt, st.session_state.context) try: prompt_intent = asyncio.run(route_prompt_with_llm(prompt)) if prompt_intent == "followup" and st.session_state.latest_result is None: prompt_intent = "no_report_followup" except Exception: prompt_intent = ( "research_run" if wants_explicit_research_run(prompt) else "followup" if wants_current_report_followup(prompt) and st.session_state.latest_result is not None else "no_report_followup" if wants_current_report_followup(prompt) and st.session_state.latest_result is None else "memory_lookup" if wants_memory_lookup(prompt) else "research_run" if wants_brief_run(prompt, st.session_state.context) else "chat" ) wants_run = prompt_intent == "research_run" if wants_run: try: parsed_context = asyncio.run(enrich_context_with_llm(prompt)) or parsed_context except Exception: pass with st.chat_message("assistant"): followup = ( followup_response(prompt, st.session_state.latest_result) if prompt_intent == "followup" else None ) if prompt_intent == "followup" and followup: response = followup st.markdown(response) elif prompt_intent == "no_report_followup": response = ( "I don’t have a generated report in this session yet, so there isn’t idea evidence to show. " "Tell me about the product and audience, then ask me to find trends or suggest talk/blog ideas." ) st.markdown(response) elif prompt_intent == "memory_lookup": with st.spinner("Checking Engram Memory..."): response = asyncio.run(search_memory_answer(prompt)) st.markdown(response) elif wants_run and missing_fields(st.session_state.context): response = conversational_response( prompt, st.session_state.context, st.session_state.latest_result, ) or assistant_prompt_for_missing() st.markdown(response) elif wants_run and not missing_fields(st.session_state.context): if not os.getenv("NEBIUS_API_KEY"): response = "I need a Nebius API key before I can run the autonomous agent team. Add it in the sidebar and click Save API Keys." st.warning(response) else: context = CompanyContext(**st.session_state.context) st.session_state.stage_messages = [] reset_pipeline_steps() st.caption( "Running Agno agents: GLM chooses HN/DEV searches, then HN, DEV.to, and Engram facts are gathered in parallel. " "Live progress appears below." ) started_at = time.time() st.session_state.is_running = True try: with st.status( f"Running {APP_NAME}...", expanded=True, ) as status: pipeline_view = st.empty() progress_log = st.empty() def update_progress(message: str) -> None: elapsed = int(time.time() - started_at) status.update( label=f"{message} ({elapsed}s elapsed)", state="running", ) pipeline_view.markdown(render_pipeline_stepper()) progress_log.markdown( render_progress_log(st.session_state.stage_messages) ) result = asyncio.run( run_brief( context, st.session_state.limit, on_progress=update_progress, ) ) elapsed = int(time.time() - started_at) status.update( label=f"Research complete in {elapsed}s", state="complete", ) pipeline_view.markdown(render_pipeline_stepper()) progress_log.markdown( render_progress_log(st.session_state.stage_messages) ) st.session_state.latest_result = result trends = result.report.trend_digest[:3] ideas = result.report.content_ideas[:3] summary_lines = [ f"Done. Found {len(result.report.trend_digest)} trends and " f"{len(result.report.content_ideas)} talk/blog ideas." ] if trends: summary_lines.append("") summary_lines.append("Top trends:") for i, trend in enumerate(trends, 1): summary_lines.append( f"{i}. **{trend.topic}** (intensity {trend.intensity_score})" ) if ideas: summary_lines.append("") summary_lines.append("Top ideas:") for i, idea in enumerate(ideas, 1): summary_lines.append( f"{i}. **{idea.title}** ({idea.format}, score {idea.score})" ) summary_lines.append("") summary_lines.append("See the full report below, or download as Markdown.") response = "\n".join(summary_lines) st.markdown(response) except Exception as exc: response = f"Content report generation failed: {exc}" st.error(response) finally: st.session_state.is_running = False elif parsed_context: context_summary = render_context_summary(st.session_state.context) response = f"{context_summary}\n\n{assistant_prompt_for_missing()}" st.markdown(response) elif chat_response := conversational_response( prompt, st.session_state.context, st.session_state.latest_result, ): response = chat_response st.markdown(response) else: response = assistant_prompt_for_missing() st.markdown(response) st.session_state.messages.append({"role": "assistant", "content": response}) if parsed_context and has_required_context(st.session_state.context): try: asyncio.run( store_product_context_memory(CompanyContext(**st.session_state.context)) ) except Exception: pass if st.session_state.latest_result: result = st.session_state.latest_result st.markdown("---") tab_brief, tab_evidence, tab_history = st.tabs(["Report", "Evidence", "Memory & Run History"]) with tab_brief: render_structured_report(result) st.download_button( "Download Markdown", data=result.markdown, file_name="developer_trend_ideation_report.md", mime="text/markdown", use_container_width=True, ) with tab_evidence: render_grouped_evidence(result) with st.expander("Query plan", expanded=True): st.json(result.query_plan.to_dict()) with tab_history: st.markdown("#### Engram Memory") st.caption( "Cross-session product research summaries stored in Weaviate Engram " f"(user: `{st.session_state.engram_user_id}`). " "Search is user-wide across old sessions in the same group; conversation id is stored only as metadata. " "Only compact trend and idea summaries are persisted, not agent run logs." ) st.markdown(f"**Group:** `{get_settings(require_nebius=False).engram_namespace}`") st.markdown("**Old-session retrieval:** enabled through stable user/group search.") if result.memory_notes: for note in result.memory_notes: st.markdown(f"- {note}") else: st.info("No Engram store confirmation for this run.") preview = build_research_summary_preview( result.context, result.report, result.content_gaps, ) if preview: st.markdown("**Stored summary preview**") st.markdown(preview) st.markdown("#### This run — Report checks") st.caption("Local formatting and evidence-link checks for this report only; Engram stores only compact memories.") if result.judge_score is not None: st.markdown(f"**Score:** {result.judge_score:.0f}/10") if result.judge_notes: for note in result.judge_notes: st.markdown(f"- {truncate_judge_note(note)}") elif result.judge_score is None: st.caption("No separate judge run recorded for this fast path.") st.markdown("#### This run — Agent progress") st.caption("Live pipeline log from the current Streamlit session.") if st.session_state.stage_messages: for stage in st.session_state.stage_messages: st.markdown(f"- {stage}") else: st.caption("No stage log captured for this run.") if result.team_member_responses: st.markdown("#### This run — Member responses") for note in result.team_member_responses: st.markdown(f"- {note}") st.markdown("#### Agno session trace") st.caption( "This is the current Streamlit session's agent progress log. " "Cross-session memory is stored only in Engram." ) st.caption(f"Conversation scope: `{st.session_state.conversation_id}`")