ConstCorrectness commited on
Commit
6e212c2
Β·
1 Parent(s): 15c0fc5
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