MSGEncrypted commited on
Commit
a822dca
·
1 Parent(s): 1706cd9

model loading + fix preview + tracing + pptx skills

Browse files
.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
apps/gradio-space/src/gradio_space/app.py CHANGED
@@ -2,7 +2,9 @@ import os
2
 
3
  import gradio as gr
4
 
 
5
  from gradio_space.tabs import build_chat_tab, build_education_pptx_tab
 
6
  from inference.config import get_app_config
7
 
8
  _app_config = get_app_config()
@@ -40,13 +42,13 @@ Part of the [Build Small Hackathon](https://huggingface.co/build-small-hackathon
40
  return demo
41
 
42
 
43
- demo = build_demo()
44
-
45
-
46
  def main() -> None:
 
 
47
  demo.launch(
48
  server_name="0.0.0.0",
49
  server_port=int(os.environ.get("PORT", "7860")),
 
50
  )
51
 
52
 
 
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()
 
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 CHANGED
@@ -69,10 +69,23 @@ def warmup(model_key: str | None = None) -> str:
69
  device_hint = runtime_device_hint(key)
70
  return (
71
  f"Preset `{key}` selected ({model.backend}, {device_hint}). "
72
- "Weights load on the first request."
73
  )
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def model_status(model_key: str) -> str:
77
  model = get_model_config(model_key)
78
  return f"**{model.label}**\n\n- Backend: `{model.backend}`\n- {warmup(model_key)}"
 
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)}"
apps/gradio-space/src/gradio_space/tabs/chat.py CHANGED
@@ -1,6 +1,6 @@
1
  import gradio as gr
2
 
3
- from gradio_space.model_loading import chat, model_status, warmup
4
  from inference.config import get_app_config
5
 
6
  _app_config = get_app_config()
 
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()
apps/gradio-space/src/gradio_space/tabs/education_pptx.py CHANGED
@@ -1,22 +1,37 @@
 
 
1
  import gradio as gr
2
 
3
  from agent.runner import AgentRunner
 
4
  from gradio_space.model_loading import ensure_model_loaded, get_active_model_key, model_status
5
  from inference.factory import get_backend
6
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  def generate_lesson_slides(
8
  topic: str,
9
  grade: str,
10
  slide_count: int,
11
- ) -> tuple[str, str, list[tuple[str, str]], str | None, str | None, str | None, str, str]:
12
  model_key = get_active_model_key()
13
  load_error = ensure_model_loaded(model_key)
14
  if load_error:
15
- return load_error, "", [], None, None, None, load_error, load_error
16
 
17
  if not topic.strip():
18
  message = "Please enter a lesson topic."
19
- return message, "", [], None, None, None, message, message
20
 
21
  try:
22
  runner = AgentRunner()
@@ -29,9 +44,9 @@ def generate_lesson_slides(
29
  )
30
  except Exception as exc: # noqa: BLE001 — show agent errors in UI
31
  message = f"Agent error: {exc}"
32
- return message, "", [], None, None, None, message, message
33
 
34
- gallery = [(path, f"Slide {i}") for i, path in enumerate(result.preview_images)]
35
  trace_summary = (
36
  f"Run `{result.trace.run_id}` · skill `{result.trace.skill}` · "
37
  f"model `{result.trace.model}`\n\n"
@@ -41,9 +56,9 @@ def generate_lesson_slides(
41
  result.markdown_preview,
42
  result.html_preview,
43
  gallery,
44
- result.pptx_path,
45
- result.docx_path,
46
- result.html_export_path,
47
  trace_summary,
48
  result.trace.to_json(),
49
  )
@@ -88,8 +103,9 @@ the agent then builds a downloadable PowerPoint — no cloud LLM API.
88
  slide_gallery = gr.Gallery(
89
  label="Slide thumbnails",
90
  columns=2,
91
- height="auto",
92
  object_fit="contain",
 
93
  )
94
  with gr.Tab("Outline"):
95
  outline_preview = gr.Markdown(label="Outline (markdown)")
@@ -137,3 +153,9 @@ then choose **Open with → Google Docs**. You can also upload the `.html` file
137
  trace_box,
138
  ],
139
  )
 
 
 
 
 
 
 
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("<", "&lt;")
14
+ .replace(">", "&gt;")
15
+ )
16
+ return (
17
+ f'<div style="padding:12px;border:1px solid #c44;border-radius:8px;'
18
+ f'background:#fff5f5;color:#8a1f1f;">{safe}</div>'
19
+ )
20
+
21
+
22
  def generate_lesson_slides(
23
  topic: str,
24
  grade: str,
25
  slide_count: int,
26
+ ) -> tuple[str, str, list[str], str | None, str | None, str | None, str, str]:
27
  model_key = get_active_model_key()
28
  load_error = ensure_model_loaded(model_key)
29
  if load_error:
30
+ return load_error, _error_html(load_error), [], None, None, None, load_error, load_error
31
 
32
  if not topic.strip():
33
  message = "Please enter a lesson topic."
34
+ return message, _error_html(message), [], None, None, None, message, message
35
 
36
  try:
37
  runner = AgentRunner()
 
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"
 
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
  )
 
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)")
 
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/src/agent/preview.py CHANGED
@@ -215,12 +215,17 @@ def _draw_slide_image(
215
 
216
  y = margin
217
  if is_title:
218
- draw.text((margin, height // 2 - 80), _wrap_text(title, 28), fill=fg, font=title_font)
 
 
 
219
  if subtitle:
220
  draw.text((margin, height // 2 + 40), subtitle, fill=accent, font=small_font)
221
  else:
222
- draw.text((margin, y), _wrap_text(title, 32), fill=fg, font=title_font)
223
- y += 90
 
 
224
  for bullet in bullets:
225
  line = _wrap_text(f"• {bullet}", 48)
226
  for part in line.split("\n"):
 
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"):
libs/agent/src/agent/prompts.py CHANGED
@@ -40,10 +40,19 @@ def education_outline_user(req: EducationPptxInput) -> str:
40
  )
41
 
42
 
43
- def education_outline_repair(invalid_output: str, error: str) -> str:
 
 
 
 
 
 
 
 
44
  return (
45
  "The previous response was invalid JSON or did not match the schema.\n"
46
  f"Validation error: {error}\n"
 
47
  f"Previous output:\n{invalid_output}\n\n"
48
  "Return corrected JSON only, no explanation."
49
  )
 
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
  )
libs/agent/src/agent/runner.py CHANGED
@@ -7,7 +7,7 @@ from typing import Any
7
 
8
  from inference.base import InferenceBackend
9
 
10
- from agent.models import EducationPptxInput, SlideOutline
11
  from agent.preview import outline_to_html, render_slide_images
12
  from agent.prompts import (
13
  education_outline_repair,
@@ -125,34 +125,94 @@ class AgentRunner:
125
  trace.log_llm(prompt_text, raw)
126
 
127
  try:
128
- return self._parse_outline(raw, req.slide_count)
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(raw, str(first_error)),
 
 
135
  },
136
  ]
 
 
 
137
  repaired = backend.chat(repair_messages, max_tokens=2048, temperature=0.1)
138
- trace.log_llm(education_outline_repair(raw, str(first_error)), repaired)
139
- return self._parse_outline(repaired, req.slide_count)
140
 
141
- def _parse_outline(self, raw: str, expected_slides: int) -> SlideOutline:
142
- data = self._extract_json(raw)
 
 
 
 
 
143
  outline = SlideOutline.model_validate(data)
144
- if len(outline.slides) != expected_slides:
145
- if len(outline.slides) > expected_slides:
146
- outline = SlideOutline(
147
- title=outline.title,
148
- slides=outline.slides[:expected_slides],
149
- )
150
- else:
151
- raise ValueError(
152
- f"Expected {expected_slides} slides, got {len(outline.slides)}"
153
- )
154
  return outline
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  @staticmethod
157
  def _extract_json(text: str) -> dict[str, Any]:
158
  cleaned = text.strip()
 
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,
 
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()
libs/agent/src/agent/tools/pptx.py CHANGED
@@ -10,6 +10,11 @@ from pptx.util import Inches, Pt
10
  from agent.models import SlideOutline
11
 
12
 
 
 
 
 
 
13
  def _outputs_dir() -> Path:
14
  import os
15
  import tempfile
 
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
libs/agent/src/agent/trace.py CHANGED
@@ -32,6 +32,9 @@ class TraceRecorder:
32
  }
33
  )
34
 
 
 
 
35
  def log_tool(self, name: str, arguments: dict[str, Any], result: str) -> None:
36
  self.steps.append(
37
  {
 
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
  {
libs/agent/tests/test_runner.py CHANGED
@@ -5,6 +5,33 @@ from agent.tools.docx import create_docx, create_html_export
5
  from agent.tools.pptx import create_pptx
6
 
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  def test_extract_json_from_fenced_block():
9
  raw = '```json\n{"title": "T", "slides": [{"title": "S", "bullets": ["a"]}]}\n```'
10
  data = AgentRunner._extract_json(raw)
 
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)
uv.lock CHANGED
@@ -45,7 +45,9 @@ version = "0.1.0"
45
  source = { editable = "libs/agent" }
46
  dependencies = [
47
  { name = "inference" },
 
48
  { name = "pydantic" },
 
49
  { name = "python-pptx" },
50
  { name = "pyyaml" },
51
  ]
@@ -53,7 +55,9 @@ dependencies = [
53
  [package.metadata]
54
  requires-dist = [
55
  { name = "inference", editable = "libs/inference" },
 
56
  { name = "pydantic", specifier = ">=2.0.0" },
 
57
  { name = "python-pptx", specifier = ">=1.0.0" },
58
  { name = "pyyaml", specifier = ">=6.0.2" },
59
  ]
@@ -1324,6 +1328,19 @@ wheels = [
1324
  { 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" },
1325
  ]
1326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1327
  [[package]]
1328
  name = "python-multipart"
1329
  version = "0.0.32"
 
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
  ]
 
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
  ]
 
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"