arindam200--awesome-ai-apps
1336 行
49 KiB
Python
1336 行
49 KiB
Python
"""
|
|
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"<img src='data:image/png;base64,{encoded}' "
|
|
f"style='height:{height}px; width:auto; display:inline-block; "
|
|
f"vertical-align:middle; margin:0 8px;' alt='{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"<img src='data:image/svg+xml;base64,{encoded}' "
|
|
f"style='height:{height}px; width:auto; display:inline-block; "
|
|
f"vertical-align:middle; margin:0 8px;' alt='{path.stem}'>"
|
|
)
|
|
|
|
|
|
st.markdown(
|
|
"""
|
|
<style>
|
|
.stApp { background: #0B1020; color: #E5E7EB; }
|
|
[data-testid="stAppViewContainer"] .main .block-container {
|
|
max-width: 1580px;
|
|
padding-top: 5.5rem;
|
|
padding-left: 4.25rem;
|
|
padding-right: 4.25rem;
|
|
}
|
|
[data-testid="stSidebar"] {
|
|
background: #111827;
|
|
min-width: 300px;
|
|
max-width: 300px;
|
|
}
|
|
[data-testid="stSidebar"] * {
|
|
font-size: 1rem;
|
|
}
|
|
[data-testid="stSidebar"] h3 {
|
|
font-size: 1.15rem;
|
|
}
|
|
[data-testid="stSidebar"] .stCaptionContainer,
|
|
[data-testid="stSidebar"] .stCaptionContainer p {
|
|
font-size: 0.96rem;
|
|
line-height: 1.45;
|
|
}
|
|
[data-testid="stChatMessage"] {
|
|
background: rgba(31, 41, 55, 0.72);
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
border-radius: 8px;
|
|
padding: 1rem 1.1rem;
|
|
max-width: 100%;
|
|
}
|
|
[data-testid="stChatMessage"] p,
|
|
[data-testid="stChatMessage"] li {
|
|
font-size: 1.06rem;
|
|
line-height: 1.65;
|
|
}
|
|
[data-testid="stChatMessage"] [data-testid="stMarkdownContainer"] {
|
|
max-width: 980px;
|
|
}
|
|
[data-testid="stCaptionContainer"] p {
|
|
font-size: 1rem;
|
|
line-height: 1.5;
|
|
}
|
|
[data-testid="stChatInput"] textarea {
|
|
font-size: 1.02rem;
|
|
line-height: 1.45;
|
|
}
|
|
.eca-provider-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
margin: 6px 0 4px;
|
|
}
|
|
.eca-provider-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
|
background: rgba(15, 23, 42, 0.76);
|
|
border-radius: 8px;
|
|
padding: 5px 8px;
|
|
font-size: 0.86rem;
|
|
line-height: 1.1;
|
|
}
|
|
.eca-title {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 14px;
|
|
margin: 0;
|
|
padding: 0;
|
|
font-size: 2.65rem;
|
|
font-weight: 800;
|
|
color: #F9FAFB;
|
|
line-height: 1.15;
|
|
}
|
|
.eca-source-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
.eca-source-chip img {
|
|
height: 40px;
|
|
width: auto;
|
|
display: inline-block;
|
|
}
|
|
.eca-brand-card {
|
|
width: 100%;
|
|
height: 70px;
|
|
border-radius: 8px;
|
|
background: #FFFFFF;
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
padding: 10px 14px;
|
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 8px 0 10px;
|
|
}
|
|
.eca-brand-card img {
|
|
display: block;
|
|
height: 42px;
|
|
width: 175px;
|
|
object-fit: contain;
|
|
}
|
|
.eca-metric-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
margin: 6px 0 12px;
|
|
}
|
|
.eca-metric {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
|
border-radius: 8px;
|
|
background: rgba(15, 23, 42, 0.7);
|
|
padding: 5px 9px;
|
|
font-size: 0.92rem;
|
|
}
|
|
.eca-section-note {
|
|
color: #AEB7C7;
|
|
font-size: 0.98rem;
|
|
line-height: 1.45;
|
|
margin: 0.1rem 0 1rem;
|
|
}
|
|
@media (max-width: 900px) {
|
|
[data-testid="stAppViewContainer"] .main .block-container {
|
|
padding-left: 1rem;
|
|
padding-right: 1rem;
|
|
}
|
|
.eca-title {
|
|
font-size: 2rem;
|
|
}
|
|
.eca-source-chip img {
|
|
height: 32px;
|
|
}
|
|
}
|
|
</style>
|
|
""",
|
|
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"""
|
|
<div style='display:flex; align-items:center; width:100%; padding:8px 0;'>
|
|
<h1 class='eca-title'>
|
|
<span>{APP_NAME} with</span>
|
|
<span class='eca-source-chip'>{hackernews_img_inline}<span>Hacker News</span></span>
|
|
<span>and</span>
|
|
<span class='eca-source-chip'>{devto_img_inline}<span>DEV.to</span></span>
|
|
</h1>
|
|
</div>
|
|
"""
|
|
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"<span class='eca-metric'><strong>{html.escape(str(label))}:</strong> {html.escape(str(value))}</span>"
|
|
for label, value in items
|
|
)
|
|
st.markdown(f"<div class='eca-metric-row'>{chips}</div>", 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"<p class='eca-section-note'>{html.escape(report.summary)}</p>", 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"""
|
|
<div class="eca-brand-card">
|
|
<img class="eca-sidebar-logo" src="{_png_data_uri(APP_DIR / "assets" / "Nebius.png")}" alt="Nebius" style="width:175px;height:42px;object-fit:contain;">
|
|
</div>
|
|
""",
|
|
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"""
|
|
<div class="eca-brand-card">
|
|
<img class="eca-sidebar-logo" src="{_png_data_uri(APP_DIR / "assets" / "Weaviate_long.png")}" alt="Weaviate" style="width:175px;height:42px;object-fit:contain;">
|
|
</div>
|
|
""",
|
|
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}`")
|