Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to
### Added

- 💄(front) add tooltip co2
- ✨(back) add slide deck generation tool
- ✨(back) add model fallback mechanism
- ✨(back) add celery for running background tasks
- 🧱(helm) add celery worker and beat deployments
Expand Down
52 changes: 52 additions & 0 deletions src/backend/chat/agents/presentation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Build the presentation agent."""

import dataclasses

from django.conf import settings

from chat.file_generation import GENERIC_TEMPLATE, Presentation

from .base import BaseAgent


def _build_layout_instructions() -> str:
"""Describe the available slide types, straight from the template bindings."""
return "\n".join(
f"- `{layout.slide_type}`: {layout.prompt}" for layout in GENERIC_TEMPLATE.layouts
)


PRESENTATION_SYSTEM_PROMPT = """
You are an agent specializing in building slide decks. From a brief, produce a
structured presentation: a deck title and an ordered list of slides.

The following slide types are available:
{layouts}

REQUIREMENTS:
- Only use the slide types listed above, and only fill the fields each one declares.
- Open with a `cover` slide, and use `section` slides to separate the main parts.
- Vary the layouts to keep the deck readable; avoid repeating one type throughout.
- Aim for several slides, each carrying one idea rather than a wall of text.
- Write field content in Markdown. Inline styles, links, nested lists (indent
with 4 spaces) and tables are supported. A table takes over its whole field,
so do not put other content alongside it.
- Add presenter notes on slides where context helps the speaker.
- Write in the language of the brief.
Comment on lines +26 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Explicitly permit slide_notes on every slide.

Line 27 limits output to fields declared by a layout, but no layout declares slide_notes; Line 34 simultaneously requests notes. Clarify that slide_notes is allowed for every slide, otherwise the model is steered away from producing them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/chat/agents/presentation.py` around lines 26 - 35, Update the
REQUIREMENTS instructions to explicitly allow the slide_notes field on every
slide, alongside each layout’s declared fields. Preserve the existing guidance
to add presenter notes where context helps, while clarifying that slide_notes is
the permitted field name for those notes.

"""


@dataclasses.dataclass(init=False)
class PresentationAgent(BaseAgent):
"""Create a Pydantic AI Agent producing a structured deck from a brief."""

def __init__(self, **kwargs):
"""Initialize the agent with the configured model."""
super().__init__(
model_hrid=settings.LLM_DEFAULT_MODEL_HRID,
output_type=Presentation,
**kwargs,
)

def get_system_prompt(self) -> str:
return PRESENTATION_SYSTEM_PROMPT.format(layouts=_build_layout_instructions())
24 changes: 24 additions & 0 deletions src/backend/chat/clients/pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,15 @@
DOCUMENT_SUMMARIZE_PROJECT_TOOL_DESCRIPTION,
DOCUMENT_SUMMARIZE_SYSTEM_PROMPT,
DOCUMENT_SUMMARIZE_TOOL_DESCRIPTION,
GENERATE_PRESENTATION_TOOL_DESCRIPTION,
SELF_DOCUMENTATION_SYSTEM_PROMPT,
SELF_DOCUMENTATION_TOOL_DESCRIPTION,
WEB_SEARCH_TOOL_DESCRIPTION,
)
from chat.tools.document_generic_search_rag import add_document_rag_search_tool_from_setting
from chat.tools.document_search_rag import add_document_rag_search_tool
from chat.tools.document_summarize import document_summarize, document_summarize_project
from chat.tools.generate_presentation import generate_presentation
from chat.tools.self_documentation import build_self_documentation_payload
from chat.vercel_ai_sdk.core import events_v4, events_v5
from chat.vercel_ai_sdk.encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder
Expand Down Expand Up @@ -319,6 +321,9 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument
# Feature flags
self._is_document_upload_enabled = is_feature_enabled(self.user, "document_upload")
self._is_web_search_enabled = is_feature_enabled(self.user, "web_search")
self._is_presentation_generation_enabled = is_feature_enabled(
self.user, "presentation_generation"
)
self._is_smart_search_enabled = user.allow_smart_web_search
self._fake_streaming_delay = settings.FAKE_STREAMING_DELAY

Expand All @@ -330,6 +335,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument
)
self._web_search_tool_registered = False
self._self_documentation_tool_registered = False
self._presentation_tool_registered = False

_capabilities = (
[
Expand Down Expand Up @@ -1123,6 +1129,23 @@ async def web_search(ctx: RunContext, *args, **kwargs) -> ToolReturn:

self._web_search_tool_registered = True

def _setup_presentation_tool(self) -> None:
"""Register the slide deck generation tool when the feature is enabled."""
if self._presentation_tool_registered or not self._is_presentation_generation_enabled:
return

@self.conversation_agent.tool(
name="generate_presentation",
retries=1,
description=GENERATE_PRESENTATION_TOOL_DESCRIPTION,
)
@functools.wraps(generate_presentation)
async def generate_presentation_tool(ctx: RunContext, *args, **kwargs) -> ToolReturn:
"""Wrap the generate_presentation tool to provide context and add the tool."""
return await generate_presentation(ctx, *args, **kwargs)

self._presentation_tool_registered = True

def _setup_self_documentation_tool(self) -> None:
"""Register a tool exposing static and runtime self-documentation metadata."""
if self._self_documentation_tool_registered:
Expand Down Expand Up @@ -1527,6 +1550,7 @@ async def _run_agent( # pylint: disable=too-many-locals,too-many-branches,too-m

await self._agent_stop_streaming(force_cache_check=True)
self._setup_self_documentation_tool()
self._setup_presentation_tool()
self._setup_web_search_tool()
self._setup_web_search(force_web_search)

Expand Down
14 changes: 14 additions & 0 deletions src/backend/chat/file_generation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Generation of office documents from model-produced specs."""

from .builder import PresentationBuildError, build_presentation
from .entities import Presentation, Slide, SlideType
from .templates import GENERIC_TEMPLATE

__all__ = [
"GENERIC_TEMPLATE",
"Presentation",
"PresentationBuildError",
"Slide",
"SlideType",
"build_presentation",
]
85 changes: 85 additions & 0 deletions src/backend/chat/file_generation/builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Assemble a pptx deck from a validated presentation spec."""

import logging
from io import BytesIO

import pptx

from chat.file_generation.entities import Presentation, PresentationTemplate, Slide, SlideLayout
from chat.file_generation.renderer import render

logger = logging.getLogger(__name__)


class PresentationBuildError(Exception):
"""Raised when a deck cannot be assembled from its template."""


def build_presentation(template: PresentationTemplate, presentation: Presentation) -> bytes:
"""Render a presentation spec into the bytes of a pptx file."""
try:
deck = pptx.Presentation(str(template.path))
except Exception as exc:
logger.exception("Failed to open presentation template %s", template.path)
raise PresentationBuildError(
f"Could not open the presentation template at {template.path}."
) from exc
Comment thread
coderabbitai[bot] marked this conversation as resolved.

for slide in presentation.slides:
layout = template.get_layout(slide.type)
if layout is None:
# Unreachable for shipped types: the spec is validated against
# SlideType and every member is bound (test_every_slide_type_...).
# Guards a new type added without a binding.
raise PresentationBuildError(f"No layout is bound to slide type {slide.type!r}.")
_add_slide(deck, layout, slide)

blob = BytesIO()
deck.save(blob)
return blob.getvalue()


def _add_slide(deck, layout: SlideLayout, slide: Slide) -> None:
"""Create a slide from its layout and fill the mapped placeholders."""
pptx_layout = deck.slide_layouts.get_by_name(layout.layout_name)
if pptx_layout is None:
raise PresentationBuildError(
f"Layout {layout.layout_name!r} is missing from the presentation template."
)

pptx_slide = deck.slides.add_slide(pptx_layout)
for shape_index, field in layout.shape_index_to_field.items():
render(pptx_slide.shapes[shape_index], getattr(slide, field, ""))

if slide.slide_notes:
render(pptx_slide.notes_slide.notes_text_frame, slide.slide_notes)


def check_template(template: PresentationTemplate) -> None:
"""
Assert a template is consistent with the layouts declared against it.

Called from the tests rather than at import time: it opens the file and
builds a throwaway slide per layout, which is too costly to pay on every
worker start.
"""
deck = pptx.Presentation(str(template.path))
layout_names = [layout.name for layout in deck.slide_layouts]

for layout in template.layouts:
pptx_layout = deck.slide_layouts.get_by_name(layout.layout_name)
if pptx_layout is None:
raise PresentationBuildError(
f"Layout {layout.layout_name!r} not found in {template.path}. "
f"Available layouts: {layout_names}"
)

# A layout and a slide created from it do not carry the same shapes, so
# the declared indices can only be checked against a real slide.
pptx_slide = deck.slides.add_slide(pptx_layout)
shape_count = len(pptx_slide.shapes)
if max(layout.shape_index_to_field) >= shape_count:
raise PresentationBuildError(
f"Layout {layout.layout_name!r} has {shape_count} shapes, but a "
f"placeholder index {max(layout.shape_index_to_field)} is declared."
)
78 changes: 78 additions & 0 deletions src/backend/chat/file_generation/entities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Data structures describing a presentation and the template that renders it."""

from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path

from pydantic import BaseModel, Field


class SlideType(StrEnum):
"""Layout kinds a generated slide can use."""

COVER = "cover"
SECTION = "section"
TITLE_ONE_COLUMN = "title_one_column"
TITLE_TWO_COLUMNS = "title_two_columns"
TITLE_THREE_COLUMNS = "title_three_columns"


class Slide(BaseModel):
"""
A single slide.

Every field but ``type`` is optional: which ones are meaningful depends on
the slide type, and the mapping is declared by the ``SlideLayout`` bound to
that type. Text fields hold Markdown, rendered by ``renderer.render``. The
presentation agent's system prompt documents which field each type uses.
"""

type: SlideType
title: str = ""
subtitle: str = ""
content: str = ""
left: str = ""
center: str = ""
right: str = ""
slide_notes: str = ""


class Presentation(BaseModel):
"""A whole deck, as produced by the presentation agent."""

title: str
slides: list[Slide] = Field(min_length=1)
Comment on lines +43 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'src/backend/chat/file_generation/entities.py|file_generation|presentation|generation' || true

echo "== target outline =="
ast-grep outline src/backend/chat/file_generation/entities.py --view expanded || true

echo "== target lines =="
cat -n src/backend/chat/file_generation/entities.py

echo "== searches for Presentation/SlideType/constraints =="
rg -n "class Presentation|class Slide|SlideType|cover|cover_first|first slide|slides\\[0\\]|model_validator|hypothesis|validator" src/backend/chat src/backend -S || true

Repository: suitenumerique/conversations

Length of output: 38944


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== presentation agent lines =="
cat -n src/backend/chat/agents/presentation.py | sed -n '1,110p'

echo "== generate presentation lines =="
cat -n src/backend/chat/tools/generate_presentation.py | sed -n '40,90p'

echo "== builder lines =="
cat -n src/backend/chat/file_generation/builder.py | sed -n '1,70p'

echo "== tests around validation/use =="
cat -n src/backend/chat/tests/file_generation/test_builder.py | sed -n '140,160p'
cat -n src/backend/chat/tests/tools/test_generate_presentation.py | sed -n '55,85p'

echo "== pydantic availability =="
python3 - <<'PY'
try:
    import pydantic, pydantic.version
    print("pydantic", pydantic.__version__, pydantic.version.VERSION)
except Exception as e:
    print("pydantic unavailable:", type(e).__name__, e)
else:
    from pydantic import BaseModel, Field, model_validator
    from enum import StrEnum
    class SlideType(StrEnum):
        COVER = "cover"
        OTHER = "other"
    class Slide(BaseModel):
        type: SlideType
    class WeakPresentation(BaseModel):
        title: str
        slides: list[str] = Field(min_length=1)
    class StrongPresentation(BaseModel):
        title: str
        slides: list[Slide] = Field(min_length=1)
        `@model_validator`(mode="after")
        def validate_structure(self):
            if self.slides[0].type != "cover":
                raise ValueError("The first slide must be a cover slide")
    print("weak valid non-cover", WeakPresentation(title="x", slides=["other"]).slides)
    try:
        StrongPresentation(title="x", slides=[{"type": "cover"}])
        print("strong valid cover allowed")
    except Exception as e:
        print("strong cover error", e)
    try:
        StrongPresentation(title="x", slides=[{"type": "other"}])
    except Exception as e:
        print("strong non-cover error", type(e).__name__)
PY

Repository: suitenumerique/conversations

Length of output: 10604


Enforce the cover-first deck contract in Presentation.

The agent contract requires the first slide to be cover, but Presentation only rejects empty decks. A non-cover first slide is accepted via structured output and passed to deck rendering; add a Pydantic validator so this violates the schema boundary instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/chat/file_generation/entities.py` around lines 43 - 44, Update
the Presentation model validation for slides so the collection must be non-empty
and its first Slide has type “cover”; reject any other first-slide type at
schema validation time before rendering.



@dataclass(frozen=True)
class SlideLayout:
"""Binds a ``SlideType`` to a layout of the pptx template."""

slide_type: SlideType

# Name of the layout inside the pptx template.
layout_name: str

# Placeholder index on a slide created from the layout -> ``Slide`` field name.
# Beware: a layout and a slide created from it do not hold the same shapes,
# so these indices only make sense once the slide exists. ``check_template``
# verifies them.
shape_index_to_field: dict[int, str]

# How this layout is described to the model.
prompt: str


@dataclass(frozen=True)
class PresentationTemplate:
"""A pptx template file plus the layouts usable within it."""

path: Path
layouts: tuple[SlideLayout, ...]

def get_layout(self, slide_type: SlideType) -> SlideLayout | None:
"""Return the layout bound to ``slide_type``, or None if unbound."""
for layout in self.layouts:
if layout.slide_type == slide_type:
return layout
return None
Loading
Loading