11"""Build the summarization agent."""
22
3+ import asyncio
34import dataclasses
45import logging
5- import asyncio
66
77from django .conf import settings
88from django .core .files .storage import default_storage
99
10+ import semchunk
1011from asgiref .sync import sync_to_async
1112from pydantic_ai import RunContext
1213from pydantic_ai .messages import ToolReturn
1314
1415from .base import BaseAgent
15- from ..tools .document_search_rag import add_document_rag_search_tool
1616
1717logger = logging .getLogger (__name__ )
1818
@@ -37,28 +37,49 @@ def read_document_content(doc):
3737 return doc .file_name , f .read ().decode ("utf-8" )
3838
3939
40- async def hand_off_to_summarization_agent (
40+ async def summarize_chunk (idx , chunk , total_chunks , summarization_agent , ctx ):
41+ """Summarize a single chunk of text."""
42+ sum_prompt = (
43+ "You are an agent specializing in text summarization. "
44+ "Generate a clear and concise summary of the following passage "
45+ f"(part { idx } /{ total_chunks } ):\n '''\n { chunk } \n '''\n \n "
46+ )
47+
48+ logger .debug (
49+ "[summarize] CHUNK %s/%s prompt=> %s" , idx , total_chunks , sum_prompt [0 :100 ] + "..."
50+ )
51+
52+ resp = await summarization_agent .run (sum_prompt , usage = ctx .usage )
53+
54+ logger .debug ("[summarize] CHUNK %s/%s response<= %s" , idx , total_chunks , resp .output or "" )
55+ return resp .output or ""
56+
57+
58+ async def hand_off_to_summarization_agent ( # pylint: disable=too-many-locals
4159 ctx : RunContext , * , instructions : str | None = None
4260) -> ToolReturn :
4361 """
44- Summarize the documents for the user, only when asked for.
62+ Generate a complete, ready-to-use summary of the documents in context
63+ (do not request the documents to the user).
64+ Return this summary directly to the user WITHOUT any modification,
65+ or additional summarization.
66+ The summary is already optimized and MUST be presented as-is in the final response
67+ or translated preserving the information.
68+
4569 Instructions are optional but should reflect the user's request.
46- Examples :
47- "Résume ce doc en 2 paragraphes" -> instructions = "résumé en 2 paragraphes"
48- "Résume ce doc en anglais" -> instructions = "In English"
49- "Résume ce doc" -> instructions = "" (default)
70+
71+ Examples:
72+ "Summarize this doc in 2 paragraphs" -> instructions = "summary in 2 paragraphs"
73+ "Summarize this doc in English" -> instructions = "In English"
74+ "Summarize this doc" -> instructions = "" (default)
75+
5076 Args:
5177 instructions (str | None): The instructions the user gave to use for the summarization
5278 """
53- summarization_agent = SummarizationAgent ()
54-
55- prompt = (
56- "Do not mention the user request in your answer.\n "
57- "User request:\n "
58- "{user_prompt}\n \n "
59- "Document contents:\n "
60- "{documents_prompt}\n "
79+ instructions_hint = (
80+ instructions .strip () if instructions else "The summary should contain 2 or 3 parts."
6181 )
82+ summarization_agent = SummarizationAgent ()
6283
6384 # Collect documents content
6485 text_attachment = await sync_to_async (list )(
@@ -69,70 +90,70 @@ async def hand_off_to_summarization_agent(
6990
7091 documents = [await read_document_content (doc ) for doc in text_attachment ]
7192
72- # Instructions: rely on tool argument only; model should extract them upstream
73- if instructions is not None :
74- instructions_hint : str = instructions .strip ()
75- else :
76- instructions_hint = ""
77-
78- # Helpers
79- def chunk_text (text : str , size : int = 10000 ) -> list [str ]:
80- if size <= 0 :
81- return [text ]
82- return [text [i : i + size ] for i in range (0 , len (text ), size )]
83-
84- # 2) Chunk documents and summarize each chunk
85- full_text = "\n \n " .join (doc [1 ] for doc in documents )
86- chunks = chunk_text (full_text , size = 10000 )
93+ # Chunk documents and summarize each chunk
94+ chunk_size = settings .SUMMARIZATION_CHUNK_SIZE
95+ chunker = semchunk .chunkerify (
96+ tokenizer_or_token_counter = lambda text : len (text .split ()),
97+ chunk_size = chunk_size ,
98+ )
99+ documents_chunks = chunker (
100+ [doc [1 ] for doc in documents ],
101+ overlap = settings .SUMMARIZATION_OVERLAP_SIZE ,
102+ )
103+
87104 logger .info (
88105 "[summarize] chunking: %s parts (size~%s), instructions='%s'" ,
89- len (chunks ),
90- 10000 ,
91- instructions_hint or "" ,
106+ sum ( len (chunks ) for chunks in documents_chunks ),
107+ chunk_size ,
108+ instructions_hint ,
92109 )
93110
94- async def summarize_chunk (idx , chunk , total_chunks , summarization_agent , ctx ):
95- sum_prompt = (
96- "Tu es un agent spécialisé en synthèses de textes. "
97- "Génère un résumé clair et concis du passage suivant (partie {idx}/{total}) :\n "
98- "'''\n {context}\n '''\n \n "
99- ).format (context = chunk , idx = idx , total = total_chunks )
100- logger .info ("[summarize] CHUNK %s/%s prompt=> %s" , idx , total_chunks , sum_prompt [0 :100 ]+ '...' )
101- resp = await summarization_agent .run (sum_prompt , usage = ctx .usage )
102- logger .info ("[summarize] CHUNK %s/%s response<= %s" , idx , total_chunks , resp .output or "" )
103- return resp .output or ""
104-
105- # Parallelize the chunk summarization in batches of 5 using asyncio.gather
106- chunk_summaries : list [str ] = []
107- batch_size = 5
108- for start_idx in range (0 , len (chunks ), batch_size ):
109- end_idx = start_idx + batch_size
110- batch_chunks = chunks [start_idx :end_idx ]
111+ # Parallelize the chunk summarization with a semaphore to limit concurrent tasks
112+ # because it can be very resource intensive on the LLM backend
113+ semaphore = asyncio .Semaphore (settings .SUMMARIZATION_CONCURRENT_REQUESTS )
114+
115+ async def summarize_chunk_with_semaphore (idx , chunk , total_chunks ):
116+ """Summarize a chunk with semaphore-controlled concurrency."""
117+ async with semaphore :
118+ return await summarize_chunk (idx , chunk , total_chunks , summarization_agent , ctx )
119+
120+ doc_chunk_summaries = []
121+ for doc_chunks in documents_chunks :
111122 summarization_tasks = [
112- summarize_chunk (idx , chunk , len (chunks ), summarization_agent , ctx )
113- for idx , chunk in enumerate (batch_chunks , start = start_idx + 1 )
123+ summarize_chunk_with_semaphore (idx , chunk , len (doc_chunks ) )
124+ for idx , chunk in enumerate (doc_chunks , start = 1 )
114125 ]
115- batch_results = await asyncio .gather (* summarization_tasks )
116- chunk_summaries .extend (batch_results )
117-
118- if not instructions_hint :
119- instructions_hint = "Le résumé doit être en Français, contenir 2 ou 3 parties."
126+ chunk_summaries = await asyncio .gather (* summarization_tasks )
127+ doc_chunk_summaries .append (chunk_summaries )
128+
129+ context = "\n \n " .join (
130+ doc_name + "\n \n " + "\n \n " .join (summaries )
131+ for doc_name , summaries in zip (
132+ (doc [0 ] for doc in documents ),
133+ doc_chunk_summaries ,
134+ strict = True ,
135+ )
136+ )
120137
121- # 3) Merge chunk summaries into a single concise summary
138+ # Merge chunk summaries into a single concise summary
122139 merged_prompt = (
123- "Produit une synthèse cohérente à partir des résumés ci-dessous.\n \n "
124- "'''\n {context}\n '''\n \n "
125- "Contraintes :\n "
126- "- Résumer sans répéter.\n "
127- "- Harmoniser le style et la terminologie.\n "
128- "- Le résumé final doit être bien structuré et formaté en markdown. \n "
129- "- Respecter les consignes : {instructions}\n "
130- "Réponds directement avec le résumé final."
131- ).format (context = "\n \n " .join (chunk_summaries ), instructions = instructions_hint or "" )
132- logger .info ("[summarize] MERGE prompt=> %s" , merged_prompt )
140+ "Produce a coherent synthesis from the summaries below.\n \n "
141+ f"'''\n { context } \n '''\n \n "
142+ "Constraints:\n "
143+ "- Summarize without repetition.\n "
144+ "- Harmonize style and terminology.\n "
145+ "- The final summary must be well-structured and formatted in markdown.\n "
146+ f"- Follow the instructions: { instructions_hint } \n "
147+ "Respond directly with the final summary."
148+ )
149+
150+ logger .debug ("[summarize] MERGE prompt=> %s" , merged_prompt )
151+
133152 merged_resp = await summarization_agent .run (merged_prompt , usage = ctx .usage )
153+
134154 final_summary = (merged_resp .output or "" ).strip ()
135- logger .info ("[summarize] MERGE response<= %s" , final_summary )
155+
156+ logger .debug ("[summarize] MERGE response<= %s" , final_summary )
136157
137158 return ToolReturn (
138159 return_value = final_summary ,
0 commit comments