✨(summarize) new summarize tool integration
Improve the existing tool to manage bigger documents.
This commit is contained in:
@@ -13,6 +13,7 @@ and this project adheres to
|
||||
- 🐛(front) fix target blank links in chat #103
|
||||
- 🚑️(posthog) pass str instead of UUID for user PK #134
|
||||
- ⚡️(web-search) keep running when tool call fails #137
|
||||
- ✨(summarize): new summarize tool integration #78
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -11,6 +12,7 @@ from pydantic_ai import RunContext
|
||||
from pydantic_ai.messages import ToolReturn
|
||||
|
||||
from .base import BaseAgent
|
||||
from ..tools.document_search_rag import add_document_rag_search_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,14 +37,18 @@ def read_document_content(doc):
|
||||
return doc.file_name, f.read().decode("utf-8")
|
||||
|
||||
|
||||
async def hand_off_to_summarization_agent(ctx: RunContext) -> ToolReturn:
|
||||
async def hand_off_to_summarization_agent(
|
||||
ctx: RunContext, *, instructions: str | None = None
|
||||
) -> ToolReturn:
|
||||
"""
|
||||
Generate a complete, ready-to-use summary of the documents in context
|
||||
(do not request the documents to the user).
|
||||
Return this summary directly to the user WITHOUT any modification,
|
||||
or additional summarization.
|
||||
The summary is already optimized and MUST be presented as-is in the final response
|
||||
or translated preserving the information.
|
||||
Summarize the documents for the user, only when asked for.
|
||||
Instructions are optional but should reflect the user's request.
|
||||
Examples :
|
||||
"Résume ce doc en 2 paragraphes" -> instructions = "résumé en 2 paragraphes"
|
||||
"Résume ce doc en anglais" -> instructions = "In English"
|
||||
"Résume ce doc" -> instructions = "" (default)
|
||||
Args:
|
||||
instructions (str | None): The instructions the user gave to use for the summarization
|
||||
"""
|
||||
summarization_agent = SummarizationAgent()
|
||||
|
||||
@@ -53,6 +59,8 @@ async def hand_off_to_summarization_agent(ctx: RunContext) -> ToolReturn:
|
||||
"Document contents:\n"
|
||||
"{documents_prompt}\n"
|
||||
)
|
||||
|
||||
# Collect documents content
|
||||
text_attachment = await sync_to_async(list)(
|
||||
ctx.deps.conversation.attachments.filter(
|
||||
content_type__startswith="text/",
|
||||
@@ -61,25 +69,72 @@ async def hand_off_to_summarization_agent(ctx: RunContext) -> ToolReturn:
|
||||
|
||||
documents = [await read_document_content(doc) for doc in text_attachment]
|
||||
|
||||
documents_prompt = "\n\n".join(
|
||||
[
|
||||
(f"<document>\n<name>\n{name}\n</name>\n<content>\n{content}\n</content>\n</document>")
|
||||
for name, content in documents
|
||||
# Instructions: rely on tool argument only; model should extract them upstream
|
||||
if instructions is not None:
|
||||
instructions_hint: str = instructions.strip()
|
||||
else:
|
||||
instructions_hint = ""
|
||||
|
||||
# Helpers
|
||||
def chunk_text(text: str, size: int = 10000) -> list[str]:
|
||||
if size <= 0:
|
||||
return [text]
|
||||
return [text[i : i + size] for i in range(0, len(text), size)]
|
||||
|
||||
# 2) Chunk documents and summarize each chunk
|
||||
full_text = "\n\n".join(doc[1] for doc in documents)
|
||||
chunks = chunk_text(full_text, size=10000)
|
||||
logger.info(
|
||||
"[summarize] chunking: %s parts (size~%s), instructions='%s'",
|
||||
len(chunks),
|
||||
10000,
|
||||
instructions_hint or "",
|
||||
)
|
||||
|
||||
async def summarize_chunk(idx, chunk, total_chunks, summarization_agent, ctx):
|
||||
sum_prompt = (
|
||||
"Tu es un agent spécialisé en synthèses de textes. "
|
||||
"Génère un résumé clair et concis du passage suivant (partie {idx}/{total}) :\n"
|
||||
"'''\n{context}\n'''\n\n"
|
||||
).format(context=chunk, idx=idx, total=total_chunks)
|
||||
logger.info("[summarize] CHUNK %s/%s prompt=> %s", idx, total_chunks, sum_prompt[0:100]+'...')
|
||||
resp = await summarization_agent.run(sum_prompt, usage=ctx.usage)
|
||||
logger.info("[summarize] CHUNK %s/%s response<= %s", idx, total_chunks, resp.output or "")
|
||||
return resp.output or ""
|
||||
|
||||
# Parallelize the chunk summarization in batches of 5 using asyncio.gather
|
||||
chunk_summaries: list[str] = []
|
||||
batch_size = 5
|
||||
for start_idx in range(0, len(chunks), batch_size):
|
||||
end_idx = start_idx + batch_size
|
||||
batch_chunks = chunks[start_idx:end_idx]
|
||||
summarization_tasks = [
|
||||
summarize_chunk(idx, chunk, len(chunks), summarization_agent, ctx)
|
||||
for idx, chunk in enumerate(batch_chunks, start=start_idx + 1)
|
||||
]
|
||||
)
|
||||
batch_results = await asyncio.gather(*summarization_tasks)
|
||||
chunk_summaries.extend(batch_results)
|
||||
|
||||
formatted_prompt = prompt.format(
|
||||
user_prompt=ctx.prompt,
|
||||
documents_prompt=documents_prompt,
|
||||
)
|
||||
if not instructions_hint:
|
||||
instructions_hint = "Le résumé doit être en Français, contenir 2 ou 3 parties."
|
||||
|
||||
logger.debug("Summarize prompt: %s", formatted_prompt)
|
||||
|
||||
response = await summarization_agent.run(formatted_prompt, usage=ctx.usage)
|
||||
|
||||
logger.debug("Summarize response: %s", response)
|
||||
# 3) Merge chunk summaries into a single concise summary
|
||||
merged_prompt = (
|
||||
"Produit une synthèse cohérente à partir des résumés ci-dessous.\n\n"
|
||||
"'''\n{context}\n'''\n\n"
|
||||
"Contraintes :\n"
|
||||
"- Résumer sans répéter.\n"
|
||||
"- Harmoniser le style et la terminologie.\n"
|
||||
"- Le résumé final doit être bien structuré et formaté en markdown. \n"
|
||||
"- Respecter les consignes : {instructions}\n"
|
||||
"Réponds directement avec le résumé final."
|
||||
).format(context="\n\n".join(chunk_summaries), instructions=instructions_hint or "")
|
||||
logger.info("[summarize] MERGE prompt=> %s", merged_prompt)
|
||||
merged_resp = await summarization_agent.run(merged_prompt, usage=ctx.usage)
|
||||
final_summary = (merged_resp.output or "").strip()
|
||||
logger.info("[summarize] MERGE response<= %s", final_summary)
|
||||
|
||||
return ToolReturn(
|
||||
return_value=response.output,
|
||||
return_value=final_summary,
|
||||
metadata={"sources": {doc[0] for doc in documents}},
|
||||
)
|
||||
|
||||
@@ -483,14 +483,20 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
|
||||
@self.conversation_agent.system_prompt
|
||||
def summarization_system_prompt() -> str:
|
||||
return (
|
||||
"When you receive a result from the summarization tool, you MUST return it "
|
||||
"directly to the user without any modification, paraphrasing, or additional "
|
||||
"summarization."
|
||||
"The tool already produces optimized summaries that should be presented "
|
||||
"verbatim."
|
||||
"You may translate the summary if required, but you MUST preserve all the "
|
||||
"information from the original summary."
|
||||
"You may add a follow-up question after the summary if needed."
|
||||
"When the user asks to summarize attached document(s), you MUST call the"
|
||||
" summarize tool. Pass user's instructions if provided, otherwise pass an"
|
||||
" empty instructions string once the user confirms (e.g. says 'ok'). Do NOT"
|
||||
" call web search or document_search_rag to produce summaries; rely only on"
|
||||
" the attached documents stored in context."
|
||||
)
|
||||
|
||||
# Inform the model (system-level) that documents are attached and available
|
||||
@self.conversation_agent.system_prompt
|
||||
def attached_documents_note() -> str:
|
||||
return (
|
||||
"[Internal context] User documents are attached to this conversation. "
|
||||
"Do not request re-upload of documents; consider them already available "
|
||||
"via the internal store."
|
||||
)
|
||||
|
||||
@self.conversation_agent.tool
|
||||
|
||||
@@ -20,8 +20,12 @@ def add_document_rag_search_tool(agent: Agent) -> None:
|
||||
|
||||
Args:
|
||||
ctx (RunContext): The run context containing the conversation.
|
||||
query (str): The term to search the internet for.
|
||||
query (str): The query to search the documents for.
|
||||
"""
|
||||
# Defensive: ctx.deps or ctx.deps.conversation may be unavailable in some flows (start of conversation)
|
||||
if not getattr(ctx, "deps", None) or not getattr(ctx.deps, "conversation", None):
|
||||
return ToolReturn(return_value=[], content="", metadata={"sources": set()})
|
||||
|
||||
document_store_backend = import_string(settings.RAG_DOCUMENT_SEARCH_BACKEND)
|
||||
|
||||
document_store = document_store_backend(ctx.deps.conversation.collection_id)
|
||||
@@ -43,8 +47,6 @@ def add_document_rag_search_tool(agent: Agent) -> None:
|
||||
def document_rag_instructions() -> str:
|
||||
"""Dynamic system prompt function to add RAG instructions if any."""
|
||||
return (
|
||||
"If the user wants specific information from a document, invoke "
|
||||
"web_search_albert_rag with an appropriate query string."
|
||||
"Do not ask the user for the document; rely on the tool to locate "
|
||||
"and return relevant passages."
|
||||
"Use document_search_rag ONLY to retrieve specific passages from attached documents. "
|
||||
"Do NOT use it to summarize; for summaries, call the summarize tool instead."
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
|
||||
>
|
||||
<Loader />
|
||||
<Text $variation="600" $size="md">
|
||||
{t('Search...')}
|
||||
{toolInvocation.toolName === 'summarize' ? t('Summarizing...') : t('Search...')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user