Files
Hamhoosh/edge/orangepi_service/service.py
T

279 lines
9.7 KiB
Python
Raw Normal View History

2026-06-25 21:21:30 +03:30
import os
import shutil
import logging
from typing import List, Dict, Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import chromadb
from chromadb.utils import embedding_functions
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from llama_cpp import Llama
import uvicorn
# ------------------------------------------------------------
# Configuration
# ------------------------------------------------------------
MODEL_PATH = os.getenv("MODEL_PATH", "/home/orangepi/daemon/orangepi_service/models/qwen2.5-0.5b-instruct-q8_0.gguf")
N_CTX = int(os.getenv("N_CTX", "1024")) # Smaller context for Orange Pi
N_THREADS = int(os.getenv("N_THREADS", "4"))
COLLECTION_NAME = os.getenv("COLLECTION_NAME", "rag_docs")
CHROMA_DIR = os.getenv("CHROMA_DIR", "/home/orangepi/daemon/orangepi_service/models/chroma_db")
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "/home/orangepi/daemon/orangepi_service/models/uploaded_pdfs")
CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", "500"))
CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", "50"))
TOP_K = int(os.getenv("TOP_K", "3")) # Number of chunks to retrieve
# Generation defaults
TEMP = float(os.getenv("TEMP", "0.3"))
MAX_TOKENS = int(os.getenv("MAX_TOKENS", "256"))
# ------------------------------------------------------------
# Logging
# ------------------------------------------------------------
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orangepi-rag")
# ------------------------------------------------------------
# Global objects (initialised at startup)
# ------------------------------------------------------------
llm = None
chroma_client = None
collection = None
embedding_fn = None
# ------------------------------------------------------------
# Initialisation
# ------------------------------------------------------------
def init_directories():
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(CHROMA_DIR, exist_ok=True)
def init_chroma():
global chroma_client, collection, embedding_fn
chroma_client = chromadb.PersistentClient(path=CHROMA_DIR)
# Use sentence-transformers embedding function (runs on CPU)
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)
# Get or create collection
collection = chroma_client.get_or_create_collection(
name=COLLECTION_NAME,
embedding_function=embedding_fn
)
logger.info(f"Chroma initialised, collection '{COLLECTION_NAME}' has {collection.count()} documents")
def init_llm():
global llm
logger.info(f"Loading GGUF model from {MODEL_PATH} ...")
llm = Llama(
model_path=MODEL_PATH,
n_ctx=N_CTX,
n_threads=N_THREADS,
verbose=False,
)
logger.info("LLM loaded.")
def index_pdf(file_path: str, doc_id: str) -> int:
"""Load PDF, split, embed, and add to Chroma. Returns number of chunks."""
loader = PyPDFLoader(file_path)
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", " ", ""]
)
chunks = text_splitter.split_documents(documents)
if not chunks:
logger.warning(f"No text extracted from {file_path}")
return 0
ids = [f"{doc_id}_{i}" for i in range(len(chunks))]
texts = [chunk.page_content for chunk in chunks]
metadatas = [{"source": doc_id, "page": chunk.metadata.get("page", 0)} for chunk in chunks]
collection.add(
ids=ids,
documents=texts,
metadatas=metadatas
)
logger.info(f"Indexed {len(chunks)} chunks from {doc_id}")
return len(chunks)
def retrieve_relevant_chunks(question: str) -> List[str]:
"""Return top-K document chunks as strings."""
results = collection.query(query_texts=[question], n_results=TOP_K)
if results and results['documents']:
return results['documents'][0]
return []
def generate_answer(question: str, context_chunks: List[str]) -> str:
"""Generate answer using local LLM with retrieved context."""
if not context_chunks:
prompt = f"""Question: {question}\n\nNo relevant documents found. Answer based on your general knowledge.\nAnswer:"""
else:
context = "\n\n".join(context_chunks)
prompt = f"""Use only the following context to answer the question. If the answer is not in the context, say "I don't know".
Context:
{context}
Question: {question}
Answer:"""
output = llm.create_completion(
prompt="Hello how are you?",
max_tokens=MAX_TOKENS,
temperature=TEMP,
stop=None,
echo=False
)
print(output)
return output["choices"][0]["text"].strip()
# ------------------------------------------------------------
# FastAPI lifespan
# ------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
init_directories()
init_chroma()
init_llm()
yield
# Optional: cleanup
logger.info("Shutting down OrangePi RAG service.")
app = FastAPI(
title="OrangePi RAG Service",
description="PDF upload, retrieval, and answer generation using a local GGUF model.",
version="1.0",
lifespan=lifespan,
)
# ------------------------------------------------------------
# Request/Response models
# ------------------------------------------------------------
class QuestionRequest(BaseModel):
question: str
class AnswerResponse(BaseModel):
answer: str
retrieved_chunks: List[str] = [] # for debugging
class DocumentInfo(BaseModel):
doc_id: str
filename: str
chunks: int
# ------------------------------------------------------------
# API Endpoints
# ------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok", "model_loaded": llm is not None, "docs_indexed": collection.count() if collection else 0}
@app.post("/upload")
async def upload_pdf(file: UploadFile = File(...)):
"""Upload a PDF file, store it, and index its contents."""
if not file.filename.endswith('.pdf'):
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
# Generate a unique doc_id from filename + timestamp
doc_id = file.filename.replace(' ', '_').replace('/', '_')
base, ext = os.path.splitext(doc_id)
doc_id = f"{base}_{os.urandom(4).hex()}{ext}"
file_path = os.path.join(UPLOAD_DIR, doc_id)
try:
# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Index the PDF
num_chunks = index_pdf(file_path, doc_id)
return JSONResponse(content={
"message": "PDF uploaded and indexed",
"doc_id": doc_id,
"original_filename": file.filename,
"chunks_indexed": num_chunks
})
except Exception as e:
logger.exception("Upload/index failed")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/documents")
async def list_documents():
"""List all indexed documents with metadata."""
# Chroma doesn't store document-level metadata easily; we maintain a separate index.
# For simplicity, we'll read from the collection's metadata (if we stored source per chunk)
# Actually we stored 'source' in each chunk's metadata. Let's aggregate.
all_metas = collection.get() # returns ids, metadatas, documents
if not all_metas or not all_metas['metadatas']:
return {"documents": []}
doc_map = {}
for meta in all_metas['metadatas']:
source = meta.get('source')
if source:
if source not in doc_map:
doc_map[source] = 0
doc_map[source] += 1
docs = [{"doc_id": doc_id, "chunks": count} for doc_id, count in doc_map.items()]
return {"documents": docs}
@app.post("/answer", response_model=AnswerResponse)
async def answer_question(req: QuestionRequest):
"""RAG answer: retrieve relevant chunks and generate answer."""
if llm is None:
raise HTTPException(status_code=503, detail="LLM not loaded")
# Retrieve
chunks = retrieve_relevant_chunks(req.question)
# Generate
answer = generate_answer(req.question, chunks)
return AnswerResponse(answer=answer, retrieved_chunks=chunks)
@app.delete("/documents/{doc_id}")
async def delete_document(doc_id: str):
"""
Delete a document and its chunks from the vector database.
Optionally also delete the uploaded PDF file.
"""
try:
# 1. Retrieve all chunks with metadata source == doc_id
# Chroma doesn't support direct delete by metadata, so we get all chunk ids with that source
all_chunks = collection.get(where={"source": doc_id})
if not all_chunks or not all_chunks['ids']:
raise HTTPException(status_code=404, detail=f"Document {doc_id} not found")
# 2. Delete the chunks
collection.delete(ids=all_chunks['ids'])
# 3. (Optional) Delete the physical PDF file if it exists
pdf_path = os.path.join(UPLOAD_DIR, doc_id)
if os.path.exists(pdf_path):
os.remove(pdf_path)
return JSONResponse(content={"message": f"Document {doc_id} deleted", "chunks_deleted": len(all_chunks['ids'])})
except Exception as e:
logger.exception(f"Failed to delete document {doc_id}")
raise HTTPException(status_code=500, detail=str(e))
# ------------------------------------------------------------
# Run
# ------------------------------------------------------------
if __name__ == "__main__":
uvicorn.run(
"service:app",
host="0.0.0.0",
port=8080,
reload=False,
log_level="info"
)