Skip to main content

batuta/content/
emitter.rs

1//! Prompt emitter for content generation
2//!
3//! This module contains the PromptEmitter and EmitConfig extracted from content/mod.rs.
4
5use super::{ContentError, ContentType, CourseLevel, ModelContext, TokenBudget};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9// ============================================================================
10// EMIT CONFIG
11// ============================================================================
12
13/// Configuration for prompt emission
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct EmitConfig {
16    /// Content type to generate
17    pub content_type: Option<ContentType>,
18    /// Title or topic
19    pub title: Option<String>,
20    /// Target audience
21    pub audience: Option<String>,
22    /// Word count target
23    pub word_count: Option<usize>,
24    /// Source context paths
25    pub source_context_paths: Vec<PathBuf>,
26    /// RAG context directory
27    pub rag_context_path: Option<PathBuf>,
28    /// RAG token limit
29    pub rag_limit: usize,
30    /// Model context
31    pub model: ModelContext,
32    /// Show token budget
33    pub show_budget: bool,
34    /// Course level (for detailed outlines)
35    pub course_level: CourseLevel,
36}
37
38impl EmitConfig {
39    /// Create a new emit config
40    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    /// Set title
50    pub fn with_title(mut self, title: impl Into<String>) -> Self {
51        self.title = Some(title.into());
52        self
53    }
54
55    /// Set audience
56    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
57        self.audience = Some(audience.into());
58        self
59    }
60
61    /// Set word count
62    pub fn with_word_count(mut self, count: usize) -> Self {
63        self.word_count = Some(count);
64        self
65    }
66
67    /// Add source context path
68    pub fn with_source_context(mut self, path: PathBuf) -> Self {
69        self.source_context_paths.push(path);
70        self
71    }
72
73    /// Set RAG context
74    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    /// Set course level (for detailed outlines)
81    pub fn with_course_level(mut self, level: CourseLevel) -> Self {
82        self.course_level = level;
83        self
84    }
85}
86
87// ============================================================================
88// PROMPT EMITTER
89// ============================================================================
90
91/// Prompt emitter for content generation
92#[derive(Debug, Clone)]
93pub struct PromptEmitter {
94    /// Toyota Way constraints (shared across all content types)
95    toyota_constraints: String,
96    /// Quality gates template
97    quality_gates: String,
98}
99
100impl PromptEmitter {
101    /// Get the Toyota Way constraints
102    pub fn toyota_constraints(&self) -> &str {
103        &self.toyota_constraints
104    }
105
106    /// Get the quality gates
107    pub fn quality_gates(&self) -> &str {
108        &self.quality_gates
109    }
110
111    /// Create a new prompt emitter
112    pub fn new() -> Self {
113        Self {
114            toyota_constraints: Self::default_toyota_constraints(),
115            quality_gates: Self::default_quality_gates(),
116        }
117    }
118
119    /// Default Toyota Way constraints
120    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    /// Default quality gates
142    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    /// Emit a prompt for the given configuration
156    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        // Header
164        prompt.push_str(&format!("# Content Generation Request: {}\n\n", content_type.name()));
165
166        // Context section
167        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        // Toyota Way constraints
203        prompt.push_str(&self.toyota_constraints);
204        prompt.push('\n');
205
206        // Content-type specific instructions
207        prompt.push_str(&self.emit_type_specific(content_type, config));
208        prompt.push('\n');
209
210        // Quality gates
211        prompt.push_str(&self.quality_gates);
212        prompt.push('\n');
213
214        // Token budget if requested
215        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        // Final instruction
225        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    /// Emit type-specific instructions
232    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                // Add weekly learning objectives for non-short courses
314                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                // Generate module entries
332                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                    // Generate video entries
348                    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}