Hermes Agent commited on
Commit
ec8cd47
·
1 Parent(s): 5ff93ff

feat(ai): add Omniroute AI candidate disambiguation with automated hardcoded fallback

Browse files
Files changed (2) hide show
  1. apex/tests/test_tmdb_meta.py +115 -1
  2. apex/tg_streamer.py +159 -6
apex/tests/test_tmdb_meta.py CHANGED
@@ -568,4 +568,118 @@ class TestNfoRepair:
568
  ]
569
  match = best_tmdb_match(candidates, "obsession", "movie", year=2025)
570
  assert match is not None
571
- assert match["id"] == 1339713
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  ]
569
  match = best_tmdb_match(candidates, "obsession", "movie", year=2025)
570
  assert match is not None
571
+ assert match["id"] == 1339713
572
+
573
+ @pytest.mark.asyncio
574
+ async def test_ai_disambiguate_candidates_no_credentials(self, monkeypatch):
575
+ import sys, types
576
+ from unittest.mock import MagicMock
577
+ pg = sys.modules.setdefault("pyrogram", types.ModuleType("pyrogram"))
578
+ pg.__path__ = []
579
+ pg.Client = MagicMock()
580
+ pg.filters = MagicMock()
581
+ for sub in ("raw", "raw.functions", "raw.types", "enums", "types"):
582
+ mod = types.ModuleType(f"pyrogram.{sub}")
583
+ mod.ChatType = MagicMock()
584
+ mod.Message = MagicMock()
585
+ sys.modules[f"pyrogram.{sub}"] = mod
586
+ err_mod = types.ModuleType("pyrogram.errors")
587
+ err_mod.FloodWait = type("FloodWait", (Exception,), {})
588
+ err_mod.RPCError = type("RPCError", (Exception,), {})
589
+ err_mod.AuthKeyDuplicated = type("AuthKeyDuplicated", (Exception,), {})
590
+ sys.modules["pyrogram.errors"] = err_mod
591
+
592
+ import tg_streamer
593
+ monkeypatch.setattr(tg_streamer, "OMNIROUTE_URL", "")
594
+ monkeypatch.setattr(tg_streamer, "OMNIROUTE_KEY", "")
595
+
596
+ candidates = [{"id": 1, "title": "A"}, {"id": 2, "title": "B"}]
597
+ res = await tg_streamer._ai_disambiguate_candidates("Some Movie", candidates)
598
+ assert res is None
599
+
600
+ @pytest.mark.asyncio
601
+ async def test_ai_disambiguate_candidates_success_and_fallback(self, monkeypatch):
602
+ import sys, types
603
+ from unittest.mock import MagicMock, AsyncMock
604
+ pg = sys.modules.setdefault("pyrogram", types.ModuleType("pyrogram"))
605
+ pg.__path__ = []
606
+ pg.Client = MagicMock()
607
+ pg.filters = MagicMock()
608
+ for sub in ("raw", "raw.functions", "raw.types", "enums", "types"):
609
+ mod = types.ModuleType(f"pyrogram.{sub}")
610
+ mod.ChatType = MagicMock()
611
+ mod.Message = MagicMock()
612
+ sys.modules[f"pyrogram.{sub}"] = mod
613
+ err_mod = types.ModuleType("pyrogram.errors")
614
+ err_mod.FloodWait = type("FloodWait", (Exception,), {})
615
+ err_mod.RPCError = type("RPCError", (Exception,), {})
616
+ err_mod.AuthKeyDuplicated = type("AuthKeyDuplicated", (Exception,), {})
617
+ sys.modules["pyrogram.errors"] = err_mod
618
+
619
+ import tg_streamer
620
+ monkeypatch.setattr(tg_streamer, "OMNIROUTE_URL", "https://api.omniroute.ai/v1")
621
+ monkeypatch.setattr(tg_streamer, "OMNIROUTE_KEY", "test_key")
622
+ tg_streamer._AI_DISAMBIGUATE_CACHE.clear()
623
+
624
+ candidates = [
625
+ {"id": 100, "title": "Patriot", "release_date": "2000-06-28", "original_language": "en"},
626
+ {"id": 200, "title": "Patriot", "release_date": "2026-01-15", "original_language": "ml"},
627
+ ]
628
+
629
+ class FakeResponse:
630
+ def __init__(self, status, json_data):
631
+ self.status = status
632
+ self._json_data = json_data
633
+
634
+ async def json(self):
635
+ return self._json_data
636
+
637
+ async def text(self):
638
+ return ""
639
+
640
+ async def __aenter__(self):
641
+ return self
642
+
643
+ async def __aexit__(self, *args):
644
+ pass
645
+
646
+ class FakeClientSession:
647
+ def __init__(self, *args, **kwargs):
648
+ pass
649
+
650
+ def post(self, url, **kwargs):
651
+ return FakeResponse(200, {
652
+ "choices": [{
653
+ "message": {
654
+ "content": '```json\n{"tmdb_id": 200, "reason": "2026 Malayalam release"}\n```'
655
+ }
656
+ }]
657
+ })
658
+
659
+ async def __aenter__(self):
660
+ return self
661
+
662
+ async def __aexit__(self, *args):
663
+ pass
664
+
665
+ monkeypatch.setattr("aiohttp.ClientSession", FakeClientSession)
666
+
667
+ # 1. Success case: returns matched candidate
668
+ res = await tg_streamer._ai_disambiguate_candidates("Patriot 2026 Malayalam", candidates)
669
+ assert res is not None
670
+ assert res["id"] == 200
671
+
672
+ # 2. Cached case
673
+ cached_res = await tg_streamer._ai_disambiguate_candidates("Patriot 2026 Malayalam", candidates)
674
+ assert cached_res is not None
675
+ assert cached_res["id"] == 200
676
+
677
+ # 3. Error case: 500 status code fails safe to None
678
+ class FailingClientSession(FakeClientSession):
679
+ def post(self, url, **kwargs):
680
+ return FakeResponse(500, {})
681
+
682
+ monkeypatch.setattr("aiohttp.ClientSession", FailingClientSession)
683
+ fail_res = await tg_streamer._ai_disambiguate_candidates("Other Movie 2026", candidates)
684
+ assert fail_res is None
685
+
apex/tg_streamer.py CHANGED
@@ -117,8 +117,9 @@ AI_TITLE_MODEL = get_env("AI_TITLE_MODEL", default="auto/best-fast")
117
  # Reasoning models consume tokens on reasoning_content before producing the
118
  # answer; keep the budget large enough that a JSON reply still fits.
119
  AI_TITLE_MAX_TOKENS = int(get_env("AI_TITLE_MAX_TOKENS", default="1024"))
120
- # Small in-memory cache so restart/idempotency re-checks don't re-call the LLM.
121
  _AI_TITLE_CACHE = {}
 
 
122
 
123
  # Public URL Telegram must deliver bot updates to (through the Apex gateway).
124
  PUBLIC_BASE_URL = get_env(
@@ -1203,6 +1204,128 @@ async def _ai_extract_title(filename):
1203
  return None, None
1204
 
1205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1206
  async def _download_image(session, url, save_path):
1207
  """Download an image (e.g. TMDB poster/backdrop) to save_path."""
1208
  try:
@@ -1354,7 +1477,13 @@ async def _enrich_media_metadata(entry):
1354
  results = await _tmdb_search(media_type, cand, entry.get("year"), language="en-US")
1355
  except Exception as _exc:
1356
  results = []
1357
- hit = best_tmdb_match(results, cand, media_type, entry.get("year"), language=lang_code)
 
 
 
 
 
 
1358
  used_query = cand
1359
  if hit:
1360
  break
@@ -1367,7 +1496,13 @@ async def _enrich_media_metadata(entry):
1367
  results = await _tmdb_search(media_type, cand, entry.get("year"), language=lang_code)
1368
  except Exception as _exc:
1369
  results = []
1370
- hit = best_tmdb_match(results, cand, media_type, entry.get("year"), language=lang_code)
 
 
 
 
 
 
1371
  used_query = cand
1372
  if hit:
1373
  logger.info(
@@ -1408,14 +1543,26 @@ async def _enrich_media_metadata(entry):
1408
  results = await _tmdb_search(media_type, ai_title, year_hint, language="en-US")
1409
  except Exception as _exc:
1410
  results = []
1411
- hit = best_tmdb_match(results, ai_title, media_type, year_hint, language=lang_code)
 
 
 
 
 
 
1412
 
1413
  if not hit and lang_code and lang_code != "en":
1414
  try:
1415
  results = await _tmdb_search(media_type, ai_title, year_hint, language=lang_code)
1416
  except Exception as _exc:
1417
  results = []
1418
- hit = best_tmdb_match(results, ai_title, media_type, year_hint, language=lang_code)
 
 
 
 
 
 
1419
 
1420
  if not hit and year_hint:
1421
  for delta in (-1, 1):
@@ -1424,7 +1571,13 @@ async def _enrich_media_metadata(entry):
1424
  results = await _tmdb_search(media_type, ai_title, adj_year, language="en-US")
1425
  except Exception:
1426
  results = []
1427
- hit = best_tmdb_match(results, ai_title, media_type, adj_year, language=lang_code)
 
 
 
 
 
 
1428
  if hit:
1429
  break
1430
 
 
117
  # Reasoning models consume tokens on reasoning_content before producing the
118
  # answer; keep the budget large enough that a JSON reply still fits.
119
  AI_TITLE_MAX_TOKENS = int(get_env("AI_TITLE_MAX_TOKENS", default="1024"))
 
120
  _AI_TITLE_CACHE = {}
121
+ _AI_DISAMBIGUATE_CACHE = {}
122
+
123
 
124
  # Public URL Telegram must deliver bot updates to (through the Apex gateway).
125
  PUBLIC_BASE_URL = get_env(
 
1204
  return None, None
1205
 
1206
 
1207
+ _AI_DISAMBIGUATE_SYSTEM = (
1208
+ "You are an expert film and television media disambiguator. Given a raw media release filename or "
1209
+ "query context, and a JSON list of candidate TMDB search results, determine the exact TMDB candidate ID "
1210
+ "that matches the media.\n"
1211
+ "Disambiguation criteria:\n"
1212
+ "1. Match against release keywords, language (e.g. Malayalam, Tamil, Telugu, Hindi, Kannada, English), "
1213
+ "director, cast, and release year (allow ±1 year difference for festival premieres vs theatrical releases, "
1214
+ "e.g. 2025 festival vs 2026 general release).\n"
1215
+ "2. NEVER match a modern Indian or regional film to an unrelated classic or English/Hollywood film "
1216
+ "(e.g., 'Patriot 2026 Malayalam' matches 2026 Malayalam 'Patriot', NEVER the 2000 English movie 'The Patriot').\n"
1217
+ "3. Prefer popular, officially distributed titles with posters/overviews over zero-vote placeholders.\n"
1218
+ "4. If no candidate in the list is a genuine match, return null.\n"
1219
+ "Return strict JSON only in this format: {\"tmdb_id\": <integer id or null>, \"reason\": \"<short explanation>\"}. "
1220
+ "No markdown fences, no explanations outside JSON."
1221
+ )
1222
+
1223
+
1224
+ async def _ai_disambiguate_candidates(raw_context, candidates, media_type="movie", year=None, language=None):
1225
+ """Use Omniroute LLM proxy to disambiguate multiple TMDB candidates.
1226
+
1227
+ Returns the matching candidate dictionary from candidates, or None if no match / failure.
1228
+ Never raises; automatically fails safe to None so caller immediately falls back
1229
+ to deterministic best_tmdb_match.
1230
+ """
1231
+ if not (OMNIROUTE_URL and OMNIROUTE_KEY) or not candidates or len(candidates) <= 1:
1232
+ return None
1233
+
1234
+ cache_key = f"{raw_context}:{media_type}:{year}:{language}:{len(candidates)}"
1235
+ if cache_key in _AI_DISAMBIGUATE_CACHE:
1236
+ cached_id = _AI_DISAMBIGUATE_CACHE[cache_key]
1237
+ if cached_id is None:
1238
+ return None
1239
+ for c in candidates:
1240
+ if c.get("id") == cached_id:
1241
+ return c
1242
+ return None
1243
+
1244
+ summaries = []
1245
+ for c in candidates[:8]:
1246
+ date = (c.get("release_date") or c.get("first_air_date") or "")[:4]
1247
+ overview = (c.get("overview") or "").strip()
1248
+ if len(overview) > 160:
1249
+ overview = overview[:157] + "..."
1250
+ summaries.append({
1251
+ "id": c.get("id"),
1252
+ "title": c.get("title") or c.get("name"),
1253
+ "original_title": c.get("original_title") or c.get("original_name"),
1254
+ "year": date,
1255
+ "original_language": c.get("original_language"),
1256
+ "popularity": round(float(c.get("popularity") or 0), 1),
1257
+ "overview": overview,
1258
+ })
1259
+
1260
+ user_prompt = json.dumps({
1261
+ "release_context": raw_context,
1262
+ "media_type": media_type,
1263
+ "year_hint": year,
1264
+ "language_hint": language,
1265
+ "candidates": summaries,
1266
+ }, ensure_ascii=False)
1267
+
1268
+ connector = aiohttp.TCPConnector(family=socket.AF_INET)
1269
+ payload = {
1270
+ "model": AI_TITLE_MODEL,
1271
+ "temperature": 0,
1272
+ "max_tokens": 256,
1273
+ "messages": [
1274
+ {"role": "system", "content": _AI_DISAMBIGUATE_SYSTEM},
1275
+ {"role": "user", "content": user_prompt},
1276
+ ],
1277
+ }
1278
+ headers = {
1279
+ "Authorization": f"Bearer {OMNIROUTE_KEY}",
1280
+ "Content-Type": "application/json",
1281
+ }
1282
+ try:
1283
+ async with aiohttp.ClientSession(connector=connector) as session:
1284
+ async with session.post(
1285
+ f"{OMNIROUTE_URL}/chat/completions",
1286
+ json=payload,
1287
+ headers=headers,
1288
+ timeout=12,
1289
+ ) as resp:
1290
+ if resp.status != 200:
1291
+ body = (await resp.text())[:160] or ""
1292
+ logger.warning(f"[AI-DISAMBIGUATE] proxy {resp.status} for '{raw_context}': {body}")
1293
+ _AI_DISAMBIGUATE_CACHE[cache_key] = None
1294
+ return None
1295
+ data = await resp.json()
1296
+
1297
+ content = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
1298
+ stripped = content.strip()
1299
+ if stripped.startswith("```"):
1300
+ stripped = re.sub(r"^```[a-z]*\s*", "", stripped)
1301
+ stripped = re.sub(r"\s*```$", "", stripped)
1302
+ try:
1303
+ parsed = json.loads(stripped)
1304
+ except Exception:
1305
+ m = re.search(r"\{.*\}", stripped, re.DOTALL)
1306
+ parsed = json.loads(m.group(0)) if m else {}
1307
+
1308
+ chosen_id = parsed.get("tmdb_id")
1309
+ if chosen_id is not None:
1310
+ try:
1311
+ chosen_id = int(chosen_id)
1312
+ for cand in candidates:
1313
+ if cand.get("id") == chosen_id:
1314
+ _AI_DISAMBIGUATE_CACHE[cache_key] = chosen_id
1315
+ logger.info(
1316
+ f"[AI-DISAMBIGUATE] '{raw_context}' -> TMDB ID {chosen_id} "
1317
+ f"('{cand.get('title') or cand.get('name')}', reason: {parsed.get('reason', 'matched')})"
1318
+ )
1319
+ return cand
1320
+ except (ValueError, TypeError):
1321
+ pass
1322
+ except Exception as e:
1323
+ logger.warning(f"[AI-DISAMBIGUATE] call notice for '{raw_context}': {e}")
1324
+
1325
+ _AI_DISAMBIGUATE_CACHE[cache_key] = None
1326
+ return None
1327
+
1328
+
1329
  async def _download_image(session, url, save_path):
1330
  """Download an image (e.g. TMDB poster/backdrop) to save_path."""
1331
  try:
 
1477
  results = await _tmdb_search(media_type, cand, entry.get("year"), language="en-US")
1478
  except Exception as _exc:
1479
  results = []
1480
+ ai_hit = None
1481
+ if len(results) > 1:
1482
+ raw_context = entry.get("file_name") or entry.get("raw_name") or entry.get("caption") or entry.get("title") or cand
1483
+ ai_hit = await _ai_disambiguate_candidates(
1484
+ raw_context, results, media_type=media_type, year=entry.get("year"), language=lang_code
1485
+ )
1486
+ hit = ai_hit or best_tmdb_match(results, cand, media_type, entry.get("year"), language=lang_code)
1487
  used_query = cand
1488
  if hit:
1489
  break
 
1496
  results = await _tmdb_search(media_type, cand, entry.get("year"), language=lang_code)
1497
  except Exception as _exc:
1498
  results = []
1499
+ ai_hit = None
1500
+ if len(results) > 1:
1501
+ raw_context = entry.get("file_name") or entry.get("raw_name") or entry.get("caption") or entry.get("title") or cand
1502
+ ai_hit = await _ai_disambiguate_candidates(
1503
+ raw_context, results, media_type=media_type, year=entry.get("year"), language=lang_code
1504
+ )
1505
+ hit = ai_hit or best_tmdb_match(results, cand, media_type, entry.get("year"), language=lang_code)
1506
  used_query = cand
1507
  if hit:
1508
  logger.info(
 
1543
  results = await _tmdb_search(media_type, ai_title, year_hint, language="en-US")
1544
  except Exception as _exc:
1545
  results = []
1546
+ ai_hit = None
1547
+ if len(results) > 1:
1548
+ raw_context = entry.get("file_name") or entry.get("raw_name") or entry.get("caption") or ai_title
1549
+ ai_hit = await _ai_disambiguate_candidates(
1550
+ raw_context, results, media_type=media_type, year=year_hint, language=lang_code
1551
+ )
1552
+ hit = ai_hit or best_tmdb_match(results, ai_title, media_type, year_hint, language=lang_code)
1553
 
1554
  if not hit and lang_code and lang_code != "en":
1555
  try:
1556
  results = await _tmdb_search(media_type, ai_title, year_hint, language=lang_code)
1557
  except Exception as _exc:
1558
  results = []
1559
+ ai_hit = None
1560
+ if len(results) > 1:
1561
+ raw_context = entry.get("file_name") or entry.get("raw_name") or entry.get("caption") or ai_title
1562
+ ai_hit = await _ai_disambiguate_candidates(
1563
+ raw_context, results, media_type=media_type, year=year_hint, language=lang_code
1564
+ )
1565
+ hit = ai_hit or best_tmdb_match(results, ai_title, media_type, year_hint, language=lang_code)
1566
 
1567
  if not hit and year_hint:
1568
  for delta in (-1, 1):
 
1571
  results = await _tmdb_search(media_type, ai_title, adj_year, language="en-US")
1572
  except Exception:
1573
  results = []
1574
+ ai_hit = None
1575
+ if len(results) > 1:
1576
+ raw_context = entry.get("file_name") or entry.get("raw_name") or entry.get("caption") or ai_title
1577
+ ai_hit = await _ai_disambiguate_candidates(
1578
+ raw_context, results, media_type=media_type, year=adj_year, language=lang_code
1579
+ )
1580
+ hit = ai_hit or best_tmdb_match(results, ai_title, media_type, adj_year, language=lang_code)
1581
  if hit:
1582
  break
1583