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:
parent
3f0d3377e7
commit
533490f1a2
4 changed files with 321 additions and 0 deletions
78
ui/src/components/studio/classifyIntent.test.ts
Normal file
78
ui/src/components/studio/classifyIntent.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
75
ui/src/components/studio/classifyIntent.ts
Normal file
75
ui/src/components/studio/classifyIntent.ts
Normal 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;
|
||||||
|
}
|
||||||
58
ui/src/components/studio/workshops.test.ts
Normal file
58
ui/src/components/studio/workshops.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
110
ui/src/components/studio/workshops.ts
Normal file
110
ui/src/components/studio/workshops.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue