199 lines
7.8 KiB
Python
199 lines
7.8 KiB
Python
"""
|
|
RAG microservice for the CP 707 knowledge base (ChromaDB + SentenceTransformers).
|
|
|
|
Runtime entrypoint used by Django `insight_service.fetch_rag_chunks`
|
|
via HTTP `RAG_SERVICE_URL` (default compose :5002; NUC AI lab often :5102).
|
|
Offline HuggingFace mode — do not confuse with `query_rag.py` (CLI/dev only).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from dotenv import load_dotenv
|
|
|
|
# Set offline agar tidak download dari HuggingFace
|
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
|
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
|
|
|
# Load .env dari direktori yang sama dengan script ini
|
|
script_dir = Path(__file__).parent
|
|
load_dotenv(script_dir / ".env")
|
|
|
|
CHROMA_DB_DIR = str(script_dir / os.getenv("CHROMA_DB_DIR", "chroma_db"))
|
|
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
|
RAG_PORT = int(os.getenv("RAG_PORT", "5002"))
|
|
COLLECTION_NAME = "company_sop"
|
|
|
|
print(f"[RAG Service] ChromaDB path: {CHROMA_DB_DIR}")
|
|
print(f"[RAG Service] Embedding model: {EMBEDDING_MODEL_NAME}")
|
|
|
|
# Import setelah env set
|
|
import chromadb
|
|
from sentence_transformers import SentenceTransformer
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
import uvicorn
|
|
|
|
# ─── Init ChromaDB & Embedding Model ─────────────────────────────────────────
|
|
print("[RAG Service] Memuat ChromaDB...")
|
|
try:
|
|
chroma_client = chromadb.PersistentClient(path=CHROMA_DB_DIR)
|
|
collection = chroma_client.get_collection(name=COLLECTION_NAME)
|
|
total_chunks = collection.count()
|
|
print(f"[RAG Service] ChromaDB loaded. Total chunks: {total_chunks}")
|
|
except Exception as e:
|
|
print(f"[RAG Service] ERROR: Gagal load ChromaDB: {e}")
|
|
sys.exit(1)
|
|
|
|
print(f"[RAG Service] Memuat embedding model '{EMBEDDING_MODEL_NAME}'...")
|
|
try:
|
|
embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
|
|
print("[RAG Service] Embedding model berhasil dimuat.")
|
|
except Exception as e:
|
|
print(f"[RAG Service] ERROR: Gagal load embedding model: {e}")
|
|
print("[RAG Service] Pastikan model sudah didownload. Jalankan populate_db.py terlebih dahulu.")
|
|
sys.exit(1)
|
|
|
|
# ─── FastAPI App ──────────────────────────────────────────────────────────────
|
|
app = FastAPI(
|
|
title="CP707 RAG Service",
|
|
description="Retrieval-Augmented Generation service untuk buku Manajemen Broiler CP 707",
|
|
version="1.0.0"
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[
|
|
"http://localhost:3000",
|
|
"http://localhost:3001",
|
|
"http://localhost:5001",
|
|
"http://127.0.0.1:5001",
|
|
"http://localhost:8000",
|
|
"http://127.0.0.1:8000",
|
|
],
|
|
allow_methods=["GET", "POST"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# ─── Request/Response Models ─────────────────────────────────────────────────
|
|
class QueryRequest(BaseModel):
|
|
query: str = Field(..., min_length=1, description="Query text untuk mencari chunk CP707 relevan")
|
|
n_results: int = Field(default=4, ge=1, le=10, description="Jumlah chunk yang dikembalikan")
|
|
topic: str = Field(default="", description="Topic insight: berat_ayam, fcr, iot_panel, dll")
|
|
# Prefer prose SOP; table rows without headers are dangerous for the LLM.
|
|
tipe: str = Field(default="prosa", description="Filter metadata tipe: prosa | tabel | any")
|
|
|
|
class QueryResponse(BaseModel):
|
|
success: bool
|
|
chunks: list
|
|
sources: list
|
|
metadatas: list
|
|
total_found: int
|
|
query_used: str
|
|
|
|
class HealthResponse(BaseModel):
|
|
status: str
|
|
total_chunks: int
|
|
embedding_model: str
|
|
|
|
# ─── Topic → Query enhancement mapping ──────────────────────────────────────
|
|
# Tambahkan keyword relevan per topic agar embedding search lebih tepat sasaran
|
|
TOPIC_QUERY_HINTS = {
|
|
"berat_ayam": "berat badan target bobot ADG pertumbuhan standar mingguan ayam broiler",
|
|
"fcr": "FCR feed conversion ratio konsumsi pakan efisiensi standar broiler",
|
|
"iot_panel": "suhu kandang kelembapan amonia CO2 ventilasi lingkungan pemeliharaan broiler",
|
|
"eef": "EEF indeks performa IP efisiensi produksi siklus broiler",
|
|
"hitung_ayam": "mortalitas deplesi kematian afkir populasi standar toleransi broiler",
|
|
"hitung_karung": "pakan karung konsumsi harian feed intake standar broiler",
|
|
}
|
|
|
|
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
|
@app.get("/health", response_model=HealthResponse)
|
|
def health_check():
|
|
return HealthResponse(
|
|
status="ok",
|
|
total_chunks=collection.count(),
|
|
embedding_model=EMBEDDING_MODEL_NAME,
|
|
)
|
|
|
|
@app.post("/query", response_model=QueryResponse)
|
|
def query_cp707(req: QueryRequest):
|
|
"""
|
|
Cari chunk CP707 yang relevan berdasarkan query.
|
|
Jika topic disediakan, tambahkan hint keyword agar hasil lebih relevan.
|
|
"""
|
|
enhanced_query = req.query
|
|
if req.topic and req.topic in TOPIC_QUERY_HINTS:
|
|
enhanced_query = f"{req.query} {TOPIC_QUERY_HINTS[req.topic]}"
|
|
|
|
try:
|
|
query_embedding = embedding_model.encode([enhanced_query])[0].tolist()
|
|
|
|
# Over-fetch then filter by tipe so prosa chunks win when metadata exists.
|
|
fetch_n = min(max(req.n_results * 3, req.n_results), max(collection.count(), 1))
|
|
where = None
|
|
if req.tipe and req.tipe != "any":
|
|
where = {"tipe": req.tipe}
|
|
|
|
try:
|
|
results = collection.query(
|
|
query_embeddings=[query_embedding],
|
|
n_results=fetch_n,
|
|
where=where,
|
|
)
|
|
except Exception:
|
|
# Older indexes may lack tipe metadata — fall back unfiltered.
|
|
results = collection.query(
|
|
query_embeddings=[query_embedding],
|
|
n_results=fetch_n,
|
|
)
|
|
|
|
chunks = results["documents"][0] if results["documents"] else []
|
|
metadatas = results["metadatas"][0] if results["metadatas"] else []
|
|
|
|
# If unfiltered fallback returned tables, drop them when prosa was requested.
|
|
if req.tipe == "prosa" and metadatas:
|
|
paired = [
|
|
(c, m)
|
|
for c, m in zip(chunks, metadatas)
|
|
if (m or {}).get("tipe", "prosa") != "tabel"
|
|
]
|
|
if paired:
|
|
chunks, metadatas = [list(x) for x in zip(*paired)]
|
|
else:
|
|
# Keep original if everything was tabel (better something than nothing;
|
|
# Django stripHeaderlessTables still cleans numeric rows).
|
|
pass
|
|
|
|
chunks = chunks[: req.n_results]
|
|
metadatas = metadatas[: req.n_results]
|
|
|
|
sources = []
|
|
for m in metadatas:
|
|
bab = (m or {}).get("bab") or ""
|
|
src = (m or {}).get("source", "unknown")
|
|
idx = (m or {}).get("chunk_index", "?")
|
|
label = f"{src}"
|
|
if bab:
|
|
label += f" / {bab}"
|
|
label += f" (chunk {idx})"
|
|
sources.append(label)
|
|
|
|
return QueryResponse(
|
|
success=True,
|
|
chunks=chunks,
|
|
sources=sources,
|
|
metadatas=metadatas,
|
|
total_found=len(chunks),
|
|
query_used=enhanced_query,
|
|
)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"RAG query error: {str(e)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print(f"[RAG Service] Starting on http://0.0.0.0:{RAG_PORT}")
|
|
uvicorn.run(app, host="0.0.0.0", port=RAG_PORT, log_level="warning")
|