skamathramesh commited on
Commit
f4193f0
Β·
verified Β·
1 Parent(s): 98a986c

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. .gitattributes +3 -3
  2. app.py +311 -139
  3. blog-post.md +90 -0
.gitattributes CHANGED
@@ -33,6 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- examples/crochet.png filter=lfs diff=lfs merge=lfs -text
37
- examples/embroidery.jpeg filter=lfs diff=lfs merge=lfs -text
38
- examples/sew_keychain.jpeg filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
37
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
38
+ *.jpg filter=lfs diff=lfs merge=lfs -text
app.py CHANGED
@@ -14,10 +14,11 @@ import json
14
  import logging
15
  import os
16
  import tempfile
 
17
  import time
18
 
19
  import gradio as gr
20
- from huggingface_hub import hf_hub_download
21
  from PIL import Image
22
 
23
  from agents import cataloger, copywriter, pricer
@@ -28,11 +29,36 @@ from agents.pipeline import VISION_PROMPT
28
  logging.basicConfig(level=logging.INFO)
29
  logger = logging.getLogger(__name__)
30
 
31
- # Set HF token if available (for faster downloads)
32
  hf_token = os.getenv("HF_TOKEN")
 
 
 
 
33
  if hf_token:
34
  os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  CRAFT_TYPES = [
37
  "Crochet",
38
  "Embroidery",
@@ -192,15 +218,116 @@ CUSTOM_CSS = """
192
  border-radius: 0 8px 8px 0;
193
  }
194
 
195
- /* Status indicator */
196
- #status-text {
 
197
  text-align: center;
198
- padding: 0.75rem;
199
  }
200
 
201
- #status-text p {
202
- color: var(--craft-amber) !important;
203
- font-weight: 500 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  }
205
 
206
  /* Footer */
@@ -268,70 +395,6 @@ def get_client() -> LLMClient:
268
  return _llm_client
269
 
270
 
271
- def _format_summary(result: PipelineResult) -> str:
272
- """Build a combined summary view of all pipeline results."""
273
- parts = []
274
-
275
- # Title & description
276
- if result.copy_data:
277
- c = result.copy_data
278
- parts.append(f"## {c.title}\n")
279
- parts.append(f"*{c.short_desc}*\n")
280
-
281
- # Price highlight
282
- if result.pricing:
283
- p = result.pricing
284
- parts.append(
285
- f"### Suggested Price: ${p.suggested_price_min}"
286
- f" \u2013 ${p.suggested_price_max}\n"
287
- )
288
-
289
- parts.append("---\n")
290
-
291
- # Catalog snapshot
292
- if result.catalog:
293
- cat = result.catalog
294
- tags = " ".join(f"`{t}`" for t in cat.tags[:6])
295
- parts.append(
296
- f"**Category:** {cat.category} / {cat.sub_category} \n"
297
- f"**Materials:** {', '.join(cat.materials)} \n"
298
- f"**Colors:** {', '.join(cat.colors)} \n"
299
- f"**Complexity:** {cat.complexity}\n\n"
300
- f"{tags}\n"
301
- )
302
-
303
- parts.append("---\n")
304
-
305
- # Full description
306
- if result.copy_data:
307
- parts.append(f"**Description**\n\n{result.copy_data.long_desc}\n")
308
-
309
- parts.append("---\n")
310
-
311
- # Instagram captions
312
- if result.copy_data:
313
- parts.append("**Instagram Captions**\n")
314
- for i, cap in enumerate(result.copy_data.captions, 1):
315
- parts.append(f"{i}. {cap}\n")
316
-
317
- # Pricing reasoning
318
- if result.pricing and result.pricing.reasoning:
319
- parts.append(f"\n---\n\n**Pricing Rationale**\n\n{result.pricing.reasoning}\n")
320
- if result.pricing.cost_breakdown:
321
- parts.append(f"\n**Cost Breakdown:** {result.pricing.cost_breakdown}\n")
322
-
323
- # Timing
324
- if result.traces:
325
- agent_times = " \u2192 ".join(
326
- f"{t.agent_name} ({t.duration_ms}ms)" for t in result.traces
327
- )
328
- parts.append(
329
- f"\n---\n\n<small>Pipeline: {agent_times} "
330
- f"| Total: {result.total_duration_ms}ms</small>"
331
- )
332
-
333
- return "\n".join(parts)
334
-
335
 
336
  def _format_vision(result: PipelineResult) -> str:
337
  if not result.image_description:
@@ -463,20 +526,85 @@ def _build_export_text(result: PipelineResult) -> str:
463
  return "\n".join(lines)
464
 
465
 
466
- def _snapshot(result: PipelineResult, status: str):
467
- """Yield a snapshot of all outputs from current pipeline state."""
468
- summary = _format_summary(result) if result.image_description else ""
469
- summary = f"**{status}**\n\n---\n\n{summary}" if summary else f"**{status}**"
470
- return (
471
- summary,
472
- _format_vision(result),
473
- _format_catalog(result),
474
- _format_copy(result),
475
- _format_pricing(result),
476
- _format_traces(result),
477
- gr.skip(), # trace file β€” only on completion
478
- gr.skip(), # export file β€” only on completion
479
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
 
481
 
482
  def analyze_craft(
@@ -486,15 +614,22 @@ def analyze_craft(
486
  material_cost: str,
487
  time_hours: str,
488
  ):
489
- """Streaming generator: yields results as each agent completes."""
490
- empty = ("",) * 7 + (None, None)
 
 
 
 
 
 
 
491
 
492
  if image is None:
493
- yield ("Upload an image to get started.",) + ("",) * 5 + (None, None)
494
  return
495
 
496
  if not craft_type:
497
- yield ("Please select a craft type.",) + ("",) * 5 + (None, None)
498
  return
499
 
500
  cost = float(material_cost) if material_cost else None
@@ -502,8 +637,7 @@ def analyze_craft(
502
  notes = user_notes.strip() or None
503
 
504
  try:
505
- # Load model
506
- yield ("**Loading model...**",) + ("",) * 5 + (None, None)
507
  llm = get_client()
508
 
509
  start = time.monotonic()
@@ -511,7 +645,7 @@ def analyze_craft(
511
  result = PipelineResult(image_description="")
512
 
513
  # Stage 1: Vision
514
- yield _snapshot(result, "Analyzing image...")
515
  description, vision_ms = llm.describe_image(image, VISION_PROMPT)
516
  traces.append(AgentTrace(
517
  agent_name="vision", input_text="[image]",
@@ -521,9 +655,12 @@ def analyze_craft(
521
  result.image_description = description
522
  result.traces = list(traces)
523
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
524
- yield _snapshot(result, "Vision complete. Cataloging item...")
525
 
526
  # Stage 2: Cataloger
 
 
 
 
527
  try:
528
  catalog_result, catalog_trace = asyncio.run(
529
  cataloger.run(llm, description, craft_type.lower(), notes)
@@ -534,10 +671,10 @@ def analyze_craft(
534
  logger.warning("Cataloger failed: %s", e)
535
  result.traces = list(traces)
536
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
537
- yield _snapshot(result, "Catalog done. Writing copy...")
538
 
539
- # Stage 3: Copywriter (needs catalog β€” skip if catalog failed)
540
  if result.catalog:
 
541
  try:
542
  copy_result, copy_trace = asyncio.run(
543
  copywriter.run(llm, craft_type.lower(), result.catalog, notes)
@@ -548,10 +685,10 @@ def analyze_craft(
548
  logger.warning("Copywriter failed: %s", e)
549
  result.traces = list(traces)
550
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
551
- yield _snapshot(result, "Copy done. Calculating pricing...")
552
 
553
- # Stage 4: Pricer (needs catalog β€” skip if catalog failed)
554
  if result.catalog:
 
555
  try:
556
  price_result, price_trace = asyncio.run(
557
  pricer.run(llm, craft_type.lower(), result.catalog, cost, hours)
@@ -563,29 +700,65 @@ def analyze_craft(
563
  result.traces = list(traces)
564
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
565
 
566
- # Build export file
 
 
 
 
 
 
 
567
  export_text = _build_export_text(result)
568
  export_file = tempfile.NamedTemporaryFile(
569
  mode="w", suffix=".txt", prefix="craftpilot-listing-", delete=False
570
  )
571
  export_file.write(export_text)
572
  export_file.close()
 
 
573
 
574
- # Final yield with everything
575
- yield (
576
- _format_summary(result),
577
- _format_vision(result),
578
- _format_catalog(result),
579
- _format_copy(result),
580
- _format_pricing(result),
581
- _format_traces(result),
582
- _make_trace_file(result),
583
- export_file.name,
584
- )
585
 
586
  except Exception as e:
587
  logger.exception("Pipeline failed")
588
- yield (f"**Error:** {e}",) + ("",) * 5 + (None, None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
 
590
 
591
  def build_ui() -> gr.Blocks:
@@ -652,33 +825,33 @@ def build_ui() -> gr.Blocks:
652
  with gr.Column(scale=2, min_width=500, elem_id="output-panel"):
653
  with gr.Tabs():
654
  with gr.Tab("Summary"):
655
- summary_output = gr.Markdown(
656
- value="*Your results will appear here after analysis.*",
 
 
657
  elem_classes=["prose"],
658
  )
659
- with gr.Tab("Vision"):
660
  vision_output = gr.Markdown(elem_classes=["prose"])
661
- with gr.Tab("Catalog"):
662
  catalog_output = gr.Markdown(elem_classes=["prose"])
663
- with gr.Tab("Copy"):
664
  copy_output = gr.Markdown(elem_classes=["prose"])
665
- with gr.Tab("Pricing"):
 
 
 
 
 
666
  price_output = gr.Markdown(elem_classes=["prose"])
667
- with gr.Tab("Agent Traces"):
668
  trace_output = gr.Markdown(elem_classes=["prose"])
669
- trace_download = gr.File(
670
- label="Download Trace JSON",
671
- file_count="single",
672
- interactive=False,
673
  )
674
 
675
- with gr.Row():
676
- export_download = gr.File(
677
- label="Download Listing (Etsy/Instagram ready)",
678
- file_count="single",
679
- interactive=False,
680
- )
681
-
682
  analyze_btn.click(
683
  fn=analyze_craft,
684
  inputs=[
@@ -688,18 +861,17 @@ def build_ui() -> gr.Blocks:
688
  material_cost,
689
  time_hours,
690
  ],
691
- outputs=[
692
- summary_output,
693
- vision_output,
694
- catalog_output,
695
- copy_output,
696
- price_output,
697
- trace_output,
698
- trace_download,
699
- export_download,
700
- ],
701
  )
702
 
 
 
 
 
 
 
 
703
  # Footer
704
  gr.Markdown(
705
  "Built for the Build Small Hackathon 2026 \u00b7 Backyard AI track \n"
 
14
  import logging
15
  import os
16
  import tempfile
17
+ import threading
18
  import time
19
 
20
  import gradio as gr
21
+ from huggingface_hub import HfApi, hf_hub_download
22
  from PIL import Image
23
 
24
  from agents import cataloger, copywriter, pricer
 
29
  logging.basicConfig(level=logging.INFO)
30
  logger = logging.getLogger(__name__)
31
 
32
+ # HF token: env var > token.txt fallback
33
  hf_token = os.getenv("HF_TOKEN")
34
+ if not hf_token:
35
+ _token_path = os.path.join(os.path.dirname(__file__), "token.txt")
36
+ if os.path.exists(_token_path):
37
+ hf_token = open(_token_path).read().strip()
38
  if hf_token:
39
  os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token
40
 
41
+ TRACES_DATASET = "skamathramesh/craftpilot-traces"
42
+
43
+
44
+ def _upload_trace_async(trace_json: str):
45
+ """Upload trace JSON to HF dataset in background thread."""
46
+ def _upload():
47
+ try:
48
+ api = HfApi(token=hf_token)
49
+ filename = f"trace_{int(time.time())}_{os.getpid()}.json"
50
+ api.upload_file(
51
+ path_or_fileobj=trace_json.encode(),
52
+ path_in_repo=f"traces/{filename}",
53
+ repo_id=TRACES_DATASET,
54
+ repo_type="dataset",
55
+ )
56
+ logger.info("Trace uploaded: %s", filename)
57
+ except Exception as e:
58
+ logger.warning("Trace upload failed (non-fatal): %s", e)
59
+ if hf_token:
60
+ threading.Thread(target=_upload, daemon=True).start()
61
+
62
  CRAFT_TYPES = [
63
  "Crochet",
64
  "Embroidery",
 
218
  border-radius: 0 8px 8px 0;
219
  }
220
 
221
+ /* Pipeline progress stepper */
222
+ .pipeline-status {
223
+ padding: 2.5rem 1.5rem;
224
  text-align: center;
 
225
  }
226
 
227
+ .pipeline-status .status-label {
228
+ font-size: 1.15rem;
229
+ font-weight: 500;
230
+ color: var(--craft-amber);
231
+ margin-bottom: 1.5rem;
232
+ animation: pulse 1.8s ease-in-out infinite;
233
+ }
234
+
235
+ .pipeline-steps {
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ gap: 0;
240
+ margin: 0 auto;
241
+ max-width: 420px;
242
+ }
243
+
244
+ .pipeline-step {
245
+ display: flex;
246
+ flex-direction: column;
247
+ align-items: center;
248
+ flex: 1;
249
+ position: relative;
250
+ }
251
+
252
+ .step-dot {
253
+ width: 28px;
254
+ height: 28px;
255
+ border-radius: 50%;
256
+ background: var(--craft-linen);
257
+ border: 2px solid #d5ccc3;
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ font-size: 0.7rem;
262
+ font-weight: 600;
263
+ color: #b0a89e;
264
+ transition: all 0.3s ease;
265
+ z-index: 1;
266
+ }
267
+
268
+ .step-dot.done {
269
+ background: var(--craft-amber);
270
+ border-color: var(--craft-amber);
271
+ color: white;
272
+ }
273
+
274
+ .step-dot.active {
275
+ background: white;
276
+ border-color: var(--craft-amber);
277
+ color: var(--craft-amber);
278
+ box-shadow: 0 0 0 4px rgba(212, 131, 10, 0.15);
279
+ animation: pulse-ring 1.8s ease-in-out infinite;
280
+ }
281
+
282
+ .step-label {
283
+ font-size: 0.7rem;
284
+ color: #b0a89e;
285
+ margin-top: 0.4rem;
286
+ font-weight: 500;
287
+ white-space: nowrap;
288
+ }
289
+
290
+ .step-label.done, .step-label.active {
291
+ color: var(--craft-warm-gray);
292
+ }
293
+
294
+ .step-connector {
295
+ height: 2px;
296
+ flex: 1;
297
+ background: #d5ccc3;
298
+ margin: 0 -2px;
299
+ margin-bottom: 1.2rem;
300
+ }
301
+
302
+ .step-connector.done {
303
+ background: var(--craft-amber);
304
+ }
305
+
306
+ @keyframes pulse {
307
+ 0%, 100% { opacity: 1; }
308
+ 50% { opacity: 0.5; }
309
+ }
310
+
311
+ @keyframes pulse-ring {
312
+ 0%, 100% { box-shadow: 0 0 0 4px rgba(212, 131, 10, 0.15); }
313
+ 50% { box-shadow: 0 0 0 6px rgba(212, 131, 10, 0.08); }
314
+ }
315
+
316
+ /* Auto-cycling wave animation for loading state */
317
+ .pipeline-status.loading .step-dot {
318
+ background: white;
319
+ border-color: var(--craft-amber);
320
+ color: var(--craft-amber);
321
+ animation: dot-wave 2.4s ease-in-out infinite;
322
+ }
323
+
324
+ .pipeline-status.loading .step-connector {
325
+ background: linear-gradient(90deg, var(--craft-amber), #d5ccc3);
326
+ }
327
+
328
+ @keyframes dot-wave {
329
+ 0%, 100% { transform: scale(1); opacity: 0.5; }
330
+ 50% { transform: scale(1.2); opacity: 1; background: var(--craft-amber); color: white; }
331
  }
332
 
333
  /* Footer */
 
395
  return _llm_client
396
 
397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
 
399
  def _format_vision(result: PipelineResult) -> str:
400
  if not result.image_description:
 
526
  return "\n".join(lines)
527
 
528
 
529
+ def _status_html(active_step: int, label: str) -> str:
530
+ """Build an HTML progress stepper. active_step: 0-3 (or -1 for pre-pipeline)."""
531
+ steps = ["Vision", "Catalog", "Copy", "Pricing"]
532
+ parts = ['<div class="pipeline-status">']
533
+ parts.append(f'<div class="status-label">{label}</div>')
534
+ parts.append('<div class="pipeline-steps">')
535
+ for i, name in enumerate(steps):
536
+ if i > 0:
537
+ conn_cls = "step-connector done" if i <= active_step else "step-connector"
538
+ parts.append(f'<div class="{conn_cls}"></div>')
539
+ if i < active_step:
540
+ dot_cls, lbl_cls, icon = "step-dot done", "step-label done", "&#10003;"
541
+ elif i == active_step:
542
+ dot_cls, lbl_cls, icon = "step-dot active", "step-label active", str(i + 1)
543
+ else:
544
+ dot_cls, lbl_cls, icon = "step-dot", "step-label", str(i + 1)
545
+ parts.append(f'<div class="pipeline-step"><div class="{dot_cls}">{icon}</div>')
546
+ parts.append(f'<div class="{lbl_cls}">{name}</div></div>')
547
+ parts.append("</div></div>")
548
+ return "".join(parts)
549
+
550
+
551
+ def _summary_html(result: PipelineResult) -> str:
552
+ """Build an HTML summary for gr.HTML output."""
553
+ parts = []
554
+
555
+ if result.copy_data:
556
+ c = result.copy_data
557
+ parts.append(f"<h2>{c.title}</h2>")
558
+ parts.append(f"<p><em>{c.short_desc}</em></p>")
559
+
560
+ if result.pricing:
561
+ p = result.pricing
562
+ parts.append(f"<h3>Suggested Price: ${p.suggested_price_min} \u2013 ${p.suggested_price_max}</h3>")
563
+
564
+ parts.append("<hr>")
565
+
566
+ if result.catalog:
567
+ cat = result.catalog
568
+ tags = " ".join(f"<code>{t}</code>" for t in cat.tags[:6])
569
+ parts.append(
570
+ f"<p><strong>Category:</strong> {cat.category} / {cat.sub_category}<br>"
571
+ f"<strong>Materials:</strong> {', '.join(cat.materials)}<br>"
572
+ f"<strong>Colors:</strong> {', '.join(cat.colors)}<br>"
573
+ f"<strong>Complexity:</strong> {cat.complexity}</p>"
574
+ f"<p>{tags}</p>"
575
+ )
576
+
577
+ parts.append("<hr>")
578
+
579
+ if result.copy_data:
580
+ parts.append(f"<p><strong>Description</strong></p><p>{result.copy_data.long_desc}</p>")
581
+ parts.append("<hr>")
582
+ parts.append("<p><strong>Instagram Captions</strong></p><ol>")
583
+ for cap in result.copy_data.captions:
584
+ parts.append(f"<li>{cap}</li>")
585
+ parts.append("</ol>")
586
+
587
+ if result.pricing and result.pricing.reasoning:
588
+ parts.append(f"<hr><p><strong>Pricing Rationale</strong></p><p>{result.pricing.reasoning}</p>")
589
+ if result.pricing.cost_breakdown:
590
+ parts.append(f"<p><strong>Cost Breakdown:</strong> {result.pricing.cost_breakdown}</p>")
591
+
592
+ if result.traces:
593
+ agent_times = " \u2192 ".join(
594
+ f"{t.agent_name} ({t.duration_ms}ms)" for t in result.traces
595
+ )
596
+ parts.append(
597
+ f"<hr><p><small>Pipeline: {agent_times} "
598
+ f"| Total: {result.total_duration_ms}ms</small></p>"
599
+ )
600
+
601
+ return f'<div class="prose">{"".join(parts)}</div>'
602
+
603
+
604
+ # Stores the last pipeline result for tab-select handlers.
605
+ _last_result: PipelineResult | None = None
606
+ _last_trace_path: str | None = None
607
+ _last_export_path: str | None = None
608
 
609
 
610
  def analyze_craft(
 
614
  material_cost: str,
615
  time_hours: str,
616
  ):
617
+ """Streaming generator β†’ gr.HTML (single output).
618
+
619
+ Uses gr.HTML to bypass Svelte 5 i18n bug that crashes gr.Markdown
620
+ during streaming. Tab content populated via tab-select handlers.
621
+ """
622
+ global _last_result, _last_trace_path, _last_export_path
623
+ _last_result = None
624
+ _last_trace_path = None
625
+ _last_export_path = None
626
 
627
  if image is None:
628
+ yield "<p>Upload an image to get started.</p>"
629
  return
630
 
631
  if not craft_type:
632
+ yield "<p>Please select a craft type.</p>"
633
  return
634
 
635
  cost = float(material_cost) if material_cost else None
 
637
  notes = user_notes.strip() or None
638
 
639
  try:
640
+ yield _status_html(-1, "Loading model...")
 
641
  llm = get_client()
642
 
643
  start = time.monotonic()
 
645
  result = PipelineResult(image_description="")
646
 
647
  # Stage 1: Vision
648
+ yield _status_html(0, "Analyzing your craft...")
649
  description, vision_ms = llm.describe_image(image, VISION_PROMPT)
650
  traces.append(AgentTrace(
651
  agent_name="vision", input_text="[image]",
 
655
  result.image_description = description
656
  result.traces = list(traces)
657
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
 
658
 
659
  # Stage 2: Cataloger
660
+ yield (
661
+ _status_html(1, "Cataloging item...") +
662
+ f'<hr><p><strong>Vision</strong> ({vision_ms}ms)</p><p>{description}</p>'
663
+ )
664
  try:
665
  catalog_result, catalog_trace = asyncio.run(
666
  cataloger.run(llm, description, craft_type.lower(), notes)
 
671
  logger.warning("Cataloger failed: %s", e)
672
  result.traces = list(traces)
673
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
 
674
 
675
+ # Stage 3: Copywriter
676
  if result.catalog:
677
+ yield _status_html(2, "Writing copy...") + '<hr>' + _summary_html(result)
678
  try:
679
  copy_result, copy_trace = asyncio.run(
680
  copywriter.run(llm, craft_type.lower(), result.catalog, notes)
 
685
  logger.warning("Copywriter failed: %s", e)
686
  result.traces = list(traces)
687
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
 
688
 
689
+ # Stage 4: Pricer
690
  if result.catalog:
691
+ yield _status_html(3, "Calculating pricing...") + '<hr>' + _summary_html(result)
692
  try:
693
  price_result, price_trace = asyncio.run(
694
  pricer.run(llm, craft_type.lower(), result.catalog, cost, hours)
 
700
  result.traces = list(traces)
701
  result.total_duration_ms = int((time.monotonic() - start) * 1000)
702
 
703
+ # Upload trace to HF dataset (background, non-blocking)
704
+ trace_json = json.dumps(
705
+ [t.model_dump() for t in result.traces], indent=2
706
+ )
707
+ _upload_trace_async(trace_json)
708
+
709
+ # Build download files for tab handlers
710
+ _last_trace_path = _make_trace_file(result)
711
  export_text = _build_export_text(result)
712
  export_file = tempfile.NamedTemporaryFile(
713
  mode="w", suffix=".txt", prefix="craftpilot-listing-", delete=False
714
  )
715
  export_file.write(export_text)
716
  export_file.close()
717
+ _last_export_path = export_file.name
718
+ _last_result = result
719
 
720
+ # Final: render summary as HTML
721
+ yield _summary_html(result)
 
 
 
 
 
 
 
 
 
722
 
723
  except Exception as e:
724
  logger.exception("Pipeline failed")
725
+ yield f"<p><strong>Error:</strong> {e}</p>"
726
+
727
+
728
+ def _on_vision_tab():
729
+ if not _last_result:
730
+ return ""
731
+ return _format_vision(_last_result)
732
+
733
+
734
+ def _on_catalog_tab():
735
+ if not _last_result:
736
+ return ""
737
+ return _format_catalog(_last_result)
738
+
739
+
740
+ def _on_copy_tab():
741
+ if not _last_result:
742
+ return "", gr.DownloadButton(visible=False)
743
+ return (
744
+ _format_copy(_last_result),
745
+ gr.DownloadButton(value=_last_export_path, visible=True) if _last_export_path else gr.DownloadButton(visible=False),
746
+ )
747
+
748
+
749
+ def _on_pricing_tab():
750
+ if not _last_result:
751
+ return ""
752
+ return _format_pricing(_last_result)
753
+
754
+
755
+ def _on_traces_tab():
756
+ if not _last_result:
757
+ return "", gr.DownloadButton(visible=False)
758
+ return (
759
+ _format_traces(_last_result),
760
+ gr.DownloadButton(value=_last_trace_path, visible=True) if _last_trace_path else gr.DownloadButton(visible=False),
761
+ )
762
 
763
 
764
  def build_ui() -> gr.Blocks:
 
825
  with gr.Column(scale=2, min_width=500, elem_id="output-panel"):
826
  with gr.Tabs():
827
  with gr.Tab("Summary"):
828
+ # gr.HTML instead of gr.Markdown β€” experiment to
829
+ # bypass Svelte 5 i18n crash during streaming
830
+ summary_output = gr.HTML(
831
+ value="<p><em>Your results will appear here after analysis.</em></p>",
832
  elem_classes=["prose"],
833
  )
834
+ with gr.Tab("Vision") as vision_tab:
835
  vision_output = gr.Markdown(elem_classes=["prose"])
836
+ with gr.Tab("Catalog") as catalog_tab:
837
  catalog_output = gr.Markdown(elem_classes=["prose"])
838
+ with gr.Tab("Copy") as copy_tab:
839
  copy_output = gr.Markdown(elem_classes=["prose"])
840
+ export_download = gr.DownloadButton(
841
+ label="Export Listing (Etsy / Instagram)",
842
+ elem_id="export-btn",
843
+ visible=False,
844
+ )
845
+ with gr.Tab("Pricing") as pricing_tab:
846
  price_output = gr.Markdown(elem_classes=["prose"])
847
+ with gr.Tab("Agent Traces") as traces_tab:
848
  trace_output = gr.Markdown(elem_classes=["prose"])
849
+ trace_download = gr.DownloadButton(
850
+ label="Download Agent Trace JSON",
851
+ visible=False,
 
852
  )
853
 
854
+ # Streaming generator β†’ single gr.HTML output (bypasses i18n crash?)
 
 
 
 
 
 
855
  analyze_btn.click(
856
  fn=analyze_craft,
857
  inputs=[
 
861
  material_cost,
862
  time_hours,
863
  ],
864
+ outputs=[summary_output],
865
+ show_progress="minimal",
 
 
 
 
 
 
 
 
866
  )
867
 
868
+ # Tab-select handlers: populate content + downloads on demand
869
+ vision_tab.select(fn=_on_vision_tab, outputs=[vision_output])
870
+ catalog_tab.select(fn=_on_catalog_tab, outputs=[catalog_output])
871
+ copy_tab.select(fn=_on_copy_tab, outputs=[copy_output, export_download])
872
+ pricing_tab.select(fn=_on_pricing_tab, outputs=[price_output])
873
+ traces_tab.select(fn=_on_traces_tab, outputs=[trace_output, trace_download])
874
+
875
  # Footer
876
  gr.Markdown(
877
  "Built for the Build Small Hackathon 2026 \u00b7 Backyard AI track \n"
blog-post.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CraftPilot: Building a Multi-Agent Craft Business Assistant with a Single Small Model
2
+
3
+ *How I built a tool for someone I know β€” a creative introvert who makes beautiful crafts but struggles to sell them.*
4
+
5
+ ## The Problem
6
+
7
+ I know someone who crochets, embroiders, paints, and sews the most beautiful things. Friends and family constantly tell her: "You should sell these!" But she never does. Not because the work isn't good enough β€” it's because the *selling* part is overwhelming.
8
+
9
+ Writing product descriptions? Agonizing. Picking a fair price? Impossible. Crafting Instagram captions? Exhausting for an introvert. So the crafts pile up, gifted away or tucked into drawers, while she moves on to the next project.
10
+
11
+ I built CraftPilot to fix that.
12
+
13
+ ## The Solution
14
+
15
+ **CraftPilot** is a photo-in, listing-out tool. Upload a photo of your handmade craft, and you get:
16
+
17
+ - **Catalog metadata** β€” category, materials, colors, complexity, searchable tags
18
+ - **Product copy** β€” a title, short description, full description, and 3 Instagram captions
19
+ - **Fair pricing** β€” a price range based on material cost, labor time, and market rates
20
+ - **Downloadable listing** β€” export everything as an Etsy/Instagram-ready text file
21
+ - **Agent traces** β€” full transparency into what each AI agent did
22
+
23
+ The key constraint: everything runs on a **single model** (MiniCPM-V 2.6, ~8B parameters) via llama.cpp. No cloud APIs. No subscriptions. No sending your craft photos to OpenAI.
24
+
25
+ ## How It Works: The Multi-Agent Pipeline
26
+
27
+ CraftPilot uses a 4-agent pipeline, all powered by the same model:
28
+
29
+ ```
30
+ Photo -> [Vision Agent] -> [Cataloger Agent] -> [Copywriter Agent]
31
+ -> [Pricer Agent]
32
+ ```
33
+
34
+ 1. **Vision Agent** β€” Takes the photo and produces a detailed text description of the craft item (materials, colors, techniques, style)
35
+ 2. **Cataloger Agent** β€” Reads the description and outputs structured metadata as JSON (category, materials, tags, complexity)
36
+ 3. **Copywriter Agent** β€” Takes the catalog data and writes warm, authentic product copy and Instagram captions
37
+ 4. **Pricer Agent** β€” Considers materials cost, labor hours, complexity, and market rates to suggest a fair price range
38
+
39
+ Each agent has a specialized system prompt and outputs structured JSON via constrained generation. The pipeline streams results β€” you see the vision analysis appear first, then catalog data fills in, then copy and pricing.
40
+
41
+ ## The Technical Story
42
+
43
+ ### One Model, Four Agents
44
+
45
+ MiniCPM-V 2.6 is a multimodal model from OpenBMB that handles both vision (image understanding) and text generation. Running it via llama.cpp means:
46
+
47
+ - No GPU required (works on CPU)
48
+ - No API costs
49
+ - Full privacy β€” your photos never leave your machine
50
+
51
+ ### Why Not Just Use ChatGPT?
52
+
53
+ Fair question. Here's the difference:
54
+
55
+ - **No account or subscription needed** β€” privacy matters when selling your own work
56
+ - **Structured, repeatable outputs** β€” same format every time, not a wall of text
57
+ - **Purpose-built workflow** β€” pricing considers actual material costs and labor hours
58
+ - **One-click export** β€” download a ready-to-paste listing for Etsy or Instagram
59
+
60
+ ### What I Learned About Small Models
61
+
62
+ A small model is surprisingly capable when you give it:
63
+ - Clear, focused system prompts (one job per agent)
64
+ - Structured output constraints (JSON schema)
65
+ - Pre-computed math (the model can't reliably add, so the pricing template does the arithmetic)
66
+
67
+ Where it struggles: complex reasoning, nuanced pricing logic, and occasionally inconsistent JSON. Error recovery in the pipeline handles this gracefully β€” if one agent fails, you still get results from the others.
68
+
69
+ ## The Stack
70
+
71
+ - **Model:** MiniCPM-V 2.6 (~8B) via llama-cpp-python
72
+ - **UI:** Gradio 6.x with custom CSS
73
+ - **Orchestration:** Python async pipeline with Pydantic models
74
+ - **Hosting:** Hugging Face Spaces (CPU)
75
+
76
+ ## What's Next
77
+
78
+ - Better pricing with real market data integration
79
+ - Batch processing for multiple items at once
80
+ - Support for more craft types and regional pricing
81
+
82
+ ## Try It
83
+
84
+ CraftPilot is live on Hugging Face Spaces: [Try CraftPilot](https://huggingface.co/spaces/build-small-hackathon/craftpilot)
85
+
86
+ Built for the Build Small Hackathon 2026 β€” Backyard AI track. Single model, no cloud APIs, full agent transparency.
87
+
88
+ ---
89
+
90
+ *Built with love for someone who deserves to share her craft with the world.*