Skip to main content

tuitbot_core/content/
frameworks.rs

1//! Content frameworks for varied, human-sounding output.
2//!
3//! Provides archetypes for replies, formats for tweets, and structures
4//! for threads. Each variant includes prompt fragment guidance so the
5//! LLM produces distinctly different content depending on the chosen
6//! framework.
7
8use rand::seq::IndexedRandom;
9
10// ============================================================================
11// Reply archetypes
12// ============================================================================
13
14/// How we engage in a reply — shapes the prompt so the LLM varies
15/// its approach instead of always producing the same structure.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ReplyArchetype {
18    /// Agree with the author and extend their point.
19    AgreeAndExpand,
20    /// Respectfully offer an alternative perspective.
21    RespectfulDisagree,
22    /// Add a concrete data point, stat, or example.
23    AddData,
24    /// Ask a thoughtful follow-up question.
25    AskQuestion,
26    /// Share a brief personal experience related to the topic.
27    ShareExperience,
28}
29
30impl ReplyArchetype {
31    /// Weighted selection — prefer archetypes that start conversations.
32    pub fn select(rng: &mut impl rand::Rng) -> Self {
33        // Weights: AgreeAndExpand 30, AskQuestion 25, ShareExperience 20,
34        //          AddData 15, RespectfulDisagree 10
35        let choices: &[(Self, u32)] = &[
36            (Self::AgreeAndExpand, 30),
37            (Self::AskQuestion, 25),
38            (Self::ShareExperience, 20),
39            (Self::AddData, 15),
40            (Self::RespectfulDisagree, 10),
41        ];
42
43        let total: u32 = choices.iter().map(|(_, w)| w).sum();
44        let mut roll = rng.random_range(0..total);
45        for (archetype, weight) in choices {
46            if roll < *weight {
47                return *archetype;
48            }
49            roll -= weight;
50        }
51        Self::AgreeAndExpand
52    }
53
54    /// Prompt fragment injected into the system prompt.
55    pub fn prompt_fragment(self) -> &'static str {
56        match self {
57            Self::AgreeAndExpand => {
58                "Approach: Agree with the author's point and extend it with \
59                 an additional insight or implication they didn't mention."
60            }
61            Self::RespectfulDisagree => {
62                "Approach: Respectfully offer an alternative take. Start with \
63                 what you agree with, then pivot to where you see it differently. \
64                 Keep it constructive — never confrontational."
65            }
66            Self::AddData => {
67                "Approach: Add a concrete data point, stat, example, or case study \
68                 that supports or contextualizes the topic. Cite specifics when possible."
69            }
70            Self::AskQuestion => {
71                "Approach: Ask a thoughtful follow-up question that shows you've engaged \
72                 deeply with the tweet. The question should invite the author to elaborate."
73            }
74            Self::ShareExperience => {
75                "Approach: Share a brief personal experience or observation related to the \
76                 topic. Use 'I' language and keep it genuine and specific."
77            }
78        }
79    }
80}
81
82impl std::fmt::Display for ReplyArchetype {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::AgreeAndExpand => write!(f, "agree_and_expand"),
86            Self::RespectfulDisagree => write!(f, "respectful_disagree"),
87            Self::AddData => write!(f, "add_data"),
88            Self::AskQuestion => write!(f, "ask_question"),
89            Self::ShareExperience => write!(f, "share_experience"),
90        }
91    }
92}
93
94// ============================================================================
95// Tweet formats
96// ============================================================================
97
98/// Structural format for an original tweet.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum TweetFormat {
101    /// Numbered list of tips or points.
102    List,
103    /// "Most people think X. But actually Y."
104    ContrarianTake,
105    /// "Most people think X, but the reality is..."
106    MostPeopleThinkX,
107    /// A short story or anecdote.
108    Storytelling,
109    /// "Before: X. After: Y."
110    BeforeAfter,
111    /// Pose a question to the audience.
112    Question,
113    /// A single actionable tip.
114    Tip,
115}
116
117impl TweetFormat {
118    /// All available formats.
119    const ALL: &'static [Self] = &[
120        Self::List,
121        Self::ContrarianTake,
122        Self::MostPeopleThinkX,
123        Self::Storytelling,
124        Self::BeforeAfter,
125        Self::Question,
126        Self::Tip,
127    ];
128
129    /// Pick a random format, avoiding recently used ones.
130    pub fn select(recent: &[Self], rng: &mut impl rand::Rng) -> Self {
131        let available: Vec<Self> = Self::ALL
132            .iter()
133            .copied()
134            .filter(|f| !recent.contains(f))
135            .collect();
136
137        if available.is_empty() {
138            *Self::ALL.choose(rng).expect("ALL is non-empty")
139        } else {
140            *available.choose(rng).expect("available is non-empty")
141        }
142    }
143
144    /// Prompt fragment injected into the system prompt.
145    pub fn prompt_fragment(self) -> &'static str {
146        match self {
147            Self::List => {
148                "Format: Write a numbered list of 3-5 quick tips or insights. \
149                 Keep each item to one line."
150            }
151            Self::ContrarianTake => {
152                "Format: Start with a common belief, then challenge it with an \
153                 unexpected truth. Structure: 'Everyone says X. But actually, Y.'"
154            }
155            Self::MostPeopleThinkX => {
156                "Format: 'Most people think [common assumption]. The reality: [insight].'"
157            }
158            Self::Storytelling => {
159                "Format: Tell a very brief story or anecdote (2-3 sentences) that \
160                 illustrates the topic. End with the lesson."
161            }
162            Self::BeforeAfter => {
163                "Format: Show a transformation. 'Before: [old way]. After: [new way]. \
164                 [Brief insight on why the change matters].'"
165            }
166            Self::Question => {
167                "Format: Pose a thought-provoking question to the audience that invites \
168                 engagement. Optionally share your own answer in 1-2 sentences."
169            }
170            Self::Tip => {
171                "Format: Share one specific, actionable tip. Be concrete — include the \
172                 exact steps or command, not vague advice."
173            }
174        }
175    }
176}
177
178impl std::fmt::Display for TweetFormat {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        match self {
181            Self::List => write!(f, "list"),
182            Self::ContrarianTake => write!(f, "contrarian_take"),
183            Self::MostPeopleThinkX => write!(f, "most_people_think_x"),
184            Self::Storytelling => write!(f, "storytelling"),
185            Self::BeforeAfter => write!(f, "before_after"),
186            Self::Question => write!(f, "question"),
187            Self::Tip => write!(f, "tip"),
188        }
189    }
190}
191
192// ============================================================================
193// Thread structures
194// ============================================================================
195
196/// Structural template for a multi-tweet thread.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum ThreadStructure {
199    /// "I went from X to Y. Here's the journey" — transformation arc.
200    Transformation,
201    /// "My framework for X" — step-by-step process.
202    Framework,
203    /// "N mistakes I made doing X" — lessons learned.
204    Mistakes,
205    /// Deep analysis of a topic with supporting evidence.
206    Analysis,
207}
208
209impl ThreadStructure {
210    /// All available structures.
211    const ALL: &'static [Self] = &[
212        Self::Transformation,
213        Self::Framework,
214        Self::Mistakes,
215        Self::Analysis,
216    ];
217
218    /// Pick a random structure.
219    pub fn select(rng: &mut impl rand::Rng) -> Self {
220        *Self::ALL.choose(rng).expect("ALL is non-empty")
221    }
222
223    /// Prompt fragment injected into the system prompt.
224    pub fn prompt_fragment(self) -> &'static str {
225        match self {
226            Self::Transformation => {
227                "Structure: Tell a transformation story. Start with the 'before' state, \
228                 walk through the key turning points, and end with the 'after' state \
229                 and lessons learned."
230            }
231            Self::Framework => {
232                "Structure: Present a step-by-step framework. Tweet 1 hooks with the \
233                 problem, subsequent tweets present each step, and the last tweet \
234                 summarizes the framework."
235            }
236            Self::Mistakes => {
237                "Structure: Share mistakes and lessons. Tweet 1 hooks with 'N mistakes \
238                 I made doing X', each subsequent tweet is one mistake with what you \
239                 learned, and the last tweet is the key takeaway."
240            }
241            Self::Analysis => {
242                "Structure: Deep-dive analysis. Tweet 1 states the thesis, subsequent \
243                 tweets provide evidence or arguments, and the last tweet draws a conclusion."
244            }
245        }
246    }
247}
248
249impl std::fmt::Display for ThreadStructure {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        match self {
252            Self::Transformation => write!(f, "transformation"),
253            Self::Framework => write!(f, "framework"),
254            Self::Mistakes => write!(f, "mistakes"),
255            Self::Analysis => write!(f, "analysis"),
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn reply_archetype_select_returns_valid() {
266        let mut rng = rand::rng();
267        for _ in 0..100 {
268            let _ = ReplyArchetype::select(&mut rng);
269        }
270    }
271
272    #[test]
273    fn reply_archetype_select_distribution() {
274        let mut rng = rand::rng();
275        let mut counts = [0u32; 5];
276        for _ in 0..1000 {
277            let archetype = ReplyArchetype::select(&mut rng);
278            match archetype {
279                ReplyArchetype::AgreeAndExpand => counts[0] += 1,
280                ReplyArchetype::RespectfulDisagree => counts[1] += 1,
281                ReplyArchetype::AddData => counts[2] += 1,
282                ReplyArchetype::AskQuestion => counts[3] += 1,
283                ReplyArchetype::ShareExperience => counts[4] += 1,
284            }
285        }
286        // All archetypes should appear at least once in 1000 samples
287        for (i, count) in counts.iter().enumerate() {
288            assert!(
289                *count > 0,
290                "archetype index {i} never selected in 1000 samples"
291            );
292        }
293        // AgreeAndExpand should appear more often than RespectfulDisagree
294        assert!(
295            counts[0] > counts[1],
296            "AgreeAndExpand should be more frequent"
297        );
298    }
299
300    #[test]
301    fn reply_archetype_prompt_fragments_non_empty() {
302        let archetypes = [
303            ReplyArchetype::AgreeAndExpand,
304            ReplyArchetype::RespectfulDisagree,
305            ReplyArchetype::AddData,
306            ReplyArchetype::AskQuestion,
307            ReplyArchetype::ShareExperience,
308        ];
309        for a in archetypes {
310            assert!(!a.prompt_fragment().is_empty());
311        }
312    }
313
314    #[test]
315    fn reply_archetype_display() {
316        assert_eq!(
317            ReplyArchetype::AgreeAndExpand.to_string(),
318            "agree_and_expand"
319        );
320        assert_eq!(ReplyArchetype::AskQuestion.to_string(), "ask_question");
321    }
322
323    #[test]
324    fn tweet_format_select_avoids_recent() {
325        let mut rng = rand::rng();
326        let recent = vec![TweetFormat::List, TweetFormat::Tip, TweetFormat::Question];
327
328        for _ in 0..50 {
329            let format = TweetFormat::select(&recent, &mut rng);
330            assert!(!recent.contains(&format));
331        }
332    }
333
334    #[test]
335    fn tweet_format_select_clears_when_all_recent() {
336        let mut rng = rand::rng();
337        let recent: Vec<TweetFormat> = TweetFormat::ALL.to_vec();
338        // When all are recent, should still pick one
339        let format = TweetFormat::select(&recent, &mut rng);
340        assert!(TweetFormat::ALL.contains(&format));
341    }
342
343    #[test]
344    fn tweet_format_prompt_fragments_non_empty() {
345        for f in TweetFormat::ALL {
346            assert!(!f.prompt_fragment().is_empty());
347        }
348    }
349
350    #[test]
351    fn tweet_format_display() {
352        assert_eq!(TweetFormat::List.to_string(), "list");
353        assert_eq!(TweetFormat::ContrarianTake.to_string(), "contrarian_take");
354        assert_eq!(TweetFormat::BeforeAfter.to_string(), "before_after");
355    }
356
357    #[test]
358    fn thread_structure_select_returns_valid() {
359        let mut rng = rand::rng();
360        for _ in 0..50 {
361            let structure = ThreadStructure::select(&mut rng);
362            assert!(ThreadStructure::ALL.contains(&structure));
363        }
364    }
365
366    #[test]
367    fn thread_structure_prompt_fragments_non_empty() {
368        for s in ThreadStructure::ALL {
369            assert!(!s.prompt_fragment().is_empty());
370        }
371    }
372
373    #[test]
374    fn thread_structure_display() {
375        assert_eq!(
376            ThreadStructure::Transformation.to_string(),
377            "transformation"
378        );
379        assert_eq!(ThreadStructure::Framework.to_string(), "framework");
380        assert_eq!(ThreadStructure::Mistakes.to_string(), "mistakes");
381        assert_eq!(ThreadStructure::Analysis.to_string(), "analysis");
382    }
383
384    #[test]
385    fn reply_archetype_all_variants_reachable() {
386        use std::collections::HashSet;
387        let mut rng = rand::rng();
388        let mut seen = HashSet::new();
389        for _ in 0..10_000 {
390            seen.insert(ReplyArchetype::select(&mut rng).to_string());
391        }
392        assert_eq!(
393            seen.len(),
394            5,
395            "expected all 5 reply archetypes, got {seen:?}"
396        );
397    }
398
399    #[test]
400    fn tweet_format_all_variants_reachable() {
401        use std::collections::HashSet;
402        let mut rng = rand::rng();
403        let mut seen = HashSet::new();
404        let recent: Vec<TweetFormat> = vec![];
405        for _ in 0..10_000 {
406            seen.insert(TweetFormat::select(&recent, &mut rng).to_string());
407        }
408        assert_eq!(seen.len(), 7, "expected all 7 tweet formats, got {seen:?}");
409    }
410
411    #[test]
412    fn thread_structure_all_variants_reachable() {
413        use std::collections::HashSet;
414        let mut rng = rand::rng();
415        let mut seen = HashSet::new();
416        for _ in 0..10_000 {
417            seen.insert(ThreadStructure::select(&mut rng).to_string());
418        }
419        assert_eq!(
420            seen.len(),
421            4,
422            "expected all 4 thread structures, got {seen:?}"
423        );
424    }
425
426    #[test]
427    fn tweet_format_display_all_variants() {
428        assert_eq!(TweetFormat::List.to_string(), "list");
429        assert_eq!(TweetFormat::ContrarianTake.to_string(), "contrarian_take");
430        assert_eq!(
431            TweetFormat::MostPeopleThinkX.to_string(),
432            "most_people_think_x"
433        );
434        assert_eq!(TweetFormat::Storytelling.to_string(), "storytelling");
435        assert_eq!(TweetFormat::BeforeAfter.to_string(), "before_after");
436        assert_eq!(TweetFormat::Question.to_string(), "question");
437        assert_eq!(TweetFormat::Tip.to_string(), "tip");
438    }
439
440    #[test]
441    fn reply_archetype_display_all_variants() {
442        assert_eq!(
443            ReplyArchetype::AgreeAndExpand.to_string(),
444            "agree_and_expand"
445        );
446        assert_eq!(
447            ReplyArchetype::RespectfulDisagree.to_string(),
448            "respectful_disagree"
449        );
450        assert_eq!(ReplyArchetype::AddData.to_string(), "add_data");
451        assert_eq!(ReplyArchetype::AskQuestion.to_string(), "ask_question");
452        assert_eq!(
453            ReplyArchetype::ShareExperience.to_string(),
454            "share_experience"
455        );
456    }
457
458    #[test]
459    fn tweet_format_select_single_available() {
460        let mut rng = rand::rng();
461        // Put 6 of 7 in recent — only Storytelling remains.
462        let recent = vec![
463            TweetFormat::List,
464            TweetFormat::ContrarianTake,
465            TweetFormat::MostPeopleThinkX,
466            TweetFormat::BeforeAfter,
467            TweetFormat::Question,
468            TweetFormat::Tip,
469        ];
470        for _ in 0..50 {
471            let picked = TweetFormat::select(&recent, &mut rng);
472            assert_eq!(
473                picked,
474                TweetFormat::Storytelling,
475                "only Storytelling should be available"
476            );
477        }
478    }
479
480    // -----------------------------------------------------------------------
481    // Additional frameworks coverage tests
482    // -----------------------------------------------------------------------
483
484    #[test]
485    fn reply_archetype_prompt_fragment_content() {
486        // Verify each prompt fragment contains relevant guidance words
487        let frag = ReplyArchetype::AgreeAndExpand.prompt_fragment();
488        assert!(frag.contains("Agree"));
489        let frag = ReplyArchetype::RespectfulDisagree.prompt_fragment();
490        assert!(frag.contains("alternative"));
491        let frag = ReplyArchetype::AddData.prompt_fragment();
492        assert!(frag.contains("data"));
493        let frag = ReplyArchetype::AskQuestion.prompt_fragment();
494        assert!(frag.contains("question"));
495        let frag = ReplyArchetype::ShareExperience.prompt_fragment();
496        assert!(frag.contains("experience"));
497    }
498
499    #[test]
500    fn tweet_format_prompt_fragment_content() {
501        let frag = TweetFormat::List.prompt_fragment();
502        assert!(frag.contains("list"));
503        let frag = TweetFormat::ContrarianTake.prompt_fragment();
504        assert!(frag.contains("challenge"));
505        let frag = TweetFormat::Storytelling.prompt_fragment();
506        assert!(frag.contains("story"));
507        let frag = TweetFormat::BeforeAfter.prompt_fragment();
508        assert!(frag.contains("Before"));
509        let frag = TweetFormat::Question.prompt_fragment();
510        assert!(frag.contains("question"));
511        let frag = TweetFormat::Tip.prompt_fragment();
512        assert!(frag.contains("tip"));
513    }
514
515    #[test]
516    fn thread_structure_prompt_fragment_content() {
517        let frag = ThreadStructure::Transformation.prompt_fragment();
518        assert!(frag.contains("transformation"));
519        let frag = ThreadStructure::Framework.prompt_fragment();
520        assert!(frag.contains("framework"));
521        let frag = ThreadStructure::Mistakes.prompt_fragment();
522        assert!(frag.contains("mistakes"));
523        let frag = ThreadStructure::Analysis.prompt_fragment();
524        assert!(frag.contains("analysis"));
525    }
526
527    #[test]
528    fn tweet_format_all_count() {
529        assert_eq!(TweetFormat::ALL.len(), 7);
530    }
531
532    #[test]
533    fn thread_structure_all_count() {
534        assert_eq!(ThreadStructure::ALL.len(), 4);
535    }
536
537    #[test]
538    fn reply_archetype_equality() {
539        assert_eq!(ReplyArchetype::AddData, ReplyArchetype::AddData);
540        assert_ne!(ReplyArchetype::AddData, ReplyArchetype::AskQuestion);
541    }
542
543    #[test]
544    fn tweet_format_equality() {
545        assert_eq!(TweetFormat::Tip, TweetFormat::Tip);
546        assert_ne!(TweetFormat::Tip, TweetFormat::List);
547    }
548
549    #[test]
550    fn thread_structure_equality() {
551        assert_eq!(ThreadStructure::Analysis, ThreadStructure::Analysis);
552        assert_ne!(ThreadStructure::Analysis, ThreadStructure::Framework);
553    }
554
555    #[test]
556    fn tweet_format_empty_recent() {
557        let mut rng = rand::rng();
558        let format = TweetFormat::select(&[], &mut rng);
559        assert!(TweetFormat::ALL.contains(&format));
560    }
561
562    #[test]
563    fn thread_structure_debug() {
564        let debug = format!("{:?}", ThreadStructure::Transformation);
565        assert!(debug.contains("Transformation"));
566    }
567
568    #[test]
569    fn tweet_format_debug() {
570        let debug = format!("{:?}", TweetFormat::List);
571        assert!(debug.contains("List"));
572    }
573
574    #[test]
575    fn reply_archetype_debug() {
576        let debug = format!("{:?}", ReplyArchetype::AddData);
577        assert!(debug.contains("AddData"));
578    }
579
580    #[test]
581    fn tweet_format_most_people_think_x_prompt() {
582        let frag = TweetFormat::MostPeopleThinkX.prompt_fragment();
583        assert!(frag.contains("Most people"));
584    }
585}