File size: 6,404 Bytes
b9fa4a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import json
from pathlib import Path

from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel

from pdf_to_text import ExtractionResult, extract_uploaded_pdf, stream_uploaded_pdf
from rag_pipeline import SmartNotesRAG


BASE_DIR = Path(__file__).resolve().parent
PROJECT_METADATA = {
    "name": "SmartNotes AI",
    "title": "SmartNotes PDF OCR",
    "version": "1.0.0",
    "description": "FastAPI app for PDF OCR, text extraction, and RAG-based question answering.",
    "runtime": "Python 3.12",
    "framework": "FastAPI",
    "default_port": 8000,
    "entrypoint": "app.py",
    "storage": "SQLite local fallback with optional PostgreSQL",
    "vector_store": "Local fallback with optional Qdrant",
    "features": [
        "PDF direct text extraction",
        "OCR fallback with OpenCV, Gemini Vision OCR, and TrOCR",
        "RAG indexing with parent and child chunks",
        "Hybrid retrieval with embeddings and BM25",
        "Reranked answers with citations",
    ],
}

app = FastAPI(
    title=PROJECT_METADATA["title"],
    description=PROJECT_METADATA["description"],
    version=PROJECT_METADATA["version"],
)
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=BASE_DIR / "templates")
rag = SmartNotesRAG()


class IndexTextRequest(BaseModel):
    file_name: str
    text: str


class QueryRequest(BaseModel):
    document_id: str
    query: str


class FeedbackRequest(BaseModel):
    document_id: str
    query: str
    rating: int
    comment: str = ""


@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    return templates.TemplateResponse(request, "index.html")


@app.get("/api/metadata")
async def project_metadata():
    return PROJECT_METADATA


@app.post("/api/pdf-to-text")
async def pdf_to_text(file: UploadFile = File(...)):
    if not _looks_like_pdf(file.filename, file.content_type):
        raise HTTPException(status_code=400, detail="Please upload a PDF file.")

    file_bytes = await file.read()
    if not file_bytes:
        raise HTTPException(status_code=400, detail="Uploaded PDF is empty.")

    try:
        result = extract_uploaded_pdf(file.filename, file_bytes)
    except RuntimeError as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"PDF processing failed: {exc}") from exc

    return _result_payload(result, original_file=file.filename)


@app.post("/api/pdf-to-text-stream")
async def pdf_to_text_stream(file: UploadFile = File(...)):
    if not _looks_like_pdf(file.filename, file.content_type):
        raise HTTPException(status_code=400, detail="Please upload a PDF file.")

    original_file = file.filename
    file_bytes = await file.read()
    if not file_bytes:
        raise HTTPException(status_code=400, detail="Uploaded PDF is empty.")

    def events():
        try:
            for event in stream_uploaded_pdf(original_file, file_bytes):
                yield json.dumps(event, ensure_ascii=True) + "\n"
        except Exception as exc:
            yield json.dumps(
                {
                    "type": "error",
                    "message": f"PDF processing failed: {exc}",
                },
                ensure_ascii=True,
            ) + "\n"

    return StreamingResponse(events(), media_type="application/x-ndjson")


@app.post("/api/index-text-stream")
async def index_text_stream(payload: IndexTextRequest):
    if not payload.text.strip():
        raise HTTPException(status_code=400, detail="Extracted text is empty.")

    def events():
        try:
            for event in rag.index_text_stream(payload.file_name, payload.text):
                yield json.dumps(event, ensure_ascii=True) + "\n"
        except Exception as exc:
            yield json.dumps(
                {
                    "type": "error",
                    "message": f"Indexing failed: {exc}",
                },
                ensure_ascii=True,
            ) + "\n"

    return StreamingResponse(events(), media_type="application/x-ndjson")


@app.post("/api/index-text-background")
async def index_text_background(payload: IndexTextRequest, background_tasks: BackgroundTasks):
    if not payload.text.strip():
        raise HTTPException(status_code=400, detail="Extracted text is empty.")

    def run_indexing() -> None:
        for _ in rag.index_text_stream(payload.file_name, payload.text):
            pass

    background_tasks.add_task(run_indexing)
    return {"status": "queued"}


@app.post("/api/query")
async def query_document(payload: QueryRequest):
    return rag.query(payload.document_id, payload.query)


@app.post("/api/feedback")
async def save_feedback(payload: FeedbackRequest):
    rating = max(1, min(payload.rating, 5))
    return rag.save_feedback(payload.document_id, payload.query, rating, payload.comment)


@app.get("/api/documents/{document_id}")
async def get_document(document_id: str):
    document = rag.repository.get_document(document_id)
    if not document:
        raise HTTPException(status_code=404, detail="Document not found.")
    document.pop("cleaned_text", None)
    return document


@app.get("/health")
async def health():
    return {"status": "ok"}


def _result_payload(result: ExtractionResult, original_file: str) -> dict:
    return {
        "file": original_file,
        "text": result.text,
        "route": result.route,
        "page_count": result.page_count,
        "direct_text_found": result.direct_text_found,
        "warnings": result.warnings,
        "pages": [
            {
                "page_number": page.page_number,
                "engine": page.engine,
                "confidence": page.confidence,
                "text_length": len(page.text),
            }
            for page in result.pages
        ],
    }


def _looks_like_pdf(file_name: str | None, content_type: str | None) -> bool:
    if content_type in {"application/pdf", "application/x-pdf"}:
        return True
    if file_name and Path(file_name).suffix.lower() == ".pdf":
        return True
    return False