# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Responses execution path for audio-only segment analysis. Extracted from ``video_analyzer.py`` as a pure structural split. Patchable collaborators are looked up late through the facade module so ``mock.patch("artemis.agents.video_analyzer.video_analyzer.")`` targets keep working. """ import asyncio from pathlib import Path from artemis.agents.video_analyzer import video_analyzer as _va from artemis.agents.video_analyzer.reliability import ( SubAgentAnswerExhausted, classify_video_failure, ) from artemis.data_engine.trace import TraceSpan from artemis.llm.reliability import retry_policy_for from artemis.utils.logger import get_logger logger = get_logger(__name__) _BAD_CONFIDENCE_ERROR = ( "Error: 'confidence_score' is missing or out of bounds. It must be a float" " between 0.0 and 1.0 inclusive. Fix the payload and call submit_answer again." ) class _AudioMedia: """Mutable holder for per-attempt audio artifacts shared with failure handling.""" __slots__ = ("audio_path", "actual_start", "prompt_with_context") def __init__(self): self.audio_path: Path | None = None self.actual_start: float | None = None self.prompt_with_context: str | None = None async def exec_analyze_audio_only( analyzer, start_time: float, end_time: float | None, specific_query: str ) -> str: """Analyzes only the audio track of a segment, with claim/lease and retries.""" if not isinstance(start_time, (int, float)): return "Error: start_time must be a numeric recording-relative timestamp." if not str(specific_query or "").strip(): return "Error: specific_query must describe the audio evidence to find." max_retries = analyzer.max_retries current_start = round(start_time, 1) if isinstance(start_time, (int, float)) else start_time current_end = round(end_time, 1) if isinstance(end_time, (int, float)) else end_time if current_end is None: controller = _va.get_controller(analyzer.ctx) async with _va.TRANSCODE_SEMAPHORE: metadata = await controller.extract_segment_metadata(current_start, None) duration = getattr(metadata, "duration_seconds", None) actual_start = getattr(metadata, "actual_start_relative_time", current_start) if not metadata.success or not isinstance(duration, (int, float)) or duration <= 0: return f"Error fetching audio segment metadata: {metadata.message}" current_start = round(float(actual_start), 1) current_end = round(current_start + float(duration), 1) if metadata.video_path: metadata_path = Path(metadata.video_path) analyzer.local_files_to_cleanup.add(metadata_path) analyzer.local_dirs_to_cleanup.add(metadata_path.parent) if current_end <= current_start: return "Error: end_time must be greater than start_time." device_id = getattr(getattr(analyzer.ctx, "device", None), "device_id", None) active_session = _va.get_active_session(device_id) if device_id else None claim = analyzer.blackboard.claim_segment( current_start, current_end, specific_query, modality="audio", model_name=analyzer.model_name, source_generation=(active_session.generation if active_session is not None else None), ) if claim.state == "cached": result = ( f"[from {current_start:.1f}s to {current_end:.1f}s] Audio Summary: " f"{claim.summary or 'No audio summary provided.'}" ) if claim.analysis: result += f" Analysis: {claim.analysis}" return "CACHED AUDIO ANALYSIS: " + result if claim.state == "in_progress": return ( f"[from {current_start:.1f}s to {current_end:.1f}s] " "Audio analysis is already in progress; do not duplicate it." ) lease_owner = claim.lease_owner if lease_owner is None: raise RuntimeError("Video blackboard returned an audio claim without a lease owner") media = _AudioMedia() for attempt in range(max_retries + 1): try: logger.info( f"Attempt {attempt + 1}/{max_retries + 1} for" f" analyze_audio_only: start={current_start}," f" end={current_end}" ) result = await _prepare_audio_media(analyzer, media, current_start, current_end) media.prompt_with_context = _build_audio_prompt( analyzer, result, media.actual_start, start_time, end_time, specific_query ) audio_result = await analyzer._exec_analyze_audio_universal( audio_path=media.audio_path, start_time=current_start, end_time=current_end, actual_start=media.actual_start, prompt_with_context=media.prompt_with_context, specific_query=specific_query, ) summary, analysis = analyzer._parse_chunk_result(audio_result) analyzer.blackboard.complete_segment( current_start, current_end, specific_query, lease_owner, summary, analysis, modality="audio", ) return audio_result except Exception as e: # Same contract as the video sub-agent loop: classification # owns the retry decision and every retry restarts the attempt # from scratch. failure = classify_video_failure(e) attempts_so_far = attempt + 1 if not failure.retryable: logger.warning( f"Audio non-retryable failure [{failure.category.value}]" f" on attempt {attempts_so_far}: {e}" ) return await _finish_failed_audio( analyzer, media, current_start, current_end, specific_query, lease_owner, e, retryable=False, ) policy = retry_policy_for(failure.category) allowed_attempts = min(max_retries + 1, policy.max_attempts) if attempts_so_far >= allowed_attempts: logger.warning( f"Audio analysis exhausted {attempts_so_far} attempt(s)" f" [{failure.category.value}]: {e}" ) return await _finish_failed_audio( analyzer, media, current_start, current_end, specific_query, lease_owner, e, ) delay = policy.delay_for(attempts_so_far) logger.warning( f"Audio retryable failure [{failure.category.value}]" f" ({attempts_so_far}/{allowed_attempts}): {e}; retrying" f" from scratch in {delay:.2f}s" ) _va._record_llm_retry( str(e), delay, attempt=attempts_so_far, max_retries=allowed_attempts, source="video_audio_agent", ) await asyncio.sleep(delay) async def _finish_failed_audio( analyzer, media: _AudioMedia, current_start: float, current_end: float, specific_query: str, lease_owner: str, error: Exception, *, retryable: bool | None = None, ): """Records terminal audio failure, attempting the universal fallback first.""" failure = classify_video_failure(error) analyzer.blackboard.fail_segment( current_start, current_end, specific_query, lease_owner, str(error), modality="audio", retryable=failure.retryable if retryable is None else retryable, error_category=failure.category.value, ) raise error async def _prepare_audio_media( analyzer, media: _AudioMedia, current_start: float, current_end: float ): """Extracts the segment and its audio track, tracking artifacts for cleanup.""" controller = _va.get_controller(analyzer.ctx) async with _va.TRANSCODE_SEMAPHORE: result = await controller.extract_segment_metadata(current_start, current_end) if not result.success or not result.video_path: raise Exception(f"Failed to get video segment: {result.message}") path = Path(result.video_path) analyzer.local_files_to_cleanup.add(path) analyzer.local_dirs_to_cleanup.add(path.parent) async with _va.TRANSCODE_SEMAPHORE: audio_path = await _va.extract_audio_from_video(path) analyzer.local_files_to_cleanup.add(audio_path) analyzer.local_dirs_to_cleanup.add(audio_path.parent) media.audio_path = audio_path actual_start = getattr(result, "actual_start_relative_time", current_start) if not isinstance(actual_start, (int, float)): actual_start = current_start media.actual_start = actual_start return result def _build_audio_prompt( analyzer, result, actual_start: float, start_time: float, end_time: float | None, specific_query: str, ) -> str: """Assembles the audio sub-agent prompt with timing context, ledger, and warnings.""" warnings = analyzer.get_overlapping_warnings(start_time, end_time) warning_block = "" if warnings: lines = ["WARNING: The following queries were already tried in this timeframe:\n"] for w in warnings: lines.append(f"Searched for: {w['target']} -> {w['summary']}") warning_block = "\n".join(lines) + "\n\n" duration_secs = getattr(result, "duration_seconds", None) actual_end = actual_start + duration_secs if isinstance(duration_secs, (int, float)) else None end_str = f" to {actual_end:.1f}s" if actual_end is not None else "" warning_val = getattr(result, "warning", None) truncation_note = ( f" (NOTE: {warning_val})" if isinstance(warning_val, str) and warning_val else "" ) prompt_with_context = ( "IMPORTANT CONTEXT: This audio segment corresponds to" " the test's relative time from" f" {actual_start:.1f}s{end_str}{truncation_note}. When" " you report timestamps, remember that your 00:00 is" f" exactly {actual_start:.1f}s in the test relative" " time. Please calculate and output all timestamps as" " the exact test relative time (e.g., if you hear" " something at 00:05, report" f" {actual_start + 5:.1f}s).\n\n" ) if analyzer.enable_ledger: prompt_with_context += ( "BLACKBOARD" f" LEDGER:\n{analyzer.blackboard_ledger}\nDirective:" " Use the Blackboard to understand what happened" " outside your assigned timeframe. Do not" " duplicate findings already on the board.\n\n" ) if warning_block: prompt_with_context += ( f"{warning_block}Directive: Do not repeat the" " failed searches listed above. Shift your" " attention to the current task.\n\n" ) prompt_with_context += f"{specific_query}" return prompt_with_context