yc1838 commited on
Commit
6aa62ca
·
1 Parent(s): 9d361f6

feat: YouTube fallback strategy + dataset file logging + BLAS thread safety

Browse files

- Add YouTube fallback strategy to agent prompt: try transcript first, then search for video ID/title/dialogue if blocked
- Log all GAIA dataset file operations: missing rows, downloads, local copies, failures
- Scoring client: fallback to dataset on 404 and invalid JSON payloads
- Force BLAS thread env defaults in Python sandbox to prevent threading conflicts
- Move sys.path.append before imports and apply safe thread env

app.py CHANGED
@@ -3,12 +3,16 @@ import sys
3
  import traceback
4
  from pathlib import Path
5
 
 
 
6
  from dotenv import load_dotenv
7
 
8
  env_path = Path(__file__).parent / ".env"
9
  load_dotenv(dotenv_path=env_path, override=True)
10
 
11
- sys.path.append(str(Path(__file__).parent / "src"))
 
 
12
 
13
  import gradio as gr
14
  import pandas as pd
 
3
  import traceback
4
  from pathlib import Path
5
 
6
+ sys.path.append(str(Path(__file__).parent / "src"))
7
+
8
  from dotenv import load_dotenv
9
 
10
  env_path = Path(__file__).parent / ".env"
11
  load_dotenv(dotenv_path=env_path, override=True)
12
 
13
+ from lilith_agent.runtime_env import apply_safe_thread_env
14
+
15
+ apply_safe_thread_env()
16
 
17
  import gradio as gr
18
  import pandas as pd
src/lilith_agent/app.py CHANGED
@@ -595,8 +595,15 @@ def build_react_agent(cfg: Config):
595
  "If `find_files` returns 'No files found' for an exact filename, do NOT escalate to `find_files(root='/')` — "
596
  "broaden instead: `grep` for a substring, `find_files` with a shorter/partial name, or `ls` the parent "
597
  "directory to see what's actually there. User filename references may be casual or imprecise (e.g. `.lol` in chat is often laughter, not an extension).\n"
598
- "8. MATHEMATICAL PRECISION: If the question requires math, double-check your algebraic calculations carefully. If a specific decimal precision or rounding is asked for (e.g., 'to 2 decimal places', 'nearest tenth'), you MUST calculate precisely and round STRICTLY AT THE VERY END. Do NOT prematurely round intermediate numbers.\n"
599
- "9. FINAL ANSWER FORMAT: When you have the final answer, output ONLY the value itself. Do not say 'The answer is...', do not provide explanations in your final output. Just output the bare minimum exact string, number, or list."
 
 
 
 
 
 
 
600
  )
601
 
602
  if memory_context:
 
595
  "If `find_files` returns 'No files found' for an exact filename, do NOT escalate to `find_files(root='/')` — "
596
  "broaden instead: `grep` for a substring, `find_files` with a shorter/partial name, or `ls` the parent "
597
  "directory to see what's actually there. User filename references may be casual or imprecise (e.g. `.lol` in chat is often laughter, not an extension).\n"
598
+ "8. YOUTUBE FALLBACK STRATEGY: First try `youtube_transcript` for spoken captions. If it is blocked, unavailable, "
599
+ "or says YouTube is blocking requests, do not repeatedly retry transcript or yt-dlp. Extract the video ID and "
600
+ "search for `\"<video id>\" transcript`, `\"<video id>\"`, the exact video title, and distinctive quoted dialogue "
601
+ "or on-screen phrases. Use search snippets, cached transcripts in Hugging Face Spaces/datasets, and reliable web "
602
+ "pages as evidence. For visual questions, try `youtube_frame_at` only when a timestamp is needed; if video download "
603
+ "is blocked, pivot to title/time/object searches and answer from the strongest available evidence."
604
+ "directory to see what's actually there. User filename references may be casual or imprecise (e.g. `.lol` in chat is often laughter, not an extension).\n"
605
+ "9. MATHEMATICAL PRECISION: If the question requires math, double-check your algebraic calculations carefully. If a specific decimal precision or rounding is asked for (e.g., 'to 2 decimal places', 'nearest tenth'), you MUST calculate precisely and round STRICTLY AT THE VERY END. Do NOT prematurely round intermediate numbers.\n"
606
+ "10. FINAL ANSWER FORMAT: When you have the final answer, output ONLY the value itself. Do not say 'The answer is...', do not provide explanations in your final output. Just output the bare minimum exact string, number, or list."
607
  )
608
 
609
  if memory_context:
src/lilith_agent/gaia_dataset.py CHANGED
@@ -58,10 +58,18 @@ class GaiaDatasetClient:
58
  """Copy the dataset's local file_path into dest_dir."""
59
  row = next((r for r in self._rows if r["task_id"] == task_id), None)
60
  if row is None:
 
 
 
 
61
  return None
62
 
63
  file_name = row.get("file_name")
64
  if not file_name:
 
 
 
 
65
  return None
66
 
67
  dest_root = Path(dest_dir)
@@ -69,16 +77,16 @@ class GaiaDatasetClient:
69
  dest = dest_root / file_name
70
 
71
  if dest.exists() and dest.stat().st_size > 300:
 
72
  return dest
73
 
74
- # 1. Try local data dir fallback
75
  level = row.get("Level", "1")
76
  local_data_path = Path("data") / f"gaia_level{level}" / "files" / task_id
77
  if local_data_path.exists() and local_data_path.stat().st_size > 300:
78
  shutil.copy(local_data_path, dest)
 
79
  return dest
80
 
81
- # 2. Direct download
82
  year = "2023"
83
  if hasattr(self, "config") and "_" in self.config:
84
  year = self.config.split("_")[0]
@@ -96,8 +104,20 @@ class GaiaDatasetClient:
96
  if response.status_code == 200:
97
  with open(dest, "wb") as f:
98
  shutil.copyfileobj(response.raw, f)
 
99
  return dest
100
- except Exception:
101
- pass
 
 
 
 
 
 
 
102
 
 
 
 
 
103
  return None
 
58
  """Copy the dataset's local file_path into dest_dir."""
59
  row = next((r for r in self._rows if r["task_id"] == task_id), None)
60
  if row is None:
61
+ print(
62
+ f"[gaia_dataset] file row missing task={task_id} split={self.split} config={self.config}",
63
+ flush=True,
64
+ )
65
  return None
66
 
67
  file_name = row.get("file_name")
68
  if not file_name:
69
+ print(
70
+ f"[gaia_dataset] file_name missing task={task_id} split={self.split} config={self.config}",
71
+ flush=True,
72
+ )
73
  return None
74
 
75
  dest_root = Path(dest_dir)
 
77
  dest = dest_root / file_name
78
 
79
  if dest.exists() and dest.stat().st_size > 300:
80
+ print(f"[gaia_dataset] file already exists task={task_id} path={dest}", flush=True)
81
  return dest
82
 
 
83
  level = row.get("Level", "1")
84
  local_data_path = Path("data") / f"gaia_level{level}" / "files" / task_id
85
  if local_data_path.exists() and local_data_path.stat().st_size > 300:
86
  shutil.copy(local_data_path, dest)
87
+ print(f"[gaia_dataset] copied local file task={task_id} source={local_data_path} dest={dest}", flush=True)
88
  return dest
89
 
 
90
  year = "2023"
91
  if hasattr(self, "config") and "_" in self.config:
92
  year = self.config.split("_")[0]
 
104
  if response.status_code == 200:
105
  with open(dest, "wb") as f:
106
  shutil.copyfileobj(response.raw, f)
107
+ print(f"[gaia_dataset] downloaded hf file task={task_id} path={dest} url={hf_url}", flush=True)
108
  return dest
109
+ print(
110
+ f"[gaia_dataset] hf download failed task={task_id} status={response.status_code} url={hf_url}",
111
+ flush=True,
112
+ )
113
+ except Exception as exc:
114
+ print(
115
+ f"[gaia_dataset] hf download exception task={task_id} type={type(exc).__name__} error={exc} url={hf_url}",
116
+ flush=True,
117
+ )
118
 
119
+ print(
120
+ f"[gaia_dataset] file unavailable task={task_id} file_name={file_name} split={self.split} config={self.config}",
121
+ flush=True,
122
+ )
123
  return None
src/lilith_agent/runtime_env.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ SAFE_THREAD_ENV = {
6
+ "OPENBLAS_NUM_THREADS": "1",
7
+ "OMP_NUM_THREADS": "1",
8
+ "MKL_NUM_THREADS": "1",
9
+ "NUMEXPR_NUM_THREADS": "1",
10
+ "VECLIB_MAXIMUM_THREADS": "1",
11
+ "TOKENIZERS_PARALLELISM": "false",
12
+ }
13
+
14
+
15
+ def apply_safe_thread_env() -> None:
16
+ for key, value in SAFE_THREAD_ENV.items():
17
+ os.environ[key] = value
src/lilith_agent/scoring_client.py CHANGED
@@ -48,18 +48,51 @@ class ScoringApiClient:
48
  response = self.session.get(f"{self.api_url}/files/{task_id}", timeout=60)
49
  response.raise_for_status()
50
  except requests.RequestException as exc:
51
- fallback = self._fallback_dataset_client_for(exc, action=f"download file for {task_id}")
 
 
 
 
52
  if fallback is None:
 
 
 
 
53
  return None
54
- return fallback.download_file(task_id, dest_dir)
 
 
55
 
56
  filename = task_id
57
  cd = response.headers.get("content-disposition", "")
58
  if "filename=" in cd:
59
  filename = cd.split("filename=")[-1].strip().strip('"')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  out = dest_dir / filename
61
  out.write_bytes(response.content)
62
  self.last_warning = None
 
 
 
 
 
63
  return out
64
 
65
  def _fallback_dataset_client_for(
@@ -67,8 +100,9 @@ class ScoringApiClient:
67
  exc: requests.RequestException,
68
  *,
69
  action: str,
 
70
  ) -> Any | None:
71
- if not self._should_use_dataset_fallback(exc):
72
  return None
73
  try:
74
  dataset_client = self._get_dataset_client()
@@ -99,8 +133,14 @@ class ScoringApiClient:
99
  return self._dataset_client
100
 
101
  @staticmethod
102
- def _should_use_dataset_fallback(exc: requests.RequestException) -> bool:
103
  if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
104
  return True
105
  status_code = getattr(getattr(exc, "response", None), "status_code", None)
 
 
106
  return status_code in {429, 502, 503, 504}
 
 
 
 
 
48
  response = self.session.get(f"{self.api_url}/files/{task_id}", timeout=60)
49
  response.raise_for_status()
50
  except requests.RequestException as exc:
51
+ fallback = self._fallback_dataset_client_for(
52
+ exc,
53
+ action=f"download file for {task_id}",
54
+ missing_is_fallback=True,
55
+ )
56
  if fallback is None:
57
+ print(
58
+ f"[scoring] file download failed without fallback task={task_id} error={exc.__class__.__name__}",
59
+ flush=True,
60
+ )
61
  return None
62
+ path = fallback.download_file(task_id, dest_dir)
63
+ print(f"[scoring] fallback file result task={task_id} path={path}", flush=True)
64
+ return path
65
 
66
  filename = task_id
67
  cd = response.headers.get("content-disposition", "")
68
  if "filename=" in cd:
69
  filename = cd.split("filename=")[-1].strip().strip('"')
70
+ content_type = response.headers.get("content-type", "")
71
+ if self._is_invalid_file_payload(response.content, content_type):
72
+ print(
73
+ f"[scoring] invalid file payload task={task_id} status={response.status_code} "
74
+ f"bytes={len(response.content)} content_type={content_type!r}; trying dataset fallback",
75
+ flush=True,
76
+ )
77
+ try:
78
+ fallback = self._get_dataset_client()
79
+ except Exception as exc:
80
+ print(
81
+ f"[scoring] dataset fallback unavailable task={task_id} type={type(exc).__name__} error={exc}",
82
+ flush=True,
83
+ )
84
+ return None
85
+ path = fallback.download_file(task_id, dest_dir)
86
+ print(f"[scoring] fallback file result task={task_id} path={path}", flush=True)
87
+ return path
88
  out = dest_dir / filename
89
  out.write_bytes(response.content)
90
  self.last_warning = None
91
+ print(
92
+ f"[scoring] file downloaded task={task_id} path={out} bytes={len(response.content)} "
93
+ f"content_type={response.headers.get('content-type', '')!r}",
94
+ flush=True,
95
+ )
96
  return out
97
 
98
  def _fallback_dataset_client_for(
 
100
  exc: requests.RequestException,
101
  *,
102
  action: str,
103
+ missing_is_fallback: bool = False,
104
  ) -> Any | None:
105
+ if not self._should_use_dataset_fallback(exc, missing_is_fallback=missing_is_fallback):
106
  return None
107
  try:
108
  dataset_client = self._get_dataset_client()
 
133
  return self._dataset_client
134
 
135
  @staticmethod
136
+ def _should_use_dataset_fallback(exc: requests.RequestException, *, missing_is_fallback: bool = False) -> bool:
137
  if isinstance(exc, (requests.Timeout, requests.ConnectionError)):
138
  return True
139
  status_code = getattr(getattr(exc, "response", None), "status_code", None)
140
+ if missing_is_fallback and status_code == 404:
141
+ return True
142
  return status_code in {429, 502, 503, 504}
143
+
144
+ @staticmethod
145
+ def _is_invalid_file_payload(content: bytes, content_type: str) -> bool:
146
+ return "application/json" in content_type.lower() and len(content) < 4096
src/lilith_agent/tools/python_exec.py CHANGED
@@ -27,9 +27,12 @@ import sys
27
  import tempfile
28
  import textwrap
29
 
 
 
30
  _OUTPUT_CAP_CHARS = 200_000
31
  _DOCKER_IMAGE_DEFAULT = "lilith-pysandbox:latest"
32
  _DOCKER_STARTUP_HEADROOM_S = 5
 
33
 
34
  # Env vars that are safe (or necessary) to forward. Everything else is dropped.
35
  _ENV_ALLOWLIST = frozenset({
@@ -46,6 +49,12 @@ _ENV_ALLOWLIST = frozenset({
46
  "PYTHONPATH",
47
  "PYTHONHOME",
48
  "PYTHONIOENCODING",
 
 
 
 
 
 
49
  # Proxy config is routinely needed for scraping; not a secret.
50
  "HTTP_PROXY",
51
  "HTTPS_PROXY",
@@ -102,7 +111,9 @@ _RUNNER_SCRIPT = textwrap.dedent(
102
 
103
  def _scrubbed_env() -> dict[str, str]:
104
  """Return a fresh env dict containing only allowlisted keys from os.environ."""
105
- return {k: v for k, v in os.environ.items() if k in _ENV_ALLOWLIST}
 
 
106
 
107
 
108
  def _truncate_output(text: str) -> str:
 
27
  import tempfile
28
  import textwrap
29
 
30
+ from lilith_agent.runtime_env import SAFE_THREAD_ENV
31
+
32
  _OUTPUT_CAP_CHARS = 200_000
33
  _DOCKER_IMAGE_DEFAULT = "lilith-pysandbox:latest"
34
  _DOCKER_STARTUP_HEADROOM_S = 5
35
+ BLAS_THREAD_ENV_DEFAULTS = SAFE_THREAD_ENV
36
 
37
  # Env vars that are safe (or necessary) to forward. Everything else is dropped.
38
  _ENV_ALLOWLIST = frozenset({
 
49
  "PYTHONPATH",
50
  "PYTHONHOME",
51
  "PYTHONIOENCODING",
52
+ "OPENBLAS_NUM_THREADS",
53
+ "OMP_NUM_THREADS",
54
+ "MKL_NUM_THREADS",
55
+ "NUMEXPR_NUM_THREADS",
56
+ "VECLIB_MAXIMUM_THREADS",
57
+ "TOKENIZERS_PARALLELISM",
58
  # Proxy config is routinely needed for scraping; not a secret.
59
  "HTTP_PROXY",
60
  "HTTPS_PROXY",
 
111
 
112
  def _scrubbed_env() -> dict[str, str]:
113
  """Return a fresh env dict containing only allowlisted keys from os.environ."""
114
+ env = {k: v for k, v in os.environ.items() if k in _ENV_ALLOWLIST}
115
+ env.update(BLAS_THREAD_ENV_DEFAULTS)
116
+ return env
117
 
118
 
119
  def _truncate_output(text: str) -> str:
tests/test_gaia_dataset.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import Mock, patch
4
+
5
+ from lilith_agent.gaia_dataset import GaiaDatasetClient
6
+
7
+
8
+ def _client_with_rows(rows):
9
+ with patch("lilith_agent.gaia_dataset.load_dataset", return_value=rows):
10
+ return GaiaDatasetClient(config="2023_all", split="test", level=None, token="hf-token")
11
+
12
+
13
+ def test_download_file_prints_when_task_row_is_missing(tmp_path):
14
+ client = _client_with_rows([])
15
+
16
+ with patch("builtins.print") as printed:
17
+ path = client.download_file("missing-task", tmp_path)
18
+
19
+ assert path is None
20
+ printed.assert_any_call(
21
+ "[gaia_dataset] file row missing task=missing-task split=test config=2023_all",
22
+ flush=True,
23
+ )
24
+
25
+
26
+ def test_download_file_prints_hf_download_status_on_failure(tmp_path):
27
+ client = _client_with_rows([
28
+ {
29
+ "task_id": "task-img",
30
+ "Question": "Q",
31
+ "Level": "1",
32
+ "file_name": "task-img.png",
33
+ "Final answer": "",
34
+ }
35
+ ])
36
+ response = Mock(status_code=404)
37
+
38
+ with patch("lilith_agent.gaia_dataset.requests.get", return_value=response) as get, patch("builtins.print") as printed:
39
+ path = client.download_file("task-img", tmp_path)
40
+
41
+ assert path is None
42
+ assert get.call_args.args[0] == "https://huggingface.co/datasets/gaia-benchmark/GAIA/resolve/main/2023/test/task-img.png"
43
+ printed.assert_any_call(
44
+ "[gaia_dataset] hf download failed task=task-img status=404 url=https://huggingface.co/datasets/gaia-benchmark/GAIA/resolve/main/2023/test/task-img.png",
45
+ flush=True,
46
+ )
tests/test_graph.py CHANGED
@@ -91,6 +91,40 @@ def test_build_react_agent_prints_effective_recursion_limit(monkeypatch, tmp_pat
91
  assert "[graph] effective_recursion_limit=79 logical_recursion_limit=50 budget_hard_cap=25 headroom=4" in captured
92
 
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  def test_fail_safe_uses_unbound_model_to_prevent_more_tool_calls(monkeypatch, tmp_path):
95
  class FakeBoundModel:
96
  def __init__(self):
 
91
  assert "[graph] effective_recursion_limit=79 logical_recursion_limit=50 budget_hard_cap=25 headroom=4" in captured
92
 
93
 
94
+ def test_model_prompt_includes_youtube_fallback_strategy(monkeypatch, tmp_path):
95
+ class FakeModel:
96
+ def __init__(self):
97
+ self.system_prompt = ""
98
+
99
+ def bind_tools(self, tools):
100
+ return self
101
+
102
+ def invoke(self, messages):
103
+ self.system_prompt = str(messages[0].content)
104
+ return AIMessage(content="Final Answer: inspected")
105
+
106
+ fake_model = FakeModel()
107
+ cfg = Config.from_env()
108
+ cfg.compact_summarize = False
109
+ monkeypatch.setenv("LILITH_HOME", str(tmp_path / ".lilith"))
110
+ monkeypatch.setattr("lilith_agent.app.get_extra_strong_model", lambda cfg: fake_model)
111
+ monkeypatch.setattr("lilith_agent.app.get_cheap_model", lambda cfg: fake_model)
112
+ monkeypatch.setattr("lilith_agent.tools.build_tools", lambda cfg: [echo_tool])
113
+ monkeypatch.setattr("lilith_agent.memory.extract_and_compress_facts", lambda messages, model: None)
114
+
115
+ graph = build_react_agent(cfg)
116
+ graph.invoke(
117
+ {"messages": [HumanMessage(content="What happens in https://www.youtube.com/watch?v=abcdefghijk?")], "iterations": 0, "todos": []},
118
+ {"configurable": {"thread_id": "youtube-fallback-prompt-test"}},
119
+ )
120
+
121
+ prompt = fake_model.system_prompt.lower()
122
+ assert "youtube fallback strategy" in prompt
123
+ assert "video id" in prompt
124
+ assert "transcript" in prompt
125
+ assert "do not repeatedly retry" in prompt
126
+
127
+
128
  def test_fail_safe_uses_unbound_model_to_prevent_more_tool_calls(monkeypatch, tmp_path):
129
  class FakeBoundModel:
130
  def __init__(self):
test_memory_pick_up.py → tests/test_memory_pick_up.py RENAMED
File without changes
tests/test_python_sandbox.py CHANGED
@@ -75,6 +75,18 @@ def test_allowlisted_env_passes_through():
75
  assert "HAS_PATH=True" in result
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def test_cwd_is_not_repo_root():
79
  """Subprocess runs in a scratch dir so relative-path writes cannot hit the repo."""
80
  repo_root = str(Path(__file__).resolve().parent.parent)
 
75
  assert "HAS_PATH=True" in result
76
 
77
 
78
+ def test_blas_thread_env_defaults_are_forced_in_sandbox(monkeypatch):
79
+ from lilith_agent.tools import python_exec as pe
80
+
81
+ for key in pe.BLAS_THREAD_ENV_DEFAULTS:
82
+ monkeypatch.delenv(key, raising=False)
83
+
84
+ env = pe._scrubbed_env()
85
+
86
+ for key, value in pe.BLAS_THREAD_ENV_DEFAULTS.items():
87
+ assert env[key] == value
88
+
89
+
90
  def test_cwd_is_not_repo_root():
91
  """Subprocess runs in a scratch dir so relative-path writes cannot hit the repo."""
92
  repo_root = str(Path(__file__).resolve().parent.parent)
tests/test_runtime_env.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ def test_apply_safe_thread_env_overrides_existing_values(monkeypatch):
5
+ from lilith_agent.runtime_env import SAFE_THREAD_ENV, apply_safe_thread_env
6
+
7
+ for key in SAFE_THREAD_ENV:
8
+ monkeypatch.setenv(key, "99")
9
+
10
+ apply_safe_thread_env()
11
+
12
+ for key, value in SAFE_THREAD_ENV.items():
13
+ assert __import__("os").environ[key] == value
tests/test_scoring_client.py CHANGED
@@ -99,6 +99,67 @@ class ScoringApiClientTests(unittest.TestCase):
99
  self.assertIn("429", client.last_warning or "")
100
  dataset_client.download_file.assert_called_once_with("task-123", Path(tmpdir))
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def test_default_dataset_client_is_built_lazily(self) -> None:
103
  session = Mock()
104
  response = Mock(status_code=429, headers={})
 
99
  self.assertIn("429", client.last_warning or "")
100
  dataset_client.download_file.assert_called_once_with("task-123", Path(tmpdir))
101
 
102
+ def test_download_file_falls_back_to_dataset_on_404(self) -> None:
103
+ response = Mock(status_code=404, headers={})
104
+ error = requests.HTTPError("Not Found", response=response)
105
+ response.raise_for_status.side_effect = error
106
+
107
+ session = Mock()
108
+ session.get.return_value = response
109
+ dataset_client = Mock()
110
+
111
+ with tempfile.TemporaryDirectory() as tmpdir:
112
+ fallback_path = Path(tmpdir) / "from-dataset.png"
113
+ fallback_path.write_bytes(b"image-bytes")
114
+ dataset_client.download_file.return_value = fallback_path
115
+
116
+ client = ScoringApiClient(
117
+ api_url="https://example.com",
118
+ session=session,
119
+ dataset_client=dataset_client,
120
+ )
121
+
122
+ with patch("builtins.print") as printed:
123
+ payload = client.download_file("task-404", tmpdir)
124
+
125
+ self.assertEqual(payload, fallback_path)
126
+ self.assertIn("404", client.last_warning or "")
127
+ dataset_client.download_file.assert_called_once_with("task-404", Path(tmpdir))
128
+ printed.assert_any_call(
129
+ "Scoring API unavailable while trying to download file for task-404 (status=404); falling back to GAIA dataset.",
130
+ flush=True,
131
+ )
132
+
133
+ def test_download_file_falls_back_when_api_returns_tiny_json_payload(self) -> None:
134
+ response = Mock(status_code=200, headers={"content-type": "application/json"})
135
+ response.content = b'{"detail":"file not found"}'
136
+ response.raise_for_status.return_value = None
137
+
138
+ session = Mock()
139
+ session.get.return_value = response
140
+ dataset_client = Mock()
141
+
142
+ with tempfile.TemporaryDirectory() as tmpdir:
143
+ fallback_path = Path(tmpdir) / "from-dataset.png"
144
+ fallback_path.write_bytes(b"image-bytes")
145
+ dataset_client.download_file.return_value = fallback_path
146
+
147
+ client = ScoringApiClient(
148
+ api_url="https://example.com",
149
+ session=session,
150
+ dataset_client=dataset_client,
151
+ )
152
+
153
+ with patch("builtins.print") as printed:
154
+ payload = client.download_file("task-json", tmpdir)
155
+
156
+ self.assertEqual(payload, fallback_path)
157
+ dataset_client.download_file.assert_called_once_with("task-json", Path(tmpdir))
158
+ printed.assert_any_call(
159
+ "[scoring] invalid file payload task=task-json status=200 bytes=27 content_type='application/json'; trying dataset fallback",
160
+ flush=True,
161
+ )
162
+
163
  def test_default_dataset_client_is_built_lazily(self) -> None:
164
  session = Mock()
165
  response = Mock(status_code=429, headers={})