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:
co-authored by
Claude Opus 4.8
parent
4c9486dca2
commit
da212d0606
@@ -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="[]")
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")],
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user