Skip to main content

sokuto_server/schema/
dag.rs

1//! # DAG 実行エンドポイント向け DTO スキーマモジュール
2//!
3//! 非同期グラフ巡回、反事実ルール、および多段階条件分岐判定を処理する
4//! 宣言的 DAG 定義のリクエスト・レスポンス構造体を定義する。
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use sokuto_core::gating::GatingMetadata;
9use sokuto_core::schema::{Answer, Question};
10use sokuto_runtime::dag::schema::{
11    DagDefinition, DagExecutionResult, DagNode, DagNodeType, NodeCondition, NodeConditionOp,
12    StepExecutionResult,
13};
14use utoipa::ToSchema;
15
16/// DAG 実行リクエスト構造体。
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
18pub struct DagRequest {
19    /// 判断の材料となる非構造化コンテキストデータ(文字列、オブジェクト、配列など)。
20    #[schema(value_type = Object)]
21    pub state: serde_json::Value,
22
23    /// 実行対象の宣言的 DAG 定義。
24    pub dag: DagDefinitionDto,
25
26    /// クライアント指定のハードタイムアウト(ミリ秒、任意)。DAG 定義内の timeout_ms より優先される。
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub timeout_ms: Option<u64>,
29}
30
31/// 宣言的 DAG 定義 DTO 構造体。
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
33pub struct DagDefinitionDto {
34    /// DAG の一意識別子。
35    pub dag_id: String,
36
37    /// 全体タイムアウト時間(ミリ秒)。
38    #[serde(default = "default_timeout_ms")]
39    pub timeout_ms: u64,
40
41    /// 実行開始ノードの識別子。
42    pub entry_node: String,
43
44    /// DAG を構成するノードマップ。
45    pub nodes: IndexMap<String, DagNodeDto>,
46}
47
48fn default_timeout_ms() -> u64 {
49    100
50}
51
52/// DAG ノード種別 DTO。
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
54#[serde(rename_all = "snake_case")]
55pub enum DagNodeTypeDto {
56    /// 単一質問のモデル推論を実行するノード。
57    Inference,
58    /// 先行ノードの判定値・確信度のみに基づいて遷移先を決定する純粋な条件分岐ノード。
59    Branch,
60    /// 独立した複数の質問を並行実行し合流するノード。
61    Parallel,
62    /// 確信度不足や例外検知時に上位 LLM または人手へ委ねるエスカレーションノード。
63    Escalate,
64}
65
66/// DAG を構成する個々のノード定義 DTO。
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
68pub struct DagNodeDto {
69    /// ノードの種別。
70    pub node_type: DagNodeTypeDto,
71
72    /// 推論ノード (`Inference`) で実行する質問仕様。
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub question: Option<Question>,
75
76    /// ノード完了後の条件付き遷移規則リスト。
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub conditions: Vec<NodeConditionDto>,
79
80    /// 並行実行対象となるノード ID 一覧 (`Parallel` ノード用)。
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub parallel_nodes: Vec<String>,
83
84    /// 並行実行完了後の合流先ノード ID (`Parallel` ノード用)。
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub join_node: Option<String>,
87
88    /// エスカレーションまたは分岐の理由説明文。
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub reason: Option<String>,
91}
92
93/// 条件付き遷移規則 DTO。
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
95pub struct NodeConditionDto {
96    /// 評価対象フィールド (`"answer"`, `"confidence"`, `"route"` など)。
97    pub field: String,
98
99    /// 比較演算子。
100    pub op: NodeConditionOpDto,
101
102    /// 比較対象の値。
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    #[schema(value_type = Option<Object>)]
105    pub value: Option<serde_json::Value>,
106
107    /// 条件合致時の遷移先ノード ID。
108    pub target_node: String,
109}
110
111/// 条件評価演算子 DTO。
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
113#[serde(rename_all = "snake_case")]
114pub enum NodeConditionOpDto {
115    /// 等価 (`==`)。
116    Eq,
117    /// 不等価 (`!=`)。
118    Neq,
119    /// より大きい (`>`)。
120    Gt,
121    /// 以上 (`>=`)。
122    Gte,
123    /// より小さい (`<`)。
124    Lt,
125    /// 以下 (`<=`)。
126    Lte,
127    /// 配列に含まれる (`in`)。
128    In,
129    /// 確信度が指定値以上。
130    ConfidenceGte,
131    /// 確信度が指定値未満。
132    ConfidenceLt,
133}
134
135/// DAG 実行レスポンス構造体。
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
137pub struct DagResponse {
138    /// 実行された DAG の識別子。
139    pub dag_id: String,
140
141    /// 実行終了ステータス (`"completed"` または `"escalated"`)。
142    pub status: String,
143
144    /// 最終到達ノード ID。
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub final_node: Option<String>,
147
148    /// 最終推論ノードの決定結果(推論ノードを通過した場合)。
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub final_decision: Option<Answer>,
151
152    /// 辿ったノード ID の実行経路。
153    pub execution_path: Vec<String>,
154
155    /// 各ノードの実行詳細マップ(ノード ID -> 実行記録)。
156    pub steps: IndexMap<String, DagStepResultDto>,
157
158    /// エスカレーション発生時の診断情報(該当する場合)。
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub escalation: Option<DagEscalationDetailDto>,
161
162    /// リソース消費サマリー(`completion_tokens: 0` を含む)。
163    pub usage: DagUsageDto,
164}
165
166/// 単一ステップの実行結果 DTO。
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
168pub struct DagStepResultDto {
169    /// 実行ノード ID。
170    pub node_id: String,
171
172    /// ノード種別。
173    pub node_type: DagNodeTypeDto,
174
175    /// 推論ノードの場合の判定結果。
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub answer: Option<Answer>,
178
179    /// ステップ実行所要時間(ミリ秒)。
180    pub duration_ms: f64,
181
182    /// 次に遷移したノード ID。
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub next_node: Option<String>,
185
186    /// ノードの理由または補足メッセージ。
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub reason: Option<String>,
189}
190
191/// エスカレーション発生時の詳細診断情報 DTO。
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
193pub struct DagEscalationDetailDto {
194    /// エスカレーション理由。
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub reason: Option<String>,
197
198    /// エスカレーションをトリガーしたノード ID。
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub node_id: Option<String>,
201
202    /// 上位 LLM (System 2) 向け Guided CoT プロンプト案。
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub prompt: Option<String>,
205
206    /// ゲーティング詳細メタデータ。
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub gating: Option<GatingMetadata>,
209}
210
211/// DAG 実行のリソース消費サマリー DTO。
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
213pub struct DagUsageDto {
214    /// DAG 全体の実行所要時間(ミリ秒)。
215    pub total_duration_ms: f64,
216
217    /// 実行されたステップ数。
218    pub steps_executed: usize,
219
220    /// 自己回帰生成トークン数(常に 0)。
221    pub completion_tokens: usize,
222}
223
224// ---------------------------------------------------------------------------
225// sokuto-runtime 型との相互変換 (From / Into) 実装
226// ---------------------------------------------------------------------------
227
228impl From<DagNodeTypeDto> for DagNodeType {
229    fn from(dto: DagNodeTypeDto) -> Self {
230        match dto {
231            DagNodeTypeDto::Inference => DagNodeType::Inference,
232            DagNodeTypeDto::Branch => DagNodeType::Branch,
233            DagNodeTypeDto::Parallel => DagNodeType::Parallel,
234            DagNodeTypeDto::Escalate => DagNodeType::Escalate,
235        }
236    }
237}
238
239impl From<DagNodeType> for DagNodeTypeDto {
240    fn from(kind: DagNodeType) -> Self {
241        match kind {
242            DagNodeType::Inference => DagNodeTypeDto::Inference,
243            DagNodeType::Branch => DagNodeTypeDto::Branch,
244            DagNodeType::Parallel => DagNodeTypeDto::Parallel,
245            DagNodeType::Escalate => DagNodeTypeDto::Escalate,
246        }
247    }
248}
249
250impl From<NodeConditionOpDto> for NodeConditionOp {
251    fn from(dto: NodeConditionOpDto) -> Self {
252        match dto {
253            NodeConditionOpDto::Eq => NodeConditionOp::Eq,
254            NodeConditionOpDto::Neq => NodeConditionOp::Neq,
255            NodeConditionOpDto::Gt => NodeConditionOp::Gt,
256            NodeConditionOpDto::Gte => NodeConditionOp::Gte,
257            NodeConditionOpDto::Lt => NodeConditionOp::Lt,
258            NodeConditionOpDto::Lte => NodeConditionOp::Lte,
259            NodeConditionOpDto::In => NodeConditionOp::In,
260            NodeConditionOpDto::ConfidenceGte => NodeConditionOp::ConfidenceGte,
261            NodeConditionOpDto::ConfidenceLt => NodeConditionOp::ConfidenceLt,
262        }
263    }
264}
265
266impl From<NodeConditionOp> for NodeConditionOpDto {
267    fn from(op: NodeConditionOp) -> Self {
268        match op {
269            NodeConditionOp::Eq => NodeConditionOpDto::Eq,
270            NodeConditionOp::Neq => NodeConditionOpDto::Neq,
271            NodeConditionOp::Gt => NodeConditionOpDto::Gt,
272            NodeConditionOp::Gte => NodeConditionOpDto::Gte,
273            NodeConditionOp::Lt => NodeConditionOpDto::Lt,
274            NodeConditionOp::Lte => NodeConditionOpDto::Lte,
275            NodeConditionOp::In => NodeConditionOpDto::In,
276            NodeConditionOp::ConfidenceGte => NodeConditionOpDto::ConfidenceGte,
277            NodeConditionOp::ConfidenceLt => NodeConditionOpDto::ConfidenceLt,
278        }
279    }
280}
281
282impl From<NodeConditionDto> for NodeCondition {
283    fn from(dto: NodeConditionDto) -> Self {
284        Self {
285            field: dto.field,
286            op: dto.op.into(),
287            value: dto.value,
288            target_node: dto.target_node,
289        }
290    }
291}
292
293impl From<NodeCondition> for NodeConditionDto {
294    fn from(c: NodeCondition) -> Self {
295        Self {
296            field: c.field,
297            op: c.op.into(),
298            value: c.value,
299            target_node: c.target_node,
300        }
301    }
302}
303
304impl From<DagNodeDto> for DagNode {
305    fn from(dto: DagNodeDto) -> Self {
306        Self {
307            node_type: dto.node_type.into(),
308            question: dto.question,
309            conditions: dto.conditions.into_iter().map(Into::into).collect(),
310            parallel_nodes: dto.parallel_nodes,
311            join_node: dto.join_node,
312            reason: dto.reason,
313        }
314    }
315}
316
317impl From<DagNode> for DagNodeDto {
318    fn from(node: DagNode) -> Self {
319        Self {
320            node_type: node.node_type.into(),
321            question: node.question,
322            conditions: node.conditions.into_iter().map(Into::into).collect(),
323            parallel_nodes: node.parallel_nodes,
324            join_node: node.join_node,
325            reason: node.reason,
326        }
327    }
328}
329
330impl From<DagDefinitionDto> for DagDefinition {
331    fn from(dto: DagDefinitionDto) -> Self {
332        let mut nodes = IndexMap::with_capacity(dto.nodes.len());
333        for (k, v) in dto.nodes {
334            nodes.insert(k, v.into());
335        }
336        Self {
337            dag_id: dto.dag_id,
338            timeout_ms: dto.timeout_ms,
339            entry_node: dto.entry_node,
340            nodes,
341        }
342    }
343}
344
345impl From<DagDefinition> for DagDefinitionDto {
346    fn from(dag: DagDefinition) -> Self {
347        let mut nodes = IndexMap::with_capacity(dag.nodes.len());
348        for (k, v) in dag.nodes {
349            nodes.insert(k, v.into());
350        }
351        Self {
352            dag_id: dag.dag_id,
353            timeout_ms: dag.timeout_ms,
354            entry_node: dag.entry_node,
355            nodes,
356        }
357    }
358}
359
360impl From<StepExecutionResult> for DagStepResultDto {
361    fn from(step: StepExecutionResult) -> Self {
362        Self {
363            node_id: step.node_id,
364            node_type: step.node_type.into(),
365            answer: step.answer,
366            duration_ms: step.duration_ms,
367            next_node: step.next_node,
368            reason: step.reason,
369        }
370    }
371}
372
373impl From<DagExecutionResult> for DagResponse {
374    fn from(res: DagExecutionResult) -> Self {
375        let status = if res.escalated {
376            "escalated".to_string()
377        } else {
378            "completed".to_string()
379        };
380
381        let final_node = res.execution_path.last().cloned();
382        let steps_executed = res.steps.len();
383
384        let mut steps = IndexMap::with_capacity(res.steps.len());
385        for (k, v) in res.steps {
386            steps.insert(k, v.into());
387        }
388
389        let escalation = if res.escalated {
390            Some(DagEscalationDetailDto {
391                reason: res.escalation_reason,
392                node_id: res.escalation_node_id,
393                prompt: res.escalation_prompt,
394                gating: res.escalation_gating,
395            })
396        } else {
397            None
398        };
399
400        Self {
401            dag_id: res.dag_id,
402            status,
403            final_node,
404            final_decision: res.final_answer,
405            execution_path: res.execution_path,
406            steps,
407            escalation,
408            usage: DagUsageDto {
409                total_duration_ms: res.total_duration_ms,
410                steps_executed,
411                completion_tokens: 0,
412            },
413        }
414    }
415}