"""Summarizer tool exposing a project-aware PDF generation capability.""" from __future__ import annotations import logging import os from datetime import datetime, timezone from typing import Annotated, Literal, Optional from langchain.agents import create_agent from langchain.tools import InjectedToolCallId, ToolRuntime, tool from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage from langgraph.config import get_stream_writer from langgraph.types import Command from graphs.middleware import build_project_subagent_middlewares from graphs.prompts_v2 import load_v2_prompt from graphs.state import CustomState from graphs.tools.retrieval_tools import search_project_docs def _extract_markdown(result: object) -> str: if isinstance(result, dict): messages = result.get("messages") or [] if messages: content = getattr(messages[-1], "content", "") or "" if isinstance(content, str): return content return "" def _clean_history(msgs: list[BaseMessage]) -> list[BaseMessage]: """Keep conversational turns safe to replay on a fresh sub-agent. Rules: - HumanMessage: always kept. - AIMessage: kept if it has non-empty text content. If it also carries tool_calls we strip them (unmatched tool_calls would break replay). - ToolMessage: dropped (no matching assistant turn in the cleaned list). """ cleaned: list[BaseMessage] = [] for m in msgs: if isinstance(m, HumanMessage): cleaned.append(m) elif isinstance(m, AIMessage): content = m.content if isinstance(m.content, str) else "" if not content.strip(): continue if getattr(m, "tool_calls", None): cleaned.append(AIMessage(content=content)) else: cleaned.append(m) return cleaned def build_generate_summary_pdf(_llm_unused): """Build a tool that generates a summary PDF for conversation/project scopes.""" summarizer_prompt = load_v2_prompt("summarizer_system.md") # Intentionally hardcoded model, aligned with requested implementation choice. project_subagent = create_agent( model="mistral-small-2603", tools=[search_project_docs], system_prompt=summarizer_prompt, state_schema=CustomState, middleware=build_project_subagent_middlewares(), ) conversation_subagent = create_agent( model="mistral-small-2603", tools=[], system_prompt=summarizer_prompt, state_schema=CustomState, ) @tool("generate_summary_pdf") def generate_summary_pdf( scope: Literal["conversation", "project"], focus: Optional[str] = None, runtime: ToolRuntime[None, CustomState] = None, tool_call_id: Annotated[str, InjectedToolCallId] = "", ) -> Command: """Generate a PDF summary for current conversation or active project.""" state = getattr(runtime, "state", None) or {} messages = list(state.get("messages", [])) project_id = state.get("project_id") writer = get_stream_writer() writer( { "kind": "tool", "tool": "generate_summary_pdf", "message": "Preparation de la synthese...", } ) if scope == "project" and not project_id: return Command( update={ "messages": [ ToolMessage( content="Aucun projet actif: impossible de generer une fiche exploitant.", tool_call_id=tool_call_id, status="error", ) ] } ) clean_history = _clean_history(messages) logging.info( "generate_summary_pdf: scope=%s raw_msgs=%d clean_msgs=%d project_id=%s focus=%s", scope, len(messages), len(clean_history), project_id, focus, ) if scope == "project": instruction = ( "Genere une fiche exploitant basee sur le projet actif. " f"project_id={project_id}." ) if focus: instruction += f" Angle prioritaire: {focus}." agent_input = { "messages": clean_history + [HumanMessage(content=instruction)], "project_id": project_id, } result = project_subagent.invoke(agent_input) summary_markdown = _extract_markdown(result) else: instruction = "Genere une synthese de la conversation courante." if focus: instruction += f" Angle prioritaire: {focus}." result = conversation_subagent.invoke( {"messages": clean_history + [HumanMessage(content=instruction)]} ) summary_markdown = _extract_markdown(result) writer( { "kind": "tool", "tool": "generate_summary_pdf", "message": "Conversion PDF en cours...", } ) # Lazy imports to keep graph import lightweight. from tools.pdf import markdown_to_pdf from tools.storage import upload_pdf_to_supabase pdf_result = markdown_to_pdf(summary_markdown) pdf_bytes = pdf_result.read() if hasattr(pdf_result, "read") else pdf_result now = datetime.now(timezone.utc) filename = f"summary-{now.strftime('%Y%m%d-%H%M%S')}.pdf" local_dir = "tmp_summaries" os.makedirs(local_dir, exist_ok=True) local_path = os.path.join(local_dir, filename) with open(local_path, "wb") as handle: handle.write(pdf_bytes) uploaded_url = None try: uploaded_url = upload_pdf_to_supabase(local_path, filename) except Exception: logging.exception("Upload of summary PDF failed") final_path = uploaded_url or local_path writer( { "kind": "tool", "tool": "generate_summary_pdf", "message": f"Synthese prete: {final_path}", } ) doc_meta = { "file_name": filename, "type": "project_summary_pdf" if scope == "project" else "conversation_summary_pdf", "generated_at": now.isoformat(), "link": uploaded_url or "", "scope": scope, } return Command( update={ "summary_markdown": summary_markdown, "summary_pdf_path": final_path, "messages": [ ToolMessage( content=f"Synthese generee: {final_path}", tool_call_id=tool_call_id, artifact={"document": doc_meta}, additional_kwargs={"metadata": {"document": doc_meta}}, ) ], } ) return generate_summary_pdf