Coverage for wrapper/podcasttranscript/wrapper_podcasttranscript.py: 44%

369 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 04:47 +0000

1""" 

2Podcast transcript generation wrapper. 

3It uses an LLM to generate podcast transcripts from PDF documents. 

4""" 

5 

6import asyncio 

7import logging 

8import requests 

9import re 

10import traceback 

11import json 

12import tempfile 

13import aiofiles 

14 

15from azure.identity import DefaultAzureCredential 

16from azure.identity import get_bearer_token_provider 

17from openai import AsyncAzureOpenAI 

18from openai import AsyncOpenAI 

19 

20from typing import TYPE_CHECKING 

21from typing import cast 

22from typing import override 

23from typing import List 

24from typing import Dict 

25from typing import Optional 

26from typing import Any 

27from typing import AsyncGenerator 

28 

29if TYPE_CHECKING: 

30 from openai import AsyncStream 

31 from openai.types.chat import ChatCompletionChunk 

32 

33from pydantic import BaseModel 

34from pydantic import Field 

35from tenacity import retry 

36from tenacity import stop_after_attempt 

37from tenacity import wait_random_exponential 

38 

39from wrapper_model import ModelGeneration 

40 

41from file_utils import base64_to_binary 

42from media_utils import fix_json_like_string 

43 

44from pdf_utils import parse_pdf 

45 

46from transcript_prompts import QUESTION_MODIFIER 

47from transcript_prompts import SYSTEM_PROMPT 

48from transcript_prompts import IMG_STYLE_MODIFIERS 

49from transcript_prompts import IMG_SCENE_MODIFIERS 

50 

51from http import HTTPStatus 

52 

53 

54DEFAULT_MAX_TOKENS = 5000 

55DEFAULT_MAX_DIALOGUES = 50 

56DEFAULT_NUM_CHARACTERS = 2 

57DEFAULT_TEMPERATURE = 0.7 

58 

59 

60class Dialogue(BaseModel): 

61 character: str = Field( 

62 ..., description="Name of the character delivering the dialogue." 

63 ) 

64 transcript: str = Field( 

65 ..., description="Transcript of the dialogue. Must not be more than 50 words." 

66 ) 

67 end_script: bool = Field( 

68 default=False, 

69 description="Indicates if the script is finished. If True, no more dialogues will be generated." 

70 ) 

71 

72 def __str__(self) -> str: 

73 str_ = f"{self.character}: {self.transcript}" 

74 return str_ 

75 

76 

77class Script(BaseModel): 

78 dialogues: List[Dialogue] = Field( 

79 ..., 

80 description="Ordered list of dialogues in the script." 

81 ) 

82 

83 def __str__(self) -> str: 

84 str_ = "Script:\n\n" 

85 for dialogue in self.dialogues: 

86 str_ += f"{dialogue.__str__()}\n\n" 

87 return str_ 

88 

89 

90class Scene(BaseModel): 

91 characters: List[str] = Field( 

92 ..., description="List of characters shown in this scene." 

93 ) 

94 dialogues: List[Dialogue] = Field( 

95 ..., description="Ordered list of dialogues in the scene." 

96 ) 

97 

98 def __str__(self) -> str: 

99 str_ = f"Characters: {self.characters}\n\n" 

100 str_ += "Transcript:\n\n" 

101 for dialogue in self.dialogues: 

102 str_ += f"{dialogue.__str__()}\n\n" 

103 return str_ 

104 

105 

106class Podcast(BaseModel): 

107 scenes: List[Scene] = Field( 

108 ..., description="Ordered list of scenes in the podcast." 

109 ) 

110 

111 def __str__(self) -> str: 

112 str_ = "" 

113 for scene_idx, scene in enumerate(self.scenes): 

114 str_ += f"Scene: {scene_idx}\n{scene.__str__()}" 

115 return str_ 

116 

117 

118class PodcastTranscriptGenerator(ModelGeneration): 

119 """ 

120 A class to generate podcast transcripts from PDF documents using an LLM. 

121 This class handles downloading PDFs, parsing them into text and images, 

122 and generating a structured podcast script with scenes and dialogues. 

123 """ 

124 

125 def __init__( 

126 self, 

127 llm_url: str = "http://localhost:8000/v1", 

128 llm_model: str = "meta-llama/Meta-Llama-3.1-8B", 

129 multi_modal: bool = False, 

130 ) -> None: 

131 super().__init__("podcasttranscript") 

132 self.set_llm(llm_model, llm_url, multi_modal) 

133 

134 def load_model(self) -> None: 

135 logging.debug("No model for podcast transcript generation.") 

136 

137 def init_model_parallelism(self) -> None: 

138 logging.debug("No parallelism for podcast transcript generation.") 

139 

140 def model_compile(self) -> None: 

141 logging.debug("No compilation for podcast transcript generation.") 

142 

143 def _get_azure_openai_client(self, base_url: str) -> AsyncAzureOpenAI: 

144 def _get_azure_api_version(base_url: str) -> Optional[str]: 

145 api_version_pattern = r"api-version=([^&]+)" 

146 match = re.search(api_version_pattern, base_url) 

147 if match: 

148 return match.group(1) 

149 logging.error("Failed to extract API version from URL") 

150 return None 

151 

152 api_version = _get_azure_api_version(base_url) 

153 azure_token_url = "https://cognitiveservices.azure.com/.default" 

154 token_provider = get_bearer_token_provider( 

155 DefaultAzureCredential(), azure_token_url 

156 ) 

157 return AsyncAzureOpenAI( 

158 azure_ad_token_provider=token_provider, 

159 azure_endpoint=base_url, 

160 api_version=api_version) 

161 

162 def set_llm( 

163 self, 

164 llm_model: str, 

165 llm_url: str, 

166 multi_modal: bool = False, 

167 api_key: str = "n/a", 

168 ) -> None: 

169 self.llm_model = llm_model 

170 self.llm_url = llm_url 

171 self.multi_modal = multi_modal 

172 self.llm_client: AsyncOpenAI 

173 if "azure" in llm_url: 

174 self.llm_client = self._get_azure_openai_client(llm_url) 

175 self.extra_body = None 

176 else: 

177 self.llm_client = AsyncOpenAI( 

178 api_key=api_key, 

179 base_url=self.llm_url) 

180 # timeout=10.0) # Set a timeout for LLM requests 

181 self.extra_body = dict(guided_decoding_backend="xgrammar") 

182 

183 def download_pdf( 

184 self, 

185 url: str, 

186 job_id: Optional[str] = None, 

187 ) -> str: 

188 """ 

189 Download the PDF from the given URL and save it to a temporary file. 

190 """ 

191 response = requests.get(url, timeout=30) 

192 if response.status_code == HTTPStatus.OK: 

193 if not job_id: 

194 output_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name 

195 else: 

196 output_path = f"/tmp/{job_id}.pdf" 

197 with open(output_path, "wb") as file: 

198 file.write(response.content) 

199 return output_path 

200 raise Exception(f"Failed to download PDF, status code: {response.status_code}") 

201 

202 async def gen_script( 

203 self, 

204 pdf_images: List[str], 

205 pdf_text: List[str], 

206 max_tokens: int = DEFAULT_MAX_TOKENS, 

207 temperature: float = DEFAULT_TEMPERATURE, 

208 max_dialogues: int = DEFAULT_MAX_DIALOGUES, 

209 max_words_per_dialogue: int = -1, 

210 job_id: Optional[str] = None, 

211 ) -> Script: 

212 QUESTION = "What is the main theme of this paper?" 

213 

214 # Prepare the user prompt text string 

215 user_prompt_text = "\n\n".join(pdf_text) 

216 user_prompt_text += f"\n\n{QUESTION_MODIFIER} {QUESTION}" 

217 

218 # Add images to the user prompt 

219 user_prompt: List[Dict[str, Any]] = [{"type": "text", "text": user_prompt_text}] 

220 if self.multi_modal: 

221 for image_url in pdf_images: 

222 user_prompt.append({"type": "image_url", "image_url": {"url": image_url}}) 

223 

224 # Prepare the system prompt text string 

225 system_prompt = SYSTEM_PROMPT 

226 system_prompt += f"\n\n{QUESTION_MODIFIER} {QUESTION}" 

227 

228 # Combine the prompts into a single message 

229 messages: List[Dict[str, Any]] = [ 

230 {"role": "system", "content": system_prompt}, 

231 {"role": "user", "content": user_prompt} 

232 ] 

233 

234 async with aiofiles.open(f"/tmp/{job_id}_prompt.json", "w") as prompt_file: 

235 await prompt_file.write(json.dumps(messages, indent=2)) 

236 

237 try: 

238 response = await self.llm_client.beta.chat.completions.parse( 

239 model=self.llm_model, 

240 messages=messages, # type: ignore[arg-type] 

241 temperature=temperature, 

242 max_tokens=max_tokens, 

243 extra_body=self.extra_body, 

244 response_format=Script, 

245 ) 

246 logging.info(f"LLM response: {response}") 

247 except Exception as ex: 

248 msg = f"Cannot query LLM for script at {self.llm_url}: {ex}." 

249 logging.error(msg) 

250 raise Exception(msg) 

251 

252 response_message = response.choices[0].message 

253 if response_message.parsed: 

254 return response_message.parsed 

255 raise ValueError("LLM response did not contain a parsed Script object.") 

256 

257 @retry(stop=stop_after_attempt(3), wait=wait_random_exponential(min=1, max=3)) 

258 async def gen_script_stream( 

259 self, 

260 pdf_images: List[str], 

261 pdf_text: List[str], 

262 max_tokens: int = DEFAULT_MAX_TOKENS, 

263 max_dialogues: int = DEFAULT_MAX_DIALOGUES, 

264 max_words_per_dialogue: int = -1, 

265 num_characters: int = DEFAULT_NUM_CHARACTERS, 

266 style_prompt: Optional[str] = None, 

267 scene_prompt: Optional[str] = None, 

268 custom_prompt: Optional[str] = None, 

269 temperature: float = DEFAULT_TEMPERATURE, 

270 job_id: Optional[str] = None, 

271 ) -> AsyncGenerator[Dict, None]: 

272 QUESTION = "What is the main theme of this paper?" 

273 system_prompt_text = SYSTEM_PROMPT + f"\n\n{QUESTION_MODIFIER} {QUESTION}" 

274 user_prompt_text = "\n\n".join(pdf_text) + f"\n\n{QUESTION_MODIFIER} {QUESTION}" 

275 

276 contraint_instruction = "" 

277 if max_words_per_dialogue > 0: 

278 contraint_instruction += f"Each dialogue should not exceed {max_words_per_dialogue} words.\n" 

279 

280 if max_dialogues < 0: 

281 logging.warning(f"'max_dialogues' not set, using default value of {DEFAULT_MAX_DIALOGUES}.") 

282 max_dialogues = DEFAULT_MAX_DIALOGUES 

283 if max_dialogues > 0: 

284 contraint_instruction += f"Generate as close as possible to {max_dialogues} dialogues.\n" 

285 contraint_instruction += f"Do not generate more than {max_dialogues} dialogues.\n" 

286 if max_dialogues > 2: 

287 contraint_instruction += "Make the last two dialogues conclude the discussion.\n" 

288 

289 if num_characters == 1: 

290 contraint_instruction += "Generate an image description with 1 character. " 

291 contraint_instruction += "The image description must feature exactly 1 person. " 

292 contraint_instruction += "GENERATE ONLY 1 CHARACTER. " 

293 contraint_instruction += "The scene must feature a single person. " 

294 contraint_instruction += "All dialogues belong to this person. " 

295 elif num_characters > 1: 

296 contraint_instruction += f"Generate an image description with {num_characters} characters." 

297 contraint_instruction += f"The image description must feature exactly {num_characters} people. " 

298 contraint_instruction += f"GENERATE ONLY {num_characters} CHARACTERS. " 

299 contraint_instruction += f"The scene must feature {num_characters} people. " 

300 contraint_instruction += f"Make the {num_characters} characters have a dialogue. " 

301 contraint_instruction += f"There must be {num_characters} characters in the dialogues. " 

302 contraint_instruction += "Do NOT add any other characters.\n" 

303 

304 if style_prompt and style_prompt in IMG_STYLE_MODIFIERS: 

305 style_modifier = IMG_STYLE_MODIFIERS[style_prompt] 

306 contraint_instruction += f"The style of the image should be {style_modifier}.\n" 

307 if scene_prompt and scene_prompt in IMG_SCENE_MODIFIERS: 

308 scene_modifier = IMG_SCENE_MODIFIERS[scene_prompt] 

309 contraint_instruction += f"The scene of the image should be in {scene_modifier}.\n" 

310 if custom_prompt: 

311 contraint_instruction += f"The image should have {custom_prompt}.\n" 

312 

313 # Compose prompt 

314 system_prompt_text = ( 

315 f"{system_prompt_text}\n\n" 

316 f"{contraint_instruction}" 

317 ) 

318 user_prompt_text = ( 

319 f"{user_prompt_text}\n\n" 

320 f"{contraint_instruction}" 

321 ) 

322 

323 # Build the messages 

324 user_msg: List[Dict[str, Any]] = [{ 

325 "type": "text", 

326 "text": user_prompt_text 

327 }] 

328 if self.multi_modal: 

329 for image_url in pdf_images: 

330 user_msg.append({ 

331 "type": "image_url", 

332 "image_url": {"url": image_url}}) 

333 

334 messages: List[Dict[str, Any]] = [ 

335 {"role": "system", "content": system_prompt_text}, 

336 {"role": "user", "content": user_msg} 

337 ] 

338 

339 async with aiofiles.open(f"/tmp/{job_id}_prompt.json", "w") as prompt_file: 

340 await prompt_file.write(json.dumps(messages, indent=2)) 

341 

342 response_stream = cast("AsyncStream[ChatCompletionChunk]", await self.llm_client.chat.completions.create( 

343 model=self.llm_model, 

344 messages=messages, # type: ignore[arg-type] 

345 temperature=temperature, 

346 max_tokens=max_tokens, 

347 extra_body=self.extra_body, 

348 stream=True, 

349 )) 

350 

351 it = 0 

352 buffer_text = "" 

353 async for chunk in response_stream: 

354 if self.is_interrupted(): 

355 logging.info("Generation interrupted.") 

356 return 

357 

358 delta = chunk.choices[0].delta.content or "" 

359 buffer_text += delta 

360 if delta.endswith("\n"): 

361 buffer_text = buffer_text.strip() 

362 if buffer_text.startswith("{") and buffer_text.endswith("}"): 

363 try: 

364 buffer_text = fix_json_like_string(buffer_text) 

365 buffer_json = json.loads(buffer_text) 

366 yield buffer_json 

367 except json.JSONDecodeError as json_error: 

368 logging.error(f"JSON error: {json_error} for buffer: {buffer_text}") 

369 else: 

370 logging.info(f"Ignoring: {buffer_text}") 

371 buffer_text = "" 

372 it += 1 

373 

374 async def gen_podcast( 

375 self, 

376 script: str, 

377 max_tokens: int = DEFAULT_MAX_TOKENS, 

378 temperature: float = DEFAULT_TEMPERATURE, 

379 job_id: Optional[str] = None, 

380 ) -> Podcast: 

381 # Prepare the user prompt text string 

382 user_prompt = "Given the podcast description, split the dialogues into scenes such that some scenes focus on " 

383 user_prompt += "one character, others on multiple characters." 

384 user_prompt += f"\n\nThe transcript is as follows: {script}" 

385 

386 # Prepare the system prompt text string 

387 system_prompt = "You are a world-class podcast director who can organize a script into a collection of scenes " 

388 system_prompt += "that will be later converted into a video." 

389 

390 # Combine the prompts into a single message 

391 messages: List[Dict[str, Any]] = [ 

392 {"role": "system", "content": system_prompt}, 

393 {"role": "user", "content": user_prompt} 

394 ] 

395 

396 async with aiofiles.open(f"/tmp/{job_id}_prompt.json", "w") as prompt_file: 

397 await prompt_file.write(json.dumps(messages, indent=2)) 

398 

399 try: 

400 response = await self.llm_client.beta.chat.completions.parse( 

401 model=self.llm_model, 

402 messages=messages, # type: ignore[arg-type] 

403 temperature=temperature, 

404 max_tokens=max_tokens, 

405 extra_body=self.extra_body, 

406 response_format=Podcast, 

407 ) 

408 except Exception as ex: 

409 msg = f"Cannot query LLM for podcast at {self.llm_url}: {ex}." 

410 logging.error(msg) 

411 raise Exception(msg) 

412 

413 response_message = response.choices[0].message 

414 if response_message.parsed: 

415 return response_message.parsed 

416 raise ValueError("LLM response did not contain a parsed Podcast object.") 

417 

418 @override 

419 async def generate( 

420 self, 

421 pdf_url: Optional[str] = None, 

422 pdf_text: Optional[List[str]] = None, 

423 pdf_images: Optional[List[str]] = None, 

424 max_tokens: int = DEFAULT_MAX_TOKENS, 

425 temperature: float = DEFAULT_TEMPERATURE, 

426 llm_model: Optional[str] = None, 

427 llm_url: Optional[str] = None, 

428 multi_modal: bool = False, 

429 max_dialogues: int = -1, 

430 max_words_per_dialogue: int = -1, 

431 job_id: Optional[str] = None, 

432 ) -> Podcast: 

433 gen_timer = self._new_gen_timer(job_id) 

434 

435 self.running = True # We can run in parallel but good to know if we are running 

436 

437 if llm_model is not None and llm_url is not None: 

438 logging.info(f"Setting LLM model to {llm_model} at {llm_url}.") 

439 self.set_llm( 

440 llm_model, 

441 llm_url, 

442 multi_modal) 

443 

444 try: 

445 if pdf_url: 

446 if not pdf_url.startswith("http"): 

447 raise ValueError(f"PDF URL must start with 'http': {pdf_url}") 

448 gen_timer.start("download_pdf") 

449 pdf_path = self.download_pdf(pdf_url, job_id) 

450 pdf_text, pdf_images = parse_pdf(pdf_path) 

451 gen_timer.end("download_pdf") 

452 

453 if pdf_text is None or pdf_images is None: 

454 raise ValueError("PDF text or images are not provided or could not be parsed.") 

455 

456 gen_timer.start("gen_script") 

457 script = await self.gen_script( 

458 pdf_images, 

459 pdf_text, 

460 max_tokens=max_tokens, 

461 temperature=temperature, 

462 max_dialogues=max_dialogues, 

463 max_words_per_dialogue=max_words_per_dialogue, 

464 job_id=job_id, 

465 ) 

466 gen_timer.end("gen_script") 

467 

468 gen_timer.start("gen_podcast") 

469 podcast = await self.gen_podcast( 

470 str(script), 

471 max_tokens=max_tokens, 

472 temperature=temperature) 

473 gen_timer.end("gen_podcast") 

474 finally: 

475 self.running = False 

476 gen_timer.end("total") 

477 

478 return podcast 

479 

480 async def generate_stream( 

481 self, 

482 pdf_url: Optional[str] = None, 

483 pdf_text: Optional[List[str]] = None, 

484 pdf_images: Optional[List[str]] = None, 

485 max_tokens: int = DEFAULT_MAX_TOKENS, 

486 temperature: float = DEFAULT_TEMPERATURE, 

487 llm_model: Optional[str] = None, 

488 llm_url: Optional[str] = None, 

489 multi_modal: bool = False, 

490 max_dialogues: int = -1, 

491 max_words_per_dialogue: int = -1, 

492 num_characters: int = DEFAULT_NUM_CHARACTERS, 

493 style_prompt: Optional[str] = None, 

494 scene_prompt: Optional[str] = None, 

495 custom_prompt: Optional[str] = None, 

496 job_id: Optional[str] = None, 

497 ) -> AsyncGenerator[Dict, None]: 

498 gen_timer = self._new_gen_timer(job_id) 

499 

500 self.running = True # We can run in parallel but good to know if we are running 

501 

502 if llm_model is not None and llm_url is not None: 

503 logging.info(f"Setting LLM model to {llm_model} at {llm_url}.") 

504 self.set_llm( 

505 llm_model, 

506 llm_url, 

507 multi_modal) 

508 

509 try: 

510 if pdf_url: 

511 if not pdf_url.startswith("http"): 

512 raise ValueError(f"PDF URL must start with 'http': {pdf_url}") 

513 gen_timer.start("download_pdf") 

514 pdf_path = self.download_pdf(pdf_url) 

515 pdf_text, pdf_images = parse_pdf(pdf_path) 

516 gen_timer.end("download_pdf") 

517 

518 if pdf_text is None or pdf_images is None: 

519 raise ValueError("PDF text or images are not provided or could not be parsed.") 

520 

521 it = 0 

522 gen_timer.start("gen_script_stream") 

523 gen_timer.start(f"gen_script_stream_{it}") 

524 async for dialogue in self.gen_script_stream( 

525 pdf_images, 

526 pdf_text, 

527 max_tokens=max_tokens, 

528 temperature=temperature, 

529 max_dialogues=max_dialogues, 

530 max_words_per_dialogue=max_words_per_dialogue, 

531 num_characters=num_characters, 

532 style_prompt=style_prompt, 

533 scene_prompt=scene_prompt, 

534 custom_prompt=custom_prompt, 

535 job_id=job_id, 

536 ): 

537 yield dialogue 

538 gen_timer.end(f"gen_script_stream_{it}") 

539 it += 1 

540 gen_timer.start(f"gen_script_stream_{it}") 

541 gen_timer.end(f"gen_script_stream_{it}") 

542 gen_timer.end("gen_script_stream") 

543 finally: 

544 self.running = False 

545 gen_timer.end("total") 

546 

547 async def warmup(self) -> None: 

548 pass # No specific warmup needed for this model 

549 

550 async def get_rest_args( 

551 self, 

552 data_json: Dict[str, str] 

553 ) -> Dict[str, Any]: 

554 if data_json is None: 

555 raise ValueError("Missing JSON body") 

556 

557 job_id = data_json.get("job_id", None) 

558 

559 pdf_url = data_json.get("pdf_url", None) 

560 

561 pdf_base64 = data_json.get("doc", None) 

562 pdf_text = None 

563 pdf_images = None 

564 if pdf_base64 is not None: 

565 if not job_id: 

566 output_path = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False).name 

567 else: 

568 output_path = f"/tmp/{job_id}.pdf" 

569 doc_bytes = base64_to_binary(pdf_base64) 

570 async with aiofiles.open(output_path, "wb") as doc_file: 

571 await doc_file.write(doc_bytes) 

572 pdf_text, pdf_images = parse_pdf(output_path) 

573 

574 if pdf_url is None and pdf_text is None and pdf_images is None: 

575 raise ValueError("Either 'pdf_url' or 'doc' must be provided") 

576 

577 rest_args: Dict[str, Any] = { 

578 "job_id": job_id, 

579 "temperature": float(data_json.get("temperature", 0.6)), 

580 "max_tokens": int(data_json.get("max_tokens", DEFAULT_MAX_TOKENS)), 

581 "llm_url": data_json.get("llm_url", "http://localhost:8000/v1"), 

582 "llm_model": data_json.get("llm_model", "google/gemma-3-27b-it"), 

583 "multi_modal": bool(data_json.get("multi_modal", False)), 

584 "max_dialogues": int(data_json.get("max_dialogues", DEFAULT_MAX_DIALOGUES)), 

585 "max_words_per_dialogue": int(data_json.get("max_words_per_dialogue", -1)), 

586 "num_characters": int(data_json.get("num_characters", DEFAULT_NUM_CHARACTERS)), 

587 } 

588 

589 if pdf_url is not None: 

590 rest_args["pdf_url"] = pdf_url 

591 if pdf_text is not None: 

592 rest_args["pdf_text"] = pdf_text 

593 if pdf_images is not None: 

594 rest_args["pdf_images"] = pdf_images 

595 

596 if "style_prompt" in data_json: 

597 rest_args["style_prompt"] = data_json["style_prompt"] 

598 if "scene_prompt" in data_json: 

599 rest_args["scene_prompt"] = data_json["scene_prompt"] 

600 if "custom_prompt" in data_json: 

601 rest_args["custom_prompt"] = data_json["custom_prompt"] 

602 

603 return { 

604 "task": self.model_name, 

605 "args": rest_args 

606 } 

607 

608 def get_health(self) -> Dict[str, Any]: 

609 ret = super().get_health() 

610 ret.update({ 

611 # "gpu": torch.cuda.get_device_name(0) 

612 "gpu": None, 

613 "llm_url": self.llm_url, 

614 "llm_model": self.llm_model, 

615 "multi_modal": self.multi_modal, 

616 }) 

617 return ret 

618 

619 

620def log_podcast(scene: Dict[str, Any]) -> None: 

621 if isinstance(scene, dict): 

622 assert "type" in scene 

623 line_type = scene["type"] 

624 if line_type == "image" and "content" in scene: 

625 scene_img_description = scene['content'] 

626 logging.info(f"IMAGE: {scene_img_description}") 

627 elif line_type == "character": 

628 gender = scene.get('gender', 'Unknown') 

629 description = scene.get('description', 'No description provided') 

630 logging.info( 

631 f"Character: {scene['name']} ({gender}) - {description}") 

632 elif line_type == "dialogue" and "character" in scene and "content" in scene: 

633 logging.info(f"[{scene['character']}] {scene['content']}") 

634 else: 

635 logging.info(f"Scene: {scene}") 

636 else: 

637 logging.info(f"Scene: {scene}.") 

638 

639 

640async def main() -> None: 

641 LLM_MODEL = "meta-llama/Meta-Llama-3.1-8B" 

642 LMM_MULTI_MODAL = False 

643 

644 LLM_MODEL = "google/gemma-3-27b-it" 

645 LMM_MULTI_MODAL = True 

646 

647 LLM_URL = "http://localhost:8000/v1" 

648 LLM_URL = "http://localhost:18086/v1" 

649 

650 # Azure OpenAI 

651 LLM_MODEL = "gpt-4o-2024-08-06" 

652 LLM_URL = "https://girfan-az-openai-001.openai.azure.com/openai/" \ 

653 "deployments/gpt-4o-2/chat/completions?api-version=2025-01-01-preview" 

654 

655 # Gemma on Kubernetes 

656 LLM_MODEL = "google/gemma-3-27b-it" 

657 LMM_MULTI_MODAL = True 

658 LLM_URL = "http://10.244.22.5:8000/v1" 

659 

660 logging.basicConfig(level=logging.INFO) 

661 

662 logging.info("Starting Podcast Transcript Generation...") 

663 logging.info(f"Using LLM model: {LLM_MODEL}.") 

664 logging.info(f"Using LLM URL: {LLM_URL}.") 

665 logging.info(f"Multi-modal support: {LMM_MULTI_MODAL}.") 

666 

667 try: 

668 podcast_generator = PodcastTranscriptGenerator( 

669 llm_url=LLM_URL, 

670 llm_model=LLM_MODEL, 

671 multi_modal=LMM_MULTI_MODAL, 

672 ) 

673 

674 PDF_URL = "https://arxiv.org/pdf/2501.16634" 

675 

676 # Output podcast transcript using the async generator 

677 # Podcast 1 

678 logging.info("Generating podcast 1 with a max of 7 dialogues:") 

679 async for scene in podcast_generator.generate_stream( 

680 pdf_url=PDF_URL, 

681 max_dialogues=7, 

682 max_words_per_dialogue=20, 

683 ): 

684 log_podcast(scene) 

685 

686 # Podcast 2 

687 logging.info("Generating podcast 2 with a max of 20 dialogues:") 

688 async for scene in podcast_generator.generate_stream( 

689 pdf_url=PDF_URL, 

690 max_dialogues=20, 

691 max_words_per_dialogue=50, 

692 ): 

693 log_podcast(scene) 

694 

695 # Podcast 3 

696 logging.info("Generating podcast 3 with 3 characters and a max of 15 dialogues:") 

697 async for scene in podcast_generator.generate_stream( 

698 pdf_url=PDF_URL, 

699 max_dialogues=15, 

700 num_characters=3, 

701 max_words_per_dialogue=10, 

702 ): 

703 log_podcast(scene) 

704 

705 # Output podcast transcript in a single pass 

706 podcast = await podcast_generator.generate(PDF_URL) 

707 for podcast_scene in podcast.scenes: 

708 logging.info(f"Scene with characters: {podcast_scene.characters}.") 

709 for dialogue in podcast_scene.dialogues: 

710 logging.info(f"{dialogue.character}: {dialogue.transcript}.") 

711 # We can also output the podcast in JSON 

712 podcast_json = podcast.model_dump_json(indent=2) 

713 logging.info(f"Podcast JSON:\n{podcast_json}") 

714 

715 except Exception as ex: 

716 exc_str = ''.join(traceback.format_tb(ex.__traceback__)) 

717 logging.error(f"An error occurred: {ex}:\n{exc_str}.") 

718 

719 

720if __name__ == "__main__": 

721 asyncio.run(main())