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