dineshb commited on
Commit
e9ff8d4
·
verified ·
1 Parent(s): 4a34d1c

Upload 5 files

Browse files

Adding all Files To my Space

Files changed (4) hide show
  1. Dockerfile +23 -0
  2. README.md +183 -11
  3. app.py +602 -59
  4. 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: DocuChat AI
3
- emoji: 💬
4
- colorFrom: yellow
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.5.1
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
- An example chatbot using [Gradio](https://gradio.app), [`huggingface_hub`](https://huggingface.co/docs/huggingface_hub/v0.22.2/en/index), and the [Hugging Face Inference API](https://huggingface.co/docs/api-inference/index).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 gradio as gr
2
- from huggingface_hub import InferenceClient
3
-
4
-
5
- def respond(
6
- message,
7
- history: list[dict[str, str]],
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
- messages = [{"role": "system", "content": system_message}]
 
 
 
 
 
 
 
 
 
 
20
 
21
- messages.extend(history)
22
 
23
- messages.append({"role": "user", "content": message})
 
 
 
 
 
24
 
25
- response = ""
 
 
 
 
 
26
 
27
- for message in client.chat_completion(
28
- messages,
29
- max_tokens=max_tokens,
30
- stream=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  temperature=temperature,
32
- top_p=top_p,
33
- ):
34
- choices = message.choices
35
- token = ""
36
- if len(choices) and choices[0].delta.content:
37
- token = choices[0].delta.content
38
-
39
- response += token
40
- yield response
41
-
42
-
43
- """
44
- For information on how to customize the ChatInterface, peruse the gradio docs: https://www.gradio.app/docs/chatinterface
45
- """
46
- chatbot = gr.ChatInterface(
47
- respond,
48
- additional_inputs=[
49
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
50
- gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
51
- gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
52
- gr.Slider(
53
- minimum=0.1,
54
- maximum=1.0,
55
- value=0.95,
56
- step=0.05,
57
- label="Top-p (nucleus sampling)",
58
- ),
59
- ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  )
61
 
62
- with gr.Blocks() as demo:
63
- with gr.Sidebar():
64
- gr.LoginButton()
65
- chatbot.render()
 
 
 
 
 
 
 
 
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- if __name__ == "__main__":
69
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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