-
Notifications
You must be signed in to change notification settings - Fork 22
✨(back) add pptx generation tool #624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| """ | ||
|
|
||
|
|
||
| @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()) | ||
| 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", | ||
| ] |
| 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 | ||
|
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." | ||
| ) | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: 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__)
PYRepository: suitenumerique/conversations Length of output: 10604 Enforce the cover-first deck contract in The agent contract requires the first slide to be 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| @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 | ||
There was a problem hiding this comment.
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_noteson every slide.Line 27 limits output to fields declared by a layout, but no layout declares
slide_notes; Line 34 simultaneously requests notes. Clarify thatslide_notesis allowed for every slide, otherwise the model is steered away from producing them.🤖 Prompt for AI Agents