Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v6
with:
python-version: "3.13"

- name: Install dependencies
run: uv sync --extra dev

- name: Lint
run: uv run ruff check .

- name: Test
run: uv run pytest tests/ -x -q
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ __pycache__/
*.mp3
*.mp4
*.wav
.venv/
.pytest_cache/
uv.lock
101 changes: 86 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# prax-plugins

Example plugin collection for [Prax](https://github.com/praxagent/prax). Each subfolder is a self-contained plugin with its own `plugin.py`.
Plugin collection for [Prax](https://github.com/praxagent/prax). Each subfolder is a self-contained plugin with its own `plugin.py`.

## Available plugins

| Plugin | Description |
|--------|-------------|
| [`pdf2presentation`](pdf2presentation/) | PDF → narrated video presentation (Beamer + TTS + ffmpeg) |
| [`flight_search`](flight_search/) | Search for the cheapest flights between airports (Amadeus API) |
| Plugin | Version | Description |
|--------|---------|-------------|
| [`pdf2presentation`](pdf2presentation/) | 4 | PDF → narrated video presentation (Beamer + TTS + ffmpeg) |
| [`flight_search`](flight_search/) | 1 | Search for the cheapest flights between airports (Amadeus API) |

## Installing plugins

Expand Down Expand Up @@ -36,7 +36,43 @@ cd /path/to/prax/workspaces/<your-user-id>/plugins/shared/
git submodule add https://github.com/praxagent/prax-plugins.git prax-plugins
```

### How it works
## Updating plugins

Once installed, ask Prax to pull the latest version:

> "Prax, please update the prax-plugins plugin"

or more specifically:

> "Update the pdf2presentation plugin"

Prax runs `plugin_import_update("prax-plugins")` under the hood, which:

1. Pulls the latest commit from this repo via `git submodule update --remote --merge`
2. Re-scans the updated code for security warnings
3. If clean, hot-reloads the plugin tools immediately — no restart needed
4. If new security concerns are found, shows them and waits for your confirmation

You can also check the current plugin version at any time:

> "What version of the pdf2presentation plugin am I running?"

Prax will call `plugin_status("prax-plugins")` and show the active version, health status, and failure count.

### Checking for updates manually

If you prefer manual control:

```bash
cd /path/to/prax/workspaces/<your-user-id>/plugins/shared/prax-plugins/
git pull origin main
```

Then tell Prax to reload:

> "Reload plugins"

## How it works

When you import a plugin repo, Prax:

Expand All @@ -48,7 +84,7 @@ When you import a plugin repo, Prax:

All plugin lifecycle events (import, activate, block, rollback, remove, security warnings) are recorded in the workspace trace log and searchable via `search_trace`.

#### Trust tiers
### Trust tiers

Prax tags every plugin with a trust tier based on its origin:

Expand All @@ -62,15 +98,13 @@ Imported plugins default to the least-trusted tier. Trust tiers are visible in `

When you import a specific subfolder from a multi-plugin repo, Prax writes a filter file (`.reponame_plugin_filter`) next to the submodule so only that subfolder's `plugin.py` is activated. The filter lives outside the submodule to avoid modifying its git working tree.

### Plugin structure
### Plugin failure tracking

Each plugin subfolder should contain:
Prax monitors every plugin tool invocation. If a tool fails 3 times consecutively, the plugin is automatically rolled back to its previous version. You'll see a message like:

```
my-plugin/
├── plugin.py # Required — tools + register()
└── README.md # Recommended — docs for users
```
> "Plugin pdf2presentation auto-rolled back after 3 consecutive failures."

You can check health status with `plugin_status` and manually roll back with `plugin_rollback` if needed.

---

Expand All @@ -85,6 +119,15 @@ PDF → Markdown → Beamer LaTeX + speaker notes (LLM) → slide images → TTS
| `pdf_to_presentation` | Full pipeline: PDF → narrated video (.mp4) |
| `pdf_to_slides` | Lighter: PDF → Beamer slide deck + speaker notes (no video) |

### Input validation

The plugin validates that the source is actually a PDF before processing:

- **Content-Type check** — HTTP responses with `text/html` or other non-PDF content types are rejected immediately with a clear error message
- **Magic bytes check** — Downloaded files are verified to start with `%PDF`. HTML pages, JSON responses, and other non-PDF content are detected and rejected with guidance (e.g., "use fetch_url_content to extract text first")

This prevents cryptic parser crashes when a URL returns an HTML page instead of a PDF.

### Requirements

**System dependencies:**
Expand Down Expand Up @@ -153,7 +196,9 @@ Once installed, just talk to Prax:
### Architecture

```
PDF file
PDF file / URL
│
├─ Content-Type + magic bytes validation
│
├─ opendataloader-pdf / pymupdf / pdftotext
▼
Expand Down Expand Up @@ -292,6 +337,32 @@ from prax.agent.llm_factory import build_llm # LLM
from prax.settings import settings # Settings (NOT os.environ)
```

---

## Development

### Setup

```bash
uv sync --extra dev
```

### Running tests

```bash
uv run pytest tests/ -x -q
```

### Linting

```bash
uv run ruff check .
```

### CI

Pull requests run lint + tests automatically via GitHub Actions. Merges to `main` trigger [release-please](https://github.com/googleapis/release-please) for automated semantic versioning.

## License

Apache 2.0
2 changes: 1 addition & 1 deletion flight_search/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ def flight_search(
f"Date: {departure_date}"
+ (f" → {return_date}" if return_date else "")
+ f" | {adults} adult{'s' if adults > 1 else ''}"
+ (f" | Nonstop only" if nonstop_only else "")
+ (" | Nonstop only" if nonstop_only else "")
+ (f" | {cabin_class.replace('_', ' ').title()}" if cabin_class else "")
+ f"\n\nFound {len(offers)} option{'s' if len(offers) != 1 else ''} (cheapest first):\n"
)
Expand Down
61 changes: 52 additions & 9 deletions pdf2presentation/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"""
from __future__ import annotations

PLUGIN_VERSION = "3"
PLUGIN_VERSION = "4"
PLUGIN_DESCRIPTION = "Convert a PDF into a narrated video presentation"

import json
Expand All @@ -28,8 +28,6 @@
import re
import subprocess
import tempfile
import time
from pathlib import Path

from langchain_core.tools import tool

Expand Down Expand Up @@ -139,21 +137,66 @@ def _extract_text_from_pdf(pdf_path: str) -> str:


def _download_pdf(url: str, dest_dir: str) -> str:
"""Download a PDF from a URL. Returns the local file path."""
"""Download a PDF from a URL. Returns the local file path.

Validates that the response is actually a PDF (by content-type header
and magic bytes) so that HTML pages, error pages, etc. are rejected
early with a clear error instead of crashing the PDF parser.
"""
# Prefer Prax's PDF service which handles arXiv URLs etc.
try:
from prax.services.pdf_service import download_pdf
return download_pdf(url)
except Exception:
path = download_pdf(url)
_validate_pdf(path, url)
return path
except ImportError:
pass

# Fallback: urllib.
import urllib.request
# Fallback: requests (preferred) or urllib.
dest = os.path.join(dest_dir, "input.pdf")
urllib.request.urlretrieve(url, dest)
try:
import requests as _req
resp = _req.get(url, timeout=60, allow_redirects=True)
resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "").lower()
if "html" in content_type:
raise ValueError(
f"URL returned HTML, not a PDF (Content-Type: {content_type}). "
f"If this is a web article, use fetch_url_content or web_summary_tool "
f"to extract the text first, then pass the text to pdf_to_slides."
)
with open(dest, "wb") as f:
f.write(resp.content)
except ImportError:
import urllib.request
urllib.request.urlretrieve(url, dest)

_validate_pdf(dest, url)
return dest


def _validate_pdf(path: str, source_url: str = "") -> None:
"""Check that a file is actually a PDF by reading its magic bytes."""
try:
with open(path, "rb") as f:
header = f.read(16)
except OSError:
return # Can't read — let downstream handle it.
if not header.startswith(b"%PDF"):
# Try to detect what we actually got.
if header.startswith((b"<!DOCTYPE", b"<html", b"<HTML", b"<head", b"<HEAD")):
hint = "The URL returned an HTML page, not a PDF."
elif header.startswith((b"{", b"[")):
hint = "The URL returned JSON, not a PDF."
else:
hint = f"File does not start with %PDF header (got: {header[:20]!r})."
url_note = f" URL: {source_url}" if source_url else ""
raise ValueError(
f"Not a valid PDF file. {hint}{url_note} "
f"If this is a web page, use fetch_url_content to extract its text first."
)


# ======================================================================
# Step 2: LLM — generate Beamer LaTeX + speaker notes
# ======================================================================
Expand Down
34 changes: 34 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[project]
name = "prax-plugins"
version = "0.1.0"
description = "Plugin collection for the Prax AI agent"
requires-python = ">=3.11"
license = "Apache-2.0"

dependencies = [
"langchain-core>=0.3",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.9",
"requests>=2.31",
]

[tool.ruff]
target-version = "py311"
line-length = 120

[tool.ruff.lint]
select = ["E", "F", "W", "I"]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
# Plugin files must declare PLUGIN_VERSION before imports — E402 is by design.
"*/plugin.py" = ["E402", "I001"]
# Test files need sys.modules patching before imports.
"tests/*.py" = ["E402", "I001"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Empty file added tests/__init__.py
Empty file.
Loading
Loading