Vatxzz commited on
Commit
942e22d
·
1 Parent(s): 2749a41

splash screen

Browse files
Files changed (43) hide show
  1. app/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
  2. app/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
  3. app/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
  4. app/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
  5. app/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
  6. app/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
  7. app/android/app/src/main/res/drawable-night-v21/background.png +0 -0
  8. app/android/app/src/main/res/drawable-night-v21/launch_background.xml +9 -0
  9. app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
  10. app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
  11. app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
  12. app/android/app/src/main/res/drawable-night/background.png +0 -0
  13. app/android/app/src/main/res/drawable-night/launch_background.xml +9 -0
  14. app/android/app/src/main/res/drawable-v21/background.png +0 -0
  15. app/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
  16. app/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
  17. app/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
  18. app/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
  19. app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
  20. app/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
  21. app/android/app/src/main/res/drawable/background.png +0 -0
  22. app/android/app/src/main/res/values-night-v31/styles.xml +1 -1
  23. app/android/app/src/main/res/values-v31/styles.xml +1 -1
  24. app/assets/brand/splash_logo.png +0 -0
  25. app/lib/ui/features/graph/views/graph_screen.dart +4 -4
  26. app/pubspec.yaml +2 -2
  27. app/web/splash/img/dark-1x.png +0 -0
  28. app/web/splash/img/dark-2x.png +0 -0
  29. app/web/splash/img/dark-3x.png +0 -0
  30. app/web/splash/img/dark-4x.png +0 -0
  31. app/web/splash/img/light-1x.png +0 -0
  32. app/web/splash/img/light-2x.png +0 -0
  33. app/web/splash/img/light-3x.png +0 -0
  34. app/web/splash/img/light-4x.png +0 -0
  35. backend/app/config.py +8 -0
  36. backend/app/logging_config.py +115 -0
  37. backend/app/main.py +5 -1
  38. backend/app/pipeline/extraction.py +187 -54
  39. backend/app/pipeline/structuring.py +25 -11
  40. backend/app/pipeline/worker.py +38 -20
  41. backend/app/services/artifact_images.py +164 -52
  42. backend/tests/conftest.py +2 -0
  43. backend/tests/test_pipeline.py +43 -0
app/android/app/src/main/res/drawable-hdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-hdpi/splash.png CHANGED
app/android/app/src/main/res/drawable-mdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-mdpi/splash.png CHANGED
app/android/app/src/main/res/drawable-night-hdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-night-mdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-night-v21/background.png ADDED
app/android/app/src/main/res/drawable-night-v21/launch_background.xml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <item>
4
+ <bitmap android:gravity="fill" android:src="@drawable/background"/>
5
+ </item>
6
+ <item>
7
+ <bitmap android:gravity="center" android:src="@drawable/splash"/>
8
+ </item>
9
+ </layer-list>
app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-night/background.png ADDED
app/android/app/src/main/res/drawable-night/launch_background.xml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <item>
4
+ <bitmap android:gravity="fill" android:src="@drawable/background"/>
5
+ </item>
6
+ <item>
7
+ <bitmap android:gravity="center" android:src="@drawable/splash"/>
8
+ </item>
9
+ </layer-list>
app/android/app/src/main/res/drawable-v21/background.png CHANGED
app/android/app/src/main/res/drawable-xhdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-xhdpi/splash.png CHANGED
app/android/app/src/main/res/drawable-xxhdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-xxhdpi/splash.png CHANGED
app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png CHANGED
app/android/app/src/main/res/drawable-xxxhdpi/splash.png CHANGED
app/android/app/src/main/res/drawable/background.png CHANGED
app/android/app/src/main/res/values-night-v31/styles.xml CHANGED
@@ -6,7 +6,7 @@
6
  <item name="android:windowFullscreen">false</item>
7
  <item name="android:windowDrawsSystemBarBackgrounds">false</item>
8
  <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
9
- <item name="android:windowSplashScreenBackground">#5B5BD6</item>
10
  <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
11
  </style>
12
  <!-- Theme applied to the Android Window as soon as the process has started.
 
6
  <item name="android:windowFullscreen">false</item>
7
  <item name="android:windowDrawsSystemBarBackgrounds">false</item>
8
  <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
9
+ <item name="android:windowSplashScreenBackground">#181818</item>
10
  <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
11
  </style>
12
  <!-- Theme applied to the Android Window as soon as the process has started.
app/android/app/src/main/res/values-v31/styles.xml CHANGED
@@ -6,7 +6,7 @@
6
  <item name="android:windowFullscreen">false</item>
7
  <item name="android:windowDrawsSystemBarBackgrounds">false</item>
8
  <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
9
- <item name="android:windowSplashScreenBackground">#5B5BD6</item>
10
  <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
11
  </style>
12
  <!-- Theme applied to the Android Window as soon as the process has started.
 
6
  <item name="android:windowFullscreen">false</item>
7
  <item name="android:windowDrawsSystemBarBackgrounds">false</item>
8
  <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
9
+ <item name="android:windowSplashScreenBackground">#F5F0E8</item>
10
  <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
11
  </style>
12
  <!-- Theme applied to the Android Window as soon as the process has started.
app/assets/brand/splash_logo.png CHANGED
app/lib/ui/features/graph/views/graph_screen.dart CHANGED
@@ -1421,13 +1421,13 @@ class _GraphPainter extends CustomPainter {
1421
 
1422
  double sw;
1423
  if (e.kind == 'reference') {
1424
- sw = (highlight ? 2.5 : 1.2 + e.weight) * clampedZoom;
1425
  } else if (e.kind == 'tag') {
1426
- sw = (highlight ? 1.5 : 0.5) * clampedZoom;
1427
  } else if (e.kind == 'membership') {
1428
- sw = (highlight ? 2.0 : 1.4) * clampedZoom;
1429
  } else {
1430
- sw = (highlight ? 2.2 : 0.6 + e.weight * 1.6) * clampedZoom;
1431
  }
1432
 
1433
  final paint = Paint()
 
1421
 
1422
  double sw;
1423
  if (e.kind == 'reference') {
1424
+ sw = (highlight ? 1.5 : 0.6 + e.weight * 0.5) * clampedZoom;
1425
  } else if (e.kind == 'tag') {
1426
+ sw = (highlight ? 0.9 : 0.35) * clampedZoom;
1427
  } else if (e.kind == 'membership') {
1428
+ sw = (highlight ? 1.2 : 0.7) * clampedZoom;
1429
  } else {
1430
+ sw = (highlight ? 1.4 : 0.35 + e.weight * 0.9) * clampedZoom;
1431
  }
1432
 
1433
  final paint = Paint()
app/pubspec.yaml CHANGED
@@ -107,11 +107,11 @@ flutter_launcher_icons:
107
  flutter_native_splash:
108
  color: "#F5F0E8"
109
  color_dark: "#181818"
110
- image: "assets/brand/splash_logo.png"
111
  android_12:
112
  color: "#F5F0E8"
113
  color_dark: "#181818"
114
- image: "assets/brand/splash_logo.png"
115
 
116
  # To add assets to your application, add an assets section, like this:
117
  # assets:
 
107
  flutter_native_splash:
108
  color: "#F5F0E8"
109
  color_dark: "#181818"
110
+ image: "assets/brand/adaptive_foreground.png"
111
  android_12:
112
  color: "#F5F0E8"
113
  color_dark: "#181818"
114
+ image: "assets/brand/adaptive_foreground.png"
115
 
116
  # To add assets to your application, add an assets section, like this:
117
  # assets:
app/web/splash/img/dark-1x.png CHANGED
app/web/splash/img/dark-2x.png CHANGED
app/web/splash/img/dark-3x.png CHANGED
app/web/splash/img/dark-4x.png CHANGED
app/web/splash/img/light-1x.png CHANGED
app/web/splash/img/light-2x.png CHANGED
app/web/splash/img/light-3x.png CHANGED
app/web/splash/img/light-4x.png CHANGED
backend/app/config.py CHANGED
@@ -42,6 +42,10 @@ class Settings(BaseSettings):
42
  gemini_api_key: str = ""
43
  gemini_vision_model: str = "gemini-2.0-flash-lite"
44
 
 
 
 
 
45
  # local Whisper via faster-whisper (no API, no rate limits)
46
  # tiny=39MB | base=74MB | small=244MB
47
  local_whisper_model: str = "base"
@@ -93,6 +97,10 @@ class Settings(BaseSettings):
93
  def gemini_vision_enabled(self) -> bool:
94
  return bool(self.gemini_api_key.strip())
95
 
 
 
 
 
96
  @property
97
  def local_whisper_enabled(self) -> bool:
98
  return self.whisper_backend == "local"
 
42
  gemini_api_key: str = ""
43
  gemini_vision_model: str = "gemini-2.0-flash-lite"
44
 
45
+ # NVIDIA NVLM vision (OpenAI-compatible; free credits at integrate.api.nvidia.com)
46
+ nvidia_api_key: str = ""
47
+ nvidia_vision_model: str = "nvidia/nvlm-d-72b"
48
+
49
  # local Whisper via faster-whisper (no API, no rate limits)
50
  # tiny=39MB | base=74MB | small=244MB
51
  local_whisper_model: str = "base"
 
97
  def gemini_vision_enabled(self) -> bool:
98
  return bool(self.gemini_api_key.strip())
99
 
100
+ @property
101
+ def nvidia_vision_enabled(self) -> bool:
102
+ return bool(self.nvidia_api_key.strip())
103
+
104
  @property
105
  def local_whisper_enabled(self) -> bool:
106
  return self.whisper_backend == "local"
backend/app/logging_config.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clean, colour-coded logging for the Cachy pipeline.
2
+
3
+ Replaces Python's default ``INFO:logger.name:message`` format with:
4
+
5
+ 11:42:03 INFO worker [card ab12] pipeline start · instagram
6
+ 11:42:08 WARN extraction ffmpeg not found; skipping audio extraction
7
+
8
+ Call ``configure_logging()`` once at startup. Safe to call multiple times
9
+ (idempotent — replaces formatters rather than stacking handlers).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import sys
16
+
17
+ _RESET = "\033[0m"
18
+ _DIM = "\033[2m"
19
+ _CYAN = "\033[36m"
20
+
21
+ _LEVEL_FMT: dict[int, tuple[str, str]] = {
22
+ logging.DEBUG: ("DEBUG", "\033[90m"),
23
+ logging.INFO: ("INFO ", "\033[32m"),
24
+ logging.WARNING: ("WARN ", "\033[33m"),
25
+ logging.ERROR: ("ERROR", "\033[31m"),
26
+ logging.CRITICAL: ("CRIT ", "\033[1;31m"),
27
+ }
28
+
29
+ # Third-party libraries whose INFO/DEBUG output is pure noise during development.
30
+ _QUIET_LOGGERS = (
31
+ "yt_dlp",
32
+ "instaloader",
33
+ "httpx",
34
+ "httpcore",
35
+ "PIL",
36
+ "urllib3",
37
+ "faster_whisper",
38
+ "filelock",
39
+ "multipart",
40
+ "google.auth",
41
+ "google.api_core",
42
+ "asyncio",
43
+ )
44
+
45
+
46
+ class _PipelineFormatter(logging.Formatter):
47
+ """Single-line formatter: ``HH:MM:SS LEVEL logger_short message``."""
48
+
49
+ def __init__(self, colours: bool) -> None:
50
+ super().__init__()
51
+ self._colours = colours
52
+
53
+ def _c(self, text: str, code: str) -> str:
54
+ return f"{code}{text}{_RESET}" if self._colours else text
55
+
56
+ def format(self, record: logging.LogRecord) -> str:
57
+ ts = self.formatTime(record, "%H:%M:%S")
58
+ label, colour = _LEVEL_FMT.get(record.levelno, ("?????", ""))
59
+
60
+ # Last dotted segment of the logger name, fixed-width for alignment.
61
+ short = record.name.rsplit(".", 1)[-1][:11].ljust(11)
62
+
63
+ msg = record.getMessage()
64
+ if record.exc_info and not record.exc_text:
65
+ record.exc_text = self.formatException(record.exc_info)
66
+ if record.exc_text:
67
+ msg = f"{msg}\n{record.exc_text}"
68
+ if record.stack_info:
69
+ msg = f"{msg}\n{self.formatStack(record.stack_info)}"
70
+
71
+ if self._colours:
72
+ return (
73
+ f"{self._c(ts, _DIM)} "
74
+ f"{self._c(label, colour)} "
75
+ f"{self._c(short, _CYAN)} "
76
+ f"{msg}"
77
+ )
78
+ return f"{ts} {label} {short} {msg}"
79
+
80
+
81
+ def configure_logging() -> None:
82
+ """Apply the pipeline formatter and silence third-party noise.
83
+
84
+ Designed to be called both at module-import time (before uvicorn applies
85
+ its own config) and again inside the FastAPI lifespan (after uvicorn has
86
+ set up its handlers) so the format is always consistent.
87
+ """
88
+ colours = sys.stderr.isatty()
89
+ fmt = _PipelineFormatter(colours=colours)
90
+
91
+ # Root logger: add our handler if absent, or reformat existing ones.
92
+ root = logging.getLogger()
93
+ root.setLevel(logging.DEBUG)
94
+ if root.handlers:
95
+ for h in root.handlers:
96
+ h.setFormatter(fmt)
97
+ if h.level == logging.NOTSET:
98
+ h.setLevel(logging.INFO)
99
+ else:
100
+ h = logging.StreamHandler(sys.stderr)
101
+ h.setLevel(logging.INFO)
102
+ h.setFormatter(fmt)
103
+ root.addHandler(h)
104
+
105
+ # Override uvicorn's handlers so its startup/error messages look consistent.
106
+ for name in ("uvicorn", "uvicorn.error"):
107
+ for h in logging.getLogger(name).handlers:
108
+ h.setFormatter(fmt)
109
+
110
+ # Per-request access log is too noisy during development; silence it.
111
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
112
+
113
+ # Silence chatty third-party libraries.
114
+ for name in _QUIET_LOGGERS:
115
+ logging.getLogger(name).setLevel(logging.WARNING)
backend/app/main.py CHANGED
@@ -15,11 +15,13 @@ from fastapi.staticfiles import StaticFiles
15
 
16
  from app import discovery
17
  from app.api import cards, catalog, collections, concepts, graph, library_chat, search
 
18
  from app.models.card import SCHEMA_VERSION
19
  from app.pipeline import worker
20
  from app.store import db, media
21
 
22
- logging.basicConfig(level=logging.INFO)
 
23
  log = logging.getLogger("app.main")
24
 
25
  _worker_task: asyncio.Task | None = None
@@ -28,6 +30,8 @@ _worker_task: asyncio.Task | None = None
28
  @asynccontextmanager
29
  async def lifespan(app: FastAPI):
30
  global _worker_task
 
 
31
  await db.init_db()
32
  async with db.session() as s:
33
  reset_count = await db.reset_orphaned_processing_jobs(s)
 
15
 
16
  from app import discovery
17
  from app.api import cards, catalog, collections, concepts, graph, library_chat, search
18
+ from app.logging_config import configure_logging
19
  from app.models.card import SCHEMA_VERSION
20
  from app.pipeline import worker
21
  from app.store import db, media
22
 
23
+ # Apply at import time (catches logs before lifespan runs).
24
+ configure_logging()
25
  log = logging.getLogger("app.main")
26
 
27
  _worker_task: asyncio.Task | None = None
 
30
  @asynccontextmanager
31
  async def lifespan(app: FastAPI):
32
  global _worker_task
33
+ # Re-apply after uvicorn has configured its own handlers so our format wins.
34
+ configure_logging()
35
  await db.init_db()
36
  async with db.session() as s:
37
  reset_count = await db.reset_orphaned_processing_jobs(s)
backend/app/pipeline/extraction.py CHANGED
@@ -76,12 +76,20 @@ def _extract_frames(video_path: str, frame_dir: str) -> list[str]:
76
  out_pattern = os.path.join(frame_dir, "frame_%03d.jpg")
77
  try:
78
  # scene-change selection; fall back to ~1 frame/2s via fps if no scenes hit.
79
- subprocess.run(
 
 
 
80
  ["ffmpeg", "-y", "-i", video_path,
81
  "-vf", "select='gt(scene,0.3)',showinfo", "-vsync", "vfr",
82
  "-frames:v", "30", out_pattern],
83
- check=True, capture_output=True, timeout=120,
84
  )
 
 
 
 
 
85
  except (subprocess.SubprocessError, OSError) as e:
86
  log.warning("scene-frame extract failed (%s); trying fps fallback", e)
87
  frames = _list_frames(frame_dir)
@@ -98,6 +106,7 @@ def _extract_frames(video_path: str, frame_dir: str) -> list[str]:
98
  return frames
99
 
100
 
 
101
  def _list_frames(frame_dir: str) -> list[str]:
102
  if not os.path.isdir(frame_dir):
103
  return []
@@ -112,6 +121,14 @@ def _list_frames(frame_dir: str) -> list[str]:
112
  # Frame dedup (perceptual hash)
113
  # --------------------------------------------------------------------------- #
114
 
 
 
 
 
 
 
 
 
115
  def _dedup_frames(frames: list[str]) -> list[str]:
116
  """Drop near-identical frames before OCR. Cheap perceptual-hash comparison."""
117
  try:
@@ -148,10 +165,13 @@ def _transcribe_local(audio_path: str) -> str:
148
  log.warning("faster-whisper not installed; skipping local transcription")
149
  return ""
150
  settings = get_settings()
 
151
  try:
152
  model = WhisperModel(settings.local_whisper_model, device="cpu", compute_type="int8")
153
  segments, _ = model.transcribe(audio_path, beam_size=1)
154
- return " ".join(s.text for s in segments).strip()
 
 
155
  except Exception as e:
156
  log.warning("local whisper failed: %s", e)
157
  return ""
@@ -161,15 +181,29 @@ def _transcribe_groq(audio_path: str) -> str:
161
  settings = get_settings()
162
  if not settings.groq_enabled:
163
  return ""
 
164
  try:
165
  from groq import Groq
166
  client = Groq(api_key=settings.groq_api_key)
167
  with open(audio_path, "rb") as f:
 
 
 
 
 
 
 
 
 
 
 
168
  resp = client.audio.transcriptions.create(
169
- file=(os.path.basename(audio_path), f.read()),
170
  model=settings.groq_whisper_model,
171
  )
172
- return (getattr(resp, "text", "") or "").strip()
 
 
173
  except Exception as e:
174
  log.warning("groq whisper failed: %s", e)
175
  return ""
@@ -185,7 +219,7 @@ def _transcribe(audio_path: str | None) -> str:
185
  result = _transcribe_groq(audio_path)
186
  if result:
187
  return result
188
- # Groq failed (rate limit / no key) — try local as fallback
189
  return _transcribe_local(audio_path)
190
 
191
 
@@ -247,44 +281,68 @@ def _ocr(frames: list[str]) -> str:
247
  # --------------------------------------------------------------------------- #
248
 
249
  _VISION_PROMPT = (
250
- "This is one slide from a social-media carousel post. Transcribe ALL text "
251
- "shown on it verbatim, preserving reading order, and briefly note any key "
252
- "visual that carries meaning (chart, diagram, product, place). Output plain "
253
- "text only — no preamble, no markdown, no commentary."
 
 
 
 
 
 
 
 
 
254
  )
255
 
256
  _VISION_NO_TEXT = {"none", "no text", "no text shown"}
257
 
258
 
259
- def _vision_read_gemini(frames: list[str]) -> str:
260
- """Gemini Flash Lite: 1M TPM / 1500 req/day freeprimary vision backend."""
261
  settings = get_settings()
262
  if not frames or not settings.gemini_vision_enabled:
263
  return ""
264
  try:
265
- import google.generativeai as genai
 
 
266
  from PIL import Image
267
  except Exception:
268
  return ""
269
 
270
- genai.configure(api_key=settings.gemini_api_key)
271
- model = genai.GenerativeModel(settings.gemini_vision_model)
272
- chunks: list[str] = []
273
- for idx, path in enumerate(frames, 1):
274
- try:
275
  with Image.open(path) as img:
276
- resp = model.generate_content([_VISION_PROMPT, img])
277
- text = (resp.text or "").strip()
278
- except Exception as e:
279
- log.warning("gemini vision failed on slide %d: %s", idx, e)
280
- continue
 
 
 
 
281
  if text and text.lower() not in _VISION_NO_TEXT:
282
- chunks.append(f"[slide {idx}] {text}")
283
- return "\n".join(chunks)
 
 
 
 
 
 
 
 
 
 
284
 
285
 
286
- def _vision_read_groq(frames: list[str]) -> str:
287
- """Groq VLM: 30k TPM free fallback when Gemini unavailable/exhausted."""
288
  settings = get_settings()
289
  if not frames or not settings.groq_vision_enabled:
290
  return ""
@@ -295,55 +353,123 @@ def _vision_read_groq(frames: list[str]) -> str:
295
  return ""
296
 
297
  client = Groq(api_key=settings.groq_api_key)
298
- chunks: list[str] = []
299
- for idx, path in enumerate(frames, 1):
300
- try:
 
 
301
  with open(path, "rb") as f:
302
  b64 = base64.b64encode(f.read()).decode()
303
  ext = os.path.splitext(path)[1].lstrip(".").lower() or "jpeg"
304
  if ext == "jpg":
305
  ext = "jpeg"
306
- resp = client.chat.completions.create(
307
- model=settings.groq_vision_model,
308
- messages=[{
309
- "role": "user",
310
- "content": [
311
- {"type": "text", "text": _VISION_PROMPT},
312
- {"type": "image_url",
313
- "image_url": {"url": f"data:image/{ext};base64,{b64}"}},
314
- ],
315
- }],
316
- temperature=0.0,
317
- max_tokens=512,
318
- )
319
- text = (resp.choices[0].message.content or "").strip() if resp.choices else ""
320
- except Exception as e:
321
- log.warning("groq vision failed on slide %d: %s", idx, e)
322
- continue
323
  if text and text.lower() not in _VISION_NO_TEXT:
324
- chunks.append(f"[slide {idx}] {text}")
325
- return "\n".join(chunks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
 
328
  def _vision_read(frames: list[str]) -> str:
329
- """Cascade: Gemini → Groq → empty (caller falls back to Tesseract)."""
330
  result = _vision_read_gemini(frames)
 
 
 
331
  if result:
332
  return result
333
  return _vision_read_groq(frames)
334
 
335
 
 
 
 
 
 
 
 
 
 
 
 
336
  # --------------------------------------------------------------------------- #
337
  # Aggregate
338
  # --------------------------------------------------------------------------- #
339
 
340
- def _aggregate(caption: str, transcript: str, ocr_text: str, source_line: str) -> str:
 
 
 
 
 
 
341
  parts = [
342
  f"CAPTION: {caption.strip()}" if caption.strip() else "CAPTION:",
343
  f"TRANSCRIPT: {transcript.strip()}" if transcript.strip() else "TRANSCRIPT:",
344
  f"ON-SCREEN TEXT: {ocr_text.strip()}" if ocr_text.strip() else "ON-SCREEN TEXT:",
345
- f"SOURCE: {source_line}",
346
  ]
 
 
 
347
  return "\n".join(parts)
348
 
349
 
@@ -359,7 +485,7 @@ def _aggregate_article(
359
  ]
360
  if ocr_text.strip():
361
  parts.append(f"ATTACHED VISUAL CONTENT / ON-SCREEN TEXT:\n{ocr_text.strip()}")
362
- parts.append(f"SOURCE: {source_line}")
363
  return "\n".join(parts)
364
 
365
 
@@ -451,6 +577,7 @@ def extract(
451
  # Carousel slides are stylized text-as-image; Tesseract reads them poorly
452
  # (docs/03 step 5), so prefer the free Groq VLM, falling back to OCR.
453
  had_visual = False
 
454
  if is_carousel and settings.groq_vision_enabled:
455
  ocr_text = _vision_read(frames)
456
  if ocr_text.strip():
@@ -459,10 +586,16 @@ def extract(
459
  ocr_text = _ocr(frames)
460
  else:
461
  ocr_text = _ocr(frames)
 
 
 
 
 
 
462
 
463
  thumbnail = frames[0] if frames else None
464
 
465
- aggregated = _aggregate(download.caption or "", transcript, ocr_text, source_line)
466
 
467
  return ExtractionResult(
468
  aggregated_text=aggregated,
 
76
  out_pattern = os.path.join(frame_dir, "frame_%03d.jpg")
77
  try:
78
  # scene-change selection; fall back to ~1 frame/2s via fps if no scenes hit.
79
+ # NOTE: exit code 234 from ffmpeg means the vf filter matched zero frames
80
+ # (common for short clips with no scene changes) — that is NOT an error.
81
+ # We run without check=True and only warn on genuinely unexpected codes.
82
+ result = subprocess.run(
83
  ["ffmpeg", "-y", "-i", video_path,
84
  "-vf", "select='gt(scene,0.3)',showinfo", "-vsync", "vfr",
85
  "-frames:v", "30", out_pattern],
86
+ capture_output=True, timeout=120,
87
  )
88
+ if result.returncode not in (0, 234):
89
+ log.debug(
90
+ "scene-frame extract exited %d; trying fps fallback",
91
+ result.returncode,
92
+ )
93
  except (subprocess.SubprocessError, OSError) as e:
94
  log.warning("scene-frame extract failed (%s); trying fps fallback", e)
95
  frames = _list_frames(frame_dir)
 
106
  return frames
107
 
108
 
109
+
110
  def _list_frames(frame_dir: str) -> list[str]:
111
  if not os.path.isdir(frame_dir):
112
  return []
 
121
  # Frame dedup (perceptual hash)
122
  # --------------------------------------------------------------------------- #
123
 
124
+ def _sample_frames(frames: list[str], n: int) -> list[str]:
125
+ """Return n evenly-spaced frames from the list (or all if len <= n)."""
126
+ if len(frames) <= n:
127
+ return frames
128
+ step = len(frames) / n
129
+ return [frames[int(i * step)] for i in range(n)]
130
+
131
+
132
  def _dedup_frames(frames: list[str]) -> list[str]:
133
  """Drop near-identical frames before OCR. Cheap perceptual-hash comparison."""
134
  try:
 
165
  log.warning("faster-whisper not installed; skipping local transcription")
166
  return ""
167
  settings = get_settings()
168
+ log.info("transcribe: local whisper (%s) — may take a moment on CPU", settings.local_whisper_model)
169
  try:
170
  model = WhisperModel(settings.local_whisper_model, device="cpu", compute_type="int8")
171
  segments, _ = model.transcribe(audio_path, beam_size=1)
172
+ result = " ".join(s.text for s in segments).strip()
173
+ log.info("transcribe: local whisper OK (%d chars)", len(result))
174
+ return result
175
  except Exception as e:
176
  log.warning("local whisper failed: %s", e)
177
  return ""
 
181
  settings = get_settings()
182
  if not settings.groq_enabled:
183
  return ""
184
+ log.info("transcribe: Groq Whisper (%s)", settings.groq_whisper_model)
185
  try:
186
  from groq import Groq
187
  client = Groq(api_key=settings.groq_api_key)
188
  with open(audio_path, "rb") as f:
189
+ audio_bytes = f.read()
190
+ # translations endpoint: auto-detects language and outputs English.
191
+ # For Hindi/non-English audio this is the key difference vs transcriptions.
192
+ try:
193
+ resp = client.audio.translations.create(
194
+ file=(os.path.basename(audio_path), audio_bytes),
195
+ model=settings.groq_whisper_model,
196
+ )
197
+ except Exception:
198
+ # Some Whisper model variants don't support translation; fall back.
199
+ import io
200
  resp = client.audio.transcriptions.create(
201
+ file=(os.path.basename(audio_path), io.BytesIO(audio_bytes)),
202
  model=settings.groq_whisper_model,
203
  )
204
+ result = (getattr(resp, "text", "") or "").strip()
205
+ log.info("transcribe: Groq Whisper OK (%d chars)", len(result))
206
+ return result
207
  except Exception as e:
208
  log.warning("groq whisper failed: %s", e)
209
  return ""
 
219
  result = _transcribe_groq(audio_path)
220
  if result:
221
  return result
222
+ log.info("transcribe: Groq unavailable, falling back to local whisper")
223
  return _transcribe_local(audio_path)
224
 
225
 
 
281
  # --------------------------------------------------------------------------- #
282
 
283
  _VISION_PROMPT = (
284
+ "These are slides from a social-media carousel post. For each slide shown, "
285
+ "transcribe ALL text verbatim in reading order and note any key visual that "
286
+ "carries meaning (chart, diagram, product, place). "
287
+ "Output plain text only — no preamble, no markdown, no commentary."
288
+ )
289
+
290
+ _VIDEO_VISION_PROMPT = (
291
+ "These are keyframes from a short-form social media video (Reel / TikTok / Short). "
292
+ "Describe what is shown across all frames in plain English: "
293
+ "(1) what the person is doing or demonstrating; "
294
+ "(2) all visible text — book/product titles, subtitles, on-screen overlays; "
295
+ "(3) any recognizable objects, books, products, or brands. "
296
+ "Output a concise combined summary. No preamble, no markdown."
297
  )
298
 
299
  _VISION_NO_TEXT = {"none", "no text", "no text shown"}
300
 
301
 
302
+ def _vision_read_gemini(frames: list[str], prompt: str = _VISION_PROMPT) -> str:
303
+ """Gemini Flash Lite: single batched call with all frames1 API call instead of N."""
304
  settings = get_settings()
305
  if not frames or not settings.gemini_vision_enabled:
306
  return ""
307
  try:
308
+ import io
309
+ from google import genai as google_genai
310
+ from google.genai import types as genai_types
311
  from PIL import Image
312
  except Exception:
313
  return ""
314
 
315
+ client = google_genai.Client(api_key=settings.gemini_api_key)
316
+ try:
317
+ contents: list = [prompt]
318
+ for path in frames:
 
319
  with Image.open(path) as img:
320
+ buf = io.BytesIO()
321
+ img.convert("RGB").save(buf, format="JPEG")
322
+ contents.append(
323
+ genai_types.Part.from_bytes(data=buf.getvalue(), mime_type="image/jpeg")
324
+ )
325
+ resp = client.models.generate_content(
326
+ model=settings.gemini_vision_model, contents=contents
327
+ )
328
+ text = (resp.text or "").strip()
329
  if text and text.lower() not in _VISION_NO_TEXT:
330
+ return text
331
+ return ""
332
+ except Exception as e:
333
+ err_str = str(e)
334
+ if "429" in err_str or "RESOURCE_EXHAUSTED" in err_str or "quota" in err_str.lower():
335
+ log.warning("gemini vision: quota exhausted, handing off to Groq fallback")
336
+ else:
337
+ log.warning("gemini vision failed: %s", e)
338
+ return ""
339
+
340
+
341
+ _GROQ_VISION_MAX_FRAMES = 5 # Llama-4-scout multi-image limit
342
 
343
 
344
+ def _vision_read_groq(frames: list[str], prompt: str = _VISION_PROMPT) -> str:
345
+ """Groq VLM: single batched call (capped at 5 frames — model limit)."""
346
  settings = get_settings()
347
  if not frames or not settings.groq_vision_enabled:
348
  return ""
 
353
  return ""
354
 
355
  client = Groq(api_key=settings.groq_api_key)
356
+ # Sample evenly across all frames so we get representative coverage even when capped.
357
+ batch = _sample_frames(frames, _GROQ_VISION_MAX_FRAMES)
358
+ try:
359
+ content: list = [{"type": "text", "text": prompt}]
360
+ for path in batch:
361
  with open(path, "rb") as f:
362
  b64 = base64.b64encode(f.read()).decode()
363
  ext = os.path.splitext(path)[1].lstrip(".").lower() or "jpeg"
364
  if ext == "jpg":
365
  ext = "jpeg"
366
+ content.append({
367
+ "type": "image_url",
368
+ "image_url": {"url": f"data:image/{ext};base64,{b64}"},
369
+ })
370
+ resp = client.chat.completions.create(
371
+ model=settings.groq_vision_model,
372
+ messages=[{"role": "user", "content": content}],
373
+ temperature=0.0,
374
+ max_tokens=1024,
375
+ )
376
+ text = (resp.choices[0].message.content or "").strip() if resp.choices else ""
 
 
 
 
 
 
377
  if text and text.lower() not in _VISION_NO_TEXT:
378
+ return text
379
+ return ""
380
+ except Exception as e:
381
+ log.warning("groq vision failed: %s", e)
382
+ return ""
383
+
384
+
385
+ def _vision_read_nvidia(frames: list[str], prompt: str = _VISION_PROMPT) -> str:
386
+ """NVLM-D-72B via NVIDIA API (OpenAI-compatible, free credits). Multi-image batch."""
387
+ settings = get_settings()
388
+ if not frames or not settings.nvidia_vision_enabled:
389
+ return ""
390
+ try:
391
+ import base64
392
+ import requests as _requests
393
+ except Exception:
394
+ return ""
395
+
396
+ content: list = [{"type": "text", "text": prompt}]
397
+ for path in frames:
398
+ with open(path, "rb") as f:
399
+ b64 = base64.b64encode(f.read()).decode()
400
+ ext = os.path.splitext(path)[1].lstrip(".").lower() or "jpeg"
401
+ if ext == "jpg":
402
+ ext = "jpeg"
403
+ content.append({
404
+ "type": "image_url",
405
+ "image_url": {"url": f"data:image/{ext};base64,{b64}"},
406
+ })
407
+ try:
408
+ resp = _requests.post(
409
+ "https://integrate.api.nvidia.com/v1/chat/completions",
410
+ headers={
411
+ "Authorization": f"Bearer {settings.nvidia_api_key}",
412
+ "Accept": "application/json",
413
+ },
414
+ json={
415
+ "model": settings.nvidia_vision_model,
416
+ "messages": [{"role": "user", "content": content}],
417
+ "max_tokens": 1024,
418
+ "temperature": 0.2,
419
+ },
420
+ timeout=60,
421
+ )
422
+ resp.raise_for_status()
423
+ text = (resp.json()["choices"][0]["message"]["content"] or "").strip()
424
+ if text and text.lower() not in _VISION_NO_TEXT:
425
+ return text
426
+ return ""
427
+ except Exception as e:
428
+ log.warning("nvidia vision failed: %s", e)
429
+ return ""
430
 
431
 
432
  def _vision_read(frames: list[str]) -> str:
433
+ """Cascade: Gemini → NVLM (NVIDIA) → Groq → empty (caller falls back to Tesseract)."""
434
  result = _vision_read_gemini(frames)
435
+ if result:
436
+ return result
437
+ result = _vision_read_nvidia(frames)
438
  if result:
439
  return result
440
  return _vision_read_groq(frames)
441
 
442
 
443
+ def _vision_read_video(frames: list[str]) -> str:
444
+ """VLM description of video keyframes: Gemini → NVLM → Groq."""
445
+ result = _vision_read_gemini(frames, prompt=_VIDEO_VISION_PROMPT)
446
+ if result:
447
+ return result
448
+ result = _vision_read_nvidia(frames, prompt=_VIDEO_VISION_PROMPT)
449
+ if result:
450
+ return result
451
+ return _vision_read_groq(frames, prompt=_VIDEO_VISION_PROMPT)
452
+
453
+
454
  # --------------------------------------------------------------------------- #
455
  # Aggregate
456
  # --------------------------------------------------------------------------- #
457
 
458
+ def _aggregate(
459
+ caption: str,
460
+ transcript: str,
461
+ ocr_text: str,
462
+ source_line: str,
463
+ visual_context: str = "",
464
+ ) -> str:
465
  parts = [
466
  f"CAPTION: {caption.strip()}" if caption.strip() else "CAPTION:",
467
  f"TRANSCRIPT: {transcript.strip()}" if transcript.strip() else "TRANSCRIPT:",
468
  f"ON-SCREEN TEXT: {ocr_text.strip()}" if ocr_text.strip() else "ON-SCREEN TEXT:",
 
469
  ]
470
+ if visual_context.strip():
471
+ parts.append(f"VISUAL CONTEXT (FRAME DESCRIPTIONS):\n{visual_context.strip()}")
472
+ parts.append(f"SOURCE PLATFORM: {source_line}")
473
  return "\n".join(parts)
474
 
475
 
 
485
  ]
486
  if ocr_text.strip():
487
  parts.append(f"ATTACHED VISUAL CONTENT / ON-SCREEN TEXT:\n{ocr_text.strip()}")
488
+ parts.append(f"SOURCE PLATFORM: {source_line}")
489
  return "\n".join(parts)
490
 
491
 
 
577
  # Carousel slides are stylized text-as-image; Tesseract reads them poorly
578
  # (docs/03 step 5), so prefer the free Groq VLM, falling back to OCR.
579
  had_visual = False
580
+ visual_context = ""
581
  if is_carousel and settings.groq_vision_enabled:
582
  ocr_text = _vision_read(frames)
583
  if ocr_text.strip():
 
586
  ocr_text = _ocr(frames)
587
  else:
588
  ocr_text = _ocr(frames)
589
+ # For videos, also run VLM on keyframes to capture what's visually shown
590
+ # (book/product names, on-screen text, speaker context) — Tesseract misses all of this.
591
+ if frames and (settings.gemini_vision_enabled or settings.groq_vision_enabled):
592
+ visual_context = _vision_read_video(frames)
593
+ if visual_context.strip():
594
+ had_visual = True
595
 
596
  thumbnail = frames[0] if frames else None
597
 
598
+ aggregated = _aggregate(download.caption or "", transcript, ocr_text, source_line, visual_context)
599
 
600
  return ExtractionResult(
601
  aggregated_text=aggregated,
backend/app/pipeline/structuring.py CHANGED
@@ -176,8 +176,7 @@ Rules for blocks (MOST IMPORTANT):
176
 
177
  Other rules:
178
  - base.one_liner and base.tldr MUST always be non-empty.
179
- - base.tags: 3-6 short, lowercase, topical keywords.
180
- - artifacts: include ONLY concrete, named, real-world things the video names. Do NOT invent any.
181
  - Inline references: wrap artifact names and concept names in [[double brackets]] the FIRST time they appear in prose.
182
  - action_items: concrete doable tasks the video tells the viewer to take. Short imperative phrases, max ~8.
183
  - depth: "deep" for idea-rich, knowledge-heavy, or argumentative content. "shallow" for procedural/lightweight.
@@ -197,19 +196,20 @@ Extracted text bundle:
197
  # Bundle preprocessor (Gemini Flash Lite — separate quota pool)
198
  # --------------------------------------------------------------------------- #
199
 
200
- _PREPROCESS_PROMPT = """You clean a noisy text bundle extracted from a short-form video \
201
  (caption + audio transcript + on-screen/slide text) before it is turned into a detailed knowledge note.
202
 
203
- Your job is ONLY to remove noise, never to reduce information density.
204
 
205
  Do ONLY this:
 
206
  - Remove EXACT duplicates across channels (e.g. on-screen text word-for-word repeating the transcript). If in doubt, KEEP BOTH.
207
  - Remove pure engagement bait with zero informational value ("like and subscribe", "link in bio", "follow for more").
208
  - Remove spoken filler with zero content ("um", "uh", "you know", "so basically").
209
  - NEVER remove or merge: names, numbers, steps, tips, tools, apps, products, prices, quantities, quotes, claims, or explanations — even partial ones.
210
  - NEVER summarize, paraphrase, reorder, or shorten any real information.
211
  - NEVER drop a sentence just because it seems redundant to you — the structuring model will judge relevance.
212
- - Keep the CAPTION / TRANSCRIPT / ON-SCREEN TEXT / SOURCE section labels and structure intact.
213
  - Output ONLY the cleaned bundle text — no preamble, no explanation, no markdown.
214
 
215
  If you are unsure whether something is noise or content, KEEP IT.
@@ -229,19 +229,25 @@ def _preprocess_bundle(bundle: str) -> str:
229
  settings = get_settings()
230
  if not settings.gemini_preprocess_enabled:
231
  return bundle
 
232
  try:
233
- import google.generativeai as genai
234
 
235
- genai.configure(api_key=settings.gemini_api_key)
236
- model = genai.GenerativeModel(settings.gemini_preprocess_model)
237
- resp = model.generate_content(_PREPROCESS_PROMPT.format(bundle=bundle))
 
 
238
  cleaned = (resp.text or "").strip()
 
 
239
  return cleaned or bundle
240
  except Exception as e:
241
  log.warning("bundle preprocess (gemini) failed: %s; using raw bundle", e)
242
  return bundle
243
 
244
 
 
245
  # --------------------------------------------------------------------------- #
246
  # LLM call (Cerebras primary -> Groq fallback)
247
  # --------------------------------------------------------------------------- #
@@ -269,6 +275,7 @@ def _call_llm(bundle: str) -> str | None:
269
 
270
  def _call_cerebras(prompt: str, max_tokens: int, temperature: float) -> str | None:
271
  settings = get_settings()
 
272
  try:
273
  from cerebras.cloud.sdk import Cerebras
274
 
@@ -280,7 +287,10 @@ def _call_cerebras(prompt: str, max_tokens: int, temperature: float) -> str | No
280
  max_tokens=max_tokens,
281
  )
282
  text = resp.choices[0].message.content if resp.choices else ""
283
- return (text or "").strip()
 
 
 
284
  except Exception as e:
285
  log.warning("llm call (cerebras) failed: %s", e)
286
  return None
@@ -288,6 +298,7 @@ def _call_cerebras(prompt: str, max_tokens: int, temperature: float) -> str | No
288
 
289
  def _call_groq(prompt: str, max_tokens: int, temperature: float) -> str | None:
290
  settings = get_settings()
 
291
  try:
292
  from groq import Groq
293
 
@@ -299,7 +310,10 @@ def _call_groq(prompt: str, max_tokens: int, temperature: float) -> str | None:
299
  max_tokens=max_tokens, # rich carousels (e.g. 140-item lists) overflow a smaller cap
300
  )
301
  text = resp.choices[0].message.content if resp.choices else ""
302
- return (text or "").strip()
 
 
 
303
  except Exception as e:
304
  log.warning("llm call (groq) failed: %s", e)
305
  return None
 
176
 
177
  Other rules:
178
  - base.one_liner and base.tldr MUST always be non-empty.
179
+ - artifacts: include ONLY concrete, named, real-world things the video names (books, tools, products). Do NOT include social media hosting platforms (Instagram, TikTok) or downloading scrapers.
 
180
  - Inline references: wrap artifact names and concept names in [[double brackets]] the FIRST time they appear in prose.
181
  - action_items: concrete doable tasks the video tells the viewer to take. Short imperative phrases, max ~8.
182
  - depth: "deep" for idea-rich, knowledge-heavy, or argumentative content. "shallow" for procedural/lightweight.
 
196
  # Bundle preprocessor (Gemini Flash Lite — separate quota pool)
197
  # --------------------------------------------------------------------------- #
198
 
199
+ _PREPROCESS_PROMPT = """You clean and prepare a noisy text bundle extracted from a short-form video \
200
  (caption + audio transcript + on-screen/slide text) before it is turned into a detailed knowledge note.
201
 
202
+ Your job: remove noise AND translate non-English text to English. Never reduce information density.
203
 
204
  Do ONLY this:
205
+ - If the TRANSCRIPT is in a non-English language, translate it fully to English in-place, preserving ALL content, names, examples, and meaning. Update the section label to: "TRANSCRIPT (translated from [Language]):". Keep every detail from the original — do not summarize.
206
  - Remove EXACT duplicates across channels (e.g. on-screen text word-for-word repeating the transcript). If in doubt, KEEP BOTH.
207
  - Remove pure engagement bait with zero informational value ("like and subscribe", "link in bio", "follow for more").
208
  - Remove spoken filler with zero content ("um", "uh", "you know", "so basically").
209
  - NEVER remove or merge: names, numbers, steps, tips, tools, apps, products, prices, quantities, quotes, claims, or explanations — even partial ones.
210
  - NEVER summarize, paraphrase, reorder, or shorten any real information.
211
  - NEVER drop a sentence just because it seems redundant to you — the structuring model will judge relevance.
212
+ - Keep the CAPTION / TRANSCRIPT / ON-SCREEN TEXT / VISUAL CONTEXT / SOURCE PLATFORM section labels and structure intact.
213
  - Output ONLY the cleaned bundle text — no preamble, no explanation, no markdown.
214
 
215
  If you are unsure whether something is noise or content, KEEP IT.
 
229
  settings = get_settings()
230
  if not settings.gemini_preprocess_enabled:
231
  return bundle
232
+ log.info("preprocess: cleaning bundle with Gemini (%s)", settings.gemini_preprocess_model)
233
  try:
234
+ from google import genai as google_genai
235
 
236
+ client = google_genai.Client(api_key=settings.gemini_api_key)
237
+ resp = client.models.generate_content(
238
+ model=settings.gemini_preprocess_model,
239
+ contents=_PREPROCESS_PROMPT.format(bundle=bundle),
240
+ )
241
  cleaned = (resp.text or "").strip()
242
+ if cleaned:
243
+ log.info("preprocess: done (%d → %d chars)", len(bundle), len(cleaned))
244
  return cleaned or bundle
245
  except Exception as e:
246
  log.warning("bundle preprocess (gemini) failed: %s; using raw bundle", e)
247
  return bundle
248
 
249
 
250
+
251
  # --------------------------------------------------------------------------- #
252
  # LLM call (Cerebras primary -> Groq fallback)
253
  # --------------------------------------------------------------------------- #
 
275
 
276
  def _call_cerebras(prompt: str, max_tokens: int, temperature: float) -> str | None:
277
  settings = get_settings()
278
+ log.info("llm: calling Cerebras (%s)", settings.cerebras_llm_model)
279
  try:
280
  from cerebras.cloud.sdk import Cerebras
281
 
 
287
  max_tokens=max_tokens,
288
  )
289
  text = resp.choices[0].message.content if resp.choices else ""
290
+ result = (text or "").strip()
291
+ if result:
292
+ log.info("llm: Cerebras OK (%d chars)", len(result))
293
+ return result
294
  except Exception as e:
295
  log.warning("llm call (cerebras) failed: %s", e)
296
  return None
 
298
 
299
  def _call_groq(prompt: str, max_tokens: int, temperature: float) -> str | None:
300
  settings = get_settings()
301
+ log.info("llm: calling Groq (%s)", settings.groq_llm_model)
302
  try:
303
  from groq import Groq
304
 
 
310
  max_tokens=max_tokens, # rich carousels (e.g. 140-item lists) overflow a smaller cap
311
  )
312
  text = resp.choices[0].message.content if resp.choices else ""
313
+ result = (text or "").strip()
314
+ if result:
315
+ log.info("llm: Groq OK (%d chars)", len(result))
316
+ return result
317
  except Exception as e:
318
  log.warning("llm call (groq) failed: %s", e)
319
  return None
backend/app/pipeline/worker.py CHANGED
@@ -13,6 +13,7 @@ from __future__ import annotations
13
  import asyncio
14
  import logging
15
  from datetime import datetime, timezone
 
16
 
17
  from sqlalchemy import or_, select, update
18
 
@@ -157,14 +158,23 @@ async def _write_media_and_meta(
157
  await session.commit()
158
 
159
 
160
- async def _persist_artifacts(session, card_id: str, artifacts) -> None:
161
- """Aggregate referenced things into the global catalog (docs/12). Each lookup
162
- is best-effort and isolateda failure here never affects the card itself."""
163
- for art in artifacts:
164
  try:
165
  thumbnail = await asyncio.to_thread(
166
  artifact_images.resolve_thumbnail, art
167
  )
 
 
 
 
 
 
 
 
 
168
  await db.upsert_artifact(
169
  session,
170
  card_id=card_id,
@@ -174,21 +184,23 @@ async def _persist_artifacts(session, card_id: str, artifacts) -> None:
174
  year=art.year,
175
  thumbnail=thumbnail,
176
  )
177
- except Exception: # noqa: BLE001 — catalog is non-critical to the card
178
- log.warning("catalog upsert failed for %r", art.title, exc_info=True)
 
179
 
180
 
181
- async def _persist_concepts(session, card_id: str, concepts: list[str]) -> None:
182
  """Aggregate evergreen ideas into the global concept store. Best-effort and
183
  isolated — a failure here never affects the card itself."""
184
  for name in concepts:
185
  try:
186
  await db.upsert_concept(session, card_id=card_id, name=name)
187
- except Exception: # noqa: BLE001 — concepts are non-critical to the card
188
- log.warning("concept upsert failed for %r", name, exc_info=True)
 
189
 
190
 
191
- async def _embed_card(session, card_id: str) -> None:
192
  """Compute + store a semantic-search embedding for the card (docs/09).
193
  Best-effort and isolated: any failure (no HF key, network) leaves the card
194
  embedding-less, and search degrades to full-text. Reuses the same flattened
@@ -288,7 +300,7 @@ async def _run_job(session, job: db.JobRow) -> None:
288
  events.publish(card_id, "extracting", "processing", "Extracting audio + frames")
289
  log.info("%s Step 2/6 extract: transcript + on-screen text from media", tag)
290
  work_dir = media.job_dir_for_card(card_id)
291
- source_line = f"{platform or 'unknown'} / {download.resolver}"
292
  extraction = await extract_async(download, work_dir, source_line)
293
  log.info(
294
  "%s Step 2/6 extract OK | frames=%d transcript=%s ocr=%s vision=%s",
@@ -373,8 +385,9 @@ async def _run_job(session, job: db.JobRow) -> None:
373
  })
374
  else:
375
  log.info("%s Step 4b deep-analysis: no usable layer produced", tag)
376
- except Exception: # noqa: BLE001 — insight is non-critical to the card
377
- log.warning("%s Step 4b deep-analysis failed", tag, exc_info=True)
 
378
  else:
379
  log.info("%s Step 4b deep-analysis: depth=shallow, skipping", tag)
380
 
@@ -399,9 +412,10 @@ async def _run_job(session, job: db.JobRow) -> None:
399
  log.info("%s Step 6/6 index: embedding card for semantic search", tag)
400
  try:
401
  await _embed_card(session, card_id)
402
- except Exception: # noqa: BLE001 — semantic index is non-critical to the card
 
403
  log.warning("%s Step 6/6 index: embedding failed (search degrades to "
404
- "full-text)", tag, exc_info=True)
405
 
406
  # Optionally discard the source video, keep keyframes+thumbnail (docs/11)
407
  if get_settings().discard_source_video and download.media_type == "video":
@@ -428,20 +442,24 @@ async def run_worker_loop() -> None:
428
  if job is None:
429
  await asyncio.sleep(settings.worker_poll_seconds)
430
  continue
 
 
431
  try:
432
  await asyncio.wait_for(
433
  _run_job(session, job),
434
  timeout=settings.job_timeout_seconds,
435
  )
436
- except asyncio.TimeoutError:
 
437
  await _fail(
438
- session, job.card_id, job, FailureReason.TIMEOUT, "job timed out"
439
  )
440
- events.publish(job.card_id, "failed", "failed", "Timed out",
441
  FailureReason.TIMEOUT.value)
442
  except Exception as e: # noqa: BLE001 — pipeline must never crash the loop
443
- log.exception("job %s failed", job.id)
444
- await _fail(session, job.card_id, job, FailureReason.UNAVAILABLE, str(e))
 
445
  except Exception: # noqa: BLE001
446
  log.exception("worker loop iteration error")
447
  await asyncio.sleep(settings.worker_poll_seconds)
 
13
  import asyncio
14
  import logging
15
  from datetime import datetime, timezone
16
+ from typing import Any
17
 
18
  from sqlalchemy import or_, select, update
19
 
 
158
  await session.commit()
159
 
160
 
161
+ async def _persist_artifacts(session: Any, card_id: str, artifacts: list[Any]) -> None:
162
+ """Aggregate referenced things into the global catalog (docs/12). Thumbnail
163
+ lookups run in paralleleach is best-effort and never blocks the card."""
164
+ async def _resolve_thumb(art: Any) -> tuple[Any, str | None]:
165
  try:
166
  thumbnail = await asyncio.to_thread(
167
  artifact_images.resolve_thumbnail, art
168
  )
169
+ return art, thumbnail
170
+ except Exception as e:
171
+ log.warning("thumbnail lookup failed for %r: %s", art.title, str(e), exc_info=True)
172
+ return art, None
173
+
174
+ resolved = await asyncio.gather(*(_resolve_thumb(art) for art in artifacts))
175
+
176
+ for art, thumbnail in resolved:
177
+ try:
178
  await db.upsert_artifact(
179
  session,
180
  card_id=card_id,
 
184
  year=art.year,
185
  thumbnail=thumbnail,
186
  )
187
+ except Exception as e: # noqa: BLE001 — catalog is non-critical to the card
188
+ await session.rollback()
189
+ log.warning("catalog upsert failed for %r: %s", art.title, str(e), exc_info=True)
190
 
191
 
192
+ async def _persist_concepts(session: Any, card_id: str, concepts: list[str]) -> None:
193
  """Aggregate evergreen ideas into the global concept store. Best-effort and
194
  isolated — a failure here never affects the card itself."""
195
  for name in concepts:
196
  try:
197
  await db.upsert_concept(session, card_id=card_id, name=name)
198
+ except Exception as e: # noqa: BLE001 — concepts are non-critical to the card
199
+ await session.rollback()
200
+ log.warning("concept upsert failed for %r: %s", name, str(e), exc_info=True)
201
 
202
 
203
+ async def _embed_card(session: Any, card_id: str) -> None:
204
  """Compute + store a semantic-search embedding for the card (docs/09).
205
  Best-effort and isolated: any failure (no HF key, network) leaves the card
206
  embedding-less, and search degrades to full-text. Reuses the same flattened
 
300
  events.publish(card_id, "extracting", "processing", "Extracting audio + frames")
301
  log.info("%s Step 2/6 extract: transcript + on-screen text from media", tag)
302
  work_dir = media.job_dir_for_card(card_id)
303
+ source_line = platform.title() if platform else "Short-form video"
304
  extraction = await extract_async(download, work_dir, source_line)
305
  log.info(
306
  "%s Step 2/6 extract OK | frames=%d transcript=%s ocr=%s vision=%s",
 
385
  })
386
  else:
387
  log.info("%s Step 4b deep-analysis: no usable layer produced", tag)
388
+ except Exception as e: # noqa: BLE001 — insight is non-critical to the card
389
+ await session.rollback()
390
+ log.warning("%s Step 4b deep-analysis failed: %s", tag, str(e), exc_info=True)
391
  else:
392
  log.info("%s Step 4b deep-analysis: depth=shallow, skipping", tag)
393
 
 
412
  log.info("%s Step 6/6 index: embedding card for semantic search", tag)
413
  try:
414
  await _embed_card(session, card_id)
415
+ except Exception as e: # noqa: BLE001 — semantic index is non-critical to the card
416
+ await session.rollback()
417
  log.warning("%s Step 6/6 index: embedding failed (search degrades to "
418
+ "full-text): %s", tag, str(e), exc_info=True)
419
 
420
  # Optionally discard the source video, keep keyframes+thumbnail (docs/11)
421
  if get_settings().discard_source_video and download.media_type == "video":
 
442
  if job is None:
443
  await asyncio.sleep(settings.worker_poll_seconds)
444
  continue
445
+ job_id = job.id
446
+ card_id = job.card_id
447
  try:
448
  await asyncio.wait_for(
449
  _run_job(session, job),
450
  timeout=settings.job_timeout_seconds,
451
  )
452
+ except asyncio.TimeoutError as e:
453
+ await session.rollback()
454
  await _fail(
455
+ session, card_id, job, FailureReason.TIMEOUT, f"job timed out: {e}"
456
  )
457
+ events.publish(card_id, "failed", "failed", "Timed out",
458
  FailureReason.TIMEOUT.value)
459
  except Exception as e: # noqa: BLE001 — pipeline must never crash the loop
460
+ await session.rollback()
461
+ log.exception("job %s failed: %s", job_id, str(e))
462
+ await _fail(session, card_id, job, FailureReason.UNAVAILABLE, str(e))
463
  except Exception: # noqa: BLE001
464
  log.exception("worker loop iteration error")
465
  await asyncio.sleep(settings.worker_poll_seconds)
backend/app/services/artifact_images.py CHANGED
@@ -23,7 +23,15 @@ from app.models.artifact import Artifact, ArtifactType
23
  log = logging.getLogger("services.artifact_images")
24
 
25
  _TIMEOUT = httpx.Timeout(8.0)
26
- _HEADERS = {"User-Agent": "Cachy/0.1 (catalog thumbnail lookup)"}
 
 
 
 
 
 
 
 
27
 
28
  # iTunes Search `entity` per artifact type (covers most media).
29
  _ITUNES_ENTITY: dict[ArtifactType, str] = {
@@ -43,21 +51,28 @@ def resolve_thumbnail(artifact: Artifact) -> str | None:
43
  _from_open_library(artifact)
44
  or _from_google_books(artifact)
45
  or _from_wikipedia(artifact)
 
46
  )
47
  if artifact.type == ArtifactType.MUSIC:
48
  return (
49
  _from_itunes(artifact)
50
  or _from_musicbrainz(artifact)
51
  or _from_wikipedia(artifact)
 
52
  )
53
  if artifact.type in _ITUNES_ENTITY:
54
  return (
55
  _from_itunes(artifact)
56
  or _from_wikidata(artifact)
57
  or _from_wikipedia(artifact)
 
58
  )
59
- # product / place / other
60
- return _from_wikidata(artifact) or _from_wikipedia(artifact)
 
 
 
 
61
  except Exception as e: # noqa: BLE001 — thumbnail lookup must never break a card
62
  log.info("thumbnail lookup failed for %r: %s", artifact.title, e)
63
  return None
@@ -70,20 +85,38 @@ def _query(artifact: Artifact) -> str:
70
  return " ".join(parts)
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  def _from_itunes(artifact: Artifact) -> str | None:
74
  entity = _ITUNES_ENTITY[artifact.type]
75
- params = {"term": _query(artifact), "entity": entity, "limit": 1}
76
- with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
77
- resp = client.get("https://itunes.apple.com/search", params=params)
78
- resp.raise_for_status()
79
- results = resp.json().get("results") or []
80
- if not results:
81
- return None
82
- art = results[0].get("artworkUrl100")
83
- if not art:
84
- return None
85
- # Upscale the 100px thumbnail iTunes returns to a crisper catalog cover.
86
- return art.replace("100x100bb", "400x400bb")
87
 
88
 
89
  def _from_open_library(artifact: Artifact) -> str | None:
@@ -106,17 +139,74 @@ def _from_open_library(artifact: Artifact) -> str | None:
106
 
107
 
108
  def _from_wikipedia(artifact: Artifact) -> str | None:
109
- title = quote(artifact.title.replace(" ", "_"))
110
- url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{title}"
111
- with httpx.Client(
112
- timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True
113
- ) as client:
114
- resp = client.get(url)
115
- if resp.status_code != 200:
116
- return None
117
- data = resp.json()
118
- thumb = data.get("thumbnail") or {}
119
- return thumb.get("source")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
 
122
  def _from_google_books(artifact: Artifact) -> str | None:
@@ -162,32 +252,54 @@ def _from_musicbrainz(artifact: Artifact) -> str | None:
162
 
163
 
164
  def _from_wikidata(artifact: Artifact) -> str | None:
165
- search_params = {
166
- "action": "wbsearchentities",
167
- "search": _query(artifact),
168
- "language": "en",
169
- "format": "json",
170
- "limit": 1,
171
- }
172
- with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
173
- resp = client.get("https://www.wikidata.org/w/api.php", params=search_params)
174
- resp.raise_for_status()
175
- results = resp.json().get("search") or []
 
 
 
 
176
  if not results:
177
  return None
178
- qid = results[0].get("id")
179
- if not qid:
180
- return None
181
  with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
182
- entity_resp = client.get(f"https://www.wikidata.org/wiki/Special:EntityData/{qid}.json")
183
- entity_resp.raise_for_status()
184
- entities = entity_resp.json().get("entities", {})
185
- claims = entities.get(qid, {}).get("claims", {})
186
- p18 = claims.get("P18", []) # P18 = image
187
- if not p18:
188
- return None
189
- filename = p18[0].get("mainsnak", {}).get("datavalue", {}).get("value")
190
- if not filename:
191
- return None
192
- encoded = filename.replace(" ", "_")
193
- return f"https://commons.wikimedia.org/wiki/Special:FilePath/{encoded}?width=400"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  log = logging.getLogger("services.artifact_images")
24
 
25
  _TIMEOUT = httpx.Timeout(8.0)
26
+ # Wikimedia policy requires a descriptive User-Agent with a contact URL/email.
27
+ # Without this, their API returns 403. See: https://meta.wikimedia.org/wiki/User-Agent_policy
28
+ _HEADERS = {
29
+ "User-Agent": (
30
+ "Cachy/1.0 (personal knowledge library; "
31
+ "https://github.com/Vatsal057/Cachy) httpx/python"
32
+ )
33
+ }
34
+
35
 
36
  # iTunes Search `entity` per artifact type (covers most media).
37
  _ITUNES_ENTITY: dict[ArtifactType, str] = {
 
51
  _from_open_library(artifact)
52
  or _from_google_books(artifact)
53
  or _from_wikipedia(artifact)
54
+ or _from_wikimedia_commons(artifact)
55
  )
56
  if artifact.type == ArtifactType.MUSIC:
57
  return (
58
  _from_itunes(artifact)
59
  or _from_musicbrainz(artifact)
60
  or _from_wikipedia(artifact)
61
+ or _from_wikimedia_commons(artifact)
62
  )
63
  if artifact.type in _ITUNES_ENTITY:
64
  return (
65
  _from_itunes(artifact)
66
  or _from_wikidata(artifact)
67
  or _from_wikipedia(artifact)
68
+ or _from_wikimedia_commons(artifact)
69
  )
70
+ # product / place / other / software / web
71
+ return (
72
+ _from_wikidata(artifact)
73
+ or _from_wikipedia(artifact)
74
+ or _from_wikimedia_commons(artifact)
75
+ )
76
  except Exception as e: # noqa: BLE001 — thumbnail lookup must never break a card
77
  log.info("thumbnail lookup failed for %r: %s", artifact.title, e)
78
  return None
 
85
  return " ".join(parts)
86
 
87
 
88
+ def _queries(artifact: Artifact) -> list[str]:
89
+ qs = []
90
+ full = _query(artifact)
91
+ qs.append(full)
92
+ if artifact.creator and artifact.title != full:
93
+ qs.append(artifact.title)
94
+ return qs
95
+
96
+
97
+ def _words_overlap(query: str, title: str) -> bool:
98
+ """Return True if at least one significant word (>3 chars) from the query
99
+ appears in the matched Wikipedia article title (case-insensitive).
100
+ Prevents clearly wrong fuzzy matches (e.g. 'SkyKit Learn' -> 'A Discovery of Witches').
101
+ """
102
+ q_words = {w.lower() for w in query.split() if len(w) > 3}
103
+ t_lower = title.lower()
104
+ return any(w in t_lower for w in q_words)
105
+
106
+
107
  def _from_itunes(artifact: Artifact) -> str | None:
108
  entity = _ITUNES_ENTITY[artifact.type]
109
+ for query in _queries(artifact):
110
+ params = {"term": query, "entity": entity, "limit": 1}
111
+ with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
112
+ resp = client.get("https://itunes.apple.com/search", params=params)
113
+ resp.raise_for_status()
114
+ results = resp.json().get("results") or []
115
+ if results:
116
+ art = results[0].get("artworkUrl100")
117
+ if art:
118
+ return art.replace("100x100bb", "400x400bb")
119
+ return None
 
120
 
121
 
122
  def _from_open_library(artifact: Artifact) -> str | None:
 
139
 
140
 
141
  def _from_wikipedia(artifact: Artifact) -> str | None:
142
+ """Two-step fuzzy lookup: search Wikipedia first (handles typos/alt names),
143
+ then fetch the summary of the best match to get its thumbnail."""
144
+ for query in _queries(artifact):
145
+ with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
146
+ search_resp = client.get(
147
+ "https://en.wikipedia.org/w/api.php",
148
+ params={
149
+ "action": "query",
150
+ "list": "search",
151
+ "srsearch": query,
152
+ "format": "json",
153
+ "srlimit": 1,
154
+ "srprop": "",
155
+ },
156
+ )
157
+ if search_resp.status_code != 200:
158
+ continue
159
+ hits = search_resp.json().get("query", {}).get("search", [])
160
+ if not hits:
161
+ continue
162
+ matched_title = hits[0]["title"]
163
+ if not _words_overlap(query, matched_title):
164
+ continue
165
+ page_title = quote(matched_title.replace(" ", "_"))
166
+ with httpx.Client(
167
+ timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True
168
+ ) as client:
169
+ summary_resp = client.get(
170
+ f"https://en.wikipedia.org/api/rest_v1/page/summary/{page_title}"
171
+ )
172
+ if summary_resp.status_code == 200:
173
+ thumb = summary_resp.json().get("thumbnail") or {}
174
+ source = thumb.get("source")
175
+ if source:
176
+ return source
177
+ return None
178
+
179
+
180
+ def _from_wikimedia_commons(artifact: Artifact) -> str | None:
181
+ """Search Wikimedia Commons directly for logos or icons."""
182
+ for query in _queries(artifact):
183
+ search_q = f"{query} logo"
184
+ with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
185
+ resp = client.get(
186
+ "https://commons.wikimedia.org/w/api.php",
187
+ params={
188
+ "action": "query",
189
+ "list": "search",
190
+ "srsearch": search_q,
191
+ "format": "json",
192
+ "srnamespace": 6,
193
+ "srlimit": 1,
194
+ },
195
+ )
196
+ if resp.status_code != 200:
197
+ continue
198
+ hits = resp.json().get("query", {}).get("search", [])
199
+ if not hits:
200
+ continue
201
+ matched_title = hits[0]["title"]
202
+ if not _words_overlap(query, matched_title):
203
+ continue
204
+ filename = matched_title.replace("File:", "").strip()
205
+ encoded = filename.replace(" ", "_")
206
+ return f"https://commons.wikimedia.org/wiki/Special:FilePath/{encoded}?width=400"
207
+ return None
208
+
209
+
210
 
211
 
212
  def _from_google_books(artifact: Artifact) -> str | None:
 
252
 
253
 
254
  def _from_wikidata(artifact: Artifact) -> str | None:
255
+ results = []
256
+ for query in _queries(artifact):
257
+ search_params = {
258
+ "action": "wbsearchentities",
259
+ "search": query,
260
+ "language": "en",
261
+ "format": "json",
262
+ "limit": 3,
263
+ }
264
+ with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
265
+ resp = client.get("https://www.wikidata.org/w/api.php", params=search_params)
266
+ resp.raise_for_status()
267
+ results = resp.json().get("search") or []
268
+ if results:
269
+ break
270
  if not results:
271
  return None
272
+
273
+ best_fallback = None
 
274
  with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
275
+ for hit in results[:3]:
276
+ qid = hit.get("id")
277
+ if not qid:
278
+ continue
279
+ try:
280
+ entity_resp = client.get(f"https://www.wikidata.org/wiki/Special:EntityData/{qid}.json")
281
+ entity_resp.raise_for_status()
282
+ entities = entity_resp.json().get("entities", {})
283
+ except Exception:
284
+ continue
285
+ claims = entities.get(qid, {}).get("claims", {})
286
+ for prop in ("P154", "P18"):
287
+ entries = claims.get(prop, [])
288
+ if entries:
289
+ filename = entries[0].get("mainsnak", {}).get("datavalue", {}).get("value")
290
+ if filename:
291
+ encoded = filename.replace(" ", "_")
292
+ url = f"https://commons.wikimedia.org/wiki/Special:FilePath/{encoded}?width=400"
293
+ if prop == "P154":
294
+ return url
295
+ if not best_fallback:
296
+ best_fallback = url
297
+ p856 = claims.get("P856", [])
298
+ if p856:
299
+ website_url = p856[0].get("mainsnak", {}).get("datavalue", {}).get("value")
300
+ if website_url:
301
+ from urllib.parse import urlparse
302
+ domain = urlparse(website_url).netloc
303
+ if domain:
304
+ return f"https://www.google.com/s2/favicons?domain={domain}&sz=256"
305
+ return best_fallback
backend/tests/conftest.py CHANGED
@@ -18,6 +18,8 @@ os.environ["MEDIA_DIR"] = f"{_TMP}/media"
18
  os.environ["LLM_BACKEND"] = "none"
19
  os.environ["HF_API_KEY"] = ""
20
  os.environ["GROQ_API_KEY"] = ""
 
 
21
  os.environ["WHISPER_BACKEND"] = "none"
22
  os.environ["MAX_ATTEMPTS"] = "2"
23
 
 
18
  os.environ["LLM_BACKEND"] = "none"
19
  os.environ["HF_API_KEY"] = ""
20
  os.environ["GROQ_API_KEY"] = ""
21
+ os.environ["CEREBRAS_API_KEY"] = ""
22
+ os.environ["GEMINI_API_KEY"] = ""
23
  os.environ["WHISPER_BACKEND"] = "none"
24
  os.environ["MAX_ATTEMPTS"] = "2"
25
 
backend/tests/test_pipeline.py CHANGED
@@ -5,6 +5,7 @@ import os
5
 
6
  import pytest
7
 
 
8
  from app.models.card import CardState
9
  from app.models.job import JobState
10
  from app.pipeline import worker
@@ -159,3 +160,45 @@ async def test_preserve_custom_collection(database, stub_pipeline):
159
  col = await s.get(db.CollectionRow, row.collection_id)
160
  assert col.name == "My Custom Folder"
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  import pytest
7
 
8
+ from app.models.artifact import Artifact, ArtifactType
9
  from app.models.card import CardState
10
  from app.models.job import JobState
11
  from app.pipeline import worker
 
160
  col = await s.get(db.CollectionRow, row.collection_id)
161
  assert col.name == "My Custom Folder"
162
 
163
+
164
+ async def test_persist_artifacts_sequential_and_rollback(database, monkeypatch):
165
+ """Verify that multiple artifacts resolve thumbnails in parallel but upsert
166
+ sequentially, and any individual failure triggers session rollback so the
167
+ session remains valid for subsequent operations."""
168
+ card_id, _ = await _make_card_and_job("https://instagram.com/reel/artifacts_test")
169
+ artifacts = [
170
+ Artifact(type=ArtifactType.BOOK, title=f"Book {i}") for i in range(5)
171
+ ]
172
+
173
+ def fake_thumb(art):
174
+ return f"https://img/{art.title}.jpg"
175
+
176
+ monkeypatch.setattr(worker.artifact_images, "resolve_thumbnail", fake_thumb)
177
+
178
+ orig_upsert = db.upsert_artifact
179
+
180
+ async def flaky_upsert(session, **kwargs):
181
+ if kwargs["title"] == "Book 2":
182
+ raise RuntimeError("simulated DB constraint failure")
183
+ return await orig_upsert(session, **kwargs)
184
+
185
+ monkeypatch.setattr(db, "upsert_artifact", flaky_upsert)
186
+
187
+ async with db.session() as s:
188
+ await worker._persist_artifacts(s, card_id, artifacts)
189
+ # Session must still be usable (not in PendingRollbackError state)
190
+ row = await db.get_card_row(s, card_id)
191
+ assert row is not None
192
+
193
+ async with db.session() as s:
194
+ from sqlalchemy import select
195
+ res = await s.execute(select(db.ArtifactRow).order_by(db.ArtifactRow.title))
196
+ rows = res.scalars().all()
197
+ titles = [r.title for r in rows]
198
+ assert "Book 0" in titles
199
+ assert "Book 1" in titles
200
+ assert "Book 2" not in titles
201
+ assert "Book 3" in titles
202
+ assert "Book 4" in titles
203
+
204
+