1use super::{ContentError, ContentType, CourseLevel, ModelContext, TokenBudget};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct EmitConfig {
16 pub content_type: Option<ContentType>,
18 pub title: Option<String>,
20 pub audience: Option<String>,
22 pub word_count: Option<usize>,
24 pub source_context_paths: Vec<PathBuf>,
26 pub rag_context_path: Option<PathBuf>,
28 pub rag_limit: usize,
30 pub model: ModelContext,
32 pub show_budget: bool,
34 pub course_level: CourseLevel,
36}
37
38impl EmitConfig {
39 pub fn new(content_type: ContentType) -> Self {
41 Self {
42 content_type: Some(content_type),
43 model: ModelContext::Claude200K,
44 rag_limit: 4000,
45 ..Default::default()
46 }
47 }
48
49 pub fn with_title(mut self, title: impl Into<String>) -> Self {
51 self.title = Some(title.into());
52 self
53 }
54
55 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
57 self.audience = Some(audience.into());
58 self
59 }
60
61 pub fn with_word_count(mut self, count: usize) -> Self {
63 self.word_count = Some(count);
64 self
65 }
66
67 pub fn with_source_context(mut self, path: PathBuf) -> Self {
69 self.source_context_paths.push(path);
70 self
71 }
72
73 pub fn with_rag_context(mut self, path: PathBuf, limit: usize) -> Self {
75 self.rag_context_path = Some(path);
76 self.rag_limit = limit;
77 self
78 }
79
80 pub fn with_course_level(mut self, level: CourseLevel) -> Self {
82 self.course_level = level;
83 self
84 }
85}
86
87#[derive(Debug, Clone)]
93pub struct PromptEmitter {
94 toyota_constraints: String,
96 quality_gates: String,
98}
99
100impl PromptEmitter {
101 pub fn toyota_constraints(&self) -> &str {
103 &self.toyota_constraints
104 }
105
106 pub fn quality_gates(&self) -> &str {
108 &self.quality_gates
109 }
110
111 pub fn new() -> Self {
113 Self {
114 toyota_constraints: Self::default_toyota_constraints(),
115 quality_gates: Self::default_quality_gates(),
116 }
117 }
118
119 fn default_toyota_constraints() -> String {
121 r#"## Toyota Way Constraints
122
123These principles MUST be followed:
124
1251. **Jidoka (Built-in Quality)**: Every output must pass quality gates
1262. **Poka-Yoke (Error Prevention)**: Follow structural constraints exactly
1273. **Genchi Genbutsu (Go and See)**: Reference provided source material
1284. **Heijunka (Level Loading)**: Stay within target length range
1295. **Kaizen (Continuous Improvement)**: Learn from feedback
130
131**Voice Requirements**:
132- Use direct instruction, not meta-commentary
133- NO: "In this chapter, we will learn about..."
134- YES: "Create a new project with cargo new..."
135- NO: "This section covers error handling."
136- YES: "Rust's Result type provides explicit error handling."
137"#
138 .to_string()
139 }
140
141 fn default_quality_gates() -> String {
143 r#"## Quality Gates (Andon)
144
145Before submitting, verify:
146- [ ] No meta-commentary ("In this section, we will...")
147- [ ] All code blocks specify language (```rust, ```python)
148- [ ] Heading hierarchy is strict (no skipped levels)
149- [ ] Content is within target length range
150- [ ] Source material is referenced where provided
151"#
152 .to_string()
153 }
154
155 pub fn emit(&self, config: &EmitConfig) -> Result<String, ContentError> {
157 let content_type = config
158 .content_type
159 .ok_or_else(|| ContentError::MissingRequiredField("content_type".to_string()))?;
160
161 let mut prompt = String::new();
162
163 prompt.push_str(&format!("# Content Generation Request: {}\n\n", content_type.name()));
165
166 prompt.push_str("## Context\n\n");
168 prompt.push_str(&format!(
169 "You are creating a {} ({}).\n\n",
170 content_type.name(),
171 content_type.code()
172 ));
173
174 if let Some(title) = &config.title {
175 prompt.push_str(&format!("**Title/Topic**: {}\n", title));
176 }
177 if let Some(audience) = &config.audience {
178 prompt.push_str(&format!("**Target Audience**: {}\n", audience));
179 }
180 if let Some(word_count) = config.word_count {
181 prompt.push_str(&format!("**Target Length**: {} words\n", word_count));
182 } else {
183 let range = content_type.target_length();
184 if range.start > 0 {
185 prompt.push_str(&format!(
186 "**Target Length**: {}-{} {}\n",
187 range.start,
188 range.end,
189 if matches!(
190 content_type,
191 ContentType::HighLevelOutline | ContentType::DetailedOutline
192 ) {
193 "lines"
194 } else {
195 "words"
196 }
197 ));
198 }
199 }
200 prompt.push_str(&format!("**Output Format**: {}\n\n", content_type.output_format()));
201
202 prompt.push_str(&self.toyota_constraints);
204 prompt.push('\n');
205
206 prompt.push_str(&self.emit_type_specific(content_type, config));
208 prompt.push('\n');
209
210 prompt.push_str(&self.quality_gates);
212 prompt.push('\n');
213
214 if config.show_budget {
216 let budget = TokenBudget::new(config.model).with_output_target(
217 TokenBudget::words_to_tokens(config.word_count.unwrap_or(4000)),
218 );
219 prompt.push_str("## Token Budget\n\n");
220 prompt.push_str(&budget.format_display(config.model.name()));
221 prompt.push('\n');
222 }
223
224 prompt.push_str("---\n\n");
226 prompt.push_str("Generate the content now, following all constraints above.\n");
227
228 Ok(prompt)
229 }
230
231 fn emit_type_specific(&self, content_type: ContentType, config: &EmitConfig) -> String {
233 match content_type {
234 ContentType::HighLevelOutline => r#"## Structure Requirements (Poka-Yoke)
235
2361. **Parts**: 3-7 major parts/sections
2372. **Chapters**: 3-5 chapters per part
2383. **Learning Objectives**: 2-4 per chapter (use Bloom's taxonomy verbs)
2394. **Balance**: No part exceeds 25% of total content
2405. **Progression**: Fundamentals → Intermediate → Advanced
241
242## Output Schema
243
244```yaml
245type: high_level_outline
246version: "1.0"
247metadata:
248 title: string
249 description: string
250 target_audience: string
251 prerequisites: [string]
252 estimated_duration: string
253structure:
254 - part: string
255 title: string
256 chapters:
257 - number: int
258 title: string
259 summary: string
260 learning_objectives: [string]
261```
262"#
263 .to_string(),
264 ContentType::DetailedOutline => {
265 let level = &config.course_level;
266 let weeks = level.weeks();
267 let modules = level.modules();
268 let videos = level.videos_per_module();
269 let is_short = matches!(level, CourseLevel::Short);
270
271 let mut output = format!(
272 r#"## Structure Requirements (Poka-Yoke)
273
2741. **Duration**: {} week{} total course length
2752. **Modules**: {} module{} ({} per week)
2763. **Per Module**: {} video{} + 1 quiz + 1 reading + 1 lab
2774. **Video Length**: 5-15 minutes each
2785. **Balance**: 60% video instruction, 15% reading, 15% lab, 10% quiz
2796. **Transitions**: Each video connects to previous/next
2807. **Learning Objectives**: 3 for course{}
281
282## Output Schema
283
284```yaml
285type: detailed_outline
286version: "1.0"
287course:
288 title: string
289 description: string (2-3 sentences summarizing the course)
290 duration_weeks: {}
291 total_modules: {}
292 learning_objectives:
293 - objective: string
294 - objective: string
295 - objective: string
296"#,
297 weeks,
298 if weeks == 1 { "" } else { "s" },
299 modules,
300 if modules == 1 { "" } else { "s" },
301 if weeks > 0 {
302 format!("{:.1}", modules as f32 / weeks as f32)
303 } else {
304 "N/A".to_string()
305 },
306 videos,
307 if videos == 1 { "" } else { "s" },
308 if is_short { "" } else { ", 3 per week" },
309 weeks,
310 modules
311 );
312
313 if !is_short {
315 output.push_str("weeks:\n");
316 for w in 1..=weeks {
317 output.push_str(&format!(
318 r" - week: {}
319 learning_objectives:
320 - objective: string
321 - objective: string
322 - objective: string
323",
324 w
325 ));
326 }
327 }
328
329 output.push_str("modules:\n");
330
331 for m in 1..=modules {
333 let week = if weeks > 0 { ((m - 1) / (modules / weeks).max(1)) + 1 } else { 1 };
334 output.push_str(&format!(
335 r" - id: module_{}
336 week: {}
337 title: string
338 description: string
339 learning_objectives:
340 - objective: string
341 videos:
342",
343 m,
344 week.min(weeks)
345 ));
346
347 for v in 1..=videos {
349 if v == 1 {
350 output.push_str(&format!(
351 r" - id: video_{}_{}
352 title: string
353 duration_minutes: int (5-15)
354 key_points:
355 - point: string
356 code_snippet: optional
357",
358 m, v
359 ));
360 } else {
361 output.push_str(&format!(
362 r" - id: video_{}_{}
363 title: string
364 duration_minutes: int
365",
366 m, v
367 ));
368 }
369 }
370
371 output.push_str(
372 r" reading:
373 title: string
374 duration_minutes: int (15-30)
375 content_summary: string
376 key_concepts:
377 - concept: string
378 quiz:
379 title: string
380 num_questions: int (5-10)
381 topics_covered:
382 - topic: string
383 lab:
384 title: string
385 duration_minutes: int (30-60)
386 objectives:
387 - objective: string
388 starter_code: optional
389",
390 );
391 }
392
393 output.push_str("```\n");
394 output
395 }
396 ContentType::BookChapter => r#"## Writing Guidelines
397
3981. **Instructor voice**: Direct teaching, show then explain
3992. **Code-first**: Show implementation, then explain
4003. **Progressive complexity**: Start simple, build up
4014. **mdBook format**: Proper heading hierarchy
402
403## Formatting Requirements
404
405- H1 (#) for chapter title ONLY
406- H2 (##) for major sections (5-7 per chapter)
407- H3 (###) for subsections as needed
408- Code blocks with language specifier: ```rust, ```python
409- Callouts: > **Note/Warning/Tip**: text
410
411## Example Structure
412
413```markdown
414# Chapter Title
415
416Introduction paragraph...
417
418## Section 1: Getting Started
419
420Content...
421
422### Subsection 1.1
423
424```rust
425// Code with comments
426fn example() {
427 println!("Hello");
428}
429```
430
431> **Note**: Important information here.
432
433## Summary
434
435- Key point 1
436- Key point 2
437
438## Exercises
439
4401. Exercise description...
441```
442"#
443 .to_string(),
444 ContentType::BlogPost => r#"## Blog Post Guidelines
445
4461. **Hook**: First paragraph must grab attention
4472. **Scannable**: Clear headings, short paragraphs
4483. **Practical**: Real-world examples and takeaways
4494. **SEO-friendly**: Title under 60 chars, description under 160
450
451## Required TOML Frontmatter
452
453```toml
454+++
455title = "Post Title"
456date = 2025-12-05
457description = "SEO description under 160 characters"
458[taxonomies]
459tags = ["tag1", "tag2", "tag3"]
460categories = ["category"]
461[extra]
462author = "Author Name"
463reading_time = "X min"
464+++
465```
466
467## Structure
468
4691. Introduction with hook
4702. 3-5 main sections with H2 headings
4713. Code examples where relevant
4724. Conclusion with call-to-action
473"#
474 .to_string(),
475 ContentType::PresentarDemo => r#"## Presentar Demo Configuration
476
477Generate a complete interactive demo specification.
478
479## Required Output Schema
480
481```yaml
482type: presentar_demo
483version: "1.0"
484metadata:
485 title: string
486 description: string
487 demo_type: shell-autocomplete|ml-inference|wasm-showcase|terminal-repl
488
489wasm_config:
490 module_path: string
491 model_path: optional
492 runner_path: string
493
494ui_config:
495 theme: light|dark|high-contrast
496 show_performance_metrics: bool
497 debounce_ms: int
498
499performance_gates:
500 latency_target_ms: 1
501 cold_start_target_ms: 100
502 bundle_size_kb: 500
503
504instructions:
505 setup: string
506 interaction_guide: string
507 expected_behavior: string
508```
509
510## Accessibility Requirements
511
512- WCAG 2.1 AA compliance
513- Keyboard navigation
514- Screen reader support
515- Graceful degradation
516"#
517 .to_string(),
518 }
519 }
520}
521
522impl Default for PromptEmitter {
523 fn default() -> Self {
524 Self::new()
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn test_emit_config_new() {
534 let config = EmitConfig::new(ContentType::BlogPost);
535 assert_eq!(config.content_type, Some(ContentType::BlogPost));
536 assert_eq!(config.model, ModelContext::Claude200K);
537 assert_eq!(config.rag_limit, 4000);
538 }
539
540 #[test]
541 fn test_emit_config_with_title() {
542 let config = EmitConfig::new(ContentType::BookChapter).with_title("Introduction to Rust");
543 assert_eq!(config.title, Some("Introduction to Rust".to_string()));
544 }
545
546 #[test]
547 fn test_emit_config_with_audience() {
548 let config = EmitConfig::new(ContentType::BlogPost).with_audience("Rust beginners");
549 assert_eq!(config.audience, Some("Rust beginners".to_string()));
550 }
551
552 #[test]
553 fn test_emit_config_with_word_count() {
554 let config = EmitConfig::new(ContentType::BlogPost).with_word_count(2000);
555 assert_eq!(config.word_count, Some(2000));
556 }
557
558 #[test]
559 fn test_emit_config_with_source_context() {
560 let config = EmitConfig::new(ContentType::BookChapter)
561 .with_source_context(PathBuf::from("/src/lib.rs"));
562 assert_eq!(config.source_context_paths.len(), 1);
563 assert_eq!(config.source_context_paths[0], PathBuf::from("/src/lib.rs"));
564 }
565
566 #[test]
567 fn test_emit_config_with_rag_context() {
568 let config =
569 EmitConfig::new(ContentType::BlogPost).with_rag_context(PathBuf::from("/docs"), 8000);
570 assert_eq!(config.rag_context_path, Some(PathBuf::from("/docs")));
571 assert_eq!(config.rag_limit, 8000);
572 }
573
574 #[test]
575 fn test_emit_config_with_course_level() {
576 let config =
577 EmitConfig::new(ContentType::DetailedOutline).with_course_level(CourseLevel::Short);
578 assert_eq!(config.course_level, CourseLevel::Short);
579 }
580
581 #[test]
582 fn test_prompt_emitter_new() {
583 let emitter = PromptEmitter::new();
584 assert!(emitter.toyota_constraints().contains("Jidoka"));
585 assert!(emitter.quality_gates().contains("Quality Gates"));
586 }
587
588 #[test]
589 fn test_prompt_emitter_default() {
590 let emitter = PromptEmitter::default();
591 assert!(emitter.toyota_constraints().contains("Toyota Way"));
592 }
593
594 #[test]
595 fn test_prompt_emitter_emit_missing_content_type() {
596 let emitter = PromptEmitter::new();
597 let config = EmitConfig::default();
598 let result = emitter.emit(&config);
599 assert!(result.is_err());
600 }
601
602 #[test]
603 fn test_prompt_emitter_emit_blog_post() {
604 let emitter = PromptEmitter::new();
605 let config = EmitConfig::new(ContentType::BlogPost)
606 .with_title("My Blog Post")
607 .with_audience("Developers")
608 .with_word_count(1500);
609 let result = emitter.emit(&config).unwrap();
610 assert!(result.contains("Blog Post"));
611 assert!(result.contains("My Blog Post"));
612 assert!(result.contains("Developers"));
613 assert!(result.contains("1500 words"));
614 }
615
616 #[test]
617 fn test_prompt_emitter_emit_book_chapter() {
618 let emitter = PromptEmitter::new();
619 let config =
620 EmitConfig::new(ContentType::BookChapter).with_title("Chapter 1: Getting Started");
621 let result = emitter.emit(&config).unwrap();
622 assert!(result.contains("Book Chapter"));
623 assert!(result.contains("Getting Started"));
624 assert!(result.contains("mdBook format"));
625 }
626
627 #[test]
628 fn test_prompt_emitter_emit_high_level_outline() {
629 let emitter = PromptEmitter::new();
630 let config =
631 EmitConfig::new(ContentType::HighLevelOutline).with_title("Rust Programming Course");
632 let result = emitter.emit(&config).unwrap();
633 assert!(result.contains("High-Level Outline"));
634 assert!(result.contains("3-7 major parts"));
635 assert!(result.contains("Bloom's taxonomy"));
636 }
637
638 #[test]
639 fn test_prompt_emitter_emit_detailed_outline() {
640 let emitter = PromptEmitter::new();
641 let config = EmitConfig::new(ContentType::DetailedOutline)
642 .with_title("Advanced Rust")
643 .with_course_level(CourseLevel::Extended);
644 let result = emitter.emit(&config).unwrap();
645 assert!(result.contains("Detailed Outline"));
646 assert!(result.contains("modules"));
647 }
648
649 #[test]
650 fn test_prompt_emitter_emit_presentar_demo() {
651 let emitter = PromptEmitter::new();
652 let config = EmitConfig::new(ContentType::PresentarDemo).with_title("WASM Demo");
653 let result = emitter.emit(&config).unwrap();
654 assert!(result.contains("Presentar Demo"));
655 assert!(result.contains("wasm_config"));
656 assert!(result.contains("WCAG 2.1"));
657 }
658
659 #[test]
660 fn test_prompt_emitter_emit_with_token_budget() {
661 let emitter = PromptEmitter::new();
662 let mut config = EmitConfig::new(ContentType::BlogPost).with_title("Test Post");
663 config.show_budget = true;
664 let result = emitter.emit(&config).unwrap();
665 assert!(result.contains("Token Budget"));
666 }
667}