import os
import logging
from pathlib import Path
from dotenv import load_dotenv
from langchain_community.vectorstores import FAISS
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.embeddings import OpenAIEmbeddings
from langchain_openai import ChatOpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent
ENV_PATH = BASE_DIR / ".env"
if ENV_PATH.exists():
load_dotenv(dotenv_path=ENV_PATH, override=False)
# --- Konstanta global ---
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
PDF_DIR = "documents"
VECTOR_DIR = "vector_store"
VECTOR_INDEX_PATH = os.path.join(VECTOR_DIR, "faiss_index")
_CHAT_KW = {"model": "gpt-4o-mini", "api_key": OPENAI_API_KEY}
MASKOT_NAME = "Hadmin AI"
SYSTEM_INSTRUCTION = f"""
Kamu adalah maskot chat AI bernama {MASKOT_NAME}, mewakili perusahaan.
Kamu ramah, sopan, profesional, dan mudah diajak bicara — seperti customer service yang hangat dan ahli.
Tugas:
1. Pahami konteks dan kebutuhan pengguna dengan cermat.
2. Berikan saran, rekomendasi, dan informasi relevan secara jelas, logis, dan solutif.
3. Jika informasi kurang lengkap atau tidak tersedia:
- Sampaikan dengan sopan bahwa informasi terkait tidak tersedia.
- Tawarkan alternatif atau sumber resmi yang relevan (misal website, pendaftaran, layanan terkait).
- Jelaskan mengapa alternatif tersebut bisa menjadi opsi yang cocok.
4. Informasi yang sudah tersedia **tidak boleh diubah, dikarang, atau disesuaikan** atas permintaan pengguna.
5. Gunakan nama {MASKOT_NAME} **hanya saat menyapa pengguna atau membalas sapaan pertama**.
6. Jangan menganggap diri sebagai manusia; kamu adalah maskot/Asisten Virtual.
7. Jawaban harus empatik, mudah dipahami, natural, profesional, dan nyaman dibaca.
Format HTML:
- Gunakan
untuk paragraf.
- Gunakan
/- untuk daftar.
- Gunakan / untuk penekanan.
- Gunakan
//| / | untuk tabel.
- Jangan menggunakan –, CSS, warna, atau layout kompleks.
- Jika menyertakan link, gunakan nama link.
Perilaku:
- Saat pengguna menyapa: balas hangat, cukup tanyakan kebutuhan mereka.
- Saat pengguna meminta saran/rekomendasi: berikan beberapa opsi relevan, jelaskan alasannya, dan tawarkan solusi praktis.
- Jika info dari pengguna kurang jelas atau ambigu: ajukan pertanyaan klarifikasi sebelum menjawab.
- Sesuaikan bahasa jawaban dengan bahasa pesan pengguna:
- Inggris → jawaban dalam bahasa Inggris.
- Prancis → jawaban dalam bahasa Prancis.
- Bahasa lain → jawab dengan bahasa yang sama.
- Pertahankan istilah atau nama properti unik dalam bahasa Indonesia (misal: “BPJS Kesehatan”, nama kota, produk).
- Format HTML, daftar, dan tabel tetap sama.
- Jika ditanya kemampuan bahasa (misal: "bisa kamu berbicara bahasa Indonesia?"):
- Jawab **singkat dan jelas** sesuai bahasa yang diminta.
- Tidak perlu menambahkan saran, rekomendasi, atau info lain.
- Jangan menyapa pada setiap jawaban; sapaan hanya pada interaksi pertama atau bila diperlukan.
- Jangan menyatakan diri sebagai AI, menyebut model OpenAI, atau membahas batasan teknologi.
- Jangan memberikan informasi palsu, menyesatkan, atau tindakan ilegal/berbahaya.
- Jangan memberikan nasihat medis, hukum, atau keuangan profesional.
"""
# ------------------ INIT ------------------
def get_embeddings():
"""Inisialisasi embedding OpenAI."""
try:
return OpenAIEmbeddings(api_key=OPENAI_API_KEY)
except Exception as e:
logger.warning(f"OpenAIEmbeddings init failed: {e}")
raise RuntimeError("Embeddings gagal diinisialisasi. Pastikan OPENAI_API_KEY sudah benar.")
# ------------------ BUILD ------------------
def build_vector_index(pdf_dir: str = PDF_DIR, vector_path: str = VECTOR_INDEX_PATH):
"""
Bangun FAISS index dari seluruh PDF di folder pdf_dir.
Simpan ke vector_path.
"""
embeddings = get_embeddings()
os.makedirs(vector_path, exist_ok=True)
pdf_files = [f for f in os.listdir(pdf_dir) if f.lower().endswith(".pdf")]
if not pdf_files:
raise ValueError(f"Tidak ada PDF ditemukan di folder: {pdf_dir}")
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
all_docs = []
for file in pdf_files:
path = os.path.join(pdf_dir, file)
try:
loader = PyPDFLoader(path)
docs = loader.load_and_split(text_splitter=splitter)
for d in docs:
d.metadata["source"] = path
all_docs.extend(docs)
logger.info(f"✅ Berhasil memproses {file}, total potongan: {len(docs)}")
except Exception as e:
logger.error(f"❌ Gagal memproses {file}: {e}")
if not all_docs:
raise ValueError("Tidak ada teks valid yang berhasil di-load dari PDF mana pun.")
vectordb = FAISS.from_documents(all_docs, embeddings)
vectordb.save_local(vector_path)
return {
"status": "success",
"pdf_count": len(pdf_files),
"chunks_total": len(all_docs),
"vector_path": vector_path,
}
# ------------------ SESSION ------------------
# Simpan percakapan per session (untuk prototipe, pakai memory)
chat_sessions = {} # key = session_id, value = list of {"role": "user"/"assistant", "content": str}
def add_to_session(session_id: str, role: str, content: str):
if session_id not in chat_sessions:
chat_sessions[session_id] = []
chat_sessions[session_id].append({"role": role, "content": content})
# ------------------ QUERY ------------------
def query_from_vector(query: str, session_id: str = None, vector_path: str = VECTOR_INDEX_PATH, k: int = 3):
"""
Query ke FAISS index dan hasilkan jawaban singkat dari LLM.
session_id -> untuk multi-turn chat
output_format: "string" | "markdown" | "html"
"""
embeddings = get_embeddings()
if not os.path.exists(vector_path):
raise FileNotFoundError(f"Vector DB belum dibuat di {vector_path}")
vectordb = FAISS.load_local(vector_path, embeddings, allow_dangerous_deserialization=True)
docs = vectordb.similarity_search(query, k=k)
if not docs:
return {"error": "Tidak ditemukan hasil relevan."}
context = "\n\n---\n\n".join([d.page_content for d in docs])
sources = [d.metadata.get("source", "") for d in docs]
# Ambil history chat sebelumnya
history_messages = chat_sessions.get(session_id, []) if session_id else []
# Siapkan prompt dengan history
messages = [("system", SYSTEM_INSTRUCTION)]
for msg in history_messages:
messages.append((msg["role"], msg["content"]))
messages.append(("human", f"Pertanyaan: {query}\n\nKonteks:\n{context}\n\nJawaban:"))
prompt = ChatPromptTemplate.from_messages(messages)
llm = ChatOpenAI(temperature=0, **_CHAT_KW)
result = (prompt | llm).invoke({"q": query, "ctx": context})
answer_text = (result.content or "").strip()
# Simpan percakapan ke session
if session_id:
add_to_session(session_id, "user", query)
add_to_session(session_id, "assistant", answer_text)
token_usage = getattr(result, "usage_metadata", {})
return {
"query": query,
"answer": answer_text,
"sources": sources,
"token_usage": {
"input_tokens": token_usage.get("input_tokens", 0),
"output_tokens": token_usage.get("output_tokens", 0),
"total_tokens": token_usage.get("total_tokens", 0),
},
} |