skamathramesh commited on
Commit
98a986c
·
verified ·
1 Parent(s): 05edc7b

CraftPilot: multi-agent craft business assistant

Browse files
.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Vision model (MiniCPM-V 2.6 GGUF)
2
+ VISION_REPO=openbmb/MiniCPM-V-2_6-gguf
3
+ VISION_MODEL_FILE=ggml-model-Q4_K_M.gguf
4
+ VISION_FILE=mmproj-model-f16.gguf
5
+
6
+ # Text model (Qwen2.5-1.5B-Instruct GGUF)
7
+ TEXT_REPO=Qwen/Qwen2.5-1.5B-Instruct-GGUF
8
+ TEXT_FILE=qwen2.5-1.5b-instruct-q4_k_m.gguf
.gitattributes CHANGED
@@ -33,3 +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
 
 
 
 
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
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ *.gguf
5
+ models/
6
+ .venv/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ token.txt
11
+ docs/
README.md CHANGED
@@ -1,13 +1,54 @@
1
  ---
2
- title: Craftpilot
3
- emoji:
4
- colorFrom: indigo
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.17.3
8
- python_version: '3.13'
9
  app_file: app.py
 
10
  pinned: false
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: CraftPilot
3
+ emoji: 🧶
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: "6.16.0"
 
8
  app_file: app.py
9
+ python_version: "3.10"
10
  pinned: false
11
+ license: mit
12
+ tags:
13
+ - craft
14
+ - vision
15
+ - multi-agent
16
+ - llama-cpp
17
+ - hackathon
18
  ---
19
 
20
+ # CraftPilot AI-Powered Craft Business Assistant
21
+
22
+ Upload a photo of your handmade craft. Get a complete marketplace listing with catalog data, product descriptions, social captions, and fair pricing.
23
+
24
+ **Built for someone I know** — a creative introvert who does crochet, painting, embroidery, and sewing but struggles to write product listings and price her work fairly.
25
+
26
+ ## How it works
27
+
28
+ 1. Upload a craft photo
29
+ 2. Select craft type
30
+ 3. (Optional) Add notes, material cost, time spent
31
+ 4. Click "Analyze My Craft"
32
+
33
+ The AI runs a multi-agent pipeline:
34
+ - **Vision** — describes your craft item in detail
35
+ - **Cataloger** — extracts structured metadata (category, materials, colors, tags)
36
+ - **Copywriter** — generates product title, descriptions, and Instagram captions
37
+ - **Pricer** — suggests fair pricing based on materials, labor, and market rates
38
+
39
+ ## Technical Details
40
+
41
+ - **Single model:** MiniCPM-V 2.6 (~8B) via llama.cpp — handles both vision and text
42
+ - **No cloud APIs** — everything runs locally
43
+ - **Agent traces** — full pipeline transparency in the Agent Traces tab
44
+
45
+ ## Badges
46
+
47
+ - Llama Champion — all inference via llama.cpp
48
+ - Off the Grid — no cloud API calls
49
+ - Sharing is Caring — agent traces published
50
+ - Field Notes — blog post about the build
51
+
52
+ ## Team
53
+
54
+ - Solo builder
agents/__init__.py ADDED
File without changes
agents/cataloger.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cataloger agent: analyzes craft items and produces structured metadata."""
2
+
3
+ from jinja2 import Template
4
+
5
+ from agents.llm import LLMClient
6
+ from agents.models import AgentTrace, CatalogerOutput
7
+
8
+ SYSTEM_PROMPT = (
9
+ "You are a craft catalog expert. Analyze handmade craft items and "
10
+ "produce structured catalog entries.\n\n"
11
+ "You understand materials, techniques, and categories for: crochet, "
12
+ "painting, embroidery, sewing, stitching, and other handmade crafts.\n\n"
13
+ "Always respond with valid JSON."
14
+ )
15
+
16
+ USER_TEMPLATE = Template(
17
+ "Analyze this {{ craft_type }} item and create a catalog entry.\n\n"
18
+ "Image description: {{ image_description }}\n"
19
+ "{% if user_notes %}Creator's notes: {{ user_notes }}{% endif %}\n\n"
20
+ "Categorize it with: category (e.g., 'home decor', 'fashion accessory', "
21
+ "'wall art'), sub_category (e.g., 'coaster', 'scarf', 'portrait'), "
22
+ "materials used, colors, estimated size, relevant tags for "
23
+ "searchability, and complexity level (simple/moderate/complex)."
24
+ )
25
+
26
+
27
+ async def run(
28
+ llm: LLMClient,
29
+ image_description: str,
30
+ craft_type: str,
31
+ user_notes: str | None = None,
32
+ ) -> tuple[CatalogerOutput, AgentTrace]:
33
+ prompt = USER_TEMPLATE.render(
34
+ craft_type=craft_type,
35
+ image_description=image_description,
36
+ user_notes=user_notes,
37
+ )
38
+ result, duration_ms = await llm.agenerate(
39
+ system=SYSTEM_PROMPT,
40
+ prompt=prompt,
41
+ output_schema=CatalogerOutput,
42
+ )
43
+ trace = AgentTrace(
44
+ agent_name="cataloger",
45
+ input_text=prompt,
46
+ output_data=result.model_dump(),
47
+ duration_ms=duration_ms,
48
+ model_id=llm.model_id,
49
+ )
50
+ return result, trace
agents/copywriter.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Copywriter agent: generates product descriptions and social captions."""
2
+
3
+ from jinja2 import Template
4
+
5
+ from agents.llm import LLMClient
6
+ from agents.models import AgentTrace, CatalogerOutput, CopywriterOutput
7
+
8
+ SYSTEM_PROMPT = (
9
+ "You are a warm, authentic copywriter for handmade crafts. You write "
10
+ "product descriptions and social media captions that feel personal and "
11
+ "artisan -- never corporate or salesy.\n\n"
12
+ "Your tone: warm, genuine, storytelling, inviting. Write as if a real "
13
+ "craftsperson is sharing their work.\n\n"
14
+ "Always respond with valid JSON."
15
+ )
16
+
17
+ USER_TEMPLATE = Template(
18
+ "Write marketing copy for this {{ craft_type }} item:\n\n"
19
+ "Category: {{ catalog.category }} / {{ catalog.sub_category }}\n"
20
+ "Materials: {{ catalog.materials | join(', ') }}\n"
21
+ "Colors: {{ catalog.colors | join(', ') }}\n"
22
+ "Size: {{ catalog.estimated_size }}\n"
23
+ "Complexity: {{ catalog.complexity }}\n"
24
+ "{% if user_notes %}Creator's notes: {{ user_notes }}{% endif %}\n\n"
25
+ "Provide:\n"
26
+ "1. A catchy product title (under 60 characters)\n"
27
+ "2. A short description (1-2 sentences)\n"
28
+ "3. A longer description (1 paragraph, warm and personal)\n"
29
+ "4. Exactly 3 Instagram caption variations (each under 200 characters, "
30
+ "include relevant hashtags)"
31
+ )
32
+
33
+
34
+ async def run(
35
+ llm: LLMClient,
36
+ craft_type: str,
37
+ catalog: CatalogerOutput,
38
+ user_notes: str | None = None,
39
+ ) -> tuple[CopywriterOutput, AgentTrace]:
40
+ prompt = USER_TEMPLATE.render(
41
+ craft_type=craft_type,
42
+ catalog=catalog,
43
+ user_notes=user_notes,
44
+ )
45
+ result, duration_ms = await llm.agenerate(
46
+ system=SYSTEM_PROMPT,
47
+ prompt=prompt,
48
+ output_schema=CopywriterOutput,
49
+ max_tokens=1024,
50
+ )
51
+ trace = AgentTrace(
52
+ agent_name="copywriter",
53
+ input_text=prompt,
54
+ output_data=result.model_dump(),
55
+ duration_ms=duration_ms,
56
+ model_id=llm.model_id,
57
+ )
58
+ return result, trace
agents/llm.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unified LLM client: uses a single model for both vision and text tasks."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import io
6
+ import json
7
+ import logging
8
+ import time
9
+
10
+ from PIL import Image
11
+ from pydantic import BaseModel
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class LLMClient:
17
+ """Single model client for vision + text via llama.cpp."""
18
+
19
+ def __init__(self, model_path: str, projection_path: str):
20
+ self.model_path = model_path
21
+ self.projection_path = projection_path
22
+ self.model_id = model_path.split("/")[-1]
23
+ self._model = None
24
+
25
+ def _ensure_model(self):
26
+ if self._model is not None:
27
+ return
28
+ from llama_cpp import Llama
29
+ from llama_cpp.llama_chat_format import MiniCPMv26ChatHandler
30
+
31
+ logger.info("Loading model: %s", self.model_path)
32
+ chat_handler = MiniCPMv26ChatHandler(
33
+ clip_model_path=self.projection_path
34
+ )
35
+ self._model = Llama(
36
+ model_path=self.model_path,
37
+ chat_handler=chat_handler,
38
+ n_ctx=2048,
39
+ n_threads=2,
40
+ verbose=False,
41
+ )
42
+ logger.info("Model loaded")
43
+
44
+ def _prepare_image(self, image: Image.Image) -> str:
45
+ max_dim = 384
46
+ if max(image.size) > max_dim:
47
+ ratio = max_dim / max(image.size)
48
+ new_size = (int(image.width * ratio), int(image.height * ratio))
49
+ image = image.resize(new_size, Image.LANCZOS)
50
+ if image.mode != "RGB":
51
+ image = image.convert("RGB")
52
+ buffer = io.BytesIO()
53
+ image.save(buffer, format="JPEG", quality=85)
54
+ b64 = base64.b64encode(buffer.getvalue()).decode()
55
+ return f"data:image/jpeg;base64,{b64}"
56
+
57
+ def _build_example_json(self, schema: type[BaseModel]) -> str:
58
+ example: dict = {}
59
+ for name, field in schema.model_fields.items():
60
+ annotation = field.annotation
61
+ origin = getattr(annotation, "__origin__", None)
62
+ if origin is type(None):
63
+ example[name] = None
64
+ continue
65
+ args = getattr(annotation, "__args__", None)
66
+ if args and type(None) in args:
67
+ annotation = [a for a in args if a is not type(None)][0]
68
+ if annotation == str:
69
+ example[name] = "..."
70
+ elif annotation == int:
71
+ example[name] = 0
72
+ elif annotation == float:
73
+ example[name] = 0.0
74
+ elif annotation == bool:
75
+ example[name] = False
76
+ elif annotation is list or (
77
+ hasattr(annotation, "__origin__")
78
+ and getattr(annotation, "__origin__", None) is list
79
+ ):
80
+ example[name] = ["..."]
81
+ else:
82
+ example[name] = "..."
83
+ return json.dumps(example, indent=2)
84
+
85
+ def describe_image(self, image: Image.Image, prompt: str) -> tuple[str, int]:
86
+ """Describe an image. Returns (description, duration_ms)."""
87
+ self._ensure_model()
88
+ data_uri = self._prepare_image(image)
89
+
90
+ start = time.monotonic()
91
+ response = self._model.create_chat_completion(
92
+ messages=[
93
+ {
94
+ "role": "user",
95
+ "content": [
96
+ {"type": "image_url", "image_url": {"url": data_uri}},
97
+ {"type": "text", "text": prompt},
98
+ ],
99
+ }
100
+ ],
101
+ max_tokens=256,
102
+ )
103
+ duration_ms = int((time.monotonic() - start) * 1000)
104
+ content = response["choices"][0]["message"]["content"]
105
+ return content, duration_ms
106
+
107
+ def generate(
108
+ self,
109
+ system: str,
110
+ prompt: str,
111
+ output_schema: type[BaseModel],
112
+ max_tokens: int = 512,
113
+ ) -> tuple[BaseModel, int]:
114
+ """Generate structured JSON output. Returns (parsed_model, duration_ms)."""
115
+ self._ensure_model()
116
+
117
+ example_json = self._build_example_json(output_schema)
118
+ json_instruction = (
119
+ "\n\nRespond ONLY with a valid JSON object. "
120
+ f"Use exactly these keys:\n{example_json}\n"
121
+ "Fill in real values. Do not include any text outside the JSON."
122
+ )
123
+
124
+ start = time.monotonic()
125
+ response = self._model.create_chat_completion(
126
+ messages=[
127
+ {"role": "system", "content": system + json_instruction},
128
+ {"role": "user", "content": prompt},
129
+ ],
130
+ max_tokens=max_tokens,
131
+ response_format={"type": "json_object"},
132
+ )
133
+ duration_ms = int((time.monotonic() - start) * 1000)
134
+
135
+ content = response["choices"][0]["message"]["content"]
136
+ return output_schema.model_validate_json(content), duration_ms
137
+
138
+ async def adescribe_image(
139
+ self, image: Image.Image, prompt: str
140
+ ) -> tuple[str, int]:
141
+ return await asyncio.to_thread(self.describe_image, image, prompt)
142
+
143
+ async def agenerate(
144
+ self,
145
+ system: str,
146
+ prompt: str,
147
+ output_schema: type[BaseModel],
148
+ max_tokens: int = 512,
149
+ ) -> tuple[BaseModel, int]:
150
+ return await asyncio.to_thread(
151
+ self.generate, system, prompt, output_schema, max_tokens
152
+ )
agents/models.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for the agent system."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class CatalogerOutput(BaseModel):
7
+ category: str
8
+ sub_category: str
9
+ materials: list[str]
10
+ colors: list[str]
11
+ estimated_size: str
12
+ tags: list[str]
13
+ complexity: str
14
+
15
+
16
+ class CopywriterOutput(BaseModel):
17
+ title: str
18
+ short_desc: str
19
+ long_desc: str
20
+ captions: list[str]
21
+
22
+
23
+ class PricerOutput(BaseModel):
24
+ suggested_price_min: int
25
+ suggested_price_max: int
26
+ reasoning: str
27
+ cost_breakdown: str | None = None
28
+
29
+
30
+ class AgentTrace(BaseModel):
31
+ agent_name: str
32
+ input_text: str
33
+ output_data: dict
34
+ duration_ms: int
35
+ model_id: str
36
+
37
+
38
+ class PipelineResult(BaseModel):
39
+ image_description: str
40
+ catalog: CatalogerOutput | None = None
41
+ copy_data: CopywriterOutput | None = None
42
+ pricing: PricerOutput | None = None
43
+ traces: list[AgentTrace] = []
44
+ total_duration_ms: int = 0
agents/pipeline.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestration pipeline: vision -> cataloger -> (copywriter + pricer)."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from collections.abc import Callable
7
+
8
+ from PIL import Image
9
+
10
+ from agents import cataloger, copywriter, pricer
11
+ from agents.llm import LLMClient
12
+ from agents.models import AgentTrace, PipelineResult
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ VISION_PROMPT = (
17
+ "Describe this handmade craft item in detail. Include: "
18
+ "what type of craft it is (crochet, embroidery, painting, sewing, etc.), "
19
+ "colors used, materials visible, approximate size, style, "
20
+ "and any notable patterns or techniques. "
21
+ "Be specific and descriptive."
22
+ )
23
+
24
+
25
+ async def run(
26
+ llm: LLMClient,
27
+ image: Image.Image,
28
+ craft_type: str,
29
+ user_notes: str | None = None,
30
+ material_cost: float | None = None,
31
+ time_hours: float | None = None,
32
+ on_progress: Callable | None = None,
33
+ ) -> PipelineResult:
34
+ """Run the full craft analysis pipeline with a single model."""
35
+ start = time.monotonic()
36
+ traces: list[AgentTrace] = []
37
+
38
+ # Stage 0: Vision analysis
39
+ if on_progress:
40
+ on_progress("Analyzing image...")
41
+ description, vision_ms = await llm.adescribe_image(image, VISION_PROMPT)
42
+ traces.append(AgentTrace(
43
+ agent_name="vision",
44
+ input_text="[image]",
45
+ output_data={"description": description},
46
+ duration_ms=vision_ms,
47
+ model_id=llm.model_id,
48
+ ))
49
+
50
+ # Stage 1: Cataloger
51
+ if on_progress:
52
+ on_progress("Cataloging item...")
53
+ catalog_result, catalog_trace = await cataloger.run(
54
+ llm, description, craft_type, user_notes
55
+ )
56
+ traces.append(catalog_trace)
57
+
58
+ # Stage 2: Copywriter + Pricer (sequential — single model)
59
+ if on_progress:
60
+ on_progress("Writing copy...")
61
+ copy_result, copy_trace = await copywriter.run(
62
+ llm, craft_type, catalog_result, user_notes
63
+ )
64
+ traces.append(copy_trace)
65
+
66
+ if on_progress:
67
+ on_progress("Calculating pricing...")
68
+ price_result, price_trace = await pricer.run(
69
+ llm, craft_type, catalog_result, material_cost, time_hours
70
+ )
71
+ traces.append(price_trace)
72
+
73
+ total_ms = int((time.monotonic() - start) * 1000)
74
+
75
+ return PipelineResult(
76
+ image_description=description,
77
+ catalog=catalog_result,
78
+ copy_data=copy_result,
79
+ pricing=price_result,
80
+ traces=traces,
81
+ total_duration_ms=total_ms,
82
+ )
agents/pricer.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pricer agent: suggests fair pricing for handmade craft items."""
2
+
3
+ from jinja2 import Template
4
+
5
+ from agents.llm import LLMClient
6
+ from agents.models import AgentTrace, CatalogerOutput, PricerOutput
7
+
8
+ SYSTEM_PROMPT = (
9
+ "You are a pricing expert for handmade crafts sold on Etsy, Instagram, "
10
+ "and at craft fairs.\n\n"
11
+ "PRICING RULES (follow strictly):\n"
12
+ "1. The price MUST ALWAYS be HIGHER than the material cost. Never suggest "
13
+ "a price below material cost.\n"
14
+ "2. Labor rate: $15-25/hour for moderate work, $25-40/hour for complex work.\n"
15
+ "3. Formula: price = material_cost + (hours * labor_rate) + profit_margin.\n"
16
+ "4. Profit margin: add 20-40% on top.\n"
17
+ "5. If material_cost + labor exceeds $100, the price must reflect that.\n\n"
18
+ "Always respond with valid JSON."
19
+ )
20
+
21
+ USER_TEMPLATE = Template(
22
+ "Suggest a fair price range for this {{ craft_type }} item:\n\n"
23
+ "Category: {{ catalog.category }} / {{ catalog.sub_category }}\n"
24
+ "Materials: {{ catalog.materials | join(', ') }}\n"
25
+ "Size: {{ catalog.estimated_size }}\n"
26
+ "Complexity: {{ catalog.complexity }}\n"
27
+ "{% if material_cost %}Material cost: ${{ material_cost }}{% endif %}\n"
28
+ "{% if time_hours %}Time spent: {{ time_hours }} hours{% endif %}\n"
29
+ "{% if material_cost and time_hours %}\n"
30
+ "MINIMUM price calculation:\n"
31
+ "- Materials: ${{ material_cost }}\n"
32
+ "- Labor ({{ time_hours }}h x $20/hr): ${{ (time_hours * 20) | int }}\n"
33
+ "- Subtotal: ${{ (material_cost + time_hours * 20) | int }}\n"
34
+ "- The suggested_price_min MUST be at least ${{ (material_cost + time_hours * 20) | int }}\n"
35
+ "{% endif %}\n"
36
+ "Provide suggested_price_min, suggested_price_max, reasoning, and cost_breakdown."
37
+ )
38
+
39
+
40
+ async def run(
41
+ llm: LLMClient,
42
+ craft_type: str,
43
+ catalog: CatalogerOutput,
44
+ material_cost: float | None = None,
45
+ time_hours: float | None = None,
46
+ ) -> tuple[PricerOutput, AgentTrace]:
47
+ prompt = USER_TEMPLATE.render(
48
+ craft_type=craft_type,
49
+ catalog=catalog,
50
+ material_cost=material_cost,
51
+ time_hours=time_hours,
52
+ )
53
+ result, duration_ms = await llm.agenerate(
54
+ system=SYSTEM_PROMPT,
55
+ prompt=prompt,
56
+ output_schema=PricerOutput,
57
+ )
58
+ trace = AgentTrace(
59
+ agent_name="pricer",
60
+ input_text=prompt,
61
+ output_data=result.model_dump(),
62
+ duration_ms=duration_ms,
63
+ model_id=llm.model_id,
64
+ )
65
+ return result, trace
app.py ADDED
@@ -0,0 +1,737 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CraftPilot - AI-powered craft business assistant.
2
+
3
+ Upload a photo of your handmade craft, get a complete marketplace listing
4
+ with catalog metadata, product descriptions, social captions, and pricing.
5
+
6
+ Built for the Build Small Hackathon 2026.
7
+ Multi-agent pipeline: Vision -> Cataloger -> Copywriter + Pricer
8
+ Single model (MiniCPM-V 2.6, ~8B) running locally via llama.cpp.
9
+ No cloud APIs.
10
+ """
11
+
12
+ import asyncio
13
+ 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
24
+ from agents.llm import LLMClient
25
+ from agents.models import AgentTrace, PipelineResult
26
+ from agents.pipeline import VISION_PROMPT
27
+
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",
39
+ "Painting",
40
+ "Sewing",
41
+ "Knitting",
42
+ "Other",
43
+ ]
44
+
45
+ MODEL_REPO = os.getenv("MODEL_REPO", "openbmb/MiniCPM-V-2_6-gguf")
46
+ MODEL_FILE = os.getenv("MODEL_FILE", "ggml-model-Q4_K_M.gguf")
47
+ PROJ_FILE = os.getenv("PROJ_FILE", "mmproj-model-f16.gguf")
48
+
49
+ _llm_client: LLMClient | None = None
50
+
51
+ CUSTOM_HEAD = '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Source+Sans+3:wght@300;400;500;600&display=swap">'
52
+
53
+ CUSTOM_CSS = """
54
+ :root {
55
+ --craft-amber: #d4830a;
56
+ --craft-amber-light: #f5e6cc;
57
+ --craft-terracotta: #c1644a;
58
+ --craft-cream: #faf6f0;
59
+ --craft-warm-gray: #6b5e53;
60
+ --craft-dark: #3a2e26;
61
+ --craft-sage: #8a9a7b;
62
+ --craft-linen: #f0ebe3;
63
+ }
64
+
65
+ .gradio-container {
66
+ max-width: 100% !important;
67
+ background: var(--craft-cream) !important;
68
+ font-family: 'Source Sans 3', sans-serif !important;
69
+ padding: 0 2rem !important;
70
+ }
71
+
72
+ /* Header */
73
+ #craft-header {
74
+ text-align: center;
75
+ padding: 2rem 1rem 1.5rem;
76
+ background: linear-gradient(135deg, var(--craft-amber-light) 0%, var(--craft-linen) 50%, #e8ddd0 100%);
77
+ border-radius: 16px;
78
+ margin-bottom: 1.5rem;
79
+ border: 1px solid rgba(212, 131, 10, 0.15);
80
+ position: relative;
81
+ overflow: hidden;
82
+ }
83
+
84
+ #craft-header::before {
85
+ content: '';
86
+ position: absolute;
87
+ top: 0;
88
+ left: 0;
89
+ right: 0;
90
+ bottom: 0;
91
+ 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");
92
+ pointer-events: none;
93
+ }
94
+
95
+ #craft-header h1 {
96
+ font-family: 'DM Serif Display', serif !important;
97
+ font-size: 2.8rem !important;
98
+ color: var(--craft-dark) !important;
99
+ margin: 0 0 0.25rem !important;
100
+ letter-spacing: -0.02em;
101
+ }
102
+
103
+ #craft-header h3 {
104
+ font-family: 'Source Sans 3', sans-serif !important;
105
+ color: var(--craft-warm-gray) !important;
106
+ font-weight: 400 !important;
107
+ font-size: 1.1rem !important;
108
+ margin: 0 !important;
109
+ letter-spacing: 0.03em;
110
+ }
111
+
112
+ #craft-header p {
113
+ color: var(--craft-warm-gray) !important;
114
+ font-size: 0.95rem !important;
115
+ max-width: 600px;
116
+ margin: 0.75rem auto 0 !important;
117
+ line-height: 1.5;
118
+ }
119
+
120
+ /* Input panel */
121
+ #input-panel {
122
+ background: white !important;
123
+ border-radius: 14px !important;
124
+ border: 1px solid rgba(107, 94, 83, 0.1) !important;
125
+ padding: 1.25rem !important;
126
+ box-shadow: 0 2px 12px rgba(58, 46, 38, 0.06) !important;
127
+ }
128
+
129
+ /* Primary button */
130
+ #analyze-btn {
131
+ background: linear-gradient(135deg, var(--craft-amber) 0%, var(--craft-terracotta) 100%) !important;
132
+ border: none !important;
133
+ color: white !important;
134
+ font-family: 'Source Sans 3', sans-serif !important;
135
+ font-weight: 600 !important;
136
+ font-size: 1.05rem !important;
137
+ letter-spacing: 0.03em;
138
+ padding: 0.85rem 2rem !important;
139
+ border-radius: 10px !important;
140
+ transition: all 0.25s ease !important;
141
+ box-shadow: 0 4px 14px rgba(212, 131, 10, 0.3) !important;
142
+ }
143
+
144
+ #analyze-btn:hover {
145
+ transform: translateY(-1px) !important;
146
+ box-shadow: 0 6px 20px rgba(212, 131, 10, 0.4) !important;
147
+ }
148
+
149
+ /* Export button */
150
+ #export-btn {
151
+ border: 1px solid var(--craft-amber) !important;
152
+ color: var(--craft-amber) !important;
153
+ background: white !important;
154
+ font-weight: 500 !important;
155
+ border-radius: 8px !important;
156
+ }
157
+
158
+ #export-btn:hover {
159
+ background: var(--craft-amber-light) !important;
160
+ }
161
+
162
+ /* Output panel */
163
+ #output-panel {
164
+ background: white !important;
165
+ border-radius: 14px !important;
166
+ border: 1px solid rgba(107, 94, 83, 0.1) !important;
167
+ box-shadow: 0 2px 12px rgba(58, 46, 38, 0.06) !important;
168
+ }
169
+
170
+ /* Tab styling */
171
+ .tabs > .tab-nav > button {
172
+ font-family: 'Source Sans 3', sans-serif !important;
173
+ font-weight: 500 !important;
174
+ font-size: 0.9rem !important;
175
+ color: var(--craft-warm-gray) !important;
176
+ border-bottom: 2px solid transparent !important;
177
+ padding: 0.6rem 1rem !important;
178
+ }
179
+
180
+ .tabs > .tab-nav > button.selected {
181
+ color: var(--craft-amber) !important;
182
+ border-bottom-color: var(--craft-amber) !important;
183
+ background: rgba(212, 131, 10, 0.05) !important;
184
+ }
185
+
186
+ /* Summary card styles */
187
+ .summary-section {
188
+ padding: 1rem 1.25rem;
189
+ margin: 0.5rem 0;
190
+ border-left: 3px solid var(--craft-amber);
191
+ background: var(--craft-cream);
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 */
207
+ #craft-footer {
208
+ text-align: center;
209
+ padding: 1.5rem 1rem;
210
+ margin-top: 1rem;
211
+ }
212
+
213
+ #craft-footer p {
214
+ color: var(--craft-warm-gray) !important;
215
+ font-size: 0.8rem !important;
216
+ opacity: 0.7;
217
+ }
218
+
219
+ /* Markdown content styling */
220
+ .prose h2 {
221
+ font-family: 'DM Serif Display', serif !important;
222
+ color: var(--craft-dark) !important;
223
+ }
224
+
225
+ .prose strong {
226
+ color: var(--craft-dark) !important;
227
+ }
228
+
229
+ /* Image upload area */
230
+ .image-container {
231
+ border: 2px dashed rgba(212, 131, 10, 0.25) !important;
232
+ border-radius: 12px !important;
233
+ background: var(--craft-cream) !important;
234
+ }
235
+
236
+ /* Dropdown & textbox refinements */
237
+ .gradio-container input, .gradio-container textarea, .gradio-container select {
238
+ font-family: 'Source Sans 3', sans-serif !important;
239
+ border-radius: 8px !important;
240
+ }
241
+
242
+ label > span {
243
+ font-family: 'Source Sans 3', sans-serif !important;
244
+ font-weight: 500 !important;
245
+ color: var(--craft-warm-gray) !important;
246
+ font-size: 0.9rem !important;
247
+ }
248
+ """
249
+
250
+
251
+ def download_models() -> tuple[str, str]:
252
+ """Download GGUF model from HF Hub. Returns (model_path, proj_path)."""
253
+ logger.info("Downloading models from %s...", MODEL_REPO)
254
+ model_path = hf_hub_download(repo_id=MODEL_REPO, filename=MODEL_FILE)
255
+ proj_path = hf_hub_download(repo_id=MODEL_REPO, filename=PROJ_FILE)
256
+ logger.info("Models downloaded.")
257
+ return model_path, proj_path
258
+
259
+
260
+ def get_client() -> LLMClient:
261
+ """Lazy-load the unified model client."""
262
+ global _llm_client
263
+ if _llm_client is None:
264
+ model_path, proj_path = download_models()
265
+ _llm_client = LLMClient(
266
+ model_path=model_path, projection_path=proj_path
267
+ )
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:
338
+ return ""
339
+ return f"### What the AI sees\n\n{result.image_description}"
340
+
341
+
342
+ def _format_catalog(result: PipelineResult) -> str:
343
+ cat = result.catalog
344
+ if not cat:
345
+ return ""
346
+ tags = " ".join(f"`{t}`" for t in cat.tags)
347
+ return (
348
+ f"### Catalog Entry\n\n"
349
+ f"| Field | Value |\n"
350
+ f"|-------|-------|\n"
351
+ f"| **Category** | {cat.category} / {cat.sub_category} |\n"
352
+ f"| **Materials** | {', '.join(cat.materials)} |\n"
353
+ f"| **Colors** | {', '.join(cat.colors)} |\n"
354
+ f"| **Size** | {cat.estimated_size} |\n"
355
+ f"| **Complexity** | {cat.complexity} |\n\n"
356
+ f"**Tags:** {tags}"
357
+ )
358
+
359
+
360
+ def _format_copy(result: PipelineResult) -> str:
361
+ if not result.copy_data:
362
+ return ""
363
+ c = result.copy_data
364
+ copy_text = (
365
+ f"### {c.title}\n\n"
366
+ f"**One-liner:** {c.short_desc}\n\n"
367
+ f"---\n\n"
368
+ f"**Full Description**\n\n{c.long_desc}\n\n"
369
+ f"---\n\n"
370
+ f"**Instagram Captions**\n\n"
371
+ )
372
+ for i, cap in enumerate(c.captions, 1):
373
+ copy_text += f"{i}. {cap}\n\n"
374
+ return copy_text
375
+
376
+
377
+ def _format_pricing(result: PipelineResult) -> str:
378
+ if not result.pricing:
379
+ return ""
380
+ p = result.pricing
381
+ price_text = (
382
+ f"### Pricing Recommendation\n\n"
383
+ f"## ${p.suggested_price_min} \u2013 ${p.suggested_price_max}\n\n"
384
+ f"---\n\n"
385
+ f"**Rationale**\n\n{p.reasoning}\n"
386
+ )
387
+ if p.cost_breakdown:
388
+ price_text += f"\n---\n\n**Cost Breakdown**\n\n{p.cost_breakdown}\n"
389
+ return price_text
390
+
391
+
392
+ def _format_traces(result: PipelineResult) -> str:
393
+ if not result.traces:
394
+ return ""
395
+ trace_parts = [
396
+ f"### Pipeline Trace\n\n"
397
+ f"**Total time:** {result.total_duration_ms}ms \n"
398
+ f"**Agents:** {len(result.traces)} steps\n\n"
399
+ f"| Step | Agent | Duration |\n"
400
+ f"|------|-------|----------|\n"
401
+ ]
402
+ for i, t in enumerate(result.traces, 1):
403
+ trace_parts.append(f"| {i} | {t.agent_name} | {t.duration_ms}ms |\n")
404
+
405
+ trace_json = json.dumps(
406
+ [t.model_dump() for t in result.traces], indent=2
407
+ )
408
+ trace_parts.append(f"\n<details><summary>Raw JSON</summary>\n\n```json\n{trace_json}\n```\n</details>")
409
+ return "".join(trace_parts)
410
+
411
+
412
+ def _make_trace_file(result: PipelineResult) -> str | None:
413
+ if not result.traces:
414
+ return None
415
+ trace_json = json.dumps(
416
+ [t.model_dump() for t in result.traces], indent=2
417
+ )
418
+ trace_file = tempfile.NamedTemporaryFile(
419
+ mode="w", suffix=".json", prefix="craftpilot-trace-", delete=False
420
+ )
421
+ trace_file.write(trace_json)
422
+ trace_file.close()
423
+ return trace_file.name
424
+
425
+
426
+ def _build_export_text(result: PipelineResult) -> str:
427
+ """Build a plain text listing ready to copy-paste to Etsy or Instagram."""
428
+ lines = []
429
+
430
+ if result.copy_data:
431
+ c = result.copy_data
432
+ lines.append(f"PRODUCT TITLE: {c.title}")
433
+ lines.append("")
434
+ lines.append(f"SHORT DESCRIPTION: {c.short_desc}")
435
+ lines.append("")
436
+ lines.append("FULL DESCRIPTION:")
437
+ lines.append(c.long_desc)
438
+ lines.append("")
439
+
440
+ if result.catalog:
441
+ cat = result.catalog
442
+ lines.append(f"CATEGORY: {cat.category} / {cat.sub_category}")
443
+ lines.append(f"MATERIALS: {', '.join(cat.materials)}")
444
+ lines.append(f"COLORS: {', '.join(cat.colors)}")
445
+ lines.append(f"SIZE: {cat.estimated_size}")
446
+ lines.append(f"TAGS: {', '.join(cat.tags)}")
447
+ lines.append("")
448
+
449
+ if result.pricing:
450
+ p = result.pricing
451
+ lines.append(f"SUGGESTED PRICE: ${p.suggested_price_min} - ${p.suggested_price_max}")
452
+ lines.append("")
453
+
454
+ if result.copy_data:
455
+ lines.append("INSTAGRAM CAPTIONS:")
456
+ for i, cap in enumerate(result.copy_data.captions, 1):
457
+ lines.append(f" {i}. {cap}")
458
+ lines.append("")
459
+
460
+ lines.append("---")
461
+ lines.append("Generated by CraftPilot | craftpilot.hf.space")
462
+
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(
483
+ image: Image.Image | None,
484
+ craft_type: str,
485
+ user_notes: str,
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
501
+ hours = float(time_hours) if time_hours else None
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()
510
+ traces: list[AgentTrace] = []
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]",
518
+ output_data={"description": description},
519
+ duration_ms=vision_ms, model_id=llm.model_id,
520
+ ))
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)
530
+ )
531
+ traces.append(catalog_trace)
532
+ result.catalog = catalog_result
533
+ except Exception as e:
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)
544
+ )
545
+ traces.append(copy_trace)
546
+ result.copy_data = copy_result
547
+ except Exception as e:
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)
558
+ )
559
+ traces.append(price_trace)
560
+ result.pricing = price_result
561
+ except Exception as e:
562
+ logger.warning("Pricer failed: %s", e)
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:
592
+ """Build the Gradio interface."""
593
+ with gr.Blocks(
594
+ title="CraftPilot \u2014 AI Craft Business Assistant",
595
+ ) as app:
596
+ # Header
597
+ gr.Markdown(
598
+ "# CraftPilot\n"
599
+ "### Photo in, marketplace listing out\n"
600
+ "Upload a photo of your handmade craft and get catalog data, "
601
+ "product copy, social captions, and fair pricing \u2014 all from a "
602
+ "single small model running locally. No cloud APIs.",
603
+ elem_id="craft-header",
604
+ )
605
+
606
+ with gr.Row(equal_height=False):
607
+ # Left: inputs
608
+ with gr.Column(scale=1, min_width=340):
609
+ with gr.Group(elem_id="input-panel"):
610
+ image_input = gr.Image(
611
+ label="Your Craft",
612
+ type="pil",
613
+ height=360,
614
+ )
615
+ craft_type = gr.Dropdown(
616
+ choices=CRAFT_TYPES,
617
+ label="Craft Type",
618
+ value="Crochet",
619
+ )
620
+ user_notes = gr.Textbox(
621
+ label="Notes (optional)",
622
+ placeholder="e.g. Made with organic cotton, took 3 evenings...",
623
+ lines=2,
624
+ )
625
+ with gr.Row():
626
+ material_cost = gr.Textbox(
627
+ label="Material Cost ($)",
628
+ placeholder="e.g. 15",
629
+ )
630
+ time_hours = gr.Textbox(
631
+ label="Time Spent (hrs)",
632
+ placeholder="e.g. 8",
633
+ )
634
+ analyze_btn = gr.Button(
635
+ "Analyze My Craft",
636
+ variant="primary",
637
+ size="lg",
638
+ elem_id="analyze-btn",
639
+ )
640
+
641
+ gr.Examples(
642
+ examples=[
643
+ ["examples/crochet.png", "Crochet", "Handmade amigurumi with cotton yarn", "16", "6"],
644
+ ["examples/embroidery.jpeg", "Embroidery", "Hand-stitched floral pattern on Jeans", "20", "10"],
645
+ ["examples/sew_keychain.jpeg", "Sewing", "Fabric keychain with felt and thread", "5", "2"],
646
+ ],
647
+ inputs=[image_input, craft_type, user_notes, material_cost, time_hours],
648
+ label="Try these examples",
649
+ )
650
+
651
+ # Right: outputs
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=[
685
+ image_input,
686
+ craft_type,
687
+ user_notes,
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"
706
+ "MiniCPM-V 2.6 (~8B) via llama.cpp \u00b7 No cloud APIs \n"
707
+ "Llama Champion \u00b7 Off the Grid \u00b7 Sharing is Caring \u00b7 Field Notes",
708
+ elem_id="craft-footer",
709
+ )
710
+
711
+ return app
712
+
713
+
714
+ if __name__ == "__main__":
715
+ # Pre-load models at startup (avoids 60s request timeout on HF Spaces)
716
+ logger.info("Pre-loading models at startup...")
717
+ try:
718
+ get_client()
719
+ logger.info("Models ready!")
720
+ except Exception as e:
721
+ logger.error("Failed to load models: %s", e)
722
+
723
+ theme = gr.themes.Soft(
724
+ primary_hue="amber",
725
+ secondary_hue="orange",
726
+ neutral_hue="stone",
727
+ font=gr.themes.GoogleFont("Source Sans 3"),
728
+ font_mono=gr.themes.GoogleFont("JetBrains Mono"),
729
+ )
730
+ app = build_ui()
731
+ app.launch(
732
+ server_name="0.0.0.0",
733
+ server_port=7860,
734
+ theme=theme,
735
+ css=CUSTOM_CSS,
736
+ head=CUSTOM_HEAD,
737
+ )
examples/crochet.png ADDED

Git LFS Details

  • SHA256: 47a0d152964c1c4342c81841276040b7708ed9ad5607c430397afa9be6216f01
  • Pointer size: 131 Bytes
  • Size of remote file: 817 kB
examples/embroidery.jpeg ADDED

Git LFS Details

  • SHA256: 4e7529dc073075fa920dd222f98e1ff2b921085cdacbebd3d1460193a597dce2
  • Pointer size: 131 Bytes
  • Size of remote file: 262 kB
examples/sew_keychain.jpeg ADDED

Git LFS Details

  • SHA256: 3a40992d592d820d1e91155307d31d1fbc01dfcf02ca9becf29d9fbca15db8a1
  • Pointer size: 131 Bytes
  • Size of remote file: 790 kB
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu/
2
+ gradio>=4.0.0
3
+ llama-cpp-python>=0.3.0
4
+ huggingface_hub>=0.20.0
5
+ Pillow>=10.0.0
6
+ pydantic>=2.0.0
7
+ Jinja2>=3.1.0