Ajout des fonctionnalités d'export Word et PDF pour le chat RAG
- Ajout de python-docx et reportlab aux dépendances - Création du module utils/word_exporter.py pour l'export Word - Création du module utils/pdf_exporter.py pour l'export PDF - Ajout des routes /chat/export-word et /chat/export-pdf dans flask_app.py - Ajout des boutons d'export (Word et PDF) dans chat.html - Les boutons apparaissent après chaque réponse de l'assistant - Support des questions reformulées avec question originale 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
# PDF Upload & Processing
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ mistralai>=1.0.0
|
|||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
werkzeug>=3.0.0
|
werkzeug>=3.0.0
|
||||||
|
python-docx>=1.1.0
|
||||||
|
reportlab>=4.0.0
|
||||||
|
|
||||||
# MCP Server dependencies
|
# MCP Server dependencies
|
||||||
mcp>=1.0.0
|
mcp>=1.0.0
|
||||||
|
|||||||
@@ -587,6 +587,44 @@
|
|||||||
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
.sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(125, 110, 88, 0.5);
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
@@ -850,6 +888,13 @@
|
|||||||
const chosenQuestion = btn.getAttribute('data-question');
|
const chosenQuestion = btn.getAttribute('data-question');
|
||||||
const isReformulated = btn.classList.contains('btn-reformulated');
|
const isReformulated = btn.classList.contains('btn-reformulated');
|
||||||
|
|
||||||
|
// Store context for export
|
||||||
|
window.currentChatContext = {
|
||||||
|
question: chosenQuestion,
|
||||||
|
isReformulated: isReformulated,
|
||||||
|
originalQuestion: isReformulated ? original : null
|
||||||
|
};
|
||||||
|
|
||||||
// Disable both buttons
|
// Disable both buttons
|
||||||
buttons.forEach(b => b.disabled = true);
|
buttons.forEach(b => b.disabled = true);
|
||||||
|
|
||||||
@@ -906,8 +951,8 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const sessionId = data.session_id;
|
const sessionId = data.session_id;
|
||||||
|
|
||||||
// SSE stream
|
// SSE stream with chat context
|
||||||
startSSEStream(sessionId, typingId);
|
startSSEStream(sessionId, typingId, window.currentChatContext || {});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -919,7 +964,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSSEStream(sessionId, typingId) {
|
function startSSEStream(sessionId, typingId, chatContext = {}) {
|
||||||
// Close previous EventSource if exists
|
// Close previous EventSource if exists
|
||||||
if (currentEventSource) {
|
if (currentEventSource) {
|
||||||
currentEventSource.close();
|
currentEventSource.close();
|
||||||
@@ -930,8 +975,16 @@
|
|||||||
|
|
||||||
let assistantMessageDiv = null;
|
let assistantMessageDiv = null;
|
||||||
let assistantContentDiv = null;
|
let assistantContentDiv = null;
|
||||||
|
let exportWordBtn = null;
|
||||||
|
let exportPdfBtn = null;
|
||||||
|
let exportContainer = null;
|
||||||
let accumulatedText = '';
|
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) {
|
eventSource.onmessage = function(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -951,6 +1004,9 @@
|
|||||||
const result = createAssistantMessage();
|
const result = createAssistantMessage();
|
||||||
assistantMessageDiv = result.messageDiv;
|
assistantMessageDiv = result.messageDiv;
|
||||||
assistantContentDiv = result.contentDiv;
|
assistantContentDiv = result.contentDiv;
|
||||||
|
exportWordBtn = result.exportWordBtn;
|
||||||
|
exportPdfBtn = result.exportPdfBtn;
|
||||||
|
exportContainer = result.exportContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accumulate text
|
// Accumulate text
|
||||||
@@ -969,6 +1025,32 @@
|
|||||||
}
|
}
|
||||||
else if (data.type === 'complete') {
|
else if (data.type === 'complete') {
|
||||||
// Generation 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();
|
eventSource.close();
|
||||||
currentEventSource = null;
|
currentEventSource = null;
|
||||||
isGenerating = false;
|
isGenerating = false;
|
||||||
@@ -1033,11 +1115,42 @@
|
|||||||
const contentDiv = document.createElement('div');
|
const contentDiv = document.createElement('div');
|
||||||
contentDiv.className = 'message-content';
|
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 = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
|
||||||
|
<path d="M14 2v6h6"/>
|
||||||
|
<path d="M10.5 13.5l1.5 3 1.5-3"/>
|
||||||
|
</svg>
|
||||||
|
Word
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add export PDF button
|
||||||
|
const exportPdfBtn = document.createElement('button');
|
||||||
|
exportPdfBtn.className = 'export-pdf-btn';
|
||||||
|
exportPdfBtn.innerHTML = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
|
||||||
|
<path d="M14 2v6h6"/>
|
||||||
|
</svg>
|
||||||
|
PDF
|
||||||
|
`;
|
||||||
|
|
||||||
|
exportContainer.appendChild(exportWordBtn);
|
||||||
|
exportContainer.appendChild(exportPdfBtn);
|
||||||
|
|
||||||
messageDiv.appendChild(label);
|
messageDiv.appendChild(label);
|
||||||
messageDiv.appendChild(contentDiv);
|
messageDiv.appendChild(contentDiv);
|
||||||
|
messageDiv.appendChild(exportContainer);
|
||||||
chatMessages.appendChild(messageDiv);
|
chatMessages.appendChild(messageDiv);
|
||||||
|
|
||||||
return { messageDiv, contentDiv };
|
return { messageDiv, contentDiv, exportWordBtn, exportPdfBtn, exportContainer };
|
||||||
}
|
}
|
||||||
|
|
||||||
function addErrorMessage(message) {
|
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
|
// Initialize
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
204
generations/library_rag/utils/pdf_exporter.py
Normal file
204
generations/library_rag/utils/pdf_exporter.py
Normal file
@@ -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
|
||||||
144
generations/library_rag/utils/word_exporter.py
Normal file
144
generations/library_rag/utils/word_exporter.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user