MSG commited on
Commit
abab738
·
2 Parent(s): 1f5cc33a822dca

Merge pull request #3 from MSghais/feat/next_step_basics

Browse files
.cursor/plans/skill_agent_pptx_5413e3c2.plan.md ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Skill Agent PPTX
3
+ overview: Add a Hermes-style skill agent library on top of your existing TransformersBackend, with one education PowerPoint task as the first tab in a multi-tab Gradio Space — structured so more task tabs can be added later.
4
+ todos:
5
+ - id: agent-lib
6
+ content: "Create libs/agent: SkillRegistry, ToolRegistry, AgentRunner, TraceRecorder, pydantic outline models"
7
+ status: completed
8
+ - id: pptx-skill
9
+ content: Add skills/education-pptx/SKILL.md and create_pptx tool (python-pptx)
10
+ status: completed
11
+ - id: gradio-tabs
12
+ content: "Refactor Gradio app into Tabs: Education PPTX (primary) + Chat (debug)"
13
+ status: completed
14
+ - id: docker-workspace
15
+ content: Wire agent package into uv workspace, Dockerfile, models.yaml active_model
16
+ status: completed
17
+ - id: trace-demo
18
+ content: Add trace JSON export + README demo script for hackathon submission
19
+ status: completed
20
+ isProject: false
21
+ ---
22
+
23
+ # Skill Agent + Education PowerPoint (Phase 1)
24
+
25
+ ## Goal
26
+
27
+ Replace the generic chat demo with a **local skill-based agent** that uses your **transformers** presets (default: `minicpm5-1b` or `openbmb/MiniCPM5-1B` from [`models.yaml`](models.yaml)) to run a real workflow: **topic in → slide outline → downloadable `.pptx` out**.
28
+
29
+ This follows the **agentskills.io / Hermes SKILL.md pattern** without embedding the full Hermes runtime (too heavy for HF Space Docker).
30
+
31
+ **Hackathon alignment:** Backyard AI (teacher/tutor you know), **Best Agent**, **Tiny Titan** (≤4B), **OpenBMB** (MiniCPM), **Sharing is Caring** (publish trace JSON to Hub), optional **Well-Tuned** if you ship a fine-tuned preset later.
32
+
33
+ ---
34
+
35
+ ## Architecture
36
+
37
+ ```mermaid
38
+ flowchart TB
39
+ subgraph ui [apps/gradio-space]
40
+ Tabs[gr.Tabs]
41
+ EduTab[EducationPptxTab]
42
+ ChatTab[ChatTab optional later]
43
+ Tabs --> EduTab
44
+ end
45
+
46
+ subgraph agent [libs/agent]
47
+ Runner[AgentRunner]
48
+ Skills[SkillRegistry]
49
+ Tools[ToolRegistry]
50
+ Trace[TraceRecorder]
51
+ Runner --> Skills
52
+ Runner --> Tools
53
+ Runner --> Trace
54
+ end
55
+
56
+ subgraph inference [libs/inference]
57
+ Factory[factory.get_backend]
58
+ TF[TransformersBackend]
59
+ Factory --> TF
60
+ end
61
+
62
+ EduTab --> Runner
63
+ Runner --> Factory
64
+ Tools --> PptxTool[python-pptx]
65
+ ```
66
+
67
+ **Agent loop (simple, reliable for small models):**
68
+
69
+ 1. Load skill `education-pptx` from `skills/education-pptx/SKILL.md`
70
+ 2. User provides: topic, grade level, number of slides (3–8)
71
+ 3. **Step A — LLM:** generate structured slide outline (JSON schema in prompt; parse with fallback regex)
72
+ 4. **Step B — Tool:** `create_pptx(outline)` writes file via `python-pptx` (deterministic; no LLM needed for file bytes)
73
+ 5. **Step C — LLM (optional):** one-sentence “teacher notes” per slide
74
+ 6. Return: trace steps + `gr.File` download + markdown preview
75
+
76
+ Small models are weak at multi-hop tool JSON — keep the loop **fixed 2-step** (outline → tool) rather than open-ended ReAct for v1.
77
+
78
+ ---
79
+
80
+ ## New package: `libs/agent`
81
+
82
+ Add workspace member `libs/agent` with:
83
+
84
+ | Module | Responsibility |
85
+ |--------|----------------|
86
+ | `skills.py` | Load `SKILL.md` (YAML frontmatter + body), list skills by `task` tag |
87
+ | `tools.py` | Register callable tools with name, description, JSON schema |
88
+ | `runner.py` | `AgentRunner.run(skill_id, user_input, backend)` — orchestrates LLM + tools |
89
+ | `trace.py` | Append-only step log (`thought`, `tool_call`, `tool_result`, `artifact`) |
90
+ | `prompts.py` | Skill-specific system prompts and JSON outline template |
91
+
92
+ **Dependencies:** `inference` (workspace), `python-pptx`, `pydantic` (outline validation)
93
+
94
+ **Extend [`libs/inference/src/inference/base.py`](libs/inference/src/inference/base.py) usage only** — no changes required to `TransformersBackend` beyond optionally bumping `max_tokens` for outline generation via env or per-call kwargs (already supported in [`transformers.py`](libs/inference/src/inference/transformers.py)).
95
+
96
+ ---
97
+
98
+ ## First skill: `skills/education-pptx/SKILL.md`
99
+
100
+ ```markdown
101
+ ---
102
+ name: education-pptx
103
+ description: Create a short lesson PowerPoint from a topic and grade level
104
+ task: education
105
+ tools:
106
+ - create_pptx
107
+ model_hints:
108
+ - minicpm5-1b
109
+ - qwen3b-gguf
110
+ ---
111
+
112
+ ## Workflow
113
+ 1. Ask for topic, audience grade, slide count.
114
+ 2. Produce JSON outline: title, slides[{title, bullets[], speaker_note}].
115
+ 3. Call create_pptx with validated outline.
116
+ 4. Return download link and preview.
117
+ ```
118
+
119
+ ---
120
+
121
+ ## First tool: `create_pptx`
122
+
123
+ Implement in `libs/agent/src/agent/tools/pptx.py`:
124
+
125
+ - Input: Pydantic model `SlideOutline` (title, slides list)
126
+ - Output: path under `/tmp/agent_outputs/{run_id}.pptx` (HF Space writable temp)
127
+ - Simple template: title slide + bullet slides + optional speaker notes in notes field
128
+ - No images in v1 (keeps scope shippable by June 15)
129
+
130
+ ---
131
+
132
+ ## Gradio UI changes: [`apps/gradio-space/src/gradio_space/app.py`](apps/gradio-space/src/gradio_space/app.py)
133
+
134
+ Refactor into:
135
+
136
+ ```
137
+ gradio_space/
138
+ app.py # build_demo(), launch
139
+ tabs/
140
+ __init__.py
141
+ education_pptx.py # first task tab
142
+ chat.py # move existing ChatInterface here (secondary tab)
143
+ ```
144
+
145
+ **Tab 1 — Lesson Slides (primary submission UI):**
146
+
147
+ - Inputs: Topic, Grade (dropdown), Slides (slider 3–8)
148
+ - Button: Generate
149
+ - Outputs: Markdown outline preview, File download, Agent trace (accordion)
150
+ - Status line: model name + device from existing `warmup()` / `model_status()`
151
+
152
+ **Tab 2 — Chat (keep for debugging):** existing chat wired to same `ACTIVE_MODEL`
153
+
154
+ Use `gr.Tabs()` at top level; only Tab 1 needs polish for demo video.
155
+
156
+ **Model default for Space:** set `active_model: minicpm5-1b` in [`models.yaml`](models.yaml) (OpenBMB + Tiny Titan). Space hardware: GPU basic if transformers on CPU is too slow.
157
+
158
+ ---
159
+
160
+ ## Trace export (Sharing is Caring badge)
161
+
162
+ After each run, write trace JSON to `outputs/traces/{run_id}.json` and expose a “Copy trace” / optional Hub dataset upload script:
163
+
164
+ - `scripts/upload_trace.py` — pushes latest trace to a HF dataset repo (manual one-time setup; not required for v1 demo)
165
+
166
+ Trace schema (minimal):
167
+
168
+ ```json
169
+ {
170
+ "skill": "education-pptx",
171
+ "model": "minicpm5-1b",
172
+ "input": {"topic": "...", "grade": "6", "slides": 5},
173
+ "steps": [{"type": "llm", "prompt_hash": "...", "output": "..."}, {"type": "tool", "name": "create_pptx", "result": "..."}],
174
+ "artifact": "lesson_photosynthesis.pptx"
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Docker / workspace updates
181
+
182
+ - [`pyproject.toml`](pyproject.toml): add `agent` workspace member + root dep
183
+ - [`Dockerfile`](Dockerfile): COPY `libs/agent`, `skills/`, update `uv sync`
184
+ - [`apps/gradio-space/pyproject.toml`](apps/gradio-space/pyproject.toml): depend on `agent`
185
+ - Update root [`README.md`](README.md) hackathon story: “Lesson slide builder for a teacher you know”
186
+
187
+ ---
188
+
189
+ ## Phase 2 (after v1 ships — not in first PR)
190
+
191
+ - New tabs: `tabs/quiz_maker.py`, `tabs/worksheet.py` — each maps to a new `skills/*/SKILL.md`
192
+ - `SkillRegistry` already supports multiple skills; tabs just call `AgentRunner.run(skill_id=...)`
193
+ - **Off-Brand:** custom layout via `gr.Blocks` theming or `gr.Server` if time allows
194
+ - Fine-tuned Gemma preset from [`notebook/gemma-finetune.ipynb`](notebook/gemma-finetune.ipynb) for **Well-Tuned** badge
195
+
196
+ ---
197
+
198
+ ## Demo video script (for submission)
199
+
200
+ 1. Introduce real user (teacher/tutor) and problem: “building a 5-slide lesson takes 30+ minutes”
201
+ 2. Enter topic + grade in Tab 1, click Generate (~30–90s on GPU)
202
+ 3. Show outline preview + download `.pptx`, open in LibreOffice/Google Slides
203
+ 4. Show agent trace proving local model + tool pipeline (no cloud LLM API)
204
+
205
+ ---
206
+
207
+ ## Risks and mitigations
208
+
209
+ | Risk | Mitigation |
210
+ |------|------------|
211
+ | Small model outputs invalid JSON | Pydantic validate + one repair retry with “fix JSON only” prompt |
212
+ | CPU Space too slow | Pin `minicpm5-1b`, use GPU basic, or fallback `qwen3b-gguf` + llama.cpp for outline-only step |
213
+ | pptx dependency size | `python-pptx` is lightweight (~few MB) |
214
+ | Scope creep (many tabs) | Ship Tab 1 only; stub Tab 2 chat for dev |
215
+
216
+ ---
217
+
218
+ ## Files to create / modify (summary)
219
+
220
+ **Create:**
221
+ - `libs/agent/` (package + runner, tools, skills loader, trace)
222
+ - `skills/education-pptx/SKILL.md`
223
+ - `apps/gradio-space/src/gradio_space/tabs/education_pptx.py`
224
+ - `apps/gradio-space/src/gradio_space/tabs/chat.py`
225
+
226
+ **Modify:**
227
+ - [`apps/gradio-space/src/gradio_space/app.py`](apps/gradio-space/src/gradio_space/app.py) — Tabs shell
228
+ - [`models.yaml`](models.yaml) — `active_model: minicpm5-1b` for Space
229
+ - [`Dockerfile`](Dockerfile), [`pyproject.toml`](pyproject.toml), workspace lockfile
230
+ - [`README.md`](README.md) — product story + agent docs
.env.example CHANGED
@@ -1,25 +1,30 @@
1
  # --- Preset selection (models.yaml is the source of truth) ---
2
- ACTIVE_MODEL=qwen3b-gguf
3
  # Dev: enable dropdown in Gradio. Space: leave false to pin one model for visitors.
4
- ALLOW_MODEL_SWITCH=true
5
  # MODEL_PRESETS_PATH=./models.yaml
6
 
7
- # --- Legacy single-model overrides (optional; applied to ACTIVE_MODEL only) ---
8
- INFERENCE_BACKEND=llama_cpp
9
- MODEL_REPO=Qwen/Qwen2.5-3B-Instruct-GGUF
10
- MODEL_FILE=qwen2.5-3b-instruct-q4_k_m.gguf
11
- N_CTX=4096
12
- N_GPU_LAYERS=0
13
-
14
- # Optional: local GGUF path instead of Hub download (set in models.yaml model_path too)
15
- # MODEL_PATH=./models/qwen2.5-3b-instruct-q4_k_m.gguf
16
 
17
- # Optional: transformers presets (requires inference[transformers] extra)
18
- # ACTIVE_MODEL=minicpm5-1b
19
  # INFERENCE_BACKEND=transformers
20
  # MODEL_ID=openbmb/MiniCPM5-1B
21
  # TRUST_REMOTE_CODE=true
22
 
 
 
 
 
 
 
 
 
 
 
 
23
  # Optional: local fine-tuned merged weights
24
  # ACTIVE_MODEL=gemma-merged-local
25
  # MODEL_ID=./gemma_merged_model
 
1
  # --- Preset selection (models.yaml is the source of truth) ---
2
+ ACTIVE_MODEL=minicpm5-1b
3
  # Dev: enable dropdown in Gradio. Space: leave false to pin one model for visitors.
4
+ ALLOW_MODEL_SWITCH=false
5
  # MODEL_PRESETS_PATH=./models.yaml
6
 
7
+ # --- Agent outputs ---
8
+ # AGENT_OUTPUTS_DIR=/tmp/agent_outputs
9
+ # AGENT_TRACES_DIR=outputs/traces
10
+ # SKILLS_DIR=./skills
 
 
 
 
 
11
 
12
+ # --- Legacy single-model overrides (optional; applied to ACTIVE_MODEL only) ---
 
13
  # INFERENCE_BACKEND=transformers
14
  # MODEL_ID=openbmb/MiniCPM5-1B
15
  # TRUST_REMOTE_CODE=true
16
 
17
+ # --- llama.cpp presets (optional) ---
18
+ # ACTIVE_MODEL=qwen3b-gguf
19
+ # INFERENCE_BACKEND=llama_cpp
20
+ # MODEL_REPO=Qwen/Qwen2.5-3B-Instruct-GGUF
21
+ # MODEL_FILE=qwen2.5-3b-instruct-q4_k_m.gguf
22
+ # N_CTX=4096
23
+ # N_GPU_LAYERS=0
24
+
25
+ # Optional: local GGUF path instead of Hub download
26
+ # MODEL_PATH=./models/qwen2.5-3b-instruct-q4_k_m.gguf
27
+
28
  # Optional: local fine-tuned merged weights
29
  # ACTIVE_MODEL=gemma-merged-local
30
  # MODEL_ID=./gemma_merged_model
.gitignore CHANGED
@@ -9,3 +9,5 @@ models/
9
  *.egg-info/
10
  dist/
11
  build/
 
 
 
9
  *.egg-info/
10
  dist/
11
  build/
12
+
13
+ outputs/traces
Dockerfile CHANGED
@@ -16,8 +16,11 @@ WORKDIR /app
16
  COPY pyproject.toml uv.lock .python-version README.md models.yaml ./
17
  COPY apps/gradio-space/pyproject.toml apps/gradio-space/README.md apps/gradio-space/
18
  COPY libs/inference/pyproject.toml libs/inference/README.md libs/inference/
 
19
  COPY apps/gradio-space/src apps/gradio-space/src
20
  COPY libs/inference/src libs/inference/src
 
 
21
 
22
  RUN useradd -m -u 1000 user && \
23
  uv sync --frozen --no-dev --package gradio-space && \
@@ -25,7 +28,11 @@ RUN useradd -m -u 1000 user && \
25
 
26
  USER user
27
  ENV HOME=/home/user \
28
- PATH="/app/.venv/bin:$PATH"
 
 
 
 
29
 
30
  EXPOSE 7860
31
 
 
16
  COPY pyproject.toml uv.lock .python-version README.md models.yaml ./
17
  COPY apps/gradio-space/pyproject.toml apps/gradio-space/README.md apps/gradio-space/
18
  COPY libs/inference/pyproject.toml libs/inference/README.md libs/inference/
19
+ COPY libs/agent/pyproject.toml libs/agent/README.md libs/agent/
20
  COPY apps/gradio-space/src apps/gradio-space/src
21
  COPY libs/inference/src libs/inference/src
22
+ COPY libs/agent/src libs/agent/src
23
+ COPY skills skills
24
 
25
  RUN useradd -m -u 1000 user && \
26
  uv sync --frozen --no-dev --package gradio-space && \
 
28
 
29
  USER user
30
  ENV HOME=/home/user \
31
+ PATH="/app/.venv/bin:$PATH" \
32
+ AGENT_OUTPUTS_DIR=/tmp/agent_outputs \
33
+ AGENT_TRACES_DIR=/tmp/agent_traces
34
+
35
+ RUN mkdir -p /tmp/agent_outputs /tmp/agent_traces
36
 
37
  EXPOSE 7860
38
 
README.md CHANGED
@@ -1,7 +1,7 @@
1
  ---
2
 
3
- ## title: Small Model Hackathon
4
- emoji: 🦙
5
  colorFrom: blue
6
  colorTo: green
7
  sdk: docker
@@ -9,11 +9,13 @@ app_port: 7860
9
  pinned: false
10
  license: apache-2.0
11
 
12
- # Small Model Hackathon
13
 
14
- Gradio chat Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon). Runs local inference with **llama.cpp** (GGUF) by default; optional **transformers** backend via env.
15
 
16
- See **[USAGE.md](USAGE.md)** for local run, Docker smoke test, and HF Space deployment steps.
 
 
17
 
18
  ## Prerequisites
19
 
@@ -26,81 +28,75 @@ See **[USAGE.md](USAGE.md)** for local run, Docker smoke test, and HF Space depl
26
  uv sync --all-packages
27
  cp .env.example .env # optional: edit model settings
28
 
29
- # Download GGUF for offline dev (optional)
30
- uv run python scripts/download_model.py
31
-
32
  # Run Gradio locally
33
  uv run --package gradio-space python -m gradio_space.app
34
  ```
35
 
36
- Open [http://localhost:7860](http://localhost:7860). The model downloads from Hugging Face Hub on the first chat message (or set `MODEL_PATH` to a local GGUF).
37
-
38
- ## Environment variables
39
-
40
-
41
- | Variable | Default | Description |
42
- | ------------------- | --------------------------------- | ------------------------------------------ |
43
- | `INFERENCE_BACKEND` | `llama_cpp` | `llama_cpp` or `transformers` |
44
- | `MODEL_REPO` | `Qwen/Qwen2.5-3B-Instruct-GGUF` | Hub repo for GGUF |
45
- | `MODEL_FILE` | `qwen2.5-3b-instruct-q4_k_m.gguf` | GGUF filename |
46
- | `MODEL_PATH` | — | Local GGUF path (skips Hub download) |
47
- | `N_CTX` | `4096` | Context window |
48
- | `N_GPU_LAYERS` | `0` | GPU layers for llama.cpp (0 = CPU) |
49
- | `MODEL_ID` | `Qwen/Qwen2.5-3B-Instruct` | Used when `INFERENCE_BACKEND=transformers` |
50
-
51
 
52
- See `[.env.example](.env.example)` for a full template.
53
 
54
- ## Monorepo layout
 
 
 
55
 
56
  ```text
57
- apps/gradio-space/ # Gradio UI (HF Space entrypoint)
58
- libs/inference/ # Swappable inference backends
59
- scripts/ # Dev utilities
 
60
  ```
61
 
62
- ### Common commands
63
 
64
- ```bash
65
- uv add --package gradio-space <package>
66
- uv add --package inference <package>
67
- uv run --package gradio-space python -m gradio_space.app
68
- uv run python -c "from inference.factory import get_backend"
69
- ```
 
 
70
 
71
  ## Hugging Face Space deployment
72
 
73
  1. Create a Space under [build-small-hackathon](https://huggingface.co/build-small-hackathon) with **Docker** SDK.
74
  2. Link this repository (root `Dockerfile` + root `README.md` YAML above).
75
- 3. Hardware: start with **CPU basic**; upgrade to GPU if you set `N_GPU_LAYERS > 0`.
76
- 4. Add Space secrets: `MODEL_REPO`, `MODEL_FILE`, `N_CTX`, `N_GPU_LAYERS`.
77
 
78
  ```bash
79
- # Optional local Docker smoke test
80
  docker build -t hackathon-space .
81
- docker run --rm -p 7860:7860 -e MODEL_REPO=Qwen/Qwen2.5-3B-Instruct-GGUF hackathon-space
82
  ```
83
 
84
  ## Hackathon checklist
85
 
86
- - Choose a track (Backyard AI or Thousand Token Wood)
87
  - Space live under build-small-hackathon
88
- - Demo video recorded
89
  - Social post published
90
- - Submission locked in by **June 15, 2026**
91
 
92
  ### Badge targets
93
 
94
- - **Off-the-Grid** — local llama.cpp inference (default setup)
95
- - **Llama Champion** — llama.cpp + GGUF model
96
- - **Off-Brand** — custom UI via `gr.Server` (Phase 2)
97
- - **Sharing is Caring** — agent traces dataset (Phase 2)
 
 
98
 
99
- ## Transformers backend (optional)
100
 
101
  ```bash
102
- uv sync --package inference --extra transformers
103
- INFERENCE_BACKEND=transformers MODEL_ID=Qwen/Qwen2.5-3B-Instruct \
104
- uv run --package gradio-space python -m gradio_space.app
105
  ```
106
 
 
 
 
 
 
 
 
1
  ---
2
 
3
+ ## title: Lesson Agent
4
+ emoji: 📚
5
  colorFrom: blue
6
  colorTo: green
7
  sdk: docker
 
9
  pinned: false
10
  license: apache-2.0
11
 
12
+ # Lesson Agent
13
 
14
+ **Backyard AI** Gradio Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
15
 
16
+ A local skill-based agent helps a teacher you know turn a **topic + grade level** into a downloadable **PowerPoint** powered by a small transformers model (`MiniCPM5-1B` by default), no cloud LLM API.
17
+
18
+ See **[USAGE.md](USAGE.md)** for local run, Docker smoke test, and HF Space deployment.
19
 
20
  ## Prerequisites
21
 
 
28
  uv sync --all-packages
29
  cp .env.example .env # optional: edit model settings
30
 
 
 
 
31
  # Run Gradio locally
32
  uv run --package gradio-space python -m gradio_space.app
33
  ```
34
 
35
+ Open [http://localhost:7860](http://localhost:7860). Use the **Lesson slides** tab: enter a topic, grade, and slide count. The model loads on first generate.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ ## How it works
38
 
39
+ 1. **Skill** — `skills/education-pptx/SKILL.md` (Hermes / agentskills.io format)
40
+ 2. **LLM** — local model drafts a JSON slide outline
41
+ 3. **Tool** — `create_pptx` builds the file with `python-pptx`
42
+ 4. **Trace** — JSON log saved under `outputs/traces/` for the Sharing is Caring badge
43
 
44
  ```text
45
+ apps/gradio-space/ # Gradio tabs (Lesson slides + Chat debug)
46
+ libs/agent/ # Skill agent runner, tools, trace recorder
47
+ libs/inference/ # Transformers + llama.cpp backends
48
+ skills/ # SKILL.md task definitions
49
  ```
50
 
51
+ ## Environment variables
52
 
53
+ | Variable | Default | Description |
54
+ | -------- | ------- | ----------- |
55
+ | `ACTIVE_MODEL` | `minicpm5-1b` | Preset key from `models.yaml` |
56
+ | `AGENT_OUTPUTS_DIR` | `/tmp/agent_outputs` | Generated `.pptx` files |
57
+ | `AGENT_TRACES_DIR` | `outputs/traces` | Agent trace JSON |
58
+ | `SKILLS_DIR` | `./skills` | Skill definitions root |
59
+
60
+ See [`.env.example`](.env.example) and [`models.yaml`](models.yaml) for model presets.
61
 
62
  ## Hugging Face Space deployment
63
 
64
  1. Create a Space under [build-small-hackathon](https://huggingface.co/build-small-hackathon) with **Docker** SDK.
65
  2. Link this repository (root `Dockerfile` + root `README.md` YAML above).
66
+ 3. Hardware: **GPU basic** recommended for transformers (`minicpm5-1b`).
67
+ 4. Optional secrets: `ACTIVE_MODEL`, `N_GPU_LAYERS` (if using GGUF preset).
68
 
69
  ```bash
 
70
  docker build -t hackathon-space .
71
+ docker run --rm -p 7860:7860 -e ACTIVE_MODEL=minicpm5-1b hackathon-space
72
  ```
73
 
74
  ## Hackathon checklist
75
 
76
+ - **Track:** Backyard AI lesson slide builder for a teacher you know
77
  - Space live under build-small-hackathon
78
+ - Demo video: real user enters topic → download `.pptx` → show agent trace
79
  - Social post published
80
+ - Submission by **June 15, 2026**
81
 
82
  ### Badge targets
83
 
84
+ - **Best Agent** — skill loop + `create_pptx` tool
85
+ - **Tiny Titan** — MiniCPM5 1B (≤4B)
86
+ - **OpenBMB** — `openbmb/MiniCPM5-1B`
87
+ - **Sharing is Caring** — upload traces with `scripts/upload_trace.py`
88
+ - **Off-the-Grid** — local inference only (no cloud LLM API)
89
+ - **Well-Tuned** — optional fine-tuned preset in `models.yaml` (Phase 2)
90
 
91
+ ## Agent trace upload
92
 
93
  ```bash
94
+ uv run python scripts/upload_trace.py --repo-id YOUR_USER/build-small-agent-traces
 
 
95
  ```
96
 
97
+ ## Demo video script
98
+
99
+ 1. Introduce the teacher and the problem (building a 5-slide lesson takes 30+ minutes).
100
+ 2. Open **Lesson slides**, enter topic + grade, click **Generate**.
101
+ 3. Show outline preview and download the `.pptx`.
102
+ 4. Expand the agent trace JSON — local model, no cloud API.
USAGE.md CHANGED
@@ -1,6 +1,8 @@
1
  # Usage
2
 
3
- How to run the Gradio chat app locally, test it in Docker, and deploy to a Hugging Face Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
 
 
4
 
5
  ## Prerequisites
6
 
@@ -23,11 +25,11 @@ uv sync --all-packages
23
  cp .env.example .env
24
  ```
25
 
26
- Edit `.env` if you want a different model or local GGUF path. Defaults work out of the box.
27
 
28
- ### 3. Pre-download the model (recommended)
29
 
30
- The app can download the GGUF on first chat, but pre-downloading avoids a long wait during your first message:
31
 
32
  ```bash
33
  uv run python scripts/download_model.py
@@ -47,7 +49,13 @@ uv run --package gradio-space python -m gradio_space.app
47
 
48
  Open [http://localhost:7860](http://localhost:7860).
49
 
50
- The model loads on the **first chat message** unless you set `MODEL_PATH`. After code changes, restart the process to pick up updates.
 
 
 
 
 
 
51
 
52
  ### 5. Quick sanity checks
53
 
 
1
  # Usage
2
 
3
+ How to run the **Lesson Agent** Gradio app locally, test it in Docker, and deploy to a Hugging Face Space for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
4
+
5
+ The primary UI is the **Lesson slides** tab (topic → local model outline → downloadable `.pptx`). The **Chat (debug)** tab tests the underlying model.
6
 
7
  ## Prerequisites
8
 
 
25
  cp .env.example .env
26
  ```
27
 
28
+ Edit `.env` if you want a different model preset. Default is `minicpm5-1b` (transformers).
29
 
30
+ ### 3. Pre-download the model (optional for GGUF presets)
31
 
32
+ If using a GGUF preset (`qwen3b-gguf`), pre-download avoids a long wait on first use:
33
 
34
  ```bash
35
  uv run python scripts/download_model.py
 
49
 
50
  Open [http://localhost:7860](http://localhost:7860).
51
 
52
+ The model loads on the **first Generate** (Lesson slides) or chat message. Agent traces are written to `outputs/traces/`. After code changes, restart the process to pick up updates.
53
+
54
+ ### 5. Upload agent trace (Sharing is Caring badge)
55
+
56
+ ```bash
57
+ uv run python scripts/upload_trace.py --repo-id YOUR_USER/build-small-agent-traces
58
+ ```
59
 
60
  ### 5. Quick sanity checks
61
 
apps/gradio-space/pyproject.toml CHANGED
@@ -8,11 +8,13 @@ authors = [
8
  ]
9
  requires-python = ">=3.12"
10
  dependencies = [
 
11
  "gradio>=5.0.0",
12
  "inference",
13
  ]
14
 
15
  [tool.uv.sources]
 
16
  inference = { workspace = true }
17
 
18
  [build-system]
 
8
  ]
9
  requires-python = ">=3.12"
10
  dependencies = [
11
+ "agent",
12
  "gradio>=5.0.0",
13
  "inference",
14
  ]
15
 
16
  [tool.uv.sources]
17
+ agent = { workspace = true }
18
  inference = { workspace = true }
19
 
20
  [build-system]
apps/gradio-space/src/gradio_space/app.py CHANGED
@@ -2,103 +2,12 @@ import os
2
 
3
  import gradio as gr
4
 
5
- from inference.config import get_app_config, get_model_config
6
- from inference.factory import get_backend, reset_backend
 
 
7
 
8
  _app_config = get_app_config()
9
- _current_model_key: str | None = None
10
- _load_state: dict[str, bool] = {}
11
- _load_errors: dict[str, str] = {}
12
-
13
-
14
- def _ensure_model_loaded(model_key: str) -> str | None:
15
- global _current_model_key
16
-
17
- if model_key != _current_model_key:
18
- reset_backend()
19
- _current_model_key = model_key
20
-
21
- if _load_state.get(model_key):
22
- return None
23
-
24
- if model_key in _load_errors:
25
- return _load_errors[model_key]
26
-
27
- try:
28
- get_backend(model_key).load()
29
- _load_state[model_key] = True
30
- return None
31
- except Exception as exc: # noqa: BLE001 — surface model load failures in the UI
32
- message = f"Failed to load model: {exc}"
33
- _load_errors[model_key] = message
34
- return message
35
-
36
-
37
- def _history_to_messages(history: list) -> list[dict[str, str]]:
38
- messages: list[dict[str, str]] = []
39
- for item in history:
40
- if isinstance(item, dict):
41
- messages.append({"role": item["role"], "content": item["content"]})
42
- else:
43
- user_msg, assistant_msg = item
44
- messages.append({"role": "user", "content": user_msg})
45
- if assistant_msg:
46
- messages.append({"role": "assistant", "content": assistant_msg})
47
- return messages
48
-
49
-
50
- def chat(message: str, history: list, model_key: str) -> str:
51
- load_error = _ensure_model_loaded(model_key)
52
- if load_error:
53
- return load_error
54
-
55
- messages = _history_to_messages(history)
56
- messages.append({"role": "user", "content": message})
57
- return get_backend(model_key).chat(messages)
58
-
59
-
60
- def _runtime_device_hint(model_key: str) -> str:
61
- model = get_model_config(model_key)
62
- if model.backend == "transformers":
63
- try:
64
- import torch
65
-
66
- if torch.cuda.is_available():
67
- return f"GPU ({torch.cuda.get_device_name(0)})"
68
- except ImportError:
69
- pass
70
- return "CPU"
71
- if model.n_gpu_layers > 0:
72
- return f"llama.cpp GPU offload ({model.n_gpu_layers} layers)"
73
- return "CPU"
74
-
75
-
76
- def warmup(model_key: str | None = None) -> str:
77
- key = model_key or _app_config.active_model
78
- model = get_model_config(key)
79
-
80
- if _load_state.get(key):
81
- backend = get_backend(key)
82
- device = (
83
- backend.device_label
84
- if hasattr(backend, "device_label")
85
- else _runtime_device_hint(key)
86
- )
87
- return f"Model ready: {model.label} on {device}"
88
-
89
- if key in _load_errors:
90
- return _load_errors[key]
91
-
92
- device_hint = _runtime_device_hint(key)
93
- return (
94
- f"Preset `{key}` selected ({model.backend}, {device_hint}). "
95
- "Weights load on the first chat message."
96
- )
97
-
98
-
99
- def model_status(model_key: str) -> str:
100
- model = get_model_config(model_key)
101
- return f"**{model.label}**\n\n- Backend: `{model.backend}`\n- {warmup(model_key)}"
102
 
103
 
104
  def build_demo() -> gr.Blocks:
@@ -109,14 +18,14 @@ def build_demo() -> gr.Blocks:
109
  else "Using built-in presets (models.yaml not found)."
110
  )
111
 
112
- with gr.Blocks(title="Small Model Hackathon") as demo:
113
  gr.Markdown(
114
  f"""
115
- # Small Model Chat
116
 
117
- Local inference with preset-based configuration.
118
 
119
- - **Default preset:** `{active.key}` — {active.label}
120
  - **Backend:** `{active.backend}`
121
  - {presets_note}
122
 
@@ -124,47 +33,22 @@ Part of the [Build Small Hackathon](https://huggingface.co/build-small-hackathon
124
  """
125
  )
126
 
127
- if _app_config.allow_model_switch and len(_app_config.models) > 1:
128
- model_dropdown = gr.Dropdown(
129
- choices=_app_config.model_choices(),
130
- value=_app_config.active_model,
131
- label="Model preset",
132
- info="Switch presets for local testing. Each preset loads on first use.",
133
- )
134
- status = gr.Markdown(model_status(_app_config.active_model))
135
-
136
- model_dropdown.change(
137
- fn=model_status,
138
- inputs=model_dropdown,
139
- outputs=status,
140
- )
141
-
142
- gr.ChatInterface(
143
- fn=chat,
144
- additional_inputs=[model_dropdown],
145
- examples=[
146
- ["Hello! What can you help me with?", _app_config.active_model],
147
- ["Explain llama.cpp in one sentence.", _app_config.active_model],
148
- ],
149
- )
150
- else:
151
- status = gr.Markdown(model_status(_app_config.active_model))
152
- gr.ChatInterface(
153
- fn=lambda message, history: chat(message, history, _app_config.active_model),
154
- examples=["Hello! What can you help me with?", "Explain llama.cpp in one sentence."],
155
- )
156
- demo.load(lambda: warmup(_app_config.active_model), outputs=status)
157
 
158
  return demo
159
 
160
 
161
- demo = build_demo()
162
-
163
-
164
  def main() -> None:
 
 
165
  demo.launch(
166
  server_name="0.0.0.0",
167
  server_port=int(os.environ.get("PORT", "7860")),
 
168
  )
169
 
170
 
 
2
 
3
  import gradio as gr
4
 
5
+ from gradio_space.model_loading import preload_active_model
6
+ from gradio_space.tabs import build_chat_tab, build_education_pptx_tab
7
+ from gradio_space.tabs.education_pptx import gradio_allowed_paths
8
+ from inference.config import get_app_config
9
 
10
  _app_config = get_app_config()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
  def build_demo() -> gr.Blocks:
 
18
  else "Using built-in presets (models.yaml not found)."
19
  )
20
 
21
+ with gr.Blocks(title="Lesson Agent — Build Small Hackathon") as demo:
22
  gr.Markdown(
23
  f"""
24
+ # Lesson Agent
25
 
26
+ Local skill-based agent for teachers — **topic in, PowerPoint out**.
27
 
28
+ - **Model:** `{active.key}` — {active.label}
29
  - **Backend:** `{active.backend}`
30
  - {presets_note}
31
 
 
33
  """
34
  )
35
 
36
+ with gr.Tabs():
37
+ with gr.Tab("Lesson slides"):
38
+ build_education_pptx_tab()
39
+ with gr.Tab("Chat (debug)"):
40
+ build_chat_tab()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  return demo
43
 
44
 
 
 
 
45
  def main() -> None:
46
+ preload_active_model()
47
+ demo = build_demo()
48
  demo.launch(
49
  server_name="0.0.0.0",
50
  server_port=int(os.environ.get("PORT", "7860")),
51
+ allowed_paths=gradio_allowed_paths(),
52
  )
53
 
54
 
apps/gradio-space/src/gradio_space/model_loading.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from inference.config import get_app_config, get_model_config
2
+ from inference.factory import get_backend, reset_backend
3
+
4
+ _app_config = get_app_config()
5
+ _current_model_key: str | None = None
6
+ _load_state: dict[str, bool] = {}
7
+ _load_errors: dict[str, str] = {}
8
+
9
+
10
+ def get_active_model_key() -> str:
11
+ return _app_config.active_model
12
+
13
+
14
+ def ensure_model_loaded(model_key: str) -> str | None:
15
+ global _current_model_key
16
+
17
+ if model_key != _current_model_key:
18
+ reset_backend()
19
+ _current_model_key = model_key
20
+
21
+ if _load_state.get(model_key):
22
+ return None
23
+
24
+ if model_key in _load_errors:
25
+ return _load_errors[model_key]
26
+
27
+ try:
28
+ get_backend(model_key).load()
29
+ _load_state[model_key] = True
30
+ return None
31
+ except Exception as exc: # noqa: BLE001 — surface model load failures in the UI
32
+ message = f"Failed to load model: {exc}"
33
+ _load_errors[model_key] = message
34
+ return message
35
+
36
+
37
+ def runtime_device_hint(model_key: str) -> str:
38
+ model = get_model_config(model_key)
39
+ if model.backend == "transformers":
40
+ try:
41
+ import torch
42
+
43
+ if torch.cuda.is_available():
44
+ return f"GPU ({torch.cuda.get_device_name(0)})"
45
+ except ImportError:
46
+ pass
47
+ return "CPU"
48
+ if model.n_gpu_layers > 0:
49
+ return f"llama.cpp GPU offload ({model.n_gpu_layers} layers)"
50
+ return "CPU"
51
+
52
+
53
+ def warmup(model_key: str | None = None) -> str:
54
+ key = model_key or _app_config.active_model
55
+ model = get_model_config(key)
56
+
57
+ if _load_state.get(key):
58
+ backend = get_backend(key)
59
+ device = (
60
+ backend.device_label
61
+ if hasattr(backend, "device_label")
62
+ else runtime_device_hint(key)
63
+ )
64
+ return f"Model ready: {model.label} on {device}"
65
+
66
+ if key in _load_errors:
67
+ return _load_errors[key]
68
+
69
+ device_hint = runtime_device_hint(key)
70
+ return (
71
+ f"Preset `{key}` selected ({model.backend}, {device_hint}). "
72
+ "Loading weights…"
73
+ )
74
+
75
+
76
+ def preload_active_model() -> str:
77
+ """Load the active preset at startup so the first request is fast."""
78
+ key = get_active_model_key()
79
+ print(f"[startup] Loading model preset `{key}`…", flush=True)
80
+ error = ensure_model_loaded(key)
81
+ if error:
82
+ print(f"[startup] {error}", flush=True)
83
+ return error
84
+ status = warmup(key)
85
+ print(f"[startup] {status}", flush=True)
86
+ return status
87
+
88
+
89
+ def model_status(model_key: str) -> str:
90
+ model = get_model_config(model_key)
91
+ return f"**{model.label}**\n\n- Backend: `{model.backend}`\n- {warmup(model_key)}"
92
+
93
+
94
+ def _history_to_messages(history: list) -> list[dict[str, str]]:
95
+ messages: list[dict[str, str]] = []
96
+ for item in history:
97
+ if isinstance(item, dict):
98
+ messages.append({"role": item["role"], "content": item["content"]})
99
+ else:
100
+ user_msg, assistant_msg = item
101
+ messages.append({"role": "user", "content": user_msg})
102
+ if assistant_msg:
103
+ messages.append({"role": "assistant", "content": assistant_msg})
104
+ return messages
105
+
106
+
107
+ def chat(message: str, history: list, model_key: str) -> str:
108
+ load_error = ensure_model_loaded(model_key)
109
+ if load_error:
110
+ return load_error
111
+
112
+ messages = _history_to_messages(history)
113
+ messages.append({"role": "user", "content": message})
114
+ return get_backend(model_key).chat(messages)
apps/gradio-space/src/gradio_space/tabs/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from gradio_space.tabs.chat import build_chat_tab
2
+ from gradio_space.tabs.education_pptx import build_education_pptx_tab
3
+
4
+ __all__ = ["build_chat_tab", "build_education_pptx_tab"]
apps/gradio-space/src/gradio_space/tabs/chat.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+
3
+ from gradio_space.model_loading import chat, model_status
4
+ from inference.config import get_app_config
5
+
6
+ _app_config = get_app_config()
7
+
8
+
9
+ def build_chat_tab() -> None:
10
+ gr.Markdown(
11
+ """
12
+ ### Model chat (debug)
13
+
14
+ Test the active local model with a simple chat interface.
15
+ """
16
+ )
17
+
18
+ model_key = _app_config.active_model
19
+
20
+ if _app_config.allow_model_switch and len(_app_config.models) > 1:
21
+ model_dropdown = gr.Dropdown(
22
+ choices=_app_config.model_choices(),
23
+ value=_app_config.active_model,
24
+ label="Model preset",
25
+ )
26
+ status = gr.Markdown(model_status(model_key))
27
+ model_dropdown.change(fn=model_status, inputs=model_dropdown, outputs=status)
28
+ gr.ChatInterface(
29
+ fn=chat,
30
+ additional_inputs=[model_dropdown],
31
+ examples=[
32
+ ["Hello! What can you help me with?", _app_config.active_model],
33
+ ["Explain photosynthesis in one sentence.", _app_config.active_model],
34
+ ],
35
+ )
36
+ else:
37
+ status = gr.Markdown(model_status(model_key))
38
+ gr.ChatInterface(
39
+ fn=lambda message, history: chat(message, history, model_key),
40
+ examples=[
41
+ "Hello! What can you help me with?",
42
+ "Explain photosynthesis in one sentence.",
43
+ ],
44
+ )
apps/gradio-space/src/gradio_space/tabs/education_pptx.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import gradio as gr
4
+
5
+ from agent.runner import AgentRunner
6
+ from agent.tools.pptx import get_outputs_dir
7
+ from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
8
+ from inference.factory import get_backend
9
+
10
+ def _error_html(message: str) -> str:
11
+ safe = (
12
+ message.replace("&", "&amp;")
13
+ .replace("<", "&lt;")
14
+ .replace(">", "&gt;")
15
+ )
16
+ return (
17
+ f'<div style="padding:12px;border:1px solid #c44;border-radius:8px;'
18
+ f'background:#fff5f5;color:#8a1f1f;">{safe}</div>'
19
+ )
20
+
21
+
22
+ def generate_lesson_slides(
23
+ topic: str,
24
+ grade: str,
25
+ slide_count: int,
26
+ ) -> tuple[str, str, list[str], str | None, str | None, str | None, str, str]:
27
+ model_key = get_active_model_key()
28
+ load_error = ensure_model_loaded(model_key)
29
+ if load_error:
30
+ return load_error, _error_html(load_error), [], None, None, None, load_error, load_error
31
+
32
+ if not topic.strip():
33
+ message = "Please enter a lesson topic."
34
+ return message, _error_html(message), [], None, None, None, message, message
35
+
36
+ try:
37
+ runner = AgentRunner()
38
+ result = runner.run_education_pptx(
39
+ topic=topic,
40
+ grade=grade,
41
+ slide_count=int(slide_count),
42
+ model_key=model_key,
43
+ backend=get_backend(model_key),
44
+ )
45
+ except Exception as exc: # noqa: BLE001 — show agent errors in UI
46
+ message = f"Agent error: {exc}"
47
+ return message, _error_html(message), [], None, None, None, message, message
48
+
49
+ gallery = [str(Path(p).resolve()) for p in result.preview_images]
50
+ trace_summary = (
51
+ f"Run `{result.trace.run_id}` · skill `{result.trace.skill}` · "
52
+ f"model `{result.trace.model}`\n\n"
53
+ f"Trace saved: `{result.trace_path}`"
54
+ )
55
+ return (
56
+ result.markdown_preview,
57
+ result.html_preview,
58
+ gallery,
59
+ str(Path(result.pptx_path).resolve()),
60
+ str(Path(result.docx_path).resolve()),
61
+ str(Path(result.html_export_path).resolve()),
62
+ trace_summary,
63
+ result.trace.to_json(),
64
+ )
65
+
66
+
67
+ def build_education_pptx_tab() -> None:
68
+ model_key = get_active_model_key()
69
+
70
+ gr.Markdown(
71
+ """
72
+ ### Lesson slide builder
73
+
74
+ Enter a topic and grade level. A **local small model** drafts the outline;
75
+ the agent then builds a downloadable PowerPoint — no cloud LLM API.
76
+ """
77
+ )
78
+ gr.Markdown(model_status(model_key))
79
+
80
+ with gr.Row():
81
+ topic = gr.Textbox(
82
+ label="Lesson topic",
83
+ placeholder="e.g. Photosynthesis, Fractions, The water cycle",
84
+ )
85
+ grade = gr.Dropdown(
86
+ label="Grade level",
87
+ choices=["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "Adult"],
88
+ value="6",
89
+ )
90
+ slide_count = gr.Slider(
91
+ minimum=3,
92
+ maximum=8,
93
+ step=1,
94
+ value=5,
95
+ label="Content slides",
96
+ )
97
+
98
+ generate_btn = gr.Button("Generate lesson slides", variant="primary")
99
+
100
+ with gr.Tabs():
101
+ with gr.Tab("Slide preview"):
102
+ slide_preview = gr.HTML(label="Slides")
103
+ slide_gallery = gr.Gallery(
104
+ label="Slide thumbnails",
105
+ columns=2,
106
+ height=420,
107
+ object_fit="contain",
108
+ preview=True,
109
+ )
110
+ with gr.Tab("Outline"):
111
+ outline_preview = gr.Markdown(label="Outline (markdown)")
112
+
113
+ with gr.Row():
114
+ pptx_file = gr.File(label="Download PowerPoint (.pptx)", interactive=False)
115
+ docx_file = gr.File(
116
+ label="Download Word / Google Docs (.docx)",
117
+ interactive=False,
118
+ )
119
+ html_file = gr.File(
120
+ label="Download HTML (import to Google Docs)",
121
+ interactive=False,
122
+ )
123
+
124
+ gr.Markdown(
125
+ """
126
+ **Open in Google Docs:** download the `.docx` file, upload it to [Google Drive](https://drive.google.com),
127
+ then choose **Open with → Google Docs**. You can also upload the `.html` file via
128
+ **Google Docs → File → Open → Upload**.
129
+ """
130
+ )
131
+
132
+ trace_box = gr.Textbox(
133
+ label="Agent trace (JSON)",
134
+ lines=12,
135
+ max_lines=20,
136
+ interactive=False,
137
+ )
138
+
139
+ with gr.Accordion("Trace summary", open=False):
140
+ trace_summary = gr.Markdown()
141
+
142
+ generate_btn.click(
143
+ fn=generate_lesson_slides,
144
+ inputs=[topic, grade, slide_count],
145
+ outputs=[
146
+ outline_preview,
147
+ slide_preview,
148
+ slide_gallery,
149
+ pptx_file,
150
+ docx_file,
151
+ html_file,
152
+ trace_summary,
153
+ trace_box,
154
+ ],
155
+ )
156
+
157
+
158
+ def gradio_allowed_paths() -> list[str]:
159
+ """Paths Gradio must be allowed to read for previews and downloads."""
160
+ root = get_outputs_dir().resolve()
161
+ return [str(root)]
libs/agent/README.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # agent
2
+
3
+ Hermes-style skill agent on top of the local `inference` backends.
4
+
5
+ ```python
6
+ from agent.runner import AgentRunner
7
+ from inference.factory import get_backend
8
+
9
+ runner = AgentRunner()
10
+ result = runner.run_education_pptx(
11
+ topic="Photosynthesis",
12
+ grade="6",
13
+ slide_count=5,
14
+ model_key="minicpm5-1b",
15
+ backend=get_backend("minicpm5-1b"),
16
+ )
17
+ ```
libs/agent/pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "agent"
3
+ version = "0.1.0"
4
+ description = "Skill-based local agent loop for hackathon tasks"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "MSGhais", email = "msghais135@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "inference",
12
+ "pillow>=10.0.0",
13
+ "pydantic>=2.0.0",
14
+ "python-docx>=1.1.0",
15
+ "python-pptx>=1.0.0",
16
+ "pyyaml>=6.0.2",
17
+ ]
18
+
19
+ [tool.uv.sources]
20
+ inference = { workspace = true }
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.8.13,<0.9.0"]
24
+ build-backend = "uv_build"
libs/agent/src/agent/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agent.runner import AgentResult, AgentRunner
2
+ from agent.skills import Skill, SkillRegistry
3
+ from agent.trace import TraceRecorder
4
+
5
+ __all__ = [
6
+ "AgentResult",
7
+ "AgentRunner",
8
+ "Skill",
9
+ "SkillRegistry",
10
+ "TraceRecorder",
11
+ ]
libs/agent/src/agent/models.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class SlideSpec(BaseModel):
7
+ title: str
8
+ bullets: list[str] = Field(default_factory=list, min_length=1)
9
+ speaker_note: str = ""
10
+
11
+
12
+ class SlideOutline(BaseModel):
13
+ title: str
14
+ slides: list[SlideSpec] = Field(min_length=1)
15
+
16
+
17
+ class EducationPptxInput(BaseModel):
18
+ topic: str
19
+ grade: str
20
+ slide_count: int = Field(ge=3, le=8)
libs/agent/src/agent/preview.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ from pathlib import Path
5
+
6
+ from PIL import Image, ImageDraw, ImageFont
7
+
8
+ from agent.models import SlideOutline
9
+ from agent.tools.pptx import _outputs_dir, _safe_filename
10
+
11
+
12
+ def outline_to_html(outline: SlideOutline) -> str:
13
+ """Render slide-like cards for in-browser preview."""
14
+ slides_html: list[str] = []
15
+ slides_html.append(
16
+ _slide_card_html(
17
+ title=outline.title,
18
+ subtitle="Lesson slides",
19
+ bullets=[],
20
+ speaker_note="",
21
+ index=0,
22
+ is_title=True,
23
+ )
24
+ )
25
+ for index, slide in enumerate(outline.slides, start=1):
26
+ slides_html.append(
27
+ _slide_card_html(
28
+ title=slide.title,
29
+ subtitle="",
30
+ bullets=slide.bullets,
31
+ speaker_note=slide.speaker_note,
32
+ index=index,
33
+ is_title=False,
34
+ )
35
+ )
36
+
37
+ return f"""
38
+ <div class="lesson-deck">
39
+ <style>
40
+ .lesson-deck {{
41
+ font-family: Georgia, "Iowan Old Style", serif;
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 16px;
45
+ max-width: 960px;
46
+ }}
47
+ .lesson-slide {{
48
+ border: 2px solid #5a3a22;
49
+ border-radius: 12px;
50
+ background: linear-gradient(180deg, #fbf6e8 0%, #f6efe1 100%);
51
+ box-shadow: 0 4px 0 rgba(58,37,22,0.12);
52
+ padding: 24px 28px;
53
+ min-height: 180px;
54
+ }}
55
+ .lesson-slide.title-slide {{
56
+ background: linear-gradient(135deg, #3a2516 0%, #5a3a22 100%);
57
+ color: #f6efe1;
58
+ min-height: 220px;
59
+ display: flex;
60
+ flex-direction: column;
61
+ justify-content: center;
62
+ }}
63
+ .lesson-slide .slide-index {{
64
+ font-size: 11px;
65
+ letter-spacing: 0.18em;
66
+ text-transform: uppercase;
67
+ color: #8a4a2b;
68
+ margin-bottom: 8px;
69
+ font-weight: 700;
70
+ }}
71
+ .lesson-slide.title-slide .slide-index {{
72
+ color: #e6a85c;
73
+ }}
74
+ .lesson-slide h3 {{
75
+ margin: 0 0 12px 0;
76
+ font-size: 1.5rem;
77
+ line-height: 1.2;
78
+ }}
79
+ .lesson-slide .subtitle {{
80
+ margin: 0;
81
+ opacity: 0.85;
82
+ font-style: italic;
83
+ }}
84
+ .lesson-slide ul {{
85
+ margin: 0;
86
+ padding-left: 1.25rem;
87
+ }}
88
+ .lesson-slide li {{
89
+ margin-bottom: 6px;
90
+ line-height: 1.45;
91
+ }}
92
+ .lesson-slide .speaker-note {{
93
+ margin-top: 14px;
94
+ padding-top: 10px;
95
+ border-top: 1px dashed #8a6a48;
96
+ font-size: 0.9rem;
97
+ color: #5a3a22;
98
+ font-style: italic;
99
+ }}
100
+ </style>
101
+ {''.join(slides_html)}
102
+ </div>
103
+ """
104
+
105
+
106
+ def _slide_card_html(
107
+ *,
108
+ title: str,
109
+ subtitle: str,
110
+ bullets: list[str],
111
+ speaker_note: str,
112
+ index: int,
113
+ is_title: bool,
114
+ ) -> str:
115
+ safe_title = html.escape(title)
116
+ safe_subtitle = html.escape(subtitle)
117
+ klass = "lesson-slide title-slide" if is_title else "lesson-slide"
118
+ label = "Title" if is_title else f"Slide {index}"
119
+
120
+ bullets_html = ""
121
+ if bullets:
122
+ items = "".join(f"<li>{html.escape(b)}</li>" for b in bullets)
123
+ bullets_html = f"<ul>{items}</ul>"
124
+
125
+ note_html = ""
126
+ if speaker_note:
127
+ note_html = f'<div class="speaker-note">Teacher note: {html.escape(speaker_note)}</div>'
128
+
129
+ subtitle_html = f'<p class="subtitle">{safe_subtitle}</p>' if subtitle else ""
130
+
131
+ return f"""
132
+ <article class="{klass}">
133
+ <div class="slide-index">{label}</div>
134
+ <h3>{safe_title}</h3>
135
+ {subtitle_html}
136
+ {bullets_html}
137
+ {note_html}
138
+ </article>
139
+ """
140
+
141
+
142
+ def render_slide_images(outline: SlideOutline, run_id: str) -> list[Path]:
143
+ """Render PNG thumbnails for gr.Gallery preview."""
144
+ out_dir = _outputs_dir() / f"preview_{run_id}"
145
+ out_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ width, height = 1280, 720
148
+ paths: list[Path] = []
149
+
150
+ title_path = out_dir / "00_title.png"
151
+ _draw_slide_image(
152
+ title_path,
153
+ width,
154
+ height,
155
+ title=outline.title,
156
+ subtitle="Generated lesson slides",
157
+ bullets=[],
158
+ is_title=True,
159
+ )
160
+ paths.append(title_path)
161
+
162
+ for index, slide in enumerate(outline.slides, start=1):
163
+ path = out_dir / f"{index:02d}_{_safe_filename(slide.title)}.png"
164
+ _draw_slide_image(
165
+ path,
166
+ width,
167
+ height,
168
+ title=slide.title,
169
+ subtitle="",
170
+ bullets=slide.bullets,
171
+ is_title=False,
172
+ )
173
+ paths.append(path)
174
+
175
+ return paths
176
+
177
+
178
+ def _load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
179
+ candidates = [
180
+ "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
181
+ "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
182
+ ]
183
+ for path in candidates:
184
+ if Path(path).exists():
185
+ return ImageFont.truetype(path, size)
186
+ return ImageFont.load_default()
187
+
188
+
189
+ def _draw_slide_image(
190
+ path: Path,
191
+ width: int,
192
+ height: int,
193
+ *,
194
+ title: str,
195
+ subtitle: str,
196
+ bullets: list[str],
197
+ is_title: bool,
198
+ ) -> None:
199
+ if is_title:
200
+ bg = (58, 37, 22)
201
+ fg = (246, 239, 225)
202
+ accent = (230, 168, 92)
203
+ else:
204
+ bg = (251, 246, 232)
205
+ fg = (42, 33, 24)
206
+ accent = (138, 106, 72)
207
+
208
+ image = Image.new("RGB", (width, height), bg)
209
+ draw = ImageDraw.Draw(image)
210
+
211
+ margin = 80
212
+ title_font = _load_font(56 if is_title else 44, bold=True)
213
+ body_font = _load_font(30)
214
+ small_font = _load_font(24)
215
+
216
+ y = margin
217
+ if is_title:
218
+ y_title = height // 2 - 80
219
+ for part in _wrap_text(title, 28).split("\n"):
220
+ draw.text((margin, y_title), part, fill=fg, font=title_font)
221
+ y_title += 64
222
+ if subtitle:
223
+ draw.text((margin, height // 2 + 40), subtitle, fill=accent, font=small_font)
224
+ else:
225
+ for part in _wrap_text(title, 32).split("\n"):
226
+ draw.text((margin, y), part, fill=fg, font=title_font)
227
+ y += 52
228
+ y += 20
229
+ for bullet in bullets:
230
+ line = _wrap_text(f"• {bullet}", 48)
231
+ for part in line.split("\n"):
232
+ draw.text((margin + 10, y), part, fill=fg, font=body_font)
233
+ y += 42
234
+ y += 8
235
+
236
+ draw.rectangle([(0, 0), (width, 8)], fill=accent)
237
+ image.save(path)
238
+
239
+
240
+ def _wrap_text(text: str, max_chars: int) -> str:
241
+ words = text.split()
242
+ lines: list[str] = []
243
+ current: list[str] = []
244
+ for word in words:
245
+ candidate = " ".join(current + [word])
246
+ if len(candidate) <= max_chars:
247
+ current.append(word)
248
+ else:
249
+ if current:
250
+ lines.append(" ".join(current))
251
+ current = [word]
252
+ if current:
253
+ lines.append(" ".join(current))
254
+ return "\n".join(lines) if lines else text
libs/agent/src/agent/prompts.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from agent.models import EducationPptxInput
6
+
7
+
8
+ def education_outline_system(skill_body: str) -> str:
9
+ return f"""You are a lesson-planning assistant for teachers.
10
+ Follow the skill workflow below and output ONLY valid JSON (no markdown fences).
11
+
12
+ Skill workflow:
13
+ {skill_body}
14
+
15
+ JSON schema:
16
+ {{
17
+ "title": "string — presentation title",
18
+ "slides": [
19
+ {{
20
+ "title": "string — slide heading",
21
+ "bullets": ["string", "..."],
22
+ "speaker_note": "string — one sentence for the teacher"
23
+ }}
24
+ ]
25
+ }}
26
+
27
+ Rules:
28
+ - Use exactly the requested number of content slides (title slide is added separately by the tool).
29
+ - Bullets should be short, age-appropriate, and factual.
30
+ - speaker_note is optional but helpful for each slide.
31
+ """
32
+
33
+
34
+ def education_outline_user(req: EducationPptxInput) -> str:
35
+ return (
36
+ f"Topic: {req.topic}\n"
37
+ f"Grade level: {req.grade}\n"
38
+ f"Number of content slides: {req.slide_count}\n"
39
+ "Return JSON only."
40
+ )
41
+
42
+
43
+ def education_outline_repair(
44
+ invalid_output: str,
45
+ error: str,
46
+ *,
47
+ expected_slides: int | None = None,
48
+ ) -> str:
49
+ count_line = ""
50
+ if expected_slides is not None:
51
+ count_line = f"\nYou must include exactly {expected_slides} items in the slides array.\n"
52
+ return (
53
+ "The previous response was invalid JSON or did not match the schema.\n"
54
+ f"Validation error: {error}\n"
55
+ f"{count_line}"
56
+ f"Previous output:\n{invalid_output}\n\n"
57
+ "Return corrected JSON only, no explanation."
58
+ )
59
+
60
+
61
+ def outline_to_markdown(title: str, slides: list[dict]) -> str:
62
+ lines = [f"# {title}", ""]
63
+ for index, slide in enumerate(slides, start=1):
64
+ lines.append(f"## Slide {index}: {slide.get('title', 'Untitled')}")
65
+ for bullet in slide.get("bullets", []):
66
+ lines.append(f"- {bullet}")
67
+ note = slide.get("speaker_note", "")
68
+ if note:
69
+ lines.append(f"\n*Teacher note:* {note}")
70
+ lines.append("")
71
+ return "\n".join(lines).strip()
72
+
73
+
74
+ def outline_json_example(slide_count: int) -> str:
75
+ example = {
76
+ "title": "Example Lesson",
77
+ "slides": [
78
+ {
79
+ "title": f"Slide {i}",
80
+ "bullets": ["Key point A", "Key point B"],
81
+ "speaker_note": "Brief teaching tip.",
82
+ }
83
+ for i in range(1, slide_count + 1)
84
+ ],
85
+ }
86
+ return json.dumps(example, indent=2)
libs/agent/src/agent/runner.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from inference.base import InferenceBackend
9
+
10
+ from agent.models import EducationPptxInput, SlideOutline, SlideSpec
11
+ from agent.preview import outline_to_html, render_slide_images
12
+ from agent.prompts import (
13
+ education_outline_repair,
14
+ education_outline_system,
15
+ education_outline_user,
16
+ outline_to_markdown,
17
+ )
18
+ from agent.skills import SkillRegistry
19
+ from agent.tools.docx import create_docx, create_html_export
20
+ from agent.tools_registry import ToolRegistry
21
+ from agent.trace import TraceRecorder
22
+
23
+ EDUCATION_PPTX_SKILL = "education-pptx"
24
+
25
+
26
+ @dataclass
27
+ class AgentResult:
28
+ markdown_preview: str
29
+ html_preview: str
30
+ preview_images: list[str]
31
+ pptx_path: str
32
+ docx_path: str
33
+ html_export_path: str
34
+ trace: TraceRecorder
35
+ trace_path: str
36
+ outline: SlideOutline
37
+
38
+
39
+ class AgentRunner:
40
+ def __init__(
41
+ self,
42
+ skills: SkillRegistry | None = None,
43
+ tools: ToolRegistry | None = None,
44
+ ) -> None:
45
+ self._skills = skills or SkillRegistry()
46
+ self._tools = tools or ToolRegistry()
47
+
48
+ def run_education_pptx(
49
+ self,
50
+ *,
51
+ topic: str,
52
+ grade: str,
53
+ slide_count: int,
54
+ model_key: str,
55
+ backend: InferenceBackend,
56
+ ) -> AgentResult:
57
+ skill = self._skills.get(EDUCATION_PPTX_SKILL)
58
+ req = EducationPptxInput(topic=topic.strip(), grade=grade, slide_count=slide_count)
59
+
60
+ trace = TraceRecorder(
61
+ skill=skill.name,
62
+ model=model_key,
63
+ user_input=req.model_dump(),
64
+ )
65
+
66
+ backend.load()
67
+ outline = self._generate_outline(skill, req, backend, trace)
68
+ tool = self._tools.get("create_pptx")
69
+ pptx_path = tool.handler(outline, run_id=trace.run_id)
70
+ trace.log_tool(
71
+ "create_pptx",
72
+ {"title": outline.title, "slide_count": len(outline.slides)},
73
+ pptx_path,
74
+ )
75
+
76
+ docx_path = create_docx(outline, run_id=trace.run_id)
77
+ trace.log_tool(
78
+ "create_docx",
79
+ {"title": outline.title, "slide_count": len(outline.slides)},
80
+ str(docx_path),
81
+ )
82
+
83
+ html_export_path = create_html_export(outline, run_id=trace.run_id)
84
+ trace.log_tool(
85
+ "create_html_export",
86
+ {"title": outline.title},
87
+ str(html_export_path),
88
+ )
89
+
90
+ trace.set_artifact(pptx_path)
91
+
92
+ slides_dicts = [s.model_dump() for s in outline.slides]
93
+ markdown = outline_to_markdown(outline.title, slides_dicts)
94
+ html_preview = outline_to_html(outline)
95
+ preview_images = [str(p) for p in render_slide_images(outline, trace.run_id)]
96
+ trace_path = trace.save()
97
+
98
+ return AgentResult(
99
+ markdown_preview=markdown,
100
+ html_preview=html_preview,
101
+ preview_images=preview_images,
102
+ pptx_path=pptx_path,
103
+ docx_path=str(docx_path),
104
+ html_export_path=str(html_export_path),
105
+ trace=trace,
106
+ trace_path=str(trace_path),
107
+ outline=outline,
108
+ )
109
+
110
+ def _generate_outline(
111
+ self,
112
+ skill: Any,
113
+ req: EducationPptxInput,
114
+ backend: InferenceBackend,
115
+ trace: TraceRecorder,
116
+ ) -> SlideOutline:
117
+ system = education_outline_system(skill.body)
118
+ user = education_outline_user(req)
119
+ messages = [
120
+ {"role": "system", "content": system},
121
+ {"role": "user", "content": user},
122
+ ]
123
+ prompt_text = system + "\n\n" + user
124
+ raw = backend.chat(messages, max_tokens=2048, temperature=0.3)
125
+ trace.log_llm(prompt_text, raw)
126
+
127
+ try:
128
+ return self._parse_outline(raw, req.slide_count, trace)
129
+ except (json.JSONDecodeError, ValueError) as first_error:
130
+ repair_messages = messages + [
131
+ {"role": "assistant", "content": raw},
132
+ {
133
+ "role": "user",
134
+ "content": education_outline_repair(
135
+ raw, str(first_error), expected_slides=req.slide_count
136
+ ),
137
+ },
138
+ ]
139
+ repair_prompt = education_outline_repair(
140
+ raw, str(first_error), expected_slides=req.slide_count
141
+ )
142
+ repaired = backend.chat(repair_messages, max_tokens=2048, temperature=0.1)
143
+ trace.log_llm(repair_prompt, repaired)
144
+ return self._parse_outline(repaired, req.slide_count, trace)
145
+
146
+ def _parse_outline(
147
+ self,
148
+ raw: str,
149
+ expected_slides: int,
150
+ trace: TraceRecorder | None = None,
151
+ ) -> SlideOutline:
152
+ data = self._sanitize_outline_data(self._extract_json(raw))
153
+ outline = SlideOutline.model_validate(data)
154
+ original_count = len(outline.slides)
155
+ outline = self._normalize_slide_count(outline, expected_slides)
156
+ if trace and original_count != expected_slides:
157
+ trace.log_note(
158
+ "Adjusted slide count to match request",
159
+ requested=expected_slides,
160
+ model_returned=original_count,
161
+ final=len(outline.slides),
162
+ )
163
+ return outline
164
+
165
+ @staticmethod
166
+ def _sanitize_outline_data(data: dict[str, Any]) -> dict[str, Any]:
167
+ title = str(data.get("title") or "Lesson").strip() or "Lesson"
168
+ slides_in = data.get("slides") or []
169
+ slides_out: list[dict[str, Any]] = []
170
+ for index, slide in enumerate(slides_in):
171
+ if not isinstance(slide, dict):
172
+ continue
173
+ slide_title = str(slide.get("title") or f"Slide {index + 1}").strip()
174
+ bullets_raw = slide.get("bullets") or []
175
+ if isinstance(bullets_raw, str):
176
+ bullets_raw = [bullets_raw]
177
+ bullets = [str(b).strip() for b in bullets_raw if str(b).strip()]
178
+ if not bullets:
179
+ bullets = ["Discuss this topic with the class"]
180
+ slides_out.append(
181
+ {
182
+ "title": slide_title or f"Slide {index + 1}",
183
+ "bullets": bullets,
184
+ "speaker_note": str(slide.get("speaker_note") or ""),
185
+ }
186
+ )
187
+ if not slides_out:
188
+ slides_out.append(
189
+ {
190
+ "title": "Introduction",
191
+ "bullets": ["Overview of the topic", "Why it matters"],
192
+ "speaker_note": "",
193
+ }
194
+ )
195
+ return {"title": title, "slides": slides_out}
196
+
197
+ @staticmethod
198
+ def _normalize_slide_count(outline: SlideOutline, expected: int) -> SlideOutline:
199
+ slides = list(outline.slides)
200
+ if len(slides) > expected:
201
+ slides = slides[:expected]
202
+ while len(slides) < expected:
203
+ number = len(slides) + 1
204
+ slides.append(
205
+ SlideSpec(
206
+ title=f"More about {outline.title}",
207
+ bullets=[
208
+ "Key idea to expand in class",
209
+ "Question for students",
210
+ ],
211
+ speaker_note="Add details for this slide during the lesson.",
212
+ )
213
+ )
214
+ return SlideOutline(title=outline.title, slides=slides)
215
+
216
+ @staticmethod
217
+ def _extract_json(text: str) -> dict[str, Any]:
218
+ cleaned = text.strip()
219
+ fence = re.search(r"```(?:json)?\s*(\{.*\})\s*```", cleaned, re.DOTALL)
220
+ if fence:
221
+ cleaned = fence.group(1)
222
+ else:
223
+ start = cleaned.find("{")
224
+ end = cleaned.rfind("}")
225
+ if start >= 0 and end > start:
226
+ cleaned = cleaned[start : end + 1]
227
+ return json.loads(cleaned)
libs/agent/src/agent/skills.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Skill:
13
+ name: str
14
+ description: str
15
+ task: str
16
+ tools: list[str]
17
+ model_hints: list[str]
18
+ body: str
19
+ path: Path
20
+
21
+
22
+ def _find_skills_root() -> Path | None:
23
+ import os
24
+
25
+ env_path = os.environ.get("SKILLS_DIR")
26
+ if env_path:
27
+ root = Path(env_path)
28
+ if root.is_dir():
29
+ return root.resolve()
30
+
31
+ for base in (Path.cwd(), *Path.cwd().parents):
32
+ candidate = base / "skills"
33
+ if candidate.is_dir():
34
+ return candidate.resolve()
35
+ return None
36
+
37
+
38
+ def _parse_skill_md(path: Path) -> Skill:
39
+ text = path.read_text()
40
+ match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL)
41
+ if not match:
42
+ raise ValueError(f"{path}: expected YAML frontmatter delimited by ---")
43
+
44
+ meta: dict[str, Any] = yaml.safe_load(match.group(1)) or {}
45
+ body = match.group(2).strip()
46
+
47
+ return Skill(
48
+ name=str(meta.get("name", path.parent.name)),
49
+ description=str(meta.get("description", "")),
50
+ task=str(meta.get("task", "")),
51
+ tools=[str(t) for t in meta.get("tools", [])],
52
+ model_hints=[str(m) for m in meta.get("model_hints", [])],
53
+ body=body,
54
+ path=path,
55
+ )
56
+
57
+
58
+ class SkillRegistry:
59
+ def __init__(self, skills_root: Path | None = None) -> None:
60
+ self._root = skills_root or _find_skills_root()
61
+ self._skills: dict[str, Skill] = {}
62
+ if self._root is not None:
63
+ self._load_all()
64
+
65
+ @property
66
+ def root(self) -> Path | None:
67
+ return self._root
68
+
69
+ def _load_all(self) -> None:
70
+ assert self._root is not None
71
+ for skill_md in sorted(self._root.glob("*/SKILL.md")):
72
+ skill = _parse_skill_md(skill_md)
73
+ self._skills[skill.name] = skill
74
+
75
+ def get(self, name: str) -> Skill:
76
+ if name not in self._skills:
77
+ known = ", ".join(sorted(self._skills)) or "(none found)"
78
+ raise KeyError(f"Unknown skill {name!r}. Known: {known}")
79
+ return self._skills[name]
80
+
81
+ def list_by_task(self, task: str) -> list[Skill]:
82
+ return [s for s in self._skills.values() if s.task == task]
83
+
84
+ def all(self) -> list[Skill]:
85
+ return list(self._skills.values())
libs/agent/src/agent/tools/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from agent.tools.docx import create_docx, create_html_export
2
+ from agent.tools.pptx import create_pptx
3
+
4
+ __all__ = ["create_docx", "create_html_export", "create_pptx"]
libs/agent/src/agent/tools/docx.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import uuid
5
+ from pathlib import Path
6
+
7
+ from docx import Document
8
+ from docx.shared import Pt
9
+
10
+ from agent.models import SlideOutline
11
+ from agent.tools.pptx import _outputs_dir, _safe_filename
12
+
13
+
14
+ def create_docx(outline: SlideOutline, run_id: str | None = None) -> Path:
15
+ """Build a Word document from a slide outline (opens in Google Docs when uploaded)."""
16
+ rid = run_id or uuid.uuid4().hex[:12]
17
+ out_dir = _outputs_dir()
18
+ filename = f"{_safe_filename(outline.title)}_{rid}.docx"
19
+ path = out_dir / filename
20
+
21
+ doc = Document()
22
+ title = doc.add_heading(outline.title, level=0)
23
+ title.runs[0].font.size = Pt(28)
24
+ doc.add_paragraph("Generated lesson slides").italic = True
25
+ doc.add_page_break()
26
+
27
+ for index, slide in enumerate(outline.slides, start=1):
28
+ heading = doc.add_heading(f"Slide {index}: {slide.title}", level=1)
29
+ heading.runs[0].font.size = Pt(22)
30
+ for bullet in slide.bullets:
31
+ para = doc.add_paragraph(bullet, style="List Bullet")
32
+ para.runs[0].font.size = Pt(14)
33
+ if slide.speaker_note:
34
+ note = doc.add_paragraph(f"Teacher note: {slide.speaker_note}")
35
+ note.runs[0].italic = True
36
+ note.runs[0].font.size = Pt(11)
37
+ if index < len(outline.slides):
38
+ doc.add_page_break()
39
+
40
+ doc.save(str(path))
41
+ return path
42
+
43
+
44
+ def create_html_export(outline: SlideOutline, run_id: str | None = None) -> Path:
45
+ """Standalone HTML file — import into Google Docs via File → Open → Upload."""
46
+ from agent.preview import outline_to_html
47
+
48
+ rid = run_id or uuid.uuid4().hex[:12]
49
+ out_dir = _outputs_dir()
50
+ filename = f"{_safe_filename(outline.title)}_{rid}.html"
51
+ path = out_dir / filename
52
+
53
+ body = outline_to_html(outline)
54
+ full = f"""<!DOCTYPE html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8" />
58
+ <title>{_escape_html(outline.title)}</title>
59
+ </head>
60
+ <body>
61
+ {body}
62
+ </body>
63
+ </html>
64
+ """
65
+ path.write_text(full)
66
+ return path
67
+
68
+
69
+ def _escape_html(text: str) -> str:
70
+ return (
71
+ text.replace("&", "&amp;")
72
+ .replace("<", "&lt;")
73
+ .replace(">", "&gt;")
74
+ .replace('"', "&quot;")
75
+ )
libs/agent/src/agent/tools/pptx.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import uuid
5
+ from pathlib import Path
6
+
7
+ from pptx import Presentation
8
+ from pptx.util import Inches, Pt
9
+
10
+ from agent.models import SlideOutline
11
+
12
+
13
+ def get_outputs_dir() -> Path:
14
+ """Directory for generated artifacts (pptx, docx, preview images)."""
15
+ return _outputs_dir()
16
+
17
+
18
+ def _outputs_dir() -> Path:
19
+ import os
20
+ import tempfile
21
+
22
+ env = os.environ.get("AGENT_OUTPUTS_DIR")
23
+ if env:
24
+ path = Path(env)
25
+ else:
26
+ path = Path(tempfile.gettempdir()) / "agent_outputs"
27
+ path.mkdir(parents=True, exist_ok=True)
28
+ return path
29
+
30
+
31
+ def _safe_filename(title: str) -> str:
32
+ slug = re.sub(r"[^a-zA-Z0-9]+", "_", title.strip().lower()).strip("_")
33
+ return slug[:60] or "lesson"
34
+
35
+
36
+ def create_pptx(outline: SlideOutline, run_id: str | None = None) -> Path:
37
+ """Build a .pptx from a validated slide outline."""
38
+ rid = run_id or uuid.uuid4().hex[:12]
39
+ out_dir = _outputs_dir()
40
+ filename = f"{_safe_filename(outline.title)}_{rid}.pptx"
41
+ path = out_dir / filename
42
+
43
+ prs = Presentation()
44
+ prs.slide_width = Inches(10)
45
+ prs.slide_height = Inches(7.5)
46
+
47
+ title_layout = prs.slide_layouts[0]
48
+ title_slide = prs.slides.add_slide(title_layout)
49
+ title_slide.shapes.title.text = outline.title
50
+ if title_slide.placeholders[1].text_frame:
51
+ title_slide.placeholders[1].text = "Generated lesson slides"
52
+
53
+ bullet_layout = prs.slide_layouts[1]
54
+ for slide_spec in outline.slides:
55
+ slide = prs.slides.add_slide(bullet_layout)
56
+ slide.shapes.title.text = slide_spec.title
57
+ body = slide.placeholders[1].text_frame
58
+ body.clear()
59
+ for index, bullet in enumerate(slide_spec.bullets):
60
+ if index == 0:
61
+ p = body.paragraphs[0]
62
+ else:
63
+ p = body.add_paragraph()
64
+ p.text = bullet
65
+ p.level = 0
66
+ p.font.size = Pt(20)
67
+ if slide_spec.speaker_note:
68
+ notes = slide.notes_slide.notes_text_frame
69
+ notes.text = slide_spec.speaker_note
70
+
71
+ prs.save(str(path))
72
+ return path
libs/agent/src/agent/tools_registry.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from agent.models import SlideOutline
8
+ from agent.tools.pptx import create_pptx
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ToolSpec:
13
+ name: str
14
+ description: str
15
+ handler: Callable[..., Any]
16
+
17
+
18
+ class ToolRegistry:
19
+ def __init__(self) -> None:
20
+ self._tools: dict[str, ToolSpec] = {}
21
+ self.register(
22
+ "create_pptx",
23
+ "Create a PowerPoint file from a validated SlideOutline",
24
+ self._handle_create_pptx,
25
+ )
26
+
27
+ def register(self, name: str, description: str, handler: Callable[..., Any]) -> None:
28
+ self._tools[name] = ToolSpec(name=name, description=description, handler=handler)
29
+
30
+ def get(self, name: str) -> ToolSpec:
31
+ if name not in self._tools:
32
+ raise KeyError(f"Unknown tool {name!r}")
33
+ return self._tools[name]
34
+
35
+ def _handle_create_pptx(self, outline: SlideOutline, run_id: str | None = None) -> str:
36
+ path = create_pptx(outline, run_id=run_id)
37
+ return str(path)
libs/agent/src/agent/trace.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def _prompt_hash(text: str) -> str:
13
+ return hashlib.sha256(text.encode()).hexdigest()[:16]
14
+
15
+
16
+ @dataclass
17
+ class TraceRecorder:
18
+ skill: str
19
+ model: str
20
+ user_input: dict[str, Any]
21
+ run_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
22
+ steps: list[dict[str, Any]] = field(default_factory=list)
23
+ artifact: str | None = None
24
+ created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
25
+
26
+ def log_llm(self, prompt: str, output: str) -> None:
27
+ self.steps.append(
28
+ {
29
+ "type": "llm",
30
+ "prompt_hash": _prompt_hash(prompt),
31
+ "output": output,
32
+ }
33
+ )
34
+
35
+ def log_note(self, message: str, **details: Any) -> None:
36
+ self.steps.append({"type": "note", "message": message, **details})
37
+
38
+ def log_tool(self, name: str, arguments: dict[str, Any], result: str) -> None:
39
+ self.steps.append(
40
+ {
41
+ "type": "tool",
42
+ "name": name,
43
+ "arguments": arguments,
44
+ "result": result,
45
+ }
46
+ )
47
+
48
+ def set_artifact(self, path: str) -> None:
49
+ self.artifact = path
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ return {
53
+ "run_id": self.run_id,
54
+ "skill": self.skill,
55
+ "model": self.model,
56
+ "input": self.user_input,
57
+ "steps": self.steps,
58
+ "artifact": self.artifact,
59
+ "created_at": self.created_at,
60
+ }
61
+
62
+ def to_json(self, *, indent: int = 2) -> str:
63
+ return json.dumps(self.to_dict(), indent=indent)
64
+
65
+ def save(self, traces_dir: Path | None = None) -> Path:
66
+ base = traces_dir or _default_traces_dir()
67
+ base.mkdir(parents=True, exist_ok=True)
68
+ path = base / f"{self.run_id}.json"
69
+ path.write_text(self.to_json())
70
+ return path
71
+
72
+
73
+ def _default_traces_dir() -> Path:
74
+ env = __import__("os").environ.get("AGENT_TRACES_DIR")
75
+ if env:
76
+ return Path(env)
77
+ for base in (Path.cwd(), *Path.cwd().parents):
78
+ candidate = base / "outputs" / "traces"
79
+ if (base / "models.yaml").is_file() or (base / "pyproject.toml").is_file():
80
+ return candidate
81
+ return Path.cwd() / "outputs" / "traces"
libs/agent/tests/test_runner.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agent.models import SlideOutline, SlideSpec
2
+ from agent.preview import outline_to_html, render_slide_images
3
+ from agent.runner import AgentRunner
4
+ from agent.tools.docx import create_docx, create_html_export
5
+ from agent.tools.pptx import create_pptx
6
+
7
+
8
+ def test_parse_outline_pads_when_model_returns_too_few():
9
+ runner = AgentRunner()
10
+ raw = (
11
+ '{"title": "AI Agents", "slides": ['
12
+ '{"title": "Intro", "bullets": ["What is an agent?"]},'
13
+ '{"title": "Uses", "bullets": ["Automation"]}'
14
+ "]}"
15
+ )
16
+ outline = runner._parse_outline(raw, expected_slides=5)
17
+ assert len(outline.slides) == 5
18
+ assert outline.title == "AI Agents"
19
+
20
+
21
+ def test_parse_outline_trims_when_model_returns_too_many():
22
+ runner = AgentRunner()
23
+ raw = (
24
+ '{"title": "Topic", "slides": ['
25
+ '{"title": "A", "bullets": ["a"]},'
26
+ '{"title": "B", "bullets": ["b"]},'
27
+ '{"title": "C", "bullets": ["c"]},'
28
+ '{"title": "D", "bullets": ["d"]}'
29
+ "]}"
30
+ )
31
+ outline = runner._parse_outline(raw, expected_slides=3)
32
+ assert len(outline.slides) == 3
33
+
34
+
35
+ def test_extract_json_from_fenced_block():
36
+ raw = '```json\n{"title": "T", "slides": [{"title": "S", "bullets": ["a"]}]}\n```'
37
+ data = AgentRunner._extract_json(raw)
38
+ assert data["title"] == "T"
39
+
40
+
41
+ def test_create_pptx_writes_file(tmp_path, monkeypatch):
42
+ monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
43
+ outline = SlideOutline(
44
+ title="Photosynthesis",
45
+ slides=[
46
+ SlideSpec(title="What is it?", bullets=["Plants make food", "Uses sunlight"]),
47
+ SlideSpec(title="Why it matters", bullets=["Oxygen", "Food chain"]),
48
+ ],
49
+ )
50
+ path = create_pptx(outline, run_id="test")
51
+ assert path.exists()
52
+ assert path.suffix == ".pptx"
53
+
54
+
55
+ def test_create_docx_writes_file(tmp_path, monkeypatch):
56
+ monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
57
+ outline = SlideOutline(
58
+ title="Photosynthesis",
59
+ slides=[SlideSpec(title="Intro", bullets=["Sunlight", "Chlorophyll"])],
60
+ )
61
+ path = create_docx(outline, run_id="test")
62
+ assert path.exists()
63
+ assert path.suffix == ".docx"
64
+
65
+
66
+ def test_outline_preview_and_images(tmp_path, monkeypatch):
67
+ monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
68
+ outline = SlideOutline(
69
+ title="Water Cycle",
70
+ slides=[SlideSpec(title="Evaporation", bullets=["Heat", "Vapor"])],
71
+ )
72
+ html = outline_to_html(outline)
73
+ assert "Water Cycle" in html
74
+ assert "Evaporation" in html
75
+ images = render_slide_images(outline, run_id="prev")
76
+ assert len(images) == 2
77
+ assert all(p.exists() for p in images)
78
+
79
+
80
+ def test_create_html_export(tmp_path, monkeypatch):
81
+ monkeypatch.setenv("AGENT_OUTPUTS_DIR", str(tmp_path))
82
+ outline = SlideOutline(
83
+ title="Fractions",
84
+ slides=[SlideSpec(title="Parts", bullets=["Half", "Quarter"])],
85
+ )
86
+ path = create_html_export(outline, run_id="html")
87
+ assert path.exists()
88
+ assert "Fractions" in path.read_text()
models.yaml CHANGED
@@ -3,6 +3,9 @@
3
 
4
  defaults:
5
  active_model: minicpm-v-4.6
 
 
 
6
  # Dev: set ALLOW_MODEL_SWITCH=true in .env to expose a dropdown in Gradio.
7
  # Space: keep false so visitors use one pinned model.
8
  allow_model_switch: false
 
3
 
4
  defaults:
5
  active_model: minicpm-v-4.6
6
+
7
+ # active_model: minicpm5-1b
8
+
9
  # Dev: set ALLOW_MODEL_SWITCH=true in .env to expose a dropdown in Gradio.
10
  # Space: keep false so visitors use one pinned model.
11
  allow_model_switch: false
outputs/traces/5ffe463cd9ff.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run_id": "5ffe463cd9ff",
3
+ "skill": "education-pptx",
4
+ "model": "minicpm5-1b",
5
+ "input": {
6
+ "topic": "ai agent",
7
+ "grade": "6",
8
+ "slide_count": 5
9
+ },
10
+ "steps": [
11
+ {
12
+ "type": "llm",
13
+ "prompt_hash": "92e1b034dc5f5dca",
14
+ "output": "<think>\nThe user wants a lesson plan for teaching AI agent to grade level 6 students. I need to create a JSON outline with 5 slides. The topic is \"AI Agent\", grade level is 6, and content slides are 5.\n\nI'll structure the JSON with a title and then 5 slides. Each slide should have a title, bullets (short factual statements), and optionally a speaker_note. I'll make sure to keep it simple and age-appropriate for 6th graders.\n\nLet me outline the content:\n\n1. Introduction to AI Agent\n2. What is an AI Agent?\n3. Key features of an AI Agent\n4. How AI Agents work\n5. Real-world applications\n\nI'll make sure each slide has 2-3 bullets for brevity. The speaker_note can be brief if helpful.\n\nNow, I'll generate the JSON accordingly.\n</think>\n\n{\n \"title\": \"AI Agent: Teaching a 6th Grade\",\n \"slides\": [\n {\n \"title\": \"Introduction to AI Agent\",\n \"bullets\": [\n \"An AI Agent is a computer program that can perform tasks for you.\",\n \"It can learn from experience and improve over time.\",\n \"It can be used for many different tasks.\"\n ],\n \"speaker_note\": \"AI agents are like helpful digital assistants that can help with homework, answering questions, and more.\"\n },\n {\n \"title\": \"What is an AI Agent?\",\n \"bullets\": [\n \"An AI Agent is a program that can learn from experience and improve over time.\",\n \"It can be used for many different tasks, such as answering questions, helping with homework, and more.\"\n ],\n \"speaker_note\": \"Think of it as a smart program that can learn from your interactions and become better over time.\"\n },\n {\n \"title\": \"Key Features of an AI Agent\",\n \"bullets\": [\n \"It can learn from experience and improve over time.\",\n \"It can be used for many different tasks, such as answering questions, helping with homework, and more.\"\n ],\n \"speaker_note\": \"AI agents are designed to be helpful and efficient in their tasks.\"\n },\n {\n \"title\": \"How AI Agents Work\",\n \"bullets\": [\n \"An AI Agent uses algorithms and data to make decisions.\",\n \"It learns from user interactions to become more accurate over time.\",\n \"It can be programmed to perform specific tasks.\"\n ],\n \"speaker_note\": \"AI agents use algorithms to make decisions based on data.\"\n },\n {\n \"title\": \"Real-World Applications\",\n \"bullets\": [\n \"AI agents can help with homework, answering questions, and more.\",\n \"They can be used in many different fields, like education, customer service, and more.\"\n ],\n \"speaker_note\": \"AI agents are used in many different fields, including education and customer service.\"\n }\n ]\n}"
15
+ },
16
+ {
17
+ "type": "tool",
18
+ "name": "create_pptx",
19
+ "arguments": {
20
+ "title": "AI Agent: Teaching a 6th Grade",
21
+ "slide_count": 5
22
+ },
23
+ "result": "/tmp/agent_outputs/ai_agent_teaching_a_6th_grade_5ffe463cd9ff.pptx"
24
+ }
25
+ ],
26
+ "artifact": "/tmp/agent_outputs/ai_agent_teaching_a_6th_grade_5ffe463cd9ff.pptx",
27
+ "created_at": "2026-06-11T12:15:49.294920+00:00"
28
+ }
pyproject.toml CHANGED
@@ -5,6 +5,7 @@ description = "Build Small Hackathon — Gradio Space with local llama.cpp infer
5
  readme = "README.md"
6
  requires-python = ">=3.12"
7
  dependencies = [
 
8
  "gradio-space",
9
  "inference",
10
  ]
@@ -22,5 +23,6 @@ members = [
22
  ]
23
 
24
  [tool.uv.sources]
 
25
  gradio-space = { workspace = true }
26
  inference = { workspace = true }
 
5
  readme = "README.md"
6
  requires-python = ">=3.12"
7
  dependencies = [
8
+ "agent",
9
  "gradio-space",
10
  "inference",
11
  ]
 
23
  ]
24
 
25
  [tool.uv.sources]
26
+ agent = { workspace = true }
27
  gradio-space = { workspace = true }
28
  inference = { workspace = true }
scripts/upload_trace.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Upload the latest agent trace JSON to a Hugging Face dataset repo."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from huggingface_hub import HfApi
12
+
13
+
14
+ def _latest_trace(traces_dir: Path) -> Path:
15
+ files = sorted(traces_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
16
+ if not files:
17
+ raise FileNotFoundError(f"No trace files in {traces_dir}")
18
+ return files[0]
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser(description="Upload agent trace to HF dataset")
23
+ parser.add_argument(
24
+ "--traces-dir",
25
+ type=Path,
26
+ default=Path(os.environ.get("AGENT_TRACES_DIR", "outputs/traces")),
27
+ )
28
+ parser.add_argument(
29
+ "--repo-id",
30
+ required=True,
31
+ help="HF dataset repo, e.g. username/build-small-agent-traces",
32
+ )
33
+ parser.add_argument("--trace", type=Path, default=None, help="Specific trace file")
34
+ args = parser.parse_args()
35
+
36
+ trace_path = args.trace or _latest_trace(args.traces_dir)
37
+ data = json.loads(trace_path.read_text())
38
+
39
+ api = HfApi()
40
+ api.create_repo(args.repo_id, repo_type="dataset", exist_ok=True)
41
+ api.upload_file(
42
+ path_or_fileobj=trace_path.read_bytes(),
43
+ path_in_repo=f"traces/{trace_path.name}",
44
+ repo_id=args.repo_id,
45
+ repo_type="dataset",
46
+ commit_message=f"Add agent trace {trace_path.stem}",
47
+ )
48
+
49
+ print(f"Uploaded {trace_path} -> {args.repo_id}/traces/{trace_path.name}")
50
+ print(f"Skill: {data.get('skill')} | Model: {data.get('model')} | Run: {data.get('run_id')}")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
skills/education-pptx/SKILL.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: education-pptx
3
+ description: Create a short lesson PowerPoint from a topic and grade level
4
+ task: education
5
+ tools:
6
+ - create_pptx
7
+ model_hints:
8
+ - minicpm5-1b
9
+ - qwen3b-gguf
10
+ ---
11
+
12
+ ## Workflow
13
+
14
+ 1. Ask for topic, audience grade, and slide count (3–8 content slides).
15
+ 2. Produce a JSON outline with `title` and `slides` (each slide has `title`, `bullets`, `speaker_note`).
16
+ 3. Call `create_pptx` with the validated outline.
17
+ 4. Return a download link and markdown preview for the teacher.
uv.lock CHANGED
@@ -15,6 +15,7 @@ resolution-markers = [
15
 
16
  [manifest]
17
  members = [
 
18
  "gradio-space",
19
  "inference",
20
  "small-model-hackathon",
@@ -38,6 +39,29 @@ wheels = [
38
  { url = "https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl", hash = "sha256:cf1a3efb96c18f7b152eb0fa7490f3710b19c3f395699358f08decca2b8b62e0", size = 383744, upload-time = "2026-03-04T19:34:10.313Z" },
39
  ]
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  [[package]]
42
  name = "annotated-doc"
43
  version = "0.0.4"
@@ -362,12 +386,14 @@ name = "gradio-space"
362
  version = "0.1.0"
363
  source = { editable = "apps/gradio-space" }
364
  dependencies = [
 
365
  { name = "gradio" },
366
  { name = "inference" },
367
  ]
368
 
369
  [package.metadata]
370
  requires-dist = [
 
371
  { name = "gradio", specifier = ">=5.0.0" },
372
  { name = "inference", editable = "libs/inference" },
373
  ]
@@ -551,6 +577,86 @@ dependencies = [
551
  ]
552
  sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/4cf485fccebb768cb07b54a497335127235852e2d9a50b219e754c7a4c8d/llama_cpp_python-0.3.26.tar.gz", hash = "sha256:71b004d52a274273ae03ec954d62771d26c86f32193fd30be57601508bec0064", size = 68011908, upload-time = "2026-06-05T02:14:43.27Z" }
553
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  [[package]]
555
  name = "markdown-it-py"
556
  version = "4.2.0"
@@ -1222,6 +1328,19 @@ wheels = [
1222
  { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1223
  ]
1224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1225
  [[package]]
1226
  name = "python-multipart"
1227
  version = "0.0.32"
@@ -1231,6 +1350,21 @@ wheels = [
1231
  { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
1232
  ]
1233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1234
  [[package]]
1235
  name = "pytz"
1236
  version = "2026.2"
@@ -1487,6 +1621,7 @@ name = "small-model-hackathon"
1487
  version = "0.1.0"
1488
  source = { virtual = "." }
1489
  dependencies = [
 
1490
  { name = "gradio-space" },
1491
  { name = "inference" },
1492
  ]
@@ -1499,6 +1634,7 @@ dev = [
1499
 
1500
  [package.metadata]
1501
  requires-dist = [
 
1502
  { name = "gradio-space", editable = "apps/gradio-space" },
1503
  { name = "inference", editable = "libs/inference" },
1504
  ]
@@ -1751,3 +1887,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069
1751
  wheels = [
1752
  { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
1753
  ]
 
 
 
 
 
 
 
 
 
 
15
 
16
  [manifest]
17
  members = [
18
+ "agent",
19
  "gradio-space",
20
  "inference",
21
  "small-model-hackathon",
 
39
  { url = "https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl", hash = "sha256:cf1a3efb96c18f7b152eb0fa7490f3710b19c3f395699358f08decca2b8b62e0", size = 383744, upload-time = "2026-03-04T19:34:10.313Z" },
40
  ]
41
 
42
+ [[package]]
43
+ name = "agent"
44
+ version = "0.1.0"
45
+ source = { editable = "libs/agent" }
46
+ dependencies = [
47
+ { name = "inference" },
48
+ { name = "pillow" },
49
+ { name = "pydantic" },
50
+ { name = "python-docx" },
51
+ { name = "python-pptx" },
52
+ { name = "pyyaml" },
53
+ ]
54
+
55
+ [package.metadata]
56
+ requires-dist = [
57
+ { name = "inference", editable = "libs/inference" },
58
+ { name = "pillow", specifier = ">=10.0.0" },
59
+ { name = "pydantic", specifier = ">=2.0.0" },
60
+ { name = "python-docx", specifier = ">=1.1.0" },
61
+ { name = "python-pptx", specifier = ">=1.0.0" },
62
+ { name = "pyyaml", specifier = ">=6.0.2" },
63
+ ]
64
+
65
  [[package]]
66
  name = "annotated-doc"
67
  version = "0.0.4"
 
386
  version = "0.1.0"
387
  source = { editable = "apps/gradio-space" }
388
  dependencies = [
389
+ { name = "agent" },
390
  { name = "gradio" },
391
  { name = "inference" },
392
  ]
393
 
394
  [package.metadata]
395
  requires-dist = [
396
+ { name = "agent", editable = "libs/agent" },
397
  { name = "gradio", specifier = ">=5.0.0" },
398
  { name = "inference", editable = "libs/inference" },
399
  ]
 
577
  ]
578
  sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/4cf485fccebb768cb07b54a497335127235852e2d9a50b219e754c7a4c8d/llama_cpp_python-0.3.26.tar.gz", hash = "sha256:71b004d52a274273ae03ec954d62771d26c86f32193fd30be57601508bec0064", size = 68011908, upload-time = "2026-06-05T02:14:43.27Z" }
579
 
580
+ [[package]]
581
+ name = "lxml"
582
+ version = "6.1.1"
583
+ source = { registry = "https://pypi.org/simple" }
584
+ sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
585
+ wheels = [
586
+ { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" },
587
+ { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" },
588
+ { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" },
589
+ { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" },
590
+ { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" },
591
+ { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" },
592
+ { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" },
593
+ { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" },
594
+ { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" },
595
+ { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" },
596
+ { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" },
597
+ { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" },
598
+ { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" },
599
+ { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" },
600
+ { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" },
601
+ { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" },
602
+ { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" },
603
+ { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" },
604
+ { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" },
605
+ { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" },
606
+ { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" },
607
+ { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" },
608
+ { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" },
609
+ { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" },
610
+ { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" },
611
+ { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" },
612
+ { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" },
613
+ { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" },
614
+ { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" },
615
+ { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" },
616
+ { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" },
617
+ { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" },
618
+ { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" },
619
+ { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" },
620
+ { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" },
621
+ { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
622
+ { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" },
623
+ { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" },
624
+ { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" },
625
+ { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" },
626
+ { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" },
627
+ { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" },
628
+ { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" },
629
+ { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" },
630
+ { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" },
631
+ { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" },
632
+ { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" },
633
+ { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" },
634
+ { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" },
635
+ { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" },
636
+ { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" },
637
+ { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" },
638
+ { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" },
639
+ { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" },
640
+ { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" },
641
+ { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" },
642
+ { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" },
643
+ { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" },
644
+ { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" },
645
+ { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" },
646
+ { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" },
647
+ { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" },
648
+ { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" },
649
+ { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" },
650
+ { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" },
651
+ { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" },
652
+ { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" },
653
+ { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" },
654
+ { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" },
655
+ { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" },
656
+ { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" },
657
+ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" },
658
+ ]
659
+
660
  [[package]]
661
  name = "markdown-it-py"
662
  version = "4.2.0"
 
1328
  { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1329
  ]
1330
 
1331
+ [[package]]
1332
+ name = "python-docx"
1333
+ version = "1.2.0"
1334
+ source = { registry = "https://pypi.org/simple" }
1335
+ dependencies = [
1336
+ { name = "lxml" },
1337
+ { name = "typing-extensions" },
1338
+ ]
1339
+ sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
1340
+ wheels = [
1341
+ { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
1342
+ ]
1343
+
1344
  [[package]]
1345
  name = "python-multipart"
1346
  version = "0.0.32"
 
1350
  { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
1351
  ]
1352
 
1353
+ [[package]]
1354
+ name = "python-pptx"
1355
+ version = "1.0.2"
1356
+ source = { registry = "https://pypi.org/simple" }
1357
+ dependencies = [
1358
+ { name = "lxml" },
1359
+ { name = "pillow" },
1360
+ { name = "typing-extensions" },
1361
+ { name = "xlsxwriter" },
1362
+ ]
1363
+ sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" }
1364
+ wheels = [
1365
+ { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
1366
+ ]
1367
+
1368
  [[package]]
1369
  name = "pytz"
1370
  version = "2026.2"
 
1621
  version = "0.1.0"
1622
  source = { virtual = "." }
1623
  dependencies = [
1624
+ { name = "agent" },
1625
  { name = "gradio-space" },
1626
  { name = "inference" },
1627
  ]
 
1634
 
1635
  [package.metadata]
1636
  requires-dist = [
1637
+ { name = "agent", editable = "libs/agent" },
1638
  { name = "gradio-space", editable = "apps/gradio-space" },
1639
  { name = "inference", editable = "libs/inference" },
1640
  ]
 
1887
  wheels = [
1888
  { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
1889
  ]
1890
+
1891
+ [[package]]
1892
+ name = "xlsxwriter"
1893
+ version = "3.2.9"
1894
+ source = { registry = "https://pypi.org/simple" }
1895
+ sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
1896
+ wheels = [
1897
+ { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
1898
+ ]