feat: harden video analysis and runtime resilience

This commit is contained in:
somew1nd committed 2026-08-25 20:58:25 -07:00
1 parent 5ada543a09
commit bb16991f2c
55 files changed
+5023 -437

No files matched your search

+180 -23
View File
@@ -14,8 +14,8 @@
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
import asyncio
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock, Mock, call
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@@ -65,27 +65,26 @@ async def test_summarizer_dispatch_and_caching(mock_context):
@pytest.mark.asyncio
async def test_summarizer_retrigger_stalled_steps(mock_context):
"""Verify that earlier stalled steps are re-triggered when subsequent steps arrive."""
async def test_summarizer_retries_independently(mock_context):
"""A failed summary retries without waiting for another action to arrive."""
summarizer = VisualStepSummarizer(mock_context)
attempt_count = 0
async def mock_ainvoke(messages):
nonlocal attempt_count
attempt_count += 1
# Fail the first attempt (Step 1 first call)
# Fail the first attempt, then return the requested step summary.
if attempt_count == 1:
raise asyncio.CancelledError("Simulated timeout")
# Check messages for step number
raise TimeoutError("Simulated timeout")
msg_str = str(messages)
if "Step 1" in msg_str:
return AIMessage(content="[Step 1 State: Recovered Step 1 summary]")
return AIMessage(content="[Step 2 State: Step 2 summary]")
raise AssertionError("Unexpected step")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
# Dispatch step 1 (fails on first attempt)
# No Step 2 dispatch is needed to trigger the retry.
summarizer.dispatch(
step_number=1,
action_name="click",
@@ -95,22 +94,10 @@ async def test_summarizer_retrigger_stalled_steps(mock_context):
exec_outcome="Outcome 1",
)
await summarizer.flush()
assert not summarizer.has_summary(1)
# Dispatch step 2 (should detect stalled Step 1 and re-trigger)
summarizer.dispatch(
step_number=2,
action_name="swipe",
action_args={"action": "up"},
pre_img_bytes=b"pre2",
post_img_bytes=b"post2",
exec_outcome="Outcome 2",
)
await summarizer.flush()
assert summarizer.has_summary(1)
assert summarizer.has_summary(2)
assert "Recovered Step 1" in summarizer.get_summary(1)
assert attempt_count == 2
def test_context_compressor_with_ready_summaries(mock_context):
@@ -156,7 +143,10 @@ def test_context_compressor_with_ready_summaries(mock_context):
# Step 1 (ToolMessage 1): Image replaced with Step 1 summary, old XML pruned
assert len(messages[1].content) == 2
assert messages[1].content[0]["text"] == "Action 'click' completed."
assert messages[1].content[1]["text"] == "[Step 1 State: Tapped Search bar. Keyboard appeared.]"
assert messages[1].content[1]["text"] == (
"--- Historical Visual Transition ---\n"
"[Step 1 State: Tapped Search bar. Keyboard appeared.]"
)
# Step 2 (ToolMessage 2 - Latest live screen): Image and latest live XML MUST be preserved
assert len(messages[2].content) == 3
@@ -198,6 +188,173 @@ def test_context_compressor_graceful_fallback(mock_context):
assert messages[1].content[1]["type"] == "image_url"
def test_context_compressor_backfills_late_summary_idempotently(mock_context):
"""A pending image remains until its late summary replaces it."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._step_inputs["action-1"] = {"step_number": 1}
messages = [
HumanMessage(
content=[
{"type": "text", "text": "Task: Search"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}},
]
),
ToolMessage(
tool_call_id="action-1",
name="click",
content=[
{"type": "text", "text": "Tapped Search."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}},
],
),
ToolMessage(
tool_call_id="action-2",
name="input_text",
content=[
{"type": "text", "text": "Typed query."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_3"}},
],
),
]
compress_flash_messages(messages, summarizer=summarizer)
assert any(block["type"] == "image_url" for block in messages[1].content)
assert len(messages[1].content) == 2
summarizer._summaries["action-1"] = "Search field focused; keyboard appeared."
compress_flash_messages(messages, summarizer=summarizer)
compress_flash_messages(messages, summarizer=summarizer)
generated_blocks = [
block
for block in messages[1].content
if block.get("text", "").startswith("--- Historical Visual Transition ---")
]
assert len(generated_blocks) == 1
assert generated_blocks[0]["text"].endswith("Search field focused; keyboard appeared.")
assert all(block["type"] != "image_url" for block in messages[1].content)
def test_context_compressor_uses_tool_call_id_over_message_ordinal(mock_context):
"""Generic tool messages cannot shift action summaries onto the wrong result."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._summaries["click-id"] = "Click-specific visual memory."
summarizer._summaries["type-id"] = "Typing-specific visual memory."
messages = [
ToolMessage(
tool_call_id="click-id",
name="click",
content=[
{"type": "text", "text": "Clicked."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
],
),
ToolMessage(
tool_call_id="explorer-id",
name="ask_explorer",
content=[{"type": "text", "text": "Explorer result."}],
),
ToolMessage(
tool_call_id="type-id",
name="input_text",
content=[
{"type": "text", "text": "Typed."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
],
),
]
compress_flash_messages(messages, summarizer=summarizer)
assert "Click-specific visual memory." in str(messages[0].content)
assert "Typing-specific visual memory." not in str(messages[0].content)
assert "Visual Transition" not in str(messages[1].content)
# The latest live observation remains uncompressed until a newer screen exists.
assert messages[2].content[-1]["type"] == "image_url"
def test_context_compressor_preserves_text_before_embedded_ui_list(mock_context):
"""Pruning a combined XML block must retain its action result prefix."""
messages = [
ToolMessage(
tool_call_id="old",
name="click",
content=[
{
"type": "text",
"text": "Tapped Settings.\n--- UI Element List ---\n[huge tree]",
},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
],
),
ToolMessage(
tool_call_id="live",
name="click",
content=[
{"type": "text", "text": "Opened page."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
],
),
]
compress_flash_messages(messages, prune_history_xml=True)
assert messages[0].content == [{"type": "text", "text": "Tapped Settings."}]
@pytest.mark.asyncio
async def test_summarizer_keys_actions_independently_of_turn_number(mock_context):
"""Multiple actions from one LLM turn keep independent inputs and summaries."""
mock_context.data_engine = Mock()
summarizer = VisualStepSummarizer(mock_context)
async def mock_ainvoke(messages):
prompt = str(messages)
if "first action" in prompt:
return AIMessage(content="First action memory.")
return AIMessage(content="Second action memory.")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
first_step_id = uuid4()
second_step_id = uuid4()
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"label": "first action"},
pre_img_bytes=None,
post_img_bytes=None,
exec_outcome="one",
action_key="tc-1",
data_engine_step_id=first_step_id,
)
summarizer.dispatch(
step_number=2,
action_name="click",
action_args={"label": "second action"},
pre_img_bytes=None,
post_img_bytes=None,
exec_outcome="two",
action_key="tc-2",
data_engine_step_id=second_step_id,
)
await summarizer.flush()
assert summarizer.get_summary("tc-1") == "First action memory."
assert summarizer.get_summary("tc-2") == "Second action memory."
assert summarizer._step_inputs["tc-1"]["data_engine_step_id"] == first_step_id
assert summarizer._step_inputs["tc-2"]["data_engine_step_id"] == second_step_id
mock_context.data_engine.update_step_summary.assert_has_calls(
[
call(first_step_id, "First action memory."),
call(second_step_id, "Second action memory."),
],
any_order=True,
)
def test_flash_config_and_builder():
"""Verify Flash profile configuration model and SDK builder fluent API."""
cfg = Builders.AgentConfig.with_flash_config(
@@ -47,6 +47,18 @@ def test_operator_prompt_omits_environment_trust_and_explorer_directives():
assert "Visual Explorer Rule" not in prompt
def test_operator_prompt_keeps_large_list_traversal_single_pass_until_boundary():
for template in load_operator_prompts().values():
prompt = apply_operator_prompt_contract(template)
assert "do not advance to another milestone or mark it complete" in prompt
assert "A single scroll that reveals no new content is not sufficient evidence" in prompt
assert "one additional successful swipe in the same direction" in prompt
assert "never reverse direction merely to prove completion" in prompt
assert "even if older execution history has been pruned" in prompt
assert "Terminate exploration when a definitive boundary is reached" not in prompt
def test_compatibility_builder_uses_canonical_operator_template():
expected = apply_operator_prompt_contract(load_operator_prompts()["main_template"])
@@ -181,6 +181,9 @@ async def test_exec_analyze_audio_universal(mock_context, tmp_path):
assert "Heard notification sound" in result
assert len(agent.blackboard_entries) == 1
assert agent.blackboard_entries[0]["summary"] == "Heard notification sound."
sent_content = mock_bound.ainvoke.await_args.args[0][1].content
assert sent_content[1]["type"] == "audio"
assert sent_content[1]["base64"] == "YXVkaW9fYnl0ZXM="
@pytest.mark.asyncio
+283
View File
@@ -0,0 +1,283 @@
# 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.
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from artemis.agents.video_analyzer.blackboard import (
VideoBlackboard,
get_video_blackboard,
)
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer, _invoke_with_retry
from artemis.context import ArtemisContext
def test_blackboard_persists_segments_observations_and_evidence(tmp_path):
db_path = tmp_path / "trace.db"
evidence_dir = tmp_path / "video_blackboard" / "evidence"
source_image = tmp_path / "temporary.jpg"
source_image.write_bytes(b"jpeg evidence")
board = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
stored = board.add_observation(
{
"start": 1.0,
"end": 1.5,
"target": "verify login",
"summary": "Login screen became visible.",
"confidence_score": 0.95,
"screenshot": str(source_image),
}
)
# A duplicate commit is idempotent.
board.add_observation(stored)
claim = board.claim_segment(0.0, 5.0, "verify login")
assert claim.state == "claimed"
board.complete_segment(
0.0,
5.0,
"verify login",
claim.lease_owner,
"Login succeeded.",
"The authenticated home screen appeared.",
)
reopened = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
observations = reopened.list_observations()
assert len(observations) == 1
persisted_evidence = observations[0]["screenshot"]
assert persisted_evidence != str(source_image)
assert (evidence_dir / f"{stored['observation_id']}.jpg").read_bytes() == b"jpeg evidence"
assert reopened.missing_intervals(0.0, 5.0, "verify login") == []
cached = reopened.claim_segment(0.0, 5.0, "verify login")
assert cached.state == "cached"
assert cached.summary == "Login succeeded."
assert any(entry.get("kind") == "segment_result" for entry in reopened.list_ledger_entries())
def test_blackboard_calculates_only_uncovered_intervals(tmp_path):
board = VideoBlackboard("video:coverage", db_path=tmp_path / "coverage.db")
for start, end in ((0.0, 10.0), (8.0, 20.0), (30.0, 40.0)):
claim = board.claim_segment(start, end, "find spinner")
board.complete_segment(
start, end, "find spinner", claim.lease_owner, f"Analyzed {start}-{end}"
)
assert board.missing_intervals(0.0, 50.0, "find spinner") == [
(20.0, 30.0),
(40.0, 50.0),
]
# Different questions can use the observations as context but do not share
# query-specific completion coverage.
assert board.missing_intervals(0.0, 10.0, "count spinner frames") == [(0.0, 10.0)]
def test_blackboard_lease_suppresses_duplicates_and_is_reclaimable():
board = VideoBlackboard("video:lease", lease_seconds=0.02)
first = board.claim_segment(0.0, 5.0, "verify tap")
assert first.state == "claimed"
assert board.claim_segment(0.0, 5.0, "verify tap").state == "in_progress"
time.sleep(0.03)
reclaimed = board.claim_segment(0.0, 5.0, "verify tap")
assert reclaimed.state == "claimed"
assert reclaimed.lease_owner != first.lease_owner
board.fail_segment(0.0, 5.0, "verify tap", reclaimed.lease_owner, "temporary provider outage")
assert board.claim_segment(0.0, 5.0, "verify tap").state == "claimed"
def test_get_video_blackboard_is_shared_by_context():
ctx = MagicMock(spec=ArtemisContext)
ctx._video_blackboard = None
ctx.data_engine = None
ctx.execution_setup = None
ctx.device = MagicMock()
ctx.device.device_id = "device-1"
first = get_video_blackboard(ctx)
first.add_observation(
{
"start": 0.0,
"end": 1.0,
"target": "shared",
"summary": "Persisted in context.",
}
)
second = get_video_blackboard(ctx)
assert first is second
assert second.list_observations()[0]["summary"] == "Persisted in context."
@pytest.fixture
def analyzer_context():
ctx = MagicMock(spec=ArtemisContext)
ctx._video_blackboard = None
ctx._mobile_controller = None
ctx.data_engine = None
ctx.execution_setup = None
ctx.agent_config = None
ctx.device = MagicMock()
ctx.device.device_id = "device-analyzer"
ctx.llm_config = MagicMock()
ctx.llm_config.utils.video_analyzer.model = "universal-test-model"
ctx.llm_config.utils.video_analyzer.temperature = 0.2
return ctx
@pytest.mark.asyncio
async def test_spawn_sub_agent_reuses_complete_persistent_coverage(analyzer_context):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(analyzer_context)
claim = analyzer.blackboard.claim_segment(10.0, 20.0, "verify login")
analyzer.blackboard.complete_segment(
10.0,
20.0,
"verify login",
claim.lease_owner,
"Login succeeded.",
"Home screen appeared.",
)
with patch.object(analyzer, "_exec_single_chunk", new_callable=AsyncMock) as child:
result = await analyzer.exec_spawn_sub_agent(10.0, 20.0, "verify login")
child.assert_not_awaited()
assert result.startswith("CACHED VIDEO ANALYSIS:")
assert "Login succeeded" in result
@pytest.mark.asyncio
async def test_spawn_sub_agent_returns_partial_and_keeps_prior_success(analyzer_context):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(analyzer_context)
claim = analyzer.blackboard.claim_segment(0.0, 60.0, "summarize flow")
analyzer.blackboard.complete_segment(
0.0,
60.0,
"summarize flow",
claim.lease_owner,
"First minute completed.",
)
with patch.object(
analyzer,
"_exec_single_chunk",
new_callable=AsyncMock,
side_effect=RuntimeError("provider unavailable"),
) as child:
result = await analyzer.exec_spawn_sub_agent(0.0, 120.0, "summarize flow")
child.assert_awaited_once_with(60.0, 120.0, "summarize flow")
assert result.startswith("PARTIAL VIDEO ANALYSIS")
assert "First minute completed" in result
assert analyzer.blackboard.missing_intervals(0.0, 120.0, "summarize flow") == [(60.0, 120.0)]
@pytest.mark.asyncio
async def test_video_coordinator_retries_transient_failures():
operation = AsyncMock(
side_effect=[TimeoutError("timeout one"), ConnectionError("reset"), "completed"]
)
with patch(
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", new_callable=AsyncMock
) as sleep:
result = await _invoke_with_retry(operation, "test coordinator")
assert result == "completed"
assert operation.await_count == 3
assert sleep.await_count == 2
@pytest.mark.asyncio
async def test_audio_analysis_reuses_exact_completed_coverage(analyzer_context):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(analyzer_context)
claim = analyzer.blackboard.claim_segment(
2.0, 6.0, "hear notification", modality="audio"
)
analyzer.blackboard.complete_segment(
2.0,
6.0,
"hear notification",
claim.lease_owner,
"A notification sounded.",
"One short bell was audible.",
modality="audio",
)
with patch(
"artemis.agents.video_analyzer.video_analyzer.get_controller"
) as controller:
result = await analyzer.exec_analyze_audio_only(
2.0, 6.0, "hear notification"
)
controller.assert_not_called()
assert result.startswith("CACHED AUDIO ANALYSIS:")
assert "notification sounded" in result
def test_blackboard_metrics_include_structured_failures(tmp_path):
board = VideoBlackboard("video:metrics", db_path=tmp_path / "metrics.db")
claim = board.claim_segment(
0.0,
5.0,
"inspect",
model_name="vlm-primary",
source_generation=2,
)
board.fail_segment(
0.0,
5.0,
"inspect",
claim.lease_owner,
"timeout",
error_category="timeout",
)
assert board.metrics()["segments"] == {"retryable_failed": 1}
assert board.metrics()["failure_categories"] == {"timeout": 1}
def test_session_deletion_removes_video_blackboard_rows(tmp_path):
from uuid import uuid4
from artemis.data_engine.engine import DataEngine
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = MagicMock(traces_path=str(tmp_path / "traces"))
ctx.device = None
engine = DataEngine(ctx)
session_id = engine.start_session(goal="blackboard cleanup")
video_id = uuid4()
recording = tmp_path / "recording.mp4"
recording.write_bytes(b"video")
engine.record_video_start(video_id, "device", recording)
board = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
board.add_observation(
{"start": 0.0, "end": 1.0, "target": "cleanup", "summary": "evidence"}
)
assert board.list_observations()
engine.storage.delete_session(session_id)
reopened = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
assert reopened.list_observations() == []
+193
View File
@@ -0,0 +1,193 @@
# 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.
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage
import pytest
from artemis.agents.video_analyzer.reliability import (
VideoCircuitBreaker,
VideoFailureCategory,
classify_video_failure,
)
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer
from artemis.context import ArtemisContext
def _context() -> MagicMock:
ctx = MagicMock(spec=ArtemisContext)
ctx._video_blackboard = None
ctx._video_circuit_breaker = None
ctx._mobile_controller = None
ctx.execution_setup = None
ctx.data_engine = None
ctx.agent_config = SimpleNamespace(
video_analyzer=SimpleNamespace(
enable_ledger=True,
chunk_size_seconds=60.0,
min_chunk_seconds=4.0,
max_split_depth=4,
circuit_breaker_threshold=2,
circuit_breaker_cooldown_seconds=30.0,
action_window_seconds=2.0,
dense_action_fps=4.0,
max_dense_action_frames=24,
)
)
ctx.device = SimpleNamespace(device_id="reliability-device")
ctx.llm_config = MagicMock()
ctx.llm_config.utils.video_analyzer.model = "primary-video-model"
ctx.llm_config.utils.video_analyzer.temperature = 0.2
return ctx
def test_failure_classifier_chooses_safe_recovery():
outage = classify_video_failure(RuntimeError("503 service unavailable"))
assert outage.category == VideoFailureCategory.PROVIDER_UNAVAILABLE
assert outage.retryable and outage.should_fallback
assert not outage.should_split
timeout = classify_video_failure(TimeoutError("clip timed out"))
assert timeout.category == VideoFailureCategory.TIMEOUT
assert timeout.should_split and timeout.should_fallback
invalid = SimpleNamespace(code=400)
invalid_error = RuntimeError("bad request")
invalid_error.code = invalid.code
classified = classify_video_failure(invalid_error)
assert classified.category == VideoFailureCategory.BAD_REQUEST
assert not classified.retryable
def test_circuit_breaker_opens_and_half_opens():
breaker = VideoCircuitBreaker(threshold=2, cooldown_seconds=10.0)
failure = classify_video_failure(TimeoutError("timeout"))
breaker.record_failure("model", failure, now=10.0)
assert breaker.allow("model", now=10.1)
breaker.record_failure("model", failure, now=11.0)
assert not breaker.allow("model", now=15.0)
assert breaker.allow("model", now=21.1)
assert not breaker.allow("model", now=21.2)
breaker.record_success("model")
assert breaker.snapshot() == {}
@pytest.mark.asyncio
async def test_timeout_chunk_is_bisected_and_successful_leaves_survive():
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
async def analyze(start: float, end: float, query: str) -> str:
if end - start > 4.0:
raise TimeoutError("large clip timed out")
return f"[from {start:.1f}s to {end:.1f}s] Summary: recovered {query}"
with patch.object(analyzer, "_exec_single_chunk", side_effect=analyze) as child:
result = await analyzer.exec_spawn_sub_agent(0.0, 16.0, "find transition")
assert child.await_count == 7
assert result.count("recovered find transition") == 4
assert "PARTIAL" not in result
@pytest.mark.asyncio
async def test_universal_primary_failure_uses_configured_fallback():
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
primary = MagicMock()
primary_bound = MagicMock()
primary_bound.ainvoke = AsyncMock(side_effect=TimeoutError("primary timeout"))
primary.bind_tools.return_value = primary_bound
fallback = MagicMock()
fallback_bound = MagicMock()
fallback_bound.ainvoke = AsyncMock(return_value=AIMessage(content="fallback answer"))
fallback.bind_tools.return_value = fallback_bound
with patch(
"artemis.agents.video_analyzer.video_analyzer.get_llm",
side_effect=[primary, fallback],
) as get_model:
response = await analyzer._invoke_universal_model(
[HumanMessage(content="inspect")],
[],
timeout=1.0,
label="test",
)
assert response.content == "fallback answer"
assert get_model.call_args_list[0].kwargs["is_utils"] is True
assert get_model.call_args_list[1].kwargs["use_fallback"] is True
@pytest.mark.asyncio
async def test_native_chunk_exhaustion_commits_universal_fallback(tmp_path):
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
analyzer.use_native_gemini = True
raw_video = tmp_path / "segment.mp4"
raw_video.write_bytes(b"video")
extracted = SimpleNamespace(
success=True,
video_path=raw_video,
actual_start_relative_time=0.0,
duration_seconds=5.0,
warning=None,
)
controller = SimpleNamespace(
extract_segment_metadata=AsyncMock(return_value=extracted)
)
fallback_text = "[from 0.0s to 5.0s] Summary: fallback recovered Analysis: grounded"
with (
patch(
"artemis.agents.video_analyzer.video_analyzer.get_controller",
return_value=controller,
),
patch(
"artemis.agents.video_analyzer.video_analyzer.compress_video_for_api",
new=AsyncMock(return_value=raw_video),
),
patch.object(
analyzer,
"upload_and_poll_file",
new=AsyncMock(side_effect=RuntimeError("503 provider unavailable")),
) as upload,
patch.object(
analyzer,
"_exec_single_chunk_universal",
new=AsyncMock(return_value=fallback_text),
) as fallback,
patch(
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep",
new=AsyncMock(),
),
):
result = await analyzer._exec_single_chunk(0.0, 5.0, "verify transition")
assert result == fallback_text
assert upload.await_count == 2
fallback.assert_awaited_once()
assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify transition") == []
def test_action_timestamps_drive_dense_sampling():
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
analyzer = VideoAnalyzer(_context())
step = SimpleNamespace(timestamp=104.0, action_taken={"name": "tap"})
storage = SimpleNamespace(get_steps=lambda _session: [step])
analyzer.ctx.data_engine = SimpleNamespace(
current_session_id="session-1",
session_start_time=100.0,
storage=storage,
)
offsets = analyzer._dense_action_offsets(0.0, 10.0, actual_start=0.0)
assert offsets[0] == 2.0
assert 4.0 in offsets
assert offsets[-1] == 6.0