Spaces:
Runtime error
Runtime error
ConstCorrectness commited on
Commit Β·
6e212c2
1
Parent(s): 15c0fc5
v1
Browse files- Dockerfile +18 -0
- backend/__init__.py +0 -0
- backend/embedder.py +23 -0
- backend/jobs.py +36 -0
- backend/main.py +119 -0
- backend/parser.py +30 -0
- backend/vectordb.py +90 -0
- frontend/__init__.py +0 -0
- frontend/app.py +124 -0
- requirements.txt +10 -0
- start.sh +10 -0
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
build-essential \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY . .
|
| 13 |
+
|
| 14 |
+
RUN chmod +x start.sh
|
| 15 |
+
|
| 16 |
+
EXPOSE 7860
|
| 17 |
+
|
| 18 |
+
CMD ["./start.sh"]
|
backend/__init__.py
ADDED
|
File without changes
|
backend/embedder.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from openai import OpenAI
|
| 3 |
+
|
| 4 |
+
_client: OpenAI | None = None
|
| 5 |
+
EMBED_MODEL = "text-embedding-3-small"
|
| 6 |
+
BATCH_SIZE = 50
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _get_client() -> OpenAI:
|
| 10 |
+
global _client
|
| 11 |
+
if _client is None:
|
| 12 |
+
_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
|
| 13 |
+
return _client
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def embed_texts(texts: list[str]) -> list[list[float]]:
|
| 17 |
+
client = _get_client()
|
| 18 |
+
vectors = []
|
| 19 |
+
for i in range(0, len(texts), BATCH_SIZE):
|
| 20 |
+
batch = texts[i : i + BATCH_SIZE]
|
| 21 |
+
response = client.embeddings.create(model=EMBED_MODEL, input=batch)
|
| 22 |
+
vectors.extend([item.embedding for item in response.data])
|
| 23 |
+
return vectors
|
backend/jobs.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
_jobs: dict[str, "Job"] = {}
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass
|
| 9 |
+
class Job:
|
| 10 |
+
id: str
|
| 11 |
+
status: str = "pending" # pending | running | done | error
|
| 12 |
+
progress: int = 0 # 0-100
|
| 13 |
+
message: str = ""
|
| 14 |
+
error: Optional[str] = None
|
| 15 |
+
total_rows: int = 0
|
| 16 |
+
processed_rows: int = 0
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def create_job() -> Job:
|
| 20 |
+
job = Job(id=str(uuid.uuid4()))
|
| 21 |
+
_jobs[job.id] = job
|
| 22 |
+
return job
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_job(job_id: str) -> Optional[Job]:
|
| 26 |
+
return _jobs.get(job_id)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def update_job(job_id: str, **kwargs) -> None:
|
| 30 |
+
job = _jobs.get(job_id)
|
| 31 |
+
if not job:
|
| 32 |
+
return
|
| 33 |
+
for k, v in kwargs.items():
|
| 34 |
+
setattr(job, k, v)
|
| 35 |
+
if job.total_rows > 0:
|
| 36 |
+
job.progress = int((job.processed_rows / job.total_rows) * 100)
|
backend/main.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
from backend.jobs import create_job, get_job, update_job
|
| 9 |
+
from backend.parser import parse_file, row_to_text, SUPPORTED_EXTENSIONS
|
| 10 |
+
from backend.embedder import embed_texts, BATCH_SIZE
|
| 11 |
+
from backend.vectordb import ensure_collection, upsert_points, search, list_source_files
|
| 12 |
+
|
| 13 |
+
load_dotenv()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(app: FastAPI):
|
| 18 |
+
ensure_collection()
|
| 19 |
+
yield
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
app = FastAPI(title="SemanticSearch API", lifespan=lifespan)
|
| 23 |
+
|
| 24 |
+
app.add_middleware(
|
| 25 |
+
CORSMiddleware,
|
| 26 |
+
allow_origins=["*"],
|
| 27 |
+
allow_methods=["*"],
|
| 28 |
+
allow_headers=["*"],
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Background task
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
def _ingest(job_id: str, filename: str, content: bytes) -> None:
|
| 37 |
+
try:
|
| 38 |
+
update_job(job_id, status="running", message="Parsing file...")
|
| 39 |
+
rows = parse_file(filename, content)
|
| 40 |
+
total = len(rows)
|
| 41 |
+
update_job(job_id, total_rows=total, message=f"Parsed {total} rows. Embedding...")
|
| 42 |
+
|
| 43 |
+
texts = [row_to_text(r) for r in rows]
|
| 44 |
+
|
| 45 |
+
all_vectors: list[list[float]] = []
|
| 46 |
+
for i in range(0, total, BATCH_SIZE):
|
| 47 |
+
batch_texts = texts[i : i + BATCH_SIZE]
|
| 48 |
+
batch_vectors = embed_texts(batch_texts)
|
| 49 |
+
all_vectors.extend(batch_vectors)
|
| 50 |
+
update_job(
|
| 51 |
+
job_id,
|
| 52 |
+
processed_rows=min(i + BATCH_SIZE, total),
|
| 53 |
+
message=f"Embedded {min(i + BATCH_SIZE, total)}/{total} rows...",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
update_job(job_id, message="Storing vectors...")
|
| 57 |
+
upsert_points(all_vectors, rows, source_file=filename)
|
| 58 |
+
update_job(job_id, status="done", progress=100, message="Done!")
|
| 59 |
+
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
update_job(job_id, status="error", message="Failed.", error=str(exc))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Routes
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
@app.post("/upload")
|
| 69 |
+
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
|
| 70 |
+
ext = "." + file.filename.rsplit(".", 1)[-1].lower()
|
| 71 |
+
if ext not in SUPPORTED_EXTENSIONS:
|
| 72 |
+
raise HTTPException(
|
| 73 |
+
status_code=400,
|
| 74 |
+
detail=f"Unsupported file type. Allowed: {', '.join(SUPPORTED_EXTENSIONS)}",
|
| 75 |
+
)
|
| 76 |
+
content = await file.read()
|
| 77 |
+
job = create_job()
|
| 78 |
+
background_tasks.add_task(_ingest, job.id, file.filename, content)
|
| 79 |
+
return {"job_id": job.id}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@app.get("/status/{job_id}")
|
| 83 |
+
def job_status(job_id: str):
|
| 84 |
+
job = get_job(job_id)
|
| 85 |
+
if not job:
|
| 86 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 87 |
+
return {
|
| 88 |
+
"job_id": job.id,
|
| 89 |
+
"status": job.status,
|
| 90 |
+
"progress": job.progress,
|
| 91 |
+
"message": job.message,
|
| 92 |
+
"error": job.error,
|
| 93 |
+
"total_rows": job.total_rows,
|
| 94 |
+
"processed_rows": job.processed_rows,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class SearchRequest(BaseModel):
|
| 99 |
+
query: str
|
| 100 |
+
top_k: int = 10
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.post("/search")
|
| 104 |
+
def semantic_search(req: SearchRequest):
|
| 105 |
+
if not req.query.strip():
|
| 106 |
+
raise HTTPException(status_code=400, detail="Query cannot be empty")
|
| 107 |
+
vectors = embed_texts([req.query])
|
| 108 |
+
results = search(vectors[0], top_k=req.top_k)
|
| 109 |
+
return {"results": results}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@app.get("/collections")
|
| 113 |
+
def get_collections():
|
| 114 |
+
return {"files": list_source_files()}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.get("/health")
|
| 118 |
+
def health():
|
| 119 |
+
return {"status": "ok"}
|
backend/parser.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import json
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
SUPPORTED_EXTENSIONS = {".csv", ".xlsx", ".xls", ".json"}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def parse_file(filename: str, content: bytes) -> list[dict]:
|
| 10 |
+
ext = _ext(filename)
|
| 11 |
+
if ext == ".csv":
|
| 12 |
+
df = pd.read_csv(io.BytesIO(content))
|
| 13 |
+
elif ext in {".xlsx", ".xls"}:
|
| 14 |
+
df = pd.read_excel(io.BytesIO(content))
|
| 15 |
+
elif ext == ".json":
|
| 16 |
+
data = json.loads(content)
|
| 17 |
+
df = pd.DataFrame(data if isinstance(data, list) else [data])
|
| 18 |
+
else:
|
| 19 |
+
raise ValueError(f"Unsupported file type: {ext}")
|
| 20 |
+
|
| 21 |
+
df = df.dropna(how="all").fillna("")
|
| 22 |
+
return df.to_dict(orient="records")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def row_to_text(row: dict) -> str:
|
| 26 |
+
return " | ".join(f"{k}: {v}" for k, v in row.items() if str(v).strip())
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _ext(filename: str) -> str:
|
| 30 |
+
return "." + filename.rsplit(".", 1)[-1].lower()
|
backend/vectordb.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uuid
|
| 3 |
+
from qdrant_client import QdrantClient
|
| 4 |
+
from qdrant_client.models import (
|
| 5 |
+
Distance,
|
| 6 |
+
VectorParams,
|
| 7 |
+
PointStruct,
|
| 8 |
+
Filter,
|
| 9 |
+
FieldCondition,
|
| 10 |
+
MatchValue,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
VECTOR_SIZE = 1536 # text-embedding-3-small dimension
|
| 14 |
+
|
| 15 |
+
_client: QdrantClient | None = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _get_client() -> QdrantClient:
|
| 19 |
+
global _client
|
| 20 |
+
if _client is None:
|
| 21 |
+
_client = QdrantClient(
|
| 22 |
+
url=os.environ["QDRANT_URL"],
|
| 23 |
+
api_key=os.environ["QDRANT_API_KEY"],
|
| 24 |
+
)
|
| 25 |
+
return _client
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _collection() -> str:
|
| 29 |
+
return os.environ.get("QDRANT_COLLECTION", "documents")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def ensure_collection() -> None:
|
| 33 |
+
client = _get_client()
|
| 34 |
+
col = _collection()
|
| 35 |
+
existing = [c.name for c in client.get_collections().collections]
|
| 36 |
+
if col not in existing:
|
| 37 |
+
client.create_collection(
|
| 38 |
+
collection_name=col,
|
| 39 |
+
vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def upsert_points(
|
| 44 |
+
vectors: list[list[float]],
|
| 45 |
+
payloads: list[dict],
|
| 46 |
+
source_file: str,
|
| 47 |
+
) -> None:
|
| 48 |
+
client = _get_client()
|
| 49 |
+
points = [
|
| 50 |
+
PointStruct(
|
| 51 |
+
id=str(uuid.uuid4()),
|
| 52 |
+
vector=vec,
|
| 53 |
+
payload={**payload, "source_file": source_file},
|
| 54 |
+
)
|
| 55 |
+
for vec, payload in zip(vectors, payloads)
|
| 56 |
+
]
|
| 57 |
+
client.upsert(collection_name=_collection(), points=points)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def search(query_vector: list[float], top_k: int = 10) -> list[dict]:
|
| 61 |
+
client = _get_client()
|
| 62 |
+
results = client.search(
|
| 63 |
+
collection_name=_collection(),
|
| 64 |
+
query_vector=query_vector,
|
| 65 |
+
limit=top_k,
|
| 66 |
+
with_payload=True,
|
| 67 |
+
)
|
| 68 |
+
return [
|
| 69 |
+
{"score": round(hit.score, 4), **hit.payload}
|
| 70 |
+
for hit in results
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def list_source_files() -> list[str]:
|
| 75 |
+
client = _get_client()
|
| 76 |
+
seen: set[str] = set()
|
| 77 |
+
offset = None
|
| 78 |
+
while True:
|
| 79 |
+
records, offset = client.scroll(
|
| 80 |
+
collection_name=_collection(),
|
| 81 |
+
with_payload=["source_file"],
|
| 82 |
+
limit=256,
|
| 83 |
+
offset=offset,
|
| 84 |
+
)
|
| 85 |
+
for r in records:
|
| 86 |
+
if r.payload and "source_file" in r.payload:
|
| 87 |
+
seen.add(r.payload["source_file"])
|
| 88 |
+
if offset is None:
|
| 89 |
+
break
|
| 90 |
+
return sorted(seen)
|
frontend/__init__.py
ADDED
|
File without changes
|
frontend/app.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import httpx
|
| 3 |
+
import streamlit as st
|
| 4 |
+
|
| 5 |
+
API_BASE = "http://localhost:8000"
|
| 6 |
+
|
| 7 |
+
st.set_page_config(page_title="Semantic Search", page_icon="π", layout="wide")
|
| 8 |
+
st.title("π Semantic Search")
|
| 9 |
+
|
| 10 |
+
tab_upload, tab_search, tab_files = st.tabs(["Upload", "Search", "Indexed Files"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
# Upload tab
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
with tab_upload:
|
| 17 |
+
st.subheader("Upload a file to index")
|
| 18 |
+
st.caption("Supported formats: CSV, XLSX, XLS, JSON β one file at a time")
|
| 19 |
+
|
| 20 |
+
uploaded = st.file_uploader(
|
| 21 |
+
"Choose a file",
|
| 22 |
+
type=["csv", "xlsx", "xls", "json"],
|
| 23 |
+
label_visibility="collapsed",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
if uploaded and st.button("Upload & Embed", type="primary"):
|
| 27 |
+
with st.spinner("Sending file to API..."):
|
| 28 |
+
try:
|
| 29 |
+
resp = httpx.post(
|
| 30 |
+
f"{API_BASE}/upload",
|
| 31 |
+
files={"file": (uploaded.name, uploaded.getvalue(), "application/octet-stream")},
|
| 32 |
+
timeout=30,
|
| 33 |
+
)
|
| 34 |
+
resp.raise_for_status()
|
| 35 |
+
job_id = resp.json()["job_id"]
|
| 36 |
+
except Exception as e:
|
| 37 |
+
st.error(f"Upload failed: {e}")
|
| 38 |
+
st.stop()
|
| 39 |
+
|
| 40 |
+
st.info(f"Job started: `{job_id}`")
|
| 41 |
+
progress_bar = st.progress(0)
|
| 42 |
+
status_text = st.empty()
|
| 43 |
+
|
| 44 |
+
while True:
|
| 45 |
+
try:
|
| 46 |
+
status_resp = httpx.get(f"{API_BASE}/status/{job_id}", timeout=10)
|
| 47 |
+
data = status_resp.json()
|
| 48 |
+
except Exception as e:
|
| 49 |
+
st.error(f"Could not poll status: {e}")
|
| 50 |
+
break
|
| 51 |
+
|
| 52 |
+
pct = data.get("progress", 0)
|
| 53 |
+
msg = data.get("message", "")
|
| 54 |
+
state = data.get("status", "running")
|
| 55 |
+
|
| 56 |
+
progress_bar.progress(pct)
|
| 57 |
+
status_text.text(msg)
|
| 58 |
+
|
| 59 |
+
if state == "done":
|
| 60 |
+
st.success(f"Indexed {data['total_rows']} rows from **{uploaded.name}**")
|
| 61 |
+
break
|
| 62 |
+
elif state == "error":
|
| 63 |
+
st.error(f"Error: {data.get('error', 'Unknown error')}")
|
| 64 |
+
break
|
| 65 |
+
|
| 66 |
+
time.sleep(1)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
# Search tab
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
with tab_search:
|
| 73 |
+
st.subheader("Search across indexed documents")
|
| 74 |
+
|
| 75 |
+
query = st.text_input("Enter your search query", placeholder="e.g. high revenue customers in Q3")
|
| 76 |
+
top_k = st.slider("Results to return", min_value=1, max_value=25, value=10)
|
| 77 |
+
|
| 78 |
+
if st.button("Search", type="primary") and query.strip():
|
| 79 |
+
with st.spinner("Searching..."):
|
| 80 |
+
try:
|
| 81 |
+
resp = httpx.post(
|
| 82 |
+
f"{API_BASE}/search",
|
| 83 |
+
json={"query": query, "top_k": top_k},
|
| 84 |
+
timeout=30,
|
| 85 |
+
)
|
| 86 |
+
resp.raise_for_status()
|
| 87 |
+
results = resp.json()["results"]
|
| 88 |
+
except Exception as e:
|
| 89 |
+
st.error(f"Search failed: {e}")
|
| 90 |
+
st.stop()
|
| 91 |
+
|
| 92 |
+
if not results:
|
| 93 |
+
st.warning("No results found.")
|
| 94 |
+
else:
|
| 95 |
+
st.success(f"{len(results)} results")
|
| 96 |
+
for i, r in enumerate(results, 1):
|
| 97 |
+
score = r.pop("score", None)
|
| 98 |
+
source = r.pop("source_file", "unknown")
|
| 99 |
+
with st.expander(f"#{i} β score: {score} | source: {source}"):
|
| 100 |
+
st.json(r)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
# Files tab
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
with tab_files:
|
| 107 |
+
st.subheader("Indexed source files")
|
| 108 |
+
|
| 109 |
+
if st.button("Refresh", type="secondary"):
|
| 110 |
+
st.rerun()
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
resp = httpx.get(f"{API_BASE}/collections", timeout=10)
|
| 114 |
+
resp.raise_for_status()
|
| 115 |
+
files = resp.json()["files"]
|
| 116 |
+
except Exception as e:
|
| 117 |
+
st.error(f"Could not fetch files: {e}")
|
| 118 |
+
files = []
|
| 119 |
+
|
| 120 |
+
if not files:
|
| 121 |
+
st.info("No files indexed yet. Upload one in the Upload tab.")
|
| 122 |
+
else:
|
| 123 |
+
for f in files:
|
| 124 |
+
st.markdown(f"- `{f}`")
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
python-multipart==0.0.12
|
| 4 |
+
streamlit==1.38.0
|
| 5 |
+
openai==1.50.0
|
| 6 |
+
qdrant-client==1.11.3
|
| 7 |
+
pandas==2.2.3
|
| 8 |
+
openpyxl==3.1.5
|
| 9 |
+
python-dotenv==1.0.1
|
| 10 |
+
httpx==0.27.2
|
start.sh
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
uvicorn backend.main:app --host 0.0.0.0 --port 8000 &
|
| 5 |
+
|
| 6 |
+
streamlit run frontend/app.py \
|
| 7 |
+
--server.port 7860 \
|
| 8 |
+
--server.address 0.0.0.0 \
|
| 9 |
+
--server.headless true \
|
| 10 |
+
--server.fileWatcherType none
|