Seth McKnight commited on
Commit
74e758d
·
1 Parent(s): 29c3655

Enhance deployment pipeline and modern chat interface (#53)

Browse files

* feat: Add modern chat interface (Issue #25)

* feat: Load environment variables from .env file in app and enhanced_app

* Implement Issue #25: Modern Chat Interface Enhancements

- Add conversation persistence with localStorage and server API
- Implement feedback collection system with thumbs up/down options
- Add source document visualization with interactive side panel
- Add query suggestions to help users get started
- Create tests for the new functionality

* Enhanced Chat Interface:

- Added timestamp display to messages for better context
- Improved accessibility with ARIA attributes and keyboard navigation
- Added mobile swipe gestures for conversation history
- Added conversation export/import functionality
- Enhanced error handling with auto-retry for transient issues
- Improved focus management for side panels

* Fix test fixtures in enhanced chat interface tests

* Fix conftest.py flake8 compliance by suppressing E402 for imports after sys.path setup

* Fix test failures: improve ChromaDB telemetry disabling and mock LLM service

- Enhanced ChromaDB telemetry mocking in conftest.py with comprehensive patches
- Added LLM service mocking to test_chat_endpoint_structure to prevent 503 errors
- Set additional environment variables to disable ChromaDB telemetry
- Fixed flake8 E722 and E501 line length issues

* Address PR feedback: Fix JavaScript security and maintainability issues

- Fix const reassignment error in showSourceDocument (let instead of const)
- Add HTML escaping to prevent XSS in formatDocumentContent method
- Extract renderSourceError helper to eliminate code duplication
- Improve error handling with consistent retry functionality and accessibility
- All error messages now properly escaped and centrally managed

.gitignore CHANGED
@@ -39,6 +39,7 @@ dev-tools/query-expansion-tests/
39
  *.log
40
  *.tmp
41
  .env.local
 
42
 
43
  # Vector Database (ChromaDB data)
44
  data/chroma_db/
 
39
  *.log
40
  *.tmp
41
  .env.local
42
+ .env
43
 
44
  # Vector Database (ChromaDB data)
45
  data/chroma_db/
app.py CHANGED
@@ -1,5 +1,14 @@
 
 
 
 
 
 
1
  from flask import Flask, jsonify, render_template, request
2
 
 
 
 
3
  # Disable ChromaDB anonymized telemetry for local development so the
4
  # library doesn't attempt to call external PostHog telemetry endpoints.
5
  # This avoids noisy errors in server logs and respects developer privacy.
@@ -7,7 +16,8 @@ try:
7
  import chromadb
8
 
9
  # Turn off anonymized telemetry (the chromadb package defaults this to True)
10
- chromadb.configure(anonymized_telemetry=False)
 
11
  except Exception:
12
  # If chromadb isn't installed in this environment yet, ignore silently.
13
  pass
@@ -18,9 +28,9 @@ app = Flask(__name__)
18
  @app.route("/")
19
  def index():
20
  """
21
- Renders the main page.
22
  """
23
- return render_template("index.html")
24
 
25
 
26
  @app.route("/health")
@@ -44,8 +54,8 @@ def ingest():
44
  from src.ingestion.ingestion_pipeline import IngestionPipeline
45
 
46
  # Get optional parameters from request
47
- data = request.get_json() if request.is_json else {}
48
- store_embeddings = data.get("store_embeddings", True)
49
 
50
  pipeline = IngestionPipeline(
51
  chunk_size=DEFAULT_CHUNK_SIZE,
@@ -57,7 +67,7 @@ def ingest():
57
  result = pipeline.process_directory_with_embeddings(CORPUS_DIRECTORY)
58
 
59
  # Create response with enhanced information
60
- response = {
61
  "status": result["status"],
62
  "chunks_processed": result["chunks_processed"],
63
  "files_processed": result["files_processed"],
@@ -160,7 +170,7 @@ def search():
160
  )
161
 
162
  # Format response
163
- response = {
164
  "status": "success",
165
  "query": query.strip(),
166
  "results_count": len(results),
@@ -176,6 +186,192 @@ def search():
176
  return jsonify({"status": "error", "message": f"Search failed: {str(e)}"}), 500
177
 
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  @app.route("/chat", methods=["POST"])
180
  def chat():
181
  """
@@ -293,6 +489,153 @@ def chat():
293
  )
294
 
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  @app.route("/chat/health", methods=["GET"])
297
  def chat_health():
298
  """
@@ -351,6 +694,26 @@ def chat_health():
351
  503,
352
  )
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  except Exception as e:
355
  return (
356
  jsonify({"status": "error", "message": f"Health check failed: {str(e)}"}),
@@ -359,4 +722,5 @@ def chat_health():
359
 
360
 
361
  if __name__ == "__main__":
362
- app.run(debug=True)
 
 
1
+ import os
2
+
3
+ # Import type annotations
4
+ from typing import Any, Dict
5
+
6
+ from dotenv import load_dotenv
7
  from flask import Flask, jsonify, render_template, request
8
 
9
+ # Load environment variables from .env file
10
+ load_dotenv()
11
+
12
  # Disable ChromaDB anonymized telemetry for local development so the
13
  # library doesn't attempt to call external PostHog telemetry endpoints.
14
  # This avoids noisy errors in server logs and respects developer privacy.
 
16
  import chromadb
17
 
18
  # Turn off anonymized telemetry (the chromadb package defaults this to True)
19
+ # Using Any type to avoid issues with unknown parameters
20
+ chromadb.configure(anonymized_telemetry=False) # type: ignore
21
  except Exception:
22
  # If chromadb isn't installed in this environment yet, ignore silently.
23
  pass
 
28
  @app.route("/")
29
  def index():
30
  """
31
+ Renders the chat interface.
32
  """
33
+ return render_template("chat.html")
34
 
35
 
36
  @app.route("/health")
 
54
  from src.ingestion.ingestion_pipeline import IngestionPipeline
55
 
56
  # Get optional parameters from request
57
+ data: Dict[str, Any] = request.get_json() if request.is_json else {}
58
+ store_embeddings: bool = bool(data.get("store_embeddings", True))
59
 
60
  pipeline = IngestionPipeline(
61
  chunk_size=DEFAULT_CHUNK_SIZE,
 
67
  result = pipeline.process_directory_with_embeddings(CORPUS_DIRECTORY)
68
 
69
  # Create response with enhanced information
70
+ response: Dict[str, Any] = {
71
  "status": result["status"],
72
  "chunks_processed": result["chunks_processed"],
73
  "files_processed": result["files_processed"],
 
170
  )
171
 
172
  # Format response
173
+ response: Dict[str, Any] = {
174
  "status": "success",
175
  "query": query.strip(),
176
  "results_count": len(results),
 
186
  return jsonify({"status": "error", "message": f"Search failed: {str(e)}"}), 500
187
 
188
 
189
+ @app.route("/chat/suggestions")
190
+ def get_query_suggestions():
191
+ """
192
+ Get query suggestions based on available documents.
193
+
194
+ Returns a list of suggested queries based on the most common topics
195
+ in the document corpus.
196
+ """
197
+ try:
198
+ # In a real implementation, these might come from analytics or document metadata
199
+ # For now, we'll return a static list of suggestions based on our corpus
200
+ suggestions = [
201
+ "What is our remote work policy?",
202
+ "How do I request time off?",
203
+ "What are our information security guidelines?",
204
+ "How does our expense reimbursement work?",
205
+ "Tell me about our diversity and inclusion policy",
206
+ "What's the process for employee performance reviews?",
207
+ "How do I report an emergency at work?",
208
+ "What professional development opportunities are available?",
209
+ ]
210
+
211
+ return jsonify({"status": "success", "suggestions": suggestions})
212
+
213
+ except Exception as e:
214
+ return (
215
+ jsonify(
216
+ {
217
+ "status": "error",
218
+ "message": f"Failed to retrieve suggestions: {str(e)}",
219
+ }
220
+ ),
221
+ 500,
222
+ )
223
+
224
+
225
+ @app.route("/chat/feedback", methods=["POST"])
226
+ def submit_feedback():
227
+ """
228
+ Submit feedback for a specific chat message.
229
+
230
+ Collects user feedback on answer quality and relevance.
231
+ """
232
+ try:
233
+ # Get the feedback data from the request
234
+ feedback_data = request.json
235
+
236
+ if not feedback_data:
237
+ return (
238
+ jsonify({"status": "error", "message": "No feedback data provided"}),
239
+ 400,
240
+ )
241
+
242
+ # Validate the required fields
243
+ required_fields = ["conversation_id", "message_id", "feedback_type"]
244
+ for field in required_fields:
245
+ if field not in feedback_data:
246
+ return (
247
+ jsonify(
248
+ {
249
+ "status": "error",
250
+ "message": f"Missing required field: {field}",
251
+ }
252
+ ),
253
+ 400,
254
+ )
255
+
256
+ # Log the feedback for now
257
+ # In a production system, you'd save this to a database
258
+ print(f"Received feedback: {feedback_data}")
259
+
260
+ # Return a success response
261
+ return jsonify(
262
+ {
263
+ "status": "success",
264
+ "message": "Feedback received",
265
+ "feedback": feedback_data,
266
+ }
267
+ )
268
+ except Exception as e:
269
+ print(f"Error processing feedback: {str(e)}")
270
+ return (
271
+ jsonify(
272
+ {"status": "error", "message": f"Error processing feedback: {str(e)}"}
273
+ ),
274
+ 500,
275
+ )
276
+
277
+
278
+ @app.route("/chat/source/<source_id>")
279
+ def get_source_document(source_id: str):
280
+ """
281
+ Get source document content by ID.
282
+
283
+ Returns the content and metadata of a source document
284
+ referenced in chat responses.
285
+ """
286
+ try:
287
+ # In a real implementation, you'd retrieve this from your vector store
288
+ # For this implementation, we'll use a simplified approach with mock data
289
+
290
+ # We'll use hardcoded mock data instead of actual imports
291
+
292
+ # Map of source IDs to policy content
293
+ # In a real implementation, this would come from your vector store
294
+ from typing import Union
295
+
296
+ source_map: Dict[str, Dict[str, Union[str, Dict[str, str]]]] = {
297
+ "remote_work": {
298
+ "content": (
299
+ "# Remote Work Policy\n\n"
300
+ "Employees may work remotely up to 3 days per week with manager"
301
+ " approval."
302
+ ),
303
+ "metadata": {
304
+ "filename": "remote_work_policy.md",
305
+ "last_updated": "2025-09-15",
306
+ },
307
+ },
308
+ "pto": {
309
+ "content": (
310
+ "# PTO Policy\n\n"
311
+ "Full-time employees receive 20 days of PTO annually, accrued"
312
+ " monthly."
313
+ ),
314
+ "metadata": {"filename": "pto_policy.md", "last_updated": "2025-08-20"},
315
+ },
316
+ "security": {
317
+ "content": (
318
+ "# Information Security Policy\n\n"
319
+ "All employees must use company-approved devices and software"
320
+ " for work tasks."
321
+ ),
322
+ "metadata": {
323
+ "filename": "information_security_policy.md",
324
+ "last_updated": "2025-10-01",
325
+ },
326
+ },
327
+ "expense": {
328
+ "content": (
329
+ "# Expense Reimbursement\n\n"
330
+ "Submit all expense reports within 30 days of incurring"
331
+ " the expense."
332
+ ),
333
+ "metadata": {
334
+ "filename": "expense_reimbursement_policy.md",
335
+ "last_updated": "2025-07-10",
336
+ },
337
+ },
338
+ }
339
+
340
+ # Try to find the source in our mock data
341
+ if source_id in source_map:
342
+ source_data: Dict[str, Union[str, Dict[str, str]]] = source_map[source_id]
343
+ return jsonify(
344
+ {
345
+ "status": "success",
346
+ "source_id": source_id,
347
+ "content": source_data["content"],
348
+ "metadata": source_data["metadata"],
349
+ }
350
+ )
351
+ else:
352
+ # If we don't find it, return a generic response
353
+ return (
354
+ jsonify(
355
+ {
356
+ "status": "error",
357
+ "message": f"Source document with ID {source_id} not found",
358
+ }
359
+ ),
360
+ 404,
361
+ )
362
+
363
+ except Exception as e:
364
+ return (
365
+ jsonify(
366
+ {
367
+ "status": "error",
368
+ "message": f"Failed to retrieve source document: {str(e)}",
369
+ }
370
+ ),
371
+ 500,
372
+ )
373
+
374
+
375
  @app.route("/chat", methods=["POST"])
376
  def chat():
377
  """
 
489
  )
490
 
491
 
492
+ @app.route("/conversations", methods=["GET"])
493
+ def get_conversations():
494
+ """
495
+ Get a list of all conversations for the current user.
496
+
497
+ Returns conversation IDs, titles, and timestamps.
498
+ """
499
+ # In a production system, you'd retrieve these from a database
500
+ # For now, we'll create some mock data
501
+
502
+ conversations = [
503
+ {
504
+ "id": "conv-123456",
505
+ "title": "HR Policy Questions",
506
+ "timestamp": "2025-10-15T14:30:00Z",
507
+ "preview": "What is our remote work policy?",
508
+ },
509
+ {
510
+ "id": "conv-789012",
511
+ "title": "Project Planning Queries",
512
+ "timestamp": "2025-10-14T09:15:00Z",
513
+ "preview": "How do we handle project kickoffs?",
514
+ },
515
+ {
516
+ "id": "conv-345678",
517
+ "title": "Security Compliance",
518
+ "timestamp": "2025-10-12T16:45:00Z",
519
+ "preview": "What are our password requirements?",
520
+ },
521
+ ]
522
+
523
+ return jsonify({"status": "success", "conversations": conversations})
524
+
525
+
526
+ @app.route("/conversations/<conversation_id>", methods=["GET"])
527
+ def get_conversation(conversation_id: str):
528
+ """
529
+ Get the full content of a specific conversation.
530
+
531
+ Returns all messages in the conversation.
532
+ """
533
+ try:
534
+ # In a production system, you'd retrieve this from a database
535
+ # For now, we'll create some mock data based on the ID
536
+
537
+ # Mock conversation data
538
+ if conversation_id == "conv-123456":
539
+ from typing import List, Union
540
+
541
+ messages: List[Dict[str, Union[str, List[Dict[str, str]]]]] = [
542
+ {
543
+ "id": "msg-111",
544
+ "role": "user",
545
+ "content": "What is our remote work policy?",
546
+ "timestamp": "2025-10-15T14:30:00Z",
547
+ },
548
+ {
549
+ "id": "msg-112",
550
+ "role": "assistant",
551
+ "content": (
552
+ "According to our remote work policy, employees may work "
553
+ "up to 3 days per week with manager approval. You need to "
554
+ "coordinate with your team to ensure adequate in-office "
555
+ "coverage."
556
+ ),
557
+ "timestamp": "2025-10-15T14:30:15Z",
558
+ "sources": [{"id": "remote_work", "title": "Remote Work Policy"}],
559
+ },
560
+ ]
561
+ elif conversation_id == "conv-789012":
562
+ messages: List[Dict[str, Union[str, List[Dict[str, str]]]]] = [
563
+ {
564
+ "id": "msg-221",
565
+ "role": "user",
566
+ "content": "How do we handle project kickoffs?",
567
+ "timestamp": "2025-10-14T09:15:00Z",
568
+ },
569
+ {
570
+ "id": "msg-222",
571
+ "role": "assistant",
572
+ "content": (
573
+ "Our project kickoff procedure includes a meeting with all "
574
+ "stakeholders, defining project scope and goals, establishing "
575
+ "communication channels, and setting up the initial project "
576
+ "timeline."
577
+ ),
578
+ "timestamp": "2025-10-14T09:15:30Z",
579
+ "sources": [
580
+ {"id": "project_kickoff", "title": "Project Kickoff Procedure"}
581
+ ],
582
+ },
583
+ ]
584
+ elif conversation_id == "conv-345678":
585
+ messages: List[Dict[str, Union[str, List[Dict[str, str]]]]] = [
586
+ {
587
+ "id": "msg-331",
588
+ "role": "user",
589
+ "content": "What are our password requirements?",
590
+ "timestamp": "2025-10-12T16:45:00Z",
591
+ },
592
+ {
593
+ "id": "msg-332",
594
+ "role": "assistant",
595
+ "content": (
596
+ "Our security policy requires passwords to be at least "
597
+ "12 characters long with a mix of uppercase letters, "
598
+ "lowercase letters, numbers, and special characters. "
599
+ "Passwords must be changed every 90 days and cannot be "
600
+ "reused for 12 cycles."
601
+ ),
602
+ "timestamp": "2025-10-12T16:45:20Z",
603
+ "sources": [
604
+ {"id": "security", "title": "Information Security Policy"}
605
+ ],
606
+ },
607
+ ]
608
+ else:
609
+ return (
610
+ jsonify(
611
+ {
612
+ "status": "error",
613
+ "message": f"Conversation {conversation_id} not found",
614
+ }
615
+ ),
616
+ 404,
617
+ )
618
+
619
+ return jsonify(
620
+ {
621
+ "status": "success",
622
+ "conversation_id": conversation_id,
623
+ "messages": messages,
624
+ }
625
+ )
626
+
627
+ except Exception as e:
628
+ return (
629
+ jsonify(
630
+ {
631
+ "status": "error",
632
+ "message": f"Error retrieving conversation: {str(e)}",
633
+ }
634
+ ),
635
+ 500,
636
+ )
637
+
638
+
639
  @app.route("/chat/health", methods=["GET"])
640
  def chat_health():
641
  """
 
694
  503,
695
  )
696
 
697
+ except ValueError as e:
698
+ # Specific handling for LLM configuration errors
699
+ return (
700
+ jsonify(
701
+ {
702
+ "status": "error",
703
+ "message": f"LLM configuration error: {str(e)}",
704
+ "health": {
705
+ "pipeline_status": "unhealthy",
706
+ "components": {
707
+ "llm_service": {
708
+ "status": "unconfigured",
709
+ "error": str(e),
710
+ }
711
+ },
712
+ },
713
+ }
714
+ ),
715
+ 503,
716
+ )
717
  except Exception as e:
718
  return (
719
  jsonify({"status": "error", "message": f"Health check failed: {str(e)}"}),
 
722
 
723
 
724
  if __name__ == "__main__":
725
+ port = int(os.environ.get("PORT", 8080))
726
+ app.run(debug=True, host="0.0.0.0", port=port)
enhanced_app.py CHANGED
@@ -5,17 +5,23 @@ This module demonstrates how to integrate the guardrails system
5
  with the existing Flask API endpoints.
6
  """
7
 
 
 
 
8
  from flask import Flask, jsonify, render_template, request
9
 
 
 
 
10
  app = Flask(__name__)
11
 
12
 
13
  @app.route("/")
14
  def index():
15
  """
16
- Renders the main page.
17
  """
18
- return render_template("index.html")
19
 
20
 
21
  @app.route("/health")
@@ -206,6 +212,26 @@ def chat_health():
206
  }
207
  )
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  except Exception as e:
210
  return (
211
  jsonify(
@@ -290,4 +316,4 @@ def validate_response():
290
 
291
 
292
  if __name__ == "__main__":
293
- app.run(debug=True)
 
5
  with the existing Flask API endpoints.
6
  """
7
 
8
+ # ...existing code...
9
+
10
+ from dotenv import load_dotenv
11
  from flask import Flask, jsonify, render_template, request
12
 
13
+ # Load environment variables from .env file
14
+ load_dotenv()
15
+
16
  app = Flask(__name__)
17
 
18
 
19
  @app.route("/")
20
  def index():
21
  """
22
+ Renders the chat interface.
23
  """
24
+ return render_template("chat.html")
25
 
26
 
27
  @app.route("/health")
 
212
  }
213
  )
214
 
215
+ except ValueError as e:
216
+ # Specific handling for LLM configuration errors
217
+ return (
218
+ jsonify(
219
+ {
220
+ "status": "error",
221
+ "message": f"LLM configuration error: {str(e)}",
222
+ "health": {
223
+ "pipeline_status": "unhealthy",
224
+ "components": {
225
+ "llm_service": {
226
+ "status": "unconfigured",
227
+ "error": str(e),
228
+ }
229
+ },
230
+ },
231
+ }
232
+ ),
233
+ 503,
234
+ )
235
  except Exception as e:
236
  return (
237
  jsonify(
 
316
 
317
 
318
  if __name__ == "__main__":
319
+ app.run(debug=True, host="0.0.0.0", port=8080)
project-plan.md CHANGED
@@ -79,9 +79,15 @@ This plan outlines the steps to design, build, and deploy a Retrieval-Augmented
79
 
80
  ## 7. Web Application Completion
81
 
82
- - [ ] **Chat Interface:** Implement a simple web chat interface for the `/` endpoint.
 
 
 
 
 
 
83
  - [x] **API Endpoint:** Create the `/chat` API endpoint that receives user questions (POST) and returns model-generated answers with citations and snippets.
84
- - [ ] **UI/UX:** Ensure the web interface is clean, user-friendly, and handles loading/error states gracefully.
85
  - [x] **Testing:** Write end-to-end tests for the chat functionality.
86
 
87
  ## 8. Evaluation
 
79
 
80
  ## 7. Web Application Completion
81
 
82
+ - [x] **Chat Interface:** ✅ **COMPLETED** - Implement a simple web chat interface for the `/` endpoint.
83
+ - [x] **Modern Chat UI:** Interactive chat interface with real-time messaging
84
+ - [x] **Message History:** Conversation display with user and assistant messages
85
+ - [x] **Source Citations:** Visual display of source documents and confidence scores
86
+ - [x] **Responsive Design:** Mobile-friendly interface with modern styling
87
+ - [x] **Error Handling:** Graceful error display and loading states
88
+ - [x] **System Health:** Status indicators and health monitoring
89
  - [x] **API Endpoint:** Create the `/chat` API endpoint that receives user questions (POST) and returns model-generated answers with citations and snippets.
90
+ - [x] **UI/UX:** ✅ **COMPLETED** - Ensure the web interface is clean, user-friendly, and handles loading/error states gracefully.
91
  - [x] **Testing:** Write end-to-end tests for the chat functionality.
92
 
93
  ## 8. Evaluation
static/chat-enhanced.css ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Enhanced Chat Interface Styles */
2
+
3
+ /* Message timestamp styles */
4
+ .message-header {
5
+ display: flex;
6
+ justify-content: space-between;
7
+ padding: 0 1rem 0.5rem 1rem;
8
+ font-size: 0.8rem;
9
+ color: #64748b;
10
+ }
11
+
12
+ .sender-label {
13
+ font-weight: 500;
14
+ }
15
+
16
+ .message-timestamp {
17
+ font-size: 0.75rem;
18
+ color: #94a3b8;
19
+ }
20
+
21
+ .message-user .message-header {
22
+ text-align: right;
23
+ }
24
+
25
+ .message-assistant .message-header {
26
+ text-align: left;
27
+ }
28
+
29
+ /* Accessibility Improvements */
30
+ .message:focus {
31
+ outline: 2px solid #667eea;
32
+ border-radius: 8px;
33
+ }
34
+
35
+ .side-panel:focus {
36
+ outline: 2px solid #667eea;
37
+ }
38
+
39
+ button:focus-visible,
40
+ textarea:focus-visible,
41
+ input:focus-visible,
42
+ .clickable:focus-visible {
43
+ outline: 2px solid #667eea;
44
+ outline-offset: 2px;
45
+ }
46
+
47
+ .sr-only {
48
+ position: absolute;
49
+ width: 1px;
50
+ height: 1px;
51
+ padding: 0;
52
+ margin: -1px;
53
+ overflow: hidden;
54
+ clip: rect(0, 0, 0, 0);
55
+ white-space: nowrap;
56
+ border-width: 0;
57
+ }
58
+
59
+ /* Mobile Experience Optimization */
60
+ @media (max-width: 768px) {
61
+ /* Adjustments for smaller screens */
62
+ .message-header {
63
+ padding: 0 0.5rem 0.25rem 0.5rem;
64
+ font-size: 0.75rem;
65
+ }
66
+
67
+ /* Mobile swipe gestures */
68
+ .chat-container {
69
+ position: relative;
70
+ touch-action: pan-y;
71
+ }
72
+
73
+ /* Improved touch targets for mobile */
74
+ .feedback-btn, .icon-button, .primary-button {
75
+ min-height: 44px;
76
+ min-width: 44px;
77
+ }
78
+ }
79
+
80
+ /* Mobile swipe indicator */
81
+ .swipe-indicator {
82
+ position: absolute;
83
+ top: 50%;
84
+ right: -5px;
85
+ width: 30px;
86
+ height: 50px;
87
+ background: rgba(102, 126, 234, 0.2);
88
+ border-top-left-radius: 25px;
89
+ border-bottom-left-radius: 25px;
90
+ display: none;
91
+ justify-content: center;
92
+ align-items: center;
93
+ z-index: 90;
94
+ opacity: 0.7;
95
+ box-shadow: -1px 0 3px rgba(0, 0, 0, 0.1);
96
+ animation: pulse 2s infinite;
97
+ }
98
+
99
+ /* Only show swipe indicator on smaller screens */
100
+ @media (max-width: 768px) {
101
+ .swipe-indicator {
102
+ display: flex;
103
+ }
104
+ }
105
+
106
+ .swipe-indicator svg {
107
+ width: 16px;
108
+ height: 16px;
109
+ stroke: #667eea;
110
+ }
111
+
112
+ /* Conversation export/import */
113
+ .export-import-buttons {
114
+ display: flex;
115
+ gap: 0.5rem;
116
+ padding-bottom: 0.5rem;
117
+ border-bottom: 1px solid #e2e8f0;
118
+ }
119
+
120
+ .search-conversations {
121
+ margin-top: 0.5rem;
122
+ margin-bottom: 0.5rem;
123
+ }
124
+
125
+ .search-input {
126
+ width: 100%;
127
+ padding: 0.75rem;
128
+ border: 1px solid #e2e8f0;
129
+ border-radius: 8px;
130
+ font-size: 0.875rem;
131
+ }
132
+
133
+ .search-input:focus {
134
+ border-color: #667eea;
135
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
136
+ outline: none;
137
+ }
138
+
139
+ /* Improved error handling */
140
+ .retry-button {
141
+ background: #f8fafc;
142
+ border: 1px solid #ef4444;
143
+ color: #ef4444;
144
+ border-radius: 8px;
145
+ padding: 0.5rem 0.75rem;
146
+ font-size: 0.875rem;
147
+ margin-top: 0.5rem;
148
+ cursor: pointer;
149
+ transition: all 0.2s;
150
+ display: inline-flex;
151
+ align-items: center;
152
+ gap: 0.5rem;
153
+ }
154
+
155
+ .retry-button:hover {
156
+ background: #fef2f2;
157
+ }
158
+
159
+ .error-details {
160
+ background: #fef2f2;
161
+ padding: 0.5rem;
162
+ border-radius: 6px;
163
+ margin-top: 0.5rem;
164
+ font-size: 0.8rem;
165
+ color: #b91c1c;
166
+ max-height: 100px;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ /* File upload for import */
171
+ .file-upload {
172
+ display: none;
173
+ }
174
+
175
+ .file-upload-label {
176
+ cursor: pointer;
177
+ display: inline-flex;
178
+ align-items: center;
179
+ gap: 0.5rem;
180
+ background: #f8fafc;
181
+ border: 1px solid #e2e8f0;
182
+ border-radius: 8px;
183
+ padding: 0.5rem 0.75rem;
184
+ font-size: 0.875rem;
185
+ color: #1e293b;
186
+ transition: all 0.2s;
187
+ }
188
+
189
+ .file-upload-label:hover {
190
+ background: #f1f5f9;
191
+ border-color: #cbd5e1;
192
+ }
193
+
194
+ /* Auto-retry status */
195
+ .auto-retry-status {
196
+ font-size: 0.8rem;
197
+ color: #f59e0b;
198
+ margin-top: 0.5rem;
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 0.5rem;
202
+ }
203
+
204
+ .retry-countdown {
205
+ font-weight: 600;
206
+ }
static/chat.css ADDED
@@ -0,0 +1,719 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Chat Interface Styles */
2
+
3
+ .chat-container {
4
+ max-width: 1200px;
5
+ margin: 0 auto;
6
+ height: 100vh;
7
+ display: flex;
8
+ flex-direction: column;
9
+ background: #ffffff;
10
+ }
11
+
12
+ .chat-header {
13
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
14
+ color: white;
15
+ padding: 1.5rem 2rem;
16
+ display: flex;
17
+ justify-content: space-between;
18
+ align-items: center;
19
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
20
+ }
21
+
22
+ .header-content h1 {
23
+ margin: 0;
24
+ font-size: 2rem;
25
+ font-weight: 700;
26
+ }
27
+
28
+ .header-content .subtitle {
29
+ margin: 0.25rem 0 0 0;
30
+ opacity: 0.9;
31
+ font-size: 1rem;
32
+ }
33
+
34
+ .header-controls {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 1rem;
38
+ }
39
+
40
+ .status-indicator {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ background: rgba(255,255,255,0.1);
45
+ padding: 0.5rem 1rem;
46
+ border-radius: 20px;
47
+ backdrop-filter: blur(10px);
48
+ }
49
+
50
+ .status-dot {
51
+ width: 8px;
52
+ height: 8px;
53
+ border-radius: 50%;
54
+ background: #4ade80;
55
+ animation: pulse 2s infinite;
56
+ }
57
+
58
+ .status-text {
59
+ font-size: 0.875rem;
60
+ font-weight: 500;
61
+ }
62
+
63
+ @keyframes pulse {
64
+ 0%, 100% { opacity: 1; }
65
+ 50% { opacity: 0.5; }
66
+ }
67
+
68
+ .chat-main {
69
+ flex: 1;
70
+ display: flex;
71
+ flex-direction: column;
72
+ overflow: hidden;
73
+ }
74
+
75
+ .messages-container {
76
+ flex: 1;
77
+ overflow-y: auto;
78
+ padding: 2rem;
79
+ background: #f8fafc;
80
+ }
81
+
82
+ .welcome-message {
83
+ text-align: center;
84
+ max-width: 600px;
85
+ margin: 0 auto;
86
+ padding: 3rem 2rem;
87
+ }
88
+
89
+ .welcome-icon {
90
+ font-size: 4rem;
91
+ margin-bottom: 1rem;
92
+ }
93
+
94
+ .welcome-message h2 {
95
+ color: #1e293b;
96
+ margin-bottom: 1rem;
97
+ font-size: 1.875rem;
98
+ font-weight: 600;
99
+ }
100
+
101
+ .welcome-message p {
102
+ color: #64748b;
103
+ margin-bottom: 2rem;
104
+ font-size: 1.125rem;
105
+ line-height: 1.6;
106
+ }
107
+
108
+ .policy-topics {
109
+ list-style: none;
110
+ padding: 0;
111
+ display: grid;
112
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
113
+ gap: 0.75rem;
114
+ margin-top: 1.5rem;
115
+ }
116
+
117
+ .policy-topics li {
118
+ background: white;
119
+ padding: 1rem;
120
+ border-radius: 8px;
121
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
122
+ color: #475569;
123
+ font-weight: 500;
124
+ transition: transform 0.2s, box-shadow 0.2s;
125
+ }
126
+
127
+ .policy-topics li:hover {
128
+ transform: translateY(-2px);
129
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
130
+ }
131
+
132
+ .message {
133
+ margin-bottom: 1.5rem;
134
+ animation: fadeInUp 0.3s ease-out;
135
+ }
136
+
137
+ .message-user {
138
+ display: flex;
139
+ justify-content: flex-end;
140
+ }
141
+
142
+ .message-assistant {
143
+ display: flex;
144
+ justify-content: flex-start;
145
+ }
146
+
147
+ .message-content {
148
+ max-width: 70%;
149
+ padding: 1rem 1.25rem;
150
+ border-radius: 18px;
151
+ position: relative;
152
+ }
153
+
154
+ .message-user .message-content {
155
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
156
+ color: white;
157
+ border-bottom-right-radius: 4px;
158
+ }
159
+
160
+ .message-assistant .message-content {
161
+ background: white;
162
+ color: #1e293b;
163
+ border: 1px solid #e2e8f0;
164
+ border-bottom-left-radius: 4px;
165
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
166
+ }
167
+
168
+ .message-text {
169
+ line-height: 1.5;
170
+ margin-bottom: 0;
171
+ }
172
+
173
+ .message-sources {
174
+ margin-top: 1rem;
175
+ padding-top: 1rem;
176
+ border-top: 1px solid #e2e8f0;
177
+ }
178
+
179
+ .sources-header {
180
+ font-size: 0.875rem;
181
+ font-weight: 600;
182
+ color: #64748b;
183
+ margin-bottom: 0.5rem;
184
+ }
185
+
186
+ .source-citation {
187
+ display: flex;
188
+ align-items: center;
189
+ gap: 0.5rem;
190
+ padding: 0.5rem;
191
+ background: #f1f5f9;
192
+ border-radius: 6px;
193
+ margin-bottom: 0.5rem;
194
+ font-size: 0.875rem;
195
+ }
196
+
197
+ .source-icon {
198
+ width: 16px;
199
+ height: 16px;
200
+ color: #64748b;
201
+ }
202
+
203
+ .confidence-score {
204
+ margin-top: 0.75rem;
205
+ padding: 0.5rem;
206
+ background: #f8fafc;
207
+ border-radius: 6px;
208
+ font-size: 0.875rem;
209
+ color: #64748b;
210
+ }
211
+
212
+ .confidence-bar {
213
+ height: 4px;
214
+ background: #e2e8f0;
215
+ border-radius: 2px;
216
+ margin-top: 0.25rem;
217
+ overflow: hidden;
218
+ }
219
+
220
+ .confidence-fill {
221
+ height: 100%;
222
+ background: linear-gradient(90deg, #ef4444 0%, #f59e0b 50%, #10b981 100%);
223
+ border-radius: 2px;
224
+ transition: width 0.3s ease;
225
+ }
226
+
227
+ .message-feedback {
228
+ display: flex;
229
+ gap: 0.75rem;
230
+ margin-top: 1rem;
231
+ padding-top: 0.75rem;
232
+ border-top: 1px solid #e2e8f0;
233
+ }
234
+
235
+ .feedback-btn {
236
+ display: flex;
237
+ align-items: center;
238
+ gap: 0.5rem;
239
+ background: #f8fafc;
240
+ border: 1px solid #e2e8f0;
241
+ border-radius: 20px;
242
+ padding: 0.5rem 0.75rem;
243
+ color: #64748b;
244
+ font-size: 0.875rem;
245
+ cursor: pointer;
246
+ transition: all 0.2s;
247
+ }
248
+
249
+ .feedback-btn:hover {
250
+ background: #f1f5f9;
251
+ transform: translateY(-1px);
252
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
253
+ }
254
+
255
+ .feedback-btn svg {
256
+ color: #94a3b8;
257
+ }
258
+
259
+ .feedback-thanks {
260
+ color: #10b981;
261
+ font-size: 0.875rem;
262
+ font-style: italic;
263
+ padding: 0.5rem 0;
264
+ }
265
+
266
+ .feedback-form {
267
+ width: 100%;
268
+ display: flex;
269
+ flex-direction: column;
270
+ gap: 0.75rem;
271
+ }
272
+
273
+ .feedback-form p {
274
+ margin: 0;
275
+ font-size: 0.875rem;
276
+ color: #64748b;
277
+ }
278
+
279
+ .feedback-select, .feedback-textarea {
280
+ padding: 0.75rem;
281
+ border: 1px solid #e2e8f0;
282
+ border-radius: 6px;
283
+ background: white;
284
+ font-family: inherit;
285
+ font-size: 0.875rem;
286
+ color: #1e293b;
287
+ width: 100%;
288
+ }
289
+
290
+ .feedback-textarea {
291
+ resize: vertical;
292
+ min-height: 80px;
293
+ }
294
+
295
+ .feedback-actions {
296
+ display: flex;
297
+ justify-content: flex-end;
298
+ }
299
+
300
+ .feedback-actions .primary-button {
301
+ padding: 0.5rem 1rem;
302
+ font-size: 0.875rem;
303
+ }
304
+
305
+ .input-container {
306
+ padding: 1.5rem 2rem;
307
+ background: white;
308
+ border-top: 1px solid #e2e8f0;
309
+ }
310
+
311
+ .chat-form {
312
+ max-width: 800px;
313
+ margin: 0 auto;
314
+ }
315
+
316
+ .input-wrapper {
317
+ display: flex;
318
+ align-items: flex-end;
319
+ gap: 0.75rem;
320
+ background: #f8fafc;
321
+ border: 2px solid #e2e8f0;
322
+ border-radius: 12px;
323
+ padding: 0.75rem;
324
+ transition: border-color 0.2s;
325
+ }
326
+
327
+ .input-wrapper:focus-within {
328
+ border-color: #667eea;
329
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
330
+ }
331
+
332
+ #messageInput {
333
+ flex: 1;
334
+ border: none;
335
+ background: transparent;
336
+ resize: none;
337
+ font-family: 'Inter', sans-serif;
338
+ font-size: 1rem;
339
+ line-height: 1.5;
340
+ min-height: 20px;
341
+ max-height: 120px;
342
+ padding: 0;
343
+ outline: none;
344
+ color: #1e293b;
345
+ }
346
+
347
+ #messageInput::placeholder {
348
+ color: #94a3b8;
349
+ }
350
+
351
+ .send-button {
352
+ background: #667eea;
353
+ color: white;
354
+ border: none;
355
+ border-radius: 8px;
356
+ padding: 0.75rem;
357
+ cursor: pointer;
358
+ transition: all 0.2s;
359
+ display: flex;
360
+ align-items: center;
361
+ justify-content: center;
362
+ min-width: 44px;
363
+ }
364
+
365
+ .send-button:hover:not(:disabled) {
366
+ background: #5a67d8;
367
+ transform: translateY(-1px);
368
+ }
369
+
370
+ .send-button:disabled {
371
+ background: #cbd5e1;
372
+ cursor: not-allowed;
373
+ transform: none;
374
+ }
375
+
376
+ .input-options {
377
+ display: flex;
378
+ justify-content: space-between;
379
+ align-items: center;
380
+ margin-top: 0.75rem;
381
+ font-size: 0.875rem;
382
+ }
383
+
384
+ .checkbox-label {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 0.5rem;
388
+ cursor: pointer;
389
+ color: #64748b;
390
+ user-select: none;
391
+ }
392
+
393
+ .checkbox-label input[type="checkbox"] {
394
+ display: none;
395
+ }
396
+
397
+ .checkmark {
398
+ width: 18px;
399
+ height: 18px;
400
+ border: 2px solid #cbd5e1;
401
+ border-radius: 4px;
402
+ position: relative;
403
+ transition: all 0.2s;
404
+ }
405
+
406
+ .checkbox-label input[type="checkbox"]:checked + .checkmark {
407
+ background: #667eea;
408
+ border-color: #667eea;
409
+ }
410
+
411
+ .checkbox-label input[type="checkbox"]:checked + .checkmark::after {
412
+ content: '';
413
+ position: absolute;
414
+ left: 5px;
415
+ top: 2px;
416
+ width: 4px;
417
+ height: 8px;
418
+ border: solid white;
419
+ border-width: 0 2px 2px 0;
420
+ transform: rotate(45deg);
421
+ }
422
+
423
+ .char-counter {
424
+ color: #94a3b8;
425
+ font-size: 0.8rem;
426
+ }
427
+
428
+ .char-counter.warning {
429
+ color: #f59e0b;
430
+ }
431
+
432
+ .char-counter.error {
433
+ color: #ef4444;
434
+ }
435
+
436
+ .chat-footer {
437
+ padding: 1rem 2rem;
438
+ background: #f8fafc;
439
+ border-top: 1px solid #e2e8f0;
440
+ text-align: center;
441
+ font-size: 0.875rem;
442
+ color: #64748b;
443
+ }
444
+
445
+ .chat-footer a {
446
+ color: #667eea;
447
+ text-decoration: none;
448
+ }
449
+
450
+ .chat-footer a:hover {
451
+ text-decoration: underline;
452
+ }
453
+
454
+ .loading-overlay {
455
+ position: fixed;
456
+ top: 0;
457
+ left: 0;
458
+ right: 0;
459
+ bottom: 0;
460
+ background: rgba(255,255,255,0.9);
461
+ display: flex;
462
+ flex-direction: column;
463
+ align-items: center;
464
+ justify-content: center;
465
+ z-index: 1000;
466
+ backdrop-filter: blur(4px);
467
+ }
468
+
469
+ .loading-overlay.hidden {
470
+ display: none;
471
+ }
472
+
473
+ .loading-spinner {
474
+ width: 40px;
475
+ height: 40px;
476
+ border: 4px solid #e2e8f0;
477
+ border-top: 4px solid #667eea;
478
+ border-radius: 50%;
479
+ animation: spin 1s linear infinite;
480
+ margin-bottom: 1rem;
481
+ }
482
+
483
+ .loading-overlay p {
484
+ color: #64748b;
485
+ font-size: 1.125rem;
486
+ margin: 0;
487
+ }
488
+
489
+ @keyframes spin {
490
+ 0% { transform: rotate(0deg); }
491
+ 100% { transform: rotate(360deg); }
492
+ }
493
+
494
+ @keyframes fadeInUp {
495
+ from {
496
+ opacity: 0;
497
+ transform: translateY(20px);
498
+ }
499
+ to {
500
+ opacity: 1;
501
+ transform: translateY(0);
502
+ }
503
+ }
504
+
505
+ /* Error message styles */
506
+ .error-message {
507
+ background: #fef2f2;
508
+ border: 1px solid #fecaca;
509
+ color: #dc2626;
510
+ padding: 1rem;
511
+ border-radius: 8px;
512
+ margin: 1rem 0;
513
+ }
514
+
515
+ .error-message strong {
516
+ display: block;
517
+ margin-bottom: 0.5rem;
518
+ }
519
+
520
+ /* Responsive design */
521
+ @media (max-width: 768px) {
522
+ .chat-header {
523
+ padding: 1rem;
524
+ flex-direction: column;
525
+ gap: 1rem;
526
+ text-align: center;
527
+ }
528
+
529
+ .header-content h1 {
530
+ font-size: 1.5rem;
531
+ }
532
+
533
+ .messages-container {
534
+ padding: 1rem;
535
+ }
536
+
537
+ .welcome-message {
538
+ padding: 2rem 1rem;
539
+ }
540
+
541
+ .policy-topics {
542
+ grid-template-columns: 1fr;
543
+ }
544
+
545
+ .message-content {
546
+ max-width: 85%;
547
+ }
548
+
549
+ .input-container {
550
+ padding: 1rem;
551
+ }
552
+
553
+ .input-options {
554
+ flex-direction: column;
555
+ gap: 0.5rem;
556
+ align-items: flex-start;
557
+ }
558
+ }
559
+
560
+ /* Side Panel */
561
+ .side-panel {
562
+ position: fixed;
563
+ top: 0;
564
+ right: -400px;
565
+ width: 350px;
566
+ max-width: 90vw;
567
+ height: 100vh;
568
+ background: white;
569
+ box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
570
+ z-index: 100;
571
+ transition: right 0.3s ease;
572
+ display: flex;
573
+ flex-direction: column;
574
+ }
575
+
576
+ .side-panel.show {
577
+ right: 0;
578
+ }
579
+
580
+ .panel-header {
581
+ padding: 1.5rem;
582
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
583
+ color: white;
584
+ display: flex;
585
+ justify-content: space-between;
586
+ align-items: center;
587
+ }
588
+
589
+ .panel-header h3 {
590
+ margin: 0;
591
+ font-size: 1.25rem;
592
+ font-weight: 600;
593
+ }
594
+
595
+ .panel-body {
596
+ flex: 1;
597
+ overflow-y: auto;
598
+ padding: 1rem;
599
+ display: flex;
600
+ flex-direction: column;
601
+ gap: 1rem;
602
+ }
603
+
604
+ .panel-actions {
605
+ padding: 0.5rem 0;
606
+ border-bottom: 1px solid #e2e8f0;
607
+ margin-bottom: 1rem;
608
+ }
609
+
610
+ .icon-button {
611
+ background: transparent;
612
+ border: none;
613
+ color: currentColor;
614
+ cursor: pointer;
615
+ padding: 0.5rem;
616
+ display: flex;
617
+ align-items: center;
618
+ justify-content: center;
619
+ border-radius: 50%;
620
+ transition: background-color 0.2s;
621
+ }
622
+
623
+ .icon-button:hover {
624
+ background-color: rgba(255, 255, 255, 0.2);
625
+ }
626
+
627
+ .icon-button.small {
628
+ padding: 0.25rem;
629
+ }
630
+
631
+ .primary-button {
632
+ display: flex;
633
+ align-items: center;
634
+ gap: 0.5rem;
635
+ background: #667eea;
636
+ color: white;
637
+ border: none;
638
+ padding: 0.75rem 1rem;
639
+ border-radius: 8px;
640
+ font-weight: 500;
641
+ cursor: pointer;
642
+ transition: all 0.2s;
643
+ }
644
+
645
+ .primary-button:hover {
646
+ background: #5a67d8;
647
+ transform: translateY(-1px);
648
+ }
649
+
650
+ .conversation-list {
651
+ display: flex;
652
+ flex-direction: column;
653
+ gap: 0.75rem;
654
+ flex: 1;
655
+ }
656
+
657
+ .conversation-item {
658
+ padding: 0.75rem;
659
+ background: #f8fafc;
660
+ border-radius: 8px;
661
+ border: 1px solid #e2e8f0;
662
+ cursor: pointer;
663
+ transition: all 0.2s;
664
+ position: relative;
665
+ }
666
+
667
+ .conversation-item.active {
668
+ background: rgba(102, 126, 234, 0.1);
669
+ border-color: #667eea;
670
+ }
671
+
672
+ .conversation-item:hover {
673
+ background: #f1f5f9;
674
+ transform: translateY(-1px);
675
+ }
676
+
677
+ .conversation-item h4 {
678
+ margin: 0 0 0.5rem 0;
679
+ font-size: 1rem;
680
+ font-weight: 600;
681
+ color: #1e293b;
682
+ }
683
+
684
+ .conversation-meta {
685
+ font-size: 0.8rem;
686
+ color: #64748b;
687
+ }
688
+
689
+ .conversation-actions {
690
+ position: absolute;
691
+ top: 0.75rem;
692
+ right: 0.75rem;
693
+ opacity: 0;
694
+ transition: opacity 0.2s;
695
+ }
696
+
697
+ .conversation-item:hover .conversation-actions {
698
+ opacity: 1;
699
+ }
700
+
701
+ .empty-state {
702
+ text-align: center;
703
+ padding: 2rem 1rem;
704
+ color: #64748b;
705
+ }
706
+
707
+ @media (max-width: 480px) {
708
+ .welcome-message h2 {
709
+ font-size: 1.5rem;
710
+ }
711
+
712
+ .welcome-message p {
713
+ font-size: 1rem;
714
+ }
715
+
716
+ .message-content {
717
+ max-width: 95%;
718
+ }
719
+ }
static/js/chat.js ADDED
@@ -0,0 +1,1939 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PolicyWise Chat Interface
3
+ * Interactive web chat for the RAG application
4
+ */
5
+
6
+ class ChatInterface {
7
+ constructor() {
8
+ this.messageInput = document.getElementById('messageInput');
9
+ this.sendButton = document.getElementById('sendButton');
10
+ this.chatForm = document.getElementById('chatForm');
11
+ this.messagesContainer = document.getElementById('messagesContainer');
12
+ this.includeSources = document.getElementById('includeSources');
13
+ this.loadingOverlay = document.getElementById('loadingOverlay');
14
+ this.statusIndicator = document.getElementById('statusIndicator');
15
+ this.charCount = document.getElementById('charCount');
16
+ this.sourcePanel = document.getElementById('sourcePanel');
17
+ this.conversationHistoryPanel = document.getElementById('conversationHistoryPanel');
18
+ this.swipeIndicator = document.getElementById('swipeIndicator');
19
+
20
+ // Search and filter elements
21
+ this.searchConversations = document.getElementById('searchConversations');
22
+ this.exportConversationsBtn = document.getElementById('exportConversationsBtn');
23
+ this.importConversations = document.getElementById('importConversations');
24
+
25
+ this.messages = []; // Store messages in memory
26
+ this.conversationId = this.loadOrCreateConversation();
27
+ this.isLoading = false;
28
+ this.autoRetryCount = 0;
29
+ this.maxAutoRetries = 3;
30
+
31
+ // Track focused elements for keyboard navigation
32
+ this.lastFocusedElement = null;
33
+
34
+ // Touch handling for mobile swipe
35
+ this.touchStartX = 0;
36
+ this.touchEndX = 0;
37
+
38
+ this.initializeEventListeners();
39
+ this.setupKeyboardNavigation();
40
+ this.setupTouchHandlers();
41
+ this.checkSystemHealth();
42
+ this.loadConversationHistory();
43
+ this.loadQuerySuggestions();
44
+ this.focusInput();
45
+ this.initializeSourcePanel();
46
+ }
47
+
48
+ /**
49
+ * Set up keyboard navigation and accessibility
50
+ */
51
+ setupKeyboardNavigation() {
52
+ // Trap focus within modal dialogs
53
+ const handleTabKey = (e, containerElement) => {
54
+ if (e.key !== 'Tab') return;
55
+
56
+ const focusableElements = containerElement.querySelectorAll(
57
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
58
+ );
59
+
60
+ const firstElement = focusableElements[0];
61
+ const lastElement = focusableElements[focusableElements.length - 1];
62
+
63
+ if (e.shiftKey) {
64
+ if (document.activeElement === firstElement) {
65
+ e.preventDefault();
66
+ lastElement.focus();
67
+ }
68
+ } else {
69
+ if (document.activeElement === lastElement) {
70
+ e.preventDefault();
71
+ firstElement.focus();
72
+ }
73
+ }
74
+ };
75
+
76
+ // Add keyboard event listeners to side panels
77
+ if (this.conversationHistoryPanel) {
78
+ this.conversationHistoryPanel.addEventListener('keydown', (e) => {
79
+ if (e.key === 'Escape') {
80
+ this.closeConversationPanel();
81
+ } else if (e.key === 'Tab') {
82
+ handleTabKey(e, this.conversationHistoryPanel);
83
+ }
84
+ });
85
+ }
86
+
87
+ if (this.sourcePanel) {
88
+ document.addEventListener('keydown', (e) => {
89
+ if (e.key === 'Escape' && this.sourcePanel.classList.contains('show')) {
90
+ this.closeSourcePanel();
91
+ } else if (e.key === 'Tab' && this.sourcePanel.classList.contains('show')) {
92
+ handleTabKey(e, this.sourcePanel);
93
+ }
94
+ });
95
+ }
96
+
97
+ // Add keyboard shortcuts
98
+ document.addEventListener('keydown', (e) => {
99
+ // Cmd+/ or Ctrl+/ to focus search
100
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') {
101
+ e.preventDefault();
102
+ this.focusInput();
103
+ }
104
+
105
+ // Cmd+Shift+E or Ctrl+Shift+E to export conversations
106
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'E') {
107
+ e.preventDefault();
108
+ this.exportConversationsToFile();
109
+ }
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Set up touch handlers for mobile swipe gestures
115
+ */
116
+ setupTouchHandlers() {
117
+ // Only set up on mobile devices
118
+ if (window.innerWidth <= 768) {
119
+ document.addEventListener('touchstart', (e) => {
120
+ this.touchStartX = e.changedTouches[0].screenX;
121
+ }, { passive: true });
122
+
123
+ document.addEventListener('touchend', (e) => {
124
+ this.touchEndX = e.changedTouches[0].screenX;
125
+ this.handleSwipeGesture();
126
+ }, { passive: true });
127
+
128
+ // Show swipe indicator occasionally
129
+ setTimeout(() => {
130
+ if (this.swipeIndicator && !this.conversationHistoryPanel.classList.contains('show')) {
131
+ this.swipeIndicator.style.display = 'flex';
132
+ setTimeout(() => {
133
+ this.swipeIndicator.style.display = 'none';
134
+ }, 3000);
135
+ }
136
+ }, 5000);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Initialize source document panel
142
+ */
143
+ /**
144
+ * Load query suggestions from the server
145
+ */
146
+ async loadQuerySuggestions() {
147
+ const suggestionsContainer = document.getElementById('suggestedQueries');
148
+ if (!suggestionsContainer) return;
149
+
150
+ try {
151
+ const response = await fetch('/chat/suggestions');
152
+ const data = await response.json();
153
+
154
+ if (response.ok && data.status === 'success' && data.suggestions && data.suggestions.length > 0) {
155
+ suggestionsContainer.innerHTML = '';
156
+
157
+ data.suggestions.forEach(suggestion => {
158
+ const suggestionDiv = document.createElement('div');
159
+ suggestionDiv.className = 'query-suggestion';
160
+
161
+ const iconSpan = document.createElement('span');
162
+ iconSpan.className = 'suggestion-icon';
163
+ iconSpan.innerHTML = `
164
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
165
+ <circle cx="12" cy="12" r="10"></circle>
166
+ <line x1="12" y1="8" x2="12" y2="12"></line>
167
+ <line x1="12" y1="16" x2="12" y2="16"></line>
168
+ </svg>
169
+ `;
170
+
171
+ const textSpan = document.createElement('span');
172
+ textSpan.className = 'suggestion-text';
173
+ textSpan.textContent = suggestion;
174
+
175
+ suggestionDiv.appendChild(iconSpan);
176
+ suggestionDiv.appendChild(textSpan);
177
+
178
+ // Add click event to populate the input
179
+ suggestionDiv.addEventListener('click', () => {
180
+ this.messageInput.value = suggestion;
181
+ this.updateCharCount();
182
+ this.autoResizeTextarea();
183
+ this.updateSendButton();
184
+ this.focusInput();
185
+ });
186
+
187
+ suggestionsContainer.appendChild(suggestionDiv);
188
+ });
189
+ }
190
+ } catch (error) {
191
+ console.warn('Failed to load query suggestions:', error);
192
+ }
193
+ }
194
+
195
+ initializeSourcePanel() {
196
+ // Create the source panel if it doesn't exist
197
+ if (!this.sourcePanel) {
198
+ const sourcePanelHTML = `
199
+ <div id="sourcePanel" class="side-panel source-document-panel"
200
+ role="dialog"
201
+ aria-labelledby="sourcePanelHeader"
202
+ aria-hidden="true">
203
+ <div class="panel-header">
204
+ <h3 id="sourcePanelHeader">Source Document</h3>
205
+ <button id="closeSourcePanel" class="icon-button" aria-label="Close source document">
206
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
207
+ <line x1="18" y1="6" x2="6" y2="18"></line>
208
+ <line x1="6" y1="6" x2="18" y2="18"></line>
209
+ </svg>
210
+ </button>
211
+ </div>
212
+ <div class="panel-body">
213
+ <div id="sourceContent" class="source-document-content">
214
+ <!-- Document content will be loaded here -->
215
+ <p class="sr-only">Document content will be loaded here.</p>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ `;
220
+
221
+ const tempDiv = document.createElement('div');
222
+ tempDiv.innerHTML = sourcePanelHTML;
223
+ document.body.appendChild(tempDiv.firstElementChild);
224
+
225
+ this.sourcePanel = document.getElementById('sourcePanel');
226
+
227
+ // Add ESC key handler for accessibility
228
+ this.sourcePanel.addEventListener('keydown', (e) => {
229
+ if (e.key === 'Escape') {
230
+ this.closeSourcePanel();
231
+ }
232
+ });
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Initialize all event listeners
238
+ */
239
+ initializeEventListeners() {
240
+ // Form submission
241
+ this.chatForm.addEventListener('submit', (e) => {
242
+ e.preventDefault();
243
+ this.sendMessage();
244
+ });
245
+
246
+ // Input validation and auto-resize
247
+ this.messageInput.addEventListener('input', () => {
248
+ this.updateCharCount();
249
+ this.autoResizeTextarea();
250
+ this.updateSendButton();
251
+ });
252
+
253
+ // Enter key handling (Shift+Enter for new line, Enter to send)
254
+ this.messageInput.addEventListener('keydown', (e) => {
255
+ if (e.key === 'Enter' && !e.shiftKey) {
256
+ e.preventDefault();
257
+ if (!this.isLoading && this.messageInput.value.trim()) {
258
+ this.sendMessage();
259
+ }
260
+ }
261
+ });
262
+
263
+ // Clear welcome message on first input
264
+ this.messageInput.addEventListener('focus', () => {
265
+ this.clearWelcomeMessage();
266
+ }, { once: true });
267
+
268
+ // Conversation history panel
269
+ const conversationHistoryBtn = document.getElementById('conversationHistoryBtn');
270
+ const closeConversationsBtn = document.getElementById('closeConversationsBtn');
271
+ const newConversationBtn = document.getElementById('newConversationBtn');
272
+
273
+ if (conversationHistoryBtn) {
274
+ conversationHistoryBtn.addEventListener('click', () => {
275
+ this.openConversationPanel();
276
+ });
277
+ }
278
+
279
+ if (closeConversationsBtn) {
280
+ closeConversationsBtn.addEventListener('click', () => {
281
+ this.closeConversationPanel();
282
+ });
283
+ }
284
+
285
+ if (newConversationBtn) {
286
+ newConversationBtn.addEventListener('click', () => {
287
+ this.startNewConversation();
288
+ });
289
+ }
290
+
291
+ // Conversation search functionality
292
+ if (this.searchConversations) {
293
+ this.searchConversations.addEventListener('input', () => {
294
+ this.filterConversations(this.searchConversations.value);
295
+ });
296
+ }
297
+
298
+ // Export/Import functionality
299
+ if (this.exportConversationsBtn) {
300
+ this.exportConversationsBtn.addEventListener('click', () => {
301
+ this.exportConversationsToFile();
302
+ });
303
+ }
304
+
305
+ if (this.importConversations) {
306
+ this.importConversations.addEventListener('change', (e) => {
307
+ this.importConversationsFromFile(e.target.files[0]);
308
+ });
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Handle swipe gestures for mobile
314
+ */
315
+ handleSwipeGesture() {
316
+ const swipeThreshold = 100;
317
+
318
+ // Right to left swipe (open conversation panel)
319
+ if (this.touchEndX < this.touchStartX - swipeThreshold) {
320
+ if (!this.conversationHistoryPanel.classList.contains('show')) {
321
+ this.openConversationPanel();
322
+ }
323
+ }
324
+
325
+ // Left to right swipe (close conversation panel)
326
+ if (this.touchEndX > this.touchStartX + swipeThreshold) {
327
+ if (this.conversationHistoryPanel.classList.contains('show')) {
328
+ this.closeConversationPanel();
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Open conversation panel with focus management
335
+ */
336
+ openConversationPanel() {
337
+ this.updateConversationList();
338
+ this.conversationHistoryPanel.classList.add('show');
339
+ this.conversationHistoryPanel.setAttribute('aria-hidden', 'false');
340
+
341
+ // Store last focused element to restore focus when closing
342
+ this.lastFocusedElement = document.activeElement;
343
+
344
+ // Focus the close button
345
+ const closeBtn = document.getElementById('closeConversationsBtn');
346
+ if (closeBtn) {
347
+ setTimeout(() => closeBtn.focus(), 100);
348
+ }
349
+
350
+ // Hide the swipe indicator
351
+ if (this.swipeIndicator) {
352
+ this.swipeIndicator.style.display = 'none';
353
+ }
354
+
355
+ // Add class to the body for potential styling
356
+ document.body.classList.add('panel-open');
357
+ }
358
+
359
+ /**
360
+ * Close conversation panel with focus management
361
+ */
362
+ closeConversationPanel() {
363
+ this.conversationHistoryPanel.classList.remove('show');
364
+ this.conversationHistoryPanel.setAttribute('aria-hidden', 'true');
365
+
366
+ // Restore focus to previous element
367
+ if (this.lastFocusedElement) {
368
+ this.lastFocusedElement.focus();
369
+ }
370
+
371
+ document.body.classList.remove('panel-open');
372
+ }
373
+
374
+ /**
375
+ * Close source document panel
376
+ */
377
+ closeSourcePanel() {
378
+ if (this.sourcePanel) {
379
+ this.sourcePanel.classList.remove('show');
380
+ document.body.classList.remove('source-panel-open');
381
+
382
+ // Restore focus
383
+ if (this.lastFocusedElement) {
384
+ this.lastFocusedElement.focus();
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Update the conversation list in the side panel
391
+ */
392
+ updateConversationList() {
393
+ const conversationList = document.getElementById('conversationList');
394
+ if (!conversationList) return;
395
+
396
+ // Clear current list
397
+ conversationList.innerHTML = '';
398
+
399
+ // Get conversations from storage
400
+ const conversations = this.getConversationsFromStorage();
401
+
402
+ if (conversations.length === 0) {
403
+ const emptyState = document.createElement('div');
404
+ emptyState.className = 'empty-state';
405
+ emptyState.innerHTML = '<p>No conversation history found.</p>';
406
+ conversationList.appendChild(emptyState);
407
+ return;
408
+ }
409
+
410
+ // Sort conversations by last updated (newest first)
411
+ conversations.sort((a, b) => {
412
+ const dateA = new Date(a.metadata.last_updated);
413
+ const dateB = new Date(b.metadata.last_updated);
414
+ return dateB - dateA;
415
+ });
416
+
417
+ // Add each conversation to the list
418
+ conversations.forEach(conversation => {
419
+ const item = document.createElement('div');
420
+ item.className = 'conversation-item';
421
+ item.setAttribute('role', 'listitem');
422
+ item.setAttribute('tabindex', '0');
423
+ item.setAttribute('aria-label', `${conversation.title || 'Untitled Conversation'}, ${conversation.metadata.message_count} messages, last updated ${this.formatDateTime(conversation.metadata.last_updated)}`);
424
+
425
+ if (conversation.id === this.conversationId) {
426
+ item.classList.add('active');
427
+ item.setAttribute('aria-current', 'true');
428
+ }
429
+
430
+ const title = document.createElement('h4');
431
+ title.textContent = conversation.title || 'Untitled Conversation';
432
+
433
+ const meta = document.createElement('div');
434
+ meta.className = 'conversation-meta';
435
+
436
+ const date = new Date(conversation.metadata.last_updated);
437
+ const formattedDate = date.toLocaleDateString(undefined, {
438
+ year: 'numeric',
439
+ month: 'short',
440
+ day: 'numeric'
441
+ });
442
+
443
+ meta.textContent = `${conversation.metadata.message_count} messages · ${formattedDate}`;
444
+
445
+ const actions = document.createElement('div');
446
+ actions.className = 'conversation-actions';
447
+
448
+ const deleteBtn = document.createElement('button');
449
+ deleteBtn.className = 'icon-button small';
450
+ deleteBtn.setAttribute('aria-label', `Delete conversation: ${conversation.title || 'Untitled Conversation'}`);
451
+ deleteBtn.innerHTML = `
452
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
453
+ <path d="M3 6h18"></path>
454
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
455
+ </svg>
456
+ `;
457
+ deleteBtn.title = 'Delete conversation';
458
+ deleteBtn.addEventListener('click', (e) => {
459
+ e.stopPropagation();
460
+ this.deleteConversation(conversation.id);
461
+ });
462
+
463
+ actions.appendChild(deleteBtn);
464
+
465
+ item.appendChild(title);
466
+ item.appendChild(meta);
467
+ item.appendChild(actions);
468
+
469
+ // Add click event to load this conversation
470
+ item.addEventListener('click', () => {
471
+ this.loadConversation(conversation.id);
472
+ this.closeConversationPanel();
473
+ });
474
+
475
+ // Add keyboard support
476
+ item.addEventListener('keydown', (e) => {
477
+ if (e.key === 'Enter' || e.key === ' ') {
478
+ e.preventDefault();
479
+ this.loadConversation(conversation.id);
480
+ this.closeConversationPanel();
481
+ }
482
+ });
483
+
484
+ conversationList.appendChild(item);
485
+ });
486
+ }
487
+
488
+ /**
489
+ * Filter conversations based on search query
490
+ */
491
+ filterConversations(query) {
492
+ if (!query) {
493
+ this.updateConversationList();
494
+ return;
495
+ }
496
+
497
+ query = query.toLowerCase();
498
+ const conversationList = document.getElementById('conversationList');
499
+ if (!conversationList) return;
500
+
501
+ // Clear current list
502
+ conversationList.innerHTML = '';
503
+
504
+ // Get conversations from storage
505
+ const conversations = this.getConversationsFromStorage();
506
+
507
+ // Filter conversations
508
+ const filteredConversations = conversations.filter(conv => {
509
+ // Search in title
510
+ if (conv.title && conv.title.toLowerCase().includes(query)) {
511
+ return true;
512
+ }
513
+
514
+ // Search in messages
515
+ if (conv.messages && conv.messages.some(msg =>
516
+ msg.content && msg.content.toLowerCase().includes(query))) {
517
+ return true;
518
+ }
519
+
520
+ return false;
521
+ });
522
+
523
+ if (filteredConversations.length === 0) {
524
+ const emptyState = document.createElement('div');
525
+ emptyState.className = 'empty-state';
526
+ emptyState.innerHTML = `<p>No conversations matching "${query}"</p>`;
527
+ conversationList.appendChild(emptyState);
528
+ return;
529
+ }
530
+
531
+ // Sort and display filtered conversations
532
+ filteredConversations.sort((a, b) => {
533
+ const dateA = new Date(a.metadata.last_updated);
534
+ const dateB = new Date(b.metadata.last_updated);
535
+ return dateB - dateA;
536
+ });
537
+
538
+ // Add each filtered conversation to the list
539
+ filteredConversations.forEach(conversation => {
540
+ const item = document.createElement('div');
541
+ item.className = 'conversation-item';
542
+ item.setAttribute('role', 'listitem');
543
+ item.setAttribute('tabindex', '0');
544
+
545
+ if (conversation.id === this.conversationId) {
546
+ item.classList.add('active');
547
+ item.setAttribute('aria-current', 'true');
548
+ }
549
+
550
+ const title = document.createElement('h4');
551
+ title.textContent = conversation.title || 'Untitled Conversation';
552
+
553
+ const meta = document.createElement('div');
554
+ meta.className = 'conversation-meta';
555
+
556
+ const date = new Date(conversation.metadata.last_updated);
557
+ const formattedDate = this.formatDateTime(conversation.metadata.last_updated);
558
+
559
+ meta.textContent = `${conversation.metadata.message_count} messages · ${formattedDate}`;
560
+
561
+ item.appendChild(title);
562
+ item.appendChild(meta);
563
+
564
+ // Add click event to load this conversation
565
+ item.addEventListener('click', () => {
566
+ this.loadConversation(conversation.id);
567
+ this.closeConversationPanel();
568
+ });
569
+
570
+ // Add keyboard support
571
+ item.addEventListener('keydown', (e) => {
572
+ if (e.key === 'Enter' || e.key === ' ') {
573
+ e.preventDefault();
574
+ this.loadConversation(conversation.id);
575
+ this.closeConversationPanel();
576
+ }
577
+ });
578
+
579
+ conversationList.appendChild(item);
580
+ });
581
+ }
582
+
583
+ /**
584
+ * Export conversations to a JSON file
585
+ */
586
+ exportConversationsToFile() {
587
+ try {
588
+ const conversations = this.getConversationsFromStorage();
589
+ if (conversations.length === 0) {
590
+ alert('No conversations to export.');
591
+ return;
592
+ }
593
+
594
+ const dataStr = JSON.stringify(conversations, null, 2);
595
+ const blob = new Blob([dataStr], { type: 'application/json' });
596
+ const url = URL.createObjectURL(blob);
597
+
598
+ const downloadLink = document.createElement('a');
599
+ downloadLink.href = url;
600
+ downloadLink.download = `policywise_conversations_${new Date().toISOString().split('T')[0]}.json`;
601
+
602
+ // Append to the document, click it, then remove it
603
+ document.body.appendChild(downloadLink);
604
+ downloadLink.click();
605
+ document.body.removeChild(downloadLink);
606
+
607
+ // Clean up the URL
608
+ setTimeout(() => URL.revokeObjectURL(url), 100);
609
+
610
+ // Show confirmation
611
+ const statusText = this.statusIndicator.querySelector('.status-text');
612
+ const originalText = statusText.textContent;
613
+ statusText.textContent = 'Conversations exported!';
614
+ setTimeout(() => {
615
+ statusText.textContent = originalText;
616
+ }, 3000);
617
+
618
+ } catch (error) {
619
+ console.error('Failed to export conversations:', error);
620
+ alert('Failed to export conversations. Please try again.');
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Import conversations from a JSON file
626
+ */
627
+ importConversationsFromFile(file) {
628
+ if (!file) return;
629
+
630
+ const reader = new FileReader();
631
+ reader.onload = (event) => {
632
+ try {
633
+ const importedData = JSON.parse(event.target.result);
634
+
635
+ if (!Array.isArray(importedData)) {
636
+ throw new Error('Invalid format: expected an array of conversations');
637
+ }
638
+
639
+ // Validate the structure
640
+ const validConversations = importedData.filter(conv => {
641
+ return (
642
+ conv.id &&
643
+ conv.messages &&
644
+ Array.isArray(conv.messages) &&
645
+ conv.metadata
646
+ );
647
+ });
648
+
649
+ if (validConversations.length === 0) {
650
+ throw new Error('No valid conversations found in the file');
651
+ }
652
+
653
+ // Merge with existing conversations
654
+ const existingConversations = this.getConversationsFromStorage();
655
+ const existingIds = new Set(existingConversations.map(c => c.id));
656
+
657
+ // Add only conversations that don't exist
658
+ const newConversations = validConversations.filter(c => !existingIds.has(c.id));
659
+ const mergedConversations = [...existingConversations, ...newConversations];
660
+
661
+ // Save to localStorage
662
+ this.saveConversationsToStorage(mergedConversations);
663
+
664
+ // Update UI
665
+ this.updateConversationList();
666
+
667
+ // Show confirmation with count of imported conversations
668
+ alert(`Successfully imported ${newConversations.length} conversation(s).`);
669
+
670
+ } catch (error) {
671
+ console.error('Failed to import conversations:', error);
672
+ alert(`Failed to import conversations: ${error.message}`);
673
+ }
674
+ };
675
+
676
+ reader.readAsText(file);
677
+ }
678
+
679
+ /**
680
+ * Start a new conversation
681
+ */
682
+ startNewConversation() {
683
+ // Generate new ID
684
+ this.conversationId = this.generateConversationId();
685
+
686
+ // Clear messages
687
+ this.messages = [];
688
+ this.messagesContainer.innerHTML = '';
689
+
690
+ // Add welcome message
691
+ this.addWelcomeMessage();
692
+
693
+ // Update URL without reloading
694
+ const url = new URL(window.location.href);
695
+ url.searchParams.set('conversation_id', this.conversationId);
696
+ window.history.pushState({}, '', url);
697
+
698
+ // Close conversation panel
699
+ document.getElementById('conversationHistoryPanel').classList.remove('show');
700
+
701
+ // Focus input
702
+ this.focusInput();
703
+ }
704
+
705
+ /**
706
+ * Load a conversation by ID
707
+ */
708
+ loadConversation(conversationId) {
709
+ const conversations = this.getConversationsFromStorage();
710
+ const conversation = conversations.find(c => c.id === conversationId);
711
+
712
+ if (conversation) {
713
+ // Update current conversation ID
714
+ this.conversationId = conversationId;
715
+
716
+ // Update URL without reloading
717
+ const url = new URL(window.location.href);
718
+ url.searchParams.set('conversation_id', this.conversationId);
719
+ window.history.pushState({}, '', url);
720
+
721
+ // Clear current messages
722
+ this.messages = [];
723
+ this.messagesContainer.innerHTML = '';
724
+
725
+ // Load messages
726
+ conversation.messages.forEach(msg => {
727
+ this.addMessage(msg.content, msg.sender, msg.metadata, false);
728
+ });
729
+
730
+ // Add messages to memory
731
+ this.messages = [...conversation.messages];
732
+
733
+ // Update conversation metadata
734
+ conversation.metadata.last_accessed = new Date().toISOString();
735
+ this.saveConversationsToStorage(conversations);
736
+
737
+ // Focus input
738
+ this.focusInput();
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Delete a conversation by ID
744
+ */
745
+ deleteConversation(conversationId) {
746
+ if (confirm('Are you sure you want to delete this conversation? This cannot be undone.')) {
747
+ const conversations = this.getConversationsFromStorage();
748
+ const updatedConversations = conversations.filter(c => c.id !== conversationId);
749
+ this.saveConversationsToStorage(updatedConversations);
750
+
751
+ // If we deleted the current conversation, start a new one
752
+ if (conversationId === this.conversationId) {
753
+ this.startNewConversation();
754
+ }
755
+
756
+ // Update the conversation list
757
+ this.updateConversationList();
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Add welcome message to the UI
763
+ */
764
+ addWelcomeMessage() {
765
+ const welcomeDiv = document.createElement('div');
766
+ welcomeDiv.className = 'welcome-message';
767
+ welcomeDiv.innerHTML = `
768
+ <div class="welcome-icon">🤖</div>
769
+ <h2>Welcome to PolicyWise!</h2>
770
+ <p>I'm here to help you find information about company policies and procedures. Ask me anything about:</p>
771
+ <ul class="policy-topics">
772
+ <li>Remote work policies</li>
773
+ <li>PTO and leave policies</li>
774
+ <li>Expense reimbursement</li>
775
+ <li>Information security</li>
776
+ <li>Employee benefits</li>
777
+ <li>And much more...</li>
778
+ </ul>
779
+ `;
780
+ this.messagesContainer.appendChild(welcomeDiv);
781
+ }
782
+
783
+ /**
784
+ * Generate a unique conversation ID
785
+ */
786
+ generateConversationId() {
787
+ return 'conv_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
788
+ }
789
+
790
+ /**
791
+ * Load existing conversation or create new one
792
+ */
793
+ loadOrCreateConversation() {
794
+ // Check URL parameters for conversation ID
795
+ const urlParams = new URLSearchParams(window.location.search);
796
+ const conversationId = urlParams.get('conversation_id');
797
+
798
+ if (conversationId) {
799
+ // If this conversation exists in localStorage, use it
800
+ const conversations = this.getConversationsFromStorage();
801
+ if (conversations.some(conv => conv.id === conversationId)) {
802
+ return conversationId;
803
+ }
804
+ }
805
+
806
+ // Otherwise generate a new ID
807
+ return this.generateConversationId();
808
+ }
809
+
810
+ /**
811
+ * Load conversation history from localStorage
812
+ */
813
+ loadConversationHistory() {
814
+ const conversations = this.getConversationsFromStorage();
815
+ const conversation = conversations.find(conv => conv.id === this.conversationId);
816
+
817
+ if (conversation && conversation.messages && conversation.messages.length > 0) {
818
+ // Clear welcome message if we're loading history
819
+ this.clearWelcomeMessage();
820
+
821
+ // Load messages
822
+ conversation.messages.forEach(msg => {
823
+ this.addMessage(msg.content, msg.sender, msg.metadata, false);
824
+ });
825
+
826
+ // Update conversation metadata
827
+ conversation.metadata.last_accessed = new Date().toISOString();
828
+ this.saveConversationsToStorage(conversations);
829
+ }
830
+ }
831
+
832
+ /**
833
+ * Get conversations from localStorage
834
+ */
835
+ getConversationsFromStorage() {
836
+ try {
837
+ return JSON.parse(localStorage.getItem('policywise_conversations') || '[]');
838
+ } catch (e) {
839
+ console.error('Failed to parse conversations from localStorage:', e);
840
+ return [];
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Save conversations to localStorage
846
+ */
847
+ saveConversationsToStorage(conversations) {
848
+ try {
849
+ localStorage.setItem('policywise_conversations', JSON.stringify(conversations));
850
+ } catch (e) {
851
+ console.error('Failed to save conversations to localStorage:', e);
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Save current conversation to localStorage
857
+ */
858
+ saveCurrentConversation() {
859
+ const conversations = this.getConversationsFromStorage();
860
+ const now = new Date().toISOString();
861
+
862
+ // Find existing conversation or create new one
863
+ let conversation = conversations.find(conv => conv.id === this.conversationId);
864
+
865
+ if (!conversation) {
866
+ // Create new conversation
867
+ conversation = {
868
+ id: this.conversationId,
869
+ title: this.getConversationTitle(),
870
+ messages: this.messages,
871
+ metadata: {
872
+ created_at: now,
873
+ last_updated: now,
874
+ last_accessed: now,
875
+ message_count: this.messages.length
876
+ }
877
+ };
878
+ conversations.push(conversation);
879
+ } else {
880
+ // Update existing conversation
881
+ conversation.messages = this.messages;
882
+ conversation.title = this.getConversationTitle();
883
+ conversation.metadata.last_updated = now;
884
+ conversation.metadata.message_count = this.messages.length;
885
+ }
886
+
887
+ this.saveConversationsToStorage(conversations);
888
+ }
889
+
890
+ /**
891
+ * Generate a title for the conversation based on first user message
892
+ */
893
+ getConversationTitle() {
894
+ const firstUserMessage = this.messages.find(msg => msg.sender === 'user');
895
+ if (firstUserMessage) {
896
+ // Truncate to reasonable title length
897
+ const title = firstUserMessage.content.trim();
898
+ return title.length > 50 ? title.substring(0, 50) + '...' : title;
899
+ }
900
+ return 'New Conversation';
901
+ }
902
+
903
+ /**
904
+ * Update character count and styling
905
+ */
906
+ updateCharCount() {
907
+ const count = this.messageInput.value.length;
908
+ this.charCount.textContent = count;
909
+
910
+ const counter = this.charCount.parentElement;
911
+ counter.classList.remove('warning', 'error');
912
+
913
+ if (count > 900) {
914
+ counter.classList.add('error');
915
+ } else if (count > 800) {
916
+ counter.classList.add('warning');
917
+ }
918
+ }
919
+
920
+ /**
921
+ * Auto-resize textarea based on content
922
+ */
923
+ autoResizeTextarea() {
924
+ this.messageInput.style.height = 'auto';
925
+ this.messageInput.style.height = Math.min(this.messageInput.scrollHeight, 120) + 'px';
926
+ }
927
+
928
+ /**
929
+ * Update send button state
930
+ */
931
+ updateSendButton() {
932
+ const hasText = this.messageInput.value.trim().length > 0;
933
+ this.sendButton.disabled = !hasText || this.isLoading;
934
+ }
935
+
936
+ /**
937
+ * Focus the input field
938
+ */
939
+ focusInput() {
940
+ this.messageInput.focus();
941
+ }
942
+
943
+ /**
944
+ * Check system health status
945
+ */
946
+ async checkSystemHealth() {
947
+ try {
948
+ const response = await fetch('/chat/health');
949
+ const data = await response.json();
950
+
951
+ if (data.status === 'healthy') {
952
+ this.updateStatus('Ready', 'ready');
953
+ } else {
954
+ this.updateStatus('Degraded', 'warning');
955
+ }
956
+ } catch (error) {
957
+ console.warn('Health check failed:', error);
958
+ this.updateStatus('Offline', 'error');
959
+ }
960
+ }
961
+
962
+ /**
963
+ * Update status indicator
964
+ */
965
+ updateStatus(text, type) {
966
+ const statusText = this.statusIndicator.querySelector('.status-text');
967
+ const statusDot = this.statusIndicator.querySelector('.status-dot');
968
+
969
+ statusText.textContent = text;
970
+
971
+ // Remove existing status classes
972
+ statusDot.classList.remove('ready', 'warning', 'error', 'loading');
973
+ statusDot.classList.add(type);
974
+ }
975
+
976
+ /**
977
+ * Clear the welcome message
978
+ */
979
+ clearWelcomeMessage() {
980
+ const welcomeMessage = this.messagesContainer.querySelector('.welcome-message');
981
+ if (welcomeMessage) {
982
+ welcomeMessage.style.display = 'none';
983
+ }
984
+ }
985
+
986
+ /**
987
+ * Send a message to the chat API
988
+ */
989
+ async sendMessage() {
990
+ const message = this.messageInput.value.trim();
991
+ if (!message || this.isLoading) return;
992
+
993
+ // Add user message to chat
994
+ this.addMessage(message, 'user');
995
+
996
+ // Clear input and reset
997
+ this.messageInput.value = '';
998
+ this.updateCharCount();
999
+ this.autoResizeTextarea();
1000
+ this.updateSendButton();
1001
+
1002
+ // Send the message to the API
1003
+ await this.sendMessageToAPI(message);
1004
+ }
1005
+
1006
+ /**
1007
+ * Send a message to the chat API with enhanced error handling
1008
+ */
1009
+ async sendMessageToAPI(message) {
1010
+ if (this.isLoading) return;
1011
+
1012
+ // Show loading state
1013
+ this.setLoading(true);
1014
+
1015
+ try {
1016
+ const requestData = {
1017
+ message: message,
1018
+ conversation_id: this.conversationId,
1019
+ include_sources: this.includeSources.checked,
1020
+ include_debug: false // Set to true for debugging
1021
+ };
1022
+
1023
+ // Set timeout for the request (30 seconds)
1024
+ const controller = new AbortController();
1025
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
1026
+
1027
+ const response = await fetch('/chat', {
1028
+ method: 'POST',
1029
+ headers: {
1030
+ 'Content-Type': 'application/json',
1031
+ },
1032
+ body: JSON.stringify(requestData),
1033
+ signal: controller.signal
1034
+ });
1035
+
1036
+ clearTimeout(timeoutId);
1037
+
1038
+ const data = await response.json();
1039
+
1040
+ if (response.ok && data.status === 'success') {
1041
+ // Reset auto retry count on success
1042
+ this.autoRetryCount = 0;
1043
+
1044
+ this.addMessage(data.answer || data.response, 'assistant', {
1045
+ sources: data.sources,
1046
+ citations: data.citations,
1047
+ confidence: data.confidence,
1048
+ processing_time: data.processing_time_ms,
1049
+ timestamp: new Date().toISOString()
1050
+ });
1051
+ } else {
1052
+ // Handle API error
1053
+ const errorInfo = {
1054
+ status: response.status,
1055
+ message: data.message || 'Unknown error'
1056
+ };
1057
+
1058
+ let errorMessage = 'An error occurred while processing your request.';
1059
+ let canRetry = true;
1060
+
1061
+ // Customize message based on status code
1062
+ if (response.status === 400) {
1063
+ errorMessage = 'Invalid request. Please modify your query and try again.';
1064
+ canRetry = false;
1065
+ } else if (response.status === 401 || response.status === 403) {
1066
+ errorMessage = 'You are not authorized to perform this action.';
1067
+ canRetry = false;
1068
+ } else if (response.status === 404) {
1069
+ errorMessage = 'The requested resource could not be found.';
1070
+ } else if (response.status === 429) {
1071
+ errorMessage = 'You\'ve sent too many requests. Please wait a moment and try again.';
1072
+ canRetry = true;
1073
+ } else if (response.status >= 500) {
1074
+ errorMessage = 'The server encountered an error. We\'ll automatically retry shortly.';
1075
+ canRetry = true;
1076
+ }
1077
+
1078
+ if (data.message) {
1079
+ errorMessage += ` Details: ${data.message}`;
1080
+ }
1081
+
1082
+ this.addErrorMessage(errorMessage, errorInfo, canRetry);
1083
+ }
1084
+
1085
+ } catch (error) {
1086
+ console.error('Chat request failed:', error);
1087
+
1088
+ const errorInfo = {
1089
+ code: error.name,
1090
+ message: error.message
1091
+ };
1092
+
1093
+ let errorMessage = 'Failed to connect to the server.';
1094
+
1095
+ if (error.name === 'AbortError') {
1096
+ errorMessage = 'The request took too long and was cancelled. The server might be experiencing high load.';
1097
+ } else if (error.name === 'TypeError' && error.message.includes('NetworkError')) {
1098
+ errorMessage = 'Network error. Please check your internet connection and try again.';
1099
+ } else if (error.name === 'SyntaxError') {
1100
+ errorMessage = 'Received an invalid response from the server.';
1101
+ }
1102
+
1103
+ this.addErrorMessage(errorMessage, errorInfo, true);
1104
+ } finally {
1105
+ this.setLoading(false);
1106
+ this.focusInput();
1107
+ }
1108
+ }
1109
+
1110
+ /**
1111
+ * Set loading state
1112
+ */
1113
+ setLoading(loading) {
1114
+ this.isLoading = loading;
1115
+
1116
+ if (loading) {
1117
+ this.loadingOverlay.classList.remove('hidden');
1118
+ this.updateStatus('Processing...', 'loading');
1119
+ } else {
1120
+ this.loadingOverlay.classList.add('hidden');
1121
+ this.updateStatus('Ready', 'ready');
1122
+ }
1123
+
1124
+ this.updateSendButton();
1125
+ }
1126
+
1127
+ /**
1128
+ * Add a message to the chat interface
1129
+ */
1130
+ addMessage(text, sender, metadata = {}, save = true) {
1131
+ const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
1132
+ const timestamp = metadata.timestamp || new Date().toISOString();
1133
+
1134
+ // Create message element
1135
+ const messageDiv = document.createElement('div');
1136
+ messageDiv.className = `message message-${sender}`;
1137
+ messageDiv.dataset.messageId = messageId;
1138
+
1139
+ // Add header with timestamp
1140
+ const messageHeader = document.createElement('div');
1141
+ messageHeader.className = 'message-header';
1142
+
1143
+ const senderLabel = document.createElement('span');
1144
+ senderLabel.className = 'sender-label';
1145
+ senderLabel.textContent = sender === 'user' ? 'You' : 'PolicyWise';
1146
+
1147
+ const timestampSpan = document.createElement('span');
1148
+ timestampSpan.className = 'message-timestamp';
1149
+ timestampSpan.setAttribute('aria-label', `Sent ${this.formatDateTime(timestamp)}`);
1150
+ timestampSpan.textContent = this.formatDateTime(timestamp);
1151
+
1152
+ messageHeader.appendChild(senderLabel);
1153
+ messageHeader.appendChild(timestampSpan);
1154
+ messageDiv.appendChild(messageHeader);
1155
+
1156
+ const contentDiv = document.createElement('div');
1157
+ contentDiv.className = 'message-content';
1158
+
1159
+ const textDiv = document.createElement('div');
1160
+ textDiv.className = 'message-text';
1161
+ textDiv.textContent = text;
1162
+
1163
+ contentDiv.appendChild(textDiv);
1164
+
1165
+ // Add sources and citations for assistant messages
1166
+ if (sender === 'assistant' && metadata.sources && this.includeSources.checked) {
1167
+ this.addSourcesToMessage(contentDiv, metadata);
1168
+ }
1169
+
1170
+ // Add confidence score for assistant messages
1171
+ if (sender === 'assistant' && metadata.confidence !== undefined) {
1172
+ this.addConfidenceScore(contentDiv, metadata.confidence);
1173
+ }
1174
+
1175
+ // Add feedback controls for assistant messages
1176
+ if (sender === 'assistant' && save) {
1177
+ this.addFeedbackControls(contentDiv, messageId);
1178
+ }
1179
+
1180
+ messageDiv.appendChild(contentDiv);
1181
+ this.messagesContainer.appendChild(messageDiv);
1182
+
1183
+ // Store message in memory
1184
+ if (save) {
1185
+ this.messages.push({
1186
+ id: messageId,
1187
+ sender,
1188
+ content: text,
1189
+ timestamp,
1190
+ metadata: {...metadata}
1191
+ });
1192
+
1193
+ // Save to localStorage
1194
+ this.saveCurrentConversation();
1195
+ }
1196
+
1197
+ // Scroll to bottom
1198
+ this.scrollToBottom();
1199
+ }
1200
+
1201
+ /**
1202
+ * Add feedback controls to assistant messages
1203
+ */
1204
+ addFeedbackControls(contentDiv, messageId) {
1205
+ const feedbackDiv = document.createElement('div');
1206
+ feedbackDiv.className = 'message-feedback';
1207
+
1208
+ const helpfulBtn = document.createElement('button');
1209
+ helpfulBtn.className = 'feedback-btn';
1210
+ helpfulBtn.title = 'Helpful';
1211
+ helpfulBtn.innerHTML = `
1212
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1213
+ <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path>
1214
+ </svg>
1215
+ <span>Helpful</span>
1216
+ `;
1217
+
1218
+ const unhelpfulBtn = document.createElement('button');
1219
+ unhelpfulBtn.className = 'feedback-btn';
1220
+ unhelpfulBtn.title = 'Not helpful';
1221
+ unhelpfulBtn.innerHTML = `
1222
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1223
+ <path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path>
1224
+ </svg>
1225
+ <span>Not helpful</span>
1226
+ `;
1227
+
1228
+ // Add event listeners
1229
+ helpfulBtn.addEventListener('click', () => {
1230
+ this.submitFeedback(messageId, true);
1231
+ feedbackDiv.innerHTML = '<div class="feedback-thanks">Thanks for your feedback!</div>';
1232
+ });
1233
+
1234
+ unhelpfulBtn.addEventListener('click', () => {
1235
+ this.submitFeedback(messageId, false);
1236
+
1237
+ // Replace with detailed feedback form
1238
+ feedbackDiv.innerHTML = `
1239
+ <div class="feedback-form">
1240
+ <p>What was the issue with this response?</p>
1241
+ <select id="feedback-reason-${messageId}" class="feedback-select">
1242
+ <option value="inaccurate">Information is inaccurate</option>
1243
+ <option value="incomplete">Information is incomplete</option>
1244
+ <option value="irrelevant">Response is irrelevant</option>
1245
+ <option value="sources">Sources are missing or incorrect</option>
1246
+ <option value="other">Other issue</option>
1247
+ </select>
1248
+ <textarea id="feedback-detail-${messageId}" class="feedback-textarea"
1249
+ placeholder="Optional: Provide more details about the issue"></textarea>
1250
+ <div class="feedback-actions">
1251
+ <button class="primary-button submit-feedback-btn">Submit</button>
1252
+ </div>
1253
+ </div>
1254
+ `;
1255
+
1256
+ // Add event listener to the new submit button
1257
+ const submitBtn = feedbackDiv.querySelector('.submit-feedback-btn');
1258
+ submitBtn.addEventListener('click', () => {
1259
+ const reason = document.getElementById(`feedback-reason-${messageId}`).value;
1260
+ const detail = document.getElementById(`feedback-detail-${messageId}`).value;
1261
+
1262
+ this.submitDetailedFeedback(messageId, reason, detail);
1263
+ feedbackDiv.innerHTML = '<div class="feedback-thanks">Thanks for your detailed feedback!</div>';
1264
+ });
1265
+ });
1266
+
1267
+ feedbackDiv.appendChild(helpfulBtn);
1268
+ feedbackDiv.appendChild(unhelpfulBtn);
1269
+
1270
+ contentDiv.appendChild(feedbackDiv);
1271
+ }
1272
+
1273
+ /**
1274
+ * Add sources and citations to a message
1275
+ */
1276
+ addSourcesToMessage(contentDiv, metadata) {
1277
+ if (!metadata.sources || metadata.sources.length === 0) return;
1278
+
1279
+ const sourcesDiv = document.createElement('div');
1280
+ sourcesDiv.className = 'message-sources';
1281
+ sourcesDiv.setAttribute('aria-label', 'Source documents');
1282
+
1283
+ const headerDiv = document.createElement('div');
1284
+ headerDiv.className = 'sources-header';
1285
+ headerDiv.textContent = 'Sources:';
1286
+ sourcesDiv.appendChild(headerDiv);
1287
+
1288
+ metadata.sources.forEach(source => {
1289
+ const citationDiv = document.createElement('div');
1290
+ citationDiv.className = 'source-citation';
1291
+
1292
+ const iconSvg = document.createElement('svg');
1293
+ iconSvg.className = 'source-icon';
1294
+ iconSvg.innerHTML = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14,2 14,8 20,8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10,9 9,9 8,9"></polyline>';
1295
+ iconSvg.setAttribute('width', '16');
1296
+ iconSvg.setAttribute('height', '16');
1297
+ iconSvg.setAttribute('viewBox', '0 0 24 24');
1298
+ iconSvg.setAttribute('fill', 'none');
1299
+ iconSvg.setAttribute('stroke', 'currentColor');
1300
+ iconSvg.setAttribute('stroke-width', '2');
1301
+ iconSvg.setAttribute('aria-hidden', 'true');
1302
+
1303
+ const textSpan = document.createElement('span');
1304
+ textSpan.textContent = source.document || source.title || source.chunk_id || 'Unknown source';
1305
+
1306
+ // Make the source citation clickable to view the full document
1307
+ if (source.id) {
1308
+ citationDiv.classList.add('clickable');
1309
+ citationDiv.setAttribute('role', 'button');
1310
+ citationDiv.setAttribute('tabindex', '0');
1311
+ citationDiv.setAttribute('aria-label', `View source: ${source.document || source.title || 'Source Document'}`);
1312
+ citationDiv.title = 'Click to view full source document';
1313
+ citationDiv.dataset.sourceId = source.id;
1314
+
1315
+ // Add click event
1316
+ citationDiv.addEventListener('click', () => {
1317
+ this.showSourceDocument(source.id, source.document || source.title || 'Source Document');
1318
+ });
1319
+
1320
+ // Add keyboard support
1321
+ citationDiv.addEventListener('keydown', (e) => {
1322
+ if (e.key === 'Enter' || e.key === ' ') {
1323
+ e.preventDefault();
1324
+ this.showSourceDocument(source.id, source.document || source.title || 'Source Document');
1325
+ }
1326
+ });
1327
+ }
1328
+
1329
+ citationDiv.appendChild(iconSvg);
1330
+ citationDiv.appendChild(textSpan);
1331
+ sourcesDiv.appendChild(citationDiv);
1332
+ });
1333
+
1334
+ contentDiv.appendChild(sourcesDiv);
1335
+ }
1336
+
1337
+ /**
1338
+ * Show the full source document in a side panel
1339
+ */
1340
+ async showSourceDocument(sourceId, title) {
1341
+ // Get source panel elements
1342
+ let sourcePanel = document.getElementById('sourcePanel');
1343
+ if (!sourcePanel) {
1344
+ this.initializeSourcePanel();
1345
+ sourcePanel = document.getElementById('sourcePanel');
1346
+ if (!sourcePanel) {
1347
+ console.error('Failed to create source panel');
1348
+ return;
1349
+ }
1350
+ }
1351
+
1352
+ const sourceContent = document.getElementById('sourceContent');
1353
+ const closeBtn = document.getElementById('closeSourcePanel');
1354
+
1355
+ // Store last focused element for accessibility
1356
+ this.lastFocusedElement = document.activeElement;
1357
+
1358
+ // Clear existing content
1359
+ sourceContent.innerHTML = '<div class="loading-spinner" role="status" aria-label="Loading source document"></div><p>Loading source document...</p>';
1360
+
1361
+ // Show the panel
1362
+ sourcePanel.classList.add('show');
1363
+ sourcePanel.setAttribute('aria-hidden', 'false');
1364
+ document.body.classList.add('source-panel-open');
1365
+
1366
+ // Set up close button
1367
+ if (closeBtn) {
1368
+ closeBtn.addEventListener('click', () => {
1369
+ this.closeSourcePanel();
1370
+ }, { once: true });
1371
+
1372
+ // Focus the close button for keyboard accessibility
1373
+ setTimeout(() => closeBtn.focus(), 100);
1374
+ }
1375
+
1376
+ try {
1377
+ // Set timeout for the request (15 seconds)
1378
+ const controller = new AbortController();
1379
+ const timeoutId = setTimeout(() => controller.abort(), 15000);
1380
+
1381
+ // Fetch source document content
1382
+ const response = await fetch(`/chat/source/${sourceId}`, {
1383
+ signal: controller.signal
1384
+ });
1385
+
1386
+ clearTimeout(timeoutId);
1387
+ const data = await response.json();
1388
+
1389
+ if (response.ok && data.status === 'success') {
1390
+ // Create content elements
1391
+ const documentTitle = data.metadata?.filename || data.metadata?.title || title;
1392
+ const contentHTML = `
1393
+ <h3 id="sourceTitle">${documentTitle}</h3>
1394
+ <div class="metadata" aria-label="Document metadata">
1395
+ ${data.metadata?.last_updated ?
1396
+ `<div class="metadata-item"><span>Last Updated:</span> ${data.metadata.last_updated}</div>` : ''}
1397
+ ${data.metadata?.author ?
1398
+ `<div class="metadata-item"><span>Author:</span> ${data.metadata.author}</div>` : ''}
1399
+ ${data.metadata?.department ?
1400
+ `<div class="metadata-item"><span>Department:</span> ${data.metadata.department}</div>` : ''}
1401
+ </div>
1402
+ <div class="document-content" tabindex="0">
1403
+ ${this.formatDocumentContent(data.content)}
1404
+ </div>
1405
+ `;
1406
+
1407
+ sourceContent.innerHTML = contentHTML;
1408
+
1409
+ // Update ARIA labels
1410
+ sourcePanel.setAttribute('aria-label', `Source document: ${documentTitle}`);
1411
+
1412
+ // Make content keyboard navigable
1413
+ const docContent = sourceContent.querySelector('.document-content');
1414
+ if (docContent) {
1415
+ docContent.addEventListener('keydown', (e) => {
1416
+ const scrollAmount = 100;
1417
+ if (e.key === 'ArrowDown') {
1418
+ e.preventDefault();
1419
+ docContent.scrollTop += scrollAmount;
1420
+ } else if (e.key === 'ArrowUp') {
1421
+ e.preventDefault();
1422
+ docContent.scrollTop -= scrollAmount;
1423
+ }
1424
+ });
1425
+ }
1426
+ } else {
1427
+ // Show error with retry button
1428
+ this.renderSourceError(data.message || 'Failed to load document', sourceId, title, sourceContent);
1429
+ }
1430
+ } catch (error) {
1431
+ console.error('Failed to fetch source document:', error);
1432
+
1433
+ let errorMessage = 'Failed to load the source document.';
1434
+ if (error.name === 'AbortError') {
1435
+ errorMessage = 'The request took too long and was cancelled.';
1436
+ } else if (error.name === 'TypeError' && error.message.includes('NetworkError')) {
1437
+ errorMessage = 'Network error. Please check your internet connection.';
1438
+ }
1439
+
1440
+ this.renderSourceError(`${errorMessage} Please try again later.`, sourceId, title, sourceContent);
1441
+ }
1442
+ }
1443
+
1444
+ /**
1445
+ * Escape HTML characters to prevent XSS
1446
+ */
1447
+ escapeHtml(text) {
1448
+ const div = document.createElement('div');
1449
+ div.textContent = text;
1450
+ return div.innerHTML;
1451
+ }
1452
+
1453
+ /**
1454
+ * Render error message with retry functionality for source documents
1455
+ */
1456
+ renderSourceError(message, sourceId, title, sourceContent) {
1457
+ const errorHtml = `
1458
+ <div class="error-message" role="alert">
1459
+ <strong>Error loading source document</strong>
1460
+ <p>${this.escapeHtml(message)}</p>
1461
+ <button class="retry-button" id="retrySourceLoad" aria-label="Retry loading the source document">
1462
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
1463
+ <polyline points="23 4 23 10 17 10"></polyline>
1464
+ <polyline points="1 20 1 14 7 14"></polyline>
1465
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
1466
+ </svg>
1467
+ Retry
1468
+ </button>
1469
+ </div>
1470
+ `;
1471
+
1472
+ sourceContent.innerHTML = errorHtml;
1473
+
1474
+ // Add retry functionality
1475
+ document.getElementById('retrySourceLoad')?.addEventListener('click', () => {
1476
+ this.showSourceDocument(sourceId, title);
1477
+ });
1478
+ }
1479
+
1480
+ /**
1481
+ * Format document content for display
1482
+ */
1483
+ formatDocumentContent(content) {
1484
+ if (!content) return '<p>No content available</p>';
1485
+
1486
+ // First escape HTML to prevent XSS
1487
+ const escapedContent = this.escapeHtml(content);
1488
+
1489
+ // Check if content is markdown and convert if needed
1490
+ if (content.includes('#') || content.includes('*')) {
1491
+ // Simple markdown formatting on escaped content
1492
+ return escapedContent
1493
+ .replace(/^# (.+)$/gm, '<h1>$1</h1>')
1494
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
1495
+ .replace(/^### (.+)$/gm, '<h3>$1</h3>')
1496
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
1497
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
1498
+ .replace(/\n\n/g, '</p><p>')
1499
+ .replace(/\n/g, '<br>')
1500
+ .replace(/^(.+)$/gm, function(match) {
1501
+ if (!match.startsWith('<')) return '<p>' + match + '</p>';
1502
+ return match;
1503
+ });
1504
+ }
1505
+
1506
+ // If not markdown, wrap escaped content in paragraphs
1507
+ return '<p>' + escapedContent + '</p>';
1508
+ }
1509
+
1510
+ /**
1511
+ * Add confidence score visualization
1512
+ */
1513
+ addConfidenceScore(contentDiv, confidence) {
1514
+ const confidenceDiv = document.createElement('div');
1515
+ confidenceDiv.className = 'confidence-score';
1516
+
1517
+ const labelSpan = document.createElement('span');
1518
+ labelSpan.textContent = `Confidence: ${Math.round(confidence * 100)}%`;
1519
+
1520
+ const barDiv = document.createElement('div');
1521
+ barDiv.className = 'confidence-bar';
1522
+
1523
+ const fillDiv = document.createElement('div');
1524
+ fillDiv.className = 'confidence-fill';
1525
+ fillDiv.style.width = `${confidence * 100}%`;
1526
+
1527
+ barDiv.appendChild(fillDiv);
1528
+ confidenceDiv.appendChild(labelSpan);
1529
+ confidenceDiv.appendChild(barDiv);
1530
+ contentDiv.appendChild(confidenceDiv);
1531
+ }
1532
+
1533
+ /**
1534
+ * Add an error message to the chat with retry options
1535
+ */
1536
+ addErrorMessage(errorText, error = null, canRetry = true) {
1537
+ const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
1538
+ const timestamp = new Date().toISOString();
1539
+
1540
+ const messageDiv = document.createElement('div');
1541
+ messageDiv.className = 'message message-assistant';
1542
+ messageDiv.dataset.messageId = messageId;
1543
+
1544
+ // Add header with timestamp
1545
+ const messageHeader = document.createElement('div');
1546
+ messageHeader.className = 'message-header';
1547
+
1548
+ const senderLabel = document.createElement('span');
1549
+ senderLabel.className = 'sender-label';
1550
+ senderLabel.textContent = 'System';
1551
+
1552
+ const timestampSpan = document.createElement('span');
1553
+ timestampSpan.className = 'message-timestamp';
1554
+ timestampSpan.textContent = this.formatDateTime(timestamp);
1555
+
1556
+ messageHeader.appendChild(senderLabel);
1557
+ messageHeader.appendChild(timestampSpan);
1558
+ messageDiv.appendChild(messageHeader);
1559
+
1560
+ const contentDiv = document.createElement('div');
1561
+ contentDiv.className = 'message-content error-message';
1562
+
1563
+ const strongElement = document.createElement('strong');
1564
+ strongElement.textContent = 'Error:';
1565
+ strongElement.setAttribute('aria-hidden', 'true');
1566
+
1567
+ const textSpan = document.createElement('span');
1568
+ textSpan.textContent = ` ${errorText}`;
1569
+ textSpan.setAttribute('role', 'alert');
1570
+
1571
+ contentDiv.appendChild(strongElement);
1572
+ contentDiv.appendChild(textSpan);
1573
+
1574
+ // Add retry button if applicable
1575
+ if (canRetry) {
1576
+ const retryButton = document.createElement('button');
1577
+ retryButton.className = 'retry-button';
1578
+ retryButton.innerHTML = `
1579
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
1580
+ <polyline points="23 4 23 10 17 10"></polyline>
1581
+ <polyline points="1 20 1 14 7 14"></polyline>
1582
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
1583
+ </svg>
1584
+ Retry
1585
+ `;
1586
+ retryButton.setAttribute('aria-label', 'Retry sending your last message');
1587
+
1588
+ retryButton.addEventListener('click', () => {
1589
+ // Remove the error message
1590
+ messageDiv.remove();
1591
+
1592
+ // Retry the last user message
1593
+ this.retryLastMessage();
1594
+ });
1595
+
1596
+ contentDiv.appendChild(retryButton);
1597
+ }
1598
+
1599
+ // Add more detailed error info if available
1600
+ if (error && (error.status || error.code)) {
1601
+ const detailsDiv = document.createElement('div');
1602
+ detailsDiv.className = 'error-details';
1603
+
1604
+ let detailsText = '';
1605
+ if (error.status) detailsText += `Status code: ${error.status}. `;
1606
+ if (error.code) detailsText += `Error code: ${error.code}. `;
1607
+ if (error.message) detailsText += error.message;
1608
+
1609
+ detailsDiv.textContent = detailsText;
1610
+ contentDiv.appendChild(detailsDiv);
1611
+ }
1612
+
1613
+ messageDiv.appendChild(contentDiv);
1614
+ this.messagesContainer.appendChild(messageDiv);
1615
+
1616
+ // Store in messages array
1617
+ this.messages.push({
1618
+ id: messageId,
1619
+ sender: 'system',
1620
+ content: errorText,
1621
+ timestamp,
1622
+ metadata: { error: true }
1623
+ });
1624
+
1625
+ // Save to localStorage
1626
+ this.saveCurrentConversation();
1627
+
1628
+ this.scrollToBottom();
1629
+
1630
+ // Auto retry for server errors (5xx) if retry count is under the limit
1631
+ if (error && error.status && error.status >= 500 && this.autoRetryCount < this.maxAutoRetries) {
1632
+ this.autoRetryCount++;
1633
+
1634
+ // Show auto-retry status
1635
+ const retryStatus = document.createElement('div');
1636
+ retryStatus.className = 'auto-retry-status';
1637
+ retryStatus.innerHTML = `
1638
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
1639
+ <circle cx="12" cy="12" r="10"></circle>
1640
+ <polyline points="12 6 12 12 16 14"></polyline>
1641
+ </svg>
1642
+ Retrying in <span class="retry-countdown">5</span> seconds...
1643
+ `;
1644
+ contentDiv.appendChild(retryStatus);
1645
+
1646
+ // Countdown timer
1647
+ const countdownEl = retryStatus.querySelector('.retry-countdown');
1648
+ let countdown = 5;
1649
+
1650
+ const countdownInterval = setInterval(() => {
1651
+ countdown--;
1652
+ if (countdownEl) countdownEl.textContent = countdown.toString();
1653
+
1654
+ if (countdown <= 0) {
1655
+ clearInterval(countdownInterval);
1656
+ messageDiv.remove();
1657
+ this.retryLastMessage();
1658
+ }
1659
+ }, 1000);
1660
+ }
1661
+ }
1662
+
1663
+ /**
1664
+ * Retry sending the last user message
1665
+ */
1666
+ retryLastMessage() {
1667
+ // Find the last user message
1668
+ const lastUserMessage = [...this.messages].reverse().find(msg => msg.sender === 'user');
1669
+
1670
+ if (lastUserMessage) {
1671
+ // Reset auto retry count if this is a manual retry
1672
+ this.autoRetryCount = 0;
1673
+
1674
+ // Resend the message
1675
+ this.sendMessageToAPI(lastUserMessage.content);
1676
+ } else {
1677
+ this.addErrorMessage("Couldn't find a previous message to retry.", null, false);
1678
+ }
1679
+ }
1680
+
1681
+ /**
1682
+ * Submit simple feedback (helpful/not helpful)
1683
+ */
1684
+ submitFeedback(messageId, isHelpful) {
1685
+ const message = this.messages.find(msg => msg.id === messageId);
1686
+ if (!message) return;
1687
+
1688
+ // Update message with feedback
1689
+ message.feedback = {
1690
+ rating: isHelpful ? 5 : 1,
1691
+ timestamp: new Date().toISOString()
1692
+ };
1693
+
1694
+ // Save to localStorage
1695
+ this.saveCurrentConversation();
1696
+
1697
+ // Send to server if available
1698
+ this.sendFeedbackToServer(messageId, isHelpful);
1699
+ }
1700
+
1701
+ /**
1702
+ * Submit detailed feedback
1703
+ */
1704
+ submitDetailedFeedback(messageId, reason, detail) {
1705
+ const message = this.messages.find(msg => msg.id === messageId);
1706
+ if (!message) return;
1707
+
1708
+ // Update message with detailed feedback
1709
+ message.feedback = {
1710
+ rating: 1, // Not helpful
1711
+ reason: reason,
1712
+ detail: detail,
1713
+ timestamp: new Date().toISOString()
1714
+ };
1715
+
1716
+ // Save to localStorage
1717
+ this.saveCurrentConversation();
1718
+
1719
+ // Send to server if available
1720
+ this.sendDetailedFeedbackToServer(messageId, reason, detail);
1721
+ }
1722
+
1723
+ /**
1724
+ * Send feedback to server
1725
+ */
1726
+ sendFeedbackToServer(messageId, isHelpful) {
1727
+ try {
1728
+ const feedback = {
1729
+ feedback_id: 'feedback_' + Date.now(),
1730
+ conversation_id: this.conversationId,
1731
+ message_id: messageId,
1732
+ feedback_type: 'response_rating',
1733
+ rating: isHelpful ? 5 : 1,
1734
+ timestamp: new Date().toISOString()
1735
+ };
1736
+
1737
+ fetch('/chat/feedback', {
1738
+ method: 'POST',
1739
+ headers: {
1740
+ 'Content-Type': 'application/json'
1741
+ },
1742
+ body: JSON.stringify(feedback)
1743
+ })
1744
+ .then(response => {
1745
+ if (!response.ok) {
1746
+ console.warn('Failed to send feedback to server:', response.status);
1747
+ }
1748
+ })
1749
+ .catch(error => {
1750
+ console.warn('Error sending feedback to server:', error);
1751
+ });
1752
+ } catch (error) {
1753
+ console.warn('Error preparing feedback:', error);
1754
+ }
1755
+ }
1756
+
1757
+ /**
1758
+ * Send detailed feedback to server
1759
+ */
1760
+ sendDetailedFeedbackToServer(messageId, reason, detail) {
1761
+ try {
1762
+ const feedback = {
1763
+ feedback_id: 'feedback_' + Date.now(),
1764
+ conversation_id: this.conversationId,
1765
+ message_id: messageId,
1766
+ feedback_type: 'detailed',
1767
+ rating: 1,
1768
+ reason: reason,
1769
+ comment: detail,
1770
+ timestamp: new Date().toISOString()
1771
+ };
1772
+
1773
+ fetch('/chat/feedback', {
1774
+ method: 'POST',
1775
+ headers: {
1776
+ 'Content-Type': 'application/json'
1777
+ },
1778
+ body: JSON.stringify(feedback)
1779
+ })
1780
+ .then(response => {
1781
+ if (!response.ok) {
1782
+ console.warn('Failed to send detailed feedback to server:', response.status);
1783
+ }
1784
+ })
1785
+ .catch(error => {
1786
+ console.warn('Error sending detailed feedback to server:', error);
1787
+ });
1788
+ } catch (error) {
1789
+ console.warn('Error preparing detailed feedback:', error);
1790
+ }
1791
+ }
1792
+
1793
+ /**
1794
+ * Scroll to the bottom of the messages container
1795
+ */
1796
+ scrollToBottom() {
1797
+ this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
1798
+ }
1799
+
1800
+ /**
1801
+ * Format ISO datetime string to a user-friendly format
1802
+ */
1803
+ formatDateTime(isoString) {
1804
+ try {
1805
+ const date = new Date(isoString);
1806
+
1807
+ // For messages from today, just show the time
1808
+ const today = new Date();
1809
+ if (date.toDateString() === today.toDateString()) {
1810
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1811
+ }
1812
+
1813
+ // For messages from this year, show month, day and time
1814
+ if (date.getFullYear() === today.getFullYear()) {
1815
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
1816
+ ' at ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1817
+ }
1818
+
1819
+ // For older messages, show full date
1820
+ return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }) +
1821
+ ' at ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1822
+ } catch (e) {
1823
+ console.warn('Invalid timestamp format:', isoString);
1824
+ return 'Unknown time';
1825
+ }
1826
+ }
1827
+ }
1828
+
1829
+ // CSS for additional status states
1830
+ const additionalStyles = `
1831
+ .status-dot.loading {
1832
+ background: #f59e0b;
1833
+ }
1834
+
1835
+ .status-dot.warning {
1836
+ background: #f59e0b;
1837
+ }
1838
+
1839
+ .status-dot.error {
1840
+ background: #ef4444;
1841
+ }
1842
+
1843
+ .status-dot.ready {
1844
+ background: #10b981;
1845
+ }
1846
+
1847
+ /* Source document panel styles */
1848
+ .source-document-panel {
1849
+ position: fixed;
1850
+ top: 0;
1851
+ right: -500px;
1852
+ width: 450px;
1853
+ max-width: 90vw;
1854
+ height: 100vh;
1855
+ background: white;
1856
+ box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
1857
+ z-index: 101;
1858
+ transition: right 0.3s ease;
1859
+ display: flex;
1860
+ flex-direction: column;
1861
+ }
1862
+
1863
+ .source-document-panel.show {
1864
+ right: 0;
1865
+ }
1866
+
1867
+ .source-document-content {
1868
+ padding: 1rem;
1869
+ overflow-y: auto;
1870
+ }
1871
+
1872
+ .source-document-content h1 {
1873
+ font-size: 1.5rem;
1874
+ margin-bottom: 1rem;
1875
+ color: #1e293b;
1876
+ }
1877
+
1878
+ .source-document-content h2 {
1879
+ font-size: 1.25rem;
1880
+ margin: 1.5rem 0 0.75rem 0;
1881
+ color: #1e293b;
1882
+ }
1883
+
1884
+ .source-document-content h3 {
1885
+ font-size: 1.125rem;
1886
+ margin: 1.25rem 0 0.5rem 0;
1887
+ color: #1e293b;
1888
+ }
1889
+
1890
+ .source-document-content p {
1891
+ margin: 0.75rem 0;
1892
+ color: #4b5563;
1893
+ line-height: 1.6;
1894
+ }
1895
+
1896
+ .source-document-content .metadata {
1897
+ margin: 1rem 0;
1898
+ padding: 0.75rem;
1899
+ background: #f1f5f9;
1900
+ border-radius: 6px;
1901
+ font-size: 0.875rem;
1902
+ color: #64748b;
1903
+ }
1904
+
1905
+ .source-document-content .metadata-item {
1906
+ margin: 0.25rem 0;
1907
+ }
1908
+
1909
+ .source-document-content .metadata-item span {
1910
+ font-weight: 600;
1911
+ }
1912
+
1913
+ .source-citation.clickable {
1914
+ cursor: pointer;
1915
+ transition: background-color 0.2s;
1916
+ }
1917
+
1918
+ .source-citation.clickable:hover {
1919
+ background: #e2e8f0;
1920
+ }
1921
+ `;
1922
+
1923
+ // Add additional styles to document
1924
+ const styleSheet = document.createElement('style');
1925
+ styleSheet.textContent = additionalStyles;
1926
+ document.head.appendChild(styleSheet);
1927
+
1928
+ // Initialize the chat interface when the DOM is loaded
1929
+ document.addEventListener('DOMContentLoaded', () => {
1930
+ new ChatInterface();
1931
+ });
1932
+
1933
+ // Service worker registration for potential offline functionality
1934
+ if ('serviceWorker' in navigator) {
1935
+ window.addEventListener('load', () => {
1936
+ // Optional: register a service worker for offline functionality
1937
+ // navigator.serviceWorker.register('/sw.js').catch(console.warn);
1938
+ });
1939
+ }
templates/chat.html ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PolicyWise - Chat</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="{{ url_for('static', filename='chat.css') }}">
10
+ <link rel="stylesheet" href="{{ url_for('static', filename='chat-enhanced.css') }}">
11
+ </head>
12
+ <body>
13
+ <div class="chat-container" role="application" aria-label="PolicyWise Chat Interface">
14
+ <header class="chat-header">
15
+ <div class="header-content">
16
+ <h1>PolicyWise</h1>
17
+ <p class="subtitle">Your Intelligent Policy Assistant</p>
18
+ </div>
19
+ <div class="header-controls">
20
+ <button id="conversationHistoryBtn" class="icon-button" title="Conversation History"
21
+ aria-label="Open conversation history" aria-haspopup="dialog">
22
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
23
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
24
+ </svg>
25
+ </button>
26
+ <div class="status-indicator" id="statusIndicator" role="status" aria-live="polite">
27
+ <span class="status-dot" aria-hidden="true"></span>
28
+ <span class="status-text">Ready</span>
29
+ </div>
30
+ </div>
31
+ </header>
32
+
33
+ <main class="chat-main">
34
+ <div class="messages-container" id="messagesContainer" role="log" aria-label="Chat messages" aria-live="polite">
35
+ <div class="welcome-message">
36
+ <div class="welcome-icon" aria-hidden="true">🤖</div>
37
+ <h2>Welcome to PolicyWise!</h2>
38
+ <p>I'm here to help you find information about company policies and procedures. Ask me anything about:</p>
39
+ <ul class="policy-topics" aria-label="Suggested policy topics">
40
+ <li>Remote work policies</li>
41
+ <li>PTO and leave policies</li>
42
+ <li>Expense reimbursement</li>
43
+ <li>Information security</li>
44
+ <li>Employee benefits</li>
45
+ <li>And much more...</li>
46
+ </ul>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="input-container">
51
+ <form id="chatForm" class="chat-form" aria-label="Chat message form">
52
+ <div class="input-wrapper">
53
+ <textarea
54
+ id="messageInput"
55
+ placeholder="Ask about company policies..."
56
+ rows="1"
57
+ maxlength="1000"
58
+ required
59
+ aria-label="Message input"
60
+ aria-describedby="charCount"></textarea>
61
+ <button type="submit" id="sendButton" class="send-button" disabled aria-label="Send message">
62
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
63
+ <line x1="22" y1="2" x2="11" y2="13"></line>
64
+ <polygon points="22,2 15,22 11,13 2,9"></polygon>
65
+ </svg>
66
+ </button>
67
+ </div>
68
+ <div class="input-options">
69
+ <label class="checkbox-label" for="includeSources">
70
+ <input type="checkbox" id="includeSources" checked>
71
+ <span class="checkmark" aria-hidden="true"></span>
72
+ Include source citations
73
+ </label>
74
+ <span class="char-counter" role="status">
75
+ <span id="charCount">0</span>/1000
76
+ </span>
77
+ </div>
78
+ </form>
79
+ </div>
80
+ </main>
81
+
82
+ <footer class="chat-footer">
83
+ <p>&copy; 2025 MSSE AI Engineering Project | <a href="/health">System Status</a></p>
84
+ </footer>
85
+ </div>
86
+
87
+ <!-- Loading overlay -->
88
+ <div id="loadingOverlay" class="loading-overlay hidden">
89
+ <div class="loading-spinner"></div>
90
+ <p>Analyzing policies...</p>
91
+ </div>
92
+
93
+ <!-- Conversation history panel -->
94
+ <div id="conversationHistoryPanel" class="side-panel" role="dialog" aria-labelledby="conversationHistoryHeader" aria-hidden="true">
95
+ <div class="panel-header">
96
+ <h3 id="conversationHistoryHeader">Conversation History</h3>
97
+ <button id="closeConversationsBtn" class="icon-button" aria-label="Close conversation history">
98
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
99
+ <line x1="18" y1="6" x2="6" y2="18"></line>
100
+ <line x1="6" y1="6" x2="18" y2="18"></line>
101
+ </svg>
102
+ </button>
103
+ </div>
104
+ <div class="panel-body">
105
+ <div class="panel-actions">
106
+ <button id="newConversationBtn" class="primary-button" aria-label="Start new conversation">
107
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
108
+ <line x1="12" y1="5" x2="12" y2="19"></line>
109
+ <line x1="5" y1="12" x2="19" y2="12"></line>
110
+ </svg>
111
+ New Conversation
112
+ </button>
113
+ <div class="export-import-buttons">
114
+ <button id="exportConversationsBtn" class="file-upload-label" aria-label="Export conversations">
115
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
116
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
117
+ <polyline points="7 10 12 15 17 10"></polyline>
118
+ <line x1="12" y1="15" x2="12" y2="3"></line>
119
+ </svg>
120
+ Export
121
+ </button>
122
+ <label for="importConversations" class="file-upload-label" aria-label="Import conversations">
123
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
124
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
125
+ <polyline points="17 8 12 3 7 8"></polyline>
126
+ <line x1="12" y1="3" x2="12" y2="15"></line>
127
+ </svg>
128
+ Import
129
+ </label>
130
+ <input type="file" id="importConversations" class="file-upload" accept=".json" aria-label="Import conversations from file">
131
+ </div>
132
+ </div>
133
+ <div class="search-conversations">
134
+ <input type="text" id="searchConversations" class="search-input" placeholder="Search conversations..." aria-label="Search conversations">
135
+ </div>
136
+ <div class="conversation-list" id="conversationList" role="list" aria-label="Your conversations">
137
+ <!-- Conversations will be added here dynamically -->
138
+ <div class="empty-state">
139
+ <p>No conversation history found.</p>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <!-- Swipe indicator for mobile -->
146
+ <div class="swipe-indicator" id="swipeIndicator" aria-hidden="true">
147
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
148
+ <path d="M9 18l6-6-6-6"></path>
149
+ </svg>
150
+ </div>
151
+
152
+ <script src="{{ url_for('static', filename='js/chat.js') }}"></script>
153
+ </body>
154
+ </html>
tests/conftest.py CHANGED
@@ -10,3 +10,59 @@ if PROJECT_ROOT not in sys.path:
10
 
11
  if SRC_PATH not in sys.path:
12
  sys.path.insert(0, SRC_PATH)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  if SRC_PATH not in sys.path:
12
  sys.path.insert(0, SRC_PATH)
13
+
14
+ # Set environment variables to disable ChromaDB telemetry
15
+ os.environ["ANONYMIZED_TELEMETRY"] = "False"
16
+ os.environ["CHROMA_TELEMETRY"] = "False"
17
+
18
+ from unittest.mock import MagicMock, patch # noqa: E402
19
+
20
+ import pytest # noqa: E402
21
+
22
+ from app import app as flask_app # noqa: E402
23
+
24
+
25
+ @pytest.fixture(scope="session", autouse=True)
26
+ def disable_chromadb_telemetry():
27
+ """Disable ChromaDB telemetry to avoid errors in tests"""
28
+ patches = []
29
+ try:
30
+ # Patch multiple telemetry-related functions
31
+ patches.extend(
32
+ [
33
+ patch("chromadb.telemetry.product.posthog.capture", return_value=None),
34
+ patch(
35
+ "chromadb.telemetry.product.posthog.Posthog.capture",
36
+ return_value=None,
37
+ ),
38
+ patch(
39
+ "chromadb.telemetry.product.posthog.Posthog",
40
+ return_value=MagicMock(),
41
+ ),
42
+ patch("chromadb.configure", return_value=None),
43
+ ]
44
+ )
45
+ for p in patches:
46
+ p.start()
47
+ yield
48
+ except (ImportError, AttributeError):
49
+ # If modules don't exist, continue without patching
50
+ yield
51
+ finally:
52
+ for p in patches:
53
+ try:
54
+ p.stop()
55
+ except Exception:
56
+ pass
57
+
58
+
59
+ @pytest.fixture
60
+ def app():
61
+ """Flask application fixture."""
62
+ yield flask_app
63
+
64
+
65
+ @pytest.fixture
66
+ def client(app):
67
+ """Flask test client fixture."""
68
+ return app.test_client()
tests/test_enhanced_chat_interface.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Any, Dict
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from flask.testing import FlaskClient
7
+
8
+
9
+ @patch.dict(os.environ, {"OPENROUTER_API_KEY": "test_key"})
10
+ @patch("src.rag.rag_pipeline.RAGPipeline")
11
+ @patch("src.rag.response_formatter.ResponseFormatter")
12
+ @patch("src.llm.llm_service.LLMService")
13
+ @patch("src.search.search_service.SearchService")
14
+ @patch("src.vector_store.vector_db.VectorDatabase")
15
+ @patch("src.embedding.embedding_service.EmbeddingService")
16
+ def test_chat_endpoint_structure(
17
+ mock_embedding,
18
+ mock_vector,
19
+ mock_search,
20
+ mock_llm,
21
+ mock_formatter,
22
+ mock_rag,
23
+ client: FlaskClient,
24
+ ):
25
+ """Test that the chat endpoint returns properly formatted responses with
26
+ citations."""
27
+ # Mock the RAG pipeline response
28
+ mock_response = {
29
+ "answer": (
30
+ "Based on the remote work policy, employees can work "
31
+ "remotely up to 3 days per week."
32
+ ),
33
+ "confidence": 0.85,
34
+ "sources": [{"chunk_id": "123", "content": "Remote work policy content..."}],
35
+ "citations": ["remote_work_policy.md"],
36
+ "processing_time_ms": 1500,
37
+ }
38
+
39
+ # Setup mock instances
40
+ mock_rag_instance = MagicMock()
41
+ mock_rag_instance.generate_answer.return_value = mock_response
42
+ mock_rag.return_value = mock_rag_instance
43
+
44
+ mock_formatter_instance = MagicMock()
45
+ mock_formatter_instance.format_api_response.return_value = {
46
+ "status": "success",
47
+ "answer": mock_response["answer"],
48
+ "confidence": mock_response["confidence"],
49
+ "sources": mock_response["sources"],
50
+ "citations": mock_response["citations"],
51
+ }
52
+ mock_formatter.return_value = mock_formatter_instance
53
+
54
+ # Mock LLMService.from_environment to return a mock instance
55
+ mock_llm_instance = MagicMock()
56
+ mock_llm.from_environment.return_value = mock_llm_instance
57
+
58
+ response = client.post(
59
+ "/chat",
60
+ json={"message": "What is our remote work policy?", "include_sources": True},
61
+ )
62
+
63
+ assert response.status_code == 200
64
+ data = json.loads(response.data)
65
+
66
+ assert "status" in data
67
+ assert data["status"] == "success"
68
+ assert "response" in data or "answer" in data
69
+
70
+ # Check for sources when include_sources is True
71
+ assert "sources" in data
72
+ assert isinstance(data["sources"], list)
73
+
74
+
75
+ def test_conversation_endpoints(client: FlaskClient):
76
+ """Test the conversation management endpoints."""
77
+ # Test getting conversation list
78
+ response = client.get("/conversations")
79
+ assert response.status_code == 200
80
+ data = json.loads(response.data)
81
+
82
+ assert "status" in data
83
+ assert data["status"] == "success"
84
+ assert "conversations" in data
85
+ assert isinstance(data["conversations"], list)
86
+
87
+ # Test getting a specific conversation
88
+ if len(data["conversations"]) > 0:
89
+ conv_id = data["conversations"][0]["id"]
90
+ response = client.get(f"/conversations/{conv_id}")
91
+ assert response.status_code == 200
92
+ conv_data = json.loads(response.data)
93
+
94
+ assert "status" in conv_data
95
+ assert conv_data["status"] == "success"
96
+ assert "conversation_id" in conv_data
97
+ assert "messages" in conv_data
98
+ assert isinstance(conv_data["messages"], list)
99
+
100
+
101
+ def test_feedback_endpoint(client: FlaskClient):
102
+ """Test the feedback submission endpoint."""
103
+ feedback_data: Dict[str, Any] = {
104
+ "conversation_id": "test_conv_id",
105
+ "message_id": "test_msg_id",
106
+ "feedback_type": "response_rating",
107
+ "rating": 5,
108
+ }
109
+
110
+ response = client.post("/chat/feedback", json=feedback_data)
111
+
112
+ assert response.status_code == 200
113
+ data = json.loads(response.data)
114
+
115
+ assert "status" in data
116
+ assert data["status"] == "success"
117
+ assert "feedback" in data
118
+
119
+
120
+ def test_source_document_endpoint(client: FlaskClient):
121
+ """Test retrieving source documents."""
122
+ # Test a valid source ID
123
+ response = client.get("/chat/source/remote_work")
124
+ assert response.status_code == 200
125
+ data = json.loads(response.data)
126
+
127
+ assert "status" in data
128
+ assert data["status"] == "success"
129
+ assert "content" in data
130
+ assert "metadata" in data
131
+
132
+ # Test an invalid source ID
133
+ response = client.get("/chat/source/nonexistent_source")
134
+ assert response.status_code == 404
135
+ data = json.loads(response.data)
136
+
137
+ assert "status" in data
138
+ assert data["status"] == "error"
139
+
140
+
141
+ def test_query_suggestions_endpoint(client: FlaskClient):
142
+ """Test query suggestions endpoint."""
143
+ response = client.get("/chat/suggestions")
144
+ assert response.status_code == 200
145
+ data = json.loads(response.data)
146
+
147
+ assert "status" in data
148
+ assert data["status"] == "success"
149
+ assert "suggestions" in data
150
+ assert isinstance(data["suggestions"], list)