import concurrent.futures import contextvars import sys import uuid from contextlib import contextmanager, nullcontext from dotenv import load_dotenv from crewai import Agent, Crew, LLM, Process, Task from crewai_tools import SerperDevTool load_dotenv() # ------ Session tracking (no-op fallback when OTel pkgs not installed) ------ try: from openinference.instrumentation import using_session as _using_session except ImportError: @contextmanager def _using_session(session_id): # noqa: F811 yield try: from openinference.semconv.trace import SpanAttributes except ImportError: class _SpanAttributes: SESSION_ID = "session_id" SpanAttributes = _SpanAttributes() # type: ignore # ------ Phoenix / OpenTelemetry tracing ------ try: from phoenix.otel import register from huggingface_hub.utils import build_hf_headers from openinference.instrumentation.litellm import LiteLLMInstrumentor from opentelemetry import trace from openinference.instrumentation.crewai import CrewAIInstrumentor tp = trace.get_tracer_provider() if tp and hasattr(tp, "shutdown"): tp.shutdown() trace._TRACER_PROVIDER = None tracer_provider = register( project_name="crewai", endpoint="https://RCaz-phoenix-arize-observability.hf.space/v1/traces", headers=build_hf_headers(), ) LiteLLMInstrumentor().instrument(tracer_provider=tracer_provider) CrewAIInstrumentor().instrument(tracer_provider=tracer_provider) tracer = trace.get_tracer("crewai") except ImportError: tracer = None ###### The agentic app # ------ LLM endpoint constants ------ _VLLM_BASE_URL = "https://rcaz33--example-vllm-inference-serve.modal.run/v1" _VLLM_MODEL = "openai/google/gemma-4-26B-A4B-it" # LiteLLM prefix — for crewai _VLLM_SERVED_MODEL = ( "google/gemma-4-26B-A4B-it" # actual vLLM served name — for direct API calls ) # Define our LLM using the Modal-deployed Gemma 4 26B model via vLLM (OpenAI-compatible API) llm = LLM( model=_VLLM_MODEL, base_url=_VLLM_BASE_URL, api_key="sk-dummy-key-not-needed", max_tokens=4096, ) search_tool = SerperDevTool() # ------ Modal service handles (lazy) ------ _flux_url = None _vox_url = None _transcribe_url = None def _get_flux_url() -> str: global _flux_url if _flux_url is None: import modal as _modal _flux_url = _modal.Cls.from_name( "flux-image-generator", "FluxGenerator" )().generate.get_web_url() return _flux_url def _get_vox_url() -> str: global _vox_url if _vox_url is None: import modal as _modal _vox_url = _modal.Cls.from_name( "voxcpm-generator", "VoxCPMGenerator" )().synthesize.get_web_url() return _vox_url def _get_transcribe_url() -> str: global _transcribe_url if _transcribe_url is None: import modal as _modal _transcribe_url = _modal.Cls.from_name( "cohere-transcriber", "CohereTranscriber" )().transcribe.get_web_url() return _transcribe_url CREW_TIMEOUT = 300 # seconds per crew _POOL = concurrent.futures.ThreadPoolExecutor(max_workers=1) def _run_with_timeout(fn, *, timeout=CREW_TIMEOUT): """Run a callable with a hard timeout. Returns result or fallback str. Propagates OTel context (session ID, etc.) to the worker thread. """ ctx = contextvars.copy_context() fut = _POOL.submit(ctx.run, fn) try: return fut.result(timeout=timeout) except concurrent.futures.TimeoutError: print(f"\n⚠ Crew timed out after {timeout}s — using fallback\n") return "The search timed out. Using general knowledge as fallback." # ------ Research Pipeline ------ def run_pipeline(statement: str, session_id: str | None = None) -> dict: """ Run the 3-crew research pipeline on a statement. Args: statement: The statement to research. session_id: Optional Phoenix session ID for trace grouping. Returns a dict with keys: prompt, result_corroborate, result_opposite """ if session_id is None: session_id = str(uuid.uuid4()) print(f"\n{'=' * 60}") print(f'Researching statement: "{statement}"') print(f"Session: {session_id}") print(f"{'=' * 60}\n") with _using_session(session_id): # ------------------------------------------------------- # CREW 1 — Corroborative research # ------------------------------------------------------- researcher_corroborate = Agent( role="Corroboration Researcher", goal=f"Find facts and evidence that support or corroborate the statement: '{statement}'", backstory="You are a researcher skilled at finding supporting evidence for a given claim. You provide detailed findings with sources.", verbose=True, allow_delegation=False, tools=[search_tool], llm=llm, max_iter=1, ) task_corroborate = Task( description=f"Search the web for facts, data, and expert opinions that support the statement: '{statement}'. Provide a detailed list of corroborative evidence with sources.", expected_output="Detailed bullet-point list of corroborative facts with sources", agent=researcher_corroborate, ) crew_corroborate = Crew( agents=[researcher_corroborate], tasks=[task_corroborate], verbose=True, process=Process.sequential, ) print("\n>>> Crew 1: Corroborative research <<<\n") result_corroborate = _run_with_timeout(crew_corroborate.kickoff) # ------------------------------------------------------- # CREW 2 — Opposite / complementary research # ------------------------------------------------------- researcher_opposite = Agent( role="Opposition Researcher", goal=f"Find facts and evidence that challenge, contradict, or offer a complementary perspective to the statement: '{statement}'", backstory="You are a researcher skilled at finding counterarguments, alternative viewpoints, and complementary angles to a given claim.", verbose=True, allow_delegation=False, tools=[search_tool], llm=llm, max_iter=1, ) task_opposite = Task( description=f"Search the web for facts, data, and expert opinions that contradict, challenge, or offer a different perspective on the statement: '{statement}'. Provide a detailed list of opposing or complementary evidence with sources.", expected_output="Detailed bullet-point list of opposing/complementary facts with sources", agent=researcher_opposite, ) crew_opposite = Crew( agents=[researcher_opposite], tasks=[task_opposite], verbose=True, process=Process.sequential, ) print("\n>>> Crew 2: Opposite / complementary research <<<\n") result_opposite = _run_with_timeout(crew_opposite.kickoff) # ------------------------------------------------------- # CREW 3 — Synthesize into image prompt # ------------------------------------------------------- synthesizer = Agent( role="Creative Director & Visual Designer", goal="Synthesize two research perspectives into a compelling visual concept", backstory="You are a world-class creative director who translates complex, contrasting ideas into powerful visual concepts.", verbose=True, allow_delegation=False, llm=llm, max_iter=1, ) task_synthesize = Task( description=f"""A user made this statement: "{statement}" Two research crews investigated it. Here are their findings: === CORROBORATIVE EVIDENCE === {result_corroborate} === OPPOSING / COMPLEMENTARY EVIDENCE === {result_opposite} Your job: Create a highly detailed visual scene description that captures and merges the dialogue, contrast, or tension between these two perspectives. Describe the composition, colors, lighting, mood, subjects, setting, and visual metaphor in vivid detail — as if instructing an artist or image generation model. Output exactly in this format: IMAGE PROMPT: """, expected_output="A detailed image prompt", agent=synthesizer, ) crew_synthesize = Crew( agents=[synthesizer], tasks=[task_synthesize], verbose=True, process=Process.sequential, ) print("\n>>> Crew 3: Generating image prompt <<<\n") result = crew_synthesize.kickoff() print(f"\n{'=' * 60}") print("IMAGE PROMPT") print(f"{'=' * 60}\n") print(result) # Extract IMAGE PROMPT result_text = str(result) prompt = None for line in result_text.split("\n"): if line.startswith("IMAGE PROMPT:"): prompt = line[len("IMAGE PROMPT:") :].strip() return { "prompt": prompt, "result_corroborate": str(result_corroborate), "result_opposite": str(result_opposite), } # ------ Image Generation (Flux on Modal) ------ def generate_image(prompt: str) -> bytes: print(f"{'=' * 60}") print("Generating image with Flux on Modal...") print(f"{'=' * 60}\n") print(f"Prompt: {prompt}\n") import httpx as _httpx with ( tracer.start_as_current_span("flux.generate_image") if tracer else nullcontext() ): url = _get_flux_url() resp = _httpx.post( url, json={"prompt": prompt, "steps": 30}, timeout=600, follow_redirects=True, ) resp.raise_for_status() return resp.content # ------ Caption Generation (direct LLM call with image prompt context) ------ def generate_caption( corroborate: str, opposite: str, image_prompt: str, session_id: str | None = None ) -> dict: """ Send the research + image prompt to Gemma 4 and get a 30-second spoken caption + voice style back. """ if session_id is None: session_id = str(uuid.uuid4()) import httpx as _httpx print(f"\n{'=' * 60}") print("Generating caption via Gemma 4...") print(f"{'=' * 60}\n") payload = { "model": _VLLM_SERVED_MODEL, "max_tokens": 1024, "messages": [ { "role": "system", "content": "You are a creative narrator. Given two research perspectives and the description of an image they inspired, write a 30-second spoken narration (60-90 words) and choose a matching voice style. The caption should tie the research and the visual together.", }, { "role": "user", "content": f"""Two research crews investigated this statement. Here are their findings: === CORROBORATIVE EVIDENCE === {corroborate} === OPPOSING / COMPLEMENTARY EVIDENCE === {opposite} The image below was generated from this prompt (based on the findings above): "{image_prompt}" Your job: 1. Write a 30-second spoken narration (60-90 words) that weaves the two research perspectives together and describes what the image shows. 2. Choose a voice style for the narration (e.g. "gentle melancholic girl", "laid-back surfer dude", "authoritative news anchor", "warm thoughtful professor", etc.). Output exactly in this format: CAPTION: <30-second spoken narration, 60-90 words> VOICE_STYLE: """, }, ], } with _using_session(session_id): _span_cm = ( tracer.start_as_current_span("llm.generate_caption") if tracer else nullcontext() ) with _span_cm as _span: resp = _httpx.post( f"{_VLLM_BASE_URL}/chat/completions", json=payload, headers={"Authorization": "Bearer sk-dummy-key-not-needed"}, timeout=300, ) resp.raise_for_status() body = resp.json() text = body["choices"][0]["message"]["content"] if tracer and "usage" in body: usage = body["usage"] _span.set_attribute( "llm.token_count.prompt", usage.get("prompt_tokens", 0) ) _span.set_attribute( "llm.token_count.completion", usage.get("completion_tokens", 0) ) _span.set_attribute( "llm.token_count.total", usage.get("total_tokens", 0) ) _span.set_attribute("llm.model_name", _VLLM_SERVED_MODEL) print(f"Gemma response:\n{text}\n") caption = None voice_style = None for line in text.split("\n"): if line.startswith("CAPTION:"): caption = line[len("CAPTION:") :].strip() elif line.startswith("VOICE_STYLE:"): voice_style = line[len("VOICE_STYLE:") :].strip().lower() return {"caption": caption, "voice_style": voice_style} # ------ Voice Generation (VoxCPM on Modal) ------ def generate_voice( voice_style: str, voice_script: str, session_id: str | None = None ) -> bytes: """Send script to VoxCPM on Modal T4, return WAV bytes.""" if session_id is None: session_id = str(uuid.uuid4()) print(f"\n{'=' * 60}") print("Generating voice with VoxCPM on Modal...") print(f"{'=' * 60}\n") import httpx as _httpx with _using_session(session_id): with ( tracer.start_as_current_span("voxcpm.generate_voice") if tracer else nullcontext() ): url = _get_vox_url() resp = _httpx.post( url, json={"text": voice_script, "voice_style": voice_style}, timeout=600, follow_redirects=True, ) resp.raise_for_status() return resp.content # ------ Audio Transcription (Cohere Transcribe on Modal) ------ def transcribe_audio(audio_path: str, session_id: str | None = None) -> str: import base64 import httpx as _httpx if session_id is None: session_id = str(uuid.uuid4()) url = _get_transcribe_url() with _using_session(session_id): with ( tracer.start_as_current_span("cohere.transcribe_audio") if tracer else nullcontext() ): with open(audio_path, "rb") as f: audio_b64 = base64.b64encode(f.read()).decode() resp = _httpx.post(url, json={"audio": audio_b64}, timeout=600) resp.raise_for_status() text = resp.json()["transcription"] print(f'Transcribed: "{text}"\n') return text # ------ CLI Entry Point ------ if __name__ == "__main__": audio_path = None statement = None session_id = str(uuid.uuid4()) if len(sys.argv) > 1: if sys.argv[1] == "--audio" and len(sys.argv) > 2: audio_path = sys.argv[2] else: statement = " ".join(sys.argv[1:]) if audio_path: statement = transcribe_audio(audio_path, session_id=session_id) if not statement: statement = input("Enter a statement to research: ") with _using_session(session_id): results = run_pipeline(statement, session_id=session_id) prompt = results["prompt"] corroborate = results["result_corroborate"] opposite = results["result_opposite"] # Generate image if prompt: try: image_bytes = generate_image(prompt) filename = "crew_flux_output.png" with open(filename, "wb") as f: f.write(image_bytes) print(f"\n✓ Image saved to {filename}") except Exception as e: print(f"\n✗ Failed to generate image: {e}") print( " Make sure flux_generator.py is deployed: modal deploy flux_generator.py" ) else: print("(Skipping image generation — no IMAGE PROMPT found in output)") image_bytes = None # Generate caption from research + image prompt caption_result = None if prompt and corroborate and opposite: try: caption_result = generate_caption( corroborate, opposite, prompt, session_id=session_id ) if caption_result["caption"]: print(f"\n✓ Caption: {caption_result['caption']}") if caption_result["voice_style"]: print(f"\n✓ Voice style: {caption_result['voice_style']}") except Exception as e: print(f"\n✗ Failed to generate caption: {e}") else: print("(Skipping caption generation — missing prompt or research)") # Generate voice if ( caption_result and caption_result["voice_style"] and caption_result["caption"] ): try: audio_bytes = generate_voice( caption_result["voice_style"], caption_result["caption"], session_id=session_id, ) filename = "crew_voice_output.wav" with open(filename, "wb") as f: f.write(audio_bytes) print(f"\n✓ Voice saved to {filename}") except Exception as e: print(f"\n✗ Failed to generate voice: {e}") print( " Make sure voxcpm_generator.py is deployed: modal deploy voxcpm_generator.py" ) else: print("\n(Skipping voice generation — no caption/style found)")