Add Library RAG project and cleanup root directory

- Add complete Library RAG application (Flask + MCP server)
  - PDF processing pipeline with OCR and LLM extraction
  - Weaviate vector database integration (BGE-M3 embeddings)
  - Flask web interface with search and document management
  - MCP server for Claude Desktop integration
  - Comprehensive test suite (134 tests)

- Clean up root directory
  - Remove obsolete documentation files
  - Remove backup and temporary files
  - Update autonomous agent configuration

- Update prompts
  - Enhance initializer bis prompt with better instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 11:57:12 +01:00
parent 48470236da
commit d2f7165120
84 changed files with 26517 additions and 2 deletions

View File

@@ -0,0 +1,785 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Philosophia{% endblock %} Visualiseur Weaviate</title>
<!-- Google Fonts: DM Sans + Lato -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Lato:ital,wght@0,300;0,400;0,700;1,400&display=swap" rel="stylesheet">
<style>
/* =========================================================
Charte graphique Site RAG Philosophie
Palette beige + contrastes doux
Typo : DM Sans (titres) + Lato (texte)
========================================================= */
/* Reset basique */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: 'Lato', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: #F8F4EE;
color: #2B2B2B;
line-height: 1.6;
}
/* Variables CSS */
:root {
--color-bg-main: #F8F4EE;
--color-bg-secondary: #EAE5E0;
--color-text-main: #2B2B2B;
--color-text-strong: #1F1F1F;
--color-accent: #7D6E58;
--color-accent-alt: #556B63;
--max-content-width: 1100px;
--font-title: 'DM Sans', system-ui, sans-serif;
--font-body: 'Lato', system-ui, sans-serif;
}
/* Conteneur principal */
.wrapper {
max-width: var(--max-content-width);
margin: 0 auto;
padding: 1.5rem 1.5rem;
}
@media (min-width: 992px) {
.wrapper {
padding: 2.5rem 3rem;
}
}
/* Titres & textes */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-title);
color: var(--color-text-strong);
margin-bottom: 0.75rem;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: 0.02em;
}
h2 {
font-size: 2rem;
font-weight: 600;
}
h3 {
font-size: 1.5rem;
font-weight: 500;
}
p {
margin-bottom: 1.5rem;
font-family: var(--font-body);
font-size: 1rem;
color: var(--color-text-main);
}
strong {
font-weight: 600;
}
.lead {
font-size: 1.1rem;
font-style: italic;
color: var(--color-accent-alt);
margin-bottom: 2rem;
}
.caption {
font-size: 0.9rem;
font-weight: 300;
color: var(--color-accent);
margin-top: 0.25rem;
}
/* Liens */
a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s ease, text-decoration-color 0.2s ease;
}
a:hover,
a:focus {
text-decoration: underline;
text-decoration-thickness: 1px;
}
/* Boutons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.65rem 1.6rem;
border-radius: 8px;
border: 1.5px solid var(--color-accent);
background-color: transparent;
color: var(--color-accent);
font-family: var(--font-body);
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.btn:hover,
.btn:focus {
background-color: var(--color-accent);
color: var(--color-bg-main);
text-decoration: none;
}
.btn-primary {
background-color: var(--color-accent);
color: var(--color-bg-main);
}
.btn-primary:hover,
.btn-primary:focus {
background-color: var(--color-accent-alt);
border-color: var(--color-accent-alt);
}
.btn-sm {
padding: 0.4rem 1rem;
font-size: 0.85rem;
}
/* Sidebar Navigation */
.nav-sidebar {
position: fixed;
left: 0;
top: 0;
width: 260px;
height: 100vh;
background-color: #fff;
border-right: 1px solid rgba(125, 110, 88, 0.15);
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.03);
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
overflow-y: auto;
}
.nav-sidebar.visible {
transform: translateX(0);
}
.sidebar-header {
padding: 1.75rem 1.5rem;
border-bottom: 1px solid rgba(125, 110, 88, 0.1);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.sidebar-header-text {
flex: 1;
}
.sidebar-title {
font-family: var(--font-title);
font-size: 1.3rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-strong);
margin-bottom: 0.25rem;
}
.sidebar-subtitle {
font-family: var(--font-body);
font-size: 0.85rem;
color: var(--color-accent-alt);
}
.sidebar-close-btn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--color-accent);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
transition: transform 0.2s ease, color 0.2s ease;
}
.sidebar-close-btn:hover {
transform: scale(1.1);
color: var(--color-accent-alt);
}
.sidebar-nav {
padding: 1rem 0;
}
.sidebar-nav a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1.5rem;
font-family: var(--font-title);
font-size: 0.95rem;
font-weight: 500;
color: var(--color-accent-alt);
text-decoration: none;
border-left: 3px solid transparent;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.sidebar-nav a:hover {
background-color: rgba(125, 110, 88, 0.05);
color: var(--color-accent);
}
.sidebar-nav a.active {
background-color: rgba(125, 110, 88, 0.08);
border-left-color: var(--color-accent);
color: var(--color-accent);
}
.sidebar-nav a .icon {
font-size: 1.1rem;
width: 20px;
text-align: center;
}
/* Sidebar overlay */
.sidebar-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.3);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
z-index: 999;
}
.sidebar-overlay.visible {
opacity: 1;
visibility: visible;
}
/* Hamburger button */
.hamburger-btn {
position: fixed;
left: 1rem;
top: 1rem;
width: 48px;
height: 48px;
background-color: #fff;
border: 1px solid rgba(125, 110, 88, 0.2);
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
cursor: pointer;
z-index: 1001;
transition: background-color 0.2s ease, transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.hamburger-btn.active {
opacity: 0;
visibility: hidden;
}
.hamburger-btn:hover {
background-color: rgba(125, 110, 88, 0.05);
transform: scale(1.05);
}
.hamburger-btn span {
width: 22px;
height: 2px;
background-color: var(--color-accent);
border-radius: 2px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* Header */
.site-header {
padding: 1.5rem 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
margin-bottom: 1.5rem;
margin-left: 64px; /* Space for hamburger button */
}
.site-title {
font-family: var(--font-title);
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-strong);
}
.site-subtitle {
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-accent-alt);
margin-top: 0.2rem;
}
.header-inner {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
/* Hide old navigation in header */
.nav-links {
display: none;
}
/* Sections */
.section {
padding: 2.5rem 0;
}
.section--alt {
background-color: var(--color-bg-secondary);
}
/* Cards */
.card {
background-color: rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 1.75rem;
border: 1px solid rgba(125, 110, 88, 0.25);
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.03);
}
/* Passage cards */
.passage-card {
background-color: rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 1.75rem;
border: 1px solid rgba(125, 110, 88, 0.25);
border-left: 3px solid var(--color-accent);
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.03);
margin-bottom: 1.25rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.passage-card:hover {
transform: translateX(4px);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.06);
}
.passage-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.passage-text {
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.65;
color: var(--color-text-main);
font-style: italic;
margin-bottom: 1rem;
}
.passage-meta {
font-family: var(--font-body);
font-size: 0.85rem;
font-weight: 300;
color: var(--color-accent);
padding-top: 0.75rem;
border-top: 1px solid rgba(125, 110, 88, 0.15);
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
border: 1px solid rgba(85, 107, 99, 0.4);
font-size: 0.75rem;
color: var(--color-accent-alt);
margin-right: 0.4rem;
margin-bottom: 0.3rem;
}
.badge-author {
background-color: var(--color-accent);
color: var(--color-bg-main);
border: none;
font-family: var(--font-title);
font-weight: 500;
}
.badge-work {
background-color: transparent;
border-color: var(--color-accent-alt);
color: var(--color-accent-alt);
}
.badge-similarity {
background-color: var(--color-accent-alt);
color: var(--color-bg-main);
border: none;
}
.keyword-tag {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.5rem;
border-radius: 999px;
border: 1px solid rgba(85, 107, 99, 0.3);
font-size: 0.7rem;
color: var(--color-accent-alt);
margin: 0.1rem;
}
/* Stats grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.25rem;
margin-bottom: 2rem;
}
.stat-box {
background-color: var(--color-bg-secondary);
border: 1px solid rgba(125, 110, 88, 0.25);
border-radius: 12px;
padding: 1.75rem;
text-align: center;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.03);
}
.stat-number {
font-family: var(--font-title);
font-size: 2.5rem;
font-weight: 700;
color: var(--color-accent);
line-height: 1;
}
.stat-label {
font-family: var(--font-title);
font-size: 0.85rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-accent-alt);
margin-top: 0.5rem;
}
/* Forms */
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-family: var(--font-title);
font-size: 0.9rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-accent-alt);
margin-bottom: 0.4rem;
}
.form-control {
width: 100%;
padding: 0.8rem 1rem;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.08);
background-color: #fff;
font-family: var(--font-body);
font-size: 0.95rem;
color: var(--color-text-main);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.form-control:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(125, 110, 88, 0.1);
}
select.form-control {
cursor: pointer;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
/* Search box */
.search-box {
background-color: var(--color-bg-secondary);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
border: 1px solid rgba(125, 110, 88, 0.2);
}
.search-input {
font-size: 1.1rem;
padding: 1rem 1.25rem;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(125, 110, 88, 0.15);
}
.pagination-info {
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-accent-alt);
}
/* Footer */
.site-footer {
margin-top: 4rem;
padding: 2rem 0 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.04);
text-align: center;
}
.footer-quote {
font-family: var(--font-body);
font-style: italic;
font-size: 0.9rem;
color: var(--color-accent-alt);
}
/* Utilitaires */
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mb-4 { margin-bottom: 2rem; }
.text-center { text-align: center; }
.text-muted { color: rgba(43, 43, 43, 0.7); }
/* Divider */
.divider {
border: none;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(125, 110, 88, 0.3), transparent);
margin: 2rem 0;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-accent-alt);
}
.empty-state-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Lists */
.list-inline {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
}
.list-inline li {
font-family: var(--font-body);
color: var(--color-text-main);
}
/* Alert */
.alert {
padding: 1rem 1.25rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.alert-warning {
background-color: rgba(125, 110, 88, 0.1);
border: 1px solid rgba(125, 110, 88, 0.3);
color: var(--color-accent);
}
.alert-success {
background-color: rgba(85, 107, 99, 0.1);
border: 1px solid rgba(85, 107, 99, 0.3);
color: var(--color-accent-alt);
}
/* Ornament */
.ornament {
text-align: center;
color: var(--color-accent);
font-size: 1rem;
letter-spacing: 0.5em;
opacity: 0.4;
margin: 1.5rem 0;
}
</style>
</head>
<body>
<!-- Hamburger button -->
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
<span></span>
<span></span>
<span></span>
</button>
<!-- Sidebar overlay -->
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<!-- Sidebar navigation -->
<nav class="nav-sidebar" id="nav-sidebar">
<div class="sidebar-header">
<div class="sidebar-header-text">
<div class="sidebar-title">Philosophia</div>
<div class="sidebar-subtitle">Base Weaviate</div>
</div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Fermer le menu"></button>
</div>
<div class="sidebar-nav">
<a href="/" class="{{ 'active' if request.endpoint == 'index' else '' }}">
<span class="icon">🏠</span>
<span>Accueil</span>
</a>
<a href="/passages" class="{{ 'active' if request.endpoint == 'passages' else '' }}">
<span class="icon">📄</span>
<span>Passages</span>
</a>
<a href="/search" class="{{ 'active' if request.endpoint == 'search' else '' }}">
<span class="icon">🔍</span>
<span>Recherche</span>
</a>
<a href="/chat" class="{{ 'active' if request.endpoint == 'chat' else '' }}">
<span class="icon">💬</span>
<span>Conversation</span>
</a>
<a href="/upload" class="{{ 'active' if request.endpoint == 'upload' else '' }}">
<span class="icon">📤</span>
<span>Parser PDF</span>
</a>
<a href="/documents" class="{{ 'active' if request.endpoint == 'documents' else '' }}">
<span class="icon">📚</span>
<span>Documents</span>
</a>
</div>
</nav>
<div class="wrapper">
<!-- Header -->
<header class="site-header">
<div class="header-inner">
<div>
<a href="/" style="text-decoration: none;">
<div class="site-title">Philosophia</div>
<div class="site-subtitle">Visualiseur de base Weaviate</div>
</a>
</div>
<nav class="nav-links">
<a href="/" class="{{ 'active' if request.endpoint == 'index' else '' }}">Accueil</a>
<a href="/passages" class="{{ 'active' if request.endpoint == 'passages' else '' }}">Passages</a>
<a href="/search" class="{{ 'active' if request.endpoint == 'search' else '' }}">Recherche</a>
<a href="/chat" class="{{ 'active' if request.endpoint == 'chat' else '' }}">Conversation</a>
<a href="/upload" class="{{ 'active' if request.endpoint == 'upload' else '' }}">Parser PDF</a>
<a href="/documents" class="{{ 'active' if request.endpoint == 'documents' else '' }}">Documents</a>
</nav>
</div>
</header>
<!-- Main Content -->
<main>
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="site-footer">
<p class="footer-quote">« La philosophie est la médecine de l'âme. » — Cicéron</p>
</footer>
</div>
<!-- Sidebar toggle script -->
<script>
const hamburgerBtn = document.getElementById('hamburger-btn');
const navSidebar = document.getElementById('nav-sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
function toggleSidebar() {
hamburgerBtn.classList.toggle('active');
navSidebar.classList.toggle('visible');
sidebarOverlay.classList.toggle('visible');
}
function closeSidebar() {
hamburgerBtn.classList.remove('active');
navSidebar.classList.remove('visible');
sidebarOverlay.classList.remove('visible');
}
hamburgerBtn.addEventListener('click', toggleSidebar);
sidebarOverlay.addEventListener('click', closeSidebar);
sidebarCloseBtn.addEventListener('click', closeSidebar);
// Close sidebar on link click
const sidebarLinks = navSidebar.querySelectorAll('a');
sidebarLinks.forEach(link => {
link.addEventListener('click', closeSidebar);
});
// Close sidebar on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && navSidebar.classList.contains('visible')) {
closeSidebar();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,541 @@
{% extends "base.html" %}
{% block title %}{{ result.document_name }} - Détails{% endblock %}
{% block content %}
{# Dictionnaire de traduction des types de chunks #}
{% set chunk_types = {
'main_content': {'label': 'Contenu principal', 'icon': '📄', 'desc': 'Paragraphe de contenu substantiel', 'color': 'rgba(125, 110, 88, 0.15)'},
'exposition': {'label': 'Exposition', 'icon': '📖', 'desc': 'Présentation d\'idées ou de contexte', 'color': 'rgba(85, 107, 99, 0.15)'},
'argument': {'label': 'Argument', 'icon': '💭', 'desc': 'Raisonnement ou argumentation', 'color': 'rgba(164, 132, 92, 0.15)'},
'définition': {'label': 'Définition', 'icon': '📌', 'desc': 'Définition de concept ou terme', 'color': 'rgba(125, 110, 88, 0.2)'},
'example': {'label': 'Exemple', 'icon': '💡', 'desc': 'Illustration ou cas pratique', 'color': 'rgba(218, 188, 134, 0.2)'},
'citation': {'label': 'Citation', 'icon': '💬', 'desc': 'Citation d\'auteur ou référence', 'color': 'rgba(85, 107, 99, 0.2)'},
'abstract': {'label': 'Résumé', 'icon': '📋', 'desc': 'Résumé ou synthèse', 'color': 'rgba(164, 132, 92, 0.2)'},
'preface': {'label': 'Préface', 'icon': '✍️', 'desc': 'Préface, avant-propos ou avertissement', 'color': 'rgba(85, 107, 99, 0.15)'},
'conclusion': {'label': 'Conclusion', 'icon': '🎯', 'desc': 'Conclusion d\'une argumentation', 'color': 'rgba(125, 110, 88, 0.2)'}
} %}
<style>
/* TOC hiérarchique */
.toc-tree {
list-style: none;
padding-left: 0;
margin: 0;
}
.toc-tree ul {
list-style: none;
padding-left: 1.5rem;
margin: 0;
display: none;
}
.toc-tree ul.expanded {
display: block;
}
.toc-item {
padding: 0.4rem 0;
border-bottom: 1px solid rgba(125, 110, 88, 0.1);
}
.toc-item-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.toc-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-accent);
font-size: 0.8rem;
transition: transform 0.2s;
}
.toc-toggle.expanded {
transform: rotate(90deg);
}
.toc-toggle.no-children {
visibility: hidden;
}
.toc-level-1 { font-weight: bold; color: var(--color-text-main); }
.toc-level-2 { color: var(--color-accent-alt); padding-left: 0.5rem; }
.toc-level-3 { color: var(--color-text-muted); font-size: 0.9rem; padding-left: 0.5rem; }
.toc-level-4 { color: var(--color-text-muted); font-size: 0.85rem; font-style: italic; padding-left: 0.5rem; }
/* Passages dépliables */
.passage-card {
background: var(--color-bg-secondary);
border-radius: 8px;
margin-bottom: 0.75rem;
border-left: 3px solid var(--color-accent);
overflow: hidden;
}
.passage-header {
padding: 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: flex-start;
transition: background-color 0.2s;
}
.passage-header:hover {
background-color: rgba(125, 110, 88, 0.05);
}
.passage-toggle {
color: var(--color-accent);
font-size: 1.2rem;
transition: transform 0.2s;
}
.passage-toggle.expanded {
transform: rotate(180deg);
}
.passage-content {
display: none;
padding: 0 1rem 1rem 1rem;
border-top: 1px solid rgba(125, 110, 88, 0.1);
}
.passage-content.expanded {
display: block;
}
.passage-text {
font-style: italic;
color: var(--color-text-main);
font-size: 0.9rem;
line-height: 1.6;
background: var(--color-bg-main);
padding: 1rem;
border-radius: 6px;
margin-top: 0.75rem;
max-height: 300px;
overflow-y: auto;
}
.passage-meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
margin-top: 0.75rem;
}
.passage-meta-item {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.passage-meta-label {
color: var(--color-text-muted);
min-width: 80px;
}
.concepts-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.5rem;
}
.concept-tag {
background: var(--color-accent);
color: var(--color-bg-main);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
}
/* Expand/Collapse all */
.toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.toolbar button {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
background: var(--color-bg-secondary);
border: 1px solid rgba(125, 110, 88, 0.3);
border-radius: 4px;
cursor: pointer;
color: var(--color-text-main);
}
.toolbar button:hover {
background: var(--color-accent);
color: var(--color-bg-main);
}
</style>
<section class="section">
<h1>📄 {{ result.document_name }}</h1>
<p class="lead">Détails du document traité</p>
<div class="ornament">· · ·</div>
<!-- Statistiques -->
<div class="stats-grid">
<div class="stat-box">
<div class="stat-number">{{ result.pages or 0 }}</div>
<div class="stat-label">Pages</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ result.chunks_count or 0 }}</div>
<div class="stat-label">Chunks</div>
</div>
{% if result.weaviate_ingest and result.weaviate_ingest.success %}
<div class="stat-box">
<div class="stat-number">{{ result.weaviate_ingest.count }}</div>
<div class="stat-label">Dans Weaviate</div>
</div>
{% endif %}
{% if result.toc %}
<div class="stat-box">
<div class="stat-number">{{ result.flat_toc|length if result.flat_toc else result.toc|length }}</div>
<div class="stat-label">Entrées TOC</div>
</div>
{% endif %}
</div>
<hr class="divider">
<!-- Métadonnées du document -->
<div class="card">
<h3>📖 Informations du document</h3>
<div class="mt-2">
<table style="width: 100%; border-collapse: collapse;">
{% if result.metadata.title %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0; width: 150px;"><strong>Titre</strong></td>
<td style="padding: 0.75rem 0;">{{ result.metadata.title }}</td>
</tr>
{% endif %}
{% if result.metadata.author %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Auteur</strong></td>
<td style="padding: 0.75rem 0;">
<span class="badge badge-author">{{ result.metadata.author }}</span>
</td>
</tr>
{% endif %}
{% if result.metadata.publisher %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Éditeur</strong></td>
<td style="padding: 0.75rem 0;">{{ result.metadata.publisher }}</td>
</tr>
{% endif %}
{% if result.metadata.year %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Année</strong></td>
<td style="padding: 0.75rem 0;">{{ result.metadata.year }}</td>
</tr>
{% endif %}
{% if result.metadata.doi %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>DOI</strong></td>
<td style="padding: 0.75rem 0;"><code>{{ result.metadata.doi }}</code></td>
</tr>
{% endif %}
{% if result.metadata.isbn %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>ISBN</strong></td>
<td style="padding: 0.75rem 0;"><code>{{ result.metadata.isbn }}</code></td>
</tr>
{% endif %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Pages</strong></td>
<td style="padding: 0.75rem 0;">{{ result.pages or 0 }}</td>
</tr>
<tr>
<td style="padding: 0.75rem 0;"><strong>Chunks</strong></td>
<td style="padding: 0.75rem 0;">{{ result.chunks_count or 0 }} segments de texte</td>
</tr>
</table>
</div>
</div>
<!-- Table des matières hiérarchique -->
{% if result.toc and result.toc|length > 0 %}
<div class="card mt-3">
<h3>📑 Table des matières ({{ result.flat_toc|length if result.flat_toc else '?' }} entrées)</h3>
<div class="toolbar">
<button onclick="expandAllToc()">▼ Tout déplier</button>
<button onclick="collapseAllToc()">▲ Tout replier</button>
</div>
<div class="mt-2">
<ul class="toc-tree" id="toc-tree">
{% macro render_toc(items) %}
{% for item in items %}
<li class="toc-item">
<div class="toc-item-header" onclick="toggleTocItem(this)">
<span class="toc-toggle {% if not item.children or item.children|length == 0 %}no-children{% endif %}"></span>
<span class="toc-level-{{ item.level }}">{{ item.title }}</span>
</div>
{% if item.children and item.children|length > 0 %}
<ul>
{{ render_toc(item.children) }}
</ul>
{% endif %}
</li>
{% endfor %}
{% endmacro %}
{{ render_toc(result.toc) }}
</ul>
</div>
</div>
{% endif %}
<hr class="divider">
<!-- Fichiers générés -->
<div class="card">
<h3>📁 Fichiers générés</h3>
<div class="mt-2">
<table style="width: 100%; border-collapse: collapse;">
{% if result.files.markdown %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Markdown</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}.md" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% endif %}
{% if result.files.chunks %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Chunks JSON</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}_chunks.json" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% endif %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>OCR brut</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}_ocr.json" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% if result.files.weaviate %}
<tr>
<td style="padding: 0.75rem 0;"><strong>Weaviate JSON</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}_weaviate.json" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% endif %}
</table>
</div>
</div>
<!-- Tous les passages avec métadonnées -->
{% if result.chunks and result.chunks|length > 0 %}
<div class="card mt-3">
<h3>📝 Passages ({{ result.chunks|length }})</h3>
<div class="toolbar">
<button onclick="expandAllPassages()">▼ Tout déplier</button>
<button onclick="collapseAllPassages()">▲ Tout replier</button>
</div>
<div class="mt-2" id="passages-container">
{% for chunk in result.chunks %}
{% set level = chunk.section_level or chunk.sectionLevel or 1 %}
<div class="passage-card" data-index="{{ loop.index0 }}" style="{% if level > 1 %}margin-left: {{ (level - 1) * 1 }}rem; border-left: 3px solid {% if level == 2 %}var(--color-accent-alt){% else %}rgba(125, 110, 88, 0.3){% endif %};{% endif %}">
<div class="passage-header" onclick="togglePassage(this)">
<div style="flex: 1;">
<!-- Hiérarchie visuelle -->
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
{% if chunk.chapter_title and chunk.chapter_title != chunk.section and level > 1 %}
<span style="font-size: 0.75rem; color: var(--color-text-muted);">{{ chunk.chapter_title }} </span>
{% endif %}
{% if chunk.subsection_title and chunk.subsection_title != chunk.chapter_title and chunk.subsection_title != chunk.section %}
<span style="font-size: 0.75rem; color: var(--color-accent-alt);">{{ chunk.subsection_title }} </span>
{% endif %}
{% if chunk.paragraph_number %}
<span class="badge" style="background-color: var(--color-accent); color: white; font-weight: bold;">
§ {{ chunk.paragraph_number }}
</span>
{% endif %}
<span class="badge badge-work" style="{% if level == 1 %}background-color: var(--color-accent); color: white;{% endif %}">
{% if level == 1 %}📚{% elif level == 2 %}📖{% else %}📄{% endif %}
{{ chunk.section or 'Sans section' }}
</span>
{% if chunk.type %}
{% set type_info = chunk_types.get(chunk.type, {'label': chunk.type, 'icon': '📝', 'desc': 'Type de contenu', 'color': 'rgba(125, 110, 88, 0.15)'}) %}
<span class="type-badge" style="background: {{ type_info.color }};" title="{{ type_info.desc }}">
{{ type_info.icon }} {{ type_info.label }}
</span>
{% endif %}
</div>
{% if chunk.summary %}
<div style="margin-top: 0.3rem; font-size: 0.85rem; color: var(--color-text-muted);">
{{ chunk.summary[:100] }}{% if chunk.summary|length > 100 %}...{% endif %}
</div>
{% endif %}
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="caption">{{ chunk.chunk_id or 'chunk_' ~ loop.index0 }}</span>
<span class="passage-toggle"></span>
</div>
</div>
<div class="passage-content">
{% set level = chunk.section_level or chunk.sectionLevel or 1 %}
<!-- Métadonnées simplifiées -->
<div style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; padding: 0.75rem 0; border-bottom: 1px solid rgba(125, 110, 88, 0.1);">
<!-- Hiérarchie -->
{% if chunk.chapter_title and chunk.chapter_title != chunk.section %}
<span style="font-size: 0.85rem; color: var(--color-text-muted);">
📚 {{ chunk.chapter_title }}
</span>
<span style="color: var(--color-text-muted);"></span>
{% endif %}
{% if chunk.section %}
<span style="font-size: 0.85rem; {% if level == 1 %}font-weight: 600; color: var(--color-accent);{% else %}color: var(--color-accent-alt);{% endif %}">
{% if level == 1 %}📖{% elif level == 2 %}📄{% else %}📃{% endif %} {{ chunk.section }}
</span>
{% endif %}
<!-- Type -->
{% if chunk.type %}
{% set type_info = chunk_types.get(chunk.type, {'label': chunk.type, 'icon': '📝', 'desc': 'Type de contenu', 'color': 'rgba(125, 110, 88, 0.15)'}) %}
<span style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px; background: {{ type_info.color }};" title="{{ type_info.desc }}">
{{ type_info.icon }} {{ type_info.label }}
</span>
{% endif %}
<!-- Niveau -->
<span style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px;
{% if level == 1 %}background-color: var(--color-accent); color: white;
{% elif level == 2 %}background-color: var(--color-accent-alt); color: white;
{% else %}background-color: rgba(125, 110, 88, 0.2);{% endif %}">
Niv. {{ level }}
</span>
<!-- Paragraphe -->
{% if chunk.paragraph_number %}
<span style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px; background-color: var(--color-accent); color: white;">
§ {{ chunk.paragraph_number }}
</span>
{% endif %}
</div>
<!-- Concepts si présents -->
{% if chunk.concepts and chunk.concepts|length > 0 %}
<div style="padding: 0.5rem 0;">
<div class="concepts-list">
{% for concept in chunk.concepts %}
<span class="concept-tag">{{ concept }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Texte complet -->
<div class="passage-text">
{{ chunk.text }}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Données Weaviate -->
{% if result.weaviate_ingest %}
<div class="card mt-3">
<h3>🗄️ Ingestion Weaviate</h3>
<div class="mt-2">
{% if result.weaviate_ingest.success %}
<div class="alert alert-success" style="background-color: rgba(85, 107, 99, 0.1); border: 1px solid rgba(85, 107, 99, 0.3); color: var(--color-accent-alt); padding: 1rem; border-radius: 8px;">
<strong>✓ Ingestion réussie :</strong> {{ result.weaviate_ingest.count }} passages insérés dans la collection <code>Passage</code>
</div>
{% else %}
<div class="alert alert-warning" style="background-color: rgba(125, 110, 88, 0.1); border: 1px solid rgba(125, 110, 88, 0.3); color: var(--color-accent); padding: 1rem; border-radius: 8px;">
<strong>⚠️ Erreur d'ingestion :</strong> {{ result.weaviate_ingest.error }}
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Images extraites -->
{% if result.files.images %}
<div class="card mt-3">
<h3>🖼️ Images extraites ({{ result.files.images|length }})</h3>
<div class="mt-2" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem;">
{% for img in result.files.images[:12] %}
<div style="text-align: center;">
<a href="/output/{{ result.document_name }}/images/{{ img.split('/')[-1].split('\\')[-1] }}" target="_blank">
<img
src="/output/{{ result.document_name }}/images/{{ img.split('/')[-1].split('\\')[-1] }}"
alt="Image"
style="max-width: 100%; max-height: 120px; border-radius: 8px; border: 1px solid rgba(125, 110, 88, 0.2);"
>
</a>
<div class="caption">{{ img.split('/')[-1].split('\\')[-1] }}</div>
</div>
{% endfor %}
{% if result.files.images|length > 12 %}
<div style="display: flex; align-items: center; justify-content: center;">
<span class="text-muted">+ {{ result.files.images|length - 12 }} autres</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="text-center mt-4">
<a href="/documents" class="btn btn-primary">← Retour aux documents</a>
<a href="/upload" class="btn" style="margin-left: 0.5rem;">Analyser un autre PDF</a>
</div>
</section>
<script>
// TOC toggle
function toggleTocItem(header) {
const item = header.parentElement;
const toggle = header.querySelector('.toc-toggle');
const children = item.querySelector('ul');
if (children) {
children.classList.toggle('expanded');
toggle.classList.toggle('expanded');
}
}
function expandAllToc() {
document.querySelectorAll('.toc-tree ul').forEach(ul => ul.classList.add('expanded'));
document.querySelectorAll('.toc-toggle').forEach(t => t.classList.add('expanded'));
}
function collapseAllToc() {
document.querySelectorAll('.toc-tree ul').forEach(ul => ul.classList.remove('expanded'));
document.querySelectorAll('.toc-toggle').forEach(t => t.classList.remove('expanded'));
}
// Passages toggle
function togglePassage(header) {
const card = header.parentElement;
const content = card.querySelector('.passage-content');
const toggle = header.querySelector('.passage-toggle');
content.classList.toggle('expanded');
toggle.classList.toggle('expanded');
}
function expandAllPassages() {
document.querySelectorAll('.passage-content').forEach(c => c.classList.add('expanded'));
document.querySelectorAll('.passage-toggle').forEach(t => t.classList.add('expanded'));
}
function collapseAllPassages() {
document.querySelectorAll('.passage-content').forEach(c => c.classList.remove('expanded'));
document.querySelectorAll('.passage-toggle').forEach(t => t.classList.remove('expanded'));
}
</script>
{% endblock %}

View File

@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block title %}Documents{% endblock %}
{% block content %}
<!-- Messages flash -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div style="max-width: 900px; margin: 0 auto 2rem auto;">
{% for category, message in messages %}
<div class="alert alert-{{ category }}" style="
padding: 1rem 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
border-left: 4px solid;
{% if category == 'success' %}
background-color: rgba(85, 107, 99, 0.1);
border-color: var(--color-accent-alt);
color: var(--color-accent-alt);
{% elif category == 'warning' %}
background-color: rgba(218, 188, 134, 0.15);
border-color: #dabc86;
color: #a89159;
{% elif category == 'error' %}
background-color: rgba(160, 82, 82, 0.1);
border-color: #a05252;
color: #a05252;
{% else %}
background-color: rgba(125, 110, 88, 0.1);
border-color: var(--color-accent);
color: var(--color-text-main);
{% endif %}
">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<section class="section">
<h1>📚 Documents traités</h1>
<p class="lead">Liste des documents analysés par le parser PDF</p>
{% if documents %}
<div class="stats-grid mb-4">
<div class="stat-box">
<div class="stat-number">{{ documents|length }}</div>
<div class="stat-label">Documents</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ documents|sum(attribute='summaries_count') }}</div>
<div class="stat-label">Résumés totaux</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ documents|sum(attribute='chunks_count') }}</div>
<div class="stat-label">Chunks totaux</div>
</div>
</div>
{% for doc in documents %}
<div class="passage-card">
<div class="passage-header">
<div>
<span class="badge badge-author">{{ doc.name }}</span>
{% if doc.has_structured %}
<span class="badge badge-similarity">LLM</span>
{% endif %}
</div>
<div>
{% if doc.summaries_count %}
<span class="badge">{{ doc.summaries_count }} résumés</span>
{% endif %}
{% if doc.authors_count %}
<span class="badge">{{ doc.authors_count }} auteur{{ 's' if doc.authors_count > 1 else '' }}</span>
{% endif %}
{% if doc.chunks_count %}
<span class="badge">{{ doc.chunks_count }} chunks</span>
{% endif %}
{% if doc.has_images %}
<span class="badge">{{ doc.image_count }} images</span>
{% endif %}
</div>
</div>
<!-- Métadonnées -->
{% if doc.title or doc.author %}
<div class="mt-2 mb-2">
{% if doc.title %}
<div><strong>Titre :</strong> {{ doc.title }}</div>
{% endif %}
{% if doc.author %}
<div><strong>Auteur :</strong> {{ doc.author }}</div>
{% endif %}
</div>
{% endif %}
<!-- Table des matières (aperçu) -->
{% if doc.toc and doc.toc|length > 0 %}
<div class="mt-2 mb-2" style="background: var(--color-bg-secondary); padding: 0.75rem 1rem; border-radius: 8px;">
<strong style="font-size: 0.85rem; color: var(--color-accent-alt);">Table des matières :</strong>
<ul style="list-style: none; padding-left: 0; margin: 0.5rem 0 0 0; font-size: 0.9rem;">
{% for item in doc.toc[:5] %}
<li style="padding: 0.2rem 0; padding-left: {{ (item.level - 1) * 1 }}rem;">
{% if item.level == 1 %}
<strong>{{ item.title }}</strong>
{% else %}
<span style="color: var(--color-accent-alt);">{{ item.title }}</span>
{% endif %}
</li>
{% endfor %}
{% if doc.toc|length > 5 %}
<li style="padding: 0.2rem 0; color: var(--color-accent); font-style: italic;">
... et {{ doc.toc|length - 5 }} autres sections
</li>
{% endif %}
</ul>
</div>
{% endif %}
<!-- Boutons d'accès aux fichiers -->
<div class="mt-2">
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<a href="/documents/{{ doc.name }}/view" class="btn btn-sm btn-primary">
👁️ Voir détails
</a>
{% if doc.has_markdown %}
<a href="/output/{{ doc.name }}/{{ doc.name }}.md" target="_blank" class="btn btn-sm">
📄 Markdown
</a>
{% endif %}
{% if doc.has_chunks %}
<a href="/output/{{ doc.name }}/{{ doc.name }}_chunks.json" target="_blank" class="btn btn-sm">
📊 Chunks
</a>
{% endif %}
{% if doc.has_structured %}
<a href="/output/{{ doc.name }}/{{ doc.name }}_structured.json" target="_blank" class="btn btn-sm">
🧠 Structure LLM
</a>
{% endif %}
<a href="/output/{{ doc.name }}/{{ doc.name }}_ocr.json" target="_blank" class="btn btn-sm">
🔍 OCR brut
</a>
</div>
</div>
<div class="passage-meta mt-2" style="display: flex; justify-content: space-between; align-items: center;">
<span><strong>Dossier :</strong> output/{{ doc.name }}/</span>
<form action="/documents/delete/{{ doc.name }}" method="post" style="margin: 0;" onsubmit="return confirm('⚠️ Supprimer le document « {{ doc.name }} » ?\n\n• Fichiers locaux (markdown, chunks, images)\n• Chunks dans Weaviate ({{ doc.chunks_count or 0 }} passages)\n\n⚠ Cette action est IRRÉVERSIBLE.');">
<button type="submit" class="btn btn-sm" style="color: #a05252; border-color: #a05252; padding: 0.3rem 0.6rem;" title="Supprimer ce document et ses données Weaviate">
🗑️ Supprimer
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h3>Aucun document traité</h3>
<p class="text-muted">Uploadez un PDF pour commencer.</p>
</div>
{% endif %}
<div class="text-center mt-4">
<a href="/upload" class="btn btn-primary">Analyser un PDF</a>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}Accueil{% endblock %}
{% block content %}
<section class="section">
<h1 class="text-center">Bienvenue sur Philosophia</h1>
<p class="lead text-center">Explorez les textes philosophiques indexés dans Weaviate</p>
<div class="ornament">· · ·</div>
{% if stats %}
<!-- Statistics -->
<div class="stats-grid">
<div class="stat-box">
<div class="stat-number">{{ stats.passages }}</div>
<div class="stat-label">Passages</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ stats.works }}</div>
<div class="stat-label">Œuvres</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ stats.authors }}</div>
<div class="stat-label">Auteurs</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ stats.languages }}</div>
<div class="stat-label">Langues</div>
</div>
</div>
<hr class="divider">
<div class="form-row">
<!-- Works -->
<div class="card">
<h3>📖 Œuvres disponibles</h3>
{% if stats.work_list %}
<ul class="mt-2" style="list-style: none;">
{% for work in stats.work_list %}
<li style="padding: 0.3rem 0;">
<a href="/passages?work={{ work | urlencode }}">{{ work }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">Aucune œuvre trouvée</p>
{% endif %}
</div>
<!-- Authors -->
<div class="card">
<h3>✍️ Auteurs</h3>
{% if stats.author_list %}
<ul class="mt-2" style="list-style: none;">
{% for author in stats.author_list %}
<li style="padding: 0.3rem 0;">
<a href="/passages?author={{ author | urlencode }}">{{ author }}</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">Aucun auteur trouvé</p>
{% endif %}
</div>
</div>
<hr class="divider">
<!-- How to use -->
<div class="card">
<h3>💡 Comment utiliser Philosophia ?</h3>
<div class="mt-2">
<p><strong>1. Parcourir les passages</strong> — Consultez tous les passages indexés avec filtres par auteur ou œuvre</p>
<p><strong>2. Recherche sémantique</strong> — Posez une question en langage naturel pour trouver des passages pertinents</p>
<p class="mb-1">Exemples de recherches :</p>
<ul class="list-inline">
<li><span class="badge">Qu'est-ce que la vertu ?</span></li>
<li><span class="badge">La mort est-elle à craindre ?</span></li>
<li><span class="badge">Comment vivre une vie juste ?</span></li>
</ul>
</div>
</div>
<div class="text-center mt-4">
<a href="/search" class="btn btn-primary">Commencer une recherche</a>
<a href="/passages" class="btn" style="margin-left: 0.5rem;">Parcourir les passages</a>
</div>
{% else %}
<div class="alert alert-warning">
<strong>⚠️ Base de données non disponible</strong><br>
Assurez-vous que Weaviate est démarré et que les données sont chargées.
</div>
<div class="card">
<h3>Pour démarrer :</h3>
<pre style="background: var(--color-bg-secondary); padding: 1rem; border-radius: 8px; margin-top: 1rem; overflow-x: auto;">docker-compose up -d
python schema.py
python ingest_test.py</pre>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}Passages{% endblock %}
{% block content %}
<section class="section">
<h1>📚 Parcourir les passages</h1>
<p class="lead">Explorez tous les passages indexés dans la base de données</p>
<!-- Filters -->
<div class="search-box">
<form method="get" action="/passages">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="author">Auteur</label>
<select name="author" id="author" class="form-control">
<option value="">Tous les auteurs</option>
{% if stats and stats.author_list %}
{% for author in stats.author_list %}
<option value="{{ author }}" {{ 'selected' if author_filter == author else '' }}>{{ author }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="form-group">
<label class="form-label" for="work">Œuvre</label>
<select name="work" id="work" class="form-control">
<option value="">Toutes les œuvres</option>
{% if stats and stats.work_list %}
{% for work in stats.work_list %}
<option value="{{ work }}" {{ 'selected' if work_filter == work else '' }}>{{ work }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="form-group">
<label class="form-label" for="per_page">Par page</label>
<select name="per_page" id="per_page" class="form-control">
<option value="10" {{ 'selected' if per_page == 10 else '' }}>10</option>
<option value="20" {{ 'selected' if per_page == 20 else '' }}>20</option>
<option value="50" {{ 'selected' if per_page == 50 else '' }}>50</option>
</select>
</div>
</div>
<div class="mt-2">
<button type="submit" class="btn btn-primary">Filtrer</button>
<a href="/passages" class="btn" style="margin-left: 0.5rem;">Réinitialiser</a>
</div>
</form>
</div>
<!-- Active filters -->
{% if author_filter or work_filter %}
<div class="mb-3">
<span class="text-muted">Filtres actifs :</span>
{% if author_filter %}
<span class="badge badge-author">{{ author_filter }}</span>
{% endif %}
{% if work_filter %}
<span class="badge badge-work">{{ work_filter }}</span>
{% endif %}
</div>
{% endif %}
<!-- Chunks list -->
{% if chunks %}
{% for chunk in chunks %}
<div class="passage-card">
<div class="passage-header">
<div>
<span class="badge badge-work">{{ chunk.work.title if chunk.work else '?' }} {{ chunk.sectionPath or '' }}</span>
<span class="badge badge-author">{{ chunk.work.author if chunk.work else 'Anonyme' }}</span>
</div>
</div>
<div class="passage-text">"{{ chunk.text }}"</div>
<div class="passage-meta">
<strong>Type :</strong> {{ chunk.unitType or '—' }} &nbsp;&nbsp;
<strong>Langue :</strong> {{ (chunk.language or '—') | upper }} &nbsp;&nbsp;
<strong>Index :</strong> {{ chunk.orderIndex or '—' }}
</div>
{% if chunk.keywords %}
<div class="mt-2">
{% for kw in chunk.keywords %}
<span class="keyword-tag">{{ kw }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
<!-- Pagination -->
<div class="pagination">
{% if page > 1 %}
<a href="/passages?page={{ page - 1 }}&per_page={{ per_page }}{% if author_filter %}&author={{ author_filter | urlencode }}{% endif %}{% if work_filter %}&work={{ work_filter | urlencode }}{% endif %}" class="btn btn-sm">← Précédent</a>
{% else %}
<span class="btn btn-sm" style="opacity: 0.4; cursor: not-allowed;">← Précédent</span>
{% endif %}
<span class="pagination-info">Page {{ page }}</span>
{% if passages | length >= per_page %}
<a href="/passages?page={{ page + 1 }}&per_page={{ per_page }}{% if author_filter %}&author={{ author_filter | urlencode }}{% endif %}{% if work_filter %}&work={{ work_filter | urlencode }}{% endif %}" class="btn btn-sm">Suivant →</a>
{% else %}
<span class="btn btn-sm" style="opacity: 0.4; cursor: not-allowed;">Suivant →</span>
{% endif %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h3>Aucun passage trouvé</h3>
<p class="text-muted">Essayez de modifier vos filtres ou <a href="/passages">réinitialisez</a>.</p>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block title %}Recherche{% endblock %}
{% block content %}
<section class="section">
<h1>🔍 Recherche sémantique</h1>
<p class="lead">Posez une question en langage naturel pour trouver des passages pertinents</p>
<!-- Search form -->
<div class="search-box">
<form method="get" action="/search">
<div class="form-group">
<label class="form-label" for="q">Votre question</label>
<input
type="text"
name="q"
id="q"
class="form-control search-input"
value="{{ query }}"
placeholder="Ex: Qu'est-ce que la sagesse ? Pourquoi philosopher ?"
autofocus
>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="author">Auteur</label>
<select name="author" id="author" class="form-control">
<option value="">Tous les auteurs</option>
{% if stats and stats.author_list %}
{% for author in stats.author_list %}
<option value="{{ author }}" {{ 'selected' if author_filter == author else '' }}>{{ author }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="form-group">
<label class="form-label" for="work">Œuvre</label>
<select name="work" id="work" class="form-control">
<option value="">Toutes les œuvres</option>
{% if stats and stats.work_list %}
{% for work in stats.work_list %}
<option value="{{ work }}" {{ 'selected' if work_filter == work else '' }}>{{ work }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="form-group">
<label class="form-label" for="limit">Résultats</label>
<select name="limit" id="limit" class="form-control">
<option value="5" {{ 'selected' if limit == 5 else '' }}>5</option>
<option value="10" {{ 'selected' if limit == 10 else '' }}>10</option>
<option value="20" {{ 'selected' if limit == 20 else '' }}>20</option>
</select>
</div>
</div>
<div class="mt-2">
<button type="submit" class="btn btn-primary">Rechercher</button>
<a href="/search" class="btn" style="margin-left: 0.5rem;">Réinitialiser</a>
</div>
</form>
</div>
<!-- Results -->
{% if query %}
<div class="ornament">·</div>
{% if results %}
<div class="mb-3">
<strong>{{ results | length }}</strong> passage{% if results | length > 1 %}s{% endif %} trouvé{% if results | length > 1 %}s{% endif %}
{% if author_filter or work_filter %}
<span class="text-muted"></span>
{% if author_filter %}
<span class="badge badge-author">{{ author_filter }}</span>
{% endif %}
{% if work_filter %}
<span class="badge badge-work">{{ work_filter }}</span>
{% endif %}
{% endif %}
</div>
{% for result in results %}
<div class="passage-card">
<div class="passage-header">
<div>
<span class="badge badge-work">{{ result.work.title if result.work else '?' }} {{ result.sectionPath or '' }}</span>
<span class="badge badge-author">{{ result.work.author if result.work else 'Anonyme' }}</span>
</div>
{% if result.similarity %}
<span class="badge badge-similarity">⚡ {{ result.similarity }}% similaire</span>
{% endif %}
</div>
<div class="passage-text">"{{ result.text }}"</div>
<div class="passage-meta">
<strong>Type :</strong> {{ result.unitType or '—' }} &nbsp;&nbsp;
<strong>Langue :</strong> {{ (result.language or '—') | upper }} &nbsp;&nbsp;
<strong>Index :</strong> {{ result.orderIndex or '—' }}
</div>
{% if result.keywords %}
<div class="mt-2">
{% for kw in result.keywords %}
<span class="keyword-tag">{{ kw }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="empty-state-icon">🔮</div>
<h3>Aucun résultat trouvé</h3>
<p class="text-muted">Essayez une autre formulation ou modifiez vos filtres.</p>
</div>
{% endif %}
{% else %}
<!-- Suggestions -->
<div class="card">
<h3>💡 Suggestions de recherche</h3>
<div class="mt-2">
<p class="mb-2">Voici quelques exemples de questions que vous pouvez poser :</p>
<div>
<a href="/search?q=Qu%27est-ce%20que%20la%20vertu%20%3F" class="badge" style="cursor: pointer;">Qu'est-ce que la vertu ?</a>
<a href="/search?q=La%20mort%20est-elle%20%C3%A0%20craindre%20%3F" class="badge" style="cursor: pointer;">La mort est-elle à craindre ?</a>
<a href="/search?q=Comment%20atteindre%20le%20bonheur%20%3F" class="badge" style="cursor: pointer;">Comment atteindre le bonheur ?</a>
<a href="/search?q=Qu%27est-ce%20que%20la%20justice%20%3F" class="badge" style="cursor: pointer;">Qu'est-ce que la justice ?</a>
<a href="/search?q=L%27%C3%A2me%20est-elle%20immortelle%20%3F" class="badge" style="cursor: pointer;">L'âme est-elle immortelle ?</a>
</div>
</div>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Chat Backend</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1000px;
margin: 2rem auto;
padding: 0 1rem;
background: #f5f5f5;
}
h1 {
color: #333;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: #555;
}
input, select, textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
textarea {
min-height: 100px;
font-family: inherit;
}
button {
background: #007bff;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.output {
margin-top: 2rem;
padding: 1.5rem;
background: #f9f9f9;
border-radius: 4px;
border: 1px solid #ddd;
}
.output h3 {
margin-top: 0;
}
.context {
background: #e3f2fd;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.context-item {
background: white;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
border-left: 3px solid #2196f3;
}
.response {
background: white;
padding: 1.5rem;
border-radius: 4px;
white-space: pre-wrap;
font-family: -apple-system, system-ui, sans-serif;
line-height: 1.6;
min-height: 100px;
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
}
.status-searching { background: #fff3cd; color: #856404; }
.status-generating { background: #d1ecf1; color: #0c5460; }
.status-complete { background: #d4edda; color: #155724; }
.status-error { background: #f8d7da; color: #721c24; }
.log {
font-family: monospace;
font-size: 0.85rem;
color: #666;
margin-top: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 Test Chat Backend RAG</h1>
<div class="form-group">
<label for="question">Question :</label>
<textarea id="question" placeholder="Qu'est-ce que la vertu ?">Qu'est-ce que la vertu ?</textarea>
</div>
<div class="form-group">
<label for="provider">Provider :</label>
<select id="provider">
<option value="mistral">Mistral API</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI</option>
<option value="ollama">Ollama (local)</option>
</select>
</div>
<div class="form-group">
<label for="model">Model :</label>
<select id="model">
<!-- Mistral models -->
<option value="mistral-small-latest" data-provider="mistral">mistral-small-latest</option>
<option value="mistral-large-latest" data-provider="mistral">mistral-large-latest</option>
<!-- Anthropic models -->
<option value="claude-sonnet-4-5-20250929" data-provider="anthropic">claude-sonnet-4-5</option>
<option value="claude-opus-4-5-20251101" data-provider="anthropic">claude-opus-4-5</option>
<!-- OpenAI models -->
<option value="gpt-5.2" data-provider="openai">ChatGPT 5.2</option>
<option value="gpt-4o" data-provider="openai">GPT-4o</option>
<option value="gpt-4o-mini" data-provider="openai">GPT-4o Mini</option>
<option value="o1-preview" data-provider="openai">o1-preview</option>
<!-- Ollama models -->
<option value="qwen2.5:7b" data-provider="ollama">qwen2.5:7b</option>
</select>
</div>
<div class="form-group">
<label for="limit">Nombre de contextes RAG :</label>
<input type="number" id="limit" value="3" min="1" max="10">
</div>
<button id="sendBtn" onclick="sendQuestion()">Envoyer</button>
<div class="output" id="output" style="display: none;">
<h3>Résultat :</h3>
<div class="log" id="log"></div>
<div id="contextSection" style="display: none;">
<h4>📚 Contexte RAG :</h4>
<div class="context" id="context"></div>
</div>
<div id="responseSection" style="display: none;">
<h4>💬 Réponse :</h4>
<div class="response" id="response"></div>
</div>
</div>
</div>
<script>
// Auto-select model when provider changes
document.getElementById('provider').addEventListener('change', function() {
const provider = this.value;
const modelSelect = document.getElementById('model');
const options = modelSelect.querySelectorAll('option');
for (let option of options) {
if (option.dataset.provider === provider) {
option.selected = true;
break;
}
}
});
async function sendQuestion() {
const question = document.getElementById('question').value.trim();
const provider = document.getElementById('provider').value;
const model = document.getElementById('model').value;
const limit = parseInt(document.getElementById('limit').value);
if (!question) {
alert('Veuillez entrer une question');
return;
}
// Reset UI
document.getElementById('output').style.display = 'block';
document.getElementById('contextSection').style.display = 'none';
document.getElementById('responseSection').style.display = 'none';
document.getElementById('context').innerHTML = '';
document.getElementById('response').textContent = '';
document.getElementById('sendBtn').disabled = true;
// Log
const logDiv = document.getElementById('log');
logDiv.innerHTML = `<span class="status status-searching">Envoi...</span>`;
try {
// Step 1: POST /chat/send
const response = await fetch('/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, provider, model, limit })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erreur HTTP');
}
const data = await response.json();
const sessionId = data.session_id;
logDiv.innerHTML = `<span class="status status-generating">Session: ${sessionId}</span>`;
// Step 2: SSE /chat/stream/<session_id>
const eventSource = new EventSource(`/chat/stream/${sessionId}`);
eventSource.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
if (data.type === 'context') {
// Show RAG context
document.getElementById('contextSection').style.display = 'block';
const contextDiv = document.getElementById('context');
contextDiv.innerHTML = data.chunks.map((chunk, i) => `
<div class="context-item">
<strong>Passage ${i + 1}</strong> (${chunk.similarity}%) - ${chunk.author} - ${chunk.work}<br>
<small>${chunk.section}</small><br>
<div style="margin-top: 0.5rem; font-size: 0.9rem;">${chunk.text.substring(0, 200)}...</div>
</div>
`).join('');
}
else if (data.type === 'token') {
// Stream tokens
document.getElementById('responseSection').style.display = 'block';
const responseDiv = document.getElementById('response');
responseDiv.textContent += data.content;
}
else if (data.type === 'complete') {
// Complete
logDiv.innerHTML = `<span class="status status-complete">✓ Terminé</span>`;
eventSource.close();
document.getElementById('sendBtn').disabled = false;
}
else if (data.type === 'error') {
// Error
logDiv.innerHTML = `<span class="status status-error">✗ ${data.message}</span>`;
eventSource.close();
document.getElementById('sendBtn').disabled = false;
}
} catch (e) {
console.error('Parse error:', e);
}
};
eventSource.onerror = function(error) {
console.error('SSE error:', error);
logDiv.innerHTML = `<span class="status status-error">✗ Erreur de connexion SSE</span>`;
eventSource.close();
document.getElementById('sendBtn').disabled = false;
};
} catch (error) {
logDiv.innerHTML = `<span class="status status-error">✗ ${error.message}</span>`;
document.getElementById('sendBtn').disabled = false;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,178 @@
{% extends "base.html" %}
{% block title %}Upload Document{% endblock %}
{% block content %}
<section class="section">
<h1>📄 Parser PDF/Markdown</h1>
<p class="lead">Uploadez un fichier PDF ou Markdown pour l'analyser et structurer son contenu</p>
{% if error %}
<div class="alert alert-warning">
<strong>Erreur :</strong> {{ error }}
</div>
{% endif %}
<div class="search-box">
<form method="post" enctype="multipart/form-data">
<div class="form-group">
<label class="form-label" for="file">Fichier PDF ou Markdown</label>
<input
type="file"
name="file"
id="file"
class="form-control"
accept=".pdf,.md"
required
>
<div class="caption mt-1">Taille maximale : 50 MB</div>
<div class="caption" style="color: var(--color-accent); margin-top: 0.25rem;">
💡 Pour retester un document existant sans refaire l'OCR payant, cochez "Skip OCR"
</div>
</div>
<div class="form-row mt-3">
<div class="form-group">
<label class="form-label">Options</label>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input
type="checkbox"
name="skip_ocr"
id="skip_ocr"
style="width: auto;"
>
<label for="skip_ocr" style="margin: 0; font-size: 0.95rem; text-transform: none; letter-spacing: 0;">
⚡ Skip OCR (réutiliser markdown existant)
</label>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input
type="checkbox"
name="use_llm"
id="use_llm"
checked
style="width: auto;"
>
<label for="use_llm" style="margin: 0; font-size: 0.95rem; text-transform: none; letter-spacing: 0;">
Activer la structuration LLM (Ollama)
</label>
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input
type="checkbox"
name="ingest_weaviate"
id="ingest_weaviate"
checked
style="width: auto;"
>
<label for="ingest_weaviate" style="margin: 0; font-size: 0.95rem; text-transform: none; letter-spacing: 0;">
Insérer dans Weaviate (vectorisation)
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="llm_provider">Provider LLM</label>
<select name="llm_provider" id="llm_provider" class="form-control" onchange="updateModelOptions()">
<option value="mistral" selected>⚡ Mistral API (rapide)</option>
<option value="ollama">🖥️ Ollama (local, lent)</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="llm_model">Modèle LLM</label>
<select name="llm_model" id="llm_model" class="form-control">
<!-- Options Mistral API -->
<option value="mistral-small-latest" selected>mistral-small (rapide, économique)</option>
<option value="mistral-medium-latest">mistral-medium (équilibré)</option>
<option value="mistral-large-latest">mistral-large (puissant)</option>
</select>
</div>
<script>
function updateModelOptions() {
const provider = document.getElementById('llm_provider').value;
const modelSelect = document.getElementById('llm_model');
if (provider === 'mistral') {
modelSelect.innerHTML = `
<option value="mistral-small-latest" selected>mistral-small (rapide, économique)</option>
<option value="mistral-medium-latest">mistral-medium (équilibré)</option>
<option value="mistral-large-latest">mistral-large (puissant)</option>
`;
} else {
modelSelect.innerHTML = `
<option value="qwen2.5:7b" selected>qwen2.5:7b (recommandé)</option>
<option value="qwen2.5:14b">qwen2.5:14b</option>
<option value="llama3.2:3b">llama3.2:3b (rapide)</option>
<option value="mistral:7b">mistral:7b</option>
`;
}
}
</script>
</div>
<!-- Options Extraction TOC améliorée -->
<div class="card mt-4" style="border-left: 3px solid #4CAF50;">
<h4 style="color: #4CAF50;">📑 Extraction TOC améliorée (Recommandé)</h4>
<p style="font-size: 0.9rem; color: #666;">
Analyse l'indentation du texte pour détecter automatiquement la hiérarchie de la table des matières.
<br><strong style="color: #4CAF50;">✅ Fiable, rapide et sans coût supplémentaire</strong>
</p>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-top: 1rem;">
<input
type="checkbox"
name="use_ocr_annotations"
id="use_ocr_annotations"
style="width: auto;"
checked
>
<label for="use_ocr_annotations" style="margin: 0; font-size: 0.95rem; font-weight: 600;">
Activer l'analyse d'indentation pour la TOC
</label>
</div>
<div style="margin-top: 0.75rem; padding: 0.75rem; background: #f0f9f0; border-radius: 4px; font-size: 0.85rem;">
<strong>Fonctionnement :</strong> Détecte les niveaux hiérarchiques en comptant les espaces d'indentation dans la table des matières.
<br>
<em>Idéal pour les documents académiques avec TOC structurée.</em>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">
Analyser le document
</button>
</div>
</form>
</div>
<hr class="divider">
<div class="card">
<h3>📋 Pipeline de traitement</h3>
<div class="mt-2">
<p><strong>1. OCR Mistral</strong> — Extraction du texte et des images via l'API Mistral</p>
<p><strong>2. Markdown</strong> — Construction du document Markdown avec images</p>
<p><strong>3. Hiérarchie</strong> — Analyse des titres pour créer une structure arborescente</p>
<p><strong>4. LLM (optionnel)</strong> — Amélioration de la structure via Ollama</p>
</div>
</div>
<div class="card mt-3">
<h3>📁 Fichiers générés</h3>
<div class="mt-2">
<ul style="list-style: none;">
<li class="mb-1"><span class="badge">document.md</span> Texte Markdown OCR</li>
<li class="mb-1"><span class="badge">document_chunks.json</span> Chunks hiérarchiques</li>
<li class="mb-1"><span class="badge">document_structured.json</span> Structure LLM</li>
<li class="mb-1"><span class="badge">document_ocr.json</span> Réponse OCR brute</li>
<li><span class="badge">images/</span> Images extraites</li>
</ul>
</div>
</div>
<div class="text-center mt-4">
<a href="/documents" class="btn">Voir les documents traités</a>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,447 @@
{% extends "base.html" %}
{% block title %}Traitement en cours{% endblock %}
{% block content %}
<style>
.progress-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.progress-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.progress-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.progress-icon.error {
background: #c0392b;
}
.progress-icon.success {
background: var(--color-accent-alt);
}
.progress-title {
flex: 1;
}
.progress-title h2 {
margin: 0;
font-size: 1.3rem;
}
.progress-title .subtitle {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* Barre de progression globale */
.overall-progress {
margin-bottom: 2rem;
}
.overall-progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.progress-bar-container {
height: 8px;
background: rgba(125, 110, 88, 0.2);
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-alt));
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
/* Liste des étapes */
.steps-list {
list-style: none;
padding: 0;
margin: 0;
}
.step-item {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid rgba(125, 110, 88, 0.1);
opacity: 0.5;
transition: all 0.3s ease;
}
.step-item.active {
opacity: 1;
}
.step-item.completed {
opacity: 0.8;
}
.step-item.error {
opacity: 1;
color: #c0392b;
}
.step-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(125, 110, 88, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
font-size: 0.9rem;
transition: all 0.3s ease;
}
.step-item.active .step-icon {
background: var(--color-accent);
color: var(--color-bg-main);
}
.step-item.completed .step-icon {
background: var(--color-accent-alt);
color: var(--color-bg-main);
}
.step-item.error .step-icon {
background: #c0392b;
color: white;
}
.step-content {
flex: 1;
}
.step-name {
font-weight: 500;
font-size: 0.95rem;
}
.step-detail {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.2rem;
}
.step-progress {
font-size: 0.85rem;
color: var(--color-text-muted);
min-width: 40px;
text-align: right;
}
/* Spinner */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(125, 110, 88, 0.3);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.step-item.active .spinner {
border-top-color: var(--color-bg-main);
}
/* Message d'erreur */
.error-message {
background: rgba(192, 57, 43, 0.1);
border: 1px solid rgba(192, 57, 43, 0.3);
color: #c0392b;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
display: none;
}
.error-message.visible {
display: block;
}
/* Footer */
.progress-footer {
margin-top: 2rem;
text-align: center;
}
.progress-footer .caption {
margin-bottom: 1rem;
}
</style>
<section class="section">
<div class="progress-container">
<div class="progress-header">
<div class="progress-icon" id="main-icon">
<div class="spinner"></div>
</div>
<div class="progress-title">
<h2 id="main-title">Traitement en cours...</h2>
<div class="subtitle" id="main-subtitle">{{ filename }}</div>
</div>
</div>
<div class="overall-progress">
<div class="overall-progress-header">
<span>Progression globale</span>
<span id="progress-percent">0%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" id="progress-bar"></div>
</div>
</div>
<ul class="steps-list" id="steps-list">
<li class="step-item" data-step="ocr">
<div class="step-icon">📄</div>
<div class="step-content">
<div class="step-name">OCR Mistral</div>
<div class="step-detail">Extraction du texte et des images</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="markdown">
<div class="step-icon">📝</div>
<div class="step-content">
<div class="step-name">Construction Markdown</div>
<div class="step-detail">Génération du document structuré</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="metadata">
<div class="step-icon">📖</div>
<div class="step-content">
<div class="step-name">Extraction métadonnées</div>
<div class="step-detail">Titre, auteur, éditeur via LLM</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="toc">
<div class="step-icon">📑</div>
<div class="step-content">
<div class="step-name">Table des matières</div>
<div class="step-detail">Extraction de la structure via LLM</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="classify">
<div class="step-icon">🏷️</div>
<div class="step-content">
<div class="step-name">Classification sections</div>
<div class="step-detail">Identification des types de contenu</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="chunking">
<div class="step-icon">✂️</div>
<div class="step-content">
<div class="step-name">Chunking sémantique</div>
<div class="step-detail">Découpage intelligent du texte</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="cleaning">
<div class="step-icon">🧹</div>
<div class="step-content">
<div class="step-name">Nettoyage</div>
<div class="step-detail">Correction des artefacts OCR</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="validation">
<div class="step-icon"></div>
<div class="step-content">
<div class="step-name">Validation</div>
<div class="step-detail">Vérification de la qualité</div>
</div>
<div class="step-progress"></div>
</li>
<li class="step-item" data-step="weaviate">
<div class="step-icon">🗄️</div>
<div class="step-content">
<div class="step-name">Ingestion Weaviate</div>
<div class="step-detail">Vectorisation et stockage</div>
</div>
<div class="step-progress"></div>
</li>
</ul>
<div class="error-message" id="error-message"></div>
<div class="progress-footer">
<p class="caption" id="footer-message">Le traitement peut prendre quelques minutes selon la taille du document...</p>
<a href="/upload" class="btn" id="back-btn" style="display: none;">← Retour</a>
</div>
</div>
</section>
<script>
const jobId = "{{ job_id }}";
const steps = [
{ id: "ocr", weight: 20 },
{ id: "markdown", weight: 5 },
{ id: "metadata", weight: 10 },
{ id: "toc", weight: 15 },
{ id: "classify", weight: 10 },
{ id: "chunking", weight: 20 },
{ id: "cleaning", weight: 10 },
{ id: "validation", weight: 5 },
{ id: "weaviate", weight: 5 }
];
let completedWeight = 0;
let currentStepIndex = -1;
function updateStep(stepId, status, detail = null) {
const stepItem = document.querySelector(`[data-step="${stepId}"]`);
if (!stepItem) return;
const stepIndex = steps.findIndex(s => s.id === stepId);
// Marquer les étapes précédentes comme complétées
steps.forEach((s, i) => {
if (i < stepIndex) {
const item = document.querySelector(`[data-step="${s.id}"]`);
if (item && !item.classList.contains('completed') && !item.classList.contains('error')) {
item.classList.remove('active');
item.classList.add('completed');
item.querySelector('.step-icon').innerHTML = '✓';
item.querySelector('.step-progress').textContent = '100%';
}
}
});
// Mettre à jour l'étape actuelle
stepItem.classList.remove('active', 'completed', 'error');
if (status === 'active') {
stepItem.classList.add('active');
stepItem.querySelector('.step-icon').innerHTML = '<div class="spinner"></div>';
stepItem.querySelector('.step-progress').textContent = '...';
currentStepIndex = stepIndex;
} else if (status === 'completed') {
stepItem.classList.add('completed');
stepItem.querySelector('.step-icon').innerHTML = '✓';
stepItem.querySelector('.step-progress').textContent = '100%';
completedWeight += steps[stepIndex].weight;
} else if (status === 'error') {
stepItem.classList.add('error');
stepItem.querySelector('.step-icon').innerHTML = '✗';
stepItem.querySelector('.step-progress').textContent = '—';
} else if (status === 'skipped') {
stepItem.classList.add('completed');
stepItem.querySelector('.step-icon').innerHTML = '⚡';
stepItem.querySelector('.step-progress').textContent = 'skip';
stepItem.querySelector('.step-detail').textContent = 'Réutilisation du cache';
completedWeight += steps[stepIndex].weight;
}
if (detail) {
stepItem.querySelector('.step-detail').textContent = detail;
}
// Mettre à jour la barre de progression
updateProgressBar();
}
function updateProgressBar() {
const totalWeight = steps.reduce((sum, s) => sum + s.weight, 0);
const percent = Math.round((completedWeight / totalWeight) * 100);
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-percent').textContent = percent + '%';
}
function showError(message) {
document.getElementById('main-icon').classList.add('error');
document.getElementById('main-icon').innerHTML = '✗';
document.getElementById('main-title').textContent = 'Erreur de traitement';
document.getElementById('error-message').textContent = message;
document.getElementById('error-message').classList.add('visible');
document.getElementById('footer-message').style.display = 'none';
document.getElementById('back-btn').style.display = 'inline-block';
}
function showSuccess(redirectUrl) {
document.getElementById('main-icon').classList.add('success');
document.getElementById('main-icon').innerHTML = '✓';
document.getElementById('main-title').textContent = 'Traitement terminé !';
document.getElementById('progress-bar').style.width = '100%';
document.getElementById('progress-percent').textContent = '100%';
document.getElementById('footer-message').textContent = 'Redirection vers les résultats...';
setTimeout(() => {
window.location.href = redirectUrl;
}, 1000);
}
// Connexion SSE pour recevoir les mises à jour
const eventSource = new EventSource('/upload/progress/' + jobId);
eventSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'step') {
updateStep(data.step, data.status, data.detail);
} else if (data.type === 'error') {
showError(data.message);
eventSource.close();
} else if (data.type === 'complete') {
showSuccess(data.redirect);
eventSource.close();
}
};
eventSource.onerror = function() {
// Vérifier si le traitement est terminé
fetch('/upload/status/' + jobId)
.then(r => r.json())
.then(data => {
if (data.status === 'complete') {
showSuccess(data.redirect);
} else if (data.status === 'error') {
showError(data.message);
}
})
.catch(() => {
showError('Connexion perdue avec le serveur');
});
eventSource.close();
};
</script>
{% endblock %}

View File

@@ -0,0 +1,297 @@
{% extends "base.html" %}
{% block title %}Résultat - {{ result.document_name }}{% endblock %}
{% block content %}
{# Dictionnaire de traduction des types de chunks #}
{% set chunk_types = {
'main_content': {'label': 'Contenu principal', 'icon': '📄', 'desc': 'Paragraphe de contenu substantiel'},
'exposition': {'label': 'Exposition', 'icon': '📖', 'desc': 'Présentation d\'idées ou de contexte'},
'argument': {'label': 'Argument', 'icon': '💭', 'desc': 'Raisonnement ou argumentation'},
'définition': {'label': 'Définition', 'icon': '📌', 'desc': 'Définition de concept ou terme'},
'example': {'label': 'Exemple', 'icon': '💡', 'desc': 'Illustration ou cas pratique'},
'citation': {'label': 'Citation', 'icon': '💬', 'desc': 'Citation d\'auteur ou référence'},
'abstract': {'label': 'Résumé', 'icon': '📋', 'desc': 'Résumé ou synthèse'},
'preface': {'label': 'Préface', 'icon': '✍️', 'desc': 'Préface, avant-propos ou avertissement'},
'conclusion': {'label': 'Conclusion', 'icon': '🎯', 'desc': 'Conclusion d\'une argumentation'}
} %}
<section class="section">
<h1>✅ Traitement terminé</h1>
<p class="lead">Le document <strong>{{ result.document_name }}</strong> a été analysé avec succès</p>
<div class="ornament">· · ·</div>
<!-- Statistiques -->
<div class="stats-grid">
<div class="stat-box">
<div class="stat-number">{{ result.pages }}</div>
<div class="stat-label">Pages</div>
</div>
<div class="stat-box">
<div class="stat-number">{{ result.chunks_count or 0 }}</div>
<div class="stat-label">Chunks</div>
</div>
{% if result.files.images %}
<div class="stat-box">
<div class="stat-number">{{ result.files.images|length }}</div>
<div class="stat-label">Images</div>
</div>
{% endif %}
<div class="stat-box">
<div class="stat-number">{{ "%.4f"|format(result.cost_total or result.cost or 0) }}€</div>
<div class="stat-label">Coût Total</div>
</div>
</div>
<!-- Détail des coûts si Mistral API -->
{% if result.llm_stats %}
<div class="card mt-3">
<h3>💰 Détail des coûts</h3>
<div class="mt-2">
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.5rem 0;"><strong>OCR Mistral</strong></td>
<td style="padding: 0.5rem 0; text-align: right;">{{ "%.4f"|format(result.cost_ocr or 0) }}€</td>
</tr>
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.5rem 0;"><strong>LLM Mistral API</strong></td>
<td style="padding: 0.5rem 0; text-align: right;">{{ "%.4f"|format(result.cost_llm or 0) }}€</td>
</tr>
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.5rem 0; color: var(--color-text-muted);">└ {{ result.llm_stats.calls_count }} appels</td>
<td style="padding: 0.5rem 0; text-align: right; color: var(--color-text-muted);">
{{ result.llm_stats.total_input_tokens + result.llm_stats.total_output_tokens }} tokens
</td>
</tr>
<tr>
<td style="padding: 0.5rem 0;"><strong>Total</strong></td>
<td style="padding: 0.5rem 0; text-align: right; font-weight: bold; color: var(--color-accent);">
{{ "%.4f"|format(result.cost_total or 0) }}€
</td>
</tr>
</table>
</div>
</div>
{% endif %}
<hr class="divider">
<!-- Métadonnées du document -->
<div class="card">
<h3>📖 Informations du document</h3>
<div class="mt-2">
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0; width: 150px;"><strong>Œuvre</strong></td>
<td style="padding: 0.75rem 0;">
<span class="badge badge-author">{{ result.metadata.work or result.document_name }}</span>
</td>
</tr>
{% if result.metadata.title %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Titre</strong></td>
<td style="padding: 0.75rem 0;">{{ result.metadata.title }}</td>
</tr>
{% endif %}
{% if result.metadata.author %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Auteur</strong></td>
<td style="padding: 0.75rem 0;">
<span class="badge badge-author">{{ result.metadata.author }}</span>
</td>
</tr>
{% endif %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Pages</strong></td>
<td style="padding: 0.75rem 0;">{{ result.pages }}</td>
</tr>
<tr>
<td style="padding: 0.75rem 0;"><strong>Chunks</strong></td>
<td style="padding: 0.75rem 0;">{{ result.chunks_count or result.metadata.chunks_count or 0 }} segments de texte</td>
</tr>
</table>
</div>
</div>
<!-- Table des matières -->
{% if result.metadata.toc and result.metadata.toc|length > 0 %}
<div class="card mt-3">
<h3>📑 Table des matières</h3>
<div class="mt-2">
<ul style="list-style: none; padding-left: 0;">
{% for item in result.metadata.toc[:20] %}
<li style="padding: 0.4rem 0; padding-left: {{ (item.level - 1) * 1.5 }}rem; border-bottom: 1px solid rgba(125, 110, 88, 0.1);">
{% if item.level == 1 %}
<strong>{{ item.title }}</strong>
{% else %}
<span style="color: var(--color-accent-alt);">{{ item.title }}</span>
{% endif %}
</li>
{% endfor %}
{% if result.metadata.toc|length > 20 %}
<li style="padding: 0.5rem 0; color: var(--color-accent);">
<em>... et {{ result.metadata.toc|length - 20 }} autres sections</em>
</li>
{% endif %}
</ul>
</div>
</div>
{% endif %}
<hr class="divider">
<!-- Fichiers générés -->
<div class="card">
<h3>📁 Fichiers générés</h3>
<div class="mt-2">
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Markdown</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}.md" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Chunks JSON</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}_chunks.json" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% if result.files.structured %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Structure LLM</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}_structured.json" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% endif %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>OCR brut</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}_ocr.json" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% if result.files.weaviate %}
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.75rem 0;"><strong>Weaviate JSON</strong></td>
<td style="padding: 0.75rem 0;">
<a href="/output/{{ result.document_name }}/{{ result.document_name }}_weaviate.json" target="_blank" class="btn btn-sm">
Voir le fichier
</a>
</td>
</tr>
{% endif %}
{% if result.files.images %}
<tr>
<td style="padding: 0.75rem 0;"><strong>Images</strong></td>
<td style="padding: 0.75rem 0;">
{{ result.files.images|length }} image(s) dans <code>images/</code>
</td>
</tr>
{% endif %}
</table>
</div>
</div>
<!-- Données insérées dans Weaviate -->
{% if result.weaviate_ingest %}
<div class="card mt-3">
<h3>🗄️ Données insérées dans Weaviate</h3>
<div class="mt-2">
{% if result.weaviate_ingest.success %}
<div class="alert alert-success" style="background-color: rgba(85, 107, 99, 0.1); border: 1px solid rgba(85, 107, 99, 0.3); color: var(--color-accent-alt); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
<strong>✓ Ingestion réussie :</strong> {{ result.weaviate_ingest.count }} passages insérés dans la collection <code>Passage</code>
</div>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.5rem 0; width: 120px;"><strong>Œuvre</strong></td>
<td style="padding: 0.5rem 0;"><span class="badge badge-author">{{ result.weaviate_ingest.work }}</span></td>
</tr>
<tr style="border-bottom: 1px solid rgba(125, 110, 88, 0.2);">
<td style="padding: 0.5rem 0;"><strong>Auteur</strong></td>
<td style="padding: 0.5rem 0;"><span class="badge badge-author">{{ result.weaviate_ingest.author }}</span></td>
</tr>
<tr>
<td style="padding: 0.5rem 0;"><strong>Passages</strong></td>
<td style="padding: 0.5rem 0;">{{ result.weaviate_ingest.count }} objets vectorisés</td>
</tr>
</table>
<h4 style="font-size: 1rem; margin-top: 1.5rem; margin-bottom: 0.75rem;">Aperçu des passages insérés :</h4>
{% for passage in result.weaviate_ingest.inserted[:5] %}
<div style="background: var(--color-bg-secondary); padding: 1rem; border-radius: 8px; margin-bottom: 0.75rem; border-left: 3px solid var(--color-accent);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<span style="font-size: 0.85rem; color: var(--color-accent);">📄 {{ passage.section }}</span>
{% set type_info = chunk_types.get(passage.unitType, {'label': passage.unitType, 'icon': '📝', 'desc': 'Type de contenu'}) %}
<span style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px; background: rgba(125, 110, 88, 0.15);" title="{{ type_info.desc }}">
{{ type_info.icon }} {{ type_info.label }}
</span>
</div>
<span style="font-size: 0.7rem; color: var(--color-text-muted);">{{ passage.chunk_id }}</span>
</div>
<div style="font-style: italic; color: var(--color-text-main); font-size: 0.9rem; line-height: 1.5;">
"{{ passage.text_preview }}"
</div>
</div>
{% endfor %}
{% if result.weaviate_ingest.count > 5 %}
<p class="text-muted text-center" style="margin-top: 1rem;">
<em>... et {{ result.weaviate_ingest.count - 5 }} autres passages</em>
</p>
{% endif %}
{% else %}
<div class="alert alert-warning" style="background-color: rgba(125, 110, 88, 0.1); border: 1px solid rgba(125, 110, 88, 0.3); color: var(--color-accent); padding: 1rem; border-radius: 8px;">
<strong>⚠️ Erreur d'ingestion :</strong> {{ result.weaviate_ingest.error }}
</div>
<p class="text-muted">Vérifiez que Weaviate est démarré (<code>docker compose up -d</code>) et que le schéma est initialisé (<code>python schema.py</code>).</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Images extraites -->
{% if result.files.images %}
<div class="card mt-3">
<h3>🖼️ Images extraites</h3>
<div class="mt-2" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem;">
{% for img in result.files.images[:12] %}
<div style="text-align: center;">
<a href="/output/{{ result.document_name }}/images/{{ img.split('/')[-1].split('\\')[-1] }}" target="_blank">
<img
src="/output/{{ result.document_name }}/images/{{ img.split('/')[-1].split('\\')[-1] }}"
alt="Image"
style="max-width: 100%; max-height: 120px; border-radius: 8px; border: 1px solid rgba(125, 110, 88, 0.2);"
>
</a>
<div class="caption">{{ img.split('/')[-1].split('\\')[-1] }}</div>
</div>
{% endfor %}
{% if result.files.images|length > 12 %}
<div style="display: flex; align-items: center; justify-content: center;">
<span class="text-muted">+ {{ result.files.images|length - 12 }} autres</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="text-center mt-4">
<a href="/upload" class="btn btn-primary">Analyser un autre PDF</a>
<a href="/documents" class="btn" style="margin-left: 0.5rem;">Voir tous les documents</a>
</div>
</section>
{% endblock %}