diff --git a/README.md b/README.md index 153ae59..0e99793 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ A curated collection of **Awesome LLM apps built with RAG, AI Agents, Multi-agen * [🧠 AI Mental Wellbeing Agent](advanced_ai_agents/multi_agent_apps/ai_mental_wellbeing_agent/) * [📑 AI Meeting Agent](advanced_ai_agents/single_agent_apps/ai_meeting_agent/) * [🧬 AI Self-Evolving Agent](advanced_ai_agents/multi_agent_apps/ai_Self-Evolving_agent/) +* [🎧 AI Social Media News and Podcast Agent](advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/) ### 🎮 Autonomous Game Playing Agents diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/audio_generate_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/audio_generate_agent.py new file mode 100644 index 0000000..71e2e23 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/audio_generate_agent.py @@ -0,0 +1,359 @@ +from agno.agent import Agent +import os +from datetime import datetime +import tempfile +import numpy as np +import soundfile as sf +from typing import Any, Dict, List, Optional, Tuple +from utils.load_api_keys import load_api_key +from openai import OpenAI +from scipy import signal + + +PODCASTS_FOLDER = "podcasts" +PODCAST_AUDIO_FOLDER = os.path.join(PODCASTS_FOLDER, "audio") +PODCAST_MUSIC_FOLDER = os.path.join('static', "musics") +OPENAI_VOICES = {1: "alloy", 2: "echo", 3: "fable", 4: "onyx", 5: "nova", 6: "shimmer"} +DEFAULT_VOICE_MAP = {1: "alloy", 2: "nova"} +TTS_MODEL = "gpt-4o-mini-tts" +INTRO_MUSIC_FILE = os.path.join(PODCAST_MUSIC_FOLDER, "intro_audio.mp3") +OUTRO_MUSIC_FILE = os.path.join(PODCAST_MUSIC_FOLDER, "intro_audio.mp3") + + +def resample_audio_scipy(audio, original_sr, target_sr): + if original_sr == target_sr: + return audio + resampling_ratio = target_sr / original_sr + num_samples = int(len(audio) * resampling_ratio) + resampled = signal.resample(audio, num_samples) + return resampled + + +def create_silence_audio(silence_duration: float, sampling_rate: int) -> np.ndarray: + if sampling_rate <= 0: + print(f"Invalid sampling rate ({sampling_rate}) for silence generation") + return np.zeros(0, dtype=np.float32) + return np.zeros(int(sampling_rate * silence_duration), dtype=np.float32) + + +def combine_audio_segments(audio_segments: List[np.ndarray], silence_duration: float, sampling_rate: int) -> np.ndarray: + if not audio_segments: + return np.zeros(0, dtype=np.float32) + silence = create_silence_audio(silence_duration, sampling_rate) + combined_segments = [] + for i, segment in enumerate(audio_segments): + combined_segments.append(segment) + if i < len(audio_segments) - 1: + combined_segments.append(silence) + combined = np.concatenate(combined_segments) + max_amp = np.max(np.abs(combined)) + if max_amp > 0: + combined = combined / max_amp * 0.95 + return combined + + +def process_audio_file(temp_path: str) -> Optional[Tuple[np.ndarray, int]]: + try: + from pydub import AudioSegment + + audio_segment = AudioSegment.from_mp3(temp_path) + channels = audio_segment.channels + sample_width = audio_segment.sample_width + frame_rate = audio_segment.frame_rate + samples = np.array(audio_segment.get_array_of_samples()) + if channels == 2: + samples = samples.reshape(-1, 2).mean(axis=1) + max_possible_value = float(2 ** (8 * sample_width - 1)) + samples = samples.astype(np.float32) / max_possible_value + return samples, frame_rate + except ImportError: + print("Pydub not available, falling back to soundfile") + except Exception as e: + print(f"Pydub processing failed: {e}") + try: + audio_np, samplerate = sf.read(temp_path) + return audio_np, samplerate + except Exception as e: + print(f"Failed to process audio with soundfile: {e}") + try: + from pydub import AudioSegment + + sound = AudioSegment.from_mp3(temp_path) + wav_path = temp_path.replace(".mp3", ".wav") + sound.export(wav_path, format="wav") + audio_np, samplerate = sf.read(wav_path) + os.unlink(wav_path) + return audio_np, samplerate + except Exception as e: + print(f"All audio processing methods failed: {e}") + return None + + +def resample_audio(audio, orig_sr, target_sr): + try: + import librosa + + return librosa.resample(audio, orig_sr=orig_sr, target_sr=target_sr) + except ImportError: + print("Librosa not available for resampling") + return audio + except Exception as e: + print(f"Resampling failed: {e}") + return audio + + +def text_to_speech_openai( + client: OpenAI, + text: str, + speaker_id: int, + voice_map: Dict[int, str] = None, + model: str = TTS_MODEL, +) -> Optional[Tuple[np.ndarray, int]]: + if not text.strip(): + print("Empty text provided, skipping TTS generation") + return None + voice_map = voice_map or DEFAULT_VOICE_MAP + voice = voice_map.get(speaker_id) + if not voice: + if speaker_id in OPENAI_VOICES: + voice = OPENAI_VOICES[speaker_id] + else: + voice = next(iter(voice_map.values()), "alloy") + print(f"No voice mapping for speaker {speaker_id}, using {voice}") + try: + print(f"Generating TTS for speaker {speaker_id} using voice '{voice}'") + response = client.audio.speech.create( + model=model, + voice=voice, + input=text, + response_format="mp3", + ) + audio_data = response.content + if not audio_data: + print("OpenAI TTS returned empty response") + return None + print(f"Received {len(audio_data)} bytes from OpenAI TTS") + temp_file = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) + temp_path = temp_file.name + temp_file.close() + with open(temp_path, "wb") as f: + f.write(audio_data) + try: + return process_audio_file(temp_path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + except Exception as e: + print(f"OpenAI TTS API error: {e}") + import traceback + + traceback.print_exc() + return None + + +def create_podcast( + script: Any, + output_path: str, + tts_engine: str = "openai", + language_code: str = "en", + silence_duration: float = 0.7, + voice_map: Dict[int, str] = None, + model: str = TTS_MODEL, +) -> Optional[str]: + if tts_engine.lower() != "openai": + print(f"Only OpenAI TTS engine is available in this standalone version. Requested: {tts_engine}") + return None + try: + api_key = load_api_key("OPENAI_API_KEY") + if not api_key: + print("No OpenAI API key provided") + return None + client = OpenAI(api_key=api_key) + print("OpenAI client initialized") + except Exception as e: + print(f"Failed to initialize OpenAI client: {e}") + return None + output_path = os.path.abspath(output_path) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + if voice_map is None: + voice_map = DEFAULT_VOICE_MAP.copy() + model_to_use = model + if model == "tts-1" and language_code == "en": + model_to_use = "tts-1-hd" + print(f"Using high-definition TTS model for English: {model_to_use}") + generated_segments = [] + sampling_rate_detected = None + + if hasattr(script, "entries"): + entries = script.entries + else: + entries = script + + print(f"Processing {len(entries)} script entries") + for i, entry in enumerate(entries): + if hasattr(entry, "speaker"): + speaker_id = entry.speaker + entry_text = entry.text + else: + speaker_id = entry["speaker"] + entry_text = entry["text"] + print(f"Processing entry {i + 1}/{len(entries)}: Speaker {speaker_id}") + result = text_to_speech_openai( + client=client, + text=entry_text, + speaker_id=speaker_id, + voice_map=voice_map, + model=model_to_use, + ) + if result: + segment_audio, segment_rate = result + if sampling_rate_detected is None: + sampling_rate_detected = segment_rate + print(f"Using sample rate: {sampling_rate_detected} Hz") + elif sampling_rate_detected != segment_rate: + print(f"Sample rate mismatch: {sampling_rate_detected} vs {segment_rate}") + try: + segment_audio = resample_audio(segment_audio, segment_rate, sampling_rate_detected) + print(f"Resampled to {sampling_rate_detected} Hz") + except Exception as e: + sampling_rate_detected = segment_rate + print(f"Resampling failed: {e}") + generated_segments.append(segment_audio) + else: + print(f"Failed to generate audio for entry {i + 1}") + if not generated_segments: + print("No audio segments were generated") + return None + if sampling_rate_detected is None: + print("Could not determine sample rate") + return None + print(f"Combining {len(generated_segments)} audio segments") + full_audio = combine_audio_segments(generated_segments, silence_duration, sampling_rate_detected) + if full_audio.size == 0: + print("Combined audio is empty") + return None + + try: + if os.path.exists(INTRO_MUSIC_FILE): + intro_music, intro_sr = sf.read(INTRO_MUSIC_FILE) + print(f"Loaded intro music: {len(intro_music) / intro_sr:.1f} seconds") + + if intro_music.ndim == 2: + intro_music = np.mean(intro_music, axis=1) + + if intro_sr != sampling_rate_detected: + intro_music = resample_audio_scipy(intro_music, intro_sr, sampling_rate_detected) + + full_audio = np.concatenate([intro_music, full_audio]) + print("Added intro music") + + if os.path.exists(OUTRO_MUSIC_FILE): + outro_music, outro_sr = sf.read(OUTRO_MUSIC_FILE) + print(f"Loaded outro music: {len(outro_music) / outro_sr:.1f} seconds") + + if outro_music.ndim == 2: + outro_music = np.mean(outro_music, axis=1) + + if outro_sr != sampling_rate_detected: + outro_music = resample_audio_scipy(outro_music, outro_sr, sampling_rate_detected) + + full_audio = np.concatenate([full_audio, outro_music]) + print("Added outro music") + + except Exception as e: + print(f"Could not add intro/outro music: {e}") + print("Continuing without background music") + + print(f"Writing audio to {output_path}") + try: + sf.write(output_path, full_audio, sampling_rate_detected) + except Exception as e: + print(f"Failed to write audio file: {e}") + return None + if os.path.exists(output_path): + file_size = os.path.getsize(output_path) + print(f"Audio file created: {output_path} ({file_size / 1024:.1f} KB)") + return output_path + else: + print(f"Failed to create audio file at {output_path}") + return None + + +def audio_generate_agent_run(agent: Agent) -> str: + """ + Generate an audio file for the podcast using the selected TTS engine. + + Args: + agent: The agent instance + + Returns: + A message with the result of audio generation + """ + from services.internal_session_service import SessionService + + session_id = agent.session_id + session = SessionService.get_session(session_id) + session_state = session["state"] + script_data = session_state.get("generated_script", {}) + if not script_data or (isinstance(script_data, dict) and not script_data.get("sections")): + error_msg = "Cannot generate audio: No podcast script data found. Please generate a script first." + print(error_msg) + return error_msg + if isinstance(script_data, dict): + podcast_title = script_data.get("title", "Your Podcast") + else: + podcast_title = "Your Podcast" + session_state["stage"] = "audio" + audio_dir = PODCAST_AUDIO_FOLDER + audio_filename = f"podcast_{datetime.now().strftime('%Y%m%d_%H%M%S')}.wav" + audio_path = os.path.join(audio_dir, audio_filename) + try: + if isinstance(script_data, dict) and "sections" in script_data: + speaker_map = {"ALEX": 1, "MORGAN": 2} + script_entries = [] + for section in script_data.get("sections", []): + for dialog in section.get("dialog", []): + speaker = dialog.get("speaker", "ALEX") + text = dialog.get("text", "") + + if text and speaker in speaker_map: + script_entries.append({"text": text, "speaker": speaker_map[speaker]}) + if not script_entries: + error_msg = "Cannot generate audio: No dialog found in the script." + print(error_msg) + return error_msg + + selected_language = session_state.get("selected_language", {"code": "en", "name": "English"}) + language_code = selected_language.get("code", "en") + language_name = selected_language.get("name", "English") + tts_engine = "openai" + if tts_engine == "openai" and not load_api_key("OPENAI_API_KEY"): + error_msg = "Cannot generate audio: OpenAI API key not found." + print(error_msg) + return error_msg + print(f"Generating podcast audio using {tts_engine} TTS engine in {language_name} language") + full_audio_path = create_podcast( + script=script_entries, + output_path=audio_path, + tts_engine=tts_engine, + language_code=language_code, + ) + if not full_audio_path: + error_msg = f"Failed to generate podcast audio with {tts_engine} TTS engine." + print(error_msg) + return error_msg + + audio_url = f"{os.path.basename(full_audio_path)}" + session_state["audio_url"] = audio_url + session_state["show_audio_for_confirmation"] = True + SessionService.save_session(session_id, session_state) + print(f"Successfully generated podcast audio: {full_audio_path}") + return f"I've generated the audio for your '{podcast_title}' podcast using {tts_engine.capitalize()} voices in {language_name}. You can listen to it in the player below. What do you think? If it sounds good, click 'Sounds Great!' to complete your podcast." + else: + error_msg = "Cannot generate audio: Script is not in the expected format." + print(error_msg) + return error_msg + except Exception as e: + error_msg = f"Error generating podcast audio: {str(e)}" + print(error_msg) + return f"I encountered an error while generating the podcast audio: {str(e)}. Please try again or let me know if you'd like to proceed without audio." \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/image_generate_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/image_generate_agent.py new file mode 100644 index 0000000..a108896 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/image_generate_agent.py @@ -0,0 +1,100 @@ +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from agno.tools.dalle import DalleTools +from textwrap import dedent +import json +from dotenv import load_dotenv +import uuid +from db.agent_config_v2 import PODCAST_IMG_DIR +import os +import requests + + +load_dotenv() + +IMAGE_GENERATION_AGENT_DESCRIPTION = "You are an AI agent that can generate images using DALL-E." +IMAGE_GENERATION_AGENT_INSTRUCTIONS = dedent(""" + When the user asks you to create an image, use the `create_image` tool to create the image. + Create a modern, eye-catching podcast cover images that represents a podcast given podcast topic. + Create 3 images for the given podcast topic. + + IMPORTANT INSTRUCTIONS: + - DO NOT include ANY text in the image + - DO NOT include any words, titles, or lettering + - Create a purely visual and symbolic representation + - Use imagery that represents the specific topics mentioned + - I like Studio Ghibli flavor if possible + - The image should work well as a podcast cover thumbnail + - Create a clean, professional design suitable for a podcast + - AGAIN, DO NOT INCLUDE ANY TEXT + """) + + +def download_images(image_urls): + local_image_filenames = [] + try: + if image_urls: + for image_url in image_urls: + unique_id = str(uuid.uuid4()) + filename = f"podcast_banner_{unique_id}.png" + os.makedirs(PODCAST_IMG_DIR, exist_ok=True) + print(f"Downloading image: {filename}") + response = requests.get(image_url, timeout=30) + response.raise_for_status() + image_path = os.path.join(PODCAST_IMG_DIR, filename) + with open(image_path, "wb") as f: + f.write(response.content) + local_image_filenames.append(filename) + print(f"Successfully downloaded: {filename}") + except requests.exceptions.RequestException as e: + print(f"Error downloading images (network): {e}") + except Exception as e: + print(f"Error downloading images: {e}") + + return local_image_filenames + + +def image_generation_agent_run(agent: Agent, query: str) -> str: + """ + Image Generation Agent that takes the generated_script (internally from session_state) and creates a images for the given podcast script. + + Args: + agent: The agent instance + query: any custom preferences for the image generation + Returns: + Response status + """ + from services.internal_session_service import SessionService + + session_id = agent.session_id + session = SessionService.get_session(session_id) + session_state = session["state"] + print("Image Generation Agent input: ", query) + + try: + image_agent = Agent( + model=OpenAIChat(id="gpt-4o"), + tools=[DalleTools()], + description=IMAGE_GENERATION_AGENT_DESCRIPTION, + instructions=IMAGE_GENERATION_AGENT_INSTRUCTIONS, + markdown=True, + show_tool_calls=True, + session_id=agent.session_id, + ) + image_agent.run(f"query: {query},\n podcast script: {json.dumps(session_state['generated_script'])}", session_id=agent.session_id) + images = image_agent.get_images() + image_urls = [] + if images and isinstance(images, list): + for image_response in images: + image_url = image_response.url + image_urls.append(image_url) + local_image_filenames = download_images(image_urls) + session_state["banner_images"] = local_image_filenames + if local_image_filenames: + session_state["banner_url"] = local_image_filenames[0] + except Exception as e: + print(f"Error in Image Generation Agent: {e}") + return "Error in Image Generation Agent" + session_state["stage"] = "image" + SessionService.save_session(session_id, session_state) + return "Required banner images for the podcast are generated successfully." \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/scrape_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/scrape_agent.py new file mode 100644 index 0000000..84e7310 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/scrape_agent.py @@ -0,0 +1,148 @@ +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from pydantic import BaseModel, Field +from dotenv import load_dotenv +from tools.browser_crawler import create_browser_crawler +from textwrap import dedent + + +load_dotenv() + + +class ScrapedContent(BaseModel): + url: str = Field(..., description="The URL of the search result") + description: str = Field(description="The description of the search result") + full_text: str = Field( + ..., + description="The full text of the given source URL, if not available or not applicable keep it empty", + ) + published_date: str = Field( + ..., + description="The published date of the content in ISO format, if not available keep it empty", + ) + + +SCRAPE_AGENT_DESCRIPTION = "You are a helpful assistant that can scrape the URL for full content." +SCRAPE_AGENT_INSTRUCTIONS = dedent(""" + You are a content verification and formatting assistant. + + You will receive a batch of pre-scraped content from various URLs along with a search query. + Your job is to: + + 1. VERIFY RELEVANCE: Ensure each piece of content is relevant to the given query + 2. QUALITY CONTROL: Filter out low-quality, duplicate, or irrelevant content + 3. FORMAT CONSISTENCY: Ensure all content follows a consistent format + 4. LENGTH OPTIMIZATION: Keep content at reasonable length - not too long, not too short + 5. CLEAN TEXT: Remove any formatting artifacts, ads, or navigation elements from scraped content + + For each piece of content, return: + - full_text: The cleaned, relevant text content (or empty if not relevant/low quality) + - published_date: The publication date in ISO format (or empty if not available) + + Note: Some content may be fallback descriptions (when scraping failed) - treat these appropriately and don't penalize them for being shorter. + + IMPORTANT: Focus on quality over quantity. It's better to return fewer high-quality, relevant pieces than many low-quality ones. + """) + + +def crawl_urls_batch(search_results): + url_to_search_results = {} + unique_urls = [] + for search_result in search_results: + if not search_result.get("url", False): + continue + if not search_result.get("is_scrapping_required", True): + continue + if not search_result.get('original_url'): + search_result['original_url'] = search_result['url'] + url = search_result["url"] + if url not in url_to_search_results: + url_to_search_results[url] = [] + unique_urls.append(url) + url_to_search_results[url].append(search_result) + browser_crawler = create_browser_crawler() + scraped_results = browser_crawler.scrape_urls(unique_urls) + url_to_scraped = {result["original_url"]: result for result in scraped_results} + updated_search_results = [] + successful_scrapes = 0 + failed_scrapes = 0 + for search_result in search_results: + original_url = search_result["url"] + scraped = url_to_scraped.get(original_url, {}) + updated_result = search_result.copy() + updated_result["original_url"] = original_url + if scraped.get("success", False): + updated_result["url"] = scraped.get("final_url", original_url) + updated_result["full_text"] = scraped.get("full_text", "") + updated_result["published_date"] = scraped.get("published_date", "") + successful_scrapes += 1 + else: + updated_result["url"] = original_url + updated_result["full_text"] = search_result.get("description", "") + updated_result["published_date"] = "" + failed_scrapes += 1 + updated_search_results.append(updated_result) + return updated_search_results, successful_scrapes, failed_scrapes + + +def verify_content_with_agent(agent, query, search_results, use_agent=True): + if not use_agent: + return search_results + verified_search_results = [] + for _, search_result in enumerate(search_results): + content_for_verification = { + "url": search_result["url"], + "description": search_result.get("description", ""), + "full_text": search_result["full_text"], + "published_date": search_result["published_date"], + } + search_result["agent_verified"] = False + try: + scrape_agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=SCRAPE_AGENT_INSTRUCTIONS, + description=SCRAPE_AGENT_DESCRIPTION, + use_json_mode=True, + session_id=agent.session_id, + response_model=ScrapedContent, + ) + response = scrape_agent.run( + f"Query: {query}\n" + f"Verify and format this scraped content. " + f"Keep content relevant to the query and ensure quality: {content_for_verification}", + session_id=agent.session_id, + ) + verified_item = response.to_dict()["content"] + search_result["full_text"] = verified_item.get("full_text", search_result["full_text"]) + search_result["published_date"] = verified_item.get("published_date", search_result["published_date"]) + search_result["agent_verified"] = True + except Exception as _: + pass + verified_search_results.append(search_result) + return verified_search_results + + +def scrape_agent_run( + agent: Agent, + query: str, +) -> str: + """ + Scrape Agent that takes the search_results (internaly from search_results) and scrapes each URL for full content, making sure those contents are of high quality and relevant to the topic. + Args: + agent: The agent instance + query: The search query + Returns: + Response status + """ + print("Scrape Agent Input:", query) + session_id = agent.session_id + from services.internal_session_service import SessionService + + session = SessionService.get_session(session_id) + current_state = session["state"] + updated_results, _, _ = crawl_urls_batch(current_state["search_results"]) + verified_results = verify_content_with_agent(agent, query, updated_results, use_agent=False) + current_state["search_results"] = verified_results + SessionService.save_session(session_id, current_state) + has_results = "search_results" in current_state and current_state["search_results"] + return f"Scraped {len(current_state['search_results'])} sources with full content relevant to '{query}'{' and updated the full text and published date in the search_results items' if has_results else ''}." \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/script_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/script_agent.py new file mode 100644 index 0000000..11d7ef9 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/script_agent.py @@ -0,0 +1,135 @@ +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from pydantic import BaseModel, Field +from typing import List, Optional +from dotenv import load_dotenv +from textwrap import dedent +from datetime import datetime + +load_dotenv() + + +class Dialog(BaseModel): + speaker: str = Field(..., description="The speaker name (SHOULD BE 'ALEX' OR 'MORGAN')") + text: str = Field( + ..., + description="The spoken text content for this speaker based on the requested langauge, default is English", + ) + + +class Section(BaseModel): + type: str = Field(..., description="The section type (intro, headlines, article, outro)") + title: Optional[str] = Field(None, description="Optional title for the section (required for article type)") + dialog: List[Dialog] = Field(..., description="List of dialog exchanges between speakers") + + +class PodcastScript(BaseModel): + title: str = Field(..., description="The podcast episode title with date") + sections: List[Section] = Field(..., description="List of podcast sections (intro, headlines, articles, outro)") + + +PODCAST_AGENT_DESCRIPTION = "You are a helpful assistant that can generate engaging podcast scripts for the given sources." +PODCAST_AGENT_INSTRUCTIONS = dedent(""" + You are a helpful assistant that can generate engaging podcast scripts for the given source content and query. + For given content, create an engaging podcast script that should be at least 15 minutes worth of content and your allowed enhance the script beyond given sources if you know something additional info will be interesting to the discussion or not enough conents available. + You use the provided sources to ground your podcast script generation process. Keep it engaging and interesting. + + IMPORTANT: Generate the entire script in the provided language. basically only text field needs to be in requested language, + + CONTENT GUIDELINES [THIS IS EXAMPLE YOU CAN CHANGE THE GUIDELINES ANYWAY BASED ON THE QUERY OR TOPIC DISCUSSED]: + - Provide insightful analysis that helps the audience understand the significance + - Include discussions on potential implications and broader context of each story + - Explain complex concepts in an accessible but thorough manner + - Make connections between current and relevant historical developments when applicable + - Provide comparisons and contrasts with similar stories or trends when relevant + + PERSONALITY NOTES [THIS IS EXAMPLE YOU CAN CHANGE THE PERSONALITY OF ALEX AND MORGAN ANYWAY BASED ON THE QUERY OR TOPIC DISCUSSED]: + - Alex is more analytical and fact-focused + * Should reference specific details and data points + * Should explain complex topics clearly + * Should identify key implications of stories + - Morgan is more focused on human impact, social context, and practical applications + * Should analyze broader implications + * Should consider ethical implications and real-world applications + - Include natural, conversational banter and smooth transitions between topics + - Each article discussion should go beyond the basic summary to provide valuable insights + - Maintain a conversational but informed tone that would appeal to a general audience + + IMPORTNAT: + - MAKE SURE PODCAST SCRIPS ARE AT LEAST 15 MINUTES LONG WHICH MEANS YOU NEED TO HAVE DETAILED DISCUSSIONS OFFCOURSE KEEP IT INTERESTING AND ENGAGING. + """) + + +def format_search_results_for_podcast( + search_results: List[dict], +) -> tuple[str, List[str]]: + created_at = datetime.now().strftime("%B %d, %Y at %I:%M %p") + structured_content = [] + structured_content.append(f"PODCAST CREATION: {created_at}\n") + sources = [] + for idx, search_result in enumerate(search_results): + try: + if search_result.get("confirmed", False): + sources.append(search_result["url"]) + structured_content.append( + f""" + SOURCE {idx + 1}: + Title: {search_result['title']} + URL: {search_result['url']} + Content: {search_result.get('full_text') or search_result.get('description', '')} + ---END OF SOURCE {idx + 1}--- + """.strip() + ) + except Exception as e: + print(f"Error processing search result: {e}") + content_texts = "\n\n".join(structured_content) + return content_texts, sources + + +def podcast_script_agent_run( + agent: Agent, + query: str, + language_name: str, +) -> str: + """ + Podcast Script Agent that takes the search_results (internally from search_results) and creates a podcast script for the given query and language. + + Args: + agent: The agent instance + query: The search query + language_name: The language the podcast script should be. + Returns: + Response status + """ + from services.internal_session_service import SessionService + session_id = agent.session_id + session = SessionService.get_session(session_id) + session_state = session["state"] + + print("Podcast Script Agent Input:", query) + content_texts, sources = format_search_results_for_podcast(session_state.get("search_results", [])) + if not content_texts: + return "No confirmed sources found to generate podcast script." + + podcast_script_agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=PODCAST_AGENT_INSTRUCTIONS, + description=PODCAST_AGENT_DESCRIPTION, + use_json_mode=True, + response_model=PodcastScript, + session_id=agent.session_id, + ) + response = podcast_script_agent.run( + f"query: {query}\n language_name: {language_name}\n content_texts: {content_texts}\n, IMPORTANT: texts should be in {language_name} language.", + session_id=agent.session_id, + ) + response_dict = response.to_dict() + response_dict = response_dict["content"] + response_dict["sources"] = sources + session_state["generated_script"] = response_dict + session_state['stage'] = 'script' + SessionService.save_session(session_id, session_state) + + if not session_state["generated_script"] and not session_state["generated_script"].get("sections"): + return "Failed to generate podcast script." + return f"Generated podcast script for '{query}' with {len(sources)} confirmed sources." \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/search_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/search_agent.py new file mode 100644 index 0000000..c193b60 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/agents/search_agent.py @@ -0,0 +1,105 @@ +from typing import List +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from pydantic import BaseModel, Field +from dotenv import load_dotenv +from agno.tools.duckduckgo import DuckDuckGoTools +from textwrap import dedent +from tools.wikipedia_search import wikipedia_search +from tools.google_news_discovery import google_news_discovery_run +from tools.jikan_search import jikan_search +from tools.embedding_search import embedding_search +from tools.social_media_search import social_media_search, social_media_trending_search +from tools.search_articles import search_articles +from tools.web_search import run_browser_search + + +load_dotenv() + + +class ReturnItem(BaseModel): + url: str = Field(..., description="The URL of the search result") + title: str = Field(..., description="The title of the search result") + description: str = Field(..., description="A brief description or summary of the search result content") + source_name: str = Field( + ..., + description="The name/type of the source (e.g., 'wikipedia', 'general', or any reputable source tag)", + ) + tool_used: str = Field( + ..., + description="The tools used to generate the search results, unknown if not used or not applicable", + ) + published_date: str = Field( + ..., + description="The published date of the content in ISO format, if not available keep it empty", + ) + is_scrapping_required: bool = Field( + ..., + description="Set to True if the content need scraping, False otherwise, default keep it True if not sure", + ) + + +class SearchResults(BaseModel): + items: List[ReturnItem] = Field(..., description="A list of search result items") + + +SEARCH_AGENT_DESCRIPTION = "You are a helpful assistant that can search the web for information." +SEARCH_AGENT_INSTRUCTIONS = dedent(""" + You are a helpful assistant that can search the web or any other sources for information. + You should create topic for the search from the given query instead of blindly apply the query to the search tools. + For a given topic, your job is to search the web or any other sources and return the top 5 to 10 sources about the topic. + Keep the search sources of high quality and reputable, and sources should be relevant to the asked topic. + Sources should be from diverse platforms with no duplicates. + IMPORTANT: User queries might be fuzzy or misspelled. Understand the user's intent and act accordingly. + IMPORTANT: The output source_name field can be one of ["wikipedia", "general", or any source tag used"]. + IMPORTANT: You have access to different search tools use them when appropriate which one is best for the given search query. Don't use particular tool if not required. + IMPORTANT: Make sure you are able to detect what tool to use and use it available tool tags = ["google_news_discovery", "duckduckgo", "wikipedia_search", "jikan_search", "social_media_search", "social_media_trending_search", "browser_search", "unknown"]. + IMPORTANT: If query is news related please prefere google news over other news tools. + IMPORTANT: If returned sources are not of high quality or not relevant to the asked topic, don't include them in the returned sources. + IMPORTANT: Never include dates to the search query unless user explicitly asks for it. + IMPORTANT: You are allowed to use appropriate tools to get the best results even the single tool return enough results diverse check is better. + IMPORTANT: You have access to browser agent for searching as well use it when other source can't suitable for the given tasks but input should detailed instruction to the run_browser_search agent to get the best results and also use it conservatively because it's expensive process. + """) + + +def search_agent_run(agent: Agent, query: str) -> str: + """ + Search Agent which searches the web and other sources for relevant sources about the given topic or query. + Args: + agent: The agent instance + query: The search query + Returns: + A formatted string response with the search results (link and gist only) + """ + print("Search Agent Input:", query) + session_id = agent.session_id + from services.internal_session_service import SessionService + + session = SessionService.get_session(session_id) + current_state = session["state"] + search_agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=SEARCH_AGENT_INSTRUCTIONS, + description=SEARCH_AGENT_DESCRIPTION, + use_json_mode=True, + response_model=SearchResults, + tools=[ + google_news_discovery_run, + DuckDuckGoTools(), + wikipedia_search, + jikan_search, + embedding_search, + social_media_search, + social_media_trending_search, + search_articles, + run_browser_search, + ], + session_id=session_id, + ) + response = search_agent.run(query, session_id=session_id) + response_dict = response.to_dict() + current_state["stage"] = "search" + current_state["search_results"] = response_dict["content"]["items"] + SessionService.save_session(session_id, current_state) + has_results = "search_results" in current_state and current_state["search_results"] + return f"Found {len(response_dict['content']['items'])} sources about {query} {'and added to the search_results' if has_results else ''}" \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/bootstrap_demo.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/bootstrap_demo.py new file mode 100644 index 0000000..f95fc7e --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/bootstrap_demo.py @@ -0,0 +1,75 @@ +import os +import sys +import tempfile +import requests +import zipfile +from tqdm import tqdm + +DEMO_URL = "https://github.com/arun477/beifong/releases/download/v1.2.0/demo_content.zip" +TARGET_DIRS = ["databases", "podcasts"] + + +def ensure_empty(dir_path): + """check if directory is empty (or create it). exit if not empty.""" + if os.path.exists(dir_path): + if os.listdir(dir_path): + print(f"✗ '{dir_path}' is not empty. aborting.") + sys.exit(1) + else: + os.makedirs(dir_path, exist_ok=True) + + +def download_file(url, dest_path): + """stream-download a file from url to dest_path with progress bar.""" + print("↓ downloading demo content...") + + response = requests.get(url, stream=True) + response.raise_for_status() + + + total_size = int(response.headers.get('content-length', 0)) + block_size = 8192 + + with open(dest_path, "wb") as f: + with tqdm(total=total_size, unit='B', unit_scale=True, desc="Download Progress") as pbar: + for chunk in response.iter_content(chunk_size=block_size): + if chunk: + f.write(chunk) + pbar.update(len(chunk)) + + +def extract_zip(zip_path, extract_to): + """extract zip file into extract_to (project root) with progress bar.""" + print("✂ extracting demo content...") + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + total_files = len(zip_ref.infolist()) + + with tqdm(total=total_files, desc="Extraction Progress") as pbar: + for file in zip_ref.infolist(): + zip_ref.extract(file, extract_to) + pbar.update(1) + + +def main(): + print("populating demo folders…") + for d in TARGET_DIRS: + ensure_empty(d) + + with tempfile.TemporaryDirectory() as tmp: + tmp_zip = os.path.join(tmp, "demo_content.zip") + download_file(DEMO_URL, tmp_zip) + extract_zip(tmp_zip, os.getcwd()) + + print("✓ demo folders populated successfully.") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n✗ Download cancelled by user.") + sys.exit(1) + except Exception as e: + print(f"\n✗ Error: {e}") + sys.exit(1) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/celery_worker.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/celery_worker.py new file mode 100644 index 0000000..67f66ef --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/celery_worker.py @@ -0,0 +1,13 @@ +from services.celery_tasks import app + +worker_options = [ + "worker", + "--loglevel=INFO", + "--concurrency=4", + "--hostname=beifong_worker@%h", + "--pool=threads", +] + +if __name__ == "__main__": + print("Starting Beifong podcast agent workers...") + app.worker_main(worker_options) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/agent_config_v2.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/agent_config_v2.py new file mode 100644 index 0000000..19aaa5d --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/agent_config_v2.py @@ -0,0 +1,169 @@ +from agno.storage.sqlite import SqliteStorage +from db.config import get_agent_session_db_path +import json + +AGENT_MODEL = "gpt-4o" +AVAILABLE_LANGS = [ + {"code": "en", "name": "English"}, + {"code": "zh", "name": "Chinese"}, + {"code": "de", "name": "German"}, + {"code": "es", "name": "Spanish"}, + {"code": "ru", "name": "Russian"}, + {"code": "ko", "name": "Korean"}, + {"code": "fr", "name": "French"}, + {"code": "ja", "name": "Japanese"}, + {"code": "pt", "name": "Portuguese"}, + {"code": "tr", "name": "Turkish"}, + {"code": "pl", "name": "Polish"}, + {"code": "ca", "name": "Catalan"}, + {"code": "nl", "name": "Dutch"}, + {"code": "ar", "name": "Arabic"}, + {"code": "sv", "name": "Swedish"}, + {"code": "it", "name": "Italian"}, + {"code": "id", "name": "Indonesian"}, + {"code": "hi", "name": "Hindi"}, + {"code": "fi", "name": "Finnish"}, + {"code": "vi", "name": "Vietnamese"}, + {"code": "he", "name": "Hebrew"}, + {"code": "uk", "name": "Ukrainian"}, + {"code": "el", "name": "Greek"}, + {"code": "ms", "name": "Malay"}, + {"code": "cs", "name": "Czech"}, + {"code": "ro", "name": "Romanian"}, + {"code": "da", "name": "Danish"}, + {"code": "hu", "name": "Hungarian"}, + {"code": "ta", "name": "Tamil"}, + {"code": "no", "name": "Norwegian"}, + {"code": "th", "name": "Thai"}, + {"code": "ur", "name": "Urdu"}, + {"code": "hr", "name": "Croatian"}, + {"code": "bg", "name": "Bulgarian"}, + {"code": "lt", "name": "Lithuanian"}, + {"code": "la", "name": "Latin"}, + {"code": "mi", "name": "Maori"}, + {"code": "ml", "name": "Malayalam"}, + {"code": "cy", "name": "Welsh"}, + {"code": "sk", "name": "Slovak"}, + {"code": "te", "name": "Telugu"}, + {"code": "fa", "name": "Persian"}, + {"code": "lv", "name": "Latvian"}, + {"code": "bn", "name": "Bengali"}, + {"code": "sr", "name": "Serbian"}, + {"code": "az", "name": "Azerbaijani"}, + {"code": "sl", "name": "Slovenian"}, + {"code": "kn", "name": "Kannada"}, + {"code": "et", "name": "Estonian"}, + {"code": "mk", "name": "Macedonian"}, + {"code": "br", "name": "Breton"}, + {"code": "eu", "name": "Basque"}, + {"code": "is", "name": "Icelandic"}, + {"code": "hy", "name": "Armenian"}, + {"code": "ne", "name": "Nepali"}, + {"code": "mn", "name": "Mongolian"}, + {"code": "bs", "name": "Bosnian"}, + {"code": "kk", "name": "Kazakh"}, + {"code": "sq", "name": "Albanian"}, + {"code": "sw", "name": "Swahili"}, + {"code": "gl", "name": "Galician"}, + {"code": "mr", "name": "Marathi"}, + {"code": "pa", "name": "Punjabi"}, + {"code": "si", "name": "Sinhala"}, + {"code": "km", "name": "Khmer"}, + {"code": "sn", "name": "Shona"}, + {"code": "yo", "name": "Yoruba"}, + {"code": "so", "name": "Somali"}, + {"code": "af", "name": "Afrikaans"}, + {"code": "oc", "name": "Occitan"}, + {"code": "ka", "name": "Georgian"}, + {"code": "be", "name": "Belarusian"}, + {"code": "tg", "name": "Tajik"}, + {"code": "sd", "name": "Sindhi"}, + {"code": "gu", "name": "Gujarati"}, + {"code": "am", "name": "Amharic"}, + {"code": "yi", "name": "Yiddish"}, + {"code": "lo", "name": "Lao"}, + {"code": "uz", "name": "Uzbek"}, + {"code": "fo", "name": "Faroese"}, + {"code": "ht", "name": "Haitian creole"}, + {"code": "ps", "name": "Pashto"}, + {"code": "tk", "name": "Turkmen"}, + {"code": "nn", "name": "Nynorsk"}, + {"code": "mt", "name": "Maltese"}, + {"code": "sa", "name": "Sanskrit"}, + {"code": "lb", "name": "Luxembourgish"}, + {"code": "my", "name": "Myanmar"}, + {"code": "bo", "name": "Tibetan"}, + {"code": "tl", "name": "Tagalog"}, + {"code": "mg", "name": "Malagasy"}, + {"code": "as", "name": "Assamese"}, + {"code": "tt", "name": "Tatar"}, + {"code": "haw", "name": "Hawaiian"}, + {"code": "ln", "name": "Lingala"}, + {"code": "ha", "name": "Hausa"}, + {"code": "ba", "name": "Bashkir"}, + {"code": "jw", "name": "Javanese"}, + {"code": "su", "name": "Sundanese"}, + {"code": "yue", "name": "Cantonese"}, +] + +TOGGLE_UI_STATES = [ + "show_sources_for_selection", + "show_script_for_confirmation", + "show_banner_for_confirmation", + "show_audio_for_confirmation", +] + +AGENT_DESCRIPTION = "You are name is Beifong, a helpful assistant that guides users to choose best sources for the podcast and allow them to generate the podcast script." + +# sacred commandments, touch these with devotion. +AGENT_INSTRUCTIONS = [ + "Guide users to choose the best sources for the podcast and allow them to generate the podcast script and images for the podcast and audio for the podcast.", + "1. Make sure you get the intent of the topic from the user. It can be fuzzy and contain spelling mistakes from the user, so act as intent detection and get clarification only if needed.", + "1a. Keep this phase as quick as possible. Try to avoid too many back and forth conversations. Try to infer the intent if you're confident you can go right to the next search phase without confirming with the user. The less back and forth, the better for the user experience.", + "2. Once you understand the intent of the topic, use the available search tools (we have search agent where you can pass the query and along with appropriate prompt search agent has lot of search tools and api access which you don't have so you can instruct if needed) to get diverse and high-quality sources for the topic. and also make sure give appropriate short title for the chat and update the chat title using update_chat_title tool", + "2a. Once we receive the search results. do the full scraping using appropriate scraping tool to get the full text of the each source.", + "2b. Don't do back and forth during this source collection process with the user. Either you have the results or not, then inform the user and ask the user if they want to try again or give more details about the topic.", + "3. Once you have the results, ask the user to pick which sources they want to use for the podcast. You don't have to list out the found sources; just tell them to pick from the list of sources that will be visible in the UI.", + "4. User do the selection by selecting the index of sources from the list of sources that will be visible in the UI. so response will be a list of indices of sources selected by the user. sometime user will also send prefered language for the podcast along with the selection." + "4a. You have to use user_source_selection tool to update the user selection. and after this point immediately switch off any UI states.", + "4b. If user sends prefered language for the podcast along with the selection, you have to use update_language tool to update the user language if not leave it default is english. and after this point immediately switch off any UI states.", + "5. Once you we have the confimed selection from the user let's immediatly call the podcast script agent to generate the podcast script for the given sources and query and perfered language (pass full lanage name).", + "5a. Once podcast script is ready switch on the podcast_script UI state and so user will see if the generated podcast script is fine or not, you dont' have to show the script active podasshow_script_for_confirmation will take care of it.", + "6. Once you got the confirmation from the user (throug UI) let's immediatly call the image generation agent to generate the image for the given podcast script.", + "6a. Once image generation successfully generated switch on the image UI state and so user will see if the generated image is fine or not, you just ask user to confirm the image through UI. show_banner_for_confirmation will take care of it." + "7. Once you got the confirmation from the user (throug UI) let's immediatly call the audio generation agent to generate the audio for the given podcast script.", + "7a. Once audio generation successfully generated switch on the audio UI state and so user will see if the generated audio is fine or not, you just ask user to confirm the audio through UI. show_audio_for_confirmation will take care of it." + "8. Once you got the confirmation from the user for audio (throug UI) let's immediatly call the mark_session_finished tool to mark the session as finished and if finish is successful then no further conversation are allowed and only new session can be started.", + "8a. It's important mark_session_finished should be called only when we have all the stages search->selection->script->image->audio are completed." + "APPENDIX:", + "1. You can enable appropriate UI states using the ui_manager tool, which takes [state_type, active] as input, and it takes care of the appropriate UI state for activating appropriate UI state.", + f"1a. Available UI state types: {TOGGLE_UI_STATES}", + "1b. During the conversation, at any place you feel a UI state is not necessary, you can disable it using the ui_manager tool by setting active to False. For switching off all states, pass all to False.", + f"2. Supported Languges: {json.dumps(AVAILABLE_LANGS)}", + "3. Search Agent has a lot off tools, so you can instruct the search query as prompt to get the best results as because search agent has lot of tools you can instruct instead of directly passing the query to search agent when required.", + "4. You are not allowed to include year or date in your seach query construction for the search agent unless that request explicilty with yeear or date come for the users.", +] +DB_PATH = "databases" +PODCAST_DIR = "podcasts" +PODCAST_IMG_DIR = PODCAST_DIR + "/images" +PODCAST_AUIDO_DIR = PODCAST_DIR + "/audio" +PODCAST_RECORDINGS_DIR = PODCAST_DIR + "/recordings" + +INITIAL_SESSION_STATE = { + "search_results": [], + "show_sources_for_selection": False, + "show_script_for_confirmation": False, + "generated_script": {}, + "selected_language": {"code": "en", "name": "English"}, + "available_languages": AVAILABLE_LANGS, + "banner_images": [], + "banner_url": "", + "audio_url": "", + "title": "Untitled", + "created_at": "", + "finished": False, + "show_banner_for_confirmation": False, + "show_audio_for_confirmation": False, +} + +STORAGE = SqliteStorage(table_name="podcast_sessions", db_file=get_agent_session_db_path()) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/articles.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/articles.py new file mode 100644 index 0000000..75db55a --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/articles.py @@ -0,0 +1,276 @@ +import json +from datetime import datetime +from .connection import db_connection, execute_query + + +def store_crawled_article(tracking_db_path, entry, raw_content, metadata): + metadata_json = json.dumps(metadata) + query = """ + INSERT INTO crawled_articles + (entry_id, source_id, feed_id, title, url, published_date, raw_content, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """ + try: + params = ( + entry["id"], + entry.get("source_id"), + entry.get("feed_id"), + entry.get("title", ""), + entry.get("link", ""), + entry.get("published_date", datetime.now().isoformat()), + raw_content, + metadata_json, + ) + execute_query(tracking_db_path, query, params) + return True + except Exception: + return False + + +def update_entry_status(tracking_db_path, entry_id, status): + query = """ + UPDATE feed_entries + SET crawl_attempts = crawl_attempts + 1, crawl_status = ? + WHERE id = ? + """ + return execute_query(tracking_db_path, query, (status, entry_id)) + + +def get_unprocessed_articles(tracking_db_path, limit=5, max_attempts=1): + reset_stuck_articles(tracking_db_path) + query = """ + SELECT id, entry_id, source_id, feed_id, title, url, published_date, raw_content, metadata, ai_attempts + FROM crawled_articles + WHERE (ai_status = 'pending' OR ai_status = 'error') + AND ai_attempts < ? + AND processed = 0 + ORDER BY published_date DESC + LIMIT ? + """ + articles = execute_query(tracking_db_path, query, (max_attempts, limit), fetch=True) + for article in articles: + if article.get("metadata"): + try: + article["metadata"] = json.loads(article["metadata"]) + except json.JSONDecodeError: + article["metadata"] = {} + if articles: + article_ids = [a["id"] for a in articles] + mark_articles_as_processing(tracking_db_path, article_ids) + return articles + + +def reset_stuck_articles(tracking_db_path): + query = """ + UPDATE crawled_articles + SET ai_status = 'pending' + WHERE ai_status = 'processing' + """ + return execute_query(tracking_db_path, query) + + +def mark_articles_as_processing(tracking_db_path, article_ids): + if not article_ids: + return 0 + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + placeholders = ",".join(["?"] * len(article_ids)) + query = f""" + UPDATE crawled_articles + SET ai_status = 'processing' + WHERE id IN ({placeholders}) + """ + cursor.execute(query, article_ids) + conn.commit() + return cursor.rowcount + + +def save_article_categories(tracking_db_path, article_id, categories): + if not categories: + return 0 + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + DELETE FROM article_categories + WHERE article_id = ? + """, + (article_id,), + ) + count = 0 + for category in categories: + try: + cursor.execute( + """ + INSERT INTO article_categories (article_id, category_name) + VALUES (?, ?) + """, + (article_id, category.lower().strip()), + ) + count += 1 + except Exception as _: + pass + conn.commit() + return count + + +def get_article_categories(tracking_db_path, article_id): + query = """ + SELECT category_name + FROM article_categories + WHERE article_id = ? + """ + results = execute_query(tracking_db_path, query, (article_id,), fetch=True) + return [row["category_name"] for row in results] + + +def update_article_status(tracking_db_path, article_id, results=None, success=False, error_message=None): + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE crawled_articles + SET ai_attempts = ai_attempts + 1 + WHERE id = ? + """, + (article_id,), + ) + if success and results: + categories = [] + if "categories" in results: + if isinstance(results["categories"], str): + try: + categories = json.loads(results["categories"]) + except json.JSONDecodeError: + categories = [c.strip() for c in results["categories"].split(",") if c.strip()] + elif isinstance(results["categories"], list): + categories = results["categories"] + cursor.execute( + """ + UPDATE crawled_articles + SET summary = ?, content = ?, processed = 1, ai_status = 'success' + WHERE id = ? + """, + (results.get("summary", ""), results.get("content", ""), article_id), + ) + conn.commit() + if categories: + save_article_categories(tracking_db_path, article_id, categories) + else: + cursor.execute( + """ + UPDATE crawled_articles + SET ai_status = 'error', ai_error = ? + WHERE id = ? + """, + (error_message, article_id), + ) + cursor.execute( + """ + UPDATE crawled_articles + SET ai_status = 'failed' + WHERE id = ? AND ai_attempts >= 3 + """, + (article_id,), + ) + conn.commit() + return cursor.rowcount + + +def get_articles_by_date_range(tracking_db_path, start_date=None, end_date=None, limit=None, offset=0): + query_parts = [ + "SELECT ca.id, ca.feed_id, ca.source_id, ca.title, ca.url, ca.published_date,", + "ca.summary, ca.content", + "FROM crawled_articles ca", + "WHERE ca.processed = 1", + "AND ca.ai_status = 'success'", + ] + query_params = [] + if start_date: + query_parts.append("AND ca.published_date >= ?") + query_params.append(start_date) + if end_date: + query_parts.append("AND ca.published_date <= ?") + query_params.append(end_date) + query_parts.append("ORDER BY ca.published_date DESC") + if limit is not None: + query_parts.append("LIMIT ? OFFSET ?") + query_params.append(limit) + query_params.append(offset) + query = " ".join(query_parts) + return execute_query(tracking_db_path, query, tuple(query_params), fetch=True) + + +def get_article_by_id(tracking_db_path, article_id): + query = """ + SELECT id, entry_id, source_id, feed_id, title, url, published_date, + raw_content, content, summary, metadata, ai_status, ai_error, + ai_attempts, crawled_date, processed + FROM crawled_articles + WHERE id = ? + """ + article = execute_query(tracking_db_path, query, (article_id,), fetch=True, fetch_one=True) + if article: + if article.get("metadata"): + try: + article["metadata"] = json.loads(article["metadata"]) + except json.JSONDecodeError: + article["metadata"] = {} + article["categories"] = get_article_categories(tracking_db_path, article_id) + return article + + +def get_articles_by_category(tracking_db_path, category, limit=20, offset=0): + query = """ + SELECT ca.id, ca.title, ca.url, ca.published_date, ca.summary + FROM crawled_articles ca + JOIN article_categories ac ON ca.id = ac.article_id + WHERE ac.category_name = ? + AND ca.processed = 1 + AND ca.ai_status = 'success' + ORDER BY ca.published_date DESC + LIMIT ? OFFSET ? + """ + return execute_query(tracking_db_path, query, (category, limit, offset), fetch=True) + + +def get_article_stats(tracking_db_path): + query = """ + SELECT + COUNT(*) as total_articles, + SUM(CASE WHEN processed = 1 THEN 1 ELSE 0 END) as processed_articles, + SUM(CASE WHEN ai_status = 'pending' THEN 1 ELSE 0 END) as pending_articles, + SUM(CASE WHEN ai_status = 'processing' THEN 1 ELSE 0 END) as processing_articles, + SUM(CASE WHEN ai_status = 'success' THEN 1 ELSE 0 END) as success_articles, + SUM(CASE WHEN ai_status = 'error' THEN 1 ELSE 0 END) as error_articles, + SUM(CASE WHEN ai_status = 'failed' THEN 1 ELSE 0 END) as failed_articles + FROM crawled_articles + """ + return execute_query(tracking_db_path, query, fetch=True, fetch_one=True) + + +def get_categories_with_counts(tracking_db_path, limit=20): + query = """ + SELECT category_name, COUNT(*) as article_count + FROM article_categories + GROUP BY category_name + ORDER BY article_count DESC + LIMIT ? + """ + return execute_query(tracking_db_path, query, (limit,), fetch=True) + + +def get_articles_with_source_info(tracking_db_path, limit=20, offset=0): + query = """ + SELECT ca.id, ca.title, ca.url, ca.published_date, ca.summary, + ft.feed_url, s.name as source_name + FROM crawled_articles ca + LEFT JOIN feed_tracking ft ON ca.feed_id = ft.feed_id + LEFT JOIN source_feeds sf ON ca.feed_id = sf.id + LEFT JOIN sources s ON sf.source_id = s.id + WHERE ca.processed = 1 + AND ca.ai_status = 'success' + ORDER BY ca.published_date DESC + LIMIT ? OFFSET ? + """ + return execute_query(tracking_db_path, query, (limit, offset), fetch=True) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/config.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/config.py new file mode 100644 index 0000000..9aeacbf --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/config.py @@ -0,0 +1,71 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +env_path = Path(".") / ".env" +load_dotenv(dotenv_path=env_path) +DEFAULT_DB_PATHS = { + "sources_db": "databases/sources.db", + "tracking_db": "databases/feed_tracking.db", + "podcasts_db": "databases/podcasts.db", + "tasks_db": "databases/tasks.db", + "agent_session_db": "databases/agent_sessions.db", + "faiss_index_db": "databases/faiss/article_index.faiss", + "faiss_mapping_file": "databases/faiss/article_id_map.npy", + "internal_sessions_db": "databases/internal_sessions.db", + "social_media_db": "databases/social_media.db", + "slack_sessions_db": "databases/slack_sessions.db", +} + + +def get_db_path(db_name): + env_var = f"{db_name.upper()}_PATH" + path = os.environ.get(env_var, DEFAULT_DB_PATHS.get(db_name)) + db_dir = os.path.dirname(path) + os.makedirs(db_dir, exist_ok=True) + return path + + +def get_sources_db_path(): + return get_db_path("sources_db") + + +def get_tracking_db_path(): + return get_db_path("tracking_db") + + +def get_podcasts_db_path(): + return get_db_path("podcasts_db") + + +def get_tasks_db_path(): + return get_db_path("tasks_db") + + +def get_agent_session_db_path(): + return get_db_path("agent_session_db") + + +def get_faiss_db_path(): + return get_db_path("faiss_index_db"), get_db_path("faiss_mapping_file") + + +def get_internal_sessions_db_path(): + return get_db_path("internal_sessions_db") + + +def get_social_media_db_path(): + return get_db_path("social_media_db") + + +def get_browser_session_path(): + return "browsers/playwright_persistent_profile" + +def get_slack_sessions_db_path(): + return get_db_path("slack_sessions_db") + +DB_PATH = "databases" +PODCAST_DIR = "podcasts" +PODCAST_IMG_DIR = PODCAST_DIR + "/images" +PODCAST_AUIDO_DIR = PODCAST_DIR + "/audio" +PODCAST_RECORDINGS_DIR = PODCAST_DIR + "/recordings" diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/connection.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/connection.py new file mode 100644 index 0000000..9c8ce0a --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/connection.py @@ -0,0 +1,27 @@ +import sqlite3 +from contextlib import contextmanager + + +@contextmanager +def db_connection(db_path): + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def execute_query(db_path, query, params=(), fetch=False, fetch_one=False): + with db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute(query, params) + + if fetch_one: + result = cursor.fetchone() + return dict(result) if result else None + elif fetch: + return [dict(row) for row in cursor.fetchall()] + else: + conn.commit() + return cursor.lastrowid diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/feeds.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/feeds.py new file mode 100644 index 0000000..1271baa --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/feeds.py @@ -0,0 +1,179 @@ +from datetime import datetime +import sqlite3 +from .connection import db_connection, execute_query + + +def get_active_feeds(sources_db_path, limit=None, offset=0): + if limit: + query = """ + SELECT sf.id, sf.source_id, sf.feed_url, sf.feed_type, sf.last_crawled, + s.name as source_name + FROM source_feeds sf + JOIN sources s ON sf.source_id = s.id + WHERE sf.is_active = 1 AND s.is_active = 1 + LIMIT ? OFFSET ? + """ + return execute_query(sources_db_path, query, (limit, offset), fetch=True) + else: + query = """ + SELECT sf.id, sf.source_id, sf.feed_url, sf.feed_type, sf.last_crawled, + s.name as source_name + FROM source_feeds sf + JOIN sources s ON sf.source_id = s.id + WHERE sf.is_active = 1 AND s.is_active = 1 + """ + return execute_query(sources_db_path, query, fetch=True) + + +def count_active_feeds(sources_db_path): + query = """ + SELECT COUNT(*) as count + FROM source_feeds sf + JOIN sources s ON sf.source_id = s.id + WHERE sf.is_active = 1 AND s.is_active = 1 + """ + result = execute_query(sources_db_path, query, fetch=True, fetch_one=True) + return result["count"] if result else 0 + + +def get_feed_tracking_info(tracking_db_path, feed_id): + query = "SELECT * FROM feed_tracking WHERE feed_id = ?" + return execute_query(tracking_db_path, query, (feed_id,), fetch=True, fetch_one=True) + + +def update_feed_tracking(tracking_db_path, feed_id, etag, modified, entry_hash): + query = """ + UPDATE feed_tracking + SET last_processed = ?, last_etag = ?, last_modified = ?, entry_hash = ? + WHERE feed_id = ? + """ + params = (datetime.now().isoformat(), etag, modified, entry_hash, feed_id) + return execute_query(tracking_db_path, query, params) + + +def store_feed_entries(tracking_db_path, feed_id, source_id, entries): + count = 0 + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + for entry in entries: + try: + cursor.execute( + """ + INSERT INTO feed_entries + (feed_id, source_id, entry_id, title, link, published_date, content, summary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + feed_id, + source_id, + entry.get("entry_id", ""), + entry.get("title", ""), + entry.get("link", ""), + entry.get("published_date", datetime.now().isoformat()), + entry.get("content", ""), + entry.get("summary", ""), + ), + ) + count += 1 + except sqlite3.IntegrityError: + pass + conn.commit() + return count + + +def update_tracking_info(tracking_db_path, feeds): + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + for feed in feeds: + cursor.execute( + """ + INSERT OR IGNORE INTO feed_tracking + (feed_id, source_id, feed_url, last_processed) + VALUES (?, ?, ?, NULL) + """, + (feed["id"], feed["source_id"], feed["feed_url"]), + ) + conn.commit() + + +def get_uncrawled_entries(tracking_db_path, limit=20, max_attempts=3): + reset_stuck_entries(tracking_db_path) + query = """ + SELECT e.id, e.feed_id, e.source_id, e.title, e.link, e.published_date, + e.crawl_attempts, e.entry_id as original_entry_id + FROM feed_entries e + WHERE (e.crawl_status = 'pending' OR e.crawl_status = 'failed') + AND e.crawl_attempts < ? + AND e.link IS NOT NULL + AND e.link != '' + AND NOT EXISTS ( + SELECT 1 FROM crawled_articles ca WHERE ca.url = e.link + ) + ORDER BY e.published_date DESC + LIMIT ? + """ + entries = execute_query(tracking_db_path, query, (max_attempts, limit), fetch=True) + if entries: + entry_ids = [e["id"] for e in entries] + mark_entries_as_processing(tracking_db_path, entry_ids) + return entries + + +def reset_stuck_entries(tracking_db_path): + query = """ + UPDATE feed_entries + SET crawl_status = 'pending' + WHERE crawl_status = 'processing' + """ + return execute_query(tracking_db_path, query) + + +def mark_entries_as_processing(tracking_db_path, entry_ids): + if not entry_ids: + return 0 + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + placeholders = ",".join(["?"] * len(entry_ids)) + query = f""" + UPDATE feed_entries + SET crawl_status = 'processing' + WHERE id IN ({placeholders}) + """ + cursor.execute(query, entry_ids) + conn.commit() + return cursor.rowcount + + +def ensure_feed_tracking_exists(tracking_db_path, feed_id, source_id, feed_url): + query = """ + INSERT OR IGNORE INTO feed_tracking + (feed_id, source_id, feed_url, last_processed) + VALUES (?, ?, ?, NULL) + """ + return execute_query(tracking_db_path, query, (feed_id, source_id, feed_url)) + + +def get_feed_sources_with_categories(sources_db_path): + query = """ + SELECT sf.id, sf.source_id, sf.feed_url, sf.feed_type, sf.last_crawled, + s.name as source_name, c.name as category_name + FROM source_feeds sf + JOIN sources s ON sf.source_id = s.id + LEFT JOIN source_categories sc ON s.id = sc.source_id + LEFT JOIN categories c ON sc.category_id = c.id + WHERE sf.is_active = 1 AND s.is_active = 1 + """ + return execute_query(sources_db_path, query, fetch=True) + + +def get_feed_stats(tracking_db_path): + query = """ + SELECT + COUNT(*) as total_entries, + SUM(CASE WHEN crawl_status = 'pending' THEN 1 ELSE 0 END) as pending_entries, + SUM(CASE WHEN crawl_status = 'processing' THEN 1 ELSE 0 END) as processing_entries, + SUM(CASE WHEN crawl_status = 'success' THEN 1 ELSE 0 END) as success_entries, + SUM(CASE WHEN crawl_status = 'failed' THEN 1 ELSE 0 END) as failed_entries + FROM feed_entries + """ + return execute_query(tracking_db_path, query, fetch=True, fetch_one=True) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/podcast_configs.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/podcast_configs.py new file mode 100644 index 0000000..c520454 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/podcast_configs.py @@ -0,0 +1,205 @@ +import sqlite3 +from typing import List, Dict, Any, Optional +from datetime import datetime + + +def get_podcast_config(db_path: str, config_id: int) -> Optional[Dict[str, Any]]: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + cursor = conn.cursor() + cursor.execute( + """ + SELECT id, name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at + FROM podcast_configs + WHERE id = ? + """, + (config_id,), + ) + row = cursor.fetchone() + if not row: + return None + config = dict(row) + config["is_active"] = bool(config.get("is_active", 0)) + return config + except Exception as e: + print(f"Error fetching podcast config: {e}") + return None + finally: + conn.close() + + +def get_all_podcast_configs(db_path: str, active_only: bool = False) -> List[Dict[str, Any]]: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + cursor = conn.cursor() + if active_only: + query = """ + SELECT id, name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at + FROM podcast_configs + WHERE is_active = 1 + ORDER BY name + """ + cursor.execute(query) + else: + query = """ + SELECT id, name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at + FROM podcast_configs + ORDER BY name + """ + cursor.execute(query) + configs = [] + for row in cursor.fetchall(): + config = dict(row) + config["is_active"] = bool(config.get("is_active", 0)) + configs.append(config) + return configs + except Exception as e: + print(f"Error fetching podcast configs: {e}") + return [] + finally: + conn.close() + + +def create_podcast_config( + db_path: str, + name: str, + prompt: str, + description: Optional[str] = None, + time_range_hours: int = 24, + limit_articles: int = 20, + is_active: bool = True, + tts_engine: str = "kokoro", + language_code: str = "en", + podcast_script_prompt: Optional[str] = None, + image_prompt: Optional[str] = None, +) -> Optional[int]: + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + now = datetime.now().isoformat() + cursor.execute( + """ + INSERT INTO podcast_configs + (name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + name, + description, + prompt, + time_range_hours, + limit_articles, + 1 if is_active else 0, + tts_engine, + language_code, + podcast_script_prompt, + image_prompt, + now, + now, + ), + ) + conn.commit() + return cursor.lastrowid + except Exception as e: + conn.rollback() + print(f"Error creating podcast config: {e}") + return None + finally: + conn.close() + + +def update_podcast_config(db_path: str, config_id: int, updates: Dict[str, Any]) -> bool: + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + cursor.execute("SELECT 1 FROM podcast_configs WHERE id = ?", (config_id,)) + if not cursor.fetchone(): + return False + if not updates: + return True + set_clauses = [] + params = [] + set_clauses.append("updated_at = ?") + params.append(datetime.now().isoformat()) + allowed_fields = [ + "name", + "description", + "prompt", + "time_range_hours", + "limit_articles", + "is_active", + "tts_engine", + "language_code", + "podcast_script_prompt", + "image_prompt", + ] + for field, value in updates.items(): + if field in allowed_fields: + if field == "is_active": + value = 1 if value else 0 + set_clauses.append(f"{field} = ?") + params.append(value) + params.append(config_id) + query = f""" + UPDATE podcast_configs + SET {", ".join(set_clauses)} + WHERE id = ? + """ + cursor.execute(query, tuple(params)) + conn.commit() + return True + except Exception as e: + conn.rollback() + print(f"Error updating podcast config: {e}") + return False + finally: + conn.close() + + +def delete_podcast_config(db_path: str, config_id: int) -> bool: + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + cursor.execute("DELETE FROM podcast_configs WHERE id = ?", (config_id,)) + conn.commit() + + return cursor.rowcount > 0 + except Exception as e: + conn.rollback() + print(f"Error deleting podcast config: {e}") + return False + finally: + conn.close() + + +def toggle_podcast_config(db_path: str, config_id: int, is_active: bool) -> bool: + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + now = datetime.now().isoformat() + cursor.execute( + """ + UPDATE podcast_configs + SET is_active = ?, updated_at = ? + WHERE id = ? + """, + (1 if is_active else 0, now, config_id), + ) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + conn.rollback() + print(f"Error toggling podcast config: {e}") + return False + finally: + conn.close() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/podcasts.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/podcasts.py new file mode 100644 index 0000000..221aebf --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/podcasts.py @@ -0,0 +1,124 @@ +import json +from datetime import datetime +from typing import Dict, Any, Optional +from .connection import execute_query + + +def store_podcast( + podcasts_db_path: str, + podcast_data: Dict[str, Any], + audio_path: Optional[str], + banner_path: Optional[str], + tts_engine: str = "kokoro", + language_code: str = "en", +) -> int: + today = datetime.now().strftime("%Y-%m-%d") + podcast_json = json.dumps(podcast_data) + audio_generated = 1 if audio_path else 0 + sources_json = json.dumps(podcast_data.get("sources", [])) + query = """ + INSERT INTO podcasts + (title, date, content_json, audio_generated, audio_path, banner_img_path, + tts_engine, language_code, sources_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + params = ( + podcast_data.get("title", f"Podcast {today}"), + today, + podcast_json, + audio_generated, + audio_path, + banner_path, + tts_engine, + language_code, + sources_json, + datetime.now().isoformat(), + ) + return execute_query(podcasts_db_path, query, params) + + +def get_podcast(podcasts_db_path: str, podcast_id: int) -> Optional[Dict[str, Any]]: + query = """ + SELECT id, title, date, content_json, audio_generated, audio_path, banner_img_path, + tts_engine, language_code, sources_json, created_at + FROM podcasts + WHERE id = ? + """ + podcast = execute_query(podcasts_db_path, query, (podcast_id,), fetch=True, fetch_one=True) + if podcast: + if podcast.get("content_json"): + try: + podcast["content"] = json.loads(podcast["content_json"]) + except json.JSONDecodeError: + podcast["content"] = {} + + if podcast.get("sources_json"): + try: + podcast["sources"] = json.loads(podcast["sources_json"]) + except json.JSONDecodeError: + podcast["sources"] = [] + return podcast + + +def get_recent_podcasts(podcasts_db_path: str, limit: int = 10) -> list: + query = """ + SELECT id, title, date, audio_generated, audio_path, banner_img_path, + tts_engine, language_code, sources_json, created_at + FROM podcasts + ORDER BY date DESC, created_at DESC + LIMIT ? + """ + podcasts = execute_query(podcasts_db_path, query, (limit,), fetch=True) + for podcast in podcasts: + if podcast.get("sources_json"): + try: + podcast["sources"] = json.loads(podcast["sources_json"]) + except json.JSONDecodeError: + podcast["sources"] = [] + return podcasts + + +def update_podcast_audio(podcasts_db_path: str, podcast_id: int, audio_path: str) -> bool: + query = """ + UPDATE podcasts + SET audio_path = ?, audio_generated = 1 + WHERE id = ? + """ + rows_affected = execute_query(podcasts_db_path, query, (audio_path, podcast_id)) + return rows_affected > 0 + + +def update_podcast_banner(podcasts_db_path: str, podcast_id: int, banner_path: str) -> bool: + query = """ + UPDATE podcasts + SET banner_img_path = ? + WHERE id = ? + """ + rows_affected = execute_query(podcasts_db_path, query, (banner_path, podcast_id)) + return rows_affected > 0 + + +def update_podcast_metadata( + podcasts_db_path: str, podcast_id: int, tts_engine: Optional[str] = None, language_code: Optional[str] = None, sources_json: Optional[str] = None +) -> bool: + update_parts = [] + params = [] + if tts_engine is not None: + update_parts.append("tts_engine = ?") + params.append(tts_engine) + if language_code is not None: + update_parts.append("language_code = ?") + params.append(language_code) + if sources_json is not None: + update_parts.append("sources_json = ?") + params.append(sources_json) + if not update_parts: + return False + query = f""" + UPDATE podcasts + SET {", ".join(update_parts)} + WHERE id = ? + """ + params.append(podcast_id) + rows_affected = execute_query(podcasts_db_path, query, tuple(params)) + return rows_affected > 0 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/tasks.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/tasks.py new file mode 100644 index 0000000..c44eb3c --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/db/tasks.py @@ -0,0 +1,247 @@ +from datetime import datetime, timedelta +from .connection import execute_query, db_connection + + +def create_task( + tasks_db_path, + name, + command, + frequency, + frequency_unit, + description=None, + enabled=True, +): + query = """ + INSERT INTO tasks + (name, description, command, frequency, frequency_unit, enabled, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """ + params = ( + name, + description, + command, + frequency, + frequency_unit, + 1 if enabled else 0, + datetime.now().isoformat(), + ) + return execute_query(tasks_db_path, query, params) + + +def is_task_running(tasks_db_path, task_id): + query = """ + SELECT 1 + FROM task_executions + WHERE task_id = ? AND status = 'running' + LIMIT 1 + """ + try: + result = execute_query(tasks_db_path, query, (task_id,), fetch=True, fetch_one=True) + return result is not None # True if a running entry exists + except Exception as e: + print(f"Database error checking running status for task {task_id}: {e}") + return True + + +def get_task(tasks_db_path, task_id): + query = """ + SELECT id, name, description, command, frequency, frequency_unit, enabled, last_run, created_at + FROM tasks + WHERE id = ? + """ + return execute_query(tasks_db_path, query, (task_id,), fetch=True, fetch_one=True) + + +def get_all_tasks(tasks_db_path, include_disabled=False): + if include_disabled: + query = """ + SELECT id, name, description, command, frequency, frequency_unit, enabled, last_run, created_at + FROM tasks + ORDER BY name + """ + return execute_query(tasks_db_path, query, fetch=True) + else: + query = """ + SELECT id, name, description, command, frequency, frequency_unit, enabled, last_run, created_at + FROM tasks + WHERE enabled = 1 + ORDER BY name + """ + return execute_query(tasks_db_path, query, fetch=True) + + +def update_task(tasks_db_path, task_id, updates): + allowed_fields = [ + "name", + "description", + "command", + "frequency", + "frequency_unit", + "enabled", + ] + + set_clauses = [] + params = [] + for field, value in updates.items(): + if field in allowed_fields: + if field == "enabled": + value = 1 if value else 0 + set_clauses.append(f"{field} = ?") + params.append(value) + if not set_clauses: + return 0 + query = f""" + UPDATE tasks + SET {", ".join(set_clauses)} + WHERE id = ? + """ + params.append(task_id) + return execute_query(tasks_db_path, query, tuple(params)) + + +def delete_task(tasks_db_path, task_id): + query = """ + DELETE FROM tasks + WHERE id = ? + """ + return execute_query(tasks_db_path, query, (task_id,)) + + +def update_task_last_run(tasks_db_path, task_id, timestamp=None): + if timestamp is None: + timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + query = """ + UPDATE tasks + SET last_run = ? + WHERE id = ? + """ + return execute_query(tasks_db_path, query, (timestamp, task_id)) + + +def create_task_execution(tasks_db_path, task_id, status, error_message=None, output=None): + start_time = datetime.now().isoformat() + query = """ + INSERT INTO task_executions + (task_id, start_time, status, error_message, output) + VALUES (?, ?, ?, ?, ?) + """ + params = (task_id, start_time, status, error_message, output) + execute_query(tasks_db_path, query, params) + try: + with db_connection(tasks_db_path) as conn: + cursor = conn.cursor() + cursor.execute(query, params) + conn.commit() + return cursor.lastrowid # Return the ID of the inserted row + except Exception as e: + print(f"Database error in create_task_execution: {e}") + return None # + + +def update_task_execution(tasks_db_path, execution_id, status, error_message=None, output=None): + end_time = datetime.now().isoformat() + query = """ + UPDATE task_executions + SET end_time = ?, status = ?, error_message = ?, output = ? + WHERE id = ? + """ + params = (end_time, status, error_message, output, execution_id) + return execute_query(tasks_db_path, query, params) + + +def get_recent_task_executions(tasks_db_path, task_id=None, limit=10): + if task_id: + query = """ + SELECT id, task_id, start_time, end_time, status, error_message, output + FROM task_executions + WHERE task_id = ? + ORDER BY start_time DESC + LIMIT ? + """ + params = (task_id, limit) + else: + query = """ + SELECT id, task_id, start_time, end_time, status, error_message, output + FROM task_executions + ORDER BY start_time DESC + LIMIT ? + """ + params = (limit,) + return execute_query(tasks_db_path, query, params, fetch=True) + + +def get_task_execution(tasks_db_path, execution_id): + query = """ + SELECT id, task_id, start_time, end_time, status, error_message, output + FROM task_executions + WHERE id = ? + """ + return execute_query(tasks_db_path, query, (execution_id,), fetch=True, fetch_one=True) + + +def mark_task_disabled(tasks_db_path, task_id): + query = """ + UPDATE tasks + SET enabled = 0 + WHERE id = ? + """ + return execute_query(tasks_db_path, query, (task_id,)) + + +def mark_task_enabled(tasks_db_path, task_id): + query = """ + UPDATE tasks + SET enabled = 1 + WHERE id = ? + """ + return execute_query(tasks_db_path, query, (task_id,)) + + +def get_task_stats(tasks_db_path): + query = """ + SELECT + COUNT(*) as total_tasks, + SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) as active_tasks, + SUM(CASE WHEN enabled = 0 THEN 1 ELSE 0 END) as disabled_tasks, + SUM(CASE WHEN last_run IS NULL THEN 1 ELSE 0 END) as never_run_tasks + FROM tasks + """ + return execute_query(tasks_db_path, query, fetch=True, fetch_one=True) + + +def get_execution_stats(tasks_db_path, days=7): + cutoff_date = (datetime.now() - timedelta(days=days)).isoformat() + query = """ + SELECT + COUNT(*) as total_executions, + COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) as successful_executions, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) as failed_executions, + COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0) as running_executions, + COALESCE(AVG(CASE WHEN end_time IS NOT NULL + THEN (julianday(end_time) - julianday(start_time)) * 86400.0 + ELSE NULL END), 0) as avg_execution_time_seconds + FROM task_executions + WHERE start_time >= ? + """ + return execute_query(tasks_db_path, query, (cutoff_date,), fetch=True, fetch_one=True) + + +def get_pending_tasks(tasks_db_path): + query = """ + SELECT id, name, description, command, frequency, frequency_unit, enabled, last_run + FROM tasks + WHERE enabled = 1 + AND ( + last_run IS NULL + OR + CASE frequency_unit + WHEN 'minutes' THEN datetime(last_run, '+' || frequency || ' minutes') <= datetime('now', 'localtime') + WHEN 'hours' THEN datetime(last_run, '+' || frequency || ' hours') <= datetime('now', 'localtime') + WHEN 'days' THEN datetime(last_run, '+' || frequency || ' days') <= datetime('now', 'localtime') + ELSE datetime(last_run, '+' || frequency || ' seconds') <= datetime('now', 'localtime') + END + ) + ORDER BY last_run + """ + tasks = execute_query(tasks_db_path, query, fetch=True) + return tasks diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/integrations/slack/chat.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/integrations/slack/chat.py new file mode 100644 index 0000000..aecea79 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/integrations/slack/chat.py @@ -0,0 +1,1224 @@ +import os +import re +import sqlite3 +import asyncio +import aiohttp +import json +from concurrent.futures import ThreadPoolExecutor +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from dotenv import load_dotenv +from typing import Dict, List +from datetime import datetime +from db.config import get_slack_sessions_db_path + +load_dotenv() + +app = App(token=os.environ["SLACK_BOT_TOKEN"]) +# local url works but banner images won't work in slack unless it's https with proper domain +# you can use ngrok to port forward local url to https and replace this local url with ngrok url +API_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:7000") +executor = ThreadPoolExecutor(max_workers=10) +active_sessions: Dict[str, Dict] = {} +DB_PATH = get_slack_sessions_db_path() + + +def send_error_message(thread_key: str, error_message: str): + print(f"Error for {thread_key}: {error_message}") + asyncio.create_task(send_slack_message(thread_key, f"❌ {error_message}")) + + +def init_db(): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS thread_sessions ( + thread_key TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + user_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS session_state ( + session_id TEXT PRIMARY KEY, + state_data TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + conn.close() + + +def save_session_mapping(thread_key: str, session_id: str, channel_id: str, user_id: str = None): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "INSERT OR REPLACE INTO thread_sessions (thread_key, session_id, channel_id, user_id, updated_at) VALUES (?, ?, ?, ?, ?)", + (thread_key, session_id, channel_id, user_id, datetime.now().isoformat()), + ) + conn.commit() + conn.close() + + +def get_session_info(thread_key: str): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "SELECT session_id, channel_id, user_id FROM thread_sessions WHERE thread_key = ?", + (thread_key,), + ) + result = cursor.fetchone() + conn.close() + return result if result else None + + +def save_session_state(session_id: str, state_data): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + if isinstance(state_data, str): + json_data = state_data + else: + json_data = json.dumps(state_data) + cursor.execute( + "INSERT OR REPLACE INTO session_state (session_id, state_data, updated_at) VALUES (?, ?, ?)", + (session_id, json_data, datetime.now().isoformat()), + ) + conn.commit() + conn.close() + + +def get_session_state(session_id: str): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("SELECT state_data FROM session_state WHERE session_id = ?", (session_id,)) + result = cursor.fetchone() + conn.close() + if result: + try: + return json.loads(result[0]) + except: + return {} + return {} + + +class PodcastAgentClient: + def __init__(self, base_url: str): + self.base_url = base_url + self.timeout = aiohttp.ClientTimeout(total=30) + + async def create_session(self, session_id=None): + try: + async with aiohttp.ClientSession(timeout=self.timeout) as session: + payload = {"session_id": session_id} if session_id else {} + async with session.post(f"{self.base_url}/api/podcast-agent/session", json=payload) as resp: + resp.raise_for_status() + return await resp.json() + except Exception as e: + print(f"API create_session error: {e}") + raise + + async def chat(self, session_id: str, message: str): + try: + async with aiohttp.ClientSession(timeout=self.timeout) as session: + payload = {"session_id": session_id, "message": message} + async with session.post(f"{self.base_url}/api/podcast-agent/chat", json=payload) as resp: + resp.raise_for_status() + return await resp.json() + except Exception as e: + print(f"API chat error: {e}") + raise + + async def check_status(self, session_id: str, task_id=None): + try: + async with aiohttp.ClientSession(timeout=self.timeout) as session: + payload = {"session_id": session_id} + if task_id: + payload["task_id"] = task_id + async with session.post(f"{self.base_url}/api/podcast-agent/status", json=payload) as resp: + resp.raise_for_status() + return await resp.json() + except Exception as e: + print(f"API check_status error: {e}") + raise + + +api_client = PodcastAgentClient(API_BASE_URL) + + +def get_thread_key(message, is_dm=False): + if is_dm: + return f"dm_{message['channel']}_{message['user']}" + else: + return message.get("thread_ts", message["ts"]) + + +async def get_or_create_session(thread_key: str, channel_id: str, user_id: str = None): + session_info = get_session_info(thread_key) + if not session_info: + response = await api_client.create_session(thread_key) + session_id = response["session_id"] + save_session_mapping(thread_key, session_id, channel_id, user_id) + print(f"Created new session: {session_id} for thread: {thread_key}") + return session_id + else: + return session_info[0] + + +def run_async_in_thread(coro): + def run(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + future = executor.submit(run) + return future + + +async def poll_for_completion(session_id: str, thread_key: str, task_id=None): + print(f"Starting polling for session: {session_id}, task: {task_id}") + max_polls = 60 + poll_count = 0 + active_sessions[session_id] = { + "thread_key": thread_key, + "task_id": task_id, + "start_time": datetime.now(), + } + try: + while poll_count < max_polls: + try: + status_response = await api_client.check_status(session_id, task_id) + if status_response.get("session_state"): + save_session_state(session_id, status_response.get("session_state")) + if not status_response.get("is_processing", True): + await send_completion_message(thread_key, status_response) + break + if poll_count % 10 == 0 and poll_count > 0: + process_type = status_response.get("process_type", "request") + await send_slack_message( + thread_key, + f"🔄 Still processing {process_type}... ({poll_count * 3}s elapsed)", + ) + await asyncio.sleep(3) + poll_count += 1 + except Exception as e: + print(f"Polling error: {e}") + await send_slack_message( + thread_key, + "❌ Something went wrong while processing your request. Please try again.", + ) + break + finally: + if session_id in active_sessions: + del active_sessions[session_id] + + +def start_background_polling(session_id: str, thread_key: str, task_id=None): + if session_id in active_sessions: + print(f"Replacing existing poll for session: {session_id}") + future = run_async_in_thread(poll_for_completion(session_id, thread_key, task_id)) + active_sessions[session_id] = { + "thread_key": thread_key, + "task_id": task_id, + "future": future, + "start_time": datetime.now(), + } + + +async def send_completion_message(thread_key: str, status_response): + response_text = status_response.get("response", "Task completed!") + session_state = status_response.get("session_state") + if session_state: + try: + state_data = json.loads(session_state) if isinstance(session_state, str) else session_state + if state_data.get("show_sources_for_selection") and state_data.get("search_results"): + await send_source_selection_blocks(thread_key, state_data, response_text) + elif state_data.get("show_script_for_confirmation") and state_data.get("generated_script"): + await send_script_confirmation_blocks(thread_key, state_data, response_text) + elif state_data.get("show_banner_for_confirmation") and state_data.get("banner_url"): + await send_banner_confirmation_blocks(thread_key, state_data, response_text) + elif state_data.get("show_audio_for_confirmation") and state_data.get("audio_url"): + await send_audio_confirmation_blocks(thread_key, state_data, response_text) + elif state_data.get("podcast_generated"): + await send_final_presentation_blocks(thread_key, state_data, response_text) + else: + await send_slack_message(thread_key, response_text) + except Exception as e: + print(f"Error parsing session state: {e}") + await send_slack_message(thread_key, response_text) + else: + await send_slack_message(thread_key, response_text) + + +async def send_source_selection_blocks(thread_key: str, state_data: dict, response_text: str): + sources = state_data.get("search_results", []) + languages = state_data.get("available_languages", [{"code": "en", "name": "English"}]) + session_info = get_session_info(thread_key) + if session_info: + save_session_state(session_info[0], state_data) + source_options = [] + for i, source in enumerate(sources[:10]): + title = source.get("title", f"Source {i + 1}") + if len(title) > 70: + title = title[:67] + "..." + source_options.append( + { + "text": {"type": "plain_text", "text": f"{i + 1}. {title}"}, + "value": str(i), + } + ) + language_options = [] + for lang in languages: + language_options.append( + { + "text": {"type": "plain_text", "text": lang["name"]}, + "value": lang["code"], + } + ) + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*📋 Source Selection*\n{response_text}", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"Found *{len(sources)}* sources. Select the ones you'd like to use for your podcast:", + }, + }, + ] + if source_options: + blocks.append( + { + "type": "section", + "block_id": "source_selection_block", + "text": {"type": "mrkdwn", "text": "*Select Sources:*"}, + "accessory": { + "type": "checkboxes", + "action_id": "source_selection", + "options": source_options, + "initial_options": source_options, + }, + } + ) + if len(sources) > 10: + blocks.append( + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Showing first 10 sources. {len(sources) - 10} more available._", + } + ], + } + ) + blocks.extend( + [ + { + "type": "section", + "block_id": "language_selection_block", + "text": {"type": "mrkdwn", "text": "*Select Language:*"}, + "accessory": { + "type": "static_select", + "action_id": "language_selection", + "placeholder": {"type": "plain_text", "text": "Choose language"}, + "options": language_options, + "initial_option": language_options[0] if language_options else None, + }, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "✅ Confirm Selection"}, + "style": "primary", + "action_id": "confirm_sources", + "value": thread_key, + } + ], + }, + ] + ) + await send_slack_blocks(thread_key, blocks, "📋 Source Selection") + + +def format_script_for_slack_snippet(script_data) -> str: + if isinstance(script_data, dict): + lines = [] + title = script_data.get("title", "Podcast Script") + lines.append(f"PODCAST: {title}") + lines.append("=" * (len(title) + 10)) + lines.append("") + sections = script_data.get("sections", []) + for i, section in enumerate(sections): + section_type = section.get("type", "Unknown").upper() + section_title = section.get("title", "") + if section_title: + lines.append(f"SECTION [{section_type}] {section_title}") + else: + lines.append(f"SECTION [{section_type}]") + lines.append("-" * 50) + lines.append("") + if section.get("dialog"): + for j, dialog in enumerate(section["dialog"]): + speaker = dialog.get("speaker", "SPEAKER") + text = dialog.get("text", "") + lines.append(f"SPEAKER {speaker}:") + if len(text) > 70: + words = text.split() + current_line = " " + for word in words: + if len(current_line + word) > 70: + lines.append(current_line) + current_line = " " + word + else: + current_line += " " + word if current_line != " " else word + if current_line.strip(): + lines.append(current_line) + else: + lines.append(f" {text}") + lines.append("") + if i < len(sections) - 1: + lines.append("") + return "\n".join(lines) + return str(script_data) if script_data else "Script content not available" + + +async def send_script_confirmation_blocks(thread_key: str, state_data: dict, response_text: str): + script = state_data.get("generated_script", {}) + title = script.get("title", "Podcast Script") if isinstance(script, dict) else "Podcast Script" + full_script_text = format_script_for_slack_snippet(script) + if len(full_script_text) > 2500: + full_script_text = full_script_text[:2400] + "\n\n... (script continues)\n\nFull script will be available after approval." + section_count = len(script.get("sections", [])) if isinstance(script, dict) else 0 + dialog_count = 0 + if isinstance(script, dict) and script.get("sections"): + for section in script["sections"]: + dialog_count += len(section.get("dialog", [])) + header_text = f"*📝 Script Review*\n{response_text}\n\n*{title}*\nGenerated {section_count} sections with {dialog_count} dialogue exchanges" + blocks = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": header_text}, + }, + {"type": "section", "text": {"type": "mrkdwn", "text": f"```{full_script_text}```"}}, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "✅ Approve Script"}, + "style": "primary", + "action_id": "approve_script", + "value": thread_key, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "🔄 Request Changes"}, + "action_id": "request_script_changes", + "value": thread_key, + }, + ], + }, + ] + await send_slack_blocks(thread_key, blocks, "📝 Script Review") + + +async def send_banner_confirmation_blocks(thread_key: str, state_data: dict, response_text: str): + banner_url = state_data.get("banner_url") + banner_images = state_data.get("banner_images", []) + image_url = None + if banner_images: + image_url = f"{API_BASE_URL}/podcast_img/{banner_images[0]}" + elif banner_url: + image_url = f"{API_BASE_URL}/podcast_img/{banner_url}" + blocks = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*🎨 Banner Review*\n{response_text}"}, + } + ] + if image_url: + blocks.append({"type": "image", "image_url": image_url, "alt_text": "Podcast Banner"}) + if len(banner_images) > 1: + blocks.append( + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Showing 1 of {len(banner_images)} generated banners_", + } + ], + } + ) + blocks.append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "✅ Approve Banner"}, + "style": "primary", + "action_id": "approve_banner", + "value": thread_key, + } + ], + } + ) + await send_slack_blocks(thread_key, blocks, "🎨 Banner Review") + + +async def send_audio_confirmation_blocks(thread_key: str, state_data: dict, response_text: str): + audio_url = state_data.get("audio_url") + full_audio_url = f"{API_BASE_URL}/audio/{audio_url}" if audio_url else None + blocks = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*🎵 Audio Review*\n{response_text}"}, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Your podcast audio has been generated! 🎧\n\n_Note: Click the download link to listen to your podcast audio._", + }, + }, + ] + action_elements = [] + if full_audio_url: + action_elements.append( + { + "type": "button", + "text": {"type": "plain_text", "text": "⬇️ Download Audio"}, + "url": full_audio_url, + "action_id": "download_audio", + } + ) + action_elements.append( + { + "type": "button", + "text": {"type": "plain_text", "text": "✅ Sounds Great!"}, + "style": "primary", + "action_id": "approve_audio", + "value": thread_key, + } + ) + blocks.append({"type": "actions", "elements": action_elements}) + await send_slack_blocks(thread_key, blocks, f"🎵 Audio Review") + + +async def send_final_presentation_blocks(thread_key: str, state_data: dict, response_text: str): + script = state_data.get("generated_script", {}) + podcast_title = script.get("title") if isinstance(script, dict) else None + if not podcast_title: + podcast_title = state_data.get("podcast_info", {}).get("topic", "Your Podcast") + audio_url = state_data.get("audio_url") + banner_url = state_data.get("banner_url") + banner_images = state_data.get("banner_images", []) + full_audio_url = f"{API_BASE_URL}/audio/{audio_url}" if audio_url else None + full_banner_url = None + if banner_images: + full_banner_url = f"{API_BASE_URL}/podcast_img/{banner_images[0]}" + elif banner_url: + full_banner_url = f"{API_BASE_URL}/podcast_img/{banner_url}" + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*🎉 Podcast Complete!*\n{response_text}", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{podcast_title}*\n\nYour podcast has been successfully created with all assets! 🎊", + }, + }, + ] + if full_banner_url: + blocks.append( + { + "type": "image", + "image_url": full_banner_url, + "alt_text": f"Banner for {podcast_title}", + } + ) + action_elements = [] + if full_audio_url: + action_elements.append( + { + "type": "button", + "text": {"type": "plain_text", "text": "🎵 Download Audio"}, + "url": full_audio_url, + "action_id": "download_final_audio", + } + ) + action_elements.append( + { + "type": "button", + "text": {"type": "plain_text", "text": "🎙️ Create New Podcast"}, + "style": "primary", + "action_id": "new_podcast", + "value": thread_key, + } + ) + blocks.append({"type": "actions", "elements": action_elements}) + await send_slack_blocks(thread_key, blocks, "🎉 Podcast Complete!") + + +async def send_slack_blocks(thread_key: str, blocks: list, fallback_text: str = "Interactive elements loaded"): + try: + session_info = get_session_info(thread_key) + if not session_info: + print(f"No session info found for thread: {thread_key}") + return + session_id, channel_id, user_id = session_info + if thread_key.startswith("dm_"): + app.client.chat_postMessage(channel=channel_id, blocks=blocks, text=fallback_text) + else: + app.client.chat_postMessage( + channel=channel_id, + blocks=blocks, + text=fallback_text, + thread_ts=thread_key, + ) + print(f"Sent interactive blocks to {thread_key}") + except Exception as e: + print(f"Error sending Slack blocks: {e}") + await send_slack_message( + thread_key, + "Interactive elements failed to load. Please continue with text responses.", + ) + + +async def send_slack_message(thread_key: str, text: str): + try: + session_info = get_session_info(thread_key) + if not session_info: + print(f"No session info found for thread: {thread_key}") + return + session_id, channel_id, user_id = session_info + if len(text) > 3800: + chunks = [text[i : i + 3800] for i in range(0, len(text), 3800)] + for i, chunk in enumerate(chunks): + if i == 0: + if thread_key.startswith("dm_"): + app.client.chat_postMessage(channel=channel_id, text=chunk) + else: + app.client.chat_postMessage(channel=channel_id, text=chunk, thread_ts=thread_key) + else: + if thread_key.startswith("dm_"): + app.client.chat_postMessage(channel=channel_id, text=f"...continued:\n{chunk}") + else: + app.client.chat_postMessage( + channel=channel_id, + text=f"...continued:\n{chunk}", + thread_ts=thread_key, + ) + else: + if thread_key.startswith("dm_"): + app.client.chat_postMessage(channel=channel_id, text=text) + else: + app.client.chat_postMessage(channel=channel_id, text=text, thread_ts=thread_key) + print(f"Sent message to {thread_key}: {text[:50]}...") + except Exception as e: + print(f"Error sending Slack message: {e}") + + +def clean_text(text, bot_id): + text = re.sub(f"<@{bot_id}>", "", text).strip() + return text + + +def format_script_for_slack(script_data) -> List[str]: + if isinstance(script_data, dict): + chunks = [] + current_chunk = "" + title = script_data.get("title", "Podcast Script") + current_chunk += f"*{title}*\n\n" + sections = script_data.get("sections", []) + for i, section in enumerate(sections): + section_text = f"*Section {i + 1}: {section.get('type', 'Unknown').title()}*" + if section.get("title"): + section_text += f" - {section['title']}" + section_text += "\n\n" + if section.get("dialog"): + for dialog in section["dialog"]: + speaker = dialog.get("speaker", "Speaker") + text = dialog.get("text", "") + dialog_text = f"*{speaker}:* {text}\n\n" + if len(current_chunk + section_text + dialog_text) > 3500: + if current_chunk.strip(): + chunks.append(current_chunk.strip()) + current_chunk = section_text + dialog_text + else: + current_chunk += section_text + dialog_text + section_text = "" + else: + current_chunk += section_text + current_chunk += "\n---\n\n" + if current_chunk.strip(): + chunks.append(current_chunk.strip()) + return chunks if chunks else ["Script content could not be formatted."] + elif isinstance(script_data, str): + try: + parsed_data = json.loads(script_data) + if isinstance(parsed_data, dict): + return format_script_for_slack(parsed_data) + except (json.JSONDecodeError, TypeError): + pass + text = script_data + if len(text) <= 3500: + return [text] + else: + return [text[i : i + 3500] for i in range(0, len(text), 3500)] + else: + try: + text = str(script_data) + if len(text) <= 3500: + return [text] + else: + return [text[i : i + 3500] for i in range(0, len(text), 3500)] + except Exception as e: + print(f"Error converting script data to string: {e}") + return ["Error: Could not format script data for display."] + + +@app.action("source_selection") +def handle_source_selection(ack, body, logger): + ack() + + +@app.action("language_selection") +def handle_language_selection(ack, body, logger): + ack() + + +@app.action("confirm_sources") +def handle_confirm_sources(ack, body, client): + ack() + + def process_confirmation(): + try: + thread_key = body["actions"][0]["value"] + user_id = body["user"]["id"] + selected_sources = [] + selected_language = "en" + if "state" in body and "values" in body["state"]: + values = body["state"]["values"] + if "source_selection_block" in values and "source_selection" in values["source_selection_block"]: + source_data = values["source_selection_block"]["source_selection"] + if "selected_options" in source_data and source_data["selected_options"]: + selected_sources = [int(opt["value"]) for opt in source_data["selected_options"]] + if "language_selection_block" in values and "language_selection" in values["language_selection_block"]: + lang_data = values["language_selection_block"]["language_selection"] + if "selected_option" in lang_data and lang_data["selected_option"]: + selected_language = lang_data["selected_option"]["value"] + session_info = get_session_info(thread_key) + if not session_info: + client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text="❌ Session not found. Please start a new conversation.", + ) + return + session_id = session_info[0] + state_data = get_session_state(session_id) + languages = state_data.get("available_languages", [{"code": "en", "name": "English"}]) + language_name = next( + (lang["name"] for lang in languages if lang["code"] == selected_language), + "English", + ) + sources = state_data.get("search_results", []) + if selected_sources: + source_indices = [str(i + 1) for i in selected_sources] + selected_source_titles = [sources[i].get("title", f"Source {i + 1}") for i in selected_sources if i < len(sources)] + message = f"I've selected sources {', '.join(source_indices)} and I want the podcast in {language_name}." + else: + source_indices = [str(i + 1) for i in range(len(sources))] + selected_source_titles = [source.get("title", f"Source {i + 1}") for i, source in enumerate(sources)] + message = f"I want the podcast in {language_name} using all available sources." + try: + confirmation_blocks = create_confirmation_blocks( + selected_sources, + selected_source_titles, + language_name, + len(sources), + ) + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=confirmation_blocks, + text="✅ Selection Confirmed", + ) + print(f"Updated interactive message to confirmation state for {thread_key}") + except Exception as e: + print(f"Error updating message: {e}") + client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text=f"🔄 Processing your selection: {message}\n\n_Generating podcast script..._", + ) + asyncio.run(process_source_confirmation(thread_key, message)) + except Exception as e: + print(f"Error in confirm_sources: {e}") + client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text="❌ Error processing your selection. Please try again.", + ) + + executor.submit(process_confirmation) + + +def create_confirmation_blocks(selected_sources, selected_source_titles, language_name, total_sources): + if selected_sources: + source_text = "" + for i, (idx, title) in enumerate(zip(selected_sources, selected_source_titles)): + if i < 3: + short_title = title[:50] + "..." if len(title) > 50 else title + source_text += f"• *{idx}.* {short_title}\n" + elif i == 3: + remaining = len(selected_sources) - 3 + source_text += f"• _...and {remaining} more sources_\n" + break + source_summary = f"*Selected {len(selected_sources)} of {total_sources} sources:*\n{source_text}" + else: + source_summary = f"*Selected all {total_sources} sources*" + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*✅ Selection Confirmed*\n_Your preferences have been saved and processing has started._", + }, + }, + {"type": "section", "text": {"type": "mrkdwn", "text": source_summary}}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*Language:* {language_name} 🌐"}, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Confirmed at {datetime.now().strftime('%H:%M')} • Processing script generation..._", + } + ], + }, + ] + return blocks + + +async def process_source_confirmation(thread_key: str, message: str): + try: + session_info = get_session_info(thread_key) + if not session_info: + return + session_id = session_info[0] + chat_response = await api_client.chat(session_id, message) + if chat_response.get("is_processing"): + task_id = chat_response.get("task_id") + start_background_polling(session_id, thread_key, task_id) + else: + response_text = chat_response.get("response", "Selection processed!") + await send_slack_message(thread_key, response_text) + except Exception as e: + print(f"Error processing source confirmation: {e}") + await send_slack_message(thread_key, "❌ Error processing your selection. Please try again.") + + +@app.action("request_script_changes") +def handle_request_script_changes(ack, body, client): + ack() + + def process_request(): + try: + thread_key = body["actions"][0]["value"] + change_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*🔄 Changes Requested*\n_Please describe what changes you'd like to make to the script._", + }, + } + ] + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=change_blocks, + text="Changes Requested", + ) + except Exception as e: + print(f"Error updating script message: {e}") + client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text="What specific changes would you like me to make to the script? For example:\n• Adjust the tone or style\n• Add more detail on certain topics\n• Change the dialogue flow\n• Modify the structure", + ) + except Exception as e: + print(f"Error in request_script_changes: {e}") + + executor.submit(process_request) + + +@app.action("approve_script") +def handle_approve_script(ack, body, client): + ack() + + def process_approval(): + try: + thread_key = body["actions"][0]["value"] + approval_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*✅ Script Approved*\n_Script has been approved and banner generation is starting._", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Approved at {datetime.now().strftime('%H:%M')} • Processing banner generation..._", + } + ], + }, + ] + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=approval_blocks, + text="✅ Script Approved", + ) + except Exception as e: + print(f"Error updating script message: {e}") + client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text="🔄 Script approved! Generating banner images...", + ) + asyncio.run(process_approval_action(body, "I approve this script. It looks good!")) + except Exception as e: + print(f"Error in approve_script: {e}") + + executor.submit(process_approval) + + +@app.action("approve_banner") +def handle_approve_banner(ack, body, client): + ack() + + def process_approval(): + try: + thread_key = body["actions"][0]["value"] + approval_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*✅ Banner Approved*\n_Banner has been approved and audio generation is starting._", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Approved at {datetime.now().strftime('%H:%M')} • Processing audio generation..._", + } + ], + }, + ] + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=approval_blocks, + text="✅ Banner Approved", + ) + except Exception as e: + print(f"Error updating banner message: {e}") + client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text="🔄 Banner approved! Generating podcast audio...", + ) + asyncio.run(process_approval_action(body, "I approve this banner. It looks good!")) + except Exception as e: + print(f"Error in approve_banner: {e}") + + executor.submit(process_approval) + + +@app.action("approve_audio") +def handle_approve_audio(ack, body, client): + ack() + + def process_approval(): + try: + thread_key = body["actions"][0]["value"] + approval_blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*✅ Audio Approved*\n_Audio has been approved and your podcast is being finalized._", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Approved at {datetime.now().strftime('%H:%M')} • Finalizing podcast..._", + } + ], + }, + ] + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + blocks=approval_blocks, + text="✅ Audio Approved", + ) + except Exception as e: + print(f"Error updating audio message: {e}") + client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text="🔄 Audio approved! Finalizing your podcast...", + ) + asyncio.run(process_approval_action(body, "The audio sounds great! I'm happy with the final podcast.")) + except Exception as e: + print(f"Error in approve_audio: {e}") + + executor.submit(process_approval) + + +@app.action("new_podcast") +def handle_new_podcast(ack, body, client): + ack() + + def start_new(): + try: + old_thread_key = body["actions"][0]["value"] + channel_id = body["channel"]["id"] + user_id = body["user"]["id"] + import time + + new_thread_key = f"new_{channel_id}_{user_id}_{int(time.time())}" + client.chat_postMessage( + channel=channel_id, + text="🎙️ *Welcome to AI Podcast Studio!*\n\nI'll help you create a professional podcast from your trusted sources. What topic would you like to create a podcast about?", + ) + print(f"Started new podcast conversation: {new_thread_key}") + except Exception as e: + print(f"Error starting new podcast: {e}") + client.chat_postMessage( + channel=body["channel"]["id"], + text="❌ Error starting new podcast. Please try sending a new message.", + ) + + executor.submit(start_new) + + +async def process_approval_action(body, approval_message: str): + try: + thread_key = body["actions"][0]["value"] + app.client.chat_postMessage( + channel=body["channel"]["id"], + thread_ts=thread_key if not thread_key.startswith("dm_") else None, + text=f"✅ {approval_message}\n🔄 Processing next step...", + ) + session_info = get_session_info(thread_key) + if not session_info: + await send_slack_message(thread_key, "❌ Session not found.") + return + session_id = session_info[0] + chat_response = await api_client.chat(session_id, approval_message) + if chat_response.get("is_processing"): + task_id = chat_response.get("task_id") + start_background_polling(session_id, thread_key, task_id) + else: + response_text = chat_response.get("response", "Approved! Processing next step...") + await send_slack_message(thread_key, response_text) + except Exception as e: + print(f"Error processing approval: {e}") + await send_slack_message(thread_key, "❌ Error processing approval. Please try again.") + + +@app.event("app_mention") +def handle_app_mention(event, say, client): + bot_info = client.auth_test() + bot_id = bot_info["user_id"] + user_input = clean_text(event["text"], bot_id) + thread_key = event["ts"] + channel_id = event["channel"] + user_id = event["user"] + + def handle_async(): + asyncio.run(handle_user_message(thread_key, user_input, say, channel_id, user_id, is_mention=True)) + + executor.submit(handle_async) + + +@app.message("") +def handle_message(message, say, client): + if message.get("bot_id"): + return + if message.get("text", "").startswith("<@"): + return + user_input = message["text"] + channel_type = message.get("channel_type", "") + is_dm = channel_type == "im" + channel_id = message["channel"] + user_id = message["user"] + thread_key = get_thread_key(message, is_dm) + + def handle_async(): + asyncio.run(handle_user_message(thread_key, user_input, say, channel_id, user_id, is_dm=is_dm)) + + executor.submit(handle_async) + + +async def handle_user_message( + thread_key: str, + user_input: str, + say, + channel_id: str, + user_id: str, + is_mention=False, + is_dm=False, +): + try: + session_id = await get_or_create_session(thread_key, channel_id, user_id) + session_state = get_session_state(session_id) + if session_state.get("podcast_generated") and session_state.get("stage") == "complete": + script = session_state.get("generated_script", {}) + podcast_title = script.get("title") if isinstance(script, dict) else "Your Podcast" + podcast_id = session_state.get("podcast_id", "") + completion_queries = ["download", "script", "audio", "banner", "share", "link", "asset", "file"] + is_asset_query = any(query in user_input.lower() for query in completion_queries) + if is_asset_query: + completion_message = ( + f"🎉 *'{podcast_title}' is complete!*\n\n" + "💡 *Looking for your podcast assets?*\n" + "All download links and assets were provided in the completion message above. " + "Please scroll up to find:\n" + "• Audio download link\n" + "• Banner images\n" + "• Complete script\n" + f"• Podcast ID: `{podcast_id}`\n\n" + "To create a **new podcast**, please start a fresh chat with me. 🎙️" + ) + else: + completion_message = ( + f"🎉 *'{podcast_title}' is complete!*\n\n" + "This podcast session has finished successfully. To create a new podcast:\n\n" + "• **Start a new chat** with me\n" + "• Each podcast needs a fresh conversation\n" + "• Your completed podcast assets remain available above\n\n" + "Ready to create another amazing podcast? 🎙️✨" + ) + if not is_dm and not is_mention: + say(text=completion_message, thread_ts=thread_key) + else: + say(text=completion_message) + print(f"Session {session_id} is complete - prevented API call for: {user_input[:50]}...") + return + if session_id in active_sessions: + active_session = active_sessions[session_id] + start_time = active_session.get("start_time", datetime.now()) + elapsed_minutes = (datetime.now() - start_time).total_seconds() / 60 + current_stage = session_state.get("stage", "unknown") + process_type = active_session.get("process_type", "your request") + stage_messages = { + "search": "🔍 Searching for relevant sources", + "scraping": "📰 Gathering full content from sources", + "script": "📝 Generating podcast script", + "banner": "🎨 Creating banner images", + "image": "🎨 Creating banner images", + "audio": "🎵 Generating podcast audio", + } + stage_message = stage_messages.get(current_stage, f"🔄 Processing {process_type}") + progress_message = ( + f"⏳ *Still working on your podcast...*\n\n" + f"{stage_message}\n" + f"⏱️ Running for {elapsed_minutes:.1f} minutes\n\n" + f"_Please wait while I complete this step. This can take several minutes for high-quality results._" + ) + if current_stage == "search": + progress_message += "\n\n💡 *Currently:* Finding the best sources across multiple platforms" + elif current_stage == "script": + progress_message += "\n\n💡 *Currently:* Crafting engaging dialogue and content structure" + elif current_stage in ["banner", "image"]: + progress_message += "\n\n💡 *Currently:* Generating professional banner designs" + elif current_stage == "audio": + progress_message += "\n\n💡 *Currently:* Creating high-quality voice narration" + if not is_dm and not is_mention: + say(text=progress_message, thread_ts=thread_key) + else: + say(text=progress_message) + print(f"Session {session_id} already processing ({current_stage}) - prevented API call for: {user_input[:50]}...") + return + print(f"Processing message for session {session_id}: {user_input[:50]}...") + chat_response = await api_client.chat(session_id, user_input) + if chat_response.get("response"): + response_text = chat_response["response"] + if not is_dm and not is_mention: + say(text=response_text, thread_ts=thread_key) + else: + say(text=response_text) + if chat_response.get("is_processing"): + task_id = chat_response.get("task_id") + start_background_polling(session_id, thread_key, task_id) + processing_msg = "🔄 Processing your request... This may take a moment." + if not is_dm and not is_mention: + say(text=processing_msg, thread_ts=thread_key) + else: + say(text=processing_msg) + else: + await send_completion_message(thread_key, chat_response) + except Exception as e: + print(f"Error handling message: {e}") + if "timeout" in str(e).lower(): + error_msg = "⏱️ Request timed out. The system might be busy. Please try again in a moment." + elif "connection" in str(e).lower(): + error_msg = "🔌 Connection issue. Please check your connection and try again." + else: + error_msg = "❌ Sorry, I encountered an error processing your request. Please try again." + if not is_dm and not is_mention: + say(text=error_msg, thread_ts=thread_key) + else: + say(text=error_msg) + + +init_db() + +if __name__ == "__main__": + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + print("⚡️ Podcast Bot is running! Press Ctrl+C to stop.") + print(f"🎙️ Connected to API at: {API_BASE_URL}") + handler.start() \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/main.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/main.py new file mode 100644 index 0000000..130b62b --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/main.py @@ -0,0 +1,187 @@ +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, StreamingResponse +import uvicorn +import os +import aiofiles +from contextlib import asynccontextmanager +from routers import article_router, podcast_router, source_router, task_router, podcast_config_router, async_podcast_agent_router, social_media_router +from services.db_init import init_databases +from dotenv import load_dotenv + + +load_dotenv() + +CLIENT_BUILD_PATH = os.environ.get( + "CLIENT_BUILD_PATH", + "../web/build", +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + print("Starting up application...") + os.makedirs("databases", exist_ok=True) + os.makedirs("browsers", exist_ok=True) + os.makedirs("podcasts/audio", exist_ok=True) + os.makedirs("podcasts/images", exist_ok=True) + os.makedirs("podcasts/recordings", exist_ok=True) + await init_databases() + if not os.path.exists(CLIENT_BUILD_PATH): + print(f"WARNING: React client build path not found: {CLIENT_BUILD_PATH}") + print("Application startup complete!") + yield + print("Shutting down application...") + print("Shutdown complete") + + +app = FastAPI(title="Beifong API", description="Beifong API", version="1.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +app.include_router(article_router.router, prefix="/api/articles", tags=["articles"]) +app.include_router(source_router.router, prefix="/api/sources", tags=["sources"]) +app.include_router(podcast_router.router, prefix="/api/podcasts", tags=["podcasts"]) +app.include_router(task_router.router, prefix="/api/tasks", tags=["tasks"]) +app.include_router(podcast_config_router.router, prefix="/api/podcast-configs", tags=["podcast-configs"]) +app.include_router(async_podcast_agent_router.router, prefix="/api/podcast-agent", tags=["podcast-agent"]) +app.include_router(social_media_router.router, prefix="/api/social-media", tags=["social-media"]) + + +@app.get("/stream-audio/{filename}") +async def stream_audio(filename: str, request: Request): + audio_path = os.path.join("podcasts/audio", filename) + if not os.path.exists(audio_path): + return Response(status_code=404, content="Audio file not found") + file_size = os.path.getsize(audio_path) + range_header = request.headers.get("Range", "").strip() + start = 0 + end = file_size - 1 + if range_header: + try: + range_data = range_header.replace("bytes=", "").split("-") + start = int(range_data[0]) if range_data[0] else 0 + end = int(range_data[1]) if len(range_data) > 1 and range_data[1] else file_size - 1 + except ValueError: + return Response(status_code=400, content="Invalid range header") + end = min(end, file_size - 1) + content_length = end - start + 1 + headers = { + "Accept-Ranges": "bytes", + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(content_length), + "Content-Disposition": f"inline; filename={filename}", + "Content-Type": "audio/wav", + } + + async def file_streamer(): + async with aiofiles.open(audio_path, "rb") as f: + await f.seek(start) + remaining = content_length + chunk_size = 64 * 1024 + while remaining > 0: + chunk = await f.read(min(chunk_size, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + status_code = 206 if range_header else 200 + return StreamingResponse(file_streamer(), status_code=status_code, headers=headers) + + +@app.get("/stream-recording/{session_id}/{filename}") +async def stream_recording(session_id: str, filename: str, request: Request): + recording_path = os.path.join("podcasts/recordings", session_id, filename) + if not os.path.exists(recording_path): + return Response(status_code=404, content="Recording video not found") + file_size = os.path.getsize(recording_path) + range_header = request.headers.get("Range", "").strip() + start = 0 + end = file_size - 1 + if range_header: + try: + range_data = range_header.replace("bytes=", "").split("-") + start = int(range_data[0]) if range_data[0] else 0 + end = int(range_data[1]) if len(range_data) > 1 and range_data[1] else file_size - 1 + except ValueError: + return Response(status_code=400, content="Invalid range header") + end = min(end, file_size - 1) + content_length = end - start + 1 + headers = { + "Accept-Ranges": "bytes", + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(content_length), + "Content-Disposition": f"inline; filename={filename}", + "Content-Type": "video/webm", + } + + async def file_streamer(): + async with aiofiles.open(recording_path, "rb") as f: + await f.seek(start) + remaining = content_length + chunk_size = 64 * 1024 + while remaining > 0: + chunk = await f.read(min(chunk_size, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + status_code = 206 if range_header else 200 + return StreamingResponse(file_streamer(), status_code=status_code, headers=headers) + + +app.mount("/audio", StaticFiles(directory="podcasts/audio"), name="audio") +app.mount("/server_static", StaticFiles(directory="static"), name="server_static") +app.mount("/podcast_img", StaticFiles(directory="podcasts/images"), name="podcast_img") +if os.path.exists(os.path.join(CLIENT_BUILD_PATH, "static")): + app.mount("/static", StaticFiles(directory=os.path.join(CLIENT_BUILD_PATH, "static")), name="react_static") + + +@app.get("/favicon.ico") +async def favicon(): + favicon_path = os.path.join(CLIENT_BUILD_PATH, "favicon.ico") + if os.path.exists(favicon_path): + return FileResponse(favicon_path) + return {"detail": "Favicon not found"} + + +@app.get("/manifest.json") +async def manifest(): + manifest_path = os.path.join(CLIENT_BUILD_PATH, "manifest.json") + if os.path.exists(manifest_path): + return FileResponse(manifest_path) + return {"detail": "Manifest not found"} + + +@app.get("/logo{rest_of_path:path}") +async def logo(rest_of_path: str): + logo_path = os.path.join(CLIENT_BUILD_PATH, f"logo{rest_of_path}") + if os.path.exists(logo_path): + return FileResponse(logo_path) + return {"detail": "Logo not found"} + + +@app.get("/{full_path:path}") +async def serve_react(full_path: str, request: Request): + if full_path.startswith("api/") or request.url.path.startswith("/api/"): + return {"detail": "Not Found"} + index_path = os.path.join(CLIENT_BUILD_PATH, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + else: + return {"detail": "React client not found. Build the client or set the correct CLIENT_BUILD_PATH."} + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 7000)) + uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False, timeout_keep_alive=120, timeout_graceful_shutdown=120) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/article_schemas.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/article_schemas.py new file mode 100644 index 0000000..6de8f20 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/article_schemas.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional, List, Dict, Any + + +class ArticleBase(BaseModel): + title: str + url: Optional[str] = None + published_date: str + summary: Optional[str] = None + content: Optional[str] = None + categories: Optional[List[str]] = [] + source_name: Optional[str] = None + + +class Article(ArticleBase): + id: int + metadata: Optional[Dict[str, Any]] = {} + + model_config = ConfigDict(from_attributes=True) + + +class PaginatedArticles(BaseModel): + items: List[Article] + total: int + page: int + per_page: int + total_pages: int + has_next: bool + has_prev: bool \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/podcast_config_schemas.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/podcast_config_schemas.py new file mode 100644 index 0000000..0e8fc1e --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/podcast_config_schemas.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class PodcastConfigBase(BaseModel): + name: str + prompt: str + description: Optional[str] = None + time_range_hours: int = Field(24, ge=1, le=168) + limit_articles: int = Field(20, ge=5, le=50) + is_active: bool = True + tts_engine: str = "kokoro" + language_code: str = "en" + podcast_script_prompt: Optional[str] = None + image_prompt: Optional[str] = None + + +class PodcastConfig(PodcastConfigBase): + id: int + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class PodcastConfigCreate(PodcastConfigBase): + pass + + +class PodcastConfigUpdate(BaseModel): + name: Optional[str] = None + prompt: Optional[str] = None + description: Optional[str] = None + time_range_hours: Optional[int] = Field(None, ge=1, le=168) + limit_articles: Optional[int] = Field(None, ge=5, le=50) + is_active: Optional[bool] = None + tts_engine: Optional[str] = None + language_code: Optional[str] = None + podcast_script_prompt: Optional[str] = None + image_prompt: Optional[str] = None diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/podcast_schemas.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/podcast_schemas.py new file mode 100644 index 0000000..a372d8e --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/podcast_schemas.py @@ -0,0 +1,81 @@ +from pydantic import BaseModel +from typing import Optional, List, Dict, Any, Union + + +class PodcastBase(BaseModel): + title: str + date: str + audio_generated: bool = False + banner_img: Optional[str] = None + identifier: str + language_code: Optional[str] = "en" + tts_engine: Optional[str] = "kokoro" + + +class Podcast(PodcastBase): + id: int + created_at: Optional[str] = None + audio_path: Optional[str] = None + + class Config: + from_attributes = True + + +class PodcastContent(BaseModel): + title: str + sections: List[Dict[str, Any]] + + +class PodcastSource(BaseModel): + title: Optional[str] = None + url: Optional[str] = None + source: Optional[str] = None + + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if isinstance(v, str): + return cls(url=v) + if isinstance(v, dict): + return cls(**v) + raise ValueError("Source must be a string or a dict") + + +class PodcastDetail(BaseModel): + podcast: Podcast + content: PodcastContent + audio_url: Optional[str] = None + sources: Optional[List[Union[PodcastSource, str]]] = None + banner_images: Optional[List[str]] = None + + +class PodcastCreate(BaseModel): + title: str + date: Optional[str] = None + content: Dict[str, Any] + sources: Optional[List[Union[Dict[str, str], str]]] = None + language_code: Optional[str] = "en" + tts_engine: Optional[str] = "kokoro" + + +class PodcastUpdate(BaseModel): + title: Optional[str] = None + date: Optional[str] = None + content: Optional[Dict[str, Any]] = None + audio_generated: Optional[bool] = None + sources: Optional[List[Union[Dict[str, str], str]]] = None + language_code: Optional[str] = None + tts_engine: Optional[str] = None + + +class PaginatedPodcasts(BaseModel): + items: List[Podcast] + total: int + page: int + per_page: int + total_pages: int + has_next: bool + has_prev: bool diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/schemas.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/schemas.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/social_media_schemas.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/social_media_schemas.py new file mode 100644 index 0000000..67c3b08 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/social_media_schemas.py @@ -0,0 +1,56 @@ +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime + +class PostBase(BaseModel): + post_id: str + platform: str + user_display_name: Optional[str] = None + user_handle: Optional[str] = None + user_profile_pic_url: Optional[str] = None + post_timestamp: Optional[str] = None + post_display_time: Optional[str] = None + post_url: Optional[str] = None + post_text: Optional[str] = None + post_mentions: Optional[str] = None + +class PostEngagement(BaseModel): + replies: Optional[int] = None + retweets: Optional[int] = None + likes: Optional[int] = None + bookmarks: Optional[int] = None + views: Optional[int] = None + +class MediaItem(BaseModel): + type: str + url: str + +class Post(PostBase): + engagement: Optional[PostEngagement] = None + media: Optional[List[MediaItem]] = None + media_count: Optional[int] = 0 + is_ad: Optional[bool] = False + sentiment: Optional[str] = None + categories: Optional[List[str]] = None + tags: Optional[List[str]] = None + analysis_reasoning: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + +class PaginatedPosts(BaseModel): + items: List[Post] + total: int + page: int + per_page: int + total_pages: int + has_next: bool + has_prev: bool + +class PostFilterParams(BaseModel): + platform: Optional[str] = None + user_handle: Optional[str] = None + sentiment: Optional[str] = None + category: Optional[str] = None + date_from: Optional[str] = None + date_to: Optional[str] = None + search: Optional[str] = None \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/source_schemas.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/source_schemas.py new file mode 100644 index 0000000..e36af08 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/source_schemas.py @@ -0,0 +1,68 @@ +from pydantic import BaseModel +from typing import Optional, List + + +class SourceFeed(BaseModel): + id: int + feed_url: str + feed_type: str + description: Optional[str] = None + is_active: bool + created_at: str + last_crawled: Optional[str] = None + + +class SourceFeedCreate(BaseModel): + feed_url: str + feed_type: str = "main" + description: Optional[str] = None + is_active: bool = True + + +class SourceBase(BaseModel): + name: str + url: Optional[str] = None + categories: Optional[List[str]] = [] + description: Optional[str] = None + is_active: bool = True + + +class Source(SourceBase): + id: int + created_at: Optional[str] = None + last_crawled: Optional[str] = None + + class Config: + from_attributes = True + + +class SourceCreate(SourceBase): + feeds: Optional[List[SourceFeedCreate]] = [] + + +class SourceUpdate(BaseModel): + name: Optional[str] = None + url: Optional[str] = None + categories: Optional[List[str]] = None + description: Optional[str] = None + is_active: Optional[bool] = None + + +class SourceWithFeeds(Source): + feeds: List[SourceFeed] = [] + + +class PaginatedSources(BaseModel): + items: List[Source] + total: int + page: int + per_page: int + total_pages: int + has_next: bool + has_prev: bool + + +class Category(BaseModel): + id: int + name: str + description: Optional[str] = None diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/tasks_schemas.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/tasks_schemas.py new file mode 100644 index 0000000..dbf03fb --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/models/tasks_schemas.py @@ -0,0 +1,118 @@ +from pydantic import BaseModel, validator +from typing import Optional, List, Dict, Any +from enum import Enum + + +class TaskType(str, Enum): + feed_processor = "feed_processor" + url_crawler = "url_crawler" + ai_analyzer = "ai_analyzer" + podcast_generator = "podcast_generator" + embedding_processor = "embedding_processor" + faiss_indexer = "faiss_indexer" + social_x_scraper = "social_x_scraper" + social_fb_scraper = "social_fb_scraper" + + +TASK_TYPES = { + "feed_processor": { + "name": "Feed Processor", + "command": "python -m processors.feed_processor", + "description": "Processes RSS feeds and stores new entries", + }, + "url_crawler": {"name": "URL Crawler", "command": "python -m processors.url_processor", "description": "Crawls URLs and extracts content"}, + "ai_analyzer": { + "name": "AI Analyzer", + "command": "python -m processors.ai_analysis_processor", + "description": "Analyzes article content using AI", + }, + "podcast_generator": { + "name": "Podcast Generator", + "command": "python -m processors.podcast_generator_processor", + "description": "Generates podcasts from articles", + }, + "embedding_processor": { + "name": "Embedding Processor", + "command": "python -m processors.embedding_processor", + "description": "Generates embeddings for processed articles using OpenAI", + }, + "faiss_indexer": { + "name": "FAISS Indexer", + "command": "python -m processors.faiss_indexing_processor", + "description": "Updates FAISS vector index with new article embeddings", + }, + "social_x_scraper": { + "name": "X.com Scraper", + "command": "python -m processors.x_scraper_processor", + "description": "Scrapes X.com profiles and analyzes sentiment", + }, + "social_fb_scraper": { + "name": "Facebook.com Scraper", + "command": "python -m processors.fb_scraper_processor", + "description": "Scrapes Facebook.com profiles and analyzes sentiment", + }, +} + + +class TaskBase(BaseModel): + name: str + task_type: TaskType + frequency: int + frequency_unit: str + description: Optional[str] = None + enabled: bool = True + + @validator("task_type") + def set_command_from_type(cls, v): + if v not in TASK_TYPES: + raise ValueError(f"Invalid task type: {v}") + return v + + +class Task(TaskBase): + id: int + command: str + last_run: Optional[str] = None + created_at: Optional[str] = None + + class Config: + from_attributes = True + + +class TaskCreate(TaskBase): + pass + + +class TaskUpdate(BaseModel): + name: Optional[str] = None + task_type: Optional[TaskType] = None + frequency: Optional[int] = None + frequency_unit: Optional[str] = None + description: Optional[str] = None + enabled: Optional[bool] = None + + +class TaskExecution(BaseModel): + id: int + task_id: int + task_name: Optional[str] = None + start_time: str + end_time: Optional[str] = None + status: str + error_message: Optional[str] = None + output: Optional[str] = None + + +class PaginatedTaskExecutions(BaseModel): + items: List[TaskExecution] + total: int + page: int + per_page: int + total_pages: int + has_next: bool + has_prev: bool + + +class TaskStats(BaseModel): + tasks: Dict[str, int] + executions: Dict[str, Any] diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/pack_demo.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/pack_demo.py new file mode 100644 index 0000000..4ab64b1 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/pack_demo.py @@ -0,0 +1,25 @@ +import os +import zipfile + +SOURCE_DIRS = ["databases", "podcasts"] +OUTPUT_ZIP = "demo_content.zip" + + +def create_zip(source_dirs, output_zip): + print("packing.....") + """zip up each source directory into a single archive.""" + with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as z: + for src in source_dirs: + if not os.path.isdir(src): + print(f"✗ source '{src}' not found, skipping.") + continue + for root, _, files in os.walk(src): + for file in files: + full_path = os.path.join(root, file) + arcname = os.path.relpath(full_path, os.getcwd()) + z.write(full_path, arcname) + print(f"✓ created '{output_zip}' containing: {', '.join(source_dirs)}") + + +if __name__ == "__main__": + create_zip(SOURCE_DIRS, OUTPUT_ZIP) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/ai_analysis_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/ai_analysis_processor.py new file mode 100644 index 0000000..1d44d5d --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/ai_analysis_processor.py @@ -0,0 +1,188 @@ +import json +import time +import random +import argparse +from bs4 import BeautifulSoup +from openai import OpenAI +from db.config import get_tracking_db_path +from db.articles import get_unprocessed_articles, update_article_status +from utils.load_api_keys import load_api_key + +WEB_PAGE_ANALYSE_MODEL = "gpt-4o" +MODEL_INSTRUCTION = "You are a helpful assistant that analyzes articles and extracts structured information." + + +def extract_clean_text(raw_html, max_tokens=8000): + soup = BeautifulSoup(raw_html, "html.parser") + for element in soup(["script", "style", "nav", "header", "footer", "aside"]): + element.decompose() + text = soup.get_text(separator="\n", strip=True) + lines = [line.strip() for line in text.splitlines() if line.strip()] + text = "\n".join(lines) + approx_tokens = len(text) / 4 + if approx_tokens > max_tokens: + text = text[: max_tokens * 4] + return text + + +def process_article_with_ai(client, article, max_tokens=8000): + clean_text = extract_clean_text(article["raw_content"], max_tokens) + metadata = article.get("metadata", {}) + title = article["title"] + url = article["url"] + description = "" + if metadata and isinstance(metadata, dict): + if "description" in metadata: + description = metadata["description"] + elif "og" in metadata and "description" in metadata["og"]: + description = metadata["og"]["description"] + try: + response = client.chat.completions.create( + model=WEB_PAGE_ANALYSE_MODEL, + response_format={"type": "json_object"}, + messages=[ + { + "role": "system", + "content": MODEL_INSTRUCTION, + }, + { + "role": "user", + "content": f""" + Analyze this article and provide a structured output with three components: + + 1. A list of 3-5 relevant categories for this article + 2. A concise 2-3 sentence summary of the article + 3. The extracted main article content, removing any navigation, ads, or irrelevant elements + + Article Title: {title} + Article URL: {url} + Description: {description} + + Article Text: + {clean_text} + + Provide your response as a JSON object with these keys: + - categories: an array of 3-5 relevant categories (as strings) + - summary: a 2-3 sentence summary of the article + - content: the cleaned main article content + """, + }, + ], + temperature=0.3, + max_tokens=1500, + ) + response_json = json.loads(response.choices[0].message.content) + categories = response_json.get("categories", []) + if isinstance(categories, str): + categories = [cat.strip() for cat in categories.split(",") if cat.strip()] + results = { + "categories": categories, + "summary": response_json.get("summary", ""), + "content": response_json.get("content", ""), + } + return results, True, None + except Exception as e: + error_message = str(e) + print(f"Error processing article with AI: {error_message}") + return None, False, error_message + + +def analyze_articles(tracking_db_path=None, openai_api_key=None, batch_size=5, delay_range=(1, 3)): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if openai_api_key is None: + raise ValueError("OpenAI API key is required") + client = OpenAI(api_key=openai_api_key) + articles = get_unprocessed_articles(tracking_db_path, limit=batch_size) + stats = {"total_articles": len(articles), "success_count": 0, "failed_count": 0} + for i, article in enumerate(articles): + article_id = article["id"] + title = article["title"] + attempt = article.get("ai_attempts", 0) + 1 + print(f"[{i + 1}/{len(articles)}] Processing article: {title} (Attempt {attempt})") + results, success, error_message = process_article_with_ai(client, article) + update_article_status(tracking_db_path, article_id, results, success, error_message) + if success: + categories_display = ", ".join(results["categories"]) + print(f"Successfully processed article ID {article_id}") + print(f"Categories: {categories_display}") + print(f"Summary: {results['summary'][:100]}..." if len(results["summary"]) > 100 else f"Summary: {results['summary']}") + stats["success_count"] += 1 + else: + print(f"Failed to process article ID {article_id}: {error_message}") + stats["failed_count"] += 1 + + if i < len(articles) - 1: + delay = random.uniform(delay_range[0], delay_range[1]) + time.sleep(delay) + return stats + + +def print_stats(stats): + print("\nAI Analysis Statistics:") + print(f"Total articles processed: {stats['total_articles']}") + print(f"Successfully analyzed: {stats['success_count']}") + print(f"Failed: {stats['failed_count']}") + + +def analyze_in_batches( + tracking_db_path=None, + openai_api_key=None, + batch_size=20, + total_batches=1, + delay_between_batches=10, +): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if openai_api_key is None: + raise ValueError("OpenAI API key is required") + total_stats = {"total_articles": 0, "success_count": 0, "failed_count": 0} + for i in range(total_batches): + print(f"\nProcessing batch {i + 1}/{total_batches}") + batch_stats = analyze_articles( + tracking_db_path=tracking_db_path, + openai_api_key=openai_api_key, + batch_size=batch_size, + ) + total_stats["total_articles"] += batch_stats["total_articles"] + total_stats["success_count"] += batch_stats["success_count"] + total_stats["failed_count"] += batch_stats["failed_count"] + if batch_stats["total_articles"] == 0: + print("No more articles to process") + break + if i < total_batches - 1: + print(f"Waiting {delay_between_batches} seconds before next batch...") + time.sleep(delay_between_batches) + return total_stats + + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Process articles with AI analysis") + parser.add_argument("--api_key", help="OpenAI API Key (overrides environment variables)") + parser.add_argument( + "--batch_size", + type=int, + default=10, + help="Number of articles to process in each batch", + ) + parser.add_argument( + "--total_batches", + type=int, + default=1, + help="Total number of batches to process", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_arguments() + api_key = args.api_key or load_api_key() + if not api_key: + print("Error: No OpenAI API key provided. Please provide via --api_key or set OPENAI_API_KEY in .env file") + exit(1) + stats = analyze_in_batches( + openai_api_key=api_key, + batch_size=args.batch_size, + total_batches=args.total_batches, + ) + print_stats(stats) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/embedding_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/embedding_processor.py new file mode 100644 index 0000000..18b9ed4 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/embedding_processor.py @@ -0,0 +1,229 @@ +import time +import argparse +import random +import numpy as np +from openai import OpenAI +from db.config import get_tracking_db_path +from db.connection import db_connection, execute_query +from utils.load_api_keys import load_api_key + +EMBEDDING_MODEL = "text-embedding-3-small" + +def create_embedding_table(tracking_db_path): + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='article_embeddings' + """) + table_exists = cursor.fetchone() is not None + if not table_exists: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS article_embeddings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + embedding BLOB NOT NULL, + embedding_model TEXT NOT NULL, + created_at TEXT NOT NULL, + in_faiss_index INTEGER DEFAULT 0, + FOREIGN KEY (article_id) REFERENCES crawled_articles(id) + ) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_article_embeddings_article_id + ON article_embeddings(article_id) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_article_embeddings_in_faiss + ON article_embeddings(in_faiss_index) + """) + conn.commit() + print("Article embeddings table created successfully.") + else: + print("Article embeddings table already exists.") + + +def get_articles_without_embeddings(tracking_db_path, limit=20): + query = """ + SELECT ca.id, ca.title, ca.summary, ca.content + FROM crawled_articles ca + WHERE ca.processed = 1 + AND ca.ai_status = 'success' + AND NOT EXISTS ( + SELECT 1 FROM article_embeddings ae + WHERE ae.article_id = ca.id + ) + ORDER BY ca.published_date DESC + LIMIT ? + """ + return execute_query(tracking_db_path, query, (limit,), fetch=True) + + +def mark_articles_as_processing(tracking_db_path, article_ids): + if not article_ids: + return 0 + try: + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(crawled_articles)") + columns = [col[1] for col in cursor.fetchall()] + if "embedding_status" in columns: + placeholders = ",".join(["?"] * len(article_ids)) + query = f""" + UPDATE crawled_articles + SET embedding_status = 'processing' + WHERE id IN ({placeholders}) + """ + cursor.execute(query, article_ids) + conn.commit() + return cursor.rowcount + else: + print("Note: embedding_status column doesn't exist in crawled_articles table.") + print("Skipping article marking step (this is non-critical).") + return len(article_ids) + except Exception as e: + print(f"Error marking articles as processing: {str(e)}") + print("Continuing without marking articles (this is non-critical).") + return 0 + + +def generate_embedding(client, text, model=EMBEDDING_MODEL): + try: + response = client.embeddings.create(input=text, model=model) + return response.data[0].embedding, model + except Exception as e: + print(f"Error generating embedding: {str(e)}") + return None, None + + +def prepare_article_text(article): + title = article.get("title", "") + summary = article.get("summary", "") + content = article.get("content", "") + full_text = f"Title: {title}\n\nSummary: {summary}\n\nContent: {content}" + return full_text + + +def store_embedding(tracking_db_path, article_id, embedding, model): + from datetime import datetime + import sqlite3 + embedding_blob = np.array(embedding, dtype=np.float32).tobytes() + query = """ + INSERT INTO article_embeddings + (article_id, embedding, embedding_model, created_at, in_faiss_index) + VALUES (?, ?, ?, ?, 0) + """ + params = (article_id, embedding_blob, model, datetime.now().isoformat()) + try: + execute_query(tracking_db_path, query, params) + return True + except sqlite3.IntegrityError: + print(f"Warning: Embedding already exists for article {article_id}") + return False + except Exception as e: + print(f"Error storing embedding: {str(e)}") + return False + + +def process_articles_for_embedding(tracking_db_path=None, openai_api_key=None, batch_size=20, delay_range=(1, 3)): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if openai_api_key is None: + raise ValueError("OpenAI API key is required") + create_embedding_table(tracking_db_path) + client = OpenAI(api_key=openai_api_key) + articles = get_articles_without_embeddings(tracking_db_path, limit=batch_size) + if not articles: + print("No articles found that need embeddings") + return {"total_articles": 0, "success_count": 0, "failed_count": 0} + article_ids = [article["id"] for article in articles] + mark_articles_as_processing(tracking_db_path, article_ids) + stats = {"total_articles": len(articles), "success_count": 0, "failed_count": 0} + for i, article in enumerate(articles): + article_id = article["id"] + try: + print(f"[{i + 1}/{len(articles)}] Generating embedding for article {article_id}: {article['title']}") + text = prepare_article_text(article) + embedding, model = generate_embedding(client, text) + if embedding: + success = store_embedding(tracking_db_path, article_id, embedding, model) + if success: + print(f"Successfully stored embedding for article {article_id}") + stats["success_count"] += 1 + else: + print(f"Failed to store embedding for article {article_id}") + stats["failed_count"] += 1 + else: + print(f"Failed to generate embedding for article {article_id}") + stats["failed_count"] += 1 + except Exception as e: + print(f"Error processing article {article_id}: {str(e)}") + stats["failed_count"] += 1 + if i < len(articles) - 1: + delay = random.uniform(delay_range[0], delay_range[1]) + time.sleep(delay) + return stats + + +def print_stats(stats): + print("\nEmbedding Generation Statistics:") + print(f"Total articles processed: {stats['total_articles']}") + print(f"Successfully embedded: {stats['success_count']}") + print(f"Failed: {stats['failed_count']}") + + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Generate embeddings for processed articles") + parser.add_argument("--api_key", help="OpenAI API Key (overrides environment variables)") + parser.add_argument( + "--batch_size", + type=int, + default=20, + help="Number of articles to process in each batch", + ) + return parser.parse_args() + + +def process_in_batches( + tracking_db_path=None, + openai_api_key=None, + batch_size=20, + total_batches=1, + delay_between_batches=10, +): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if openai_api_key is None: + raise ValueError("OpenAI API key is required") + total_stats = {"total_articles": 0, "success_count": 0, "failed_count": 0} + for i in range(total_batches): + print(f"\nProcessing batch {i + 1}/{total_batches}") + batch_stats = process_articles_for_embedding( + tracking_db_path=tracking_db_path, + openai_api_key=openai_api_key, + batch_size=batch_size, + ) + total_stats["total_articles"] += batch_stats["total_articles"] + total_stats["success_count"] += batch_stats["success_count"] + total_stats["failed_count"] += batch_stats["failed_count"] + if batch_stats["total_articles"] == 0: + print("No more articles to process") + break + if i < total_batches - 1: + print(f"Waiting {delay_between_batches} seconds before next batch...") + time.sleep(delay_between_batches) + return total_stats + + +if __name__ == "__main__": + args = parse_arguments() + api_key = args.api_key or load_api_key() + if not api_key: + print("Error: No OpenAI API key provided. Please provide via --api_key or set OPENAI_API_KEY in .env file") + exit(1) + stats = process_in_batches( + openai_api_key=api_key, + batch_size=args.batch_size, + total_batches=3, + ) + print_stats(stats) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/faiss_indexing_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/faiss_indexing_processor.py new file mode 100644 index 0000000..cb90176 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/faiss_indexing_processor.py @@ -0,0 +1,339 @@ +import os +import time +import argparse +import numpy as np +import faiss +from db.config import get_tracking_db_path, get_faiss_db_path +from db.connection import db_connection, execute_query + + +def initialize_faiss_index(dimension=1536, index_path=None, index_type="hnsw", n_list=100): + if index_path and os.path.exists(index_path): + print(f"Loading existing FAISS index from {index_path}") + try: + index = faiss.read_index(index_path) + print(f"Loaded index with {index.ntotal} vectors") + return index + except Exception as e: + print(f"Error loading FAISS index: {str(e)}") + print("Creating a new index instead") + print(f"Creating new FAISS index with dimension {dimension}, type: {index_type}") + if index_type == "flat": + return faiss.IndexFlatL2(dimension) + + elif index_type == "ivfflat": + quantizer = faiss.IndexFlatL2(dimension) + index = faiss.IndexIVFFlat(quantizer, dimension, n_list) + print("Training IVF index with random vectors...") + train_size = max(10000, n_list * 10) + train_vectors = np.random.random((train_size, dimension)).astype(np.float32) + index.train(train_vectors) + index.nprobe = min(10, n_list // 10) + return index + elif index_type == "ivfpq": + quantizer = faiss.IndexFlatL2(dimension) + m = 16 + bits = 8 + index = faiss.IndexIVFPQ(quantizer, dimension, n_list, m, bits) + print("Training IVF-PQ index with random vectors...") + train_size = max(10000, n_list * 10) + train_vectors = np.random.random((train_size, dimension)).astype(np.float32) + index.train(train_vectors) + index.nprobe = min(10, n_list // 10) + return index + elif index_type == "hnsw": + m = 32 + ef_construction = 100 + index = faiss.IndexHNSWFlat(dimension, m) + index.hnsw.efConstruction = ef_construction + index.hnsw.efSearch = 64 + return index + else: + print(f"Unknown index type '{index_type}', falling back to IVF Flat") + quantizer = faiss.IndexFlatL2(dimension) + index = faiss.IndexIVFFlat(quantizer, dimension, n_list) + print("Training IVF index with random vectors...") + train_size = max(10000, n_list * 10) + train_vectors = np.random.random((train_size, dimension)).astype(np.float32) + index.train(train_vectors) + index.nprobe = min(10, n_list // 10) + return index + + +def save_faiss_index(index, index_path): + try: + index_dir = os.path.dirname(index_path) + os.makedirs(index_dir, exist_ok=True) + temp_path = f"{index_path}.tmp" + faiss.write_index(index, temp_path) + os.replace(temp_path, index_path) + print(f"FAISS index saved to {index_path}") + return True + except Exception as e: + print(f"Error saving FAISS index: {str(e)}") + return False + + +def save_id_mapping(id_map, mapping_path): + try: + mapping_dir = os.path.dirname(mapping_path) + os.makedirs(mapping_dir, exist_ok=True) + np.save(mapping_path, np.array(id_map)) + print(f"ID mapping saved to {mapping_path}") + return True + except Exception as e: + print(f"Error saving ID mapping: {str(e)}") + try: + print("Trying alternative save method...") + simple_path = os.path.join(mapping_dir, "article_id_map.npy") + np.save(simple_path, np.array(id_map)) + print(f"ID mapping saved to {simple_path} (alternative path)") + return True + except Exception as e: + print(f"Alternative save method also failed: {str(e)}") + return False + + +def load_id_mapping(mapping_path): + if os.path.exists(mapping_path): + try: + return np.load(mapping_path).tolist() + except Exception as e: + print(f"Error loading ID mapping: {str(e)}") + return [] + + +def get_embeddings_not_in_index(tracking_db_path, limit=100): + query = """ + SELECT ae.id, ae.article_id, ae.embedding, ae.embedding_model + FROM article_embeddings ae + WHERE ae.in_faiss_index = 0 + LIMIT ? + """ + return execute_query(tracking_db_path, query, (limit,), fetch=True) + + +def mark_embeddings_as_indexed(tracking_db_path, embedding_ids): + if not embedding_ids: + return 0 + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + placeholders = ",".join(["?"] * len(embedding_ids)) + query = f""" + UPDATE article_embeddings + SET in_faiss_index = 1 + WHERE id IN ({placeholders}) + """ + cursor.execute(query, embedding_ids) + conn.commit() + return cursor.rowcount + + +def add_embeddings_to_index(embeddings_data, faiss_index, id_map): + if not embeddings_data: + return 0, [] + embeddings = [] + article_ids = [] + embedding_ids = [] + for data in embeddings_data: + try: + embedding_blob = data["embedding"] + embedding = np.frombuffer(embedding_blob, dtype=np.float32) + + if embedding.shape[0] != faiss_index.d: + print(f"Embedding dimension mismatch: expected {faiss_index.d}, got {embedding.shape[0]}") + continue + embeddings.append(embedding) + article_ids.append(data["article_id"]) + embedding_ids.append(data["id"]) + except Exception as e: + print(f"Error processing embedding {data['id']}: {str(e)}") + if not embeddings: + return 0, [] + try: + embeddings_array = np.vstack(embeddings).astype(np.float32) + faiss_index.add(embeddings_array) + for article_id in article_ids: + id_map.append(article_id) + print(f"Added {len(embeddings)} embeddings to FAISS index") + return len(embeddings), embedding_ids + except Exception as e: + print(f"Error adding embeddings to FAISS index: {str(e)}") + return 0, [] + + +def process_embeddings_for_indexing( + tracking_db_path=None, + index_path=None, + mapping_path=None, + batch_size=100, + index_type="ivfflat", + n_list=100, +): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + index_dir = os.path.dirname(index_path) + os.makedirs(index_dir, exist_ok=True) + id_map = load_id_mapping(mapping_path) + with db_connection(tracking_db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='article_embeddings' + """) + table_exists = cursor.fetchone() is not None + + if not table_exists: + print("article_embeddings table does not exist. Please run embedding_processor first.") + return {"processed": 0, "added": 0, "errors": 0, "total_vectors": 0, "status": "table_missing"} + sample_query = """ + SELECT embedding FROM article_embeddings LIMIT 1 + """ + sample = execute_query(tracking_db_path, sample_query, fetch=True, fetch_one=True) + if not sample: + print("No embeddings found in the database") + default_dimension = 1536 + print(f"Using default dimension: {default_dimension}") + + faiss_index = initialize_faiss_index( + dimension=default_dimension, index_path=index_path if os.path.exists(index_path) else None, index_type=index_type, n_list=n_list + ) + return { + "processed": 0, + "added": 0, + "errors": 0, + "total_vectors": faiss_index.ntotal if hasattr(faiss_index, "ntotal") else 0, + "status": "no_embeddings", + } + embedding_dimension = len(np.frombuffer(sample["embedding"], dtype=np.float32)) + print(f"Detected embedding dimension: {embedding_dimension}") + faiss_index = initialize_faiss_index(dimension=embedding_dimension, index_path=index_path, index_type=index_type, n_list=n_list) + embeddings_data = get_embeddings_not_in_index(tracking_db_path, limit=batch_size) + if not embeddings_data: + print("No new embeddings to add to the index") + return {"processed": 0, "added": 0, "errors": 0, "total_vectors": faiss_index.ntotal, "status": "no_new_embeddings"} + added_count, embedding_ids = add_embeddings_to_index(embeddings_data, faiss_index, id_map) + if added_count > 0: + save_faiss_index(faiss_index, index_path) + save_id_mapping(id_map, mapping_path) + marked_count = mark_embeddings_as_indexed(tracking_db_path, embedding_ids) + print(f"Marked {marked_count} embeddings as indexed in the database") + stats = { + "processed": len(embeddings_data), + "added": added_count, + "errors": len(embeddings_data) - added_count, + "total_vectors": faiss_index.ntotal, + "index_type": index_type, + "status": "success", + } + return stats + + +def process_in_batches( + tracking_db_path=None, + index_path=None, + mapping_path=None, + batch_size=100, + total_batches=5, + delay_between_batches=2, + index_type="ivfflat", + n_list=100, +): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + total_stats = {"processed": 0, "added": 0, "errors": 0, "index_type": index_type} + for i in range(total_batches): + print(f"\nProcessing batch {i + 1}/{total_batches}") + batch_stats = process_embeddings_for_indexing( + tracking_db_path=tracking_db_path, + index_path=index_path, + mapping_path=mapping_path, + batch_size=batch_size, + index_type=index_type, + n_list=n_list, + ) + total_stats["processed"] += batch_stats["processed"] + total_stats["added"] += batch_stats["added"] + total_stats["errors"] += batch_stats["errors"] + if "total_vectors" in batch_stats: + total_stats["total_vectors"] = batch_stats["total_vectors"] + if batch_stats["processed"] == 0: + print("No more embeddings to process") + break + if i < total_batches - 1: + print(f"Waiting {delay_between_batches} seconds before next batch...") + time.sleep(delay_between_batches) + return total_stats + + +def print_stats(stats): + print("\nFAISS Indexing Statistics:") + print(f"Total embeddings processed: {stats['processed']}") + print(f"Successfully added to index: {stats['added']}") + print(f"Errors: {stats['errors']}") + if "total_vectors" in stats: + print(f"Total vectors in index: {stats['total_vectors']}") + if "index_type" in stats: + print(f"Index type: {stats['index_type']}") + if stats["index_type"] == "flat": + print("Index performance: Most accurate but slowest for large datasets") + elif stats["index_type"] == "ivfflat": + print("Index performance: Good balance of accuracy and speed") + elif stats["index_type"] == "ivfpq": + print("Index performance: Memory efficient, good for very large datasets") + elif stats["index_type"] == "hnsw": + print("Index performance: Excellent search speed with good accuracy") + + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Process embeddings and add to FAISS index") + parser.add_argument( + "--batch_size", + type=int, + default=100, + help="Number of embeddings to process in each batch", + ) + parser.add_argument( + "--index_path", + default="databases/faiss/article_index.faiss", + help="Path to save the FAISS index", + ) + parser.add_argument( + "--mapping_path", + default="databases/faiss/article_id_map.npy", + help="Path to save the ID mapping file", + ) + parser.add_argument( + "--index_type", + choices=["flat", "ivfflat", "ivfpq", "hnsw"], + default="hnsw", + help="Type of FAISS index to create", + ) + parser.add_argument( + "--n_list", + type=int, + default=100, + help="Number of clusters for IVF-based indexes", + ) + parser.add_argument( + "--total_batches", + type=int, + default=5, + help="Total number of batches to process", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_arguments() + index_path, mapping_path = get_faiss_db_path() + index_path = args.index_path or index_path + mapping_path = args.mapping_path or mapping_path + stats = process_in_batches( + batch_size=args.batch_size, + index_path=index_path, + mapping_path=mapping_path, + total_batches=args.total_batches, + index_type=args.index_type, + n_list=args.n_list, + ) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/fb_scraper_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/fb_scraper_processor.py new file mode 100644 index 0000000..77bd3a2 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/fb_scraper_processor.py @@ -0,0 +1,24 @@ + +import sys +from datetime import datetime +from db.config import get_social_media_db_path +from tools.social.fb_scraper import crawl_facebook_feed + + +def main(): + print(f"Starting facebook.com feed scraping at {datetime.now().isoformat()}") + db_path = get_social_media_db_path() + + try: + print("Running facebook.com feed scraper") + posts = crawl_facebook_feed("https://facebook.com", db_file=db_path) + print(f"facebook.com scraping completed at {datetime.now().isoformat()}") + print(f"Collected {posts} posts from feed") + + except Exception as e: + print(f"Error executing facebook.com feed scraper: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/feed_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/feed_processor.py new file mode 100644 index 0000000..bca056d --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/feed_processor.py @@ -0,0 +1,89 @@ +import time +import random +from utils.rss_feed_parser import get_feed_data +from db.config import get_sources_db_path, get_tracking_db_path +from db.feeds import ( + get_active_feeds, + count_active_feeds, + get_feed_tracking_info, + update_feed_tracking, + store_feed_entries, + update_tracking_info, +) + + +def fetch_and_process_feeds(sources_db_path=None, tracking_db_path=None, delay_between_feeds=2, batch_size=100): + if sources_db_path is None: + sources_db_path = get_sources_db_path() + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + total_feeds = count_active_feeds(sources_db_path) + stats = { + "total_feeds": total_feeds, + "processed_feeds": 0, + "new_entries": 0, + "unchanged_feeds": 0, + "failed_feeds": 0, + } + offset = 0 + while offset < total_feeds: + feeds = get_active_feeds(sources_db_path, limit=batch_size, offset=offset) + if not feeds: + break + update_tracking_info(tracking_db_path, feeds) + for feed in feeds: + feed_id = feed["id"] + source_id = feed["source_id"] + feed_url = feed["feed_url"] + tracking_info = get_feed_tracking_info(tracking_db_path, feed_id) + etag = tracking_info.get("last_etag") if tracking_info else None + modified = tracking_info.get("last_modified") if tracking_info else None + last_hash = tracking_info.get("entry_hash") if tracking_info else None + try: + feed_data = get_feed_data(feed_url, etag=etag, modified=modified) + if not feed_data["is_rss_feed"]: + print(f"Feed {feed_url} is not a valid RSS feed") + stats["failed_feeds"] += 1 + continue + if feed_data["status"] == 304: + print(f"Feed {feed_url} not modified since last check") + stats["unchanged_feeds"] += 1 + continue + current_hash = feed_data["current_hash"] + if last_hash and current_hash == last_hash: + print(f"Feed {feed_url} content unchanged based on hash") + stats["unchanged_feeds"] += 1 + continue + parsed_entries = feed_data["parsed_entries"] + if parsed_entries: + new_entries = store_feed_entries(tracking_db_path, feed_id, source_id, parsed_entries) + stats["new_entries"] += new_entries + print(f"Stored {new_entries} new entries from {feed_url}") + update_feed_tracking( + tracking_db_path, + feed_id, + feed_data["etag"], + feed_data["modified"], + current_hash, + ) + stats["processed_feeds"] += 1 + except Exception as e: + print(f"Error processing feed {feed_url}: {str(e)}") + stats["failed_feeds"] += 1 + time.sleep(random.uniform(1, delay_between_feeds)) + offset += batch_size + return stats + + +def print_stats(stats): + print("\nFeed Processing Statistics:") + print(f"Total feeds: {stats['total_feeds']}") + print(f"Processed feeds: {stats['processed_feeds']}") + print(f"Unchanged feeds: {stats['unchanged_feeds']}") + print(f"Failed feeds: {stats['failed_feeds']}") + print(f"New entries: {stats['new_entries']}") + + +if __name__ == "__main__": + stats = fetch_and_process_feeds() + print_stats(stats) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/podcast_generator_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/podcast_generator_processor.py new file mode 100644 index 0000000..e921651 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/podcast_generator_processor.py @@ -0,0 +1,333 @@ +import os +import json +from datetime import datetime +from typing import List, Dict, Any, Optional +from tools.pipeline.search_agent import search_agent_run +from tools.pipeline.scrape_agent import scrape_agent_run +from tools.pipeline.script_agent import script_agent_run +from tools.pipeline.image_generate_agent import image_generation_agent_run +from db.config import get_tracking_db_path, get_podcasts_db_path, get_tasks_db_path +from db.podcast_configs import get_podcast_config, get_all_podcast_configs +from db.agent_config_v2 import AVAILABLE_LANGS +from utils.tts_engine_selector import generate_podcast_audio +from utils.load_api_keys import load_api_key +from tools.session_state_manager import _save_podcast_to_database_sync + +PODCAST_ASSETS_DIR = "podcasts" + + +def get_language_name(language_code: str) -> str: + language_map = {lang["code"]: lang["name"] for lang in AVAILABLE_LANGS} + return language_map.get(language_code, "English") + + +def convert_script_to_audio_format(podcast_data: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: + speaker_map = {"ALEX": 1, "MORGAN": 2} + dict_entries = [] + for section in podcast_data.get("sections", []): + for dialog in section.get("dialog", []): + speaker = dialog.get("speaker", "ALEX") + text = dialog.get("text", "") + if text and speaker in speaker_map: + dict_entries.append({"text": text, "speaker": speaker_map[speaker]}) + return {"entries": dict_entries} + + +def generate_podcast_from_prompt_v2( + prompt: str, + openai_api_key: str, + tracking_db_path: Optional[str] = None, + podcasts_db_path: Optional[str] = None, + output_dir: str = PODCAST_ASSETS_DIR, + tts_engine: str = "kokoro", + language_code: str = "en", + podcast_script_prompt: Optional[str] = None, + image_prompt: Optional[str] = None, + debug: bool = False, +) -> Dict[str, Any]: + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if podcasts_db_path is None: + podcasts_db_path = get_podcasts_db_path() + os.makedirs(output_dir, exist_ok=True) + images_dir = os.path.join(output_dir, "images") + os.makedirs(images_dir, exist_ok=True) + print(f"Starting enhanced podcast generation for prompt: {prompt}") + try: + search_results = search_agent_run(prompt) + if not search_results: + print(f"WARNING: No search results found for prompt: {prompt}") + return {"error": "No search results found"} + print(f"Found {len(search_results)} search results") + if debug: + print("Search results:", json.dumps(search_results[:2], indent=2)) + except Exception as e: + print(f"ERROR: Search agent failed: {e}") + return {"error": f"Search agent failed: {str(e)}"} + try: + scraped_results = scrape_agent_run(prompt, search_results) + if not scraped_results: + print("WARNING: No content could be scraped") + return {"error": "No content could be scraped"} + confirmed_results = [] + for result in scraped_results: + if result.get("full_text") and len(result["full_text"].strip()) > 100: + result["confirmed"] = True + confirmed_results.append(result) + if not confirmed_results: + print("WARNING: No high-quality content available after scraping") + return {"error": "No high-quality content available"} + print(f"Successfully scraped {len(confirmed_results)} high-quality articles") + if debug: + print("Sample scraped content:", confirmed_results[0].get("full_text", "")[:200]) + except Exception as e: + print(f"ERROR: Scrape agent failed: {e}") + return {"error": f"Scrape agent failed: {str(e)}"} + try: + language_name = get_language_name(language_code) + podcast_data = script_agent_run(query=prompt, search_results=confirmed_results, language_name=language_name) + if not podcast_data or not isinstance(podcast_data, dict): + print("ERROR: Failed to generate podcast script") + return {"error": "Failed to generate podcast script"} + if not podcast_data.get("sections"): + print("ERROR: Generated podcast script is missing required sections") + return {"error": "Invalid podcast script structure"} + print(f"Generated script with {len(podcast_data['sections'])} sections") + if debug: + print("Script title:", podcast_data.get("title", "No title")) + except Exception as e: + print(f"ERROR: Script agent failed: {e}") + return {"error": f"Script agent failed: {str(e)}"} + banner_filenames = [] + banner_url = None + try: + image_query = image_prompt if image_prompt else prompt + image_result = image_generation_agent_run(image_query, podcast_data) + if image_result and image_result.get("banner_images"): + banner_filenames = image_result["banner_images"] + banner_url = image_result.get("banner_url") + print(f"Generated {len(banner_filenames)} banner images") + else: + print("WARNING: No images were generated") + except Exception as e: + print(f"ERROR: Image generation failed: {e}") + audio_filename = None + full_audio_path = None + try: + audio_format = convert_script_to_audio_format(podcast_data) + audio_filename = f"podcast_{datetime.now().strftime('%Y%m%d_%H%M%S')}.wav" + audio_path = os.path.join(output_dir, "audio", audio_filename) + + class DictPodcastScript: + def __init__(self, entries): + self.entries = entries + + def __iter__(self): + return iter(self.entries) + + script_obj = DictPodcastScript(audio_format["entries"]) + full_audio_path = generate_podcast_audio( + script=script_obj, + output_path=audio_path, + tts_engine=tts_engine, + language_code=language_code, + ) + if full_audio_path: + print(f"Generated podcast audio: {full_audio_path}") + else: + print("ERROR: Failed to generate audio") + audio_filename = None + except Exception as e: + print(f"ERROR: Error generating audio: {e}") + import traceback + + traceback.print_exc() + audio_filename = None + try: + session_state = { + "generated_script": podcast_data, + "banner_url": banner_url, + "banner_images": banner_filenames, + "audio_url": full_audio_path, + "tts_engine": tts_engine, + "selected_language": {"code": language_code, "name": get_language_name(language_code)}, + "podcast_info": {"topic": prompt}, + } + success, message, podcast_id = _save_podcast_to_database_sync(session_state) + if success: + print(f"Stored podcast data with ID: {podcast_id}") + else: + print(f"ERROR: Failed to save to database: {message}") + podcast_id = 0 + except Exception as e: + print(f"ERROR: Error storing podcast data: {e}") + podcast_id = 0 + if audio_filename: + frontend_audio_path = os.path.join(output_dir, audio_filename).replace("\\", "/") + else: + frontend_audio_path = None + if banner_url: + frontend_banner_path = banner_url.replace("\\", "/") + else: + frontend_banner_path = None + return { + "podcast_id": podcast_id, + "title": podcast_data.get("title", "Podcast"), + "audio_path": frontend_audio_path, + "banner_path": frontend_banner_path, + "banner_images": banner_filenames, + "script": podcast_data, + "tts_engine": tts_engine, + "language": language_code, + "sources_count": len(confirmed_results), + "processing_stats": { + "search_results": len(search_results), + "scraped_results": len(scraped_results), + "confirmed_results": len(confirmed_results), + "images_generated": len(banner_filenames), + "audio_generated": bool(audio_filename), + }, + } + + +def generate_podcast_from_config_v2( + config_id: int, + openai_api_key: str, + tracking_db_path: Optional[str] = None, + podcasts_db_path: Optional[str] = None, + tasks_db_path: Optional[str] = None, + output_dir: str = PODCAST_ASSETS_DIR, + debug: bool = False, +) -> Dict[str, Any]: + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if podcasts_db_path is None: + podcasts_db_path = get_podcasts_db_path() + if tasks_db_path is None: + tasks_db_path = get_tasks_db_path() + config = get_podcast_config(tasks_db_path, config_id) + if not config: + print(f"ERROR: Podcast configuration not found: {config_id}") + return {"error": f"Podcast configuration not found: {config_id}"} + prompt = config.get("prompt", "") + time_range_hours = config.get("time_range_hours", 24) + limit_articles = config.get("limit_articles", 20) + tts_engine = config.get("tts_engine", "elevenlabs") + language_code = config.get("language_code", "en") + podcast_script_prompt = config.get("podcast_script_prompt") + image_prompt = config.get("image_prompt") + print(f"Generating podcast with enhanced config: {config.get('name', 'Unnamed')}") + print(f"Prompt: {prompt}") + print(f"Time range: {time_range_hours} hours") + print(f"Limit: {limit_articles} articles") + print(f"TTS Engine: {tts_engine}") + print(f"Language: {language_code}") + return generate_podcast_from_prompt_v2( + prompt=prompt, + openai_api_key=openai_api_key, + tracking_db_path=tracking_db_path, + podcasts_db_path=podcasts_db_path, + output_dir=output_dir, + tts_engine=tts_engine, + language_code=language_code, + podcast_script_prompt=podcast_script_prompt, + image_prompt=image_prompt, + debug=debug, + ) + + +def process_all_active_configs_v2( + openai_api_key: str, + tracking_db_path: Optional[str] = None, + podcasts_db_path: Optional[str] = None, + tasks_db_path: Optional[str] = None, + output_dir: str = PODCAST_ASSETS_DIR, + debug: bool = False, +) -> List[Dict[str, Any]]: + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if podcasts_db_path is None: + podcasts_db_path = get_podcasts_db_path() + if tasks_db_path is None: + tasks_db_path = get_tasks_db_path() + configs = get_all_podcast_configs(tasks_db_path, active_only=True) + if not configs: + print("WARNING: No active podcast configurations found") + return [{"error": "No active podcast configurations found"}] + results = [] + total_configs = len(configs) + print(f"Processing {total_configs} active podcast configurations with enhanced pipeline...") + for i, config in enumerate(configs, 1): + config_id = config["id"] + config_name = config["name"] + print(f"\n[{i}/{total_configs}] Processing podcast configuration {config_id}: {config_name}") + try: + result = generate_podcast_from_config_v2( + config_id=config_id, + openai_api_key=openai_api_key, + tracking_db_path=tracking_db_path, + podcasts_db_path=podcasts_db_path, + tasks_db_path=tasks_db_path, + output_dir=output_dir, + debug=debug, + ) + result["config_id"] = config_id + result["config_name"] = config_name + results.append(result) + if "error" not in result: + stats = result.get("processing_stats", {}) + print(f"Success - Podcast ID: {result.get('podcast_id', 'Unknown')}") + print(f"Sources: {stats.get('confirmed_results', 0)} articles processed") + print(f"Images: {stats.get('images_generated', 0)} generated") + print(f"Audio: {'Yes' if stats.get('audio_generated') else 'No'}") + else: + print(f"Failed: {result['error']}") + except Exception as e: + print(f"ERROR: Error generating podcast for config {config_id}: {e}") + results.append({"config_id": config_id, "config_name": config_name, "error": str(e)}) + return results + + +def main(): + openai_api_key = load_api_key() + tasks_db_path = get_tasks_db_path() + if not openai_api_key: + print("ERROR: No OpenAI API key provided. Please set OPENAI_API_KEY environment variable.") + return 1 + output_dir = PODCAST_ASSETS_DIR + debug = False + print("Starting Enhanced Agent-Based Podcast Generation System") + print("=" * 60) + results = process_all_active_configs_v2( + openai_api_key=openai_api_key, + tasks_db_path=tasks_db_path, + output_dir=output_dir, + debug=debug, + ) + print("\n" + "=" * 60) + print("PODCAST GENERATION RESULTS SUMMARY") + print("=" * 60) + successful = 0 + failed = 0 + for result in results: + config_id = result.get("config_id", "Unknown") + config_name = result.get("config_name", "Unknown") + if "error" in result: + print(f"Config {config_id} ({config_name}): {result['error']}") + failed += 1 + else: + podcast_id = result.get("podcast_id", "Unknown") + stats = result.get("processing_stats", {}) + print(f"Config {config_id} ({config_name}): Success") + print(f"Podcast ID: {podcast_id}") + print(f"Sources: {stats.get('confirmed_results', 0)} articles") + print(f"Images: {stats.get('images_generated', 0)} generated") + successful += 1 + print("=" * 60) + print(f"FINAL STATS: {successful} successful, {failed} failed out of {len(results)} total") + print("=" * 60) + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/url_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/url_processor.py new file mode 100644 index 0000000..ec67720 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/url_processor.py @@ -0,0 +1,82 @@ +from db.config import get_tracking_db_path +from db.feeds import get_uncrawled_entries +from db.articles import store_crawled_article, update_entry_status +from utils.crawl_url import get_web_data + + +def crawl_pending_entries(tracking_db_path=None, batch_size=20, delay_range=(1, 3), max_attempts=3): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + entries = get_uncrawled_entries(tracking_db_path, limit=batch_size, max_attempts=max_attempts) + stats = { + "total_entries": len(entries), + "success_count": 0, + "failed_count": 0, + "skipped_count": 0, + } + for entry in entries: + entry_id = entry["id"] + url = entry["link"] + if not url or url.strip() == "": + update_entry_status(tracking_db_path, entry_id, "skipped") + stats["skipped_count"] += 1 + continue + print(f"Crawling URL: {url}") + try: + web_data = get_web_data(url) + if not web_data or not web_data["raw_html"]: + print(f"No content retrieved for {url}") + update_entry_status(tracking_db_path, entry_id, "failed") + stats["failed_count"] += 1 + continue + success = store_crawled_article(tracking_db_path, entry, web_data["raw_html"], web_data["metadata"]) + if success: + update_entry_status(tracking_db_path, entry_id, "success") + stats["success_count"] += 1 + print(f"Successfully crawled: {url}") + else: + update_entry_status(tracking_db_path, entry_id, "failed") + stats["failed_count"] += 1 + print(f"Failed to store: {url} (likely duplicate)") + except Exception as e: + print(f"Error crawling {url}: {str(e)}") + update_entry_status(tracking_db_path, entry_id, "failed") + stats["failed_count"] += 1 + return stats + + +def print_stats(stats): + print("\nCrawl Statistics:") + print(f"Total entries processed: {stats['total_entries']}") + print(f"Successfully crawled: {stats['success_count']}") + print(f"Failed: {stats['failed_count']}") + print(f"Skipped (no URL): {stats['skipped_count']}") + + +def crawl_in_batches(tracking_db_path=None, batch_size=20, total_batches=5, delay_between_batches=10): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + total_stats = { + "total_entries": 0, + "success_count": 0, + "failed_count": 0, + "skipped_count": 0, + } + for i in range(total_batches): + print(f"\nProcessing batch {i + 1}/{total_batches}") + batch_stats = crawl_pending_entries(tracking_db_path=tracking_db_path, batch_size=batch_size) + total_stats["total_entries"] += batch_stats["total_entries"] + total_stats["success_count"] += batch_stats["success_count"] + total_stats["failed_count"] += batch_stats["failed_count"] + total_stats["skipped_count"] += batch_stats["skipped_count"] + if batch_stats["total_entries"] == 0: + print("No more entries to process") + break + if i < total_batches - 1: + print(f"Waiting {delay_between_batches} seconds before next batch...") + return total_stats + + +if __name__ == "__main__": + stats = crawl_in_batches(batch_size=20, total_batches=50) + print_stats(stats) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/x_scraper_processor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/x_scraper_processor.py new file mode 100644 index 0000000..a98e9ba --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/processors/x_scraper_processor.py @@ -0,0 +1,24 @@ + +import sys +from datetime import datetime +from db.config import get_social_media_db_path +from tools.social.x_scraper import crawl_x_profile + + +def main(): + print(f"Starting X.com feed scraping at {datetime.now().isoformat()}") + db_path = get_social_media_db_path() + + try: + print("Running X.com feed scraper") + posts = crawl_x_profile("home", db_file=db_path) + print(f"X.com feed scraping completed at {datetime.now().isoformat()}") + print(f"Collected {posts} posts from feed") + + except Exception as e: + print(f"Error executing X.com feed scraper: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/requirements.txt b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/requirements.txt new file mode 100644 index 0000000..fe092be --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/requirements.txt @@ -0,0 +1,236 @@ +addict==2.4.0 +agno==1.4.2 +aiofiles==24.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.11.18 +aioredis==2.0.1 +aiosignal==1.3.2 +aiosqlite==0.21.0 +amqp==5.3.1 +annotated-types==0.7.0 +anthropic==0.49.0 +anyio==4.9.0 +APScheduler==3.11.0 +asgi-csrf==0.11 +asgiref==3.8.1 +async-timeout==5.0.1 +attrs==25.3.0 +audioread==3.0.1 +babel==2.17.0 +backoff==2.2.1 +beautifulsoup4==4.13.4 +billiard==4.2.1 +blis==1.2.1 +boto3==1.38.3 +botocore==1.38.3 +browser-use==0.2.4 +cachetools==5.5.2 +catalogue==2.0.10 +celery==5.5.2 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +click==8.1.8 +click-default-group==1.2.4 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +cloudpathlib==0.21.0 +colorama==0.4.6 +confection==0.1.5 +csvw==3.5.1 +curated-tokenizers==0.0.9 +curated-transformers==0.1.1 +cymem==2.0.11 +Cython==3.0.12 +datasette==0.65.1 +defusedxml==0.7.1 +distro==1.9.0 +dlinfo==2.0.0 +dnspython==2.7.0 +docopt==0.6.2 +docstring_parser==0.16 +duckduckgo_search==8.0.1 +elevenlabs==1.57.0 +espeakng-loader==0.2.4 +faiss-cpu==1.9.0.post1 +fastapi==0.115.12 +feedparser==6.0.11 +filelock==3.18.0 +filetype==1.2.0 +flexcache==0.3 +flexparser==0.4 +flower==2.0.1 +frozenlist==1.6.0 +fsspec==2025.3.2 +gitdb==4.0.12 +GitPython==3.1.44 +gnews==0.4.1 +google-ai-generativelanguage==0.6.17 +google-api-core==2.24.2 +google-auth==2.39.0 +googleapis-common-protos==1.70.0 +greenlet==3.2.0 +grpcio==1.72.0rc1 +grpcio-status==1.72.0rc1 +h11==0.14.0 +h2==4.2.0 +hpack==4.1.0 +httpcore==1.0.8 +httpx==0.28.1 +huggingface-hub==0.30.2 +humanize==4.12.3 +hupper==1.12.1 +hyperframe==6.1.0 +idna==3.10 +isodate==0.7.2 +itsdangerous==2.2.0 +janus==2.0.0 +Jinja2==3.1.6 +jiter==0.9.0 +jmespath==1.0.1 +joblib==1.4.2 +jsonpatch==1.33 +jsonpointer==3.0.0 +jsonschema==4.23.0 +jsonschema-specifications==2025.4.1 +kokoro==0.9.4 +kombu==5.5.3 +langchain==0.3.22 +langchain-anthropic==0.3.3 +langchain-aws==0.2.19 +langchain-core==0.3.49 +langchain-deepseek==0.1.3 +langchain-google-genai==2.1.2 +langchain-ollama==0.3.0 +langchain-openai==0.3.11 +langchain-text-splitters==0.3.7 +langcodes==3.5.0 +langsmith==0.3.31 +language-tags==1.2.0 +language_data==1.3.0 +lazy_loader==0.4 +librosa==0.11.0 +linkify-it-py==2.0.3 +llvmlite==0.44.0 +loguru==0.7.3 +lxml==5.4.0 +lxml_html_clean==0.4.2 +marisa-trie==1.2.1 +markdown-it-py==3.0.0 +markdownify==1.1.0 +MarkupSafe==3.0.2 +mdit-py-plugins==0.4.2 +mdurl==0.1.2 +mem0ai==0.1.93 +mergedeep==1.3.4 +misaki==0.9.4 +monotonic==1.6 +mpmath==1.3.0 +msgpack==1.1.0 +multidict==6.4.3 +murmurhash==1.0.12 +networkx==3.4.2 +newspaper4k==0.9.3.1 +nltk==3.9.1 +num2words==0.5.14 +numba==0.61.2 +numpy==1.26.4 +ollama==0.4.7 +openai==1.74.0 +orjson==3.10.16 +packaging==24.2 +pandas==2.2.3 +phonemizer-fork==3.3.2 +pillow==11.2.1 +pip-chill==1.0.3 +playwright==1.52.0 +pluggy==1.6.0 +pooch==1.8.2 +portalocker==2.10.1 +posthog==3.25.0 +preshed==3.0.9 +primp==0.15.0 +prometheus_client==0.22.0 +propcache==0.3.1 +proto-plus==1.26.1 +protobuf==6.30.2 +psycopg2-binary==2.9.10 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycparser==2.22 +pydantic==2.10.6 +pydantic-settings==2.9.1 +pydantic_core==2.27.2 +pydub==0.25.1 +pyee==13.0.0 +PyJWT==2.9.0 +pyparsing==3.2.3 +pyperclip==1.9.0 +python-dotenv==1.1.0 +python-multipart==0.0.20 +pytz==2024.2 +PyYAML==6.0.2 +qdrant-client==1.14.2 +rdflib==7.1.4 +redis==5.3.0 +referencing==0.36.2 +regex==2024.11.6 +requests==2.32.3 +requests-file==2.1.0 +requests-toolbelt==1.0.0 +rfc3986==1.5.0 +rich==14.0.0 +rpds-py==0.24.0 +rsa==4.9.1 +s3transfer==0.12.0 +safetensors==0.5.3 +scikit-learn==1.6.1 +scipy==1.15.2 +screeninfo==0.8.1 +segments==2.3.0 +sentence-transformers==4.1.0 +sgmllib3k==1.0.0 +shellingham==1.5.4 +smart-open==7.1.0 +smmap==5.0.2 +sniffio==1.3.1 +soundfile==0.13.1 +soupsieve==2.6 +soxr==0.5.0.post1 +spacy==3.8.5 +spacy-curated-transformers==0.3.0 +spacy-legacy==3.0.12 +spacy-loggers==1.0.5 +SQLAlchemy==2.0.40 +srsly==2.5.1 +starlette==0.46.2 +sympy==1.14.0 +tabulate==0.9.0 +tenacity==9.1.2 +textual==3.2.0 +thinc==8.3.4 +threadpoolctl==3.6.0 +tiktoken==0.9.0 +tldextract==5.3.0 +tokenizers==0.21.1 +tomli==2.2.1 +torch==2.2.2 +tqdm==4.67.1 +transformers==4.51.3 +typer==0.15.2 +typing-inspection==0.4.0 +tzdata==2025.2 +tzlocal==5.3.1 +uc-micro-py==1.0.3 +uritemplate==4.1.1 +urllib3==2.4.0 +uvicorn==0.34.2 +vine==5.1.0 +wasabi==1.1.3 +weasel==0.4.1 +websockets==15.0.1 +widgetsnbextension==4.0.14 +wrapt==1.17.2 +yarl==1.20.0 +zstandard==0.23.0 \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/article_router.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/article_router.py new file mode 100644 index 0000000..2589823 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/article_router.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Query +from typing import List, Optional, Dict, Any +from models.article_schemas import Article, PaginatedArticles +from services.article_service import article_service + +router = APIRouter() + + +@router.get("/", response_model=PaginatedArticles) +async def read_articles( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(10, ge=1, le=100, description="Items per page"), + source: Optional[str] = Query(None, description="Filter by source name"), + category: Optional[str] = Query(None, description="Filter by category"), + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), + search: Optional[str] = Query(None, description="Search in title and summary"), +): + """ + Get all articles with pagination and filtering. + + - **page**: Page number (starting from 1) + - **per_page**: Number of items per page (max 100) + - **source**: Filter by source name + - **category**: Filter by category + - **date_from**: Filter by start date (format: YYYY-MM-DD) + - **date_to**: Filter by end date (format: YYYY-MM-DD) + - **search**: Search in title and summary + """ + return await article_service.get_articles( + page=page, per_page=per_page, source=source, category=category, date_from=date_from, date_to=date_to, search=search + ) + + +@router.get("/{article_id}", response_model=Article) +async def read_article(article_id: int): + """ + Get a specific article by ID. + + - **article_id**: ID of the article to retrieve + """ + return await article_service.get_article(article_id=article_id) + + +@router.get("/sources/list", response_model=List[str]) +async def read_sources(): + """Get all available sources.""" + return await article_service.get_sources() + + +@router.get("/categories/list", response_model=List[Dict[str, Any]]) +async def read_categories(): + """Get all available categories with article counts.""" + return await article_service.get_categories() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/async_podcast_agent_router.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/async_podcast_agent_router.py new file mode 100644 index 0000000..eba119e --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/async_podcast_agent_router.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter +from typing import Optional +from pydantic import BaseModel +from services.async_podcast_agent_service import podcast_agent_service + +router = APIRouter() + + +class SessionRequest(BaseModel): + session_id: Optional[str] = None + + +class ChatRequest(BaseModel): + session_id: str + message: str + + +class ChatResponse(BaseModel): + session_id: str + response: str + stage: str + session_state: str + is_processing: bool = False + process_type: Optional[str] = None + task_id: Optional[str] = None + browser_recording_path: Optional[str] = None + + +class StatusRequest(BaseModel): + session_id: str + task_id: Optional[str] = None + + +@router.post("/session") +async def create_session(request: SessionRequest = None): + """Create or reuse a session with the podcast agent""" + return await podcast_agent_service.create_session(request) + + +@router.post("/chat", response_model=ChatResponse) +async def chat(request: ChatRequest): + """Send a message to the podcast agent and get a response""" + return await podcast_agent_service.chat(request) + + +@router.post("/status", response_model=ChatResponse) +async def check_status(request: StatusRequest): + """Check if a result is available for the session""" + return await podcast_agent_service.check_result_status(request) + + +@router.get("/sessions") +async def list_sessions(page: int = 1, per_page: int = 10): + """List all saved podcast sessions with pagination""" + return await podcast_agent_service.list_sessions(page, per_page) + + +@router.get("/session_history") +async def get_session_history(session_id: str): + """Get the complete message history for a session""" + return await podcast_agent_service.get_session_history(session_id) + + +@router.delete("/session/{session_id}") +async def delete_session(session_id: str): + """Delete a podcast session and all its data""" + return await podcast_agent_service.delete_session(session_id) + +@router.get("/languages") +async def get_supported_languages(): + """Get the list of supported languages""" + return await podcast_agent_service.get_supported_languages() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/podcast_config_router.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/podcast_config_router.py new file mode 100644 index 0000000..511c302 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/podcast_config_router.py @@ -0,0 +1,103 @@ +from fastapi import APIRouter, Query, Path, Body, status +from typing import List +from models.podcast_config_schemas import PodcastConfig, PodcastConfigCreate, PodcastConfigUpdate +from services.podcast_config_service import podcast_config_service + + +router = APIRouter() + + +@router.get("/", response_model=List[PodcastConfig]) +async def get_podcast_configs( + active_only: bool = Query(False, description="Include only active podcast configs"), +): + """ + Get all podcast configurations with optional filtering. + + - **active_only**: If true, includes only active podcast configurations + """ + return await podcast_config_service.get_all_configs(active_only=active_only) + + +@router.get("/{config_id}", response_model=PodcastConfig) +async def get_podcast_config( + config_id: int = Path(..., description="The ID of the podcast config to retrieve"), +): + """ + Get a specific podcast configuration by ID. + + - **config_id**: The ID of the podcast configuration to retrieve + """ + return await podcast_config_service.get_config(config_id=config_id) + + +@router.post("/", response_model=PodcastConfig, status_code=status.HTTP_201_CREATED) +async def create_podcast_config(config_data: PodcastConfigCreate = Body(...)): + """ + Create a new podcast configuration. + + - **config_data**: Data for the new podcast configuration + """ + return await podcast_config_service.create_config( + name=config_data.name, + prompt=config_data.prompt, + description=config_data.description, + time_range_hours=config_data.time_range_hours, + limit_articles=config_data.limit_articles, + is_active=config_data.is_active, + tts_engine=config_data.tts_engine, + language_code=config_data.language_code, + podcast_script_prompt=config_data.podcast_script_prompt, + image_prompt=config_data.image_prompt, + ) + + +@router.put("/{config_id}", response_model=PodcastConfig) +async def update_podcast_config( + config_id: int = Path(..., description="The ID of the podcast config to update"), + config_data: PodcastConfigUpdate = Body(...), +): + """ + Update an existing podcast configuration. + + - **config_id**: The ID of the podcast configuration to update + - **config_data**: Updated data for the podcast configuration + """ + updates = {k: v for k, v in config_data.dict().items() if v is not None} + return await podcast_config_service.update_config(config_id=config_id, updates=updates) + + +@router.delete("/{config_id}") +async def delete_podcast_config( + config_id: int = Path(..., description="The ID of the podcast config to delete"), +): + """ + Delete a podcast configuration. + + - **config_id**: The ID of the podcast configuration to delete + """ + return await podcast_config_service.delete_config(config_id=config_id) + + +@router.post("/{config_id}/enable", response_model=PodcastConfig) +async def enable_podcast_config( + config_id: int = Path(..., description="The ID of the podcast config to enable"), +): + """ + Enable a podcast configuration. + + - **config_id**: The ID of the podcast configuration to enable + """ + return await podcast_config_service.toggle_config(config_id=config_id, enable=True) + + +@router.post("/{config_id}/disable", response_model=PodcastConfig) +async def disable_podcast_config( + config_id: int = Path(..., description="The ID of the podcast config to disable"), +): + """ + Disable a podcast configuration. + + - **config_id**: The ID of the podcast configuration to disable + """ + return await podcast_config_service.toggle_config(config_id=config_id, enable=False) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/podcast_router.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/podcast_router.py new file mode 100644 index 0000000..2dcc19a --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/podcast_router.py @@ -0,0 +1,200 @@ +from fastapi import APIRouter, HTTPException, File, UploadFile, Body, Query, Path +from fastapi.responses import FileResponse +from typing import List, Optional +import os +from datetime import datetime +from models.podcast_schemas import Podcast, PodcastDetail, PodcastCreate, PodcastUpdate, PaginatedPodcasts +from services.podcast_service import podcast_service + +router = APIRouter() + + +@router.get("/", response_model=PaginatedPodcasts) +async def get_podcasts( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(10, ge=1, le=100, description="Items per page"), + search: Optional[str] = Query(None, description="Search in title"), + date_from: Optional[str] = Query(None, description="Filter by date from (YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by date to (YYYY-MM-DD)"), + language_code: Optional[str] = Query(None, description="Filter by language code"), + tts_engine: Optional[str] = Query(None, description="Filter by TTS engine"), + has_audio: Optional[bool] = Query(None, description="Filter by audio availability"), +): + """ + Get a paginated list of podcasts with optional filtering. + """ + return await podcast_service.get_podcasts( + page=page, + per_page=per_page, + search=search, + date_from=date_from, + date_to=date_to, + language_code=language_code, + tts_engine=tts_engine, + has_audio=has_audio, + ) + + +@router.get("/formats", response_model=List[str]) +async def get_podcast_formats(): + """ + Get a list of available podcast formats for filtering. + """ + return await podcast_service.get_podcast_formats() + + +@router.get("/language-codes", response_model=List[str]) +async def get_language_codes(): + """ + Get a list of available language codes for filtering. + """ + return await podcast_service.get_language_codes() + + +@router.get("/tts-engines", response_model=List[str]) +async def get_tts_engines(): + """ + Get a list of available TTS engines for filtering. + """ + return await podcast_service.get_tts_engines() + + +@router.get("/{podcast_id}", response_model=PodcastDetail) +async def get_podcast(podcast_id: int = Path(..., description="The ID of the podcast to retrieve")): + """ + Get detailed information about a specific podcast. + + Parameters: + - **podcast_id**: The ID of the podcast to retrieve + + Returns the podcast metadata and content. + """ + podcast = await podcast_service.get_podcast(podcast_id) + content = await podcast_service.get_podcast_content(podcast_id) + audio_url = await podcast_service.get_podcast_audio_url(podcast) + sources = podcast.get("sources", []) + if "sources" in podcast: + del podcast["sources"] + banner_images = podcast.get("banner_images", []) + if "banner_images" in podcast: + del podcast["banner_images"] + return {"podcast": podcast, "content": content, "audio_url": audio_url, "sources": sources, "banner_images": banner_images} + + +@router.get("/by-identifier/{identifier}", response_model=PodcastDetail) +async def get_podcast_by_identifier(identifier: str = Path(..., description="The unique identifier of the podcast")): + """ + Get detailed information about a specific podcast using string identifier. + + Parameters: + - **identifier**: The unique identifier of the podcast to retrieve + + Returns the podcast metadata and content. + """ + podcast = await podcast_service.get_podcast_by_identifier(identifier) + podcast_id = int(podcast["id"]) + content = await podcast_service.get_podcast_content(podcast_id) + audio_url = await podcast_service.get_podcast_audio_url(podcast) + sources = podcast.get("sources", []) + if "sources" in podcast: + del podcast["sources"] + banner_images = podcast.get("banner_images", []) + if "banner_images" in podcast: + del podcast["banner_images"] + return {"podcast": podcast, "content": content, "audio_url": audio_url, "sources": sources, "banner_images": banner_images} + + +@router.post("/", response_model=Podcast) +async def create_podcast(podcast_data: PodcastCreate = Body(...)): + """ + Create a new podcast. + + Parameters: + - **podcast_data**: Podcast data including title, date, content, sources, language_code, and tts_engine + + Returns the created podcast metadata. + """ + date = podcast_data.date or datetime.now().strftime("%Y-%m-%d") + return await podcast_service.create_podcast( + title=podcast_data.title, + date=date, + content=podcast_data.content, + sources=podcast_data.sources, + language_code=podcast_data.language_code, + tts_engine=podcast_data.tts_engine, + ) + + +@router.put("/{podcast_id}", response_model=Podcast) +async def update_podcast(podcast_id: int = Path(..., description="The ID of the podcast to update"), podcast_data: PodcastUpdate = Body(...)): + """ + Update an existing podcast's metadata and/or content. + + Parameters: + - **podcast_id**: The ID of the podcast to update + - **podcast_data**: Updated data for the podcast + + Returns the updated podcast metadata. + """ + update_data = {k: v for k, v in podcast_data.dict().items() if v is not None} + return await podcast_service.update_podcast(podcast_id, update_data) + + +@router.delete("/{podcast_id}") +async def delete_podcast(podcast_id: int = Path(..., description="The ID of the podcast to delete")): + """ + Delete a podcast. + + Parameters: + - **podcast_id**: The ID of the podcast to delete + + Returns a success message. + """ + success = await podcast_service.delete_podcast(podcast_id) + if success: + return {"message": f"Podcast {podcast_id} deleted successfully"} + return {"message": "No podcast was deleted"} + + +@router.post("/{podcast_id}/audio", response_model=Podcast) +async def upload_audio(podcast_id: int = Path(..., description="The ID of the podcast"), file: UploadFile = File(...)): + """ + Upload an audio file for a podcast. + + Parameters: + - **podcast_id**: The ID of the podcast to attach the audio to + - **file**: The audio file to upload + + Returns the updated podcast. + """ + return await podcast_service.upload_podcast_audio(podcast_id, file) + + +@router.post("/{podcast_id}/banner", response_model=Podcast) +async def upload_banner(podcast_id: int = Path(..., description="The ID of the podcast"), file: UploadFile = File(...)): + """ + Upload a banner image for a podcast. + + Parameters: + - **podcast_id**: The ID of the podcast to attach the banner to + - **file**: The image file to upload + + Returns the updated podcast. + """ + return await podcast_service.upload_podcast_banner(podcast_id, file) + + +@router.get("/audio/{filename}") +async def get_audio_file(filename: str = Path(..., description="The filename of the audio file")): + """ + Get the audio file for a podcast. + + Parameters: + - **filename**: The filename of the audio file to retrieve + + Returns the audio file as a download. + """ + audio_path = os.path.join("podcasts", "audio", filename) + if not os.path.exists(audio_path): + raise HTTPException(status_code=404, detail="Audio file not found") + return FileResponse(audio_path) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/social_media_router.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/social_media_router.py new file mode 100644 index 0000000..fb91314 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/social_media_router.py @@ -0,0 +1,173 @@ +from fastapi import APIRouter, Query +from typing import List, Optional, Dict, Any +from services.social_media_service import social_media_service +from models.social_media_schemas import PaginatedPosts, Post +import threading +from tools.social.browser import setup_session_multi + +router = APIRouter() + + +@router.get("/", response_model=PaginatedPosts) +async def read_posts( + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(10, ge=1, le=100, description="Items per page"), + platform: Optional[str] = Query(None, description="Filter by platform (e.g., x.com, instagram)"), + user_handle: Optional[str] = Query(None, description="Filter by user handle"), + sentiment: Optional[str] = Query(None, description="Filter by sentiment"), + category: Optional[str] = Query(None, description="Filter by category"), + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), + search: Optional[str] = Query(None, description="Search in post text, user display name, or handle"), +): + """ + Get all social media posts with pagination and filtering. + """ + return await social_media_service.get_posts( + page=page, + per_page=per_page, + platform=platform, + user_handle=user_handle, + sentiment=sentiment, + category=category, + date_from=date_from, + date_to=date_to, + search=search, + ) + + +@router.get("/{post_id}", response_model=Post) +async def read_post(post_id: str): + """ + Get a specific social media post by ID. + """ + return await social_media_service.get_post(post_id=post_id) + + +@router.get("/platforms/list", response_model=List[str]) +async def read_platforms(): + """Get all available platforms.""" + return await social_media_service.get_platforms() + + +@router.get("/sentiments/list", response_model=List[Dict[str, Any]]) +async def read_sentiments( + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get sentiment distribution with post counts.""" + return await social_media_service.get_sentiments(date_from=date_from, date_to=date_to) + + +@router.get("/users/top", response_model=List[Dict[str, Any]]) +async def read_top_users( + platform: Optional[str] = Query(None, description="Filter by platform"), + limit: int = Query(10, ge=1, le=50, description="Number of top users to return"), + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get top users by post count.""" + return await social_media_service.get_top_users(platform=platform, limit=limit, date_from=date_from, date_to=date_to) + + +@router.get("/categories/list", response_model=List[Dict[str, Any]]) +async def read_categories( + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get all categories with post counts.""" + return await social_media_service.get_categories(date_from=date_from, date_to=date_to) + + +@router.get("/users/sentiment", response_model=List[Dict[str, Any]]) +async def read_user_sentiment( + limit: int = Query(10, ge=1, le=50, description="Number of users to return"), + platform: Optional[str] = Query(None, description="Filter by platform"), + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get users with their sentiment breakdown.""" + return await social_media_service.get_user_sentiment(limit=limit, platform=platform, date_from=date_from, date_to=date_to) + + +@router.get("/categories/sentiment", response_model=List[Dict[str, Any]]) +async def read_category_sentiment( + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get sentiment distribution by category.""" + return await social_media_service.get_category_sentiment(date_from=date_from, date_to=date_to) + + +@router.get("/topic/trends", response_model=List[Dict[str, Any]]) +async def read_trending_topics( + limit: int = Query(10, ge=1, le=50, description="Number of topics to return"), + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get trending topics with sentiment breakdown.""" + return await social_media_service.get_trending_topics(date_from=date_from, date_to=date_to, limit=limit) + + +@router.get("/trends/time", response_model=List[Dict[str, Any]]) +async def read_sentiment_over_time( + platform: Optional[str] = Query(None, description="Filter by platform"), + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get sentiment trends over time.""" + return await social_media_service.get_sentiment_over_time(date_from=date_from, date_to=date_to, platform=platform) + + +@router.get("/posts/influential", response_model=List[Dict[str, Any]]) +async def read_influential_posts( + sentiment: Optional[str] = Query(None, description="Filter by sentiment"), + limit: int = Query(5, ge=1, le=20, description="Number of posts to return"), + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get most influential posts by engagement, optionally filtered by sentiment.""" + return await social_media_service.get_influential_posts(sentiment=sentiment, limit=limit, date_from=date_from, date_to=date_to) + + +@router.get("/engagement/stats", response_model=Dict[str, Any]) +async def read_engagement_stats( + date_from: Optional[str] = Query(None, description="Filter by start date (format: YYYY-MM-DD)"), + date_to: Optional[str] = Query(None, description="Filter by end date (format: YYYY-MM-DD)"), +): + """Get overall engagement statistics.""" + return await social_media_service.get_engagement_stats(date_from=date_from, date_to=date_to) + + +def _run_browser_setup_background(sites: Optional[List[str]] = None): + """Background task to run browser session setup in separate thread.""" + try: + if sites and len(sites) > 1: + setup_session_multi(sites) + elif sites and len(sites) > 0: + setup_session_multi(sites) + else: + default_sites = ["https://x.com", "https://facebook.com"] + setup_session_multi(default_sites) + except Exception as e: + print(f"Browser setup error: {e}") + + +@router.post("/session/setup") +async def setup_browser_session(sites: Optional[List[str]] = Query(None, description="List of sites to setup sessions for")): + """ + Trigger browser session setup in a completely separate thread. + This will open a browser window for manual login to social media platforms. + The API immediately returns while the browser setup runs independently. + """ + thread = threading.Thread( + target=_run_browser_setup_background, + args=(sites,), + daemon=True, + ) + thread.start() + return { + "status": "ok", + "message": "Browser session setup triggered successfully", + "note": "Browser window will open shortly for manual authentication", + } \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/source_router.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/source_router.py new file mode 100644 index 0000000..81e5114 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/source_router.py @@ -0,0 +1,136 @@ +from fastapi import APIRouter, Body, Path, status +from typing import List, Optional, Dict, Any +from models.source_schemas import Source, SourceWithFeeds, Category, PaginatedSources, SourceCreate, SourceUpdate, SourceFeedCreate +from services.source_service import source_service + +router = APIRouter() + + +@router.get("/", response_model=PaginatedSources) +async def read_sources( + page: int = 1, per_page: int = 10, category: Optional[str] = None, search: Optional[str] = None, include_inactive: bool = False +): + """ + Get sources with pagination and filtering. + + - **page**: Page number (starting from 1) + - **per_page**: Number of items per page + - **category**: Filter by source category + - **search**: Search in name and description + - **include_inactive**: Include inactive sources + """ + return await source_service.get_sources(page=page, per_page=per_page, category=category, search=search, include_inactive=include_inactive) + + +@router.get("/categories", response_model=List[Category]) +async def read_categories(): + """Get all available source categories.""" + return await source_service.get_categories() + + +@router.get("/by-category/{category_name}", response_model=List[Source]) +async def read_sources_by_category(category_name: str): + """ + Get sources by category name. + + - **category_name**: Name of the category to filter by + """ + return await source_service.get_source_by_category(category_name=category_name) + + +@router.get("/{source_id}", response_model=SourceWithFeeds) +async def read_source(source_id: int = Path(..., gt=0)): + """ + Get detailed information about a specific source. + + - **source_id**: ID of the source to retrieve + """ + source = await source_service.get_source(source_id=source_id) + feeds = await source_service.get_source_feeds(source_id=source_id) + source_with_feeds = {**source, "feeds": feeds} + return source_with_feeds + + +@router.get("/by-name/{name}", response_model=SourceWithFeeds) +async def read_source_by_name(name: str): + """ + Get detailed information about a specific source by name. + + - **name**: Name of the source to retrieve + """ + source = await source_service.get_source_by_name(name=name) + feeds = await source_service.get_source_feeds(source_id=source["id"]) + source_with_feeds = {**source, "feeds": feeds} + return source_with_feeds + + +@router.post("/", response_model=SourceWithFeeds, status_code=status.HTTP_201_CREATED) +async def create_source(source_data: SourceCreate): + """ + Create a new source. + + - **source_data**: Data for the new source + """ + source = await source_service.create_source(source_data) + feeds = await source_service.get_source_feeds(source_id=source["id"]) + source_with_feeds = {**source, "feeds": feeds} + return source_with_feeds + + +@router.put("/{source_id}", response_model=SourceWithFeeds) +async def update_source(source_data: SourceUpdate, source_id: int = Path(..., gt=0)): + """ + Update an existing source. + + - **source_id**: ID of the source to update + - **source_data**: Updated data for the source + """ + source = await source_service.update_source(source_id, source_data) + feeds = await source_service.get_source_feeds(source_id=source["id"]) + source_with_feeds = {**source, "feeds": feeds} + return source_with_feeds + + +@router.delete("/{source_id}", response_model=Dict[str, Any]) +async def delete_source(source_id: int = Path(..., gt=0), permanent: bool = False): + """ + Delete a source. + + - **source_id**: ID of the source to delete + - **permanent**: If true, permanently deletes the source; otherwise, performs a soft delete + """ + if permanent: + return await source_service.hard_delete_source(source_id) + return await source_service.delete_source(source_id) + + +@router.post("/{source_id}/feeds", response_model=List[Dict[str, Any]]) +async def add_feed(feed_data: SourceFeedCreate, source_id: int = Path(..., gt=0)): + """ + Add a new feed to a source. + + - **source_id**: ID of the source to add the feed to + - **feed_data**: Data for the new feed + """ + return await source_service.add_feed_to_source(source_id, feed_data) + + +@router.put("/feeds/{feed_id}", response_model=Dict[str, Any]) +async def update_feed(feed_data: Dict[str, Any] = Body(...), feed_id: int = Path(..., gt=0)): + """ + Update an existing feed. + + - **feed_id**: ID of the feed to update + - **feed_data**: Updated data for the feed + """ + return await source_service.update_feed(feed_id, feed_data) + + +@router.delete("/feeds/{feed_id}", response_model=Dict[str, str]) +async def delete_feed(feed_id: int = Path(..., gt=0)): + """ + Delete a feed. + + - **feed_id**: ID of the feed to delete + """ + return await source_service.delete_feed(feed_id) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/task_router.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/task_router.py new file mode 100644 index 0000000..eb416ef --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/routers/task_router.py @@ -0,0 +1,138 @@ +from fastapi import APIRouter, Query, Path, Body, status +from typing import List, Optional, Dict +from models.tasks_schemas import Task, TaskCreate, TaskUpdate, TaskStats, TASK_TYPES +from services.task_service import task_service + +router = APIRouter() + + +@router.get("/", response_model=List[Task]) +async def get_tasks( + include_disabled: bool = Query(False, description="Include disabled tasks"), +): + """ + Get all tasks with optional filtering. + + - **include_disabled**: If true, includes disabled tasks + """ + return await task_service.get_tasks(include_disabled=include_disabled) + + +@router.get("/pending", response_model=List[Task]) +async def get_pending_tasks(): + """ + Get tasks that are due to run. + """ + return await task_service.get_pending_tasks() + + +@router.get("/stats", response_model=TaskStats) +async def get_task_stats(): + """ + Get task statistics. + """ + return await task_service.get_stats() + + +@router.get("/executions") +async def get_task_executions( + task_id: Optional[int] = Query(None, description="Filter by task ID"), + page: int = Query(1, ge=1, description="Page number"), + per_page: int = Query(10, ge=1, le=100, description="Items per page"), +): + """ + Get paginated task executions. + + - **task_id**: Filter by task ID + - **page**: Page number (starting from 1) + - **per_page**: Number of items per page (max 100) + """ + return await task_service.get_task_executions(task_id=task_id, page=page, per_page=per_page) + + +@router.get("/types", response_model=Dict[str, Dict[str, str]]) +async def get_task_types(): + """ + Get all available task types. + """ + return TASK_TYPES + + +@router.get("/{task_id}", response_model=Task) +async def get_task( + task_id: int = Path(..., description="The ID of the task to retrieve"), +): + """ + Get a specific task by ID. + + - **task_id**: The ID of the task to retrieve + """ + return await task_service.get_task(task_id=task_id) + + +@router.post("/", response_model=Task, status_code=status.HTTP_201_CREATED) +async def create_task(task_data: TaskCreate = Body(...)): + """ + Create a new task. + + - **task_data**: Data for the new task + """ + return await task_service.create_task( + name=task_data.name, + task_type=task_data.task_type, + frequency=task_data.frequency, + frequency_unit=task_data.frequency_unit, + description=task_data.description, + enabled=task_data.enabled, + ) + + +@router.put("/{task_id}", response_model=Task) +async def update_task( + task_id: int = Path(..., description="The ID of the task to update"), + task_data: TaskUpdate = Body(...), +): + """ + Update an existing task. + + - **task_id**: The ID of the task to update + - **task_data**: Updated data for the task + """ + updates = {k: v for k, v in task_data.dict().items() if v is not None} + return await task_service.update_task(task_id=task_id, updates=updates) + + +@router.delete("/{task_id}") +async def delete_task( + task_id: int = Path(..., description="The ID of the task to delete"), +): + """ + Delete a task. + + - **task_id**: The ID of the task to delete + """ + return await task_service.delete_task(task_id=task_id) + + +@router.post("/{task_id}/enable", response_model=Task) +async def enable_task( + task_id: int = Path(..., description="The ID of the task to enable"), +): + """ + Enable a task. + + - **task_id**: The ID of the task to enable + """ + return await task_service.toggle_task(task_id=task_id, enable=True) + + +@router.post("/{task_id}/disable", response_model=Task) +async def disable_task( + task_id: int = Path(..., description="The ID of the task to disable"), +): + """ + Disable a task. + + - **task_id**: The ID of the task to disable + """ + return await task_service.toggle_task(task_id=task_id, enable=False) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/ruff.toml b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/ruff.toml new file mode 100644 index 0000000..3c00ab7 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/ruff.toml @@ -0,0 +1 @@ +line-length = 150 \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/scheduler.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/scheduler.py new file mode 100644 index 0000000..3cdf432 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/scheduler.py @@ -0,0 +1,205 @@ +import os +import time +import signal +import subprocess +from datetime import datetime +import traceback +from concurrent.futures import ThreadPoolExecutor +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore +from db.config import get_tasks_db_path +from db.connection import db_connection +from db.tasks import ( + get_pending_tasks, + update_task_last_run, + update_task_execution, +) + +running = True +MAX_WORKERS = 5 +DEFAULT_TASK_TIMEOUT = 3600 + + +def cleanup_stuck_tasks(): + tasks_db_path = get_tasks_db_path() + try: + with db_connection(tasks_db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT id FROM task_executions + WHERE status = 'running' + LIMIT 100 + """ + ) + running_executions = [dict(row) for row in cursor.fetchall()] + if running_executions: + print(f"WARNING: Found {len(running_executions)} tasks stuck in 'running' state. Marking as failed.") + for execution in running_executions: + execution_id = execution["id"] + error_message = "Task was interrupted by system shutdown or crash" + cursor.execute( + """ + UPDATE task_executions + SET end_time = ?, status = ?, error_message = ? + WHERE id = ? + """, + (datetime.now().isoformat(), "failed", error_message, execution_id), + ) + print(f"INFO: Marked execution {execution_id} as failed") + conn.commit() + else: + print("INFO: No stuck tasks found") + except Exception as e: + print(f"ERROR: Error cleaning up stuck tasks: {str(e)}") + print(f"ERROR: {traceback.format_exc()}") + + +def execute_task(task_id, command): + tasks_db_path = get_tasks_db_path() + with db_connection(tasks_db_path) as conn: + conn.execute("BEGIN EXCLUSIVE TRANSACTION") + try: + cursor = conn.cursor() + cursor.execute( + """ + SELECT 1 FROM task_executions + WHERE task_id = ? AND status = 'running' + LIMIT 1 + """, + (task_id,), + ) + is_running = cursor.fetchone() is not None + if is_running: + print(f"WARNING: Task {task_id} is already running, skipping this execution") + conn.commit() + return + cursor.execute( + """ + INSERT INTO task_executions + (task_id, start_time, status) + VALUES (?, ?, ?) + """, + (task_id, datetime.now().isoformat(), "running"), + ) + execution_id = cursor.lastrowid + conn.commit() + if not execution_id: + print(f"ERROR: Failed to create execution record for task {task_id}") + return + except Exception as e: + conn.rollback() + print(f"ERROR: Transaction error for task {task_id}: {str(e)}") + return + print(f"INFO: Starting task {task_id}: {command}") + try: + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + stdout, stderr = process.communicate(timeout=DEFAULT_TASK_TIMEOUT) + if process.returncode == 0: + status = "success" + error_message = None + print(f"INFO: Task {task_id} completed successfully") + else: + status = "failed" + error_message = stderr if stderr else f"Process exited with code {process.returncode}" + print(f"ERROR: Task {task_id} failed: {error_message}") + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + status = "failed" + error_message = f"Task timed out after {DEFAULT_TASK_TIMEOUT} seconds" + print(f"ERROR: Task {task_id} timed out") + output = f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}" if stderr else stdout + update_task_execution(tasks_db_path, execution_id, status, error_message, output) + timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + update_task_last_run(tasks_db_path, task_id, timestamp) + except Exception as e: + print(f"ERROR: Error executing task {task_id}: {str(e)}") + error_message = traceback.format_exc() + update_task_execution(tasks_db_path, execution_id, "failed", error_message) + timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + update_task_last_run(tasks_db_path, task_id, timestamp) + + +def check_for_tasks(): + tasks_db_path = get_tasks_db_path() + try: + print("DEBUG: Checking for pending tasks...") + pending_tasks = get_pending_tasks(tasks_db_path) + if not pending_tasks: + print("DEBUG: No pending tasks found") + return + print(f"INFO: Found {len(pending_tasks)} pending tasks") + with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: + for task in pending_tasks: + task_id = task["id"] + command = task["command"] + print(f"INFO: Scheduling task {task_id}: {task['name']} (Last run: {task['last_run']})") + executor.submit(execute_task, task_id, command) + except Exception as e: + print(f"ERROR: Error in check_for_tasks: {str(e)}") + print(f"ERROR: {traceback.format_exc()}") + + +def check_missed_tasks(): + tasks_db_path = get_tasks_db_path() + try: + print("INFO: Checking for missed tasks during downtime...") + pending_tasks = get_pending_tasks(tasks_db_path) + if pending_tasks: + print(f"INFO: Found {len(pending_tasks)} tasks to run (including any missed during downtime)") + else: + print("INFO: No missed tasks found") + except Exception as e: + print(f"ERROR: Error checking for missed tasks: {str(e)}") + + +def signal_handler(sig, frame): + global running + print("INFO: Shutdown signal received, stopping scheduler...") + running = False + + +def main(): + global running + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + print("INFO: Starting task scheduler") + tasks_db_path = get_tasks_db_path() + cleanup_stuck_tasks() + scheduler_dir = os.path.dirname(tasks_db_path) + os.makedirs(scheduler_dir, exist_ok=True) + scheduler_db_path = os.path.join(scheduler_dir, "scheduler.sqlite") + jobstores = {"default": SQLAlchemyJobStore(url=f"sqlite:///{scheduler_db_path}")} + scheduler = BackgroundScheduler(jobstores=jobstores, daemon=True) + scheduler.add_job( + check_for_tasks, + IntervalTrigger(minutes=1), + id="check_tasks", + name="Check for pending tasks", + replace_existing=True, + ) + scheduler.start() + print("INFO: Scheduler started, checking for tasks every minute") + check_for_tasks() + check_missed_tasks() + try: + while running: + time.sleep(1) + except (KeyboardInterrupt, SystemExit): + print("INFO: Scheduler interrupted") + finally: + scheduler.shutdown() + print("INFO: Scheduler shutdown complete") + + +if __name__ == "__main__": + main() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/article_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/article_service.py new file mode 100644 index 0000000..c81d9ee --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/article_service.py @@ -0,0 +1,174 @@ +from typing import List, Optional, Dict, Any +from fastapi import HTTPException +import json +from services.db_service import tracking_db, sources_db +from models.article_schemas import Article, PaginatedArticles + + +class ArticleService: + """Service for managing article operations with the new database structure.""" + + async def get_articles( + self, + page: int = 1, + per_page: int = 10, + source: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + search: Optional[str] = None, + category: Optional[str] = None, + ) -> PaginatedArticles: + """Get articles with pagination and filtering.""" + try: + offset = (page - 1) * per_page + query_parts = [ + "SELECT ca.id, ca.title, ca.url, ca.published_date, ca.summary, ca.feed_id", + "FROM crawled_articles ca", + "WHERE ca.processed = 1 AND ca.ai_status = 'success'", + ] + query_params = [] + if source: + source_id_query = "SELECT id FROM sources WHERE name = ?" + source_id_result = await sources_db.execute_query(source_id_query, (source,), fetch=True, fetch_one=True) + if source_id_result and source_id_result.get("id"): + feed_ids_query = "SELECT id FROM source_feeds WHERE source_id = ?" + feed_ids_result = await sources_db.execute_query(feed_ids_query, (source_id_result["id"],), fetch=True) + if feed_ids_result: + feed_ids = [item["id"] for item in feed_ids_result] + placeholders = ",".join(["?" for _ in feed_ids]) + query_parts.append(f"AND ca.feed_id IN ({placeholders})") + query_params.extend(feed_ids) + if category: + query_parts.append(""" + AND EXISTS ( + SELECT 1 FROM article_categories ac + WHERE ac.article_id = ca.id AND ac.category_name = ? + ) + """) + query_params.append(category.lower()) + if date_from: + query_parts.append("AND datetime(ca.published_date) >= datetime(?)") + query_params.append(date_from) + if date_to: + query_parts.append("AND datetime(ca.published_date) <= datetime(?)") + query_params.append(date_to) + if search: + query_parts.append("AND (ca.title LIKE ? OR ca.summary LIKE ?)") + search_param = f"%{search}%" + query_params.extend([search_param, search_param]) + count_query = " ".join(query_parts).replace( + "SELECT ca.id, ca.title, ca.url, ca.published_date, ca.summary, ca.feed_id", + "SELECT COUNT(*)", + ) + total_articles = await tracking_db.execute_query(count_query, tuple(query_params), fetch=True, fetch_one=True) + total_count = total_articles.get("COUNT(*)", 0) if total_articles else 0 + query_parts.append("ORDER BY datetime(ca.published_date) DESC, ca.id DESC") + query_parts.append("LIMIT ? OFFSET ?") + query_params.extend([per_page, offset]) + articles_query = " ".join(query_parts) + articles = await tracking_db.execute_query(articles_query, tuple(query_params), fetch=True) + feed_ids = [article["feed_id"] for article in articles if article.get("feed_id")] + source_names = {} + if feed_ids: + feed_ids_str = ",".join("?" for _ in feed_ids) + source_query = f""" + SELECT sf.id as feed_id, s.name as source_name + FROM source_feeds sf + JOIN sources s ON sf.source_id = s.id + WHERE sf.id IN ({feed_ids_str}) + """ + sources_result = await sources_db.execute_query(source_query, tuple(feed_ids), fetch=True) + source_names = {item["feed_id"]: item["source_name"] for item in sources_result} + for article in articles: + feed_id = article.get("feed_id") + article["source_name"] = source_names.get(feed_id, "Unknown Source") + article.pop("feed_id", None) + article["categories"] = await self.get_article_categories(article["id"]) + total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 0 + has_next = page < total_pages + has_prev = page > 1 + return PaginatedArticles( + items=articles, + total=total_count, + page=page, + per_page=per_page, + total_pages=total_pages, + has_next=has_next, + has_prev=has_prev, + ) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching articles: {str(e)}") + + async def get_article(self, article_id: int) -> Article: + """Get a specific article by ID.""" + try: + article_query = """ + SELECT id, title, url, published_date, content, summary, feed_id, + metadata, ai_status + FROM crawled_articles + WHERE id = ? AND processed = 1 + """ + article = await tracking_db.execute_query(article_query, (article_id,), fetch=True, fetch_one=True) + if not article: + raise HTTPException(status_code=404, detail="Article not found") + if article.get("feed_id"): + source_query = """ + SELECT s.name as source_name + FROM source_feeds sf + JOIN sources s ON sf.source_id = s.id + WHERE sf.id = ? + """ + source_result = await sources_db.execute_query(source_query, (article["feed_id"],), fetch=True, fetch_one=True) + if source_result: + article["source_name"] = source_result["source_name"] + else: + article["source_name"] = "Unknown Source" + else: + article["source_name"] = "Unknown Source" + article.pop("feed_id", None) + if article.get("metadata"): + try: + article["metadata"] = json.loads(article["metadata"]) + except json.JSONDecodeError: + article["metadata"] = {} + article["categories"] = await self.get_article_categories(article_id) + return article + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching article: {str(e)}") + + async def get_article_categories(self, article_id: int) -> List[str]: + """Get categories for a specific article.""" + query = """ + SELECT category_name + FROM article_categories + WHERE article_id = ? + """ + categories = await tracking_db.execute_query(query, (article_id,), fetch=True) + return [category.get("category_name", "") for category in categories] + + async def get_sources(self) -> List[str]: + """Get all available active sources.""" + query = """ + SELECT DISTINCT name FROM sources + WHERE is_active = 1 + ORDER BY name + """ + result = await sources_db.execute_query(query, fetch=True) + return [row.get("name", "") for row in result if row.get("name")] + + async def get_categories(self) -> List[Dict[str, Any]]: + """Get all categories with article counts.""" + query = """ + SELECT category_name, COUNT(DISTINCT article_id) as article_count + FROM article_categories + GROUP BY category_name + ORDER BY article_count DESC + """ + return await tracking_db.execute_query(query, fetch=True) + + +article_service = ArticleService() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/async_podcast_agent_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/async_podcast_agent_service.py new file mode 100644 index 0000000..2c99b63 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/async_podcast_agent_service.py @@ -0,0 +1,392 @@ +import os +import json +import uuid +from fastapi import status +from fastapi.responses import JSONResponse +import aiosqlite +import glob +from redis.asyncio import ConnectionPool, Redis +from db.config import get_agent_session_db_path +from db.agent_config_v2 import PODCAST_DIR, PODCAST_AUIDO_DIR, PODCAST_IMG_DIR, PODCAST_RECORDINGS_DIR, AVAILABLE_LANGS +from services.celery_tasks import agent_chat +from dotenv import load_dotenv +from services.internal_session_service import SessionService + +load_dotenv() + + +class PodcastAgentService: + def __init__(self): + os.makedirs(PODCAST_DIR, exist_ok=True) + os.makedirs(PODCAST_AUIDO_DIR, exist_ok=True) + os.makedirs(PODCAST_IMG_DIR, exist_ok=True) + os.makedirs(PODCAST_RECORDINGS_DIR, exist_ok=True) + + self.redis_host = os.environ.get("REDIS_HOST", "localhost") + self.redis_port = int(os.environ.get("REDIS_PORT", 6379)) + self.redis_db = int(os.environ.get("REDIS_DB", 0)) + self.redis_pool = ConnectionPool.from_url(f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db + 1}", max_connections=10) + self.redis = Redis(connection_pool=self.redis_pool) + + async def get_active_task(self, session_id): + try: + lock_info = await self.redis.get(f"lock_info:{session_id}") + if lock_info: + try: + lock_data = json.loads(lock_info.decode("utf-8")) + task_id = lock_data.get("task_id") + if task_id: + return task_id + except (json.JSONDecodeError, AttributeError) as e: + print(f"Error parsing lock info: {e}") + return None + except Exception as e: + print(f"Error checking active task: {e}") + return None + + async def create_session(self, request=None): + if request and request.session_id: + session_id = request.session_id + try: + db_path = get_agent_session_db_path() + async with aiosqlite.connect(db_path) as conn: + async with conn.execute("SELECT 1 FROM podcast_sessions WHERE session_id = ?", (session_id,)) as cursor: + row = await cursor.fetchone() + exists = row is not None + if exists: + return {"session_id": session_id} + except Exception as e: + print(f"Error checking session existence: {e}") + new_session_id = str(uuid.uuid4()) + return {"session_id": new_session_id} + + async def chat(self, request): + try: + session_id = request.session_id + task_id = await self.get_active_task(session_id) + if task_id: + return { + "session_id": session_id, + "response": "Your request is already being processed.", + "stage": "processing", + "session_state": "{}", + "is_processing": True, + "process_type": "chat", + "task_id": task_id, + } + task = agent_chat.delay(request.session_id, request.message) + return { + "session_id": request.session_id, + "response": "Your request is being processed.", + "stage": "processing", + "session_state": "{}", + "is_processing": True, + "process_type": "chat", + "task_id": task.id, + } + except Exception as e: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "session_id": request.session_id, + "response": f"I encountered an error while processing your request: {str(e)}. Please try again.", + "stage": "error", + "session_state": "{}", + "error": str(e), + "is_processing": False, + }, + ) + + def _browser_recording(self, session_id): + try: + recordings_dir = os.path.join("podcasts/recordings", session_id) + webm_files = glob.glob(os.path.join(recordings_dir, "*.webm")) + if webm_files: + browser_recording_path = webm_files[0] + if (os.path.exists(browser_recording_path) and + os.path.getsize(browser_recording_path) > 8192 and + os.access(browser_recording_path, os.R_OK)): + return browser_recording_path + return None + except Exception as _: + return None + + async def check_result_status(self, request): + try: + if not request.session_id: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"error": "Session ID is required"}, + ) + + browser_recording_path = self._browser_recording(request.session_id) + + task_id = getattr(request, "task_id", None) + if task_id: + task = agent_chat.AsyncResult(task_id) + if task.state == "PENDING" or task.state == "STARTED": + return { + "session_id": request.session_id, + "response": "Your request is still being processed.", + "stage": "processing", + "session_state": "{}", + "is_processing": True, + "process_type": "chat", + "task_id": task_id, + "browser_recording_path": browser_recording_path, + } + elif task.state == "SUCCESS": + result = task.result + if result and isinstance(result, dict): + if result.get("session_id") != request.session_id: + return { + "session_id": request.session_id, + "response": "Error: Received result for wrong session.", + "stage": "error", + "session_state": "{}", + "is_processing": False, + "browser_recording_path": browser_recording_path, + } + return result + else: + error_info = str(task.result) if task.result else f"Task failed with state: {task.state}" + return { + "session_id": request.session_id, + "response": f"Error processing request: {error_info}", + "stage": "error", + "session_state": "{}", + "is_processing": False, + "browser_recording_path": browser_recording_path, + } + return await self.get_session_state(request.session_id) + except Exception as e: + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "error": f"Error checking result status: {str(e)}", + "session_id": request.session_id, + "response": f"Error checking result status: {str(e)}", + "stage": "error", + "session_state": "{}", + "is_processing": False, + "browser_recording_path": browser_recording_path, + }, + ) + + async def get_session_state(self, session_id): + try: + db_path = get_agent_session_db_path() + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = lambda cursor, row: {col[0]: row[idx] for idx, col in enumerate(cursor.description)} + async with conn.execute("SELECT session_data FROM podcast_sessions WHERE session_id = ?", (session_id,)) as cursor: + row = await cursor.fetchone() + if not row: + return { + "session_id": session_id, + "response": "No session data found.", + "stage": "idle", + "session_state": "{}", + "is_processing": False, + } + + session = SessionService.get_session(session_id) + session_state = session.get("state", {}) + return { + "session_id": session_id, + "response": "", + "stage": session_state.get("stage", "idle"), + "session_state": json.dumps(session_state), + "is_processing": False, + } + except Exception as e: + return { + "session_id": session_id, + "response": f"Error retrieving session state: {str(e)}", + "stage": "error", + "session_state": "{}", + "is_processing": False, + } + + async def list_sessions(self, page=1, per_page=10): + try: + db_path = get_agent_session_db_path() + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = lambda cursor, row: {col[0]: row[idx] for idx, col in enumerate(cursor.description)} + async with conn.execute( + """ + select name from sqlite_master + where type='table' and name='podcast_sessions' + """ + ) as cursor: + table = await cursor.fetchone() + if not table: + return { + "sessions": [], + "pagination": { + "total": 0, + "page": page, + "per_page": per_page, + "total_pages": 0, + }, + } + async with conn.execute("SELECT COUNT(*) as count FROM podcast_sessions") as cursor: + row = await cursor.fetchone() + total_sessions = row["count"] if row else 0 + offset = (page - 1) * per_page + async with conn.execute( + "SELECT session_id, session_data, updated_at FROM podcast_sessions ORDER BY updated_at DESC LIMIT ? OFFSET ?", (per_page, offset) + ) as cursor: + rows = await cursor.fetchall() + sessions = [] + for row in rows: + try: + session = SessionService.get_session(row["session_id"]) + session_state = session.get("state", {}) + title = session_state.get("title", "Untitled Podcast") + stage = session_state.get("stage", "welcome") + updated_at = row["updated_at"] + sessions.append({"session_id": row["session_id"], "topic": title, "stage": stage, "updated_at": updated_at}) + except Exception as e: + print(f"Error parsing session data: {e}") + return { + "sessions": sessions, + "pagination": { + "total": total_sessions, + "page": page, + "per_page": per_page, + "total_pages": (total_sessions + per_page - 1) // per_page, + }, + } + except Exception as e: + print(f"Error listing sessions: {e}") + return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": f"Failed to list sessions: {str(e)}"}) + + async def delete_session(self, session_id: str): + try: + db_path = get_agent_session_db_path() + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = lambda cursor, row: {col[0]: row[idx] for idx, col in enumerate(cursor.description)} + async with conn.execute("SELECT session_data FROM podcast_sessions WHERE session_id = ?", (session_id,)) as cursor: + row = await cursor.fetchone() + if not row: + return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"error": f"Session with ID {session_id} not found"}) + try: + session = SessionService.get_session(session_id) + session_state = session.get("state", {}) + stage = session_state.get("stage") + is_completed = stage == "complete" or session_state.get("podcast_generated", False) + banner_url = session_state.get("banner_url") + audio_url = session_state.get("audio_url") + web_search_recording = session_state.get("web_search_recording") + await conn.execute("DELETE FROM podcast_sessions WHERE session_id = ?", (session_id,)) + await conn.commit() + if is_completed: + print(f"Session {session_id} is in 'complete' stage, keeping assets but removing session record") + else: + if banner_url: + banner_path = os.path.join(PODCAST_IMG_DIR, banner_url) + if os.path.exists(banner_path): + try: + os.remove(banner_path) + print(f"Deleted banner image: {banner_path}") + except Exception as e: + print(f"Error deleting banner image: {e}") + if audio_url: + audio_path = os.path.join(PODCAST_AUIDO_DIR, audio_url) + if os.path.exists(audio_path): + try: + os.remove(audio_path) + print(f"Deleted audio file: {audio_path}") + except Exception as e: + print(f"Error deleting audio file: {e}") + if web_search_recording: + recording_dir = os.path.join(PODCAST_RECORDINGS_DIR, session_id) + if os.path.exists(recording_dir): + try: + import shutil + + shutil.rmtree(recording_dir) + print(f"Deleted recordings directory: {recording_dir}") + except Exception as e: + print(f"Error deleting recordings directory: {e}") + if is_completed: + return {"success": True, "message": f"Session {session_id} deleted, but assets preserved"} + else: + return {"success": True, "message": f"Session {session_id} and its associated data deleted successfully"} + except Exception as e: + print(f"Error parsing session data for deletion: {e}") + return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": f"Error deleting session: {str(e)}"}) + except Exception as e: + print(f"Error deleting session: {e}") + return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": f"Failed to delete session: {str(e)}"}) + + async def _get_chat_messages(self, row, session_id): + formatted_messages = [] + session_state = {} + if row["session_data"]: + try: + session = SessionService.get_session(session_id) + session_state = session.get("state", {}) + except Exception as e: + print(f"Error parsing session_data: {e}") + + if row["memory"]: + try: + memory_data = json.loads(row["memory"]) if isinstance(row["memory"], str) else row["memory"] + if "runs" in memory_data and isinstance(memory_data["runs"], list): + for run in memory_data["runs"]: + if "messages" in run and isinstance(run["messages"], list): + for msg in run["messages"]: + if msg.get("role") in ["user", "assistant"] and "content" in msg: + if msg.get("role") == "assistant" and "tool_calls" in msg: + if not msg.get("content"): + continue + if msg.get("content"): + formatted_messages.append({"role": msg["role"], "content": msg["content"]}) + except json.JSONDecodeError as e: + print(f"Error parsing memory data: {e}") + + return formatted_messages, session_state + + async def get_session_history(self, session_id: str): + try: + db_path = get_agent_session_db_path() + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = lambda cursor, row: {col[0]: row[idx] for idx, col in enumerate(cursor.description)} + async with conn.execute( + """ + select name from sqlite_master + where type='table' and name='podcast_sessions' + """ + ) as cursor: + table = await cursor.fetchone() + if not table: + return {"session_id": session_id, "messages": [], "state": "{}", "is_processing": False, "process_type": None} + async with conn.execute("SELECT memory, session_data FROM podcast_sessions WHERE session_id = ?", (session_id,)) as cursor: + row = await cursor.fetchone() + if not row: + return {"session_id": session_id, "messages": [], "state": "{}", "is_processing": False, "process_type": None} + formatted_messages, session_state = await self._get_chat_messages(row, session_id) + + task_id = await self.get_active_task(session_id) + is_processing = bool(task_id) + process_type = "chat" if is_processing else None + browser_recording_path = self._browser_recording(session_id) + + return { + "session_id": session_id, + "messages": formatted_messages, + "state": json.dumps(session_state), + "is_processing": is_processing, + "process_type": process_type, + "task_id": task_id if task_id and is_processing else None, + "browser_recording_path": browser_recording_path, + } + except Exception as e: + return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": f"Error retrieving session history: {str(e)}"}) + + async def get_supported_languages(self): + return {"languages": AVAILABLE_LANGS} + + +podcast_agent_service = PodcastAgentService() \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/celery_app.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/celery_app.py new file mode 100644 index 0000000..4880c05 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/celery_app.py @@ -0,0 +1,70 @@ +from celery import Celery, Task +import redis +import os +import time +import json +from dotenv import load_dotenv + + +load_dotenv() + +REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") +REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) +REDIS_DB = int(os.environ.get("REDIS_DB", 0)) +REDIS_LOCK_EXP_TIME_SEC = 60 * 10 +REDIS_LOCK_INFO_EXP_TIME_SEC = 60 * 15 +STALE_LOCK_THRESHOLD_SEC = 60 * 15 + +redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB + 1) + +app = Celery("beifong_tasks", broker=f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}", backend=f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") + +app.conf.update( + result_expires=60 * 2, + task_track_started=True, + worker_concurrency=2, + task_acks_late=True, + task_time_limit=600, + task_soft_time_limit=540, +) + + +class SessionLockedTask(Task): + def __call__(self, *args, **kwargs): + session_id = args[0] if args else kwargs.get("session_id") + if not session_id: + return super().__call__(*args, **kwargs) + + lock_key = f"lock:{session_id}" + lock_info = redis_client.get(f"lock_info:{session_id}") + if lock_info: + try: + lock_data = json.loads(lock_info.decode("utf-8")) + lock_time = lock_data.get("timestamp", 0) + if time.time() - lock_time > STALE_LOCK_THRESHOLD_SEC: + redis_client.delete(lock_key) + redis_client.delete(f"lock_info:{session_id}") + except (ValueError, TypeError) as e: + print(f"Error checking lock time: {e}") + + acquired = redis_client.set(lock_key, "1", nx=True, ex=REDIS_LOCK_EXP_TIME_SEC) + if acquired: + lock_data = {"timestamp": time.time(), "task_id": self.request.id if hasattr(self, "request") else None} + redis_client.set(f"lock_info:{session_id}", json.dumps(lock_data), ex=REDIS_LOCK_INFO_EXP_TIME_SEC) + + if not acquired: + return { + "error": "Session busy", + "response": "This session is already processing a message. Please wait.", + "session_id": session_id, + "stage": "busy", + "session_state": "{}", + "is_processing": True, + "process_type": "chat", + } + + try: + return super().__call__(*args, **kwargs) + finally: + redis_client.delete(lock_key) + redis_client.delete(f"lock_info:{session_id}") diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/celery_tasks.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/celery_tasks.py new file mode 100644 index 0000000..ff5735b --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/celery_tasks.py @@ -0,0 +1,85 @@ +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from agno.storage.sqlite import SqliteStorage +import os +from dotenv import load_dotenv +from services.celery_app import app, SessionLockedTask +from db.config import get_agent_session_db_path +from db.agent_config_v2 import ( + AGENT_DESCRIPTION, + AGENT_INSTRUCTIONS, + AGENT_MODEL, + INITIAL_SESSION_STATE, +) +from agents.search_agent import search_agent_run +from agents.scrape_agent import scrape_agent_run +from agents.script_agent import podcast_script_agent_run +from tools.ui_manager import ui_manager_run +from tools.user_source_selection import user_source_selection_run +from tools.session_state_manager import update_language, update_chat_title, mark_session_finished +from agents.image_generate_agent import image_generation_agent_run +from agents.audio_generate_agent import audio_generate_agent_run +import json + +load_dotenv() + +db_file = get_agent_session_db_path() + + +@app.task(bind=True, max_retries=0, base=SessionLockedTask) +def agent_chat(self, session_id, message): + try: + print(f"Processing message for session {session_id}: {message[:50]}...") + db_file = get_agent_session_db_path() + os.makedirs(os.path.dirname(db_file), exist_ok=True) + from services.internal_session_service import SessionService + + session_state = SessionService.get_session(session_id).get("state", INITIAL_SESSION_STATE) + + _agent = Agent( + model=OpenAIChat(id=AGENT_MODEL, api_key=os.getenv("OPENAI_API_KEY")), + storage=SqliteStorage(table_name="podcast_sessions", db_file=db_file), + add_history_to_messages=True, + read_chat_history=True, + add_state_in_messages=True, + num_history_runs=30, + instructions=AGENT_INSTRUCTIONS, + description=AGENT_DESCRIPTION, + session_state=session_state, + session_id=session_id, + tools=[ + search_agent_run, + scrape_agent_run, + ui_manager_run, + user_source_selection_run, + update_language, + podcast_script_agent_run, + image_generation_agent_run, + audio_generate_agent_run, + update_chat_title, + mark_session_finished, + ], + markdown=True, + ) + response = _agent.run(message, session_id=session_id) + print(f"Response generated for session {session_id}") + _agent.write_to_storage(session_id=session_id) + session_state = SessionService.get_session(session_id).get("state", INITIAL_SESSION_STATE) + return { + "session_id": session_id, + "response": response.content, + "stage": _agent.session_state.get("stage", "unknown"), + "session_state": json.dumps(session_state), + "is_processing": False, + "process_type": None, + } + except Exception as e: + print(f"Error in agent_chat for session {session_id}: {str(e)}") + return { + "session_id": session_id, + "response": f"I'm sorry, I encountered an error: {str(e)}. Please try again.", + "stage": "error", + "session_state": "{}", + "is_processing": False, + "process_type": None, + } diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/db_init.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/db_init.py new file mode 100644 index 0000000..82fcf33 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/db_init.py @@ -0,0 +1,369 @@ +import os +import sqlite3 +import asyncio +import time +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +from services.db_service import get_db_path + + +@contextmanager +def db_connection(db_path): + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA cache_size=10000") + conn.execute("PRAGMA temp_store=MEMORY") + try: + yield conn + finally: + conn.close() + + +def init_sources_db(): + start_time = time.time() + db_path = get_db_path("sources_db") + with db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute("BEGIN TRANSACTION") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + url TEXT, + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS source_categories ( + source_id INTEGER, + category_id INTEGER, + PRIMARY KEY (source_id, category_id), + FOREIGN KEY (source_id) REFERENCES sources(id), + FOREIGN KEY (category_id) REFERENCES categories(id) + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS source_feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER, + feed_url TEXT UNIQUE, + feed_type TEXT, + is_active BOOLEAN DEFAULT 1, + last_crawled TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (source_id) REFERENCES sources(id) + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_feeds_source_id ON source_feeds(source_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_sources_is_active ON sources(is_active)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_feeds_is_active ON source_feeds(is_active)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_categories_source_id ON source_categories(source_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_categories_category_id ON source_categories(category_id)") + conn.commit() + + elapsed = time.time() - start_time + print(f"Sources database initialized in {elapsed:.3f}s") + + +def init_tracking_db(): + start_time = time.time() + db_path = get_db_path("tracking_db") + with db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute("BEGIN TRANSACTION") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS feed_tracking ( + feed_id INTEGER PRIMARY KEY, + source_id INTEGER, + feed_url TEXT, + last_processed TIMESTAMP, + last_etag TEXT, + last_modified TEXT, + entry_hash TEXT + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS feed_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feed_id INTEGER, + source_id INTEGER, + entry_id TEXT, + title TEXT, + link TEXT UNIQUE, + published_date TIMESTAMP, + content TEXT, + summary TEXT, + crawl_status TEXT DEFAULT 'pending', + crawl_attempts INTEGER DEFAULT 0, + processed_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(feed_id, entry_id) + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS crawled_articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER, + source_id INTEGER, + feed_id INTEGER, + title TEXT, + url TEXT UNIQUE, + published_date TIMESTAMP, + raw_content TEXT, + content TEXT, + summary TEXT, + metadata TEXT, + ai_status TEXT DEFAULT 'pending', + ai_error TEXT DEFAULT NULL, + ai_attempts INTEGER DEFAULT 0, + crawled_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + processed BOOLEAN DEFAULT 0, + embedding_status TEXT DEFAULT NULL, + FOREIGN KEY (entry_id) REFERENCES feed_entries(id) + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS article_categories ( + article_id INTEGER, + category_name TEXT NOT NULL, + PRIMARY KEY (article_id, category_name), + FOREIGN KEY (article_id) REFERENCES crawled_articles(id) + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS article_embeddings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + embedding BLOB NOT NULL, + embedding_model TEXT NOT NULL, + created_at TEXT NOT NULL, + in_faiss_index INTEGER DEFAULT 0, + FOREIGN KEY (article_id) REFERENCES crawled_articles(id) + ) + """) + indexes = [ + "CREATE INDEX IF NOT EXISTS idx_feed_entries_feed_id ON feed_entries(feed_id)", + "CREATE INDEX IF NOT EXISTS idx_feed_entries_link ON feed_entries(link)", + "CREATE INDEX IF NOT EXISTS idx_crawled_articles_url ON crawled_articles(url)", + "CREATE INDEX IF NOT EXISTS idx_crawled_articles_entry_id ON crawled_articles(entry_id)", + "CREATE INDEX IF NOT EXISTS idx_feed_entries_crawl_status ON feed_entries(crawl_status)", + "CREATE INDEX IF NOT EXISTS idx_crawled_articles_processed ON crawled_articles(processed)", + "CREATE INDEX IF NOT EXISTS idx_crawled_articles_ai_status ON crawled_articles(ai_status)", + "CREATE INDEX IF NOT EXISTS idx_article_categories_article_id ON article_categories(article_id)", + "CREATE INDEX IF NOT EXISTS idx_article_categories_category_name ON article_categories(category_name)", + "CREATE INDEX IF NOT EXISTS idx_article_embeddings_article_id ON article_embeddings(article_id)", + "CREATE INDEX IF NOT EXISTS idx_article_embeddings_in_faiss ON article_embeddings(in_faiss_index)", + "CREATE INDEX IF NOT EXISTS idx_crawled_articles_embedding_status ON crawled_articles(embedding_status)", + ] + for index_sql in indexes: + cursor.execute(index_sql) + conn.commit() + elapsed = time.time() - start_time + print(f"Tracking database initialized in {elapsed:.3f}s") + + +def init_podcasts_db(): + start_time = time.time() + db_path = get_db_path("podcasts_db") + with db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute("BEGIN TRANSACTION") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS podcasts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + date TEXT, + content_json TEXT, + audio_generated BOOLEAN DEFAULT 0, + audio_path TEXT, + banner_img_path TEXT, + tts_engine TEXT DEFAULT 'elevenlabs', + language_code TEXT DEFAULT 'en', + sources_json TEXT, + banner_images TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + indexes = [ + "CREATE INDEX IF NOT EXISTS idx_podcasts_date ON podcasts(date)", + "CREATE INDEX IF NOT EXISTS idx_podcasts_audio_generated ON podcasts(audio_generated)", + "CREATE INDEX IF NOT EXISTS idx_podcasts_tts_engine ON podcasts(tts_engine)", + "CREATE INDEX IF NOT EXISTS idx_podcasts_language_code ON podcasts(language_code)", + ] + for index_sql in indexes: + cursor.execute(index_sql) + conn.commit() + elapsed = time.time() - start_time + print(f"Podcasts database initialized in {elapsed:.3f}s") + + +def init_tasks_db(): + start_time = time.time() + db_path = get_db_path("tasks_db") + with db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute("BEGIN TRANSACTION") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + task_type TEXT, + description TEXT, + command TEXT NOT NULL, + frequency INTEGER NOT NULL, + frequency_unit TEXT NOT NULL, + enabled BOOLEAN DEFAULT 1, + last_run TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS task_executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP, + status TEXT NOT NULL, + error_message TEXT, + output TEXT, + FOREIGN KEY (task_id) REFERENCES tasks(id) + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS podcast_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + prompt TEXT NOT NULL, + time_range_hours INTEGER DEFAULT 24, + limit_articles INTEGER DEFAULT 20, + is_active BOOLEAN DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + tts_engine TEXT DEFAULT 'elevenlabs', + language_code TEXT DEFAULT 'en', + podcast_script_prompt TEXT, + image_prompt TEXT + ) + """) + indexes = [ + "CREATE INDEX IF NOT EXISTS idx_tasks_enabled ON tasks(enabled)", + "CREATE INDEX IF NOT EXISTS idx_tasks_frequency ON tasks(frequency, frequency_unit)", + "CREATE INDEX IF NOT EXISTS idx_tasks_last_run ON tasks(last_run)", + "CREATE INDEX IF NOT EXISTS idx_task_executions_task_id ON task_executions(task_id)", + "CREATE INDEX IF NOT EXISTS idx_task_executions_status ON task_executions(status)", + "CREATE INDEX IF NOT EXISTS idx_task_executions_start_time ON task_executions(start_time)", + "CREATE INDEX IF NOT EXISTS idx_podcast_configs_is_active ON podcast_configs(is_active)", + "CREATE INDEX IF NOT EXISTS idx_podcast_configs_name ON podcast_configs(name)", + ] + for index_sql in indexes: + cursor.execute(index_sql) + conn.commit() + elapsed = time.time() - start_time + print(f"Tasks database initialized in {elapsed:.3f}s") + + +async def init_agent_session_db(): + """Initialize the agent session database. auto generated""" + pass + + +def init_internal_sessions_db(): + start_time = time.time() + db_path = get_db_path("internal_sessions_db") + with db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute("BEGIN TRANSACTION") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS session_state ( + session_id TEXT PRIMARY KEY, + state JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_state_session_id ON session_state(session_id)") + conn.commit() + elapsed = time.time() - start_time + print(f"Internal sessions database initialized in {elapsed:.3f}s") + + +def init_social_media_db(): + start_time = time.time() + db_path = get_db_path("social_media_db") + with db_connection(db_path) as conn: + cursor = conn.cursor() + cursor.execute("BEGIN TRANSACTION") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS posts ( + post_id TEXT PRIMARY KEY, + platform TEXT, + user_display_name TEXT, + user_handle TEXT, + user_profile_pic_url TEXT, + post_timestamp TEXT, + post_display_time TEXT, + post_url TEXT, + post_text TEXT, + post_mentions TEXT, + engagement_reply_count INTEGER, + engagement_retweet_count INTEGER, + engagement_like_count INTEGER, + engagement_bookmark_count INTEGER, + engagement_view_count INTEGER, + media TEXT, + media_count INTEGER, + is_ad BOOLEAN, + sentiment TEXT, + categories TEXT, + tags TEXT, + analysis_reasoning TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + indexes = [ + "CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform)", + "CREATE INDEX IF NOT EXISTS idx_posts_user_handle ON posts(user_handle)", + "CREATE INDEX IF NOT EXISTS idx_posts_post_timestamp ON posts(post_timestamp)", + "CREATE INDEX IF NOT EXISTS idx_posts_sentiment ON posts(sentiment)", + ] + for index_sql in indexes: + cursor.execute(index_sql) + conn.commit() + elapsed = time.time() - start_time + print(f"Social media database initialized in {elapsed:.3f}s") + + +async def init_databases(): + total_start = time.time() + print("Initializing all databases...") + for db_name in ["sources_db", "tracking_db", "podcasts_db", "tasks_db"]: + db_path = get_db_path(db_name) + os.makedirs(os.path.dirname(db_path), exist_ok=True) + loop = asyncio.get_event_loop() + with ThreadPoolExecutor(max_workers=4) as executor: + tasks = [ + loop.run_in_executor(executor, init_sources_db), + loop.run_in_executor(executor, init_tracking_db), + loop.run_in_executor(executor, init_podcasts_db), + loop.run_in_executor(executor, init_tasks_db), + loop.run_in_executor(executor, init_internal_sessions_db), + loop.run_in_executor(executor, init_social_media_db), + ] + await asyncio.gather(*tasks) + total_elapsed = time.time() - total_start + print(f"All databases initialized in {total_elapsed:.3f}s") + + +def init_all_databases(): + asyncio.run(init_databases()) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/db_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/db_service.py new file mode 100644 index 0000000..2a367b3 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/db_service.py @@ -0,0 +1,75 @@ +import os +from typing import Dict, List, Any, Tuple, Union +from fastapi import HTTPException +from contextlib import contextmanager +import sqlite3 +from db.config import get_db_path + + +@contextmanager +def db_connection(db_path: str): + """Context manager for database connections.""" + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail=f"Database {db_path} not found. Initialize the database first.") + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +class DatabaseService: + """Service for managing database connections and operations.""" + + def __init__(self, db_name: str): + """ + Initialize the database service. + + Args: + db_name: Name of the database (sources_db, tracking_db, etc.) + """ + self.db_path = get_db_path(db_name) + + async def execute_query( + self, query: str, params: Tuple = (), fetch: bool = False, fetch_one: bool = False + ) -> Union[List[Dict[str, Any]], Dict[str, Any], int]: + """Execute a query with error handling for FastAPI.""" + try: + with db_connection(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(query, params) + + if fetch_one: + result = cursor.fetchone() + return dict(result) if result else None + elif fetch: + return [dict(row) for row in cursor.fetchall()] + else: + conn.commit() + return cursor.lastrowid + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") + + async def execute_write_many(self, query: str, params_list: List[Tuple]) -> int: + """Execute multiple write operations in a single transaction.""" + try: + with db_connection(self.db_path) as conn: + cursor = conn.cursor() + cursor.executemany(query, params_list) + conn.commit() + return cursor.rowcount + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") + + + +sources_db = DatabaseService(db_name="sources_db") +tracking_db = DatabaseService(db_name="tracking_db") +podcasts_db = DatabaseService(db_name="podcasts_db") +tasks_db = DatabaseService(db_name="tasks_db") +social_media_db = DatabaseService(db_name="social_media_db") diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/internal_session_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/internal_session_service.py new file mode 100644 index 0000000..92c6a5d --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/internal_session_service.py @@ -0,0 +1,155 @@ +from typing import Optional, Dict, Any +from fastapi import HTTPException +import json +from datetime import datetime +from db.config import get_db_path +from db.agent_config_v2 import INITIAL_SESSION_STATE +import sqlite3 +from contextlib import contextmanager + + +@contextmanager +def get_db_connection(db_name: str): + """Get a fresh database connection each time.""" + db_path = get_db_path(db_name) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +class SessionService: + """Service for managing internal session operations.""" + + @staticmethod + def get_session(session_id: str) -> Dict[str, Any]: + try: + with get_db_connection("internal_sessions_db") as conn: + cursor = conn.cursor() + query = """ + SELECT session_id, state, created_at + FROM session_state + WHERE session_id = ? + """ + cursor.execute(query, (session_id,)) + session = cursor.fetchone() + if not session: + return SessionService._initialize_session(session_id) + session_dict = dict(session) + if session_dict.get("state"): + try: + session_dict["state"] = json.loads(session_dict["state"]) + except json.JSONDecodeError: + session_dict["state"] = {} + return session_dict + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching session: {str(e)}") + + @staticmethod + def _initialize_session(session_id: str) -> Dict[str, Any]: + try: + with get_db_connection("internal_sessions_db") as conn: + cursor = conn.cursor() + state_json = json.dumps(INITIAL_SESSION_STATE) + insert_query = """ + INSERT INTO session_state (session_id, state, created_at) + VALUES (?, ?, ?) + """ + current_time = datetime.now().isoformat() + cursor.execute(insert_query, (session_id, state_json, current_time)) + conn.commit() + return {"session_id": session_id, "state": INITIAL_SESSION_STATE, "created_at": current_time} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error initializing session: {str(e)}") + + @staticmethod + def save_session(session_id: str, state: Dict[str, Any]) -> Dict[str, Any]: + try: + state_json = json.dumps(state) + with get_db_connection("internal_sessions_db") as conn: + cursor = conn.cursor() + conn.execute("BEGIN IMMEDIATE") + existing_query = "SELECT session_id FROM session_state WHERE session_id = ?" + cursor.execute(existing_query, (session_id,)) + existing_session = cursor.fetchone() + if existing_session: + update_query = "UPDATE session_state SET state = ? WHERE session_id = ?" + cursor.execute(update_query, (state_json, session_id)) + else: + insert_query = "INSERT INTO session_state (session_id, state, created_at) VALUES (?, ?, ?)" + current_time = datetime.now().isoformat() + cursor.execute(insert_query, (session_id, state_json, current_time)) + conn.commit() + return SessionService.get_session(session_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error saving session: {str(e)}") + + @staticmethod + def delete_session(session_id: str) -> Dict[str, str]: + try: + with get_db_connection("internal_sessions_db") as conn: + cursor = conn.cursor() + existing_query = "SELECT session_id FROM session_state WHERE session_id = ?" + cursor.execute(existing_query, (session_id,)) + existing_session = cursor.fetchone() + if not existing_session: + raise HTTPException(status_code=404, detail="Session not found") + delete_query = "DELETE FROM session_state WHERE session_id = ?" + cursor.execute(delete_query, (session_id,)) + conn.commit() + return {"message": f"Session with ID {session_id} successfully deleted"} + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error deleting session: {str(e)}") + + @staticmethod + def list_sessions(page: int = 1, per_page: int = 10, search: Optional[str] = None) -> Dict[str, Any]: + try: + with get_db_connection("internal_sessions_db") as conn: + cursor = conn.cursor() + offset = (page - 1) * per_page + query_parts = [ + "SELECT session_id, created_at", + "FROM session_state", + ] + query_params = [] + if search: + query_parts.append("WHERE session_id LIKE ?") + search_param = f"%{search}%" + query_params.append(search_param) + + count_query = " ".join(query_parts).replace( + "SELECT session_id, created_at", + "SELECT COUNT(*)", + ) + cursor.execute(count_query, tuple(query_params)) + total_count = cursor.fetchone()[0] + query_parts.append("ORDER BY created_at DESC") + query_parts.append("LIMIT ? OFFSET ?") + query_params.extend([per_page, offset]) + sessions_query = " ".join(query_parts) + cursor.execute(sessions_query, tuple(query_params)) + sessions = [dict(row) for row in cursor.fetchall()] + total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 0 + has_next = page < total_pages + has_prev = page > 1 + return { + "items": sessions, + "total": total_count, + "page": page, + "per_page": per_page, + "total_pages": total_pages, + "has_next": has_next, + "has_prev": has_prev, + } + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error listing sessions: {str(e)}") \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/podcast_config_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/podcast_config_service.py new file mode 100644 index 0000000..e0c4e83 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/podcast_config_service.py @@ -0,0 +1,177 @@ +from typing import List, Optional, Dict, Any +from datetime import datetime +from fastapi import HTTPException +from services.db_service import tasks_db + + +class PodcastConfigService: + """Service for managing podcast configurations.""" + + async def get_all_configs(self, active_only: bool = False) -> List[Dict[str, Any]]: + """Get all podcast configurations with optional filtering.""" + try: + if active_only: + query = """ + SELECT id, name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at + FROM podcast_configs + WHERE is_active = 1 + ORDER BY name + """ + params = () + else: + query = """ + SELECT id, name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at + FROM podcast_configs + ORDER BY name + """ + params = () + configs = await tasks_db.execute_query(query, params, fetch=True) + for config in configs: + config["is_active"] = bool(config.get("is_active", 0)) + return configs + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching podcast configurations: {str(e)}") + + async def get_config(self, config_id: int) -> Dict[str, Any]: + """Get a specific podcast configuration by ID.""" + try: + query = """ + SELECT id, name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at + FROM podcast_configs + WHERE id = ? + """ + config = await tasks_db.execute_query(query, (config_id,), fetch=True, fetch_one=True) + if not config: + raise HTTPException(status_code=404, detail="Podcast configuration not found") + config["is_active"] = bool(config.get("is_active", 0)) + return config + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching podcast configuration: {str(e)}") + + async def create_config( + self, + name: str, + prompt: str, + description: Optional[str] = None, + time_range_hours: int = 24, + limit_articles: int = 20, + is_active: bool = True, + tts_engine: str = "kokoro", + language_code: str = "en", + podcast_script_prompt: Optional[str] = None, + image_prompt: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a new podcast configuration.""" + try: + current_time = datetime.now().isoformat() + query = """ + INSERT INTO podcast_configs + (name, description, prompt, time_range_hours, limit_articles, + is_active, tts_engine, language_code, podcast_script_prompt, + image_prompt, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + params = ( + name, + description, + prompt, + time_range_hours, + limit_articles, + 1 if is_active else 0, + tts_engine, + language_code, + podcast_script_prompt, + image_prompt, + current_time, + current_time, + ) + config_id = await tasks_db.execute_query(query, params) + return await self.get_config(config_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error creating podcast configuration: {str(e)}") + + async def update_config(self, config_id: int, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update an existing podcast configuration.""" + try: + allowed_fields = [ + "name", + "description", + "prompt", + "time_range_hours", + "limit_articles", + "is_active", + "tts_engine", + "language_code", + "podcast_script_prompt", + "image_prompt", + ] + set_clauses = [] + params = [] + set_clauses.append("updated_at = ?") + params.append(datetime.now().isoformat()) + for field, value in updates.items(): + if field in allowed_fields: + if field == "is_active": + value = 1 if value else 0 + set_clauses.append(f"{field} = ?") + params.append(value) + if not set_clauses: + return await self.get_config(config_id) + params.append(config_id) + update_query = f""" + UPDATE podcast_configs + SET {", ".join(set_clauses)} + WHERE id = ? + """ + await tasks_db.execute_query(update_query, tuple(params)) + return await self.get_config(config_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error updating podcast configuration: {str(e)}") + + async def delete_config(self, config_id: int) -> Dict[str, str]: + """Delete a podcast configuration.""" + try: + config = await self.get_config(config_id) + query = """ + DELETE FROM podcast_configs + WHERE id = ? + """ + await tasks_db.execute_query(query, (config_id,)) + return {"message": f"Podcast configuration '{config['name']}' has been deleted"} + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error deleting podcast configuration: {str(e)}") + + async def toggle_config(self, config_id: int, enable: bool) -> Dict[str, Any]: + """Enable or disable a podcast configuration.""" + try: + query = """ + UPDATE podcast_configs + SET is_active = ?, updated_at = ? + WHERE id = ? + """ + current_time = datetime.now().isoformat() + await tasks_db.execute_query(query, (1 if enable else 0, current_time, config_id)) + return await self.get_config(config_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error updating podcast configuration: {str(e)}") + + +podcast_config_service = PodcastConfigService() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/podcast_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/podcast_service.py new file mode 100644 index 0000000..2964c94 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/podcast_service.py @@ -0,0 +1,349 @@ +import os +import json +from typing import List, Dict, Optional, Any +from datetime import datetime +from fastapi import HTTPException, UploadFile +from services.db_service import podcasts_db +import math + +AUDIO_DIR = "podcasts/audio" +IMAGE_DIR = "podcasts/images" + + +class PodcastService: + """Service for managing podcast operations with the new database structure.""" + + def __init__(self): + """Initialize the podcast service with directories.""" + os.makedirs(AUDIO_DIR, exist_ok=True) + os.makedirs(IMAGE_DIR, exist_ok=True) + + async def get_podcasts( + self, + page: int = 1, + per_page: int = 10, + search: str = None, + date_from: str = None, + date_to: str = None, + language_code: str = None, + tts_engine: str = None, + has_audio: bool = None, + ) -> Dict[str, Any]: + """ + Get a paginated list of podcasts with optional filtering. + """ + try: + offset = (page - 1) * per_page + count_query = "SELECT COUNT(*) as count FROM podcasts" + query = """ + SELECT id, title, date, audio_generated, audio_path, banner_img_path, + language_code, tts_engine, created_at + FROM podcasts + """ + where_conditions = [] + params = [] + if search: + where_conditions.append("(title LIKE ?)") + search_param = f"%{search}%" + params.append(search_param) + if date_from: + where_conditions.append("date >= ?") + params.append(date_from) + if date_to: + where_conditions.append("date <= ?") + params.append(date_to) + if language_code: + where_conditions.append("language_code = ?") + params.append(language_code) + if tts_engine: + where_conditions.append("tts_engine = ?") + params.append(tts_engine) + if has_audio is not None: + where_conditions.append("audio_generated = ?") + params.append(1 if has_audio else 0) + if where_conditions: + where_clause = " WHERE " + " AND ".join(where_conditions) + query += where_clause + count_query += where_clause + query += " ORDER BY date DESC, created_at DESC" + query += " LIMIT ? OFFSET ?" + params.extend([per_page, offset]) + total_result = await podcasts_db.execute_query(count_query, tuple(params[:-2] if params else ()), fetch=True, fetch_one=True) + total_items = total_result.get("count", 0) if total_result else 0 + total_pages = math.ceil(total_items / per_page) if total_items > 0 else 0 + podcasts = await podcasts_db.execute_query(query, tuple(params), fetch=True) + for podcast in podcasts: + podcast["audio_generated"] = bool(podcast.get("audio_generated", 0)) + if podcast.get("banner_img_path"): + podcast["banner_img"] = podcast.get("banner_img_path") + else: + podcast["banner_img"] = None + podcast.pop("banner_img_path", None) + podcast["identifier"] = str(podcast.get("id", "")) + has_next = page < total_pages + has_prev = page > 1 + return { + "items": podcasts, + "total": total_items, + "page": page, + "per_page": per_page, + "total_pages": total_pages, + "has_next": has_next, + "has_prev": has_prev, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error loading podcasts: {str(e)}") + + async def get_podcast(self, podcast_id: int) -> Optional[Dict[str, Any]]: + """Get a specific podcast by ID without content.""" + try: + query = """ + SELECT id, title, date, audio_generated, audio_path, banner_img_path, + language_code, tts_engine, created_at, banner_images + FROM podcasts + WHERE id = ? + """ + podcast = await podcasts_db.execute_query(query, (podcast_id,), fetch=True, fetch_one=True) + if not podcast: + raise HTTPException(status_code=404, detail="Podcast not found") + podcast["audio_generated"] = bool(podcast.get("audio_generated", 0)) + if podcast.get("banner_img_path"): + podcast["banner_img"] = podcast.get("banner_img_path") + else: + podcast["banner_img"] = None + podcast.pop("banner_img_path", None) + podcast["identifier"] = str(podcast.get("id", "")) + sources_query = "SELECT sources_json FROM podcasts WHERE id = ?" + sources_result = await podcasts_db.execute_query(sources_query, (podcast_id,), fetch=True, fetch_one=True) + sources = [] + if sources_result and sources_result.get("sources_json"): + try: + parsed_sources = json.loads(sources_result["sources_json"]) + if isinstance(parsed_sources, list): + sources = parsed_sources + else: + sources = [parsed_sources] + except json.JSONDecodeError: + sources = [] + podcast["sources"] = sources + + try: + banner_images = json.loads(podcast.get("banner_images", "[]")) + except json.JSONDecodeError: + banner_images = [] + podcast["banner_images"] = banner_images + + return podcast + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error loading podcast: {str(e)}") + + async def get_podcast_by_identifier(self, identifier: str) -> Optional[Dict[str, Any]]: + """Get a specific podcast by string identifier (which is actually the ID).""" + try: + try: + podcast_id = int(identifier) + except ValueError: + raise HTTPException(status_code=404, detail="Invalid podcast identifier") + return await self.get_podcast(podcast_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error loading podcast: {str(e)}") + + async def get_podcast_content(self, podcast_id: int) -> Dict[str, Any]: + """Get the content of a specific podcast.""" + try: + query = """ + SELECT content_json FROM podcasts WHERE id = ? + """ + result = await podcasts_db.execute_query(query, (podcast_id,), fetch=True, fetch_one=True) + if not result or not result.get("content_json"): + raise HTTPException(status_code=404, detail="Podcast content not found") + try: + content = json.loads(result["content_json"]) + return content + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid podcast content format") + + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error loading podcast content: {str(e)}") + + async def get_podcast_audio_url(self, podcast: Dict[str, Any]) -> Optional[str]: + """Get the URL for the podcast audio file if available.""" + if podcast.get("audio_generated") and podcast.get("audio_path"): + return f"/audio/{podcast.get('audio_path')}" + return None + + async def get_podcast_formats(self) -> List[str]: + """Get list of available podcast formats for filtering.""" + # Note: This may need to be adapted if format field is added + return ["daily", "weekly", "tech", "news"] + + async def get_language_codes(self) -> List[str]: + try: + query = """ + SELECT DISTINCT language_code FROM podcasts WHERE language_code IS NOT NULL + """ + results = await podcasts_db.execute_query(query, (), fetch=True) + language_codes = [result["language_code"] for result in results if result["language_code"]] + if "en" not in language_codes: + language_codes.append("en") + return sorted(language_codes) + except Exception as _: + return ["en"] + + async def get_tts_engines(self) -> List[str]: + """Get list of available TTS engines for filtering.""" + try: + query = """ + SELECT DISTINCT tts_engine FROM podcasts WHERE tts_engine IS NOT NULL + """ + results = await podcasts_db.execute_query(query, (), fetch=True) + tts_engines = [result["tts_engine"] for result in results if result["tts_engine"]] + default_engines = ["elevenlabs", "openai", "kokoro"] + for engine in default_engines: + if engine not in tts_engines: + tts_engines.append(engine) + return sorted(tts_engines) + except Exception as e: + return ["elevenlabs", "openai", "kokoro"] + + async def create_podcast( + self, title: str, date: str, content: Dict[str, Any], sources: List[str] = None, language_code: str = "en", tts_engine: str = "kokoro" + ) -> Dict[str, Any]: + """Create a new podcast in the database.""" + try: + content_json = json.dumps(content) + sources_json = json.dumps(sources) if sources else None + current_time = datetime.now().isoformat() + query = """ + INSERT INTO podcasts + (title, date, content_json, audio_generated, sources_json, language_code, tts_engine, created_at) + VALUES (?, ?, ?, 0, ?, ?, ?, ?) + """ + params = (title, date, content_json, sources_json, language_code, tts_engine, current_time) + podcast_id = await podcasts_db.execute_query(query, params) + return await self.get_podcast(podcast_id) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error creating podcast: {str(e)}") + + async def update_podcast(self, podcast_id: int, podcast_data: Dict[str, Any]) -> Dict[str, Any]: + """Update podcast metadata and content.""" + try: + existing = await self.get_podcast(podcast_id) + if not existing: + raise HTTPException(status_code=404, detail="Podcast not found") + fields = [] + params = [] + if "title" in podcast_data: + fields.append("title = ?") + params.append(podcast_data["title"]) + if "date" in podcast_data: + fields.append("date = ?") + params.append(podcast_data["date"]) + if "content" in podcast_data and isinstance(podcast_data["content"], dict): + fields.append("content_json = ?") + params.append(json.dumps(podcast_data["content"])) + if "audio_generated" in podcast_data: + fields.append("audio_generated = ?") + params.append(1 if podcast_data["audio_generated"] else 0) + if "audio_path" in podcast_data: + fields.append("audio_path = ?") + params.append(podcast_data["audio_path"]) + if "banner_img_path" in podcast_data: + fields.append("banner_img_path = ?") + params.append(podcast_data["banner_img_path"]) + if "sources" in podcast_data: + fields.append("sources_json = ?") + params.append(json.dumps(podcast_data["sources"])) + if "language_code" in podcast_data: + fields.append("language_code = ?") + params.append(podcast_data["language_code"]) + if "tts_engine" in podcast_data: + fields.append("tts_engine = ?") + params.append(podcast_data["tts_engine"]) + if not fields: + return existing + params.append(podcast_id) + query = f""" + UPDATE podcasts SET {", ".join(fields)} + WHERE id = ? + """ + await podcasts_db.execute_query(query, tuple(params)) + return await self.get_podcast(podcast_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error updating podcast: {str(e)}") + + async def delete_podcast(self, podcast_id: int, delete_assets: bool = False) -> bool: + """Delete a podcast from the database.""" + try: + existing = await self.get_podcast(podcast_id) + if not existing: + raise HTTPException(status_code=404, detail="Podcast not found") + query = "DELETE FROM podcasts WHERE id = ?" + result = await podcasts_db.execute_query(query, (podcast_id,)) + if delete_assets: + if existing.get("audio_path"): + audio_path = os.path.join(AUDIO_DIR, existing["audio_path"]) + if os.path.exists(audio_path): + os.remove(audio_path) + if existing.get("banner_img_path"): + img_path = os.path.join(IMAGE_DIR, existing["banner_img_path"]) + if os.path.exists(img_path): + os.remove(img_path) + return result > 0 + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error deleting podcast: {str(e)}") + + async def upload_podcast_audio(self, podcast_id: int, file: UploadFile) -> Dict[str, Any]: + """Upload an audio file for a podcast.""" + try: + await self.get_podcast(podcast_id) + filename = f"podcast_{podcast_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}" + if file.filename: + ext = os.path.splitext(file.filename)[1] + filename = f"{filename}{ext}" + else: + filename = f"{filename}.mp3" + file_path = os.path.join(AUDIO_DIR, filename) + contents = await file.read() + with open(file_path, "wb") as f: + f.write(contents) + update_data = {"audio_generated": True, "audio_path": filename} + return await self.update_podcast(podcast_id, update_data) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error uploading audio: {str(e)}") + + async def upload_podcast_banner(self, podcast_id: int, file: UploadFile) -> Dict[str, Any]: + """Upload a banner image for a podcast.""" + try: + await self.get_podcast(podcast_id) + filename = f"banner_{podcast_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}" + if file.filename: + ext = os.path.splitext(file.filename)[1] + filename = f"{filename}{ext}" + else: + filename = f"{filename}.jpg" + file_path = os.path.join(IMAGE_DIR, filename) + contents = await file.read() + with open(file_path, "wb") as f: + f.write(contents) + update_data = {"banner_img_path": filename} + return await self.update_podcast(podcast_id, update_data) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error uploading banner: {str(e)}") + + +podcast_service = PodcastService() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/social_media_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/social_media_service.py new file mode 100644 index 0000000..4c48242 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/social_media_service.py @@ -0,0 +1,603 @@ +import json +from typing import List, Optional, Dict, Any +from fastapi import HTTPException +from services.db_service import social_media_db +from models.social_media_schemas import PaginatedPosts, Post +from datetime import datetime, timedelta + + +class SocialMediaService: + """Service for managing social media posts.""" + + async def get_posts( + self, + page: int = 1, + per_page: int = 10, + platform: Optional[str] = None, + user_handle: Optional[str] = None, + sentiment: Optional[str] = None, + category: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + search: Optional[str] = None, + ) -> PaginatedPosts: + """Get social media posts with pagination and filtering.""" + try: + offset = (page - 1) * per_page + query_parts = [ + "SELECT * FROM posts", + "WHERE 1=1", + ] + query_params = [] + if platform: + query_parts.append("AND platform = ?") + query_params.append(platform) + if user_handle: + query_parts.append("AND user_handle = ?") + query_params.append(user_handle) + if sentiment: + query_parts.append("AND sentiment = ?") + query_params.append(sentiment) + if category: + query_parts.append("AND categories LIKE ?") + query_params.append(f'%"{category}"%') + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + query_params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + query_params.append(date_to) + if search: + query_parts.append("AND (post_text LIKE ? OR user_display_name LIKE ? OR user_handle LIKE ?)") + search_param = f"%{search}%" + query_params.extend([search_param, search_param, search_param]) + count_query = " ".join(query_parts).replace("SELECT *", "SELECT COUNT(*)") + total_posts = await social_media_db.execute_query(count_query, tuple(query_params), fetch=True, fetch_one=True) + total_count = total_posts.get("COUNT(*)", 0) if total_posts else 0 + query_parts.append("ORDER BY datetime(post_timestamp) DESC, post_id DESC") + query_parts.append("LIMIT ? OFFSET ?") + query_params.extend([per_page, offset]) + posts_query = " ".join(query_parts) + posts_data = await social_media_db.execute_query(posts_query, tuple(query_params), fetch=True) + posts = [] + for post in posts_data: + post_dict = dict(post) + if post_dict.get("media"): + try: + post_dict["media"] = json.loads(post_dict["media"]) + except json.JSONDecodeError: + post_dict["media"] = [] + if post_dict.get("categories"): + try: + post_dict["categories"] = json.loads(post_dict["categories"]) + except json.JSONDecodeError: + post_dict["categories"] = [] + if post_dict.get("tags"): + try: + post_dict["tags"] = json.loads(post_dict["tags"]) + except json.JSONDecodeError: + post_dict["tags"] = [] + post_dict["engagement"] = { + "replies": post_dict.pop("engagement_reply_count", 0), + "retweets": post_dict.pop("engagement_retweet_count", 0), + "likes": post_dict.pop("engagement_like_count", 0), + "bookmarks": post_dict.pop("engagement_bookmark_count", 0), + "views": post_dict.pop("engagement_view_count", 0), + } + posts.append(post_dict) + total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 0 + has_next = page < total_pages + has_prev = page > 1 + return PaginatedPosts( + items=posts, + total=total_count, + page=page, + per_page=per_page, + total_pages=total_pages, + has_next=has_next, + has_prev=has_prev, + ) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching social media posts: {str(e)}") + + async def get_post(self, post_id: str) -> Dict[str, Any]: + """Get a specific post by ID.""" + try: + query = "SELECT * FROM posts WHERE post_id = ?" + post = await social_media_db.execute_query(query, (post_id,), fetch=True, fetch_one=True) + if not post: + raise HTTPException(status_code=404, detail="Post not found") + post_dict = dict(post) + if post_dict.get("media"): + try: + post_dict["media"] = json.loads(post_dict["media"]) + except json.JSONDecodeError: + post_dict["media"] = [] + if post_dict.get("categories"): + try: + post_dict["categories"] = json.loads(post_dict["categories"]) + except json.JSONDecodeError: + post_dict["categories"] = [] + if post_dict.get("tags"): + try: + post_dict["tags"] = json.loads(post_dict["tags"]) + except json.JSONDecodeError: + post_dict["tags"] = [] + post_dict["engagement"] = { + "replies": post_dict.pop("engagement_reply_count", 0), + "retweets": post_dict.pop("engagement_retweet_count", 0), + "likes": post_dict.pop("engagement_like_count", 0), + "bookmarks": post_dict.pop("engagement_bookmark_count", 0), + "views": post_dict.pop("engagement_view_count", 0), + } + return post_dict + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching social media post: {str(e)}") + + async def get_platforms(self) -> List[str]: + """Get all platforms that have posts.""" + query = "SELECT DISTINCT platform FROM posts ORDER BY platform" + result = await social_media_db.execute_query(query, fetch=True) + return [row.get("platform", "") for row in result if row.get("platform")] + + async def get_sentiments(self, date_from: Optional[str] = None, date_to: Optional[str] = None) -> List[Dict[str, Any]]: + """Get sentiment distribution with post counts.""" + try: + query_parts = [ + """ + SELECT + sentiment, COUNT(*) as post_count + FROM posts + WHERE sentiment IS NOT NULL + """ + ] + params = [] + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + params.append(date_to) + query_parts.append("GROUP BY sentiment ORDER BY post_count DESC") + query = " ".join(query_parts) + return await social_media_db.execute_query(query, tuple(params), fetch=True) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching sentiments: {str(e)}") + + async def get_top_users( + self, + platform: Optional[str] = None, + limit: int = 10, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Get top users by post count.""" + query_parts = ["SELECT user_handle, user_display_name, COUNT(*) as post_count", "FROM posts", "WHERE user_handle IS NOT NULL"] + params = [] + if platform: + query_parts.append("AND platform = ?") + params.append(platform) + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + params.append(date_to) + query_parts.extend(["GROUP BY user_handle", "ORDER BY post_count DESC", "LIMIT ?"]) + params.append(limit) + query = " ".join(query_parts) + return await social_media_db.execute_query(query, tuple(params), fetch=True) + + async def get_categories(self, date_from: Optional[str] = None, date_to: Optional[str] = None) -> List[Dict[str, Any]]: + """Get all categories with post counts.""" + try: + query_parts = ["SELECT categories FROM posts WHERE categories IS NOT NULL"] + params = [] + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + params.append(date_to) + query = " ".join(query_parts) + result = await social_media_db.execute_query(query, tuple(params), fetch=True) + category_counts = {} + for row in result: + if row.get("categories"): + try: + categories = json.loads(row["categories"]) + for category in categories: + if category in category_counts: + category_counts[category] += 1 + else: + category_counts[category] = 1 + except json.JSONDecodeError: + pass + return [{"category": category, "post_count": count} for category, count in sorted(category_counts.items(), key=lambda x: x[1], reverse=True)] + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching categories: {str(e)}") + + async def get_user_sentiment( + self, + limit: int = 10, + platform: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Get users with their sentiment breakdown.""" + try: + query_parts = [ + """ + SELECT + user_handle, + user_display_name, + COUNT(*) as total_posts, + SUM(CASE WHEN sentiment = 'positive' THEN 1 ELSE 0 END) as positive_count, + SUM(CASE WHEN sentiment = 'negative' THEN 1 ELSE 0 END) as negative_count, + SUM(CASE WHEN sentiment = 'neutral' THEN 1 ELSE 0 END) as neutral_count, + SUM(CASE WHEN sentiment = 'critical' THEN 1 ELSE 0 END) as critical_count + FROM posts + WHERE user_handle IS NOT NULL + """ + ] + params = [] + if platform: + query_parts.append("AND platform = ?") + params.append(platform) + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + params.append(date_to) + query_parts.extend(["GROUP BY user_handle, user_display_name", "ORDER BY total_posts DESC", "LIMIT ?"]) + params.append(limit) + query = " ".join(query_parts) + result = await social_media_db.execute_query(query, tuple(params), fetch=True) + for user in result: + total = user["total_posts"] + user["positive_percent"] = (user["positive_count"] / total) * 100 if total > 0 else 0 + user["negative_percent"] = (user["negative_count"] / total) * 100 if total > 0 else 0 + user["neutral_percent"] = (user["neutral_count"] / total) * 100 if total > 0 else 0 + user["critical_percent"] = (user["critical_count"] / total) * 100 if total > 0 else 0 + return result + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching user sentiment: {str(e)}") + + async def get_category_sentiment(self, date_from: Optional[str] = None, date_to: Optional[str] = None) -> List[Dict[str, Any]]: + """Get sentiment distribution by category.""" + try: + date_filter = "" + params = [] + if date_from or date_to: + date_filter = "WHERE " + if date_from: + date_filter += "datetime(p.post_timestamp) >= datetime(?)" + params.append(date_from) + if date_to: + date_filter += " AND " + if date_to: + date_filter += "datetime(p.post_timestamp) <= datetime(?)" + params.append(date_to) + query = f""" + WITH category_data AS ( + SELECT + json_each.value as category, + sentiment, + COUNT(*) as count + FROM + posts p, + json_each(p.categories) + {date_filter} + GROUP BY + json_each.value, sentiment + ) + SELECT + category, + SUM(count) as total_count, + SUM(CASE WHEN sentiment = 'positive' THEN count ELSE 0 END) as positive_count, + SUM(CASE WHEN sentiment = 'negative' THEN count ELSE 0 END) as negative_count, + SUM(CASE WHEN sentiment = 'neutral' THEN count ELSE 0 END) as neutral_count, + SUM(CASE WHEN sentiment = 'critical' THEN count ELSE 0 END) as critical_count + FROM + category_data + GROUP BY + category + ORDER BY + total_count DESC + """ + result = await social_media_db.execute_query(query, tuple(params), fetch=True) + for category in result: + total = category["total_count"] + category["positive_percent"] = (category["positive_count"] / total) * 100 if total > 0 else 0 + category["negative_percent"] = (category["negative_count"] / total) * 100 if total > 0 else 0 + category["neutral_percent"] = (category["neutral_count"] / total) * 100 if total > 0 else 0 + category["critical_percent"] = (category["critical_count"] / total) * 100 if total > 0 else 0 + return result + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching category sentiment: {str(e)}") + + async def get_trending_topics( + self, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """Get trending topics with sentiment breakdown.""" + try: + query_parts = [ + """ + WITH topic_data AS ( + SELECT + json_each.value as topic, + sentiment, + COUNT(*) as count + FROM + posts, + json_each(posts.tags) + WHERE tags IS NOT NULL + """ + ] + params = [] + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + params.append(date_to) + query_parts.append( + """ + GROUP BY + json_each.value, sentiment + ) + SELECT + topic, + SUM(count) as total_count, + SUM(CASE WHEN sentiment = 'positive' THEN count ELSE 0 END) as positive_count, + SUM(CASE WHEN sentiment = 'negative' THEN count ELSE 0 END) as negative_count, + SUM(CASE WHEN sentiment = 'neutral' THEN count ELSE 0 END) as neutral_count, + SUM(CASE WHEN sentiment = 'critical' THEN count ELSE 0 END) as critical_count + FROM + topic_data + GROUP BY + topic + ORDER BY + total_count DESC + LIMIT ? + """ + ) + params.append(limit) + query = " ".join(query_parts) + result = await social_media_db.execute_query(query, tuple(params), fetch=True) + for topic in result: + total = topic["total_count"] + topic["positive_percent"] = (topic["positive_count"] / total) * 100 if total > 0 else 0 + topic["negative_percent"] = (topic["negative_count"] / total) * 100 if total > 0 else 0 + topic["neutral_percent"] = (topic["neutral_count"] / total) * 100 if total > 0 else 0 + topic["critical_percent"] = (topic["critical_count"] / total) * 100 if total > 0 else 0 + return result + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching trending topics: {str(e)}") + + async def get_sentiment_over_time( + self, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + platform: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Get sentiment trends over time.""" + try: + date_range_query = "" + if date_from and date_to: + date_range_query = f""" + WITH RECURSIVE date_range(date) AS ( + SELECT date('{date_from}') + UNION ALL + SELECT date(date, '+1 day') + FROM date_range + WHERE date < date('{date_to}') + ) + SELECT date as post_date FROM date_range + """ + else: + days_ago = (datetime.now() - timedelta(days=30)).isoformat() + date_range_query = f""" + WITH RECURSIVE date_range(date) AS ( + SELECT date('{days_ago}') + UNION ALL + SELECT date(date, '+1 day') + FROM date_range + WHERE date < date('now') + ) + SELECT date as post_date FROM date_range + """ + query_parts = [ + f""" + WITH dates AS ( + {date_range_query} + ) + SELECT + dates.post_date, + COALESCE(SUM(CASE WHEN sentiment = 'positive' THEN 1 ELSE 0 END), 0) as positive_count, + COALESCE(SUM(CASE WHEN sentiment = 'negative' THEN 1 ELSE 0 END), 0) as negative_count, + COALESCE(SUM(CASE WHEN sentiment = 'neutral' THEN 1 ELSE 0 END), 0) as neutral_count, + COALESCE(SUM(CASE WHEN sentiment = 'critical' THEN 1 ELSE 0 END), 0) as critical_count, + COUNT(posts.post_id) as total_count + FROM + dates + LEFT JOIN + posts ON date(posts.post_timestamp) = dates.post_date + """ + ] + params = [] + if platform: + query_parts.append("AND posts.platform = ?") + params.append(platform) + query_parts.append("GROUP BY dates.post_date ORDER BY dates.post_date") + query = " ".join(query_parts) + result = await social_media_db.execute_query(query, tuple(params), fetch=True) + for day in result: + total = day["total_count"] + day["positive_percent"] = (day["positive_count"] / total) * 100 if total > 0 else 0 + day["negative_percent"] = (day["negative_count"] / total) * 100 if total > 0 else 0 + day["neutral_percent"] = (day["neutral_count"] / total) * 100 if total > 0 else 0 + day["critical_percent"] = (day["critical_count"] / total) * 100 if total > 0 else 0 + return result + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching sentiment over time: {str(e)}") + + async def get_influential_posts( + self, + sentiment: Optional[str] = None, + limit: int = 5, + date_from: Optional[str] = None, + date_to: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Get most influential posts by engagement, optionally filtered by sentiment.""" + try: + query_parts = [ + """ + SELECT *, + (COALESCE(engagement_reply_count, 0) + + COALESCE(engagement_retweet_count, 0) + + COALESCE(engagement_like_count, 0) + + COALESCE(engagement_bookmark_count, 0)) as total_engagement + FROM posts + WHERE 1=1 + """ + ] + params = [] + if sentiment: + query_parts.append("AND sentiment = ?") + params.append(sentiment) + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + params.append(date_to) + query_parts.extend(["ORDER BY total_engagement DESC", "LIMIT ?"]) + params.append(limit) + query = " ".join(query_parts) + result = await social_media_db.execute_query(query, tuple(params), fetch=True) + processed_posts = [] + for post in result: + post_dict = dict(post) + if post_dict.get("media"): + try: + post_dict["media"] = json.loads(post_dict["media"]) + except json.JSONDecodeError: + post_dict["media"] = [] + if post_dict.get("categories"): + try: + post_dict["categories"] = json.loads(post_dict["categories"]) + except json.JSONDecodeError: + post_dict["categories"] = [] + if post_dict.get("tags"): + try: + post_dict["tags"] = json.loads(post_dict["tags"]) + except json.JSONDecodeError: + post_dict["tags"] = [] + post_dict["engagement"] = { + "replies": post_dict.pop("engagement_reply_count", 0), + "retweets": post_dict.pop("engagement_retweet_count", 0), + "likes": post_dict.pop("engagement_like_count", 0), + "bookmarks": post_dict.pop("engagement_bookmark_count", 0), + "views": post_dict.pop("engagement_view_count", 0), + } + processed_posts.append(post_dict) + return processed_posts + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching influential posts: {str(e)}") + + async def get_engagement_stats( + self, + date_from: Optional[str] = None, + date_to: Optional[str] = None + ) -> Dict[str, Any]: + """Get overall engagement statistics.""" + try: + query_parts = [ + """ + SELECT + AVG(COALESCE(engagement_reply_count, 0)) as avg_replies, + AVG(COALESCE(engagement_retweet_count, 0)) as avg_retweets, + AVG(COALESCE(engagement_like_count, 0)) as avg_likes, + AVG(COALESCE(engagement_bookmark_count, 0)) as avg_bookmarks, + AVG(COALESCE(engagement_view_count, 0)) as avg_views, + MAX(COALESCE(engagement_reply_count, 0)) as max_replies, + MAX(COALESCE(engagement_retweet_count, 0)) as max_retweets, + MAX(COALESCE(engagement_like_count, 0)) as max_likes, + MAX(COALESCE(engagement_bookmark_count, 0)) as max_bookmarks, + MAX(COALESCE(engagement_view_count, 0)) as max_views, + COUNT(*) as total_posts, + COUNT(DISTINCT user_handle) as unique_authors + FROM posts + WHERE 1=1 + """ + ] + params = [] + if date_from: + query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + params.append(date_from) + if date_to: + query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + params.append(date_to) + query = " ".join(query_parts) + result = await social_media_db.execute_query(query, tuple(params), fetch=True, fetch_one=True) + if not result: + return {"avg_engagement": 0, "total_posts": 0, "unique_authors": 0} + result_dict = dict(result) + result_dict["avg_engagement"] = ( + result_dict["avg_replies"] + result_dict["avg_retweets"] + result_dict["avg_likes"] + result_dict["avg_bookmarks"] + ) + platform_query_parts = [ + """ + SELECT + platform, + COUNT(*) as post_count + FROM posts + WHERE 1=1 + """ + ] + if date_from: + platform_query_parts.append("AND datetime(post_timestamp) >= datetime(?)") + if date_to: + platform_query_parts.append("AND datetime(post_timestamp) <= datetime(?)") + platform_query_parts.extend([ + "GROUP BY platform", + "ORDER BY post_count DESC", + "LIMIT 10" + ]) + platforms = await social_media_db.execute_query( + " ".join(platform_query_parts), + tuple(params), + fetch=True + ) + result_dict["platforms"] = platforms + return result_dict + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching engagement stats: {str(e)}") + + +social_media_service = SocialMediaService() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/source_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/source_service.py new file mode 100644 index 0000000..e199d91 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/source_service.py @@ -0,0 +1,375 @@ +from typing import List, Optional, Dict, Any +from fastapi import HTTPException +from datetime import datetime +from services.db_service import sources_db, tracking_db +from models.source_schemas import SourceCreate, SourceUpdate, SourceFeedCreate, PaginatedSources + + +class SourceService: + """Service for managing source operations with the new database structure.""" + + async def get_sources( + self, page: int = 1, per_page: int = 10, category: Optional[str] = None, search: Optional[str] = None, include_inactive: bool = False + ) -> PaginatedSources: + """Get sources with pagination and filtering.""" + try: + query_parts = ["SELECT s.id, s.name, s.url, s.description, s.is_active, s.created_at", "FROM sources s", "WHERE 1=1"] + query_params = [] + if not include_inactive: + query_parts.append("AND s.is_active = 1") + if category: + query_parts.append(""" + AND EXISTS ( + SELECT 1 FROM source_categories sc + JOIN categories c ON sc.category_id = c.id + WHERE sc.source_id = s.id AND c.name = ? + ) + """) + query_params.append(category) + if search: + query_parts.append("AND (s.name LIKE ? OR s.description LIKE ?)") + search_param = f"%{search}%" + query_params.extend([search_param, search_param]) + count_query = " ".join(query_parts).replace("SELECT s.id, s.name, s.url, s.description, s.is_active, s.created_at", "SELECT COUNT(*)") + total_sources = await sources_db.execute_query(count_query, tuple(query_params), fetch=True, fetch_one=True) + total_count = total_sources.get("COUNT(*)", 0) if total_sources else 0 + query_parts.append("ORDER BY s.name") + offset = (page - 1) * per_page + query_parts.append("LIMIT ? OFFSET ?") + query_params.extend([per_page, offset]) + final_query = " ".join(query_parts) + sources = await sources_db.execute_query(final_query, tuple(query_params), fetch=True) + for source in sources: + source["categories"] = await self.get_source_categories(source["id"]) + source["last_crawled"] = await self.get_source_last_crawled(source["id"]) + source["website"] = source["url"] + if source["categories"] and isinstance(source["categories"], list): + source["category"] = source["categories"][0] if source["categories"] else "" + total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 0 + has_next = page < total_pages + has_prev = page > 1 + return PaginatedSources( + items=sources, total=total_count, page=page, per_page=per_page, total_pages=total_pages, has_next=has_next, has_prev=has_prev + ) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching sources: {str(e)}") + + async def get_source(self, source_id: int) -> Dict[str, Any]: + """Get a specific source by ID.""" + query = """ + SELECT id, name, url, description, is_active, created_at + FROM sources + WHERE id = ? + """ + source = await sources_db.execute_query(query, (source_id,), fetch=True, fetch_one=True) + if not source: + raise HTTPException(status_code=404, detail="Source not found") + source["categories"] = await self.get_source_categories(source["id"]) + source["last_crawled"] = await self.get_source_last_crawled(source["id"]) + source["website"] = source["url"] + if source["categories"] and isinstance(source["categories"], list): + source["category"] = source["categories"][0] if source["categories"] else "" + return source + + async def get_source_categories(self, source_id: int) -> List[str]: + """Get all categories for a specific source.""" + query = """ + SELECT c.name + FROM source_categories sc + JOIN categories c ON sc.category_id = c.id + WHERE sc.source_id = ? + ORDER BY c.name + """ + categories = await sources_db.execute_query(query, (source_id,), fetch=True) + return [category.get("name", "") for category in categories if category.get("name")] + + async def get_source_last_crawled(self, source_id: int) -> Optional[str]: + """Get the last crawl time for a source's feeds.""" + query = """ + SELECT MAX(ft.last_processed) as last_crawled + FROM feed_tracking ft + WHERE ft.source_id = ? + """ + result = await tracking_db.execute_query(query, (source_id,), fetch=True, fetch_one=True) + return result.get("last_crawled") if result else None + + async def get_source_by_name(self, name: str) -> Dict[str, Any]: + """Get a specific source by name.""" + query = """ + SELECT id, name, url, description, is_active, created_at + FROM sources + WHERE name = ? + """ + source = await sources_db.execute_query(query, (name,), fetch=True, fetch_one=True) + if not source: + raise HTTPException(status_code=404, detail="Source not found") + source["categories"] = await self.get_source_categories(source["id"]) + source["last_crawled"] = await self.get_source_last_crawled(source["id"]) + source["website"] = source["url"] + if source["categories"] and isinstance(source["categories"], list): + source["category"] = source["categories"][0] if source["categories"] else "" + return source + + async def get_source_feeds(self, source_id: int) -> List[Dict[str, Any]]: + """Get all feeds for a specific source.""" + query = """ + SELECT id, feed_url, feed_type, is_active, created_at, last_crawled + FROM source_feeds + WHERE source_id = ? + ORDER BY feed_type + """ + feeds = await sources_db.execute_query(query, (source_id,), fetch=True) + for feed in feeds: + feed["description"] = feed.get("feed_type", "Main feed").capitalize() + return feeds + + async def get_categories(self) -> List[Dict[str, Any]]: + """Get all source categories.""" + query = """ + SELECT id, name + FROM categories + ORDER BY name + """ + categories = await sources_db.execute_query(query, fetch=True) + for category in categories: + category["description"] = f"Articles about {category.get('name', '')}" + return categories + + async def get_source_by_category(self, category_name: str) -> List[Dict[str, Any]]: + """Get sources by category using the junction table.""" + query = """ + SELECT s.id, s.name, s.url, s.description, s.is_active + FROM sources s + JOIN source_categories sc ON s.id = sc.source_id + JOIN categories c ON sc.category_id = c.id + WHERE c.name = ? AND s.is_active = 1 + ORDER BY s.name + """ + sources = await sources_db.execute_query(query, (category_name,), fetch=True) + for source in sources: + source["categories"] = await self.get_source_categories(source["id"]) + source["website"] = source["url"] + if source["categories"] and isinstance(source["categories"], list): + source["category"] = source["categories"][0] if source["categories"] else "" + return sources + + async def create_source(self, source_data: SourceCreate) -> Dict[str, Any]: + """Create a new source.""" + try: + source_query = """ + INSERT INTO sources (name, url, description, is_active, created_at) + VALUES (?, ?, ?, ?, ?) + """ + source_params = (source_data.name, source_data.url, source_data.description, source_data.is_active, datetime.now().isoformat()) + source_id = await sources_db.execute_query(source_query, source_params) + if source_data.categories: + for category_name in source_data.categories: + await self.add_source_category(source_id, category_name) + elif hasattr(source_data, "category") and source_data.category: + await self.add_source_category(source_id, source_data.category) + if source_data.feeds: + for feed in source_data.feeds: + await self.add_feed_to_source(source_id, feed) + return await self.get_source(source_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + if "UNIQUE constraint failed" in str(e) and "name" in str(e): + raise HTTPException(status_code=409, detail="Source with this name already exists") + raise HTTPException(status_code=500, detail=f"Error creating source: {str(e)}") + + async def add_source_category(self, source_id: int, category_name: str) -> None: + """Add a category to a source, creating the category if it doesn't exist.""" + category_query = """ + INSERT OR IGNORE INTO categories (name, created_at) + VALUES (?, ?) + """ + await sources_db.execute_query(category_query, (category_name, datetime.now().isoformat())) + get_category_id_query = "SELECT id FROM categories WHERE name = ?" + category = await sources_db.execute_query(get_category_id_query, (category_name,), fetch=True, fetch_one=True) + if not category: + raise HTTPException(status_code=500, detail=f"Failed to find or create category: {category_name}") + link_query = """ + INSERT OR IGNORE INTO source_categories (source_id, category_id) + VALUES (?, ?) + """ + await sources_db.execute_query(link_query, (source_id, category["id"])) + + async def update_source(self, source_id: int, source_data: SourceUpdate) -> Dict[str, Any]: + """Update an existing source.""" + try: + await self.get_source(source_id) + update_fields = [] + update_params = [] + if source_data.name is not None: + update_fields.append("name = ?") + update_params.append(source_data.name) + if source_data.url is not None: + update_fields.append("url = ?") + update_params.append(source_data.url) + if source_data.description is not None: + update_fields.append("description = ?") + update_params.append(source_data.description) + if source_data.is_active is not None: + update_fields.append("is_active = ?") + update_params.append(source_data.is_active) + if update_fields: + update_params.append(source_id) + update_query = f""" + UPDATE sources + SET {", ".join(update_fields)} + WHERE id = ? + """ + await sources_db.execute_query(update_query, tuple(update_params)) + if source_data.categories is not None: + delete_categories_query = "DELETE FROM source_categories WHERE source_id = ?" + await sources_db.execute_query(delete_categories_query, (source_id,)) + if source_data.categories: + for category_name in source_data.categories: + await self.add_source_category(source_id, category_name) + elif hasattr(source_data, "category") and source_data.category is not None: + delete_categories_query = "DELETE FROM source_categories WHERE source_id = ?" + await sources_db.execute_query(delete_categories_query, (source_id,)) + if source_data.category: + await self.add_source_category(source_id, source_data.category) + return await self.get_source(source_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + if "UNIQUE constraint failed" in str(e) and "name" in str(e): + raise HTTPException(status_code=409, detail="Source with this name already exists") + raise HTTPException(status_code=500, detail=f"Error updating source: {str(e)}") + + async def delete_source(self, source_id: int) -> Dict[str, Any]: + """Delete a source (soft delete by setting is_active to false).""" + try: + source = await self.get_source(source_id) + update_query = """ + UPDATE sources + SET is_active = 0 + WHERE id = ? + """ + await sources_db.execute_query(update_query, (source_id,)) + feeds_query = """ + UPDATE source_feeds + SET is_active = 0 + WHERE source_id = ? + """ + await sources_db.execute_query(feeds_query, (source_id,)) + return {**source, "is_active": False} + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error deleting source: {str(e)}") + + async def hard_delete_source(self, source_id: int) -> Dict[str, str]: + """Permanently delete a source and its feeds.""" + try: + source = await self.get_source(source_id) + delete_feeds_query = """ + DELETE FROM source_feeds + WHERE source_id = ? + """ + await sources_db.execute_query(delete_feeds_query, (source_id,)) + delete_categories_query = """ + DELETE FROM source_categories + WHERE source_id = ? + """ + await sources_db.execute_query(delete_categories_query, (source_id,)) + delete_source_query = """ + DELETE FROM sources + WHERE id = ? + """ + await sources_db.execute_query(delete_source_query, (source_id,)) + return {"message": f"Source '{source['name']}' has been permanently deleted"} + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error deleting source: {str(e)}") + + async def add_feed_to_source(self, source_id: int, feed_data: SourceFeedCreate) -> Dict[str, Any]: + """Add a new feed to an existing source.""" + try: + await self.get_source(source_id) + check_query = """ + SELECT id, source_id FROM source_feeds WHERE feed_url = ? + """ + existing_feed = await sources_db.execute_query(check_query, (feed_data.feed_url,), fetch=True, fetch_one=True) + if existing_feed: + source_query = """ + SELECT name FROM sources WHERE id = ? + """ + source = await sources_db.execute_query(source_query, (existing_feed["source_id"],), fetch=True, fetch_one=True) + source_name = source["name"] if source else "another source" + raise HTTPException( + status_code=409, + detail=f"A feed with this URL already exists for {source_name}. Please edit the existing feed (ID: {existing_feed['id']}) instead.", + ) + feed_query = """ + INSERT INTO source_feeds (source_id, feed_url, feed_type, is_active, created_at) + VALUES (?, ?, ?, ?, ?) + """ + feed_params = (source_id, feed_data.feed_url, feed_data.feed_type, feed_data.is_active, datetime.now().isoformat()) + await sources_db.execute_query(feed_query, feed_params) + return await self.get_source_feeds(source_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + if "UNIQUE constraint failed" in str(e) and "feed_url" in str(e): + raise HTTPException(status_code=409, detail="This feed URL already exists. Please check your existing feeds or try a different URL.") + raise HTTPException(status_code=500, detail=f"Error adding feed: {str(e)}") + + async def update_feed(self, feed_id: int, feed_data: Dict[str, Any]) -> Dict[str, Any]: + """Update an existing feed.""" + try: + feed_query = "SELECT id, source_id FROM source_feeds WHERE id = ?" + feed = await sources_db.execute_query(feed_query, (feed_id,), fetch=True, fetch_one=True) + if not feed: + raise HTTPException(status_code=404, detail="Feed not found") + update_fields = [] + update_params = [] + if "feed_url" in feed_data: + update_fields.append("feed_url = ?") + update_params.append(feed_data["feed_url"]) + if "feed_type" in feed_data: + update_fields.append("feed_type = ?") + update_params.append(feed_data["feed_type"]) + if "is_active" in feed_data: + update_fields.append("is_active = ?") + update_params.append(feed_data["is_active"]) + if not update_fields: + return await self.get_source_feeds(feed["source_id"]) + update_params.append(feed_id) + update_query = f""" + UPDATE source_feeds + SET {", ".join(update_fields)} + WHERE id = ? + """ + await sources_db.execute_query(update_query, tuple(update_params)) + return await self.get_source_feeds(feed["source_id"]) + except Exception as e: + if isinstance(e, HTTPException): + raise e + if "UNIQUE constraint failed" in str(e) and "feed_url" in str(e): + raise HTTPException(status_code=409, detail="Feed URL already exists") + raise HTTPException(status_code=500, detail=f"Error updating feed: {str(e)}") + + async def delete_feed(self, feed_id: int) -> Dict[str, str]: + """Delete a feed.""" + try: + feed_query = "SELECT * FROM source_feeds WHERE id = ?" + feed = await sources_db.execute_query(feed_query, (feed_id,), fetch=True, fetch_one=True) + if not feed: + raise HTTPException(status_code=404, detail="Feed not found") + delete_query = "DELETE FROM source_feeds WHERE id = ?" + await sources_db.execute_query(delete_query, (feed_id,)) + return {"message": "Feed has been deleted"} + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error deleting feed: {str(e)}") + + +source_service = SourceService() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/task_service.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/task_service.py new file mode 100644 index 0000000..471ac43 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/services/task_service.py @@ -0,0 +1,315 @@ +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +from fastapi import HTTPException +from services.db_service import tasks_db +from models.tasks_schemas import TASK_TYPES + + +class TaskService: + """Service for managing scheduled tasks.""" + + async def get_tasks(self, include_disabled: bool = False) -> List[Dict[str, Any]]: + """Get all tasks with optional filtering.""" + try: + if include_disabled: + query = """ + SELECT id, name, description, command, task_type, frequency, frequency_unit, + enabled, last_run, created_at + FROM tasks + ORDER BY name + """ + params = () + else: + query = """ + SELECT id, name, description, command, task_type, frequency, frequency_unit, + enabled, last_run, created_at + FROM tasks + WHERE enabled = 1 + ORDER BY name + """ + params = () + tasks = await tasks_db.execute_query(query, params, fetch=True) + for task in tasks: + task["enabled"] = bool(task.get("enabled", 0)) + return tasks + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching tasks: {str(e)}") + + async def get_task(self, task_id: int) -> Dict[str, Any]: + """Get a specific task by ID.""" + try: + query = """ + SELECT id, name, description, command, task_type, frequency, frequency_unit, + enabled, last_run, created_at + FROM tasks + WHERE id = ? + """ + task = await tasks_db.execute_query(query, (task_id,), fetch=True, fetch_one=True) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + task["enabled"] = bool(task.get("enabled", 0)) + return task + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching task: {str(e)}") + + async def check_task_exists(self, task_type: str) -> Optional[Dict[str, Any]]: + """Check if a task with the given type already exists.""" + try: + query = """ + SELECT id, name, task_type + FROM tasks + WHERE task_type = ? + LIMIT 1 + """ + task = await tasks_db.execute_query(query, (task_type,), fetch=True, fetch_one=True) + return task + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error checking task existence: {str(e)}") + + async def create_task( + self, + name: str, + task_type: str, + frequency: int, + frequency_unit: str, + description: Optional[str] = None, + enabled: bool = True, + ) -> Dict[str, Any]: + """Create a new task.""" + try: + existing_task = await self.check_task_exists(task_type) + if existing_task: + raise HTTPException( + status_code=409, + detail=f"A task with type '{task_type}' already exists (Task: '{existing_task['name']}', ID: {existing_task['id']}). Please edit the existing task instead of creating a duplicate.", + ) + if task_type not in TASK_TYPES: + raise HTTPException( + status_code=400, detail=f"Invalid task type: '{task_type}'. Please select a valid task type from the available options." + ) + command = TASK_TYPES[task_type]["command"] + current_time = datetime.now().isoformat() + query = """ + INSERT INTO tasks + (name, description, command, task_type, frequency, frequency_unit, enabled, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """ + params = ( + name, + description, + command, + task_type, + frequency, + frequency_unit, + 1 if enabled else 0, + current_time, + ) + task_id = await tasks_db.execute_query(query, params) + return await self.get_task(task_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error creating task: {str(e)}") + + async def update_task(self, task_id: int, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update an existing task.""" + try: + current_task = await self.get_task(task_id) + if "task_type" in updates and updates["task_type"] != current_task["task_type"]: + existing_task = await self.check_task_exists(updates["task_type"]) + if existing_task and existing_task["id"] != task_id: + raise HTTPException( + status_code=409, + detail=f"A task with type '{updates['task_type']}' already exists (Task: '{existing_task['name']}', ID: {existing_task['id']}). You cannot have duplicate task types in the system.", + ) + if updates["task_type"] in TASK_TYPES: + updates["command"] = TASK_TYPES[updates["task_type"]]["command"] + allowed_fields = [ + "name", + "description", + "command", + "task_type", + "frequency", + "frequency_unit", + "enabled", + ] + set_clauses = [] + params = [] + for field, value in updates.items(): + if field in allowed_fields: + if field == "enabled": + value = 1 if value else 0 + set_clauses.append(f"{field} = ?") + params.append(value) + if not set_clauses: + return await self.get_task(task_id) + params.append(task_id) + update_query = f""" + UPDATE tasks + SET {", ".join(set_clauses)} + WHERE id = ? + """ + await tasks_db.execute_query(update_query, tuple(params)) + return await self.get_task(task_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error updating task: {str(e)}") + + async def delete_task(self, task_id: int) -> Dict[str, str]: + """Delete a task.""" + try: + task = await self.get_task(task_id) + query = """ + DELETE FROM tasks + WHERE id = ? + """ + await tasks_db.execute_query(query, (task_id,)) + return {"message": f"Task '{task['name']}' has been deleted"} + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error deleting task: {str(e)}") + + async def toggle_task(self, task_id: int, enable: bool) -> Dict[str, Any]: + """Enable or disable a task.""" + try: + query = """ + UPDATE tasks + SET enabled = ? + WHERE id = ? + """ + await tasks_db.execute_query(query, (1 if enable else 0, task_id)) + return await self.get_task(task_id) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error updating task: {str(e)}") + + async def get_task_executions(self, task_id: Optional[int] = None, page: int = 1, per_page: int = 10) -> Dict[str, Any]: + """Get paginated task executions.""" + try: + offset = (page - 1) * per_page + if task_id: + count_query = """ + SELECT COUNT(*) as count + FROM task_executions + WHERE task_id = ? + """ + count_params = (task_id,) + query = """ + SELECT id, task_id, start_time, end_time, status, error_message, output + FROM task_executions + WHERE task_id = ? + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """ + params = (task_id, per_page, offset) + else: + count_query = """ + SELECT COUNT(*) as count + FROM task_executions + """ + count_params = () + query = """ + SELECT id, task_id, start_time, end_time, status, error_message, output + FROM task_executions + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """ + params = (per_page, offset) + count_result = await tasks_db.execute_query(count_query, count_params, fetch=True, fetch_one=True) + total_items = count_result.get("count", 0) if count_result else 0 + executions = await tasks_db.execute_query(query, params, fetch=True) + for execution in executions: + if execution.get("task_id"): + try: + task = await self.get_task(execution["task_id"]) + execution["task_name"] = task.get("name", "Unknown Task") + except Exception as _: + execution["task_name"] = "Unknown Task" + total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 0 + has_next = page < total_pages + has_prev = page > 1 + return { + "items": executions, + "total": total_items, + "page": page, + "per_page": per_page, + "total_pages": total_pages, + "has_next": has_next, + "has_prev": has_prev, + } + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching task executions: {str(e)}") + + async def get_pending_tasks(self) -> List[Dict[str, Any]]: + """Get tasks that are due to run.""" + try: + query = """ + SELECT id, name, description, command, task_type, frequency, frequency_unit, enabled, last_run + FROM tasks + WHERE enabled = 1 + AND ( + last_run IS NULL + OR + CASE frequency_unit + WHEN 'minutes' THEN datetime(last_run, '+' || frequency || ' minutes') <= datetime('now', 'localtime') + WHEN 'hours' THEN datetime(last_run, '+' || frequency || ' hours') <= datetime('now', 'localtime') + WHEN 'days' THEN datetime(last_run, '+' || frequency || ' days') <= datetime('now', 'localtime') + ELSE datetime(last_run, '+' || frequency || ' seconds') <= datetime('now', 'localtime') + END + ) + ORDER BY last_run + """ + tasks = await tasks_db.execute_query(query, fetch=True) + for task in tasks: + task["enabled"] = bool(task.get("enabled", 0)) + return tasks + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching pending tasks: {str(e)}") + + async def get_stats(self) -> Dict[str, Any]: + """Get task statistics.""" + try: + task_query = """ + SELECT + COUNT(*) as total_tasks, + SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) as active_tasks, + SUM(CASE WHEN enabled = 0 THEN 1 ELSE 0 END) as disabled_tasks, + SUM(CASE WHEN last_run IS NULL THEN 1 ELSE 0 END) as never_run_tasks + FROM tasks + """ + task_stats = await tasks_db.execute_query(task_query, fetch=True, fetch_one=True) + cutoff_date = (datetime.now() - timedelta(days=7)).isoformat() + exec_query = """ + SELECT + COUNT(*) as total_executions, + COALESCE(SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END), 0) as successful_executions, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) as failed_executions, + COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0) as running_executions, + COALESCE(AVG(CASE WHEN end_time IS NOT NULL + THEN (julianday(end_time) - julianday(start_time)) * 86400.0 + ELSE NULL END), 0) as avg_execution_time_seconds + FROM task_executions + WHERE start_time >= ? + """ + exec_stats = await tasks_db.execute_query(exec_query, (cutoff_date,), fetch=True, fetch_one=True) + return {"tasks": task_stats or {}, "executions": exec_stats or {}} + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail=f"Error fetching task statistics: {str(e)}") + + +task_service = TaskService() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/static/images/ponyo.png b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/static/images/ponyo.png new file mode 100644 index 0000000..f2f2d51 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/static/images/ponyo.png differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/static/musics/intro_audio.mp3 b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/static/musics/intro_audio.mp3 new file mode 100644 index 0000000..42be2a2 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/static/musics/intro_audio.mp3 differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/agent_agno_test.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/agent_agno_test.py new file mode 100644 index 0000000..bd2f694 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/agent_agno_test.py @@ -0,0 +1,12 @@ +from typing import Iterator +from agno.agent import Agent, RunResponse +from agno.models.openai import OpenAIChat +from agno.utils.pprint import pprint_run_response +from dotenv import load_dotenv + +load_dotenv() +agent = Agent(model=OpenAIChat(id="gpt-4o-mini")) +response: RunResponse = agent.run("Tell me a 5 second short story about a robot") +response_stream: Iterator[RunResponse] = agent.run("Tell me a 5 second short story about a lion", stream=True) +pprint_run_response(response, markdown=True) +pprint_run_response(response_stream, markdown=True) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/embedding_search_test.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/embedding_search_test.py new file mode 100644 index 0000000..cbe51a9 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/embedding_search_test.py @@ -0,0 +1,194 @@ +import argparse +import os +import numpy as np +import faiss +from openai import OpenAI +from db.config import get_tracking_db_path +from db.connection import execute_query +from utils.load_api_keys import load_api_key +from db.config import get_faiss_db_path + +EMBEDDING_MODEL = "text-embedding-3-small" +FAISS_INDEX_PATH, FAIS_MAPPING_PATH = get_faiss_db_path() + + +def generate_query_embedding(client, query_text, model=EMBEDDING_MODEL): + try: + response = client.embeddings.create(input=query_text, model=model) + return response.data[0].embedding, model + except Exception as e: + print(f"Error generating query embedding: {str(e)}") + return None, None + + +def load_faiss_index(index_path=FAISS_INDEX_PATH): + if not os.path.exists(index_path): + raise FileNotFoundError(f"FAISS index not found at {index_path}") + return faiss.read_index(index_path) + + +def load_id_mapping(mapping_path=FAIS_MAPPING_PATH): + if not os.path.exists(mapping_path): + raise FileNotFoundError(f"ID mapping not found at {mapping_path}") + return np.load(mapping_path).tolist() + + +def get_article_details(tracking_db_path, article_ids): + if not article_ids: + return [] + placeholders = ",".join(["?"] * len(article_ids)) + query = f""" + SELECT id, title, url, published_date, summary + FROM crawled_articles + WHERE id IN ({placeholders}) + """ + return execute_query(tracking_db_path, query, article_ids, fetch=True) + + +def search_articles( + query_text, + tracking_db_path=None, + openai_api_key=None, + index_path="databases/faiss/article_index.faiss", + mapping_path="databases/faiss/article_id_map.npy", + top_k=5, + search_params=None, +): + if tracking_db_path is None: + tracking_db_path = get_tracking_db_path() + if openai_api_key is None: + openai_api_key = load_api_key() + if not openai_api_key: + raise ValueError("OpenAI API key is required") + client = OpenAI(api_key=openai_api_key) + query_embedding, _ = generate_query_embedding(client, query_text) + if not query_embedding: + raise ValueError("Failed to generate query embedding") + query_vector = np.array([query_embedding]).astype(np.float32) + try: + faiss_index = load_faiss_index(index_path) + id_map = load_id_mapping(mapping_path) + if search_params: + if isinstance(faiss_index, faiss.IndexIVF) and "nprobe" in search_params: + faiss_index.nprobe = search_params["nprobe"] + print(f"Set nprobe to {faiss_index.nprobe}") + if hasattr(faiss_index, "hnsw") and "ef" in search_params: + faiss_index.hnsw.efSearch = search_params["ef"] + print(f"Set efSearch to {faiss_index.hnsw.efSearch}") + index_type = "unknown" + if isinstance(faiss_index, faiss.IndexFlatL2): + index_type = "flat" + elif isinstance(faiss_index, faiss.IndexIVFFlat): + index_type = "ivfflat" + print(f"Using IVF index with nprobe = {faiss_index.nprobe}") + elif isinstance(faiss_index, faiss.IndexIVFPQ): + index_type = "ivfpq" + print(f"Using IVF-PQ index with nprobe = {faiss_index.nprobe}") + elif hasattr(faiss_index, "hnsw"): + index_type = "hnsw" + print(f"Using HNSW index with efSearch = {faiss_index.hnsw.efSearch}") + print(f"Searching {index_type} FAISS index with {len(id_map)} articles...") + distances, indices = faiss_index.search(query_vector, top_k) + result_article_ids = [id_map[idx] for idx in indices[0] if idx < len(id_map)] + results = get_article_details(tracking_db_path, result_article_ids) + for i, result in enumerate(results): + distance = float(distances[0][i]) + similarity = float(np.exp(-distance)) + result["distance"] = distance + result["similarity"] = similarity + result["score"] = similarity + return results + except Exception as e: + print(f"Error during search: {str(e)}") + import traceback + + traceback.print_exc() + return [] + + +def print_search_results(results): + if not results: + print("No results found.") + return + print(f"\nFound {len(results)} results:\n") + for i, result in enumerate(results): + similarity_pct = result.get("similarity", 0) * 100 + print(f"{i + 1}. {result['title']}") + print(f" Relevance: {similarity_pct:.1f}%") + if "distance" in result: + print(f" Vector distance: {result['distance']:.4f}") + print(f" Published: {result['published_date']}") + print(f" URL: {result['url']}") + if len(result["summary"]) > 150: + print(f" Summary: {result['summary'][:150]}...") + else: + print(f" Summary: {result['summary']}") + print() + + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Search for articles using FAISS") + parser.add_argument( + "query", + help="Search query text", + ) + parser.add_argument( + "--api_key", + help="OpenAI API Key (overrides environment variables)", + ) + parser.add_argument( + "--top_k", + type=int, + default=5, + help="Number of results to return", + ) + parser.add_argument( + "--nprobe", + type=int, + help="Number of clusters to search (for IVF indexes)", + ) + parser.add_argument( + "--ef", + type=int, + help="Search depth (for HNSW indexes)", + ) + parser.add_argument( + "--index_path", + default=FAISS_INDEX_PATH, + help="Path to the FAISS index file", + ) + parser.add_argument( + "--mapping_path", + default=FAIS_MAPPING_PATH, + help="Path to the ID mapping file", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_arguments() + api_key = args.api_key or load_api_key() + if not api_key: + print("Error: No OpenAI API key provided. Please provide via --api_key or set OPENAI_API_KEY in .env file") + exit(1) + search_params = {} + if args.nprobe: + search_params["nprobe"] = args.nprobe + if args.ef: + search_params["ef"] = args.ef + try: + results = search_articles( + query_text=args.query, + openai_api_key=api_key, + top_k=args.top_k, + index_path=args.index_path, + mapping_path=args.mapping_path, + search_params=search_params, + ) + print_search_results(results) + except Exception as e: + print(f"Error: {str(e)}") + import traceback + + traceback.print_exc() + exit(1) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/english_sample_0.wav b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/english_sample_0.wav new file mode 100644 index 0000000..1fab195 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/english_sample_0.wav differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/hindi_sample_0.wav b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/hindi_sample_0.wav new file mode 100644 index 0000000..822f93e Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/hindi_sample_0.wav differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/index_faiss_test.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/index_faiss_test.py new file mode 100644 index 0000000..8d6901c --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/index_faiss_test.py @@ -0,0 +1,25 @@ +import numpy as np +import faiss +import time + + +dimension = 128 +nb_vectors = 10000 +np.random.seed(42) +database = np.random.random((nb_vectors, dimension)).astype("float32") +query = np.random.random((1, dimension)).astype("float32") + +start_time = time.time() +index = faiss.IndexFlatL2(dimension) +index.add(database) +print(f"Index built in {time.time() - start_time:.4f} seconds") +print(f"Index contains {index.ntotal} vectors") + +k = 5 +start_time = time.time() +distances, indices = index.search(query, k) +print(f"Search completed in {time.time() - start_time:.4f} seconds") +print("\nSearch Results:") +print("Query vector finds these nearest neighbors (indices):", indices[0]) +print("With these distances:", distances[0]) +print("\nFAISS is working correctly!") diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/tool_browseruse_test.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/tool_browseruse_test.py new file mode 100644 index 0000000..cac0739 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/tool_browseruse_test.py @@ -0,0 +1,20 @@ +from langchain_openai import ChatOpenAI +from browser_use import Agent +from dotenv import load_dotenv +import asyncio + + +load_dotenv() +llm = ChatOpenAI(model="gpt-4o") + + +async def main(): + agent = Agent( + task="Compare the price of gpt-4o and DeepSeek-V3", + llm=llm, + ) + result = await agent.run() + print(result) + + +asyncio.run(main()) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/tts_kokoro_test.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/tts_kokoro_test.py new file mode 100644 index 0000000..3d3ba25 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tests/tts_kokoro_test.py @@ -0,0 +1,60 @@ +import os +import soundfile as sf +import platform +import time +import warnings + + +os.environ["PYTHONWARNINGS"] = "ignore" +os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR" +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" +warnings.filterwarnings("ignore") + +from kokoro import KPipeline + + +def play_audio(file_path): + system = platform.system() + try: + if system == "Darwin": + os.system(f"afplay {file_path}") + elif system == "Linux": + os.system(f"aplay {file_path}") + elif system == "Windows": + os.system(f'start "" "{file_path}"') + else: + print(f"Audio saved to {file_path} (auto-play not supported on this system)") + except Exception as e: + print(f"Failed to auto-play audio: {e}") + print(f"Audio saved to {file_path}") + + +print("Testing Kokoro TTS in English and Hindi...") + +print("\n=== Testing English ===") +pipeline_en = KPipeline(lang_code="a") +english_text = "This is a test of the Kokoro text-to-speech system in English." +generator_en = pipeline_en(english_text, voice="af_heart") + +for i, (gs, ps, audio) in enumerate(generator_en): + output_file = f"english_sample_{i}.wav" + sf.write(output_file, audio, 24000) + print(f"Generated English audio: {output_file}") + print(f"Text: {gs}") + play_audio(output_file) + time.sleep(2) + +print("\n=== Testing Hindi ===") +pipeline_hi = KPipeline(lang_code="h") +hindi_text = "यह हिंदी में कोकोरो टेक्स्ट-टू-स्पीच सिस्टम का एक परीक्षण है।" +generator_hi = pipeline_hi(hindi_text, voice="af_heart") + +for i, (gs, ps, audio) in enumerate(generator_hi): + output_file = f"hindi_sample_{i}.wav" + sf.write(output_file, audio, 24000) + print(f"Generated Hindi audio: {output_file}") + print(f"Text: {gs}") + play_audio(output_file) + time.sleep(2) + +print("\nTest completed. Audio files have been generated and should have auto-played.") diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/browser_crawler.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/browser_crawler.py new file mode 100644 index 0000000..02126f6 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/browser_crawler.py @@ -0,0 +1,146 @@ +from playwright.sync_api import sync_playwright +import newspaper +import time +from typing import Dict, List +from datetime import datetime + + +class PlaywrightScraper: + def __init__( + self, + headless: bool = True, + timeout: int = 20000, + fresh_context_per_url: bool = False, + ): + self.headless = headless + self.timeout = timeout + self.fresh_context_per_url = fresh_context_per_url + + def scrape_urls(self, urls: List[str]) -> List[Dict]: + with sync_playwright() as playwright: + browser = playwright.chromium.launch( + headless=self.headless, + args=["--no-sandbox", "--disable-setuid-sandbox"], + ) + if self.fresh_context_per_url: + results = [] + for url in urls: + result = self._scrape_single_with_new_context(browser, url) + results.append(result) + else: + results = self._scrape_with_reused_page(browser, urls) + browser.close() + return results + + def _scrape_with_reused_page(self, browser, urls: List[str]) -> List[Dict]: + context = browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + viewport={"width": 1920, "height": 1080}, + ) + page = context.new_page() + page.set_extra_http_headers( + { + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + } + ) + results = [] + try: + for i, url in enumerate(urls): + print(f"Scraping {i+1}/{len(urls)}") + result = self._scrape_single_url(page, url) + results.append(result) + if i < len(urls) - 1: + time.sleep(1) + finally: + context.close() + return results + + def _scrape_single_url(self, page, url: str) -> Dict: + max_retries = 0 + for attempt in range(max_retries + 1): + try: + page.goto(url, wait_until="load", timeout=self.timeout) + page.wait_for_selector("body", timeout=5000) + page.wait_for_timeout(2000) + final_url = page.url + return self._parse_with_newspaper(url, final_url) + except Exception as e: + if attempt < max_retries: + print(f"Retry {attempt + 1} for {url}") + time.sleep(2**attempt) + continue + else: + return { + "originalUrl": url, + "error": str(e), + "success": False, + "timestamp": datetime.now().isoformat(), + } + + def _scrape_single_with_new_context(self, browser, url: str) -> Dict: + max_retries = 0 + for attempt in range(max_retries + 1): + context = None + try: + context = browser.new_context( + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + viewport={"width": 1920, "height": 1080}, + ) + page = context.new_page() + page.set_extra_http_headers( + { + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + } + ) + page.goto(url, wait_until="load", timeout=self.timeout) + page.wait_for_selector("body", timeout=5000) + page.wait_for_timeout(2000) + final_url = page.url + return self._parse_with_newspaper(url, final_url) + except Exception as e: + if attempt < max_retries: + time.sleep(2**attempt) + continue + else: + return { + "originalUrl": url, + "error": str(e), + "success": False, + "timestamp": datetime.now().isoformat(), + } + finally: + if context: + context.close() + + def _parse_with_newspaper(self, original_url: str, final_url: str) -> Dict: + try: + article = newspaper.article(final_url) + return { + "original_url": original_url, + "final_url": final_url, + "title": article.title or "", + "authors": article.authors or [], + "published_date": article.publish_date.isoformat() if article.publish_date else None, + "full_text": article.text or "", + "success": True, + } + except Exception as e: + return { + "original_url": original_url, + "final_url": final_url, + "error": f"Newspaper4k parsing failed: {str(e)}", + "success": False, + } + + +def create_browser_crawler(headless=True, timeout=20000, fresh_context_per_url=False): + """Factory function to create a new PlaywrightScraper instance.""" + return PlaywrightScraper( + headless=headless, + timeout=timeout, + fresh_context_per_url=fresh_context_per_url + ) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/embedding_search.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/embedding_search.py new file mode 100644 index 0000000..d7333ef --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/embedding_search.py @@ -0,0 +1,153 @@ +from agno.agent import Agent +import os +import numpy as np +import faiss +from openai import OpenAI +from db.config import get_tracking_db_path, get_faiss_db_path, get_sources_db_path +from db.connection import execute_query +from utils.load_api_keys import load_api_key +import traceback +import json + +EMBEDDING_MODEL = "text-embedding-3-small" + + +def generate_query_embedding(query_text, model=EMBEDDING_MODEL): + try: + api_key = load_api_key("OPENAI_API_KEY") + if not api_key: + return None, "OpenAI API key not found" + client = OpenAI(api_key=api_key) + response = client.embeddings.create(input=query_text, model=model) + return response.data[0].embedding, None + except Exception as e: + return None, str(e) + + +def load_faiss_index(index_path): + if not os.path.exists(index_path): + return None, f"FAISS index not found at {index_path}" + try: + return faiss.read_index(index_path), None + except Exception as e: + return None, f"Error loading FAISS index: {str(e)}" + + +def load_id_mapping(mapping_path): + if not os.path.exists(mapping_path): + return None, f"ID mapping not found at {mapping_path}" + try: + return np.load(mapping_path).tolist(), None + except Exception as e: + return None, f"Error loading ID mapping: {str(e)}" + + +def get_article_details(tracking_db_path, article_ids): + if not article_ids: + return [] + placeholders = ",".join(["?"] * len(article_ids)) + query = f""" + SELECT id, title, url, published_date, summary, source_id, feed_id, content + FROM crawled_articles + WHERE id IN ({placeholders}) + """ + return execute_query(tracking_db_path, query, article_ids, fetch=True) + + +def get_source_names(source_ids): + if not source_ids: + return {} + unique_ids = list(set([src_id for src_id in source_ids if src_id])) + if not unique_ids: + return {} + try: + sources_db_path = get_sources_db_path() + check_query = """ + SELECT name FROM sqlite_master + WHERE type='table' AND name='sources' + """ + table_exists = execute_query(sources_db_path, check_query, fetch=True) + if not table_exists: + return {} + placeholders = ",".join(["?"] * len(unique_ids)) + query = f""" + SELECT id, name FROM sources + WHERE id IN ({placeholders}) + """ + results = execute_query(sources_db_path, query, unique_ids, fetch=True) + return {str(row["id"]): row["name"] for row in results} if results else {} + except Exception as _: + return {} + + +def embedding_search(agent: Agent, prompt: str) -> str: + """ + Perform a semantic search using embeddings to find articles related to the query on internal articles databse which are crawled from preselected user rss feeds. + This search uses vector representations to find semantically similar content, + filtering for only high-quality matches (similarity score ≥ 85%). + + Args: + agent: The Agno agent instance + prompt: The search query + + Returns: + Search results + """ + print("Embedding Search Input:", prompt) + tracking_db_path = get_tracking_db_path() + index_path, mapping_path = get_faiss_db_path() + top_k = 20 + similarity_threshold = 0.85 + if not os.path.exists(index_path) or not os.path.exists(mapping_path): + return "Embedding search not available: index files not found. Continuing with other search methods." + query_embedding, error = generate_query_embedding(prompt) + if not query_embedding: + return f"Semantic search unavailable: {error}. Continuing with other search methods." + query_vector = np.array([query_embedding]).astype(np.float32) + try: + faiss_index, error = load_faiss_index(index_path) + if error: + return f"Semantic search unavailable: {error}. Continuing with other search methods." + id_map, error = load_id_mapping(mapping_path) + if error: + return f"Semantic search unavailable: {error}. Continuing with other search methods." + distances, indices = faiss_index.search(query_vector, top_k) + results_with_metrics = [] + for i, idx in enumerate(indices[0]): + if idx >= 0 and idx < len(id_map): + distance = float(distances[0][i]) + similarity = float(np.exp(-distance)) if distance > 0 else 0 + if similarity >= similarity_threshold: + article_id = id_map[idx] + results_with_metrics.append((idx, distance, similarity, article_id)) + results_with_metrics.sort(key=lambda x: x[2], reverse=True) + result_article_ids = [item[3] for item in results_with_metrics] + if not result_article_ids: + return "No high-quality semantic matches found (threshold: 85%). Continuing with other search methods." + results = get_article_details(tracking_db_path, result_article_ids) + source_ids = [result.get("source_id") for result in results if result.get("source_id")] + source_names = get_source_names(source_ids) + formatted_results = [] + for i, result in enumerate(results): + article_id = result.get("id") + similarity = next((item[2] for item in results_with_metrics if item[3] == article_id), 0) + similarity_percent = int(similarity * 100) + source_id = str(result.get("source_id", "unknown")) + source_name = source_names.get(source_id, source_id) + formatted_result = { + "id": article_id, + "title": f"{result.get('title', 'Untitled')} (Relevance: {similarity_percent}%)", + "url": result.get("url", "#"), + "published_date": result.get("published_date"), + "description": result.get("summary", result.get("content", "")), + "source_id": source_id, + "source_name": source_name, + "similarity": similarity, + "categories": ["semantic"], + "is_scrapping_required": False, + } + formatted_results.append(formatted_result) + return f"Found {len(formatted_results)}, results: {json.dumps(formatted_results, indent=2)}" + except Exception as e: + traceback.print_exc() + return f"Error in semantic search: {str(e)}. Continuing with other search methods." \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/google_news_discovery.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/google_news_discovery.py new file mode 100644 index 0000000..5c9eb43 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/google_news_discovery.py @@ -0,0 +1,53 @@ + +def search_news(google_news, keyword): + resutls = google_news.get_news(keyword) + return resutls + + +def get_top_news(google_news): + resutls = google_news.get_top_news() + return resutls + + +def get_news_by_topic(google_news, topic): + resutls = google_news.get_news_by_topic(topic) + return resutls + + +def google_news_discovery_run( + keyword: str = None, + max_results: int = 5, + top_news: bool = False, +) -> str: + from gnews import GNews + import json + + """ + This is a wrapper function for the google news. + + Args: + keyword: The search query for specific news + top_news: Whether to get top news instead of keyword search (default: False) + max_results: The maximum number of results to return (default: 20) + + Returns: + List of news results + + Note: + Either set top_news=True for top headlines or provide a keyword for search. + If both are provided, top_news takes precedence. + """ + print("Google News Discovery:", keyword) + google_news = GNews( + language=None, + country=None, + period=None, + max_results=max_results, + exclude_websites=[], + ) + if top_news: + results = get_top_news(google_news) + if keyword: + results = search_news(google_news, keyword) + print('google news search found:', len(results)) + return f"for all results is_scrapping_required: True, results: {json.dumps(results)}" diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/jikan_search.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/jikan_search.py new file mode 100644 index 0000000..4121b76 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/jikan_search.py @@ -0,0 +1,199 @@ +from agno.agent import Agent +import requests +import time +from typing import List, Dict, Any, Optional +import html +import json + + +def jikan_search(agent: Agent, query: str) -> str: + """ + Search for anime information using the Jikan API (MyAnimeList API). + This provides anime data, reviews, and recommendations to enhance podcast content. + + Jikan scrapes public MyAnimeList pages. + The service consists of two core parts + + Args: + agent: The agent instance + query: The search query + + Returns: + Search results + """ + print("Jikan Search:", query) + + try: + formatted_query = query.replace(" ", "%20") + anime_results = _search_anime(formatted_query) + if not anime_results: + return "No relevant anime found for this topic. Continuing with other search methods." + results = [] + for anime in anime_results[:5]: + anime_id = anime.get("mal_id") + if not anime_id: + continue + anime_details = _get_anime_details(anime_id) + if anime_details: + results.append(anime_details) + time.sleep(0.5) + if not results: + return "No detailed anime information could be retrieved. Continuing with other search methods." + return f"Found {len(results)} anime titles related to your topic. results {json.dumps(results, indent=2)}." + except Exception as e: + return f"Error in anime search: {str(e)}. Continuing with other search methods." + + +def _search_anime(query: str) -> List[Dict[str, Any]]: + try: + search_url = f"https://api.jikan.moe/v4/anime?q={query}&sfw=true&order_by=popularity&sort=asc&limit=10" + response = requests.get(search_url) + if response.status_code == 429: + time.sleep(0.5) + response = requests.get(search_url) + if response.status_code != 200: + return [] + data = response.json() + if "data" not in data: + return [] + return data["data"] + except Exception as _: + return [] + + +def _get_anime_details(anime_id: int) -> Optional[Dict[str, Any]]: + try: + details_url = f"https://api.jikan.moe/v4/anime/{anime_id}/full" + details_response = requests.get(details_url) + if details_response.status_code == 429: + time.sleep(0.5) + details_response = requests.get(details_url) + if details_response.status_code != 200: + return None + details_data = details_response.json() + if "data" not in details_data: + return None + anime = details_data["data"] + return _format_anime_info(anime) + except Exception as _: + return None + + +def _get_anime_recommendations(anime_id: int) -> List[Dict[str, Any]]: + try: + recs_url = f"https://api.jikan.moe/v4/anime/{anime_id}/recommendations" + recs_response = requests.get(recs_url) + if recs_response.status_code == 429: + time.sleep(0.5) + recs_response = requests.get(recs_url) + if recs_response.status_code != 200: + return [] + recs_data = recs_response.json() + if "data" not in recs_data: + return [] + + recommendations = [] + for rec in recs_data["data"][:5]: + if "entry" in rec: + title = rec["entry"].get("title", "") + if title: + recommendations.append(title) + return recommendations + except Exception as _: + return [] + + +def _format_anime_info(anime: Dict[str, Any]) -> Dict[str, Any]: + try: + mal_id = anime.get("mal_id") + title = anime.get("title", "Unknown Anime") + title_english = anime.get("title_english") + if title_english and title_english != title: + title_display = f"{title} ({title_english})" + else: + title_display = title + + url = anime.get("url", f"https://myanimelist.net/anime/{mal_id}") + synopsis = anime.get("synopsis", "No synopsis available.") + synopsis = html.unescape(synopsis) + episodes = anime.get("episodes", "Unknown") + status = anime.get("status", "Unknown") + aired_string = anime.get("aired", {}).get("string", "Unknown") + score = anime.get("score", "N/A") + scored_by = anime.get("scored_by", 0) + rank = anime.get("rank", "N/A") + popularity = anime.get("popularity", "N/A") + studios = [] + for studio in anime.get("studios", []): + if "name" in studio: + studios.append(studio["name"]) + studio_text = ", ".join(studios) if studios else "Unknown" + genres = [] + for genre in anime.get("genres", []): + if "name" in genre: + genres.append(genre["name"]) + genre_text = ", ".join(genres) if genres else "Unknown" + themes = [] + for theme in anime.get("themes", []): + if "name" in theme: + themes.append(theme["name"]) + demographics = [] + for demo in anime.get("demographics", []): + if "name" in demo: + demographics.append(demo["name"]) + content = f"Title: {title_display}\n" + content += f"Score: {score} (rated by {scored_by:,} users)\n" + content += f"Rank: {rank}, Popularity: {popularity}\n" + content += f"Episodes: {episodes}\n" + content += f"Status: {status}\n" + content += f"Aired: {aired_string}\n" + content += f"Studio: {studio_text}\n" + content += f"Genres: {genre_text}\n" + if themes: + content += f"Themes: {', '.join(themes)}\n" + if demographics: + content += f"Demographics: {', '.join(demographics)}\n" + content += f"\nSynopsis:\n{synopsis}\n" + if mal_id: + recommendations = _get_anime_recommendations(mal_id) + if recommendations: + content += f"\nSimilar Anime: {', '.join(recommendations)}\n" + summary = f"{title_display} - {genre_text} anime with {episodes} episodes. " + summary += f"Rating: {score}/10. " + if synopsis: + short_synopsis = synopsis[:150] + "..." if len(synopsis) > 150 else synopsis + summary += short_synopsis + categories = ["anime", "japanese animation", "entertainment"] + if genres: + categories.extend(genres[:5]) + if themes: + categories.extend(themes[:2]) + return { + "id": f"jikan_{mal_id}", + "title": f"{title_display} (Anime)", + "url": url, + "published_date": aired_string.split(" to ")[0] if " to " in aired_string else aired_string, + "description": content, + "source_id": "jikan", + "source_name": "MyAnimeList", + "categories": categories, + "is_scrapping_required": False, + + } + except Exception as _: + return { + "id": f"jikan_{anime.get('mal_id', 'unknown')}", + "title": f"{anime.get('title', 'Unknown Anime')} (Anime)", + "url": anime.get("url", "https://myanimelist.net"), + "published_date": None, + "description": anime.get("synopsis", "No information available."), + "source_id": "jikan", + "source_name": "MyAnimeList", + "categories": ["anime", "japanese animation", "entertainment"], + "is_scrapping_required": False, + } + + +if __name__ == "__main__": + print(jikan_search({}, "One Piece anime overview and details")) + \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/image_generate_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/image_generate_agent.py new file mode 100644 index 0000000..b6e3bd9 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/image_generate_agent.py @@ -0,0 +1,81 @@ +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from agno.tools.dalle import DalleTools +from textwrap import dedent +import json +from dotenv import load_dotenv +import uuid +from db.agent_config_v2 import PODCAST_IMG_DIR +import os +import requests +from typing import Any + + +load_dotenv() + +IMAGE_GENERATION_AGENT_DESCRIPTION = "You are an AI agent that can generate images using DALL-E." +IMAGE_GENERATION_AGENT_INSTRUCTIONS = dedent(""" + When the user asks you to create an image, use the `create_image` tool to create the image. + Create a modern, eye-catching podcast cover images that represents a podcast given podcast topic. + Create 3 images for the given podcast topic. + + IMPORTANT INSTRUCTIONS: + - DO NOT include ANY text in the image + - DO NOT include any words, titles, or lettering + - Create a purely visual and symbolic representation + - Use imagery that represents the specific topics mentioned + - I like Studio Ghibli flavor if possible + - The image should work well as a podcast cover thumbnail + - Create a clean, professional design suitable for a podcast + - AGAIN, DO NOT INCLUDE ANY TEXT + """) + + +def download_images(image_urls): + local_image_filenames = [] + try: + if image_urls: + for image_url in image_urls: + unique_id = str(uuid.uuid4()) + filename = f"podcast_banner_{unique_id}.png" + os.makedirs(PODCAST_IMG_DIR, exist_ok=True) + print(f"Downloading image: {filename}") + response = requests.get(image_url, timeout=30) + response.raise_for_status() + image_path = os.path.join(PODCAST_IMG_DIR, filename) + with open(image_path, "wb") as f: + f.write(response.content) + local_image_filenames.append(filename) + print(f"Successfully downloaded: {filename}") + except requests.exceptions.RequestException as e: + print(f"Error downloading images (network): {e}") + except Exception as e: + print(f"Error downloading images: {e}") + + return local_image_filenames + + +def image_generation_agent_run(query: str, generated_script: Any) -> str: + session_id = str(uuid.uuid4()) + try: + image_agent = Agent( + model=OpenAIChat(id="gpt-4o"), + tools=[DalleTools()], + description=IMAGE_GENERATION_AGENT_DESCRIPTION, + instructions=IMAGE_GENERATION_AGENT_INSTRUCTIONS, + markdown=True, + show_tool_calls=True, + session_id=session_id, + ) + image_agent.run(f"query: {query},\n podcast script: {json.dumps(generated_script)}", session_id=session_id) + images = image_agent.get_images() + image_urls = [] + if images and isinstance(images, list): + for image_response in images: + image_url = image_response.url + image_urls.append(image_url) + local_image_filenames = download_images(image_urls) + return {"banner_images": local_image_filenames, "banner_url": local_image_filenames[0] if local_image_filenames else None} + except Exception as e: + print(f"Error in Image Generation Agent: {e}") + return {} diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/scrape_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/scrape_agent.py new file mode 100644 index 0000000..6c214c0 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/scrape_agent.py @@ -0,0 +1,136 @@ +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from pydantic import BaseModel, Field +from dotenv import load_dotenv +from tools.browser_crawler import create_browser_crawler +from textwrap import dedent +import uuid + + +load_dotenv() + + +class ScrapedContent(BaseModel): + url: str = Field(..., description="The URL of the search result") + description: str = Field(description="The description of the search result") + full_text: str = Field( + ..., + description="The full text of the given source URL, if not available or not applicable keep it empty", + ) + published_date: str = Field( + ..., + description="The published date of the content in ISO format, if not available keep it empty", + ) + + +SCRAPE_AGENT_DESCRIPTION = "You are a helpful assistant that can scrape the URL for full content." +SCRAPE_AGENT_INSTRUCTIONS = dedent(""" + You are a content verification and formatting assistant. + + You will receive a batch of pre-scraped content from various URLs along with a search query. + Your job is to: + + 1. VERIFY RELEVANCE: Ensure each piece of content is relevant to the given query + 2. QUALITY CONTROL: Filter out low-quality, duplicate, or irrelevant content + 3. FORMAT CONSISTENCY: Ensure all content follows a consistent format + 4. LENGTH OPTIMIZATION: Keep content at reasonable length - not too long, not too short + 5. CLEAN TEXT: Remove any formatting artifacts, ads, or navigation elements from scraped content + + For each piece of content, return: + - full_text: The cleaned, relevant text content (or empty if not relevant/low quality) + - published_date: The publication date in ISO format (or empty if not available) + + Note: Some content may be fallback descriptions (when scraping failed) - treat these appropriately and don't penalize them for being shorter. + + IMPORTANT: Focus on quality over quantity. It's better to return fewer high-quality, relevant pieces than many low-quality ones. + """) + + +def crawl_urls_batch(search_results): + url_to_search_results = {} + unique_urls = [] + for search_result in search_results: + if not search_result.get("url", False): + continue + if not search_result.get("is_scrapping_required", True): + continue + if not search_result.get('original_url'): + search_result['original_url'] = search_result['url'] + url = search_result["url"] + if url not in url_to_search_results: + url_to_search_results[url] = [] + unique_urls.append(url) + url_to_search_results[url].append(search_result) + browser_crawler = create_browser_crawler() + scraped_results = browser_crawler.scrape_urls(unique_urls) + url_to_scraped = {result["original_url"]: result for result in scraped_results} + updated_search_results = [] + successful_scrapes = 0 + failed_scrapes = 0 + for search_result in search_results: + original_url = search_result["url"] + scraped = url_to_scraped.get(original_url, {}) + updated_result = search_result.copy() + updated_result["original_url"] = original_url + if scraped.get("success", False): + updated_result["url"] = scraped.get("final_url", original_url) + updated_result["full_text"] = scraped.get("full_text", "") + updated_result["published_date"] = scraped.get("published_date", "") + successful_scrapes += 1 + else: + updated_result["url"] = original_url + updated_result["full_text"] = search_result.get("description", "") + updated_result["published_date"] = "" + failed_scrapes += 1 + updated_search_results.append(updated_result) + return updated_search_results, successful_scrapes, failed_scrapes + + +def verify_content_with_agent(query, search_results, use_agent=True): + if not use_agent: + return search_results + verified_search_results = [] + for _, search_result in enumerate(search_results): + content_for_verification = { + "url": search_result["url"], + "description": search_result.get("description", ""), + "full_text": search_result["full_text"], + "published_date": search_result["published_date"], + } + search_result["agent_verified"] = False + try: + session_id = str(uuid.uuid4()) + scrape_agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=SCRAPE_AGENT_INSTRUCTIONS, + description=SCRAPE_AGENT_DESCRIPTION, + use_json_mode=True, + session_id=session_id, + response_model=ScrapedContent, + ) + response = scrape_agent.run( + f"Query: {query}\n" + f"Verify and format this scraped content. " + f"Keep content relevant to the query and ensure quality: {content_for_verification}", + session_id=session_id, + ) + verified_item = response.to_dict()["content"] + search_result["full_text"] = verified_item.get("full_text", search_result["full_text"]) + search_result["published_date"] = verified_item.get("published_date", search_result["published_date"]) + search_result["agent_verified"] = True + except Exception as _: + pass + verified_search_results.append(search_result) + return verified_search_results + + +def scrape_agent_run(query: str, search_results) -> str: + try: + updated_results, _, _ = crawl_urls_batch(search_results) + verified_results = verify_content_with_agent(query, updated_results, use_agent=False) + return verified_results + except Exception as _: + import traceback + + traceback.print_exc() + return [] diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/script_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/script_agent.py new file mode 100644 index 0000000..bf13fa8 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/script_agent.py @@ -0,0 +1,120 @@ +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from pydantic import BaseModel, Field +from typing import List, Optional +from dotenv import load_dotenv +from textwrap import dedent +from datetime import datetime +import uuid + +load_dotenv() + + +class Dialog(BaseModel): + speaker: str = Field(..., description="The speaker name (SHOULD BE 'ALEX' OR 'MORGAN')") + text: str = Field( + ..., + description="The spoken text content for this speaker based on the requested langauge, default is English", + ) + + +class Section(BaseModel): + type: str = Field(..., description="The section type (intro, headlines, article, outro)") + title: Optional[str] = Field(None, description="Optional title for the section (required for article type)") + dialog: List[Dialog] = Field(..., description="List of dialog exchanges between speakers") + + +class PodcastScript(BaseModel): + title: str = Field(..., description="The podcast episode title with date") + sections: List[Section] = Field(..., description="List of podcast sections (intro, headlines, articles, outro)") + + +PODCAST_AGENT_DESCRIPTION = "You are a helpful assistant that can generate engaging podcast scripts for the given sources." +PODCAST_AGENT_INSTRUCTIONS = dedent(""" + You are a helpful assistant that can generate engaging podcast scripts for the given source content and query. + For given content, create an engaging podcast script that should be at least 15 minutes worth of content and your allowed enhance the script beyond given sources if you know something additional info will be interesting to the discussion or not enough conents available. + You use the provided sources to ground your podcast script generation process. Keep it engaging and interesting. + + IMPORTANT: Generate the entire script in the provided language. basically only text field needs to be in requested language, + + CONTENT GUIDELINES [THIS IS EXAMPLE YOU CAN CHANGE THE GUIDELINES ANYWAY BASED ON THE QUERY OR TOPIC DISCUSSED]: + - Provide insightful analysis that helps the audience understand the significance + - Include discussions on potential implications and broader context of each story + - Explain complex concepts in an accessible but thorough manner + - Make connections between current and relevant historical developments when applicable + - Provide comparisons and contrasts with similar stories or trends when relevant + + PERSONALITY NOTES [THIS IS EXAMPLE YOU CAN CHANGE THE PERSONALITY OF ALEX AND MORGAN ANYWAY BASED ON THE QUERY OR TOPIC DISCUSSED]: + - Alex is more analytical and fact-focused + * Should reference specific details and data points + * Should explain complex topics clearly + * Should identify key implications of stories + - Morgan is more focused on human impact, social context, and practical applications + * Should analyze broader implications + * Should consider ethical implications and real-world applications + - Include natural, conversational banter and smooth transitions between topics + - Each article discussion should go beyond the basic summary to provide valuable insights + - Maintain a conversational but informed tone that would appeal to a general audience + + IMPORTNAT: + - MAKE SURE PODCAST SCRIPS ARE AT LEAST 15 MINUTES LONG WHICH MEANS YOU NEED TO HAVE DETAILED DISCUSSIONS OFFCOURSE KEEP IT INTERESTING AND ENGAGING. + """) + + +def format_search_results_for_podcast( + search_results: List[dict], +) -> tuple[str, List[str]]: + created_at = datetime.now().strftime("%B %d, %Y at %I:%M %p") + structured_content = [] + structured_content.append(f"PODCAST CREATION: {created_at}\n") + sources = [] + for idx, search_result in enumerate(search_results): + try: + if search_result.get("confirmed", False): + sources.append(search_result["url"]) + structured_content.append( + f""" + SOURCE {idx + 1}: + Title: {search_result['title']} + URL: {search_result['url']} + Content: {search_result.get('full_text') or search_result.get('description', '')} + ---END OF SOURCE {idx + 1}--- + """.strip() + ) + except Exception as e: + print(f"Error processing search result: {e}") + content_texts = "\n\n".join(structured_content) + return content_texts, sources + + +def script_agent_run( + query: str, + search_results: List[dict], + language_name: str, +) -> str: + try: + session_id = str(uuid.uuid4()) + content_texts, sources = format_search_results_for_podcast(search_results) + if not content_texts: + return {} + podcast_script_agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=PODCAST_AGENT_INSTRUCTIONS, + description=PODCAST_AGENT_DESCRIPTION, + use_json_mode=True, + response_model=PodcastScript, + session_id=session_id, + ) + response = podcast_script_agent.run( + f"query: {query}\n language_name: {language_name}\n content_texts: {content_texts}\n, IMPORTANT: texts should be in {language_name} language.", + session_id=session_id, + ) + response_dict = response.to_dict() + response_dict = response_dict["content"] + response_dict["sources"] = sources + return response_dict + except Exception as _: + import traceback + + traceback.print_exc() + return {} \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/search_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/search_agent.py new file mode 100644 index 0000000..db55645 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/pipeline/search_agent.py @@ -0,0 +1,90 @@ +from typing import List +import uuid +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from pydantic import BaseModel, Field +from dotenv import load_dotenv +from agno.tools.duckduckgo import DuckDuckGoTools +from textwrap import dedent +from tools.wikipedia_search import wikipedia_search +from tools.google_news_discovery import google_news_discovery_run +from tools.jikan_search import jikan_search +from tools.embedding_search import embedding_search +from tools.social_media_search import social_media_search, social_media_trending_search + + +load_dotenv() + + +class ReturnItem(BaseModel): + url: str = Field(..., description="The URL of the search result") + title: str = Field(..., description="The title of the search result") + description: str = Field(..., description="A brief description or summary of the search result content") + source_name: str = Field( + ..., + description="The name/type of the source (e.g., 'wikipedia', 'general', or any reputable source tag)", + ) + tool_used: str = Field( + ..., + description="The tools used to generate the search results, unknown if not used or not applicable", + ) + published_date: str = Field( + ..., + description="The published date of the content in ISO format, if not available keep it empty", + ) + is_scrapping_required: bool = Field( + ..., + description="Set to True if the content need scraping, False otherwise, default keep it True if not sure", + ) + + +class SearchResults(BaseModel): + items: List[ReturnItem] = Field(..., description="A list of search result items") + + +SEARCH_AGENT_DESCRIPTION = "You are a helpful assistant that can search the web for information." +SEARCH_AGENT_INSTRUCTIONS = dedent(""" + You are a helpful assistant that can search the web or any other sources for information. + You should create topic for the search from the given query instead of blindly apply the query to the search tools. + For a given topic, your job is to search the web or any other sources and return the top 5 to 10 sources about the topic. + Keep the search sources of high quality and reputable, and sources should be relevant to the asked topic. + Sources should be from diverse platforms with no duplicates. + IMPORTANT: User queries might be fuzzy or misspelled. Understand the user's intent and act accordingly. + IMPORTANT: The output source_name field can be one of ["wikipedia", "general", or any source tag used"]. + IMPORTANT: You have access to different search tools use them when appropriate which one is best for the given search query. Don't use particular tool if not required. + IMPORTANT: Make sure you are able to detect what tool to use and use it available tool tags = ["google_news_discovery", "duckduckgo", "wikipedia_search", "jikan_search", "social_media_search", "social_media_trending_search", "unknown"]. + IMPORTANT: If query is news related please prefere google news over other news tools. + IMPORTANT: If returned sources are not of high quality or not relevant to the asked topic, don't include them in the returned sources. + IMPORTANT: Never include dates to the search query unless user explicitly asks for it. + IMPORTANT: You are allowed to use appropriate tools to get the best results even the single tool return enough results diverse check is better. + """) + + +def search_agent_run(query: str) -> str: + try: + session_id = str(uuid.uuid4()) + search_agent = Agent( + model=OpenAIChat(id="gpt-4o-mini"), + instructions=SEARCH_AGENT_INSTRUCTIONS, + description=SEARCH_AGENT_DESCRIPTION, + use_json_mode=True, + response_model=SearchResults, + tools=[ + google_news_discovery_run, + DuckDuckGoTools(), + wikipedia_search, + jikan_search, + embedding_search, + social_media_search, + social_media_trending_search, + ], + session_id=session_id, + ) + response = search_agent.run(query, session_id=session_id) + response_dict = response.to_dict() + return response_dict["content"]["items"] + except Exception as _: + import traceback + + traceback.print_exc() + return [] diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/search_articles.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/search_articles.py new file mode 100644 index 0000000..ca515bc --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/search_articles.py @@ -0,0 +1,67 @@ +import sqlite3 +from typing import List, Union +from agno.agent import Agent +from db.config import get_tracking_db_path +import json + + +def search_articles(agent: Agent, terms: Union[str, List[str]]) -> str: + """ + Search for articles related to a podcast topic using direct SQL queries. + The agent can pass either a string topic or a list of search terms. + + Args: + agent: The agent instance + terms: Either a single topic string or a list of search terms + + Returns: + A formatted string response with the search results + """ + print(f"Search Internal Articles terms: {terms}") + search_terms = terms if isinstance(terms, list) else [terms] + limit = 3 + db_path = get_tracking_db_path() + try: + with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn: + conn.row_factory = lambda cursor, row: {col[0]: row[idx] for idx, col in enumerate(cursor.description)} + results = execute_simple_search(conn, search_terms, limit) + if not results: + return "No relevant articles found in our database. Would you like to try a different topic or provide specific URLs?" + for article in results: + article["categories"] = get_article_categories(conn, article["id"]) + article["source_name"] = article.get("source_id", "Unknown Source") + return f"is_scrapping_required: False, Found {len(results)}, {json.dumps(results, indent=2)} potential sources that might be relevant to your topic careful my search is text bassed do quality check and ignore invalid resutls." + except Exception as e: + print(f"Error searching articles: {e}") + return "I encountered a database error while searching. Would you like to try a different approach?" + + +def execute_simple_search(conn, terms, limit): + base_query = """ + SELECT DISTINCT ca.id, ca.title, ca.url, ca.published_date, + COALESCE(ca.summary, ca.content) as content, + ca.source_id, ca.feed_id + FROM crawled_articles ca + WHERE ca.processed = 1 + AND ( + """ + clauses = [] + params = [] + for term in terms: + like_term = f"%{term}%" + clauses.append("(ca.title LIKE ? OR ca.content LIKE ? OR ca.summary LIKE ?)") + params.extend([like_term, like_term, like_term]) + + query = base_query + " OR ".join(clauses) + ") ORDER BY ca.published_date DESC LIMIT ?" + params.append(limit) + cursor = conn.execute(query, params) + return [dict(row) for row in cursor.fetchall()] + + +def get_article_categories(conn, article_id): + try: + cursor = conn.execute("SELECT category_name FROM article_categories WHERE article_id = ?", (article_id,)) + return [row["category_name"] for row in cursor.fetchall()] + except Exception as e: + print(f"Error fetching article categories: {e}") + return [] \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/session_state_manager.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/session_state_manager.py new file mode 100644 index 0000000..2a85348 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/session_state_manager.py @@ -0,0 +1,204 @@ +from agno.agent import Agent +from datetime import datetime +from db.config import get_podcasts_db_path, DB_PATH +import os +import sqlite3 +import json + + +def _save_podcast_to_database_sync(session_state: dict) -> tuple[bool, str, int]: + try: + if session_state.get("podcast_id"): + return ( + True, + f"Podcast already saved with ID: {session_state['podcast_id']}", + session_state["podcast_id"], + ) + tts_engine = session_state.get("tts_engine", "openai") + podcast_info = session_state.get("podcast_info", {}) + generated_script = session_state.get("generated_script", {}) + banner_url = session_state.get("banner_url") + banner_images = json.dumps(session_state.get("banner_images", [])) + audio_url = session_state.get("audio_url") + selected_language = session_state.get("selected_language", {"code": "en", "name": "English"}) + language_code = selected_language.get("code", "en") + if not generated_script or not isinstance(generated_script, dict): + return ( + False, + "Cannot complete podcast: Generated script is missing or invalid.", + None, + ) + if "title" not in generated_script: + generated_script["title"] = podcast_info.get("topic", "Untitled Podcast") + if "sections" not in generated_script or not isinstance(generated_script["sections"], list): + return ( + False, + "Cannot complete podcast: Generated script is missing required 'sections' array.", + None, + ) + sources = [] + if "sources" in generated_script and generated_script["sources"]: + for source in generated_script["sources"]: + if isinstance(source, str): + sources.append(source) + elif isinstance(source, dict) and "url" in source: + sources.append(source["url"]) + elif isinstance(source, dict) and "link" in source: + sources.append(source["link"]) + generated_script["sources"] = sources + db_path = get_podcasts_db_path() + db_directory = DB_PATH + os.makedirs(db_directory, exist_ok=True) + + conn = sqlite3.connect(db_path) + content_json = json.dumps(generated_script) + sources_json = json.dumps(sources) if sources else None + current_time = datetime.now().isoformat() + query = """ + INSERT INTO podcasts ( + title, + date, + content_json, + audio_generated, + audio_path, + banner_img_path, + tts_engine, + language_code, + sources_json, + created_at, + banner_images + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + conn.execute( + query, + ( + generated_script.get("title", "Untitled Podcast"), + datetime.now().strftime("%Y-%m-%d"), + content_json, + 1 if audio_url else 0, + audio_url, + banner_url, + tts_engine, + language_code, + sources_json, + current_time, + banner_images, + ), + ) + conn.commit() + + cursor = conn.execute("SELECT last_insert_rowid()") + podcast_id = cursor.fetchone() + podcast_id = podcast_id[0] if podcast_id else None + cursor.close() + conn.close() + + session_state["podcast_id"] = podcast_id + return True, f"Podcast successfully saved with ID: {podcast_id}", podcast_id + except Exception as e: + print(f"Error saving podcast to database: {e}") + return False, f"Error saving podcast to database: {str(e)}", None + + +def update_language(agent: Agent, language_code: str) -> str: + """ + Update the podcast language with the specified language code. + This ensures the language is properly tracked for generating content and audio. + Args: + agent: The agent instance + language_code: The language code (e.g., 'en', 'es', 'fr', etc..) + + Returns: + Confirmation message + """ + from services.internal_session_service import SessionService + + session_id = agent.session_id + session = SessionService.get_session(session_id) + session_state = session["state"] + language_name = "English" + for lang in session_state.get("available_languages", []): + if lang.get("code") == language_code: + language_name = lang.get("name") + break + session_state["selected_language"] = { + "code": language_code, + "name": language_name, + } + SessionService.save_session(session_id, session_state) + return f"Podcast language set to: {language_name} ({language_code})" + + +def update_chat_title(agent: Agent, title: str) -> str: + """ + Update the chat title with the specified short title. + Args: + agent: The agent instance + title: The short title to set for the chat + + Returns: + Confirmation message + """ + from services.internal_session_service import SessionService + + session_id = agent.session_id + session = SessionService.get_session(session_id) + current_state = session["state"] + current_state["title"] = title + current_state["created_at"] = datetime.now().isoformat() + SessionService.save_session(session_id, current_state) + return f"Chat title updated to: {title}" + + +def toggle_podcast_generated(session_state: dict, status: bool = False) -> str: + """ + Toggle the podcast_generated flag. + When set to true, this indicates the podcast creation process is complete and + the UI should show the final presentation view with all components. + If status is True, also saves the podcast to the podcasts database. + """ + if status: + session_state["podcast_generated"] = status + session_state["stage"] = "complete" if status else session_state.get("stage") + if status: + try: + success, message, podcast_id = _save_podcast_to_database_sync(session_state) + if success and podcast_id: + session_state["podcast_id"] = podcast_id + return f"Podcast generated and saved to database with ID: {podcast_id}. You can now access it from the Podcasts section." + else: + return f"Podcast generated, but there was an issue with saving: {message}" + except Exception as e: + print(f"Error saving podcast to database: {e}") + return f"Podcast generated, but there was an error saving it to the database: {str(e)}" + else: + session_state["podcast_generated"] = status + session_state["stage"] = "complete" if status else session_state.get("stage") + return f"Podcast generated status changed to: {status}" + + +def mark_session_finished(agent: Agent) -> str: + """ + Mark the session as finished. + Args: + agent: The agent instance + + Returns: + Confirmation message + """ + from services.internal_session_service import SessionService + + session_id = agent.session_id + session = SessionService.get_session(session_id) + session_state = session["state"] + if not session_state.get("generated_script"): + return "Podcast Script is not generated yet." + if not session_state.get("banner_url"): + return "Banner is not generated yet." + if not session_state.get("audio_url"): + return "Audio is not generated yet." + session_state["finished"] = True + session_state["stage"] = "complete" + toggle_podcast_generated(session_state, True) + SessionService.save_session(session_id, session_state) + return "Session marked as finished and generated podcast stored into podcasts database and No further conversation are allowed and only new session can be started." diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/browser.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/browser.py new file mode 100644 index 0000000..9ad56c5 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/browser.py @@ -0,0 +1,90 @@ +import os +from playwright.sync_api import sync_playwright +from tools.social.config import USER_DATA_DIR +from contextlib import contextmanager + + +def setup_session(TARGET_SITE): + print("🔐 SESSION SETUP MODE") + print(f"Opening browser with persistent profile at: {USER_DATA_DIR}") + print(f"Please log in {TARGET_SITE} manually to establish your session") + with sync_playwright() as playwright: + os.makedirs(USER_DATA_DIR, exist_ok=True) + browser_context = playwright.chromium.launch_persistent_context( + user_data_dir=USER_DATA_DIR, + headless=False, + viewport={"width": 1280, "height": 800}, + ) + try: + if browser_context.pages: + page = browser_context.pages[0] + else: + page = browser_context.new_page() + page.goto(TARGET_SITE) + print("\n✅ Browser is now open. Please:") + print("1. Log in to your account manually") + print("2. Navigate through the site as needed") + print("3. Press Ctrl+C in this terminal when you're done\n") + try: + while True: + page.wait_for_timeout(1000) + except KeyboardInterrupt: + print("\n🔑 Session saved! You can now run monitoring tasks with this session.") + finally: + browser_context.close() + + +def setup_session_multi(TARGET_SITES): + print("🔐 MULTI-SITE SESSION SETUP MODE") + print(f"Opening browser with persistent profile at: {USER_DATA_DIR}") + print(f"Setting up sessions for {len(TARGET_SITES)} sites:") + for i, site in enumerate(TARGET_SITES, 1): + print(f" {i}. {site}") + with sync_playwright() as playwright: + os.makedirs(USER_DATA_DIR, exist_ok=True) + browser_context = playwright.chromium.launch_persistent_context( + user_data_dir=USER_DATA_DIR, + headless=False, + viewport={"width": 1280, "height": 800}, + ) + try: + pages = [] + for i, site in enumerate(TARGET_SITES): + if i == 0 and browser_context.pages: + page = browser_context.pages[0] + else: + page = browser_context.new_page() + print(f"📂 Opening: {site}") + page.goto(site) + pages.append(page) + + print("\n✅ All sites opened in separate tabs. Please:") + print("1. Log in to your accounts manually in each tab") + print("2. Navigate through the sites as needed") + print("3. Press Ctrl+C in this terminal when you're done\n") + print("💡 Tip: Use Ctrl+Tab to switch between tabs") + try: + while True: + pages[0].wait_for_timeout(1000) + except KeyboardInterrupt: + print(f"\n🔑 Sessions saved for all {len(TARGET_SITES)} sites! " "You can now run monitoring tasks with these sessions.") + finally: + browser_context.close() + + +@contextmanager +def create_browser_context(): + with sync_playwright() as playwright: + browser_context = playwright.chromium.launch_persistent_context( + user_data_dir=USER_DATA_DIR, + headless=False, + viewport={"width": 1280, "height": 800}, + ) + try: + if browser_context.pages: + page = browser_context.pages[0] + else: + page = browser_context.new_page() + yield browser_context, page + finally: + browser_context.close() \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/config.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/config.py new file mode 100644 index 0000000..0962062 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/config.py @@ -0,0 +1,3 @@ +from db.config import get_browser_session_path + +USER_DATA_DIR = get_browser_session_path() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/db.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/db.py new file mode 100644 index 0000000..5db6090 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/db.py @@ -0,0 +1,161 @@ +import sqlite3 +import json + + +def create_connection(db_file="x_posts.db"): + conn = sqlite3.connect(db_file) + conn.row_factory = sqlite3.Row + return conn + + +def setup_database(conn): + create_posts_table = """ + CREATE TABLE IF NOT EXISTS posts ( + post_id TEXT PRIMARY KEY, + platform TEXT, + user_display_name TEXT, + user_handle TEXT, + user_profile_pic_url TEXT, + post_timestamp TEXT, + post_display_time TEXT, + post_url TEXT, + post_text TEXT, + post_mentions TEXT, + engagement_reply_count INTEGER, + engagement_retweet_count INTEGER, + engagement_like_count INTEGER, + engagement_bookmark_count INTEGER, + engagement_view_count INTEGER, + media TEXT, -- Stored as JSON + media_count INTEGER, + is_ad BOOLEAN, + sentiment TEXT, + categories TEXT, + tags TEXT, + analysis_reasoning TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + + conn.execute(create_posts_table) + conn.execute("CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_posts_user_handle ON posts(user_handle)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_posts_post_timestamp ON posts(post_timestamp)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_posts_sentiment ON posts(sentiment)") + conn.commit() + + +def parse_engagement_count(count_str): + if not count_str: + return 0 + count_str = str(count_str).strip() + if not count_str: + return 0 + multiplier = 1 + if "K" in count_str: + multiplier = 1000 + count_str = count_str.replace("K", "") + elif "M" in count_str: + multiplier = 1000000 + count_str = count_str.replace("M", "") + elif "B" in count_str: + multiplier = 1000000000 + count_str = count_str.replace("B", "") + try: + return int(float(count_str) * multiplier) + except (ValueError, TypeError): + return 0 + + +def process_post_data(post_data): + data = post_data.copy() + metrics = ["engagement_reply_count", "engagement_retweet_count", "engagement_like_count", "engagement_bookmark_count", "engagement_view_count"] + for metric in metrics: + if metric in data: + data[metric] = parse_engagement_count(data[metric]) + if "media" in data and isinstance(data["media"], list): + data["media"] = json.dumps(data["media"]) + if "categories" in data and isinstance(data["categories"], list): + data["categories"] = json.dumps(data["categories"]) + if "tags" in data and isinstance(data["tags"], list): + data["tags"] = json.dumps(data["tags"]) + if "is_ad" in data: + data["is_ad"] = 1 if data["is_ad"] else 0 + return data + + +def get_post(conn, post_id): + cursor = conn.cursor() + cursor.execute("SELECT * FROM posts WHERE post_id = ?", (post_id,)) + return cursor.fetchone() + + +def insert_post(conn, post_data): + data = process_post_data(post_data) + columns = ", ".join(data.keys()) + placeholders = ", ".join(["?"] * len(data)) + values = list(data.values()) + sql = f"INSERT INTO posts ({columns}) VALUES ({placeholders})" + conn.execute(sql, values) + conn.commit() + + +def check_and_store_post(conn, post_data): + post_id = post_data.get("post_id") + if not post_id: + return False + if post_data.get("is_ad", False): + return False + data = process_post_data(post_data) + existing_post = get_post(conn, post_id) + if not existing_post: + needs_analysis = bool(data.get("post_text")) + insert_post(conn, data) + return needs_analysis + else: + update_changed_metrics(conn, existing_post, data) + return False + + +def update_changed_metrics(conn, existing_post, new_data): + metrics = ["engagement_reply_count", "engagement_retweet_count", "engagement_like_count", "engagement_bookmark_count", "engagement_view_count"] + changes = {} + for metric in metrics: + if metric in new_data and metric in existing_post: + if new_data[metric] != existing_post[metric]: + changes[metric] = new_data[metric] + if changes: + set_clauses = [f"{metric} = ?" for metric in changes] + set_sql = ", ".join(set_clauses) + set_sql += ", updated_at = CURRENT_TIMESTAMP" + sql = f"UPDATE posts SET {set_sql} WHERE post_id = ?" + params = list(changes.values()) + [existing_post["post_id"]] + conn.execute(sql, params) + conn.commit() + + +def update_posts_with_analysis(conn, post_ids, analysis_results): + if not analysis_results: + return + analysis_by_id = {} + for analysis in analysis_results: + post_id = analysis.get("post_id") + if post_id: + analysis_by_id[post_id] = analysis + for post_id in post_ids: + if post_id in analysis_by_id: + analysis = analysis_by_id[post_id] + categories = json.dumps(analysis.get("categories", [])) + tags = json.dumps(analysis.get("tags", [])) + conn.execute( + """UPDATE posts SET + sentiment = ?, + categories = ?, + tags = ?, + analysis_reasoning = ?, + updated_at = CURRENT_TIMESTAMP + WHERE post_id = ?""", + (analysis.get("sentiment"), categories, tags, analysis.get("reasoning"), post_id), + ) + conn.commit() \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/fb_post_extractor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/fb_post_extractor.py new file mode 100644 index 0000000..71012c7 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/fb_post_extractor.py @@ -0,0 +1,415 @@ +import json +from datetime import datetime +from typing import Dict, Any +import re + + +def parse_facebook_posts(data): + posts = [] + try: + for post in data["data"]["viewer"]["news_feed"]["edges"]: + posts.append(parse_facebook_post(post["node"])) + except Exception as e: + print(f"Error parsing post data: {e}") + return posts + + +def parse_facebook_post(story_node: Dict[str, Any]) -> Dict[str, Any]: + try: + post_info = { + "post_id": story_node.get("post_id"), + "story_id": story_node.get("id"), + "creation_time": None, + "creation_time_formatted": None, + "url": None, + } + creation_time_sources = [ + story_node.get("creation_time"), + (story_node.get("comet_sections") or {}).get("context_layout", {}).get("story", {}).get("creation_time"), + (story_node.get("comet_sections") or {}).get("timestamp", {}).get("story", {}).get("creation_time"), + ] + for source in creation_time_sources: + if source: + post_info["creation_time"] = source + post_info["creation_time_formatted"] = datetime.fromtimestamp(source).strftime("%Y-%m-%d %H:%M:%S") + break + url_sources = [ + (story_node.get("comet_sections") or {}).get("content", {}).get("story", {}).get("wwwURL"), + (story_node.get("comet_sections") or {}).get("feedback", {}).get("story", {}).get("story_ufi_container", {}).get("story", {}).get("url"), + (story_node.get("comet_sections") or {}) + .get("feedback", {}) + .get("story", {}) + .get("story_ufi_container", {}) + .get("story", {}) + .get("shareable_from_perspective_of_feed_ufi", {}) + .get("url"), + story_node.get("wwwURL"), + story_node.get("url"), + ] + for source in url_sources: + if source: + post_info["url"] = source + break + message_info = extract_message_content(story_node) + post_info.update(message_info) + actors_info = extract_actors_info(story_node) + post_info.update(actors_info) + attachments_info = extract_attachments(story_node) + post_info.update(attachments_info) + engagement_info = extract_engagement_data(story_node) + post_info.update(engagement_info) + privacy_info = extract_privacy_info(story_node) + post_info.update(privacy_info) + return post_info + except (KeyError, IndexError, TypeError) as e: + print(f"Error parsing post data: {e}") + return {} + + +def extract_message_content(story_node: Dict[str, Any]) -> Dict[str, Any]: + message_info = {"message_text": "", "hashtags": [], "mentions": [], "links": []} + try: + message_sources = [ + (story_node.get("message") or {}).get("text", ""), + ((((story_node.get("comet_sections") or {}).get("content") or {}).get("story") or {}).get("message") or {}).get("text", ""), + ] + for source in message_sources: + if source: + message_info["message_text"] = source + break + message_data = story_node.get("message", {}) + if "ranges" in message_data: + for range_item in message_data["ranges"]: + entity = range_item.get("entity", {}) + entity_type = entity.get("__typename") + + if entity_type == "Hashtag": + hashtag_text = message_info["message_text"][range_item["offset"] : range_item["offset"] + range_item["length"]] + message_info["hashtags"].append( + { + "text": hashtag_text, + "url": entity.get("url"), + "id": entity.get("id"), + } + ) + elif entity_type == "User": + mention_text = message_info["message_text"][range_item["offset"] : range_item["offset"] + range_item["length"]] + message_info["mentions"].append( + { + "text": mention_text, + "url": entity.get("url"), + "id": entity.get("id"), + } + ) + except (KeyError, TypeError): + pass + return message_info + + +def extract_actors_info(story_node: Dict[str, Any]) -> Dict[str, Any]: + actors_info = { + "author_name": "", + "author_id": "", + "author_url": "", + "author_profile_picture": "", + "is_verified": False, + "page_info": {}, + } + try: + actors = story_node.get("actors", []) + if actors: + main_actor = actors[0] + actors_info.update( + { + "author_name": main_actor.get("name", ""), + "author_id": main_actor.get("id", ""), + "author_url": main_actor.get("url", ""), + } + ) + context_sections = (story_node.get("comet_sections") or {}).get("context_layout", {}) + if context_sections: + actor_photo = (context_sections.get("story") or {}).get("comet_sections", {}).get("actor_photo", {}) + if actor_photo: + story_actors = (actor_photo.get("story") or {}).get("actors", []) + if story_actors: + profile_pic = story_actors[0].get("profile_picture", {}) + actors_info["author_profile_picture"] = profile_pic.get("uri", "") + except (KeyError, TypeError, IndexError): + pass + return actors_info + + +def extract_attachments(story_node: Dict[str, Any]) -> Dict[str, Any]: + attachments_info = {"attachments": [], "photos": [], "videos": [], "links": []} + try: + attachments = story_node.get("attachments", []) + for attachment in attachments: + attachment_data = { + "type": attachment.get("__typename", ""), + "style_list": attachment.get("style_list", []), + } + if "photo" in attachment.get("style_list", []): + target = attachment.get("target", {}) + if target.get("__typename") == "Photo": + styles = attachment.get("styles", {}) + if styles: + media = (styles.get("attachment") or {}).get("media", {}) + photo_info = { + "id": target.get("id"), + "url": media.get("url", ""), + "width": (media.get("viewer_image") or {}).get("width"), + "height": (media.get("viewer_image") or {}).get("height"), + "image_uri": "", + "accessibility_caption": media.get("accessibility_caption", ""), + } + resolution_renderer = media.get("comet_photo_attachment_resolution_renderer", {}) + if resolution_renderer: + image = resolution_renderer.get("image", {}) + photo_info["image_uri"] = image.get("uri", "") + + attachments_info["photos"].append(photo_info) + attachments_info["attachments"].append(attachment_data) + except (KeyError, TypeError): + pass + return attachments_info + + +def extract_engagement_data(story_node: Dict[str, Any]) -> Dict[str, Any]: + """Extract likes, comments, shares, and other engagement metrics""" + engagement_info = { + "reaction_count": 0, + "comment_count": 0, + "share_count": 0, + "reactions_breakdown": {}, + "top_reactions": [], + } + try: + feedback_story = (story_node.get("comet_sections") or {}).get("feedback", {}).get("story", {}) + if feedback_story: + ufi_container = (feedback_story.get("story_ufi_container") or {}).get("story", {}) + if ufi_container: + feedback_context = ufi_container.get("feedback_context", {}) + feedback_target = feedback_context.get("feedback_target_with_context", {}) + + if feedback_target: + summary_renderer = feedback_target.get("comet_ufi_summary_and_actions_renderer", {}) + if summary_renderer: + feedback_data = summary_renderer.get("feedback", {}) + if "i18n_reaction_count" in feedback_data: + engagement_info["reaction_count"] = int(feedback_data["i18n_reaction_count"]) + elif "reaction_count" in feedback_data and isinstance(feedback_data["reaction_count"], dict): + engagement_info["reaction_count"] = feedback_data["reaction_count"].get("count", 0) + if "i18n_share_count" in feedback_data: + engagement_info["share_count"] = int(feedback_data["i18n_share_count"]) + elif "share_count" in feedback_data and isinstance(feedback_data["share_count"], dict): + engagement_info["share_count"] = feedback_data["share_count"].get("count", 0) + top_reactions = feedback_data.get("top_reactions", {}) + if "edges" in top_reactions: + for edge in top_reactions["edges"]: + reaction_node = edge.get("node", {}) + engagement_info["top_reactions"].append( + { + "reaction_id": reaction_node.get("id"), + "name": reaction_node.get("localized_name"), + "count": edge.get("reaction_count", 0), + } + ) + comment_rendering = feedback_target.get("comment_rendering_instance", {}) + if comment_rendering: + comments = comment_rendering.get("comments", {}) + engagement_info["comment_count"] = comments.get("total_count", 0) + + except (KeyError, TypeError, ValueError): + pass + return engagement_info + + +def extract_privacy_info(story_node: Dict[str, Any]) -> Dict[str, Any]: + privacy_info = {"privacy_scope": "", "audience": ""} + try: + privacy_sources = [ + (story_node.get("comet_sections") or {}).get("context_layout", {}).get("story", {}).get("privacy_scope", {}), + story_node.get("privacy_scope", {}), + next( + ( + (meta.get("story") or {}).get("privacy_scope", {}) + for meta in (story_node.get("comet_sections") or {}) + .get("context_layout", {}) + .get("story", {}) + .get("comet_sections", {}) + .get("metadata", []) + if isinstance(meta, dict) and meta.get("__typename") == "CometFeedStoryAudienceStrategy" + ), + {}, + ), + ] + for privacy_scope in privacy_sources: + if privacy_scope and "description" in privacy_scope: + privacy_info["privacy_scope"] = privacy_scope["description"] + break + if not privacy_info["privacy_scope"]: + context_layout = (story_node.get("comet_sections") or {}).get("context_layout", {}) + if context_layout: + story = context_layout.get("story", {}) + comet_sections = story.get("comet_sections", {}) + metadata = comet_sections.get("metadata", []) + for meta_item in metadata: + if isinstance(meta_item, dict) and meta_item.get("__typename") == "CometFeedStoryAudienceStrategy": + story_data = meta_item.get("story", {}) + privacy_scope = story_data.get("privacy_scope", {}) + if privacy_scope: + privacy_info["privacy_scope"] = privacy_scope.get("description", "") + break + except (KeyError, TypeError): + pass + return privacy_info + + +def normalize_facebook_post(fb_post_data): + normalized = { + "platform": "facebook.com", + "post_id": fb_post_data.get("post_id", ""), + "user_display_name": fb_post_data.get("author_name", ""), + "user_handle": extract_handle_from_url(fb_post_data.get("author_url", "")), + "user_profile_pic_url": fb_post_data.get("author_profile_picture", ""), + "post_timestamp": format_timestamp(fb_post_data.get("creation_time")), + "post_display_time": fb_post_data.get("creation_time_formatted", ""), + "post_url": fb_post_data.get("url", ""), + "post_text": fb_post_data.get("message_text", ""), + "post_mentions": format_mentions(fb_post_data.get("mentions", [])), + "engagement_reply_count": fb_post_data.get("comment_count", 0), + "engagement_retweet_count": fb_post_data.get("share_count", 0), + "engagement_like_count": fb_post_data.get("reaction_count", 0), + "engagement_bookmark_count": 0, + "engagement_view_count": 0, + "media": format_media(fb_post_data), + "media_count": calculate_media_count(fb_post_data), + "is_ad": False, + "sentiment": None, + "categories": None, + "tags": None, + "analysis_reasoning": None, + } + return normalized + + +def extract_handle_from_url(author_url: str) -> str: + if not author_url: + return "" + patterns = [ + r"facebook\.com/([^/?]+)", + r"facebook\.com/profile\.php\?id=(\d+)", + r"facebook\.com/pages/[^/]+/(\d+)", + ] + for pattern in patterns: + match = re.search(pattern, author_url) + if match: + return match.group(1) + return "" + + +def format_timestamp(creation_time) -> str: + if not creation_time: + return "" + try: + if isinstance(creation_time, (int, float)): + dt = datetime.fromtimestamp(creation_time) + return dt.isoformat() + elif isinstance(creation_time, str): + return creation_time + except (ValueError, TypeError): + pass + return "" + + +def format_mentions(mentions): + if not mentions: + return "" + mention_texts = [] + for mention in mentions: + if isinstance(mention, dict): + text = mention.get("text", "") + if text and not text.startswith("@"): + text = f"@{text}" + mention_texts.append(text) + elif isinstance(mention, str): + if not mention.startswith("@"): + mention = f"@{mention}" + mention_texts.append(mention) + return ",".join(mention_texts) + + +def format_media(fb_post_data: Dict[str, Any]) -> str: + media_items = [] + photos = fb_post_data.get("photos", []) + for photo in photos: + if isinstance(photo, dict): + media_item = { + "type": "image", + "url": photo.get("image_uri") or photo.get("url", ""), + "width": photo.get("width"), + "height": photo.get("height"), + "id": photo.get("id"), + "accessibility_caption": photo.get("accessibility_caption", ""), + } + media_item = {k: v for k, v in media_item.items() if v is not None} + media_items.append(media_item) + + videos = fb_post_data.get("videos", []) + for video in videos: + if isinstance(video, dict): + media_item = { + "type": "video", + "url": video.get("url", ""), + "id": video.get("id"), + } + media_item = {k: v for k, v in media_item.items() if v is not None} + media_items.append(media_item) + + attachments = fb_post_data.get("attachments", []) + for attachment in attachments: + if isinstance(attachment, dict): + attachment_type = attachment.get("type", "") + style_list = attachment.get("style_list", []) + if "photo" in style_list: + media_item = {"type": "image", "attachment_type": attachment_type} + media_items.append(media_item) + elif "video" in style_list: + media_item = {"type": "video", "attachment_type": attachment_type} + media_items.append(media_item) + return media_items or [] + + +def calculate_media_count(fb_post_data): + count = 0 + count += len(fb_post_data.get("photos", [])) + count += len(fb_post_data.get("videos", [])) + attachments = fb_post_data.get("attachments", []) + for attachment in attachments: + if isinstance(attachment, dict): + style_list = attachment.get("style_list", []) + if any(style in style_list for style in ["photo", "video"]): + count += 1 + return count + + +def normalize_facebook_posts_batch(fb_posts_data): + normalized_posts = [] + for fb_post in fb_posts_data: + try: + normalized_post = normalize_facebook_post(fb_post) + if normalized_post.get("post_id"): + normalized_posts.append(normalized_post) + except Exception as e: + print(f"Error normalizing Facebook post: {e}") + continue + return normalized_posts + + +if __name__ == "__main__": + with open("fb_post_input.json", "r", encoding="utf-8") as f: + facebook_data = json.load(f) + + parsed_post = parse_facebook_posts(facebook_data) + normalized_post = normalize_facebook_posts_batch(parsed_post) + print(normalized_post) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/fb_scraper.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/fb_scraper.py new file mode 100644 index 0000000..c6d37a1 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/fb_scraper.py @@ -0,0 +1,127 @@ +import time +import json +from tools.social.browser import create_browser_context +from tools.social.fb_post_extractor import parse_facebook_posts, normalize_facebook_posts_batch +from tools.social.x_agent import analyze_posts_sentiment +from tools.social.db import create_connection, setup_database, check_and_store_post, update_posts_with_analysis + + +def contains_facebook_posts(json_obj): + if not isinstance(json_obj, dict): + return False + try: + data = json_obj.get("data", {}) + viewer = data.get("viewer", {}) + news_feed = viewer.get("news_feed", {}) + edges = news_feed.get("edges", []) + + if isinstance(edges, list) and len(edges) > 0: + for edge in edges: + if isinstance(edge, dict) and "node" in edge: + node = edge["node"] + if isinstance(node, dict) and node.get("__typename") == "Story": + return True + return False + except (KeyError, TypeError, AttributeError): + return False + + +def process_facebook_graphql_response(response_text, seen_post_ids, analysis_queue, queue_post_ids, conn): + posts_processed = 0 + if not response_text: + return posts_processed + lines = response_text.split("\n") + for line in lines: + line = line.strip() + if not line: + continue + try: + json_obj = json.loads(line) + if contains_facebook_posts(json_obj): + parsed_posts = parse_facebook_posts(json_obj) + if not parsed_posts: + continue + normalized_posts = normalize_facebook_posts_batch(parsed_posts) + for post_data in normalized_posts: + post_id = post_data.get("post_id") + if not post_id or post_id in seen_post_ids: + continue + seen_post_ids.add(post_id) + posts_processed += 1 + needs_analysis = check_and_store_post(conn, post_data) + if needs_analysis and post_data.get("post_text"): + analysis_queue.append(post_data) + queue_post_ids.append(post_id) + except json.JSONDecodeError: + continue + except Exception as e: + print(f"Error processing Facebook post: {e}") + continue + + return posts_processed + + +def crawl_facebook_feed(target_url="https://facebook.com", db_file="fb_posts.db"): + conn = create_connection(db_file) + setup_database(conn) + seen_post_ids = set() + analysis_queue = [] + queue_post_ids = [] + post_count = 0 + batch_size = 5 + scroll_count = 0 + + with create_browser_context() as (browser_context, page): + + def handle_response(response): + nonlocal post_count, analysis_queue, queue_post_ids + url = response.url + if "/api/graphql/" not in url: + return + try: + content_type = response.headers.get("content-type", "") + if 'text/html; charset="utf-8"' not in content_type: + return + response_text = response.text() + posts_found = process_facebook_graphql_response(response_text, seen_post_ids, analysis_queue, queue_post_ids, conn) + if posts_found > 0: + post_count += posts_found + if len(analysis_queue) >= batch_size: + analysis_batch = analysis_queue[:batch_size] + batch_post_ids = queue_post_ids[:batch_size] + analysis_queue = analysis_queue[batch_size:] + queue_post_ids = queue_post_ids[batch_size:] + try: + analysis_results = analyze_posts_sentiment(analysis_batch) + update_posts_with_analysis(conn, batch_post_ids, analysis_results) + except Exception: + pass + except Exception: + pass + + page.on("response", handle_response) + page.goto(target_url) + time.sleep(5) + + def scroll_page(): + page.evaluate("window.scrollBy(0, window.innerHeight)") + + try: + while True: + scroll_page() + time.sleep(3) + scroll_count += 1 + if scroll_count >= 50: + break + except KeyboardInterrupt: + pass + + if analysis_queue: + try: + analysis_results = analyze_posts_sentiment(analysis_queue) + update_posts_with_analysis(conn, queue_post_ids, analysis_results) + except Exception: + pass + + conn.close() + return post_count diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/session_setup.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/session_setup.py new file mode 100644 index 0000000..5747a2e --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/session_setup.py @@ -0,0 +1,9 @@ +from tools.social.browser import setup_session_multi + +target_sites = [ + "https://x.com", + "https://facebook.com" +] + +if __name__ == "__main__": + setup_session_multi(target_sites) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_agent.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_agent.py new file mode 100644 index 0000000..b110ce4 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_agent.py @@ -0,0 +1,103 @@ +from typing import List +from pydantic import BaseModel, Field +from enum import Enum +from textwrap import dedent +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from dotenv import load_dotenv +import uuid + + +load_dotenv() + + +class SentimentType(str, Enum): + POSITIVE = "positive" + NEGATIVE = "negative" + NEUTRAL = "neutral" + CRITICAL = "critical" + + +class Category(str, Enum): + POLITICS = "politics" + TECHNOLOGY = "technology" + ENTERTAINMENT = "entertainment" + SPORTS = "sports" + BUSINESS = "business" + HEALTH = "health" + SCIENCE = "science" + EDUCATION = "education" + ENVIRONMENT = "environment" + SOCIAL_ISSUES = "social_issues" + PERSONAL = "personal" + NEWS = "news" + OTHER = "other" + + +class AnalyzedPost(BaseModel): + post_id: str = Field(..., description="The unique identifier for the post") + sentiment: SentimentType = Field(..., description="The sentiment of the post") + categories: List[Category] = Field(..., description="List of categories that best describe this post") + tags: List[str] = Field(..., description="List of relevant tags or keywords extracted from the post") + reasoning: str = Field(..., description="Brief explanation of why these sentiments and categories were assigned") + + +class AnalysisResponse(BaseModel): + analyzed_posts: List[AnalyzedPost] = Field(..., description="List of analyzed posts with sentiment and categorization") + + +SENTIMENT_AGENT_DESCRIPTION = "Expert sentiment analyzer and content categorizer for social media posts." +SENTIMENT_AGENT_INSTRUCTIONS = dedent(""" + You are an expert sentiment analyzer and content categorizer for social media posts. + + You will receive a batch of social media posts, each with a unique post_id. Your task is to analyze each post and return: + + 1. The EXACT same post_id that was provided (this is critical for matching) + 2. The sentiment (positive, negative, neutral, or critical) + 3. Relevant categories from the predefined list + 4. Generated tags or keywords + 5. Brief reasoning for your analysis + + Guidelines for sentiment classification: + - POSITIVE: Expresses joy, gratitude, excitement, optimism, or other positive emotions + - NEGATIVE: Expresses sadness, anger, disappointment, fear, or other negative emotions + - NEUTRAL: Factual, objective, or balanced without strong emotional tone + - CRITICAL: Contains criticism, skepticism, or questioning, but in a constructive or analytical way + + IMPORTANT: You MUST maintain the exact post_id provided for each post in your analysis. + IMPORTANT: Categories must be chosen ONLY from the predefined list. + IMPORTANT: Return analysis for ALL posts provided in the input. + IMPORTANT: You can add news tag if you think that could you be used to write news articles. +""") + + +def analyze_posts_sentiment(posts_data): + session_id = str(uuid.uuid4()) + analysis_agent = Agent( + model=OpenAIChat(id="gpt-4o"), + instructions=SENTIMENT_AGENT_INSTRUCTIONS, + description=SENTIMENT_AGENT_DESCRIPTION, + use_json_mode=True, + response_model=AnalysisResponse, + session_id=session_id, + ) + posts_prompt = "Analyze the sentiment and categorize the following social media posts:\n\n" + valid_posts = [] + for post in posts_data: + post_text = post.get("post_text", "") + post_id = post.get("post_id", "") + if post_text and post_id: + valid_posts.append(post) + posts_prompt += f"POST (ID: {post_id}):\n{post_text}\n\n" + if not valid_posts: + return [] + response = analysis_agent.run(posts_prompt, session_id=session_id) + analysis_results = response.to_dict()["content"]["analyzed_posts"] + validated_results = [] + valid_post_ids = {post.get("post_id") for post in valid_posts} + for analysis in analysis_results: + if analysis.get("post_id") in valid_post_ids: + validated_results.append(analysis) + else: + print(f"Warning: Analysis returned with invalid post_id: {analysis.get('post_id')}") + return validated_results \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_post_extractor.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_post_extractor.py new file mode 100644 index 0000000..fcab693 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_post_extractor.py @@ -0,0 +1,112 @@ +import os +from bs4 import BeautifulSoup +import re +import json + + +def check_ad(soup): + is_ad = False + ad_label = soup.find( + lambda tag: tag.name and tag.text and tag.text.strip() == "Ad" and "r-bcqeeo" in tag.get("class", []) if hasattr(tag, "get") else False + ) + if ad_label: + is_ad = True + username_element = soup.select_one("div[data-testid='User-Name'] a[role='link'] span") + if username_element and username_element.text.strip() in [ + "Premium", + "Twitter", + "X", + ]: + is_ad = True + handle_element = soup.select_one("div[data-testid='User-Name'] div[dir='ltr'] span") + if handle_element and "@premium" in handle_element.text: + is_ad = True + ad_tracking_links = soup.select("a[href*='referring_page=ad_'], a[href*='twclid=']") + if ad_tracking_links: + is_ad = True + return is_ad + + +def x_post_extractor(html_content): + soup = BeautifulSoup(html_content, "html.parser") + data = {"platform": "x.com"} + data["media"] = [] + data["is_ad"] = check_ad(soup) + user_element = soup.find("div", {"data-testid": "User-Name"}) + if user_element: + display_name_element = user_element.find(lambda tag: tag.name and (tag.name == "span" or tag.name == "div") and "Henrik" in tag.text) + if display_name_element: + data["user_display_name"] = display_name_element.text.strip() + username_element = user_element.find(lambda tag: tag.name == "a" and "@" in tag.text) + if username_element: + data["user_handle"] = username_element.text.strip() + profile_pic = soup.select_one("[data-testid='UserAvatar-Container-HenrikTaro'] img") + if profile_pic and profile_pic.has_attr("src"): + data["user_profile_pic_url"] = profile_pic["src"] + + time_element = soup.find("time") + if time_element: + if time_element.has_attr("datetime"): + data["post_timestamp"] = time_element["datetime"] + data["post_display_time"] = time_element.text.strip() + + post_link = soup.select_one("a[href*='/status/']") + if post_link and post_link.has_attr("href"): + status_url = post_link["href"] + post_id_match = re.search(r"/status/(\d+)", status_url) + if post_id_match: + data["post_id"] = post_id_match.group(1) + data["post_url"] = f"https://twitter.com{status_url}" + + text_element = soup.find("div", {"data-testid": "tweetText"}) + if text_element: + data["post_text"] = text_element.get_text(strip=True) + mentions = [] + mention_elements = text_element.select("a[href^='/'][role='link']") + for mention in mention_elements: + mention_text = mention.text.strip() + if mention_text.startswith("@"): + mentions.append(mention_text) + + if mentions: + data["post_mentions"] = ",".join(mentions) + + reply_button = soup.find("button", {"data-testid": "reply"}) + if reply_button: + count_span = reply_button.select_one("span[data-testid='app-text-transition-container'] span span") + if count_span: + data["engagement_reply_count"] = count_span.text.strip() + + retweet_button = soup.find("button", {"data-testid": "retweet"}) + if retweet_button: + count_span = retweet_button.select_one("span[data-testid='app-text-transition-container'] span span") + if count_span: + data["engagement_retweet_count"] = count_span.text.strip() + + like_button = soup.find("button", {"data-testid": "like"}) + if like_button: + count_span = like_button.select_one("span[data-testid='app-text-transition-container'] span span") + if count_span: + data["engagement_like_count"] = count_span.text.strip() + + bookmark_button = soup.find("button", {"data-testid": "bookmark"}) + if bookmark_button: + count_span = bookmark_button.select_one("span[data-testid='app-text-transition-container'] span span") + if count_span: + data["engagement_bookmark_count"] = count_span.text.strip() + + views_element = soup.select_one("a[href*='/analytics']") + if views_element: + views_span = views_element.select_one("span[data-testid='app-text-transition-container'] span span") + if views_span: + data["engagement_view_count"] = views_span.text.strip() + + media_elements = soup.find_all("div", {"data-testid": "tweetPhoto"}) + for media in media_elements: + img = media.find("img") + if img and img.has_attr("src"): + data["media"].append({"type": "image", "url": img["src"]}) + + data["media_count"] = len(data["media"]) + + return data \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_scraper.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_scraper.py new file mode 100644 index 0000000..49428db --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social/x_scraper.py @@ -0,0 +1,88 @@ +import time +from tools.social.browser import create_browser_context +from tools.social.x_post_extractor import x_post_extractor +from tools.social.x_agent import analyze_posts_sentiment +from tools.social.db import create_connection, setup_database, check_and_store_post, update_posts_with_analysis + + +def crawl_x_profile(profile_url, db_file="x_posts.db"): + if not profile_url.startswith("http"): + profile_url = f"https://x.com/{profile_url}" + + conn = create_connection(db_file) + setup_database(conn) + seen_post_ids = set() + analysis_queue = [] + queue_post_ids = [] + post_count = 0 + batch_size = 5 + scroll_count = 0 + + with create_browser_context() as (browser_context, page): + page.goto(profile_url) + time.sleep(5) + + try: + while True: + tweet_articles = page.query_selector_all('article[role="article"]') + for article in tweet_articles: + article_id = article.evaluate('(element) => element.getAttribute("id")') + if article_id in seen_post_ids: + continue + + show_more = article.query_selector('button[data-testid="tweet-text-show-more-link"]') + if show_more: + try: + show_more.click() + time.sleep(1) + except Exception as e: + pass + + tweet_html = article.evaluate("(element) => element.outerHTML") + post_data = x_post_extractor(tweet_html) + + post_id = post_data.get("post_id") + if not post_id or post_data.get("is_ad", False): + continue + + seen_post_ids.add(post_id) + post_count += 1 + + needs_analysis = check_and_store_post(conn, post_data) + + if needs_analysis and post_data.get("post_text"): + analysis_queue.append(post_data) + queue_post_ids.append(post_id) + + if len(analysis_queue) >= batch_size: + analysis_batch = analysis_queue[:batch_size] + batch_post_ids = queue_post_ids[:batch_size] + + analysis_queue = analysis_queue[batch_size:] + queue_post_ids = queue_post_ids[batch_size:] + + try: + analysis_results = analyze_posts_sentiment(analysis_batch) + + update_posts_with_analysis(conn, batch_post_ids, analysis_results) + except Exception: + pass + + page.evaluate("window.scrollBy(0, 800)") + time.sleep(3) + + scroll_count += 1 + if scroll_count >= 30: + break + + except KeyboardInterrupt: + if analysis_queue: + try: + analysis_results = analyze_posts_sentiment(analysis_queue) + update_posts_with_analysis(conn, queue_post_ids, analysis_results) + except Exception: + pass + + conn.close() + return post_count + return post_count \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social_media_search.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social_media_search.py new file mode 100644 index 0000000..130f4fd --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/social_media_search.py @@ -0,0 +1,142 @@ +import sqlite3 +import json +from datetime import datetime, timedelta +from contextlib import contextmanager +from agno.agent import Agent +from db.config import get_db_path + + +@contextmanager +def get_social_media_db(): + db_path = get_db_path("social_media_db") + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def social_media_search(agent: Agent, topic: str, limit: int = 10) -> str: + """ + Search social media database for the given topic on social media post texts, so topic needs to be extact keyword or phrase. + Returns minimal fields required for source selection. + + Args: + agent: The agent instance + topic: The search topic + limit: Maximum number of results to return (default: 10) + + Returns: + Search results matching agent's expected format + """ + print(f"Social Media News Search: {topic}") + try: + days_back: int = 7 + date_from = (datetime.now() - timedelta(days=days_back)).isoformat() + with get_social_media_db() as conn: + cursor = conn.cursor() + sql_query = """ + SELECT + post_id, + user_display_name, + post_timestamp, + post_url, + post_text, + platform + FROM posts + WHERE + categories LIKE '%"news"%' + AND sentiment = 'positive' + AND datetime(post_timestamp) >= datetime(?) + AND (post_text LIKE ? OR user_display_name LIKE ?) + ORDER BY datetime(post_timestamp) DESC + LIMIT ? + """ + search_term = f"%{topic}%" + cursor.execute(sql_query, (date_from, search_term, search_term, limit)) + rows = cursor.fetchall() + if not rows: + return f"No positive news posts found for '{topic}' in the last {days_back} days." + results = [] + for row in rows: + result = { + "id": f"social_{row['post_id']}", + "url": row["post_url"] or f"https://{row['platform']}/post/{row['post_id']}", + "published_date": row["post_timestamp"], + "description": row["post_text"][:200] + "..." if len(row["post_text"]) > 200 else row["post_text"], + "source_id": "social_media_db", + "source_name": f"{row['platform'].title()}", + "categories": ["news"], + "is_scrapping_required": False, + } + results.append(result) + return f"Found {len(results)} positive news posts. {json.dumps({'results': results}, indent=2)}" + except Exception as e: + return f"Error searching social media database: {str(e)}" + + +def social_media_trending_search(agent: Agent, limit: int = 10) -> str: + """ + Get trending positive news posts from social media. + Returns trending news posts in standard results format. + + + Args: + agent: The agent instance + limit: Maximum number of trending results (default: 10) + + Returns: + Trending positive news posts + """ + print(f"Social Media Trending Search: {limit}") + days_back = 3 + try: + date_from = (datetime.now() - timedelta(days=days_back)).isoformat() + with get_social_media_db() as conn: + cursor = conn.cursor() + trending_sql = """ + SELECT + post_id, + user_display_name, + post_timestamp, + post_url, + post_text, + platform + FROM posts + WHERE + categories LIKE '%"news"%' + AND sentiment = 'positive' + AND datetime(post_timestamp) >= datetime(?) + ORDER BY + (COALESCE(engagement_like_count, 0) + + COALESCE(engagement_retweet_count, 0) + + COALESCE(engagement_reply_count, 0)) DESC, + datetime(post_timestamp) DESC + LIMIT ? + """ + cursor.execute(trending_sql, (date_from, limit)) + rows = cursor.fetchall() + if not rows: + return f"No trending positive news found in the last {days_back} days." + results = [] + for row in rows: + result = { + "id": f"social_{row['post_id']}", + "url": row["post_url"] or f"https://{row['platform']}/post/{row['post_id']}", + "published_date": row["post_timestamp"], + "description": row["post_text"], + "source_id": "social_media_trending", + "source_name": f"{row['platform'].title()} Trending", + "categories": ["news"], + "is_scrapping_required": False, + } + results.append(result) + return f"Found {len(results)} trending positive news posts. {json.dumps({'results': results}, indent=2)}" + except Exception as e: + return f"Error getting trending news: {str(e)}" + + +if __name__ == "__main__": + print("here...") + print(social_media_trending_search(None, 5)) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/ui_manager.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/ui_manager.py new file mode 100644 index 0000000..b5ac257 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/ui_manager.py @@ -0,0 +1,33 @@ +from agno.agent import Agent +from dotenv import load_dotenv +from db.agent_config_v2 import TOGGLE_UI_STATES + +load_dotenv() + + +def ui_manager_run( + agent: Agent, + state_type: str, + active: bool, +) -> str: + """ + UI Manager that takes the state_type and active as input and updates the sessions UI state. + Args: + agent: The agent instance + state_type: The state type to update + active: The active state + Returns: + Response status + """ + from services.internal_session_service import SessionService + + session_id = agent.session_id + session = SessionService.get_session(session_id) + current_state = session["state"] + current_state[state_type] = active + all_ui_states = TOGGLE_UI_STATES + for ui_state in all_ui_states: + if ui_state != state_type: + current_state[ui_state] = False + SessionService.save_session(session_id, current_state) + return f"Updated {state_type} to {active}{' and all other UI states to False' if all_ui_states else ''}." \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/user_source_selection.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/user_source_selection.py new file mode 100644 index 0000000..e2de222 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/user_source_selection.py @@ -0,0 +1,30 @@ +from agno.agent import Agent +from dotenv import load_dotenv +from typing import List + +load_dotenv() + + +def user_source_selection_run( + agent: Agent, + selected_sources: List[int], +) -> str: + """ + User Source Selection that takes the selected sources indices as input and updates the final confirmed sources. + Args: + agent: The agent instance + selected_sources: The selected sources indices + Returns: + Response status + """ + from services.internal_session_service import SessionService + session_id = agent.session_id + session = SessionService.get_session(session_id) + session_state = session["state"] + for i, src in enumerate(session_state["search_results"]): + if (i+1) in selected_sources: + src["confirmed"] = True + else: + src["confirmed"] = False + SessionService.save_session(session_id, session_state) + return f"Updated selected sources to {selected_sources}." diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/web_search.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/web_search.py new file mode 100644 index 0000000..9d1c605 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/web_search.py @@ -0,0 +1,99 @@ +import os +import asyncio +from typing import List +from pydantic import BaseModel, Field +from browser_use import Agent as BrowserAgent, Controller, BrowserSession, BrowserProfile +from langchain_openai import ChatOpenAI +from dotenv import load_dotenv +from agno.agent import Agent + + +import json + +load_dotenv() + +BROWSER_AGENT_MODEL = "gpt-4o" +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +MAX_STEPS = 15 +MAX_ACTIONS_PER_STEP = 5 +USER_DATA_DIR = "browsers/playwright_persistent_profile_web" + + +class WebSearchResult(BaseModel): + title: str = Field(..., description="The title of the search result") + url: str = Field(..., description="The URL of the search result") + content: str = Field(..., description="Full content of the source, be elaborate at the same time be concise") + + +class WebSearchResults(BaseModel): + results: List[WebSearchResult] = Field(..., description="List of search results") + + +def run_browser_search(agent: Agent, instruction: str) -> str: + """ + Run browser search to get the results. + Args: + agent: The agent instance + instruction: The instruction to run the browser search, give detailed step by step prompt on how to collect the information. + Returns: + The results of the browser search + """ + print("Browser Search Input:", instruction) + try: + controller = Controller(output_model=WebSearchResults) + session_id = agent.session_id + recordings_dir = os.path.join("podcasts/recordings", session_id) + os.makedirs(recordings_dir, exist_ok=True) + + headless = True + browser_profile = BrowserProfile( + user_data_dir=USER_DATA_DIR, headless=headless, viewport={"width": 1280, "height": 800}, record_video_dir=recordings_dir, + downloads_path="podcasts/browseruse_downloads", + ) + + browser_session = BrowserSession( + browser_profile=browser_profile, + headless=headless, + disable_security=False, + record_video=True, + record_video_dir=recordings_dir, + ) + + browser_agent = BrowserAgent( + browser_session=browser_session, + task=instruction, + llm=ChatOpenAI(model=BROWSER_AGENT_MODEL, api_key=os.getenv("OPENAI_API_KEY")), + use_vision=False, + controller=controller, + max_actions_per_step=MAX_ACTIONS_PER_STEP, + ) + try: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + history = loop.run_until_complete(browser_agent.run(max_steps=MAX_STEPS)) + except RuntimeError: + history = asyncio.run(browser_agent.run(max_steps=MAX_STEPS)) + result = history.final_result() + if result: + parsed: WebSearchResults = WebSearchResults.model_validate_json(result) + results_list = [ + {"title": post.title, "url": post.url, "description": post.content, "is_scrapping_required": False} for post in parsed.results + ] + return f"is_scrapping_required: False, results: {json.dumps(results_list)}" + else: + return "No results found, something went wrong with browser based search." + except Exception as e: + return f"Error running browser search: {e}" + finally: + pass + + +def main(): + return run_browser_search(agent={"session_id": "123"}, instruction="gene therapy") + + +if __name__ == "__main__": + print(main()) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/wikipedia_search.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/wikipedia_search.py new file mode 100644 index 0000000..642c734 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/tools/wikipedia_search.py @@ -0,0 +1,62 @@ +from agno.agent import Agent + + +def wikipedia_search(agent: Agent, query: str, srlimit: int = 5) -> str: + """ + Search Wikipedia for articles using Wikipedia API. + Returns only links and short summaries without fetching full content. + + Args: + agent: The agent instance + query: The search query + srlimit: The number of results to return + + Returns: + A formatted string response with the search results (link and gist only) + """ + import requests + import html + import json + + print("Wikipedia Search Input:", query) + try: + search_url = "https://en.wikipedia.org/w/api.php" + search_params = { + "action": "query", + "format": "json", + "list": "search", + "srsearch": query, + "srlimit": srlimit, + "utf8": 1, + } + search_response = requests.get(search_url, params=search_params) + search_data = search_response.json() + if ( + "query" not in search_data + or "search" not in search_data["query"] + or not search_data["query"]["search"] + ): + print(f"No Wikipedia results found for query: {query}") + return "No relevant Wikipedia articles found for this topic." + results = [] + for item in search_data["query"]["search"]: + title = item["title"] + snippet = html.unescape( + item["snippet"] + .replace('', "") + .replace("", "") + ) + url = f"https://en.wikipedia.org/wiki/{title.replace(' ', '_')}" + result = { + "title": title, + "url": url, + "gist": snippet, + "source": "Wikipedia", + } + results.append(result) + if not results: + return "No Wikipedia search results found." + return f"for all results is_scrapping_required: True, results: {json.dumps(results, ensure_ascii=False, indent=2)}" + except Exception as e: + print(f"Error during Wikipedia search: {str(e)}") + return f"Error in Wikipedia search: {str(e)}" diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/__init__.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/crawl_url.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/crawl_url.py new file mode 100644 index 0000000..390e3ed --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/crawl_url.py @@ -0,0 +1,70 @@ +import requests +from bs4 import BeautifulSoup +import random +from typing import Dict, List, TypedDict + + +class MetadataDict(TypedDict): + title: str + description: str + og: Dict[str, str] + twitter: Dict[str, str] + other_meta: Dict[str, str] + + +class WebData(TypedDict): + raw_html: str + metadata: MetadataDict + + +USER_AGENTS: List[str] = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36", +] +HEADERS: Dict[str, str] = { + "User-Agent": random.choice(USER_AGENTS), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "DNT": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", +} + + +def extract_meta_tags(soup: BeautifulSoup) -> MetadataDict: + metadata: MetadataDict = { + "title": "", + "description": "", + "og": {}, + "twitter": {}, + "other_meta": {}, + } + title_tag = soup.find("title") + if title_tag: + metadata["title"] = title_tag.text.strip() + for meta in soup.find_all("meta"): + name = meta.get("name", "").lower() + prop = meta.get("property", "").lower() + content = meta.get("content", "") + if prop.startswith("ogg:"): + og_key = prop[3:] + metadata["og"][og_key] = content + elif prop.startswith("twitter:") or name.startswith("twitter:"): + twitter_key = prop[8:] if prop.startswith("twitter:") else name[8:] + metadata["twitter"][twitter_key] = content + elif name in ["description", "keywords", "author", "robots", "viewport"]: + metadata["other_meta"][name] = content + if name == "description": + metadata["description"] = content + return metadata + + +def get_web_data(url: str) -> WebData: + HEADERS["User-Agent"] = random.choice(USER_AGENTS) + response = requests.get(url, headers=HEADERS, timeout=10) + soup = BeautifulSoup(response.text, "html.parser") + metadata = extract_meta_tags(soup) + body = str(soup.find("body")) + return {"raw_html": body, "metadata": metadata} diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/get_articles.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/get_articles.py new file mode 100644 index 0000000..e19d321 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/get_articles.py @@ -0,0 +1,190 @@ +import sqlite3 +import openai +import json +from datetime import datetime, timedelta +from typing import List, Dict, Any + +TOPIC_EXTRACTION_MODEL = "gpt-4o-mini" + + +def extract_search_terms(prompt: str, api_key: str, max_terms: int = 10) -> list: + client = openai.OpenAI(api_key=api_key) + system_msg = ( + "analyze the user's request and extract up to " + f"{max_terms} key search terms or phrases (focus on nouns and concepts). " + "Include broad variations and synonyms to increase match chances. " + "For very specific topics, add general category terms too. " + "output only a json object following this exact schema: " + "{'terms': ['term1','term2',...]}. no additional keys or text." + ) + try: + resp = client.chat.completions.create( + model=TOPIC_EXTRACTION_MODEL, + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": prompt}, + ], + temperature=0.3, + response_format={"type": "json_object"}, + ) + parsed = json.loads(resp.choices[0].message.content.strip()) + if isinstance(parsed, dict) and isinstance(parsed.get("terms"), list): + return parsed["terms"] + except Exception as e: + print(f"Error extracting search terms: {e}") + return [prompt.strip()] + + +def search_articles( + prompt: str, + db_path: str, + api_key: str, + operator: str = "OR", + limit: int = 20, + from_date: str = None, + use_categories: bool = True, + fallback_to_broader: bool = True, +) -> List[Dict[str, Any]]: + if from_date is None: + from_date = (datetime.now() - timedelta(hours=48)).isoformat() + terms = extract_search_terms(prompt, api_key) + if not terms: + return [] + print(f"Search terms: {terms}") + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + results = [] + try: + results = _execute_search(cursor, terms, from_date, operator, limit, use_categories) + if fallback_to_broader and len(results) < min(5, limit): + print(f"Initial search returned only {len(results)} results. Trying broader search...") + if operator == "AND": + broader_results = _execute_search( + cursor, + terms, + from_date, + "OR", + limit, + use_categories=True, + ) + if len(broader_results) > len(results): + print(f"Broader search found {len(broader_results)} results") + results = broader_results + if results: + _add_source_names(cursor, results) + for article in results: + article["categories"] = _get_article_categories(cursor, article["id"]) + except Exception as e: + print(f"Error searching articles: {e}") + finally: + conn.close() + return results + + +def _execute_search( + cursor, + terms, + from_date, + operator, + limit, + use_categories=True, + partial_match=False, + days_fallback=0, +): + if days_fallback > 0: + try: + from_date_obj = datetime.fromisoformat(from_date.replace("Z", "").split("+")[0]) + adjusted_date = (from_date_obj - timedelta(days=days_fallback)).isoformat() + from_date = adjusted_date + except Exception as e: + print(f"Warning: Could not adjust date with fallback: {e}") + base_query = """ + SELECT DISTINCT ca.id, ca.title, ca.url, ca.published_date, ca.summary as content, + ca.source_id, ca.feed_id + FROM crawled_articles ca + WHERE ca.processed = 1 AND ca.published_date >= ? + """ + if use_categories: + base_query = """ + SELECT DISTINCT ca.id, ca.title, ca.url, ca.published_date, ca.summary as content, + ca.source_id, ca.feed_id + FROM crawled_articles ca + LEFT JOIN article_categories ac ON ca.id = ac.article_id + WHERE ca.processed = 1 AND ca.published_date >= ? + """ + clauses, params = [], [from_date] + for term in terms: + term_clauses = [] + like = f"%{term}%" + term_clauses.append("(ca.title LIKE ? OR ca.content LIKE ? OR ca.summary LIKE ?)") + params.extend([like, like, like]) + if use_categories: + term_clauses.append("(ac.category_name LIKE ?)") + params.append(like) + if term_clauses: + clauses.append(f"({' OR '.join(term_clauses)})") + where = f" {operator} ".join(clauses) + sql = f"{base_query} AND ({where}) ORDER BY ca.published_date DESC LIMIT {limit}" + cursor.execute(sql, params) + return [dict(row) for row in cursor.fetchall()] + + +def _add_source_names(cursor, articles): + source_ids = {a.get("source_id") for a in articles if a.get("source_id")} + feed_ids = {a.get("feed_id") for a in articles if a.get("feed_id")} + if not source_ids and not feed_ids: + return + source_names = {} + if source_ids: + source_ids = [id for id in source_ids if id is not None] + if source_ids: + placeholders = ",".join(["?"] * len(source_ids)) + try: + cursor.execute( + f"SELECT id, name FROM sources WHERE id IN ({placeholders})", + list(source_ids), + ) + for row in cursor.fetchall(): + source_names[row["id"]] = row["name"] + except Exception as e: + print(f"Error fetching source names: {e}") + if feed_ids: + feed_ids = [id for id in feed_ids if id is not None] + if feed_ids: + placeholders = ",".join(["?"] * len(feed_ids)) + try: + cursor.execute( + f""" + SELECT sf.id, s.name + FROM source_feeds sf + JOIN sources s ON sf.source_id = s.id + WHERE sf.id IN ({placeholders}) + """, + list(feed_ids), + ) + for row in cursor.fetchall(): + source_names[row["id"]] = row["name"] + except Exception as e: + print(f"Error fetching feed source names: {e}") + for article in articles: + source_id = article.get("source_id") + feed_id = article.get("feed_id") + if source_id and source_id in source_names: + article["source_name"] = source_names[source_id] + elif feed_id and feed_id in source_names: + article["source_name"] = source_names[feed_id] + else: + article["source_name"] = "Unknown Source" + + +def _get_article_categories(cursor, article_id): + try: + cursor.execute( + "SELECT category_name FROM article_categories WHERE article_id = ?", + (article_id,), + ) + return [row["category_name"] for row in cursor.fetchall()] + except Exception as e: + print(f"Error fetching article categories: {e}") + return [] diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/load_api_keys.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/load_api_keys.py new file mode 100644 index 0000000..560e9a1 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/load_api_keys.py @@ -0,0 +1,9 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + + +def load_api_key(key_name="OPENAI_API_KEY"): + env_path = Path(".") / ".env" + load_dotenv(dotenv_path=env_path) + return os.environ.get(key_name) diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/rss_feed_parser.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/rss_feed_parser.py new file mode 100644 index 0000000..e5a53d8 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/rss_feed_parser.py @@ -0,0 +1,76 @@ +import feedparser +from datetime import datetime +import hashlib +from typing import List, Dict, Any, Optional + + +def get_hash(entries: List[Dict[str, str]]) -> str: + texts = "" + for entry in entries: + texts += ( + str(entry.get("id", "")) + + str(entry.get("title", "")) + + str(entry.get("published_date", "")) + ) + return hashlib.md5(texts.encode()).hexdigest() + + +def parse_feed_entries(entries: List[Dict[str, Any]]) -> List[Dict[str, str]]: + parsed_entries = [] + for entry in entries: + content = entry.get("content") or entry.get("description") or "" + published = ( + entry.get("published") + or entry.get("updated") + or entry.get("pubDate") + or entry.get("created") + or datetime.now().isoformat() + ) + entry_id = entry.get("id") or entry.get("link", "") + link = entry.get("link", "") + summary = entry.get("summary", "") + title = entry.get("title", "") + parsed_entries.append( + { + "title": title, + "link": link, + "summary": summary, + "content": content, + "published_date": published, + "entry_id": entry_id, + } + ) + return parsed_entries + + +def is_rss_feed(feed_data: Any) -> bool: + return feed_data.bozo and hasattr(feed_data, "bozo_exception") + + +def get_feed_data( + feed_url: str, etag: Optional[str] = None, modified: Optional[Any] = None +) -> Dict[str, Any]: + feed_data = feedparser.parse(feed_url, etag=etag, modified=modified) + if is_rss_feed(feed_data): + return { + "is_rss_feed": False, + "parsed_entries": None, + "modified": None, + "status": None, + "current_hash": None, + "etag": None, + } + status = feed_data.get("status", 200) + etag = feed_data.get("etag", None) + modified = feed_data.get("modified") + entries = feed_data.get("entries", []) + parsed_entries = parse_feed_entries(entries) + current_hash = get_hash(parsed_entries) + return { + "parsed_entries": parsed_entries, + "modified": modified, + "status": status, + "current_hash": current_hash, + "etag": etag, + "is_rss_feed": True, + } diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_elevenslab.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_elevenslab.py new file mode 100644 index 0000000..e845d22 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_elevenslab.py @@ -0,0 +1,196 @@ +import os +from typing import List, Tuple, Optional, Any +import tempfile +import numpy as np +import soundfile as sf +from elevenlabs.client import ElevenLabs + +TEXT_TO_SPEECH_MODEL = "eleven_multilingual_v2" + + +def create_silence_audio(silence_duration: float, sampling_rate: int) -> np.ndarray: + if sampling_rate <= 0: + print(f"Warning: Invalid sampling rate ({sampling_rate}) for silence generation") + return np.zeros(0, dtype=np.float32) + return np.zeros(int(sampling_rate * silence_duration), dtype=np.float32) + + +def combine_audio_segments(audio_segments: List[np.ndarray], silence_duration: float, sampling_rate: int) -> np.ndarray: + if not audio_segments: + return np.zeros(0, dtype=np.float32) + if sampling_rate <= 0: + combined = np.concatenate(audio_segments) if audio_segments else np.zeros(0, dtype=np.float32) + else: + silence = create_silence_audio(silence_duration, sampling_rate) + combined_with_silence = [] + for i, segment in enumerate(audio_segments): + combined_with_silence.append(segment) + if i < len(audio_segments) - 1: + combined_with_silence.append(silence) + combined = np.concatenate(combined_with_silence) + max_amp = np.max(np.abs(combined)) + if max_amp > 0: + combined = combined / max_amp * 0.95 + return combined + + +def write_to_disk(output_path: str, audio_data: np.ndarray, sampling_rate: int) -> None: + if sampling_rate <= 0: + print(f"Error: Cannot write audio file with invalid sampling rate ({sampling_rate})") + return + try: + sf.write(output_path, audio_data, sampling_rate) + except Exception as e: + print(f"Error writing audio file '{output_path}': {e}") + + +def text_to_speech_elevenlabs( + client: ElevenLabs, + text: str, + speaker_id: int, + voice_map={1: "Rachel", 2: "Adam"}, + model_id: str = TEXT_TO_SPEECH_MODEL, +) -> Optional[Tuple[np.ndarray, int]]: + if not text.strip(): + return None + voice_name_or_id = voice_map.get(speaker_id) + if not voice_name_or_id: + print(f"No voice found for speaker_id {speaker_id}") + return None + try: + from pydub import AudioSegment + + pydub_available = True + except ImportError: + pydub_available = False + try: + audio_generator = client.generate( + text=text, + voice=voice_name_or_id, + model=model_id, + stream=True, + ) + audio_chunks = [] + for chunk in audio_generator: + if chunk: + audio_chunks.append(chunk) + if not audio_chunks: + return None + audio_data = b"".join(audio_chunks) + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_file: + temp_path = temp_file.name + temp_file.write(audio_data) + if pydub_available: + try: + audio_segment = AudioSegment.from_mp3(temp_path) + channels = audio_segment.channels + sample_width = audio_segment.sample_width + frame_rate = audio_segment.frame_rate + samples = np.array(audio_segment.get_array_of_samples()) + if channels == 2: + samples = samples.reshape(-1, 2).mean(axis=1) + max_possible_value = float(2 ** (8 * sample_width - 1)) + samples = samples.astype(np.float32) / max_possible_value + os.unlink(temp_path) + return samples, frame_rate + except Exception as pydub_error: + print(f"Pydub processing failed: {pydub_error}") + try: + audio_np, samplerate = sf.read(temp_path) + os.unlink(temp_path) + return audio_np, samplerate + except Exception as _: + if pydub_available: + try: + sound = AudioSegment.from_mp3(temp_path) + wav_path = temp_path.replace(".mp3", ".wav") + sound.export(wav_path, format="wav") + + audio_np, samplerate = sf.read(wav_path) + os.unlink(temp_path) + os.unlink(wav_path) + return audio_np, samplerate + except Exception: + pass + if os.path.exists(temp_path): + os.unlink(temp_path) + return None + + except Exception as e: + print(f"Error during ElevenLabs API call: {e}") + import traceback + + traceback.print_exc() + return None + + +def create_podcast( + script: Any, + output_path: str, + silence_duration: float = 0.7, + sampling_rate: int = 24_000, + lang_code: str = "en", + elevenlabs_model: str = "eleven_multilingual_v2", + voice_map: dict = {1: "Rachel", 2: "Adam"}, + api_key: str = None, +) -> str: + if not api_key: + print("Warning: Using hardcoded API key") + try: + client = ElevenLabs(api_key=api_key) + try: + voices = client.voices.get_all() + print(f"API connection successful. Found {len(voices)} available voices.") + except Exception as voice_error: + print(f"Warning: Could not retrieve voices: {voice_error}") + except Exception as e: + print(f"Fatal Error: Failed to initialize ElevenLabs client: {e}") + return None + output_path = os.path.abspath(output_path) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + generated_segments = [] + determined_sampling_rate = -1 + entries = script.entries if hasattr(script, "entries") else script + for i, entry in enumerate(entries): + if hasattr(entry, "speaker"): + speaker_id = entry.speaker + entry_text = entry.text + else: + speaker_id = entry["speaker"] + entry_text = entry["text"] + result = text_to_speech_elevenlabs( + client=client, + text=entry_text, + speaker_id=speaker_id, + voice_map=voice_map, + model_id=elevenlabs_model, + ) + if result: + segment_audio, segment_rate = result + + if determined_sampling_rate == -1: + determined_sampling_rate = segment_rate + elif determined_sampling_rate != segment_rate: + try: + import librosa + + segment_audio = librosa.resample( + segment_audio, + orig_sr=segment_rate, + target_sr=determined_sampling_rate, + ) + except ImportError: + determined_sampling_rate = segment_rate + except Exception: + pass + generated_segments.append(segment_audio) + if not generated_segments or determined_sampling_rate <= 0: + return None + full_audio = combine_audio_segments(generated_segments, silence_duration, determined_sampling_rate) + if full_audio.size == 0: + return None + write_to_disk(output_path, full_audio, determined_sampling_rate) + if os.path.exists(output_path): + return output_path + else: + return None diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_kokoro.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_kokoro.py new file mode 100644 index 0000000..0a8d45b --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_kokoro.py @@ -0,0 +1,110 @@ +# ruff: noqa: E402 +import os +import warnings +from typing import List, Any +import numpy as np +import soundfile as sf +from .translate_podcast import translate_script + +os.environ["PYTHONWARNINGS"] = "ignore" +os.environ["TORCH_CPP_LOG_LEVEL"] = "ERROR" +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" +warnings.filterwarnings("ignore") + +from kokoro import KPipeline + + +class ScriptEntry: + def __init__(self, text: str, speaker: int): + self.text = text + self.speaker = speaker + + +def create_slience_audio(silence_duration: float, sampling_rate: int) -> np.ndarray: + return np.zeros(int(sampling_rate * silence_duration), dtype=np.float32) + + +def text_to_speech(pipeline: KPipeline, text: str, speaker_id: int, sampling_rate: int, lang_code: str) -> np.ndarray: + if lang_code == "h": + voices = {1: "hf_alpha", 2: "hm_omega"} + else: + voices = {1: "af_heart", 2: "bm_lewis"} + voice = voices[speaker_id] + audio_chunks = [] + for _, _, audio in pipeline(text, voice=voice, speed=1.0): + if audio is not None: + audio_chunk = np.array(audio, dtype=np.float32) + if np.max(np.abs(audio_chunk)) > 0: + audio_chunk = audio_chunk / np.max(np.abs(audio_chunk)) * 0.9 + audio_chunks.append(audio_chunk) + if audio_chunks: + return np.concatenate(audio_chunks) + else: + return np.zeros(0, dtype=np.float32) + + +def create_audio_segments( + pipeline: KPipeline, + script: Any, + silence_duration: float, + sampling_rate: int, + lang_code: str, +) -> List[np.ndarray]: + audio_segments = [] + silence = create_slience_audio(silence_duration, sampling_rate) + entries = script if isinstance(script, list) else script.entries + for entry in entries: + text = entry["text"] if isinstance(entry, dict) else entry.text + speaker = entry["speaker"] if isinstance(entry, dict) else entry.speaker + segment_audio = text_to_speech( + pipeline, + text, + speaker, + sampling_rate=sampling_rate, + lang_code=lang_code, + ) + if len(segment_audio) > 0: + try: + audio_segments.append(segment_audio) + except Exception: + fallback_silence = create_slience_audio(len(text) * 0.1, sampling_rate) + audio_segments.append(fallback_silence) + audio_segments.append(silence) + return audio_segments + + +def combine_full_segments(audio_segments: List[np.ndarray]) -> np.ndarray: + full_audio = np.concatenate(audio_segments) + max_amp = np.max(np.abs(full_audio)) + if max_amp > 0: + full_audio = full_audio / max_amp * 0.9 + return full_audio + + +def write_to_disk(output_path: str, full_audio: np.ndarray, sampling_rate: int) -> None: + return sf.write(output_path, full_audio, sampling_rate, subtype="PCM_16") + + +def create_podcast( + script: Any, + output_path: str, + silence_duration: float = 0.7, + sampling_rate: int = 24_000, + lang_code: str = "b", +) -> str: + pipeline = KPipeline(lang_code=lang_code) + if lang_code != "b": + if isinstance(script, list): + script = translate_script(script, lang_code) + else: + script = translate_script(script.entries, lang_code) + output_path = os.path.abspath(output_path) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + audio_segments = create_audio_segments(pipeline, script, silence_duration, sampling_rate, lang_code) + full_audio = combine_full_segments(audio_segments) + write_to_disk(output_path, full_audio, sampling_rate) + return output_path + + +if __name__ == "__main__": + create_podcast("", "output.wav") \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_openai.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_openai.py new file mode 100644 index 0000000..95cf595 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/text_to_audio_openai.py @@ -0,0 +1,210 @@ +import os +from typing import List, Optional, Tuple, Dict, Any +import tempfile +import numpy as np +import soundfile as sf +from openai import OpenAI +from utils.load_api_keys import load_api_key + +OPENAI_VOICES = {1: "alloy", 2: "echo", 3: "fable", 4: "onyx", 5: "nova", 6: "shimmer"} +DEFAULT_VOICE_MAP = {1: "alloy", 2: "nova"} +TEXT_TO_SPEECH_MODEL = "gpt-4o-mini-tts" + + +def create_silence_audio(silence_duration: float, sampling_rate: int) -> np.ndarray: + if sampling_rate <= 0: + print(f"WARNING: Invalid sampling rate ({sampling_rate}) for silence generation") + return np.zeros(0, dtype=np.float32) + return np.zeros(int(sampling_rate * silence_duration), dtype=np.float32) + + +def combine_audio_segments(audio_segments: List[np.ndarray], silence_duration: float, sampling_rate: int) -> np.ndarray: + if not audio_segments: + return np.zeros(0, dtype=np.float32) + silence = create_silence_audio(silence_duration, sampling_rate) + combined_segments = [] + for i, segment in enumerate(audio_segments): + combined_segments.append(segment) + if i < len(audio_segments) - 1: + combined_segments.append(silence) + combined = np.concatenate(combined_segments) + max_amp = np.max(np.abs(combined)) + if max_amp > 0: + combined = combined / max_amp * 0.95 + return combined + + +def text_to_speech_openai( + client: OpenAI, + text: str, + speaker_id: int, + voice_map: Dict[int, str] = None, + model: str = TEXT_TO_SPEECH_MODEL, +) -> Optional[Tuple[np.ndarray, int]]: + if not text.strip(): + print("WARNING: Empty text provided, skipping TTS generation") + return None + voice_map = voice_map or DEFAULT_VOICE_MAP + voice = voice_map.get(speaker_id) + if not voice: + if speaker_id in OPENAI_VOICES: + voice = OPENAI_VOICES[speaker_id] + else: + voice = next(iter(voice_map.values()), "alloy") + print(f"WARNING: No voice mapping for speaker {speaker_id}, using {voice}") + try: + print(f"INFO: Generating TTS for speaker {speaker_id} using voice '{voice}'") + response = client.audio.speech.create( + model=model, + voice=voice, + input=text, + response_format="mp3", + ) + audio_data = response.content + if not audio_data: + print("ERROR: OpenAI TTS returned empty response") + return None + print(f"INFO: Received {len(audio_data)} bytes from OpenAI TTS") + with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as temp_file: + temp_path = temp_file.name + temp_file.write(audio_data) + try: + from pydub import AudioSegment + + audio_segment = AudioSegment.from_mp3(temp_path) + channels = audio_segment.channels + sample_width = audio_segment.sample_width + frame_rate = audio_segment.frame_rate + samples = np.array(audio_segment.get_array_of_samples()) + if channels == 2: + samples = samples.reshape(-1, 2).mean(axis=1) + max_possible_value = float(2 ** (8 * sample_width - 1)) + samples = samples.astype(np.float32) / max_possible_value + os.unlink(temp_path) + return samples, frame_rate + except ImportError: + print("WARNING: Pydub not available, falling back to soundfile") + except Exception as e: + print(f"ERROR: Pydub processing failed: {e}") + try: + audio_np, samplerate = sf.read(temp_path) + os.unlink(temp_path) + return audio_np, samplerate + except Exception as e: + print(f"ERROR: Failed to process audio with soundfile: {e}") + try: + from pydub import AudioSegment + + sound = AudioSegment.from_mp3(temp_path) + wav_path = temp_path.replace(".mp3", ".wav") + sound.export(wav_path, format="wav") + audio_np, samplerate = sf.read(wav_path) + os.unlink(temp_path) + os.unlink(wav_path) + return audio_np, samplerate + except Exception as e: + print(f"ERROR: All audio processing methods failed: {e}") + if os.path.exists(temp_path): + os.unlink(temp_path) + return None + + except Exception as e: + print(f"ERROR: OpenAI TTS API error: {e}") + import traceback + + traceback.print_exc() + return None + + +def create_podcast( + script: Any, + output_path: str, + silence_duration: float = 0.7, + sampling_rate: int = 24000, + lang_code: str = "en", + model: str = TEXT_TO_SPEECH_MODEL, + voice_map: Dict[int, str] = None, + api_key: str = None, +) -> Optional[str]: + try: + if not api_key: + api_key = load_api_key() + if not api_key: + print("ERROR: No OpenAI API key provided") + return None + client = OpenAI(api_key=api_key) + print("INFO: OpenAI client initialized") + except Exception as e: + print(f"ERROR: Failed to initialize OpenAI client: {e}") + return None + output_path = os.path.abspath(output_path) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + if voice_map is None: + voice_map = DEFAULT_VOICE_MAP.copy() + model_to_use = model + if model == "tts-1" and lang_code == "en": + model_to_use = "tts-1-hd" + print(f"INFO: Using high-definition TTS model for English: {model_to_use}") + generated_segments = [] + sampling_rate_detected = None + entries = script.entries if hasattr(script, "entries") else script + print(f"INFO: Processing {len(entries)} script entries") + for i, entry in enumerate(entries): + if hasattr(entry, "speaker"): + speaker_id = entry.speaker + entry_text = entry.text + else: + speaker_id = entry["speaker"] + entry_text = entry["text"] + print(f"INFO: Processing entry {i + 1}/{len(entries)}: Speaker {speaker_id}") + result = text_to_speech_openai( + client=client, + text=entry_text, + speaker_id=speaker_id, + voice_map=voice_map, + model=model_to_use, + ) + if result: + segment_audio, segment_rate = result + if sampling_rate_detected is None: + sampling_rate_detected = segment_rate + print(f"INFO: Using sample rate: {sampling_rate_detected} Hz") + elif sampling_rate_detected != segment_rate: + print(f"WARNING: Sample rate mismatch: {sampling_rate_detected} vs {segment_rate}") + try: + import librosa + + segment_audio = librosa.resample(segment_audio, orig_sr=segment_rate, target_sr=sampling_rate_detected) + print(f"INFO: Resampled to {sampling_rate_detected} Hz") + except ImportError: + sampling_rate_detected = segment_rate + print(f"WARNING: Librosa not available for resampling, using {segment_rate} Hz") + except Exception as e: + print(f"ERROR: Resampling failed: {e}") + generated_segments.append(segment_audio) + else: + print(f"WARNING: Failed to generate audio for entry {i + 1}") + if not generated_segments: + print("ERROR: No audio segments were generated") + return None + if sampling_rate_detected is None: + print("ERROR: Could not determine sample rate") + return None + print(f"INFO: Combining {len(generated_segments)} audio segments") + full_audio = combine_audio_segments(generated_segments, silence_duration, sampling_rate_detected) + if full_audio.size == 0: + print("ERROR: Combined audio is empty") + return None + print(f"INFO: Writing audio to {output_path}") + try: + sf.write(output_path, full_audio, sampling_rate_detected) + except Exception as e: + print(f"ERROR: Failed to write audio file: {e}") + return None + if os.path.exists(output_path): + file_size = os.path.getsize(output_path) + print(f"INFO: Audio file created: {output_path} ({file_size / 1024:.1f} KB)") + return output_path + else: + print(f"ERROR: Failed to create audio file at {output_path}") + return None diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/translate_podcast.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/translate_podcast.py new file mode 100644 index 0000000..7728645 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/translate_podcast.py @@ -0,0 +1,58 @@ +import json +from typing import List, Dict, Any +from openai import OpenAI +from utils.load_api_keys import load_api_key + +TRANSLATION_MODEL = "gpt-4o" +LANG_CODE_TO_NAME = { + "en": "English", + "h": "Hindi", + "b": None, + "es": "Spanish", + "fr": "French", + "de": "German", + "ru": "Russian", + "ja": "Japanese", + "zh": "Chinese", + "it": "Italian", + "pt": "Portuguese", + "ar": "Arabic", +} + + +def translate_script(script: List[Dict[str, Any]], lang_code: str = "b") -> List[Dict[str, Any]]: + script = [{"text": e["text"], "speaker": e["speaker"]} for e in script] + target_lang = LANG_CODE_TO_NAME.get(lang_code) + if target_lang is None: + return script + api_key = load_api_key("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEY environment variable not set") + client = OpenAI(api_key=api_key) + prompt = f"""Translate the following podcast script from English to {target_lang} also keep the characters espeak compatible. + Maintain the exact same structure and keep the 'speaker' values identical. + Only translate the text in each entry's 'text' field. and return the json output with translated json format (keys also will be in english only text value will be translated). + + Input script: + {json.dumps(script, indent=2)} + """ + response = client.chat.completions.create( + model=TRANSLATION_MODEL, + messages=[ + {"role": "system", "content": "You are a professional translator specializing in podcast content."}, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.3, + ) + response_content = response.choices[0].message.content + response_data = json.loads(response_content) + if "script" in response_data: + translated_script = response_data["script"] + else: + translated_script = response_data + if not isinstance(translated_script, list): + raise ValueError("Unexpected response format: not a list") + if len(translated_script) != len(script): + raise ValueError(f"Translation returned {len(translated_script)} entries, expected {len(script)}") + return translated_script diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/tts_engine_selector.py b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/tts_engine_selector.py new file mode 100644 index 0000000..c5fd6b5 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/beifong/utils/tts_engine_selector.py @@ -0,0 +1,84 @@ +import os +from typing import Any, Callable, Optional +from utils.load_api_keys import load_api_key + +_TTS_ENGINES = {} +TTS_OPENAI_MODEL = "gpt-4o-mini-tts" +TTS_ELEVENLABS_MODEL = "eleven_multilingual_v2" + + +def register_tts_engine(name: str, generator_func: Callable): + _TTS_ENGINES[name.lower()] = generator_func + + +def generate_podcast_audio( + script: Any, output_path: str, tts_engine: str = "kokoro", language_code: str = "en", silence_duration: float = 0.7, voice_map=None +) -> Optional[str]: + os.makedirs(os.path.dirname(output_path), exist_ok=True) + engine_name = tts_engine.lower() + if engine_name not in _TTS_ENGINES: + print(f"Unsupported TTS engine: {tts_engine}") + return None + try: + return _TTS_ENGINES[engine_name]( + script=script, output_path=output_path, language_code=language_code, silence_duration=silence_duration, voice_map=voice_map + ) + except Exception as e: + import traceback + + print(f"Error generating audio with {tts_engine}: {e}") + traceback.print_exc() + return None + + +def register_default_engines(): + def elevenlabs_generator(script, output_path, language_code, silence_duration, voice_map): + from utils.text_to_audio_elevenslab import create_podcast as elevenlabs_create_podcast + + if voice_map is None: + voice_map = {1: "Rachel", 2: "Adam"} + if language_code == "hi": + voice_map = {1: "Rachel", 2: "Adam"} + return elevenlabs_create_podcast( + script=script, + output_path=output_path, + silence_duration=silence_duration, + voice_map=voice_map, + elevenlabs_model=TTS_ELEVENLABS_MODEL, + api_key=load_api_key("ELEVENSLAB_API_KEY"), + ) + + def kokoro_generator(script, output_path, language_code, silence_duration, voice_map): + from utils.text_to_audio_kokoro import create_podcast as kokoro_create_podcast + + kokoro_lang_code = "b" + if language_code == "hi": + kokoro_lang_code = "h" + return kokoro_create_podcast( + script=script, output_path=output_path, silence_duration=silence_duration, sampling_rate=24_000, lang_code=kokoro_lang_code + ) + + def openai_generator(script, output_path, language_code, silence_duration, voice_map): + from utils.text_to_audio_openai import create_podcast as openai_create_podcast + + if voice_map is None: + voice_map = {1: "alloy", 2: "nova"} + if language_code == "hi": + voice_map = {1: "alloy", 2: "nova"} + model = TTS_OPENAI_MODEL + return openai_create_podcast( + script=script, + output_path=output_path, + silence_duration=silence_duration, + lang_code=language_code, + model=model, + voice_map=voice_map, + api_key=load_api_key("OPENAI_API_KEY"), + ) + + register_tts_engine("elevenlabs", elevenlabs_generator) + register_tts_engine("kokoro", kokoro_generator) + register_tts_engine("openai", openai_generator) + + +register_default_engines() diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/readme.md b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/readme.md new file mode 100644 index 0000000..94e4343 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/readme.md @@ -0,0 +1,644 @@ +# 🦉 Beifong: Your Junk-Free, Personalized Information and Podcasts + +![image](https://github.com/user-attachments/assets/b2f24f12-6f80-46fa-aa31-ee42e17765b1) + +Beifong manages your trusted articles and social media platform sources. It generates podcasts from the content you trust and curate. It handles the complete pipeline, from data collection and analysis to the production of scripts and visuals. + +▶️ [Watch demo video HD](https://www.canva.com/design/DAGoUfv8ICM/Oj-vJ19AvZYDa2SwJrCWKw/watch?utm_content=D[…]hare&utm_medium=link2&utm_source=uniquelinks&utlId=h2508379667) + +▶️ [Watch the demo on YouTube](https://youtu.be/dB8FZY3x9EY) + +🔗 [Blog](https://arun477.github.io/posts/beifong_podcast_generator/) + +## Table of Contents + +- [Getting Started](#getting-started) + - [System Requirements](#system-requirements) + - [Initial Setup and Installation](#initial-setup-and-installation) + - [Environment Configuration](#environment-configuration) + - [Starting the Application](#starting-the-application) +- [How to Use Beifong](#how-to-use-beifong) + - [Three Usage Methods](#three-usage-methods) +- [Content Processing System](#content-processing-system) + - [Built-in Content Processors](#built-in-content-processors) + - [Creating Custom Content Processors](#creating-custom-content-processors) +- [AI Agent and Tools](#ai-agent-and-tools) + - [Agent Architecture Overview](#agent-architecture-overview) + - [Adding Custom Tools](#adding-custom-tools) + - [Configuring Agent Behavior](#configuring-agent-behavior) +- [Web Search and Browser Automation](#web-search-and-browser-automation) + - [Search Commands](#search-commands) + - [Social Media Login Sessions](#social-media-login-sessions) + - [Advanced Persistent Session Configuration](#advanced-persistent-session-configuration) +- [Social Media Monitoring](#social-media-monitoring) + - [Supported Platforms](#supported-platforms) + - [Setting Up Scheduled Feed Collection](#setting-up-scheduled-feed-collection) + - [Viewing AI Insights](#viewing-ai-insights) + - [Configuring Custom Feeds](#configuring-custom-feeds) + - [Adding New Social Media Accounts](#adding-new-social-media-accounts) + - [Scheduling Best Practices](#scheduling-best-practices) +- [Audio and Voice Generation](#audio-and-voice-generation) + - [Supported TTS Engines](#supported-tts-engines) + - [Adding New Voice Engines](#adding-new-voice-engines) +- [Integrations](#integrations) + - [Slack Integration](#slack-integration) + - [Setting Up Slack App](#setting-up-slack-app) + - [Required Slack Permissions](#required-slack-permissions) + - [Environment Configuration](#environment-configuration-1) + - [Running Slack Integration](#running-slack-integration) +- [Data Storage and File Management](#data-storage-and-file-management) + - [Database Storage](#database-storage) + - [Media Asset Storage](#media-asset-storage) + - [Managing Storage Growth](#managing-storage-growth) +- [Deployment and Access Options](#deployment-and-access-options) + - [Local Network Access](#local-network-access) + - [Remote Access Solutions](#remote-access-solutions) + - [Security](#security) +- [Cloud Options](#cloud-options) + - [Beifong Cloud Features](#beifong-cloud-features) +- [Troubleshooting](#troubleshooting) + - [Kokoro Library Installation Issues](#kokoro-library-installation-issues) + - [Browseruse Installation Issues](#browseruse-installation-issues) + - [FAISS Library Installation Issues](#faiss-library-installation-issues) + - [Browser-Based Data Collection Issues](#browser-based-data-collection-issues) +- [Updates](#updates) + +## Getting Started + +### System Requirements + +Before installing Beifong, ensure you have: + +- Python 3.11+ +- Redis Server +- OpenAI API key +- (Optional) ElevenLabs API key + +### Initial Setup and Installation + +```bash +# Clone the repository +git clone https://github.com/arun477/beifong.git +cd beifong + +# Create virtual environment +cd beifong +python -m venv venv +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Install browser +python -m playwright install + +# (Optional but recommended) Download demo content +# Navigate to the beifong directory if not already there +cd beifong # Skip if already in the beifong folder +# This populates the system with sample data, curated source feeds, and assets +python bootstrap_demo.py +``` + +### Environment Configuration + +Create a `.env` file in the `/beifong` directory with your API keys: + +``` +OPENAI_API_KEY=your_openai_api_key +ELEVENSLAB_API_KEY=your_elevenlabs_api_key # Optional +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 +``` + +### Starting the Application + +Launch all required services in separate terminals (but make sure you start python main.py first before starting others, because the first time run will do db initialization): + +⚠️ Make sure to activate the virtual environment in all terminals before starting each script. + +```bash +source venv/bin/activate +``` + +```bash +# Terminal 1: Start the main backend (first time run may take 2 to 3 minutes due to the setup process) +cd beifong +python main.py + +# Terminal 2: Start the scheduler +cd beifong +python -m scheduler + +# Terminal 3: Start the chat workers +cd beifong +python -m celery_worker + +# Verify Redis is running +redis-cli ping +``` + +#### Optional: Frontend Development Mode + +```bash +# Navigate to web directory +cd web + +# Install dependencies +npm install + +# Start development server +npm start +``` + +## How to Use Beifong + +### Three Usage Methods + +Beifong offers flexibility in how you interact with the system: + +1. **Interactive Web UI** - Web interface for content management and podcast generation +2. **API Integration** - Programmatic access for custom applications and workflows +3. **Automated Scheduling** - Set up recurring tasks for hands off content processing + +## Content Processing System + +### Built-in Content Processors + +Beifong includes several specialized processors for different content sources: + +- **RSS Feed Processor** - Monitors RSS feeds for new articles and content +- **URL Content Processor** - Extracts and processes content from web pages +- **AI Content Analyzer** - Categorizes, summarizes, and analyzes content quality +- **Vector Embedding Processor** - Creates searchable vector representations of content +- **FAISS Search Indexer** - Builds search indices for content discovery +- **Podcast Script Generator** - Creates complete podcast episodes from curated content +- **X.com Social Processor** - Crawls and processes your X.com social media feed +- **Facebook Social Processor** - Crawls and processes your Facebook social media feed + +### Creating Custom Content Processors + +Extend Beifong's capabilities by adding your own content processors: + +#### Step 1: Create Your Processor Module + +```python +# processors/my_custom_processor.py +def process_custom_task(parameter1=None, parameter2=None): + # Your processing logic here + stats = {"processed": 0, "success": 0, "errors": 0} + # Processing implementation + return stats + +if __name__ == "__main__": + stats = process_custom_task() + print(f"Processed: {stats['processed']}, Success: {stats['success']}") +``` + +#### Step 2: Register Your Processor + +Add your processor to the system in `models/tasks_schemas.py`: + +```python +class TaskType(str, Enum): + # Existing task types... + my_custom_processor = "my_custom_processor" + +TASK_TYPES = { + # Existing types... + "my_custom_processor": { + "name": "My Custom Processor", + "command": "python -m processors.my_custom_processor", + "description": "Performs custom processing task", + }, +} +``` + +#### Step 3: Deploy Your Processor + +Create a new task using the API or UI with your custom processor type. + +## AI Agent and Tools + +### Agent Architecture Overview + +Beifong's AI system is built on the [agno](https://github.com/agno-agi/agno) framework and includes: + +- **Search Tools** - Semantic search, keyword search, and browser-based web research +- **Content Generation Tools** - Automated script writing, banner creation, and audio production +- **Persistent Session State** - Maintains conversation context across interactions +- **Tool Orchestration** - Manages multi step workflows automatically + +### Adding Custom Tools + +Extend the agent's capabilities with custom tools: + +```python +# tools/my_custom_tool.py +from agno.agent import Agent + +def my_custom_tool(agent: Agent, param1: str, param2: str) -> str: + """Tool description here""" + agent.session_state["my_key"] = "my_value" + # Tool implementation + result = f"Processed {param1} and {param2}" + return result +``` + +Register your tool in `services/celery_tasks.py`: + +```python +# Add import +from tools.my_custom_tool import my_custom_tool +# Add to tools list +tools = [my_custom_tool] +``` + +### Configuring Agent Behavior + +Modify the agent's instructions and behavior in `db/agent_config_v2.py`: + +```python +# Update the instructions to modify the agent's behavior +# Be careful to preserve the core flow stages while adding your customizations +``` + +## Web Search and Browser Automation + +Beifong's search agent has full browser automation capabilities through the [browseruse](https://browser-use.com/) library, enabling web research and automated data collection from any website. + +### Search Commands + +You can give the agent specific search instructions like: +- *"Go to my X.com and collect top positive and informative feeds"* +- *"Browse Reddit for discussions about AI developments this week"* +- *"Search LinkedIn for recent posts about data science trends"* +- *"Visit news sites and gather articles about renewable energy"* + +The agent will navigate websites, interact with page elements, and extract the requested information automatically. + +### Social Media Login Sessions + +For websites requiring authentication (X.com, Facebook, LinkedIn, etc.), you need to establish logged in sessions: + +**Setting Up Social Media Sessions:** + +1. **Navigate to Social Tab** in the Beifong web interface +2. **Click "Setup Session"** under the Setup section +3. **Login Process** - A browser window will open where you: + - Log into your social media accounts normally + - Complete any verification steps + - Close the browser when finished +4. **Session Persistence** - Beifong will use these authenticated sessions for future automated searches + +### Advanced Persistent Session Configuration + +For persistent logged in sessions and advanced browser management: + +**Persistent Session Path Configuration:** +- Default browser sessions are stored in `browsers/playwright_persistent_profile_web` folder +- For persistent session paths, modify `tools/web_search` to use `get_browser_session_path()` from `db/config.py` + +**Important Persistent Session Management Notes:** +- **Avoid Concurrent Usage** - Ensure no other processes use the same browser session simultaneously +- **Social Monitor Processors** typically use the path from `get_browser_session_path()` function +- **Disable Conflicting Processes** - Switch off social monitoring in the Voyager section if using persistent session paths +- **Future Separation** - Session management will be separated into individual sessions in upcoming updates + +**Persistent Session Troubleshooting:** +- If login sessions expire, repeat the Social Tab setup process +- Clear browser data if experiencing authentication issues +- Ensure only one process accesses browser sessions at a time + +## Social Media Monitoring + +### Supported Platforms + +Beifong currently supports automated monitoring for: + +- **X.com (Twitter)** - Collects and analyzes your social media feeds +- **Facebook.com** - Monitors your Facebook timeline and interactions + +### Setting Up Scheduled Feed Collection + +To automatically collect your social media feeds: + +1. **Navigate to the Voyager Tab** in the Beifong web interface +2. **Create a Scheduled Task** for social media monitoring +3. **Configure Collection Frequency** - Set how often you want feeds collected +4. **Select Platform** - Choose between X.com or Facebook.com processors + +### Viewing AI Insights + +Once your social media feeds are collected: + +1. **Navigate to the Social Tab** in the web interface +2. **View Comprehensive Analysis** - Each post is analyzed through AI providing: + - Content sentiment analysis + - Topic categorization + - Engagement insights + - Relevance scoring +3. **Browse Full Insights** - Detailed analytics for all collected social media content + +### Configuring Custom Feeds + +You can easily customize which feeds to monitor: + +**Modifying Feed Sources:** +- Navigate to `/tools/social/` directory +- Update the URLs in the social media processors +- **Monitor Specific Profiles** - Configure to track particular X.com profiles or Facebook pages +- **Custom Feed Types** - Adapt URLs for different types of content feeds + +**URL Configuration Examples:** +- Track specific X.com user: Modify URLs to target particular profiles +- Monitor Facebook pages: Configure URLs for specific Facebook feeds +- Custom hashtag monitoring: Set URLs to track specific hashtags or topics + +### Adding New Social Media Accounts + +Beifong supports easy expansion to additional platforms: + +**Currently Supported:** +- X.com (Twitter) +- Facebook.com + +**Easy Integration Options:** +- **LinkedIn** +- **Reddit** +- **Other Platforms** - Most social media platforms can be integrated using the same framework, but you must write a custom scraper or use an API for it. + +**Future Updates:** +- Next version will include more built-in connectors for popular social media platforms +- Support for multiple account management per platform + +### Scheduling Best Practices + +**Important Scheduling Considerations:** + +⚠️ **Avoid Concurrent Execution** - When scheduling multiple social media feed collection tasks, ensure they don't run simultaneously. All social media processors share the same persistent browser session. + +**Recommended Scheduling Approach:** +- **Stagger Collection Times** - Schedule X.com and Facebook.com collection at different times +- **Allow Processing Gaps** - Leave sufficient time between different social media tasks +- **Monitor Execution Times** - Track how long each collection takes to avoid overlaps + +**Example Safe Scheduling:** +- X.com feed collection: Every 2 hours at :00 minutes +- Facebook.com feed collection: Every 2 hours at :30 minutes + +**Future Improvements:** +- Next version will provide separate persistent browser sessions for each social media account +- This will eliminate the need for careful scheduling and allow concurrent collection from multiple platforms + +## Audio and Voice Generation + +### Supported TTS Engines + +Beifong supports multiple text to speech options: + +**Commercial Options:** +- **OpenAI TTS** +- **ElevenLabs** + +**Open Source Options:** +- **Kokoro** + +### Adding New Voice Engines + +The TTS system supports integration of additional engines: + +**Potential Next Open Source Integration Options:** +- **[Dia TTS](https://yummy-fir-7a4.notion.site/dia)** +- **[CSM](https://github.com/SesameAILabs/csm)** +- **[Orpheus-TTS](https://github.com/canopyai/Orpheus-TTS)** + +Add custom TTS engines through the tts_selector engine interface in the **utils** directory. + +## Integrations + +Beifong can be integrated with other platforms. + +### Slack Integration + +Beifong's Slack integration enables you to interact with the AI agent directly from your Slack workspace. Each conversation with Beifong creates a dedicated Slack thread for the session. + +**Key Feature:** +- Direct messaging with BeifongAI in Slack channels + +### Setting Up Slack App + +To integrate Beifong with your Slack workspace, you need to create a Slack app in Socket Mode: + +#### Step 1: Create Slack App + +1. Visit [Slack API Apps](https://api.slack.com/apps) and click "Create New App" +2. Choose "From scratch" and provide: + - **App Name**: BeifongAI (or your preferred name) + - **Workspace**: Select your target Slack workspace +3. **Enable Socket Mode**: + - Navigate to "Socket Mode" in the left sidebar + - Toggle "Enable Socket Mode" to ON + - Generate an App-Level Token with `connections:write` scope + - Save the **App-Level Token** (this is your `SLACK_APP_TOKEN`) + +#### Step 2: Configure Bot User + +1. Navigate to "OAuth & Permissions" in the left sidebar +2. Scroll to "Bot Token Scopes" and add the required permissions (see next section) +3. Click "Install to Workspace" and authorize the app +4. Copy the **Bot User OAuth Token** (this is your `SLACK_BOT_TOKEN`) + +#### Step 3: Enable Event Subscriptions + +1. Navigate to "Event Subscriptions" in the left sidebar +2. Toggle "Enable Events" to ON +3. Add the required bot events (see permissions section below) + +### Required Slack Permissions + +Your Slack app requires specific permissions to function properly with Beifong: + +#### OAuth & Permissions - Bot Token Scopes + +Add the following scopes under "OAuth & Permissions" → "Bot Token Scopes": + +- **`app_mentions:read`** - View messages that directly mention @BeifongAI in conversations that the app is in +- **`assistant:write`** - Allow BeifongAI to act as an App Agent +- **`channels:history`** - View messages and other content in public channels that BeifongAI has been added to +- **`channels:read`** - View basic information about public channels in a workspace +- **`chat:write`** - Send messages as @BeifongAI +- **`files:read`** - View files shared in channels and conversations that BeifongAI has been added to +- **`files:write`** - Upload, edit, and delete files as @BeifongAI +- **`im:read`** - View basic information about direct messages that BeifongAI has been added to +- **`im:write`** - Start direct messages with people + +#### Event Subscriptions - Bot Events + +Under "Event Subscriptions" → "Subscribe to bot events", add: + +- **`app_mention`** - Subscribe to only the message events that mention your app or bot + - *Required Scope: `app_mentions:read`* +- **`message.channels`** - A message was posted to a channel + - *Required Scope: `channels:history`* + +### Environment Configuration + +Add your Slack tokens to the `.env` file in the `/beifong` directory: + +```env +# Existing environment variables... +OPENAI_API_KEY=your_openai_api_key +ELEVENSLAB_API_KEY=your_elevenlabs_api_key # Optional + +# Slack Integration +SLACK_BOT_TOKEN=xoxb-your-bot-user-oauth-token +SLACK_APP_TOKEN=xapp-your-app-level-token + +# Redis configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_DB=0 +``` + +### Running Slack Integration + +Once you've configured your Slack app and environment variables: + +#### Step 1: Install App in Workspace + +1. Ensure your Slack app is installed in your workspace +2. Add BeifongAI to the channels where you want to use it +3. You can also send direct messages to BeifongAI + +#### Step 2: Start Slack Integration + +```bash +# Navigate to beifong directory +cd beifong + +# Ensure your environment is activated +source venv/bin/activate + +# Run the Slack integration script +python -m integrations.slack.chat +``` + +#### Step 3: Interact with BeifongAI + +**In Slack Channels:** +- Mention @BeifongAI to start a conversation +- Each mention creates a new thread for context continuity +- Example: `@BeifongAI Can you help me analyze the latest news about AI developments?` + +**Reference Documentation:** +- [Slack Socket Mode API](https://api.slack.com/apis/socket-mode) + +## Data Storage and File Management + +### Database Storage + +All application databases are organized in the **databases** directory for easy management and backup. + +### Media Asset Storage + +Generated podcasts, audio files, and visual assets are stored in the **podcasts** directory. + +### Managing Storage Growth + +If asset storage grows, consider these storage optimization strategies: + +**Cloud Storage Integration:** +- Use s3fs to mount an S3 bucket as a local folder for media assets +- Configure custom storage paths in `.env` to use larger drives + +**Automated Cleanup:** +- Set up periodic archiving of older podcast episodes +- Implement automated cleanup for temporary recordings and unused assets +- Configure retention policies for different types of content + +**Storage Monitoring:** +- Monitor disk usage as your content library grows +- Set up alerts for storage capacity thresholds + +**Note:** More efficient storage management and cloud connectors will be added in the next version. + +## Deployment and Access Options + +### Local Network Access + +```bash +# Start the backend with network access +cd beifong +python main.py --host 0.0.0.0 --port 7000 +``` + +This makes the application accessible via your machine's IP address on your local network. + +### Remote Access Solutions + +For accessing Beifong from outside your local network (workaround): + +#### SSH Port Forwarding +```bash +# Forward local port to remote machine +ssh -L 7000:localhost:7000 username@your-server-ip +``` + +#### Ngrok Tunneling +```bash +# Create temporary public tunnel +ngrok http 7000 +``` +Provides a temporary public URL that forwards to your local instance. + +### Security + +Beifong doesn't include an authentication layer yet. Authentication will be added in the next version. + +## Cloud Options + +### Beifong Cloud Features +Coming Soon! + +✅ Cloud version of Beifong + +✅ More social media connectors + +✅ More API options. Claude, Gemini, OpenAI, Ollama + +✅ Podcast customization with more styles + +✅ More voice options + +✅ Better data collection and storage management + +✅ Authentication layer + +## Troubleshooting + +### Kokoro Library Installation Issues + +If your installation fails due to the Kokoro library, you can skip installing this library and only install it when needed as a TTS engine. Kokoro is optional and only required if you want to use it for text-to-speech generation. + +For more information about Kokoro, check the reference: https://github.com/hexgrad/kokoro + +### Browseruse Installation Issues + +If your installation fails due to browseruse, make sure the Playwright version is properly installed. Browser automation features depend on Playwright being correctly set up. + +For more reference and troubleshooting: https://github.com/browser-use/browser-use + +### FAISS Library Installation Issues + +If the FAISS library installation fails, you can safely ignore this error and skip installing FAISS. This library is only required if you want to use the semantic search feature. If you don't need semantic search functionality, you can safely ignore the FAISS installation failure. + +For reference: https://github.com/facebookresearch/faiss + +### Browser-Based Data Collection Issues + +Some of the data collection features rely on browser automation, which sometimes won't work properly in server environments. While Beifong will still function, some browser dependent features may not work in server environments without proper browser setup. + +## Updates + +🚀 **[Repo](https://github.com/arun477/beifong)** diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/.prettierrc.json b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/.prettierrc.json new file mode 100644 index 0000000..464d45e --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "printWidth": 100, + "tabWidth": 3, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf" + } \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/android-chrome-192x192.png b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/android-chrome-192x192.png new file mode 100644 index 0000000..a0891f2 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/android-chrome-192x192.png differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/android-chrome-512x512.png b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/android-chrome-512x512.png new file mode 100644 index 0000000..8a66562 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/android-chrome-512x512.png differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/apple-touch-icon.png b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/apple-touch-icon.png new file mode 100644 index 0000000..e3cf715 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/apple-touch-icon.png differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/asset-manifest.json b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/asset-manifest.json new file mode 100644 index 0000000..5ef6a25 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/asset-manifest.json @@ -0,0 +1,15 @@ +{ + "files": { + "main.css": "/static/css/main.e6c13ad2.css", + "main.js": "/static/js/main.5073a5c8.js", + "static/js/453.258b012d.chunk.js": "/static/js/453.258b012d.chunk.js", + "index.html": "/index.html", + "main.e6c13ad2.css.map": "/static/css/main.e6c13ad2.css.map", + "main.5073a5c8.js.map": "/static/js/main.5073a5c8.js.map", + "453.258b012d.chunk.js.map": "/static/js/453.258b012d.chunk.js.map" + }, + "entrypoints": [ + "static/css/main.e6c13ad2.css", + "static/js/main.5073a5c8.js" + ] +} \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon-16x16.png b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon-16x16.png new file mode 100644 index 0000000..ae46ef1 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon-16x16.png differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon-32x32.png b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon-32x32.png new file mode 100644 index 0000000..19852fd Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon-32x32.png differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon.ico b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon.ico new file mode 100644 index 0000000..9e90277 Binary files /dev/null and b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/favicon.ico differ diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/index.html b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/index.html new file mode 100644 index 0000000..bb813bb --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/index.html @@ -0,0 +1 @@ +Beifong
\ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/manifest.json b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/manifest.json new file mode 100644 index 0000000..040c7e4 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "android-chrome-512x512", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/robots.txt b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/css/main.e6c13ad2.css b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/css/main.e6c13ad2.css new file mode 100644 index 0000000..50410fa --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/css/main.e6c13ad2.css @@ -0,0 +1,2 @@ +body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace} +/*# sourceMappingURL=main.e6c13ad2.css.map*/ \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/css/main.e6c13ad2.css.map b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/css/main.e6c13ad2.css.map new file mode 100644 index 0000000..b5b49c9 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/css/main.e6c13ad2.css.map @@ -0,0 +1 @@ +{"version":3,"file":"static/css/main.e6c13ad2.css","mappings":"AAAA,KAKE,kCAAmC,CACnC,iCAAkC,CAJlC,mIAEY,CAHZ,QAMF,CAEA,KACE,uEAEF","sources":["index.css"],"sourcesContent":["body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\ncode {\n font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',\n monospace;\n}"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/453.258b012d.chunk.js b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/453.258b012d.chunk.js new file mode 100644 index 0000000..11faff5 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/453.258b012d.chunk.js @@ -0,0 +1,2 @@ +"use strict";(self.webpackChunkclient=self.webpackChunkclient||[]).push([[453],{6453:(e,t,n)=>{n.r(t),n.d(t,{getCLS:()=>y,getFCP:()=>g,getFID:()=>C,getLCP:()=>P,getTTFB:()=>D});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",(function(t){t.persisted&&e(t)}),!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,p=function(){return"hidden"===document.visibilityState?0:1/0},d=function(){f((function(e){var t=e.timeStamp;v=t}),!0)},l=function(){return v<0&&(v=p(),d(),s((function(){setTimeout((function(){v=p(),d()}),0)}))),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},p=c("layout-shift",v);p&&(n=m(i,r,t),f((function(){p.takeRecords().map(v),n(!0)})),s((function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)})))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,b,E)}))},C=function(e,t){var n,a=l(),v=u("FID"),p=function(e){e.startTimeperformance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",(function(){return setTimeout(t,0)}))}}}]); +//# sourceMappingURL=453.258b012d.chunk.js.map \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/453.258b012d.chunk.js.map b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/453.258b012d.chunk.js.map new file mode 100644 index 0000000..a65bcb8 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/453.258b012d.chunk.js.map @@ -0,0 +1 @@ +{"version":3,"file":"static/js/453.258b012d.chunk.js","mappings":"iLAAA,IAAIA,EAAEC,EAAEC,EAAEC,EAAEC,EAAE,SAASJ,EAAEC,GAAG,MAAM,CAACI,KAAKL,EAAEM,WAAM,IAASL,GAAG,EAAEA,EAAEM,MAAM,EAAEC,QAAQ,GAAGC,GAAG,MAAMC,OAAOC,KAAKC,MAAM,KAAKF,OAAOG,KAAKC,MAAM,cAAcD,KAAKE,UAAU,MAAM,EAAEC,EAAE,SAAShB,EAAEC,GAAG,IAAI,GAAGgB,oBAAoBC,oBAAoBC,SAASnB,GAAG,CAAC,GAAG,gBAAgBA,KAAK,2BAA2BoB,MAAM,OAAO,IAAIlB,EAAE,IAAIe,qBAAqB,SAASjB,GAAG,OAAOA,EAAEqB,aAAaC,IAAIrB,EAAE,IAAI,OAAOC,EAAEqB,QAAQ,CAACC,KAAKxB,EAAEyB,UAAS,IAAKvB,CAAC,CAAC,CAAC,MAAMF,GAAG,CAAC,EAAE0B,EAAE,SAAS1B,EAAEC,GAAG,IAAIC,EAAE,SAASA,EAAEC,GAAG,aAAaA,EAAEqB,MAAM,WAAWG,SAASC,kBAAkB5B,EAAEG,GAAGF,IAAI4B,oBAAoB,mBAAmB3B,GAAE,GAAI2B,oBAAoB,WAAW3B,GAAE,IAAK,EAAE4B,iBAAiB,mBAAmB5B,GAAE,GAAI4B,iBAAiB,WAAW5B,GAAE,EAAG,EAAE6B,EAAE,SAAS/B,GAAG8B,iBAAiB,YAAY,SAAS7B,GAAGA,EAAE+B,WAAWhC,EAAEC,EAAE,IAAG,EAAG,EAAEgC,EAAE,SAASjC,EAAEC,EAAEC,GAAG,IAAIC,EAAE,OAAO,SAASC,GAAGH,EAAEK,OAAO,IAAIF,GAAGF,KAAKD,EAAEM,MAAMN,EAAEK,OAAOH,GAAG,IAAIF,EAAEM,YAAO,IAASJ,KAAKA,EAAEF,EAAEK,MAAMN,EAAEC,IAAI,CAAC,EAAEiC,GAAG,EAAEC,EAAE,WAAW,MAAM,WAAWR,SAASC,gBAAgB,EAAE,GAAG,EAAEQ,EAAE,WAAWV,GAAG,SAAS1B,GAAG,IAAIC,EAAED,EAAEqC,UAAUH,EAAEjC,CAAC,IAAG,EAAG,EAAEqC,EAAE,WAAW,OAAOJ,EAAE,IAAIA,EAAEC,IAAIC,IAAIL,GAAG,WAAWQ,YAAY,WAAWL,EAAEC,IAAIC,GAAG,GAAG,EAAE,KAAK,CAAC,mBAAII,GAAkB,OAAON,CAAC,EAAE,EAAEO,EAAE,SAASzC,EAAEC,GAAG,IAAIC,EAAEC,EAAEmC,IAAIZ,EAAEtB,EAAE,OAAO8B,EAAE,SAASlC,GAAG,2BAA2BA,EAAEK,OAAO+B,GAAGA,EAAEM,aAAa1C,EAAE2C,UAAUxC,EAAEqC,kBAAkBd,EAAEpB,MAAMN,EAAE2C,UAAUjB,EAAElB,QAAQoC,KAAK5C,GAAGE,GAAE,IAAK,EAAEiC,EAAEU,OAAOC,aAAaA,YAAYC,kBAAkBD,YAAYC,iBAAiB,0BAA0B,GAAGX,EAAED,EAAE,KAAKnB,EAAE,QAAQkB,IAAIC,GAAGC,KAAKlC,EAAE+B,EAAEjC,EAAE0B,EAAEzB,GAAGkC,GAAGD,EAAEC,GAAGJ,GAAG,SAAS5B,GAAGuB,EAAEtB,EAAE,OAAOF,EAAE+B,EAAEjC,EAAE0B,EAAEzB,GAAG+C,uBAAuB,WAAWA,uBAAuB,WAAWtB,EAAEpB,MAAMwC,YAAYlC,MAAMT,EAAEkC,UAAUnC,GAAE,EAAG,GAAG,GAAG,IAAI,EAAE+C,GAAE,EAAGC,GAAG,EAAEC,EAAE,SAASnD,EAAEC,GAAGgD,IAAIR,GAAG,SAASzC,GAAGkD,EAAElD,EAAEM,KAAK,IAAI2C,GAAE,GAAI,IAAI/C,EAAEC,EAAE,SAASF,GAAGiD,GAAG,GAAGlD,EAAEC,EAAE,EAAEiC,EAAE9B,EAAE,MAAM,GAAG+B,EAAE,EAAEC,EAAE,GAAGE,EAAE,SAAStC,GAAG,IAAIA,EAAEoD,eAAe,CAAC,IAAInD,EAAEmC,EAAE,GAAGjC,EAAEiC,EAAEA,EAAEiB,OAAO,GAAGlB,GAAGnC,EAAE2C,UAAUxC,EAAEwC,UAAU,KAAK3C,EAAE2C,UAAU1C,EAAE0C,UAAU,KAAKR,GAAGnC,EAAEM,MAAM8B,EAAEQ,KAAK5C,KAAKmC,EAAEnC,EAAEM,MAAM8B,EAAE,CAACpC,IAAImC,EAAED,EAAE5B,QAAQ4B,EAAE5B,MAAM6B,EAAED,EAAE1B,QAAQ4B,EAAElC,IAAI,CAAC,EAAEiD,EAAEnC,EAAE,eAAesB,GAAGa,IAAIjD,EAAE+B,EAAE9B,EAAE+B,EAAEjC,GAAGyB,GAAG,WAAWyB,EAAEG,cAAchC,IAAIgB,GAAGpC,GAAE,EAAG,IAAI6B,GAAG,WAAWI,EAAE,EAAEe,GAAG,EAAEhB,EAAE9B,EAAE,MAAM,GAAGF,EAAE+B,EAAE9B,EAAE+B,EAAEjC,EAAE,IAAI,EAAEsD,EAAE,CAACC,SAAQ,EAAGC,SAAQ,GAAIC,EAAE,IAAI/C,KAAKgD,EAAE,SAASxD,EAAEC,GAAGJ,IAAIA,EAAEI,EAAEH,EAAEE,EAAED,EAAE,IAAIS,KAAKiD,EAAE/B,qBAAqBgC,IAAI,EAAEA,EAAE,WAAW,GAAG5D,GAAG,GAAGA,EAAEC,EAAEwD,EAAE,CAAC,IAAItD,EAAE,CAAC0D,UAAU,cAAczD,KAAKL,EAAEwB,KAAKuC,OAAO/D,EAAE+D,OAAOC,WAAWhE,EAAEgE,WAAWrB,UAAU3C,EAAEqC,UAAU4B,gBAAgBjE,EAAEqC,UAAUpC,GAAGE,EAAE+D,SAAS,SAASlE,GAAGA,EAAEI,EAAE,IAAID,EAAE,EAAE,CAAC,EAAEgE,EAAE,SAASnE,GAAG,GAAGA,EAAEgE,WAAW,CAAC,IAAI/D,GAAGD,EAAEqC,UAAU,KAAK,IAAI1B,KAAKmC,YAAYlC,OAAOZ,EAAEqC,UAAU,eAAerC,EAAEwB,KAAK,SAASxB,EAAEC,GAAG,IAAIC,EAAE,WAAWyD,EAAE3D,EAAEC,GAAGG,GAAG,EAAED,EAAE,WAAWC,GAAG,EAAEA,EAAE,WAAWyB,oBAAoB,YAAY3B,EAAEqD,GAAG1B,oBAAoB,gBAAgB1B,EAAEoD,EAAE,EAAEzB,iBAAiB,YAAY5B,EAAEqD,GAAGzB,iBAAiB,gBAAgB3B,EAAEoD,EAAE,CAAhO,CAAkOtD,EAAED,GAAG2D,EAAE1D,EAAED,EAAE,CAAC,EAAE4D,EAAE,SAAS5D,GAAG,CAAC,YAAY,UAAU,aAAa,eAAekE,SAAS,SAASjE,GAAG,OAAOD,EAAEC,EAAEkE,EAAEZ,EAAE,GAAG,EAAEa,EAAE,SAASlE,EAAEgC,GAAG,IAAIC,EAAEC,EAAEE,IAAIG,EAAErC,EAAE,OAAO6C,EAAE,SAASjD,GAAGA,EAAE2C,UAAUP,EAAEI,kBAAkBC,EAAEnC,MAAMN,EAAEiE,gBAAgBjE,EAAE2C,UAAUF,EAAEjC,QAAQoC,KAAK5C,GAAGmC,GAAE,GAAI,EAAEe,EAAElC,EAAE,cAAciC,GAAGd,EAAEF,EAAE/B,EAAEuC,EAAEP,GAAGgB,GAAGxB,GAAG,WAAWwB,EAAEI,cAAchC,IAAI2B,GAAGC,EAAER,YAAY,IAAG,GAAIQ,GAAGnB,GAAG,WAAW,IAAIf,EAAEyB,EAAErC,EAAE,OAAO+B,EAAEF,EAAE/B,EAAEuC,EAAEP,GAAG/B,EAAE,GAAGF,GAAG,EAAED,EAAE,KAAK4D,EAAE9B,kBAAkBd,EAAEiC,EAAE9C,EAAEyC,KAAK5B,GAAG6C,GAAG,GAAG,EAAEQ,EAAE,CAAC,EAAEC,EAAE,SAAStE,EAAEC,GAAG,IAAIC,EAAEC,EAAEmC,IAAIJ,EAAE9B,EAAE,OAAO+B,EAAE,SAASnC,GAAG,IAAIC,EAAED,EAAE2C,UAAU1C,EAAEE,EAAEqC,kBAAkBN,EAAE5B,MAAML,EAAEiC,EAAE1B,QAAQoC,KAAK5C,GAAGE,IAAI,EAAEkC,EAAEpB,EAAE,2BAA2BmB,GAAG,GAAGC,EAAE,CAAClC,EAAE+B,EAAEjC,EAAEkC,EAAEjC,GAAG,IAAIwC,EAAE,WAAW4B,EAAEnC,EAAEzB,MAAM2B,EAAEkB,cAAchC,IAAIa,GAAGC,EAAEM,aAAa2B,EAAEnC,EAAEzB,KAAI,EAAGP,GAAE,GAAI,EAAE,CAAC,UAAU,SAASgE,SAAS,SAASlE,GAAG8B,iBAAiB9B,EAAEyC,EAAE,CAAC8B,MAAK,EAAGd,SAAQ,GAAI,IAAI/B,EAAEe,GAAE,GAAIV,GAAG,SAAS5B,GAAG+B,EAAE9B,EAAE,OAAOF,EAAE+B,EAAEjC,EAAEkC,EAAEjC,GAAG+C,uBAAuB,WAAWA,uBAAuB,WAAWd,EAAE5B,MAAMwC,YAAYlC,MAAMT,EAAEkC,UAAUgC,EAAEnC,EAAEzB,KAAI,EAAGP,GAAE,EAAG,GAAG,GAAG,GAAG,CAAC,EAAEsE,EAAE,SAASxE,GAAG,IAAIC,EAAEC,EAAEE,EAAE,QAAQH,EAAE,WAAW,IAAI,IAAIA,EAAE6C,YAAY2B,iBAAiB,cAAc,IAAI,WAAW,IAAIzE,EAAE8C,YAAY4B,OAAOzE,EAAE,CAAC6D,UAAU,aAAanB,UAAU,GAAG,IAAI,IAAIzC,KAAKF,EAAE,oBAAoBE,GAAG,WAAWA,IAAID,EAAEC,GAAGW,KAAK8D,IAAI3E,EAAEE,GAAGF,EAAE4E,gBAAgB,IAAI,OAAO3E,CAAC,CAAjL,GAAqL,GAAGC,EAAEI,MAAMJ,EAAEK,MAAMN,EAAE4E,cAAc3E,EAAEI,MAAM,GAAGJ,EAAEI,MAAMwC,YAAYlC,MAAM,OAAOV,EAAEM,QAAQ,CAACP,GAAGD,EAAEE,EAAE,CAAC,MAAMF,GAAG,CAAC,EAAE,aAAa2B,SAASmD,WAAWvC,WAAWtC,EAAE,GAAG6B,iBAAiB,QAAQ,WAAW,OAAOS,WAAWtC,EAAE,EAAE,GAAG,C","sources":["../node_modules/web-vitals/dist/web-vitals.js"],"sourcesContent":["var e,t,n,i,r=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:\"v2-\".concat(Date.now(),\"-\").concat(Math.floor(8999999999999*Math.random())+1e12)}},a=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if(\"first-input\"===e&&!(\"PerformanceEventTiming\"in self))return;var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},o=function(e,t){var n=function n(i){\"pagehide\"!==i.type&&\"hidden\"!==document.visibilityState||(e(i),t&&(removeEventListener(\"visibilitychange\",n,!0),removeEventListener(\"pagehide\",n,!0)))};addEventListener(\"visibilitychange\",n,!0),addEventListener(\"pagehide\",n,!0)},u=function(e){addEventListener(\"pageshow\",(function(t){t.persisted&&e(t)}),!0)},c=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},f=-1,s=function(){return\"hidden\"===document.visibilityState?0:1/0},m=function(){o((function(e){var t=e.timeStamp;f=t}),!0)},v=function(){return f<0&&(f=s(),m(),u((function(){setTimeout((function(){f=s(),m()}),0)}))),{get firstHiddenTime(){return f}}},d=function(e,t){var n,i=v(),o=r(\"FCP\"),f=function(e){\"first-contentful-paint\"===e.name&&(m&&m.disconnect(),e.startTime-1&&e(t)},f=r(\"CLS\",0),s=0,m=[],v=function(e){if(!e.hadRecentInput){var t=m[0],i=m[m.length-1];s&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(s+=e.value,m.push(e)):(s=e.value,m=[e]),s>f.value&&(f.value=s,f.entries=m,n())}},h=a(\"layout-shift\",v);h&&(n=c(i,f,t),o((function(){h.takeRecords().map(v),n(!0)})),u((function(){s=0,l=-1,f=r(\"CLS\",0),n=c(i,f,t)})))},T={passive:!0,capture:!0},y=new Date,g=function(i,r){e||(e=r,t=i,n=new Date,w(removeEventListener),E())},E=function(){if(t>=0&&t1e12?new Date:performance.now())-e.timeStamp;\"pointerdown\"==e.type?function(e,t){var n=function(){g(e,t),r()},i=function(){r()},r=function(){removeEventListener(\"pointerup\",n,T),removeEventListener(\"pointercancel\",i,T)};addEventListener(\"pointerup\",n,T),addEventListener(\"pointercancel\",i,T)}(t,e):g(t,e)}},w=function(e){[\"mousedown\",\"keydown\",\"touchstart\",\"pointerdown\"].forEach((function(t){return e(t,S,T)}))},L=function(n,f){var s,m=v(),d=r(\"FID\"),p=function(e){e.startTimeperformance.now())return;n.entries=[t],e(n)}catch(e){}},\"complete\"===document.readyState?setTimeout(t,0):addEventListener(\"load\",(function(){return setTimeout(t,0)}))};export{h as getCLS,d as getFCP,L as getFID,F as getLCP,P as getTTFB};\n"],"names":["e","t","n","i","r","name","value","delta","entries","id","concat","Date","now","Math","floor","random","a","PerformanceObserver","supportedEntryTypes","includes","self","getEntries","map","observe","type","buffered","o","document","visibilityState","removeEventListener","addEventListener","u","persisted","c","f","s","m","timeStamp","v","setTimeout","firstHiddenTime","d","disconnect","startTime","push","window","performance","getEntriesByName","requestAnimationFrame","p","l","h","hadRecentInput","length","takeRecords","T","passive","capture","y","g","w","E","entryType","target","cancelable","processingStart","forEach","S","L","b","F","once","P","getEntriesByType","timing","max","navigationStart","responseStart","readyState"],"sourceRoot":""} \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/main.5073a5c8.js b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/main.5073a5c8.js new file mode 100644 index 0000000..9720ce7 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/build/static/js/main.5073a5c8.js @@ -0,0 +1,3 @@ +/*! For license information please see main.5073a5c8.js.LICENSE.txt */ +(()=>{var e={14:e=>{e.exports=function(){return!1}},61:e=>{e.exports=function(e,t){return e{var t=/\s/;e.exports=function(e){for(var r=e.length;r--&&t.test(e.charAt(r)););return r}},149:e=>{e.exports=function(e,t){for(var r=-1,n=null==e?0:e.length,a=Array(n);++r{e.exports=function(e,t){var r=e.length;for(e.sort(t);r--;)e[r]=e[r].value;return e}},396:e=>{e.exports=function(e){return function(t){return null==t?void 0:t[e]}}},438:(e,t,r)=>{var n=r(2622);e.exports=function(e){return n(this,e).get(e)}},539:(e,t,r)=>{var n=r(9742),a=r(7498),o=r(3279);e.exports=function(e){return e&&e.length?n(e,o,a):void 0}},579:(e,t,r)=>{"use strict";e.exports=r(2799)},620:(e,t,r)=>{var n=r(6913),a=r(4052),o=r(2761);e.exports=function(e){return"string"==typeof e||!a(e)&&o(e)&&"[object String]"==n(e)}},643:(e,t,r)=>{var n=r(7676)("toUpperCase");e.exports=n},644:e=>{e.exports=function(e){return e!==e}},705:e=>{e.exports=function(e){var t=typeof e;return"string"==t||"number"==t||"symbol"==t||"boolean"==t?"__proto__"!==e:null===e}},715:e=>{var t="\\ud800-\\udfff",r="["+t+"]",n="[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]",a="\\ud83c[\\udffb-\\udfff]",o="[^"+t+"]",i="(?:\\ud83c[\\udde6-\\uddff]){2}",s="[\\ud800-\\udbff][\\udc00-\\udfff]",l="(?:"+n+"|"+a+")"+"?",c="[\\ufe0e\\ufe0f]?",u=c+l+("(?:\\u200d(?:"+[o,i,s].join("|")+")"+c+l+")*"),d="(?:"+[o+n+"?",n,i,s,r].join("|")+")",f=RegExp(a+"(?="+a+")|"+d+u,"g");e.exports=function(e){return e.match(f)||[]}},755:(e,t,r)=>{var n=r(8895),a=r(7116);e.exports=function e(t,r,o,i,s){var l=-1,c=t.length;for(o||(o=a),s||(s=[]);++l0&&o(u)?r>1?e(u,r-1,o,i,s):n(s,u):i||(s[s.length]=u)}return s}},793:e=>{e.exports=function(e){var t=this.__data__,r=t.delete(e);return this.size=t.size,r}},801:(e,t,r)=>{var n=r(1141),a=r(6686),o=r(9841),i=/^[-+]0x[0-9a-f]+$/i,s=/^0b[01]+$/i,l=/^0o[0-7]+$/i,c=parseInt;e.exports=function(e){if("number"==typeof e)return e;if(o(e))return NaN;if(a(e)){var t="function"==typeof e.valueOf?e.valueOf():e;e=a(t)?t+"":t}if("string"!=typeof e)return 0===e?e:+e;e=n(e);var r=s.test(e);return r||l.test(e)?c(e.slice(2),r?2:8):i.test(e)?NaN:+e}},914:(e,t,r)=>{var n=r(9841);e.exports=function(e){if("string"==typeof e||n(e))return e;var t=e+"";return"0"==t&&1/e==-1/0?"-0":t}},929:(e,t,r)=>{var n=r(3211),a=r(6571),o=r(9194),i=r(6686);e.exports=function(e,t,r){if(!i(r))return!1;var s=typeof t;return!!("number"==s?a(r)&&o(t,r.length):"string"==s&&t in r)&&n(r[t],e)}},977:(e,t,r)=>{var n=r(9096),a=r(4416);e.exports=function(e,t){return e&&e.length?a(e,n(t,2)):[]}},1069:(e,t,r)=>{var n=r(8541);e.exports=function(e){return null==e?"":n(e)}},1141:(e,t,r)=>{var n=r(143),a=/^\s+/;e.exports=function(e){return e?e.slice(0,n(e)+1).replace(a,""):e}},1143:(e,t,r)=>{var n=r(3028)(Object.keys,Object);e.exports=n},1170:e=>{e.exports=function(e,t,r){switch(r.length){case 0:return e.call(t);case 1:return e.call(t,r[0]);case 2:return e.call(t,r[0],r[1]);case 3:return e.call(t,r[0],r[1],r[2])}return e.apply(t,r)}},1268:(e,t,r)=>{var n=r(5428),a=r(7574),o=r(6832),i=o&&o.isTypedArray,s=i?a(i):n;e.exports=s},1310:e=>{e.exports=function(e,t){return function(r){return null!=r&&(r[e]===t&&(void 0!==t||e in Object(r)))}}},1340:(e,t,r)=>{var n=r(3211);e.exports=function(e,t){for(var r=e.length;r--;)if(n(e[r][0],t))return r;return-1}},1497:(e,t,r)=>{"use strict";var n=r(3218);function a(){}function o(){}o.resetWarningCache=a,e.exports=function(){function e(e,t,r,a,o,i){if(i!==n){var s=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw s.name="Invariant Violation",s}}function t(){return e}e.isRequired=e;var r={array:e,bigint:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:o,resetWarningCache:a};return r.PropTypes=r,r}},1558:e=>{e.exports=function(e,t,r){for(var n=-1,a=null==e?0:e.length;++n{var n=r(6913),a=r(6686);e.exports=function(e){if(!a(e))return!1;var t=n(e);return"[object Function]"==t||"[object GeneratorFunction]"==t||"[object AsyncFunction]"==t||"[object Proxy]"==t}},1714:(e,t,r)=>{var n=r(1340);e.exports=function(e,t){var r=this.__data__,a=n(r,e);return a<0?(++this.size,r.push([e,t])):r[a][1]=t,this}},1733:(e,t,r)=>{var n=r(1775),a=r(4664),o=r(9096);e.exports=function(e,t){var r={};return t=o(t,3),a(e,(function(e,a,o){n(r,a,t(e,a,o))})),r}},1775:(e,t,r)=>{var n=r(5654);e.exports=function(e,t,r){"__proto__"==t&&n?n(e,t,{configurable:!0,enumerable:!0,value:r,writable:!0}):e[t]=r}},1946:(e,t,r)=>{var n=r(1340);e.exports=function(e){return n(this.__data__,e)>-1}},2070:(e,t,r)=>{var n=r(7937)(r(6552),"Set");e.exports=n},2074:e=>{e.exports=function(e){var t=-1,r=Array(e.size);return e.forEach((function(e){r[++t]=e})),r}},2154:(e,t,r)=>{var n=r(5575),a=Object.prototype.hasOwnProperty;e.exports=function(e){var t=this.__data__;if(n){var r=t[e];return"__lodash_hash_undefined__"===r?void 0:r}return a.call(t,e)?t[e]:void 0}},2165:(e,t,r)=>{var n=r(5652);e.exports=function(e,t){var r;return n(e,(function(e,n,a){return!(r=t(e,n,a))})),!!r}},2322:(e,t,r)=>{var n=r(6913),a=r(5990),o=r(2761),i=Function.prototype,s=Object.prototype,l=i.toString,c=s.hasOwnProperty,u=l.call(Object);e.exports=function(e){if(!o(e)||"[object Object]"!=n(e))return!1;var t=a(e);if(null===t)return!0;var r=c.call(t,"constructor")&&t.constructor;return"function"==typeof r&&r instanceof r&&l.call(r)==u}},2520:(e,t,r)=>{var n=r(5816),a=r(9096),o=r(9140),i=Math.max;e.exports=function(e,t,r){var s=null==e?0:e.length;if(!s)return-1;var l=null==r?0:o(r);return l<0&&(l=i(s+l,0)),n(e,a(t,3),l)}},2536:(e,t,r)=>{var n=r(149),a=r(2969),o=r(9096),i=r(8883),s=r(320),l=r(7574),c=r(5893),u=r(3279),d=r(4052);e.exports=function(e,t,r){t=t.length?n(t,(function(e){return d(e)?function(t){return a(t,1===e.length?e[0]:e)}:e})):[u];var f=-1;t=n(t,l(o));var m=i(e,(function(e,r,a){return{criteria:n(t,(function(t){return t(e)})),index:++f,value:e}}));return s(m,(function(e,t){return c(e,t,r)}))}},2541:e=>{e.exports=function(e){return function(){return e}}},2587:e=>{e.exports=function(e,t){for(var r=-1,n=null==e?0:e.length;++r{var n=r(4052),a=r(9841),o=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,i=/^\w*$/;e.exports=function(e,t){if(n(e))return!1;var r=typeof e;return!("number"!=r&&"symbol"!=r&&"boolean"!=r&&null!=e&&!a(e))||(i.test(e)||!o.test(e)||null!=t&&e in Object(t))}},2622:(e,t,r)=>{var n=r(705);e.exports=function(e,t){var r=e.__data__;return n(t)?r["string"==typeof t?"string":"hash"]:r.map}},2662:(e,t,r)=>{var n=r(5575);e.exports=function(e,t){var r=this.__data__;return this.size+=this.has(e)?0:1,r[e]=n&&void 0===t?"__lodash_hash_undefined__":t,this}},2761:e=>{e.exports=function(e){return null!=e&&"object"==typeof e}},2777:(e,t,r)=>{var n=r(5193),a=r(2761),o=Object.prototype,i=o.hasOwnProperty,s=o.propertyIsEnumerable,l=n(function(){return arguments}())?n:function(e){return a(e)&&i.call(e,"callee")&&!s.call(e,"callee")};e.exports=l},2794:(e,t,r)=>{var n=r(9742),a=r(7498),o=r(9096);e.exports=function(e,t){return e&&e.length?n(e,o(t,2),a):void 0}},2799:(e,t)=>{"use strict";var r=Symbol.for("react.transitional.element"),n=Symbol.for("react.fragment");function a(e,t,n){var a=null;if(void 0!==n&&(a=""+n),void 0!==t.key&&(a=""+t.key),"key"in t)for(var o in n={},t)"key"!==o&&(n[o]=t[o]);else n=t;return t=n.ref,{$$typeof:r,type:e,key:a,ref:void 0!==t?t:null,props:n}}t.Fragment=n,t.jsx=a,t.jsxs=a},2866:(e,t,r)=>{var n=r(2969);e.exports=function(e){return function(t){return n(t,e)}}},2929:(e,t,r)=>{var n=r(6552).Uint8Array;e.exports=n},2969:(e,t,r)=>{var n=r(5324),a=r(914);e.exports=function(e,t){for(var r=0,o=(t=n(t,e)).length;null!=e&&r{e.exports=function(e,t){return function(r){return e(t(r))}}},3097:(e,t,r)=>{var n=r(2969);e.exports=function(e,t,r){var a=null==e?void 0:n(e,t);return void 0===a?r:a}},3204:(e,t,r)=>{var n=r(3343),a=r(2777),o=r(4052),i=r(4543),s=r(9194),l=r(1268),c=Object.prototype.hasOwnProperty;e.exports=function(e,t){var r=o(e),u=!r&&a(e),d=!r&&!u&&i(e),f=!r&&!u&&!d&&l(e),m=r||u||d||f,p=m?n(e.length,String):[],h=p.length;for(var g in e)!t&&!c.call(e,g)||m&&("length"==g||d&&("offset"==g||"parent"==g)||f&&("buffer"==g||"byteLength"==g||"byteOffset"==g)||s(g,h))||p.push(g);return p}},3211:e=>{e.exports=function(e,t){return e===t||e!==e&&t!==t}},3218:e=>{"use strict";e.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},3279:e=>{e.exports=function(e){return e}},3331:(e,t,r)=>{var n=r(9676),a=r(929),o=r(7303);e.exports=function(e){return function(t,r,i){return i&&"number"!=typeof i&&a(t,r,i)&&(r=i=void 0),t=o(t),void 0===r?(r=t,t=0):r=o(r),i=void 0===i?t{e.exports=function(e,t){for(var r=-1,n=Array(e);++r{var n=r(7894),a=r(9057);e.exports=function(e,t){return null!=e&&a(e,t,n)}},3411:(e,t,r)=>{var n=r(149),a=r(9096),o=r(8883),i=r(4052);e.exports=function(e,t){return(i(e)?n:o)(e,a(t,3))}},3440:(e,t,r)=>{var n=r(6552)["__core-js_shared__"];e.exports=n},3538:(e,t,r)=>{var n=r(755),a=r(3411);e.exports=function(e,t){return n(a(e,t),1)}},3668:(e,t,r)=>{var n=r(8902),a=r(2587),o=r(8114);e.exports=function(e,t,r,i,s,l){var c=1&r,u=e.length,d=t.length;if(u!=d&&!(c&&d>u))return!1;var f=l.get(e),m=l.get(t);if(f&&m)return f==t&&m==e;var p=-1,h=!0,g=2&r?new n:void 0;for(l.set(e,t),l.set(t,e);++p{var n=r(6140),a=r(1143),o=Object.prototype.hasOwnProperty;e.exports=function(e){if(!n(e))return a(e);var t=[];for(var r in Object(e))o.call(e,r)&&"constructor"!=r&&t.push(r);return t}},3781:(e,t,r)=>{var n=r(9417),a=r(8673);e.exports=function(e){for(var t=a(e),r=t.length;r--;){var o=t[r],i=e[o];t[r]=[o,i,n(i)]}return t}},3871:e=>{e.exports=function(e,t,r){var n=-1,a=e.length;t<0&&(t=-t>a?0:a+t),(r=r>a?a:r)<0&&(r+=a),a=t>r?0:r-t>>>0,t>>>=0;for(var o=Array(a);++n{e.exports=function(e){return this.__data__.has(e)}},3932:(e,t,r)=>{var n=r(396),a=r(2866),o=r(2597),i=r(914);e.exports=function(e){return o(e)?n(i(e)):a(e)}},3950:(e,t,r)=>{var n=r(6686),a=r(4757),o=r(801),i=Math.max,s=Math.min;e.exports=function(e,t,r){var l,c,u,d,f,m,p=0,h=!1,g=!1,y=!0;if("function"!=typeof e)throw new TypeError("Expected a function");function x(t){var r=l,n=c;return l=c=void 0,p=t,d=e.apply(n,r)}function v(e){var r=e-m;return void 0===m||r>=t||r<0||g&&e-p>=u}function b(){var e=a();if(v(e))return w(e);f=setTimeout(b,function(e){var r=t-(e-m);return g?s(r,u-(e-p)):r}(e))}function w(e){return f=void 0,y&&l?x(e):(l=c=void 0,d)}function j(){var e=a(),r=v(e);if(l=arguments,c=this,m=e,r){if(void 0===f)return function(e){return p=e,f=setTimeout(b,t),h?x(e):d}(m);if(g)return clearTimeout(f),f=setTimeout(b,t),x(m)}return void 0===f&&(f=setTimeout(b,t)),d}return t=o(t)||0,n(r)&&(h=!!r.leading,u=(g="maxWait"in r)?i(o(r.maxWait)||0,t):u,y="trailing"in r?!!r.trailing:y),j.cancel=function(){void 0!==f&&clearTimeout(f),p=0,l=m=c=f=void 0},j.flush=function(){return void 0===f?d:w(a())},j}},4020:e=>{e.exports=function(e,t,r){for(var n=r-1,a=e.length;++n{var t=Array.isArray;e.exports=t},4065:e=>{e.exports=function(e){var t=null==e?0:e.length;return t?e[t-1]:void 0}},4079:(e,t,r)=>{var n=r(8259),a=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,o=/\\(\\)?/g,i=n((function(e){var t=[];return 46===e.charCodeAt(0)&&t.push(""),e.replace(a,(function(e,r,n,a){t.push(n?a.replace(o,"$1"):r||e)})),t}));e.exports=i},4160:e=>{e.exports=function(e){var t=-1,r=Array(e.size);return e.forEach((function(e,n){r[++t]=[n,e]})),r}},4190:(e,t,r)=>{var n=r(1340);e.exports=function(e){var t=this.__data__,r=n(t,e);return r<0?void 0:t[r][1]}},4258:(e,t,r)=>{var n=r(5906)();e.exports=n},4262:(e,t,r)=>{var n=r(8895),a=r(4052);e.exports=function(e,t,r){var o=t(e);return a(e)?o:n(o,r(e))}},4288:(e,t)=>{"use strict";var r=Symbol.for("react.transitional.element"),n=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),l=Symbol.for("react.context"),c=Symbol.for("react.forward_ref"),u=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),f=Symbol.for("react.lazy"),m=Symbol.iterator;var p={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function y(e,t,r){this.props=e,this.context=t,this.refs=g,this.updater=r||p}function x(){}function v(e,t,r){this.props=e,this.context=t,this.refs=g,this.updater=r||p}y.prototype.isReactComponent={},y.prototype.setState=function(e,t){if("object"!==typeof e&&"function"!==typeof e&&null!=e)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")},y.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")},x.prototype=y.prototype;var b=v.prototype=new x;b.constructor=v,h(b,y.prototype),b.isPureReactComponent=!0;var w=Array.isArray,j={H:null,A:null,T:null,S:null,V:null},N=Object.prototype.hasOwnProperty;function k(e,t,n,a,o,i){return n=i.ref,{$$typeof:r,type:e,key:t,ref:void 0!==n?n:null,props:i}}function S(e){return"object"===typeof e&&null!==e&&e.$$typeof===r}var O=/\/+/g;function C(e,t){return"object"===typeof e&&null!==e&&null!=e.key?function(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,(function(e){return t[e]}))}(""+e.key):t.toString(36)}function E(){}function P(e,t,a,o,i){var s=typeof e;"undefined"!==s&&"boolean"!==s||(e=null);var l,c,u=!1;if(null===e)u=!0;else switch(s){case"bigint":case"string":case"number":u=!0;break;case"object":switch(e.$$typeof){case r:case n:u=!0;break;case f:return P((u=e._init)(e._payload),t,a,o,i)}}if(u)return i=i(e),u=""===o?"."+C(e,0):o,w(i)?(a="",null!=u&&(a=u.replace(O,"$&/")+"/"),P(i,t,a,"",(function(e){return e}))):null!=i&&(S(i)&&(l=i,c=a+(null==i.key||e&&e.key===i.key?"":(""+i.key).replace(O,"$&/")+"/")+u,i=k(l.type,c,void 0,0,0,l.props)),t.push(i)),1;u=0;var d,p=""===o?".":o+":";if(w(e))for(var h=0;h{"use strict";const r=/^[\u0021-\u003A\u003C\u003E-\u007E]+$/,n=/^[\u0021-\u003A\u003C-\u007E]*$/,a=/^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i,o=/^[\u0020-\u003A\u003D-\u007E]*$/,i=Object.prototype.toString,s=(()=>{const e=function(){};return e.prototype=Object.create(null),e})();function l(e,t,r){do{const r=e.charCodeAt(t);if(32!==r&&9!==r)return t}while(++tr;){const r=e.charCodeAt(--t);if(32!==r&&9!==r)return t+1}return r}function u(e){if(-1===e.indexOf("%"))return e;try{return decodeURIComponent(e)}catch(t){return e}}},4391:(e,t,r)=>{"use strict";!function e(){if("undefined"!==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&"function"===typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}(),e.exports=r(7004)},4416:(e,t,r)=>{var n=r(8902),a=r(5866),o=r(1558),i=r(8114),s=r(8182),l=r(2074);e.exports=function(e,t,r){var c=-1,u=a,d=e.length,f=!0,m=[],p=m;if(r)f=!1,u=o;else if(d>=200){var h=t?null:s(e);if(h)return l(h);f=!1,u=i,p=new n}else p=t?[]:m;e:for(;++c{e=r.nmd(e);var n=r(6552),a=r(14),o=t&&!t.nodeType&&t,i=o&&e&&!e.nodeType&&e,s=i&&i.exports===o?n.Buffer:void 0,l=(s?s.isBuffer:void 0)||a;e.exports=l},4545:(e,t,r)=>{var n=r(7160);e.exports=function(){this.__data__=new n,this.size=0}},4552:(e,t,r)=>{var n=r(9812),a=Object.prototype,o=a.hasOwnProperty,i=a.toString,s=n?n.toStringTag:void 0;e.exports=function(e){var t=o.call(e,s),r=e[s];try{e[s]=void 0;var n=!0}catch(l){}var a=i.call(e);return n&&(t?e[s]=r:delete e[s]),a}},4597:(e,t,r)=>{var n=r(2587),a=r(9096),o=r(2165),i=r(4052),s=r(929);e.exports=function(e,t,r){var l=i(e)?n:o;return r&&s(e,t,r)&&(t=void 0),l(e,a(t,3))}},4657:e=>{e.exports=function(e,t){return null==e?void 0:e[t]}},4664:(e,t,r)=>{var n=r(4258),a=r(8673);e.exports=function(e,t){return e&&n(e,t,a)}},4746:(e,t,r)=>{var n=r(5652);e.exports=function(e,t){var r=!0;return n(e,(function(e,n,a){return r=!!t(e,n,a)})),r}},4757:(e,t,r)=>{var n=r(6552);e.exports=function(){return n.Date.now()}},4816:(e,t,r)=>{var n=r(7251),a=r(7159),o=r(438),i=r(9394),s=r(6874);function l(e){var t=-1,r=null==e?0:e.length;for(this.clear();++t{var n=r(6989),a=r(3097),o=r(3366),i=r(2597),s=r(9417),l=r(1310),c=r(914);e.exports=function(e,t){return i(e)&&s(t)?l(c(e),t):function(r){var i=a(r,e);return void 0===i&&i===t?o(r,e):n(t,i,3)}}},5043:(e,t,r)=>{"use strict";e.exports=r(4288)},5051:e=>{e.exports=function(e){var t=this.has(e)&&delete this.__data__[e];return this.size-=t?1:0,t}},5173:(e,t,r)=>{e.exports=r(1497)()},5193:(e,t,r)=>{var n=r(6913),a=r(2761);e.exports=function(e){return a(e)&&"[object Arguments]"==n(e)}},5204:(e,t,r)=>{var n=r(7937)(r(6552),"Map");e.exports=n},5268:(e,t,r)=>{var n=r(9160);e.exports=function(e){return n(e)&&e!=+e}},5295:e=>{e.exports=function(e,t){for(var r=-1,n=null==e?0:e.length;++r{var n=r(4052),a=r(2597),o=r(4079),i=r(1069);e.exports=function(e,t){return n(e)?e:a(e,t)?[e]:o(i(e))}},5387:(e,t,r)=>{var n=r(7937)(r(6552),"Promise");e.exports=n},5428:(e,t,r)=>{var n=r(6913),a=r(6173),o=r(2761),i={};i["[object Float32Array]"]=i["[object Float64Array]"]=i["[object Int8Array]"]=i["[object Int16Array]"]=i["[object Int32Array]"]=i["[object Uint8Array]"]=i["[object Uint8ClampedArray]"]=i["[object Uint16Array]"]=i["[object Uint32Array]"]=!0,i["[object Arguments]"]=i["[object Array]"]=i["[object ArrayBuffer]"]=i["[object Boolean]"]=i["[object DataView]"]=i["[object Date]"]=i["[object Error]"]=i["[object Function]"]=i["[object Map]"]=i["[object Number]"]=i["[object Object]"]=i["[object RegExp]"]=i["[object Set]"]=i["[object String]"]=i["[object WeakMap]"]=!1,e.exports=function(e){return o(e)&&a(e.length)&&!!i[n(e)]}},5538:(e,t,r)=>{var n=r(7160),a=r(4545),o=r(793),i=r(7760),s=r(3892),l=r(6788);function c(e){var t=this.__data__=new n(e);this.size=t.size}c.prototype.clear=a,c.prototype.delete=o,c.prototype.get=i,c.prototype.has=s,c.prototype.set=l,e.exports=c},5575:(e,t,r)=>{var n=r(7937)(Object,"create");e.exports=n},5636:(e,t,r)=>{var n=r(1170),a=Math.max;e.exports=function(e,t,r){return t=a(void 0===t?e.length-1:t,0),function(){for(var o=arguments,i=-1,s=a(o.length-t,0),l=Array(s);++i{var n=r(3279),a=r(5636),o=r(6350);e.exports=function(e,t){return o(a(e,t,n),e+"")}},5652:(e,t,r)=>{var n=r(4664),a=r(6516)(n);e.exports=a},5654:(e,t,r)=>{var n=r(7937),a=function(){try{var e=n(Object,"defineProperty");return e({},"",{}),e}catch(t){}}();e.exports=a},5713:e=>{e.exports=function(){}},5752:(e,t,r)=>{var n=r(9395),a=Object.prototype.hasOwnProperty;e.exports=function(e,t,r,o,i,s){var l=1&r,c=n(e),u=c.length;if(u!=n(t).length&&!l)return!1;for(var d=u;d--;){var f=c[d];if(!(l?f in t:a.call(t,f)))return!1}var m=s.get(e),p=s.get(t);if(m&&p)return m==t&&p==e;var h=!0;s.set(e,t),s.set(t,e);for(var g=l;++d{var n=r(4816);function a(e,t){if("function"!=typeof e||null!=t&&"function"!=typeof t)throw new TypeError("Expected a function");var r=function(){var n=arguments,a=t?t.apply(this,n):n[0],o=r.cache;if(o.has(a))return o.get(a);var i=e.apply(this,n);return r.cache=o.set(a,i)||o,i};return r.cache=new(a.Cache||n),r}a.Cache=n,e.exports=a},5816:e=>{e.exports=function(e,t,r,n){for(var a=e.length,o=r+(n?1:-1);n?o--:++o{var n=r(8468);e.exports=function(e,t){return!!(null==e?0:e.length)&&n(e,t,0)>-1}},5893:(e,t,r)=>{var n=r(6599);e.exports=function(e,t,r){for(var a=-1,o=e.criteria,i=t.criteria,s=o.length,l=r.length;++a=l?c:c*("desc"==r[a]?-1:1)}return e.index-t.index}},5896:(e,t)=>{"use strict";function r(e,t){var r=e.length;e.push(t);e:for(;0>>1,a=e[n];if(!(0>>1;no(l,r))co(u,l)?(e[n]=u,e[c]=r,n=c):(e[n]=l,e[s]=r,n=s);else{if(!(co(u,r)))break e;e[n]=u,e[c]=r,n=c}}}return t}function o(e,t){var r=e.sortIndex-t.sortIndex;return 0!==r?r:e.id-t.id}if(t.unstable_now=void 0,"object"===typeof performance&&"function"===typeof performance.now){var i=performance;t.unstable_now=function(){return i.now()}}else{var s=Date,l=s.now();t.unstable_now=function(){return s.now()-l}}var c=[],u=[],d=1,f=null,m=3,p=!1,h=!1,g=!1,y=!1,x="function"===typeof setTimeout?setTimeout:null,v="function"===typeof clearTimeout?clearTimeout:null,b="undefined"!==typeof setImmediate?setImmediate:null;function w(e){for(var t=n(u);null!==t;){if(null===t.callback)a(u);else{if(!(t.startTime<=e))break;a(u),t.sortIndex=t.expirationTime,r(c,t)}t=n(u)}}function j(e){if(g=!1,w(e),!h)if(null!==n(c))h=!0,k||(k=!0,N());else{var t=n(u);null!==t&&M(j,t.startTime-e)}}var N,k=!1,S=-1,O=5,C=-1;function E(){return!!y||!(t.unstable_now()-Ce&&E());){var i=f.callback;if("function"===typeof i){f.callback=null,m=f.priorityLevel;var s=i(f.expirationTime<=e);if(e=t.unstable_now(),"function"===typeof s){f.callback=s,w(e),r=!0;break t}f===n(c)&&a(c),w(e)}else a(c);f=n(c)}if(null!==f)r=!0;else{var l=n(u);null!==l&&M(j,l.startTime-e),r=!1}}break e}finally{f=null,m=o,p=!1}r=void 0}}finally{r?N():k=!1}}}if("function"===typeof b)N=function(){b(P)};else if("undefined"!==typeof MessageChannel){var _=new MessageChannel,A=_.port2;_.port1.onmessage=P,N=function(){A.postMessage(null)}}else N=function(){x(P,0)};function M(e,r){S=x((function(){e(t.unstable_now())}),r)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(e){e.callback=null},t.unstable_forceFrameRate=function(e){0>e||125i?(e.sortIndex=o,r(u,e),null===n(c)&&e===n(u)&&(g?(v(S),S=-1):g=!0,M(j,o-i))):(e.sortIndex=s,r(c,e),h||p||(h=!0,k||(k=!0,N()))),e},t.unstable_shouldYield=E,t.unstable_wrapCallback=function(e){var t=m;return function(){var r=m;m=t;try{return e.apply(this,arguments)}finally{m=r}}}},5906:e=>{e.exports=function(e){return function(t,r,n){for(var a=-1,o=Object(t),i=n(t),s=i.length;s--;){var l=i[e?s:++a];if(!1===r(o[l],l,o))break}return t}}},5967:e=>{e.exports=function(e){return e.split("")}},5990:(e,t,r)=>{var n=r(3028)(Object.getPrototypeOf,Object);e.exports=n},6095:e=>{var t=Object.prototype.toString;e.exports=function(e){return t.call(e)}},6140:e=>{var t=Object.prototype;e.exports=function(e){var r=e&&e.constructor;return e===("function"==typeof r&&r.prototype||t)}},6173:e=>{e.exports=function(e){return"number"==typeof e&&e>-1&&e%1==0&&e<=9007199254740991}},6179:e=>{e.exports=function(e){return this.__data__.set(e,"__lodash_hash_undefined__"),this}},6311:e=>{var t=RegExp("[\\u200d\\ud800-\\udfff\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\ufe0e\\ufe0f]");e.exports=function(e){return t.test(e)}},6350:(e,t,r)=>{var n=r(8325),a=r(6578)(n);e.exports=a},6361:(e,t,r)=>{var n=r(6913),a=r(2761);e.exports=function(e){return!0===e||!1===e||a(e)&&"[object Boolean]"==n(e)}},6378:(e,t)=>{"use strict";var r,n=Symbol.for("react.element"),a=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),i=Symbol.for("react.strict_mode"),s=Symbol.for("react.profiler"),l=Symbol.for("react.provider"),c=Symbol.for("react.context"),u=Symbol.for("react.server_context"),d=Symbol.for("react.forward_ref"),f=Symbol.for("react.suspense"),m=Symbol.for("react.suspense_list"),p=Symbol.for("react.memo"),h=Symbol.for("react.lazy"),g=Symbol.for("react.offscreen");function y(e){if("object"===typeof e&&null!==e){var t=e.$$typeof;switch(t){case n:switch(e=e.type){case o:case s:case i:case f:case m:return e;default:switch(e=e&&e.$$typeof){case u:case c:case d:case h:case p:case l:return e;default:return t}}case a:return t}}}r=Symbol.for("react.module.reference"),t.isFragment=function(e){return y(e)===o}},6399:(e,t,r)=>{var n=r(5538),a=r(3668),o=r(9987),i=r(5752),s=r(6924),l=r(4052),c=r(4543),u=r(1268),d="[object Arguments]",f="[object Array]",m="[object Object]",p=Object.prototype.hasOwnProperty;e.exports=function(e,t,r,h,g,y){var x=l(e),v=l(t),b=x?f:s(e),w=v?f:s(t),j=(b=b==d?m:b)==m,N=(w=w==d?m:w)==m,k=b==w;if(k&&c(e)){if(!c(t))return!1;x=!0,j=!1}if(k&&!j)return y||(y=new n),x||u(e)?a(e,t,r,h,g,y):o(e,t,b,r,h,g,y);if(!(1&r)){var S=j&&p.call(e,"__wrapped__"),O=N&&p.call(t,"__wrapped__");if(S||O){var C=S?e.value():e,E=O?t.value():t;return y||(y=new n),g(C,E,r,h,y)}}return!!k&&(y||(y=new n),i(e,t,r,h,g,y))}},6516:(e,t,r)=>{var n=r(6571);e.exports=function(e,t){return function(r,a){if(null==r)return r;if(!n(r))return e(r,a);for(var o=r.length,i=t?o:-1,s=Object(r);(t?i--:++i{var n=r(5538),a=r(6989);e.exports=function(e,t,r,o){var i=r.length,s=i,l=!o;if(null==e)return!s;for(e=Object(e);i--;){var c=r[i];if(l&&c[2]?c[1]!==e[c[0]]:!(c[0]in e))return!1}for(;++i{var n=r(7105),a="object"==typeof self&&self&&self.Object===Object&&self,o=n||a||Function("return this")();e.exports=o},6571:(e,t,r)=>{var n=r(1629),a=r(6173);e.exports=function(e){return null!=e&&a(e.length)&&!n(e)}},6578:e=>{var t=Date.now;e.exports=function(e){var r=0,n=0;return function(){var a=t(),o=16-(a-n);if(n=a,o>0){if(++r>=800)return arguments[0]}else r=0;return e.apply(void 0,arguments)}}},6599:(e,t,r)=>{var n=r(9841);e.exports=function(e,t){if(e!==t){var r=void 0!==e,a=null===e,o=e===e,i=n(e),s=void 0!==t,l=null===t,c=t===t,u=n(t);if(!l&&!u&&!i&&e>t||i&&s&&c&&!l&&!u||a&&s&&c||!r&&c||!o)return 1;if(!a&&!i&&!u&&e{var n=r(7937)(r(6552),"WeakMap");e.exports=n},6604:(e,t,r)=>{var n=r(3331)();e.exports=n},6672:(e,t,r)=>{"use strict";var n=r(5043);function a(e){var t="https://react.dev/errors/"+e;if(1{e.exports=function(e){var t=typeof e;return null!=e&&("object"==t||"function"==t)}},6704:e=>{e.exports=function(e){return this.__data__.has(e)}},6745:(e,t,r)=>{var n=r(9742),a=r(61),o=r(3279);e.exports=function(e){return e&&e.length?n(e,o,a):void 0}},6788:(e,t,r)=>{var n=r(7160),a=r(5204),o=r(4816);e.exports=function(e,t){var r=this.__data__;if(r instanceof n){var i=r.__data__;if(!a||i.length<199)return i.push([e,t]),this.size=++r.size,this;r=this.__data__=new o(i)}return r.set(e,t),this.size=r.size,this}},6832:(e,t,r)=>{e=r.nmd(e);var n=r(7105),a=t&&!t.nodeType&&t,o=a&&e&&!e.nodeType&&e,i=o&&o.exports===a&&n.process,s=function(){try{var e=o&&o.require&&o.require("util").types;return e||i&&i.binding&&i.binding("util")}catch(t){}}();e.exports=s},6874:(e,t,r)=>{var n=r(2622);e.exports=function(e,t){var r=n(this,e),a=r.size;return r.set(e,t),this.size+=r.size==a?0:1,this}},6913:(e,t,r)=>{var n=r(9812),a=r(4552),o=r(6095),i=n?n.toStringTag:void 0;e.exports=function(e){return null==e?void 0===e?"[object Undefined]":"[object Null]":i&&i in Object(e)?a(e):o(e)}},6924:(e,t,r)=>{var n=r(7685),a=r(5204),o=r(5387),i=r(2070),s=r(6600),l=r(6913),c=r(6996),u="[object Map]",d="[object Promise]",f="[object Set]",m="[object WeakMap]",p="[object DataView]",h=c(n),g=c(a),y=c(o),x=c(i),v=c(s),b=l;(n&&b(new n(new ArrayBuffer(1)))!=p||a&&b(new a)!=u||o&&b(o.resolve())!=d||i&&b(new i)!=f||s&&b(new s)!=m)&&(b=function(e){var t=l(e),r="[object Object]"==t?e.constructor:void 0,n=r?c(r):"";if(n)switch(n){case h:return p;case g:return u;case y:return d;case x:return f;case v:return m}return t}),e.exports=b},6954:(e,t,r)=>{var n=r(1629),a=r(7857),o=r(6686),i=r(6996),s=/^\[object .+?Constructor\]$/,l=Function.prototype,c=Object.prototype,u=l.toString,d=c.hasOwnProperty,f=RegExp("^"+u.call(d).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");e.exports=function(e){return!(!o(e)||a(e))&&(n(e)?f:s).test(i(e))}},6989:(e,t,r)=>{var n=r(6399),a=r(2761);e.exports=function e(t,r,o,i,s){return t===r||(null==t||null==r||!a(t)&&!a(r)?t!==t&&r!==r:n(t,r,o,i,e,s))}},6996:e=>{var t=Function.prototype.toString;e.exports=function(e){if(null!=e){try{return t.call(e)}catch(r){}try{return e+""}catch(r){}}return""}},7002:(e,t,r)=>{var n=r(5295),a=r(4746),o=r(9096),i=r(4052),s=r(929);e.exports=function(e,t,r){var l=i(e)?n:a;return r&&s(e,t,r)&&(t=void 0),l(e,o(t,3))}},7004:(e,t,r)=>{"use strict";var n=r(8853),a=r(5043),o=r(7950);function i(e){var t="https://react.dev/errors/"+e;if(1I||(e.current=z[I],z[I]=null,I--)}function $(e,t){I++,z[I]=e.current,e.current=t}var U=B(null),H=B(null),W=B(null),V=B(null);function q(e,t){switch($(W,t),$(H,e),$(U,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?ad(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)e=od(t=ad(t),e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}F(U),$(U,e)}function Y(){F(U),F(H),F(W)}function X(e){null!==e.memoizedState&&$(V,e);var t=U.current,r=od(t,e.type);t!==r&&($(H,e),$(U,r))}function K(e){H.current===e&&(F(U),F(H)),V.current===e&&(F(V),Xd._currentValue=D)}var G=Object.prototype.hasOwnProperty,Q=n.unstable_scheduleCallback,Z=n.unstable_cancelCallback,J=n.unstable_shouldYield,ee=n.unstable_requestPaint,te=n.unstable_now,re=n.unstable_getCurrentPriorityLevel,ne=n.unstable_ImmediatePriority,ae=n.unstable_UserBlockingPriority,oe=n.unstable_NormalPriority,ie=n.unstable_LowPriority,se=n.unstable_IdlePriority,le=n.log,ce=n.unstable_setDisableYieldValue,ue=null,de=null;function fe(e){if("function"===typeof le&&ce(e),de&&"function"===typeof de.setStrictMode)try{de.setStrictMode(ue,e)}catch(t){}}var me=Math.clz32?Math.clz32:function(e){return 0===(e>>>=0)?32:31-(pe(e)/he|0)|0},pe=Math.log,he=Math.LN2;var ge=256,ye=4194304;function xe(e){var t=42&e;if(0!==t)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return 4194048&e;case 4194304:case 8388608:case 16777216:case 33554432:return 62914560&e;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function ve(e,t,r){var n=e.pendingLanes;if(0===n)return 0;var a=0,o=e.suspendedLanes,i=e.pingedLanes;e=e.warmLanes;var s=134217727&n;return 0!==s?0!==(n=s&~o)?a=xe(n):0!==(i&=s)?a=xe(i):r||0!==(r=s&~e)&&(a=xe(r)):0!==(s=n&~o)?a=xe(s):0!==i?a=xe(i):r||0!==(r=n&~e)&&(a=xe(r)),0===a?0:0!==t&&t!==a&&0===(t&o)&&((o=a&-a)>=(r=t&-t)||32===o&&0!==(4194048&r))?t:a}function be(e,t){return 0===(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)}function we(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;default:return-1}}function je(){var e=ge;return 0===(4194048&(ge<<=1))&&(ge=256),e}function Ne(){var e=ye;return 0===(62914560&(ye<<=1))&&(ye=4194304),e}function ke(e){for(var t=[],r=0;31>r;r++)t.push(e);return t}function Se(e,t){e.pendingLanes|=t,268435456!==t&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Oe(e,t,r){e.pendingLanes|=t,e.suspendedLanes&=~t;var n=31-me(t);e.entangledLanes|=t,e.entanglements[n]=1073741824|e.entanglements[n]|4194090&r}function Ce(e,t){var r=e.entangledLanes|=t;for(e=e.entanglements;r;){var n=31-me(r),a=1<)":-1--a||l[n]!==c[a]){var u="\n"+l[n].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}}while(1<=n&&0<=a);break}}}finally{ot=!1,Error.prepareStackTrace=r}return(r=e?e.displayName||e.name:"")?at(r):""}function st(e){switch(e.tag){case 26:case 27:case 5:return at(e.type);case 16:return at("Lazy");case 13:return at("Suspense");case 19:return at("SuspenseList");case 0:case 15:return it(e.type,!1);case 11:return it(e.type.render,!1);case 1:return it(e.type,!0);case 31:return at("Activity");default:return""}}function lt(e){try{var t="";do{t+=st(e),e=e.return}while(e);return t}catch(r){return"\nError generating stack: "+r.message+"\n"+r.stack}}function ct(e){switch(typeof e){case"bigint":case"boolean":case"number":case"string":case"undefined":case"object":return e;default:return""}}function ut(e){var t=e.type;return(e=e.nodeName)&&"input"===e.toLowerCase()&&("checkbox"===t||"radio"===t)}function dt(e){e._valueTracker||(e._valueTracker=function(e){var t=ut(e)?"checked":"value",r=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),n=""+e[t];if(!e.hasOwnProperty(t)&&"undefined"!==typeof r&&"function"===typeof r.get&&"function"===typeof r.set){var a=r.get,o=r.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return a.call(this)},set:function(e){n=""+e,o.call(this,e)}}),Object.defineProperty(e,t,{enumerable:r.enumerable}),{getValue:function(){return n},setValue:function(e){n=""+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}(e))}function ft(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var r=t.getValue(),n="";return e&&(n=ut(e)?e.checked?"true":"false":e.value),(e=n)!==r&&(t.setValue(e),!0)}function mt(e){if("undefined"===typeof(e=e||("undefined"!==typeof document?document:void 0)))return null;try{return e.activeElement||e.body}catch(t){return e.body}}var pt=/[\n"\\]/g;function ht(e){return e.replace(pt,(function(e){return"\\"+e.charCodeAt(0).toString(16)+" "}))}function gt(e,t,r,n,a,o,i,s){e.name="",null!=i&&"function"!==typeof i&&"symbol"!==typeof i&&"boolean"!==typeof i?e.type=i:e.removeAttribute("type"),null!=t?"number"===i?(0===t&&""===e.value||e.value!=t)&&(e.value=""+ct(t)):e.value!==""+ct(t)&&(e.value=""+ct(t)):"submit"!==i&&"reset"!==i||e.removeAttribute("value"),null!=t?xt(e,i,ct(t)):null!=r?xt(e,i,ct(r)):null!=n&&e.removeAttribute("value"),null==a&&null!=o&&(e.defaultChecked=!!o),null!=a&&(e.checked=a&&"function"!==typeof a&&"symbol"!==typeof a),null!=s&&"function"!==typeof s&&"symbol"!==typeof s&&"boolean"!==typeof s?e.name=""+ct(s):e.removeAttribute("name")}function yt(e,t,r,n,a,o,i,s){if(null!=o&&"function"!==typeof o&&"symbol"!==typeof o&&"boolean"!==typeof o&&(e.type=o),null!=t||null!=r){if(!("submit"!==o&&"reset"!==o||void 0!==t&&null!==t))return;r=null!=r?""+ct(r):"",t=null!=t?""+ct(t):r,s||t===e.value||(e.value=t),e.defaultValue=t}n="function"!==typeof(n=null!=n?n:a)&&"symbol"!==typeof n&&!!n,e.checked=s?e.checked:!!n,e.defaultChecked=!!n,null!=i&&"function"!==typeof i&&"symbol"!==typeof i&&"boolean"!==typeof i&&(e.name=i)}function xt(e,t,r){"number"===t&&mt(e.ownerDocument)===e||e.defaultValue===""+r||(e.defaultValue=""+r)}function vt(e,t,r,n){if(e=e.options,t){t={};for(var a=0;a=jr),Sr=String.fromCharCode(32),Or=!1;function Cr(e,t){switch(e){case"keyup":return-1!==br.indexOf(t.keyCode);case"keydown":return 229!==t.keyCode;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Er(e){return"object"===typeof(e=e.detail)&&"data"in e?e.data:null}var Pr=!1;var _r={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};function Ar(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return"input"===t?!!_r[e.type]:"textarea"===t}function Mr(e,t,r,n){Mt?Tt?Tt.push(n):Tt=[n]:Mt=n,0<(t=Wu(t,"onChange")).length&&(r=new Jt("onChange","change",null,r,n),e.push({event:r,listeners:t}))}var Tr=null,Lr=null;function Rr(e){Du(e,0)}function Dr(e){if(ft(He(e)))return e}function zr(e,t){if("change"===e)return t}var Ir=!1;if(It){var Br;if(It){var Fr="oninput"in document;if(!Fr){var $r=document.createElement("div");$r.setAttribute("oninput","return;"),Fr="function"===typeof $r.oninput}Br=Fr}else Br=!1;Ir=Br&&(!document.documentMode||9=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Gr(n)}}function Zr(e,t){return!(!e||!t)&&(e===t||(!e||3!==e.nodeType)&&(t&&3===t.nodeType?Zr(e,t.parentNode):"contains"in e?e.contains(t):!!e.compareDocumentPosition&&!!(16&e.compareDocumentPosition(t))))}function Jr(e){for(var t=mt((e=null!=e&&null!=e.ownerDocument&&null!=e.ownerDocument.defaultView?e.ownerDocument.defaultView:window).document);t instanceof e.HTMLIFrameElement;){try{var r="string"===typeof t.contentWindow.location.href}catch(n){r=!1}if(!r)break;t=mt((e=t.contentWindow).document)}return t}function en(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&("input"===t&&("text"===e.type||"search"===e.type||"tel"===e.type||"url"===e.type||"password"===e.type)||"textarea"===t||"true"===e.contentEditable)}var tn=It&&"documentMode"in document&&11>=document.documentMode,rn=null,nn=null,an=null,on=!1;function sn(e,t,r){var n=r.window===r?r.document:9===r.nodeType?r:r.ownerDocument;on||null==rn||rn!==mt(n)||("selectionStart"in(n=rn)&&en(n)?n={start:n.selectionStart,end:n.selectionEnd}:n={anchorNode:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection()).anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset},an&&Kr(an,n)||(an=n,0<(n=Wu(nn,"onSelect")).length&&(t=new Jt("onSelect","select",null,t,r),e.push({event:t,listeners:n}),t.target=rn)))}function ln(e,t){var r={};return r[e.toLowerCase()]=t.toLowerCase(),r["Webkit"+e]="webkit"+t,r["Moz"+e]="moz"+t,r}var cn={animationend:ln("Animation","AnimationEnd"),animationiteration:ln("Animation","AnimationIteration"),animationstart:ln("Animation","AnimationStart"),transitionrun:ln("Transition","TransitionRun"),transitionstart:ln("Transition","TransitionStart"),transitioncancel:ln("Transition","TransitionCancel"),transitionend:ln("Transition","TransitionEnd")},un={},dn={};function fn(e){if(un[e])return un[e];if(!cn[e])return e;var t,r=cn[e];for(t in r)if(r.hasOwnProperty(t)&&t in dn)return un[e]=r[t];return e}It&&(dn=document.createElement("div").style,"AnimationEvent"in window||(delete cn.animationend.animation,delete cn.animationiteration.animation,delete cn.animationstart.animation),"TransitionEvent"in window||delete cn.transitionend.transition);var mn=fn("animationend"),pn=fn("animationiteration"),hn=fn("animationstart"),gn=fn("transitionrun"),yn=fn("transitionstart"),xn=fn("transitioncancel"),vn=fn("transitionend"),bn=new Map,wn="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");function jn(e,t){bn.set(e,t),Xe(t,[e])}wn.push("scrollEnd");var Nn=new WeakMap;function kn(e,t){if("object"===typeof e&&null!==e){var r=Nn.get(e);return void 0!==r?r:(t={value:e,source:t,stack:lt(t)},Nn.set(e,t),t)}return{value:e,source:t,stack:lt(t)}}var Sn=[],On=0,Cn=0;function En(){for(var e=On,t=Cn=On=0;t>=i,a-=i,Qn=1<<32-me(t)+a|r<o?o:8;var i=L.T,s={};L.T=s,$i(e,!1,t,r);try{var l=a(),c=L.S;if(null!==c&&c(s,l),null!==l&&"object"===typeof l&&"function"===typeof l.then)Fi(e,t,function(e,t){var r=[],n={status:"pending",value:null,reason:null,then:function(e){r.push(e)}};return e.then((function(){n.status="fulfilled",n.value=t;for(var e=0;ep?(h=d,d=null):h=d.sibling;var g=m(a,d,s[p],l);if(null===g){null===d&&(d=h);break}e&&d&&null===g.alternate&&t(a,d),i=o(g,i,p),null===u?c=g:u.sibling=g,u=g,d=h}if(p===s.length)return r(a,d),oa&&Jn(a,p),c;if(null===d){for(;ph?(g=p,p=null):g=p.sibling;var v=m(a,p,x.value,c);if(null===v){null===p&&(p=g);break}e&&p&&null===v.alternate&&t(a,p),s=o(v,s,h),null===d?u=v:d.sibling=v,d=v,p=g}if(x.done)return r(a,p),oa&&Jn(a,h),u;if(null===p){for(;!x.done;h++,x=l.next())null!==(x=f(a,x.value,c))&&(s=o(x,s,h),null===d?u=x:d.sibling=x,d=x);return oa&&Jn(a,h),u}for(p=n(p);!x.done;h++,x=l.next())null!==(x=y(p,a,h,x.value,c))&&(e&&null!==x.alternate&&p.delete(null===x.key?h:x.key),s=o(x,s,h),null===d?u=x:d.sibling=x,d=x);return e&&p.forEach((function(e){return t(a,e)})),oa&&Jn(a,h),u}(l,c,u=v.call(u),d)}if("function"===typeof u.then)return x(l,c,Qi(u),d);if(u.$$typeof===w)return x(l,c,Ca(l,u),d);Ji(l,u)}return"string"===typeof u&&""!==u||"number"===typeof u||"bigint"===typeof u?(u=""+u,null!==c&&6===c.tag?(r(l,c.sibling),(d=a(c,u)).return=l,l=d):(r(l,c),(d=Un(u,l.mode,d)).return=l,l=d),s(l)):r(l,c)}return function(e,t,r,n){try{Gi=0;var a=x(e,t,r,n);return Ki=null,a}catch(i){if(i===Va||i===Ya)throw i;var o=Dn(29,i,null,e.mode);return o.lanes=n,o.return=e,o}}}var rs=ts(!0),ns=ts(!1),as=B(null),os=null;function is(e){var t=e.alternate;$(us,1&us.current),$(as,e),null===os&&(null===t||null!==po.current||null!==t.memoizedState)&&(os=e)}function ss(e){if(22===e.tag){if($(us,us.current),$(as,e),null===os){var t=e.alternate;null!==t&&null!==t.memoizedState&&(os=e)}}else ls()}function ls(){$(us,us.current),$(as,as.current)}function cs(e){F(as),os===e&&(os=null),F(us)}var us=B(0);function ds(e){for(var t=e;null!==t;){if(13===t.tag){var r=t.memoizedState;if(null!==r&&(null===(r=r.dehydrated)||"$?"===r.data||gd(r)))return t}else if(19===t.tag&&void 0!==t.memoizedProps.revealOrder){if(0!==(128&t.flags))return t}else if(null!==t.child){t.child.return=t,t=t.child;continue}if(t===e)break;for(;null===t.sibling;){if(null===t.return||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}function fs(e,t,r,n){r=null===(r=r(n,t=e.memoizedState))||void 0===r?t:f({},t,r),e.memoizedState=r,0===e.lanes&&(e.updateQueue.baseState=r)}var ms={enqueueSetState:function(e,t,r){e=e._reactInternals;var n=Lc(),a=ao(n);a.payload=t,void 0!==r&&null!==r&&(a.callback=r),null!==(t=oo(e,a,n))&&(Dc(t,e,n),io(t,e,n))},enqueueReplaceState:function(e,t,r){e=e._reactInternals;var n=Lc(),a=ao(n);a.tag=1,a.payload=t,void 0!==r&&null!==r&&(a.callback=r),null!==(t=oo(e,a,n))&&(Dc(t,e,n),io(t,e,n))},enqueueForceUpdate:function(e,t){e=e._reactInternals;var r=Lc(),n=ao(r);n.tag=2,void 0!==t&&null!==t&&(n.callback=t),null!==(t=oo(e,n,r))&&(Dc(t,e,r),io(t,e,r))}};function ps(e,t,r,n,a,o,i){return"function"===typeof(e=e.stateNode).shouldComponentUpdate?e.shouldComponentUpdate(n,o,i):!t.prototype||!t.prototype.isPureReactComponent||(!Kr(r,n)||!Kr(a,o))}function hs(e,t,r,n){e=t.state,"function"===typeof t.componentWillReceiveProps&&t.componentWillReceiveProps(r,n),"function"===typeof t.UNSAFE_componentWillReceiveProps&&t.UNSAFE_componentWillReceiveProps(r,n),t.state!==e&&ms.enqueueReplaceState(t,t.state,null)}function gs(e,t){var r=t;if("ref"in t)for(var n in r={},t)"ref"!==n&&(r[n]=t[n]);if(e=e.defaultProps)for(var a in r===t&&(r=f({},r)),e)void 0===r[a]&&(r[a]=e[a]);return r}var ys="function"===typeof reportError?reportError:function(e){if("object"===typeof window&&"function"===typeof window.ErrorEvent){var t=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:"object"===typeof e&&null!==e&&"string"===typeof e.message?String(e.message):String(e),error:e});if(!window.dispatchEvent(t))return}else if("object"===typeof process&&"function"===typeof process.emit)return void process.emit("uncaughtException",e);console.error(e)};function xs(e){ys(e)}function vs(e){console.error(e)}function bs(e){ys(e)}function ws(e,t){try{(0,e.onUncaughtError)(t.value,{componentStack:t.stack})}catch(r){setTimeout((function(){throw r}))}}function js(e,t,r){try{(0,e.onCaughtError)(r.value,{componentStack:r.stack,errorBoundary:1===t.tag?t.stateNode:null})}catch(n){setTimeout((function(){throw n}))}}function Ns(e,t,r){return(r=ao(r)).tag=3,r.payload={element:null},r.callback=function(){ws(e,t)},r}function ks(e){return(e=ao(e)).tag=3,e}function Ss(e,t,r,n){var a=r.type.getDerivedStateFromError;if("function"===typeof a){var o=n.value;e.payload=function(){return a(o)},e.callback=function(){js(t,r,n)}}var i=r.stateNode;null!==i&&"function"===typeof i.componentDidCatch&&(e.callback=function(){js(t,r,n),"function"!==typeof a&&(null===kc?kc=new Set([this]):kc.add(this));var e=n.stack;this.componentDidCatch(n.value,{componentStack:null!==e?e:""})})}var Os=Error(i(461)),Cs=!1;function Es(e,t,r,n){t.child=null===e?ns(t,null,r,n):rs(t,e.child,r,n)}function Ps(e,t,r,n,a){r=r.render;var o=t.ref;if("ref"in n){var i={};for(var s in n)"ref"!==s&&(i[s]=n[s])}else i=n;return Sa(t),n=Mo(e,t,r,i,o,a),s=Do(),null===e||Cs?(oa&&s&&ta(t),t.flags|=1,Es(e,t,n,a),t.child):(zo(e,t,a),Ks(e,t,a))}function _s(e,t,r,n,a){if(null===e){var o=r.type;return"function"!==typeof o||zn(o)||void 0!==o.defaultProps||null!==r.compare?((e=Fn(r.type,null,n,t,t.mode,a)).ref=t.ref,e.return=t,t.child=e):(t.tag=15,t.type=o,As(e,t,o,n,a))}if(o=e.child,!Gs(e,a)){var i=o.memoizedProps;if((r=null!==(r=r.compare)?r:Kr)(i,n)&&e.ref===t.ref)return Ks(e,t,a)}return t.flags|=1,(e=In(o,n)).ref=t.ref,e.return=t,t.child=e}function As(e,t,r,n,a){if(null!==e){var o=e.memoizedProps;if(Kr(o,n)&&e.ref===t.ref){if(Cs=!1,t.pendingProps=n=o,!Gs(e,a))return t.lanes=e.lanes,Ks(e,t,a);0!==(131072&e.flags)&&(Cs=!0)}}return Rs(e,t,r,n,a)}function Ms(e,t,r){var n=t.pendingProps,a=n.children,o=null!==e?e.memoizedState:null;if("hidden"===n.mode){if(0!==(128&t.flags)){if(n=null!==o?o.baseLanes|r:r,null!==e){for(a=t.child=e.child,o=0;null!==a;)o=o|a.lanes|a.childLanes,a=a.sibling;t.childLanes=o&~n}else t.childLanes=0,t.child=null;return Ts(e,t,n,r)}if(0===(536870912&r))return t.lanes=t.childLanes=536870912,Ts(e,t,null!==o?o.baseLanes|r:r,r);t.memoizedState={baseLanes:0,cachePool:null},null!==e&&Ha(0,null!==o?o.cachePool:null),null!==o?go(t,o):yo(),ss(t)}else null!==o?(Ha(0,o.cachePool),go(t,o),ls(),t.memoizedState=null):(null!==e&&Ha(0,null),yo(),ls());return Es(e,t,a,r),t.child}function Ts(e,t,r,n){var a=Ua();return a=null===a?null:{parent:Ma._currentValue,pool:a},t.memoizedState={baseLanes:r,cachePool:a},null!==e&&Ha(0,null),yo(),ss(t),null!==e&&Na(e,t,n,!0),null}function Ls(e,t){var r=t.ref;if(null===r)null!==e&&null!==e.ref&&(t.flags|=4194816);else{if("function"!==typeof r&&"object"!==typeof r)throw Error(i(284));null!==e&&e.ref===r||(t.flags|=4194816)}}function Rs(e,t,r,n,a){return Sa(t),r=Mo(e,t,r,n,void 0,a),n=Do(),null===e||Cs?(oa&&n&&ta(t),t.flags|=1,Es(e,t,r,a),t.child):(zo(e,t,a),Ks(e,t,a))}function Ds(e,t,r,n,a,o){return Sa(t),t.updateQueue=null,r=Lo(t,n,r,a),To(e),n=Do(),null===e||Cs?(oa&&n&&ta(t),t.flags|=1,Es(e,t,r,o),t.child):(zo(e,t,o),Ks(e,t,o))}function zs(e,t,r,n,a){if(Sa(t),null===t.stateNode){var o=Ln,i=r.contextType;"object"===typeof i&&null!==i&&(o=Oa(i)),o=new r(n,o),t.memoizedState=null!==o.state&&void 0!==o.state?o.state:null,o.updater=ms,t.stateNode=o,o._reactInternals=t,(o=t.stateNode).props=n,o.state=t.memoizedState,o.refs={},ro(t),i=r.contextType,o.context="object"===typeof i&&null!==i?Oa(i):Ln,o.state=t.memoizedState,"function"===typeof(i=r.getDerivedStateFromProps)&&(fs(t,r,i,n),o.state=t.memoizedState),"function"===typeof r.getDerivedStateFromProps||"function"===typeof o.getSnapshotBeforeUpdate||"function"!==typeof o.UNSAFE_componentWillMount&&"function"!==typeof o.componentWillMount||(i=o.state,"function"===typeof o.componentWillMount&&o.componentWillMount(),"function"===typeof o.UNSAFE_componentWillMount&&o.UNSAFE_componentWillMount(),i!==o.state&&ms.enqueueReplaceState(o,o.state,null),uo(t,n,o,a),co(),o.state=t.memoizedState),"function"===typeof o.componentDidMount&&(t.flags|=4194308),n=!0}else if(null===e){o=t.stateNode;var s=t.memoizedProps,l=gs(r,s);o.props=l;var c=o.context,u=r.contextType;i=Ln,"object"===typeof u&&null!==u&&(i=Oa(u));var d=r.getDerivedStateFromProps;u="function"===typeof d||"function"===typeof o.getSnapshotBeforeUpdate,s=t.pendingProps!==s,u||"function"!==typeof o.UNSAFE_componentWillReceiveProps&&"function"!==typeof o.componentWillReceiveProps||(s||c!==i)&&hs(t,o,n,i),to=!1;var f=t.memoizedState;o.state=f,uo(t,n,o,a),co(),c=t.memoizedState,s||f!==c||to?("function"===typeof d&&(fs(t,r,d,n),c=t.memoizedState),(l=to||ps(t,r,l,n,f,c,i))?(u||"function"!==typeof o.UNSAFE_componentWillMount&&"function"!==typeof o.componentWillMount||("function"===typeof o.componentWillMount&&o.componentWillMount(),"function"===typeof o.UNSAFE_componentWillMount&&o.UNSAFE_componentWillMount()),"function"===typeof o.componentDidMount&&(t.flags|=4194308)):("function"===typeof o.componentDidMount&&(t.flags|=4194308),t.memoizedProps=n,t.memoizedState=c),o.props=n,o.state=c,o.context=i,n=l):("function"===typeof o.componentDidMount&&(t.flags|=4194308),n=!1)}else{o=t.stateNode,no(e,t),u=gs(r,i=t.memoizedProps),o.props=u,d=t.pendingProps,f=o.context,c=r.contextType,l=Ln,"object"===typeof c&&null!==c&&(l=Oa(c)),(c="function"===typeof(s=r.getDerivedStateFromProps)||"function"===typeof o.getSnapshotBeforeUpdate)||"function"!==typeof o.UNSAFE_componentWillReceiveProps&&"function"!==typeof o.componentWillReceiveProps||(i!==d||f!==l)&&hs(t,o,n,l),to=!1,f=t.memoizedState,o.state=f,uo(t,n,o,a),co();var m=t.memoizedState;i!==d||f!==m||to||null!==e&&null!==e.dependencies&&ka(e.dependencies)?("function"===typeof s&&(fs(t,r,s,n),m=t.memoizedState),(u=to||ps(t,r,u,n,f,m,l)||null!==e&&null!==e.dependencies&&ka(e.dependencies))?(c||"function"!==typeof o.UNSAFE_componentWillUpdate&&"function"!==typeof o.componentWillUpdate||("function"===typeof o.componentWillUpdate&&o.componentWillUpdate(n,m,l),"function"===typeof o.UNSAFE_componentWillUpdate&&o.UNSAFE_componentWillUpdate(n,m,l)),"function"===typeof o.componentDidUpdate&&(t.flags|=4),"function"===typeof o.getSnapshotBeforeUpdate&&(t.flags|=1024)):("function"!==typeof o.componentDidUpdate||i===e.memoizedProps&&f===e.memoizedState||(t.flags|=4),"function"!==typeof o.getSnapshotBeforeUpdate||i===e.memoizedProps&&f===e.memoizedState||(t.flags|=1024),t.memoizedProps=n,t.memoizedState=m),o.props=n,o.state=m,o.context=l,n=u):("function"!==typeof o.componentDidUpdate||i===e.memoizedProps&&f===e.memoizedState||(t.flags|=4),"function"!==typeof o.getSnapshotBeforeUpdate||i===e.memoizedProps&&f===e.memoizedState||(t.flags|=1024),n=!1)}return o=n,Ls(e,t),n=0!==(128&t.flags),o||n?(o=t.stateNode,r=n&&"function"!==typeof r.getDerivedStateFromError?null:o.render(),t.flags|=1,null!==e&&n?(t.child=rs(t,e.child,null,a),t.child=rs(t,null,r,a)):Es(e,t,r,a),t.memoizedState=o.state,e=t.child):e=Ks(e,t,a),e}function Is(e,t,r,n){return ma(),t.flags|=256,Es(e,t,r,n),t.child}var Bs={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Fs(e){return{baseLanes:e,cachePool:Wa()}}function $s(e,t,r){return e=null!==e?e.childLanes&~r:0,t&&(e|=gc),e}function Us(e,t,r){var n,a=t.pendingProps,o=!1,s=0!==(128&t.flags);if((n=s)||(n=(null===e||null!==e.memoizedState)&&0!==(2&us.current)),n&&(o=!0,t.flags&=-129),n=0!==(32&t.flags),t.flags&=-33,null===e){if(oa){if(o?is(t):ls(),oa){var l,c=aa;if(l=c){e:{for(l=c,c=sa;8!==l.nodeType;){if(!c){c=null;break e}if(null===(l=yd(l.nextSibling))){c=null;break e}}c=l}null!==c?(t.memoizedState={dehydrated:c,treeContext:null!==Gn?{id:Qn,overflow:Zn}:null,retryLane:536870912,hydrationErrors:null},(l=Dn(18,null,null,0)).stateNode=c,l.return=t,t.child=l,na=t,aa=null,l=!0):l=!1}l||ca(t)}if(null!==(c=t.memoizedState)&&null!==(c=c.dehydrated))return gd(c)?t.lanes=32:t.lanes=536870912,null;cs(t)}return c=a.children,a=a.fallback,o?(ls(),c=Ws({mode:"hidden",children:c},o=t.mode),a=$n(a,o,r,null),c.return=t,a.return=t,c.sibling=a,t.child=c,(o=t.child).memoizedState=Fs(r),o.childLanes=$s(e,n,r),t.memoizedState=Bs,a):(is(t),Hs(t,c))}if(null!==(l=e.memoizedState)&&null!==(c=l.dehydrated)){if(s)256&t.flags?(is(t),t.flags&=-257,t=Vs(e,t,r)):null!==t.memoizedState?(ls(),t.child=e.child,t.flags|=128,t=null):(ls(),o=a.fallback,c=t.mode,a=Ws({mode:"visible",children:a.children},c),(o=$n(o,c,r,null)).flags|=2,a.return=t,o.return=t,a.sibling=o,t.child=a,rs(t,e.child,null,r),(a=t.child).memoizedState=Fs(r),a.childLanes=$s(e,n,r),t.memoizedState=Bs,t=o);else if(is(t),gd(c)){if(n=c.nextSibling&&c.nextSibling.dataset)var u=n.dgst;n=u,(a=Error(i(419))).stack="",a.digest=n,ha({value:a,source:null,stack:null}),t=Vs(e,t,r)}else if(Cs||Na(e,t,r,!1),n=0!==(r&e.childLanes),Cs||n){if(null!==(n=nc)&&(0!==(a=0!==((a=0!==(42&(a=r&-r))?1:Ee(a))&(n.suspendedLanes|r))?0:a)&&a!==l.retryLane))throw l.retryLane=a,An(e,a),Dc(n,e,a),Os;"$?"===c.data||Yc(),t=Vs(e,t,r)}else"$?"===c.data?(t.flags|=192,t.child=e.child,t=null):(e=l.treeContext,aa=yd(c.nextSibling),na=t,oa=!0,ia=null,sa=!1,null!==e&&(Xn[Kn++]=Qn,Xn[Kn++]=Zn,Xn[Kn++]=Gn,Qn=e.id,Zn=e.overflow,Gn=t),(t=Hs(t,a.children)).flags|=4096);return t}return o?(ls(),o=a.fallback,c=t.mode,u=(l=e.child).sibling,(a=In(l,{mode:"hidden",children:a.children})).subtreeFlags=65011712&l.subtreeFlags,null!==u?o=In(u,o):(o=$n(o,c,r,null)).flags|=2,o.return=t,a.return=t,a.sibling=o,t.child=a,a=o,o=t.child,null===(c=e.child.memoizedState)?c=Fs(r):(null!==(l=c.cachePool)?(u=Ma._currentValue,l=l.parent!==u?{parent:u,pool:u}:l):l=Wa(),c={baseLanes:c.baseLanes|r,cachePool:l}),o.memoizedState=c,o.childLanes=$s(e,n,r),t.memoizedState=Bs,a):(is(t),e=(r=e.child).sibling,(r=In(r,{mode:"visible",children:a.children})).return=t,r.sibling=null,null!==e&&(null===(n=t.deletions)?(t.deletions=[e],t.flags|=16):n.push(e)),t.child=r,t.memoizedState=null,r)}function Hs(e,t){return(t=Ws({mode:"visible",children:t},e.mode)).return=e,e.child=t}function Ws(e,t){return(e=Dn(22,e,null,t)).lanes=0,e.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},e}function Vs(e,t,r){return rs(t,e.child,null,r),(e=Hs(t,t.pendingProps.children)).flags|=2,t.memoizedState=null,e}function qs(e,t,r){e.lanes|=t;var n=e.alternate;null!==n&&(n.lanes|=t),wa(e.return,t,r)}function Ys(e,t,r,n,a){var o=e.memoizedState;null===o?e.memoizedState={isBackwards:t,rendering:null,renderingStartTime:0,last:n,tail:r,tailMode:a}:(o.isBackwards=t,o.rendering=null,o.renderingStartTime=0,o.last=n,o.tail=r,o.tailMode=a)}function Xs(e,t,r){var n=t.pendingProps,a=n.revealOrder,o=n.tail;if(Es(e,t,n.children,r),0!==(2&(n=us.current)))n=1&n|2,t.flags|=128;else{if(null!==e&&0!==(128&e.flags))e:for(e=t.child;null!==e;){if(13===e.tag)null!==e.memoizedState&&qs(e,r,t);else if(19===e.tag)qs(e,r,t);else if(null!==e.child){e.child.return=e,e=e.child;continue}if(e===t)break e;for(;null===e.sibling;){if(null===e.return||e.return===t)break e;e=e.return}e.sibling.return=e.return,e=e.sibling}n&=1}switch($(us,n),a){case"forwards":for(r=t.child,a=null;null!==r;)null!==(e=r.alternate)&&null===ds(e)&&(a=r),r=r.sibling;null===(r=a)?(a=t.child,t.child=null):(a=r.sibling,r.sibling=null),Ys(t,!1,a,r,o);break;case"backwards":for(r=null,a=t.child,t.child=null;null!==a;){if(null!==(e=a.alternate)&&null===ds(e)){t.child=a;break}e=a.sibling,a.sibling=r,r=a,a=e}Ys(t,!0,r,null,o);break;case"together":Ys(t,!1,null,null,void 0);break;default:t.memoizedState=null}return t.child}function Ks(e,t,r){if(null!==e&&(t.dependencies=e.dependencies),mc|=t.lanes,0===(r&t.childLanes)){if(null===e)return null;if(Na(e,t,r,!1),0===(r&t.childLanes))return null}if(null!==e&&t.child!==e.child)throw Error(i(153));if(null!==t.child){for(r=In(e=t.child,e.pendingProps),t.child=r,r.return=t;null!==e.sibling;)e=e.sibling,(r=r.sibling=In(e,e.pendingProps)).return=t;r.sibling=null}return t.child}function Gs(e,t){return 0!==(e.lanes&t)||!(null===(e=e.dependencies)||!ka(e))}function Qs(e,t,r){if(null!==e)if(e.memoizedProps!==t.pendingProps)Cs=!0;else{if(!Gs(e,r)&&0===(128&t.flags))return Cs=!1,function(e,t,r){switch(t.tag){case 3:q(t,t.stateNode.containerInfo),va(0,Ma,e.memoizedState.cache),ma();break;case 27:case 5:X(t);break;case 4:q(t,t.stateNode.containerInfo);break;case 10:va(0,t.type,t.memoizedProps.value);break;case 13:var n=t.memoizedState;if(null!==n)return null!==n.dehydrated?(is(t),t.flags|=128,null):0!==(r&t.child.childLanes)?Us(e,t,r):(is(t),null!==(e=Ks(e,t,r))?e.sibling:null);is(t);break;case 19:var a=0!==(128&e.flags);if((n=0!==(r&t.childLanes))||(Na(e,t,r,!1),n=0!==(r&t.childLanes)),a){if(n)return Xs(e,t,r);t.flags|=128}if(null!==(a=t.memoizedState)&&(a.rendering=null,a.tail=null,a.lastEffect=null),$(us,us.current),n)break;return null;case 22:case 23:return t.lanes=0,Ms(e,t,r);case 24:va(0,Ma,e.memoizedState.cache)}return Ks(e,t,r)}(e,t,r);Cs=0!==(131072&e.flags)}else Cs=!1,oa&&0!==(1048576&t.flags)&&ea(t,Yn,t.index);switch(t.lanes=0,t.tag){case 16:e:{e=t.pendingProps;var n=t.elementType,a=n._init;if(n=a(n._payload),t.type=n,"function"!==typeof n){if(void 0!==n&&null!==n){if((a=n.$$typeof)===j){t.tag=11,t=Ps(null,t,n,e,r);break e}if(a===S){t.tag=14,t=_s(null,t,n,e,r);break e}}throw t=M(n)||n,Error(i(306,t,""))}zn(n)?(e=gs(n,e),t.tag=1,t=zs(null,t,n,e,r)):(t.tag=0,t=Rs(null,t,n,e,r))}return t;case 0:return Rs(e,t,t.type,t.pendingProps,r);case 1:return zs(e,t,n=t.type,a=gs(n,t.pendingProps),r);case 3:e:{if(q(t,t.stateNode.containerInfo),null===e)throw Error(i(387));n=t.pendingProps;var o=t.memoizedState;a=o.element,no(e,t),uo(t,n,null,r);var s=t.memoizedState;if(n=s.cache,va(0,Ma,n),n!==o.cache&&ja(t,[Ma],r,!0),co(),n=s.element,o.isDehydrated){if(o={element:n,isDehydrated:!1,cache:s.cache},t.updateQueue.baseState=o,t.memoizedState=o,256&t.flags){t=Is(e,t,n,r);break e}if(n!==a){ha(a=kn(Error(i(424)),t)),t=Is(e,t,n,r);break e}if(9===(e=t.stateNode.containerInfo).nodeType)e=e.body;else e="HTML"===e.nodeName?e.ownerDocument.body:e;for(aa=yd(e.firstChild),na=t,oa=!0,ia=null,sa=!0,r=ns(t,null,n,r),t.child=r;r;)r.flags=-3&r.flags|4096,r=r.sibling}else{if(ma(),n===a){t=Ks(e,t,r);break e}Es(e,t,n,r)}t=t.child}return t;case 26:return Ls(e,t),null===e?(r=Ed(t.type,null,t.pendingProps,null))?t.memoizedState=r:oa||(r=t.type,e=t.pendingProps,(n=nd(W.current).createElement(r))[Me]=t,n[Te]=e,ed(n,r,e),Ve(n),t.stateNode=n):t.memoizedState=Ed(t.type,e.memoizedProps,t.pendingProps,e.memoizedState),null;case 27:return X(t),null===e&&oa&&(n=t.stateNode=bd(t.type,t.pendingProps,W.current),na=t,sa=!0,a=aa,md(t.type)?(xd=a,aa=yd(n.firstChild)):aa=a),Es(e,t,t.pendingProps.children,r),Ls(e,t),null===e&&(t.flags|=4194304),t.child;case 5:return null===e&&oa&&((a=n=aa)&&(null!==(n=function(e,t,r,n){for(;1===e.nodeType;){var a=r;if(e.nodeName.toLowerCase()!==t.toLowerCase()){if(!n&&("INPUT"!==e.nodeName||"hidden"!==e.type))break}else if(n){if(!e[Be])switch(t){case"meta":if(!e.hasAttribute("itemprop"))break;return e;case"link":if("stylesheet"===(o=e.getAttribute("rel"))&&e.hasAttribute("data-precedence"))break;if(o!==a.rel||e.getAttribute("href")!==(null==a.href||""===a.href?null:a.href)||e.getAttribute("crossorigin")!==(null==a.crossOrigin?null:a.crossOrigin)||e.getAttribute("title")!==(null==a.title?null:a.title))break;return e;case"style":if(e.hasAttribute("data-precedence"))break;return e;case"script":if(((o=e.getAttribute("src"))!==(null==a.src?null:a.src)||e.getAttribute("type")!==(null==a.type?null:a.type)||e.getAttribute("crossorigin")!==(null==a.crossOrigin?null:a.crossOrigin))&&o&&e.hasAttribute("async")&&!e.hasAttribute("itemprop"))break;return e;default:return e}}else{if("input"!==t||"hidden"!==e.type)return e;var o=null==a.name?null:""+a.name;if("hidden"===a.type&&e.getAttribute("name")===o)return e}if(null===(e=yd(e.nextSibling)))break}return null}(n,t.type,t.pendingProps,sa))?(t.stateNode=n,na=t,aa=yd(n.firstChild),sa=!1,a=!0):a=!1),a||ca(t)),X(t),a=t.type,o=t.pendingProps,s=null!==e?e.memoizedProps:null,n=o.children,id(a,o)?n=null:null!==s&&id(a,s)&&(t.flags|=32),null!==t.memoizedState&&(a=Mo(e,t,Ro,null,null,r),Xd._currentValue=a),Ls(e,t),Es(e,t,n,r),t.child;case 6:return null===e&&oa&&((e=r=aa)&&(null!==(r=function(e,t,r){if(""===t)return null;for(;3!==e.nodeType;){if((1!==e.nodeType||"INPUT"!==e.nodeName||"hidden"!==e.type)&&!r)return null;if(null===(e=yd(e.nextSibling)))return null}return e}(r,t.pendingProps,sa))?(t.stateNode=r,na=t,aa=null,e=!0):e=!1),e||ca(t)),null;case 13:return Us(e,t,r);case 4:return q(t,t.stateNode.containerInfo),n=t.pendingProps,null===e?t.child=rs(t,null,n,r):Es(e,t,n,r),t.child;case 11:return Ps(e,t,t.type,t.pendingProps,r);case 7:return Es(e,t,t.pendingProps,r),t.child;case 8:case 12:return Es(e,t,t.pendingProps.children,r),t.child;case 10:return n=t.pendingProps,va(0,t.type,n.value),Es(e,t,n.children,r),t.child;case 9:return a=t.type._context,n=t.pendingProps.children,Sa(t),n=n(a=Oa(a)),t.flags|=1,Es(e,t,n,r),t.child;case 14:return _s(e,t,t.type,t.pendingProps,r);case 15:return As(e,t,t.type,t.pendingProps,r);case 19:return Xs(e,t,r);case 31:return n=t.pendingProps,r=t.mode,n={mode:n.mode,children:n.children},null===e?((r=Ws(n,r)).ref=t.ref,t.child=r,r.return=t,t=r):((r=In(e.child,n)).ref=t.ref,t.child=r,r.return=t,t=r),t;case 22:return Ms(e,t,r);case 24:return Sa(t),n=Oa(Ma),null===e?(null===(a=Ua())&&(a=nc,o=Ta(),a.pooledCache=o,o.refCount++,null!==o&&(a.pooledCacheLanes|=r),a=o),t.memoizedState={parent:n,cache:a},ro(t),va(0,Ma,a)):(0!==(e.lanes&r)&&(no(e,t),uo(t,null,null,r),co()),a=e.memoizedState,o=t.memoizedState,a.parent!==n?(a={parent:n,cache:n},t.memoizedState=a,0===t.lanes&&(t.memoizedState=t.updateQueue.baseState=a),va(0,Ma,n)):(n=o.cache,va(0,Ma,n),n!==a.cache&&ja(t,[Ma],r,!0))),Es(e,t,t.pendingProps.children,r),t.child;case 29:throw t.pendingProps}throw Error(i(156,t.tag))}function Zs(e){e.flags|=4}function Js(e,t){if("stylesheet"!==t.type||0!==(4&t.state.loading))e.flags&=-16777217;else if(e.flags|=16777216,!$d(t)){if(null!==(t=as.current)&&((4194048&oc)===oc?null!==os:(62914560&oc)!==oc&&0===(536870912&oc)||t!==os))throw Za=Xa,qa;e.flags|=8192}}function el(e,t){null!==t&&(e.flags|=4),16384&e.flags&&(t=22!==e.tag?Ne():536870912,e.lanes|=t,yc|=t)}function tl(e,t){if(!oa)switch(e.tailMode){case"hidden":t=e.tail;for(var r=null;null!==t;)null!==t.alternate&&(r=t),t=t.sibling;null===r?e.tail=null:r.sibling=null;break;case"collapsed":r=e.tail;for(var n=null;null!==r;)null!==r.alternate&&(n=r),r=r.sibling;null===n?t||null===e.tail?e.tail=null:e.tail.sibling=null:n.sibling=null}}function rl(e){var t=null!==e.alternate&&e.alternate.child===e.child,r=0,n=0;if(t)for(var a=e.child;null!==a;)r|=a.lanes|a.childLanes,n|=65011712&a.subtreeFlags,n|=65011712&a.flags,a.return=e,a=a.sibling;else for(a=e.child;null!==a;)r|=a.lanes|a.childLanes,n|=a.subtreeFlags,n|=a.flags,a.return=e,a=a.sibling;return e.subtreeFlags|=n,e.childLanes=r,t}function nl(e,t,r){var n=t.pendingProps;switch(ra(t),t.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:case 1:return rl(t),null;case 3:return r=t.stateNode,n=null,null!==e&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ba(Ma),Y(),r.pendingContext&&(r.context=r.pendingContext,r.pendingContext=null),null!==e&&null!==e.child||(fa(t)?Zs(t):null===e||e.memoizedState.isDehydrated&&0===(256&t.flags)||(t.flags|=1024,pa())),rl(t),null;case 26:return r=t.memoizedState,null===e?(Zs(t),null!==r?(rl(t),Js(t,r)):(rl(t),t.flags&=-16777217)):r?r!==e.memoizedState?(Zs(t),rl(t),Js(t,r)):(rl(t),t.flags&=-16777217):(e.memoizedProps!==n&&Zs(t),rl(t),t.flags&=-16777217),null;case 27:K(t),r=W.current;var a=t.type;if(null!==e&&null!=t.stateNode)e.memoizedProps!==n&&Zs(t);else{if(!n){if(null===t.stateNode)throw Error(i(166));return rl(t),null}e=U.current,fa(t)?ua(t):(e=bd(a,n,r),t.stateNode=e,Zs(t))}return rl(t),null;case 5:if(K(t),r=t.type,null!==e&&null!=t.stateNode)e.memoizedProps!==n&&Zs(t);else{if(!n){if(null===t.stateNode)throw Error(i(166));return rl(t),null}if(e=U.current,fa(t))ua(t);else{switch(a=nd(W.current),e){case 1:e=a.createElementNS("http://www.w3.org/2000/svg",r);break;case 2:e=a.createElementNS("http://www.w3.org/1998/Math/MathML",r);break;default:switch(r){case"svg":e=a.createElementNS("http://www.w3.org/2000/svg",r);break;case"math":e=a.createElementNS("http://www.w3.org/1998/Math/MathML",r);break;case"script":(e=a.createElement("div")).innerHTML=" + + + Beifong + + + +
+
+ + + diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/public/manifest.json b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/public/manifest.json new file mode 100644 index 0000000..040c7e4 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "android-chrome-512x512", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/public/robots.txt b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/readme.md b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/readme.md new file mode 100644 index 0000000..cb2db96 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/readme.md @@ -0,0 +1,58 @@ + +### `npm start` +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in your browser. + +The page will reload when you make changes.\ +You may also see any lint errors in the console. + +### `npm test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `npm run build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +### `npm run eject` + +**Note: this is a one-way operation. Once you `eject`, you can't go back!** + +If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. + +Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. + +You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). + +### Code Splitting + +This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) + +### Analyzing the Bundle Size + +This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) + +### Making a Progressive Web App + +This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) + +### Advanced Configuration + +This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) + +### Deployment + +This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/src/App.css b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/src/App.css new file mode 100644 index 0000000..dcf6d74 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/src/App.css @@ -0,0 +1,39 @@ +.App { + text-align: center; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/src/App.js b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/src/App.js new file mode 100644 index 0000000..5b914d7 --- /dev/null +++ b/advanced_ai_agents/multi_agent_apps/ai_news_and_podcast_agents/web/src/App.js @@ -0,0 +1,142 @@ +import { BrowserRouter as Router, Routes, Route, useLocation } from 'react-router-dom'; +import Navbar from './components/Navbar'; +import Footer from './components/Footer'; +import Home from './pages/Home'; +import Articles from './pages/Articles'; +import ArticleDetail from './pages/ArticleDetail'; +import Podcasts from './pages/Podcasts'; +import PodcastDetail from './pages/PodcastDetail'; +import Sources from './pages/Sources'; +import SourceDetail from './pages/SourceDetail'; +import SourceEdit from './pages/SourceEdit'; +import StudioLanding from './pages/StudioLanding'; +import StudioChat from './pages/StudioChat'; +import Voyager from './pages/Voyager'; +import SocialMedia from './pages/SocialMedia'; +import SocialMediaDetail from './pages/SocialMediaDetail'; + +const AppLayout = ({ children }) => { + const location = useLocation(); + const isStudioPage = location.pathname.startsWith('/studio'); + if (isStudioPage) { + return <>{children}; + } + return ( + <> + +
{children}
+