From ba57eae2bb6ca10ed0628f6fc2406480ccd36158 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 1 May 2026 20:30:20 +0200 Subject: [PATCH] Add builtin deliverables route slice for delegated agents. --- .../builtins/deliverables/__init__.py | 0 .../subagents/builtins/deliverables/agent.py | 54 + .../builtins/deliverables/description.md | 1 + .../builtins/deliverables/system_prompt.md | 55 + .../builtins/deliverables/tools/__init__.py | 15 + .../deliverables/tools/generate_image.py | 247 ++++ .../builtins/deliverables/tools/index.py | 52 + .../builtins/deliverables/tools/podcast.py | 92 ++ .../builtins/deliverables/tools/report.py | 1061 +++++++++++++++++ .../builtins/deliverables/tools/resume.py | 799 +++++++++++++ .../deliverables/tools/video_presentation.py | 80 ++ 11 files changed, 2456 insertions(+) create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py create mode 100644 surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py new file mode 100644 index 000000000..e7eeec4db --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/agent.py @@ -0,0 +1,54 @@ +"""`deliverables` route: ``SubAgent`` spec for deepagents.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from deepagents import SubAgent +from langchain_core.language_models import BaseChatModel + +from app.agents.multi_agent_with_deepagents.subagents.shared.md_file_reader import ( + read_md_file, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, + merge_tools_permissions, +) +from app.agents.multi_agent_with_deepagents.subagents.shared.subagent_builder import ( + pack_subagent, +) + +from .tools.index import load_tools + +NAME = "deliverables" + + +def build_subagent( + *, + dependencies: dict[str, Any], + model: BaseChatModel | None = None, + extra_middleware: Sequence[Any] | None = None, + extra_tools_bucket: ToolsPermissions | None = None, +) -> SubAgent: + buckets = load_tools(dependencies=dependencies) + merged_tools_bucket = merge_tools_permissions(buckets, extra_tools_bucket) + tools = [ + row["tool"] + for row in (*merged_tools_bucket["allow"], *merged_tools_bucket["ask"]) + if row.get("tool") is not None + ] + interrupt_on = {r["name"]: True for r in merged_tools_bucket["ask"] if r.get("name")} + description = read_md_file(__package__, "description").strip() + if not description: + description = "Handles deliverables tasks for this workspace." + system_prompt = read_md_file(__package__, "system_prompt").strip() + return pack_subagent( + name=NAME, + description=description, + system_prompt=system_prompt, + tools=tools, + interrupt_on=interrupt_on, + model=model, + extra_middleware=extra_middleware, + ) diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md new file mode 100644 index 000000000..4dd0f67fe --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/description.md @@ -0,0 +1 @@ +Use for deliverables and shareable artifacts: generated reports, podcasts, video presentations, resumes, and images—not for routine lookups or single small edits elsewhere. diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md new file mode 100644 index 000000000..c44f131bb --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/system_prompt.md @@ -0,0 +1,55 @@ +You are the SurfSense deliverables operations sub-agent. +You receive delegated instructions from a supervisor agent and return structured results for supervisor synthesis. + + +Produce **deliverables**: shareable **artifacts** the user keeps (reports, slide-style video presentations, podcasts, resumes, images). Use explicit constraints and reliable proof of what was generated. + + + +- `generate_report` +- `generate_podcast` +- `generate_video_presentation` +- `generate_resume` +- `generate_image` + + + +- Use only tools in ``. +- Require essential generation constraints (audience, format, tone, core content). +- If critical constraints are missing, return `status=blocked` with `missing_fields`. +- Never claim artifact generation success without tool confirmation. + + + +- Do not perform connector data mutations unrelated to artifact generation. + + + +- Avoid generating artifacts with missing critical constraints. +- Prefer one complete artifact over partial multi-artifact output. + + + +- On generation failure, return `status=error` with best retry guidance. +- On missing constraints, return `status=blocked` with required fields. + + + +Return **only** one JSON object (no markdown/prose): +{ + "status": "success" | "partial" | "blocked" | "error", + "action_summary": string, + "evidence": { + "artifact_type": "report" | "podcast" | "video_presentation" | "resume" | "image" | null, + "artifact_id": string | null, + "artifact_location": string | null + }, + "next_step": string | null, + "missing_fields": string[] | null, + "assumptions": string[] | null +} +Rules: +- `status=success` -> `next_step=null`, `missing_fields=null`. +- `status=partial|blocked|error` -> `next_step` must be non-null. +- `status=blocked` due to missing required inputs -> `missing_fields` must be non-null. + diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py new file mode 100644 index 000000000..d0fe94217 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/__init__.py @@ -0,0 +1,15 @@ +"""Deliverable generators: reports, podcasts, video decks, resumes, images.""" + +from .generate_image import create_generate_image_tool +from .podcast import create_generate_podcast_tool +from .report import create_generate_report_tool +from .resume import create_generate_resume_tool +from .video_presentation import create_generate_video_presentation_tool + +__all__ = [ + "create_generate_image_tool", + "create_generate_podcast_tool", + "create_generate_report_tool", + "create_generate_resume_tool", + "create_generate_video_presentation_tool", +] diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py new file mode 100644 index 000000000..ab9dbc0ea --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/generate_image.py @@ -0,0 +1,247 @@ +"""Image generation via litellm; resolves model config from the search space and returns UI-ready payloads.""" + +import hashlib +import logging +from typing import Any + +from langchain_core.tools import tool +from litellm import aimage_generation +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import config +from app.db import ( + ImageGeneration, + ImageGenerationConfig, + SearchSpace, + shielded_async_session, +) +from app.services.image_gen_router_service import ( + IMAGE_GEN_AUTO_MODE_ID, + ImageGenRouterService, + is_image_gen_auto_mode, +) +from app.utils.signed_image_urls import generate_image_token + +logger = logging.getLogger(__name__) + +# Provider mapping (same as routes) +_PROVIDER_MAP = { + "OPENAI": "openai", + "AZURE_OPENAI": "azure", + "GOOGLE": "gemini", + "VERTEX_AI": "vertex_ai", + "BEDROCK": "bedrock", + "RECRAFT": "recraft", + "OPENROUTER": "openrouter", + "XINFERENCE": "xinference", + "NSCALE": "nscale", +} + + +def _build_model_string( + provider: str, model_name: str, custom_provider: str | None +) -> str: + if custom_provider: + return f"{custom_provider}/{model_name}" + prefix = _PROVIDER_MAP.get(provider.upper(), provider.lower()) + return f"{prefix}/{model_name}" + + +def _get_global_image_gen_config(config_id: int) -> dict | None: + """Get a global image gen config by negative ID.""" + for cfg in config.GLOBAL_IMAGE_GEN_CONFIGS: + if cfg.get("id") == config_id: + return cfg + return None + + +def create_generate_image_tool( + search_space_id: int, + db_session: AsyncSession, +): + """Create ``generate_image`` with bound search space; DB work uses a per-call session.""" + del db_session # use a fresh per-call session, see below + + @tool + async def generate_image( + prompt: str, + n: int = 1, + ) -> dict[str, Any]: + """ + Generate an image from a text description using AI image models. + + Use this tool when the user asks you to create, generate, draw, or make an image. + The generated image will be displayed directly in the chat. + + Args: + prompt: A detailed text description of the image to generate. + Be specific about subject, style, colors, composition, and mood. + n: Number of images to generate (1-4). Default: 1 + + Returns: + A dictionary containing the generated image(s) for display in the chat. + """ + try: + # Use a per-call session so concurrent tool calls don't share an + # AsyncSession (which is not concurrency-safe). The streaming + # task's session is shared across every tool; without isolation, + # autoflushes from a concurrent writer poison this tool too. + async with shielded_async_session() as session: + result = await session.execute( + select(SearchSpace).filter(SearchSpace.id == search_space_id) + ) + search_space = result.scalars().first() + if not search_space: + return {"error": "Search space not found"} + + config_id = ( + search_space.image_generation_config_id or IMAGE_GEN_AUTO_MODE_ID + ) + + # Build generation kwargs + # NOTE: size, quality, and style are intentionally NOT passed. + # Different models support different values for these params + # (e.g. DALL-E 3 wants "hd"/"standard" for quality while + # gpt-image-1 wants "high"/"medium"/"low"; size options also + # differ). Letting the model use its own defaults avoids errors. + gen_kwargs: dict[str, Any] = {} + if n is not None and n > 1: + gen_kwargs["n"] = n + + # Call litellm based on config type + if is_image_gen_auto_mode(config_id): + if not ImageGenRouterService.is_initialized(): + return { + "error": "No image generation models configured. " + "Please add an image model in Settings > Image Models." + } + response = await ImageGenRouterService.aimage_generation( + prompt=prompt, model="auto", **gen_kwargs + ) + elif config_id < 0: + cfg = _get_global_image_gen_config(config_id) + if not cfg: + return { + "error": f"Image generation config {config_id} not found" + } + + model_string = _build_model_string( + cfg.get("provider", ""), + cfg["model_name"], + cfg.get("custom_provider"), + ) + gen_kwargs["api_key"] = cfg.get("api_key") + if cfg.get("api_base"): + gen_kwargs["api_base"] = cfg["api_base"] + if cfg.get("api_version"): + gen_kwargs["api_version"] = cfg["api_version"] + if cfg.get("litellm_params"): + gen_kwargs.update(cfg["litellm_params"]) + + response = await aimage_generation( + prompt=prompt, model=model_string, **gen_kwargs + ) + else: + # Positive ID = user-created ImageGenerationConfig + cfg_result = await session.execute( + select(ImageGenerationConfig).filter( + ImageGenerationConfig.id == config_id + ) + ) + db_cfg = cfg_result.scalars().first() + if not db_cfg: + return { + "error": f"Image generation config {config_id} not found" + } + + model_string = _build_model_string( + db_cfg.provider.value, + db_cfg.model_name, + db_cfg.custom_provider, + ) + gen_kwargs["api_key"] = db_cfg.api_key + if db_cfg.api_base: + gen_kwargs["api_base"] = db_cfg.api_base + if db_cfg.api_version: + gen_kwargs["api_version"] = db_cfg.api_version + if db_cfg.litellm_params: + gen_kwargs.update(db_cfg.litellm_params) + + response = await aimage_generation( + prompt=prompt, model=model_string, **gen_kwargs + ) + + # Parse the response and store in DB + response_dict = ( + response.model_dump() + if hasattr(response, "model_dump") + else dict(response) + ) + + # Generate a random access token for this image + access_token = generate_image_token() + + # Save to image_generations table for history + db_image_gen = ImageGeneration( + prompt=prompt, + model=getattr(response, "_hidden_params", {}).get("model"), + n=n, + image_generation_config_id=config_id, + response_data=response_dict, + search_space_id=search_space_id, + access_token=access_token, + ) + session.add(db_image_gen) + await session.commit() + await session.refresh(db_image_gen) + db_image_gen_id = db_image_gen.id + + # Extract image URLs from response + images = response_dict.get("data", []) + if not images: + return {"error": "No images were generated"} + + first_image = images[0] + revised_prompt = first_image.get("revised_prompt", prompt) + + # Resolve image URL: + # - If the API returned a URL, use it directly. + # - If the API returned b64_json (e.g. gpt-image-1), serve the + # image through our backend endpoint to avoid bloating the + # LLM context with megabytes of base64 data. + if first_image.get("url"): + image_url = first_image["url"] + elif first_image.get("b64_json"): + backend_url = config.BACKEND_URL or "http://localhost:8000" + image_url = ( + f"{backend_url}/api/v1/image-generations/" + f"{db_image_gen_id}/image?token={access_token}" + ) + else: + return {"error": "No displayable image data in the response"} + + image_id = f"image-{hashlib.md5(image_url.encode()).hexdigest()[:12]}" + + return { + "id": image_id, + "assetId": image_url, + "src": image_url, + "alt": revised_prompt or prompt, + "title": "Generated Image", + "description": revised_prompt if revised_prompt != prompt else None, + "domain": "ai-generated", + "ratio": "auto", + "generated": True, + "prompt": prompt, + "image_count": len(images), + } + + except Exception as e: + logger.exception("Image generation failed in tool") + return { + "error": f"Image generation failed: {e!s}", + "prompt": prompt, + } + + return generate_image diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py new file mode 100644 index 000000000..d640837b5 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/index.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +from app.agents.multi_agent_with_deepagents.subagents.shared.permissions import ( + ToolsPermissions, +) + +from .generate_image import create_generate_image_tool +from .podcast import create_generate_podcast_tool +from .report import create_generate_report_tool +from .resume import create_generate_resume_tool +from .video_presentation import create_generate_video_presentation_tool + + +def load_tools(*, dependencies: dict[str, Any] | None = None, **kwargs: Any) -> ToolsPermissions: + resolved_dependencies = {**(dependencies or {}), **kwargs} + podcast = create_generate_podcast_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + thread_id=resolved_dependencies["thread_id"], + ) + video = create_generate_video_presentation_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + thread_id=resolved_dependencies["thread_id"], + ) + report = create_generate_report_tool( + search_space_id=resolved_dependencies["search_space_id"], + thread_id=resolved_dependencies["thread_id"], + connector_service=resolved_dependencies.get("connector_service"), + available_connectors=resolved_dependencies.get("available_connectors"), + available_document_types=resolved_dependencies.get("available_document_types"), + ) + resume = create_generate_resume_tool( + search_space_id=resolved_dependencies["search_space_id"], + thread_id=resolved_dependencies["thread_id"], + ) + image = create_generate_image_tool( + search_space_id=resolved_dependencies["search_space_id"], + db_session=resolved_dependencies["db_session"], + ) + return { + "allow": [ + {"name": getattr(podcast, "name", "") or "", "tool": podcast}, + {"name": getattr(video, "name", "") or "", "tool": video}, + {"name": getattr(report, "name", "") or "", "tool": report}, + {"name": getattr(resume, "name", "") or "", "tool": resume}, + {"name": getattr(image, "name", "") or "", "tool": image}, + ], + "ask": [], + } diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py new file mode 100644 index 000000000..55d9b3565 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/podcast.py @@ -0,0 +1,92 @@ +"""Factory for a podcast-generation tool that queues background work and returns an ID for polling.""" + +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Podcast, PodcastStatus, shielded_async_session + + +def create_generate_podcast_tool( + search_space_id: int, + db_session: AsyncSession, + thread_id: int | None = None, +): + """Create ``generate_podcast`` with bound search space and thread; DB writes use a tool-local session.""" + del db_session # writes use a fresh tool-local session, see below + + @tool + async def generate_podcast( + source_content: str, + podcast_title: str = "SurfSense Podcast", + user_prompt: str | None = None, + ) -> dict[str, Any]: + """ + Generate a podcast from the provided content. + + Use this tool when the user asks to create, generate, or make a podcast. + Common triggers include phrases like: + - "Give me a podcast about this" + - "Create a podcast from this conversation" + - "Generate a podcast summary" + - "Make a podcast about..." + - "Turn this into a podcast" + + Args: + source_content: The text content to convert into a podcast. + podcast_title: Title for the podcast (default: "SurfSense Podcast") + user_prompt: Optional instructions for podcast style, tone, or format. + + Returns: + A dictionary containing: + - status: PodcastStatus value (pending, generating, or failed) + - podcast_id: The podcast ID for polling (when status is pending or generating) + - title: The podcast title + - message: Status message (or "error" field if status is failed) + """ + try: + # One DB session per tool call so parallel invocations never share an AsyncSession. + async with shielded_async_session() as session: + podcast = Podcast( + title=podcast_title, + status=PodcastStatus.PENDING, + search_space_id=search_space_id, + thread_id=thread_id, + ) + session.add(podcast) + await session.commit() + await session.refresh(podcast) + podcast_id = podcast.id + + from app.tasks.celery_tasks.podcast_tasks import ( + generate_content_podcast_task, + ) + + task = generate_content_podcast_task.delay( + podcast_id=podcast_id, + source_content=source_content, + search_space_id=search_space_id, + user_prompt=user_prompt, + ) + + print(f"[generate_podcast] Created podcast {podcast_id}, task: {task.id}") + + return { + "status": PodcastStatus.PENDING.value, + "podcast_id": podcast_id, + "title": podcast_title, + "message": "Podcast generation started. This may take a few minutes.", + } + + except Exception as e: + error_message = str(e) + print(f"[generate_podcast] Error: {error_message}") + return { + "status": PodcastStatus.FAILED.value, + "error": error_message, + "title": podcast_title, + "podcast_id": None, + } + + return generate_podcast diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py new file mode 100644 index 000000000..385100c62 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/report.py @@ -0,0 +1,1061 @@ +"""Factory for inline Markdown reports: optional KB sourcing, section-aware revision, short-lived DB sessions.""" + +import asyncio +import json +import logging +import re +from typing import Any + +from langchain_core.callbacks import dispatch_custom_event +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + +from app.db import Report, shielded_async_session +from app.services.connector_service import ConnectorService +from app.services.llm_service import get_document_summary_llm + +logger = logging.getLogger(__name__) + +# ─── Shared Formatting Rules ──────────────────────────────────────────────── +# Reusable formatting instructions appended to section-level and review prompts. + +_FORMATTING_RULES = """\ +- IMPORTANT: Output raw Markdown directly. Do NOT wrap the entire output in a \ +code fence (e.g. ```markdown, ````markdown, or any backtick fence). Individual \ +code examples and diagrams inside the report should still use fenced code blocks, \ +but the report itself must NOT be enclosed in one. +- Maintain proper Markdown formatting throughout. +- When including code examples, ALWAYS format them as proper fenced code blocks \ +with the correct language identifier (e.g. ```java, ```python). Code inside code \ +blocks MUST have proper line breaks and indentation — NEVER put multiple statements \ +on a single line. Each statement, brace, and logical block must be on its own line \ +with correct indentation. +- When including Mermaid diagrams, use ```mermaid fenced code blocks. Each Mermaid \ +statement MUST be on its own line — NEVER use semicolons to join multiple statements \ +on one line. For line breaks inside node labels, use
(NOT
). +- When including mathematical formulas or equations, ALWAYS use LaTeX notation. \ +NEVER use backtick code spans or Unicode symbols for math.""" + +# ─── Standard Report Footer ───────────────────────────────────────────────── +# Appended to every generated report after content generation. + +_REPORT_FOOTER = "Powered by SurfSense AI." + +# ─── Prompt: Single-Shot Report Generation ─────────────────────────────────── + +_REPORT_PROMPT = """You are an expert report writer. Generate a comprehensive Markdown report. + +**Topic:** {topic} +**Report Style:** {report_style} +{user_instructions_section} +{previous_version_section} + +**Source Content:** +{source_content} + +--- + +{length_instruction} + +Write a well-structured Markdown report with a # title, executive summary, organized sections, and conclusion. Cite facts from the source content. Be thorough and professional. + +{formatting_rules} +""" + +# ─── Prompt: Full-Document Revision (fallback when section-level fails) ────── + +_REVISION_PROMPT = """You are an expert report editor. Apply ONLY the requested changes — do NOT rewrite from scratch. + +**Topic:** {topic} +**Report Style:** {report_style} +**Modification Instructions:** {user_instructions_section} + +**Source Content (use if relevant):** +{source_content} + +--- + +**EXISTING REPORT:** + +{previous_report_content} + +--- + +{length_instruction} + +Preserve all structure and content not affected by the modification. + +{formatting_rules} +""" + +# ─── Prompt: Section-Level Revision — Identify Affected Sections ───────────── + +_IDENTIFY_SECTIONS_PROMPT = """You are analyzing a Markdown report to determine which sections need modification based on the user's request. + +**User's Modification Request:** {user_instructions} + +**Report Sections (indexed starting at 0):** +{sections_listing} + +--- + +Determine which sections need to be modified, added, or removed to fulfill the user's request. + +Return ONLY a JSON object with these fields: +- "modify": Array of section indices (0-based) that need content changes +- "add": Array of objects like {{"after_index": 2, "heading": "## New Section Title", "description": "What this section should cover"}} for new sections to insert +- "remove": Array of section indices to remove entirely (use sparingly) +- "reasoning": A brief explanation of your decisions + +Guidelines: +- If the change is GLOBAL (e.g., "change the tone", "make the whole report shorter", "translate to Spanish"), include ALL section indices in "modify". +- If the change is TARGETED (e.g., "expand the budget section", "fix the conclusion"), include ONLY the affected section indices. +- For "add a section about X", use the "add" field with the appropriate insertion point. +- Prefer modifying over removing+adding when possible. + +Return ONLY valid JSON, no markdown fences: +""" + +# ─── Prompt: Section-Level Revision — Revise a Single Section ──────────────── + +_REVISE_SECTION_PROMPT = """Revise ONLY this section based on the instructions. If the instructions don't apply, return it UNCHANGED. + +**Modification Instructions:** {user_instructions} + +**Current Section:** +{section_content} + +**Context (surrounding sections — for coherence only, do NOT output them):** +{context_sections} + +**Source Content:** +{source_content} + +--- + +Keep the same heading and heading level. Preserve content not affected by the modification. +{formatting_rules} +""" + +# ─── Prompt: New Section Generation (for section-level add) ───────────────── + +_NEW_SECTION_PROMPT = """You are an expert report writer. Write a new section to be inserted into an existing report. + +**Report Topic:** {topic} +**Report Style:** {report_style} +**Section Heading:** {heading} +**Section Goal:** {description} +**User Instructions:** {user_instructions} + +**Surrounding Context:** +{context_sections} + +**Source Content:** +{source_content} + +--- + +**Rules:** +1. Write ONLY this section, starting with the heading "{heading}". +2. Ensure the section flows naturally with the surrounding context. +3. Be comprehensive — cover the topic described above. +{formatting_rules} + +Write the new section now: +""" + + +# ─── Utility Functions ────────────────────────────────────────────────────── + + +def _strip_wrapping_code_fences(text: str) -> str: + """Remove wrapping code fences that LLMs often add around Markdown output. + + Handles patterns like: + ```markdown\\n...content...\\n``` + ````markdown\\n...content...\\n```` + ```md\\n...content...\\n``` + ```\\n...content...\\n``` + ```json\\n...content...\\n``` + Supports 3 or more backticks (LLMs escalate when content has triple-backtick blocks). + """ + stripped = text.strip() + # Match opening fence with 3+ backticks and optional language tag + m = re.match(r"^(`{3,})(?:markdown|md|json)?\s*\n", stripped) + if m: + fence = m.group(1) # e.g. "```" or "````" + if stripped.endswith(fence): + stripped = stripped[m.end() :] # remove opening fence + stripped = stripped[: -len(fence)].rstrip() # remove closing fence + return stripped + + +def _extract_metadata(content: str) -> dict[str, Any]: + """Extract metadata from generated Markdown content.""" + # Count section headings + headings = re.findall(r"^(#{1,6})\s+(.+)$", content, re.MULTILINE) + + # Word count + word_count = len(content.split()) + + # Character count + char_count = len(content) + + return { + "status": "ready", + "word_count": word_count, + "char_count": char_count, + "section_count": len(headings), + } + + +def _parse_sections(content: str) -> list[dict[str, str]]: + """Parse Markdown content into sections split by # and ## headings. + + Returns a list of dicts: [{"heading": "## Title", "body": "content..."}, ...] + Content before the first heading is captured with heading="". + ### and deeper headings are kept inside their parent ## section's body. + """ + lines = content.split("\n") + sections: list[dict[str, str]] = [] + current_heading = "" + current_body_lines: list[str] = [] + in_code_block = False + + for line in lines: + # Track code blocks to avoid matching headings inside them + stripped = line.strip() + if stripped.startswith("```"): + in_code_block = not in_code_block + + # Only split on # or ## headings (not ### or deeper) and only outside code blocks + is_section_heading = ( + not in_code_block + and re.match(r"^#{1,2}\s+", line) + and not re.match(r"^#{3,}\s+", line) + ) + + if is_section_heading: + # Save previous section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + current_heading = line.strip() + current_body_lines = [] + else: + current_body_lines.append(line) + + # Save last section + if current_heading or current_body_lines: + sections.append( + { + "heading": current_heading, + "body": "\n".join(current_body_lines).strip(), + } + ) + + return sections + + +def _stitch_sections(sections: list[dict[str, str]]) -> str: + """Stitch parsed sections back into a single Markdown string.""" + parts = [] + for section in sections: + if section["heading"]: + parts.append(section["heading"]) + if section["body"]: + parts.append(section["body"]) + return "\n\n".join(parts) + + +# ─── Async Generation Helpers ─────────────────────────────────────────────── + + +async def _revise_with_sections( + llm: Any, + parent_content: str, + user_instructions: str, + source_content: str, + topic: str, + report_style: str, +) -> str | None: + """Section-level revision: identify affected sections and revise only those. + + Unchanged sections are kept byte-for-byte identical. + Returns the revised content, or None to trigger full-document revision fallback. + """ + # Parse report into sections + sections = _parse_sections(parent_content) + if len(sections) < 2: + logger.info( + "[generate_report] Too few sections for section-level revision, using full revision" + ) + return None + + # Build a sections listing for the LLM + sections_listing = "" + for i, sec in enumerate(sections): + heading = sec["heading"] or "(preamble — content before first heading)" + body_preview = ( + sec["body"][:200] + "..." if len(sec["body"]) > 200 else sec["body"] + ) + sections_listing += f"\n[{i}] {heading}\n Preview: {body_preview}\n" + + # Step 1: Ask LLM which sections need modification + identify_prompt = _IDENTIFY_SECTIONS_PROMPT.format( + user_instructions=user_instructions, + sections_listing=sections_listing, + ) + + try: + response = await llm.ainvoke([HumanMessage(content=identify_prompt)]) + raw = response.content + if not raw or not isinstance(raw, str): + return None + + raw = _strip_wrapping_code_fences(raw).strip() + json_match = re.search(r"\{[\s\S]*\}", raw) + if json_match: + raw = json_match.group(0) + + plan = json.loads(raw) + modify_indices: list[int] = plan.get("modify", []) + add_sections: list[dict[str, Any]] = plan.get("add", []) + remove_indices: list[int] = plan.get("remove", []) + reasoning = plan.get("reasoning", "") + + logger.info( + f"[generate_report] Section-level revision plan: " + f"modify={modify_indices}, add={len(add_sections)}, " + f"remove={remove_indices}, reasoning={reasoning}" + ) + except Exception: + logger.warning( + "[generate_report] Failed to identify sections for revision, " + "falling back to full revision", + exc_info=True, + ) + return None + + # If ALL sections need modification, full revision is more efficient and coherent + if len(modify_indices) >= len(sections): + logger.info( + "[generate_report] All sections need modification, deferring to full revision" + ) + return None + + # Compute total operations for progress tracking + total_ops = len(modify_indices) + len(add_sections) + current_op = 0 + + # Emit plan summary + parts = [] + if modify_indices: + parts.append( + f"modifying {len(modify_indices)} section{'s' if len(modify_indices) > 1 else ''}" + ) + if add_sections: + parts.append( + f"adding {len(add_sections)} new section{'s' if len(add_sections) > 1 else ''}" + ) + if remove_indices: + parts.append( + f"removing {len(remove_indices)} section{'s' if len(remove_indices) > 1 else ''}" + ) + plan_summary = ", ".join(parts) if parts else "no changes needed" + + dispatch_custom_event( + "report_progress", + { + "phase": "revision_plan", + "message": plan_summary.capitalize(), + "modify_count": len(modify_indices), + "add_count": len(add_sections), + "remove_count": len(remove_indices), + "total_ops": total_ops, + }, + ) + + # Step 2: Revise only the affected sections + revised_sections = list(sections) # shallow copy — unmodified sections stay as-is + + for idx in modify_indices: + if idx < 0 or idx >= len(sections): + continue + + current_op += 1 + sec = sections[idx] + + # Extract plain section name (strip markdown heading markers) + section_name = ( + re.sub(r"^#+\s*", "", sec["heading"]).strip() + if sec["heading"] + else "Preamble" + ) + dispatch_custom_event( + "report_progress", + { + "phase": "revising_section", + "message": f"Revising: {section_name} ({current_op}/{total_ops})...", + }, + ) + + section_content = ( + f"{sec['heading']}\n\n{sec['body']}" if sec["heading"] else sec["body"] + ) + + # Build context from surrounding sections + context_parts = [] + if idx > 0: + prev = sections[idx - 1] + prev_preview = prev["body"][:300] + ( + "..." if len(prev["body"]) > 300 else "" + ) + context_parts.append( + f"**Previous section:** {prev['heading']}\n{prev_preview}" + ) + if idx < len(sections) - 1: + nxt = sections[idx + 1] + nxt_preview = nxt["body"][:300] + ("..." if len(nxt["body"]) > 300 else "") + context_parts.append(f"**Next section:** {nxt['heading']}\n{nxt_preview}") + context = ( + "\n\n".join(context_parts) if context_parts else "(No surrounding sections)" + ) + + revise_prompt = _REVISE_SECTION_PROMPT.format( + user_instructions=user_instructions, + section_content=section_content, + context_sections=context, + source_content=source_content[:40000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=revise_prompt)]) + revised_text = resp.content + if revised_text and isinstance(revised_text, str): + revised_text = _strip_wrapping_code_fences(revised_text).strip() + # Parse the LLM output back into heading + body + revised_parsed = _parse_sections(revised_text) + if revised_parsed: + revised_sections[idx] = revised_parsed[0] + else: + revised_sections[idx] = { + "heading": sec["heading"], + "body": revised_text, + } + + logger.info(f"[generate_report] Revised section [{idx}]: {sec['heading']}") + + # Step 3: Handle new section additions (insert in reverse order to preserve indices) + for add_info in sorted( + add_sections, + key=lambda x: x.get("after_index", len(revised_sections) - 1), + reverse=True, + ): + current_op += 1 + after_idx = add_info.get("after_index", len(revised_sections) - 1) + heading = add_info.get("heading", "## New Section") + description = add_info.get("description", "") + + # Extract plain section name for progress display + plain_heading = re.sub(r"^#+\s*", "", heading).strip() + dispatch_custom_event( + "report_progress", + { + "phase": "adding_section", + "message": f"Adding: {plain_heading} ({current_op}/{total_ops})...", + }, + ) + + # Build context from the surrounding sections at the insertion point + ctx_parts = [] + if 0 <= after_idx < len(revised_sections): + before_sec = revised_sections[after_idx] + ctx_parts.append( + f"**Section before:** {before_sec['heading']}\n{before_sec['body'][:300]}" + ) + insert_idx = min(after_idx + 1, len(revised_sections)) + if insert_idx < len(revised_sections): + after_sec = revised_sections[insert_idx] + ctx_parts.append( + f"**Section after:** {after_sec['heading']}\n{after_sec['body'][:300]}" + ) + + new_prompt = _NEW_SECTION_PROMPT.format( + topic=topic, + report_style=report_style, + heading=heading, + description=description, + user_instructions=user_instructions, + context_sections="\n\n".join(ctx_parts) if ctx_parts else "(None)", + source_content=source_content[:30000], + formatting_rules=_FORMATTING_RULES, + ) + + resp = await llm.ainvoke([HumanMessage(content=new_prompt)]) + new_content = resp.content + if new_content and isinstance(new_content, str): + new_content = _strip_wrapping_code_fences(new_content).strip() + new_parsed = _parse_sections(new_content) + if new_parsed: + revised_sections.insert(insert_idx, new_parsed[0]) + else: + revised_sections.insert( + insert_idx, + { + "heading": heading, + "body": new_content, + }, + ) + + logger.info( + f"[generate_report] Added new section after [{after_idx}]: {heading}" + ) + + # Step 4: Handle removals (reverse order to preserve indices) + for idx in sorted(remove_indices, reverse=True): + if 0 <= idx < len(revised_sections): + logger.info( + f"[generate_report] Removed section [{idx}]: " + f"{revised_sections[idx]['heading']}" + ) + revised_sections.pop(idx) + + return _stitch_sections(revised_sections) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + +def create_generate_report_tool( + search_space_id: int, + thread_id: int | None = None, + connector_service: ConnectorService | None = None, + available_connectors: list[str] | None = None, + available_document_types: list[str] | None = None, +): + """ + Factory function to create the generate_report tool with injected dependencies. + + The tool generates a Markdown report inline using the search space's + document summary LLM, saves it to the database, and returns immediately. + + Uses short-lived database sessions for each DB operation so no connection + is held during the long LLM API call. + + Generation strategies: + - New reports: single-shot generation (1 LLM call) + - Revisions (targeted edits): section-level (unchanged sections preserved) + - Revisions (global changes): full-document revision fallback + + Source strategies: + - "provided"/"conversation": use only the supplied source_content + - "kb_search": search the knowledge base internally using targeted queries + - "auto": use source_content if sufficient, otherwise fall back to KB search + + Args: + search_space_id: The user's search space ID + thread_id: The chat thread ID for associating the report + connector_service: Optional connector service for internal KB search. + When provided, the tool can search the knowledge base internally + (used by the "kb_search" and "auto" source strategies). + available_connectors: Optional list of connector types available in the + search space (used to scope internal KB searches). + + Returns: + A configured tool function for generating reports + """ + + @tool + async def generate_report( + topic: str, + source_content: str = "", + source_strategy: str = "provided", + search_queries: list[str] | None = None, + report_style: str = "detailed", + user_instructions: str | None = None, + parent_report_id: int | None = None, + ) -> dict[str, Any]: + """ + Generate a structured Markdown report artifact from provided content. + + Use this tool when the user asks to create, generate, write, produce, + draft, or summarize into a report-style deliverable. + + Trigger classes include: + - Direct trigger words WITH creation/modification verb: report, + document, memo, letter, template, article, guide, blog post, + one-pager, briefing, comprehensive guide. + - Creation-intent phrases: "write a report", "generate a document", + "draft a summary", "create an executive summary". + - Modification-intent phrases: "revise the report", "update the + report", "make it shorter", "add a section about X", "expand the + budget section", "rewrite in formal tone". + + IMPORTANT — what does NOT count as "asking for a report": + - Questions or discussion about a report or its topic are NOT report + requests. Respond to these conversationally in chat. + Examples: "What other examples to put there?", "What else could be + added?", "Can you explain section 2?", "Is the data accurate?", + "What's missing?", "How could this be improved?", "What other + topics are related?" + - Quick summary requests, explanations, or follow-up questions. + - The test: Does the message contain a creation/modification VERB + (write, create, generate, draft, add, revise, update, expand, + rewrite, make) directed at producing a deliverable? If no verb + → answer in chat. + + FORMAT/EXPORT RULE: + - Always generate the report content in Markdown. + - If the user requests DOCX/Word/PDF or another file format, export + from the generated Markdown report. + + SOURCE STRATEGY (how to collect source material): + - source_strategy="conversation" — The conversation already has + enough context (prior Q&A, filesystem exploration, pasted text, + uploaded files, scraped webpages). Pass a thorough summary as + source_content. + - source_strategy="kb_search" — Search the knowledge base + internally. Provide 1-5 targeted search_queries. The tool + handles searching internally — do NOT manually read and dump + /documents/ files into source_content. + - source_strategy="provided" — Use only what is in source_content + (default, backward-compatible). + - source_strategy="auto" — Use source_content if it has enough + material; otherwise fall back to internal KB search using + search_queries. + + CONVERSATION REUSE (HIGH PRIORITY): + - If the user has been asking questions in this chat and the + conversation contains substantive answers/discussion on the + topic, prefer source_strategy="conversation" with a thorough + summary of the full chat history as source_content. + - The user's prior questions and your answers ARE the source + material. Do NOT redundantly search the knowledge base for + information that is already in the chat. + + VERSIONING — parent_report_id: + - Set parent_report_id when the user wants to MODIFY, REVISE, + IMPROVE, UPDATE, EXPAND, or ADD CONTENT TO an existing report + that was already generated in this conversation. + - This includes both explicit AND implicit modification requests. + If the user references the existing report using words like "it", + "this", "here", "the report", or clearly refers to a previously + generated report, treat it as a revision request. + - The value must be the report_id from a previous generate_report + result in this same conversation. + - Do NOT set parent_report_id when: + * The user asks for a report on a completely NEW/DIFFERENT topic + * The user says "generate another report" (new report, not revision) + * There is no prior report to reference + + Examples of when to SET parent_report_id: + User: "Make that report shorter" → parent_report_id = + User: "Add a cost analysis section to the report" → parent_report_id = + User: "Rewrite the report in a more formal tone" → parent_report_id = + User: "I want more details about pricing in here" → parent_report_id = + User: "Include more examples" → parent_report_id = + User: "Can you also cover nutrition in this?" → parent_report_id = + User: "Make it more detailed" → parent_report_id = + User: "Not bad, but expand on the budget section" → parent_report_id = + User: "Also mention the competitor landscape" → parent_report_id = + + Examples of when to LEAVE parent_report_id as None: + User: "Generate a report on climate change" → None (new topic) + User: "Write me a report about the budget" → None (new topic) + User: "Create another report, this time about marketing" → None + User: "Now write one about travel trends in Europe" → None (new topic) + + Args: + topic: Short title for the report (max ~8 words). + source_content: Text to base the report on. Can be empty when + using source_strategy="kb_search". + source_strategy: How to collect source material. One of + "provided", "conversation", "kb_search", or "auto". + search_queries: When source_strategy is "kb_search" or "auto", + provide 1-5 targeted search queries for the knowledge base. + These should be specific, not just the topic repeated. + report_style: "detailed", "deep_research", or "brief". + user_instructions: Optional focus or modification instructions. + When revising (parent_report_id set), describe WHAT TO CHANGE. + parent_report_id: ID of a previous report to revise (creates new + version in the same version group). + + Returns: + Dict with status, report_id, title, word_count, and message. + """ + # Initialize version tracking variables (used by _save_failed_report closure) + parent_report_content: str | None = None + report_group_id: int | None = None + + async def _save_failed_report(error_msg: str) -> int | None: + """Persist a failed report row using a short-lived session.""" + try: + async with shielded_async_session() as session: + failed_report = Report( + title=topic, + content=None, + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style=report_style, + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + session.add(failed_report) + await session.commit() + await session.refresh(failed_report) + # If this is a new group (v1 failed), set group to self + if not failed_report.report_group_id: + failed_report.report_group_id = failed_report.id + await session.commit() + logger.info( + f"[generate_report] Saved failed report {failed_report.id}: {error_msg}" + ) + return failed_report.id + except Exception: + logger.exception( + "[generate_report] Could not persist failed report row" + ) + return None + + try: + # ── Phase 1: READ (short-lived session) ────────────────────── + # Fetch parent report and LLM config, then close the session + # so no DB connection is held during the long LLM call. + async with shielded_async_session() as read_session: + if parent_report_id: + parent_report = await read_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + parent_report_content = parent_report.content + logger.info( + f"[generate_report] Creating new version from parent {parent_report_id} " + f"(group {report_group_id})" + ) + else: + logger.warning( + f"[generate_report] parent_report_id={parent_report_id} not found, " + "creating standalone report" + ) + + llm = await get_document_summary_llm(read_session, search_space_id) + # read_session closed — connection returned to pool + + if not llm: + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # Build the user instructions string + user_instructions_section = "" + if user_instructions: + user_instructions_section = ( + f"**Additional Instructions:** {user_instructions}" + ) + + # ── Phase 1b: SOURCE COLLECTION (smart KB search) ──────────── + # Decide whether to augment source_content with KB search results. + effective_source = source_content or "" + + strategy = (source_strategy or "provided").lower().strip() + + needs_kb_search = False + if strategy == "kb_search": + needs_kb_search = True + elif strategy == "auto": + # Heuristic: if source_content has fewer than 200 words, + # it's likely insufficient — augment with KB search. + word_count_estimate = len(effective_source.split()) + if word_count_estimate < 200: + needs_kb_search = True + logger.info( + f"[generate_report] auto strategy: source has ~{word_count_estimate} words, " + "triggering KB search" + ) + # "provided" and "conversation" → use source_content as-is + + if needs_kb_search and connector_service and search_queries: + query_count = min(len(search_queries), 5) + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search", + "message": f"Searching knowledge base ({query_count} queries)...", + }, + ) + logger.info( + f"[generate_report] Running internal KB search with " + f"{query_count} queries: {search_queries[:5]}" + ) + try: + from .knowledge_base import search_knowledge_base_async + + # Run all queries in parallel, each with its own session + async def _run_single_query(q: str) -> str: + async with shielded_async_session() as kb_session: + kb_connector_svc = ConnectorService( + kb_session, search_space_id + ) + return await search_knowledge_base_async( + query=q, + search_space_id=search_space_id, + db_session=kb_session, + connector_service=kb_connector_svc, + top_k=10, + available_connectors=available_connectors, + available_document_types=available_document_types, + ) + + kb_results = await asyncio.gather( + *[_run_single_query(q) for q in search_queries[:5]] + ) + + # Merge non-empty results into source_content + kb_text_parts = [r for r in kb_results if r and r.strip()] + if kb_text_parts: + kb_combined = "\n\n---\n\n".join(kb_text_parts) + if effective_source.strip(): + effective_source = ( + effective_source + + "\n\n--- Knowledge Base Search Results ---\n\n" + + kb_combined + ) + else: + effective_source = kb_combined + + # Count docs found (rough: count tags) + doc_count = kb_combined.count("") + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": f"Found {doc_count} relevant documents" + if doc_count + else f"Found results from {len(kb_text_parts)} queries", + }, + ) + logger.info( + f"[generate_report] KB search added ~{len(kb_combined)} chars " + f"from {len(kb_text_parts)} queries" + ) + else: + dispatch_custom_event( + "report_progress", + { + "phase": "kb_search_done", + "message": "No results found in knowledge base", + }, + ) + logger.info("[generate_report] KB search returned no results") + + except Exception as e: + logger.warning( + f"[generate_report] Internal KB search failed: {e}. " + "Proceeding with existing source_content." + ) + elif needs_kb_search and not connector_service: + logger.warning( + "[generate_report] KB search requested but connector_service " + "not available. Using source_content as-is." + ) + elif needs_kb_search and not search_queries: + logger.warning( + "[generate_report] KB search requested but no search_queries " + "provided. Using source_content as-is." + ) + + capped_source = effective_source[:100000] # Cap source content + + # Length constraint — only when user explicitly asks for brevity + length_instruction = "" + if report_style == "brief": + length_instruction = ( + "**LENGTH CONSTRAINT (MANDATORY):** The user wants a SHORT report. " + "Keep it concise — aim for ~400 words (~1 page) unless a different " + "length is specified in the Additional Instructions above. " + "Prioritize brevity over thoroughness. Do NOT write a long report." + ) + + # ── Phase 2: LLM GENERATION (no DB connection held) ────────── + + report_content: str | None = None + + if parent_report_content: + # ─── REVISION MODE ─────────────────────────────────────── + # Strategy: Try section-level revision first (preserves + # unchanged sections byte-for-byte). Falls back to full- + # document revision if section identification fails or if + # all sections need changes. + dispatch_custom_event( + "report_progress", + { + "phase": "revision_start", + "message": "Analyzing sections to modify...", + }, + ) + logger.info( + "[generate_report] Revision mode — attempting section-level revision" + ) + report_content = await _revise_with_sections( + llm=llm, + parent_content=parent_report_content, + user_instructions=user_instructions + or "Improve and refine the report.", + source_content=capped_source, + topic=topic, + report_style=report_style, + ) + + if report_content is None: + # Fallback: full-document revision + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Rewriting your full report"}, + ) + logger.info( + "[generate_report] Section-level revision deferred, " + "using full-document revision" + ) + prompt = _REVISION_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section + or "Improve and refine the report.", + source_content=capped_source, + previous_report_content=parent_report_content, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + else: + # ─── NEW REPORT MODE ───────────────────────────────────── + # Single-shot generation: one LLM call produces the full + # report. Fast, globally coherent, and cost-efficient. + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Writing your report"}, + ) + logger.info( + "[generate_report] New report — using single-shot generation" + ) + prompt = _REPORT_PROMPT.format( + topic=topic, + report_style=report_style, + user_instructions_section=user_instructions_section, + previous_version_section="", + source_content=capped_source, + length_instruction=length_instruction, + formatting_rules=_FORMATTING_RULES, + ) + response = await llm.ainvoke([HumanMessage(content=prompt)]) + report_content = response.content + + # ── Validate LLM output ────────────────────────────────────── + + if not report_content or not isinstance(report_content, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # LLMs often wrap output in ```markdown ... ``` fences — strip them + report_content = _strip_wrapping_code_fences(report_content) + + if not report_content: + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": topic, + } + + # Strip any existing footer(s) carried over from parent version(s) + while report_content.rstrip().endswith(_REPORT_FOOTER): + idx = report_content.rstrip().rfind(_REPORT_FOOTER) + report_content = report_content[:idx].rstrip() + if report_content.rstrip().endswith("---"): + report_content = report_content.rstrip()[:-3].rstrip() + + # Append exactly one standard disclaimer + report_content += "\n\n---\n\n" + _REPORT_FOOTER + + # Extract metadata (includes "status": "ready") + metadata = _extract_metadata(report_content) + + # ── Phase 3: WRITE (short-lived session) ───────────────────── + # Save the report to the database, then close the session. + async with shielded_async_session() as write_session: + report = Report( + title=topic, + content=report_content, + report_metadata=metadata, + report_style=report_style, + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + write_session.add(report) + await write_session.commit() + await write_session.refresh(report) + + # If this is a brand-new report (v1), set report_group_id = own id + if not report.report_group_id: + report.report_group_id = report.id + await write_session.commit() + + saved_report_id = report.id + saved_group_id = report.report_group_id + # write_session closed — connection returned to pool + + logger.info( + f"[generate_report] Created report {saved_report_id} " + f"(group={saved_group_id}): " + f"{metadata.get('word_count', 0)} words, " + f"{metadata.get('section_count', 0)} sections" + ) + + return { + "status": "ready", + "report_id": saved_report_id, + "title": topic, + "word_count": metadata.get("word_count", 0), + "is_revision": bool(parent_report_content), + "report_markdown": report_content, + "message": f"Report generated successfully: {topic}", + } + + except Exception as e: + error_message = str(e) + logger.exception(f"[generate_report] Error: {error_message}") + report_id = await _save_failed_report(error_message) + + return { + "status": "failed", + "error": error_message, + "report_id": report_id, + "title": topic, + } + + return generate_report diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py new file mode 100644 index 000000000..ece3ce241 --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/resume.py @@ -0,0 +1,799 @@ +"""Resume as Typst: LLM fills the body; backend prepends a template from ``_TEMPLATES`` and compiles.""" + +import io +import logging +import re +from datetime import UTC, datetime +from typing import Any + +import pypdf +import typst +from langchain_core.callbacks import dispatch_custom_event +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + +from app.db import Report, shielded_async_session +from app.services.llm_service import get_document_summary_llm + +logger = logging.getLogger(__name__) + + +# ─── Template Registry ─────────────────────────────────────────────────────── +# Each template defines: +# header - Typst import + show rule with {name}, {year}, {month}, {day} placeholders +# component_reference - component docs shown to the LLM +# rules - generation rules for the LLM + +_TEMPLATES: dict[str, dict[str, str]] = { + "classic": { + "header": """\ +#import "@preview/rendercv:0.3.0": * + +#show: rendercv.with( + name: "{name}", + title: "{name} - Resume", + footer: context {{ [#emph[{name} -- #str(here().page())\\/#str(counter(page).final().first())]] }}, + top-note: [ #emph[Last updated in {month_name} {year}] ], + locale-catalog-language: "en", + text-direction: ltr, + page-size: "us-letter", + page-top-margin: 0.7in, + page-bottom-margin: 0.7in, + page-left-margin: 0.7in, + page-right-margin: 0.7in, + page-show-footer: false, + page-show-top-note: true, + colors-body: rgb(0, 0, 0), + colors-name: rgb(0, 0, 0), + colors-headline: rgb(0, 0, 0), + colors-connections: rgb(0, 0, 0), + colors-section-titles: rgb(0, 0, 0), + colors-links: rgb(0, 0, 0), + colors-footer: rgb(128, 128, 128), + colors-top-note: rgb(128, 128, 128), + typography-line-spacing: 0.6em, + typography-alignment: "justified", + typography-date-and-location-column-alignment: right, + typography-font-family-body: "XCharter", + typography-font-family-name: "XCharter", + typography-font-family-headline: "XCharter", + typography-font-family-connections: "XCharter", + typography-font-family-section-titles: "XCharter", + typography-font-size-body: 10pt, + typography-font-size-name: 25pt, + typography-font-size-headline: 10pt, + typography-font-size-connections: 10pt, + typography-font-size-section-titles: 1.2em, + typography-small-caps-name: false, + typography-small-caps-headline: false, + typography-small-caps-connections: false, + typography-small-caps-section-titles: false, + typography-bold-name: false, + typography-bold-headline: false, + typography-bold-connections: false, + typography-bold-section-titles: true, + links-underline: true, + links-show-external-link-icon: false, + header-alignment: center, + header-photo-width: 3.5cm, + header-space-below-name: 0.7cm, + header-space-below-headline: 0.7cm, + header-space-below-connections: 0.7cm, + header-connections-hyperlink: true, + header-connections-show-icons: false, + header-connections-display-urls-instead-of-usernames: true, + header-connections-separator: "|", + header-connections-space-between-connections: 0.5cm, + section-titles-type: "with_full_line", + section-titles-line-thickness: 0.5pt, + section-titles-space-above: 0.5cm, + section-titles-space-below: 0.3cm, + sections-allow-page-break: true, + sections-space-between-text-based-entries: 0.15cm, + sections-space-between-regular-entries: 0.42cm, + entries-date-and-location-width: 4.15cm, + entries-side-space: 0cm, + entries-space-between-columns: 0.1cm, + entries-allow-page-break: false, + entries-short-second-row: false, + entries-degree-width: 1cm, + entries-summary-space-left: 0cm, + entries-summary-space-above: 0.08cm, + entries-highlights-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-nested-bullet: text(13pt, [\\u{2022}], baseline: -0.6pt), + entries-highlights-space-left: 0cm, + entries-highlights-space-above: 0.08cm, + entries-highlights-space-between-items: 0.02cm, + entries-highlights-space-between-bullet-and-text: 0.3em, + date: datetime( + year: {year}, + month: {month}, + day: {day}, + ), +) + +""", + "component_reference": """\ +Available components (use ONLY these): + += Full Name // Top-level heading — person's full name + +#connections( // Contact info row (pipe-separated) + [City, Country], + [#link("mailto:email@example.com", icon: false, if-underline: false, if-color: false)[email\\@example.com]], + [#link("https://linkedin.com/in/user", icon: false, if-underline: false, if-color: false)[linkedin.com\\/in\\/user]], + [#link("https://github.com/user", icon: false, if-underline: false, if-color: false)[github.com\\/user]], +) + +== Section Title // Section heading (arbitrary name) + +#regular-entry( // Work experience, projects, publications, etc. + [ + #strong[Role/Title], Company Name -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - Achievement or responsibility + - Another bullet point + ], +) + +#education-entry( // Education entries + [ + #strong[Institution], Degree in Field -- Location + ], + [ + Start -- End + ], + main-column-second-row: [ + - GPA, honours, relevant coursework + ], +) + +#summary([Short paragraph summary]) // Optional summary inside an entry +#content-area([Free-form content]) // Freeform text block + +For skills sections, use one bullet per category label: +- #strong[Category:] item1, item2, item3 + +For simple list sections (e.g. Honors), use plain bullet points: +- Item one +- Item two +""", + "rules": """\ +RULES: +- Do NOT include any #import or #show lines. Start directly with = Full Name. +- Output ONLY valid Typst content. No explanatory text before or after. +- Do NOT wrap output in ```typst code fences. +- The = heading MUST use the person's COMPLETE full name exactly as provided. NEVER shorten or abbreviate. +- Escape @ symbols inside link labels with a backslash: email\\@example.com +- Escape forward slashes in link display text: linkedin.com\\/in\\/user +- Every section MUST use == heading. +- Use #regular-entry() for experience, projects, publications, certifications, and similar entries. +- Use #education-entry() for education. +- For skills sections, use one bullet line per category with a bold label. +- Keep content professional, concise, and achievement-oriented. +- Use action verbs for bullet points (Led, Built, Designed, Reduced, etc.). +- This template works for ALL professions — adapt sections to the user's field. +- Default behavior should prioritize concise one-page content. +""", + }, +} + +DEFAULT_TEMPLATE = "classic" +MIN_RESUME_PAGES = 1 +MAX_RESUME_PAGES = 5 +MAX_COMPRESSION_ATTEMPTS = 2 + + +# ─── Template Helpers ───────────────────────────────────────────────────────── + + +def _get_template(template_id: str | None = None) -> dict[str, str]: + """Get a template by ID, falling back to default.""" + return _TEMPLATES.get(template_id or DEFAULT_TEMPLATE, _TEMPLATES[DEFAULT_TEMPLATE]) + + +_MONTH_NAMES = [ + "", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] + + +def _build_header(template: dict[str, str], name: str) -> str: + """Build the template header with the person's name and current date.""" + now = datetime.now(tz=UTC) + return ( + template["header"] + .replace("{name}", name) + .replace("{year}", str(now.year)) + .replace("{month}", str(now.month)) + .replace("{day}", str(now.day)) + .replace("{month_name}", _MONTH_NAMES[now.month]) + ) + + +def _strip_header(full_source: str) -> str: + """Strip the import + show rule from stored source to get the body only. + + Finds the closing parenthesis of the rendercv.with(...) block by tracking + nesting depth, then returns everything after it. + """ + show_match = re.search(r"#show:\s*rendercv\.with\(", full_source) + if not show_match: + return full_source + + start = show_match.end() + depth = 1 + i = start + while i < len(full_source) and depth > 0: + if full_source[i] == "(": + depth += 1 + elif full_source[i] == ")": + depth -= 1 + i += 1 + + return full_source[i:].lstrip("\n") + + +def _extract_name(body: str) -> str | None: + """Extract the person's full name from the = heading in the body.""" + match = re.search(r"^=\s+(.+)$", body, re.MULTILINE) + return match.group(1).strip() if match else None + + +def _strip_imports(body: str) -> str: + """Remove any #import or #show lines the LLM might accidentally include.""" + lines = body.split("\n") + cleaned: list[str] = [] + skip_show = False + depth = 0 + + for line in lines: + stripped = line.strip() + + if stripped.startswith("#import"): + continue + + if skip_show: + depth += stripped.count("(") - stripped.count(")") + if depth <= 0: + skip_show = False + continue + + if stripped.startswith("#show:") and "rendercv" in stripped: + depth = stripped.count("(") - stripped.count(")") + if depth > 0: + skip_show = True + continue + + cleaned.append(line) + + result = "\n".join(cleaned).strip() + return result + + +def _build_llm_reference(template: dict[str, str]) -> str: + """Build the LLM prompt reference from a template.""" + return f"""\ +You MUST output valid Typst content for a resume. +Do NOT include any #import or #show lines — those are handled automatically. +Start directly with the = Full Name heading. + +{template["component_reference"]} + +{template["rules"]}""" + + +# ─── Prompts ───────────────────────────────────────────────────────────────── + +_RESUME_PROMPT = """\ +You are an expert resume writer. Generate professional resume content as Typst markup. + +{llm_reference} + +**User Information:** +{user_info} + +**Target Maximum Pages:** {max_pages} + +{user_instructions_section} + +Generate the resume content now (starting with = Full Name): +""" + +_REVISION_PROMPT = """\ +You are an expert resume editor. Modify the existing resume according to the instructions. +Apply ONLY the requested changes — do NOT rewrite sections that are not affected. + +{llm_reference} + +**Target Maximum Pages:** {max_pages} + +**Modification Instructions:** {user_instructions} + +**EXISTING RESUME CONTENT:** + +{previous_content} + +--- + +Output the complete, updated resume content with the changes applied (starting with = Full Name): +""" + +_FIX_COMPILE_PROMPT = """\ +The resume content you generated failed to compile. Fix the error while preserving all content. + +{llm_reference} + +**Compilation Error:** +{error} + +**Full Typst Source (for context — error line numbers refer to this):** +{full_source} + +**Your content starts after the template header. Output ONLY the content portion \ +(starting with = Full Name), NOT the #import or #show rule:** +""" + +_COMPRESS_TO_PAGE_LIMIT_PROMPT = """\ +The resume compiles, but it exceeds the maximum allowed page count. +Compress the resume while preserving high-impact accomplishments and role relevance. + +{llm_reference} + +**Target Maximum Pages:** {max_pages} +**Current Page Count:** {actual_pages} +**Compression Attempt:** {attempt_number} + +Compression priorities (in this order): +1) Keep recent, high-impact, role-relevant bullets. +2) Remove low-impact or redundant bullets. +3) Shorten verbose wording while preserving meaning. +4) Trim older or less relevant details before recent ones. + +Return the complete updated Typst content (starting with = Full Name), and keep it at or below the target pages. + +**EXISTING RESUME CONTENT:** +{previous_content} +""" + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + + +def _strip_typst_fences(text: str) -> str: + """Remove wrapping ```typst ... ``` fences that LLMs sometimes add.""" + stripped = text.strip() + m = re.match(r"^(`{3,})(?:typst|typ)?\s*\n", stripped) + if m: + fence = m.group(1) + if stripped.endswith(fence): + stripped = stripped[m.end() :] + stripped = stripped[: -len(fence)].rstrip() + return stripped + + +def _compile_typst(source: str) -> bytes: + """Compile Typst source to PDF bytes. Raises on failure.""" + return typst.compile(source.encode("utf-8")) + + +def _count_pdf_pages(pdf_bytes: bytes) -> int: + """Count the number of pages in compiled PDF bytes.""" + with io.BytesIO(pdf_bytes) as pdf_stream: + reader = pypdf.PdfReader(pdf_stream) + return len(reader.pages) + + +def _validate_max_pages(max_pages: int) -> int: + """Validate and normalize max_pages input.""" + if MIN_RESUME_PAGES <= max_pages <= MAX_RESUME_PAGES: + return max_pages + msg = ( + f"max_pages must be between {MIN_RESUME_PAGES} and " + f"{MAX_RESUME_PAGES}. Received: {max_pages}" + ) + raise ValueError(msg) + + +# ─── Tool Factory ─────────────────────────────────────────────────────────── + + +def create_generate_resume_tool( + search_space_id: int, + thread_id: int | None = None, +): + """ + Factory function to create the generate_resume tool. + + Generates a Typst-based resume, validates it via compilation, + and stores the source in the Report table with content_type='typst'. + The LLM generates only the content body; the template header is + prepended by the backend. + """ + + @tool + async def generate_resume( + user_info: str, + user_instructions: str | None = None, + parent_report_id: int | None = None, + max_pages: int = 1, + ) -> dict[str, Any]: + """ + Generate a professional resume as a Typst document. + + Use this tool when the user asks to create, build, generate, write, + or draft a resume or CV. Also use it when the user wants to modify, + update, or revise an existing resume generated in this conversation. + + Trigger phrases include: + - "build me a resume", "create my resume", "generate a CV" + - "update my resume", "change my title", "add my new job" + - "make my resume more concise", "reformat my resume" + + Do NOT use this tool for: + - General questions about resumes or career advice + - Reviewing or critiquing a resume without changes + - Cover letters (use generate_report instead) + + VERSIONING — parent_report_id: + - Set parent_report_id when the user wants to MODIFY an existing + resume that was already generated in this conversation. + - Leave as None for new resumes. + + Args: + user_info: The user's resume content — work experience, + education, skills, contact info, etc. Can be structured + or unstructured text. + user_instructions: Optional style or content preferences + (e.g. "emphasize leadership", "keep it to one page", + "use a modern style"). For revisions, describe what to change. + parent_report_id: ID of a previous resume to revise (creates + new version in the same version group). + max_pages: Maximum number of pages for the generated resume. + Defaults to 1. Allowed range: 1-5. + + Returns: + Dict with status, report_id, title, and content_type. + """ + report_group_id: int | None = None + parent_content: str | None = None + + template = _get_template() + llm_reference = _build_llm_reference(template) + + async def _save_failed_report(error_msg: str) -> int | None: + try: + async with shielded_async_session() as session: + failed = Report( + title="Resume", + content=None, + content_type="typst", + report_metadata={ + "status": "failed", + "error_message": error_msg, + }, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + session.add(failed) + await session.commit() + await session.refresh(failed) + if not failed.report_group_id: + failed.report_group_id = failed.id + await session.commit() + logger.info( + f"[generate_resume] Saved failed report {failed.id}: {error_msg}" + ) + return failed.id + except Exception: + logger.exception( + "[generate_resume] Could not persist failed report row" + ) + return None + + try: + try: + validated_max_pages = _validate_max_pages(max_pages) + except ValueError as e: + error_msg = str(e) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 1: READ ───────────────────────────────────────────── + async with shielded_async_session() as read_session: + if parent_report_id: + parent_report = await read_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + parent_content = parent_report.content + logger.info( + f"[generate_resume] Revising from parent {parent_report_id} " + f"(group {report_group_id})" + ) + + llm = await get_document_summary_llm(read_session, search_space_id) + + if not llm: + error_msg = ( + "No LLM configured. Please configure a language model in Settings." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 2: LLM GENERATION ─────────────────────────────────── + + user_instructions_section = "" + if user_instructions: + user_instructions_section = ( + f"**Additional Instructions:** {user_instructions}" + ) + + if parent_content: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Updating your resume"}, + ) + parent_body = _strip_header(parent_content) + prompt = _REVISION_PROMPT.format( + llm_reference=llm_reference, + max_pages=validated_max_pages, + user_instructions=user_instructions + or "Improve and refine the resume.", + previous_content=parent_body, + ) + else: + dispatch_custom_event( + "report_progress", + {"phase": "writing", "message": "Building your resume"}, + ) + prompt = _RESUME_PROMPT.format( + llm_reference=llm_reference, + user_info=user_info, + max_pages=validated_max_pages, + user_instructions_section=user_instructions_section, + ) + + response = await llm.ainvoke([HumanMessage(content=prompt)]) + body = response.content + + if not body or not isinstance(body, str): + error_msg = "LLM returned empty or invalid content" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + body = _strip_typst_fences(body) + body = _strip_imports(body) + + # ── Phase 3: ASSEMBLE + COMPILE ─────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "compiling", "message": "Compiling resume..."}, + ) + + name = _extract_name(body) or "Resume" + typst_source = "" + actual_pages = 0 + compression_attempts = 0 + target_page_met = False + + for compression_round in range(MAX_COMPRESSION_ATTEMPTS + 1): + header = _build_header(template, name) + typst_source = header + body + compile_error: str | None = None + pdf_bytes: bytes | None = None + + for compile_attempt in range(2): + try: + pdf_bytes = _compile_typst(typst_source) + compile_error = None + break + except Exception as e: + compile_error = str(e) + logger.warning( + "[generate_resume] Compile attempt %s failed: %s", + compile_attempt + 1, + compile_error, + ) + + if compile_attempt == 0: + dispatch_custom_event( + "report_progress", + { + "phase": "fixing", + "message": "Fixing compilation issue...", + }, + ) + fix_prompt = _FIX_COMPILE_PROMPT.format( + llm_reference=llm_reference, + error=compile_error, + full_source=typst_source, + ) + fix_response = await llm.ainvoke( + [HumanMessage(content=fix_prompt)] + ) + if fix_response.content and isinstance( + fix_response.content, str + ): + body = _strip_typst_fences(fix_response.content) + body = _strip_imports(body) + name = _extract_name(body) or name + header = _build_header(template, name) + typst_source = header + body + + if compile_error or not pdf_bytes: + error_msg = ( + "Typst compilation failed after 2 attempts: " + f"{compile_error or 'Unknown compile error'}" + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + actual_pages = _count_pdf_pages(pdf_bytes) + if actual_pages <= validated_max_pages: + target_page_met = True + break + + if compression_round >= MAX_COMPRESSION_ATTEMPTS: + break + + compression_attempts += 1 + dispatch_custom_event( + "report_progress", + { + "phase": "compressing", + "message": f"Condensing resume to {validated_max_pages} page(s)...", + }, + ) + compress_prompt = _COMPRESS_TO_PAGE_LIMIT_PROMPT.format( + llm_reference=llm_reference, + max_pages=validated_max_pages, + actual_pages=actual_pages, + attempt_number=compression_attempts, + previous_content=body, + ) + compress_response = await llm.ainvoke( + [HumanMessage(content=compress_prompt)] + ) + if not compress_response.content or not isinstance( + compress_response.content, str + ): + error_msg = "LLM returned empty content while compressing resume" + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + body = _strip_typst_fences(compress_response.content) + body = _strip_imports(body) + name = _extract_name(body) or name + + if actual_pages > MAX_RESUME_PAGES: + error_msg = ( + "Resume exceeds hard page limit after compression retries. " + f"Hard limit: <= {MAX_RESUME_PAGES} page(s), actual: {actual_pages}." + ) + report_id = await _save_failed_report(error_msg) + return { + "status": "failed", + "error": error_msg, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + # ── Phase 4: SAVE ───────────────────────────────────────────── + dispatch_custom_event( + "report_progress", + {"phase": "saving", "message": "Saving your resume"}, + ) + + resume_title = f"{name} - Resume" if name != "Resume" else "Resume" + + metadata: dict[str, Any] = { + "status": "ready", + "word_count": len(typst_source.split()), + "char_count": len(typst_source), + "target_max_pages": validated_max_pages, + "actual_page_count": actual_pages, + "page_limit_enforced": True, + "compression_attempts": compression_attempts, + "target_page_met": target_page_met, + } + + async with shielded_async_session() as write_session: + report = Report( + title=resume_title, + content=typst_source, + content_type="typst", + report_metadata=metadata, + report_style="resume", + search_space_id=search_space_id, + thread_id=thread_id, + report_group_id=report_group_id, + ) + write_session.add(report) + await write_session.commit() + await write_session.refresh(report) + + if not report.report_group_id: + report.report_group_id = report.id + await write_session.commit() + + saved_id = report.id + + logger.info(f"[generate_resume] Created resume {saved_id}: {resume_title}") + + return { + "status": "ready", + "report_id": saved_id, + "title": resume_title, + "content_type": "typst", + "is_revision": bool(parent_content), + "message": ( + f"Resume generated successfully: {resume_title}" + if target_page_met + else ( + f"Resume generated, but could not fit the target of <= {validated_max_pages} " + f"page(s). Final length: {actual_pages} page(s)." + ) + ), + } + + except Exception as e: + error_message = str(e) + logger.exception(f"[generate_resume] Error: {error_message}") + report_id = await _save_failed_report(error_message) + return { + "status": "failed", + "error": error_message, + "report_id": report_id, + "title": "Resume", + "content_type": "typst", + } + + return generate_resume diff --git a/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py new file mode 100644 index 000000000..a9f3447ab --- /dev/null +++ b/surfsense_backend/app/agents/multi_agent_with_deepagents/subagents/builtins/deliverables/tools/video_presentation.py @@ -0,0 +1,80 @@ +"""Factory for a video-presentation tool that queues background work and returns an ID for polling.""" + +from typing import Any + +from langchain_core.tools import tool +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import VideoPresentation, VideoPresentationStatus, shielded_async_session + + +def create_generate_video_presentation_tool( + search_space_id: int, + db_session: AsyncSession, + thread_id: int | None = None, +): + """Create ``generate_video_presentation`` with bound search space and thread; writes use a tool-local session.""" + del db_session # writes use a fresh tool-local session, see below + + @tool + async def generate_video_presentation( + source_content: str, + video_title: str = "SurfSense Presentation", + user_prompt: str | None = None, + ) -> dict[str, Any]: + """Generate a video presentation from the provided content. + + Use this tool when the user asks to create a video, presentation, slides, or slide deck. + + Args: + source_content: The text content to turn into a presentation. + video_title: Title for the presentation (default: "SurfSense Presentation") + user_prompt: Optional style/tone instructions. + """ + try: + # One DB session per tool call so parallel invocations never share an AsyncSession. + async with shielded_async_session() as session: + video_pres = VideoPresentation( + title=video_title, + status=VideoPresentationStatus.PENDING, + search_space_id=search_space_id, + thread_id=thread_id, + ) + session.add(video_pres) + await session.commit() + await session.refresh(video_pres) + video_pres_id = video_pres.id + + from app.tasks.celery_tasks.video_presentation_tasks import ( + generate_video_presentation_task, + ) + + task = generate_video_presentation_task.delay( + video_presentation_id=video_pres_id, + source_content=source_content, + search_space_id=search_space_id, + user_prompt=user_prompt, + ) + + print( + f"[generate_video_presentation] Created video presentation {video_pres_id}, task: {task.id}" + ) + + return { + "status": VideoPresentationStatus.PENDING.value, + "video_presentation_id": video_pres_id, + "title": video_title, + "message": "Video presentation generation started. This may take a few minutes.", + } + + except Exception as e: + error_message = str(e) + print(f"[generate_video_presentation] Error: {error_message}") + return { + "status": VideoPresentationStatus.FAILED.value, + "error": error_message, + "title": video_title, + "video_presentation_id": None, + } + + return generate_video_presentation