File size: 18,245 Bytes
7ea1851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78c88cd
7ea1851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78c88cd
 
 
 
 
 
 
 
 
19b362c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ea1851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78c88cd
 
 
 
 
 
 
 
19b362c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ea1851
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
#!/usr/bin/env python3
"""Command-line ingestion entrypoint (Phase 3a scaffold).

Exposes the existing ingestion operations as subcommands so they can run
OUTSIDE the web process. This is **purely additive**: it calls the existing
processing functions unchanged. It does NOT move heavy ML (torch/transformers/
deepface/whisper) off the web request path β€” that cutover is a separate,
runtime-verified step (cold-start RSS < 500 MB gate); see REFACTOR_PLAN
"Phase 3a" and the cutover plan there.

IMPORTANT β€” lazy imports: ``process_video``, ``search`` and ``search_images``
import torch/transformers at module top, so every heavy import here is done
*inside* the subcommand that needs it. Importing this module and running
``--help`` therefore stays ML-free (a test enforces this), which is the property
the eventual web/ingestion split depends on.

DB paths: resolved via the existing env-driven ``runtime_paths`` machinery, so
exporting the five ``SEARCH_UI_*_DB_PATH`` vars (the single-DB flip) makes this
CLI target the merged DB automatically β€” no DB flags needed.

Usage:
    python backend/cli.py ingest vod --language E [--redownload]
    python backend/cli.py ingest subtitles --language E
    python backend/cli.py ingest video --natural-key pub-..._VIDEO [--label 720p] [--keep-source]
    python backend/cli.py ingest video --all [--reprocess] [--label 720p]
    python backend/cli.py ingest images --source {publications|web} [--language E]
    python backend/cli.py process subtitles --language E [--keep-html]
    python backend/cli.py reindex embeddings [--language E] [--batch-size 50]
    python backend/cli.py reindex subtitles [--language E]
    python backend/cli.py reindex concepts [--language E] [--batch-size 50]

Run ingestion when the web process is idle (SQLite write locking).
"""

from __future__ import annotations

import argparse
import json
import sys
from types import SimpleNamespace
from typing import Any

# Top-level imports MUST stay ML-free (no process_video/search/search_images here).
from app_config import get_app_config
from runtime_paths import ensure_runtime_dirs


# Optional file path to also write the result dict to (set by --result-json).
# Gives callers (e.g. the web process shelling out to this CLI) a clean JSON
# artifact to read instead of parsing it back out of log-interleaved stdout.
_RESULT_FILE: str | None = None


def _set_result_file(path: str | None) -> None:
    """Set the file path _emit writes the result dict to (None disables it)."""
    global _RESULT_FILE
    _RESULT_FILE = path


def _emit(result: Any) -> int:
    """Print a result as JSON and return an exit code (1 on error status).

    When --result-json was given, also write the dict to that file so a caller
    can read the structured result without scraping stdout (which carries logs).
    """
    if isinstance(result, dict):
        print(json.dumps(result, indent=2, default=str))
        if _RESULT_FILE:
            with open(_RESULT_FILE, "w", encoding="utf-8") as handle:
                json.dump(result, handle, default=str)
        return 1 if result.get("status") == "error" else 0
    if result is not None:
        print(result)
    return 0


# --------------------------------------------------------------------------
# Subcommand handlers. Heavy/ML imports are function-local on purpose.
# --------------------------------------------------------------------------

def _cmd_ingest_vod(args: argparse.Namespace) -> int:
    import jw_api

    vod = jw_api.download_vod(args.language, redownload=args.redownload)
    if not vod:
        return _emit({"status": "error", "message": f"Failed to download VOD for {args.language!r}"})
    media = jw_api.combine_media_info(args.language)
    return _emit(
        {"status": "success", "command": "ingest vod", "language": args.language,
         "media_items": len(media or {})}
    )


def _cmd_ingest_subtitles(args: argparse.Namespace) -> int:
    import jw_api
    import subtitles_download

    media = jw_api.combine_media_info(args.language)
    if not media:
        return _emit({"status": "error", "message": "No catalog found; run `ingest vod` first."})
    result = subtitles_download.process_all_media_for_subtitles(media, args.language)
    return _emit({"status": "success", "command": "ingest subtitles", "language": args.language, **result})


def _cmd_process_subtitles(args: argparse.Namespace) -> int:
    from search import get_search_instance
    from processing_route_helpers import process_subtitles_sync

    cfg = get_app_config()
    runtime = SimpleNamespace(
        subtitles_root=cfg.subtitles_dir,
        json_root=cfg.json_dir,
        search_service=get_search_instance(),
    )
    try:
        result = process_subtitles_sync(runtime, language=args.language, remove_html=not args.keep_html)
    except Exception as exc:  # noqa: BLE001 β€” translate any error (incl. HTTPException) to a clean CLI message
        return _emit({"status": "error", "message": str(getattr(exc, "detail", exc))})
    return _emit({"status": "success", "command": "process subtitles", "language": args.language, **result})


def _cmd_ingest_video(args: argparse.Namespace) -> int:
    if not args.all and not args.natural_key:
        # validate before the (torch-pulling) import so an arg error stays cheap
        return _emit({"status": "error", "message": "--natural-key is required (or use --all)"})

    import process_video

    delete_after = False if args.keep_source else None
    if args.all:
        result = process_video.process_all_local_videos(
            label=args.label, reprocess=args.reprocess, delete_video_after=delete_after
        )
    else:
        result = process_video.process_video(args.natural_key, args.label, delete_video_after=delete_after)
    return _emit(result)


def _cmd_ingest_images(args: argparse.Namespace) -> int:
    if args.source == "web":
        import web_images_processor

        result = web_images_processor.crawl_web_images(language=args.language)
    else:
        import search_publication_images

        result = search_publication_images.index_all_publication_images()
    return _emit(result)


def _cmd_reindex_embeddings(args: argparse.Namespace) -> int:
    from search import get_search_instance

    result = get_search_instance().rebuild_subtitle_embeddings(
        language=args.language, batch_size=args.batch_size
    )
    return _emit(result)


def _cmd_reindex_subtitles(args: argparse.Namespace) -> int:
    from search import get_search_instance

    result = get_search_instance().rebuild_index(language=args.language)
    return _emit(result)


def _cmd_reindex_concepts(args: argparse.Namespace) -> int:
    from search import get_search_instance

    result = get_search_instance().rebuild_video_concepts(
        language=args.language, batch_size=args.batch_size
    )
    return _emit(result)


# Keep in sync with search_db._init_db's FTS5 CREATE statements (porter).
_FTS_REBUILD_SCHEMA = {
    "subtitles_fts": (
        "CREATE VIRTUAL TABLE subtitles_fts USING fts5("
        "natural_key, language, content, tokenize = 'porter unicode61')"
    ),
    "ad_transcriptions_fts": (
        "CREATE VIRTUAL TABLE ad_transcriptions_fts USING fts5("
        "natural_key, language, content, tokenize = 'porter unicode61')"
    ),
}


def _cmd_backfill_chunk_timestamps(args: argparse.Namespace) -> int:
    """Attach VTT-derived start/end to subtitle_chunks (Q2.2). No re-embed.

    Anchors each chunk's opening words to the cue stream (anchor-or-skip), so a
    chunk is timed only when it can be located β€” never a wrong guess. Run after
    the VTTs are present (``ingest subtitles``).
    """
    import os

    from chunk_timestamps import compute_chunk_timestamps
    from db_utils import connect_vec_db
    from runtime_paths import get_search_db_path, get_subtitles_dir
    from subtitles_process_vtt import parse_vtt

    lang = args.language
    subroot = get_subtitles_dir()
    conn = connect_vec_db(get_search_db_path())
    videos_timed = chunks_timed = videos_no_vtt = 0
    try:
        cols = {r[1] for r in conn.execute("PRAGMA table_info(subtitle_chunks)")}
        for col in ("start_seconds", "end_seconds"):
            if col not in cols:
                conn.execute(f"ALTER TABLE subtitle_chunks ADD COLUMN {col} REAL")
        conn.commit()

        keys = [
            r[0] for r in conn.execute(
                "SELECT DISTINCT natural_key FROM subtitle_chunks WHERE language = ?",
                (lang,),
            )
        ]
        for nk in keys:
            vtt_path = os.path.join(subroot, lang, f"{nk}.vtt")
            if not os.path.exists(vtt_path):
                videos_no_vtt += 1
                continue
            try:
                with open(vtt_path, "r", encoding="utf-8") as fh:
                    cues = parse_vtt(fh.read())
            except Exception:
                continue
            rows = conn.execute(
                "SELECT chunk_index, chunk_text FROM subtitle_chunks "
                "WHERE natural_key = ? AND language = ? ORDER BY chunk_index",
                (nk, lang),
            ).fetchall()
            timestamps = compute_chunk_timestamps(cues, [r[1] for r in rows])
            timed_here = False
            for (cidx, _text), ts in zip(rows, timestamps):
                if ts is not None:
                    conn.execute(
                        "UPDATE subtitle_chunks SET start_seconds = ?, end_seconds = ? "
                        "WHERE natural_key = ? AND language = ? AND chunk_index = ?",
                        (ts[0], ts[1], nk, lang, cidx),
                    )
                    chunks_timed += 1
                    timed_here = True
            if timed_here:
                videos_timed += 1
        conn.commit()
    finally:
        conn.close()
    return _emit({
        "status": "success",
        "command": "backfill chunk-timestamps",
        "language": lang,
        "videos_timed": videos_timed,
        "chunks_timed": chunks_timed,
        "videos_without_vtt": videos_no_vtt,
    })


def _cmd_reindex_fts(args: argparse.Namespace) -> int:
    """Rebuild the keyword FTS tables in place with the CURRENT schema.

    FTS5 cannot ALTER its tokenizer, so switching to porter stemming needs a
    drop + recreate + repopulate. Content is read back from the existing FTS
    (no re-download, no embeddings touched), so this is fast. ML-free.
    """
    from db_utils import connect_vec_db
    from runtime_paths import get_search_db_path

    conn = connect_vec_db(get_search_db_path())
    rebuilt = {}
    try:
        for table, create_sql in _FTS_REBUILD_SCHEMA.items():
            try:
                rows = conn.execute(
                    f"SELECT natural_key, language, content FROM {table}"
                ).fetchall()
            except Exception:
                continue  # table absent in this DB
            conn.execute(f"DROP TABLE {table}")
            conn.execute(create_sql)
            conn.executemany(
                f"INSERT INTO {table}(natural_key, language, content) VALUES (?, ?, ?)",
                rows,
            )
            conn.commit()
            rebuilt[table] = len(rows)
    finally:
        conn.close()
    return _emit({"status": "success", "command": "reindex fts", "rebuilt": rebuilt})


def _cmd_check_integrity(args: argparse.Namespace) -> int:
    """Report cross-table row-count drift in the search DB (ML-free).

    Surfaces the kind of silent drift the audit found (e.g. embedding rows
    without a matching chunk). Counting vec0 tables needs sqlite-vec loaded but
    no torch, so this stays a lightweight health check.
    """
    from db_utils import connect_vec_db
    from runtime_paths import get_search_db_path

    conn = connect_vec_db(get_search_db_path())
    issues: list[str] = []

    def _count(table: str) -> int | None:
        try:
            return int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
        except Exception:
            return None

    try:
        chunks = _count("subtitle_chunks")
        chunk_embeddings = _count("subtitle_chunk_embeddings")
        fts_docs = _count("subtitles_fts")
        concepts = _count("video_concepts")
        concept_embeddings = _count("video_concept_embeddings")
        legacy = _count("subtitle_embeddings")

        if chunks is not None and chunk_embeddings is not None and chunks != chunk_embeddings:
            issues.append(
                f"subtitle_chunks ({chunks}) != subtitle_chunk_embeddings "
                f"({chunk_embeddings}) β€” {abs(chunks - chunk_embeddings)} orphaned rows"
            )
        if concepts is not None and concept_embeddings is not None and concepts != concept_embeddings:
            issues.append(
                f"video_concepts ({concepts}) != video_concept_embeddings "
                f"({concept_embeddings})"
            )
        if legacy:
            issues.append(
                f"legacy mxbai subtitle_embeddings table still present ({legacy} rows) β€” "
                "retired; drop via migration 0002 when convenient"
            )
    finally:
        conn.close()

    return _emit({
        "ok": not issues,
        "counts": {
            "subtitle_chunks": chunks,
            "subtitle_chunk_embeddings": chunk_embeddings,
            "subtitles_fts": fts_docs,
            "video_concepts": concepts,
            "video_concept_embeddings": concept_embeddings,
            "legacy_subtitle_embeddings": legacy,
        },
        "issues": issues,
    })


def build_parser() -> argparse.ArgumentParser:
    """Build the CLI parser. Importing/parsing pulls in no ML."""
    parser = argparse.ArgumentParser(prog="jws", description=__doc__.split("\n", 1)[0])
    parser.add_argument(
        "--result-json",
        dest="result_json",
        default=None,
        metavar="PATH",
        help="Also write the result dict as JSON to PATH (clean IPC for callers).",
    )
    top = parser.add_subparsers(dest="command", required=True)

    # ingest <noun>
    ingest = top.add_parser("ingest", help="Download/process content into the indexes.")
    ingest_sub = ingest.add_subparsers(dest="noun", required=True)

    p = ingest_sub.add_parser("vod", help="Download VOD catalog metadata for a language.")
    p.add_argument("--language", default="E")
    p.add_argument("--redownload", action="store_true")
    p.set_defaults(func=_cmd_ingest_vod)

    p = ingest_sub.add_parser("subtitles", help="Download VTT subtitles for the catalog.")
    p.add_argument("--language", default="E")
    p.set_defaults(func=_cmd_ingest_subtitles)

    p = ingest_sub.add_parser("video", help="Process local video(s): thumbnails + ML indexing.")
    p.add_argument("--natural-key", dest="natural_key", default=None)
    p.add_argument("--label", default="720p")
    p.add_argument("--all", action="store_true", help="Process all local videos.")
    p.add_argument("--reprocess", action="store_true", help="With --all, reprocess already-done videos.")
    p.add_argument("--keep-source", action="store_true", help="Do not delete the source MP4 after processing.")
    p.set_defaults(func=_cmd_ingest_video)

    p = ingest_sub.add_parser("images", help="Index images (publication or crawled web images).")
    p.add_argument("--source", choices=["publications", "web"], default="publications")
    p.add_argument("--language", default="E")
    p.set_defaults(func=_cmd_ingest_images)

    # process <noun>
    process = top.add_parser("process", help="Transform already-downloaded content.")
    process_sub = process.add_subparsers(dest="noun", required=True)

    p = process_sub.add_parser("subtitles", help="Convert downloaded VTT -> text and index it.")
    p.add_argument("--language", default="E")
    p.add_argument("--keep-html", action="store_true", help="Do not strip HTML tags from subtitle text.")
    p.set_defaults(func=_cmd_process_subtitles)

    # reindex <noun>
    reindex = top.add_parser("reindex", help="Rebuild search indexes from stored content.")
    reindex_sub = reindex.add_subparsers(dest="noun", required=True)

    p = reindex_sub.add_parser("embeddings", help="Rebuild subtitle chunk embeddings.")
    p.add_argument("--language", default=None)
    p.add_argument("--batch-size", dest="batch_size", type=int, default=50)
    p.set_defaults(func=_cmd_reindex_embeddings)

    p = reindex_sub.add_parser("subtitles", help="Rebuild the subtitle FTS/index.")
    p.add_argument("--language", default=None)
    p.set_defaults(func=_cmd_reindex_subtitles)

    p = reindex_sub.add_parser(
        "concepts",
        help="Rebuild the per-video concept layer (blends VLM scene summaries).",
    )
    p.add_argument("--language", default=None)
    p.add_argument("--batch-size", dest="batch_size", type=int, default=50)
    p.set_defaults(func=_cmd_reindex_concepts)

    p = reindex_sub.add_parser(
        "fts",
        help="Rebuild the keyword FTS tables with the current tokenizer (porter).",
    )
    p.set_defaults(func=_cmd_reindex_fts)

    # backfill <noun>
    backfill = top.add_parser("backfill", help="Backfill derived columns on existing data.")
    backfill_sub = backfill.add_subparsers(dest="noun", required=True)
    p = backfill_sub.add_parser(
        "chunk-timestamps",
        help="Attach VTT start/end times to subtitle_chunks (Q2.2, no re-embed).",
    )
    p.add_argument("--language", default="E")
    p.set_defaults(func=_cmd_backfill_chunk_timestamps)

    # check <noun>
    check = top.add_parser("check", help="Health/integrity checks on the indexes.")
    check_sub = check.add_subparsers(dest="noun", required=True)
    p = check_sub.add_parser("integrity", help="Report cross-table row-count drift.")
    p.set_defaults(func=_cmd_check_integrity)

    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    _set_result_file(getattr(args, "result_json", None))
    # Resolve runtime dirs once (env-driven; no ML, no service construction).
    ensure_runtime_dirs(get_app_config())
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())