Spaces:
Sleeping
Sleeping
Merge pull request #3 from MSghais/feat/next_step_basics
Browse files- .cursor/plans/skill_agent_pptx_5413e3c2.plan.md +230 -0
- .env.example +18 -13
- .gitignore +2 -0
- Dockerfile +8 -1
- README.md +46 -50
- USAGE.md +13 -5
- apps/gradio-space/pyproject.toml +2 -0
- apps/gradio-space/src/gradio_space/app.py +16 -132
- apps/gradio-space/src/gradio_space/model_loading.py +114 -0
- apps/gradio-space/src/gradio_space/tabs/__init__.py +4 -0
- apps/gradio-space/src/gradio_space/tabs/chat.py +44 -0
- apps/gradio-space/src/gradio_space/tabs/education_pptx.py +161 -0
- libs/agent/README.md +17 -0
- libs/agent/pyproject.toml +24 -0
- libs/agent/src/agent/__init__.py +11 -0
- libs/agent/src/agent/models.py +20 -0
- libs/agent/src/agent/preview.py +254 -0
- libs/agent/src/agent/prompts.py +86 -0
- libs/agent/src/agent/runner.py +227 -0
- libs/agent/src/agent/skills.py +85 -0
- libs/agent/src/agent/tools/__init__.py +4 -0
- libs/agent/src/agent/tools/docx.py +75 -0
- libs/agent/src/agent/tools/pptx.py +72 -0
- libs/agent/src/agent/tools_registry.py +37 -0
- libs/agent/src/agent/trace.py +81 -0
- libs/agent/tests/test_runner.py +88 -0
- models.yaml +3 -0
- outputs/traces/5ffe463cd9ff.json +28 -0
- pyproject.toml +2 -0
- scripts/upload_trace.py +54 -0
- skills/education-pptx/SKILL.md +17 -0
- uv.lock +145 -0
.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=
|
| 3 |
# Dev: enable dropdown in Gradio. Space: leave false to pin one model for visitors.
|
| 4 |
-
ALLOW_MODEL_SWITCH=
|
| 5 |
# MODEL_PRESETS_PATH=./models.yaml
|
| 6 |
|
| 7 |
-
# ---
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
#
|
| 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:
|
| 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 |
-
#
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
| 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).
|
| 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 |
-
|
| 53 |
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
```text
|
| 57 |
-
apps/gradio-space/ # Gradio
|
| 58 |
-
libs/
|
| 59 |
-
|
|
|
|
| 60 |
```
|
| 61 |
|
| 62 |
-
##
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 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:
|
| 76 |
-
4.
|
| 77 |
|
| 78 |
```bash
|
| 79 |
-
# Optional local Docker smoke test
|
| 80 |
docker build -t hackathon-space .
|
| 81 |
-
docker run --rm -p 7860:7860 -e
|
| 82 |
```
|
| 83 |
|
| 84 |
## Hackathon checklist
|
| 85 |
|
| 86 |
-
-
|
| 87 |
- Space live under build-small-hackathon
|
| 88 |
-
- Demo video
|
| 89 |
- Social post published
|
| 90 |
-
- Submission
|
| 91 |
|
| 92 |
### Badge targets
|
| 93 |
|
| 94 |
-
- **
|
| 95 |
-
- **
|
| 96 |
-
- **
|
| 97 |
-
- **Sharing is Caring** —
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
##
|
| 100 |
|
| 101 |
```bash
|
| 102 |
-
uv
|
| 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
|
|
|
|
|
|
|
| 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
|
| 27 |
|
| 28 |
-
### 3. Pre-download the model (
|
| 29 |
|
| 30 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
-
from
|
|
|
|
|
|
|
| 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="
|
| 113 |
gr.Markdown(
|
| 114 |
f"""
|
| 115 |
-
#
|
| 116 |
|
| 117 |
-
Local
|
| 118 |
|
| 119 |
-
- **
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 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("&", "&")
|
| 13 |
+
.replace("<", "<")
|
| 14 |
+
.replace(">", ">")
|
| 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("&", "&")
|
| 72 |
+
.replace("<", "<")
|
| 73 |
+
.replace(">", ">")
|
| 74 |
+
.replace('"', """)
|
| 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 |
+
]
|