feat(moodboard): interactive analyze→ask→tailor flow + SAPL grounding + design starter kits (deepseek)

Turns the moodboard from a one-shot decoder into an interactive design partner:

- Interactive flow: analyze deck (Gemini vision) → PAUSE and ask a thorough,
  deck-seeded questionnaire (8 dimensions: direction, colour, fabric, print,
  silhouette, value-adds, commercial, visuals) generated by deepseek → tailor
  the final brief to the answers. New states awaiting_input/synthesising;
  POST /decode-pdf/{id}/answers triggers run_synthesis_job. Vision + structured
  brief stay Gemini; deepseek does questions + answer interpretation (routing
  moodboard→deepseek-v4-flash); everything falls back to Gemini/defaults.

- "What this means for SAPL": brief cross-referenced against SAPL's own fabric
  + garment libraries → make-now / similar / sourcing-gap fabrics + on-DNA vs
  new silhouettes, with a short actionable read (reasoning-dump guarded).

- Design starter kit per hero piece (digitizes SAPL's handmade techpack sheet):
  concept render + technical flat sketch (front/back line drawing) + AI-drafted
  editable structured construction spec + suggested in-library fabric + on-DNA
  badge; PATCH /decode-pdf/{id}/starter-kit persists spec edits.

- Report made calm + collapsible like the scraper: collapsible SectionCards
  (dense sections collapsed by default), collapsible SAPL-grounding + starter-kit
  cards; compact readable type.

Backend: models/moodboard (+questions/answers/sapl_grounding/starter_kits, rbac
self-heal), moodboard_pdf_service split (run_decode_job pauses at questions;
run_synthesis_job), llm/routing moodboard→deepseek. tsc -b + build + py_compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ajay Sable
2026-07-27 13:06:48 +05:30
co-authored by Claude Opus 4.8
parent 4c9486dca2
commit da212d0606
8 changed files with 912 additions and 68 deletions
+8
View File
@@ -30,7 +30,15 @@ class MoodboardDecode(Base):
current_page_status = Column(String, nullable=True)
page_results = Column(Text, default="[]")
# Interactive clarify step: AI-generated questionnaire + the user's answers.
questions = Column(Text, default="[]") # JSON list of {id, question, help, type, options, allow_other, allow_decide}
answers = Column(Text, default="{}") # JSON map of question_id → {values: [...], other: str}
brief = Column(Text, default="{}")
# "What this means for SAPL" — brief cross-referenced against SAPL's own fabric
# + garment libraries (have / similar / sourcing-gap fabrics, on-DNA silhouettes).
sapl_grounding = Column(Text, default="{}")
# Per-hero-piece techpack starter: concept + flat sketch + structured construction spec.
starter_kits = Column(Text, default="[]")
library_matches = Column(Text, default="[]")
concept_images = Column(Text, default="[]")
print_swatches = Column(Text, default="[]")
+52 -2
View File
@@ -6,7 +6,7 @@ import tempfile
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -16,7 +16,7 @@ from models.moodboard import MoodboardDecode
from models.user import User
from prompts.moodboard_prompt import MOODBOARD_PROMPT
from routers.auth import current_user, require_permission
from services.moodboard_pdf_service import run_decode_job
from services.moodboard_pdf_service import run_decode_job, run_synthesis_job
from services.rbac import can_access_owned, owner_scope_query
logger = logging.getLogger(__name__)
@@ -191,7 +191,11 @@ def _decode_to_dict(decode: MoodboardDecode) -> dict:
"processed": decode.processed,
"current_page_status": decode.current_page_status,
"page_results": json.loads(decode.page_results or "[]"),
"questions": json.loads(decode.questions or "[]"),
"answers": json.loads(decode.answers or "{}"),
"brief": json.loads(decode.brief or "{}"),
"sapl_grounding": json.loads(decode.sapl_grounding or "{}"),
"starter_kits": json.loads(decode.starter_kits or "[]"),
"library_matches": json.loads(decode.library_matches or "[]"),
"concept_images": json.loads(decode.concept_images or "[]"),
"print_swatches": json.loads(decode.print_swatches or "[]"),
@@ -240,6 +244,35 @@ async def start_pdf_decode(
return {"id": decode.id, "status": decode.status}
@router.post("/decode-pdf/{decode_id}/answers", status_code=202,
dependencies=[Depends(require_permission("moodboard", "edit"))])
def submit_decode_answers(
decode_id: str,
background_tasks: BackgroundTasks,
body: dict = Body(...),
db: Session = Depends(get_db),
user: User = Depends(current_user),
):
"""Submit the designer's answers to the clarifying questions → triggers the
tailored synthesis (deepseek interprets the answers, Gemini writes the brief).
Body: {"answers": {question_id: {"values": [...], "other": "..."}}}."""
decode = _owned_decode_or_404(decode_id, db, user)
if decode.status not in ("awaiting_input", "questioning"):
raise HTTPException(status_code=400, detail={
"error": f"Decode is '{decode.status}', not awaiting answers.", "code": "NOT_AWAITING_INPUT"})
answers = body.get("answers")
if not isinstance(answers, dict):
raise HTTPException(status_code=400, detail={"error": "answers must be an object.", "code": "BAD_ANSWERS"})
decode.answers = json.dumps(answers)
decode.status = "synthesising"
decode.current_page_status = "Tailoring the brief to your answers…"
db.commit()
background_tasks.add_task(run_synthesis_job, decode_id=decode.id, embedding_svc=_embedding_svc)
return {"id": decode.id, "status": decode.status}
def _owned_decode_or_404(decode_id: str, db: Session, user: User) -> MoodboardDecode:
"""Fetch a decode the current user is allowed to see, else 404 (a decode the
user doesn't own is indistinguishable from a missing one)."""
@@ -293,6 +326,23 @@ def update_item_selection(decode_id: str, body: ItemSelectionUpdate, db: Session
return _decode_to_dict(decode)
@router.patch("/decode-pdf/{decode_id}/starter-kit", dependencies=[Depends(require_permission("moodboard", "edit"))])
def update_starter_kit(decode_id: str, body: dict = Body(...), db: Session = Depends(get_db),
user: User = Depends(current_user)):
"""Edit a hero piece's construction spec in its starter kit.
Body: {"index": int, "spec": {field: value, ...}} — merges into that kit's spec."""
decode = _owned_decode_or_404(decode_id, db, user)
kits = json.loads(decode.starter_kits or "[]")
idx = body.get("index")
spec = body.get("spec")
if not isinstance(idx, int) or idx < 0 or idx >= len(kits) or not isinstance(spec, dict):
raise HTTPException(status_code=400, detail={"error": "index/spec invalid.", "code": "BAD_KIT_EDIT"})
kits[idx]["spec"] = {**(kits[idx].get("spec") or {}), **{k: str(v) for k, v in spec.items()}}
decode.starter_kits = json.dumps(kits)
db.commit()
return _decode_to_dict(decode)
@router.patch("/decode-pdf/{decode_id}/notes", dependencies=[Depends(require_permission("moodboard", "edit"))])
def update_section_note(decode_id: str, body: SectionNoteUpdate, db: Session = Depends(get_db),
user: User = Depends(current_user)):
+3 -2
View File
@@ -26,8 +26,9 @@ _DEFAULT_ROUTES: dict[str, dict] = {
# Trend scraper — reasoning/tool-orchestration on minimax (reliable in test),
# Gemini as the cross-provider fallback. Image analysis stays Gemini (tools).
"scraper": {"provider": "opencode", "model": "minimax-m3", "temperature": 0.2},
# Moodboard decode — deepseek/glm route lands in Step 3b (still Gemini today).
"moodboard": {"provider": "gemini"},
# Moodboard — the REASONING steps (clarifying questions + interpreting the
# user's answers) run on deepseek; vision + the structured brief stay on Gemini.
"moodboard": {"provider": "opencode", "model": "deepseek-v4-flash", "temperature": 0.4},
# Ask bar: /ask stays single-model Gemini; MOA is its own endpoint.
"chatbot": {"provider": "gemini"},
}
+518 -35
View File
@@ -9,6 +9,7 @@ persisted on the MoodboardDecode row so the frontend can poll it.
import asyncio
import json
import logging
import re
from datetime import datetime
from pathlib import Path
@@ -27,6 +28,7 @@ from prompts.moodboard_pdf_prompts import (
SYNTHESIS_TOOL,
)
from services.embedding_service import EmbeddingService
from services.llm.routing import route
from services.pdf_service import extract_pages_as_images
logger = logging.getLogger(__name__)
@@ -83,7 +85,16 @@ async def _call_gemini_page_analysis(image_path: Path, page_text: str) -> dict:
thinking_config=types.ThinkingConfig(thinking_budget=1024),
),
)
return json.loads(response.text)
parsed = json.loads(response.text)
# Gemini vision usually returns the page object, but occasionally wraps it in
# an array or returns a scalar — coerce to a dict so the caller can index it.
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list):
for el in parsed:
if isinstance(el, dict):
return el
return {}
def _to_jsonable(obj):
@@ -119,6 +130,378 @@ def _call_synthesis_tool(system_prompt: str, user_prompt: str) -> dict:
raise ValueError("synthesis model returned no function call")
# ── Interactive clarify: deepseek/glm reasoning (questions + answer interpretation) ──
def _extract_json(text: str):
"""Pull a JSON array/object out of a model reply (handles ```fences``` and
leading prose). Returns the parsed value or None."""
if not text:
return None
t = text.strip()
m = re.search(r"```(?:json)?\s*(.+?)```", t, re.DOTALL)
if m:
t = m.group(1).strip()
for open_c, close_c in (("[", "]"), ("{", "}")):
i, j = t.find(open_c), t.rfind(close_c)
if 0 <= i < j:
try:
return json.loads(t[i:j + 1])
except Exception:
pass
try:
return json.loads(t)
except Exception:
return None
async def _reason_json(system_prompt: str, user_prompt: str, max_tokens: int = 4096):
"""Route a JSON-returning reasoning call to the moodboard's model (deepseek
on OpenCode Go). Returns parsed JSON, or None if unavailable/unparseable —
callers supply their own safe fallback so the flow never breaks."""
cfg = route("moodboard")
if cfg.get("provider") != "opencode" or not (settings.opencode_go_api_key or "").strip():
return None
try:
from services.llm.opencode_client import OpenCodeGoClient
client = OpenCodeGoClient()
try:
out = await client.chat(
cfg["model"],
[{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}],
temperature=cfg.get("temperature", 0.4), max_tokens=max_tokens,
)
finally:
await client.close()
return _extract_json(out.get("text") or "")
except Exception as e:
logger.warning(f"Moodboard reasoning (opencode) failed: {e!r}")
return None
def _page_summary(page_results: list[dict]) -> dict:
"""Aggregate the per-page vision analysis into a compact summary for the
reasoning model (colours / fabrics / prints / silhouettes / phases / notes)."""
agg = {"colours": set(), "fabrics": set(), "prints": set(), "silhouettes": set(), "phases": set()}
notes: list[str] = []
for p in page_results:
for k in ("colours", "fabrics", "prints", "silhouettes"):
for v in (p.get(k) or []):
name = v.get("name") if isinstance(v, dict) else v
if name:
agg[k].add(str(name))
if p.get("phase"):
agg["phases"].add(str(p["phase"]))
if p.get("key_text"):
notes.append(str(p["key_text"])[:180])
out = {k: sorted(v)[:12] for k, v in agg.items()}
out["notes"] = " ".join(notes)[:800]
return out
# Safe static fallback if deepseek is unavailable — a thorough default so no key
# design dimension is missed even without the model.
_DEFAULT_QUESTIONS = [
{"id": "direction", "question": "What design direction should I lead with?",
"help": "Shapes the whole brief", "type": "multi",
"options": ["Relaxed / resort", "Utility / workwear", "Romantic / feminine",
"Heritage / tailored", "Minimal / clean", "Bold / statement"],
"allow_other": True, "allow_decide": True},
{"id": "colour_emphasis", "question": "How should the colour palette be weighted?",
"help": "Where the story leans", "type": "single",
"options": ["Lead with hero neutrals", "Lead with accent brights", "Balanced neutrals + pops",
"Tonal / muted throughout"], "allow_other": True, "allow_decide": True},
{"id": "fabric_direction", "question": "Which fabric directions should I prioritise?",
"help": "Qualities, weights, handle", "type": "multi",
"options": ["Soft-handle wovens", "Structured / tailored wovens", "Fine knits / jersey",
"Denim", "Sheers / lightweight", "Textured (tweed, bouclé)", "Sustainable / natural"],
"allow_other": True, "allow_decide": True},
{"id": "print_pattern", "question": "Which print & pattern directions matter?",
"help": "Motifs, scale, technique", "type": "multi",
"options": ["Florals", "Checks / plaids", "Stripes", "Abstract / geometric",
"Placement prints", "Embroidery / broderie", "Solids only (no print)"],
"allow_other": True, "allow_decide": True},
{"id": "silhouette_focus", "question": "Which silhouettes / categories to prioritise?",
"help": "Where to concentrate", "type": "multi",
"options": ["Dresses", "Tops / blouses", "Trousers / bottoms", "Outerwear / jackets",
"Knitwear", "Co-ord sets"], "allow_other": True, "allow_decide": True},
{"id": "value_adds", "question": "Which techniques / value-adds are in scope?",
"help": "SAPL execution levers", "type": "multi",
"options": ["Embroidery", "Prints", "Washes / garment-dye", "Pleating / smocking",
"Lace / crochet", "Trims / hardware", "Keep it clean (minimal)"],
"allow_other": True, "allow_decide": True},
{"id": "commercial", "question": "What's the commercial priority?",
"help": "Steer emphasis", "type": "multi",
"options": ["Hero / statement pieces", "Volume / commercial staples", "Newness / trend-forward",
"Price-led / value", "Full range plan"], "allow_other": True, "allow_decide": True},
{"id": "visuals", "question": "Should I generate concept visuals?",
"help": "Adds a few minutes if yes", "type": "single",
"options": ["Yes — garment / silhouette concepts", "Yes — print & fabric swatches",
"Yes — everything", "No, just the written brief"],
"allow_other": False, "allow_decide": True},
]
async def generate_moodboard_questions(page_results: list[dict], buyer: str | None, season: str | None) -> list[dict]:
"""Ask deepseek to produce a tailored clarifying questionnaire from what the
moodboard actually contains. Falls back to a sensible static set."""
summary = _page_summary(page_results)
system = (
"You are a senior fashion designer running a design-direction interview with a buyer, using "
"their moodboard, for SAPL (a garment manufacturer). Produce a THOROUGH, PRECISE questionnaire "
"(6-8 questions) so the designer can give clear guidelines UP FRONT and NOTHING is missed in "
"the final brief. Cover these dimensions AS SEPARATE QUESTIONS wherever the deck gives material: "
"(1) overall DIRECTION to lead with; (2) COLOUR emphasis (hero vs accent, any to drop/add); "
"(3) FABRIC direction (qualities / weights / handle to prioritise); (4) PRINT & PATTERN direction "
"(which motifs, scale, technique); (5) SILHOUETTE / category focus; (6) VALUE-ADDS / techniques "
"(embroidery, washes, pleating, lace, trims); (7) COMMERCIAL priority (hero vs volume vs newness "
"vs price); (8) whether to generate concept VISUALS. "
"SEED every option list from the ACTUAL moodboard content (real detected colours, fabrics, prints, "
"silhouettes) — never generic filler. Options short (2-6 words). Skip a dimension ONLY if the deck "
"truly has no material for it. "
'Return ONLY a JSON array; each item = {"id": snake_case, "question": str, "help": short str, '
'"type": "single"|"multi", "options": [str], "allow_other": true, "allow_decide": true}.'
)
user = (f"Buyer: {buyer or 'unspecified'} · Season: {season or 'unspecified'}\n"
f"Moodboard analysis:\n{json.dumps(summary, indent=2)}")
parsed = await _reason_json(system, user, max_tokens=6000)
if isinstance(parsed, list) and parsed and all(isinstance(q, dict) and q.get("question") for q in parsed):
out = []
for i, q in enumerate(parsed[:8]):
out.append({
"id": str(q.get("id") or f"q{i}"),
"question": str(q.get("question")),
"help": str(q.get("help", "")),
"type": "multi" if q.get("type") == "multi" else "single",
"options": [str(o) for o in (q.get("options") or [])][:8],
"allow_other": bool(q.get("allow_other", True)),
"allow_decide": bool(q.get("allow_decide", True)),
})
return out
return _DEFAULT_QUESTIONS
def _answers_text(answers: dict) -> str:
parts = []
for qid, a in (answers or {}).items():
vals = a.get("values") if isinstance(a, dict) else a
other = a.get("other") if isinstance(a, dict) else None
chunk = ", ".join(str(v) for v in (vals or [])) if isinstance(vals, list) else str(vals or "")
if other:
chunk = (chunk + f"; {other}").strip("; ")
if chunk:
parts.append(f"{qid}: {chunk}")
return " | ".join(parts)
def _wants_visuals(answers: dict) -> bool:
txt = _answers_text(answers).lower()
if re.search(r"\bno\b.*(visual|brief only|just the|written)", txt) or "just the written" in txt:
return False
return True
async def interpret_answers(page_results: list[dict], questions: list[dict], answers: dict) -> dict:
"""deepseek reads the analysis + the designer's answers and distils a concise
creative STEER that tailors the final brief (and gates visual generation)."""
summary = _page_summary(page_results)
system = (
"You are a senior fashion designer. Given the moodboard analysis, the questions asked, and "
"the designer's answers, distil a concise creative STEER for the final brief. "
'Return ONLY JSON: {"direction": str, "focus_areas": [str], "generate_visuals": bool, '
'"visual_types": ["concepts"|"prints"|"fabrics"], "emphasis": str}. '
"generate_visuals + visual_types must reflect what the designer asked for."
)
user = (f"Analysis:\n{json.dumps(summary, indent=2)}\n\n"
f"Questions:\n{json.dumps(questions, indent=2)}\n\n"
f"Answers:\n{json.dumps(answers, indent=2)}")
parsed = await _reason_json(system, user)
if isinstance(parsed, dict):
vt = [str(x).lower() for x in (parsed.get("visual_types") or [])]
vt = [x for x in vt if x in ("concepts", "prints", "fabrics")] or ["concepts", "prints", "fabrics"]
return {
"direction": str(parsed.get("direction", ""))[:400],
"focus_areas": [str(x) for x in (parsed.get("focus_areas") or [])][:8],
"generate_visuals": bool(parsed.get("generate_visuals", True)),
"visual_types": vt,
"emphasis": str(parsed.get("emphasis", ""))[:600],
}
# Fallback: derive from the raw answers so the flow still tailors + gates visuals.
return {
"direction": "", "focus_areas": [], "generate_visuals": _wants_visuals(answers),
"visual_types": ["concepts", "prints", "fabrics"], "emphasis": _answers_text(answers)[:600],
}
async def _reason_text(system_prompt: str, user_prompt: str, max_tokens: int = 1200):
"""Like _reason_json but returns free text (deepseek) or None."""
cfg = route("moodboard")
if cfg.get("provider") != "opencode" or not (settings.opencode_go_api_key or "").strip():
return None
try:
from services.llm.opencode_client import OpenCodeGoClient
client = OpenCodeGoClient()
try:
out = await client.chat(
cfg["model"], [{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}],
temperature=cfg.get("temperature", 0.4), max_tokens=max_tokens)
finally:
await client.close()
return (out.get("text") or "").strip() or None
except Exception as e:
logger.warning(f"Moodboard ground-summary (opencode) failed: {e!r}")
return None
def _build_sapl_grounding(brief: dict, db) -> dict:
"""Cross-reference the brief against SAPL's OWN fabric + garment libraries →
what SAPL can make now, what's a sourcing gap, and which silhouettes are on-DNA.
Reuses the scraper's library-check tools."""
from services.scraper_tools import check_fabric_library, check_garment_library
fd = brief.get("fabric_direction") or {}
terms: list[str] = []
for cat in ("fancies", "craft", "hardworking"):
for f in fd.get(cat) or []:
t = f.get("name") if isinstance(f, dict) else f
if isinstance(t, str) and t.strip():
terms.append(t.strip())
terms = list(dict.fromkeys(terms))[:10]
def fabric_avail(term: str) -> dict:
r = check_fabric_library(term, db)
if r.get("matches", 0) > 0:
eg = (r.get("rows") or [{}])[0]
return {"material": term, "status": "have", "matches": r["matches"], "via": term, "eg": eg.get("design_no")}
# No whole-term match → probe the meaningful words (e.g. "cotton", "poplin").
for w in sorted({w for w in re.split(r"[\s/,&()-]+", term.lower()) if len(w) > 3}, key=len, reverse=True):
r = check_fabric_library(w, db)
if r.get("matches", 0) > 0:
eg = (r.get("rows") or [{}])[0]
return {"material": term, "status": "similar", "matches": r["matches"], "via": w, "eg": eg.get("design_no")}
return {"material": term, "status": "gap", "matches": 0, "via": term, "eg": None}
fabrics = [fabric_avail(t) for t in terms]
silhouettes = []
for s in (brief.get("key_silhouettes") or [])[:8]:
name = (s.get("name") if isinstance(s, dict) else str(s)) or ""
cat = s.get("category") if isinstance(s, dict) else None
r = check_garment_library(cat, None, None, None, name, db)
silhouettes.append({"silhouette": name, "on_dna": r.get("matches", 0) > 0, "matches": r.get("matches", 0)})
return {
"fabrics": fabrics,
"silhouettes": silhouettes,
"have": [f["material"] for f in fabrics if f["status"] == "have"],
"similar": [f["material"] for f in fabrics if f["status"] == "similar"],
"gaps": [f["material"] for f in fabrics if f["status"] == "gap"],
}
def _clean_read(txt: str | None) -> str | None:
"""Reject a raw chain-of-thought dump (some models emit their 'Thinking'
steps as the answer) so we fall back to the clean template instead."""
if not txt:
return None
t = txt.strip()
low = t.lower()
if (low.startswith("thinking") or "analyze the request" in low or "synthesize the key" in low
or "analyze the input" in low or t.count("**") > 6 or "1. **" in t):
return None
return t[:900].strip()
def _templated_read(grounding: dict) -> str:
have, sim, gaps = grounding.get("have") or [], grounding.get("similar") or [], grounding.get("gaps") or []
sils = grounding.get("silhouettes") or []
ondna = [s["silhouette"] for s in sils if s.get("on_dna")]
new = [s["silhouette"] for s in sils if not s.get("on_dna")]
parts = []
if have or sim:
parts.append(f"SAPL can work {len(have) + len(sim)} of the brief's fabric directions from its own "
f"library ({', '.join((have + sim)[:4])}) — no long-lead sourcing needed there.")
if gaps:
parts.append(f"Sourcing gaps to develop: {', '.join(gaps[:5])}.")
if ondna:
parts.append(f"On-DNA silhouettes SAPL has made before: {', '.join(ondna[:4])} — reuse those blocks first.")
if new and not ondna:
parts.append(f"All {len(new)} requested silhouettes are new territory — the fabric library is the "
f"strength; the shapes need new blocks, so prototype the lowest-risk one first.")
elif new:
parts.append(f"New-territory silhouettes needing development: {', '.join(new[:4])}.")
return " ".join(parts) or "No direct matches in SAPL's libraries — largely new territory for this brief."
async def _ground_summary(grounding: dict, buyer: str | None, season: str | None) -> str:
"""A short, actionable 'what this means for SAPL' read for the design team."""
system = (
"You are SAPL's senior designer/merchandiser. Write a SHORT actionable read for the design team "
"(3-5 sentences MAX, plain prose) on how the buyer's brief maps to SAPL's OWN fabric + garment "
"libraries: what we can make now, what to source, which silhouettes are on-DNA vs new, and the "
"smartest first move. Output ONLY the final read — NO 'Thinking', NO numbered analysis, NO "
"headings, NO markdown bold, NO JSON. Begin directly with the recommendation."
)
user = json.dumps(grounding, indent=2)[:3000] + f"\n\nBuyer: {buyer or '—'} · Season: {season or '—'}"
txt = _clean_read(await _reason_text(system, user, max_tokens=700))
return txt or _templated_read(grounding)
# ── Design starter kits — per-hero-piece techpack starter (spec + flat sketch) ──
_SPEC_FIELDS = ["silhouette", "neckline", "collar", "sleeve", "cuff",
"closure", "pockets", "seams", "hem", "details", "key_measures"]
async def _draft_construction_specs(silhouettes: list[dict]) -> list[dict]:
"""deepseek drafts a structured construction spec per hero silhouette — a
techpack starter the pattern/sampling team can act on. Falls back to a blank
skeleton (all fields '—') so the designer can still fill it in."""
items = [{"name": (s.get("name") if isinstance(s, dict) else str(s)) or "",
"description": (s.get("description") if isinstance(s, dict) else "") or ""}
for s in (silhouettes or [])[:_CONCEPT_IMAGE_COUNT]]
if not items:
return []
system = (
"You are a senior garment technician writing techpack starter specs for a manufacturer. For EACH "
"hero silhouette, draft a concise CONSTRUCTION SPEC as structured fields the pattern/sampling team "
"can act on. Infer sensible construction from the silhouette name + description; keep each field to a "
"short phrase. Return ONLY a JSON array, one object per silhouette IN THE SAME ORDER, each = "
'{"silhouette": short garment type, "neckline": str, "collar": str, "sleeve": str, "cuff": str, '
'"closure": str, "pockets": str, "seams": str, "hem": str, "details": str, "key_measures": str}. '
"Use '—' for a field that genuinely doesn't apply. No prose, no reasoning."
)
parsed = await _reason_json(system, json.dumps(items, indent=2), max_tokens=4000)
specs = []
for i, s in enumerate(items):
p = parsed[i] if isinstance(parsed, list) and i < len(parsed) and isinstance(parsed[i], dict) else {}
spec = {f: (str(p.get(f, "")).strip() or "—") for f in _SPEC_FIELDS}
if spec["silhouette"] == "—":
spec["silhouette"] = s["name"] or "—"
specs.append(spec)
return specs
def _build_flat_sketch_prompts(silhouettes: list[dict], specs: list[dict]) -> list[dict]:
"""Technical flat sketch (front + back line drawing) per hero silhouette."""
out = []
for i, sil in enumerate((silhouettes or [])[:_CONCEPT_IMAGE_COUNT]):
name = (sil.get("name") if isinstance(sil, dict) else str(sil)) or "garment"
desc = (sil.get("description") if isinstance(sil, dict) else "") or ""
spec = specs[i] if i < len(specs) else {}
detail = ", ".join(v for k, v in spec.items()
if k in ("neckline", "collar", "sleeve", "closure", "pockets", "hem") and v and v != "—")
prompt = (
f"Technical fashion flat sketch (CAD garment flat) of a {name}, FRONT and BACK views side by side, "
f"clean thin black line drawing on a plain white background, no shading, no colour, no model, no "
f"mannequin. Show the construction: {detail or desc or name}. Precise, symmetrical, production-style "
f"flat for a techpack. No text, no annotations, no watermark."
)
out.append({"index": i, "prompt": prompt})
return out
def _trim_page_for_synthesis(p: dict) -> dict:
"""Cap list lengths and text fields so a deck's full page set stays well
under the synthesis model's per-request token budget."""
@@ -132,7 +515,8 @@ def _trim_page_for_synthesis(p: dict) -> dict:
return trimmed
def _synthesize_brief(page_results: list[dict], buyer_name: str | None, season: str | None) -> dict:
def _synthesize_brief(page_results: list[dict], buyer_name: str | None, season: str | None,
steer: dict | None = None, answers: dict | None = None) -> dict:
"""Roll all per-page analyses into one master design brief via a forced
tool call. Pages with nothing extracted are dropped to keep the prompt lean.
@@ -146,9 +530,19 @@ def _synthesize_brief(page_results: list[dict], buyer_name: str | None, season:
if p.get("colours") or p.get("fabrics") or p.get("prints") or p.get("silhouettes") or p.get("key_text")
]
answers_text = _answers_text(answers or {})
steer_note = ("Honour the designer_direction and designer_answers below — tailor EVERY section "
"(colour, FABRIC direction, PRINT & PATTERN, silhouettes, value-adds, phasing) to "
"them; populate each section they asked for, don't skip fabric or prints.\n"
if (steer or answers_text) else "")
def synth_chunk(pages: list[dict]) -> dict:
facts = {"buyer_name": buyer_name, "season": season, "pages": pages}
return _call_synthesis_tool(SYNTHESIS_SYSTEM, f"Synthesise this design brief.\n\n{json.dumps(facts, indent=2)}")
if steer:
facts["designer_direction"] = steer
if answers_text:
facts["designer_answers"] = answers_text
return _call_synthesis_tool(SYNTHESIS_SYSTEM, f"Synthesise this design brief.\n{steer_note}\n{json.dumps(facts, indent=2)}")
if len(relevant_pages) <= _SYNTH_CHUNK_SIZE:
return synth_chunk(relevant_pages)
@@ -540,14 +934,55 @@ async def run_decode_job(decode_id: str, pdf_bytes: bytes, embedding_svc: Embedd
if i < total:
await asyncio.sleep(5)
# Synthesis
decode.status = "synthesising"
decode.current_page_status = "Writing the master brief…"
# Clarify — generate a tailored questionnaire, then PAUSE for the designer's
# answers. Synthesis + visuals run in run_synthesis_job once they respond.
decode.status = "questioning"
decode.current_page_status = "Reviewing the moodboard & preparing questions…"
db.commit()
try:
brief = _synthesize_brief(page_results, decode.buyer_name, decode.season)
questions = await generate_moodboard_questions(page_results, decode.buyer_name, decode.season)
except Exception as e:
logger.error(f"Moodboard decode {decode_id}: synthesis failed: {e}", exc_info=True)
logger.warning(f"Moodboard decode {decode_id}: question generation failed ({e}); using defaults")
questions = _DEFAULT_QUESTIONS
decode.questions = json.dumps(questions)
decode.status = "awaiting_input"
decode.current_page_status = None
db.commit()
logger.info(f"Moodboard decode {decode_id}: {len(questions)} questions ready — awaiting designer input")
finally:
db.close()
async def run_synthesis_job(decode_id: str, embedding_svc: EmbeddingService | None) -> None:
"""Stage 2 — after the designer answers the clarifying questions: interpret the
answers (deepseek) into a creative steer → synthesise the tailored brief (Gemini)
→ match the library → conditionally generate visuals → complete."""
db = SessionLocal()
try:
decode = db.query(MoodboardDecode).filter(MoodboardDecode.id == decode_id).first()
if not decode:
logger.error(f"Moodboard synthesis {decode_id}: not found")
return
images_dir = Path(settings.images_path)
page_results = json.loads(decode.page_results or "[]")
questions = json.loads(decode.questions or "[]")
answers = json.loads(decode.answers or "{}")
decode.status = "synthesising"
decode.current_page_status = "Tailoring the brief to your answers…"
db.commit()
try:
steer = await interpret_answers(page_results, questions, answers)
except Exception as e:
logger.warning(f"Moodboard synthesis {decode_id}: answer interpretation failed ({e})")
steer = {"generate_visuals": _wants_visuals(answers), "visual_types": ["concepts", "prints", "fabrics"],
"direction": "", "focus_areas": [], "emphasis": _answers_text(answers)[:600]}
try:
brief = _synthesize_brief(page_results, decode.buyer_name, decode.season, steer, answers)
except Exception as e:
logger.error(f"Moodboard synthesis {decode_id}: synthesis failed: {e}", exc_info=True)
decode.status = "failed"
decode.error = f"Synthesis failed: {e}"
decode.current_page_status = None
@@ -562,53 +997,101 @@ async def run_decode_job(decode_id: str, pdf_bytes: bytes, embedding_svc: Embedd
try:
matches = _match_library(brief, embedding_svc, db)
except Exception as e:
logger.warning(f"Moodboard decode {decode_id}: library matching failed: {e}")
logger.warning(f"Moodboard synthesis {decode_id}: library matching failed: {e}")
matches = []
decode.library_matches = json.dumps(matches)
try:
item_matches = _match_library_per_item(brief, embedding_svc, db)
except Exception as e:
logger.warning(f"Moodboard decode {decode_id}: per-item library matching failed: {e}")
logger.warning(f"Moodboard synthesis {decode_id}: per-item matching failed: {e}")
item_matches = {}
decode.item_library_matches = json.dumps(item_matches)
db.commit()
# Visualisation — concept images, print/pattern swatches, and fabric
# swatches, all grounded strictly in the decoded brief. Generated as
# one concurrency-bounded batch so the ~15-20 independent image calls
# overlap instead of running one at a time.
decode.status = "visualising"
decode.current_page_status = "Visualising concepts & generating swatches…"
# "What this means for SAPL" — ground the brief in SAPL's own fabric +
# garment libraries (make-now / sourcing gaps / on-DNA silhouettes).
try:
grounding = _build_sapl_grounding(brief, db)
grounding["summary"] = await _ground_summary(grounding, decode.buyer_name, decode.season)
except Exception as e:
logger.warning(f"Moodboard synthesis {decode_id}: SAPL grounding failed: {e}")
grounding = {}
decode.sapl_grounding = json.dumps(grounding)
db.commit()
image_items: list[dict] = []
for prompt in _build_concept_image_prompts(brief):
image_items.append({"kind": "concept", "prompt": prompt})
for swatch in _build_print_swatch_prompts(brief):
image_items.append({"kind": "swatch", "label": swatch["label"], "type": swatch["type"], "prompt": swatch["prompt"]})
for swatch in _build_fabric_swatch_prompts(brief):
image_items.append({"kind": "fabric", "label": swatch["label"], "category": swatch["category"], "prompt": swatch["prompt"]})
# Draft per-hero-piece construction specs (deepseek) — always (cheap, high value).
try:
specs = await _draft_construction_specs(brief.get("key_silhouettes") or [])
except Exception as e:
logger.warning(f"Moodboard synthesis {decode_id}: spec drafting failed: {e}")
specs = []
generated = await _generate_image_batch(decode_id, images_dir, image_items)
# Visuals — only if the designer asked for them (gated by the steer).
concept_filenames: list = []
print_swatches: list = []
fabric_swatches: list = []
flat_by_index: dict = {}
if steer.get("generate_visuals", True):
vt = set(steer.get("visual_types") or ["concepts", "prints", "fabrics"])
decode.status = "visualising"
decode.current_page_status = "Visualising concepts, flats & swatches…"
db.commit()
image_items: list[dict] = []
if "concepts" in vt:
for prompt in _build_concept_image_prompts(brief):
image_items.append({"kind": "concept", "prompt": prompt})
# Technical flats for the starter kits (front/back line drawings).
for fp in _build_flat_sketch_prompts(brief.get("key_silhouettes") or [], specs):
image_items.append({"kind": "flat", "index": fp["index"], "prompt": fp["prompt"]})
if "prints" in vt:
for swatch in _build_print_swatch_prompts(brief):
image_items.append({"kind": "swatch", "label": swatch["label"], "type": swatch["type"], "prompt": swatch["prompt"]})
if "fabrics" in vt:
for swatch in _build_fabric_swatch_prompts(brief):
image_items.append({"kind": "fabric", "label": swatch["label"], "category": swatch["category"], "prompt": swatch["prompt"]})
if image_items:
generated = await _generate_image_batch(decode_id, images_dir, image_items)
concept_filenames = [g["image"] for g in generated if g["kind"] == "concept"]
print_swatches = [{"label": g["label"], "type": g["type"], "image": g["image"]} for g in generated if g["kind"] == "swatch"]
fabric_swatches = [{"label": g["label"], "category": g["category"], "image": g["image"]} for g in generated if g["kind"] == "fabric"]
flat_by_index = {g["index"]: g["image"] for g in generated if g.get("kind") == "flat" and g.get("image")}
concept_filenames = [g["image"] for g in generated if g["kind"] == "concept"]
print_swatches = [{"label": g["label"], "type": g["type"], "image": g["image"]} for g in generated if g["kind"] == "swatch"]
fabric_swatches = [{"label": g["label"], "category": g["category"], "image": g["image"]} for g in generated if g["kind"] == "fabric"]
# Assemble the per-hero-piece design starter kits (concept + flat + spec + fabric).
sils = brief.get("key_silhouettes") or []
avail = (grounding.get("have") or []) + (grounding.get("similar") or [])
ground_sils = {s.get("silhouette"): s for s in grounding.get("silhouettes", [])}
starter_kits = []
for i, sil in enumerate(sils[:_CONCEPT_IMAGE_COUNT]):
name = (sil.get("name") if isinstance(sil, dict) else str(sil)) or ""
starter_kits.append({
"name": name,
"description": (sil.get("description") if isinstance(sil, dict) else "") or "",
"spec": specs[i] if i < len(specs) else {f: "—" for f in _SPEC_FIELDS},
"concept_image": concept_filenames[i] if i < len(concept_filenames) else None,
"flat_image": flat_by_index.get(i),
"suggested_fabric": (avail[i % len(avail)] if avail else None),
"on_dna": bool(ground_sils.get(name, {}).get("on_dna")),
})
decode.starter_kits = json.dumps(starter_kits)
decode.concept_images = json.dumps(concept_filenames)
decode.print_swatches = json.dumps(print_swatches)
decode.fabric_swatches = json.dumps(fabric_swatches)
decode.status = "completed"
decode.current_page_status = None
decode.completed_at = datetime.utcnow()
db.commit()
logger.info(
f"Moodboard decode {decode_id}: completed ({total} pages, {len(matches)} library matches, "
f"{len(item_matches)} per-item matches, {len(concept_filenames)} concept images, "
f"{len(print_swatches)} print swatches, {len(fabric_swatches)} fabric swatches)"
)
logger.info(f"Moodboard synthesis {decode_id}: completed (visuals={steer.get('generate_visuals')}, "
f"{len(concept_filenames)} concepts, {len(print_swatches)} prints, {len(fabric_swatches)} fabrics)")
except Exception as e:
logger.error(f"Moodboard synthesis {decode_id} failed: {e}", exc_info=True)
try:
d = db.query(MoodboardDecode).filter(MoodboardDecode.id == decode_id).first()
if d and d.status != "completed":
d.status = "failed"
d.error = str(e)[:300]
db.commit()
except Exception:
pass
finally:
db.close()
+1 -1
View File
@@ -133,7 +133,7 @@ _RBAC_COLUMNS = {
"garments": [("department_id", "TEXT")],
"collections": [("department_id", "TEXT")],
"trend_searches": [("department_id", "TEXT"), ("created_by", "TEXT")],
"moodboard_decodes": [("department_id", "TEXT"), ("created_by", "TEXT")],
"moodboard_decodes": [("department_id", "TEXT"), ("created_by", "TEXT"), ("questions", "TEXT"), ("answers", "TEXT"), ("sapl_grounding", "TEXT"), ("starter_kits", "TEXT")],
"inbox_mails": [("department_id", "TEXT")],
"stitch_orders": [("department_id", "TEXT")],
}
+3 -1
View File
File diff suppressed because one or more lines are too long
+49 -1
View File
@@ -1436,17 +1436,53 @@ export interface MoodboardFabricSwatch {
image: string;
}
export interface MoodboardQuestion {
id: string;
question: string;
help: string;
type: 'single' | 'multi';
options: string[];
allow_other: boolean;
allow_decide: boolean;
}
export interface MoodboardAnswer { values: string[]; other?: string }
export interface MoodboardGroundingFabric { material: string; status: 'have' | 'similar' | 'gap'; matches: number; via: string; eg: string | null }
export interface MoodboardGroundingSilhouette { silhouette: string; on_dna: boolean; matches: number }
export interface MoodboardGrounding {
fabrics?: MoodboardGroundingFabric[];
silhouettes?: MoodboardGroundingSilhouette[];
have?: string[];
similar?: string[];
gaps?: string[];
summary?: string;
}
export interface MoodboardStarterKit {
name: string;
description: string;
spec: Record<string, string>;
concept_image: string | null;
flat_image: string | null;
suggested_fabric: string | null;
on_dna: boolean;
}
export interface MoodboardPdfDecode {
id: string;
filename: string | null;
buyer_name: string | null;
season: string | null;
status: 'extracting' | 'analysing' | 'synthesising' | 'matching' | 'visualising' | 'completed' | 'failed';
status: 'extracting' | 'analysing' | 'questioning' | 'awaiting_input' | 'synthesising' | 'matching' | 'visualising' | 'completed' | 'failed';
total_pages: number;
processed: number;
current_page_status: string | null;
page_results: MoodboardPdfPageResult[];
questions: MoodboardQuestion[];
answers: Record<string, MoodboardAnswer>;
brief: MoodboardBrief | Record<string, never>;
sapl_grounding?: MoodboardGrounding;
starter_kits?: MoodboardStarterKit[];
library_matches: MoodboardLibraryMatch[];
concept_images: string[];
print_swatches: MoodboardPrintSwatch[];
@@ -1476,6 +1512,13 @@ export async function listMoodboardPdfDecodes(): Promise<{ decodes: MoodboardPdf
return res.data;
}
export async function submitMoodboardAnswers(
id: string, answers: Record<string, MoodboardAnswer>,
): Promise<{ id: string; status: string }> {
const res = await api.post(`/moodboard/decode-pdf/${id}/answers`, { answers });
return res.data;
}
export async function updateMoodboardItemSelection(id: string, itemKey: string, selected: boolean): Promise<MoodboardPdfDecode> {
const res = await api.patch(`/moodboard/decode-pdf/${id}/selection`, { item_key: itemKey, selected });
return res.data;
@@ -1486,6 +1529,11 @@ export async function updateMoodboardSectionNote(id: string, section: string, no
return res.data;
}
export async function updateMoodboardStarterKit(id: string, index: number, spec: Record<string, string>): Promise<MoodboardPdfDecode> {
const res = await api.patch(`/moodboard/decode-pdf/${id}/starter-kit`, { index, spec });
return res.data;
}
// ── Order-mail inbox (Project P1: Gmail → WFX) ─────────────────────────────
export interface InboxMailDate {
date: string;
+278 -26
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
decodeMoodboard, decodeMoodboardPdf, getMoodboardPdfDecode, listMoodboardPdfDecodes, imageUrl,
updateMoodboardItemSelection, updateMoodboardSectionNote,
updateMoodboardItemSelection, updateMoodboardSectionNote, submitMoodboardAnswers, updateMoodboardStarterKit,
type MoodboardResponse, type MoodColour, type SimilarItem,
type MoodboardPdfDecode, type MoodboardPdfColour, type MoodboardLibraryMatch,
type MoodboardQuestion, type MoodboardAnswer, type MoodboardGrounding, type MoodboardStarterKit,
} from '../api/client';
import { PageHeader, TabGroup } from '../components/editorial/page';
import ShareToStudioButton from '../components/ShareToStudioButton';
@@ -328,7 +329,8 @@ function PdfDecodeProgress({ decode, onReset }: { readonly decode: MoodboardPdfD
const STAGE_LABEL: Record<string, string> = {
extracting: 'Extracting pages…',
analysing: 'Reading the deck, page by page',
synthesising: 'Writing the master brief',
questioning: 'Preparing a few questions…',
synthesising: 'Tailoring the brief to your answers',
matching: 'Matching against the library',
visualising: 'Visualising concept garments',
};
@@ -349,6 +351,93 @@ function PdfDecodeProgress({ decode, onReset }: { readonly decode: MoodboardPdfD
);
}
function QChip({ label, active, onClick }: { readonly label: string; readonly active: boolean; readonly onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={`text-[12.5px] leading-snug rounded-full px-3.5 py-2 border transition-colors ${
active ? 'bg-ink text-paper border-ink' : 'bg-paper text-ink/80 border-line hover:border-ink/40'}`}
>
{label}
</button>
);
}
function PdfDecodeQuestions({ decode, onSubmit, onReset }: {
readonly decode: MoodboardPdfDecode;
readonly onSubmit: (answers: Record<string, MoodboardAnswer>) => Promise<void>;
readonly onReset: () => void;
}) {
const [answers, setAnswers] = useState<Record<string, MoodboardAnswer>>({});
const [submitting, setSubmitting] = useState(false);
const DECIDE = 'Decide for me';
const toggle = (q: MoodboardQuestion, opt: string) => setAnswers((prev) => {
const cur = prev[q.id]?.values ?? [];
const values = q.type === 'single'
? (cur[0] === opt ? [] : [opt])
: (cur.includes(opt) ? cur.filter((v) => v !== opt) : [...cur, opt]);
return { ...prev, [q.id]: { ...prev[q.id], values } };
});
const setOther = (q: MoodboardQuestion, other: string) => setAnswers((prev) => ({
...prev, [q.id]: { values: prev[q.id]?.values ?? [], other },
}));
async function submit() {
setSubmitting(true);
try { await onSubmit(answers); } finally { setSubmitting(false); }
}
return (
<div className="flex flex-col gap-6 anim-fade-up max-w-3xl">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<span className="font-plex text-[10px] uppercase tracking-[0.2em] text-muted">Moodboard read · {decode.buyer_name || 'brief'}</span>
<h2 className="font-display text-[22px] leading-snug text-ink">A few questions to tailor your brief</h2>
<p className="text-[12.5px] text-ink/60 leading-relaxed">I've read the deck — answer what's useful (skip the rest) and I'll shape the final brief around it.</p>
</div>
<button onClick={onReset} className="font-plex text-[10px] uppercase tracking-[0.14em] text-muted hover:text-ink transition-colors shrink-0 pt-1">← Back</button>
</div>
{decode.questions.map((q) => {
const sel = answers[q.id]?.values ?? [];
return (
<div key={q.id} className="flex flex-col gap-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-[14px] font-medium text-ink">{q.question}</span>
{q.help && <span className="text-[11px] text-muted">{q.help}{q.type === 'multi' ? ' · select all that apply' : ''}</span>}
</div>
<div className="flex flex-wrap gap-2">
{q.options.map((opt) => <QChip key={opt} label={opt} active={sel.includes(opt)} onClick={() => toggle(q, opt)} />)}
{q.allow_decide && <QChip label={DECIDE} active={sel.includes(DECIDE)} onClick={() => toggle(q, DECIDE)} />}
{q.allow_other && (
<input
value={answers[q.id]?.other ?? ''}
onChange={(e) => setOther(q, e.target.value)}
placeholder="Other…"
className="text-[12.5px] rounded-full px-3.5 py-2 bg-paper border border-line outline-none focus:border-ink/40 transition-colors min-w-[140px] placeholder:text-muted/60"
/>
)}
</div>
</div>
);
})}
<div className="flex items-center gap-3 pt-1">
<button
onClick={submit}
disabled={submitting}
className="h-11 px-5 bg-ink text-paper text-[13px] font-medium rounded hover:bg-ink/90 transition-colors disabled:opacity-50"
>
{submitting ? 'Tailoring your brief…' : 'Generate my brief →'}
</button>
<span className="text-[11px] text-muted">deepseek reads your answers to shape the final report</span>
</div>
</div>
);
}
interface BriefItem {
key: string;
label: string;
@@ -411,35 +500,181 @@ function SelectableItem({ item, selected, onToggle, onViewImage }: {
);
}
function SectionCard({ title, delay, note, onSaveNote, children }: {
const SPEC_ORDER = ['silhouette', 'neckline', 'collar', 'sleeve', 'cuff', 'closure', 'pockets', 'seams', 'hem', 'details', 'key_measures'] as const;
const SPEC_LABELS: Record<string, string> = {
silhouette: 'Silhouette', neckline: 'Neckline', collar: 'Collar', sleeve: 'Sleeve', cuff: 'Cuff',
closure: 'Closure', pockets: 'Pockets', seams: 'Seams', hem: 'Hem', details: 'Details', key_measures: 'Key measures',
};
function StarterKitCard({ decodeId, index, kit }: {
readonly decodeId: string; readonly index: number; readonly kit: MoodboardStarterKit;
}) {
const save = (field: string, value: string) => {
if (value === (kit.spec[field] ?? '')) return;
kit.spec[field] = value; // keep local in sync
updateMoodboardStarterKit(decodeId, index, { [field]: value }).catch(() => { /* non-blocking */ });
};
return (
<div className="border border-line bg-paper rounded overflow-hidden flex flex-col sm:flex-row print:break-inside-avoid">
<div className="sm:w-56 shrink-0 flex sm:flex-col bg-[#ece6d9]">
{kit.concept_image && <img src={imageUrl(kit.concept_image)} alt={kit.name} loading="lazy" className="w-1/2 sm:w-full aspect-square object-cover" />}
{kit.flat_image && <img src={imageUrl(kit.flat_image)} alt={`${kit.name} flat`} loading="lazy" className="w-1/2 sm:w-full aspect-[4/3] object-contain bg-white border-l sm:border-l-0 sm:border-t border-line" />}
</div>
<div className="flex-1 p-5 flex flex-col gap-3 min-w-0">
<div className="flex items-center justify-between gap-3">
<span className="font-display text-[16px] leading-snug text-ink">{kit.name}</span>
<span className={`font-plex text-[9px] uppercase tracking-[0.14em] px-1.5 py-0.5 rounded border shrink-0 ${kit.on_dna ? 'text-[#3f7d4e] bg-[#3f7d4e]/[0.08] border-[#3f7d4e]/25' : 'text-[#9a6a1a] bg-[#c8892b]/[0.1] border-[#c8892b]/30'}`}>
{kit.on_dna ? 'on-DNA' : 'new block'}
</span>
</div>
{kit.suggested_fabric && (
<span className="text-[11.5px] text-muted">Suggested fabric — <span className="text-ink/80">{kit.suggested_fabric}</span></span>
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 mt-0.5">
{SPEC_ORDER.map((f) => (
<label key={f} className="flex flex-col gap-0.5 min-w-0">
<span className="font-plex text-[8.5px] uppercase tracking-[0.14em] text-muted">{SPEC_LABELS[f]}</span>
<input
defaultValue={kit.spec[f] ?? ''}
onBlur={(e) => save(f, e.target.value)}
className="text-[12px] text-ink bg-ivory border border-line px-2 py-1 outline-none focus:border-ink/40 transition-colors print:border-0 print:bg-transparent"
/>
</label>
))}
</div>
</div>
</div>
);
}
function StarterKitsSection({ decode }: { readonly decode: MoodboardPdfDecode }) {
const [open, setOpen] = useState(true);
const kits = decode.starter_kits ?? [];
if (!kits.length) return null;
return (
<div className="border border-line bg-ivory rounded shadow-card anim-fade-up overflow-hidden print:break-inside-avoid" style={{ animationDelay: '200ms' }}>
<button type="button" onClick={() => setOpen((v) => !v)} aria-expanded={open}
className="w-full flex items-center justify-between gap-3 px-6 py-4 text-left hover:bg-line/10 transition-colors print:pointer-events-none">
<span className="flex items-baseline gap-3 min-w-0">
<span className="font-plex text-[10px] uppercase tracking-[0.24em] text-muted shrink-0">Design starter kits</span>
{!open && <span className="text-[11.5px] text-muted/80 truncate print:hidden">{kits.length} hero piece{kits.length > 1 ? 's' : ''} · concept + flat + spec</span>}
</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`shrink-0 text-muted transition-transform print:hidden ${open ? 'rotate-180' : ''}`}>
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className={`px-6 pb-6 flex-col gap-4 ${open ? 'flex' : 'hidden print:flex'}`}>
<p className="text-[11.5px] text-muted print:hidden">Techpack starters — edit any field, then export. Hand straight to pattern/sampling.</p>
{kits.map((kit, i) => <StarterKitCard key={`${kit.name}-${i}`} decodeId={decode.id} index={i} kit={kit} />)}
</div>
</div>
);
}
function SaplGroundingCard({ grounding }: { readonly grounding: MoodboardGrounding }) {
const [open, setOpen] = useState(true);
const fabrics = grounding.fabrics ?? [];
const silhouettes = grounding.silhouettes ?? [];
if (!grounding.summary && !fabrics.length && !silhouettes.length) return null;
const nGap = fabrics.filter((f) => f.status === 'gap').length;
const nOn = silhouettes.filter((s) => s.on_dna).length;
const chip = 'text-[11.5px] px-2 py-1 rounded-md border';
return (
<div className="border border-ink/20 bg-ink/[0.035] rounded shadow-card anim-fade-up overflow-hidden" style={{ animationDelay: '40ms' }}>
<button type="button" onClick={() => setOpen((v) => !v)} aria-expanded={open}
className="w-full flex items-center justify-between gap-3 px-6 py-4 text-left hover:bg-ink/[0.03] transition-colors print:pointer-events-none">
<span className="flex items-baseline gap-3 min-w-0">
<span className="font-plex text-[10px] uppercase tracking-[0.2em] text-muted shrink-0">What this means for SAPL</span>
{!open && (
<span className="text-[11.5px] text-muted/80 truncate print:hidden">
{fabrics.length - nGap} fabrics in-library · {nGap} to source · {nOn}/{silhouettes.length} silhouettes on-DNA
</span>
)}
</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`shrink-0 text-muted transition-transform print:hidden ${open ? 'rotate-180' : ''}`}>
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className={`px-6 pb-6 flex-col gap-4 ${open ? 'flex' : 'hidden print:flex'}`}>
{grounding.summary && <p className="font-display italic text-[15px] leading-relaxed text-ink max-w-2xl">{grounding.summary}</p>}
{fabrics.length > 0 && (
<div className="flex flex-col gap-2">
<span className="font-plex text-[9px] uppercase tracking-[0.18em] text-muted">Fabric — make now vs. source</span>
<div className="flex flex-wrap gap-1.5">
{fabrics.map((f) => {
const cls = f.status === 'have' ? 'text-[#3f7d4e] bg-[#3f7d4e]/[0.08] border-[#3f7d4e]/25'
: f.status === 'similar' ? 'text-[#9a6a1a] bg-[#c8892b]/[0.1] border-[#c8892b]/30'
: 'text-[#b23b3b] bg-[#b23b3b]/[0.07] border-[#b23b3b]/25';
const ic = f.status === 'have' ? '✓' : f.status === 'similar' ? '≈' : '✕';
return (
<span key={f.material} className={`${chip} ${cls}`}>
{ic} {f.material}{f.status !== 'gap' && f.eg ? ` · ${f.eg}` : ''}
</span>
);
})}
</div>
</div>
)}
{silhouettes.length > 0 && (
<div className="flex flex-col gap-2">
<span className="font-plex text-[9px] uppercase tracking-[0.18em] text-muted">Silhouettes — on-DNA vs. new territory</span>
<div className="flex flex-wrap gap-1.5">
{silhouettes.map((s) => (
<span key={s.silhouette} className={`${chip} ${s.on_dna ? 'text-[#3f7d4e] bg-[#3f7d4e]/[0.08] border-[#3f7d4e]/25' : 'text-ink/70 bg-ivory border-line'}`}>
{s.on_dna ? '✓' : '+'} {s.silhouette}{s.on_dna ? ` · ${s.matches} in library` : ' · new'}
</span>
))}
</div>
</div>
)}
</div>
</div>
);
}
function SectionCard({ title, delay, note, onSaveNote, children, defaultOpen = true, summary }: {
readonly title: string;
readonly delay?: string;
readonly note: string;
readonly onSaveNote: (note: string) => void;
readonly children: React.ReactNode;
readonly defaultOpen?: boolean;
readonly summary?: string;
}) {
const [value, setValue] = useState(note);
const [open, setOpen] = useState(defaultOpen);
useEffect(() => { setValue(note); }, [note]);
return (
<div className="border border-line bg-ivory p-6 flex flex-col gap-4 rounded shadow-card anim-fade-up print:border-0 print:shadow-none print:break-inside-avoid" style={delay ? { animationDelay: delay } : undefined}>
<span className="font-plex text-[10px] uppercase tracking-[0.24em] text-muted">{title}</span>
{children}
<div className="border-t border-line pt-4 flex flex-col gap-1.5 print:hidden">
<span className="font-plex text-[9px] uppercase tracking-[0.18em] text-accent">Your read on this</span>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => { if (value !== note) onSaveNote(value); }}
placeholder="Add a human conclusion for this section…"
rows={2}
className="w-full px-3 py-2 bg-paper border border-line text-[12.5px] leading-relaxed text-ink outline-none focus:border-ink/40 transition-colors resize-none placeholder:text-muted/50"
/>
</div>
{value && (
<div className="hidden print:block border-t border-line pt-3">
<p className="font-display italic text-[13px] leading-relaxed text-ink">{value}</p>
<div className="border border-line bg-ivory rounded shadow-card anim-fade-up print:border-0 print:shadow-none print:break-inside-avoid overflow-hidden" style={delay ? { animationDelay: delay } : undefined}>
<button type="button" onClick={() => setOpen((v) => !v)} aria-expanded={open}
className="w-full flex items-center justify-between gap-3 px-6 py-4 text-left hover:bg-line/10 transition-colors print:pointer-events-none">
<span className="flex items-baseline gap-3 min-w-0">
<span className="font-plex text-[10px] uppercase tracking-[0.24em] text-muted shrink-0">{title}</span>
{!open && summary && <span className="text-[11.5px] text-muted/80 truncate print:hidden">{summary}</span>}
</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`shrink-0 text-muted transition-transform print:hidden ${open ? 'rotate-180' : ''}`}>
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div className={`px-6 pb-6 flex-col gap-4 ${open ? 'flex' : 'hidden print:flex'}`}>
{children}
<div className="border-t border-line pt-4 flex flex-col gap-1.5 print:hidden">
<span className="font-plex text-[9px] uppercase tracking-[0.18em] text-accent">Your read on this</span>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => { if (value !== note) onSaveNote(value); }}
placeholder="Add a human conclusion for this section…"
rows={2}
className="w-full px-3 py-2 bg-paper border border-line text-[12.5px] leading-relaxed text-ink outline-none focus:border-ink/40 transition-colors resize-none placeholder:text-muted/50"
/>
</div>
)}
{value && (
<div className="hidden print:block border-t border-line pt-3">
<p className="font-display italic text-[13px] leading-relaxed text-ink">{value}</p>
</div>
)}
</div>
</div>
);
}
@@ -616,6 +851,8 @@ function PdfDecodeResults({ decode, onReset }: { readonly decode: MoodboardPdfDe
)}
</div>
{decode.sapl_grounding && <SaplGroundingCard grounding={decode.sapl_grounding} />}
{brief.colour_direction && (brief.colour_direction.themes?.length > 0 || brief.colour_direction.palette?.length > 0) && (
<SectionCard title="Colour direction" delay="60ms" note={notes.colour_direction ?? ''} onSaveNote={(note) => saveNote('colour_direction', note)}>
{brief.colour_direction.themes?.length > 0 && (
@@ -628,7 +865,7 @@ function PdfDecodeResults({ decode, onReset }: { readonly decode: MoodboardPdfDe
)}
{fabricItems.length > 0 && (
<SectionCard title="Fabric direction" delay="100ms" note={notes.fabric_direction ?? ''} onSaveNote={(note) => saveNote('fabric_direction', note)}>
<SectionCard title="Fabric direction" defaultOpen={false} delay="100ms" note={notes.fabric_direction ?? ''} onSaveNote={(note) => saveNote('fabric_direction', note)}>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{fabricItems.map((item) => (
<SelectableItem key={item.key} item={item} selected={selections[item.key] !== false} onToggle={() => toggleItem(item.key)} onViewImage={viewImage} />
@@ -638,7 +875,7 @@ function PdfDecodeResults({ decode, onReset }: { readonly decode: MoodboardPdfDe
)}
{printItems.length > 0 && (
<SectionCard title="Print & pattern" delay="140ms" note={notes.print_direction ?? ''} onSaveNote={(note) => saveNote('print_direction', note)}>
<SectionCard title="Print & pattern" defaultOpen={false} delay="140ms" note={notes.print_direction ?? ''} onSaveNote={(note) => saveNote('print_direction', note)}>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{printItems.map((item) => (
<SelectableItem key={item.key} item={item} selected={selections[item.key] !== false} onToggle={() => toggleItem(item.key)} onViewImage={viewImage} />
@@ -657,8 +894,10 @@ function PdfDecodeResults({ decode, onReset }: { readonly decode: MoodboardPdfDe
</SectionCard>
)}
<StarterKitsSection decode={decode} />
{brief.collection_phasing && brief.collection_phasing.length > 0 && (
<SectionCard title="Collection phasing" delay="220ms" note={notes.collection_phasing ?? ''} onSaveNote={(note) => saveNote('collection_phasing', note)}>
<SectionCard title="Collection phasing" defaultOpen={false} delay="220ms" note={notes.collection_phasing ?? ''} onSaveNote={(note) => saveNote('collection_phasing', note)}>
<div className="grid sm:grid-cols-2 gap-x-8 gap-y-4">
{brief.collection_phasing.map((p, i) => (
<div key={p.phase + i} className="flex flex-col gap-1">
@@ -767,8 +1006,9 @@ function PdfDecodeSection() {
if (f && f.type === 'application/pdf') pickFile(f);
}, []);
const STOP_POLL = ['completed', 'failed', 'awaiting_input'];
useEffect(() => {
if (!decode || decode.status === 'completed' || decode.status === 'failed') {
if (!decode || STOP_POLL.includes(decode.status)) {
if (pollRef.current) clearInterval(pollRef.current);
return;
}
@@ -776,14 +1016,22 @@ function PdfDecodeSection() {
try {
const updated = await getMoodboardPdfDecode(decode.id);
setDecode(updated);
if (updated.status === 'completed' || updated.status === 'failed') {
if (STOP_POLL.includes(updated.status)) {
if (pollRef.current) clearInterval(pollRef.current);
}
} catch { /* keep polling */ }
}, 3000);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [decode?.id, decode?.status]);
async function submitAnswers(answers: Record<string, MoodboardAnswer>) {
if (!decode) return;
await submitMoodboardAnswers(decode.id, answers);
// status flips to 'synthesising' → the poll effect re-arms and tracks it to done.
setDecode(await getMoodboardPdfDecode(decode.id));
}
async function handleDecode() {
if (!file) return;
setSubmitting(true); setErrorMsg('');
@@ -810,6 +1058,10 @@ function PdfDecodeSection() {
return <PdfDecodeResults decode={decode} onReset={reset} />;
}
if (decode?.status === 'awaiting_input') {
return <PdfDecodeQuestions decode={decode} onSubmit={submitAnswers} onReset={reset} />;
}
if (decode && decode.status !== 'failed') {
return <PdfDecodeProgress decode={decode} onReset={reset} />;
}