awellis commited on
Commit
5df4a2a
Β·
1 Parent(s): be61100

Implement modular RAG email assistant architecture

Browse files

- Add modular src/ structure with all components
- Implement document processing (loader, chunker)
- Implement OpenSearch indexing with hybrid retrieval
- Implement PydanticAI agents (intent, composer, fact checker)
- Implement pipeline orchestrator
- Add Gradio UI with draft refinement
- Create document ingestion script
- Update dependencies and configuration
- Add comprehensive documentation (README, QUICKSTART, CLAUDE.md)

.env.example ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenAI Configuration
2
+ OPENAI_API_KEY=your_openai_api_key_here
3
+ LLM_MODEL=gpt-4o
4
+ EMBEDDING_MODEL=text-embedding-3-small
5
+ LLM_TEMPERATURE=0.7
6
+ LLM_MAX_TOKENS=2000
7
+
8
+ # OpenSearch Configuration
9
+ OPENSEARCH_HOST=localhost
10
+ OPENSEARCH_PORT=9200
11
+ OPENSEARCH_USER=admin
12
+ OPENSEARCH_PASSWORD=your_password_here
13
+ OPENSEARCH_USE_SSL=true
14
+ OPENSEARCH_VERIFY_CERTS=false
15
+ INDEX_NAME=bfh_admin_docs
16
+
17
+ # Document Processing Configuration
18
+ DOCUMENTS_PATH=assets/markdown
19
+ CHUNK_SIZE=300
20
+ CHUNK_OVERLAP=50
21
+ MIN_CHUNK_SIZE=100
22
+
23
+ # Retrieval Configuration
24
+ RETRIEVAL_TOP_K=5
25
+ BM25_WEIGHT=0.5
26
+ VECTOR_WEIGHT=0.5
27
+ MIN_RELEVANCE_SCORE=0.3
28
+
29
+ # Application Configuration
30
+ DEBUG=false
.gitignore CHANGED
@@ -142,6 +142,9 @@ gradio_cached_examples/
142
  flagged/
143
  *.db
144
 
 
 
 
145
  # IDE
146
  .vscode/
147
  .idea/
 
142
  flagged/
143
  *.db
144
 
145
+ # Baseline reference file
146
+ rag_email_assistant_haystack_2_pydantic_ai_gradio_modular_2025_baseline.py
147
+
148
  # IDE
149
  .vscode/
150
  .idea/
CLAUDE.md CHANGED
@@ -6,14 +6,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
6
 
7
  This is a RAG (Retrieval-Augmented Generation) Email Assistant system designed for university administrative staff at BFH (Bern University of Applied Sciences). The system uses Haystack 2 for document processing and retrieval, PydanticAI for multi-agent orchestration, and Gradio for the user interface.
8
 
9
- **Current Status**: The project is in initial development. The baseline implementation exists as a single monolithic file that needs to be split into the proper modular structure.
10
 
11
  ## Key Files
12
 
13
- - **[rag_email_assistant_haystack_2_pydantic_ai_gradio_modular_2025_baseline.py](rag_email_assistant_haystack_2_pydantic_ai_gradio_modular_2025_baseline.py)**: Complete baseline implementation containing all code for the system. This file has comments indicating how to split it into modules.
14
  - **[docs/RAG_Email_Assistant_Specifications_v1.0.md](docs/RAG_Email_Assistant_Specifications_v1.0.md)**: Comprehensive specification document defining architecture, components, and implementation details.
15
- - **[app.py](app.py)**: Legacy Hugging Face Spaces demo file (not part of the RAG assistant).
16
- - **assets/markdown/**: Directory containing administrative documents in markdown format (forms, information sheets) that serve as the knowledge base.
 
 
 
17
 
18
  ## Architecture
19
 
@@ -28,9 +30,9 @@ The system follows a multi-agent RAG architecture with three main stages:
28
 
29
  3. **Gradio UI**: Interactive interface for composing and refining email responses.
30
 
31
- ### Target Module Structure
32
 
33
- Per the specification, the monolithic baseline should be split into:
34
  ```
35
  src/
36
  β”œβ”€β”€ config.py # Configuration management
@@ -62,11 +64,17 @@ src/
62
 
63
  ### Running the Application
64
  ```bash
65
- # Once modularized, the main entry point will be:
 
 
 
66
  python -m src.ui.gradio_app
 
67
 
68
- # For baseline (current single-file version):
69
- python rag_email_assistant_haystack_2_pydantic_ai_gradio_modular_2025_baseline.py
 
 
70
  ```
71
 
72
  ### Environment Setup
 
6
 
7
  This is a RAG (Retrieval-Augmented Generation) Email Assistant system designed for university administrative staff at BFH (Bern University of Applied Sciences). The system uses Haystack 2 for document processing and retrieval, PydanticAI for multi-agent orchestration, and Gradio for the user interface.
8
 
9
+ **Current Status**: The project has been fully implemented with a modular architecture. The baseline reference file is kept for reference but the production code is in the `src/` directory.
10
 
11
  ## Key Files
12
 
 
13
  - **[docs/RAG_Email_Assistant_Specifications_v1.0.md](docs/RAG_Email_Assistant_Specifications_v1.0.md)**: Comprehensive specification document defining architecture, components, and implementation details.
14
+ - **[app.py](app.py)**: Main entry point for Hugging Face Spaces deployment.
15
+ - **[src/](src/)**: Production implementation with modular architecture.
16
+ - **[scripts/ingest_documents.py](scripts/ingest_documents.py)**: Script to load, chunk, and index documents.
17
+ - **[assets/markdown/](assets/markdown/)**: Directory containing administrative documents in markdown format (forms, information sheets) that serve as the knowledge base.
18
+ - **rag_email_assistant_haystack_2_pydantic_ai_gradio_modular_2025_baseline.py**: Reference baseline (gitignored).
19
 
20
  ## Architecture
21
 
 
30
 
31
  3. **Gradio UI**: Interactive interface for composing and refining email responses.
32
 
33
+ ### Module Structure
34
 
35
+ The implemented modular structure:
36
  ```
37
  src/
38
  β”œβ”€β”€ config.py # Configuration management
 
64
 
65
  ### Running the Application
66
  ```bash
67
+ # Main entry point (for Hugging Face Spaces and local):
68
+ python app.py
69
+
70
+ # Or run the UI module directly:
71
  python -m src.ui.gradio_app
72
+ ```
73
 
74
+ ### Document Ingestion
75
+ ```bash
76
+ # Index markdown documents before first run:
77
+ python scripts/ingest_documents.py
78
  ```
79
 
80
  ### Environment Setup
QUICKSTART.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quick Start Guide
2
+
3
+ ## Prerequisites
4
+
5
+ 1. **Python 3.10+** installed
6
+ 2. **OpenSearch instance** running with k-NN plugin enabled
7
+ 3. **OpenAI API key**
8
+
9
+ ## Setup (5 minutes)
10
+
11
+ ### 1. Install Dependencies
12
+
13
+ ```bash
14
+ pip install -r requirements.txt
15
+ ```
16
+
17
+ ### 2. Configure Environment
18
+
19
+ ```bash
20
+ # Copy the example environment file
21
+ cp .env.example .env
22
+
23
+ # Edit .env and add your credentials
24
+ nano .env # or use your preferred editor
25
+ ```
26
+
27
+ **Required variables:**
28
+ - `OPENAI_API_KEY` - Your OpenAI API key
29
+ - `OPENSEARCH_HOST` - OpenSearch host (e.g., localhost)
30
+ - `OPENSEARCH_PORT` - OpenSearch port (e.g., 9200)
31
+ - `OPENSEARCH_USER` - OpenSearch username
32
+ - `OPENSEARCH_PASSWORD` - OpenSearch password
33
+
34
+ ### 3. Index Documents
35
+
36
+ ```bash
37
+ python scripts/ingest_documents.py
38
+ ```
39
+
40
+ This will:
41
+ - Load markdown documents from `assets/markdown/`
42
+ - Chunk them semantically
43
+ - Generate embeddings
44
+ - Index in OpenSearch
45
+
46
+ Expected output:
47
+ ```
48
+ Successfully indexed X document chunks
49
+ Total documents in index: X
50
+ βœ… Document ingestion completed successfully!
51
+ ```
52
+
53
+ ### 4. Run the Application
54
+
55
+ ```bash
56
+ python app.py
57
+ ```
58
+
59
+ The Gradio interface will launch at `http://localhost:7860`
60
+
61
+ ## Usage
62
+
63
+ 1. **Enter a student query** (e.g., "Wie kann ich mich exmatrikulieren?")
64
+ 2. **Click "Generate Email Draft"**
65
+ 3. **Review the results:**
66
+ - Intent analysis
67
+ - Email draft (subject + body)
68
+ - Fact check results
69
+ - Source documents used
70
+ 4. **Refine if needed** by providing feedback
71
+
72
+ ## Example Queries
73
+
74
+ German:
75
+ - "Wie kann ich mich exmatrikulieren?"
76
+ - "Was kostet eine NamensΓ€nderung?"
77
+ - "Ich mΓΆchte ein Modul zurΓΌckziehen. Was muss ich beachten?"
78
+ - "Welche Fristen gibt es fΓΌr die Beurlaubung?"
79
+
80
+ English:
81
+ - "How can I withdraw from the university?"
82
+ - "What are the fees for changing my name?"
83
+ - "I want to take a leave of absence. What do I need to know?"
84
+
85
+ ## Troubleshooting
86
+
87
+ ### Cannot connect to OpenSearch
88
+ - Check that OpenSearch is running: `curl -X GET "localhost:9200"`
89
+ - Verify credentials in `.env`
90
+ - Check firewall settings
91
+
92
+ ### No documents indexed
93
+ - Verify markdown files exist in `assets/markdown/`
94
+ - Check OpenSearch index: `curl -X GET "localhost:9200/_cat/indices"`
95
+ - Review ingestion script logs
96
+
97
+ ### OpenAI API errors
98
+ - Verify API key in `.env`
99
+ - Check API quota and billing
100
+ - Ensure internet connectivity
101
+
102
+ ## Next Steps
103
+
104
+ - Review [README.md](README.md) for full documentation
105
+ - Check [docs/RAG_Email_Assistant_Specifications_v1.0.md](docs/RAG_Email_Assistant_Specifications_v1.0.md) for architecture details
106
+ - See [CLAUDE.md](CLAUDE.md) for development guidance
107
+
108
+ ## Support
109
+
110
+ For issues, please check:
111
+ 1. Environment variables are correctly set
112
+ 2. OpenSearch is accessible
113
+ 3. Documents are properly indexed
114
+ 4. API keys are valid
115
+
116
+ Need help? Open an issue on GitHub.
README.md CHANGED
@@ -1,16 +1,256 @@
1
- ---
2
- title: Bfh Studadmin Assist
3
- emoji: πŸ’¬
4
- colorFrom: yellow
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 5.49.0
8
- app_file: app.py
9
- pinned: false
10
- hf_oauth: true
11
- hf_oauth_scopes:
12
- - inference-api
13
- license: mit
14
- ---
15
-
16
- 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: BFH Student Administration Assistant
3
+ emoji: πŸ“§
4
+ colorFrom: yellow
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 5.49.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # πŸ“§ BFH Student Administration Email Assistant
14
+
15
+ AI-powered RAG (Retrieval-Augmented Generation) email assistant for university administrative staff at BFH (Bern University of Applied Sciences).
16
+
17
+ ## Overview
18
+
19
+ This system helps administrative staff compose accurate, professional email responses to student inquiries using:
20
+
21
+ - **Haystack 2**: Document processing and hybrid retrieval (BM25 + semantic search)
22
+ - **OpenSearch**: Vector database with k-NN support
23
+ - **PydanticAI**: Multi-agent orchestration with structured outputs
24
+ - **Gradio**: Interactive web interface
25
+ - **OpenAI GPT-4o**: Language model for intent extraction, composition, and fact-checking
26
+
27
+ ## Features
28
+
29
+ ### 🎯 Multi-Agent Architecture
30
+
31
+ 1. **Intent Extraction Agent**: Analyzes queries to extract structured intent (action type, topic, urgency, language)
32
+ 2. **Composer Agent**: Drafts professional email responses using retrieved context
33
+ 3. **Fact Checker Agent**: Validates drafts against source documents for accuracy
34
+
35
+ ### πŸ” Hybrid Retrieval
36
+
37
+ - Combines BM25 (keyword-based) and dense vector search
38
+ - Configurable scoring weights
39
+ - Retrieves relevant administrative documents and forms
40
+
41
+ ### βœ‰οΈ Email Composition
42
+
43
+ - Multilingual support (German, English, French)
44
+ - Professional tone and formatting
45
+ - Context-aware responses based on university policies
46
+ - Draft refinement based on user feedback
47
+
48
+ ### βœ… Fact Checking
49
+
50
+ - Automated verification against source documents
51
+ - Accuracy scoring
52
+ - Issue identification and suggestions
53
+ - Chain-of-thought reasoning
54
+
55
+ ## Project Structure
56
+
57
+ ```
58
+ bfh-studadmin-assist/
59
+ β”œβ”€β”€ src/
60
+ β”‚ β”œβ”€β”€ config.py # Configuration management
61
+ β”‚ β”œβ”€β”€ document_processing/
62
+ β”‚ β”‚ β”œβ”€β”€ loader.py # Markdown document loading
63
+ β”‚ β”‚ └── chunker.py # Semantic chunking
64
+ β”‚ β”œβ”€β”€ indexing/
65
+ β”‚ β”‚ β”œβ”€β”€ opensearch_client.py # OpenSearch client
66
+ β”‚ β”‚ └── indexer.py # Document indexing
67
+ β”‚ β”œβ”€β”€ retrieval/
68
+ β”‚ β”‚ └── hybrid_retriever.py # Hybrid BM25 + vector search
69
+ β”‚ β”œβ”€β”€ agents/
70
+ β”‚ β”‚ β”œβ”€β”€ intent_agent.py # Intent extraction
71
+ β”‚ β”‚ β”œβ”€β”€ composer_agent.py # Email composition
72
+ β”‚ β”‚ └── fact_checker_agent.py # Fact checking
73
+ β”‚ β”œβ”€β”€ pipeline/
74
+ β”‚ β”‚ └── orchestrator.py # Multi-agent orchestration
75
+ β”‚ └── ui/
76
+ β”‚ └── gradio_app.py # Gradio interface
77
+ β”œβ”€β”€ scripts/
78
+ β”‚ └── ingest_documents.py # Document ingestion script
79
+ β”œβ”€β”€ assets/
80
+ β”‚ └── markdown/ # Administrative documents (German)
81
+ β”œβ”€β”€ docs/
82
+ β”‚ └── RAG_Email_Assistant_Specifications_v1.0.md
83
+ β”œβ”€β”€ app.py # Main entry point
84
+ β”œβ”€β”€ requirements.txt
85
+ β”œβ”€β”€ .env.example
86
+ └── README.md
87
+ ```
88
+
89
+ ## Setup
90
+
91
+ ### Prerequisites
92
+
93
+ - Python 3.10+
94
+ - OpenSearch instance (with k-NN plugin enabled)
95
+ - OpenAI API key
96
+
97
+ ### Installation
98
+
99
+ 1. Clone the repository:
100
+ ```bash
101
+ git clone https://github.com/yourusername/bfh-studadmin-assist.git
102
+ cd bfh-studadmin-assist
103
+ ```
104
+
105
+ 2. Install dependencies:
106
+ ```bash
107
+ pip install -r requirements.txt
108
+ ```
109
+
110
+ 3. Configure environment variables:
111
+ ```bash
112
+ cp .env.example .env
113
+ # Edit .env with your configuration
114
+ ```
115
+
116
+ Required environment variables:
117
+ - `OPENAI_API_KEY`: Your OpenAI API key
118
+ - `OPENSEARCH_HOST`: OpenSearch host
119
+ - `OPENSEARCH_PORT`: OpenSearch port
120
+ - `OPENSEARCH_USER`: OpenSearch username
121
+ - `OPENSEARCH_PASSWORD`: OpenSearch password
122
+ - `INDEX_NAME`: Name of the OpenSearch index
123
+
124
+ ### Document Ingestion
125
+
126
+ Before running the application, index the administrative documents:
127
+
128
+ ```bash
129
+ python scripts/ingest_documents.py
130
+ ```
131
+
132
+ This will:
133
+ 1. Load markdown documents from `assets/markdown/`
134
+ 2. Chunk documents using semantic splitting
135
+ 3. Generate embeddings using OpenAI
136
+ 4. Index documents in OpenSearch with hybrid retrieval support
137
+
138
+ ### Running the Application
139
+
140
+ **Local development:**
141
+ ```bash
142
+ python app.py
143
+ ```
144
+
145
+ **Production (Hugging Face Spaces):**
146
+ The app is configured for automatic deployment to Hugging Face Spaces via `app.py`.
147
+
148
+ ## Usage
149
+
150
+ 1. **Enter a student query** in the text area
151
+ 2. **Click "Generate Email Draft"** to process the query
152
+ 3. Review the generated email and analysis:
153
+ - Intent analysis
154
+ - Email subject and body
155
+ - Fact check results
156
+ - Retrieved source documents
157
+ 4. **Refine the draft** by providing feedback and clicking "Refine Draft"
158
+
159
+ ## Configuration
160
+
161
+ Key configuration options in `.env`:
162
+
163
+ ### LLM Configuration
164
+ - `LLM_MODEL`: OpenAI model (default: gpt-4o)
165
+ - `EMBEDDING_MODEL`: Embedding model (default: text-embedding-3-small)
166
+ - `LLM_TEMPERATURE`: Temperature for generation (0-1)
167
+ - `LLM_MAX_TOKENS`: Maximum tokens per response
168
+
169
+ ### Document Processing
170
+ - `DOCUMENTS_PATH`: Path to markdown documents
171
+ - `CHUNK_SIZE`: Target words per chunk
172
+ - `CHUNK_OVERLAP`: Word overlap between chunks
173
+
174
+ ### Retrieval
175
+ - `RETRIEVAL_TOP_K`: Number of documents to retrieve
176
+ - `BM25_WEIGHT`: Weight for BM25 score (0-1)
177
+ - `VECTOR_WEIGHT`: Weight for vector similarity (0-1)
178
+ - `MIN_RELEVANCE_SCORE`: Minimum score threshold
179
+
180
+ ## Administrative Documents
181
+
182
+ The system uses administrative documents from BFH including:
183
+
184
+ - Exmatriculation forms and procedures
185
+ - Leave of absence (Beurlaubung) information
186
+ - Name change forms
187
+ - Insurance information (AHV, health insurance)
188
+ - Fee schedules
189
+ - Course withdrawal procedures
190
+
191
+ Documents are stored as markdown in `assets/markdown/`.
192
+
193
+ ## Development
194
+
195
+ ### Adding New Documents
196
+
197
+ 1. Add markdown files to `assets/markdown/`
198
+ 2. Run the ingestion script:
199
+ ```bash
200
+ python scripts/ingest_documents.py
201
+ ```
202
+
203
+ ### Testing
204
+
205
+ Run the application locally and test with sample queries:
206
+ - "Wie kann ich mich exmatrikulieren?"
207
+ - "What are the fees for changing my name?"
208
+ - "Ich mΓΆchte ein Modul zurΓΌckziehen."
209
+
210
+ ### Extending the System
211
+
212
+ - **Add new agents**: Create new agent classes in `src/agents/`
213
+ - **Customize prompts**: Edit system prompts in agent initialization
214
+ - **Add new retrievers**: Implement in `src/retrieval/`
215
+ - **Modify UI**: Edit `src/ui/gradio_app.py`
216
+
217
+ ## Technical Details
218
+
219
+ ### Haystack Pipeline
220
+
221
+ The system uses Haystack 2 components:
222
+ - `MarkdownToDocument`: Convert markdown files to documents
223
+ - `DocumentSplitter`: Semantic chunking
224
+ - `OpenAIDocumentEmbedder`: Generate embeddings
225
+ - `OpenSearchDocumentStore`: Store and retrieve documents
226
+ - `OpenSearchBM25Retriever`: Keyword-based retrieval
227
+ - `OpenSearchEmbeddingRetriever`: Vector-based retrieval
228
+
229
+ ### PydanticAI Agents
230
+
231
+ Agents use structured outputs with Pydantic models:
232
+ - `IntentData`: Structured intent information
233
+ - `EmailDraft`: Email with metadata
234
+ - `FactCheckResult`: Verification results
235
+
236
+ ### OpenSearch Index Mapping
237
+
238
+ The index uses:
239
+ - Text fields with BM25 for keyword search
240
+ - k-NN vector fields for semantic search
241
+ - Metadata fields for filtering and display
242
+
243
+ ## License
244
+
245
+ MIT License - See LICENSE file for details
246
+
247
+ ## Acknowledgments
248
+
249
+ - Built for BFH (Bern University of Applied Sciences)
250
+ - Uses Haystack by deepset
251
+ - Powered by OpenAI GPT-4o
252
+ - UI built with Gradio
253
+
254
+ ## Support
255
+
256
+ For issues or questions, please open an issue on GitHub.
app.py CHANGED
@@ -1,70 +1,20 @@
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
- type="messages",
49
- additional_inputs=[
50
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
51
- gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
52
- gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
53
- gr.Slider(
54
- minimum=0.1,
55
- maximum=1.0,
56
- value=0.95,
57
- step=0.05,
58
- label="Top-p (nucleus sampling)",
59
- ),
60
- ],
61
  )
62
 
63
- with gr.Blocks() as demo:
64
- with gr.Sidebar():
65
- gr.LoginButton()
66
- chatbot.render()
67
 
 
68
 
69
  if __name__ == "__main__":
70
  demo.launch()
 
1
+ """Main application entry point for Hugging Face Spaces deployment."""
 
2
 
3
+ import logging
4
+ from src.ui.gradio_app import create_gradio_interface
5
 
6
+ # Configure logging
7
+ logging.basicConfig(
8
+ level=logging.INFO,
9
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  )
11
 
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Create and launch the Gradio interface
15
+ logger.info("Starting BFH Student Administration Email Assistant...")
16
 
17
+ demo = create_gradio_interface()
18
 
19
  if __name__ == "__main__":
20
  demo.launch()
requirements.txt CHANGED
@@ -1,60 +1,37 @@
1
- aiofiles==24.1.0
2
- annotated-types==0.7.0
3
- anyio==4.11.0
4
- audioop-lts==0.2.2
5
- Brotli==1.1.0
6
- certifi==2025.10.5
7
- charset-normalizer==3.4.3
8
- click==8.3.0
9
- distro==1.9.0
10
- fastapi==0.118.0
11
- ffmpy==0.6.1
12
- filelock==3.19.1
13
- fsspec==2025.9.0
 
 
 
 
14
  gradio==5.49.0
15
  gradio_client==1.13.3
16
- groovy==0.1.2
17
- h11==0.16.0
18
- hf-xet==1.1.10
19
- httpcore==1.0.9
20
  httpx==0.28.1
21
- huggingface-hub==0.35.3
22
- idna==3.10
23
- Jinja2==3.1.6
24
- jiter==0.11.0
25
- markdown-it-py==4.0.0
26
- MarkupSafe==3.0.3
27
- mdurl==0.1.2
28
- numpy==2.3.3
29
- openai==2.2.0
30
- orjson==3.11.3
31
- packaging==25.0
32
- pandas==2.3.3
33
- pillow==11.3.0
34
- pydantic==2.11.10
35
- pydantic_core==2.33.2
36
- pydub==0.25.1
37
- Pygments==2.19.2
38
- python-dateutil==2.9.0.post0
39
- python-dotenv==1.1.1
40
- python-multipart==0.0.20
41
- pytz==2025.2
42
- PyYAML==6.0.3
43
  requests==2.32.5
 
 
 
44
  rich==14.1.0
45
- ruff==0.13.3
46
- safehttpx==0.1.6
47
- semantic-version==2.10.0
48
- shellingham==1.5.4
49
- six==1.17.0
50
- sniffio==1.3.1
51
- starlette==0.48.0
52
- tomlkit==0.13.3
53
  tqdm==4.67.1
54
- typer==0.19.2
55
- typing-inspection==0.4.2
56
- typing_extensions==4.15.0
57
- tzdata==2025.2
58
- urllib3==2.5.0
59
  uvicorn==0.37.0
60
  websockets==15.0.1
 
 
1
+ # Core dependencies
2
+ python-dotenv==1.1.1
3
+
4
+ # Haystack and integrations
5
+ haystack-ai==2.8.0
6
+ opensearch-haystack==1.1.0
7
+ opensearch-py==2.8.0
8
+
9
+ # PydanticAI for agents
10
+ pydantic-ai==0.0.14
11
+ pydantic==2.11.10
12
+ pydantic_core==2.33.2
13
+
14
+ # OpenAI
15
+ openai==2.2.0
16
+
17
+ # Gradio UI
18
  gradio==5.49.0
19
  gradio_client==1.13.3
20
+
21
+ # HTTP and async
22
+ aiofiles==24.1.0
 
23
  httpx==0.28.1
24
+ httpcore==1.0.9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  requests==2.32.5
26
+ certifi==2025.10.5
27
+
28
+ # Utilities
29
  rich==14.1.0
 
 
 
 
 
 
 
 
30
  tqdm==4.67.1
31
+ PyYAML==6.0.3
32
+
33
+ # Supporting packages for Gradio
34
+ fastapi==0.118.0
 
35
  uvicorn==0.37.0
36
  websockets==15.0.1
37
+ huggingface-hub==0.35.3
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Scripts for document ingestion and maintenance."""
scripts/ingest_documents.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Script to ingest and index markdown documents."""
3
+
4
+ import sys
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ # Add src to path
9
+ sys.path.insert(0, str(Path(__file__).parent.parent))
10
+
11
+ from src.config import get_config
12
+ from src.document_processing.loader import MarkdownDocumentLoader
13
+ from src.document_processing.chunker import SemanticChunker
14
+ from src.indexing.opensearch_client import OpenSearchClient
15
+ from src.indexing.indexer import DocumentIndexer
16
+
17
+
18
+ def setup_logging():
19
+ """Configure logging."""
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
23
+ )
24
+
25
+
26
+ def main():
27
+ """Main ingestion workflow."""
28
+ setup_logging()
29
+ logger = logging.getLogger(__name__)
30
+
31
+ logger.info("Starting document ingestion process...")
32
+
33
+ # Load configuration
34
+ config = get_config()
35
+ logger.info(f"Using documents path: {config.document_processing.documents_path}")
36
+ logger.info(f"Target index: {config.opensearch.index_name}")
37
+
38
+ # Initialize OpenSearch client
39
+ logger.info("Connecting to OpenSearch...")
40
+ os_client = OpenSearchClient(config.opensearch)
41
+
42
+ if not os_client.ping():
43
+ logger.error("Failed to connect to OpenSearch. Please check your configuration.")
44
+ sys.exit(1)
45
+
46
+ logger.info("Successfully connected to OpenSearch")
47
+
48
+ # Create or recreate index
49
+ logger.info("Setting up index...")
50
+ if os_client.index_exists():
51
+ logger.warning(f"Index '{config.opensearch.index_name}' already exists")
52
+ response = input("Do you want to delete and recreate it? (yes/no): ")
53
+
54
+ if response.lower() in ["yes", "y"]:
55
+ logger.info("Deleting existing index...")
56
+ os_client.delete_index()
57
+ os_client.create_index(embedding_dim=1536)
58
+ else:
59
+ logger.info("Using existing index")
60
+ else:
61
+ os_client.create_index(embedding_dim=1536)
62
+
63
+ # Load documents
64
+ logger.info("Loading markdown documents...")
65
+ loader = MarkdownDocumentLoader(config.document_processing.documents_path)
66
+ documents = loader.load_documents()
67
+
68
+ if not documents:
69
+ logger.error("No documents loaded. Exiting.")
70
+ sys.exit(1)
71
+
72
+ logger.info(f"Loaded {len(documents)} documents")
73
+
74
+ # Chunk documents
75
+ logger.info("Chunking documents...")
76
+ chunker = SemanticChunker(
77
+ chunk_size=config.document_processing.chunk_size,
78
+ chunk_overlap=config.document_processing.chunk_overlap,
79
+ min_chunk_size=config.document_processing.min_chunk_size,
80
+ )
81
+ chunked_documents = chunker.chunk_documents(documents)
82
+
83
+ logger.info(f"Created {len(chunked_documents)} chunks")
84
+
85
+ # Index documents
86
+ logger.info("Indexing documents in OpenSearch...")
87
+ indexer = DocumentIndexer(
88
+ opensearch_config=config.opensearch,
89
+ llm_config=config.llm,
90
+ )
91
+
92
+ indexed_count = indexer.index_documents(chunked_documents)
93
+
94
+ logger.info(f"Successfully indexed {indexed_count} document chunks")
95
+
96
+ # Verify
97
+ final_count = indexer.get_document_count()
98
+ logger.info(f"Total documents in index: {final_count}")
99
+
100
+ logger.info("βœ… Document ingestion completed successfully!")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
src/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """BFH Student Administration RAG Email Assistant."""
2
+
3
+ __version__ = "1.0.0"
src/agents/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """PydanticAI agents for intent extraction, composition, and fact checking."""
2
+
3
+ from .intent_agent import IntentAgent
4
+ from .composer_agent import ComposerAgent
5
+ from .fact_checker_agent import FactCheckerAgent
6
+
7
+ __all__ = ["IntentAgent", "ComposerAgent", "FactCheckerAgent"]
src/agents/composer_agent.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Email composer agent using PydanticAI."""
2
+
3
+ from typing import List
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_ai import Agent
6
+ from haystack import Document
7
+ import logging
8
+
9
+ from .intent_agent import IntentData
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class EmailDraft(BaseModel):
15
+ """Structured email draft."""
16
+
17
+ subject: str = Field(description="Email subject line")
18
+ body: str = Field(description="Email body text")
19
+ tone: str = Field(
20
+ default="professional",
21
+ description="Tone of the email: 'formal', 'professional', 'friendly'",
22
+ )
23
+ sources_used: List[str] = Field(
24
+ default_factory=list,
25
+ description="List of source documents used in composing the email",
26
+ )
27
+ confidence: float = Field(
28
+ default=0.0,
29
+ description="Confidence score (0-1) in the accuracy of the response",
30
+ )
31
+
32
+
33
+ class ComposerAgent:
34
+ """Agent for composing email responses."""
35
+
36
+ def __init__(self, api_key: str, model: str = "openai:gpt-4o"):
37
+ """
38
+ Initialize the email composer agent.
39
+
40
+ Args:
41
+ api_key: OpenAI API key
42
+ model: Model to use for composition
43
+ """
44
+ self.agent = Agent(
45
+ model,
46
+ result_type=EmailDraft,
47
+ system_prompt="""You are an expert email composer for BFH (Bern University of Applied Sciences) administrative staff.
48
+
49
+ Your task is to compose professional, accurate, and helpful email responses to student inquiries based on:
50
+ 1. The user's query and extracted intent
51
+ 2. Retrieved relevant documents from the knowledge base
52
+ 3. University policies and procedures
53
+
54
+ Guidelines for email composition:
55
+ - Write in the same language as the query (German, English, or French)
56
+ - Use a professional but friendly tone
57
+ - Be clear, concise, and accurate
58
+ - Reference specific forms, deadlines, or procedures when relevant
59
+ - Include concrete next steps or actions for the student
60
+ - Cite information from the retrieved documents
61
+ - If information is incomplete, acknowledge what you can't answer
62
+ - Use appropriate greeting and closing
63
+ - Structure the email logically with paragraphs
64
+
65
+ For German emails:
66
+ - Use formal "Sie" form
67
+ - Common greetings: "Guten Tag", "Sehr geehrte/r [Name]"
68
+ - Common closings: "Freundliche GrΓΌsse", "Mit freundlichen GrΓΌssen"
69
+
70
+ For English emails:
71
+ - Use professional greeting: "Dear [Name]" or "Hello"
72
+ - Common closings: "Best regards", "Kind regards"
73
+
74
+ Track which source documents you used and estimate your confidence in the response accuracy.""",
75
+ )
76
+
77
+ async def compose_email(
78
+ self, query: str, intent: IntentData, context_docs: List[Document]
79
+ ) -> EmailDraft:
80
+ """
81
+ Compose an email response.
82
+
83
+ Args:
84
+ query: Original user query
85
+ intent: Extracted intent data
86
+ context_docs: Retrieved context documents
87
+
88
+ Returns:
89
+ Email draft
90
+ """
91
+ logger.info(f"Composing email for topic: {intent.topic}")
92
+
93
+ # Build context from documents
94
+ context_text = self._build_context(context_docs)
95
+
96
+ # Create prompt with all information
97
+ prompt = f"""Compose an email response for the following query.
98
+
99
+ User Query: {query}
100
+
101
+ Intent Analysis:
102
+ - Action Type: {intent.action_type}
103
+ - Topic: {intent.topic}
104
+ - Language: {intent.language}
105
+ - Urgency: {intent.urgency}
106
+ - Key Entities: {', '.join(intent.key_entities) if intent.key_entities else 'None'}
107
+ - Specific Questions: {', '.join(intent.specific_questions) if intent.specific_questions else 'None'}
108
+
109
+ Retrieved Context from Knowledge Base:
110
+ {context_text}
111
+
112
+ Based on this information, compose a complete email response that addresses the user's query professionally and accurately."""
113
+
114
+ try:
115
+ result = await self.agent.run(prompt)
116
+ draft = result.data
117
+
118
+ logger.info(f"Composed email - Subject: {draft.subject}")
119
+ logger.debug(f"Confidence: {draft.confidence}")
120
+
121
+ return draft
122
+
123
+ except Exception as e:
124
+ logger.error(f"Error composing email: {e}")
125
+ # Return minimal draft on error
126
+ return EmailDraft(
127
+ subject="Ihre Anfrage / Your Inquiry",
128
+ body="Vielen Dank fΓΌr Ihre Anfrage. Wir werden uns in KΓΌrze bei Ihnen melden.\n\nThank you for your inquiry. We will get back to you shortly.",
129
+ tone="professional",
130
+ confidence=0.0,
131
+ )
132
+
133
+ def _build_context(self, documents: List[Document]) -> str:
134
+ """
135
+ Build context text from retrieved documents.
136
+
137
+ Args:
138
+ documents: List of retrieved documents
139
+
140
+ Returns:
141
+ Formatted context text
142
+ """
143
+ if not documents:
144
+ return "No relevant documents found in the knowledge base."
145
+
146
+ context_parts = []
147
+ for i, doc in enumerate(documents, 1):
148
+ source = doc.meta.get("source_file", "Unknown") if doc.meta else "Unknown"
149
+ score = doc.score or 0.0
150
+
151
+ context_parts.append(
152
+ f"--- Document {i} (Source: {source}, Relevance: {score:.2f}) ---\n{doc.content}\n"
153
+ )
154
+
155
+ return "\n".join(context_parts)
src/agents/fact_checker_agent.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fact checker agent using PydanticAI for validating email responses."""
2
+
3
+ from typing import List
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_ai import Agent
6
+ from haystack import Document
7
+ import logging
8
+
9
+ from .composer_agent import EmailDraft
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class FactCheckResult(BaseModel):
15
+ """Result of fact checking an email draft."""
16
+
17
+ is_accurate: bool = Field(
18
+ description="Whether the email content is factually accurate"
19
+ )
20
+ accuracy_score: float = Field(
21
+ description="Overall accuracy score (0-1)",
22
+ ge=0.0,
23
+ le=1.0,
24
+ )
25
+ issues_found: List[str] = Field(
26
+ default_factory=list,
27
+ description="List of factual issues or inaccuracies found",
28
+ )
29
+ verification_steps: List[str] = Field(
30
+ default_factory=list,
31
+ description="Steps taken to verify the facts",
32
+ )
33
+ suggestions: List[str] = Field(
34
+ default_factory=list,
35
+ description="Suggestions for improving accuracy or completeness",
36
+ )
37
+ verified_claims: List[str] = Field(
38
+ default_factory=list,
39
+ description="Claims that were successfully verified against sources",
40
+ )
41
+
42
+
43
+ class FactCheckerAgent:
44
+ """Agent for fact-checking email drafts against source documents."""
45
+
46
+ def __init__(self, api_key: str, model: str = "openai:gpt-4o"):
47
+ """
48
+ Initialize the fact checker agent.
49
+
50
+ Args:
51
+ api_key: OpenAI API key
52
+ model: Model to use for fact checking
53
+ """
54
+ self.agent = Agent(
55
+ model,
56
+ result_type=FactCheckResult,
57
+ system_prompt="""You are an expert fact-checker for university administrative communications.
58
+
59
+ Your task is to verify the accuracy of email drafts against source documents from the knowledge base.
60
+
61
+ Verification process:
62
+ 1. Extract all factual claims from the email (dates, procedures, requirements, fees, deadlines, etc.)
63
+ 2. Cross-reference each claim with the provided source documents
64
+ 3. Identify any unsupported, incorrect, or contradictory information
65
+ 4. Check for completeness - are important details missing?
66
+ 5. Verify that references to forms, processes, or policies are accurate
67
+ 6. Ensure numerical information (fees, dates, etc.) is correct
68
+
69
+ Classification of issues:
70
+ - CRITICAL: Factually incorrect information that could mislead students
71
+ - WARNING: Information not found in sources (may be correct but unverified)
72
+ - SUGGESTION: Missing information that would improve completeness
73
+
74
+ Be thorough and precise. University administrative information must be accurate as it affects students' academic status and finances.
75
+
76
+ Provide:
77
+ - Overall accuracy assessment
78
+ - Specific issues found with severity level
79
+ - Verification steps you performed
80
+ - Suggestions for improvement
81
+ - List of verified claims""",
82
+ )
83
+
84
+ async def fact_check(
85
+ self, email_draft: EmailDraft, source_docs: List[Document]
86
+ ) -> FactCheckResult:
87
+ """
88
+ Fact-check an email draft against source documents.
89
+
90
+ Args:
91
+ email_draft: Email draft to check
92
+ source_docs: Source documents used for context
93
+
94
+ Returns:
95
+ Fact check result with accuracy assessment
96
+ """
97
+ logger.info("Fact-checking email draft...")
98
+
99
+ # Build source context
100
+ source_text = self._build_source_context(source_docs)
101
+
102
+ # Create fact-checking prompt
103
+ prompt = f"""Fact-check the following email draft against the provided source documents.
104
+
105
+ EMAIL DRAFT:
106
+ Subject: {email_draft.subject}
107
+
108
+ Body:
109
+ {email_draft.body}
110
+
111
+ SOURCE DOCUMENTS:
112
+ {source_text}
113
+
114
+ Perform a thorough fact-check and identify any inaccuracies, unsupported claims, or missing important information."""
115
+
116
+ try:
117
+ result = await self.agent.run(prompt)
118
+ fact_check_result = result.data
119
+
120
+ logger.info(f"Fact check complete - Accurate: {fact_check_result.is_accurate}")
121
+ logger.info(f"Accuracy score: {fact_check_result.accuracy_score:.2f}")
122
+
123
+ if fact_check_result.issues_found:
124
+ logger.warning(f"Issues found: {len(fact_check_result.issues_found)}")
125
+ for issue in fact_check_result.issues_found:
126
+ logger.warning(f" - {issue}")
127
+
128
+ return fact_check_result
129
+
130
+ except Exception as e:
131
+ logger.error(f"Error during fact checking: {e}")
132
+ # Return conservative result on error
133
+ return FactCheckResult(
134
+ is_accurate=False,
135
+ accuracy_score=0.5,
136
+ issues_found=["Unable to complete fact check due to error"],
137
+ verification_steps=["Attempted automated fact checking"],
138
+ )
139
+
140
+ def _build_source_context(self, documents: List[Document]) -> str:
141
+ """
142
+ Build formatted source context from documents.
143
+
144
+ Args:
145
+ documents: List of source documents
146
+
147
+ Returns:
148
+ Formatted source text
149
+ """
150
+ if not documents:
151
+ return "No source documents provided."
152
+
153
+ context_parts = []
154
+ for i, doc in enumerate(documents, 1):
155
+ source = doc.meta.get("source_file", "Unknown") if doc.meta else "Unknown"
156
+
157
+ context_parts.append(f"--- Source {i}: {source} ---\n{doc.content}\n")
158
+
159
+ return "\n".join(context_parts)
src/agents/intent_agent.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Intent extraction agent using PydanticAI."""
2
+
3
+ from typing import List
4
+ from pydantic import BaseModel, Field
5
+ from pydantic_ai import Agent
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class IntentData(BaseModel):
12
+ """Structured intent data extracted from user query."""
13
+
14
+ action_type: str = Field(
15
+ description="Type of action: 'information_request', 'form_help', 'process_guidance', 'general_inquiry'"
16
+ )
17
+ topic: str = Field(
18
+ description="Main topic or subject of the query (e.g., 'exmatriculation', 'insurance', 'fees')"
19
+ )
20
+ key_entities: List[str] = Field(
21
+ default_factory=list,
22
+ description="Key entities mentioned (dates, forms, departments, etc.)",
23
+ )
24
+ language: str = Field(
25
+ default="de", description="Detected language of the query (de, en, fr)"
26
+ )
27
+ urgency: str = Field(
28
+ default="normal", description="Urgency level: 'high', 'normal', 'low'"
29
+ )
30
+ specific_questions: List[str] = Field(
31
+ default_factory=list,
32
+ description="Specific questions or sub-questions identified in the query",
33
+ )
34
+
35
+
36
+ class IntentAgent:
37
+ """Agent for extracting structured intent from user queries."""
38
+
39
+ def __init__(self, api_key: str, model: str = "openai:gpt-4o"):
40
+ """
41
+ Initialize the intent extraction agent.
42
+
43
+ Args:
44
+ api_key: OpenAI API key
45
+ model: Model to use for intent extraction
46
+ """
47
+ self.agent = Agent(
48
+ model,
49
+ result_type=IntentData,
50
+ system_prompt="""You are an expert at analyzing user queries for a university administrative email assistant.
51
+
52
+ Your task is to extract structured intent information from user queries. Analyze:
53
+ 1. What type of action is being requested (information, form help, process guidance, etc.)
54
+ 2. The main topic or subject matter
55
+ 3. Key entities mentioned (specific forms, dates, departments, processes)
56
+ 4. The language of the query
57
+ 5. Urgency level based on context and keywords
58
+ 6. Specific questions that need to be answered
59
+
60
+ Context: This is for BFH (Bern University of Applied Sciences) administrative staff helping students with:
61
+ - Exmatriculation (leaving university)
62
+ - Leave of absence (Beurlaubung)
63
+ - Name changes
64
+ - Insurance matters (AHV, health insurance)
65
+ - Fees and payments
66
+ - Course withdrawals and deadlines
67
+
68
+ Provide accurate, structured intent extraction to help compose appropriate email responses.""",
69
+ )
70
+
71
+ async def extract_intent(self, query: str) -> IntentData:
72
+ """
73
+ Extract intent from user query.
74
+
75
+ Args:
76
+ query: User's query text
77
+
78
+ Returns:
79
+ Structured intent data
80
+ """
81
+ logger.info("Extracting intent from query...")
82
+
83
+ try:
84
+ result = await self.agent.run(query)
85
+ intent = result.data
86
+
87
+ logger.info(f"Extracted intent - Action: {intent.action_type}, Topic: {intent.topic}")
88
+ logger.debug(f"Full intent: {intent}")
89
+
90
+ return intent
91
+
92
+ except Exception as e:
93
+ logger.error(f"Error extracting intent: {e}")
94
+ # Return default intent on error
95
+ return IntentData(
96
+ action_type="general_inquiry",
97
+ topic="unknown",
98
+ language="de",
99
+ urgency="normal",
100
+ )
src/config.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration management for the RAG Email Assistant."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ from dotenv import load_dotenv
7
+
8
+ # Load environment variables from .env file
9
+ load_dotenv()
10
+
11
+
12
+ @dataclass
13
+ class OpenSearchConfig:
14
+ """OpenSearch connection configuration."""
15
+
16
+ host: str
17
+ port: int
18
+ user: str
19
+ password: str
20
+ index_name: str
21
+ use_ssl: bool = True
22
+ verify_certs: bool = False
23
+
24
+ @classmethod
25
+ def from_env(cls) -> "OpenSearchConfig":
26
+ """Create configuration from environment variables."""
27
+ return cls(
28
+ host=os.getenv("OPENSEARCH_HOST", "localhost"),
29
+ port=int(os.getenv("OPENSEARCH_PORT", "9200")),
30
+ user=os.getenv("OPENSEARCH_USER", "admin"),
31
+ password=os.getenv("OPENSEARCH_PASSWORD", ""),
32
+ index_name=os.getenv("INDEX_NAME", "bfh_admin_docs"),
33
+ use_ssl=os.getenv("OPENSEARCH_USE_SSL", "true").lower() == "true",
34
+ verify_certs=os.getenv("OPENSEARCH_VERIFY_CERTS", "false").lower() == "true",
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class LLMConfig:
40
+ """LLM configuration."""
41
+
42
+ api_key: str
43
+ model_name: str = "gpt-4o"
44
+ embedding_model: str = "text-embedding-3-small"
45
+ temperature: float = 0.7
46
+ max_tokens: int = 2000
47
+
48
+ @classmethod
49
+ def from_env(cls) -> "LLMConfig":
50
+ """Create configuration from environment variables."""
51
+ api_key = os.getenv("OPENAI_API_KEY", "")
52
+ if not api_key:
53
+ raise ValueError("OPENAI_API_KEY environment variable is required")
54
+
55
+ return cls(
56
+ api_key=api_key,
57
+ model_name=os.getenv("LLM_MODEL", "gpt-4o"),
58
+ embedding_model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
59
+ temperature=float(os.getenv("LLM_TEMPERATURE", "0.7")),
60
+ max_tokens=int(os.getenv("LLM_MAX_TOKENS", "2000")),
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class DocumentProcessingConfig:
66
+ """Document processing configuration."""
67
+
68
+ documents_path: str = "assets/markdown"
69
+ chunk_size: int = 300 # Target words per chunk
70
+ chunk_overlap: int = 50 # Words overlap between chunks
71
+ min_chunk_size: int = 100 # Minimum words per chunk
72
+
73
+ @classmethod
74
+ def from_env(cls) -> "DocumentProcessingConfig":
75
+ """Create configuration from environment variables."""
76
+ return cls(
77
+ documents_path=os.getenv("DOCUMENTS_PATH", "assets/markdown"),
78
+ chunk_size=int(os.getenv("CHUNK_SIZE", "300")),
79
+ chunk_overlap=int(os.getenv("CHUNK_OVERLAP", "50")),
80
+ min_chunk_size=int(os.getenv("MIN_CHUNK_SIZE", "100")),
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class RetrievalConfig:
86
+ """Retrieval configuration."""
87
+
88
+ top_k: int = 5 # Number of documents to retrieve
89
+ bm25_weight: float = 0.5 # Weight for BM25 score
90
+ vector_weight: float = 0.5 # Weight for vector similarity score
91
+ min_score: float = 0.3 # Minimum relevance score threshold
92
+
93
+ @classmethod
94
+ def from_env(cls) -> "RetrievalConfig":
95
+ """Create configuration from environment variables."""
96
+ return cls(
97
+ top_k=int(os.getenv("RETRIEVAL_TOP_K", "5")),
98
+ bm25_weight=float(os.getenv("BM25_WEIGHT", "0.5")),
99
+ vector_weight=float(os.getenv("VECTOR_WEIGHT", "0.5")),
100
+ min_score=float(os.getenv("MIN_RELEVANCE_SCORE", "0.3")),
101
+ )
102
+
103
+
104
+ @dataclass
105
+ class AppConfig:
106
+ """Main application configuration."""
107
+
108
+ opensearch: OpenSearchConfig
109
+ llm: LLMConfig
110
+ document_processing: DocumentProcessingConfig
111
+ retrieval: RetrievalConfig
112
+ debug: bool = False
113
+
114
+ @classmethod
115
+ def from_env(cls) -> "AppConfig":
116
+ """Create complete configuration from environment variables."""
117
+ return cls(
118
+ opensearch=OpenSearchConfig.from_env(),
119
+ llm=LLMConfig.from_env(),
120
+ document_processing=DocumentProcessingConfig.from_env(),
121
+ retrieval=RetrievalConfig.from_env(),
122
+ debug=os.getenv("DEBUG", "false").lower() == "true",
123
+ )
124
+
125
+
126
+ # Global configuration instance
127
+ _config: Optional[AppConfig] = None
128
+
129
+
130
+ def get_config() -> AppConfig:
131
+ """Get or create the global configuration instance."""
132
+ global _config
133
+ if _config is None:
134
+ _config = AppConfig.from_env()
135
+ return _config
136
+
137
+
138
+ def reset_config():
139
+ """Reset the global configuration instance (useful for testing)."""
140
+ global _config
141
+ _config = None
src/document_processing/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Document processing components for loading and chunking documents."""
2
+
3
+ from .loader import MarkdownDocumentLoader
4
+ from .chunker import SemanticChunker
5
+
6
+ __all__ = ["MarkdownDocumentLoader", "SemanticChunker"]
src/document_processing/chunker.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document chunking with semantic and sentence-based splitting."""
2
+
3
+ from typing import List
4
+ from haystack import Document
5
+ from haystack.components.preprocessors import DocumentSplitter
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class SemanticChunker:
12
+ """Chunks documents using semantic and sentence-based splitting."""
13
+
14
+ def __init__(
15
+ self,
16
+ chunk_size: int = 300,
17
+ chunk_overlap: int = 50,
18
+ min_chunk_size: int = 100,
19
+ ):
20
+ """
21
+ Initialize the chunker.
22
+
23
+ Args:
24
+ chunk_size: Target number of words per chunk
25
+ chunk_overlap: Number of words to overlap between chunks
26
+ min_chunk_size: Minimum number of words per chunk
27
+ """
28
+ self.chunk_size = chunk_size
29
+ self.chunk_overlap = chunk_overlap
30
+ self.min_chunk_size = min_chunk_size
31
+
32
+ # Use Haystack's DocumentSplitter with sentence-based splitting
33
+ self.splitter = DocumentSplitter(
34
+ split_by="sentence",
35
+ split_length=chunk_size,
36
+ split_overlap=chunk_overlap,
37
+ split_threshold=min_chunk_size,
38
+ )
39
+
40
+ def chunk_documents(self, documents: List[Document]) -> List[Document]:
41
+ """
42
+ Chunk documents into smaller pieces.
43
+
44
+ Args:
45
+ documents: List of documents to chunk
46
+
47
+ Returns:
48
+ List of chunked documents with metadata
49
+ """
50
+ if not documents:
51
+ logger.warning("No documents to chunk")
52
+ return []
53
+
54
+ logger.info(f"Chunking {len(documents)} documents")
55
+
56
+ # Split documents
57
+ result = self.splitter.run(documents=documents)
58
+ chunked_docs = result.get("documents", [])
59
+
60
+ # Add chunk metadata
61
+ for idx, doc in enumerate(chunked_docs):
62
+ if doc.meta is None:
63
+ doc.meta = {}
64
+ doc.meta["chunk_id"] = idx
65
+ doc.meta["chunk_size"] = len(doc.content.split())
66
+
67
+ logger.info(f"Created {len(chunked_docs)} chunks from {len(documents)} documents")
68
+
69
+ # Log statistics
70
+ chunk_sizes = [doc.meta.get("chunk_size", 0) for doc in chunked_docs]
71
+ if chunk_sizes:
72
+ avg_size = sum(chunk_sizes) / len(chunk_sizes)
73
+ logger.info(
74
+ f"Chunk statistics - Avg: {avg_size:.1f} words, "
75
+ f"Min: {min(chunk_sizes)}, Max: {max(chunk_sizes)}"
76
+ )
77
+
78
+ return chunked_docs
src/document_processing/loader.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document loader for markdown files."""
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+ from haystack import Document
6
+ from haystack.components.converters import MarkdownToDocument
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class MarkdownDocumentLoader:
13
+ """Loads markdown documents from a directory."""
14
+
15
+ def __init__(self, documents_path: str):
16
+ """
17
+ Initialize the document loader.
18
+
19
+ Args:
20
+ documents_path: Path to directory containing markdown files
21
+ """
22
+ self.documents_path = Path(documents_path)
23
+ self.converter = MarkdownToDocument()
24
+
25
+ def load_documents(self) -> List[Document]:
26
+ """
27
+ Load all markdown documents from the configured directory.
28
+
29
+ Returns:
30
+ List of Haystack Document objects
31
+ """
32
+ if not self.documents_path.exists():
33
+ raise FileNotFoundError(f"Documents path does not exist: {self.documents_path}")
34
+
35
+ documents = []
36
+ markdown_files = list(self.documents_path.glob("*.md"))
37
+
38
+ if not markdown_files:
39
+ logger.warning(f"No markdown files found in {self.documents_path}")
40
+ return documents
41
+
42
+ logger.info(f"Loading {len(markdown_files)} markdown files from {self.documents_path}")
43
+
44
+ for md_file in markdown_files:
45
+ try:
46
+ # Convert markdown file to Haystack Document
47
+ result = self.converter.run(sources=[md_file])
48
+ file_documents = result.get("documents", [])
49
+
50
+ # Add metadata
51
+ for doc in file_documents:
52
+ if doc.meta is None:
53
+ doc.meta = {}
54
+ doc.meta["source_file"] = md_file.name
55
+ doc.meta["file_path"] = str(md_file)
56
+
57
+ documents.extend(file_documents)
58
+ logger.info(f"Loaded document: {md_file.name}")
59
+
60
+ except Exception as e:
61
+ logger.error(f"Error loading {md_file.name}: {e}")
62
+ continue
63
+
64
+ logger.info(f"Successfully loaded {len(documents)} documents")
65
+ return documents
src/indexing/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Indexing components for OpenSearch integration."""
2
+
3
+ from .opensearch_client import OpenSearchClient
4
+ from .indexer import DocumentIndexer
5
+
6
+ __all__ = ["OpenSearchClient", "DocumentIndexer"]
src/indexing/indexer.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document indexer for storing documents in OpenSearch."""
2
+
3
+ from typing import List
4
+ from haystack import Document
5
+ from haystack.components.embedders import OpenAIDocumentEmbedder
6
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
7
+ from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore
8
+ import logging
9
+
10
+ from ..config import OpenSearchConfig, LLMConfig
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DocumentIndexer:
16
+ """Indexes documents in OpenSearch with embeddings."""
17
+
18
+ def __init__(self, opensearch_config: OpenSearchConfig, llm_config: LLMConfig):
19
+ """
20
+ Initialize the document indexer.
21
+
22
+ Args:
23
+ opensearch_config: OpenSearch configuration
24
+ llm_config: LLM configuration for embeddings
25
+ """
26
+ self.opensearch_config = opensearch_config
27
+ self.llm_config = llm_config
28
+
29
+ # Initialize document store
30
+ self.document_store = OpenSearchDocumentStore(
31
+ hosts=f"{opensearch_config.host}:{opensearch_config.port}",
32
+ index=opensearch_config.index_name,
33
+ http_auth=(opensearch_config.user, opensearch_config.password),
34
+ use_ssl=opensearch_config.use_ssl,
35
+ verify_certs=opensearch_config.verify_certs,
36
+ embedding_dim=1536, # text-embedding-3-small dimension
37
+ )
38
+
39
+ # Initialize embedder
40
+ self.embedder = OpenAIDocumentEmbedder(
41
+ api_key=llm_config.api_key,
42
+ model=llm_config.embedding_model,
43
+ )
44
+
45
+ def index_documents(self, documents: List[Document]) -> int:
46
+ """
47
+ Index documents with embeddings.
48
+
49
+ Args:
50
+ documents: List of documents to index
51
+
52
+ Returns:
53
+ Number of documents successfully indexed
54
+ """
55
+ if not documents:
56
+ logger.warning("No documents to index")
57
+ return 0
58
+
59
+ logger.info(f"Indexing {len(documents)} documents")
60
+
61
+ try:
62
+ # Generate embeddings for documents
63
+ logger.info("Generating embeddings...")
64
+ result = self.embedder.run(documents=documents)
65
+ embedded_docs = result.get("documents", [])
66
+
67
+ if not embedded_docs:
68
+ logger.error("Failed to generate embeddings")
69
+ return 0
70
+
71
+ logger.info(f"Generated embeddings for {len(embedded_docs)} documents")
72
+
73
+ # Write documents to OpenSearch
74
+ logger.info("Writing documents to OpenSearch...")
75
+ self.document_store.write_documents(embedded_docs)
76
+
77
+ doc_count = self.document_store.count_documents()
78
+ logger.info(f"Successfully indexed documents. Total documents in store: {doc_count}")
79
+
80
+ return len(embedded_docs)
81
+
82
+ except Exception as e:
83
+ logger.error(f"Error indexing documents: {e}")
84
+ raise
85
+
86
+ def clear_index(self):
87
+ """Clear all documents from the index."""
88
+ try:
89
+ self.document_store.delete_documents()
90
+ logger.info("Cleared all documents from index")
91
+ except Exception as e:
92
+ logger.error(f"Error clearing index: {e}")
93
+ raise
94
+
95
+ def get_document_count(self) -> int:
96
+ """
97
+ Get number of documents in the index.
98
+
99
+ Returns:
100
+ Document count
101
+ """
102
+ try:
103
+ return self.document_store.count_documents()
104
+ except Exception as e:
105
+ logger.error(f"Error getting document count: {e}")
106
+ return 0
src/indexing/opensearch_client.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenSearch client for document storage and retrieval."""
2
+
3
+ from typing import Optional
4
+ from opensearchpy import OpenSearch
5
+ from opensearchpy.exceptions import RequestError
6
+ import logging
7
+
8
+ from ..config import OpenSearchConfig
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class OpenSearchClient:
14
+ """Client for interacting with OpenSearch."""
15
+
16
+ def __init__(self, config: OpenSearchConfig):
17
+ """
18
+ Initialize OpenSearch client.
19
+
20
+ Args:
21
+ config: OpenSearch configuration
22
+ """
23
+ self.config = config
24
+ self.client = self._create_client()
25
+
26
+ def _create_client(self) -> OpenSearch:
27
+ """Create OpenSearch client connection."""
28
+ return OpenSearch(
29
+ hosts=[{"host": self.config.host, "port": self.config.port}],
30
+ http_auth=(self.config.user, self.config.password),
31
+ use_ssl=self.config.use_ssl,
32
+ verify_certs=self.config.verify_certs,
33
+ ssl_show_warn=False,
34
+ )
35
+
36
+ def ping(self) -> bool:
37
+ """
38
+ Check if OpenSearch is accessible.
39
+
40
+ Returns:
41
+ True if connection is successful
42
+ """
43
+ try:
44
+ return self.client.ping()
45
+ except Exception as e:
46
+ logger.error(f"Failed to ping OpenSearch: {e}")
47
+ return False
48
+
49
+ def create_index(self, index_name: Optional[str] = None, embedding_dim: int = 1536) -> bool:
50
+ """
51
+ Create an index with proper mapping for hybrid retrieval.
52
+
53
+ Args:
54
+ index_name: Name of index to create (uses config default if not provided)
55
+ embedding_dim: Dimension of embedding vectors
56
+
57
+ Returns:
58
+ True if index was created or already exists
59
+ """
60
+ index_name = index_name or self.config.index_name
61
+
62
+ # Define index mapping for hybrid retrieval
63
+ mapping = {
64
+ "settings": {
65
+ "index": {
66
+ "number_of_shards": 2,
67
+ "number_of_replicas": 1,
68
+ "knn": True, # Enable k-NN
69
+ }
70
+ },
71
+ "mappings": {
72
+ "properties": {
73
+ "content": {
74
+ "type": "text",
75
+ "analyzer": "standard",
76
+ },
77
+ "embedding": {
78
+ "type": "knn_vector",
79
+ "dimension": embedding_dim,
80
+ "method": {
81
+ "name": "hnsw",
82
+ "space_type": "cosinesimil",
83
+ "engine": "nmslib",
84
+ },
85
+ },
86
+ "meta": {
87
+ "type": "object",
88
+ "properties": {
89
+ "source_file": {"type": "keyword"},
90
+ "file_path": {"type": "keyword"},
91
+ "chunk_id": {"type": "integer"},
92
+ "chunk_size": {"type": "integer"},
93
+ },
94
+ },
95
+ }
96
+ },
97
+ }
98
+
99
+ try:
100
+ if self.client.indices.exists(index=index_name):
101
+ logger.info(f"Index '{index_name}' already exists")
102
+ return True
103
+
104
+ self.client.indices.create(index=index_name, body=mapping)
105
+ logger.info(f"Created index '{index_name}'")
106
+ return True
107
+
108
+ except RequestError as e:
109
+ logger.error(f"Failed to create index: {e}")
110
+ return False
111
+
112
+ def delete_index(self, index_name: Optional[str] = None) -> bool:
113
+ """
114
+ Delete an index.
115
+
116
+ Args:
117
+ index_name: Name of index to delete (uses config default if not provided)
118
+
119
+ Returns:
120
+ True if index was deleted
121
+ """
122
+ index_name = index_name or self.config.index_name
123
+
124
+ try:
125
+ if not self.client.indices.exists(index=index_name):
126
+ logger.warning(f"Index '{index_name}' does not exist")
127
+ return False
128
+
129
+ self.client.indices.delete(index=index_name)
130
+ logger.info(f"Deleted index '{index_name}'")
131
+ return True
132
+
133
+ except Exception as e:
134
+ logger.error(f"Failed to delete index: {e}")
135
+ return False
136
+
137
+ def index_exists(self, index_name: Optional[str] = None) -> bool:
138
+ """
139
+ Check if an index exists.
140
+
141
+ Args:
142
+ index_name: Name of index to check (uses config default if not provided)
143
+
144
+ Returns:
145
+ True if index exists
146
+ """
147
+ index_name = index_name or self.config.index_name
148
+ return self.client.indices.exists(index=index_name)
149
+
150
+ def get_document_count(self, index_name: Optional[str] = None) -> int:
151
+ """
152
+ Get number of documents in index.
153
+
154
+ Args:
155
+ index_name: Name of index (uses config default if not provided)
156
+
157
+ Returns:
158
+ Document count
159
+ """
160
+ index_name = index_name or self.config.index_name
161
+
162
+ try:
163
+ result = self.client.count(index=index_name)
164
+ return result["count"]
165
+ except Exception as e:
166
+ logger.error(f"Failed to get document count: {e}")
167
+ return 0
src/pipeline/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Pipeline orchestration for the multi-agent RAG system."""
2
+
3
+ from .orchestrator import RAGOrchestrator
4
+
5
+ __all__ = ["RAGOrchestrator"]
src/pipeline/orchestrator.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG pipeline orchestrator coordinating all agents and retrieval."""
2
+
3
+ from typing import Dict, Any, List
4
+ from pydantic import BaseModel
5
+ from haystack import Document
6
+ import logging
7
+
8
+ from ..config import AppConfig
9
+ from ..agents.intent_agent import IntentAgent, IntentData
10
+ from ..agents.composer_agent import ComposerAgent, EmailDraft
11
+ from ..agents.fact_checker_agent import FactCheckerAgent, FactCheckResult
12
+ from ..retrieval.hybrid_retriever import HybridRetriever
13
+ from ..indexing.indexer import DocumentIndexer
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class PipelineResult(BaseModel):
19
+ """Complete result from the RAG pipeline."""
20
+
21
+ query: str
22
+ intent: IntentData
23
+ retrieved_docs: List[Dict[str, Any]]
24
+ email_draft: EmailDraft
25
+ fact_check: FactCheckResult
26
+ processing_time: float = 0.0
27
+
28
+
29
+ class RAGOrchestrator:
30
+ """Orchestrates the multi-agent RAG pipeline."""
31
+
32
+ def __init__(self, config: AppConfig, document_indexer: DocumentIndexer):
33
+ """
34
+ Initialize the RAG orchestrator.
35
+
36
+ Args:
37
+ config: Application configuration
38
+ document_indexer: Document indexer instance (contains document store)
39
+ """
40
+ self.config = config
41
+
42
+ # Initialize agents
43
+ self.intent_agent = IntentAgent(
44
+ api_key=config.llm.api_key,
45
+ model=f"openai:{config.llm.model_name}",
46
+ )
47
+
48
+ self.composer_agent = ComposerAgent(
49
+ api_key=config.llm.api_key,
50
+ model=f"openai:{config.llm.model_name}",
51
+ )
52
+
53
+ self.fact_checker_agent = FactCheckerAgent(
54
+ api_key=config.llm.api_key,
55
+ model=f"openai:{config.llm.model_name}",
56
+ )
57
+
58
+ # Initialize retriever
59
+ self.retriever = HybridRetriever(
60
+ document_store=document_indexer.document_store,
61
+ llm_config=config.llm,
62
+ retrieval_config=config.retrieval,
63
+ )
64
+
65
+ async def process_query(self, query: str) -> PipelineResult:
66
+ """
67
+ Process a user query through the complete RAG pipeline.
68
+
69
+ Args:
70
+ query: User's query text
71
+
72
+ Returns:
73
+ Complete pipeline result
74
+ """
75
+ import time
76
+
77
+ start_time = time.time()
78
+
79
+ logger.info(f"Processing query: {query[:100]}...")
80
+
81
+ try:
82
+ # Step 1: Extract intent
83
+ logger.info("Step 1: Extracting intent...")
84
+ intent = await self.intent_agent.extract_intent(query)
85
+
86
+ # Step 2: Retrieve relevant documents
87
+ logger.info("Step 2: Retrieving relevant documents...")
88
+ retrieved_docs = self.retriever.retrieve(query)
89
+
90
+ logger.info(f"Retrieved {len(retrieved_docs)} documents")
91
+
92
+ # Step 3: Compose email draft
93
+ logger.info("Step 3: Composing email draft...")
94
+ email_draft = await self.composer_agent.compose_email(
95
+ query=query,
96
+ intent=intent,
97
+ context_docs=retrieved_docs,
98
+ )
99
+
100
+ # Step 4: Fact-check the draft
101
+ logger.info("Step 4: Fact-checking email draft...")
102
+ fact_check = await self.fact_checker_agent.fact_check(
103
+ email_draft=email_draft,
104
+ source_docs=retrieved_docs,
105
+ )
106
+
107
+ processing_time = time.time() - start_time
108
+
109
+ # Build result
110
+ result = PipelineResult(
111
+ query=query,
112
+ intent=intent,
113
+ retrieved_docs=self._serialize_documents(retrieved_docs),
114
+ email_draft=email_draft,
115
+ fact_check=fact_check,
116
+ processing_time=processing_time,
117
+ )
118
+
119
+ logger.info(f"Pipeline completed in {processing_time:.2f}s")
120
+
121
+ return result
122
+
123
+ except Exception as e:
124
+ logger.error(f"Error in pipeline: {e}")
125
+ raise
126
+
127
+ def _serialize_documents(self, documents: List[Document]) -> List[Dict[str, Any]]:
128
+ """
129
+ Serialize Haystack documents to dictionaries.
130
+
131
+ Args:
132
+ documents: List of Haystack documents
133
+
134
+ Returns:
135
+ List of document dictionaries
136
+ """
137
+ serialized = []
138
+ for doc in documents:
139
+ serialized.append(
140
+ {
141
+ "content": doc.content,
142
+ "score": doc.score,
143
+ "meta": doc.meta or {},
144
+ }
145
+ )
146
+ return serialized
147
+
148
+ async def refine_draft(
149
+ self,
150
+ original_query: str,
151
+ current_draft: str,
152
+ user_feedback: str,
153
+ retrieved_docs: List[Document],
154
+ ) -> EmailDraft:
155
+ """
156
+ Refine an email draft based on user feedback.
157
+
158
+ Args:
159
+ original_query: Original user query
160
+ current_draft: Current email draft text
161
+ user_feedback: User's feedback or refinement request
162
+ retrieved_docs: Previously retrieved documents
163
+
164
+ Returns:
165
+ Refined email draft
166
+ """
167
+ logger.info("Refining email draft based on user feedback...")
168
+
169
+ # Create refinement prompt
170
+ refinement_query = f"""Original Query: {original_query}
171
+
172
+ Current Draft:
173
+ {current_draft}
174
+
175
+ User Feedback/Refinement Request:
176
+ {user_feedback}
177
+
178
+ Please revise the email draft according to the user's feedback while maintaining accuracy and professionalism."""
179
+
180
+ # Re-extract intent with refinement context
181
+ intent = await self.intent_agent.extract_intent(refinement_query)
182
+
183
+ # Compose refined draft
184
+ refined_draft = await self.composer_agent.compose_email(
185
+ query=refinement_query,
186
+ intent=intent,
187
+ context_docs=retrieved_docs,
188
+ )
189
+
190
+ logger.info("Email draft refined")
191
+
192
+ return refined_draft
src/retrieval/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Retrieval components for hybrid search."""
2
+
3
+ from .hybrid_retriever import HybridRetriever
4
+
5
+ __all__ = ["HybridRetriever"]
src/retrieval/hybrid_retriever.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid retriever combining BM25 and vector search."""
2
+
3
+ from typing import List, Dict, Any
4
+ from haystack import Document
5
+ from haystack.components.embedders import OpenAITextEmbedder
6
+ from haystack_integrations.document_stores.opensearch import OpenSearchDocumentStore
7
+ from haystack_integrations.components.retrievers.opensearch import (
8
+ OpenSearchBM25Retriever,
9
+ OpenSearchEmbeddingRetriever,
10
+ )
11
+ import logging
12
+
13
+ from ..config import RetrievalConfig, LLMConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class HybridRetriever:
19
+ """Retrieves documents using hybrid BM25 + vector search."""
20
+
21
+ def __init__(
22
+ self,
23
+ document_store: OpenSearchDocumentStore,
24
+ llm_config: LLMConfig,
25
+ retrieval_config: RetrievalConfig,
26
+ ):
27
+ """
28
+ Initialize the hybrid retriever.
29
+
30
+ Args:
31
+ document_store: OpenSearch document store
32
+ llm_config: LLM configuration for embeddings
33
+ retrieval_config: Retrieval configuration
34
+ """
35
+ self.document_store = document_store
36
+ self.llm_config = llm_config
37
+ self.retrieval_config = retrieval_config
38
+
39
+ # Initialize BM25 retriever
40
+ self.bm25_retriever = OpenSearchBM25Retriever(
41
+ document_store=document_store,
42
+ )
43
+
44
+ # Initialize embedding retriever
45
+ self.embedding_retriever = OpenSearchEmbeddingRetriever(
46
+ document_store=document_store,
47
+ )
48
+
49
+ # Initialize text embedder for queries
50
+ self.text_embedder = OpenAITextEmbedder(
51
+ api_key=llm_config.api_key,
52
+ model=llm_config.embedding_model,
53
+ )
54
+
55
+ def retrieve(self, query: str) -> List[Document]:
56
+ """
57
+ Retrieve documents using hybrid search.
58
+
59
+ Args:
60
+ query: Search query
61
+
62
+ Returns:
63
+ List of relevant documents with scores
64
+ """
65
+ logger.info(f"Retrieving documents for query: {query[:100]}...")
66
+
67
+ try:
68
+ # Get BM25 results
69
+ logger.debug("Running BM25 retrieval...")
70
+ bm25_results = self.bm25_retriever.run(
71
+ query=query,
72
+ top_k=self.retrieval_config.top_k * 2, # Get more to merge
73
+ )
74
+ bm25_docs = bm25_results.get("documents", [])
75
+ logger.debug(f"BM25 retrieved {len(bm25_docs)} documents")
76
+
77
+ # Generate query embedding
78
+ logger.debug("Generating query embedding...")
79
+ embedding_result = self.text_embedder.run(text=query)
80
+ query_embedding = embedding_result.get("embedding")
81
+
82
+ if not query_embedding:
83
+ logger.warning("Failed to generate query embedding, using BM25 only")
84
+ return self._apply_score_threshold(bm25_docs)
85
+
86
+ # Get vector search results
87
+ logger.debug("Running vector retrieval...")
88
+ vector_results = self.embedding_retriever.run(
89
+ query_embedding=query_embedding,
90
+ top_k=self.retrieval_config.top_k * 2,
91
+ )
92
+ vector_docs = vector_results.get("documents", [])
93
+ logger.debug(f"Vector search retrieved {len(vector_docs)} documents")
94
+
95
+ # Merge and rank results
96
+ merged_docs = self._merge_results(bm25_docs, vector_docs)
97
+
98
+ # Apply score threshold and limit
99
+ final_docs = self._apply_score_threshold(merged_docs)
100
+ final_docs = final_docs[: self.retrieval_config.top_k]
101
+
102
+ logger.info(f"Retrieved {len(final_docs)} documents after hybrid ranking")
103
+
104
+ return final_docs
105
+
106
+ except Exception as e:
107
+ logger.error(f"Error during retrieval: {e}")
108
+ return []
109
+
110
+ def _merge_results(
111
+ self, bm25_docs: List[Document], vector_docs: List[Document]
112
+ ) -> List[Document]:
113
+ """
114
+ Merge BM25 and vector search results using weighted scoring.
115
+
116
+ Args:
117
+ bm25_docs: Documents from BM25 search
118
+ vector_docs: Documents from vector search
119
+
120
+ Returns:
121
+ Merged and ranked documents
122
+ """
123
+ # Create score maps
124
+ doc_scores: Dict[str, Dict[str, Any]] = {}
125
+
126
+ # Process BM25 results
127
+ for doc in bm25_docs:
128
+ doc_id = doc.id or doc.content[:50]
129
+ bm25_score = doc.score or 0.0
130
+
131
+ if doc_id not in doc_scores:
132
+ doc_scores[doc_id] = {
133
+ "document": doc,
134
+ "bm25_score": 0.0,
135
+ "vector_score": 0.0,
136
+ }
137
+ doc_scores[doc_id]["bm25_score"] = bm25_score
138
+
139
+ # Process vector results
140
+ for doc in vector_docs:
141
+ doc_id = doc.id or doc.content[:50]
142
+ vector_score = doc.score or 0.0
143
+
144
+ if doc_id not in doc_scores:
145
+ doc_scores[doc_id] = {
146
+ "document": doc,
147
+ "bm25_score": 0.0,
148
+ "vector_score": 0.0,
149
+ }
150
+ doc_scores[doc_id]["vector_score"] = vector_score
151
+
152
+ # Normalize and combine scores
153
+ bm25_scores = [info["bm25_score"] for info in doc_scores.values()]
154
+ vector_scores = [info["vector_score"] for info in doc_scores.values()]
155
+
156
+ max_bm25 = max(bm25_scores) if bm25_scores else 1.0
157
+ max_vector = max(vector_scores) if vector_scores else 1.0
158
+
159
+ merged_docs = []
160
+ for doc_id, info in doc_scores.items():
161
+ # Normalize scores
162
+ norm_bm25 = info["bm25_score"] / max_bm25 if max_bm25 > 0 else 0.0
163
+ norm_vector = info["vector_score"] / max_vector if max_vector > 0 else 0.0
164
+
165
+ # Combine with weights
166
+ combined_score = (
167
+ self.retrieval_config.bm25_weight * norm_bm25
168
+ + self.retrieval_config.vector_weight * norm_vector
169
+ )
170
+
171
+ doc = info["document"]
172
+ doc.score = combined_score
173
+
174
+ if doc.meta is None:
175
+ doc.meta = {}
176
+ doc.meta["bm25_score"] = info["bm25_score"]
177
+ doc.meta["vector_score"] = info["vector_score"]
178
+ doc.meta["combined_score"] = combined_score
179
+
180
+ merged_docs.append(doc)
181
+
182
+ # Sort by combined score
183
+ merged_docs.sort(key=lambda x: x.score or 0.0, reverse=True)
184
+
185
+ return merged_docs
186
+
187
+ def _apply_score_threshold(self, documents: List[Document]) -> List[Document]:
188
+ """
189
+ Filter documents by minimum score threshold.
190
+
191
+ Args:
192
+ documents: Documents to filter
193
+
194
+ Returns:
195
+ Filtered documents
196
+ """
197
+ return [
198
+ doc
199
+ for doc in documents
200
+ if doc.score and doc.score >= self.retrieval_config.min_score
201
+ ]
src/ui/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Gradio UI components."""
2
+
3
+ from .gradio_app import create_gradio_interface
4
+
5
+ __all__ = ["create_gradio_interface"]
src/ui/gradio_app.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio UI for the RAG Email Assistant."""
2
+
3
+ import gradio as gr
4
+ from typing import Tuple, List, Dict, Any
5
+ import logging
6
+ import asyncio
7
+
8
+ from ..config import get_config, AppConfig
9
+ from ..indexing.indexer import DocumentIndexer
10
+ from ..pipeline.orchestrator import RAGOrchestrator, PipelineResult
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class GradioEmailAssistant:
16
+ """Gradio interface for the email assistant."""
17
+
18
+ def __init__(self, config: AppConfig):
19
+ """
20
+ Initialize the Gradio assistant.
21
+
22
+ Args:
23
+ config: Application configuration
24
+ """
25
+ self.config = config
26
+
27
+ # Initialize indexer and orchestrator
28
+ self.indexer = DocumentIndexer(
29
+ opensearch_config=config.opensearch,
30
+ llm_config=config.llm,
31
+ )
32
+
33
+ self.orchestrator = RAGOrchestrator(
34
+ config=config,
35
+ document_indexer=self.indexer,
36
+ )
37
+
38
+ # Store last pipeline result for refinement
39
+ self.last_result: PipelineResult | None = None
40
+
41
+ async def process_query_async(
42
+ self, query: str
43
+ ) -> Tuple[str, str, str, str, str, List[Dict[str, Any]]]:
44
+ """
45
+ Process a user query asynchronously.
46
+
47
+ Args:
48
+ query: User query text
49
+
50
+ Returns:
51
+ Tuple of (subject, body, intent_info, fact_check_info, stats, sources)
52
+ """
53
+ try:
54
+ # Process through pipeline
55
+ result = await self.orchestrator.process_query(query)
56
+ self.last_result = result
57
+
58
+ # Extract components
59
+ subject = result.email_draft.subject
60
+ body = result.email_draft.body
61
+
62
+ # Format intent information
63
+ intent_info = f"""**Action Type:** {result.intent.action_type}
64
+ **Topic:** {result.intent.topic}
65
+ **Language:** {result.intent.language}
66
+ **Urgency:** {result.intent.urgency}
67
+ **Key Entities:** {', '.join(result.intent.key_entities) if result.intent.key_entities else 'None'}
68
+ **Questions:** {', '.join(result.intent.specific_questions) if result.intent.specific_questions else 'None'}"""
69
+
70
+ # Format fact check information
71
+ accuracy_emoji = "βœ…" if result.fact_check.is_accurate else "⚠️"
72
+ fact_check_info = f"""**Status:** {accuracy_emoji} {'Accurate' if result.fact_check.is_accurate else 'Issues Found'}
73
+ **Accuracy Score:** {result.fact_check.accuracy_score:.1%}
74
+
75
+ **Verified Claims:**
76
+ {self._format_list(result.fact_check.verified_claims)}
77
+
78
+ **Issues Found:**
79
+ {self._format_list(result.fact_check.issues_found) if result.fact_check.issues_found else 'None'}
80
+
81
+ **Suggestions:**
82
+ {self._format_list(result.fact_check.suggestions) if result.fact_check.suggestions else 'None'}"""
83
+
84
+ # Format statistics
85
+ stats = f"""**Processing Time:** {result.processing_time:.2f}s
86
+ **Documents Retrieved:** {len(result.retrieved_docs)}
87
+ **Confidence:** {result.email_draft.confidence:.1%}"""
88
+
89
+ # Format sources
90
+ sources = []
91
+ for i, doc in enumerate(result.retrieved_docs, 1):
92
+ sources.append(
93
+ {
94
+ "Number": i,
95
+ "Source": doc["meta"].get("source_file", "Unknown"),
96
+ "Score": f"{doc['score']:.3f}",
97
+ "Preview": doc["content"][:200] + "...",
98
+ }
99
+ )
100
+
101
+ return subject, body, intent_info, fact_check_info, stats, sources
102
+
103
+ except Exception as e:
104
+ logger.error(f"Error processing query: {e}")
105
+ error_msg = f"Error: {str(e)}"
106
+ return (
107
+ "Error",
108
+ error_msg,
109
+ error_msg,
110
+ error_msg,
111
+ error_msg,
112
+ [],
113
+ )
114
+
115
+ def process_query_sync(
116
+ self, query: str
117
+ ) -> Tuple[str, str, str, str, str, List[Dict[str, Any]]]:
118
+ """Synchronous wrapper for async query processing."""
119
+ return asyncio.run(self.process_query_async(query))
120
+
121
+ async def refine_draft_async(
122
+ self, subject: str, body: str, feedback: str
123
+ ) -> Tuple[str, str]:
124
+ """
125
+ Refine the current draft based on user feedback.
126
+
127
+ Args:
128
+ subject: Current subject
129
+ body: Current body
130
+ feedback: User feedback
131
+
132
+ Returns:
133
+ Tuple of (new_subject, new_body)
134
+ """
135
+ if not self.last_result:
136
+ return subject, "Error: No draft to refine. Please generate a draft first."
137
+
138
+ try:
139
+ # Get retrieved docs from last result
140
+ from haystack import Document
141
+
142
+ retrieved_docs = [
143
+ Document(content=doc["content"], meta=doc["meta"])
144
+ for doc in self.last_result.retrieved_docs
145
+ ]
146
+
147
+ # Refine the draft
148
+ refined = await self.orchestrator.refine_draft(
149
+ original_query=self.last_result.query,
150
+ current_draft=body,
151
+ user_feedback=feedback,
152
+ retrieved_docs=retrieved_docs,
153
+ )
154
+
155
+ return refined.subject, refined.body
156
+
157
+ except Exception as e:
158
+ logger.error(f"Error refining draft: {e}")
159
+ return subject, f"Error refining draft: {str(e)}"
160
+
161
+ def refine_draft_sync(self, subject: str, body: str, feedback: str) -> Tuple[str, str]:
162
+ """Synchronous wrapper for async draft refinement."""
163
+ return asyncio.run(self.refine_draft_async(subject, body, feedback))
164
+
165
+ def _format_list(self, items: List[str]) -> str:
166
+ """Format a list of items as markdown."""
167
+ if not items:
168
+ return "None"
169
+ return "\n".join([f"- {item}" for item in items])
170
+
171
+
172
+ def create_gradio_interface() -> gr.Blocks:
173
+ """
174
+ Create and configure the Gradio interface.
175
+
176
+ Returns:
177
+ Gradio Blocks interface
178
+ """
179
+ # Load configuration
180
+ config = get_config()
181
+
182
+ # Initialize assistant
183
+ assistant = GradioEmailAssistant(config)
184
+
185
+ # Create interface
186
+ with gr.Blocks(
187
+ title="BFH Student Administration Email Assistant",
188
+ theme=gr.themes.Soft(),
189
+ ) as demo:
190
+ gr.Markdown(
191
+ """
192
+ # πŸ“§ BFH Student Administration Email Assistant
193
+
194
+ AI-powered email assistant for university administrative staff using RAG (Retrieval-Augmented Generation).
195
+
196
+ **Features:**
197
+ - Intent extraction from student queries
198
+ - Hybrid retrieval (BM25 + semantic search)
199
+ - Multi-agent email composition
200
+ - Automated fact-checking
201
+ - Draft refinement based on feedback
202
+ """
203
+ )
204
+
205
+ with gr.Row():
206
+ with gr.Column(scale=1):
207
+ gr.Markdown("### πŸ“ Query Input")
208
+ query_input = gr.Textbox(
209
+ label="Student Query",
210
+ placeholder="Enter the student's question or email content here...",
211
+ lines=5,
212
+ )
213
+ process_btn = gr.Button("Generate Email Draft", variant="primary")
214
+
215
+ with gr.Column(scale=1):
216
+ gr.Markdown("### πŸ“Š Analysis")
217
+ intent_output = gr.Markdown(label="Intent Analysis")
218
+ stats_output = gr.Markdown(label="Statistics")
219
+
220
+ gr.Markdown("### βœ‰οΈ Email Draft")
221
+
222
+ with gr.Row():
223
+ with gr.Column(scale=2):
224
+ subject_output = gr.Textbox(label="Subject", lines=1)
225
+ body_output = gr.Textbox(label="Body", lines=15)
226
+
227
+ with gr.Column(scale=1):
228
+ fact_check_output = gr.Markdown(label="Fact Check Results")
229
+
230
+ gr.Markdown("### πŸ”„ Refine Draft")
231
+
232
+ with gr.Row():
233
+ feedback_input = gr.Textbox(
234
+ label="Feedback / Refinement Instructions",
235
+ placeholder="E.g., 'Make it more formal', 'Add information about deadlines', 'Translate to English'",
236
+ lines=3,
237
+ )
238
+ refine_btn = gr.Button("Refine Draft", variant="secondary")
239
+
240
+ gr.Markdown("### πŸ“š Retrieved Sources")
241
+ sources_output = gr.Dataframe(
242
+ headers=["Number", "Source", "Score", "Preview"],
243
+ label="Source Documents",
244
+ )
245
+
246
+ # Event handlers
247
+ process_btn.click(
248
+ fn=assistant.process_query_sync,
249
+ inputs=[query_input],
250
+ outputs=[
251
+ subject_output,
252
+ body_output,
253
+ intent_output,
254
+ fact_check_output,
255
+ stats_output,
256
+ sources_output,
257
+ ],
258
+ )
259
+
260
+ refine_btn.click(
261
+ fn=assistant.refine_draft_sync,
262
+ inputs=[subject_output, body_output, feedback_input],
263
+ outputs=[subject_output, body_output],
264
+ )
265
+
266
+ gr.Markdown(
267
+ """
268
+ ---
269
+ **Note:** This system uses AI to assist with email composition. Always review and verify the generated content before sending.
270
+ """
271
+ )
272
+
273
+ return demo
274
+
275
+
276
+ if __name__ == "__main__":
277
+ # Configure logging
278
+ logging.basicConfig(
279
+ level=logging.INFO,
280
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
281
+ )
282
+
283
+ # Create and launch interface
284
+ demo = create_gradio_interface()
285
+ demo.launch()