From 7b335de28e2d8fbde79dead37902a6c4c7a3de9e Mon Sep 17 00:00:00 2001 From: 3rd-Son Date: Mon, 15 Dec 2025 15:24:27 +0100 Subject: [PATCH 1/2] built youtube trend ananalysis agent --- .../.streamlit/config.toml | 8 + memory_agents/youtube_trend_agent/README.md | 58 ++++ memory_agents/youtube_trend_agent/app.py | 258 ++++++++++++++ .../assets/Memori_Logo.png | Bin 0 -> 8527 bytes memory_agents/youtube_trend_agent/core.py | 320 ++++++++++++++++++ .../youtube_trend_agent/pyproject.toml | 16 + 6 files changed, 660 insertions(+) create mode 100644 memory_agents/youtube_trend_agent/.streamlit/config.toml create mode 100644 memory_agents/youtube_trend_agent/README.md create mode 100644 memory_agents/youtube_trend_agent/app.py create mode 100644 memory_agents/youtube_trend_agent/assets/Memori_Logo.png create mode 100644 memory_agents/youtube_trend_agent/core.py create mode 100644 memory_agents/youtube_trend_agent/pyproject.toml diff --git a/memory_agents/youtube_trend_agent/.streamlit/config.toml b/memory_agents/youtube_trend_agent/.streamlit/config.toml new file mode 100644 index 00000000..6ae713d1 --- /dev/null +++ b/memory_agents/youtube_trend_agent/.streamlit/config.toml @@ -0,0 +1,8 @@ +[theme] +base = "light" + +# Optional: +# primaryColor = "#0b57d0" +# backgroundColor = "#ffffff" +# secondaryBackgroundColor = "#f5f7fb" +# textColor = "#000000" diff --git a/memory_agents/youtube_trend_agent/README.md b/memory_agents/youtube_trend_agent/README.md new file mode 100644 index 00000000..ddad3f07 --- /dev/null +++ b/memory_agents/youtube_trend_agent/README.md @@ -0,0 +1,58 @@ +## YouTube Trend Analysis Agent + +YouTube channel analysis agent powered by **Memori**, **Agno (OpenAI)**, **Exa**, and **yt-dlp**. +Paste your YouTube channel URL, ingest recent videos into Memori, then chat with an agent that surfaces trends and concrete new video ideas grounded in your own content. + +### Features + +- **Direct YouTube scraping**: Uses `yt-dlp` to scrape your channel or playlist from YouTube and collect titles, tags, dates, views, and descriptions. +- **Memori memory store**: Stores each video as a Memori memory (via OpenAI) for fast semantic search and reuse across chats. +- **Web trend context with Exa**: Calls Exa to pull recent articles and topics for your niche and blends them with your own channel history. +- **Streamlit UI**: Sidebar for API keys + channel URL and a chat area for asking about trends and ideas. + +--- + +### Setup (with `uv`) + +1. **Install `uv`** (if you don’t have it yet): + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +2. **Create the environment and install dependencies from `pyproject.toml`:** + +```bash +cd memory_agents/youtube_trend_agent +uv sync +``` + +This will create a virtual environment (if needed) and install all dependencies declared in `pyproject.toml`. + +3. **Environment variables** (set in your shell or a local `.env` file in this folder): + +- `OPENAI_API_KEY` – required (used both for Memori ingestion and the Agno-powered advisor). +- `EXA_API_KEY` – optional but recommended (for external trend context via Exa). +- `MEMORI_API_KEY` – optional, for Memori Advanced Augmentation / higher quotas. +- `SQLITE_DB_PATH` – optional, defaults to `./memori.sqlite` if unset. + +--- + +### Run + +From the `youtube_trend_agent` directory: + +```bash +uv run streamlit run app.py +``` + +In the **sidebar**: + +1. Enter your **OpenAI**, optional **Exa**, and optional **Memori** API keys. +2. Paste your **YouTube channel (or playlist) URL**. +3. Click **“Ingest channel into Memori”** to scrape and store recent videos. + +Then use the main chat box to ask things like: + +- “Suggest 5 new video ideas that build on my existing content and current trends.” +- “What trends am I missing in my current uploads?” diff --git a/memory_agents/youtube_trend_agent/app.py b/memory_agents/youtube_trend_agent/app.py new file mode 100644 index 00000000..713b5313 --- /dev/null +++ b/memory_agents/youtube_trend_agent/app.py @@ -0,0 +1,258 @@ +""" +YouTube Trend Analysis Agent with Memori, Agno (OpenAI), and YouTube scraping. + +Streamlit app: +- Sidebar: API keys + YouTube channel URL + "Ingest channel into Memori" button. +- Main: Chat interface to ask about trends and get new video ideas. + +This app uses: +- OpenAI (via both the OpenAI SDK and Agno's OpenAIChat model) for LLM reasoning. +- yt-dlp to scrape YouTube channel/playlist videos. +- Memori to store and search your channel's video history. +""" + +import base64 +import os + +import streamlit as st +from agno.agent import Agent +from agno.models.openai import OpenAIChat + +from youtube_trend_agent.core import fetch_exa_trends, ingest_channel_into_memori + + +def _load_inline_image(path: str, height_px: int) -> str: + """Return an inline tag for a local PNG, or empty string on failure.""" + try: + with open(path, "rb") as f: + encoded = base64.b64encode(f.read()).decode() + return ( + f"Logo" + ) + except Exception: + return "" + + +def main(): + # Page config + st.set_page_config( + page_title="YouTube Trend Analysis Agent", + layout="wide", + ) + + # Branded title with Memori logo (reusing the pattern from AI Consultant Agent) + memori_img_inline = _load_inline_image( + "assets/Memori_Logo.png", + height_px=85, + ) + title_html = f""" +
+

+ YouTube Trend Analysis Agent with + {memori_img_inline} +

+
+""" + st.markdown(title_html, unsafe_allow_html=True) + + # Initialize session state + if "messages" not in st.session_state: + st.session_state.messages = [] + # Memori/OpenAI client will be initialized lazily when needed. + + # Sidebar + with st.sidebar: + st.subheader("🔑 API Keys & Channel") + + openai_api_key_input = st.text_input( + "OpenAI API Key", + value=os.getenv("OPENAI_API_KEY", ""), + type="password", + help="Your OpenAI API key (used for both Memori and Agno).", + ) + + exa_api_key_input = st.text_input( + "Exa API Key (optional)", + value=os.getenv("EXA_API_KEY", ""), + type="password", + help="Used to fetch external web trends via Exa AI when suggesting new ideas.", + ) + + memori_api_key_input = st.text_input( + "Memori API Key (optional)", + value=os.getenv("MEMORI_API_KEY", ""), + type="password", + help="Used for Memori Advanced Augmentation and higher quotas.", + ) + + channel_url_input = st.text_input( + "YouTube channel / playlist URL", + placeholder="https://www.youtube.com/@YourChannel", + ) + + if st.button("Save Settings"): + if openai_api_key_input: + os.environ["OPENAI_API_KEY"] = openai_api_key_input + if exa_api_key_input: + os.environ["EXA_API_KEY"] = exa_api_key_input + if memori_api_key_input: + os.environ["MEMORI_API_KEY"] = memori_api_key_input + + st.success("✅ API keys saved for this session.") + + st.markdown("---") + + if st.button("Ingest channel into Memori"): + if not os.getenv("OPENAI_API_KEY"): + st.warning("OPENAI_API_KEY is required before ingestion.") + elif not channel_url_input.strip(): + st.warning("Please enter a YouTube channel or playlist URL.") + else: + with st.spinner( + "📥 Scraping channel and ingesting videos into Memori…" + ): + count = ingest_channel_into_memori(channel_url_input.strip()) + st.success(f"✅ Ingested {count} video(s) into Memori.") + + st.markdown("---") + st.markdown("### 💡 About") + st.markdown( + """ + This agent: + + - Scrapes your **YouTube channel** directly from YouTube using yt-dlp. + - Stores video metadata & summaries in **Memori**. + - Uses **Exa** and your channel info stored in **Memori** to surface trends and new video ideas. + """ + ) + + # Get keys for main app logic + if not os.getenv("OPENAI_API_KEY"): + st.warning( + "⚠️ Please enter your OpenAI API key in the sidebar to start chatting!" + ) + st.stop() + + # Display chat history + st.markdown( + "

YouTube Trend Chat

", + unsafe_allow_html=True, + ) + for message in st.session_state.messages: + with st.chat_message(message["role"]): + st.markdown(message["content"]) + + # Chat input + prompt = st.chat_input("Ask about your channel trends or new video ideas…") + if prompt: + st.session_state.messages.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.markdown(prompt) + + with st.chat_message("assistant"): + with st.spinner("🤔 Analyzing your channel memories…"): + try: + # Build context from Memori (if available) and from cached channel videos + memori_context = "" + mem = st.session_state.get("memori") + if mem is not None and hasattr(mem, "search"): + try: + results = mem.search(prompt, limit=5) + if results: + memori_context = ( + "\n\nRelevant snippets from your channel history:\n" + + "\n".join(f"- {r}" for r in results) + ) + except Exception as e: + st.warning(f"Memori search issue: {e}") + + videos = st.session_state.get("channel_videos") or [] + video_summaries = "" + if videos: + video_summaries_lines = [] + for v in videos[:10]: + title = v.get("title") or "Untitled video" + topics = v.get("topics") or [] + topics_str = ", ".join(topics) if topics else "N/A" + views = v.get("views") or "Unknown" + desc = v.get("description") or "" + if len(desc) > 120: + desc_snip = desc[:120].rstrip() + "…" + else: + desc_snip = desc + video_summaries_lines.append( + f"- {title} | topics: {topics_str} | views: {views} | desc: {desc_snip}" + ) + video_summaries = ( + "\n\nRecent videos on this channel:\n" + + "\n".join(video_summaries_lines) + ) + + channel_name = ( + st.session_state.get("channel_title") or "this YouTube channel" + ) + + exa_trends = "" + # Fetch or reuse Exa-based trend context, if Exa is configured + if os.getenv("EXA_API_KEY") and videos: + if "exa_trends" in st.session_state: + exa_trends = st.session_state["exa_trends"] + else: + exa_trends = fetch_exa_trends(channel_name, videos) + st.session_state["exa_trends"] = exa_trends + + full_prompt = f"""You are a YouTube strategy assistant analyzing the channel '{channel_name}'. + +You have access to a memory store of the user's past videos (titles, topics, views). +Use that memory to: +- Identify topics and formats that perform well on the channel. +- Suggest concrete, fresh video ideas aligned with those trends. +- Optionally point out gaps or under-explored themes. + +Always be specific and actionable (titles, angles, hooks, examples), but ONLY answer what the user actually asks. +Do NOT provide long, generic strategy plans unless the user explicitly asks for them. + +User question: +{prompt} + +Memory context (may be partial): +{memori_context} + +Channel metadata from recent scraped videos (titles, topics, views): +{video_summaries} + +External web trends for this niche (may be partial): +{exa_trends} +""" + + advisor = Agent( + name="YouTube Trend Advisor", + model=OpenAIChat( + id=os.getenv("YOUTUBE_TREND_MODEL", "gpt-4o-mini") + ), + markdown=True, + ) + + result = advisor.run(full_prompt) + response_text = ( + str(result.content) + if hasattr(result, "content") + else str(result) + ) + + st.session_state.messages.append( + {"role": "assistant", "content": response_text} + ) + st.markdown(response_text) + except Exception as e: + err = f"❌ Error generating answer: {e}" + st.session_state.messages.append( + {"role": "assistant", "content": err} + ) + st.error(err) + + +if __name__ == "__main__": + main() diff --git a/memory_agents/youtube_trend_agent/assets/Memori_Logo.png b/memory_agents/youtube_trend_agent/assets/Memori_Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4b4416b815f71d544d7ce484340921a1dbe90b55 GIT binary patch literal 8527 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%0As#r%! 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 Memori | None: + """ + Initialize Memori v3 + OpenAI client, mirroring the customer_support/ai_consultant pattern. + + This is used so Memori can automatically persist "memories" when we send + documents through the registered OpenAI client. Agno + OpenAIChat power all + YouTube analysis and idea generation. + """ + openai_key = os.getenv("OPENAI_API_KEY", "") + if not openai_key: + st.warning( + "OPENAI_API_KEY is not set – Memori v3 ingestion will not be active." + ) + return None + + try: + db_path = os.getenv("SQLITE_DB_PATH", "./memori.sqlite") + database_url = f"sqlite:///{db_path}" + engine = create_engine( + database_url, + pool_pre_ping=True, + connect_args={"check_same_thread": False}, + ) + + # Optional DB connectivity check + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + client = OpenAI(api_key=openai_key) + mem = Memori(conn=SessionLocal).openai.register(client) + # Attribution so Memori can attach memories to this process/entity. + mem.attribution(entity_id="youtube-channel", process_id="youtube-trend-agent") + mem.config.storage.build() + + st.session_state.memori = mem + st.session_state.openai_client = client + return mem + except Exception as e: + st.warning(f"Memori v3 initialization note: {e}") + return None + + +def fetch_channel_videos(channel_url: str) -> list[dict]: + """ + Use yt-dlp to fetch recent YouTube videos for a given channel or playlist URL. + + Returns: + A list of dicts: + [ + { + "title": "...", + "url": "...", + "published_at": "...", + "views": "...", + "topics": ["...", ...] + }, + ... + ] + """ + ydl_opts = { + # Don't download video files, we only want metadata + "quiet": True, + "no_warnings": True, + "skip_download": True, + # Limit to most recent 20 videos + "playlistend": 20, + # Be forgiving if some videos fail + "ignoreerrors": True, + # Silence yt-dlp's own logging + "logger": _SilentLogger(), + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(channel_url, download=False) + except Exception as e: + st.error(f"Error fetching YouTube channel info: {e}") + return [] + + entries = info.get("entries") or [] + # Cache channel title for use in prompts + if isinstance(info, dict): + st.session_state["channel_title"] = info.get("title") or "" + + videos: list[dict] = [] + + for entry in entries: + if not isinstance(entry, dict): + continue + video_id = entry.get("id") + url = entry.get("url") + # Build full watch URL when possible + full_url = url + if video_id and (not url or "watch?" not in url): + full_url = f"https://www.youtube.com/watch?v={video_id}" + + upload_date = entry.get("upload_date") or entry.get("release_date") or "" + # Convert YYYYMMDD -> YYYY-MM-DD if present + if ( + isinstance(upload_date, str) + and len(upload_date) == 8 + and upload_date.isdigit() + ): + upload_date = f"{upload_date[0:4]}-{upload_date[4:6]}-{upload_date[6:8]}" + + description = entry.get("description") or "" + duration = entry.get("duration") # in seconds, if available + + videos.append( + { + "title": entry.get("title") or "Untitled video", + "url": full_url or channel_url, + "published_at": upload_date or "Unknown", + "views": entry.get("view_count") or "Unknown", + "topics": entry.get("tags") or [], + "description": description, + "duration_seconds": duration, + } + ) + + return videos + + +def fetch_exa_trends(channel_name: str, videos: list[dict]) -> str: + """ + Use Exa AI to fetch external web trends for the channel's niche. + + Returns: + A formatted string of bullet points describing trending topics/articles. + """ + api_key = os.getenv("EXA_API_KEY", "") + if not api_key: + return "" + + # Build a niche description from tags and titles + tags: set[str] = set() + for v in videos: + for t in v.get("topics") or []: + if isinstance(t, str): + tags.add(t) + + base_niche = ", ".join(list(tags)[:10]) + if not base_niche: + titles = [v.get("title") or "" for v in videos[:5]] + base_niche = ", ".join(titles) + + if not base_niche: + return "" + + query = ( + f"Current trending topics and YouTube-style video ideas for the niche: {base_niche}. " + f"Focus on developer, programming, AI, and technology content if relevant." + ) + + try: + client = Exa(api_key=api_key) + # Keep the API call simple to avoid deprecated options like 'highlights' + res = client.search_and_contents( + query=query, + num_results=5, + type="auto", + ) + except Exception as e: + st.warning(f"Exa web search issue: {e}") + return "" + + results = getattr(res, "results", []) or [] + if not results: + return "" + + trend_lines: list[str] = [] + for doc in results[:5]: + title = getattr(doc, "title", "") or "Untitled" + url = getattr(doc, "url", "") or "" + text = getattr(doc, "text", "") or "" + snippet = " ".join(text.split())[:220] + line = f"- {title} ({url}) — {snippet}" + trend_lines.append(line) + + return "\n".join(trend_lines) + + +def ingest_channel_into_memori(channel_url: str) -> int: + """ + Scrape a YouTube channel and ingest the results into Memori. + + Returns: + Number of video documents ingested. + """ + # Ensure Memori + OpenAI client are initialized + memori: Memori | None = st.session_state.get("memori") + client: OpenAI | None = st.session_state.get("openai_client") + if memori is None or client is None: + memori = init_memori_with_openai() + client = st.session_state.get("openai_client") + + if memori is None or client is None: + st.error("Memori/OpenAI failed to initialize; cannot ingest channel.") + return 0 + + videos = fetch_channel_videos(channel_url) + if not videos: + st.warning("No videos were parsed from the YouTube channel response.") + raw = st.session_state.get("yt_raw_response") + if raw: + st.caption("Raw YouTube agent output (truncated, for debugging):") + st.code(str(raw)[:4000]) + return 0 + else: + # Debug info to help understand what was parsed + st.info(f"Parsed {len(videos)} video item(s) from the channel response.") + st.caption("First parsed item (truncated):") + try: + st.code(json.dumps(videos[0], indent=2)[:2000]) + except Exception: + # Fallback if item isn't JSON-serializable + st.code(str(videos[0])[:2000]) + + # Cache videos in session state so the chat agent can use them directly + st.session_state["channel_videos"] = videos + + ingested = 0 + for video in videos: + title = video.get("title") or "Untitled video" + url = video.get("url") or channel_url + published_at = video.get("published_at") or "Unknown" + views = video.get("views") or "Unknown" + topics = video.get("topics") or [] + description = video.get("description") or "" + duration = video.get("duration_seconds") or "Unknown" + + topics_str = ", ".join(str(t) for t in topics) if topics else "N/A" + # Truncate very long descriptions for ingestion + desc_snippet = description[:1000] + + doc_text = f"""YouTube Video +Channel URL: {channel_url} +Title: {title} +Video URL: {url} +Published at: {published_at} +Views: {views} +Duration (seconds): {duration} +Topics: {topics_str} +Description: +{desc_snippet} +""" + + try: + # Send this document through the registered OpenAI client so that + # Memori v3 can automatically capture it as a "memory". + _ = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + { + "role": "user", + "content": ( + "Store the following YouTube video metadata in memory " + "for future channel-trend analysis. Respond with a short " + "acknowledgement only.\n\n" + f"{doc_text}" + ), + } + ], + ) + ingested += 1 + except Exception as e: + st.warning(f"Memori/OpenAI issue ingesting video '{title}': {e}") + + # Flush writes if needed + try: + adapter = getattr(memori.config.storage, "adapter", None) + if adapter is not None: + adapter.commit() + except Exception: + # Non-fatal; Memori will still persist most data + pass + + return ingested diff --git a/memory_agents/youtube_trend_agent/pyproject.toml b/memory_agents/youtube_trend_agent/pyproject.toml new file mode 100644 index 00000000..0f12a66f --- /dev/null +++ b/memory_agents/youtube_trend_agent/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "youtube-trend-agent" +version = "0.1.0" +description = "YouTube Trend Analysis Agent with Memori, Agno (OpenAI), Exa, and yt-dlp" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "agno>=2.2.1", + "memori>=3.0.0", + "streamlit>=1.50.0", + "python-dotenv>=1.1.0", + "openai>=2.6.1", + "sqlalchemy>=2.0.0", + "exa-py>=1.6.0", + "yt-dlp>=2025.1.1", +] From 299e7a3f2f3740057c0bc11ccb644646ff42cbcc Mon Sep 17 00:00:00 2001 From: 3rd-Son Date: Tue, 16 Dec 2025 11:13:07 +0100 Subject: [PATCH 2/2] replaced openAI with nebius --- memory_agents/youtube_trend_agent/README.md | 6 +- memory_agents/youtube_trend_agent/app.py | 57 ++++++++++++------ .../assets/Nebius_Logo.png | Bin 0 -> 21330 bytes memory_agents/youtube_trend_agent/core.py | 41 +++++++------ .../youtube_trend_agent/pyproject.toml | 2 +- 5 files changed, 68 insertions(+), 38 deletions(-) create mode 100644 memory_agents/youtube_trend_agent/assets/Nebius_Logo.png diff --git a/memory_agents/youtube_trend_agent/README.md b/memory_agents/youtube_trend_agent/README.md index ddad3f07..45df21ba 100644 --- a/memory_agents/youtube_trend_agent/README.md +++ b/memory_agents/youtube_trend_agent/README.md @@ -1,6 +1,6 @@ ## YouTube Trend Analysis Agent -YouTube channel analysis agent powered by **Memori**, **Agno (OpenAI)**, **Exa**, and **yt-dlp**. +YouTube channel analysis agent powered by **Memori**, **Agno (Nebius)**, **Exa**, and **yt-dlp**. Paste your YouTube channel URL, ingest recent videos into Memori, then chat with an agent that surfaces trends and concrete new video ideas grounded in your own content. ### Features @@ -31,7 +31,7 @@ This will create a virtual environment (if needed) and install all dependencies 3. **Environment variables** (set in your shell or a local `.env` file in this folder): -- `OPENAI_API_KEY` – required (used both for Memori ingestion and the Agno-powered advisor). +- `NEBIUS_API_KEY` – required (used both for Memori ingestion and the Agno-powered advisor). - `EXA_API_KEY` – optional but recommended (for external trend context via Exa). - `MEMORI_API_KEY` – optional, for Memori Advanced Augmentation / higher quotas. - `SQLITE_DB_PATH` – optional, defaults to `./memori.sqlite` if unset. @@ -48,7 +48,7 @@ uv run streamlit run app.py In the **sidebar**: -1. Enter your **OpenAI**, optional **Exa**, and optional **Memori** API keys. +1. Enter your **Nebius**, optional **Exa**, and optional **Memori** API keys. 2. Paste your **YouTube channel (or playlist) URL**. 3. Click **“Ingest channel into Memori”** to scrape and store recent videos. diff --git a/memory_agents/youtube_trend_agent/app.py b/memory_agents/youtube_trend_agent/app.py index 713b5313..70675465 100644 --- a/memory_agents/youtube_trend_agent/app.py +++ b/memory_agents/youtube_trend_agent/app.py @@ -1,12 +1,12 @@ """ -YouTube Trend Analysis Agent with Memori, Agno (OpenAI), and YouTube scraping. +YouTube Trend Analysis Agent with Memori, Agno (Nebius), and YouTube scraping. Streamlit app: - Sidebar: API keys + YouTube channel URL + "Ingest channel into Memori" button. - Main: Chat interface to ask about trends and get new video ideas. This app uses: -- OpenAI (via both the OpenAI SDK and Agno's OpenAIChat model) for LLM reasoning. +- Nebius (via both the OpenAI SDK and Agno's Nebius model) for LLM reasoning. - yt-dlp to scrape YouTube channel/playlist videos. - Memori to store and search your channel's video history. """ @@ -16,9 +16,10 @@ import streamlit as st from agno.agent import Agent -from agno.models.openai import OpenAIChat +from agno.models.nebius import Nebius +from dotenv import load_dotenv -from youtube_trend_agent.core import fetch_exa_trends, ingest_channel_into_memori +from core import fetch_exa_trends, ingest_channel_into_memori def _load_inline_image(path: str, height_px: int) -> str: @@ -36,6 +37,8 @@ def _load_inline_image(path: str, height_px: int) -> str: def main(): + load_dotenv() + # Page config st.set_page_config( page_title="YouTube Trend Analysis Agent", @@ -66,11 +69,18 @@ def main(): with st.sidebar: st.subheader("🔑 API Keys & Channel") - openai_api_key_input = st.text_input( - "OpenAI API Key", - value=os.getenv("OPENAI_API_KEY", ""), + # Nebius logo above the Nebius API key field + try: + st.image("assets/Nebius_Logo.png", width=120) + except Exception: + # Non-fatal if the logo is missing + pass + + nebius_api_key_input = st.text_input( + "Nebius API Key", + value=os.getenv("NEBIUS_API_KEY", ""), type="password", - help="Your OpenAI API key (used for both Memori and Agno).", + help="Your Nebius API key (used for both Memori and Agno).", ) exa_api_key_input = st.text_input( @@ -93,8 +103,8 @@ def main(): ) if st.button("Save Settings"): - if openai_api_key_input: - os.environ["OPENAI_API_KEY"] = openai_api_key_input + if nebius_api_key_input: + os.environ["NEBIUS_API_KEY"] = nebius_api_key_input if exa_api_key_input: os.environ["EXA_API_KEY"] = exa_api_key_input if memori_api_key_input: @@ -105,8 +115,8 @@ def main(): st.markdown("---") if st.button("Ingest channel into Memori"): - if not os.getenv("OPENAI_API_KEY"): - st.warning("OPENAI_API_KEY is required before ingestion.") + if not os.getenv("NEBIUS_API_KEY"): + st.warning("NEBIUS_API_KEY is required before ingestion.") elif not channel_url_input.strip(): st.warning("Please enter a YouTube channel or playlist URL.") else: @@ -129,12 +139,27 @@ def main(): ) # Get keys for main app logic - if not os.getenv("OPENAI_API_KEY"): + nebius_key = os.getenv("NEBIUS_API_KEY", "") + if not nebius_key: st.warning( - "⚠️ Please enter your OpenAI API key in the sidebar to start chatting!" + "⚠️ Please enter your Nebius API key in the sidebar to start chatting!" ) st.stop() + # Initialize Nebius model for the advisor (once) + if "nebius_model" not in st.session_state: + try: + st.session_state.nebius_model = Nebius( + id=os.getenv( + "YOUTUBE_TREND_MODEL", + "moonshotai/Kimi-K2-Instruct", + ), + api_key=nebius_key, + ) + except Exception as e: + st.error(f"Failed to initialize Nebius model: {e}") + st.stop() + # Display chat history st.markdown( "

YouTube Trend Chat

", @@ -229,9 +254,7 @@ def main(): advisor = Agent( name="YouTube Trend Advisor", - model=OpenAIChat( - id=os.getenv("YOUTUBE_TREND_MODEL", "gpt-4o-mini") - ), + model=st.session_state.nebius_model, markdown=True, ) diff --git a/memory_agents/youtube_trend_agent/assets/Nebius_Logo.png b/memory_agents/youtube_trend_agent/assets/Nebius_Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0d7851e8813bf05e199fabb252c150ee0e9719e0 GIT binary patch literal 21330 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 Ze+KV{_d4q4XL$es002ovPDHLkV1g&BK$QRh literal 0 HcmV?d00001 diff --git a/memory_agents/youtube_trend_agent/core.py b/memory_agents/youtube_trend_agent/core.py index 9f5a7aa1..42f4f47d 100644 --- a/memory_agents/youtube_trend_agent/core.py +++ b/memory_agents/youtube_trend_agent/core.py @@ -2,7 +2,7 @@ Core logic for the YouTube Trend Analysis Agent. This module contains: -- Memori + OpenAI initialization helpers. +- Memori + Nebius initialization helpers. - YouTube scraping utilities. - Exa-based trend fetching. - Channel ingestion into Memori. @@ -38,18 +38,18 @@ def error(self, msg): pass -def init_memori_with_openai() -> Memori | None: +def init_memori_with_nebius() -> Memori | None: """ - Initialize Memori v3 + OpenAI client, mirroring the customer_support/ai_consultant pattern. + Initialize Memori v3 + Nebius client (via the OpenAI SDK). This is used so Memori can automatically persist "memories" when we send - documents through the registered OpenAI client. Agno + OpenAIChat power all + documents through the registered Nebius-backed client. Agno + Nebius power all YouTube analysis and idea generation. """ - openai_key = os.getenv("OPENAI_API_KEY", "") - if not openai_key: + nebius_key = os.getenv("NEBIUS_API_KEY", "") + if not nebius_key: st.warning( - "OPENAI_API_KEY is not set – Memori v3 ingestion will not be active." + "NEBIUS_API_KEY is not set – Memori v3 ingestion will not be active." ) return None @@ -68,14 +68,18 @@ def init_memori_with_openai() -> Memori | None: SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - client = OpenAI(api_key=openai_key) + client = OpenAI( + base_url="https://api.studio.nebius.com/v1/", + api_key=nebius_key, + ) + # Use the OpenAI-compatible registration API; the client itself points to Nebius. mem = Memori(conn=SessionLocal).openai.register(client) # Attribution so Memori can attach memories to this process/entity. mem.attribution(entity_id="youtube-channel", process_id="youtube-trend-agent") mem.config.storage.build() st.session_state.memori = mem - st.session_state.openai_client = client + st.session_state.nebius_client = client return mem except Exception as e: st.warning(f"Memori v3 initialization note: {e}") @@ -229,15 +233,15 @@ def ingest_channel_into_memori(channel_url: str) -> int: Returns: Number of video documents ingested. """ - # Ensure Memori + OpenAI client are initialized + # Ensure Memori + Nebius client are initialized memori: Memori | None = st.session_state.get("memori") - client: OpenAI | None = st.session_state.get("openai_client") + client: OpenAI | None = st.session_state.get("nebius_client") if memori is None or client is None: - memori = init_memori_with_openai() - client = st.session_state.get("openai_client") + memori = init_memori_with_nebius() + client = st.session_state.get("nebius_client") if memori is None or client is None: - st.error("Memori/OpenAI failed to initialize; cannot ingest channel.") + st.error("Memori/Nebius failed to initialize; cannot ingest channel.") return 0 videos = fetch_channel_videos(channel_url) @@ -288,10 +292,13 @@ def ingest_channel_into_memori(channel_url: str) -> int: """ try: - # Send this document through the registered OpenAI client so that + # Send this document through the registered Nebius client so that # Memori v3 can automatically capture it as a "memory". _ = client.chat.completions.create( - model="gpt-4o-mini", + model=os.getenv( + "YOUTUBE_TREND_INGEST_MODEL", + "moonshotai/Kimi-K2-Instruct", + ), messages=[ { "role": "user", @@ -306,7 +313,7 @@ def ingest_channel_into_memori(channel_url: str) -> int: ) ingested += 1 except Exception as e: - st.warning(f"Memori/OpenAI issue ingesting video '{title}': {e}") + st.warning(f"Memori/Nebius issue ingesting video '{title}': {e}") # Flush writes if needed try: diff --git a/memory_agents/youtube_trend_agent/pyproject.toml b/memory_agents/youtube_trend_agent/pyproject.toml index 0f12a66f..302fda93 100644 --- a/memory_agents/youtube_trend_agent/pyproject.toml +++ b/memory_agents/youtube_trend_agent/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "youtube-trend-agent" version = "0.1.0" -description = "YouTube Trend Analysis Agent with Memori, Agno (OpenAI), Exa, and yt-dlp" +description = "YouTube Trend Analysis Agent with Memori, Agno (Nebius), Exa, and yt-dlp" readme = "README.md" requires-python = ">=3.11" dependencies = [