Spaces:
Running
Running
Update rag.py
Browse files
rag.py
CHANGED
|
@@ -34,10 +34,32 @@ USER_AGENT = os.getenv(
|
|
| 34 |
"IndoHoaxDetector-EvidenceBot/1.0 (+kontak: isi-email-anda@domain.com)",
|
| 35 |
)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
_rag_ready: bool = False
|
| 39 |
_last_fetch_ok: Optional[bool] = None
|
| 40 |
_last_fetch_error: Optional[str] = None
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
# Kata pertama pada slug URL artikel (mis. /articles/123-salah-...) menentukan verdict.
|
|
@@ -291,27 +313,92 @@ def start_rag_background_update(
|
|
| 291 |
threading.Thread(target=_loop, daemon=True, name="rag-updater").start()
|
| 292 |
|
| 293 |
|
| 294 |
-
def
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
return []
|
| 302 |
-
top_k = top_k or RAG_TOP_K
|
| 303 |
-
threshold = RAG_SIM_THRESHOLD if threshold is None else threshold
|
| 304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
with _store.lock:
|
| 306 |
if _store.embeddings is None or not _store.metadata:
|
| 307 |
return []
|
| 308 |
-
|
| 309 |
-
q_emb = embed_fn([query_text])[0]
|
| 310 |
-
except Exception as e:
|
| 311 |
-
print(f"[WARN][RAG] Gagal encode query: {e}")
|
| 312 |
-
return []
|
| 313 |
-
|
| 314 |
-
sims = _store.embeddings @ np.asarray(q_emb, dtype="float32")
|
| 315 |
top_idx = np.argsort(-sims)[:top_k]
|
| 316 |
|
| 317 |
results = []
|
|
@@ -327,10 +414,86 @@ def retrieve_evidence(
|
|
| 327 |
"date": meta.get("date", ""),
|
| 328 |
"similarity": round(score, 4),
|
| 329 |
"excerpt": meta.get("excerpt", ""),
|
|
|
|
| 330 |
})
|
| 331 |
return results
|
| 332 |
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
def rag_status() -> Dict[str, Any]:
|
| 335 |
return {
|
| 336 |
"enabled": RAG_ENABLED,
|
|
@@ -339,4 +502,8 @@ def rag_status() -> Dict[str, Any]:
|
|
| 339 |
"last_updated": _store.last_updated,
|
| 340 |
"last_fetch_ok": _last_fetch_ok,
|
| 341 |
"last_fetch_error": _last_fetch_error,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
}
|
|
|
|
| 34 |
"IndoHoaxDetector-EvidenceBot/1.0 (+kontak: isi-email-anda@domain.com)",
|
| 35 |
)
|
| 36 |
|
| 37 |
+
# Sumber evidence kedua: API resmi MAFINDO (https://mafindodocs.netlify.app/v2/),
|
| 38 |
+
# dipakai berdampingan dengan scraping TurnBackHoax di atas. Nonaktif secara default.
|
| 39 |
+
MAFINDO_API_ENABLED = os.getenv("MAFINDO_API_ENABLED", "0") == "1"
|
| 40 |
+
MAFINDO_API_KEY = os.getenv("MAFINDO_API_KEY", "")
|
| 41 |
+
MAFINDO_API_BASE_URL = os.getenv(
|
| 42 |
+
"MAFINDO_API_BASE_URL", "https://yudistira.turnbackhoax.id/api"
|
| 43 |
+
).rstrip("/")
|
| 44 |
+
MAFINDO_API_TIMEOUT = float(os.getenv("MAFINDO_API_TIMEOUT", "10"))
|
| 45 |
+
# Jumlah kandidat yang diminta ke endpoint search MAFINDO sebelum di-ranking ulang
|
| 46 |
+
# pakai cosine similarity yang sama dengan index scraping (biar sebanding).
|
| 47 |
+
MAFINDO_API_LIMIT = int(os.getenv("MAFINDO_API_LIMIT", "10"))
|
| 48 |
+
# /antihoax/search method=content kemungkinan besar pencarian berbasis kata kunci
|
| 49 |
+
# (bukan semantik), jadi query yang dikirim dipotong dulu supaya peluang match lebih baik.
|
| 50 |
+
MAFINDO_API_QUERY_MAX_CHARS = int(os.getenv("MAFINDO_API_QUERY_MAX_CHARS", "150"))
|
| 51 |
+
|
| 52 |
+
# Strategi menggabungkan 2 sumber evidence saat keduanya aktif:
|
| 53 |
+
# "fallback" (default): coba API dulu, kalau API nonaktif/gagal/0 hasil relevan -> pakai scraping.
|
| 54 |
+
# "union" : selalu panggil dua-duanya, gabungkan & dedup hasilnya berdasarkan similarity.
|
| 55 |
+
RAG_SOURCE_MODE = os.getenv("RAG_SOURCE_MODE", "fallback").strip().lower()
|
| 56 |
+
|
| 57 |
|
| 58 |
_rag_ready: bool = False
|
| 59 |
_last_fetch_ok: Optional[bool] = None
|
| 60 |
_last_fetch_error: Optional[str] = None
|
| 61 |
+
_mafindo_warned_no_key: bool = False
|
| 62 |
+
_last_mafindo_error: Optional[str] = None
|
| 63 |
|
| 64 |
|
| 65 |
# Kata pertama pada slug URL artikel (mis. /articles/123-salah-...) menentukan verdict.
|
|
|
|
| 313 |
threading.Thread(target=_loop, daemon=True, name="rag-updater").start()
|
| 314 |
|
| 315 |
|
| 316 |
+
def _fetch_evidence_from_api(query_text: str) -> Optional[List[Dict[str, Any]]]:
|
| 317 |
+
"""
|
| 318 |
+
Ambil kandidat evidence dari API resmi MAFINDO (POST /antihoax/search, method=content).
|
| 319 |
+
Return None = API nonaktif/gagal total (sinyal ke pemanggil utk fallback ke scraping).
|
| 320 |
+
Return [] = API berhasil dipanggil tapi tidak ada artikel yang cocok.
|
| 321 |
+
"""
|
| 322 |
+
global _mafindo_warned_no_key, _last_mafindo_error
|
| 323 |
+
|
| 324 |
+
if not MAFINDO_API_ENABLED:
|
| 325 |
+
return None
|
| 326 |
+
|
| 327 |
+
if not MAFINDO_API_KEY:
|
| 328 |
+
if not _mafindo_warned_no_key:
|
| 329 |
+
print("[WARN][RAG] MAFINDO_API_ENABLED=1 tapi MAFINDO_API_KEY kosong, API dilewati.")
|
| 330 |
+
_mafindo_warned_no_key = True
|
| 331 |
+
return None
|
| 332 |
+
|
| 333 |
+
query_value = str(query_text or "").strip()[:MAFINDO_API_QUERY_MAX_CHARS]
|
| 334 |
+
if not query_value:
|
| 335 |
return []
|
|
|
|
|
|
|
| 336 |
|
| 337 |
+
try:
|
| 338 |
+
resp = requests.post(
|
| 339 |
+
f"{MAFINDO_API_BASE_URL}/antihoax/search",
|
| 340 |
+
data={
|
| 341 |
+
"key": MAFINDO_API_KEY,
|
| 342 |
+
"method": "content",
|
| 343 |
+
"value": query_value,
|
| 344 |
+
"limit": MAFINDO_API_LIMIT,
|
| 345 |
+
},
|
| 346 |
+
headers={"Accept": "application/json"},
|
| 347 |
+
timeout=MAFINDO_API_TIMEOUT,
|
| 348 |
+
)
|
| 349 |
+
if resp.status_code != 200:
|
| 350 |
+
_last_mafindo_error = f"HTTP {resp.status_code}"
|
| 351 |
+
print(f"[WARN][RAG] MAFINDO API status {resp.status_code}, lanjut tanpa hasil API.")
|
| 352 |
+
return None
|
| 353 |
+
payload = resp.json()
|
| 354 |
+
except Exception as e:
|
| 355 |
+
_last_mafindo_error = str(e)
|
| 356 |
+
print(f"[WARN][RAG] MAFINDO API gagal: {e}")
|
| 357 |
+
return None
|
| 358 |
+
|
| 359 |
+
if not isinstance(payload, list):
|
| 360 |
+
# Key salah / parameter salah biasanya direspons sbg dict/string, bukan list artikel.
|
| 361 |
+
_last_mafindo_error = f"Format tak terduga: {str(payload)[:150]}"
|
| 362 |
+
print(f"[WARN][RAG] MAFINDO API format tak terduga, dianggap 0 hasil: {str(payload)[:150]}")
|
| 363 |
+
return []
|
| 364 |
+
|
| 365 |
+
_last_mafindo_error = None
|
| 366 |
+
articles: List[Dict[str, Any]] = []
|
| 367 |
+
for item in payload:
|
| 368 |
+
if not isinstance(item, dict):
|
| 369 |
+
continue
|
| 370 |
+
news_id = str(item.get("id", "")).strip()
|
| 371 |
+
title = str(item.get("title", "")).strip()
|
| 372 |
+
if not news_id or not title:
|
| 373 |
+
continue
|
| 374 |
+
status = str(item.get("status", "")).strip()
|
| 375 |
+
verdict = "hoax" if status == "2" else "fakta" if status == "1" else "tidak diketahui"
|
| 376 |
+
url = str(item.get("references") or item.get("source_link") or "").strip()
|
| 377 |
+
excerpt_src = item.get("conclusion") or item.get("fact") or item.get("content") or ""
|
| 378 |
+
articles.append({
|
| 379 |
+
"id": f"mafindo-{news_id}",
|
| 380 |
+
"title": title,
|
| 381 |
+
"excerpt": str(excerpt_src).strip()[:300],
|
| 382 |
+
"url": url,
|
| 383 |
+
"date": str(item.get("tanggal", "")).strip(),
|
| 384 |
+
"verdict": verdict,
|
| 385 |
+
"source": "api",
|
| 386 |
+
})
|
| 387 |
+
return articles
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _retrieve_from_local_index(
|
| 391 |
+
q_emb: np.ndarray,
|
| 392 |
+
top_k: int,
|
| 393 |
+
threshold: float,
|
| 394 |
+
) -> List[Dict[str, Any]]:
|
| 395 |
+
"""Cari evidence dari index lokal hasil scraping TurnBackHoax (embeddings di _store)."""
|
| 396 |
+
if not RAG_ENABLED or not _rag_ready:
|
| 397 |
+
return []
|
| 398 |
with _store.lock:
|
| 399 |
if _store.embeddings is None or not _store.metadata:
|
| 400 |
return []
|
| 401 |
+
sims = _store.embeddings @ q_emb
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
top_idx = np.argsort(-sims)[:top_k]
|
| 403 |
|
| 404 |
results = []
|
|
|
|
| 414 |
"date": meta.get("date", ""),
|
| 415 |
"similarity": round(score, 4),
|
| 416 |
"excerpt": meta.get("excerpt", ""),
|
| 417 |
+
"source": "scrape",
|
| 418 |
})
|
| 419 |
return results
|
| 420 |
|
| 421 |
|
| 422 |
+
def _rank_candidates_by_similarity(
|
| 423 |
+
articles: List[Dict[str, Any]],
|
| 424 |
+
q_emb: np.ndarray,
|
| 425 |
+
embed_fn: Callable[[List[str]], np.ndarray],
|
| 426 |
+
threshold: float,
|
| 427 |
+
) -> List[Dict[str, Any]]:
|
| 428 |
+
"""Beri skor cosine similarity ke kandidat artikel (mis. dari API) memakai embed_fn
|
| 429 |
+
yang sama dengan index scraping, supaya keduanya sebanding & bisa di-threshold sama."""
|
| 430 |
+
if not articles:
|
| 431 |
+
return []
|
| 432 |
+
texts = [f"{a.get('title', '')}. {a.get('excerpt', '')}".strip() for a in articles]
|
| 433 |
+
try:
|
| 434 |
+
embs = np.asarray(embed_fn(texts), dtype="float32")
|
| 435 |
+
except Exception as e:
|
| 436 |
+
print(f"[WARN][RAG] Gagal encode kandidat evidence API: {e}")
|
| 437 |
+
return []
|
| 438 |
+
|
| 439 |
+
ranked = []
|
| 440 |
+
for art, emb in zip(articles, embs):
|
| 441 |
+
score = float(emb @ q_emb)
|
| 442 |
+
if score < threshold:
|
| 443 |
+
continue
|
| 444 |
+
ranked.append({
|
| 445 |
+
"title": art.get("title", ""),
|
| 446 |
+
"url": art.get("url", ""),
|
| 447 |
+
"verdict": art.get("verdict", "tidak diketahui"),
|
| 448 |
+
"date": art.get("date", ""),
|
| 449 |
+
"similarity": round(score, 4),
|
| 450 |
+
"excerpt": art.get("excerpt", ""),
|
| 451 |
+
"source": art.get("source", "api"),
|
| 452 |
+
})
|
| 453 |
+
return ranked
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def retrieve_evidence(
|
| 457 |
+
query_text: str,
|
| 458 |
+
embed_fn: Callable[[List[str]], np.ndarray],
|
| 459 |
+
top_k: Optional[int] = None,
|
| 460 |
+
threshold: Optional[float] = None,
|
| 461 |
+
) -> List[Dict[str, Any]]:
|
| 462 |
+
if not query_text or (not RAG_ENABLED and not MAFINDO_API_ENABLED):
|
| 463 |
+
return []
|
| 464 |
+
top_k = top_k or RAG_TOP_K
|
| 465 |
+
threshold = RAG_SIM_THRESHOLD if threshold is None else threshold
|
| 466 |
+
|
| 467 |
+
try:
|
| 468 |
+
q_emb = np.asarray(embed_fn([query_text])[0], dtype="float32")
|
| 469 |
+
except Exception as e:
|
| 470 |
+
print(f"[WARN][RAG] Gagal encode query: {e}")
|
| 471 |
+
return []
|
| 472 |
+
|
| 473 |
+
api_ranked: Optional[List[Dict[str, Any]]] = None
|
| 474 |
+
if MAFINDO_API_ENABLED:
|
| 475 |
+
api_articles = _fetch_evidence_from_api(query_text)
|
| 476 |
+
if api_articles is not None:
|
| 477 |
+
api_ranked = _rank_candidates_by_similarity(api_articles, q_emb, embed_fn, threshold)
|
| 478 |
+
|
| 479 |
+
if RAG_SOURCE_MODE == "union":
|
| 480 |
+
scrape_ranked = _retrieve_from_local_index(q_emb, top_k, threshold)
|
| 481 |
+
combined = (api_ranked or []) + scrape_ranked
|
| 482 |
+
dedup: Dict[str, Dict[str, Any]] = {}
|
| 483 |
+
for item in combined:
|
| 484 |
+
key = (item.get("url") or item.get("title") or "").strip().lower()
|
| 485 |
+
if not key:
|
| 486 |
+
continue
|
| 487 |
+
if key not in dedup or item["similarity"] > dedup[key]["similarity"]:
|
| 488 |
+
dedup[key] = item
|
| 489 |
+
return sorted(dedup.values(), key=lambda x: -x["similarity"])[:top_k]
|
| 490 |
+
|
| 491 |
+
# Mode default "fallback": pakai hasil API kalau ada & relevan, kalau tidak coba scraping.
|
| 492 |
+
if api_ranked:
|
| 493 |
+
return sorted(api_ranked, key=lambda x: -x["similarity"])[:top_k]
|
| 494 |
+
return _retrieve_from_local_index(q_emb, top_k, threshold)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
def rag_status() -> Dict[str, Any]:
|
| 498 |
return {
|
| 499 |
"enabled": RAG_ENABLED,
|
|
|
|
| 502 |
"last_updated": _store.last_updated,
|
| 503 |
"last_fetch_ok": _last_fetch_ok,
|
| 504 |
"last_fetch_error": _last_fetch_error,
|
| 505 |
+
"source_mode": RAG_SOURCE_MODE,
|
| 506 |
+
"mafindo_api_enabled": MAFINDO_API_ENABLED,
|
| 507 |
+
"mafindo_api_ready": MAFINDO_API_ENABLED and bool(MAFINDO_API_KEY),
|
| 508 |
+
"last_mafindo_error": _last_mafindo_error,
|
| 509 |
}
|