feat(nexus): add WORKSHOPS data + classifyIntent helper (phase 10)

Introduce the single source-of-truth data structure for the 8 Studio
workshops (diagrams, icons, themes, wallpapers, documents, brand-kits,
social, convert) and a pure keyword-based intent classifier used by
StudioPromptBar to route freeform prompts to the right workshop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 12:14:17 +00:00
parent 3f0d3377e7
commit 533490f1a2
4 changed files with 321 additions and 0 deletions

View file

@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { classifyIntent } from "./classifyIntent";
import type { WorkshopSlug } from "./workshops";
describe("classifyIntent", () => {
it("returns null for empty or whitespace-only input", () => {
expect(classifyIntent("")).toBeNull();
expect(classifyIntent(" ")).toBeNull();
expect(classifyIntent("\n\t ")).toBeNull();
});
it.each<[string, WorkshopSlug]>([
// Convert — must be checked first
["convert this pdf to markdown", "convert"],
["can you convert it", "convert"],
["from docx to pdf please", "convert"],
["pdf to docx", "convert"],
["transform html to md", "convert"],
// Wallpapers
["I need a 1920x1080 wallpaper of a forest", "wallpapers"],
["make a desktop bg with neon lights", "wallpapers"],
["generate wallpapers for my lock screen", "wallpapers"],
// Diagrams
["diagram of the auth flow", "diagrams"],
["flowchart for the checkout process", "diagrams"],
["mermaid sequence diagram", "diagrams"],
["architecture diagram of the platform", "diagrams"],
// Icons
["I want an icon set for my app", "icons"],
["svg icon of a rocket", "icons"],
["icon pack please", "icons"],
// Themes
["build me a theme from #166534", "themes"],
["color palette based on volt", "themes"],
["generate a palette", "themes"],
// Brand kits (checked before documents — logo beats document)
["create a brand identity for an AI startup", "brand-kits"],
["I need a logo", "brand-kits"],
["style guide please", "brand-kits"],
// Social
["twitter post about our launch", "social"],
["linkedin carousel on hiring", "social"],
["instagram post for the team", "social"],
// Documents (must come after convert/brand)
["generate a quarterly report", "documents"],
["one-pager about the product", "documents"],
["an invoice for client acme", "documents"],
])("routes %j → %s", (prompt, expectedSlug) => {
const result = classifyIntent(prompt);
expect(result).not.toBeNull();
expect(result?.slug).toBe(expectedSlug);
expect(result?.prefilledPrompt).toBe(prompt);
});
it.each([
"random musing about life",
"what's the weather today",
"tell me a joke",
"remind me to buy milk",
"hello world",
])("returns null for unclassified prompt %j", (prompt) => {
expect(classifyIntent(prompt)).toBeNull();
});
it("preserves the original prompt verbatim in prefilledPrompt (no lowercasing)", () => {
const prompt = "Diagram Of The Auth Flow, MERMAID style";
const result = classifyIntent(prompt);
expect(result?.prefilledPrompt).toBe(prompt);
expect(result?.slug).toBe("diagrams");
});
});

View file

@ -0,0 +1,75 @@
import type { WorkshopSlug } from "./workshops";
export interface IntentClassification {
slug: WorkshopSlug;
prefilledPrompt: string;
}
/**
* Phase 10 simple keyword-based intent classifier.
*
* Given a freeform prompt from the Studio prompt bar, return the workshop
* slug to route to and a copy of the prompt to pre-fill. Returns `null` if
* no workshop matches callers should fall back to sending the prompt
* through to the Assistant.
*
* This is deliberately a single pure function with no state, no network,
* no LLM. Phase 14 may replace it with a real classifier; for Phase 10 we
* want deterministic routing and exhaustive unit-testable behavior.
*
* Ordering matters: more specific patterns (convert with explicit formats,
* wallpaper, diagram, icon) are checked first, so that "convert this pdf
* to docx" doesn't accidentally fall through the `documents` branch.
*/
export function classifyIntent(prompt: string): IntentClassification | null {
const lower = prompt.toLowerCase().trim();
if (!lower) return null;
// Convert — most specific first: explicit "convert X to Y", "from X to Y",
// or any of the common file-format keywords that strongly imply conversion.
if (
/\bconvert\b/.test(lower) ||
/\bfrom\s+\w+\s+to\s+\w+\b/.test(lower) ||
/\b(pdf|docx?|md|markdown|html|rtf|odt|epub|txt)\s+to\s+\w+/.test(lower) ||
/\bto\s+(pdf|docx?|md|markdown|html|rtf|odt|epub|txt)\b/.test(lower)
) {
return { slug: "convert", prefilledPrompt: prompt };
}
// Wallpaper
if (/\bwallpaper(s)?\b|background\s+image|desktop\s+bg|lock\s+screen/.test(lower)) {
return { slug: "wallpapers", prefilledPrompt: prompt };
}
// Diagram
if (/\b(diagram|flowchart|sequence\s+diagram|mermaid|architecture\s+diagram|uml)\b/.test(lower)) {
return { slug: "diagrams", prefilledPrompt: prompt };
}
// Icons (note: "icon" must match as a whole word, not "iconic")
if (/\bicon(s|\s+set|\s+pack)?\b|svg\s+icon/.test(lower)) {
return { slug: "icons", prefilledPrompt: prompt };
}
// Themes / palettes
if (/\btheme(s)?\b|color\s+palette|colour\s+palette|\bpalette\b/.test(lower)) {
return { slug: "themes", prefilledPrompt: prompt };
}
// Brand kits (before documents — "logo" and "brand identity" beat "document")
if (/\b(brand|logo|identity|style\s+guide)\b/.test(lower)) {
return { slug: "brand-kits", prefilledPrompt: prompt };
}
// Social
if (/\b(social|tweet|post|instagram|linkedin|twitter|x\.com|carousel)\b/.test(lower)) {
return { slug: "social", prefilledPrompt: prompt };
}
// Documents — PDF/report/invoice/one-pager (after convert so "pdf to X" routes to convert)
if (/\b(pdf|document|invoice|report|one-?pager|whitepaper|resume|cv)\b/.test(lower)) {
return { slug: "documents", prefilledPrompt: prompt };
}
return null;
}

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { WORKSHOPS, findWorkshop, type WorkshopSlug } from "./workshops";
describe("WORKSHOPS data", () => {
it("defines exactly 8 workshops in the Phase 10 canonical order", () => {
const slugs = WORKSHOPS.map((w) => w.slug);
expect(slugs).toEqual([
"diagrams",
"icons",
"themes",
"wallpapers",
"documents",
"brand-kits",
"social",
"convert",
] satisfies WorkshopSlug[]);
});
it("has unique slugs", () => {
const slugs = WORKSHOPS.map((w) => w.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
it("has a title, subtitle, icon, and componentKey for every workshop", () => {
for (const w of WORKSHOPS) {
expect(w.title).toMatch(/^[A-Z ]+$/);
expect(w.subtitle.length).toBeGreaterThan(0);
// Lucide icons are React forwardRef components (object), not plain functions.
expect(["function", "object"]).toContain(typeof w.icon);
expect(w.icon).not.toBeNull();
expect(w.componentKey.length).toBeGreaterThan(0);
}
});
it("uses uppercase titles only", () => {
for (const w of WORKSHOPS) {
expect(w.title).toBe(w.title.toUpperCase());
}
});
it("has unique componentKeys", () => {
const keys = WORKSHOPS.map((w) => w.componentKey);
expect(new Set(keys).size).toBe(keys.length);
});
});
describe("findWorkshop", () => {
it("returns the workshop definition for a known slug", () => {
const w = findWorkshop("diagrams");
expect(w).not.toBeNull();
expect(w?.title).toBe("DIAGRAMS");
});
it("returns null for an unknown slug", () => {
expect(findWorkshop("nope")).toBeNull();
expect(findWorkshop("")).toBeNull();
});
});

View file

@ -0,0 +1,110 @@
import {
Workflow,
Shapes,
Palette,
Image as ImageIcon,
FileText,
Award,
Share2,
Repeat,
} from "lucide-react";
import type { ComponentType } from "react";
/**
* Phase 10 single source-of-truth for Studio workshops.
*
* The Studio home grid and the workshop detail view both consume this
* definition. Adding a new workshop means appending an entry here and
* wiring a component in StudioWorkshopDetail's WORKSHOP_COMPONENTS map.
*/
export type WorkshopSlug =
| "diagrams"
| "icons"
| "themes"
| "wallpapers"
| "documents"
| "brand-kits"
| "social"
| "convert";
export interface WorkshopDefinition {
slug: WorkshopSlug;
/** Inter 700 uppercase — e.g. "DIAGRAMS". */
title: string;
/** Inter 400 silver — single line. */
subtitle: string;
/** Lucide icon component, rendered at 32x32 in volt, top-right of the card. */
icon: ComponentType<{ className?: string }>;
/**
* Key used by StudioWorkshopDetail to look up the concrete generator
* component. Phase 10 does not rewrite any generator internals each
* key resolves to one of the existing ContentStudio generator panels,
* or (for `convert`) to the legacy ConvertPanel folded in from
* `/convert`.
*/
componentKey: string;
}
export const WORKSHOPS: WorkshopDefinition[] = [
{
slug: "diagrams",
title: "DIAGRAMS",
subtitle: "Mermaid → rendered SVG",
icon: Workflow,
componentKey: "diagram",
},
{
slug: "icons",
title: "ICONS",
subtitle: "SVG sets from description",
icon: Shapes,
componentKey: "icon",
},
{
slug: "themes",
title: "THEMES",
subtitle: "Color → full palette",
icon: Palette,
componentKey: "theme",
},
{
slug: "wallpapers",
title: "WALLPAPERS",
subtitle: "Desktop, mobile, banners",
icon: ImageIcon,
componentKey: "wallpaper",
},
{
slug: "documents",
title: "DOCUMENTS",
subtitle: "PDF reports, invoices",
icon: FileText,
componentKey: "document",
},
{
slug: "brand-kits",
title: "BRAND KITS",
subtitle: "Full brand identity",
icon: Award,
componentKey: "brand",
},
{
slug: "social",
title: "SOCIAL",
subtitle: "Platform-ready posts",
icon: Share2,
componentKey: "social",
},
{
slug: "convert",
title: "CONVERT",
subtitle: "File format conversion",
icon: Repeat,
componentKey: "convert",
},
];
export function findWorkshop(slug: string): WorkshopDefinition | null {
return WORKSHOPS.find((w) => w.slug === slug) ?? null;
}