RCaz commited on
Commit
86f078e
·
verified ·
1 Parent(s): 08b3875

Create crew2.py

Browse files
Files changed (1) hide show
  1. crew2.py +490 -0
crew2.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import concurrent.futures
2
+ import sys
3
+ import uuid
4
+ from contextlib import contextmanager
5
+ from dotenv import load_dotenv
6
+
7
+ from crewai import Agent, Crew, LLM, Process, Task
8
+ from crewai_tools import SerperDevTool
9
+
10
+ load_dotenv()
11
+
12
+ # ------ Session tracking (no-op fallback when OTel pkgs not installed) ------
13
+
14
+ try:
15
+ from openinference.instrumentation import using_session as _using_session
16
+ except ImportError:
17
+ @contextmanager
18
+ def _using_session(session_id): # noqa: F811
19
+ yield
20
+
21
+ try:
22
+ from openinference.semconv.trace import SpanAttributes
23
+ except ImportError:
24
+ class _SpanAttributes:
25
+ SESSION_ID = "session_id"
26
+ SpanAttributes = _SpanAttributes() # type: ignore
27
+
28
+ # ------ Phoenix / OpenTelemetry tracing ------
29
+
30
+ try:
31
+ from phoenix.otel import register
32
+ from huggingface_hub.utils import build_hf_headers
33
+ from openinference.instrumentation.openai import OpenAIInstrumentor
34
+
35
+ from opentelemetry import trace
36
+ from openinference.instrumentation.crewai import CrewAIInstrumentor
37
+
38
+ OpenAIInstrumentor().uninstrument()
39
+ tp = trace.get_tracer_provider()
40
+ if tp and hasattr(tp, "shutdown"):
41
+ tp.shutdown()
42
+ trace._TRACER_PROVIDER = None
43
+
44
+ tracer_provider = register(
45
+ project_name="crewai",
46
+ endpoint="https://RCaz-phoenix-arize-observability.hf.space/v1/traces",
47
+ headers=build_hf_headers(),
48
+ )
49
+ CrewAIInstrumentor().instrument(tracer_provider=tracer_provider)
50
+ except ImportError:
51
+ pass
52
+
53
+
54
+ ###### The agentic app
55
+
56
+ # ------ LLM endpoint constants ------
57
+ _VLLM_BASE_URL = "https://rcaz33--example-vllm-inference-serve.modal.run/v1"
58
+ _VLLM_MODEL = "openai/google/gemma-4-26B-A4B-it" # LiteLLM prefix — for crewai
59
+ _VLLM_SERVED_MODEL = (
60
+ "google/gemma-4-26B-A4B-it" # actual vLLM served name — for direct API calls
61
+ )
62
+
63
+ # Define our LLM using the Modal-deployed Gemma 4 26B model via vLLM (OpenAI-compatible API)
64
+ llm = LLM(
65
+ model=_VLLM_MODEL,
66
+ base_url=_VLLM_BASE_URL,
67
+ api_key="sk-dummy-key-not-needed",
68
+ max_tokens=4096,
69
+ )
70
+
71
+ search_tool = SerperDevTool()
72
+
73
+ # ------ Modal service handles (lazy) ------
74
+ _flux_url = None
75
+ _vox_url = None
76
+ _transcribe_url = None
77
+
78
+
79
+ def _get_flux_url() -> str:
80
+ global _flux_url
81
+ if _flux_url is None:
82
+ import modal as _modal
83
+
84
+ _flux_url = _modal.Cls.from_name(
85
+ "flux-image-generator", "FluxGenerator"
86
+ )().generate.get_web_url()
87
+ return _flux_url
88
+
89
+
90
+ def _get_vox_url() -> str:
91
+ global _vox_url
92
+ if _vox_url is None:
93
+ import modal as _modal
94
+
95
+ _vox_url = _modal.Cls.from_name(
96
+ "voxcpm-generator", "VoxCPMGenerator"
97
+ )().synthesize.get_web_url()
98
+ return _vox_url
99
+
100
+
101
+ def _get_transcribe_url() -> str:
102
+ global _transcribe_url
103
+ if _transcribe_url is None:
104
+ import modal as _modal
105
+
106
+ _transcribe_url = _modal.Cls.from_name(
107
+ "cohere-transcriber", "CohereTranscriber"
108
+ )().transcribe.get_web_url()
109
+ return _transcribe_url
110
+
111
+
112
+ CREW_TIMEOUT = 300 # seconds per crew
113
+
114
+
115
+ def _run_with_timeout(fn, *, timeout=CREW_TIMEOUT):
116
+ """Run a callable with a hard timeout. Returns result or fallback str."""
117
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
118
+ fut = pool.submit(fn)
119
+ try:
120
+ return fut.result(timeout=timeout)
121
+ except concurrent.futures.TimeoutError:
122
+ print(f"\n⚠ Crew timed out after {timeout}s — using fallback\n")
123
+ fut.cancel()
124
+ return "The search timed out. Using general knowledge as fallback."
125
+
126
+
127
+ # ------ Research Pipeline ------
128
+
129
+
130
+ def run_pipeline(statement: str, session_id: str | None = None) -> dict:
131
+ """
132
+ Run the 3-crew research pipeline on a statement.
133
+
134
+ Args:
135
+ statement: The statement to research.
136
+ session_id: Optional Phoenix session ID for trace grouping.
137
+
138
+ Returns a dict with keys:
139
+ prompt, result_corroborate, result_opposite
140
+ """
141
+ if session_id is None:
142
+ session_id = str(uuid.uuid4())
143
+
144
+ print(f"\n{'=' * 60}")
145
+ print(f'Researching statement: "{statement}"')
146
+ print(f"Session: {session_id}")
147
+ print(f"{'=' * 60}\n")
148
+
149
+ with _using_session(session_id):
150
+ # -------------------------------------------------------
151
+ # CREW 1 — Corroborative research
152
+ # -------------------------------------------------------
153
+ researcher_corroborate = Agent(
154
+ role="Corroboration Researcher",
155
+ goal=f"Find facts and evidence that support or corroborate the statement: '{statement}'",
156
+ backstory="You are a researcher skilled at finding supporting evidence for a given claim. You provide detailed findings with sources.",
157
+ verbose=True,
158
+ allow_delegation=False,
159
+ tools=[search_tool],
160
+ llm=llm,
161
+ max_iter=1,
162
+ )
163
+
164
+ task_corroborate = Task(
165
+ 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.",
166
+ expected_output="Detailed bullet-point list of corroborative facts with sources",
167
+ agent=researcher_corroborate,
168
+ )
169
+
170
+ crew_corroborate = Crew(
171
+ agents=[researcher_corroborate],
172
+ tasks=[task_corroborate],
173
+ verbose=True,
174
+ process=Process.sequential,
175
+ )
176
+
177
+ print("\n>>> Crew 1: Corroborative research <<<\n")
178
+ result_corroborate = _run_with_timeout(crew_corroborate.kickoff)
179
+
180
+ # -------------------------------------------------------
181
+ # CREW 2 — Opposite / complementary research
182
+ # -------------------------------------------------------
183
+ researcher_opposite = Agent(
184
+ role="Opposition Researcher",
185
+ goal=f"Find facts and evidence that challenge, contradict, or offer a complementary perspective to the statement: '{statement}'",
186
+ backstory="You are a researcher skilled at finding counterarguments, alternative viewpoints, and complementary angles to a given claim.",
187
+ verbose=True,
188
+ allow_delegation=False,
189
+ tools=[search_tool],
190
+ llm=llm,
191
+ max_iter=1,
192
+ )
193
+
194
+ task_opposite = Task(
195
+ 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.",
196
+ expected_output="Detailed bullet-point list of opposing/complementary facts with sources",
197
+ agent=researcher_opposite,
198
+ )
199
+
200
+ crew_opposite = Crew(
201
+ agents=[researcher_opposite],
202
+ tasks=[task_opposite],
203
+ verbose=True,
204
+ process=Process.sequential,
205
+ )
206
+
207
+ print("\n>>> Crew 2: Opposite / complementary research <<<\n")
208
+ result_opposite = _run_with_timeout(crew_opposite.kickoff)
209
+
210
+ # -------------------------------------------------------
211
+ # CREW 3 — Synthesize into image prompt
212
+ # -------------------------------------------------------
213
+ synthesizer = Agent(
214
+ role="Creative Director & Visual Designer",
215
+ goal="Synthesize two research perspectives into a compelling visual concept",
216
+ backstory="You are a world-class creative director who translates complex, contrasting ideas into powerful visual concepts.",
217
+ verbose=True,
218
+ allow_delegation=False,
219
+ llm=llm,
220
+ max_iter=1,
221
+ )
222
+
223
+ task_synthesize = Task(
224
+ description=f"""A user made this statement: "{statement}"
225
+
226
+ Two research crews investigated it. Here are their findings:
227
+
228
+ === CORROBORATIVE EVIDENCE ===
229
+ {result_corroborate}
230
+
231
+ === OPPOSING / COMPLEMENTARY EVIDENCE ===
232
+ {result_opposite}
233
+
234
+ Your job:
235
+ Create a highly detailed visual scene description that captures 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.
236
+
237
+ Output exactly in this format:
238
+
239
+ IMAGE PROMPT: <detailed visual description, 3-5 sentences>""",
240
+ expected_output="A detailed image prompt",
241
+ agent=synthesizer,
242
+ )
243
+
244
+ crew_synthesize = Crew(
245
+ agents=[synthesizer],
246
+ tasks=[task_synthesize],
247
+ verbose=True,
248
+ process=Process.sequential,
249
+ )
250
+
251
+ print("\n>>> Crew 3: Generating image prompt <<<\n")
252
+ result = crew_synthesize.kickoff()
253
+
254
+ print(f"\n{'=' * 60}")
255
+ print("IMAGE PROMPT")
256
+ print(f"{'=' * 60}\n")
257
+ print(result)
258
+
259
+ # Extract IMAGE PROMPT
260
+ result_text = str(result)
261
+ prompt = None
262
+ for line in result_text.split("\n"):
263
+ if line.startswith("IMAGE PROMPT:"):
264
+ prompt = line[len("IMAGE PROMPT:") :].strip()
265
+
266
+ return {
267
+ "prompt": prompt,
268
+ "result_corroborate": str(result_corroborate),
269
+ "result_opposite": str(result_opposite),
270
+ }
271
+
272
+
273
+ # ------ Image Generation (Flux on Modal) ------
274
+
275
+
276
+ def generate_image(prompt: str) -> bytes:
277
+ print(f"{'=' * 60}")
278
+ print("Generating image with Flux on Modal...")
279
+ print(f"{'=' * 60}\n")
280
+ print(f"Prompt: {prompt}\n")
281
+
282
+ import httpx as _httpx
283
+
284
+ url = _get_flux_url()
285
+ resp = _httpx.post(
286
+ url,
287
+ json={"prompt": prompt, "steps": 30},
288
+ timeout=600,
289
+ follow_redirects=True,
290
+ )
291
+ resp.raise_for_status()
292
+ return resp.content
293
+
294
+
295
+ # ------ Caption Generation (direct LLM call with image prompt context) ------
296
+
297
+
298
+ def generate_caption(corroborate: str, opposite: str, image_prompt: str, session_id: str | None = None) -> dict:
299
+ """
300
+ Send the research + image prompt to Gemma 4 and get a 30-second
301
+ spoken caption + voice style back.
302
+ """
303
+ if session_id is None:
304
+ session_id = str(uuid.uuid4())
305
+
306
+ import httpx as _httpx
307
+
308
+ print(f"\n{'=' * 60}")
309
+ print("Generating caption via Gemma 4...")
310
+ print(f"{'=' * 60}\n")
311
+
312
+ payload = {
313
+ "model": _VLLM_SERVED_MODEL,
314
+ "max_tokens": 1024,
315
+ "messages": [
316
+ {
317
+ "role": "system",
318
+ "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.",
319
+ },
320
+ {
321
+ "role": "user",
322
+ "content": f"""Two research crews investigated this statement. Here are their findings:
323
+
324
+ === CORROBORATIVE EVIDENCE ===
325
+ {corroborate}
326
+
327
+ === OPPOSING / COMPLEMENTARY EVIDENCE ===
328
+ {opposite}
329
+
330
+ The image below was generated from this prompt (based on the findings above):
331
+ "{image_prompt}"
332
+
333
+ Your job:
334
+ 1. Write a 30-second spoken narration (60-90 words) that weaves the two research perspectives together and describes what the image shows.
335
+ 2. Choose a voice style for the narration (e.g. "gentle melancholic girl", "laid-back surfer dude", "authoritative news anchor", "warm thoughtful professor", etc.).
336
+
337
+ Output exactly in this format:
338
+
339
+ CAPTION: <30-second spoken narration, 60-90 words>
340
+ VOICE_STYLE: <voice style description>""",
341
+ },
342
+ ],
343
+ }
344
+
345
+ with _using_session(session_id):
346
+ resp = _httpx.post(
347
+ f"{_VLLM_BASE_URL}/chat/completions",
348
+ json=payload,
349
+ headers={"Authorization": "Bearer sk-dummy-key-not-needed"},
350
+ timeout=300,
351
+ )
352
+ resp.raise_for_status()
353
+ body = resp.json()
354
+ text = body["choices"][0]["message"]["content"]
355
+
356
+ print(f"Gemma response:\n{text}\n")
357
+
358
+ caption = None
359
+ voice_style = None
360
+ for line in text.split("\n"):
361
+ if line.startswith("CAPTION:"):
362
+ caption = line[len("CAPTION:") :].strip()
363
+ elif line.startswith("VOICE_STYLE:"):
364
+ voice_style = line[len("VOICE_STYLE:") :].strip().lower()
365
+
366
+ return {"caption": caption, "voice_style": voice_style}
367
+
368
+
369
+ # ------ Voice Generation (VoxCPM on Modal) ------
370
+
371
+
372
+ def generate_voice(voice_style: str, voice_script: str, session_id: str | None = None) -> bytes:
373
+ """Send script to VoxCPM on Modal T4, return WAV bytes."""
374
+ if session_id is None:
375
+ session_id = str(uuid.uuid4())
376
+
377
+ print(f"\n{'=' * 60}")
378
+ print("Generating voice with VoxCPM on Modal...")
379
+ print(f"{'=' * 60}\n")
380
+
381
+ import httpx as _httpx
382
+
383
+ with _using_session(session_id):
384
+ url = _get_vox_url()
385
+ resp = _httpx.post(
386
+ url,
387
+ json={"text": voice_script, "voice_style": voice_style},
388
+ timeout=600,
389
+ follow_redirects=True,
390
+ )
391
+ resp.raise_for_status()
392
+ return resp.content
393
+
394
+
395
+ # ------ Audio Transcription (Cohere Transcribe on Modal) ------
396
+
397
+
398
+ def transcribe_audio(audio_path: str, session_id: str | None = None) -> str:
399
+ import base64
400
+ import httpx as _httpx
401
+
402
+ if session_id is None:
403
+ session_id = str(uuid.uuid4())
404
+
405
+ url = _get_transcribe_url()
406
+
407
+ with _using_session(session_id):
408
+ with open(audio_path, "rb") as f:
409
+ audio_b64 = base64.b64encode(f.read()).decode()
410
+
411
+ resp = _httpx.post(url, json={"audio": audio_b64}, timeout=600)
412
+ resp.raise_for_status()
413
+ text = resp.json()["transcription"]
414
+ print(f'Transcribed: "{text}"\n')
415
+ return text
416
+
417
+
418
+ # ------ CLI Entry Point ------
419
+
420
+ if __name__ == "__main__":
421
+ audio_path = None
422
+ statement = None
423
+ session_id = str(uuid.uuid4())
424
+
425
+ if len(sys.argv) > 1:
426
+ if sys.argv[1] == "--audio" and len(sys.argv) > 2:
427
+ audio_path = sys.argv[2]
428
+ else:
429
+ statement = " ".join(sys.argv[1:])
430
+
431
+ if audio_path:
432
+ statement = transcribe_audio(audio_path, session_id=session_id)
433
+
434
+ if not statement:
435
+ statement = input("Enter a statement to research: ")
436
+
437
+ with _using_session(session_id):
438
+ results = run_pipeline(statement, session_id=session_id)
439
+ prompt = results["prompt"]
440
+ corroborate = results["result_corroborate"]
441
+ opposite = results["result_opposite"]
442
+
443
+ # Generate image
444
+ if prompt:
445
+ try:
446
+ image_bytes = generate_image(prompt)
447
+ filename = "crew_flux_output.png"
448
+ with open(filename, "wb") as f:
449
+ f.write(image_bytes)
450
+ print(f"\n✓ Image saved to {filename}")
451
+ except Exception as e:
452
+ print(f"\n✗ Failed to generate image: {e}")
453
+ print(
454
+ " Make sure flux_generator.py is deployed: modal deploy flux_generator.py"
455
+ )
456
+ else:
457
+ print("(Skipping image generation — no IMAGE PROMPT found in output)")
458
+ image_bytes = None
459
+
460
+ # Generate caption from research + image prompt
461
+ caption_result = None
462
+ if prompt and corroborate and opposite:
463
+ try:
464
+ caption_result = generate_caption(corroborate, opposite, prompt, session_id=session_id)
465
+ if caption_result["caption"]:
466
+ print(f"\n✓ Caption: {caption_result['caption']}")
467
+ if caption_result["voice_style"]:
468
+ print(f"\n✓ Voice style: {caption_result['voice_style']}")
469
+ except Exception as e:
470
+ print(f"\n✗ Failed to generate caption: {e}")
471
+ else:
472
+ print("(Skipping caption generation — missing prompt or research)")
473
+
474
+ # Generate voice
475
+ if caption_result and caption_result["voice_style"] and caption_result["caption"]:
476
+ try:
477
+ audio_bytes = generate_voice(
478
+ caption_result["voice_style"], caption_result["caption"], session_id=session_id
479
+ )
480
+ filename = "crew_voice_output.wav"
481
+ with open(filename, "wb") as f:
482
+ f.write(audio_bytes)
483
+ print(f"\n✓ Voice saved to {filename}")
484
+ except Exception as e:
485
+ print(f"\n✗ Failed to generate voice: {e}")
486
+ print(
487
+ " Make sure voxcpm_generator.py is deployed: modal deploy voxcpm_generator.py"
488
+ )
489
+ else:
490
+ print("\n(Skipping voice generation — no caption/style found)")