splash screen
Browse files- app/android/app/src/main/res/drawable-hdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-hdpi/splash.png +0 -0
- app/android/app/src/main/res/drawable-mdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-mdpi/splash.png +0 -0
- app/android/app/src/main/res/drawable-night-hdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-night-mdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-night-v21/background.png +0 -0
- app/android/app/src/main/res/drawable-night-v21/launch_background.xml +9 -0
- app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-night/background.png +0 -0
- app/android/app/src/main/res/drawable-night/launch_background.xml +9 -0
- app/android/app/src/main/res/drawable-v21/background.png +0 -0
- app/android/app/src/main/res/drawable-xhdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-xhdpi/splash.png +0 -0
- app/android/app/src/main/res/drawable-xxhdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-xxhdpi/splash.png +0 -0
- app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png +0 -0
- app/android/app/src/main/res/drawable-xxxhdpi/splash.png +0 -0
- app/android/app/src/main/res/drawable/background.png +0 -0
- app/android/app/src/main/res/values-night-v31/styles.xml +1 -1
- app/android/app/src/main/res/values-v31/styles.xml +1 -1
- app/assets/brand/splash_logo.png +0 -0
- app/lib/ui/features/graph/views/graph_screen.dart +4 -4
- app/pubspec.yaml +2 -2
- app/web/splash/img/dark-1x.png +0 -0
- app/web/splash/img/dark-2x.png +0 -0
- app/web/splash/img/dark-3x.png +0 -0
- app/web/splash/img/dark-4x.png +0 -0
- app/web/splash/img/light-1x.png +0 -0
- app/web/splash/img/light-2x.png +0 -0
- app/web/splash/img/light-3x.png +0 -0
- app/web/splash/img/light-4x.png +0 -0
- backend/app/config.py +8 -0
- backend/app/logging_config.py +115 -0
- backend/app/main.py +5 -1
- backend/app/pipeline/extraction.py +187 -54
- backend/app/pipeline/structuring.py +25 -11
- backend/app/pipeline/worker.py +38 -20
- backend/app/services/artifact_images.py +164 -52
- backend/tests/conftest.py +2 -0
- 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">#
|
| 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">#
|
| 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 ?
|
| 1425 |
} else if (e.kind == 'tag') {
|
| 1426 |
-
sw = (highlight ?
|
| 1427 |
} else if (e.kind == 'membership') {
|
| 1428 |
-
sw = (highlight ?
|
| 1429 |
} else {
|
| 1430 |
-
sw = (highlight ?
|
| 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/
|
| 111 |
android_12:
|
| 112 |
color: "#F5F0E8"
|
| 113 |
color_dark: "#181818"
|
| 114 |
-
image: "assets/brand/
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
["ffmpeg", "-y", "-i", video_path,
|
| 81 |
"-vf", "select='gt(scene,0.3)',showinfo", "-vsync", "vfr",
|
| 82 |
"-frames:v", "30", out_pattern],
|
| 83 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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),
|
| 170 |
model=settings.groq_whisper_model,
|
| 171 |
)
|
| 172 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 189 |
return _transcribe_local(audio_path)
|
| 190 |
|
| 191 |
|
|
@@ -247,44 +281,68 @@ def _ocr(frames: list[str]) -> str:
|
|
| 247 |
# --------------------------------------------------------------------------- #
|
| 248 |
|
| 249 |
_VISION_PROMPT = (
|
| 250 |
-
"
|
| 251 |
-
"
|
| 252 |
-
"
|
| 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:
|
| 261 |
settings = get_settings()
|
| 262 |
if not frames or not settings.gemini_vision_enabled:
|
| 263 |
return ""
|
| 264 |
try:
|
| 265 |
-
import
|
|
|
|
|
|
|
| 266 |
from PIL import Image
|
| 267 |
except Exception:
|
| 268 |
return ""
|
| 269 |
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
try:
|
| 275 |
with Image.open(path) as img:
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
if text and text.lower() not in _VISION_NO_TEXT:
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
|
| 286 |
-
def _vision_read_groq(frames: list[str]) -> str:
|
| 287 |
-
"""Groq VLM:
|
| 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 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 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 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 frames — 1 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 |
-
-
|
| 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
|
| 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 |
-
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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).
|
| 162 |
-
|
| 163 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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,
|
| 439 |
)
|
| 440 |
-
events.publish(
|
| 441 |
FailureReason.TIMEOUT.value)
|
| 442 |
except Exception as e: # noqa: BLE001 — pipeline must never crash the loop
|
| 443 |
-
|
| 444 |
-
|
|
|
|
| 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 parallel — each 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 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 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
timeout=_TIMEOUT, headers=_HEADERS
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
if not results:
|
| 177 |
return None
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
return None
|
| 181 |
with httpx.Client(timeout=_TIMEOUT, headers=_HEADERS) as client:
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|