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