diff --git a/generations/library_rag/flask_app.py b/generations/library_rag/flask_app.py
index f754d8f..468799d 100644
--- a/generations/library_rag/flask_app.py
+++ b/generations/library_rag/flask_app.py
@@ -1430,6 +1430,151 @@ def chat_stream(session_id: str) -> WerkzeugResponse:
)
+@app.route("/chat/export-word", methods=["POST"])
+def chat_export_word() -> Union[WerkzeugResponse, tuple[Dict[str, Any], int]]:
+ """Export a chat exchange to Word format.
+
+ Generates a formatted Microsoft Word document (.docx) containing the user's
+ question and the assistant's response. Supports both original and reformulated
+ questions.
+
+ Request JSON:
+ user_question (str): The user's question (required).
+ assistant_response (str): The assistant's complete response (required).
+ is_reformulated (bool, optional): Whether the question was reformulated.
+ Default: False.
+ original_question (str, optional): Original question if reformulated.
+ Only used when is_reformulated is True.
+
+ Returns:
+ Word document file download (.docx) on success.
+ JSON error response with 400/500 status on failure.
+
+ Example:
+ POST /chat/export-word
+ Content-Type: application/json
+
+ {
+ "user_question": "What is phenomenology?",
+ "assistant_response": "Phenomenology is a philosophical movement...",
+ "is_reformulated": false
+ }
+
+ Response: chat_export_20250130_143022.docx (download)
+ """
+ try:
+ data = request.get_json()
+
+ if not data:
+ return jsonify({"error": "No JSON data provided"}), 400
+
+ user_question = data.get("user_question")
+ assistant_response = data.get("assistant_response")
+ is_reformulated = data.get("is_reformulated", False)
+ original_question = data.get("original_question")
+
+ if not user_question or not assistant_response:
+ return (
+ jsonify({"error": "user_question and assistant_response are required"}),
+ 400,
+ )
+
+ # Import word exporter
+ from utils.word_exporter import create_chat_export
+
+ # Generate Word document
+ filepath = create_chat_export(
+ user_question=user_question,
+ assistant_response=assistant_response,
+ is_reformulated=is_reformulated,
+ original_question=original_question,
+ output_dir=app.config["UPLOAD_FOLDER"],
+ )
+
+ # Send file as download
+ return send_from_directory(
+ directory=filepath.parent,
+ path=filepath.name,
+ as_attachment=True,
+ download_name=filepath.name,
+ )
+
+ except Exception as e:
+ return jsonify({"error": f"Export failed: {str(e)}"}), 500
+
+
+@app.route("/chat/export-pdf", methods=["POST"])
+def chat_export_pdf() -> Union[WerkzeugResponse, tuple[Dict[str, Any], int]]:
+ """Export a chat exchange to PDF format.
+
+ Generates a formatted PDF document containing the user's question and the
+ assistant's response. Supports both original and reformulated questions.
+
+ Request JSON:
+ user_question (str): The user's question (required).
+ assistant_response (str): The assistant's complete response (required).
+ is_reformulated (bool, optional): Whether the question was reformulated.
+ Default: False.
+ original_question (str, optional): Original question if reformulated.
+ Only used when is_reformulated is True.
+
+ Returns:
+ PDF document file download on success.
+ JSON error response with 400/500 status on failure.
+
+ Example:
+ POST /chat/export-pdf
+ Content-Type: application/json
+
+ {
+ "user_question": "What is phenomenology?",
+ "assistant_response": "Phenomenology is a philosophical movement...",
+ "is_reformulated": false
+ }
+
+ Response: chat_export_20250130_143022.pdf (download)
+ """
+ try:
+ data = request.get_json()
+
+ if not data:
+ return jsonify({"error": "No JSON data provided"}), 400
+
+ user_question = data.get("user_question")
+ assistant_response = data.get("assistant_response")
+ is_reformulated = data.get("is_reformulated", False)
+ original_question = data.get("original_question")
+
+ if not user_question or not assistant_response:
+ return (
+ jsonify({"error": "user_question and assistant_response are required"}),
+ 400,
+ )
+
+ # Import PDF exporter
+ from utils.pdf_exporter import create_chat_export_pdf
+
+ # Generate PDF document
+ filepath = create_chat_export_pdf(
+ user_question=user_question,
+ assistant_response=assistant_response,
+ is_reformulated=is_reformulated,
+ original_question=original_question,
+ output_dir=app.config["UPLOAD_FOLDER"],
+ )
+
+ # Send file as download
+ return send_from_directory(
+ directory=filepath.parent,
+ path=filepath.name,
+ as_attachment=True,
+ download_name=filepath.name,
+ )
+
+ except Exception as e:
+ return jsonify({"error": f"Export failed: {str(e)}"}), 500
+
+
# ═══════════════════════════════════════════════════════════════════════════════
# PDF Upload & Processing
# ═══════════════════════════════════════════════════════════════════════════════
diff --git a/generations/library_rag/requirements.txt b/generations/library_rag/requirements.txt
index 90833e2..a75406d 100644
--- a/generations/library_rag/requirements.txt
+++ b/generations/library_rag/requirements.txt
@@ -5,6 +5,8 @@ mistralai>=1.0.0
python-dotenv>=1.0.0
requests>=2.31.0
werkzeug>=3.0.0
+python-docx>=1.1.0
+reportlab>=4.0.0
# MCP Server dependencies
mcp>=1.0.0
diff --git a/generations/library_rag/templates/chat.html b/generations/library_rag/templates/chat.html
index 258eeed..ff78a02 100644
--- a/generations/library_rag/templates/chat.html
+++ b/generations/library_rag/templates/chat.html
@@ -587,6 +587,44 @@
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: rgba(125, 110, 88, 0.5);
}
+
+ /* Export buttons - compact size */
+ .export-word-btn,
+ .export-pdf-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ padding: 0.85rem 1.75rem;
+ margin-top: 0.75rem;
+ margin-right: 0.5rem;
+ border-radius: 8px;
+ border: 1.5px solid var(--color-accent);
+ background-color: var(--color-accent);
+ color: var(--color-bg-main);
+ font-family: var(--font-body);
+ font-size: 0.95rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ white-space: nowrap;
+ max-width: fit-content;
+ transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+ }
+
+ .export-word-btn:hover,
+ .export-pdf-btn:hover {
+ background-color: var(--color-accent-alt);
+ border-color: var(--color-accent-alt);
+ color: var(--color-bg-main);
+ }
+
+ .export-word-btn svg,
+ .export-pdf-btn svg {
+ width: 15px;
+ height: 15px;
+ flex-shrink: 0;
+ }
@@ -850,6 +888,13 @@
const chosenQuestion = btn.getAttribute('data-question');
const isReformulated = btn.classList.contains('btn-reformulated');
+ // Store context for export
+ window.currentChatContext = {
+ question: chosenQuestion,
+ isReformulated: isReformulated,
+ originalQuestion: isReformulated ? original : null
+ };
+
// Disable both buttons
buttons.forEach(b => b.disabled = true);
@@ -906,8 +951,8 @@
const data = await response.json();
const sessionId = data.session_id;
- // SSE stream
- startSSEStream(sessionId, typingId);
+ // SSE stream with chat context
+ startSSEStream(sessionId, typingId, window.currentChatContext || {});
} catch (error) {
console.error('Error:', error);
@@ -919,7 +964,7 @@
}
}
- function startSSEStream(sessionId, typingId) {
+ function startSSEStream(sessionId, typingId, chatContext = {}) {
// Close previous EventSource if exists
if (currentEventSource) {
currentEventSource.close();
@@ -930,8 +975,16 @@
let assistantMessageDiv = null;
let assistantContentDiv = null;
+ let exportWordBtn = null;
+ let exportPdfBtn = null;
+ let exportContainer = null;
let accumulatedText = '';
+ // Get chat context (question, reformulation status)
+ const currentQuestion = chatContext.question || '';
+ const isReformulated = chatContext.isReformulated || false;
+ const originalQuestion = chatContext.originalQuestion || '';
+
eventSource.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
@@ -951,6 +1004,9 @@
const result = createAssistantMessage();
assistantMessageDiv = result.messageDiv;
assistantContentDiv = result.contentDiv;
+ exportWordBtn = result.exportWordBtn;
+ exportPdfBtn = result.exportPdfBtn;
+ exportContainer = result.exportContainer;
}
// Accumulate text
@@ -969,6 +1025,32 @@
}
else if (data.type === 'complete') {
// Generation complete
+
+ // Show export buttons
+ if (exportContainer && accumulatedText) {
+ exportContainer.style.display = 'block';
+
+ // Add click handler for Word export
+ exportWordBtn.addEventListener('click', async () => {
+ await exportToWord(
+ currentQuestion,
+ accumulatedText,
+ isReformulated,
+ originalQuestion
+ );
+ });
+
+ // Add click handler for PDF export
+ exportPdfBtn.addEventListener('click', async () => {
+ await exportToPdf(
+ currentQuestion,
+ accumulatedText,
+ isReformulated,
+ originalQuestion
+ );
+ });
+ }
+
eventSource.close();
currentEventSource = null;
isGenerating = false;
@@ -1033,11 +1115,42 @@
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
+ // Add export buttons container
+ const exportContainer = document.createElement('div');
+ exportContainer.style.display = 'none'; // Hidden until response is complete
+
+ // Add export Word button
+ const exportWordBtn = document.createElement('button');
+ exportWordBtn.className = 'export-word-btn';
+ exportWordBtn.innerHTML = `
+
+ Word
+ `;
+
+ // Add export PDF button
+ const exportPdfBtn = document.createElement('button');
+ exportPdfBtn.className = 'export-pdf-btn';
+ exportPdfBtn.innerHTML = `
+
+ PDF
+ `;
+
+ exportContainer.appendChild(exportWordBtn);
+ exportContainer.appendChild(exportPdfBtn);
+
messageDiv.appendChild(label);
messageDiv.appendChild(contentDiv);
+ messageDiv.appendChild(exportContainer);
chatMessages.appendChild(messageDiv);
- return { messageDiv, contentDiv };
+ return { messageDiv, contentDiv, exportWordBtn, exportPdfBtn, exportContainer };
}
function addErrorMessage(message) {
@@ -1165,6 +1278,76 @@
});
}
+ async function exportToWord(userQuestion, assistantResponse, isReformulated = false, originalQuestion = '') {
+ try {
+ const response = await fetch('/chat/export-word', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ user_question: userQuestion,
+ assistant_response: assistantResponse,
+ is_reformulated: isReformulated,
+ original_question: originalQuestion
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Export failed');
+ }
+
+ // Download file
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `chat_export_${new Date().getTime()}.docx`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ } catch (error) {
+ console.error('Export error:', error);
+ alert(`Erreur d'export: ${error.message}`);
+ }
+ }
+
+ async function exportToPdf(userQuestion, assistantResponse, isReformulated = false, originalQuestion = '') {
+ try {
+ const response = await fetch('/chat/export-pdf', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ user_question: userQuestion,
+ assistant_response: assistantResponse,
+ is_reformulated: isReformulated,
+ original_question: originalQuestion
+ })
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Export failed');
+ }
+
+ // Download file
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `chat_export_${new Date().getTime()}.pdf`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ } catch (error) {
+ console.error('Export error:', error);
+ alert(`Erreur d'export: ${error.message}`);
+ }
+ }
+
// Initialize
sendBtn.disabled = true;
diff --git a/generations/library_rag/utils/pdf_exporter.py b/generations/library_rag/utils/pdf_exporter.py
new file mode 100644
index 0000000..8c410e7
--- /dev/null
+++ b/generations/library_rag/utils/pdf_exporter.py
@@ -0,0 +1,204 @@
+"""Generate PDF documents from chat exchanges.
+
+This module provides functionality to export chat conversations between users
+and the RAG assistant into formatted PDF documents.
+
+Example:
+ Export a simple chat exchange::
+
+ from pathlib import Path
+ from utils.pdf_exporter import create_chat_export_pdf
+
+ filepath = create_chat_export_pdf(
+ user_question="What is phenomenology?",
+ assistant_response="Phenomenology is a philosophical movement...",
+ output_dir=Path("output")
+ )
+
+ Export with reformulated question::
+
+ filepath = create_chat_export_pdf(
+ user_question="What does Husserl mean by phenomenology?",
+ assistant_response="Husserl defines phenomenology as...",
+ is_reformulated=True,
+ original_question="What is phenomenology?",
+ output_dir=Path("output")
+ )
+"""
+
+from pathlib import Path
+from typing import Optional
+from datetime import datetime
+
+try:
+ from reportlab.lib.pagesizes import A4
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+ from reportlab.lib.units import cm
+ from reportlab.lib.colors import HexColor
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
+except ImportError:
+ raise ImportError(
+ "reportlab is required for PDF export. "
+ "Install with: pip install reportlab"
+ )
+
+
+def create_chat_export_pdf(
+ user_question: str,
+ assistant_response: str,
+ is_reformulated: bool = False,
+ original_question: Optional[str] = None,
+ output_dir: Path = Path("output"),
+) -> Path:
+ """Create a PDF document from a chat exchange.
+
+ Args:
+ user_question: The user's question (or reformulated version).
+ assistant_response: The assistant's complete response text.
+ is_reformulated: Whether the question was reformulated by the system.
+ If True, both original and reformulated questions are included.
+ original_question: The original user question before reformulation.
+ Only used when is_reformulated is True.
+ output_dir: Directory where the PDF file will be saved.
+ Created if it doesn't exist.
+
+ Returns:
+ Path to the generated PDF file.
+
+ Raises:
+ OSError: If the output directory cannot be created or file cannot be saved.
+
+ Example:
+ >>> from pathlib import Path
+ >>> filepath = create_chat_export_pdf(
+ ... user_question="Qu'est-ce que la phénoménologie ?",
+ ... assistant_response="La phénoménologie est...",
+ ... output_dir=Path("output/test_exports")
+ ... )
+ >>> print(filepath)
+ output/test_exports/chat_export_20250101_123045.pdf
+ """
+ # Generate filename with timestamp
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"chat_export_{timestamp}.pdf"
+ filepath = output_dir / filename
+
+ # Create directory if needed
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ # Create PDF document
+ doc = SimpleDocTemplate(
+ str(filepath),
+ pagesize=A4,
+ rightMargin=2 * cm,
+ leftMargin=2 * cm,
+ topMargin=2.5 * cm,
+ bottomMargin=2.5 * cm,
+ )
+
+ # Get default styles
+ styles = getSampleStyleSheet()
+
+ # Custom styles
+ title_style = ParagraphStyle(
+ "CustomTitle",
+ parent=styles["Heading1"],
+ fontSize=18,
+ textColor=HexColor("#2B2B2B"),
+ spaceAfter=6,
+ alignment=TA_CENTER,
+ fontName="Helvetica-Bold",
+ )
+
+ date_style = ParagraphStyle(
+ "DateStyle",
+ parent=styles["Normal"],
+ fontSize=9,
+ textColor=HexColor("#808080"),
+ alignment=TA_CENTER,
+ spaceAfter=20,
+ )
+
+ heading_style = ParagraphStyle(
+ "CustomHeading",
+ parent=styles["Heading2"],
+ fontSize=12,
+ textColor=HexColor("#7D6E58"),
+ spaceAfter=10,
+ spaceBefore=15,
+ fontName="Helvetica-Bold",
+ )
+
+ question_style = ParagraphStyle(
+ "QuestionStyle",
+ parent=styles["Normal"],
+ fontSize=11,
+ textColor=HexColor("#2B2B2B"),
+ spaceAfter=10,
+ leftIndent=1 * cm,
+ rightIndent=1 * cm,
+ backColor=HexColor("#F8F4EE"),
+ borderPadding=10,
+ )
+
+ body_style = ParagraphStyle(
+ "BodyStyle",
+ parent=styles["Normal"],
+ fontSize=11,
+ textColor=HexColor("#2B2B2B"),
+ spaceAfter=10,
+ alignment=TA_JUSTIFY,
+ leading=16,
+ )
+
+ footer_style = ParagraphStyle(
+ "FooterStyle",
+ parent=styles["Normal"],
+ fontSize=8,
+ textColor=HexColor("#969696"),
+ alignment=TA_CENTER,
+ fontName="Helvetica-Oblique",
+ )
+
+ # Build document content
+ story = []
+
+ # Title
+ story.append(Paragraph("Conversation RAG - Export", title_style))
+
+ # Date
+ date_text = f"Exporté le {datetime.now().strftime('%d/%m/%Y à %H:%M')}"
+ story.append(Paragraph(date_text, date_style))
+
+ # Original question (if reformulated)
+ if is_reformulated and original_question:
+ story.append(Paragraph("Question Originale", heading_style))
+ story.append(Paragraph(original_question, question_style))
+ story.append(Spacer(1, 0.5 * cm))
+
+ # User question
+ question_label = "Question Reformulée" if is_reformulated else "Question"
+ story.append(Paragraph(question_label, heading_style))
+ story.append(Paragraph(user_question, question_style))
+ story.append(Spacer(1, 0.5 * cm))
+
+ # Assistant response
+ story.append(Paragraph("Réponse de l'Assistant", heading_style))
+
+ # Split response into paragraphs
+ response_paragraphs = assistant_response.split("\n\n")
+ for para in response_paragraphs:
+ if para.strip():
+ story.append(Paragraph(para.strip(), body_style))
+
+ # Footer
+ story.append(Spacer(1, 1 * cm))
+ story.append(
+ Paragraph("Généré par Library RAG - Recherche Philosophique", footer_style)
+ )
+
+ # Build PDF
+ doc.build(story)
+
+ return filepath
diff --git a/generations/library_rag/utils/word_exporter.py b/generations/library_rag/utils/word_exporter.py
new file mode 100644
index 0000000..38f3c22
--- /dev/null
+++ b/generations/library_rag/utils/word_exporter.py
@@ -0,0 +1,144 @@
+"""Generate Word documents from chat exchanges.
+
+This module provides functionality to export chat conversations between users
+and the RAG assistant into formatted Microsoft Word documents (.docx).
+
+Example:
+ Export a simple chat exchange::
+
+ from pathlib import Path
+ from utils.word_exporter import create_chat_export
+
+ filepath = create_chat_export(
+ user_question="What is phenomenology?",
+ assistant_response="Phenomenology is a philosophical movement...",
+ output_dir=Path("output")
+ )
+
+ Export with reformulated question::
+
+ filepath = create_chat_export(
+ user_question="What does Husserl mean by phenomenology?",
+ assistant_response="Husserl defines phenomenology as...",
+ is_reformulated=True,
+ original_question="What is phenomenology?",
+ output_dir=Path("output")
+ )
+"""
+
+from pathlib import Path
+from typing import Optional
+from datetime import datetime
+
+try:
+ from docx import Document
+ from docx.shared import Pt, RGBColor, Inches
+ from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
+except ImportError:
+ raise ImportError(
+ "python-docx is required for Word export. "
+ "Install with: pip install python-docx"
+ )
+
+
+def create_chat_export(
+ user_question: str,
+ assistant_response: str,
+ is_reformulated: bool = False,
+ original_question: Optional[str] = None,
+ output_dir: Path = Path("output"),
+) -> Path:
+ """Create a Word document from a chat exchange.
+
+ Args:
+ user_question: The user's question (or reformulated version).
+ assistant_response: The assistant's complete response text.
+ is_reformulated: Whether the question was reformulated by the system.
+ If True, both original and reformulated questions are included.
+ original_question: The original user question before reformulation.
+ Only used when is_reformulated is True.
+ output_dir: Directory where the .docx file will be saved.
+ Created if it doesn't exist.
+
+ Returns:
+ Path to the generated .docx file.
+
+ Raises:
+ OSError: If the output directory cannot be created or file cannot be saved.
+
+ Example:
+ >>> from pathlib import Path
+ >>> filepath = create_chat_export(
+ ... user_question="Qu'est-ce que la phénoménologie ?",
+ ... assistant_response="La phénoménologie est...",
+ ... output_dir=Path("output")
+ ... )
+ >>> print(filepath)
+ output/chat_export_20250101_123045.docx
+ """
+ # Create document
+ doc = Document()
+
+ # Set margins to 1 inch on all sides
+ sections = doc.sections
+ for section in sections:
+ section.top_margin = Inches(1)
+ section.bottom_margin = Inches(1)
+ section.left_margin = Inches(1)
+ section.right_margin = Inches(1)
+
+ # Title
+ title = doc.add_heading("Conversation RAG - Export", level=1)
+ title.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+
+ # Date
+ date_p = doc.add_paragraph()
+ date_p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+ date_run = date_p.add_run(
+ f"Exporté le {datetime.now().strftime('%d/%m/%Y à %H:%M')}"
+ )
+ date_run.font.size = Pt(10)
+ date_run.font.color.rgb = RGBColor(128, 128, 128)
+
+ doc.add_paragraph() # Spacer
+
+ # Original question (if reformulated)
+ if is_reformulated and original_question:
+ doc.add_heading("Question Originale", level=2)
+ original_p = doc.add_paragraph(original_question)
+ original_p.style = "Intense Quote"
+ doc.add_paragraph()
+
+ # User question
+ question_label = "Question Reformulée" if is_reformulated else "Question"
+ doc.add_heading(question_label, level=2)
+ question_p = doc.add_paragraph(user_question)
+ question_p.style = "Intense Quote"
+
+ doc.add_paragraph() # Spacer
+
+ # Assistant response
+ doc.add_heading("Réponse de l'Assistant", level=2)
+ response_p = doc.add_paragraph(assistant_response)
+
+ # Footer
+ doc.add_paragraph()
+ footer_p = doc.add_paragraph()
+ footer_p.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
+ footer_run = footer_p.add_run(
+ "Généré par Library RAG - Recherche Philosophique"
+ )
+ footer_run.font.size = Pt(9)
+ footer_run.font.color.rgb = RGBColor(150, 150, 150)
+ footer_run.italic = True
+
+ # Generate filename with timestamp
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"chat_export_{timestamp}.docx"
+ filepath = output_dir / filename
+
+ # Save document (create directory if needed)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ doc.save(str(filepath))
+
+ return filepath