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

Refactor RAG Email Assistant for in-memory processing; update configurations, implement memory indexing and retrieval, enhance Gradio UI, and streamline document ingestion.

Browse files
.env.example CHANGED
@@ -1,19 +1,10 @@
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
 
1
+ # OpenAI Configuration (required)
2
  OPENAI_API_KEY=your_openai_api_key_here
3
+ LLM_MODEL=gpt-5-nano
4
  EMBEDDING_MODEL=text-embedding-3-small
5
  LLM_TEMPERATURE=0.7
6
  LLM_MAX_TOKENS=2000
7
 
 
 
 
 
 
 
 
 
 
8
  # Document Processing Configuration
9
  DOCUMENTS_PATH=assets/markdown
10
  CHUNK_SIZE=300
.gitignore CHANGED
@@ -159,6 +159,7 @@ rag_email_assistant_haystack_2_pydantic_ai_gradio_modular_2025_baseline.py
159
  *.xlsx
160
  *.xls
161
  *.parquet
 
162
  data/
163
  datasets/
164
 
 
159
  *.xlsx
160
  *.xls
161
  *.parquet
162
+ *.pkl
163
  data/
164
  datasets/
165
 
QUICKSTART.md CHANGED
@@ -1,15 +1,22 @@
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
  ```
@@ -17,46 +24,46 @@ pip install -r requirements.txt
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
 
@@ -71,46 +78,77 @@ The Gradio interface will launch at `http://localhost:7860`
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.
 
1
+ # Quick Start Guide (No Docker Needed!)
2
 
3
  ## Prerequisites
4
 
5
  1. **Python 3.10+** installed
6
+ 2. **OpenAI API key** (or use gpt-4o-mini for low cost)
 
7
 
8
+ **That's it!** No Docker, no OpenSearch needed!
9
+
10
+ ## Setup (2 minutes)
11
 
12
  ### 1. Install Dependencies
13
 
14
+ Using `uv` (recommended - faster):
15
+ ```bash
16
+ uv pip install -r requirements.txt
17
+ ```
18
+
19
+ Or using `pip`:
20
  ```bash
21
  pip install -r requirements.txt
22
  ```
 
24
  ### 2. Configure Environment
25
 
26
  ```bash
27
+ # Copy the example file
28
  cp .env.example .env
29
 
30
+ # Edit and add your OpenAI API key
31
  nano .env # or use your preferred editor
32
  ```
33
 
34
+ **Required:**
 
 
 
 
 
 
 
 
35
  ```bash
36
+ OPENAI_API_KEY=sk-your-key-here
37
  ```
38
 
39
+ **Optional (has good defaults):**
40
+ ```bash
41
+ LLM_MODEL=gpt-4o-mini # Very affordable!
42
+ EMBEDDING_MODEL=text-embedding-3-small
 
 
 
 
 
 
 
43
  ```
44
 
45
+ ### 3. Run the Application
46
 
47
  ```bash
48
  python app.py
49
  ```
50
 
51
+ **That's it!** The app will:
52
+ - Automatically load markdown documents from `assets/markdown/`
53
+ - Create an in-memory document store
54
+ - Generate embeddings (first run takes ~30 seconds)
55
+ - Save the document store to `data/document_store.pkl` for faster subsequent runs
56
+ - Launch the Gradio interface at `http://localhost:7860`
57
+
58
+ ## First Run
59
+
60
+ The first time you run the app, it will:
61
+ 1. Load 8 administrative documents
62
+ 2. Chunk them into ~30-50 pieces
63
+ 3. Generate embeddings using OpenAI
64
+ 4. Save to `data/document_store.pkl`
65
+
66
+ **Next runs are instant** - it loads from the pickle file!
67
 
68
  ## Usage
69
 
 
78
 
79
  ## Example Queries
80
 
81
+ **German:**
82
  - "Wie kann ich mich exmatrikulieren?"
83
  - "Was kostet eine NamensΓ€nderung?"
84
  - "Ich mΓΆchte ein Modul zurΓΌckziehen. Was muss ich beachten?"
85
  - "Welche Fristen gibt es fΓΌr die Beurlaubung?"
86
 
87
+ **English:**
88
  - "How can I withdraw from the university?"
89
  - "What are the fees for changing my name?"
90
  - "I want to take a leave of absence. What do I need to know?"
91
 
92
+ ## Pre-indexing (Optional)
93
 
94
+ If you want to pre-index documents separately:
 
 
 
95
 
96
+ ```bash
97
+ python scripts/ingest_documents_memory.py
98
+ ```
99
+
100
+ This creates `data/document_store.pkl` which the app will use automatically.
101
+
102
+ ## Cost Estimate
103
+
104
+ With **gpt-4o-mini**:
105
+ - Typical email: **< $0.001** (less than a tenth of a cent)
106
+ - First-time indexing (8 documents): **~$0.01**
107
+ - Embeddings are cached in the pickle file
108
+
109
+ ## Hugging Face Spaces Deployment
110
+
111
+ 1. **Push your code** to a HF Space
112
+ 2. **Add Secret:** `OPENAI_API_KEY` in Space settings
113
+ 3. **Done!** The app auto-indexes on first run
114
+
115
+ The document store persists in the Space, so it only indexes once.
116
 
117
+ ## Troubleshooting
118
+
119
+ ### First run is slow
120
+ - Normal! It's generating embeddings for all documents
121
+ - Subsequent runs load from pickle (instant)
122
+
123
+ ### OpenAI API errors
124
  - Verify API key in `.env`
125
  - Check API quota and billing
126
  - Ensure internet connectivity
127
 
128
+ ### Import errors
129
+ - Run: `uv pip install -r requirements.txt` or `pip install -r requirements.txt`
130
+
131
+ ## Advantages Over Docker Version
132
+
133
+ βœ… **No Docker needed**
134
+ βœ… **No OpenSearch setup**
135
+ βœ… **Works on any machine**
136
+ βœ… **Perfect for HF Spaces**
137
+ βœ… **Faster setup (2 min vs 15 min)**
138
+ βœ… **In-memory = instant retrieval**
139
+ βœ… **Portable (just copy the pickle file)**
140
+
141
  ## Next Steps
142
 
143
  - Review [README.md](README.md) for full documentation
144
+ - Check [docs/RAG_Email_Assistant_Specifications_v1.0.md](docs/RAG_Email_Assistant_Specifications_v1.0.md) for architecture
145
  - See [CLAUDE.md](CLAUDE.md) for development guidance
146
 
147
  ## Support
148
 
149
+ Need help? The setup is simple:
150
+ 1. Install dependencies
151
+ 2. Add OpenAI API key
152
+ 3. Run `python app.py`
 
153
 
154
+ That's it! πŸš€
app.py CHANGED
@@ -1,7 +1,7 @@
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(
@@ -12,7 +12,7 @@ logging.basicConfig(
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
 
 
1
  """Main application entry point for Hugging Face Spaces deployment."""
2
 
3
  import logging
4
+ from src.ui.gradio_app_memory import create_gradio_interface
5
 
6
  # Configure logging
7
  logging.basicConfig(
 
12
  logger = logging.getLogger(__name__)
13
 
14
  # Create and launch the Gradio interface
15
+ logger.info("Starting BFH Student Administration Email Assistant (in-memory mode)...")
16
 
17
  demo = create_gradio_interface()
18
 
app_hf.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Spaces version using HF Inference API with gpt-oss-20b."""
2
+
3
+ import gradio as gr
4
+ from huggingface_hub import InferenceClient
5
+ import os
6
+
7
+ # Initialize HF Inference Client
8
+ client = InferenceClient(model="openai/gpt-oss-20b")
9
+
10
+
11
+ def compose_email(
12
+ query: str,
13
+ history: list,
14
+ system_message: str,
15
+ max_tokens: int,
16
+ temperature: float,
17
+ top_p: float,
18
+ hf_token: gr.OAuthToken | None = None,
19
+ ) -> str:
20
+ """Compose email response using HF Inference API."""
21
+
22
+ # Use OAuth token if available
23
+ token = hf_token.token if hf_token else os.getenv("HF_TOKEN")
24
+ client_with_token = InferenceClient(model="openai/gpt-oss-20b", token=token)
25
+
26
+ # Enhanced system message for email composition
27
+ email_system_prompt = """You are an AI assistant for BFH (Bern University of Applied Sciences) administrative staff.
28
+
29
+ Your task is to help compose professional email responses to student inquiries about:
30
+ - Exmatriculation (leaving university)
31
+ - Leave of absence (Beurlaubung)
32
+ - Name changes
33
+ - Insurance matters (AHV, health insurance)
34
+ - Fees and payments
35
+ - Course withdrawals and deadlines
36
+
37
+ Compose professional, accurate, and helpful email responses in the same language as the query.
38
+ Include a subject line and body. Use formal tone for German (Sie form).
39
+
40
+ Format your response as:
41
+ Subject: [subject line]
42
+
43
+ [email body]"""
44
+
45
+ messages = [{"role": "system", "content": email_system_prompt}]
46
+
47
+ # Add history
48
+ if history:
49
+ messages.extend(history)
50
+
51
+ # Add current query
52
+ messages.append({"role": "user", "content": f"Student query: {query}\n\nCompose an appropriate email response."})
53
+
54
+ # Stream response
55
+ response = ""
56
+ for message in client_with_token.chat_completion(
57
+ messages,
58
+ max_tokens=max_tokens,
59
+ stream=True,
60
+ temperature=temperature,
61
+ top_p=top_p,
62
+ ):
63
+ if message.choices and message.choices[0].delta.content:
64
+ response += message.choices[0].delta.content
65
+ yield response
66
+
67
+ return response
68
+
69
+
70
+ # Create Gradio interface
71
+ with gr.Blocks(title="BFH Email Assistant", theme=gr.themes.Soft()) as demo:
72
+ gr.Markdown(
73
+ """
74
+ # πŸ“§ BFH Student Administration Email Assistant
75
+
76
+ AI-powered assistant for composing email responses to student inquiries.
77
+ Uses **gpt-oss-20b** via Hugging Face Inference API (free!).
78
+ """
79
+ )
80
+
81
+ chatbot = gr.ChatInterface(
82
+ compose_email,
83
+ type="messages",
84
+ additional_inputs=[
85
+ gr.Textbox(
86
+ value="You are a professional university administrative assistant.",
87
+ label="System message",
88
+ visible=False,
89
+ ),
90
+ gr.Slider(
91
+ minimum=256,
92
+ maximum=2048,
93
+ value=1024,
94
+ step=1,
95
+ label="Max tokens",
96
+ ),
97
+ gr.Slider(
98
+ minimum=0.1,
99
+ maximum=2.0,
100
+ value=0.7,
101
+ step=0.1,
102
+ label="Temperature",
103
+ ),
104
+ gr.Slider(
105
+ minimum=0.1,
106
+ maximum=1.0,
107
+ value=0.95,
108
+ step=0.05,
109
+ label="Top-p",
110
+ ),
111
+ ],
112
+ examples=[
113
+ ["Wie kann ich mich exmatrikulieren?"],
114
+ ["What are the fees for changing my name?"],
115
+ ["Ich mΓΆchte ein Modul zurΓΌckziehen. Was muss ich beachten?"],
116
+ ["How do I apply for a leave of absence?"],
117
+ ],
118
+ )
119
+
120
+ with gr.Sidebar():
121
+ gr.LoginButton()
122
+ gr.Markdown(
123
+ """
124
+ ### About
125
+ This assistant helps compose email responses for BFH administrative staff.
126
+
127
+ ### Topics Covered
128
+ - Exmatriculation
129
+ - Leave of absence
130
+ - Name changes
131
+ - Insurance
132
+ - Fees
133
+ - Course withdrawals
134
+ """
135
+ )
136
+
137
+
138
+ if __name__ == "__main__":
139
+ demo.launch()
requirements.txt CHANGED
@@ -1,10 +1,8 @@
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
 
1
  # Core dependencies
2
  python-dotenv==1.1.1
3
 
4
+ # Haystack (no OpenSearch needed!)
5
  haystack-ai==2.8.0
 
 
6
 
7
  # PydanticAI for agents
8
  pydantic-ai==0.0.14
scripts/ingest_documents_memory.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Script to ingest documents and save to pickle for in-memory use."""
3
+
4
+ import sys
5
+ import logging
6
+ import pickle
7
+ from pathlib import Path
8
+
9
+ # Add src to path
10
+ sys.path.insert(0, str(Path(__file__).parent.parent))
11
+
12
+ from src.config import get_config
13
+ from src.document_processing.loader import MarkdownDocumentLoader
14
+ from src.document_processing.chunker import SemanticChunker
15
+ from src.indexing.memory_indexer import MemoryDocumentIndexer
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 (in-memory)...")
32
+
33
+ # Load configuration
34
+ config = get_config()
35
+ logger.info(f"Using documents path: {config.document_processing.documents_path}")
36
+
37
+ # Load documents
38
+ logger.info("Loading markdown documents...")
39
+ loader = MarkdownDocumentLoader(config.document_processing.documents_path)
40
+ documents = loader.load_documents()
41
+
42
+ if not documents:
43
+ logger.error("No documents loaded. Exiting.")
44
+ sys.exit(1)
45
+
46
+ logger.info(f"Loaded {len(documents)} documents")
47
+
48
+ # Chunk documents
49
+ logger.info("Chunking documents...")
50
+ chunker = SemanticChunker(
51
+ chunk_size=config.document_processing.chunk_size,
52
+ chunk_overlap=config.document_processing.chunk_overlap,
53
+ min_chunk_size=config.document_processing.min_chunk_size,
54
+ )
55
+ chunked_documents = chunker.chunk_documents(documents)
56
+
57
+ logger.info(f"Created {len(chunked_documents)} chunks")
58
+
59
+ # Index documents in memory
60
+ logger.info("Indexing documents in memory...")
61
+ indexer = MemoryDocumentIndexer(llm_config=config.llm)
62
+
63
+ indexed_count = indexer.index_documents(chunked_documents)
64
+
65
+ logger.info(f"Successfully indexed {indexed_count} document chunks")
66
+
67
+ # Save document store to pickle for later use
68
+ output_file = Path("data/document_store.pkl")
69
+ output_file.parent.mkdir(parents=True, exist_ok=True)
70
+
71
+ logger.info(f"Saving document store to {output_file}...")
72
+ with open(output_file, "wb") as f:
73
+ pickle.dump(indexer.document_store, f)
74
+
75
+ logger.info("βœ… Document ingestion completed successfully!")
76
+ logger.info(f"Document store saved to: {output_file}")
77
+ logger.info(f"Total documents indexed: {indexed_count}")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
src/config.py CHANGED
@@ -105,7 +105,6 @@ class RetrievalConfig:
105
  class AppConfig:
106
  """Main application configuration."""
107
 
108
- opensearch: OpenSearchConfig
109
  llm: LLMConfig
110
  document_processing: DocumentProcessingConfig
111
  retrieval: RetrievalConfig
@@ -115,7 +114,6 @@ class AppConfig:
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(),
 
105
  class AppConfig:
106
  """Main application configuration."""
107
 
 
108
  llm: LLMConfig
109
  document_processing: DocumentProcessingConfig
110
  retrieval: RetrievalConfig
 
114
  def from_env(cls) -> "AppConfig":
115
  """Create complete configuration from environment variables."""
116
  return cls(
 
117
  llm=LLMConfig.from_env(),
118
  document_processing=DocumentProcessingConfig.from_env(),
119
  retrieval=RetrievalConfig.from_env(),
src/indexing/memory_indexer.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document indexer using in-memory document store (no Docker/OpenSearch needed)."""
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
+ import logging
8
+
9
+ from ..config import LLMConfig
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class MemoryDocumentIndexer:
15
+ """Indexes documents in memory with embeddings (no external dependencies)."""
16
+
17
+ def __init__(self, llm_config: LLMConfig):
18
+ """
19
+ Initialize the in-memory document indexer.
20
+
21
+ Args:
22
+ llm_config: LLM configuration for embeddings
23
+ """
24
+ self.llm_config = llm_config
25
+
26
+ # Initialize in-memory document store
27
+ self.document_store = InMemoryDocumentStore()
28
+
29
+ # Initialize embedder
30
+ self.embedder = OpenAIDocumentEmbedder(
31
+ api_key=llm_config.api_key,
32
+ model=llm_config.embedding_model,
33
+ )
34
+
35
+ def index_documents(self, documents: List[Document]) -> int:
36
+ """
37
+ Index documents with embeddings in memory.
38
+
39
+ Args:
40
+ documents: List of documents to index
41
+
42
+ Returns:
43
+ Number of documents successfully indexed
44
+ """
45
+ if not documents:
46
+ logger.warning("No documents to index")
47
+ return 0
48
+
49
+ logger.info(f"Indexing {len(documents)} documents in memory")
50
+
51
+ try:
52
+ # Generate embeddings for documents
53
+ logger.info("Generating embeddings...")
54
+ result = self.embedder.run(documents=documents)
55
+ embedded_docs = result.get("documents", [])
56
+
57
+ if not embedded_docs:
58
+ logger.error("Failed to generate embeddings")
59
+ return 0
60
+
61
+ logger.info(f"Generated embeddings for {len(embedded_docs)} documents")
62
+
63
+ # Write documents to in-memory store
64
+ logger.info("Writing documents to memory...")
65
+ self.document_store.write_documents(embedded_docs)
66
+
67
+ doc_count = self.document_store.count_documents()
68
+ logger.info(f"Successfully indexed documents. Total documents in store: {doc_count}")
69
+
70
+ return len(embedded_docs)
71
+
72
+ except Exception as e:
73
+ logger.error(f"Error indexing documents: {e}")
74
+ raise
75
+
76
+ def clear_index(self):
77
+ """Clear all documents from the index."""
78
+ try:
79
+ self.document_store.delete_documents()
80
+ logger.info("Cleared all documents from index")
81
+ except Exception as e:
82
+ logger.error(f"Error clearing index: {e}")
83
+ raise
84
+
85
+ def get_document_count(self) -> int:
86
+ """
87
+ Get number of documents in the index.
88
+
89
+ Returns:
90
+ Document count
91
+ """
92
+ try:
93
+ return self.document_store.count_documents()
94
+ except Exception as e:
95
+ logger.error(f"Error getting document count: {e}")
96
+ return 0
src/pipeline/memory_orchestrator.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG pipeline orchestrator using in-memory components (no Docker needed)."""
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.memory_retriever import MemoryRetriever
13
+ from ..indexing.memory_indexer import MemoryDocumentIndexer
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 MemoryRAGOrchestrator:
30
+ """Orchestrates the multi-agent RAG pipeline using in-memory components."""
31
+
32
+ def __init__(self, config: AppConfig, document_indexer: MemoryDocumentIndexer):
33
+ """
34
+ Initialize the RAG orchestrator.
35
+
36
+ Args:
37
+ config: Application configuration
38
+ document_indexer: Memory document indexer instance
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 = MemoryRetriever(
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/memory_retriever.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retriever using in-memory document store (no Docker/OpenSearch needed)."""
2
+
3
+ from typing import List
4
+ from haystack import Document
5
+ from haystack.components.embedders import OpenAITextEmbedder
6
+ from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever, InMemoryBM25Retriever
7
+ from haystack.document_stores.in_memory import InMemoryDocumentStore
8
+ import logging
9
+
10
+ from ..config import RetrievalConfig, LLMConfig
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class MemoryRetriever:
16
+ """Retrieves documents using in-memory hybrid BM25 + vector search."""
17
+
18
+ def __init__(
19
+ self,
20
+ document_store: InMemoryDocumentStore,
21
+ llm_config: LLMConfig,
22
+ retrieval_config: RetrievalConfig,
23
+ ):
24
+ """
25
+ Initialize the in-memory hybrid retriever.
26
+
27
+ Args:
28
+ document_store: InMemory document store
29
+ llm_config: LLM configuration for embeddings
30
+ retrieval_config: Retrieval configuration
31
+ """
32
+ self.document_store = document_store
33
+ self.llm_config = llm_config
34
+ self.retrieval_config = retrieval_config
35
+
36
+ # Initialize BM25 retriever
37
+ self.bm25_retriever = InMemoryBM25Retriever(
38
+ document_store=document_store,
39
+ )
40
+
41
+ # Initialize embedding retriever
42
+ self.embedding_retriever = InMemoryEmbeddingRetriever(
43
+ document_store=document_store,
44
+ )
45
+
46
+ # Initialize text embedder for queries
47
+ self.text_embedder = OpenAITextEmbedder(
48
+ api_key=llm_config.api_key,
49
+ model=llm_config.embedding_model,
50
+ )
51
+
52
+ def retrieve(self, query: str) -> List[Document]:
53
+ """
54
+ Retrieve documents using hybrid search.
55
+
56
+ Args:
57
+ query: Search query
58
+
59
+ Returns:
60
+ List of relevant documents with scores
61
+ """
62
+ logger.info(f"Retrieving documents for query: {query[:100]}...")
63
+
64
+ try:
65
+ # Get BM25 results
66
+ logger.debug("Running BM25 retrieval...")
67
+ bm25_results = self.bm25_retriever.run(
68
+ query=query,
69
+ top_k=self.retrieval_config.top_k * 2,
70
+ )
71
+ bm25_docs = bm25_results.get("documents", [])
72
+ logger.debug(f"BM25 retrieved {len(bm25_docs)} documents")
73
+
74
+ # Generate query embedding
75
+ logger.debug("Generating query embedding...")
76
+ embedding_result = self.text_embedder.run(text=query)
77
+ query_embedding = embedding_result.get("embedding")
78
+
79
+ if not query_embedding:
80
+ logger.warning("Failed to generate query embedding, using BM25 only")
81
+ return self._apply_score_threshold(bm25_docs)
82
+
83
+ # Get vector search results
84
+ logger.debug("Running vector retrieval...")
85
+ vector_results = self.embedding_retriever.run(
86
+ query_embedding=query_embedding,
87
+ top_k=self.retrieval_config.top_k * 2,
88
+ )
89
+ vector_docs = vector_results.get("documents", [])
90
+ logger.debug(f"Vector search retrieved {len(vector_docs)} documents")
91
+
92
+ # Merge and rank results
93
+ merged_docs = self._merge_results(bm25_docs, vector_docs)
94
+
95
+ # Apply score threshold and limit
96
+ final_docs = self._apply_score_threshold(merged_docs)
97
+ final_docs = final_docs[: self.retrieval_config.top_k]
98
+
99
+ logger.info(f"Retrieved {len(final_docs)} documents after hybrid ranking")
100
+
101
+ return final_docs
102
+
103
+ except Exception as e:
104
+ logger.error(f"Error during retrieval: {e}")
105
+ return []
106
+
107
+ def _merge_results(
108
+ self, bm25_docs: List[Document], vector_docs: List[Document]
109
+ ) -> List[Document]:
110
+ """
111
+ Merge BM25 and vector search results using weighted scoring.
112
+
113
+ Args:
114
+ bm25_docs: Documents from BM25 search
115
+ vector_docs: Documents from vector search
116
+
117
+ Returns:
118
+ Merged and ranked documents
119
+ """
120
+ from typing import Dict, Any
121
+
122
+ # Create score maps
123
+ doc_scores: Dict[str, Dict[str, Any]] = {}
124
+
125
+ # Process BM25 results
126
+ for doc in bm25_docs:
127
+ doc_id = doc.id or doc.content[:50]
128
+ bm25_score = doc.score or 0.0
129
+
130
+ if doc_id not in doc_scores:
131
+ doc_scores[doc_id] = {
132
+ "document": doc,
133
+ "bm25_score": 0.0,
134
+ "vector_score": 0.0,
135
+ }
136
+ doc_scores[doc_id]["bm25_score"] = bm25_score
137
+
138
+ # Process vector results
139
+ for doc in vector_docs:
140
+ doc_id = doc.id or doc.content[:50]
141
+ vector_score = doc.score or 0.0
142
+
143
+ if doc_id not in doc_scores:
144
+ doc_scores[doc_id] = {
145
+ "document": doc,
146
+ "bm25_score": 0.0,
147
+ "vector_score": 0.0,
148
+ }
149
+ doc_scores[doc_id]["vector_score"] = vector_score
150
+
151
+ # Normalize and combine scores
152
+ bm25_scores = [info["bm25_score"] for info in doc_scores.values()]
153
+ vector_scores = [info["vector_score"] for info in doc_scores.values()]
154
+
155
+ max_bm25 = max(bm25_scores) if bm25_scores else 1.0
156
+ max_vector = max(vector_scores) if vector_scores else 1.0
157
+
158
+ merged_docs = []
159
+ for doc_id, info in doc_scores.items():
160
+ # Normalize scores
161
+ norm_bm25 = info["bm25_score"] / max_bm25 if max_bm25 > 0 else 0.0
162
+ norm_vector = info["vector_score"] / max_vector if max_vector > 0 else 0.0
163
+
164
+ # Combine with weights
165
+ combined_score = (
166
+ self.retrieval_config.bm25_weight * norm_bm25
167
+ + self.retrieval_config.vector_weight * norm_vector
168
+ )
169
+
170
+ doc = info["document"]
171
+ doc.score = combined_score
172
+
173
+ if doc.meta is None:
174
+ doc.meta = {}
175
+ doc.meta["bm25_score"] = info["bm25_score"]
176
+ doc.meta["vector_score"] = info["vector_score"]
177
+ doc.meta["combined_score"] = combined_score
178
+
179
+ merged_docs.append(doc)
180
+
181
+ # Sort by combined score
182
+ merged_docs.sort(key=lambda x: x.score or 0.0, reverse=True)
183
+
184
+ return merged_docs
185
+
186
+ def _apply_score_threshold(self, documents: List[Document]) -> List[Document]:
187
+ """
188
+ Filter documents by minimum score threshold.
189
+
190
+ Args:
191
+ documents: Documents to filter
192
+
193
+ Returns:
194
+ Filtered documents
195
+ """
196
+ return [
197
+ doc
198
+ for doc in documents
199
+ if doc.score and doc.score >= self.retrieval_config.min_score
200
+ ]
src/ui/gradio_app_memory.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio UI for the RAG Email Assistant (in-memory, no Docker needed)."""
2
+
3
+ import gradio as gr
4
+ from typing import Tuple, List, Dict, Any
5
+ import logging
6
+ import asyncio
7
+ import pickle
8
+ from pathlib import Path
9
+
10
+ from ..config import get_config, AppConfig
11
+ from ..indexing.memory_indexer import MemoryDocumentIndexer
12
+ from ..pipeline.memory_orchestrator import MemoryRAGOrchestrator, PipelineResult
13
+ from ..document_processing.loader import MarkdownDocumentLoader
14
+ from ..document_processing.chunker import SemanticChunker
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class GradioEmailAssistant:
20
+ """Gradio interface for the email assistant (in-memory)."""
21
+
22
+ def __init__(self, config: AppConfig):
23
+ """
24
+ Initialize the Gradio assistant.
25
+
26
+ Args:
27
+ config: Application configuration
28
+ """
29
+ self.config = config
30
+
31
+ # Initialize indexer
32
+ self.indexer = MemoryDocumentIndexer(llm_config=config.llm)
33
+
34
+ # Load or create document store
35
+ self._load_or_create_documents()
36
+
37
+ # Initialize orchestrator
38
+ self.orchestrator = MemoryRAGOrchestrator(
39
+ config=config,
40
+ document_indexer=self.indexer,
41
+ )
42
+
43
+ # Store last pipeline result for refinement
44
+ self.last_result: PipelineResult | None = None
45
+
46
+ def _load_or_create_documents(self):
47
+ """Load documents from pickle or create fresh."""
48
+ doc_store_path = Path("data/document_store.pkl")
49
+
50
+ if doc_store_path.exists():
51
+ logger.info(f"Loading document store from {doc_store_path}...")
52
+ try:
53
+ with open(doc_store_path, "rb") as f:
54
+ self.indexer.document_store = pickle.load(f)
55
+ logger.info(f"Loaded {self.indexer.get_document_count()} documents")
56
+ return
57
+ except Exception as e:
58
+ logger.warning(f"Failed to load document store: {e}")
59
+
60
+ # Create documents if not found
61
+ logger.info("Creating fresh document index...")
62
+ loader = MarkdownDocumentLoader(self.config.document_processing.documents_path)
63
+ documents = loader.load_documents()
64
+
65
+ chunker = SemanticChunker(
66
+ chunk_size=self.config.document_processing.chunk_size,
67
+ chunk_overlap=self.config.document_processing.chunk_overlap,
68
+ min_chunk_size=self.config.document_processing.min_chunk_size,
69
+ )
70
+ chunked_docs = chunker.chunk_documents(documents)
71
+
72
+ self.indexer.index_documents(chunked_docs)
73
+
74
+ # Save for next time
75
+ doc_store_path.parent.mkdir(parents=True, exist_ok=True)
76
+ with open(doc_store_path, "wb") as f:
77
+ pickle.dump(self.indexer.document_store, f)
78
+ logger.info(f"Saved document store to {doc_store_path}")
79
+
80
+ async def process_query_async(
81
+ self, query: str
82
+ ) -> Tuple[str, str, str, str, str, List[Dict[str, Any]]]:
83
+ """
84
+ Process a user query asynchronously.
85
+
86
+ Args:
87
+ query: User query text
88
+
89
+ Returns:
90
+ Tuple of (subject, body, intent_info, fact_check_info, stats, sources)
91
+ """
92
+ try:
93
+ # Process through pipeline
94
+ result = await self.orchestrator.process_query(query)
95
+ self.last_result = result
96
+
97
+ # Extract components
98
+ subject = result.email_draft.subject
99
+ body = result.email_draft.body
100
+
101
+ # Format intent information
102
+ intent_info = f"""**Action Type:** {result.intent.action_type}
103
+ **Topic:** {result.intent.topic}
104
+ **Language:** {result.intent.language}
105
+ **Urgency:** {result.intent.urgency}
106
+ **Key Entities:** {', '.join(result.intent.key_entities) if result.intent.key_entities else 'None'}
107
+ **Questions:** {', '.join(result.intent.specific_questions) if result.intent.specific_questions else 'None'}"""
108
+
109
+ # Format fact check information
110
+ accuracy_emoji = "βœ…" if result.fact_check.is_accurate else "⚠️"
111
+ fact_check_info = f"""**Status:** {accuracy_emoji} {'Accurate' if result.fact_check.is_accurate else 'Issues Found'}
112
+ **Accuracy Score:** {result.fact_check.accuracy_score:.1%}
113
+
114
+ **Verified Claims:**
115
+ {self._format_list(result.fact_check.verified_claims)}
116
+
117
+ **Issues Found:**
118
+ {self._format_list(result.fact_check.issues_found) if result.fact_check.issues_found else 'None'}
119
+
120
+ **Suggestions:**
121
+ {self._format_list(result.fact_check.suggestions) if result.fact_check.suggestions else 'None'}"""
122
+
123
+ # Format statistics
124
+ stats = f"""**Processing Time:** {result.processing_time:.2f}s
125
+ **Documents Retrieved:** {len(result.retrieved_docs)}
126
+ **Confidence:** {result.email_draft.confidence:.1%}"""
127
+
128
+ # Format sources
129
+ sources = []
130
+ for i, doc in enumerate(result.retrieved_docs, 1):
131
+ sources.append(
132
+ {
133
+ "Number": i,
134
+ "Source": doc["meta"].get("source_file", "Unknown"),
135
+ "Score": f"{doc['score']:.3f}",
136
+ "Preview": doc["content"][:200] + "...",
137
+ }
138
+ )
139
+
140
+ return subject, body, intent_info, fact_check_info, stats, sources
141
+
142
+ except Exception as e:
143
+ logger.error(f"Error processing query: {e}")
144
+ error_msg = f"Error: {str(e)}"
145
+ return (
146
+ "Error",
147
+ error_msg,
148
+ error_msg,
149
+ error_msg,
150
+ error_msg,
151
+ [],
152
+ )
153
+
154
+ def process_query_sync(
155
+ self, query: str
156
+ ) -> Tuple[str, str, str, str, str, List[Dict[str, Any]]]:
157
+ """Synchronous wrapper for async query processing."""
158
+ return asyncio.run(self.process_query_async(query))
159
+
160
+ async def refine_draft_async(
161
+ self, subject: str, body: str, feedback: str
162
+ ) -> Tuple[str, str]:
163
+ """
164
+ Refine the current draft based on user feedback.
165
+
166
+ Args:
167
+ subject: Current subject
168
+ body: Current body
169
+ feedback: User feedback
170
+
171
+ Returns:
172
+ Tuple of (new_subject, new_body)
173
+ """
174
+ if not self.last_result:
175
+ return subject, "Error: No draft to refine. Please generate a draft first."
176
+
177
+ try:
178
+ # Get retrieved docs from last result
179
+ from haystack import Document
180
+
181
+ retrieved_docs = [
182
+ Document(content=doc["content"], meta=doc["meta"])
183
+ for doc in self.last_result.retrieved_docs
184
+ ]
185
+
186
+ # Refine the draft
187
+ refined = await self.orchestrator.refine_draft(
188
+ original_query=self.last_result.query,
189
+ current_draft=body,
190
+ user_feedback=feedback,
191
+ retrieved_docs=retrieved_docs,
192
+ )
193
+
194
+ return refined.subject, refined.body
195
+
196
+ except Exception as e:
197
+ logger.error(f"Error refining draft: {e}")
198
+ return subject, f"Error refining draft: {str(e)}"
199
+
200
+ def refine_draft_sync(self, subject: str, body: str, feedback: str) -> Tuple[str, str]:
201
+ """Synchronous wrapper for async draft refinement."""
202
+ return asyncio.run(self.refine_draft_async(subject, body, feedback))
203
+
204
+ def _format_list(self, items: List[str]) -> str:
205
+ """Format a list of items as markdown."""
206
+ if not items:
207
+ return "None"
208
+ return "\n".join([f"- {item}" for item in items])
209
+
210
+
211
+ def create_gradio_interface() -> gr.Blocks:
212
+ """
213
+ Create and configure the Gradio interface.
214
+
215
+ Returns:
216
+ Gradio Blocks interface
217
+ """
218
+ # Load configuration
219
+ config = get_config()
220
+
221
+ # Initialize assistant
222
+ assistant = GradioEmailAssistant(config)
223
+
224
+ # Create interface
225
+ with gr.Blocks(
226
+ title="BFH Student Administration Email Assistant",
227
+ theme=gr.themes.Soft(),
228
+ ) as demo:
229
+ gr.Markdown(
230
+ """
231
+ # πŸ“§ BFH Student Administration Email Assistant
232
+
233
+ AI-powered email assistant for university administrative staff using RAG (Retrieval-Augmented Generation).
234
+
235
+ **No Docker Required!** Uses in-memory document store.
236
+
237
+ **Features:**
238
+ - Intent extraction from student queries
239
+ - Hybrid retrieval (BM25 + semantic search)
240
+ - Multi-agent email composition
241
+ - Automated fact-checking
242
+ - Draft refinement based on feedback
243
+ """
244
+ )
245
+
246
+ with gr.Row():
247
+ with gr.Column(scale=1):
248
+ gr.Markdown("### πŸ“ Query Input")
249
+ query_input = gr.Textbox(
250
+ label="Student Query",
251
+ placeholder="Enter the student's question or email content here...",
252
+ lines=5,
253
+ )
254
+ process_btn = gr.Button("Generate Email Draft", variant="primary")
255
+
256
+ with gr.Column(scale=1):
257
+ gr.Markdown("### πŸ“Š Analysis")
258
+ intent_output = gr.Markdown(label="Intent Analysis")
259
+ stats_output = gr.Markdown(label="Statistics")
260
+
261
+ gr.Markdown("### βœ‰οΈ Email Draft")
262
+
263
+ with gr.Row():
264
+ with gr.Column(scale=2):
265
+ subject_output = gr.Textbox(label="Subject", lines=1)
266
+ body_output = gr.Textbox(label="Body", lines=15)
267
+
268
+ with gr.Column(scale=1):
269
+ fact_check_output = gr.Markdown(label="Fact Check Results")
270
+
271
+ gr.Markdown("### πŸ”„ Refine Draft")
272
+
273
+ with gr.Row():
274
+ feedback_input = gr.Textbox(
275
+ label="Feedback / Refinement Instructions",
276
+ placeholder="E.g., 'Make it more formal', 'Add information about deadlines', 'Translate to English'",
277
+ lines=3,
278
+ )
279
+ refine_btn = gr.Button("Refine Draft", variant="secondary")
280
+
281
+ gr.Markdown("### πŸ“š Retrieved Sources")
282
+ sources_output = gr.Dataframe(
283
+ headers=["Number", "Source", "Score", "Preview"],
284
+ label="Source Documents",
285
+ )
286
+
287
+ # Event handlers
288
+ process_btn.click(
289
+ fn=assistant.process_query_sync,
290
+ inputs=[query_input],
291
+ outputs=[
292
+ subject_output,
293
+ body_output,
294
+ intent_output,
295
+ fact_check_output,
296
+ stats_output,
297
+ sources_output,
298
+ ],
299
+ )
300
+
301
+ refine_btn.click(
302
+ fn=assistant.refine_draft_sync,
303
+ inputs=[subject_output, body_output, feedback_input],
304
+ outputs=[subject_output, body_output],
305
+ )
306
+
307
+ gr.Markdown(
308
+ """
309
+ ---
310
+ **Note:** This system uses AI to assist with email composition. Always review and verify the generated content before sending.
311
+ """
312
+ )
313
+
314
+ return demo
315
+
316
+
317
+ if __name__ == "__main__":
318
+ # Configure logging
319
+ logging.basicConfig(
320
+ level=logging.INFO,
321
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
322
+ )
323
+
324
+ # Create and launch interface
325
+ demo = create_gradio_interface()
326
+ demo.launch()