Cyril Dupland commited on
Commit
3cd0aad
·
1 Parent(s): 49420f1

Enhance PDF generation and update configurations: Add PDF_LOGO_PATH to .env.example, include markdown-pdf in requirements.txt, and refactor PDF generation logic to support logo integration. Introduce tests for PDF generation from Markdown input and update summarizer agent to streamline export functionality.

Browse files
.env.example CHANGED
@@ -18,4 +18,7 @@ LANGCHAIN_API_KEY=
18
  LANGCHAIN_PROJECT=routeur-ia
19
 
20
  # Voice WebRTC ICE servers (STUN/TURN pour NAT traversal sur HF Spaces)
21
- # DAILY_API_KEY=61efebb10fa3956006a11e194980876c4453311ef25737fb2e434bc040326deb
 
 
 
 
18
  LANGCHAIN_PROJECT=routeur-ia
19
 
20
  # Voice WebRTC ICE servers (STUN/TURN pour NAT traversal sur HF Spaces)
21
+ # DAILY_API_KEY=61efebb10fa3956006a11e194980876c4453311ef25737fb2e434bc040326deb
22
+
23
+ # PDF - logo en haut du document (section dédiée)
24
+ PDF_LOGO_PATH=assets/logo.png
assets/.gitkeep ADDED
File without changes
assets/logo.png ADDED
config/settings.py CHANGED
@@ -108,6 +108,9 @@ class Settings(BaseSettings):
108
  # Voice / Daily.co (alternative transport for HF Spaces, NAT traversal)
109
  daily_api_key: Optional[str] = None
110
 
 
 
 
111
  model_config = SettingsConfigDict(
112
  env_file=".env",
113
  env_file_encoding="utf-8",
 
108
  # Voice / Daily.co (alternative transport for HF Spaces, NAT traversal)
109
  daily_api_key: Optional[str] = None
110
 
111
+ # PDF branding - logo displayed in top section (assets/logo.png)
112
+ pdf_logo_path: Optional[str] = None
113
+
114
  model_config = SettingsConfigDict(
115
  env_file=".env",
116
  env_file_encoding="utf-8",
graphs/agents/summarizer_agent.py CHANGED
@@ -1,5 +1,6 @@
1
  """Summarizer agents: split into two nodes (LLM -> Export PDF/Upload)."""
2
- from typing import Callable, Optional
 
3
  from datetime import datetime, timezone
4
 
5
  from langchain_core.messages import SystemMessage
@@ -38,8 +39,7 @@ def summarizer_llm_node(
38
 
39
 
40
  def summarizer_export_node(
41
- md_to_html: Callable[[str, Optional[str]], str],
42
- html_to_pdf: Callable[[str], bytes],
43
  upload_pdf: Callable[[bytes, str], str],
44
  ):
45
  """Node: takes `summary_markdown` from state, builds PDF and uploads it.
@@ -54,9 +54,8 @@ def summarizer_export_node(
54
  messages = list(state.get("messages", []))
55
  summary_markdown = state.get("summary_markdown", "") or ""
56
 
57
- # Convert to HTML, then PDF, then upload
58
- html = md_to_html(summary_markdown)
59
- pdf_result = html_to_pdf(html)
60
 
61
  # Ensure pdf_bytes is of type bytes (not BytesIO)
62
  if hasattr(pdf_result, "read"):
@@ -105,13 +104,12 @@ def summarizer_export_node(
105
 
106
  def summarizer_node(
107
  llm: BaseChatModel,
108
- md_to_html: Callable[[str, Optional[str]], str],
109
- html_to_pdf: Callable[[str], bytes],
110
  upload_pdf: Callable[[bytes, str], str],
111
  ):
112
  """Compatibility wrapper: runs LLM summarization then export in one node."""
113
  llm_runner = summarizer_llm_node(llm)
114
- export_runner = summarizer_export_node(md_to_html, html_to_pdf, upload_pdf)
115
 
116
  def _run(state: AgentState) -> AgentState:
117
  state_after_llm = llm_runner(state)
 
1
  """Summarizer agents: split into two nodes (LLM -> Export PDF/Upload)."""
2
+ from io import BytesIO
3
+ from typing import Callable, Union
4
  from datetime import datetime, timezone
5
 
6
  from langchain_core.messages import SystemMessage
 
39
 
40
 
41
  def summarizer_export_node(
42
+ markdown_to_pdf: Callable[[str], Union[bytes, BytesIO]],
 
43
  upload_pdf: Callable[[bytes, str], str],
44
  ):
45
  """Node: takes `summary_markdown` from state, builds PDF and uploads it.
 
54
  messages = list(state.get("messages", []))
55
  summary_markdown = state.get("summary_markdown", "") or ""
56
 
57
+ # Convert Markdown to PDF
58
+ pdf_result = markdown_to_pdf(summary_markdown)
 
59
 
60
  # Ensure pdf_bytes is of type bytes (not BytesIO)
61
  if hasattr(pdf_result, "read"):
 
104
 
105
  def summarizer_node(
106
  llm: BaseChatModel,
107
+ markdown_to_pdf: Callable[[str], Union[bytes, BytesIO]],
 
108
  upload_pdf: Callable[[bytes, str], str],
109
  ):
110
  """Compatibility wrapper: runs LLM summarization then export in one node."""
111
  llm_runner = summarizer_llm_node(llm)
112
+ export_runner = summarizer_export_node(markdown_to_pdf, upload_pdf)
113
 
114
  def _run(state: AgentState) -> AgentState:
115
  state_after_llm = llm_runner(state)
graphs/workflows/conversation_with_summary.py CHANGED
@@ -6,8 +6,7 @@ from graphs.state import AgentState
6
  from graphs.nodes.retrieval import retrieve_both_types
7
  from graphs.agents.chat_agent import chat_node
8
  from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
9
- from tools.markdown import markdown_to_html
10
- from tools.pdf import html_to_pdf
11
  from tools.storage import upload_pdf_to_supabase
12
 
13
 
@@ -21,8 +20,7 @@ def create_conversation_with_summary_graph(llm: BaseChatModel):
21
  workflow.add_node(
22
  "summarizer_export",
23
  summarizer_export_node(
24
- md_to_html=markdown_to_html,
25
- html_to_pdf=html_to_pdf,
26
  upload_pdf=upload_pdf_to_supabase,
27
  ),
28
  )
 
6
  from graphs.nodes.retrieval import retrieve_both_types
7
  from graphs.agents.chat_agent import chat_node
8
  from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
9
+ from tools.pdf import markdown_to_pdf
 
10
  from tools.storage import upload_pdf_to_supabase
11
 
12
 
 
20
  workflow.add_node(
21
  "summarizer_export",
22
  summarizer_export_node(
23
+ markdown_to_pdf=markdown_to_pdf,
 
24
  upload_pdf=upload_pdf_to_supabase,
25
  ),
26
  )
graphs/workflows/orchestrated.py CHANGED
@@ -7,8 +7,7 @@ from graphs.agents.classifier_agent import classifier_node
7
  from graphs.nodes.retrieval import retrieve_catalogue, retrieve_projects
8
  from graphs.agents.chat_agent import chat_node
9
  from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
10
- # from tools.markdown import markdown_to_html
11
- from tools.pdf import html_to_pdf, markdown_to_html
12
  from tools.storage import upload_pdf_to_supabase
13
 
14
 
@@ -30,8 +29,7 @@ def create_orchestrated_graph(llm: BaseChatModel, checkpointer=None):
30
  workflow.add_node(
31
  "summarizer_export",
32
  summarizer_export_node(
33
- md_to_html=markdown_to_html,
34
- html_to_pdf=html_to_pdf,
35
  upload_pdf=upload_pdf_to_supabase,
36
  ),
37
  )
 
7
  from graphs.nodes.retrieval import retrieve_catalogue, retrieve_projects
8
  from graphs.agents.chat_agent import chat_node
9
  from graphs.agents.summarizer_agent import summarizer_llm_node, summarizer_export_node
10
+ from tools.pdf import markdown_to_pdf
 
11
  from tools.storage import upload_pdf_to_supabase
12
 
13
 
 
29
  workflow.add_node(
30
  "summarizer_export",
31
  summarizer_export_node(
32
+ markdown_to_pdf=markdown_to_pdf,
 
33
  upload_pdf=upload_pdf_to_supabase,
34
  ),
35
  )
requirements.txt CHANGED
@@ -37,6 +37,10 @@ daily-python>=0.10.0 ; sys_platform == "linux"
37
  # Summarizer tooling
38
  reportlab>=4.4.4,<5
39
  markdown>=3.7,<4
 
40
 
41
  # Ecologits Carbon Footprint
42
  ecologits>=0.8.2,<1
 
 
 
 
37
  # Summarizer tooling
38
  reportlab>=4.4.4,<5
39
  markdown>=3.7,<4
40
+ markdown-pdf>=1.13.0
41
 
42
  # Ecologits Carbon Footprint
43
  ecologits>=0.8.2,<1
44
+
45
+ # Tests
46
+ pytest>=8.0.0,<9
tests/ai_synthesis_response.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # **Synthèse de l'entretien avec Cédric LAMBERT**
2
+ *Exploitant en arboriculture et productions de semences*
3
+
4
+ ---
5
+
6
+ ## **Contexte**
7
+ - **Exploitation** : Arboriculture (espèces/variétés à préciser) et productions de semences (légumières, fourragères, etc.).
8
+ - **Enjeux identifiés** (à confirmer lors de la visite) :
9
+ - Optimisation des itinéraires techniques (arboriculture et semences).
10
+ - Gestion des aléas climatiques (gel, sécheresse) et sanitaires (maladies, ravageurs).
11
+ - Rentabilité économique et diversification des débouchés.
12
+ - Transition agroécologique (réduction des intrants, certification HVE/bio).
13
+ - **Objectif de la visite** : Réaliser un premier diagnostic technique et économique pour proposer des solutions adaptées (prestations, formations).
14
+
15
+ ---
16
+
17
+ ## **Objectifs de l'agriculteur**
18
+ *(À compléter après l’entretien)*
19
+ - **Priorités techniques** :
20
+ - Exemple : Améliorer la gestion du gel en verger, réduire l’usage des phytosanitaires.
21
+ - **Priorités économiques** :
22
+ - Exemple : Augmenter la marge brute des cultures, diversifier les canaux de vente.
23
+ - **Projets** :
24
+ - Exemple : Plantation de nouveaux vergers, développement de la vente directe de semences.
25
+ - **Attentes vis-à-vis de la Chambre d’Agriculture** :
26
+ - Exemple : Accompagnement sur la transition agroenvironnementale, formation des salariés.
27
+
28
+ ---
29
+
30
+ ## **Recommandations**
31
+
32
+ ### **Prestations**
33
+ | **Prestation** | **Page** | **Source** | **Contact** |
34
+ |----------------|----------|------------|-------------|
35
+ | Conseil en conduite de vergers (itinéraire technique, protection phytosanitaire) | p.28 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Service Productions Végétales – [Voir contacts p.50-51] |
36
+ | Diagnostic et plan d’action agro-environnemental (HVE, bio, réduction des intrants) | p.28 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Service Environnement – [Voir contacts p.50-51] |
37
+ | Accompagnement à la plantation et gestion du bocage (haies, agroforesterie) | p.32 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Aurélie PAYRAUDEAU (p.32) |
38
+ | Étude économique et stratégie commerciale (diversification, vente directe) | p.16 | Catalogue Prestations 2024 – Chambre d’Agriculture Pays de la Loire | Service Circuits Courts – [Voir contacts p.50-51] |
39
+
40
+ ---
41
+
42
+ ### **Formations**
43
+ | **Formation** | **Page** | **Source** | **Contact** | **Prochaine date / Message par défaut** |
44
+ |---------------|----------|------------|-------------|----------------------------------------|
45
+ | Réussir ses plantations : des choix stratégiques (arboriculture) | p.103 | Catalogue Formations 2024-2025 – Chambre d’Agriculture Pays de la Loire | Florent BANCTEL (conseiller Viticulture) | 05/03 et 12/03/2025 (Clisson) ou contacter pour 49. |
46
+ | Se protéger contre le gel de printemps | p.103 | Catalogue Formations 2024-2025 – Chambre d’Agriculture Pays de la Loire | Thomas CHASSAING (conseiller Viticulture) | 04/03/2025 (44) ou 05/03/2025 (49). |
47
+ | Renforcer l'agronomie pour plus de durabilité | p.46 | Catalogue Formations 2024-2025 – Chambre d’Agriculture Pays de la Loire | Alexandre HATET (expert Sols) | Contacter le service formation pour connaître la prochaine date. |
48
+
49
+ ---
50
+
51
+ ## **Prochaines étapes**
52
+ 1. **Après l’entretien** :
53
+ - Envoyer un email de remerciement à Cédric LAMBERT avec :
54
+ - Un résumé des points clés abordés.
55
+ - Les prestations/formations proposées (liens vers les pages du catalogue).
56
+ - Une proposition de rendez-vous pour affiner les solutions.
57
+ - Transmettre les documents demandés (parcellaire, registres) au service concerné pour analyse.
58
+
59
+ 2. **Plan d’action personnalisé** :
60
+ - Prioriser les prestations/formations en fonction de ses enjeux (ex : si gel = formation "Se protéger contre le gel").
61
+ - Proposer un calendrier (ex : diagnostic agroenvironnemental en mars, formation en avril).
62
+
63
+ 3. **Suivi** :
64
+ - Planifier un deuxième rendez-vous pour valider le plan d’action et les devis.
65
+ - Informer les services internes (Environnement, Productions Végétales) pour préparer les interventions.
66
+
67
+ ---
68
+ **Documents joints** (à adapter) :
69
+ - Fiche de synthèse personnalisée (cette version).
70
+ - Extraits des catalogues prestations/formations (pages citées).
71
+ - Modèle d’email de suivi.
tests/conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Pytest configuration and fixtures."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add project root to path so "from tools.xxx" imports work
6
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
tests/test_pdf_generation.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_pdf_generation.py
2
+ """Test PDF generation from Markdown input (markdown-pdf)."""
3
+ from pathlib import Path
4
+
5
+ from tools.pdf import markdown_to_pdf
6
+
7
+ _DIR = Path(__file__).resolve().parent
8
+ MD_INPUT_PATH = _DIR / "ai_synthesis_response.md"
9
+ PDF_OUTPUT_PATH = _DIR / "ai_synthesis_response.pdf"
10
+
11
+
12
+ def test_generates_pdf_from_markdown_locally():
13
+ """Generate a PDF from markdown input and verify it is written locally."""
14
+ md_input = MD_INPUT_PATH.read_text(encoding="utf-8")
15
+ buffer = markdown_to_pdf(md_input)
16
+
17
+ PDF_OUTPUT_PATH.write_bytes(buffer.getvalue())
18
+
19
+ assert PDF_OUTPUT_PATH.exists()
20
+ assert PDF_OUTPUT_PATH.stat().st_size > 0
21
+ assert PDF_OUTPUT_PATH.read_bytes()[:4] == b"%PDF"
tools/pdf.py CHANGED
@@ -1,50 +1,51 @@
1
- import markdown
2
- import re
3
-
4
  from io import BytesIO
5
- from reportlab.lib.pagesizes import A4
6
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
7
- from reportlab.lib.styles import getSampleStyleSheet
8
- from reportlab.lib.enums import TA_CENTER
9
- from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
10
-
11
- # Fonction pour convertir le Markdown en HTML pour le PDF
12
- def markdown_to_html(md_text):
13
- return markdown.markdown(md_text, output_format='html' )
14
-
15
-
16
- def html_to_pdf(html):
17
-
18
- html = html.replace("\n", "<br>")
19
- # Nettoyage minimal du HTML pour compatibilité ReportLab
20
- html = html.replace("<br>", "<br/>")
21
- html = html.replace("<br />", "<br/>")
22
-
23
- # Supprimer les balises non supportées (ul, ol, li, h1, h2, etc.)
24
- html = re.sub(r"</?(ul|ol|li|h\d|blockquote|hr)>", "", html)
25
- html = html.replace("**", "<b>")
26
- html = html.replace("*", "<i>")
27
- html = html.replace("~", "<s>")
28
- html = html.replace("```markdown", "")
29
- html = html.replace("```", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  buffer = BytesIO()
32
- doc = SimpleDocTemplate(buffer, pagesize=A4)
33
-
34
- styles = getSampleStyleSheet()
35
- style_title = styles["Title"]
36
- style_title.alignment = TA_CENTER # Centrer le titre
37
- style_header = styles["Heading2"]
38
- style_message = styles["BodyText"]
39
-
40
- elements = []
41
-
42
- # Titre principal du document
43
- elements.append(Paragraph("Synthèse", style_title))
44
- elements.append(Spacer(1, 20)) # Espacement après le titre
45
-
46
- elements.append(Paragraph(html, style_message))
47
-
48
- doc.build(elements)
49
  buffer.seek(0)
50
  return buffer
 
1
+ """PDF generation from Markdown using markdown-pdf."""
 
 
2
  from io import BytesIO
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from markdown_pdf import MarkdownPdf, Section
7
+
8
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent
9
+
10
+
11
+ def markdown_to_pdf(
12
+ md_text: str,
13
+ toc_level: int = 2,
14
+ logo_path: Optional[str] = None,
15
+ ) -> BytesIO:
16
+ """Convert Markdown to PDF using markdown-pdf (PyMuPDF + markdown-it-py).
17
+
18
+ Supports UTF-8, tables, links, images, and TOC from headings.
19
+ Optionally adds a dedicated logo section at the top (centered) if logo_path is set.
20
+ """
21
+ if logo_path is None:
22
+ try:
23
+ from config.settings import settings
24
+ logo_path = getattr(settings, "pdf_logo_path", None)
25
+ except Exception:
26
+ logo_path = None
27
+
28
+ pdf = MarkdownPdf(toc_level=toc_level, optimize=True)
29
+
30
+ # Section 1: logo centered at top (if file exists)
31
+ logo_file = _PROJECT_ROOT / logo_path if logo_path else None
32
+ if logo_file and logo_file.is_file():
33
+ logo_md = f"![Logo]({logo_path})"
34
+ logo_css = "img { display: block; margin-left: auto; margin-right: auto; }"
35
+ logo_section = Section(logo_md, toc=False)
36
+ logo_section.root = str(_PROJECT_ROOT)
37
+ pdf.add_section(logo_section, user_css=logo_css)
38
+
39
+ # Section 2: main content (réduit la police)
40
+ content_css = (
41
+ "body, p, li, ul, ol, td, th { font-size: 9pt; } "
42
+ "h1 { font-size: 14pt; } h2 { font-size: 12pt; } h3 { font-size: 10pt; }"
43
+ )
44
+ content_section = Section(md_text)
45
+ content_section.root = str(_PROJECT_ROOT)
46
+ pdf.add_section(content_section, user_css=content_css)
47
 
48
  buffer = BytesIO()
49
+ pdf.save_bytes(buffer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  buffer.seek(0)
51
  return buffer