Skip to main content

sokuto_runtime/tokenizer/
prompt.rs

1//! # プロンプト書式化モジュール
2//!
3//! Jev 互換の質問仕様 (`Question`) から、Python 学習パイプラインと
4//! 完全一致するプロンプトパーツおよび候補キー列を構築する。
5
6use sokuto_core::contract::model_spec::TOKEN_OPTION_MARKER;
7use sokuto_core::schema::{Criteria, Question, QuestionType};
8
9use crate::error::{Result, RuntimeError};
10
11/// 固定プレフィックス文字列。
12pub const PREFIX_STATE: &str = "State: ";
13
14/// フォーマット済みのプロンプトパーツ構造体。
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct FormattedPrompt {
17    /// プレフィックス文字列 (`"State: "`)。
18    pub prefix: &'static str,
19    /// サフィックス文字列 (`"\nInstructions: ...\nCriteria: [OP] ..."` 等)。
20    pub suffix: String,
21    /// プロンプト出現順に整列した候補識別子リスト。
22    pub option_keys: Vec<String>,
23}
24
25/// 質問仕様からフォーマット済みプロンプトパーツを生成する。
26///
27/// # 挙動仕様
28/// - **Choice 型**: `Criteria::Map` の要素順序を保持し、各記述の先頭に `[OP]` を付与して半角スペースで結合する。
29/// - **Score 型**: `Criteria::List` の段階順序を保持し、各段階の先頭に `[OP]` を付与して半角スペースで結合する。候補キーは `"0"`, `"1"`, ... とする。
30/// - **Noul 型**: 暗黙の真偽2候補 `"[OP] 真 (True) [OP] 偽 (False)"` を展開し、候補キーは `["true", "false"]` とする。
31///
32/// # エラー
33/// 質問の整合性検証(候補数範囲、対応する Criteria 形式)に違反した場合は `RuntimeError` を返却する。
34pub fn format_prompt(question: &Question) -> Result<FormattedPrompt> {
35    match question.question_type {
36        QuestionType::Choice => {
37            let criteria = question.criteria.as_ref().ok_or_else(|| {
38                RuntimeError::InvalidQuestion(
39                    "Choice 型には Criteria::Map が必須です。".to_string(),
40                )
41            })?;
42
43            let map = match criteria {
44                Criteria::Map(map) => map,
45                _ => {
46                    return Err(RuntimeError::InvalidQuestion(
47                        "Choice 型には Criteria::Map が指定される必要があります。".to_string(),
48                    ));
49                }
50            };
51
52            let count = map.len();
53            if count == 0 || count > 255 {
54                return Err(RuntimeError::InvalidQuestion(format!(
55                    "Choice 型の候補数は 1〜255 件である必要がありますが、{count} 件指定されました。"
56                )));
57            }
58
59            let mut option_keys = Vec::with_capacity(count);
60            let mut criteria_parts = Vec::with_capacity(count);
61
62            for (key, desc) in map {
63                option_keys.push(key.clone());
64                criteria_parts.push(format!("{TOKEN_OPTION_MARKER} {desc}"));
65            }
66
67            let criteria_text = criteria_parts.join(" ");
68            let suffix = format!(
69                "\nInstructions: {}\nCriteria: {}",
70                question.instructions, criteria_text
71            );
72
73            Ok(FormattedPrompt {
74                prefix: PREFIX_STATE,
75                suffix,
76                option_keys,
77            })
78        }
79        QuestionType::Score => {
80            let criteria = question.criteria.as_ref().ok_or_else(|| {
81                RuntimeError::InvalidQuestion(
82                    "Score 型には Criteria::List が必須です。".to_string(),
83                )
84            })?;
85
86            let list = match criteria {
87                Criteria::List(list) => list,
88                _ => {
89                    return Err(RuntimeError::InvalidQuestion(
90                        "Score 型には Criteria::List が指定される必要があります。".to_string(),
91                    ));
92                }
93            };
94
95            let count = list.len();
96            if !(2..=10).contains(&count) {
97                return Err(RuntimeError::InvalidQuestion(format!(
98                    "Score 型の段階数は 2〜10 段階である必要がありますが、{count} 段階指定されました。"
99                )));
100            }
101
102            let mut option_keys = Vec::with_capacity(count);
103            let mut criteria_parts = Vec::with_capacity(count);
104
105            for (i, desc) in list.iter().enumerate() {
106                option_keys.push(i.to_string());
107                criteria_parts.push(format!("{TOKEN_OPTION_MARKER} {desc}"));
108            }
109
110            let criteria_text = criteria_parts.join(" ");
111            let suffix = format!(
112                "\nInstructions: {}\nCriteria: {}",
113                question.instructions, criteria_text
114            );
115
116            Ok(FormattedPrompt {
117                prefix: PREFIX_STATE,
118                suffix,
119                option_keys,
120            })
121        }
122        QuestionType::Noul => {
123            let option_keys = vec!["true".to_string(), "false".to_string()];
124            let criteria_text =
125                format!("{TOKEN_OPTION_MARKER} 真 (True) {TOKEN_OPTION_MARKER} 偽 (False)");
126            let suffix = format!(
127                "\nInstructions: {}\nCriteria: {}",
128                question.instructions, criteria_text
129            );
130
131            Ok(FormattedPrompt {
132                prefix: PREFIX_STATE,
133                suffix,
134                option_keys,
135            })
136        }
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use indexmap::IndexMap;
144
145    #[test]
146    fn test_format_prompt_choice() {
147        let mut criteria = IndexMap::new();
148        criteria.insert("activate".to_string(), "カードの有効化".to_string());
149        criteria.insert("status".to_string(), "配送状況の確認".to_string());
150
151        let question = Question::new_choice("意図を分類せよ。", criteria);
152        let formatted = format_prompt(&question).unwrap();
153
154        assert_eq!(formatted.prefix, "State: ");
155        assert_eq!(formatted.option_keys, vec!["activate", "status"]);
156        assert!(formatted.suffix.contains("Instructions: 意図を分類せよ。"));
157        assert!(
158            formatted
159                .suffix
160                .contains("Criteria: [OP] カードの有効化 [OP] 配送状況の確認")
161        );
162    }
163
164    #[test]
165    fn test_format_prompt_score() {
166        let criteria = vec![
167            "極めて不満".to_string(),
168            "不満".to_string(),
169            "満足".to_string(),
170            "大変満足".to_string(),
171        ];
172        let question = Question::new_score("顧客満足度を評価せよ。", criteria);
173        let formatted = format_prompt(&question).unwrap();
174
175        assert_eq!(formatted.prefix, "State: ");
176        assert_eq!(formatted.option_keys, vec!["0", "1", "2", "3"]);
177        assert!(
178            formatted
179                .suffix
180                .contains("Instructions: 顧客満足度を評価せよ。")
181        );
182        assert!(
183            formatted
184                .suffix
185                .contains("Criteria: [OP] 極めて不満 [OP] 不満 [OP] 満足 [OP] 大変満足")
186        );
187    }
188
189    #[test]
190    fn test_format_prompt_noul() {
191        let question = Question::new_noul("この言明は妥当であるか判定せよ。");
192        let formatted = format_prompt(&question).unwrap();
193
194        assert_eq!(formatted.prefix, "State: ");
195        assert_eq!(formatted.option_keys, vec!["true", "false"]);
196        assert!(
197            formatted
198                .suffix
199                .contains("Instructions: この言明は妥当であるか判定せよ。")
200        );
201        assert!(
202            formatted
203                .suffix
204                .contains("Criteria: [OP] 真 (True) [OP] 偽 (False)")
205        );
206    }
207
208    #[test]
209    fn test_format_prompt_invalid_choice_empty() {
210        let question = Question::new_choice("空の候補", IndexMap::new());
211        assert!(format_prompt(&question).is_err());
212    }
213
214    #[test]
215    fn test_format_prompt_invalid_score_count() {
216        let question = Question::new_score("1段階のみ", vec!["評価".to_string()]);
217        assert!(format_prompt(&question).is_err());
218    }
219}