File size: 13,090 Bytes
a6913bb
 
 
 
 
a9a9358
a6913bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72697e4
a6913bb
 
a9a9358
a6913bb
 
 
 
 
 
 
 
 
 
a9a9358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a6913bb
 
 
 
a9a9358
a6913bb
 
 
 
 
 
 
a9a9358
a6913bb
 
 
 
 
 
 
 
 
 
 
 
 
a9a9358
a6913bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a9a9358
a6913bb
 
a9a9358
 
 
 
 
a6913bb
 
 
 
a9a9358
 
 
 
 
 
a6913bb
 
a9a9358
 
a6913bb
 
 
a9a9358
 
a6913bb
 
 
 
a9a9358
a6913bb
 
 
a9a9358
a6913bb
 
a9a9358
a6913bb
 
 
 
 
 
 
 
 
a9a9358
a6913bb
 
 
 
 
 
 
 
 
a9a9358
 
 
 
 
a6913bb
a9a9358
a6913bb
 
a9a9358
a6913bb
 
a9a9358
 
 
 
 
 
 
 
 
 
 
 
 
a6913bb
 
 
 
 
a9a9358
 
 
a6913bb
 
a9a9358
 
 
 
 
 
 
 
a6913bb
 
 
a9a9358
 
a6913bb
 
 
 
a9a9358
a6913bb
 
a9a9358
 
a6913bb
 
 
 
 
 
 
a9a9358
 
a6913bb
 
 
 
a9a9358
 
a6913bb
 
 
 
 
 
 
a9a9358
a6913bb
 
a9a9358
 
a6913bb
 
 
 
 
 
 
 
 
 
 
a9a9358
 
 
a6913bb
 
 
 
 
 
200801a
a9a9358
 
 
a6913bb
200801a
a6913bb
a9a9358
a6913bb
 
 
a9a9358
 
a6913bb
 
200801a
a9a9358
 
a6913bb
 
 
a9a9358
 
a6913bb
 
a9a9358
 
 
 
 
a6913bb
 
a9a9358
 
a6913bb
a9a9358
 
a6913bb
 
a9a9358
 
 
 
a6913bb
 
a9a9358
a6913bb
a9a9358
 
a6913bb
 
a9a9358
a6913bb
a9a9358
 
a6913bb
 
a9a9358
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# app.py
import os
import asyncio
import json
import hashlib
import shutil
from io import BytesIO, StringIO
from typing import List, Tuple

import gradio as gr
import numpy as np
import faiss
import requests
import pandas as pd
from sentence_transformers import SentenceTransformer
import fitz  # PyMuPDF
import docx
from pptx import Presentation
from crawl4ai import AsyncWebCrawler

# ---------------- Config ----------------
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_MODEL = "nvidia/nemotron-nano-12b-v2-vl:free"
EMBEDDING_MODEL_NAME = "all-MiniLM-L6-v2"
CACHE_DIR = "./cache"
SYSTEM_PROMPT = "You are a helpful assistant."
os.makedirs(CACHE_DIR, exist_ok=True)

embedder = SentenceTransformer(EMBEDDING_MODEL_NAME)

DOCS: List[str] = []
FILENAMES: List[str] = []
EMBEDDINGS: np.ndarray = None
FAISS_INDEX = None
CURRENT_CACHE_KEY: str = ""


# ---------------- Periodic cache cleanup ----------------
async def clear_cache_every_5min():
    while True:
        await asyncio.sleep(300)  # 5 minutes
        try:
            if os.path.exists(CACHE_DIR):
                shutil.rmtree(CACHE_DIR)
            os.makedirs(CACHE_DIR, exist_ok=True)
            print("🧹 Cache cleared successfully.")
        except Exception as e:
            print(f"[Cache cleanup error] {e}")

# Launch the cleaner in background
asyncio.get_event_loop().create_task(clear_cache_every_5min())


# ---------------- File extraction helpers ----------------
def extract_text_from_pdf(file_bytes: bytes) -> str:
    try:
        doc = fitz.open(stream=file_bytes, filetype="pdf")
        return "\n".join(page.get_text() for page in doc)
    except Exception as e:
        return f"[PDF extraction error] {e}"

def extract_text_from_docx(file_bytes: bytes) -> str:
    try:
        f = BytesIO(file_bytes)
        doc = docx.Document(f)
        return "\n".join(p.text for p in doc.paragraphs)
    except Exception as e:
        return f"[DOCX extraction error] {e}"

def extract_text_from_txt(file_bytes: bytes) -> str:
    try:
        return file_bytes.decode("utf-8", errors="ignore")
    except Exception as e:
        return f"[TXT extraction error] {e}"

def extract_text_from_excel(file_bytes: bytes) -> str:
    try:
        f = BytesIO(file_bytes)
        df = pd.read_excel(f, dtype=str)
        return "\n".join("\n".join(df[col].fillna("").astype(str).tolist()) for col in df.columns)
    except Exception as e:
        return f"[EXCEL extraction error] {e}"

def extract_text_from_pptx(file_bytes: bytes) -> str:
    try:
        f = BytesIO(file_bytes)
        prs = Presentation(f)
        texts = []
        for slide in prs.slides:
            for shape in slide.shapes:
                if hasattr(shape, "text"):
                    texts.append(shape.text)
        return "\n".join(texts)
    except Exception as e:
        return f"[PPTX extraction error] {e}"

def extract_text_from_csv(file_bytes: bytes) -> str:
    try:
        f = StringIO(file_bytes.decode("utf-8", errors="ignore"))
        df = pd.read_csv(f, dtype=str)
        return df.to_string(index=False)
    except Exception as e:
        return f"[CSV extraction error] {e}"

def extract_text_from_file_tuple(file_tuple) -> Tuple[str, bytes]:
    try:
        if hasattr(file_tuple, "name") and hasattr(file_tuple, "read"):
            return os.path.basename(file_tuple.name), file_tuple.read()
    except Exception:
        pass
    if isinstance(file_tuple, tuple) and len(file_tuple) == 2 and isinstance(file_tuple[1], (bytes, bytearray)):
        return file_tuple[0], bytes(file_tuple[1])
    if isinstance(file_tuple, str) and os.path.exists(file_tuple):
        with open(file_tuple, "rb") as fh:
            return os.path.basename(file_tuple), fh.read()
    raise ValueError("Unsupported file object passed by Gradio.")

def extract_text_by_ext(filename: str, file_bytes: bytes) -> str:
    name = filename.lower()
    if name.endswith(".pdf"): return extract_text_from_pdf(file_bytes)
    if name.endswith(".docx"): return extract_text_from_docx(file_bytes)
    if name.endswith(".txt"): return extract_text_from_txt(file_bytes)
    if name.endswith((".xlsx", ".xls")): return extract_text_from_excel(file_bytes)
    if name.endswith(".pptx"): return extract_text_from_pptx(file_bytes)
    if name.endswith(".csv"): return extract_text_from_csv(file_bytes)
    return extract_text_from_txt(file_bytes)


# ---------------- Cache + FAISS helpers ----------------
def make_cache_key_for_files(files: List[Tuple[str, bytes]]) -> str:
    h = hashlib.sha256()
    for name, b in sorted(files, key=lambda x: x[0]):
        h.update(name.encode())
        h.update(str(len(b)).encode())
        h.update(hashlib.sha256(b).digest())
    return h.hexdigest()

def cache_save_embeddings(cache_key: str, embeddings: np.ndarray, filenames: List[str]):
    np.savez_compressed(os.path.join(CACHE_DIR, f"{cache_key}.npz"), embeddings=embeddings, filenames=np.array(filenames))

def cache_load_embeddings(cache_key: str):
    path = os.path.join(CACHE_DIR, f"{cache_key}.npz")
    if not os.path.exists(path): return None
    try:
        arr = np.load(path, allow_pickle=True)
        return arr["embeddings"], arr["filenames"].tolist()
    except Exception:
        return None

def build_faiss_index(embeddings: np.ndarray):
    global FAISS_INDEX
    if embeddings is None or len(embeddings) == 0:
        FAISS_INDEX = None
        return None
    emb = embeddings.astype("float32")
    index = faiss.IndexFlatL2(emb.shape[1])
    index.add(emb)
    FAISS_INDEX = index
    return index

def search_top_k(query: str, k: int = 3):
    if FAISS_INDEX is None:
        return []
    q_emb = embedder.encode([query], convert_to_numpy=True).astype("float32")
    D, I = FAISS_INDEX.search(q_emb, k)
    return [{"index": int(i), "distance": float(d), "text": DOCS[i], "source": FILENAMES[i]} for d, i in zip(D[0], I[0]) if i >= 0]


# ---------------- OpenRouter Client ----------------
def openrouter_chat_system_user(user_prompt: str):
    """
    Sends user prompt to OpenRouter and expects a plain text response.
    """
    if not OPENROUTER_API_KEY:
        return "[OpenRouter error] Missing OPENROUTER_API_KEY."

    url = "https://openrouter.ai/api/v1/chat/completions"
    headers = {
        "Authorization": f"Bearer {OPENROUTER_API_KEY}",
        "Content-Type": "application/json",
    }

    # Tell the model explicitly to reply as plain text only
    payload = {
        "model": OPENROUTER_MODEL,
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT + " Always respond in plain text. Avoid JSON or markdown formatting."},
            {"role": "user", "content": user_prompt},
        ],
    }

    try:
        r = requests.post(url, headers=headers, json=payload, timeout=60)
        r.raise_for_status()
        obj = r.json()

        # Safely extract plain text
        if "choices" in obj and obj["choices"]:
            choice = obj["choices"][0]
            if "message" in choice and "content" in choice["message"]:
                text = choice["message"]["content"]
                # Ensure no markdown or code blocks
                text = text.strip().replace("```", "").replace("json", "")
                return text
            elif "text" in choice:
                return choice["text"].strip()
        return "[OpenRouter] Unexpected response format."

    except Exception as e:
        return f"[OpenRouter request error] {e}"


# ---------------- Crawl4AI Logic ----------------
async def _crawl_async_get_markdown(url: str):
    async with AsyncWebCrawler() as crawler:
        result = await crawler.arun(url=url)
        if hasattr(result, "success") and result.success is False:
            return f"[Crawl4AI error] {getattr(result, 'error_message', '[Unknown error]')}"
        md_obj = getattr(result, "markdown", None)
        if md_obj:
            return getattr(md_obj, "fit_markdown", None) or getattr(md_obj, "raw_markdown", None) or str(md_obj)
        return getattr(result, "text", None) or getattr(result, "html", None) or "[Crawl4AI returned no usable fields]"

def crawl_url_sync(url: str) -> str:
    try:
        return asyncio.run(_crawl_async_get_markdown(url))
    except Exception as e:
        return f"[Crawl4AI runtime error] {e}"


# ---------------- Gradio Handlers ----------------
def upload_and_index(files):
    global DOCS, FILENAMES, EMBEDDINGS, CURRENT_CACHE_KEY
    if not files:
        return "No files uploaded.", ""
    prepared = [(name := extract_text_from_file_tuple(f)[0], extract_text_from_file_tuple(f)[1]) for f in files]
    previews = [{"name": n, "size": len(b)} for n, b in prepared]
    cache_key = make_cache_key_for_files(prepared)
    CURRENT_CACHE_KEY = cache_key
    cached = cache_load_embeddings(cache_key)
    if cached:
        emb, filenames = cached
        EMBEDDINGS = np.array(emb)
        FILENAMES = filenames
        DOCS = [extract_text_by_ext(n, b) for n, b in prepared]
        build_faiss_index(EMBEDDINGS)
        return f"Loaded embeddings from cache ({len(FILENAMES)} docs).", json.dumps(previews)
    DOCS, FILENAMES = zip(*[(extract_text_by_ext(n, b), n) for n, b in prepared])
    EMBEDDINGS = embedder.encode(DOCS, convert_to_numpy=True, show_progress_bar=False).astype("float32")
    cache_save_embeddings(cache_key, EMBEDDINGS, FILENAMES)
    build_faiss_index(EMBEDDINGS)
    return f"Uploaded and indexed {len(DOCS)} documents.", json.dumps(previews)

def crawl_and_index(url: str):
    global DOCS, FILENAMES, EMBEDDINGS, CURRENT_CACHE_KEY
    if not url:
        return "No URL provided.", ""
    crawled = crawl_url_sync(url)
    if crawled.startswith("[Crawl4AI"):
        return crawled, ""
    key_hash = hashlib.sha256((url + crawled).encode()).hexdigest()
    CURRENT_CACHE_KEY = key_hash
    cached = cache_load_embeddings(key_hash)
    if cached:
        emb, filenames = cached
        EMBEDDINGS = np.array(emb)
        FILENAMES = filenames
        DOCS = [crawled]
        build_faiss_index(EMBEDDINGS)
        return f"Crawled and loaded embeddings from cache for {url}", crawled[:20000]
    DOCS, FILENAMES = [crawled], [url]
    EMBEDDINGS = embedder.encode(DOCS, convert_to_numpy=True, show_progress_bar=False).astype("float32")
    cache_save_embeddings(key_hash, EMBEDDINGS, FILENAMES)
    build_faiss_index(EMBEDDINGS)
    return f"Crawled and indexed {url}", crawled[:20000]

def ask_question(question: str):
    if not question:
        return "Please enter a question."
    if not DOCS or FAISS_INDEX is None:
        return "No indexed data found."
    results = search_top_k(question, k=3)
    if not results:
        return "No relevant documents found."
    context = "\n".join(f"Source: {r['source']}\n\n{r['text'][:18000]}\n---\n" for r in results)
    user_prompt = f"Use the following context to answer the question.\n\nContext:\n{context}\nQuestion: {question}\nAnswer:"
    return openrouter_chat_system_user(user_prompt)


# ---------------- Gradio UI ----------------
with gr.Blocks(title="AI Ally — Crawl4AI + OpenRouter + FAISS") as demo:
    gr.Markdown("# 🤖 AI Ally — Document & Website QA\nCrawl4AI for websites, file uploads for docs. FAISS retrieval + sentence-transformers + OpenRouter LLM.")

    with gr.Tab("Documents"):
        file_input = gr.File(label="Upload files", file_count="multiple",
                             file_types=[".pdf", ".docx", ".txt", ".xlsx", ".pptx", ".csv"])
        upload_btn = gr.Button("Upload & Index")
        upload_status = gr.Textbox(label="Status", interactive=False)
        preview_box = gr.Textbox(label="Uploads (preview JSON)", interactive=False)
        upload_btn.click(upload_and_index, inputs=[file_input], outputs=[upload_status, preview_box])

        gr.Markdown("### Ask about your documents")
        q = gr.Textbox(label="Question", lines=3)
        ask_btn = gr.Button("Ask")
        answer_out = gr.Textbox(label="Answer", interactive=False, lines=15)
        ask_btn.click(ask_question, inputs=[q], outputs=[answer_out])

    with gr.Tab("Website Crawl"):
        url = gr.Textbox(label="URL to crawl")
        crawl_btn = gr.Button("Crawl & Index")
        crawl_status = gr.Textbox(label="Status", interactive=False)
        crawl_preview = gr.Textbox(label="Crawl preview", interactive=False)
        crawl_btn.click(crawl_and_index, inputs=[url], outputs=[crawl_status, crawl_preview])

        q2 = gr.Textbox(label="Question", lines=3)
        ask_btn2 = gr.Button("Ask site")
        answer_out2 = gr.Textbox(label="Answer", interactive=False, lines=15)
        ask_btn2.click(ask_question, inputs=[q2], outputs=[answer_out2])

    with gr.Tab("Settings / Info"):
        gr.Markdown(f"- Model: `{OPENROUTER_MODEL}`")
        gr.Markdown(f"- Embedding model: `{EMBEDDING_MODEL_NAME}`")
        gr.Markdown(f"- Cache clears automatically every 5 minutes.")
        gr.Markdown(f"- System prompt is fixed internally: `{SYSTEM_PROMPT}`")

if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=7860, debug=True)