feat(ai): teach the planning corpus real deck design
The slides domain skill never reached mid- and small-tier models: the compact allowlist dropped it outright on the basic tier, and the standard tier's budget truncated it to a fragment. Fix the pipeline (allowlist, a deck budget tier keyed off the fixed-size board, guards that assert each skill's last line survives into the assembled prompt), then grow the corpus itself: four style tiers with measured contrast floors, slide skeleton patterns lifted from the shipped templates, outline modes per deck kind, and keyword routing. A stray 'deck' trigger that spent the budget on card-stack guidance is narrowed, and the CJK typography skill no longer loses its slot to a larger optional skill. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
parent
6a5a9c8ad1
commit
dfbc80a8d7
|
|
@ -5,7 +5,7 @@ phase: [generation]
|
|||
trigger:
|
||||
keywords:
|
||||
- "/[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]/"
|
||||
priority: 25
|
||||
priority: 21
|
||||
budget: 500
|
||||
category: domain
|
||||
---
|
||||
|
|
|
|||
108
crates/op-ai-skills/skills/domains/deck-patterns.md
Normal file
108
crates/op-ai-skills/skills/domains/deck-patterns.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
name: deck-patterns
|
||||
description: Slide-pattern skeletons for presentation decks — board placement, cover/data/timeline/table/points/quote/closing structures with measured numbers
|
||||
phase: [generation]
|
||||
trigger:
|
||||
keywords: [slide, slides, deck, presentation, pitch deck, keynote, ppt, 幻灯片, 演示, 演示文稿, 路演, 课件, 汇报]
|
||||
priority: 23
|
||||
budget: 1900
|
||||
category: domain
|
||||
---
|
||||
|
||||
DECK PATTERNS — SLIDE SKELETONS
|
||||
|
||||
Companion to the `slides` skill: that one picks the tier and the layout contract, this one is the structure to emit. Colour names below are roles from the chosen tier (`bg` / `surface` / `ink` / `muted` / `accent` / `accent-soft` / `border`). The numbers are measured from shipped decks — copy them, do not re-derive.
|
||||
|
||||
## Board placement
|
||||
|
||||
Each slide is its own top-level 1920×1080 frame and MUST carry explicit `x`/`y`; without them every board lands at the origin and the deck renders as one slide with the rest hidden underneath. 3 boards per row: `x = (i % 3) * (1920 + 120)`, `y = (i / 3) * (1080 + 360)`. The row gap is 360, not 120, because the canvas paints each frame's NAME above it at a fixed screen-space offset — at the zoom where a 3-wide deck fits the screen, 120px is ~16 screen px and every second-row label collides with the row above.
|
||||
|
||||
## Slide frame
|
||||
|
||||
```
|
||||
frame(name="01 封面", x, y, width=1920, height=1080, layout="vertical",
|
||||
padding=[120,120], gap=48..56, justifyContent="start",
|
||||
alignItems="start", fill=[bg], clipContent=true)
|
||||
```
|
||||
|
||||
Never `fit_content` on a board — the artboard is the projector. Padding 120 satisfies the ≥100 safe area on all four sides.
|
||||
|
||||
## Keep page titles on one line across the deck
|
||||
|
||||
A page-title block followed by a transparent `height="fill_container"`, `justifyContent="center"` wrapper holding the body. The title sits at the same y on every slide (repetition), while the body stays vertically centred instead of being stretched.
|
||||
|
||||
```
|
||||
frame(slide) ├── 页头 (vertical, gap=18) ├── text(title, 64, 700, ink, lh=1.15)
|
||||
│ └── text(subtitle, 30, 400, muted, lh=1.45)
|
||||
└── frame(fill_container, justifyContent="center", gap=0) └── <body>
|
||||
```
|
||||
|
||||
## Cover — three forms, pick one
|
||||
|
||||
- **Left-aligned + sign-off** (default): slide `justifyContent="space_between"`, children = eyebrow pill, lede column, meta row. Lede = title 100–112/700 lh 1.12 (`\n` for the intended break) + accent bar + subtitle 32–34/400 lh 1.45, gap 36. Meta row `alignItems="center"`, `justifyContent="space_between"`: speaker 28/500 ink, occasion/date 28/400 muted.
|
||||
- **Centred claim**: slide `justifyContent="center"`, one column gap 32 — eyebrow, title 104/700, subtitle 34/400 muted, accent bar.
|
||||
- **Oversized statement** (S4 only): title 120–140/700 and nothing but one 30–34/400 line.
|
||||
|
||||
Eyebrow pill: `frame(fit_content, padding=[8..10, 18..22], cornerRadius=999, fill=[accent-soft])` wrapping `text(24..26, 600, accent, letterSpacing=1.0..1.2)`. Accent bar: `rect(width=120..160, height=8..10, cornerRadius=999, fill=[accent])`. Use at most ONE pill per slide.
|
||||
|
||||
## Data page — the number is the slide
|
||||
|
||||
3 KPI cards in a row, `alignItems="stretch"`, gap 36.
|
||||
|
||||
```
|
||||
card = frame(vertical, height="fill_container", justifyContent="center",
|
||||
padding=[56,44], cornerRadius=24, gap=18..20, fill=[surface])
|
||||
├── row(gap=10, alignItems="end", width="fit_content")
|
||||
│ ├── text(value, 140, 700, accent, lh=1.0, width="fit_content")
|
||||
│ └── frame(fit_content, padding=[0,0,19,0]) └── text(unit, 44, 600, accent, lh=1.0)
|
||||
├── text(label, 32, 600, ink, lh=1.25)
|
||||
└── text(note, 26, 400, muted, lh=1.45)
|
||||
```
|
||||
|
||||
The unit's `19` bottom padding is a baseline compensation: `round((valueSize - unitSize) * 0.2)`. There is no real baseline alignment (`alignItems:"baseline"` folds to `end`), so without it a 44px unit sits below the 140px digits. Recompute it whenever either size changes. One KPI → 120–200/700 centred; two → 80–120.
|
||||
|
||||
## Points page — number, title, description
|
||||
|
||||
Three layers per item, never two: `序号 + 标题 + 说明`.
|
||||
|
||||
- **Numbered circle**: `frame(width=size, height=size, cornerRadius=size/2, layout="horizontal", alignItems="center", justifyContent="center", fill=[accent])` + `text(digit, round(size*0.46), 700, "#FFFFFF", lh=1.0)`. size 52 inline, 64 as a card leading. Planned-but-not-done → `fill=[surface]`, ink `accent`, `stroke(accent, 3)`.
|
||||
- **Bullet dot**: `rect(14×14, cornerRadius=7, fill=[accent])` inside a `fit_content` column with `padding=[15,0,0,0]` — the lift is `round(fontSize * lineHeight / 2 - dotSize / 2)`, which drops the dot onto the first line's optical centre instead of its top edge.
|
||||
- Card variant: each item is `padding=[36..48, 36..44], cornerRadius=20, fill=[surface]`, item gap 24, text column gap 12. Title 34–40/600 lh 1.2–1.25, description 27–28/400 lh 1.5–1.55.
|
||||
- 3 items per row when horizontal (`alignItems="stretch"`, gap 36, each `height="fill_container"`), 3–4 stacked when vertical.
|
||||
|
||||
## Timeline
|
||||
|
||||
A row of equal columns with **gap 0** — the axis is assembled from each column's own segment, and any column gap breaks it into disconnected pieces. Breathing room comes from a right inset on the text, never from the row gap.
|
||||
|
||||
```
|
||||
column(gap=28)
|
||||
├── 节点日期 frame(padding=[0,56,0,0]) └── text(date, 28, 600, accent, lh=1.2)
|
||||
├── 轴段 row(gap=0, alignItems="center")
|
||||
│ ├── rect(28×28, cornerRadius=14, fill=[accent]) -- done
|
||||
│ │ (planned: fill=[bg] + stroke(accent, 4))
|
||||
│ └── rect(width="fill_container", height=4, fill=[border])
|
||||
└── 节点文案 frame(padding=[0,56,0,0], gap=10)
|
||||
├── text(title, 34, 600, ink, lh=1.25)
|
||||
└── text(desc, 26, 400, muted, lh=1.5)
|
||||
```
|
||||
|
||||
Never put padding on the axis row itself. 4–5 nodes maximum.
|
||||
|
||||
## Comparison table
|
||||
|
||||
Rules only, via per-side stroke — a full box turns the table into a grid and hides the row relationships.
|
||||
|
||||
- Header row: `padding=[26,32]`, `stroke={thickness:{bottom:3}, fill:[accent]}`, column headings 34/700 accent.
|
||||
- Body row: same padding, `stroke={thickness:{bottom:2}, fill:[border]}`. **The last row carries no stroke** — a trailing rule dangles under nothing.
|
||||
- Row-label column: fixed `width=260`, 27/600 muted. Content columns fill, 28/400 ink lh 1.5. Row gap 40 between cells, 0 between rows.
|
||||
- Wrapper: `frame(vertical, gap=0, cornerRadius=20, fill=[surface], clipContent=true)`. The header needs a blank label cell (`text(" ")`) so its columns line up with the body.
|
||||
|
||||
## Chart placeholder
|
||||
|
||||
`frame(horizontal, gap=28, alignItems="end", padding=[40,40], cornerRadius=24, fill=[surface], height="fill_container")` holding 5 `rect(width="fill_container", cornerRadius=12)` bars at heights 180/250/220/330/420. Exactly ONE bar — the one the takeaway is about — uses `accent`, the rest `accent-soft`. Pair it with a `width=520` notes column of 2–3 title 30/600 + body 26/400 pairs: the insight goes in words, never left for the audience to find.
|
||||
|
||||
## Closing / CTA
|
||||
|
||||
`justifyContent="space_between"`: a lede column (accent bar, headline 88–96/700 lh 1.15, one 30–32/400 muted line) and a contact card `row(padding=[48,56], cornerRadius=24, fill=[surface], justifyContent="space_between", alignItems="center")`. Left = name 32/600 over a nested column of 30/500 accent + 30/400 muted lines (nesting keeps each level's sizes internally consistent). Right = a `200×200` `frame` placeholder, `cornerRadius=16`, `fill=[accent-soft]`, `stroke(accent, 2)`, with a centred 26/600 label inside.
|
||||
|
||||
Placeholders for a QR code, a photo or a chart must be `frame`, never `rectangle`: a rectangle does not render its children, so the label inside a rectangle placeholder is invisible.
|
||||
|
|
@ -4,8 +4,8 @@ description: Presentation slide / deck design — 16:9 layout contracts, slide t
|
|||
phase: [generation]
|
||||
trigger:
|
||||
keywords: [slide, slides, deck, presentation, pitch deck, keynote, ppt, 幻灯片, 演示, 演示文稿, 路演]
|
||||
priority: 28
|
||||
budget: 1800
|
||||
priority: 24
|
||||
budget: 2400
|
||||
category: domain
|
||||
---
|
||||
|
||||
|
|
@ -17,6 +17,53 @@ You design slides readable in real conditions (projector, Zoom, mobile). Priorit
|
|||
|
||||
The selected style guide is a brand/product palette — it is NOT slide-optimized. ALWAYS adapt it for slides: scale type up to the sizes below, widen spacing, raise contrast, simplify. If the guide's body size or contrast would hurt readability on a projector, override it. Readability beats brand fidelity every time. Pull core/accent/neutral from the guide, then enforce the slide sizes regardless of the guide's own scale.
|
||||
|
||||
## Style tiers — pick ONE for the whole deck, then hold it on every slide
|
||||
|
||||
Route the tier from the request (rules below), then use that tier's palette, type scale and element cap verbatim. When a style guide IS selected, keep its accent HUE but move the values onto the tier's roles so the contrast floors below still hold. Hex values and contrast ratios here are measured, not estimates — the listed floor pair is the one that fails first when you swap the accent, so re-measure THAT pair, never the title.
|
||||
|
||||
S1 WARM-WHITE BUSINESS — default. Reports, quarterly reviews, corporate updates, 汇报.
|
||||
|
||||
- bg `#FFFFFF` · surface `#F4F6F9` · ink `#0B1220` · muted `#5A6B85` · accent `#2F5BEA` · accent-soft `#E4EAFD` · border `#DCE2EC`
|
||||
- Contrast: ink/bg 18.72 · muted/bg 5.42 · accent/bg 5.52 · muted/accent-soft 4.51 (FLOOR)
|
||||
- Type: cover 104/700 · page title 64/700 · card title 34–40/600 · body 26–32/400 · KPI 140/700 · eyebrow 24/600
|
||||
- ≤6 elements per slide. Cards carry the `surface` fill; the slide background stays `bg`.
|
||||
- NEVER: a second accent hue; a coloured page background on anything but the closing slide.
|
||||
|
||||
S2 DARK PITCH — 深色 / 暗色 / dark / 科技感 / tech / 路演 / investor pitch.
|
||||
|
||||
- bg `#0B1220` · surface `#18263F` · ink `#F2F6FF` · muted `#93A4C4` · accent `#4D8DFF` · accent-soft `#16233D` · border `#24314D`
|
||||
- Contrast: ink/bg 17.30 · muted/bg 7.44 · accent/bg 5.86 · accent/surface 4.73 (FLOOR)
|
||||
- Type: cover 88–112/700 lh 1.12 · page title 64/700 · card title 38/600 · body 26–30/400 · KPI 140/700 with its unit at 44/600
|
||||
- EXACTLY TWO background levels (bg + surface). ≤5 elements per slide.
|
||||
- NEVER: a third grey plane — on dark, hierarchy comes from weight and the accent, not from stacking more greys. NEVER body text lighter-weight than `muted`, drop shadows, or a second accent.
|
||||
|
||||
S3 LIGHT LECTURE — 课件 / 教学 / 培训 / lecture / course / tutorial / workshop.
|
||||
|
||||
- bg `#F2EEE2` (paper, deliberately not pure white — pure white lights the whole room) · surface `#FFFFFF` · ink `#17211C` · muted `#55635A` · accent `#1B6B4C` · accent-soft `#D8E9DE` · border `#D9D2C2`
|
||||
- Contrast: ink/bg 14.25 · accent/bg 5.56 · white-on-accent 6.45 · muted/accent-soft 5.01 (FLOOR)
|
||||
- Type: cover 100/700 · page title 64/700 · step/objective title 30–38/600 · body 26–30/400 at lineHeight 1.5–1.6 (denser than a pitch — the audience is taking notes)
|
||||
- ≤8 elements per slide. The numbered circle is this tier's signature: a filled accent circle with a white digit is the highest-contrast point on the page.
|
||||
- NEVER: a procedure slide with no visible step order; body below 26.
|
||||
|
||||
S4 MINIMAL KEYNOTE — 极简 / 简约 / minimal / keynote / "one big idea".
|
||||
|
||||
- Pick ONE ground and never mix: S1's `#FFFFFF`/`#0B1220` pair, or S2's `#0B1220`/`#F2F6FF` pair. Exactly one accent, used at most once per slide.
|
||||
- Type: statement 88–140/700 lh 1.12 · one supporting line 30–34/400. Nothing else.
|
||||
- ≤4 elements per slide, counting the accent bar.
|
||||
- NEVER: bullet lists, cards, borders, tables, icons, or a slide carrying title + subtitle + body + footer at once.
|
||||
|
||||
## Route the tier from the request
|
||||
|
||||
Scan the user's words in this order and take the FIRST hit; scan the deck's subject only if no style word appears:
|
||||
|
||||
1. 深色/暗色/黑色/dark/night/科技感/tech/cyber/neon → S2. 路演/pitch/投资人/investor/融资 also → S2.
|
||||
2. 极简/简约/minimal/keynote/性冷淡/one big idea/less is more → S4.
|
||||
3. 课件/教学/讲义/培训/lecture/course/tutorial/workshop/教程 → S3.
|
||||
4. 浅色/明亮/light/白底/商务/汇报/季度/年度/report/review/corporate → S1.
|
||||
5. No style word at all → S1.
|
||||
|
||||
An explicit brand colour in the request overrides the tier's accent (keep every other role). A tier is a whole-deck decision — never switch tiers between slides of one deck.
|
||||
|
||||
## Format
|
||||
|
||||
- Each slide is a 16:9 frame, 1920×1080. Keep all content ≥100px from the edges.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name: shapes-and-decks
|
|||
description: Full worked examples for concentric progress rings/donuts/gauges and stacked-card decks (companion depth to the layout skill's compact rules)
|
||||
phase: [generation]
|
||||
trigger:
|
||||
keywords: [ring, donut, gauge, pie chart, progress ring, activity ring, deck, stacked card, card stack, swipeable, flashcard, 进度环, 甜甜圈, 仪表盘, 堆叠卡]
|
||||
keywords: [ring, donut, gauge, pie chart, progress ring, activity ring, card deck, stacked deck, stacked card, card stack, swipeable, flashcard, 进度环, 甜甜圈, 仪表盘, 堆叠卡]
|
||||
priority: 28
|
||||
budget: 1200
|
||||
category: knowledge
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Orchestrator task decomposition — splits UI requests into cohesiv
|
|||
phase: [planning]
|
||||
trigger: null
|
||||
priority: 0
|
||||
budget: 3000
|
||||
budget: 3500
|
||||
category: base
|
||||
---
|
||||
|
||||
|
|
@ -44,7 +44,15 @@ OTHERWISE classify by purpose:
|
|||
- Every subtask MUST carry a `screen` field naming that slide, and no two may share a value. This field is what makes each slide its own frame; without it the whole deck collapses onto one board.
|
||||
- Each subtask's region is the full slide: {"width":1920,"height":1080}
|
||||
- Structure per slide: one idea — a takeaway title plus its supporting content. NO status bar, NO navigation bar, NO footer.
|
||||
- Honour an explicitly requested slide count ("6 页"/"5-slide"); otherwise plan 5-8 slides covering cover, agenda, the argument, evidence, and a close.
|
||||
- Honour an explicitly requested slide count ("6 页"/"5-slide"); otherwise plan 5-8 slides.
|
||||
- OUTLINE MODE — pick the running order from the deck's PURPOSE, then name each subtask after the slide it produces (that name is the board's title, so it must read as a slide title, not as "Section 3"):
|
||||
- Pitch / 路演 / 融资: cover - the problem (3 pains) - the solution - proof data (3 KPIs) - milestones/roadmap - the ask + contact.
|
||||
- Lecture / 课件 / 培训: cover - learning objectives - the concept - a worked example (numbered steps) - a comparison of the two things students confuse - summary + homework.
|
||||
- Report / 汇报 / 季度: cover - agenda - what we did (3 points) - results (3 KPIs) - a trend chart with its takeaway - next steps.
|
||||
- Product launch / 发布: cover - the change in the market - the product - three capabilities - evidence/benchmark - availability + CTA.
|
||||
- Anything else: cover - agenda - 2-4 argument slides - one evidence slide - closing.
|
||||
- COPY LIMITS (a slide is a visual aid, not a document — plan the amount, do not leave it to the generator): slide title <= 14 CJK chars / ~10 English words. Bullet or card item <= 20 CJK chars / ~14 English words. Total body copy per slide <= 80 CJK chars / ~55 English words. At most 3 cards, 3 KPIs, 5 timeline nodes, or 5 bullets on one slide — if the content needs more, plan another slide instead.
|
||||
- The "elements" field for a slide names the slide's ONE takeaway plus its supporting parts (e.g. "takeaway title, 3 KPI cards each with value + unit + label + note"), never a list of paragraphs.
|
||||
|
||||
CRITICAL — "MOBILE" MEANS MOBILE-SIZED SCREEN, NOT A PHONE MOCKUP:
|
||||
When the user says "mobile"/"移动端"/"手机" + a screen type (login, profile, settings, etc.), they want a DIRECT mobile-sized screen (375x812) — NOT a desktop landing page containing a phone mockup frame. A "mobile login page" = type 2 (375x812 login screen). Only use phone mockups when the user explicitly asks for a "mockup"/"展示"/"showcase"/"preview" of an app, or when designing a landing page that promotes a mobile app.
|
||||
|
|
|
|||
266
crates/op-ai-skills/src/deck_corpus_tests.rs
Normal file
266
crates/op-ai-skills/src/deck_corpus_tests.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
//! Corpus guards for the deck skills (`slides` + `deck-patterns`).
|
||||
//!
|
||||
//! Delivery into the assembled system prompt is guarded on the orchestrator
|
||||
//! side (`prompt_deck_skill_tests`); this file guards the corpus itself —
|
||||
//! registration, trigger wiring, and the tier/pattern content the deck
|
||||
//! generation path depends on.
|
||||
|
||||
use crate::loader::{get_skill_by_name, get_skills_by_phase};
|
||||
use crate::resolver::match_keyword;
|
||||
use crate::types::{Phase, SkillCategory, SkillTrigger};
|
||||
|
||||
#[test]
|
||||
fn deck_patterns_registers_as_a_keyword_gated_generation_domain_skill() {
|
||||
let skill = get_skill_by_name("deck-patterns").expect("deck-patterns must be registered");
|
||||
assert_eq!(skill.meta.category, SkillCategory::Domain);
|
||||
assert!(skill.meta.phase.contains(&Phase::Generation));
|
||||
// Priority below every other generation Domain skill that a deck prompt can
|
||||
// also trigger (`dashboard` 28, `web-app` 30, `landing-page` 35): the budget
|
||||
// filler walks Domain skills in ascending priority, so a deck prompt that
|
||||
// also says "数据"/"product" must spend its remaining tokens on the deck
|
||||
// teaching first. Measured: at the old priority 28 `slides` lost its tail —
|
||||
// or was dropped entirely — on "做一份季度数据汇报 PPT,要有仪表盘和数据表格".
|
||||
assert!(
|
||||
skill.meta.priority < 28,
|
||||
"deck-patterns must outrank the generic page domains, got {}",
|
||||
skill.meta.priority
|
||||
);
|
||||
match &skill.meta.trigger {
|
||||
SkillTrigger::Keywords(keywords) => {
|
||||
for word in ["deck", "ppt", "幻灯片", "课件", "路演"] {
|
||||
assert!(
|
||||
keywords.iter().any(|k| k == word),
|
||||
"deck-patterns must trigger on {word:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("deck-patterns must be keyword-gated, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_presentation_deck_prompt_no_longer_pulls_the_stacked_card_worked_example() {
|
||||
// `shapes-and-decks` teaches concentric rings and STACKED CARD decks. Its
|
||||
// bare `deck` keyword also fired on every presentation deck, spending ~1000
|
||||
// tokens of the generation budget on a ring/card-stack worked example that
|
||||
// has nothing to do with slides — and those are tokens `slides` then could
|
||||
// not have. The narrowed keywords must still cover the real stacked-card
|
||||
// intents while leaving presentation decks alone.
|
||||
let shapes = get_skill_by_name("shapes-and-decks").expect("shapes-and-decks registered");
|
||||
let SkillTrigger::Keywords(keywords) = &shapes.meta.trigger else {
|
||||
panic!("shapes-and-decks must be keyword-gated");
|
||||
};
|
||||
let fires = |message: &str| {
|
||||
let lowered = message.to_lowercase();
|
||||
keywords
|
||||
.iter()
|
||||
.any(|k| match_keyword(&lowered, &k.to_lowercase()))
|
||||
};
|
||||
for slides_prompt in [
|
||||
"generate a 6-slide pitch deck about our launch",
|
||||
"帮我做一个融资路演 PPT",
|
||||
"a keynote deck for the all-hands",
|
||||
] {
|
||||
assert!(
|
||||
!fires(slides_prompt),
|
||||
"the stacked-card worked example must not load for {slides_prompt:?}"
|
||||
);
|
||||
}
|
||||
for stack_prompt in [
|
||||
"a swipeable card deck of testimonials",
|
||||
"show a stacked card stack",
|
||||
"堆叠卡组",
|
||||
"an activity ring for the fitness screen",
|
||||
] {
|
||||
assert!(
|
||||
fires(stack_prompt),
|
||||
"shapes-and-decks must still fire for {stack_prompt:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_teaches_four_routable_style_tiers_with_measured_contrast_floors() {
|
||||
let slides = get_skill_by_name("slides").expect("slides registered");
|
||||
let body = &slides.content;
|
||||
for tier in [
|
||||
"S1 WARM-WHITE BUSINESS",
|
||||
"S2 DARK PITCH",
|
||||
"S3 LIGHT LECTURE",
|
||||
"S4 MINIMAL KEYNOTE",
|
||||
] {
|
||||
assert!(body.contains(tier), "slides must define {tier}");
|
||||
}
|
||||
// Every tier states the pair that fails first, so the model re-measures the
|
||||
// right one after swapping an accent. These ratios are copied from the
|
||||
// shipped template generators (`templates/step0/_generators/*.py`) — a tier
|
||||
// without a FLOOR line is a tier whose palette can be silently broken.
|
||||
assert_eq!(
|
||||
body.matches("(FLOOR)").count(),
|
||||
3,
|
||||
"S1/S2/S3 each declare their floor pair; S4 reuses S1/S2's ground"
|
||||
);
|
||||
for floor in [
|
||||
"muted/accent-soft 4.51 (FLOOR)", // S1, computed from tpl_slides.py's palette
|
||||
"accent/surface 4.73 (FLOOR)", // S2, tpl_pitch_dark.py's own measurement
|
||||
"muted/accent-soft 5.01 (FLOOR)", // S3, tpl_lecture_light.py's own measurement
|
||||
] {
|
||||
assert!(
|
||||
body.contains(floor),
|
||||
"slides must carry the measured {floor}"
|
||||
);
|
||||
}
|
||||
// Routing must be executable: each tier is reachable from words a user
|
||||
// actually types, in both languages.
|
||||
assert!(body.contains("## Route the tier from the request"));
|
||||
for word in ["深色", "极简", "课件", "minimal", "lecture", "dark"] {
|
||||
assert!(body.contains(word), "tier routing must cover {word:?}");
|
||||
}
|
||||
// Per-tier element caps — the rule that keeps a generated slide from
|
||||
// becoming a document.
|
||||
assert_eq!(
|
||||
body.matches("elements per slide").count(),
|
||||
4,
|
||||
"every tier must cap its element count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_patterns_carries_the_structural_numbers_the_templates_proved() {
|
||||
let body = &get_skill_by_name("deck-patterns")
|
||||
.expect("deck-patterns registered")
|
||||
.content;
|
||||
for rule in [
|
||||
// Board placement — the "six slides stacked at the origin" bug.
|
||||
"x = (i % 3) * (1920 + 120)",
|
||||
"y = (i / 3) * (1080 + 360)",
|
||||
// KPI unit baseline compensation — there is no real baseline align.
|
||||
"round((valueSize - unitSize) * 0.2)",
|
||||
// Timeline axis continuity.
|
||||
"**gap 0**",
|
||||
// Table rules.
|
||||
"**The last row carries no stroke**",
|
||||
// Bullet-dot optical centring.
|
||||
"round(fontSize * lineHeight / 2 - dotSize / 2)",
|
||||
// Numbered circle digit size.
|
||||
"round(size*0.46)",
|
||||
// Placeholders must be frames, not rectangles.
|
||||
"a rectangle does not render its children",
|
||||
] {
|
||||
assert!(body.contains(rule), "deck-patterns must teach {rule:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposition_carries_the_deck_outline_templates_and_copy_caps() {
|
||||
let body = &get_skill_by_name("decomposition")
|
||||
.expect("decomposition registered")
|
||||
.content;
|
||||
assert!(body.contains("OUTLINE MODE"));
|
||||
for outline in [
|
||||
"Pitch / 路演 / 融资",
|
||||
"Lecture / 课件 / 培训",
|
||||
"Report / 汇报 / 季度",
|
||||
"Product launch / 发布",
|
||||
] {
|
||||
assert!(body.contains(outline), "type-4 must plan {outline}");
|
||||
}
|
||||
assert!(body.contains("COPY LIMITS"));
|
||||
assert!(body.contains("slide title <= 14 CJK chars"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_deck_prompt_resolves_both_deck_skills_untruncated() {
|
||||
// The generation-phase total is what actually decides this; these prompts
|
||||
// are the ones that used to lose `slides` (each pulls a second, larger
|
||||
// domain skill: dashboard / web-app / landing-page / mobile-app).
|
||||
for prompt in [
|
||||
"帮我做一个 8 页的融资路演 PPT,深色科技感",
|
||||
"做一份季度数据汇报 PPT,要有仪表盘和数据表格",
|
||||
"generate a 10-slide deck for our SaaS admin console product with analytics data tables",
|
||||
"极简 keynote 演示,讲移动端 app 的设计",
|
||||
"帮我做一个教学课件幻灯片,讲解表单设计和登录页",
|
||||
"pitch deck landing page marketing homepage slides",
|
||||
] {
|
||||
let ctx =
|
||||
crate::resolve_skills(Phase::Generation, prompt, &crate::ResolveOptions::default());
|
||||
for name in ["slides", "deck-patterns"] {
|
||||
let entry = ctx
|
||||
.report
|
||||
.included
|
||||
.iter()
|
||||
.find(|e| e.name == name)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{prompt:?} dropped {name}; kept {:?} at {}/{} tokens",
|
||||
ctx.report
|
||||
.included
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
ctx.report.budget_used,
|
||||
ctx.report.budget_max
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
!entry.truncated,
|
||||
"{prompt:?} tail-truncated {name} at {}/{} tokens",
|
||||
ctx.report.budget_used, ctx.report.budget_max
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_typography_outranks_the_large_optional_domains() {
|
||||
// 339 tokens of CJK correctness must never lose its slot to a 1800-token
|
||||
// optional skill. It did, once the deck skills joined the same queue:
|
||||
// "深色路演 deck,交互式原型演示" dropped `cjk-typography` for
|
||||
// `interactivity`. Priority is the only lever that fixes that ordering.
|
||||
let cjk = get_skill_by_name("cjk-typography").expect("cjk-typography registered");
|
||||
for name in [
|
||||
"interactivity",
|
||||
"dashboard",
|
||||
"web-app",
|
||||
"mobile-app",
|
||||
"slides",
|
||||
] {
|
||||
let other = get_skill_by_name(name).unwrap_or_else(|| panic!("{name} registered"));
|
||||
assert!(
|
||||
cjk.meta.priority < other.meta.priority,
|
||||
"cjk-typography ({}) must fill before {name} ({})",
|
||||
cjk.meta.priority,
|
||||
other.meta.priority
|
||||
);
|
||||
}
|
||||
let ctx = crate::resolve_skills(
|
||||
Phase::Generation,
|
||||
"深色路演 deck,交互式原型演示,展示我们的 web app 控制台",
|
||||
&crate::ResolveOptions::default(),
|
||||
);
|
||||
assert!(
|
||||
ctx.report
|
||||
.included
|
||||
.iter()
|
||||
.any(|e| e.name == "cjk-typography" && !e.truncated),
|
||||
"a CJK deck prompt must keep cjk-typography; kept {:?}",
|
||||
ctx.report
|
||||
.included
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_deck_corpus_is_reachable_from_the_generation_phase_registry() {
|
||||
// Guards the loader's directory walk: a new file under `skills/domains/`
|
||||
// only becomes a skill if `include_dir` embedded it AND the frontmatter
|
||||
// parsed. A typo in either yields silence, not an error.
|
||||
let names: Vec<&str> = get_skills_by_phase(Phase::Generation)
|
||||
.iter()
|
||||
.map(|s| s.meta.name.as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&"deck-patterns"), "got {names:?}");
|
||||
assert!(names.contains(&"slides"), "got {names:?}");
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ use include_dir::{include_dir, Dir};
|
|||
pub mod budget;
|
||||
pub mod color;
|
||||
pub mod compose;
|
||||
#[cfg(test)]
|
||||
mod deck_corpus_tests;
|
||||
pub mod design_systems;
|
||||
pub mod frontmatter;
|
||||
pub mod loader;
|
||||
|
|
@ -84,7 +86,11 @@ const GUIDELINE_TOPICS: &[(&str, &[&str], &[&str])] = &[
|
|||
&["table"],
|
||||
&["dashboard", "product-principles"],
|
||||
),
|
||||
("slides", &["deck", "presentation"], &["slides"]),
|
||||
(
|
||||
"slides",
|
||||
&["deck", "presentation"],
|
||||
&["slides", "deck-patterns"],
|
||||
),
|
||||
("form", &["form-ui"], &["form-ui"]),
|
||||
("design-system", &[], &["design-system-composition"]),
|
||||
("interactivity", &[], &["interactivity"]),
|
||||
|
|
@ -396,12 +402,22 @@ mod tests {
|
|||
lp.contains("DESIGN CRAFT"),
|
||||
"landing-page must include design craft"
|
||||
);
|
||||
// slides resolves to the slide layout contracts.
|
||||
// slides resolves to the slide layout contracts AND the pattern
|
||||
// skeletons — an external agent asking for slide guidance needs both
|
||||
// the tier/format rules and the structures that satisfy them.
|
||||
let sl = guideline_for("slides").expect("slides guideline present");
|
||||
assert!(
|
||||
sl.to_uppercase().contains("SLIDE"),
|
||||
"slides must include slide guidance"
|
||||
);
|
||||
assert!(
|
||||
sl.contains("## Style tiers — pick ONE for the whole deck"),
|
||||
"slides guideline must carry the style tiers"
|
||||
);
|
||||
assert!(
|
||||
sl.contains("DECK PATTERNS — SLIDE SKELETONS"),
|
||||
"slides guideline must carry the pattern skeletons"
|
||||
);
|
||||
// dashboard / table both resolve.
|
||||
assert!(
|
||||
guideline_for("dashboard").is_some(),
|
||||
|
|
|
|||
|
|
@ -176,6 +176,13 @@ fn compact_subagent_skills<T: SkillNamed>(
|
|||
"cjk-typography",
|
||||
"mobile-app",
|
||||
"mobile-ui",
|
||||
// Deck teaching. Keyword-gated at the resolve layer (deck words
|
||||
// only), so a no-op on every non-deck prompt. Required on a deck:
|
||||
// the allow-set used to drop `slides` unconditionally, so a
|
||||
// Basic-tier model asked for a PPT got the generic page skills and
|
||||
// none of the 16:9 contract — measured 2026-08-04.
|
||||
"slides",
|
||||
"deck-patterns",
|
||||
"icon-catalog",
|
||||
"style-defaults",
|
||||
"elements",
|
||||
|
|
@ -204,6 +211,13 @@ fn compact_subagent_skills<T: SkillNamed>(
|
|||
// under the tight retry budget. (Codex review 2026-06-06.)
|
||||
"design-system",
|
||||
"cjk-typography",
|
||||
// Kept on the retry for the same reason as the allow-set above:
|
||||
// keyword-gated, so a no-op off a deck, and on a deck the retry
|
||||
// has to keep the 16:9 contract or it regenerates a scrolling
|
||||
// page. `deck-patterns` is deliberately omitted — the retry
|
||||
// wants the smallest viable prompt, and `slides` carries the
|
||||
// non-negotiable format/typography rules on its own.
|
||||
"slides",
|
||||
// Kept on the retry too: `build_subagent_prompt` still injects
|
||||
// the AVAILABLE COMPONENTS manifest on a reduced-complexity
|
||||
// retry (the manifest block is gated on the library being
|
||||
|
|
|
|||
239
crates/op-orchestrator/src/prompt_deck_skill_tests.rs
Normal file
239
crates/op-orchestrator/src/prompt_deck_skill_tests.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
//! Deck-corpus delivery guards — "the corpus was edited" vs "the model saw it".
|
||||
//!
|
||||
//! Every assertion here reads the FINAL assembled `system_prompt`, not the
|
||||
//! resolved skill list, because the two failure modes this file exists for are
|
||||
//! both invisible upstream of assembly: a skill dropped for `BudgetExhausted`
|
||||
//! (the phase total ran out) and a skill whose TAIL was chopped by the Step 3
|
||||
//! knapsack. Both leave the corpus file on disk perfectly intact.
|
||||
//!
|
||||
//! Measured before these guards existed (2026-08-04): on the plain non-mobile
|
||||
//! tier budgets a deck subtask resolved ~6200 tokens of always-kept Base skills
|
||||
//! against a 5200 (Basic) / 6500 (Standard) ceiling, so `slides` was dropped
|
||||
//! outright at Basic and cut to 271 of its tokens at Standard — and the
|
||||
//! Basic-tier allow-set in `compact_skills` then dropped `slides` a second time
|
||||
//! regardless of budget. A weak model asked for a PPT received zero slide
|
||||
//! guidance while `domains/slides.md` sat in the repo looking correct.
|
||||
|
||||
use super::*;
|
||||
use crate::plan::{Region, RootFrameSpec};
|
||||
|
||||
/// A plan shaped like a real deck board: the fixed 1920x1080 projector
|
||||
/// artboard `decomposition`'s type-4 branch mandates.
|
||||
fn deck_plan() -> OrchestratorPlan {
|
||||
OrchestratorPlan {
|
||||
root_frame: RootFrameSpec {
|
||||
id: "deck".into(),
|
||||
name: "Deck".into(),
|
||||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
layout: None,
|
||||
gap: None,
|
||||
padding: None,
|
||||
fill: None,
|
||||
},
|
||||
subtasks: vec![],
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn deck_subtask() -> crate::plan::Subtask {
|
||||
crate::plan::Subtask {
|
||||
id: "cover".into(),
|
||||
label: "封面".into(),
|
||||
region: Region {
|
||||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
},
|
||||
id_prefix: "cover".into(),
|
||||
parent_frame_id: None,
|
||||
elements: None,
|
||||
screen: Some("封面".into()),
|
||||
generated_root_id: None,
|
||||
existing_section_labels: None,
|
||||
retry_feedback: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn deck_request(model: &str) -> DesignRequest {
|
||||
DesignRequest {
|
||||
prompt: "帮我做一个 8 页的融资路演 PPT,深色科技感".into(),
|
||||
model: Some(model.into()),
|
||||
provider: None,
|
||||
design_md: None,
|
||||
concurrency: 1,
|
||||
append_context: None,
|
||||
validation_enabled: true,
|
||||
visual_ref_enabled: false,
|
||||
pinned_style_guide: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The last line of a skill's markdown body. Asserting on the TAIL is what
|
||||
/// makes these guards non-vacuous: both truncation mechanisms cut from the
|
||||
/// end, so a head-only assertion passes on a prompt that lost half the skill.
|
||||
fn skill_tail(name: &str) -> &'static str {
|
||||
op_ai_skills::get_skill_by_name(name)
|
||||
.unwrap_or_else(|| panic!("{name} must be registered"))
|
||||
.content
|
||||
.trim_end()
|
||||
.lines()
|
||||
.last()
|
||||
.expect("skill body is non-empty")
|
||||
}
|
||||
|
||||
/// Signature lines from the deck corpus that must reach the model verbatim.
|
||||
/// Head, middle and tail of each file, so a partial delivery still fails.
|
||||
const SLIDES_MARKERS: [&str; 5] = [
|
||||
"## Style tiers — pick ONE for the whole deck",
|
||||
"S2 DARK PITCH",
|
||||
"accent/surface 4.73 (FLOOR)",
|
||||
"## Route the tier from the request",
|
||||
"L09 Single KPI",
|
||||
];
|
||||
|
||||
const DECK_PATTERNS_MARKERS: [&str; 5] = [
|
||||
"DECK PATTERNS — SLIDE SKELETONS",
|
||||
"y = (i / 3) * (1080 + 360)",
|
||||
"round((valueSize - unitSize) * 0.2)",
|
||||
"**The last row carries no stroke**",
|
||||
"a rectangle does not render its children",
|
||||
];
|
||||
|
||||
/// Full / Standard / Basic all reach the model with the deck corpus whole.
|
||||
/// One model id per tier — the tier is what selects the budget arm and the
|
||||
/// `compact_skills` allow-set, and each of those dropped the deck skills on
|
||||
/// its own before this was wired.
|
||||
#[test]
|
||||
fn every_tier_receives_the_deck_corpus_intact() {
|
||||
for model in ["claude-opus-5", "kimi-k2.5", "glm-4.6"] {
|
||||
let plan = deck_plan();
|
||||
let subtask = deck_subtask();
|
||||
let (call, report) = build_subagent_prompt(
|
||||
&subtask,
|
||||
&plan,
|
||||
&deck_request(model),
|
||||
AbortFlag::new(),
|
||||
false,
|
||||
false,
|
||||
&op_editor_core::ComponentLibrary::default(),
|
||||
);
|
||||
let prompt = &call.system_prompt;
|
||||
|
||||
for marker in SLIDES_MARKERS.iter().chain(DECK_PATTERNS_MARKERS.iter()) {
|
||||
assert!(
|
||||
prompt.contains(marker),
|
||||
"model {model:?}: assembled system prompt is missing {marker:?} — \
|
||||
the deck corpus was dropped or truncated before the model saw it. \
|
||||
Loaded: {:?}; used {}/{} tokens",
|
||||
report
|
||||
.included
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
report.budget_used,
|
||||
report.budget_max,
|
||||
);
|
||||
}
|
||||
|
||||
// The tails specifically — the knapsack cuts from the end.
|
||||
for name in ["slides", "deck-patterns"] {
|
||||
assert!(
|
||||
prompt.contains(skill_tail(name)),
|
||||
"model {model:?}: {name} reached the prompt without its last line — \
|
||||
it was tail-truncated by the phase budget ({}/{})",
|
||||
report.budget_used,
|
||||
report.budget_max,
|
||||
);
|
||||
}
|
||||
|
||||
let truncated: Vec<&str> = report
|
||||
.included
|
||||
.iter()
|
||||
.filter(|e| e.truncated)
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
truncated.is_empty(),
|
||||
"model {model:?}: truncated skills {truncated:?} ({}/{} tokens)",
|
||||
report.budget_used,
|
||||
report.budget_max,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The budget arm must be selected by the deck's ARTBOARD, not by the prompt
|
||||
/// wording — a deck plan whose request never says "deck" still needs the room.
|
||||
#[test]
|
||||
fn the_deck_budget_arm_keys_off_the_projector_artboard() {
|
||||
assert!(is_deck_board(&deck_plan()));
|
||||
// A desktop page is 1200x0 (auto-height) and a mobile screen 375x812 —
|
||||
// neither may claim the deck budget.
|
||||
let mut page = deck_plan();
|
||||
page.root_frame.width = 1200.0;
|
||||
page.root_frame.height = 0.0;
|
||||
assert!(!is_deck_board(&page));
|
||||
let mut mobile = deck_plan();
|
||||
mobile.root_frame.width = 375.0;
|
||||
mobile.root_frame.height = 812.0;
|
||||
assert!(!is_deck_board(&mobile));
|
||||
}
|
||||
|
||||
/// A non-deck plan must be byte-for-byte unaffected: the deck skills are
|
||||
/// keyword-gated, so they must not appear on an ordinary page prompt, and the
|
||||
/// tier budget for that page must stay where it was.
|
||||
#[test]
|
||||
fn a_non_deck_subtask_is_unchanged_by_the_deck_arm() {
|
||||
let (call, _report) = bsp(&subtask(), &plan(), &req(), AbortFlag::new(), false, false);
|
||||
assert!(!call
|
||||
.system_prompt
|
||||
.contains("DECK PATTERNS — SLIDE SKELETONS"));
|
||||
assert!(!call
|
||||
.system_prompt
|
||||
.contains("## Route the tier from the request"));
|
||||
}
|
||||
|
||||
/// The reduced-complexity retry keeps the 16:9 contract. Without `slides` in
|
||||
/// `RETRY_ALLOWED` a Basic-tier retry re-generates the board as a scrolling
|
||||
/// page, which is the failure the retry was supposed to fix.
|
||||
#[test]
|
||||
fn the_basic_reduced_retry_keeps_the_slide_format_contract() {
|
||||
let (call, _report) = build_subagent_prompt(
|
||||
&deck_subtask(),
|
||||
&deck_plan(),
|
||||
&deck_request("glm-4.6"),
|
||||
AbortFlag::new(),
|
||||
true, // reduced_complexity
|
||||
false,
|
||||
&op_editor_core::ComponentLibrary::default(),
|
||||
);
|
||||
assert!(
|
||||
call.system_prompt
|
||||
.contains("Each slide is a 16:9 frame, 1920×1080"),
|
||||
"the reduced-complexity retry lost the slide format contract"
|
||||
);
|
||||
}
|
||||
|
||||
/// Planning side: the type-4 outline templates and copy caps must survive
|
||||
/// `decomposition`'s own per-skill budget AND the planning phase total.
|
||||
#[test]
|
||||
fn the_planning_prompt_carries_the_deck_outline_mode() {
|
||||
let request = DesignRequest {
|
||||
prompt: "帮我做一个 8 页的融资路演 PPT".into(),
|
||||
..deck_request("claude-opus-5")
|
||||
};
|
||||
let pp = build_orchestrator_prompt(&request, PlanningMode::Rich, AbortFlag::new());
|
||||
let prompt = &pp.call_request.system_prompt;
|
||||
for marker in [
|
||||
"OUTLINE MODE",
|
||||
"Pitch / 路演 / 融资: cover - the problem (3 pains)",
|
||||
"Lecture / 课件 / 培训: cover - learning objectives",
|
||||
"COPY LIMITS",
|
||||
"slide title <= 14 CJK chars",
|
||||
] {
|
||||
assert!(
|
||||
prompt.contains(marker),
|
||||
"planning prompt is missing {marker:?} — `decomposition` was truncated \
|
||||
by its own budget or by the planning phase total"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -73,7 +73,7 @@ fn worst_case_style_guide_context() -> (String, String) {
|
|||
for model in models {
|
||||
for prompt in prompts {
|
||||
for spec in [None, Some(&design_md)] {
|
||||
let ctx = build_planning_style_guide_context(prompt, Some(model), mode, spec);
|
||||
let ctx = build_planning_style_guide_context(prompt, Some(model), mode, spec, None);
|
||||
if ctx.available_style_guides.chars().count() > worst.0.chars().count() {
|
||||
let label = format!(
|
||||
"mode={mode:?} model={model:?} design_md={} prompt={prompt:?}",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,24 @@ pub(super) fn is_mobile_full_screen(plan: &OrchestratorPlan) -> bool {
|
|||
plan.subtasks.len() >= 2
|
||||
}
|
||||
|
||||
/// 该 plan 是否代表一块投影幻灯片(16:9 定尺画板)。
|
||||
///
|
||||
/// The deck preset is a fixed 1920x1080 board (`design_type::DECK_PRESET`), and
|
||||
/// `decomposition` mandates that size for every type-4 plan — nothing else in
|
||||
/// the corpus asks for a root that is both this wide AND fixed-height (landing
|
||||
/// pages and dashboards carry `height = 0` so they auto-expand). That pair is
|
||||
/// therefore the deck signature.
|
||||
///
|
||||
/// It exists for the same reason `is_mobile_full_screen` feeds the budget
|
||||
/// override below: the deck teaching (`slides` + `deck-patterns`) is ~4000
|
||||
/// tokens, so on the plain non-mobile tier budgets (Basic 5200 / Standard 6500,
|
||||
/// against ~6200 of always-kept Base skills) BOTH are dropped for
|
||||
/// `BudgetExhausted` and a weak model designs a deck with no deck guidance at
|
||||
/// all — measured 2026-08-04, before this arm existed.
|
||||
pub(super) fn is_deck_board(plan: &OrchestratorPlan) -> bool {
|
||||
plan.root_frame.width >= 1600.0 && plan.root_frame.height >= 900.0
|
||||
}
|
||||
|
||||
/// Build the sub-agent style-guide instruction block for the planner-selected
|
||||
/// guide. Port of `buildSubAgentStyleGuideInstruction`
|
||||
/// (orchestrator-sub-agent-compact.ts:78-124).
|
||||
|
|
|
|||
|
|
@ -195,10 +195,21 @@ pub(super) fn build_subagent_prompt_core(
|
|||
// compact filter; the `mobile-ui` rules used to be appended to the user prompt
|
||||
// (uncounted) and now live in a budgeted skill, so the budget grows by ~its
|
||||
// size — the TOTAL prompt is unchanged, the rules just moved user→system.
|
||||
// A deck board gets its own arm for the same reason mobile does: the
|
||||
// `slides` + `deck-patterns` teaching is ~4000 tokens on top of ~6200 of
|
||||
// always-kept Base skills, so under the plain 5200 / 6500 arms both are
|
||||
// dropped for BudgetExhausted and the model designs slides with no slide
|
||||
// guidance. 11500 covers the always-kept Base set (which on this path
|
||||
// includes `style-defaults`, loaded by the `noStyleGuideMatch` flag) plus
|
||||
// `cjk-typography` and both deck skills whole; at 10600 `slides` still lost
|
||||
// its tail, which `prompt_deck_skill_tests` asserts against.
|
||||
let is_deck = is_deck_board(plan);
|
||||
let budget_override = match tier {
|
||||
ModelTier::Basic if is_mobile_layout || is_mobile_screen => Some(9200),
|
||||
ModelTier::Basic if is_deck => Some(11500),
|
||||
ModelTier::Basic => Some(5200),
|
||||
ModelTier::Standard if is_mobile_layout => Some(9500),
|
||||
ModelTier::Standard if is_deck => Some(11500),
|
||||
ModelTier::Standard => Some(6500),
|
||||
ModelTier::Full => None,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue