"""CraftPilot - AI-powered craft business assistant.
Upload a photo of your handmade craft, get a complete marketplace listing
with catalog metadata, product descriptions, social captions, and pricing.
Built for the Build Small Hackathon 2026.
Multi-agent pipeline: Vision -> Cataloger -> Copywriter + Pricer
Single model (MiniCPM-V 2.6, ~8B) running locally via llama.cpp.
No cloud APIs.
"""
import asyncio
import json
import logging
import os
import tempfile
import threading
import time
import gradio as gr
from huggingface_hub import HfApi, hf_hub_download
from PIL import Image
from agents import cataloger, copywriter, pricer
from agents.llm import LLMClient
from agents.models import AgentTrace, PipelineResult
from agents.pipeline import VISION_PROMPT
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# HF token: env var > token.txt fallback
hf_token = os.getenv("HF_TOKEN")
if not hf_token:
_token_path = os.path.join(os.path.dirname(__file__), "token.txt")
if os.path.exists(_token_path):
hf_token = open(_token_path).read().strip()
if hf_token:
os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token
TRACES_DATASET = "skamathramesh/craftpilot-traces"
def _upload_trace_async(trace_json: str):
"""Upload trace JSON to HF dataset in background thread."""
def _upload():
try:
api = HfApi(token=hf_token)
filename = f"trace_{int(time.time())}_{os.getpid()}.json"
api.upload_file(
path_or_fileobj=trace_json.encode(),
path_in_repo=f"traces/{filename}",
repo_id=TRACES_DATASET,
repo_type="dataset",
)
logger.info("Trace uploaded: %s", filename)
except Exception as e:
logger.warning("Trace upload failed (non-fatal): %s", e)
if hf_token:
threading.Thread(target=_upload, daemon=True).start()
CRAFT_TYPES = [
"Crochet",
"Embroidery",
"Painting",
"Sewing",
"Knitting",
"Other",
]
MODEL_REPO = os.getenv("MODEL_REPO", "openbmb/MiniCPM-V-2_6-gguf")
MODEL_FILE = os.getenv("MODEL_FILE", "ggml-model-Q4_K_M.gguf")
PROJ_FILE = os.getenv("PROJ_FILE", "mmproj-model-f16.gguf")
_llm_client: LLMClient | None = None
CUSTOM_HEAD = ''
CUSTOM_CSS = """
:root {
--craft-amber: #d4830a;
--craft-amber-light: #f5e6cc;
--craft-terracotta: #c1644a;
--craft-cream: #faf6f0;
--craft-warm-gray: #6b5e53;
--craft-dark: #3a2e26;
--craft-sage: #8a9a7b;
--craft-linen: #f0ebe3;
}
.gradio-container {
max-width: 100% !important;
background: var(--craft-cream) !important;
font-family: 'Source Sans 3', sans-serif !important;
padding: 0 2rem !important;
}
/* Header */
#craft-header {
text-align: center;
padding: 2rem 1rem 1.5rem;
background: linear-gradient(135deg, var(--craft-amber-light) 0%, var(--craft-linen) 50%, #e8ddd0 100%);
border-radius: 16px;
margin-bottom: 1.5rem;
border: 1px solid rgba(212, 131, 10, 0.15);
position: relative;
overflow: hidden;
}
#craft-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23d4830a' fill-opacity='0.04'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
pointer-events: none;
}
#craft-header h1 {
font-family: 'DM Serif Display', serif !important;
font-size: 2.8rem !important;
color: var(--craft-dark) !important;
margin: 0 0 0.25rem !important;
letter-spacing: -0.02em;
}
#craft-header h3 {
font-family: 'Source Sans 3', sans-serif !important;
color: var(--craft-warm-gray) !important;
font-weight: 400 !important;
font-size: 1.1rem !important;
margin: 0 !important;
letter-spacing: 0.03em;
}
#craft-header p {
color: var(--craft-warm-gray) !important;
font-size: 0.95rem !important;
max-width: 600px;
margin: 0.75rem auto 0 !important;
line-height: 1.5;
}
/* Input panel */
#input-panel {
background: white !important;
border-radius: 14px !important;
border: 1px solid rgba(107, 94, 83, 0.1) !important;
padding: 1.25rem !important;
box-shadow: 0 2px 12px rgba(58, 46, 38, 0.06) !important;
}
/* Primary button */
#analyze-btn {
background: linear-gradient(135deg, var(--craft-amber) 0%, var(--craft-terracotta) 100%) !important;
border: none !important;
color: white !important;
font-family: 'Source Sans 3', sans-serif !important;
font-weight: 600 !important;
font-size: 1.05rem !important;
letter-spacing: 0.03em;
padding: 0.85rem 2rem !important;
border-radius: 10px !important;
transition: all 0.25s ease !important;
box-shadow: 0 4px 14px rgba(212, 131, 10, 0.3) !important;
}
#analyze-btn:hover {
transform: translateY(-1px) !important;
box-shadow: 0 6px 20px rgba(212, 131, 10, 0.4) !important;
}
/* Export button */
#export-btn {
border: 1px solid var(--craft-amber) !important;
color: var(--craft-amber) !important;
background: white !important;
font-weight: 500 !important;
border-radius: 8px !important;
}
#export-btn:hover {
background: var(--craft-amber-light) !important;
}
/* Output panel */
#output-panel {
background: white !important;
border-radius: 14px !important;
border: 1px solid rgba(107, 94, 83, 0.1) !important;
box-shadow: 0 2px 12px rgba(58, 46, 38, 0.06) !important;
}
/* Tab styling */
.tabs > .tab-nav > button {
font-family: 'Source Sans 3', sans-serif !important;
font-weight: 500 !important;
font-size: 0.9rem !important;
color: var(--craft-warm-gray) !important;
border-bottom: 2px solid transparent !important;
padding: 0.6rem 1rem !important;
}
.tabs > .tab-nav > button.selected {
color: var(--craft-amber) !important;
border-bottom-color: var(--craft-amber) !important;
background: rgba(212, 131, 10, 0.05) !important;
}
/* Summary card styles */
.summary-section {
padding: 1rem 1.25rem;
margin: 0.5rem 0;
border-left: 3px solid var(--craft-amber);
background: var(--craft-cream);
border-radius: 0 8px 8px 0;
}
/* Pipeline progress stepper */
.pipeline-status {
padding: 2.5rem 1.5rem;
text-align: center;
}
.pipeline-status .status-label {
font-size: 1.15rem;
font-weight: 500;
color: var(--craft-amber);
margin-bottom: 1.5rem;
animation: pulse 1.8s ease-in-out infinite;
}
.pipeline-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin: 0 auto;
max-width: 420px;
}
.pipeline-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.step-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--craft-linen);
border: 2px solid #d5ccc3;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 600;
color: #b0a89e;
transition: all 0.3s ease;
z-index: 1;
}
.step-dot.done {
background: var(--craft-amber);
border-color: var(--craft-amber);
color: white;
}
.step-dot.active {
background: white;
border-color: var(--craft-amber);
color: var(--craft-amber);
box-shadow: 0 0 0 4px rgba(212, 131, 10, 0.15);
animation: pulse-ring 1.8s ease-in-out infinite;
}
.step-label {
font-size: 0.7rem;
color: #b0a89e;
margin-top: 0.4rem;
font-weight: 500;
white-space: nowrap;
}
.step-label.done, .step-label.active {
color: var(--craft-warm-gray);
}
.step-connector {
height: 2px;
flex: 1;
background: #d5ccc3;
margin: 0 -2px;
margin-bottom: 1.2rem;
}
.step-connector.done {
background: var(--craft-amber);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pulse-ring {
0%, 100% { box-shadow: 0 0 0 4px rgba(212, 131, 10, 0.15); }
50% { box-shadow: 0 0 0 6px rgba(212, 131, 10, 0.08); }
}
/* Auto-cycling wave animation for loading state */
.pipeline-status.loading .step-dot {
background: white;
border-color: var(--craft-amber);
color: var(--craft-amber);
animation: dot-wave 2.4s ease-in-out infinite;
}
.pipeline-status.loading .step-connector {
background: linear-gradient(90deg, var(--craft-amber), #d5ccc3);
}
@keyframes dot-wave {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.2); opacity: 1; background: var(--craft-amber); color: white; }
}
/* Footer */
#craft-footer {
text-align: center;
padding: 1.5rem 1rem;
margin-top: 1rem;
}
#craft-footer p {
color: var(--craft-warm-gray) !important;
font-size: 0.8rem !important;
opacity: 0.7;
}
/* Markdown content styling */
.prose h2 {
font-family: 'DM Serif Display', serif !important;
color: var(--craft-dark) !important;
}
.prose strong {
color: var(--craft-dark) !important;
}
/* Image upload area */
.image-container {
border: 2px dashed rgba(212, 131, 10, 0.25) !important;
border-radius: 12px !important;
background: var(--craft-cream) !important;
}
/* Dropdown & textbox refinements */
.gradio-container input, .gradio-container textarea, .gradio-container select {
font-family: 'Source Sans 3', sans-serif !important;
border-radius: 8px !important;
}
label > span {
font-family: 'Source Sans 3', sans-serif !important;
font-weight: 500 !important;
color: var(--craft-warm-gray) !important;
font-size: 0.9rem !important;
}
"""
def download_models() -> tuple[str, str]:
"""Download GGUF model from HF Hub. Returns (model_path, proj_path)."""
logger.info("Downloading models from %s...", MODEL_REPO)
model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
proj_path = hf_hub_download(repo_id=MODEL_REPO, filename=PROJ_FILE)
logger.info("Models downloaded.")
return model_path, proj_path
def get_client() -> LLMClient:
"""Lazy-load the unified model client."""
global _llm_client
if _llm_client is None:
model_path, proj_path = download_models()
_llm_client = LLMClient(
model_path=model_path, projection_path=proj_path
)
return _llm_client
def _format_vision(result: PipelineResult) -> str:
if not result.image_description:
return ""
return f"### What the AI sees\n\n{result.image_description}"
def _format_catalog(result: PipelineResult) -> str:
cat = result.catalog
if not cat:
return ""
tags = " ".join(f"`{t}`" for t in cat.tags)
return (
f"### Catalog Entry\n\n"
f"| Field | Value |\n"
f"|-------|-------|\n"
f"| **Category** | {cat.category} / {cat.sub_category} |\n"
f"| **Materials** | {', '.join(cat.materials)} |\n"
f"| **Colors** | {', '.join(cat.colors)} |\n"
f"| **Size** | {cat.estimated_size} |\n"
f"| **Complexity** | {cat.complexity} |\n\n"
f"**Tags:** {tags}"
)
def _format_copy(result: PipelineResult) -> str:
if not result.copy_data:
return ""
c = result.copy_data
copy_text = (
f"### {c.title}\n\n"
f"**One-liner:** {c.short_desc}\n\n"
f"---\n\n"
f"**Full Description**\n\n{c.long_desc}\n\n"
f"---\n\n"
f"**Instagram Captions**\n\n"
)
for i, cap in enumerate(c.captions, 1):
copy_text += f"{i}. {cap}\n\n"
return copy_text
def _format_pricing(result: PipelineResult) -> str:
if not result.pricing:
return ""
p = result.pricing
price_text = (
f"### Pricing Recommendation\n\n"
f"## ${p.suggested_price_min} \u2013 ${p.suggested_price_max}\n\n"
f"---\n\n"
f"**Rationale**\n\n{p.reasoning}\n"
)
if p.cost_breakdown:
price_text += f"\n---\n\n**Cost Breakdown**\n\n{p.cost_breakdown}\n"
return price_text
def _format_traces(result: PipelineResult) -> str:
if not result.traces:
return ""
trace_parts = [
f"### Pipeline Trace\n\n"
f"**Total time:** {result.total_duration_ms}ms \n"
f"**Agents:** {len(result.traces)} steps\n\n"
f"| Step | Agent | Duration |\n"
f"|------|-------|----------|\n"
]
for i, t in enumerate(result.traces, 1):
trace_parts.append(f"| {i} | {t.agent_name} | {t.duration_ms}ms |\n")
trace_json = json.dumps(
[t.model_dump() for t in result.traces], indent=2
)
trace_parts.append(f"\nRaw JSON
\n\n```json\n{trace_json}\n```\n
{c.short_desc}
") if result.pricing: p = result.pricing parts.append(f"{t}" for t in cat.tags[:6])
parts.append(
f"Category: {cat.category} / {cat.sub_category}
"
f"Materials: {', '.join(cat.materials)}
"
f"Colors: {', '.join(cat.colors)}
"
f"Complexity: {cat.complexity}
{tags}
" ) parts.append("Description
{result.copy_data.long_desc}
") parts.append("Instagram Captions
Pricing Rationale
{result.pricing.reasoning}
") if result.pricing.cost_breakdown: parts.append(f"Cost Breakdown: {result.pricing.cost_breakdown}
") if result.traces: agent_times = " \u2192 ".join( f"{t.agent_name} ({t.duration_ms}ms)" for t in result.traces ) parts.append( f"Pipeline: {agent_times} " f"| Total: {result.total_duration_ms}ms
" ) return f'Upload an image to get started.
" return if not craft_type: yield "Please select a craft type.
" return cost = float(material_cost) if material_cost else None hours = float(time_hours) if time_hours else None notes = user_notes.strip() or None try: yield _status_html(-1, "Loading model...") llm = get_client() start = time.monotonic() traces: list[AgentTrace] = [] result = PipelineResult(image_description="") # Stage 1: Vision yield _status_html(0, "Analyzing your craft...") description, vision_ms = llm.describe_image(image, VISION_PROMPT) traces.append(AgentTrace( agent_name="vision", input_text="[image]", output_data={"description": description}, duration_ms=vision_ms, model_id=llm.model_id, )) result.image_description = description result.traces = list(traces) result.total_duration_ms = int((time.monotonic() - start) * 1000) # Stage 2: Cataloger yield ( _status_html(1, "Cataloging item...") + f'Vision ({vision_ms}ms)
{description}
' ) try: catalog_result, catalog_trace = asyncio.run( cataloger.run(llm, description, craft_type.lower(), notes) ) traces.append(catalog_trace) result.catalog = catalog_result except Exception as e: logger.warning("Cataloger failed: %s", e) result.traces = list(traces) result.total_duration_ms = int((time.monotonic() - start) * 1000) # Stage 3: Copywriter if result.catalog: yield _status_html(2, "Writing copy...") + 'Error: {e}
" def _on_vision_tab(): if not _last_result: return "" return _format_vision(_last_result) def _on_catalog_tab(): if not _last_result: return "" return _format_catalog(_last_result) def _on_copy_tab(): if not _last_result: return "", gr.DownloadButton(visible=False) return ( _format_copy(_last_result), gr.DownloadButton(value=_last_export_path, visible=True) if _last_export_path else gr.DownloadButton(visible=False), ) def _on_pricing_tab(): if not _last_result: return "" return _format_pricing(_last_result) def _on_traces_tab(): if not _last_result: return "", gr.DownloadButton(visible=False) return ( _format_traces(_last_result), gr.DownloadButton(value=_last_trace_path, visible=True) if _last_trace_path else gr.DownloadButton(visible=False), ) def build_ui() -> gr.Blocks: """Build the Gradio interface.""" with gr.Blocks( title="CraftPilot \u2014 AI Craft Business Assistant", ) as app: # Header gr.Markdown( "# CraftPilot\n" "### Photo in, marketplace listing out\n" "Upload a photo of your handmade craft and get catalog data, " "product copy, social captions, and fair pricing \u2014 all from a " "single small model running locally. No cloud APIs.", elem_id="craft-header", ) with gr.Row(equal_height=False): # Left: inputs with gr.Column(scale=1, min_width=340): with gr.Group(elem_id="input-panel"): image_input = gr.Image( label="Your Craft", type="pil", height=360, ) craft_type = gr.Dropdown( choices=CRAFT_TYPES, label="Craft Type", value="Crochet", ) user_notes = gr.Textbox( label="Notes (optional)", placeholder="e.g. Made with organic cotton, took 3 evenings...", lines=2, ) with gr.Row(): material_cost = gr.Textbox( label="Material Cost ($)", placeholder="e.g. 15", ) time_hours = gr.Textbox( label="Time Spent (hrs)", placeholder="e.g. 8", ) analyze_btn = gr.Button( "Analyze My Craft", variant="primary", size="lg", elem_id="analyze-btn", ) gr.Examples( examples=[ ["examples/crochet.png", "Crochet", "Handmade amigurumi with cotton yarn", "16", "6"], ["examples/embroidery.jpeg", "Embroidery", "Hand-stitched floral pattern on Jeans", "20", "10"], ["examples/sew_keychain.jpeg", "Sewing", "Fabric keychain with felt and thread", "5", "2"], ], inputs=[image_input, craft_type, user_notes, material_cost, time_hours], label="Try these examples", ) # Right: outputs with gr.Column(scale=2, min_width=500, elem_id="output-panel"): with gr.Tabs(): with gr.Tab("Summary"): # gr.HTML instead of gr.Markdown — experiment to # bypass Svelte 5 i18n crash during streaming summary_output = gr.HTML( value="Your results will appear here after analysis.
", elem_classes=["prose"], ) with gr.Tab("Vision") as vision_tab: vision_output = gr.Markdown(elem_classes=["prose"]) with gr.Tab("Catalog") as catalog_tab: catalog_output = gr.Markdown(elem_classes=["prose"]) with gr.Tab("Copy") as copy_tab: copy_output = gr.Markdown(elem_classes=["prose"]) export_download = gr.DownloadButton( label="Export Listing (Etsy / Instagram)", elem_id="export-btn", visible=False, ) with gr.Tab("Pricing") as pricing_tab: price_output = gr.Markdown(elem_classes=["prose"]) with gr.Tab("Agent Traces") as traces_tab: trace_output = gr.Markdown(elem_classes=["prose"]) trace_download = gr.DownloadButton( label="Download Agent Trace JSON", visible=False, ) # Streaming generator → single gr.HTML output (bypasses i18n crash?) analyze_btn.click( fn=analyze_craft, inputs=[ image_input, craft_type, user_notes, material_cost, time_hours, ], outputs=[summary_output], show_progress="minimal", ) # Tab-select handlers: populate content + downloads on demand vision_tab.select(fn=_on_vision_tab, outputs=[vision_output]) catalog_tab.select(fn=_on_catalog_tab, outputs=[catalog_output]) copy_tab.select(fn=_on_copy_tab, outputs=[copy_output, export_download]) pricing_tab.select(fn=_on_pricing_tab, outputs=[price_output]) traces_tab.select(fn=_on_traces_tab, outputs=[trace_output, trace_download]) # Footer gr.Markdown( "Built for the Build Small Hackathon 2026 \u00b7 Backyard AI track \n" "MiniCPM-V 2.6 (~8B) via llama.cpp \u00b7 No cloud APIs \n" "Llama Champion \u00b7 Off the Grid \u00b7 Sharing is Caring \u00b7 Field Notes", elem_id="craft-footer", ) return app if __name__ == "__main__": # Pre-load models at startup (avoids 60s request timeout on HF Spaces) logger.info("Pre-loading models at startup...") try: get_client() logger.info("Models ready!") except Exception as e: logger.error("Failed to load models: %s", e) theme = gr.themes.Soft( primary_hue="amber", secondary_hue="orange", neutral_hue="stone", font=gr.themes.GoogleFont("Source Sans 3"), font_mono=gr.themes.GoogleFont("JetBrains Mono"), ) app = build_ui() app.launch( server_name="0.0.0.0", server_port=7860, theme=theme, css=CUSTOM_CSS, head=CUSTOM_HEAD, )