diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d91d1f..db0d092d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/chat/agents/presentation.py b/src/backend/chat/agents/presentation.py new file mode 100644 index 00000000..0706c3f2 --- /dev/null +++ b/src/backend/chat/agents/presentation.py @@ -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. +""" + + +@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()) diff --git a/src/backend/chat/clients/pydantic_ai.py b/src/backend/chat/clients/pydantic_ai.py index 974909e7..61144927 100644 --- a/src/backend/chat/clients/pydantic_ai.py +++ b/src/backend/chat/clients/pydantic_ai.py @@ -180,6 +180,7 @@ 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, @@ -187,6 +188,7 @@ 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 @@ -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 @@ -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 = ( [ @@ -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: @@ -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) diff --git a/src/backend/chat/file_generation/__init__.py b/src/backend/chat/file_generation/__init__.py new file mode 100644 index 00000000..8e7e0256 --- /dev/null +++ b/src/backend/chat/file_generation/__init__.py @@ -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", +] diff --git a/src/backend/chat/file_generation/builder.py b/src/backend/chat/file_generation/builder.py new file mode 100644 index 00000000..ff74d9aa --- /dev/null +++ b/src/backend/chat/file_generation/builder.py @@ -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 + + 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." + ) diff --git a/src/backend/chat/file_generation/entities.py b/src/backend/chat/file_generation/entities.py new file mode 100644 index 00000000..dad5793d --- /dev/null +++ b/src/backend/chat/file_generation/entities.py @@ -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) + + +@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 diff --git a/src/backend/chat/file_generation/renderer.py b/src/backend/chat/file_generation/renderer.py new file mode 100644 index 00000000..03d04c22 --- /dev/null +++ b/src/backend/chat/file_generation/renderer.py @@ -0,0 +1,276 @@ +"""Render Markdown into pptx shapes. + +The model writes slide content as Markdown; this module turns it into formatted +runs, bulleted lists and tables inside a placeholder. Markdown is converted to +HTML first, then walked with BeautifulSoup, both of which the project already +depends on. + +Inline styles are only ever set when the Markdown asks for them, never reset to +their falsy value, so the template's own styling keeps applying otherwise. +""" + +from dataclasses import dataclass + +import markdown as markdown_lib +from bs4 import BeautifulSoup, NavigableString, Tag +from pptx.oxml.ns import qn +from pptx.oxml.xmlchemy import OxmlElement +from pptx.shapes.base import BaseShape +from pptx.text.text import TextFrame, _Paragraph +from pptx.util import Pt + +BULLET_CHAR = "•" +MONO_FONT = "Consolas" +MONO_FONT_SIZE = Pt(10) + +HEADING_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6") +LIST_TAGS = ("ul", "ol") + +# Left indent of a bullet level, and the hanging indent pulling the bullet +# character back into the margin, in EMU. +INDENT_PER_LEVEL = 342900 +HANGING_INDENT = -342900 + + +def render(target: BaseShape | TextFrame, markdown_text: str) -> None: + """ + Render Markdown into a shape or a text frame. + + A Markdown table takes over the whole placeholder: when one is present, the + rest of the content in the same field is dropped, because a pptx table is a + separate graphic frame rather than something that can flow with text. + """ + html = markdown_lib.markdown(markdown_text or "", extensions=["tables"]) + soup = BeautifulSoup(html, "html.parser") + + table = soup.find("table") + if table is not None: + # pylint: disable=protected-access + shape = target if isinstance(target, BaseShape) else target._parent # noqa: SLF001 + _render_table(shape, table) + return + + text_frame = target.text_frame if hasattr(target, "text_frame") else target + _render_text(text_frame, soup) + + +@dataclass +class _RenderContext: + """Tracks the text frame being filled and whether its first paragraph is used.""" + + text_frame: TextFrame + first_paragraph_used: bool = False + + +@dataclass +class _InlineState: + """Inline styles accumulated while walking down nested inline tags.""" + + bold: bool = False + italic: bool = False + underline: bool = False + mono: bool = False + link: str | None = None + + +def _render_text(text_frame: TextFrame, soup: BeautifulSoup) -> None: + """Fill a text frame with the block-level content of the parsed Markdown.""" + text_frame.clear() + text_frame.word_wrap = True + context = _RenderContext(text_frame=text_frame) + + for node in soup.contents: + if isinstance(node, NavigableString) and not str(node).strip(): + continue + + name = getattr(node, "name", None) + if name in HEADING_TAGS: + paragraph = _new_paragraph(context) + run = paragraph.add_run() + run.text = node.get_text() + run.font.bold = True + elif name in LIST_TAGS: + _render_list(context, node, level=0, ordered=name == "ol") + else: + paragraph = _new_paragraph(context) + _render_inline_children(paragraph, node) + + +def _render_table(shape: BaseShape, table_node: Tag) -> None: + """Replace a placeholder's content with a pptx table built from an HTML table.""" + rows = table_node.find_all("tr") + if not rows: + return + + row_count = len(rows) + column_count = max(len(row.find_all(["th", "td"])) for row in rows) + + if hasattr(shape, "insert_table"): + graphic_frame = shape.insert_table(rows=row_count, cols=column_count) + else: + graphic_frame = shape._parent.add_table( # noqa: SLF001 # pylint: disable=protected-access + row_count, column_count, shape.left, shape.top, shape.width, shape.height + ) + + table = graphic_frame.table + for row_index, row_node in enumerate(rows): + is_header_row = row_node.parent.name == "thead" + for column_index, cell_node in enumerate(row_node.find_all(["th", "td"])): + cell = table.cell(row_index, column_index) + cell.text_frame.clear() + cell.text_frame.word_wrap = True + + paragraph = cell.text_frame.paragraphs[0] + paragraph.clear() + _render_inline_children(paragraph, cell_node) + + if is_header_row or cell_node.name == "th": + for run in paragraph.runs: + run.font.bold = True + + +def _new_paragraph(context: _RenderContext) -> _Paragraph: + """Reuse the text frame's initial empty paragraph, then append new ones.""" + if not context.first_paragraph_used: + paragraph = context.text_frame.paragraphs[0] + paragraph.clear() + context.first_paragraph_used = True + return paragraph + return context.text_frame.add_paragraph() + + +def _render_list(context: _RenderContext, list_node: Tag, level: int, ordered: bool) -> None: + """Render a list and its nested lists, one paragraph per item.""" + items = list_node.find_all("li", recursive=False) + for index, item in enumerate(items, start=1): + _render_list_item(context, item, level, ordered, index) + + +def _render_list_item( + context: _RenderContext, item: Tag, level: int, ordered: bool, index: int +) -> None: + """Render one list item: its text into a marked paragraph, nested lists apart.""" + paragraph = None + for child in item.contents: + name = getattr(child, "name", None) + + if name in LIST_TAGS: + _render_list(context, child, level + 1, ordered=name == "ol") + continue + + if isinstance(child, NavigableString) and not str(child).strip(): + continue + + if paragraph is None: + paragraph = _new_paragraph(context) + _mark_list_paragraph(paragraph, level, ordered, index) + + _add_inline(paragraph, child) + + +def _mark_list_paragraph(paragraph: _Paragraph, level: int, ordered: bool, index: int) -> None: + """Give a list item's paragraph its number or bullet.""" + if ordered: + _apply_number(paragraph, level, index) + else: + _apply_bullet(paragraph, level) + + +def _render_inline_children(paragraph: _Paragraph, node: Tag) -> None: + """Render every inline child of a block node into one paragraph.""" + for child in node.contents: + _add_inline(paragraph, child) + + +def _add_inline( + paragraph: _Paragraph, + node: Tag | NavigableString, + state: _InlineState | None = None, +) -> None: + """Walk an inline subtree, emitting one run per text node with its styles.""" + state = state or _InlineState() + + if isinstance(node, NavigableString): + if text := str(node): + run = paragraph.add_run() + run.text = text + _apply_run_style(run, state) + return + + tag = (node.name or "").lower() + if tag == "br": + return + + nested_state = _InlineState( + bold=state.bold or tag in ("strong", "b"), + italic=state.italic or tag in ("em", "i"), + underline=state.underline or tag == "u", + mono=state.mono or tag == "code", + link=node.get("href") if tag == "a" else state.link, + ) + for child in node.contents: + _add_inline(paragraph, child, nested_state) + + +def _apply_run_style(run, state: _InlineState) -> None: + """Apply the accumulated inline styles, leaving unset ones to the template.""" + if state.bold: + run.font.bold = True + if state.italic: + run.font.italic = True + if state.underline: + run.font.underline = True + if state.mono: + run.font.name = MONO_FONT + run.font.size = MONO_FONT_SIZE + if state.link: + run.hyperlink.address = state.link + + +def _paragraph_properties(paragraph: _Paragraph): + """Return the paragraph's ```` element, creating it if needed.""" + # pylint: disable=protected-access + return paragraph._p.get_or_add_pPr() # noqa: SLF001 + + +def _clear_bullet_properties(properties) -> None: + """Drop any bullet definition inherited from the layout.""" + for tag in ("a:buNone", "a:buChar", "a:buAutoNum"): + element = properties.find(qn(tag)) + if element is not None: + properties.remove(element) + + +def _apply_indent(properties, paragraph: _Paragraph, level: int) -> None: + """Indent a paragraph to its nesting level, with a hanging first line.""" + properties.set("marL", str(INDENT_PER_LEVEL * (level + 1))) + properties.set("indent", str(HANGING_INDENT)) + paragraph.level = level + + +def _apply_bullet(paragraph: _Paragraph, level: int) -> None: + """Give an unordered list item its bullet character.""" + properties = _paragraph_properties(paragraph) + _clear_bullet_properties(properties) + _apply_indent(properties, paragraph, level) + + bullet = OxmlElement("a:buChar") + bullet.set("char", BULLET_CHAR) + properties.append(bullet) + + +def _apply_number(paragraph: _Paragraph, level: int, index: int) -> None: + """ + Prefix an ordered list item with its number as plain text. + + Auto-numbering is not used on purpose: it restarts per shape and cannot be + controlled per nesting level here, which produced wrong numbers on nested + ordered lists. + """ + properties = _paragraph_properties(paragraph) + _clear_bullet_properties(properties) + properties.append(OxmlElement("a:buNone")) + _apply_indent(properties, paragraph, level) + + run = paragraph.add_run() + run.text = f"{index}. " diff --git a/src/backend/chat/file_generation/templates/__init__.py b/src/backend/chat/file_generation/templates/__init__.py new file mode 100644 index 00000000..b6ef3eda --- /dev/null +++ b/src/backend/chat/file_generation/templates/__init__.py @@ -0,0 +1,5 @@ +"""Presentation templates bundled with the application.""" + +from .generic import GENERIC_TEMPLATE + +__all__ = ["GENERIC_TEMPLATE"] diff --git a/src/backend/chat/file_generation/templates/generic.pptx b/src/backend/chat/file_generation/templates/generic.pptx new file mode 100644 index 00000000..471b5beb Binary files /dev/null and b/src/backend/chat/file_generation/templates/generic.pptx differ diff --git a/src/backend/chat/file_generation/templates/generic.py b/src/backend/chat/file_generation/templates/generic.py new file mode 100644 index 00000000..8c55f344 --- /dev/null +++ b/src/backend/chat/file_generation/templates/generic.py @@ -0,0 +1,60 @@ +"""Binding of the bundled generic template to the slide types. + +The pptx file carries the visual identity (colour scheme, Marianne typeface, +masters and layout geometry); this module only declares which placeholder of +which layout receives which field. + +Layout names are French because they are what a user sees in the layout picker +once the deck is opened in PowerPoint or Impress. + +Two placeholder choices look surprising and are deliberate: in `Couverture` and +`Titre et sous-titre` the `Title 1` shape is a degenerate 0.2 inch box, so the +visible block is the one at index 1, which is why the cover title is mapped +there and the `Couverture` layout is left unused. +""" + +from pathlib import Path + +from chat.file_generation.entities import PresentationTemplate, SlideLayout, SlideType + +TEMPLATE_PATH = Path(__file__).parent / "generic.pptx" + + +GENERIC_TEMPLATE = PresentationTemplate( + path=TEMPLATE_PATH, + layouts=( + SlideLayout( + slide_type=SlideType.COVER, + layout_name="Titre et sous-titre", + shape_index_to_field={1: "title"}, + prompt="Cover slide with one large block for the deck title. Fields: title", + ), + SlideLayout( + slide_type=SlideType.SECTION, + layout_name="Chapitre", + shape_index_to_field={1: "title", 0: "subtitle"}, + prompt="Section divider slide. Fields: title, subtitle", + ), + SlideLayout( + slide_type=SlideType.TITLE_ONE_COLUMN, + layout_name="Titre et textes 1 colonne", + shape_index_to_field={0: "title", 1: "content"}, + prompt="Slide with a title and a single large body block. Fields: title, content", + ), + SlideLayout( + slide_type=SlideType.TITLE_TWO_COLUMNS, + layout_name="Titre et textes 2 colonnes", + shape_index_to_field={0: "title", 1: "left", 3: "right"}, + prompt=("Slide with a title and two side-by-side blocks. Fields: title, left, right"), + ), + SlideLayout( + slide_type=SlideType.TITLE_THREE_COLUMNS, + layout_name="Titre et textes 3 colonnes", + shape_index_to_field={0: "title", 2: "left", 3: "center", 4: "right"}, + prompt=( + "Slide with a title and three side-by-side blocks. " + "Fields: title, left, center, right" + ), + ), + ), +) diff --git a/src/backend/chat/tests/file_generation/__init__.py b/src/backend/chat/tests/file_generation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/chat/tests/file_generation/test_builder.py b/src/backend/chat/tests/file_generation/test_builder.py new file mode 100644 index 00000000..ca5cd1b0 --- /dev/null +++ b/src/backend/chat/tests/file_generation/test_builder.py @@ -0,0 +1,163 @@ +""" +Tests for the presentation builder. + +Real components: the bundled pptx template and python-pptx. Nothing is mocked; +decks are built in memory and read back with python-pptx to assert what a user +would actually open. +""" + +import dataclasses +from io import BytesIO + +import pptx +import pytest + +from chat.file_generation.builder import ( + PresentationBuildError, + build_presentation, + check_template, +) +from chat.file_generation.entities import Presentation, Slide, SlideType +from chat.file_generation.templates import GENERIC_TEMPLATE + + +def build_deck(slides): + """Build a deck from slides and reopen it as python-pptx would.""" + blob = build_presentation(GENERIC_TEMPLATE, Presentation(title="Deck", slides=slides)) + return pptx.Presentation(BytesIO(blob)) + + +def shape_texts(slide): + """Return the non-empty texts carried by a slide, in shape order.""" + return [ + shape.text_frame.text + for shape in slide.shapes + if shape.has_text_frame and shape.text_frame.text.strip() + ] + + +def test_check_template_accepts_the_bundled_template(): + """The declared layouts and placeholder indices match the shipped file.""" + check_template(GENERIC_TEMPLATE) + + +def test_every_slide_type_has_a_layout(): + """Every slide type the model may emit is bound to a layout.""" + for slide_type in SlideType: + assert GENERIC_TEMPLATE.get_layout(slide_type) is not None + + +def test_check_template_rejects_an_unknown_layout(): + """A layout name absent from the pptx file is reported, not silently skipped.""" + broken_layout = dataclasses.replace(GENERIC_TEMPLATE.layouts[0], layout_name="Nonexistent") + broken = dataclasses.replace(GENERIC_TEMPLATE, layouts=(broken_layout,)) + + with pytest.raises(PresentationBuildError, match="Nonexistent"): + check_template(broken) + + +def test_check_template_rejects_an_out_of_range_placeholder(): + """A placeholder index beyond the slide's shapes is reported.""" + broken_layout = dataclasses.replace( + GENERIC_TEMPLATE.layouts[0], shape_index_to_field={99: "title"} + ) + broken = dataclasses.replace(GENERIC_TEMPLATE, layouts=(broken_layout,)) + + with pytest.raises(PresentationBuildError, match="placeholder index 99"): + check_template(broken) + + +@pytest.mark.parametrize( + "slide,expected_layout,expected_texts", + [ + ( + Slide(type=SlideType.COVER, title="Titre"), + "Titre et sous-titre", + {"Titre"}, + ), + ( + Slide(type=SlideType.SECTION, title="Partie", subtitle="Sous-titre"), + "Chapitre", + {"Partie", "Sous-titre"}, + ), + ( + Slide(type=SlideType.TITLE_ONE_COLUMN, title="Titre", content="Corps"), + "Titre et textes 1 colonne", + {"Titre", "Corps"}, + ), + ( + Slide(type=SlideType.TITLE_TWO_COLUMNS, title="Titre", left="G", right="D"), + "Titre et textes 2 colonnes", + {"Titre", "G", "D"}, + ), + ( + Slide( + type=SlideType.TITLE_THREE_COLUMNS, + title="Titre", + left="A", + center="B", + right="C", + ), + "Titre et textes 3 colonnes", + {"Titre", "A", "B", "C"}, + ), + ], +) +def test_each_slide_type_uses_its_layout(slide, expected_layout, expected_texts): + """Every slide type lands on its bound layout with its fields filled.""" + deck = build_deck([slide]) + + assert len(deck.slides) == 1 + assert deck.slides[0].slide_layout.name == expected_layout + assert set(shape_texts(deck.slides[0])) == expected_texts + + +def test_slides_keep_their_order(): + """Slides appear in the order the spec declares them.""" + deck = build_deck( + [ + Slide(type=SlideType.COVER, title="Un"), + Slide(type=SlideType.SECTION, title="Deux"), + Slide(type=SlideType.COVER, title="Trois"), + ] + ) + + assert [shape_texts(slide)[0] for slide in deck.slides] == ["Un", "Deux", "Trois"] + + +def test_presenter_notes_are_attached(): + """Slide notes land on the slide's notes page.""" + deck = build_deck([Slide(type=SlideType.COVER, title="Titre", slide_notes="À dire à l'oral")]) + + assert deck.slides[0].notes_slide.notes_text_frame.text == "À dire à l'oral" + + +def test_slides_without_notes_are_left_alone(): + """No notes page is created when a slide carries no notes.""" + deck = build_deck([Slide(type=SlideType.COVER, title="Titre")]) + + assert not deck.slides[0].has_notes_slide + + +def test_unbound_slide_type_is_reported(): + """A type with no layout binding fails loudly instead of silently.""" + cover_only = dataclasses.replace(GENERIC_TEMPLATE, layouts=(GENERIC_TEMPLATE.layouts[0],)) + spec = Presentation(title="Deck", slides=[Slide(type=SlideType.SECTION, title="x")]) + + with pytest.raises(PresentationBuildError, match="No layout is bound"): + build_presentation(cover_only, spec) + + +def test_missing_template_file_is_reported(): + """A template path that cannot be opened raises a build error.""" + missing = dataclasses.replace(GENERIC_TEMPLATE, path="/nonexistent/template.pptx") + spec = Presentation(title="x", slides=[Slide(type=SlideType.COVER)]) + + with pytest.raises(PresentationBuildError, match="Could not open"): + build_presentation(missing, spec) + + +def test_a_deck_needs_at_least_one_slide(): + """An empty deck is rejected at validation time.""" + with pytest.raises(ValueError): + Presentation(title="Deck", slides=[]) diff --git a/src/backend/chat/tests/file_generation/test_renderer.py b/src/backend/chat/tests/file_generation/test_renderer.py new file mode 100644 index 00000000..ca8b0367 --- /dev/null +++ b/src/backend/chat/tests/file_generation/test_renderer.py @@ -0,0 +1,134 @@ +""" +Tests for the Markdown-to-pptx renderer. + +Real components: the bundled pptx template, python-markdown and python-pptx. +Content is rendered into a real placeholder taken from the template so the +assertions run against the same shape types production uses. +""" + +import pptx +import pytest + +from chat.file_generation.renderer import render +from chat.file_generation.templates.generic import TEMPLATE_PATH + +BODY_LAYOUT = "Titre et textes 1 colonne" +BODY_PLACEHOLDER_INDEX = 1 + + +@pytest.fixture(name="slide") +def slide_fixture(): + """A real slide built from the bundled template's body layout.""" + deck = pptx.Presentation(str(TEMPLATE_PATH)) + return deck.slides.add_slide(deck.slide_layouts.get_by_name(BODY_LAYOUT)) + + +@pytest.fixture(name="placeholder") +def placeholder_fixture(slide): + """The body placeholder that renders receive.""" + return slide.shapes[BODY_PLACEHOLDER_INDEX] + + +def table_of(slide): + """Return the single table shape rendered onto the slide.""" + return next(shape for shape in slide.shapes if shape.has_table).table + + +def runs_of(placeholder): + """Flatten every run of the placeholder across its paragraphs.""" + return [run for paragraph in placeholder.text_frame.paragraphs for run in paragraph.runs] + + +def test_plain_text_is_rendered(placeholder): + """A plain paragraph lands in the placeholder as-is.""" + render(placeholder, "Bonjour") + + assert placeholder.text_frame.text == "Bonjour" + + +def test_empty_content_is_safe(placeholder): + """An empty field clears the placeholder without failing.""" + render(placeholder, "") + + assert placeholder.text_frame.text == "" + + +def test_bold_and_italic_are_applied(placeholder): + """Emphasis markers become run-level styles.""" + render(placeholder, "normal **gras** et *italique*") + + styles = {run.text: (run.font.bold, run.font.italic) for run in runs_of(placeholder)} + assert styles["gras"] == (True, None) + assert styles["italique"] == (None, True) + + +def test_unstyled_runs_inherit_from_the_template(placeholder): + """Styles the Markdown does not ask for are left unset, not forced off.""" + render(placeholder, "**gras** puis normal") + + normal = next(run for run in runs_of(placeholder) if run.text.strip() == "puis normal") + assert normal.font.bold is None + assert normal.font.italic is None + + +def test_links_become_hyperlinks(placeholder): + """A Markdown link carries its target onto the run.""" + render(placeholder, "voir [le site](https://example.org)") + + linked = next(run for run in runs_of(placeholder) if run.text == "le site") + assert linked.hyperlink.address == "https://example.org" + + +def test_inline_code_uses_the_mono_font(placeholder): + """Inline code switches font family and size.""" + render(placeholder, "appelle `ma_fonction`") + + code = next(run for run in runs_of(placeholder) if run.text == "ma_fonction") + assert code.font.name == "Consolas" + + +def test_headings_are_rendered_bold(placeholder): + """Headings have no pptx equivalent and degrade to a bold paragraph.""" + render(placeholder, "# Un titre") + + assert placeholder.text_frame.text == "Un titre" + assert runs_of(placeholder)[0].font.bold is True + + +def test_nested_bullets_keep_their_level(placeholder): + """List nesting maps onto paragraph levels.""" + render(placeholder, "- un\n- deux\n - imbriqué") + + levels = [paragraph.level for paragraph in placeholder.text_frame.paragraphs] + assert levels == [0, 0, 1] + + +def test_ordered_lists_are_numbered(placeholder): + """Ordered items are prefixed with their number as plain text.""" + render(placeholder, "1. un\n2. deux") + + assert placeholder.text_frame.text == "1. un\n2. deux" + + +def test_nested_ordered_lists_restart_numbering(placeholder): + """A nested ordered list numbers itself independently of its parent.""" + render(placeholder, "1. un\n2. deux\n 1. deux-a\n3. trois") + + assert placeholder.text_frame.text == "1. un\n2. deux\n1. deux-a\n3. trois" + + +def test_tables_are_rendered_as_tables(slide, placeholder): + """A Markdown table becomes a pptx table with a bold header row.""" + render(placeholder, "| A | B |\n|---|---|\n| 1 | 2 |") + + table = table_of(slide) + + assert [[cell.text for cell in row.cells] for row in table.rows] == [["A", "B"], ["1", "2"]] + assert table.cell(0, 0).text_frame.paragraphs[0].runs[0].font.bold is True + + +def test_a_table_takes_over_the_whole_field(slide, placeholder): + """Text alongside a table is dropped rather than rendered under it.""" + render(placeholder, "Du texte avant\n\n| A |\n|---|\n| 1 |") + + assert "Du texte avant" not in table_of(slide).cell(0, 0).text diff --git a/src/backend/chat/tests/tools/test_generate_presentation.py b/src/backend/chat/tests/tools/test_generate_presentation.py new file mode 100644 index 00000000..90104b3d --- /dev/null +++ b/src/backend/chat/tests/tools/test_generate_presentation.py @@ -0,0 +1,233 @@ +""" +Tests for generate_presentation. + +Real components: Django ORM (factory-built conversation), real default_storage, +the real pptx template and builder, real RunContext + ContextDeps. + +The only thing mocked is the PresentationAgent's LLM (via FunctionModel) - the +standard pydantic-ai idiom for driving deterministic model output. +""" + +import re +from io import BytesIO +from unittest import mock +from urllib.parse import parse_qs, urlparse + +from django.core.files.storage import default_storage + +import pptx +import pytest +from asgiref.sync import sync_to_async +from pydantic_ai import ModelResponse, RunContext +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.messages import ToolCallPart +from pydantic_ai.models.function import FunctionModel +from pydantic_ai.usage import RunUsage + +from chat.agents.presentation import PresentationAgent +from chat.clients.schema import ContextDeps +from chat.factories import ChatConversationFactory, UserFactory +from chat.llm_configuration import LLModel, LLMProvider +from chat.tools.generate_presentation import ( + DOWNLOAD_URL_EXPIRATION, + build_object_name, + generate_presentation, +) + +# transaction=True is required so writes done via sync_to_async (which run on +# threadpool connections distinct from the test's wrapping transaction) commit +# and are flushed via TRUNCATE between tests instead of leaking across them. +pytestmark = pytest.mark.django_db(transaction=True) + + +@pytest.fixture(autouse=True) +def fixture_presentation_agent_config(settings): + """Configure the LLM model used by PresentationAgent.""" + settings.LLM_CONFIGURATIONS = { + settings.LLM_DEFAULT_MODEL_HRID: LLModel( + hrid="mistral-model", + model_name="mistral-7b-instruct-v0.1", + human_readable_name="Mistral 7B Instruct", + profile=None, + provider=LLMProvider( + hrid="mistral", + kind="mistral", + base_url="https://api.mistral.ai/v1", + api_key="testkey", + ), + is_active=True, + system_prompt="direct", + tools=[], + ), + } + + +DECK = { + "title": "Sobriété énergétique", + "slides": [ + {"type": "cover", "title": "Sobriété énergétique"}, + { + "type": "title_one_column", + "title": "Constats", + "content": "- Premier **constat**\n- Second constat", + "slide_notes": "Insister sur le premier point", + }, + ], +} + + +def model_returns_deck(deck): + """Build a FunctionModel callback answering with `deck` as structured output.""" + + def respond(_messages, info): + return ModelResponse(parts=[ToolCallPart(tool_name=info.output_tools[0].name, args=deck)]) + + return respond + + +@sync_to_async +def setup_context(max_retries=2): + """Build a conversation and a real RunContext in one sync block.""" + user = UserFactory() + conversation = ChatConversationFactory(owner=user) + ctx = RunContext( + model="test", + usage=RunUsage(input_tokens=0, output_tokens=0), + deps=ContextDeps(conversation=conversation, user=user), + max_retries=max_retries, + retries={}, + tool_name="generate_presentation", + ) + return ctx, conversation, user + + +async def run_tool(ctx, brief="Un support sur la sobriété énergétique", deck=None): + """Run the tool with the presentation agent's LLM stubbed out.""" + agent = PresentationAgent() + with agent.override(model=FunctionModel(model_returns_deck(deck or DECK))): + with mock.patch("chat.tools.generate_presentation.PresentationAgent", return_value=agent): + return await generate_presentation(ctx, brief=brief) + + +def object_key(url): + """Recover the S3 object key from a presigned URL.""" + path = urlparse(url).path + return path.split(f"/{default_storage.bucket_name}/", 1)[1] + + +@pytest.mark.asyncio +async def test_returns_a_signed_time_limited_link(): + """The deck is handed back as a signed URL scoped to the conversation.""" + ctx, conversation, _user = await setup_context() + + result = await run_tool(ctx) + + url = result.metadata["url"] + assert url in result.return_value + + key = object_key(url) + assert key.startswith(f"{conversation.pk}/attachments/") + # Readable slug from the deck title, plus a random hex, not a bare UUID. + assert re.fullmatch(r"sobriete_energetique_[0-9a-f]{8}\.pptx", key.rsplit("/", 1)[1]) + + query = parse_qs(urlparse(url).query) + assert query["X-Amz-Signature"] + assert query["X-Amz-Expires"] == [str(DOWNLOAD_URL_EXPIRATION)] + + +@pytest.mark.asyncio +async def test_the_stored_file_is_a_readable_deck(): + """What lands in storage opens as a deck holding the generated slides.""" + ctx, _conversation, _user = await setup_context() + + result = await run_tool(ctx) + + key = object_key(result.metadata["url"]) + blob = await sync_to_async(lambda: default_storage.open(key).read())() + deck = pptx.Presentation(BytesIO(blob)) + + assert len(deck.slides) == 2 + assert deck.slides[0].slide_layout.name == "Titre et sous-titre" + assert deck.slides[1].notes_slide.notes_text_frame.text == "Insister sur le premier point" + + +@pytest.mark.asyncio +async def test_no_attachment_row_is_created(): + """Generated decks live in storage only; nothing is recorded in database.""" + ctx, conversation, _user = await setup_context() + + await run_tool(ctx) + + attachment_count = await sync_to_async(conversation.attachments.count)() + assert attachment_count == 0 + + +@pytest.mark.asyncio +async def test_tells_the_model_not_to_restate_the_deck(): + """The return value steers the model away from repeating the slides in chat.""" + ctx, _conversation, _user = await setup_context() + + result = await run_tool(ctx) + + assert "2 slides" in result.return_value + assert "do not restate" in result.return_value.lower() + + +@pytest.mark.asyncio +async def test_an_empty_brief_is_sent_back_to_the_model(): + """An empty brief is a model mistake it can correct while retries remain.""" + ctx, _conversation, _user = await setup_context(max_retries=2) + + with pytest.raises(ModelRetry, match="brief is empty"): + await run_tool(ctx, brief=" ") + + +@pytest.mark.asyncio +async def test_an_empty_brief_soft_fails_once_retries_run_out(): + """ + Out of retries, the tool returns guidance instead of raising. + + The tool is registered with `retries=1`, so this is what the model actually + sees on a second empty brief: a message to relay, rather than an exception + that would let it answer from its own knowledge. + """ + ctx, _conversation, _user = await setup_context(max_retries=1) + + result = await run_tool(ctx, brief=" ") + + assert "brief is empty" in result + + +@pytest.mark.asyncio +async def test_nothing_is_written_when_the_brief_is_empty(): + """A rejected brief writes no orphan file to storage.""" + ctx, conversation, _user = await setup_context(max_retries=2) + + with pytest.raises(ModelRetry): + await run_tool(ctx, brief="") + + _dirs, files = await sync_to_async(default_storage.listdir)(f"{conversation.pk}/attachments") + assert files == [] + + +@pytest.mark.parametrize( + "title,expected_slug", + [ + ("Sobriété énergétique", "sobriete_energetique"), + ("Bilan 2026 / T1 : résultats !", "bilan_2026_t1_resultats"), + (" ", "presentation"), # empty title falls back + ("///", "presentation"), # no alphanumerics falls back + ("A" * 120, "a" * 60), # long titles are capped + ], +) +def test_build_object_name_is_readable_and_unguessable(title, expected_slug): + """The file name is a snake_case slug plus a random hex, ending in .pptx.""" + name = build_object_name(title) + assert re.fullmatch(rf"{expected_slug}_[0-9a-f]{{8}}\.pptx", name) + + +def test_build_object_name_hex_suffix_varies(): + """Two calls for the same title yield different, non-guessable names.""" + first = build_object_name("Deck") + second = build_object_name("Deck") + assert first != second diff --git a/src/backend/chat/tests/views/chat/conversations/test_conversation.py b/src/backend/chat/tests/views/chat/conversations/test_conversation.py index 59db3cc3..245f8d15 100644 --- a/src/backend/chat/tests/views/chat/conversations/test_conversation.py +++ b/src/backend/chat/tests/views/chat/conversations/test_conversation.py @@ -672,7 +672,8 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool): 'c:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","argsTextDelta":' '"{\\"location\\":\\"Paris\\", \\"unit\\":\\"celsius\\"}"}\n' 'a:{"toolCallId":"xLDcIljdsDrz0idal7tATWSMm2jhMj47","result":"Unknown tool ' - "name: 'get_current_weather'. Available tools: 'self_documentation'\"}\n" + "name: 'get_current_weather'. Available tools: 'generate_presentation', " + "'self_documentation'\"}\n" '0:"I cannot give you an answer to that."\n' 'f:{"messageId":""}\n' 'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0' @@ -784,7 +785,7 @@ def test_post_conversation_tool_call_fails(api_client, mock_openai_stream_tool): "parts": [ { "content": "Unknown tool name: 'get_current_weather'. " - "Available tools: 'self_documentation'", + "Available tools: 'generate_presentation', 'self_documentation'", "part_kind": "retry-prompt", "timestamp": FROZEN_TIMESTAMP, "tool_call_id": "xLDcIljdsDrz0idal7tATWSMm2jhMj47", diff --git a/src/backend/chat/tools/descriptions.py b/src/backend/chat/tools/descriptions.py index 1402f076..436fef2e 100644 --- a/src/backend/chat/tools/descriptions.py +++ b/src/backend/chat/tools/descriptions.py @@ -110,6 +110,32 @@ - "Résume ce texte" """ +GENERATE_PRESENTATION_TOOL_DESCRIPTION = """ +Generate a slide deck (.pptx) and return a link to download it. + +Use this tool whenever the user asks for a presentation, a deck, slides, or a +support to project in a meeting. The tool writes the slides itself: pass a +brief describing the subject, the audience and any structure the user asked +for, not the slides themselves. + +The tool returns a download link. Pass that link on to the user as-is; do NOT +restate the deck's content or list the slides. + +Do NOT use this tool to: +- Write a document, a report or a note (no slides involved) +- Summarize an attached document (use the summarize tool) +- Answer a question about presentations in general + +Examples that MUST trigger this tool: +- "Fais-moi une présentation sur la sobriété énergétique" +- "Prépare un support de 10 slides pour le comité de lundi" +- "Convertis cette note en deck" + +Examples that must NOT trigger this tool: +- "Résume ce document" +- "Comment structurer une bonne présentation ?" +""" + SELF_DOCUMENTATION_SYSTEM_PROMPT = ( "For meta questions about this assistant itself (identity, model, " "capabilities, limitations, privacy, internet access, accepted files, " diff --git a/src/backend/chat/tools/generate_presentation.py b/src/backend/chat/tools/generate_presentation.py new file mode 100644 index 00000000..d526a570 --- /dev/null +++ b/src/backend/chat/tools/generate_presentation.py @@ -0,0 +1,115 @@ +"""Slide deck generation tool for the chat agent.""" + +import logging +import secrets + +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.utils.text import slugify + +from asgiref.sync import sync_to_async +from pydantic_ai import ModelRetry, RunContext +from pydantic_ai.messages import ToolReturn + +from core.file_upload.mixins import AttachmentMixin +from core.file_upload.utils import generate_retrieve_policy + +from chat.agents.presentation import PresentationAgent +from chat.file_generation import GENERIC_TEMPLATE, PresentationBuildError, build_presentation +from chat.tools.exceptions import ModelCannotRetry +from chat.tools.utils import last_model_retry_soft_fail + +logger = logging.getLogger(__name__) + +PPTX_EXTENSION = "pptx" + +# Cap the readable part of the file name so long titles stay manageable. +MAX_NAME_LENGTH = 60 + +# Lifetime of the download link. Longer than the default retrieve policy, which +# is tuned for URLs the LLM reads at once: this one is for a user to click. +DOWNLOAD_URL_EXPIRATION = 60 * 60 # 1 hour + + +def build_object_name(title: str) -> str: + """ + Build a readable, non-guessable file name from the deck title. + + Shaped as ``_.pptx``: the slug is what the user sees + when downloading (the presigned URL exposes the key's last segment), and the + random hex keeps the object key from being guessable. + """ + slug = slugify(title).replace("-", "_")[:MAX_NAME_LENGTH].strip("_") or "presentation" + return f"{slug}_{secrets.token_hex(4)}.{PPTX_EXTENSION}" + + +@sync_to_async +def store_presentation(conversation, title: str, blob: bytes) -> str: + """ + Write the deck to object storage and return a presigned URL to download it. + + The deck is a one-off artifact, so no attachment row is created: it is + reachable through a signed, time-limited URL rather than a durable record. + The key is scoped to the conversation to keep the artifact tied to it. + """ + key = f"{conversation.pk}/{AttachmentMixin.ATTACHMENTS_FOLDER}/{build_object_name(title)}" + # Storage may hand back a different name than requested (S3 is configured + # with AWS_S3_FILE_OVERWRITE=False), so sign the key that was written. + key = default_storage.save(key, ContentFile(blob)) + + return generate_retrieve_policy(key, expiration=DOWNLOAD_URL_EXPIRATION) + + +@last_model_retry_soft_fail +async def generate_presentation(ctx: RunContext, brief: str) -> ToolReturn: + """ + Generate a slide deck and return a link to download it. + + Args: + brief: What the deck must cover, in the user's own terms. Include the + subject, the audience and any structure the user asked for. + + Returns: + ToolReturn: confirmation carrying the download link. + """ + if not brief.strip(): + raise ModelRetry("The brief is empty. Describe what the presentation must cover.") + + try: + result = await PresentationAgent().run(brief, usage=ctx.usage) + except Exception as exc: + logger.warning("Presentation agent failed: %s", exc, exc_info=True) + raise ModelRetry("The presentation outline could not be produced.") from exc + + presentation = result.output + + try: + blob = await sync_to_async(build_presentation)(GENERIC_TEMPLATE, presentation) + except PresentationBuildError as exc: + # The template is bundled with the application: a failure here is a + # deployment problem, not something a different brief would fix. + logger.exception("Failed to build the presentation") + raise ModelCannotRetry( + "The presentation could not be generated because of a server-side error." + ) from exc + + try: + url = await store_presentation(ctx.deps.conversation, presentation.title, blob) + except Exception as exc: + logger.exception("Failed to store the generated presentation") + raise ModelCannotRetry("The presentation was generated but could not be saved.") from exc + + logger.info( + "Generated presentation (%d slides) for conversation %s", + len(presentation.slides), + ctx.deps.conversation.pk, + ) + + return ToolReturn( + return_value=( + f"The presentation '{presentation.title}' was generated with " + f"{len(presentation.slides)} slides. Give the user this temporary " + f"download link: {url} — do not restate the deck's content." + ), + metadata={"url": url}, + ) diff --git a/src/backend/core/feature_flags/flags.py b/src/backend/core/feature_flags/flags.py index a255f0a4..290f4929 100644 --- a/src/backend/core/feature_flags/flags.py +++ b/src/backend/core/feature_flags/flags.py @@ -44,6 +44,7 @@ class FeatureFlags(BaseModel): # features web_search: FeatureToggle = FeatureToggle.DISABLED document_upload: FeatureToggle = FeatureToggle.DISABLED + presentation_generation: FeatureToggle = FeatureToggle.DISABLED def __getattr__(self, name: str): """Dynamically get specific RAG document search tool feature flags from settings.""" diff --git a/src/backend/core/file_upload/utils.py b/src/backend/core/file_upload/utils.py index 534e2d7b..56103ee6 100644 --- a/src/backend/core/file_upload/utils.py +++ b/src/backend/core/file_upload/utils.py @@ -129,12 +129,15 @@ def generate_upload_policy(key: str): return policy -def generate_retrieve_policy(key: str): +def generate_retrieve_policy(key: str, expiration: int | None = None): """ Generate a S3 retrieve policy for a given item. Args: key (str): The S3 object key where the file is stored. + expiration (int | None): Lifetime of the signed URL in seconds. Defaults + to AWS_S3_RETRIEVE_POLICY_EXPIRATION, which suits URLs the LLM reads + at once; pass a longer value for links a user is meant to click. """ # Get the S3 client according to the settings @@ -144,7 +147,7 @@ def generate_retrieve_policy(key: str): policy = s3_client.generate_presigned_url( ClientMethod="get_object", Params={"Bucket": default_storage.bucket_name, "Key": key}, - ExpiresIn=settings.AWS_S3_RETRIEVE_POLICY_EXPIRATION, + ExpiresIn=expiration or settings.AWS_S3_RETRIEVE_POLICY_EXPIRATION, ) return policy diff --git a/src/backend/core/tests/feature_flags/test_flags.py b/src/backend/core/tests/feature_flags/test_flags.py index 18eab378..83938e84 100644 --- a/src/backend/core/tests/feature_flags/test_flags.py +++ b/src/backend/core/tests/feature_flags/test_flags.py @@ -81,12 +81,16 @@ def test_round_trip_serialization(): raw = original.model_dump_json() restored = FeatureFlags.model_validate_json(raw) assert restored == original - assert raw == ('{"web_search":"dynamic","document_upload":"enabled"}') + assert raw == ( + '{"web_search":"dynamic","document_upload":"enabled","presentation_generation":"disabled"}' + ) raw_alias = original.model_dump_json(by_alias=True) restored_alias = FeatureFlags.model_validate_json(raw_alias) assert restored_alias == original - assert raw_alias == ('{"web-search":"dynamic","document-upload":"enabled"}') + assert raw_alias == ( + '{"web-search":"dynamic","document-upload":"enabled","presentation-generation":"disabled"}' + ) def test_all_fields_are_feature_toggle(): diff --git a/src/backend/core/tests/file_upload/test_utils.py b/src/backend/core/tests/file_upload/test_utils.py index 3029be8d..7f73b375 100644 --- a/src/backend/core/tests/file_upload/test_utils.py +++ b/src/backend/core/tests/file_upload/test_utils.py @@ -97,6 +97,16 @@ def test_generate_retrieve_policy(): assert len(query_params) == 0 +@freeze_time() +def test_generate_retrieve_policy_custom_expiration(): + """A custom expiration overrides the default retrieve-policy lifetime.""" + key = f"test/{uuid4()!s}/key.txt" + policy = generate_retrieve_policy(key, expiration=3600) + + query_params = parse_qs(urlparse(policy).query) + assert query_params["X-Amz-Expires"] == ["3600"] + + @freeze_time() def test_generate_retrieve_policy_s3_domain_replace(settings): """ diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 64277f3f..018951d0 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -51,7 +51,11 @@ def test_api_config(is_authenticated): "STATUS_PAGE_URL": "https://status.example.com", "DOCS_BASE_URL": None, "ENVIRONMENT": "test", - "FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"}, + "FEATURE_FLAGS": { + "document-upload": "enabled", + "presentation-generation": "enabled", + "web-search": "enabled", + }, "FILE_UPLOAD_MODE": "presigned_url", "FRONTEND_CONTACT_EMAIL": "contact@test.com", "FRONTEND_CSS_URL": "http://testcss/", @@ -232,7 +236,11 @@ async def test_api_config_async(is_authenticated): "STATUS_PAGE_URL": "https://status.example.com", "DOCS_BASE_URL": None, "ENVIRONMENT": "test", - "FEATURE_FLAGS": {"document-upload": "enabled", "web-search": "enabled"}, + "FEATURE_FLAGS": { + "document-upload": "enabled", + "presentation-generation": "enabled", + "web-search": "enabled", + }, "FILE_UPLOAD_MODE": "presigned_url", "FRONTEND_CONTACT_EMAIL": "contact@test.com", "FRONTEND_CSS_URL": "http://testcss/", diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 6b667f8c..9821a8e6 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "psycopg[binary]==3.3.4", "PyJWT==2.13.0", "python-magic==0.4.27", + "python-pptx==1.0.2", "redis<6.0.0", "requests==2.34.2", "semchunk==4.0.0", diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 0eb2cd28..8a927fb9 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -533,6 +533,7 @@ dependencies = [ { name = "pyjwt" }, { name = "pypdf" }, { name = "python-magic" }, + { name = "python-pptx" }, { name = "redis" }, { name = "requests" }, { name = "semchunk" }, @@ -625,6 +626,7 @@ requires-dist = [ { name = "pytest-icdiff", marker = "extra == 'dev'", specifier = "==0.9" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, { name = "python-magic", specifier = "==0.4.27" }, + { name = "python-pptx", specifier = "==1.0.2" }, { name = "redis", specifier = "<6.0.0" }, { name = "requests", specifier = "==2.34.2" }, { name = "responses", marker = "extra == 'dev'", specifier = "==0.26.1" }, @@ -2192,7 +2194,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -3047,8 +3049,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ diff --git a/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx index 2a21719e..722be3c4 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx @@ -153,6 +153,21 @@ export const splitStreamingContent = (content: string): StreamingContent => { return { completedBlocks, pending: pendingContent }; }; +/** Label shown next to the loader while a tool is running. */ +const getStreamingToolLabel = ( + toolName: string | undefined, + t: (key: string) => string, +): string => { + switch (toolName) { + case 'summarize': + return t('Summarizing...'); + case 'generate_presentation': + return t('Generating the slides...'); + default: + return t('Search...'); + } +}; + interface SourceMetadata { title: string | null; favicon: string | null; @@ -405,9 +420,7 @@ const MessageItemComponent: React.FC = ({ > - {activeToolInvocation?.toolName === 'summarize' - ? t('Summarizing...') - : t('Search...')} + {getStreamingToolLabel(activeToolInvocation?.toolName, t)} )} diff --git a/src/frontend/apps/conversations/src/i18n/translations.json b/src/frontend/apps/conversations/src/i18n/translations.json index 59d64622..5038baa5 100644 --- a/src/frontend/apps/conversations/src/i18n/translations.json +++ b/src/frontend/apps/conversations/src/i18n/translations.json @@ -97,6 +97,7 @@ "Files": "Fichiers", "Find recent news about...": "Trouver les dernières actualités concernant...", "General": "Général", + "Generating the slides...": "Génération des slides...", "Get help with coding, writing, proofreading, and more. Just ask.": "Obtenez de l'aide pour le codage, l'écriture, la correction d'épreuves, et plus encore. Demandez.", "Get notified about the Public Beta.": "Soyez informé de la Bêta publique.", "Get notified for the public beta": "Être notifié pour la bêta publique", diff --git a/src/frontend/apps/e2e/__tests__/app-conversations/common.ts b/src/frontend/apps/e2e/__tests__/app-conversations/common.ts index fc2e207c..c8daf884 100644 --- a/src/frontend/apps/e2e/__tests__/app-conversations/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-conversations/common.ts @@ -7,6 +7,7 @@ export const CONFIG = { ENVIRONMENT: 'development', FEATURE_FLAGS: { 'document-upload': 'enabled', + 'presentation-generation': 'disabled', 'web-search': 'enabled', }, FILE_UPLOAD_MODE: 'presigned_url',