Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitattributes +3 -3
- app.py +311 -139
- 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 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
| 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 |
-
#
|
| 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 |
-
/*
|
| 196 |
-
|
|
|
|
| 197 |
text-align: center;
|
| 198 |
-
padding: 0.75rem;
|
| 199 |
}
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
font-weight: 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 467 |
-
"""
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 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
|
| 490 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
|
| 492 |
if image is None:
|
| 493 |
-
yield
|
| 494 |
return
|
| 495 |
|
| 496 |
if not craft_type:
|
| 497 |
-
yield
|
| 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 |
-
|
| 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
|
| 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
|
| 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
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 656 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 670 |
-
label="Download Trace JSON",
|
| 671 |
-
|
| 672 |
-
interactive=False,
|
| 673 |
)
|
| 674 |
|
| 675 |
-
|
| 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 |
-
|
| 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", "✓"
|
| 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.*
|