File size: 19,366 Bytes
18f908a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
import os, io, base64, time, json, re
from pathlib import Path
from typing import List, Optional
import numpy as np

from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from groq import Groq

# ── Optional heavy deps (graceful fallback) ───────────────────────
try:
    import fitz  # PyMuPDF
    HAS_FITZ = True
except ImportError:
    HAS_FITZ = False

try:
    from sentence_transformers import SentenceTransformer
    import faiss
    embedder = SentenceTransformer("all-MiniLM-L6-v2")
    HAS_EMBEDDER = True
except ImportError:
    HAS_EMBEDDER = False

try:
    import cv2
    HAS_CV2 = True
except ImportError:
    HAS_CV2 = False

from PIL import Image

# ── In-memory knowledge base ──────────────────────────────────────
KB = {
    "files": {},       # filename -> {type, summary, entities, chunks, thumb_b64}
    "chunks": [],      # [{text, source, page, embedding}]
    "index": None,     # FAISS index
}

CHUNK_SIZE = 600  # words per chunk
CHUNK_OVERLAP = 80

# ── Helpers ───────────────────────────────────────────────────────
def chunk_text(text: str, source: str, page: int = 0) -> List[dict]:
    words = text.split()
    chunks = []
    for i in range(0, len(words), CHUNK_SIZE - CHUNK_OVERLAP):
        chunk = " ".join(words[i:i + CHUNK_SIZE])
        if chunk.strip():
            chunks.append({"text": chunk, "source": source, "page": page})
    return chunks

def embed_chunks(chunks: List[dict]) -> np.ndarray:
    if not HAS_EMBEDDER:
        return np.random.rand(len(chunks), 384).astype("float32")
    texts = [c["text"] for c in chunks]
    return embedder.encode(texts, convert_to_numpy=True).astype("float32")

def rebuild_index():
    if not KB["chunks"]:
        KB["index"] = None
        return
    vecs = np.array([c["embedding"] for c in KB["chunks"]], dtype="float32")
    dim = vecs.shape[1]
    if HAS_EMBEDDER:
        index = faiss.IndexFlatIP(dim)
        faiss.normalize_L2(vecs)
        index.add(vecs)
        KB["index"] = index
    else:
        KB["index"] = None

def retrieve(query: str, top_k: int = 8) -> List[dict]:
    if not KB["chunks"]:
        return []
    
    # Vector search if available
    vector_results = []
    if KB["index"] is not None and HAS_EMBEDDER:
        q_vec = embedder.encode([query], convert_to_numpy=True).astype("float32")
        faiss.normalize_L2(q_vec)
        _, indices = KB["index"].search(q_vec, min(top_k, len(KB["chunks"])))
        vector_results = [KB["chunks"][i] for i in indices[0] if i < len(KB["chunks"])]

    # Keyword fallback β€” always run to catch what vector search misses
    query_words = set(query.lower().split())
    keyword_results = []
    for chunk in KB["chunks"]:
        chunk_lower = chunk["text"].lower()
        # score by how many query words appear in chunk
        score = sum(1 for w in query_words if len(w) > 3 and w in chunk_lower)
        if score > 0:
            keyword_results.append((score, chunk))
    keyword_results.sort(key=lambda x: x[0], reverse=True)
    keyword_chunks = [c for _, c in keyword_results[:top_k]]

    # Merge β€” deduplicate by text, prioritize vector results
    seen = set()
    merged = []
    for chunk in vector_results + keyword_chunks:
        key = chunk["text"][:100]
        if key not in seen:
            seen.add(key)
            merged.append(chunk)
    
    return merged[:top_k + 4]  # return a few extra for better coverage

def pil_to_b64(pil_img, max_size=1024) -> str:
    img = pil_img.copy()
    img.thumbnail((max_size, max_size), Image.LANCZOS)
    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=85)
    return base64.b64encode(buf.getvalue()).decode()

def claude_client(api_key: str):
    return Groq(api_key=api_key)

def claude_vision(client, b64_img: str, prompt: str) -> str:
    msg = client.chat.completions.create(
        model="meta-llama/llama-4-scout-17b-16e-instruct",
        max_tokens=1000,
        messages=[{"role": "user", "content": [
            {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_img}"}},
            {"type": "text", "text": prompt}
        ]}]
    )
    return msg.choices[0].message.content

def claude_text(client, prompt: str) -> str:
    msg = client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        max_tokens=1500,
        messages=[{"role": "user", "content": prompt}]
    )
    return msg.choices[0].message.content

# ── File processors ───────────────────────────────────────────────
def process_pdf(raw: bytes, filename: str, client) -> dict:
    chunks = []
    thumb_b64 = None
    if not HAS_FITZ:
        return {"chunks": [{"text": "PDF processing unavailable (PyMuPDF not installed)", "source": filename, "page": 0}], "thumb_b64": None, "raw_text": ""}
    doc = fitz.open(stream=raw, filetype="pdf")
    full_text = ""
    for page_num, page in enumerate(doc):
        # extract text with better layout preservation
        text = page.get_text("text")
        if not text.strip():
            # try blocks mode for scanned/structured PDFs
            text = page.get_text("blocks")
            if isinstance(text, list):
                text = "\n".join([b[4] for b in text if len(b) > 4 and isinstance(b[4], str)])
        full_text += f"\n[Page {page_num + 1}]\n{text}"
        page_chunks = chunk_text(text, filename, page_num + 1)
        chunks.extend(page_chunks)
        if page_num == 0:
            pix = page.get_pixmap(matrix=fitz.Matrix(2.0, 2.0))
            img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
            thumb_b64 = pil_to_b64(img, 300)
    # store up to 80000 chars for large multi-section documents
    return {"chunks": chunks, "thumb_b64": thumb_b64, "raw_text": full_text[:80000]}

def process_image(raw: bytes, filename: str, client) -> dict:
    pil_img = Image.open(io.BytesIO(raw)).convert("RGB")
    b64 = pil_to_b64(pil_img)
    thumb_b64 = pil_to_b64(pil_img, 300)
    description = claude_vision(client, b64, "Describe this image in detail. Extract any text visible. Note objects, people, scenes, data, charts, or diagrams present.")
    chunks = chunk_text(description, filename, 0)
    return {"chunks": chunks, "thumb_b64": thumb_b64, "raw_text": description[:5000]}

def process_video(raw: bytes, filename: str, client) -> dict:
    if not HAS_CV2:
        return {"chunks": [{"text": "Video processing unavailable", "source": filename, "page": 0}], "thumb_b64": None, "raw_text": ""}
    import tempfile
    suffix = Path(filename).suffix or ".mp4"
    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
        tmp.write(raw)
        tmp_path = tmp.name
    try:
        cap = cv2.VideoCapture(tmp_path)
        total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        sample_at = np.linspace(0, max(total - 1, 0), min(6, total), dtype=int)
        descriptions = []
        thumb_b64 = None
        for i, idx in enumerate(sample_at):
            cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
            ret, frame = cap.read()
            if not ret:
                continue
            pil_img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
            b64 = pil_to_b64(pil_img)
            if i == 0:
                thumb_b64 = pil_to_b64(pil_img, 300)
            desc = claude_vision(client, b64, f"Frame {i+1} of a video. Describe what's happening. Note any text, people, objects, or key events.")
            descriptions.append(f"[Frame {i+1}] {desc}")
        cap.release()
        os.unlink(tmp_path)
        full_text = "\n\n".join(descriptions)
        chunks = chunk_text(full_text, filename, 0)
        return {"chunks": chunks, "thumb_b64": thumb_b64, "raw_text": full_text[:5000]}
    except Exception as e:
        os.unlink(tmp_path)
        raise e

def process_text(raw: bytes, filename: str) -> dict:
    text = raw.decode("utf-8", errors="ignore")
    chunks = chunk_text(text, filename, 0)
    return {"chunks": chunks, "thumb_b64": None, "raw_text": text[:5000]}

def summarize_section(client, section_text: str, section_label: str) -> str:
    """Summarize a single section/batch of text."""
    prompt = f"""Summarize this section of a document ({section_label}) in 3-4 sentences. 
Capture all distinct topics, subjects, or subsections mentioned. Be specific, not generic.

Text:
{section_text}"""
    try:
        return claude_text(client, prompt)
    except Exception as e:
        return f"[Could not summarize this section: {e}]"

def generate_summary_and_entities(client, raw_text: str, filename: str) -> dict:
    BATCH_SIZE = 15000  # chars per batch, safely under token limits

    if len(raw_text) <= BATCH_SIZE:
        # Short document β€” single pass
        content = raw_text
        combined_summary_input = content
    else:
        # Long document β€” map-reduce: summarize each batch, then combine
        batches = [raw_text[i:i + BATCH_SIZE] for i in range(0, len(raw_text), BATCH_SIZE)]
        section_summaries = []
        for idx, batch in enumerate(batches):
            label = f"part {idx + 1} of {len(batches)}"
            s = summarize_section(client, batch, label)
            section_summaries.append(f"[{label}]: {s}")
        combined_summary_input = "\n\n".join(section_summaries)

    prompt = f"""Based on the following content (or section summaries) from "{filename}", produce a complete analysis.
This document may cover MULTIPLE topics/sections/subjects β€” make sure your output covers ALL of them, not just the first part.

Respond in JSON only (no markdown):
{{
  "summary": "Detailed 5-8 sentence summary that covers ALL major sections/subjects/topics found in the document",
  "key_entities": ["entity1", "entity2", ...up to 20 entities, covering the whole document],
  "topics": ["topic1", "topic2", ...up to 10 topics covering the full scope],
  "file_type_detected": "what kind of document this is",
  "key_sections": ["list ALL section/chapter/subject titles found in the document"],
  "important_facts": ["fact1", "fact2", ...up to 10 specific facts, drawn from across the ENTIRE document"]
}}

Content:
{combined_summary_input[:14000]}"""
    try:
        result = claude_text(client, prompt)
        clean = result.strip()
        if clean.startswith("```"):
            clean = re.sub(r"```(?:json)?", "", clean).strip().rstrip("`").strip()
        return json.loads(clean)
    except Exception:
        return {"summary": "Could not generate summary.", "key_entities": [], "topics": [], "file_type_detected": "unknown"}

def find_connections(client) -> List[dict]:
    if len(KB["files"]) < 2:
        return []
    summaries = "\n".join([f"- {name}: {info.get('summary','')}" for name, info in KB["files"].items()])
    prompt = f"""Given these documents in a knowledge base, find meaningful connections between them.
Respond in JSON only (no markdown):
[{{"doc1": "filename1", "doc2": "filename2", "connection": "brief description of how they relate"}}]

Documents:
{summaries}"""
    try:
        result = claude_text(client, prompt)
        clean = result.strip()
        if clean.startswith("```"):
            clean = re.sub(r"```(?:json)?", "", clean).strip().rstrip("`").strip()
        return json.loads(clean)
    except Exception:
        return []

# ── FastAPI ───────────────────────────────────────────────────────
app = FastAPI(title="Cortex API")

@app.post("/api/upload")
async def upload(
    file: UploadFile = File(...),
    api_key: str = Form(...)
):
    if not api_key.strip():
        raise HTTPException(400, "API key required")
    raw = await file.read()
    filename = file.filename or "upload"
    ext = Path(filename).suffix.lower()
    client = claude_client(api_key)

    try:
        if ext == ".pdf":
            result = process_pdf(raw, filename, client)
            ftype = "pdf"
        elif ext in {".jpg", ".jpeg", ".png", ".webp", ".gif"}:
            result = process_image(raw, filename, client)
            ftype = "image"
        elif ext in {".mp4", ".mov", ".avi", ".mkv", ".webm"}:
            result = process_video(raw, filename, client)
            ftype = "video"
        else:
            result = process_text(raw, filename)
            ftype = "text"

        meta = generate_summary_and_entities(client, result["raw_text"], filename)
        chunks = result["chunks"]
        if chunks and HAS_EMBEDDER:
            vecs = embed_chunks(chunks)
            for i, c in enumerate(chunks):
                c["embedding"] = vecs[i].tolist()
        else:
            for c in chunks:
                c["embedding"] = [0.0] * 384

        KB["files"][filename] = {
            "type": ftype,
            "summary": meta.get("summary", ""),
            "entities": meta.get("key_entities", []),
            "topics": meta.get("topics", []),
            "key_sections": meta.get("key_sections", []),
            "important_facts": meta.get("important_facts", []),
            "file_type_detected": meta.get("file_type_detected", ""),
            "chunk_count": len(chunks),
            "thumb_b64": result.get("thumb_b64"),
            "preview": result.get("raw_text", "")[:5000],
        }
        old_chunks = [c for c in KB["chunks"] if c["source"] != filename]
        for c in chunks:
            c["embedding"] = np.array(c["embedding"], dtype="float32")
        KB["chunks"] = old_chunks + chunks
        rebuild_index()

        connections = find_connections(client)

        return JSONResponse({
            "filename": filename,
            "type": ftype,
            "summary": meta.get("summary", ""),
            "entities": meta.get("key_entities", []),
            "topics": meta.get("topics", []),
            "key_sections": meta.get("key_sections", []),
            "important_facts": meta.get("important_facts", []),
            "chunk_count": len(chunks),
            "total_chunks": len(KB["chunks"]),
            "connections": connections,
            "thumb_b64": result.get("thumb_b64"),
            "preview": result.get("raw_text", "")[:5000],
        })
    except Exception as e:
        err = str(e)
        if "401" in err or "invalid_api_key" in err or "auth" in err.lower():
            raise HTTPException(401, "Invalid API key")
        raise HTTPException(500, err)


@app.post("/api/ask")
async def ask(query: str = Form(...), api_key: str = Form(...)):
    if not api_key.strip():
        raise HTTPException(400, "API key required")
    if not KB["chunks"]:
        raise HTTPException(400, "No documents indexed yet")
    client = claude_client(api_key)

    # Detect broad "list all / summarize everything" queries
    broad_keywords = ["list all", "all subjects", "all topics", "all courses", "all codes",
                      "all units", "every subject", "complete list", "full list", "what are all",
                      "how many", "entire", "overview", "syllabus contains", "subjects in"]
    query_lower = query.lower()
    is_broad = any(kw in query_lower for kw in broad_keywords)

    if is_broad:
        # For broad queries: use file summaries + first chunk of each page
        # This gives a document-wide view without blowing token limits
        summary_context = []
        for fname, finfo in KB["files"].items():
            summary_context.append(
                f"[FILE: {fname}]\n"
                f"Summary: {finfo.get('summary','')}\n"
                f"Key Sections: {', '.join(finfo.get('key_sections', []))}\n"
                f"Topics: {', '.join(finfo.get('topics', []))}\n"
                f"Entities: {', '.join(finfo.get('key_entities', finfo.get('entities', [])))}"
            )
        # Also grab first chunk from every unique page for full coverage
        seen_pages = set()
        page_chunks = []
        for c in KB["chunks"]:
            key = (c["source"], c.get("page", 0))
            if key not in seen_pages:
                seen_pages.add(key)
                page_chunks.append(f"[{c['source']} p.{c.get('page',0)}] {c['text'][:300]}")
        context = "\n\n".join(summary_context) + "\n\nPER-PAGE EXCERPTS:\n" + "\n".join(page_chunks[:30])
        source_file = list(KB["files"].keys())[0] if KB["files"] else "document"
        source_hint = [{"file": source_file, "page": 0}]
    else:
        # For specific queries: use hybrid vector + keyword retrieval
        relevant = retrieve(query, top_k=10)
        context_parts = []
        for c in relevant[:10]:
            text = c["text"][:800]
            context_parts.append(f"[Source: {c['source']}, Page: {c.get('page',0)}]\n{text}")
        context = "\n\n".join(context_parts)
        source_hint = [{"file": c["source"], "page": c.get("page", 0)} for c in relevant[:3]]

    prompt = f"""You are Cortex, an intelligent document assistant. Answer the question using the provided context.
Be specific, complete and detailed. If the question asks to "list all" something, make sure you include EVERY item found across ALL pages.
Extract exact names, codes, titles from the context β€” do not summarize or omit items.
Only say "not found" if truly absent after checking all context.

Context:
{context[:6000]}

Question: {query}

Respond in JSON only (no markdown backticks):
{{"answer": "complete detailed answer here", "sources": [{{"file": "filename", "page": 0}}], "confidence": "high/medium/low"}}"""
    try:
        result = claude_text(client, prompt)
        clean = result.strip()
        if clean.startswith("```"):
            clean = re.sub(r"```(?:json)?", "", clean).strip().rstrip("`").strip()
        data = json.loads(clean)
        return JSONResponse(data)
    except Exception as e:
        return JSONResponse({"answer": f"Error: {str(e)}", "sources": [], "confidence": "low"})

@app.get("/api/status")
async def status():
    return JSONResponse({
        "files": {k: {**v, "thumb_b64": None} for k, v in KB["files"].items()},
        "total_chunks": len(KB["chunks"]),
        "file_count": len(KB["files"]),
    })

@app.post("/api/connections")
async def connections(api_key: str = Form(...)):
    if not api_key.strip():
        raise HTTPException(400, "API key required")
    client = claude_client(api_key)
    result = find_connections(client)
    return JSONResponse({"connections": result})

@app.delete("/api/file/{filename}")
async def delete_file(filename: str):
    if filename in KB["files"]:
        del KB["files"][filename]
        KB["chunks"] = [c for c in KB["chunks"] if c["source"] != filename]
        rebuild_index()
    return JSONResponse({"ok": True})

STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

@app.get("/")
async def root():
    return FileResponse(str(STATIC_DIR / "index.html"))

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=7860)