Spaces:
Sleeping
Sleeping
Upload 5 files
Browse filesAdding all Files To my Space
- Dockerfile +23 -0
- README.md +183 -11
- app.py +602 -59
- requirements.txt +15 -0
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
ENV PIP_NO_CACHE_DIR=1
|
| 8 |
+
|
| 9 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
+
build-essential \
|
| 11 |
+
curl \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
EXPOSE 8501
|
| 20 |
+
|
| 21 |
+
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1
|
| 22 |
+
|
| 23 |
+
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
README.md
CHANGED
|
@@ -1,17 +1,189 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
hf_oauth: true
|
| 11 |
-
hf_oauth_scopes:
|
| 12 |
-
- inference-api
|
| 13 |
license: mit
|
| 14 |
-
short_description: Chat with documents using AI summaries and cited answers
|
| 15 |
---
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Document Intelligence RAG Assistant
|
| 3 |
+
emoji: 📄
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.40.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
|
|
|
|
|
|
| 10 |
license: mit
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Document Intelligence RAG Assistant
|
| 14 |
+
|
| 15 |
+
Upload documents, generate summaries, and ask grounded questions using Retrieval-Augmented Generation.
|
| 16 |
+
|
| 17 |
+
## Demo
|
| 18 |
+
|
| 19 |
+
[Live Demo](YOUR_HUGGING_FACE_SPACE_URL)
|
| 20 |
+
|
| 21 |
+
Screenshots:
|
| 22 |
+
|
| 23 |
+
- `assets/app_home.png`
|
| 24 |
+
- `assets/document_processing.png`
|
| 25 |
+
- `assets/chat_with_sources.png`
|
| 26 |
+
|
| 27 |
+
## Problem Statement
|
| 28 |
+
|
| 29 |
+
Professionals often need to quickly understand long PDFs, policies, resumes, contracts, research papers, reports, meeting notes, and business documents. Reading every page manually is slow, and generic chatbots can hallucinate when they are not grounded in the source material.
|
| 30 |
+
|
| 31 |
+
Document Intelligence RAG Assistant helps users upload documents, generate structured summaries, ask questions, inspect source citations, and export useful outputs from a clean Streamlit interface.
|
| 32 |
+
|
| 33 |
+
## Key Features
|
| 34 |
+
|
| 35 |
+
- PDF, TXT, and DOCX upload
|
| 36 |
+
- Document summarization with multiple modes
|
| 37 |
+
- Question answering over uploaded files
|
| 38 |
+
- FAISS vector search
|
| 39 |
+
- HuggingFace sentence-transformer embeddings
|
| 40 |
+
- Groq / LLM-powered generation
|
| 41 |
+
- Source citations with file, page, chunk, and preview text
|
| 42 |
+
- Chat history within the current session
|
| 43 |
+
- Export summary and chat history as Markdown
|
| 44 |
+
- Streamlit user interface
|
| 45 |
+
- Hugging Face Spaces deployment support
|
| 46 |
+
|
| 47 |
+
## Architecture
|
| 48 |
+
|
| 49 |
+
The app follows a lightweight RAG pipeline:
|
| 50 |
+
|
| 51 |
+
Document Upload -> Text Extraction -> Chunking -> Embeddings -> FAISS Vector Store -> Retriever -> LLM -> Grounded Answer with Sources
|
| 52 |
+
|
| 53 |
+
```mermaid
|
| 54 |
+
flowchart LR
|
| 55 |
+
A[Upload Documents] --> B[Extract Text]
|
| 56 |
+
B --> C[Split into Chunks]
|
| 57 |
+
C --> D[Generate Embeddings]
|
| 58 |
+
D --> E[FAISS Vector Store]
|
| 59 |
+
E --> F[Retriever]
|
| 60 |
+
F --> G[LLM]
|
| 61 |
+
G --> H[Answer with Citations]
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Tech Stack
|
| 65 |
+
|
| 66 |
+
- Python
|
| 67 |
+
- Streamlit
|
| 68 |
+
- LangChain
|
| 69 |
+
- FAISS
|
| 70 |
+
- HuggingFace sentence-transformers
|
| 71 |
+
- Groq API / LLM provider
|
| 72 |
+
- PyPDF and document loaders
|
| 73 |
+
- Docker / Hugging Face Spaces
|
| 74 |
+
|
| 75 |
+
## Local Setup
|
| 76 |
+
|
| 77 |
+
1. Clone the repository:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
git clone YOUR_REPOSITORY_URL
|
| 81 |
+
cd YOUR_REPOSITORY_NAME
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
2. Create and activate a virtual environment:
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
python -m venv .venv
|
| 88 |
+
.venv\Scripts\activate
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
On macOS/Linux:
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
python -m venv .venv
|
| 95 |
+
source .venv/bin/activate
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
3. Install dependencies:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
pip install -r requirements.txt
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
4. Create a `.env` file:
|
| 105 |
+
|
| 106 |
+
```env
|
| 107 |
+
GROQ_API_KEY=your_api_key_here
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
5. Run the app:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
streamlit run app.py
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
## Hugging Face Spaces Deployment
|
| 117 |
+
|
| 118 |
+
1. Create a new Hugging Face Space.
|
| 119 |
+
2. Choose Streamlit as the Space SDK.
|
| 120 |
+
3. Upload `app.py`, `README.md`, `requirements.txt`, `Dockerfile`, `.gitattributes`, and the `assets/` folder.
|
| 121 |
+
4. Add `GROQ_API_KEY` under Space Settings -> Secrets.
|
| 122 |
+
5. Confirm `app.py` is the entry point.
|
| 123 |
+
6. Restart the Space if dependencies are updated.
|
| 124 |
+
|
| 125 |
+
The app is designed to run on Hugging Face Spaces CPU. It uses CPU-compatible embeddings and avoids GPU-only dependencies.
|
| 126 |
+
|
| 127 |
+
## Usage Guide
|
| 128 |
+
|
| 129 |
+
1. Upload one or more PDF, TXT, or DOCX files.
|
| 130 |
+
2. Click **Process documents** to extract text, chunk content, generate embeddings, and build a FAISS index.
|
| 131 |
+
3. Choose a summary mode and click **Generate summary**.
|
| 132 |
+
4. Ask document-specific questions in the chat tab.
|
| 133 |
+
5. Open source citations under each answer to inspect retrieved evidence.
|
| 134 |
+
6. Export the summary or chat history as Markdown.
|
| 135 |
+
|
| 136 |
+
## Example Questions
|
| 137 |
+
|
| 138 |
+
- "Summarize this document in 5 bullet points."
|
| 139 |
+
- "What are the key risks mentioned?"
|
| 140 |
+
- "Extract all dates, people, and organizations."
|
| 141 |
+
- "What are the main recommendations?"
|
| 142 |
+
- "Explain the document for a non-technical audience."
|
| 143 |
+
- "What evidence supports this answer?"
|
| 144 |
+
|
| 145 |
+
## Project Structure
|
| 146 |
+
|
| 147 |
+
```text
|
| 148 |
+
.
|
| 149 |
+
├── app.py
|
| 150 |
+
├── README.md
|
| 151 |
+
├── requirements.txt
|
| 152 |
+
├── Dockerfile
|
| 153 |
+
├── .gitattributes
|
| 154 |
+
└── assets/
|
| 155 |
+
└── .gitkeep
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
## Privacy and Security
|
| 159 |
+
|
| 160 |
+
- API keys are read from Hugging Face Space secrets, environment variables, or a password input field.
|
| 161 |
+
- Uploaded documents are processed through temporary files and are not intentionally stored by the app.
|
| 162 |
+
- The FAISS index and chat history live only in the active Streamlit session.
|
| 163 |
+
- Users should avoid uploading highly sensitive documents to public demo deployments.
|
| 164 |
+
|
| 165 |
+
## Limitations
|
| 166 |
+
|
| 167 |
+
- Quality depends on uploaded document text extraction.
|
| 168 |
+
- Scanned PDFs may require OCR, which is not included in this version.
|
| 169 |
+
- LLM answers are grounded in retrieved chunks, but users should verify important outputs.
|
| 170 |
+
- Free Hugging Face Spaces may have CPU and memory limits.
|
| 171 |
+
- Very large documents may need smaller files or reduced chunk settings.
|
| 172 |
+
|
| 173 |
+
## Future Improvements
|
| 174 |
+
|
| 175 |
+
- OCR for scanned PDFs
|
| 176 |
+
- Multi-document comparison
|
| 177 |
+
- Persistent vector database option
|
| 178 |
+
- User authentication
|
| 179 |
+
- Retrieval quality evaluation metrics
|
| 180 |
+
- CSV, Excel, and HTML support
|
| 181 |
+
- Better citation highlighting inside source documents
|
| 182 |
+
|
| 183 |
+
## Why This Project Matters
|
| 184 |
+
|
| 185 |
+
This project demonstrates practical skills across RAG architecture, document NLP, vector search, LLM integration, Streamlit product UI, Hugging Face deployment, and AI-assisted workflow automation. It is relevant for Data Analyst, Data Scientist, AI Engineer, and RAG Engineer portfolios because it turns unstructured documents into searchable, explainable, and exportable insights.
|
| 186 |
+
|
| 187 |
+
## License
|
| 188 |
+
|
| 189 |
+
MIT License.
|
app.py
CHANGED
|
@@ -1,69 +1,612 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
system_message,
|
| 9 |
-
max_tokens,
|
| 10 |
-
temperature,
|
| 11 |
-
top_p,
|
| 12 |
-
hf_token: gr.OAuthToken,
|
| 13 |
-
):
|
| 14 |
-
"""
|
| 15 |
-
For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
|
| 16 |
-
"""
|
| 17 |
-
client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
temperature=temperature,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import os
|
| 3 |
+
import tempfile
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Iterable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
import streamlit as st
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
from langchain_community.document_loaders import Docx2txtLoader, PyPDFLoader, TextLoader
|
| 12 |
+
from langchain_community.vectorstores import FAISS
|
| 13 |
+
from langchain_core.documents import Document
|
| 14 |
+
from langchain_core.messages import AIMessage, HumanMessage
|
| 15 |
+
from langchain_core.output_parsers import StrOutputParser
|
| 16 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 17 |
+
from langchain_groq import ChatGroq
|
| 18 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 19 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 20 |
|
| 21 |
+
load_dotenv()
|
| 22 |
|
| 23 |
+
st.set_page_config(
|
| 24 |
+
page_title="Document Intelligence RAG Assistant",
|
| 25 |
+
page_icon="DI",
|
| 26 |
+
layout="wide",
|
| 27 |
+
initial_sidebar_state="expanded",
|
| 28 |
+
)
|
| 29 |
|
| 30 |
+
APP_TITLE = "Document Intelligence RAG Assistant"
|
| 31 |
+
SUPPORTED_EXTENSIONS = {"pdf", "txt", "docx"}
|
| 32 |
+
MAX_FILE_SIZE_MB = 25
|
| 33 |
+
MAX_CONTEXT_CHARS = 14_000
|
| 34 |
+
MAX_SUMMARY_CHARS = 28_000
|
| 35 |
+
MAX_CHAT_TURNS = 16
|
| 36 |
|
| 37 |
+
MODEL_OPTIONS = {
|
| 38 |
+
"Llama 3.1 8B - fast": "llama-3.1-8b-instant",
|
| 39 |
+
"Llama 3.3 70B - stronger reasoning": "llama-3.3-70b-versatile",
|
| 40 |
+
"Gemma2 9B - efficient": "gemma2-9b-it",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
SUMMARY_MODES = {
|
| 44 |
+
"Executive summary": "Write a concise executive summary for a busy professional. Include the core message, major conclusions, and practical implications.",
|
| 45 |
+
"Bullet-point summary": "Summarize the document as clear bullet points. Prioritize facts, decisions, risks, and recommendations.",
|
| 46 |
+
"Detailed study notes": "Create structured study notes with headings, definitions, important details, and takeaways.",
|
| 47 |
+
"Key facts / entities / dates": "Extract important facts, people, organizations, locations, dates, metrics, obligations, and deadlines.",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
EXAMPLE_QUESTIONS = [
|
| 51 |
+
"Summarize this document in 5 bullet points.",
|
| 52 |
+
"What are the key risks mentioned?",
|
| 53 |
+
"Extract action items and owners.",
|
| 54 |
+
"List important dates, people, and organizations.",
|
| 55 |
+
"Explain this for a non-technical audience.",
|
| 56 |
+
"What evidence supports the main recommendation?",
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def init_state() -> None:
|
| 61 |
+
defaults = {
|
| 62 |
+
"vectorstore": None,
|
| 63 |
+
"documents": [],
|
| 64 |
+
"chunks": [],
|
| 65 |
+
"raw_text": "",
|
| 66 |
+
"file_hash": "",
|
| 67 |
+
"doc_stats": {},
|
| 68 |
+
"messages": [],
|
| 69 |
+
"chat_history": [],
|
| 70 |
+
"latest_sources": [],
|
| 71 |
+
"summary": "",
|
| 72 |
+
"summary_mode": "",
|
| 73 |
+
}
|
| 74 |
+
for key, value in defaults.items():
|
| 75 |
+
if key not in st.session_state:
|
| 76 |
+
st.session_state[key] = value
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
init_state()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
st.markdown(
|
| 83 |
+
"""
|
| 84 |
+
<style>
|
| 85 |
+
.block-container {padding-top: 1.5rem; max-width: 1240px;}
|
| 86 |
+
.app-hero {
|
| 87 |
+
border: 1px solid #e6e8ef;
|
| 88 |
+
border-radius: 8px;
|
| 89 |
+
padding: 1.1rem 1.25rem;
|
| 90 |
+
background: #ffffff;
|
| 91 |
+
margin-bottom: 1rem;
|
| 92 |
+
}
|
| 93 |
+
.app-hero h1 {font-size: 2.05rem; margin-bottom: .2rem;}
|
| 94 |
+
.app-hero p {color: #4b5563; margin-bottom: 0;}
|
| 95 |
+
.small-muted {color: #6b7280; font-size: .92rem;}
|
| 96 |
+
.source-box {
|
| 97 |
+
border-left: 3px solid #2563eb;
|
| 98 |
+
padding: .65rem .85rem;
|
| 99 |
+
background: #f8fafc;
|
| 100 |
+
border-radius: 6px;
|
| 101 |
+
margin: .4rem 0;
|
| 102 |
+
}
|
| 103 |
+
.chip-row {display: flex; flex-wrap: wrap; gap: .45rem; margin: .5rem 0 1rem 0;}
|
| 104 |
+
.chip {
|
| 105 |
+
border: 1px solid #d8dee9;
|
| 106 |
+
border-radius: 999px;
|
| 107 |
+
padding: .35rem .7rem;
|
| 108 |
+
background: #f9fafb;
|
| 109 |
+
color: #374151;
|
| 110 |
+
font-size: .88rem;
|
| 111 |
+
}
|
| 112 |
+
footer {visibility: hidden;}
|
| 113 |
+
</style>
|
| 114 |
+
""",
|
| 115 |
+
unsafe_allow_html=True,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@st.cache_resource(show_spinner=False)
|
| 120 |
+
def get_embeddings() -> HuggingFaceEmbeddings:
|
| 121 |
+
return HuggingFaceEmbeddings(
|
| 122 |
+
model_name="sentence-transformers/all-MiniLM-L6-v2",
|
| 123 |
+
model_kwargs={"device": "cpu"},
|
| 124 |
+
encode_kwargs={"normalize_embeddings": True, "batch_size": 32},
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def get_llm(api_key: str, model: str, temperature: float, max_tokens: int = 2048) -> ChatGroq:
|
| 129 |
+
return ChatGroq(
|
| 130 |
+
groq_api_key=api_key,
|
| 131 |
+
model_name=model,
|
| 132 |
temperature=temperature,
|
| 133 |
+
max_tokens=max_tokens,
|
| 134 |
+
max_retries=2,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def file_fingerprint(files: Iterable) -> str:
|
| 139 |
+
digest = hashlib.sha256()
|
| 140 |
+
for file in files:
|
| 141 |
+
digest.update(file.name.encode("utf-8"))
|
| 142 |
+
digest.update(str(file.size).encode("utf-8"))
|
| 143 |
+
digest.update(file.getvalue()[:4096])
|
| 144 |
+
return digest.hexdigest()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def validate_files(files: list) -> list[str]:
|
| 148 |
+
errors = []
|
| 149 |
+
for file in files:
|
| 150 |
+
suffix = Path(file.name).suffix.lower().lstrip(".")
|
| 151 |
+
if suffix not in SUPPORTED_EXTENSIONS:
|
| 152 |
+
errors.append(f"{file.name}: unsupported file type.")
|
| 153 |
+
if file.size > MAX_FILE_SIZE_MB * 1024 * 1024:
|
| 154 |
+
errors.append(f"{file.name}: file is larger than {MAX_FILE_SIZE_MB} MB.")
|
| 155 |
+
if file.size == 0:
|
| 156 |
+
errors.append(f"{file.name}: file is empty.")
|
| 157 |
+
return errors
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def load_uploaded_documents(files: list) -> list[Document]:
|
| 161 |
+
loaded_docs: list[Document] = []
|
| 162 |
+
|
| 163 |
+
for file in files:
|
| 164 |
+
suffix = Path(file.name).suffix.lower()
|
| 165 |
+
temp_path = None
|
| 166 |
+
try:
|
| 167 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
| 168 |
+
temp_file.write(file.getbuffer())
|
| 169 |
+
temp_path = temp_file.name
|
| 170 |
+
|
| 171 |
+
if suffix == ".pdf":
|
| 172 |
+
loader = PyPDFLoader(temp_path)
|
| 173 |
+
elif suffix == ".docx":
|
| 174 |
+
loader = Docx2txtLoader(temp_path)
|
| 175 |
+
else:
|
| 176 |
+
loader = TextLoader(temp_path, encoding="utf-8", autodetect_encoding=True)
|
| 177 |
+
|
| 178 |
+
docs = loader.load()
|
| 179 |
+
for index, doc in enumerate(docs):
|
| 180 |
+
doc.metadata["source"] = file.name
|
| 181 |
+
doc.metadata["file_type"] = suffix.lstrip(".")
|
| 182 |
+
doc.metadata["page"] = doc.metadata.get("page", index)
|
| 183 |
+
loaded_docs.extend(docs)
|
| 184 |
+
finally:
|
| 185 |
+
if temp_path and os.path.exists(temp_path):
|
| 186 |
+
os.remove(temp_path)
|
| 187 |
+
|
| 188 |
+
return [doc for doc in loaded_docs if doc.page_content and doc.page_content.strip()]
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def build_vectorstore(docs: list[Document], chunk_size: int, chunk_overlap: int) -> tuple[FAISS, list[Document]]:
|
| 192 |
+
splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
|
| 193 |
+
chunk_size=chunk_size,
|
| 194 |
+
chunk_overlap=chunk_overlap,
|
| 195 |
+
separators=["\n\n", "\n", ". ", " ", ""],
|
| 196 |
+
)
|
| 197 |
+
chunks = splitter.split_documents(docs)
|
| 198 |
+
for index, chunk in enumerate(chunks, start=1):
|
| 199 |
+
chunk.metadata["chunk_id"] = index
|
| 200 |
+
embeddings = get_embeddings()
|
| 201 |
+
return FAISS.from_documents(chunks, embeddings), chunks
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def format_source_label(doc: Document) -> str:
|
| 205 |
+
source = doc.metadata.get("source", "Uploaded document")
|
| 206 |
+
page = doc.metadata.get("page")
|
| 207 |
+
page_text = f", page {int(page) + 1}" if isinstance(page, int) else ""
|
| 208 |
+
chunk_id = doc.metadata.get("chunk_id")
|
| 209 |
+
chunk_text = f", chunk {chunk_id}" if chunk_id else ""
|
| 210 |
+
return f"{source}{page_text}{chunk_text}"
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def preview_text(text: str, limit: int = 380) -> str:
|
| 214 |
+
cleaned = " ".join(text.split())
|
| 215 |
+
if len(cleaned) <= limit:
|
| 216 |
+
return cleaned
|
| 217 |
+
return f"{cleaned[:limit].rstrip()}..."
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def format_docs_for_context(docs: list[Document]) -> str:
|
| 221 |
+
parts = []
|
| 222 |
+
total = 0
|
| 223 |
+
for doc in docs:
|
| 224 |
+
label = format_source_label(doc)
|
| 225 |
+
content = doc.page_content.strip()
|
| 226 |
+
block = f"Source: {label}\n{content}"
|
| 227 |
+
if total + len(block) > MAX_CONTEXT_CHARS:
|
| 228 |
+
remaining = MAX_CONTEXT_CHARS - total
|
| 229 |
+
if remaining > 500:
|
| 230 |
+
parts.append(block[:remaining])
|
| 231 |
+
break
|
| 232 |
+
parts.append(block)
|
| 233 |
+
total += len(block)
|
| 234 |
+
return "\n\n---\n\n".join(parts)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def make_chat_export() -> str:
|
| 238 |
+
lines = [f"# {APP_TITLE} - Chat Export", "", f"Exported: {datetime.now():%Y-%m-%d %H:%M}", ""]
|
| 239 |
+
for message in st.session_state.messages:
|
| 240 |
+
role = "User" if message["role"] == "user" else "Assistant"
|
| 241 |
+
lines.extend([f"## {role}", message["content"], ""])
|
| 242 |
+
sources = message.get("sources") or []
|
| 243 |
+
if sources:
|
| 244 |
+
lines.append("### Sources")
|
| 245 |
+
for source in sources:
|
| 246 |
+
lines.append(f"- {source['label']}: {source['preview']}")
|
| 247 |
+
lines.append("")
|
| 248 |
+
return "\n".join(lines)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def make_summary_export() -> str:
|
| 252 |
+
return "\n".join(
|
| 253 |
+
[
|
| 254 |
+
f"# {APP_TITLE} - Summary",
|
| 255 |
+
"",
|
| 256 |
+
f"Mode: {st.session_state.summary_mode or 'Summary'}",
|
| 257 |
+
f"Exported: {datetime.now():%Y-%m-%d %H:%M}",
|
| 258 |
+
"",
|
| 259 |
+
st.session_state.summary or "",
|
| 260 |
+
]
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def reset_documents() -> None:
|
| 265 |
+
for key in ["vectorstore", "documents", "chunks", "raw_text", "file_hash", "doc_stats", "latest_sources", "summary", "summary_mode"]:
|
| 266 |
+
st.session_state[key] = "" if key in {"raw_text", "file_hash", "summary", "summary_mode"} else [] if key in {"documents", "chunks", "latest_sources"} else {} if key == "doc_stats" else None
|
| 267 |
+
st.session_state.messages = []
|
| 268 |
+
st.session_state.chat_history = []
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def render_sources(sources: list[dict]) -> None:
|
| 272 |
+
if not sources:
|
| 273 |
+
st.caption("No source chunks were returned.")
|
| 274 |
+
return
|
| 275 |
+
for source in sources:
|
| 276 |
+
st.markdown(
|
| 277 |
+
f"""
|
| 278 |
+
<div class="source-box">
|
| 279 |
+
<strong>{source["label"]}</strong><br/>
|
| 280 |
+
<span class="small-muted">{source["preview"]}</span>
|
| 281 |
+
</div>
|
| 282 |
+
""",
|
| 283 |
+
unsafe_allow_html=True,
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
with st.sidebar:
|
| 288 |
+
st.header("Configuration")
|
| 289 |
+
env_api_key = os.getenv("GROQ_API_KEY", "")
|
| 290 |
+
api_key = env_api_key or st.text_input("Groq API key", type="password", placeholder="gsk_...")
|
| 291 |
+
|
| 292 |
+
selected_model = MODEL_OPTIONS[
|
| 293 |
+
st.selectbox("LLM model", options=list(MODEL_OPTIONS.keys()), index=0)
|
| 294 |
+
]
|
| 295 |
+
temperature = st.slider("Answer creativity", 0.0, 0.8, 0.1, 0.05)
|
| 296 |
+
|
| 297 |
+
with st.expander("Retrieval settings", expanded=False):
|
| 298 |
+
chunk_size = st.slider("Chunk size", 350, 1200, 700, 50)
|
| 299 |
+
chunk_overlap = st.slider("Chunk overlap", 50, 300, 120, 10)
|
| 300 |
+
top_k = st.slider("Source chunks", 2, 8, 4, 1)
|
| 301 |
+
retrieval_mode = st.radio("Retrieval mode", ["MMR", "Similarity"], horizontal=True)
|
| 302 |
+
|
| 303 |
+
st.divider()
|
| 304 |
+
st.header("Upload")
|
| 305 |
+
uploaded_files = st.file_uploader(
|
| 306 |
+
"PDF, TXT, or DOCX files",
|
| 307 |
+
type=sorted(SUPPORTED_EXTENSIONS),
|
| 308 |
+
accept_multiple_files=True,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
process_clicked = st.button("Process documents", type="primary", use_container_width=True)
|
| 312 |
+
|
| 313 |
+
summary_mode = st.selectbox("Summary mode", list(SUMMARY_MODES.keys()))
|
| 314 |
+
summary_clicked = st.button("Generate summary", use_container_width=True)
|
| 315 |
+
|
| 316 |
+
st.divider()
|
| 317 |
+
col_a, col_b = st.columns(2)
|
| 318 |
+
if col_a.button("Clear chat", use_container_width=True):
|
| 319 |
+
st.session_state.messages = []
|
| 320 |
+
st.session_state.chat_history = []
|
| 321 |
+
st.rerun()
|
| 322 |
+
if col_b.button("Reset app", use_container_width=True):
|
| 323 |
+
reset_documents()
|
| 324 |
+
st.rerun()
|
| 325 |
+
|
| 326 |
+
if st.session_state.summary:
|
| 327 |
+
st.download_button(
|
| 328 |
+
"Download summary",
|
| 329 |
+
data=make_summary_export(),
|
| 330 |
+
file_name=f"document_summary_{datetime.now():%Y%m%d_%H%M}.md",
|
| 331 |
+
mime="text/markdown",
|
| 332 |
+
use_container_width=True,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
if st.session_state.messages:
|
| 336 |
+
st.download_button(
|
| 337 |
+
"Download chat",
|
| 338 |
+
data=make_chat_export(),
|
| 339 |
+
file_name=f"document_chat_{datetime.now():%Y%m%d_%H%M}.md",
|
| 340 |
+
mime="text/markdown",
|
| 341 |
+
use_container_width=True,
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
st.markdown(
|
| 346 |
+
f"""
|
| 347 |
+
<div class="app-hero">
|
| 348 |
+
<h1>{APP_TITLE}</h1>
|
| 349 |
+
<p>Upload documents, generate summaries, and ask grounded questions with source citations.</p>
|
| 350 |
+
</div>
|
| 351 |
+
""",
|
| 352 |
+
unsafe_allow_html=True,
|
| 353 |
)
|
| 354 |
|
| 355 |
+
overview_col, stats_col = st.columns([2, 1])
|
| 356 |
+
with overview_col:
|
| 357 |
+
st.markdown(
|
| 358 |
+
"This assistant extracts text from uploaded documents, splits it into searchable chunks, builds a local FAISS index with HuggingFace embeddings, and uses a Groq-hosted LLM to answer only from retrieved context."
|
| 359 |
+
)
|
| 360 |
+
with stats_col:
|
| 361 |
+
if st.session_state.doc_stats:
|
| 362 |
+
stats = st.session_state.doc_stats
|
| 363 |
+
st.metric("Files", stats["files"])
|
| 364 |
+
st.metric("Chunks", stats["chunks"])
|
| 365 |
+
else:
|
| 366 |
+
st.info("Upload and process documents to start.")
|
| 367 |
+
|
| 368 |
|
| 369 |
+
if process_clicked:
|
| 370 |
+
if not uploaded_files:
|
| 371 |
+
st.warning("Upload at least one PDF, TXT, or DOCX file first.")
|
| 372 |
+
else:
|
| 373 |
+
validation_errors = validate_files(uploaded_files)
|
| 374 |
+
if validation_errors:
|
| 375 |
+
for error in validation_errors:
|
| 376 |
+
st.error(error)
|
| 377 |
+
else:
|
| 378 |
+
current_hash = file_fingerprint(uploaded_files)
|
| 379 |
+
if current_hash == st.session_state.file_hash and st.session_state.vectorstore:
|
| 380 |
+
st.success("These files are already processed. The current index is ready.")
|
| 381 |
+
else:
|
| 382 |
+
with st.status("Processing documents", expanded=True) as status:
|
| 383 |
+
try:
|
| 384 |
+
started = time.time()
|
| 385 |
+
st.write("Reading uploaded files from temporary storage...")
|
| 386 |
+
docs = load_uploaded_documents(uploaded_files)
|
| 387 |
+
if not docs:
|
| 388 |
+
status.update(label="No readable text found.", state="error")
|
| 389 |
+
st.stop()
|
| 390 |
|
| 391 |
+
st.write("Splitting document text into retrieval chunks...")
|
| 392 |
+
vectorstore, chunks = build_vectorstore(docs, chunk_size, chunk_overlap)
|
| 393 |
+
|
| 394 |
+
raw_text = "\n\n".join(doc.page_content for doc in docs).strip()
|
| 395 |
+
elapsed = round(time.time() - started, 2)
|
| 396 |
+
st.session_state.vectorstore = vectorstore
|
| 397 |
+
st.session_state.documents = docs
|
| 398 |
+
st.session_state.chunks = chunks
|
| 399 |
+
st.session_state.raw_text = raw_text
|
| 400 |
+
st.session_state.file_hash = current_hash
|
| 401 |
+
st.session_state.summary = ""
|
| 402 |
+
st.session_state.summary_mode = ""
|
| 403 |
+
st.session_state.doc_stats = {
|
| 404 |
+
"files": len(uploaded_files),
|
| 405 |
+
"pages": len(docs),
|
| 406 |
+
"chunks": len(chunks),
|
| 407 |
+
"characters": len(raw_text),
|
| 408 |
+
"seconds": elapsed,
|
| 409 |
+
}
|
| 410 |
+
status.update(label=f"Index ready in {elapsed}s.", state="complete", expanded=False)
|
| 411 |
+
except Exception as exc:
|
| 412 |
+
status.update(label="Processing failed.", state="error")
|
| 413 |
+
st.error(f"Could not process the uploaded files: {exc}")
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
if summary_clicked:
|
| 417 |
+
if not api_key:
|
| 418 |
+
st.error("Add a Groq API key in Space secrets or the sidebar before generating a summary.")
|
| 419 |
+
elif not st.session_state.raw_text:
|
| 420 |
+
st.warning("Process documents before generating a summary.")
|
| 421 |
+
else:
|
| 422 |
+
try:
|
| 423 |
+
with st.spinner("Generating grounded summary..."):
|
| 424 |
+
llm = get_llm(api_key, selected_model, temperature=0.1, max_tokens=2200)
|
| 425 |
+
text_for_summary = st.session_state.raw_text[:MAX_SUMMARY_CHARS]
|
| 426 |
+
prompt = ChatPromptTemplate.from_messages(
|
| 427 |
+
[
|
| 428 |
+
(
|
| 429 |
+
"system",
|
| 430 |
+
"You summarize uploaded documents for professional analysis. Use only the supplied document text. If the text is insufficient, say so.",
|
| 431 |
+
),
|
| 432 |
+
(
|
| 433 |
+
"human",
|
| 434 |
+
"Summary mode: {mode}\n\nInstructions: {instructions}\n\nDocument text:\n{document_text}",
|
| 435 |
+
),
|
| 436 |
+
]
|
| 437 |
+
)
|
| 438 |
+
chain = prompt | llm | StrOutputParser()
|
| 439 |
+
st.session_state.summary = chain.invoke(
|
| 440 |
+
{
|
| 441 |
+
"mode": summary_mode,
|
| 442 |
+
"instructions": SUMMARY_MODES[summary_mode],
|
| 443 |
+
"document_text": text_for_summary,
|
| 444 |
+
}
|
| 445 |
+
)
|
| 446 |
+
st.session_state.summary_mode = summary_mode
|
| 447 |
+
except Exception as exc:
|
| 448 |
+
st.error(f"Summary generation failed: {exc}")
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
tabs = st.tabs(["Upload", "Summary", "Chat", "Sources", "Export"])
|
| 452 |
+
|
| 453 |
+
with tabs[0]:
|
| 454 |
+
st.subheader("Upload and Process")
|
| 455 |
+
if st.session_state.doc_stats:
|
| 456 |
+
stats = st.session_state.doc_stats
|
| 457 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 458 |
+
c1.metric("Files", stats["files"])
|
| 459 |
+
c2.metric("Pages / sections", stats["pages"])
|
| 460 |
+
c3.metric("Chunks", stats["chunks"])
|
| 461 |
+
c4.metric("Processing time", f"{stats['seconds']}s")
|
| 462 |
+
st.caption("Uploaded files are read through temporary files and are not stored permanently by the app.")
|
| 463 |
+
st.markdown('<div class="chip-row">' + "".join(f'<span class="chip">{q}</span>' for q in EXAMPLE_QUESTIONS) + "</div>", unsafe_allow_html=True)
|
| 464 |
+
else:
|
| 465 |
+
st.info("No document index yet. Upload files in the sidebar and select Process documents.")
|
| 466 |
+
st.markdown("Accepted formats: PDF, TXT, DOCX. Maximum file size per upload: 25 MB.")
|
| 467 |
+
|
| 468 |
+
with tabs[1]:
|
| 469 |
+
st.subheader("Summary")
|
| 470 |
+
if st.session_state.summary:
|
| 471 |
+
with st.expander(st.session_state.summary_mode, expanded=True):
|
| 472 |
+
st.markdown(st.session_state.summary)
|
| 473 |
+
else:
|
| 474 |
+
st.info("Choose a summary mode in the sidebar after processing documents.")
|
| 475 |
+
|
| 476 |
+
with tabs[2]:
|
| 477 |
+
st.subheader("Chat")
|
| 478 |
+
if not api_key:
|
| 479 |
+
st.warning("Add a Groq API key in Space secrets or the sidebar to enable chat.")
|
| 480 |
+
elif not st.session_state.vectorstore:
|
| 481 |
+
st.info("Process documents first. Answers are grounded only in your uploaded files.")
|
| 482 |
+
|
| 483 |
+
for message in st.session_state.messages:
|
| 484 |
+
with st.chat_message(message["role"]):
|
| 485 |
+
st.markdown(message["content"])
|
| 486 |
+
if message.get("sources"):
|
| 487 |
+
with st.expander("Source citations"):
|
| 488 |
+
render_sources(message["sources"])
|
| 489 |
+
if message.get("meta"):
|
| 490 |
+
st.caption(message["meta"])
|
| 491 |
+
|
| 492 |
+
user_query = st.chat_input("Ask a question about the processed documents")
|
| 493 |
+
if user_query:
|
| 494 |
+
st.session_state.messages.append({"role": "user", "content": user_query})
|
| 495 |
+
with st.chat_message("user"):
|
| 496 |
+
st.markdown(user_query)
|
| 497 |
+
|
| 498 |
+
if not api_key:
|
| 499 |
+
st.error("A Groq API key is required for answers.")
|
| 500 |
+
elif not st.session_state.vectorstore:
|
| 501 |
+
st.warning("No document index is available. Upload and process documents first.")
|
| 502 |
+
else:
|
| 503 |
+
with st.chat_message("assistant"):
|
| 504 |
+
try:
|
| 505 |
+
started = time.time()
|
| 506 |
+
llm = get_llm(api_key, selected_model, temperature=temperature)
|
| 507 |
+
retriever = st.session_state.vectorstore.as_retriever(
|
| 508 |
+
search_type="mmr" if retrieval_mode == "MMR" else "similarity",
|
| 509 |
+
search_kwargs={"k": top_k, "fetch_k": max(top_k * 4, 12)}
|
| 510 |
+
if retrieval_mode == "MMR"
|
| 511 |
+
else {"k": top_k},
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
rewrite_prompt = ChatPromptTemplate.from_messages(
|
| 515 |
+
[
|
| 516 |
+
(
|
| 517 |
+
"system",
|
| 518 |
+
"Rewrite the latest user question as a standalone retrieval query. Return only the query.",
|
| 519 |
+
),
|
| 520 |
+
MessagesPlaceholder("chat_history"),
|
| 521 |
+
("human", "{question}"),
|
| 522 |
+
]
|
| 523 |
+
)
|
| 524 |
+
rewrite_chain = rewrite_prompt | llm | StrOutputParser()
|
| 525 |
+
retrieval_query = rewrite_chain.invoke(
|
| 526 |
+
{
|
| 527 |
+
"question": user_query,
|
| 528 |
+
"chat_history": st.session_state.chat_history[-MAX_CHAT_TURNS:],
|
| 529 |
+
}
|
| 530 |
+
)
|
| 531 |
+
retrieved_docs = retriever.invoke(retrieval_query)
|
| 532 |
+
context = format_docs_for_context(retrieved_docs)
|
| 533 |
+
|
| 534 |
+
qa_prompt = ChatPromptTemplate.from_messages(
|
| 535 |
+
[
|
| 536 |
+
(
|
| 537 |
+
"system",
|
| 538 |
+
"You are a document intelligence assistant. Answer using only the provided context. "
|
| 539 |
+
"If the answer is not supported by the context, say: 'The uploaded document does not contain enough information to answer that.' "
|
| 540 |
+
"Cite source labels naturally when useful. Do not invent facts.\n\nContext:\n{context}",
|
| 541 |
+
),
|
| 542 |
+
MessagesPlaceholder("chat_history"),
|
| 543 |
+
("human", "{question}"),
|
| 544 |
+
]
|
| 545 |
+
)
|
| 546 |
+
qa_chain = qa_prompt | llm | StrOutputParser()
|
| 547 |
+
stream = qa_chain.stream(
|
| 548 |
+
{
|
| 549 |
+
"question": user_query,
|
| 550 |
+
"chat_history": st.session_state.chat_history[-MAX_CHAT_TURNS:],
|
| 551 |
+
"context": context,
|
| 552 |
+
}
|
| 553 |
+
)
|
| 554 |
+
answer = st.write_stream(stream)
|
| 555 |
+
elapsed = round(time.time() - started, 2)
|
| 556 |
+
|
| 557 |
+
sources = [
|
| 558 |
+
{"label": format_source_label(doc), "preview": preview_text(doc.page_content)}
|
| 559 |
+
for doc in retrieved_docs
|
| 560 |
+
]
|
| 561 |
+
meta = f"Response time: {elapsed}s | Source chunks used: {len(retrieved_docs)}"
|
| 562 |
+
st.caption(meta)
|
| 563 |
+
with st.expander("Source citations"):
|
| 564 |
+
render_sources(sources)
|
| 565 |
+
|
| 566 |
+
st.session_state.messages.append(
|
| 567 |
+
{"role": "assistant", "content": answer, "sources": sources, "meta": meta}
|
| 568 |
+
)
|
| 569 |
+
st.session_state.chat_history.extend([HumanMessage(content=user_query), AIMessage(content=answer)])
|
| 570 |
+
st.session_state.chat_history = st.session_state.chat_history[-MAX_CHAT_TURNS:]
|
| 571 |
+
st.session_state.latest_sources = sources
|
| 572 |
+
except Exception as exc:
|
| 573 |
+
st.error(f"Answer generation failed: {exc}")
|
| 574 |
+
|
| 575 |
+
with tabs[3]:
|
| 576 |
+
st.subheader("Sources")
|
| 577 |
+
if st.session_state.latest_sources:
|
| 578 |
+
render_sources(st.session_state.latest_sources)
|
| 579 |
+
elif st.session_state.chunks:
|
| 580 |
+
st.caption("Ask a question to see the source chunks used for the answer.")
|
| 581 |
+
sample_sources = [
|
| 582 |
+
{"label": format_source_label(doc), "preview": preview_text(doc.page_content)}
|
| 583 |
+
for doc in st.session_state.chunks[: min(3, len(st.session_state.chunks))]
|
| 584 |
+
]
|
| 585 |
+
render_sources(sample_sources)
|
| 586 |
+
else:
|
| 587 |
+
st.info("Source citations will appear after documents are processed and queried.")
|
| 588 |
+
|
| 589 |
+
with tabs[4]:
|
| 590 |
+
st.subheader("Export")
|
| 591 |
+
st.markdown("Download summaries and chat history as Markdown for reports, notes, or portfolio demos.")
|
| 592 |
+
if st.session_state.summary:
|
| 593 |
+
st.download_button(
|
| 594 |
+
"Download summary as Markdown",
|
| 595 |
+
data=make_summary_export(),
|
| 596 |
+
file_name=f"document_summary_{datetime.now():%Y%m%d_%H%M}.md",
|
| 597 |
+
mime="text/markdown",
|
| 598 |
+
)
|
| 599 |
+
if st.session_state.messages:
|
| 600 |
+
st.download_button(
|
| 601 |
+
"Download chat as Markdown",
|
| 602 |
+
data=make_chat_export(),
|
| 603 |
+
file_name=f"document_chat_{datetime.now():%Y%m%d_%H%M}.md",
|
| 604 |
+
mime="text/markdown",
|
| 605 |
+
)
|
| 606 |
+
if not st.session_state.summary and not st.session_state.messages:
|
| 607 |
+
st.info("Generate a summary or chat with the document to enable exports.")
|
| 608 |
+
|
| 609 |
+
st.divider()
|
| 610 |
+
st.caption(
|
| 611 |
+
"Privacy note: documents are processed in the current Streamlit session through temporary files. Answers are grounded only in retrieved chunks from uploaded documents."
|
| 612 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.40.0
|
| 2 |
+
python-dotenv==1.0.1
|
| 3 |
+
langchain==0.2.16
|
| 4 |
+
langchain-core==0.2.39
|
| 5 |
+
langchain-community==0.2.16
|
| 6 |
+
langchain-groq==0.1.10
|
| 7 |
+
langchain-huggingface==0.0.3
|
| 8 |
+
langchain-text-splitters==0.2.4
|
| 9 |
+
faiss-cpu==1.8.0.post1
|
| 10 |
+
sentence-transformers==3.1.1
|
| 11 |
+
transformers==4.44.2
|
| 12 |
+
torch==2.4.1
|
| 13 |
+
pypdf==4.3.1
|
| 14 |
+
docx2txt==0.8
|
| 15 |
+
tiktoken==0.7.0
|