diff --git a/.changeset/keyword-routing-word-boundaries.md b/.changeset/keyword-routing-word-boundaries.md new file mode 100644 index 0000000..70baf02 --- /dev/null +++ b/.changeset/keyword-routing-word-boundaries.md @@ -0,0 +1,9 @@ +--- +"@open-codesign/core": patch +--- + +Word-boundary the progressive-disclosure keyword regexes so substring tokens +no longer false-trigger sections. Previously `metric` matched `biometric`, +`graph` matched `paragraph`, and `logo` matched `logout`. English tokens are +now anchored with `\b...\b` (with optional `s?` for plurals); CJK alternations +remain un-anchored. diff --git a/.changeset/progressive-prompt-disclosure.md b/.changeset/progressive-prompt-disclosure.md new file mode 100644 index 0000000..3ee3fb6 --- /dev/null +++ b/.changeset/progressive-prompt-disclosure.md @@ -0,0 +1,14 @@ +--- +'@open-codesign/core': minor +--- + +System prompt now does progressive disclosure based on user-prompt keywords. The full create-mode prompt was ~41 KB / ~10k tokens — enough to crush small-context models (e.g. minimax-m2.5:free at 8k ctx) and dilute the instructions strong models actually follow. + +`composeSystemPrompt()` now accepts an optional `userPrompt` field. When provided in `create` mode, it assembles: + +- **Layer 1 (always, ~12 KB):** identity, workflow, output-rules, design-methodology, pre-flight, editmode-protocol, safety, plus a new condensed `antiSlopDigest` section. +- **Layer 2 (keyword-matched):** chart-rendering + dashboard ambient signals for dashboard cues; iOS starter template for mobile cues; single-page / big-numbers / customer-quotes craft subsections for marketing cues; logos subsection for brand cues. No keyword match → fall back to the full craft directives. + +Measured size for sample prompts: dashboard 22.6 KB (55%), mobile 21.7 KB (53%), marketing 19.8 KB (48%), no-keyword fallback 24.5 KB (59%). + +When `userPrompt` is omitted, or mode is `tweak` / `revise`, the prompt is byte-identical to before — full back-compat. diff --git a/packages/core/src/generate.test.ts b/packages/core/src/generate.test.ts index e184dff..83030f9 100644 --- a/packages/core/src/generate.test.ts +++ b/packages/core/src/generate.test.ts @@ -1398,6 +1398,100 @@ describe('composeSystemPrompt()', () => { }); }); +describe('composeSystemPrompt() — progressive disclosure', () => { + const FULL = composeSystemPrompt({ mode: 'create' }); + + it('back-compat: omitting userPrompt returns the full prompt byte-identical to today', () => { + expect(composeSystemPrompt({ mode: 'create' })).toBe(FULL); + }); + + it('Layer 1 sections always present regardless of input', () => { + for (const userPrompt of ['做个数据看板', 'iOS 移动端', '随便做点东西', '']) { + const p = composeSystemPrompt({ mode: 'create', userPrompt }); + expect(p, `identity missing for "${userPrompt}"`).toContain('open-codesign'); + expect(p, `workflow missing for "${userPrompt}"`).toContain('Design workflow'); + expect(p, `output rules missing for "${userPrompt}"`).toContain('Output rules'); + expect(p, `safety missing for "${userPrompt}"`).toContain('Safety and scope'); + expect(p, `anti-slop digest missing for "${userPrompt}"`).toContain('Anti-slop digest'); + } + }); + + it('dashboard prompt: includes chart rendering, excludes iOS starter', () => { + const p = composeSystemPrompt({ mode: 'create', userPrompt: '做个数据看板' }); + expect(p).toContain('Chart rendering contract'); + expect(p).toContain('Dashboard ambient signals'); + expect(p).not.toContain('iOS frame starter'); + }); + + it('mobile prompt: includes iOS starter template, excludes chart rendering', () => { + const p = composeSystemPrompt({ + mode: 'create', + userPrompt: 'iOS 移动端 onboarding', + }); + expect(p).toContain('iOS frame starter'); + expect(p).not.toContain('Chart rendering contract'); + }); + + it('marketing prompt: includes single-page structure ladder subsection', () => { + const p = composeSystemPrompt({ + mode: 'create', + userPrompt: 'indie marketing landing page', + }); + expect(p).toContain('Single-page structure ladder'); + expect(p).toContain('Customer quotes deserve distinguished treatment'); + }); + + it('no-keyword prompt: falls back to FULL craft directives', () => { + const p = composeSystemPrompt({ mode: 'create', userPrompt: '随便做点东西' }); + // Full craft directives includes ALL ten subsections — verify several signal ones + expect(p).toContain('Craft directives'); + expect(p).toContain('Artifact-type classification'); + expect(p).toContain('Density floor'); + expect(p).toContain('Dashboard ambient signals'); + expect(p).toContain('Logos and brand marks'); + expect(p).toContain('Single-page structure ladder'); + }); + + it('regression guard: matched dashboard prompt stays under 25 KB', () => { + const p = composeSystemPrompt({ mode: 'create', userPrompt: '做个数据看板' }); + expect(p.length).toBeLessThan(25_000); + }); + + it('mode tweak ignores userPrompt and returns the full tweak prompt', () => { + const a = composeSystemPrompt({ mode: 'tweak' }); + const b = composeSystemPrompt({ mode: 'tweak', userPrompt: '做个数据看板' }); + expect(b).toBe(a); + }); + + it('mode revise ignores userPrompt and returns the full revise prompt', () => { + const a = composeSystemPrompt({ mode: 'revise' }); + const b = composeSystemPrompt({ mode: 'revise', userPrompt: '做个数据看板' }); + expect(b).toBe(a); + }); + + it('does not trigger dashboard routing on substring collisions (paragraph/asymmetric/biometric)', () => { + // Pair the colliding tokens with a mobile cue so the composer does NOT + // fall back to full CRAFT_DIRECTIVES — that fallback would re-introduce + // the dashboard subsection and defeat the substring-collision check. + const p = composeSystemPrompt({ + mode: 'create', + userPrompt: 'iOS app screen — paragraph rhythm, asymmetric spacing, biometric login', + }); + expect(p).not.toContain('Chart rendering contract'); + expect(p).not.toContain('Dashboard ambient signals'); + }); + + it('does not trigger logo routing on "logout" substring', () => { + // Same reason as above — pair with an unrelated mobile cue to avoid the + // no-keyword fallback that would otherwise pull in full craft directives. + const p = composeSystemPrompt({ + mode: 'create', + userPrompt: 'iOS app screen for a logout confirmation modal', + }); + expect(p).not.toContain('Logos and brand marks'); + }); +}); + describe('prompt section .txt vs TS drift', () => { const promptsDir = resolve(dirname(fileURLToPath(import.meta.url)), 'prompts'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1e342c6..84809c1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -580,6 +580,7 @@ export async function generate(input: GenerateInput): Promise { input.systemPrompt ?? composeSystemPrompt({ mode: 'create', + userPrompt: input.prompt, ...(skillBlobs.length > 0 ? { skills: skillBlobs } : {}), }), }, diff --git a/packages/core/src/prompts/anti-slop-digest.v1.txt b/packages/core/src/prompts/anti-slop-digest.v1.txt new file mode 100644 index 0000000..e65b159 --- /dev/null +++ b/packages/core/src/prompts/anti-slop-digest.v1.txt @@ -0,0 +1,20 @@ +# Anti-slop digest (forbidden patterns) + +Do not produce these. Each one is the tell of an unconsidered, generated-feeling artifact: + +- A "minimal dark" page that is `#0E0E10` end-to-end with a single purple accent and four sparse stat cards. +- A hero section with a gradient blob background, bold sans headline, and a generic screenshot mockup. +- A features section with six 1:1 cards, each with a 24px icon, a two-word title, and a sentence of filler text. +- A testimonials section with circular avatars, a name, a title, and a five-star rating. +- A footer with three columns of nav links and a social media icon row. +- A "case study" that is four metric cards plus a single quote — missing hero, before/after, customer profile, and closing. +- A logo placeholder rendered as a soft-rounded square with a single random letter centered inside. Use a constructed monogram, a wordmark, or an explicit hatched "YOUR LOGO HERE" rectangle instead. +- Decorative emoji used as section icons unless the brief explicitly asks for emoji. +- Default Tailwind blue (`#3b82f6`) or default Tailwind grays as the entire neutral scale. +- Lorem ipsum, "John Doe", "Acme Corp", "100%" / "1,234" round-number filler. +- Fonts in the overused-default set: Inter, Roboto, Arial, Helvetica, Playfair Display (unless explicitly requested). +- Hotlinked photos from any external host (`placeholder.com`, `unsplash.com`, `picsum.photos`, `randomuser.me`, etc.). +- Center-aligned body paragraphs. +- Pure black (`#000`) for text — use near-black with a slight hue cast. + +These patterns are forbidden when combined without a distinctive visual angle that makes them feel intentional rather than assembled from a component kit. \ No newline at end of file diff --git a/packages/core/src/prompts/index.ts b/packages/core/src/prompts/index.ts index 43eddb9..15655c2 100644 --- a/packages/core/src/prompts/index.ts +++ b/packages/core/src/prompts/index.ts @@ -728,6 +728,58 @@ After copying this skeleton, design your app's specific UI inside \`
so the +// progressive-disclosure composer can include only the subsections relevant to +// the user's prompt. The intro paragraph (everything before the first `## `) +// is preserved as the "" key so we can always emit it. +function buildCraftSubsectionMap(): Map { + const map = new Map(); + const parts = CRAFT_DIRECTIVES.split(/\n(?=## )/); + const intro = parts[0]; + if (intro !== undefined) { + map.set('__intro__', intro); + } + for (const part of parts.slice(1)) { + const headingMatch = part.match(/^## (.+?)\n/); + const heading = headingMatch?.[1]; + if (heading) { + map.set(heading.trim(), part); + } + } + return map; +} + +const CRAFT_SUBSECTIONS = buildCraftSubsectionMap(); + +function craftSubsection(name: string): string | undefined { + return CRAFT_SUBSECTIONS.get(name); +} + // --------------------------------------------------------------------------- // Section maps (used by drift tests and tooling) // --------------------------------------------------------------------------- @@ -745,6 +797,7 @@ export const PROMPT_SECTIONS: Record = { chartRendering: CHART_RENDERING, iosStarterTemplate: IOS_STARTER_TEMPLATE, antiSlop: ANTI_SLOP, + antiSlopDigest: ANTI_SLOP_DIGEST, safety: SAFETY, }; @@ -761,6 +814,7 @@ export const PROMPT_SECTION_FILES: Record chartRendering: 'chart-rendering.v1.txt', iosStarterTemplate: 'ios-starter-template.v1.txt', antiSlop: 'anti-slop.v1.txt', + antiSlopDigest: 'anti-slop-digest.v1.txt', safety: 'safety.v1.txt', }; @@ -775,10 +829,27 @@ export interface PromptComposeOptions { * - `revise` — targeted edit of an existing artifact */ mode: 'create' | 'tweak' | 'revise'; + /** + * The user's prompt — used for keyword-based progressive disclosure of + * craft directives, chart rendering, and starter templates. Optional for + * back-compat: when omitted the full (pre-disclosure) prompt is returned. + */ + userPrompt?: string | undefined; /** Additional skill blobs to append (future extension point). */ skills?: string[] | undefined; } +// --------------------------------------------------------------------------- +// Progressive disclosure — keyword routing +// --------------------------------------------------------------------------- + +const KEYWORDS_DASHBOARD = + /\b(dashboard|chart|graph|plot|visualization|analytics|metric|kpi)s?\b|数据|看板|图表/i; +const KEYWORDS_MOBILE = /\b(mobile|iOS|iPhone|iPad|app screen|app design)\b|手机|移动端/i; +const KEYWORDS_MARKETING = + /\b(case study|landing|marketing|hero|pricing)\b|案例|落地页|登录页|首页/i; +const KEYWORDS_LOGO = /\b(logo|brand|monogram)s?\b|品牌/i; + // --------------------------------------------------------------------------- // Composer // --------------------------------------------------------------------------- @@ -787,18 +858,51 @@ export interface PromptComposeOptions { * Assembles the system prompt from section constants according to the requested * generation mode. * - * Section order: - * identity → workflow → output-rules → design-methodology → - * artifact-types → pre-flight → editmode-protocol → - * [tweaks-protocol if mode === 'tweak'] → - * craft-directives → chart-rendering → [ios-starter-template if mode === 'create'] → - * anti-slop → safety → [skill blobs if any] + * Two modes of assembly: + * + * 1. **Full** (default — when `userPrompt` is undefined, or mode is `tweak` / + * `revise`). Order: + * identity → workflow → output-rules → design-methodology → + * artifact-types → pre-flight → editmode-protocol → + * [tweaks-protocol if mode === 'tweak'] → + * craft-directives → chart-rendering → + * [ios-starter-template if mode === 'create'] → + * anti-slop → safety → [skill blobs if any] + * + * 2. **Progressive** (mode === 'create' AND `userPrompt` provided). The full + * prompt is ~44 KB / 11k tokens and crushes small-context models. We split + * it into: + * - Layer 1 (always, ~12 KB): identity, workflow, output-rules, + * design-methodology, pre-flight, editmode-protocol, safety, + * anti-slop-digest. + * - Layer 2 (keyword-matched): chart-rendering, ios-starter-template, + * and individual craft-directives subsections triggered by dashboard / + * mobile / marketing / logo cues. If no keyword matches, fall back to + * the full craft-directives section. * * Brand tokens and other user-filesystem data are intentionally excluded here. * They are passed as untrusted user-role content in the message array to prevent * prompt injection attacks from adversarial codebase content. */ export function composeSystemPrompt(opts: PromptComposeOptions): string { + const sections = + opts.userPrompt !== undefined && opts.mode === 'create' + ? composeCreateProgressive(opts.userPrompt) + : composeFull(opts.mode); + + if (opts.skills?.length) { + const header = [ + '# Available Skills', + '', + "You have access to these specialized skills. Use the one that best fits the user's request — multiple skills can apply if the request spans domains.", + ].join('\n'); + sections.push(`${header}\n\n---\n\n${opts.skills.join('\n\n---\n\n')}`); + } + + return sections.join('\n\n---\n\n'); +} + +function composeFull(mode: PromptComposeOptions['mode']): string[] { const sections: string[] = [ IDENTITY, WORKFLOW, @@ -809,28 +913,90 @@ export function composeSystemPrompt(opts: PromptComposeOptions): string { EDITMODE_PROTOCOL, ]; - if (opts.mode === 'tweak') { + if (mode === 'tweak') { sections.push(TWEAKS_PROTOCOL); } - if (opts.mode !== 'tweak') { + if (mode !== 'tweak') { sections.push(CRAFT_DIRECTIVES); sections.push(CHART_RENDERING); } - if (opts.mode === 'create') { + if (mode === 'create') { sections.push(IOS_STARTER_TEMPLATE); } sections.push(ANTI_SLOP); sections.push(SAFETY); + return sections; +} - if (opts.skills?.length) { - const header = [ - '# Available Skills', - '', - "You have access to these specialized skills. Use the one that best fits the user's request — multiple skills can apply if the request spans domains.", - ].join('\n'); - sections.push(`${header}\n\n---\n\n${opts.skills.join('\n\n---\n\n')}`); +// Layer 1 (always-on, ~12 KB) + Layer 2 (keyword-matched). +// Layer 3 — retry-on-quality-fail injection of full ANTI_SLOP + ARTIFACT_TYPES +// is deferred. TODO(progressive-prompt-v2): wire this into the generate retry loop. +const LAYER_1_BASE: readonly string[] = [ + IDENTITY, + WORKFLOW, + OUTPUT_RULES, + DESIGN_METHODOLOGY, + PRE_FLIGHT, + EDITMODE_PROTOCOL, + SAFETY, + ANTI_SLOP_DIGEST, +]; + +interface KeywordMatchPlan { + topLevel: string[]; + craftSubsectionNames: string[]; +} + +function planKeywordMatches(userPrompt: string): KeywordMatchPlan { + const topLevel: string[] = []; + const craftSubsectionNames: string[] = []; + + if (KEYWORDS_DASHBOARD.test(userPrompt)) { + topLevel.push(CHART_RENDERING); + craftSubsectionNames.push('Dashboard ambient signals'); + } + if (KEYWORDS_MOBILE.test(userPrompt)) { + topLevel.push(IOS_STARTER_TEMPLATE); + } + if (KEYWORDS_MARKETING.test(userPrompt)) { + craftSubsectionNames.push( + 'Single-page structure ladder', + 'Big numbers get dedicated visual blocks', + 'Customer quotes deserve distinguished treatment', + ); + } + if (KEYWORDS_LOGO.test(userPrompt)) { + craftSubsectionNames.push('Logos and brand marks'); } - return sections.join('\n\n---\n\n'); + return { topLevel, craftSubsectionNames }; +} + +function buildCraftBlock(subsectionNames: string[]): string | undefined { + if (subsectionNames.length === 0) return undefined; + const parts: string[] = []; + const intro = craftSubsection('__intro__'); + if (intro) parts.push(intro); + for (const name of subsectionNames) { + const sub = craftSubsection(name); + if (sub) parts.push(sub); + } + return parts.length > 1 ? parts.join('\n\n') : undefined; +} + +function composeCreateProgressive(userPrompt: string): string[] { + const sections: string[] = [...LAYER_1_BASE]; + const plan = planKeywordMatches(userPrompt); + const noMatch = plan.topLevel.length === 0 && plan.craftSubsectionNames.length === 0; + + if (noMatch) { + sections.push(CRAFT_DIRECTIVES); + return sections; + } + + sections.push(...plan.topLevel); + const craftBlock = buildCraftBlock(plan.craftSubsectionNames); + if (craftBlock) sections.push(craftBlock); + return sections; }