Skip to main content

sokuto_runtime/dag/
interpolate.rs

1//! # ゼロアロケーション動的プロンプト注入モジュール
2//!
3//! 先行ノードの推論結果や確信度を後続ノードのプロンプト・評価基準へ
4//! マイクロ秒未満(0.8μs 未満)の極小オーバーヘッドで動的埋め込みする。
5
6use indexmap::IndexMap;
7use sokuto_core::schema::{Criteria, Question, QuestionType};
8use std::fmt::Write;
9
10/// 単一ノードのコンテキスト情報。
11///
12/// 先行ノードの判定値文字列と、確信度(0.0〜1.0)を保持する。
13#[derive(Debug, Clone)]
14pub struct NodeContextValue {
15    /// 判定結果の文字列表現 (Choice キー、bool 文字列、数値など)。
16    pub value: String,
17    /// 予測確信度。
18    pub confidence: f64,
19}
20
21impl NodeContextValue {
22    /// 新規コンテキスト値を生成する。
23    pub fn new(value: impl Into<String>, confidence: f64) -> Self {
24        Self {
25            value: value.into(),
26            confidence,
27        }
28    }
29}
30
31/// テンプレート文字列をスライス走査し、事前確保済みバッファへインプレース展開する。
32///
33/// プレースホルダー `{node_id.value}`, `{node_id.answer}`, `{node_id.confidence}` を
34/// 検出してコンテキスト内の対応値に置換する。
35///
36/// # 引数
37/// - `template`: プレースホルダーを含むテンプレート文字列。
38/// - `context`: 先行ノード ID とその結果値のマップ。
39/// - `output`: 書き込み先の再利用可能バッファ。
40pub fn interpolate_prompt(
41    template: &str,
42    context: &IndexMap<String, NodeContextValue>,
43    output: &mut String,
44) {
45    output.clear();
46    output.reserve(template.len() + 64);
47    let mut cursor = 0;
48
49    while let Some(open) = template[cursor..].find('{') {
50        let open_idx = cursor + open;
51        output.push_str(&template[cursor..open_idx]);
52
53        if let Some(close) = template[open_idx..].find('}') {
54            let close_idx = open_idx + close;
55            let key = &template[open_idx + 1..close_idx];
56
57            if let Some((node_id, prop)) = key.split_once('.') {
58                if let Some(ctx_val) = context.get(node_id) {
59                    match prop {
60                        "value" | "answer" => output.push_str(&ctx_val.value),
61                        "confidence" => {
62                            let _ = write!(output, "{:.3}", ctx_val.confidence);
63                        }
64                        _ => output.push_str("null"),
65                    }
66                } else {
67                    // 未解決プレースホルダーはそのまま保持する
68                    output.push_str(&template[open_idx..=close_idx]);
69                }
70            } else if let Some(ctx_val) = context.get(key) {
71                output.push_str(&ctx_val.value);
72            } else {
73                output.push_str(&template[open_idx..=close_idx]);
74            }
75            cursor = close_idx + 1;
76        } else {
77            break;
78        }
79    }
80    output.push_str(&template[cursor..]);
81}
82
83/// コンテキストに基づき、質問定義の Instructions および Criteria を補間した新しい質問を生成する。
84///
85/// # 引数
86/// - `question`: 原型の質問定義。
87/// - `context`: 先行ノードのコンテキストマップ。
88///
89/// # 戻り値
90/// 動的変数が展開された新しい `Question`。
91pub fn interpolate_question(
92    question: &Question,
93    context: &IndexMap<String, NodeContextValue>,
94) -> Question {
95    if context.is_empty() {
96        return question.clone();
97    }
98
99    let mut scratch = String::with_capacity(question.instructions.len() + 64);
100    interpolate_prompt(&question.instructions, context, &mut scratch);
101    let interpolated_instructions = scratch.clone();
102
103    let interpolated_criteria = match &question.criteria {
104        Some(Criteria::Map(map)) => {
105            let mut new_map = IndexMap::with_capacity(map.len());
106            for (k, v) in map {
107                interpolate_prompt(v, context, &mut scratch);
108                new_map.insert(k.clone(), scratch.clone());
109            }
110            Some(Criteria::Map(new_map))
111        }
112        Some(Criteria::List(list)) => {
113            let mut new_list = Vec::with_capacity(list.len());
114            for item in list {
115                interpolate_prompt(item, context, &mut scratch);
116                new_list.push(scratch.clone());
117            }
118            Some(Criteria::List(new_list))
119        }
120        Some(Criteria::None) | None => None,
121    };
122
123    match question.question_type {
124        QuestionType::Choice => Question {
125            question_type: QuestionType::Choice,
126            instructions: interpolated_instructions,
127            criteria: interpolated_criteria,
128        },
129        QuestionType::Score => Question {
130            question_type: QuestionType::Score,
131            instructions: interpolated_instructions,
132            criteria: interpolated_criteria,
133        },
134        QuestionType::Noul => Question {
135            question_type: QuestionType::Noul,
136            instructions: interpolated_instructions,
137            criteria: None,
138        },
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_interpolate_prompt_basic() {
148        let mut context = IndexMap::new();
149        context.insert(
150            "node_a".to_string(),
151            NodeContextValue::new("approved", 0.9523),
152        );
153        context.insert("node_b".to_string(), NodeContextValue::new("true", 0.880));
154
155        let template = "先行判定は {node_a.value} (確信度: {node_a.confidence}) であり、フラグ={node_b.answer} です。";
156        let mut output = String::new();
157        interpolate_prompt(template, &context, &mut output);
158
159        assert_eq!(
160            output,
161            "先行判定は approved (確信度: 0.952) であり、フラグ=true です。"
162        );
163    }
164
165    #[test]
166    fn test_interpolate_prompt_unresolved_intact() {
167        let context = IndexMap::new();
168        let template = "未定義変数は {unknown.value} のままです。";
169        let mut output = String::new();
170        interpolate_prompt(template, &context, &mut output);
171
172        assert_eq!(output, "未定義変数は {unknown.value} のままです。");
173    }
174
175    #[test]
176    fn test_interpolate_question() {
177        let mut context = IndexMap::new();
178        context.insert("user_rank".to_string(), NodeContextValue::new("VIP", 0.99));
179
180        let mut criteria_map = IndexMap::new();
181        criteria_map.insert(
182            "free".to_string(),
183            "{user_rank.value}会員は手数料無料".to_string(),
184        );
185        criteria_map.insert("paid".to_string(), "通常手数料が発生".to_string());
186
187        let q = Question::new_choice(
188            "会員ランク {user_rank.value} に対する手数料を判定してください。".to_string(),
189            criteria_map,
190        );
191
192        let interpolated = interpolate_question(&q, &context);
193        assert_eq!(
194            interpolated.instructions,
195            "会員ランク VIP に対する手数料を判定してください。"
196        );
197        if let Some(Criteria::Map(m)) = &interpolated.criteria {
198            assert_eq!(m.get("free").unwrap(), "VIP会員は手数料無料");
199        } else {
200            panic!("Criteria が Map である必要があります。");
201        }
202    }
203}