1use crate::{
4 RiskClass, TaskComplexity,
5 common::{
6 ValidationError, deserialize_milliunit, deserialize_schema_version, validate_milliunit,
7 validate_schema_version,
8 },
9};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum TaskScope {
16 #[default]
17 SingleFile,
18 MultiFile,
19 CrossModule,
20 CrossProject,
21}
22
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum TriageBackend {
27 #[default]
28 Rules,
29 Semantic,
30 Hybrid,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct TaskProfileV1 {
37 pub primary_intent: String,
38 pub task_class: String,
39 pub complexity: TaskComplexity,
40 pub scope: TaskScope,
41 #[serde(deserialize_with = "deserialize_milliunit")]
42 pub context_need_milli: u16,
43 #[serde(deserialize_with = "deserialize_milliunit")]
44 pub reasoning_need_milli: u16,
45 pub risk_signal: RiskClass,
46 #[serde(deserialize_with = "deserialize_milliunit")]
47 pub confidence_milli: u16,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub capability_id: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub capability_version: Option<String>,
54 #[serde(default)]
55 pub keywords: Vec<String>,
56 #[serde(default)]
57 pub language_hints: Vec<String>,
58}
59
60impl Default for TaskProfileV1 {
61 fn default() -> Self {
62 Self {
63 primary_intent: String::new(),
64 task_class: String::new(),
65 complexity: TaskComplexity::Low,
66 scope: TaskScope::SingleFile,
67 context_need_milli: 0,
68 reasoning_need_milli: 0,
69 risk_signal: RiskClass::Low,
70 confidence_milli: 0,
71 capability_id: None,
72 capability_version: None,
73 keywords: Vec::new(),
74 language_hints: Vec::new(),
75 }
76 }
77}
78
79impl TaskProfileV1 {
80 pub fn validate(&self) -> Result<(), ValidationError> {
82 validate_milliunit(self.context_need_milli, "context_need_milli")?;
83 validate_milliunit(self.reasoning_need_milli, "reasoning_need_milli")?;
84 validate_milliunit(self.confidence_milli, "confidence_milli")
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct TriageResultV1 {
92 #[serde(deserialize_with = "deserialize_schema_version")]
93 pub schema_version: u32,
94 pub triage_result_id: String,
95 pub task_id: String,
96 pub profile: TaskProfileV1,
97 #[serde(deserialize_with = "deserialize_milliunit")]
98 pub confidence_milli: u16,
99 pub backend: TriageBackend,
100 pub analyzer_version: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub model_ref: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub degraded_reason: Option<String>,
105}
106
107impl Default for TriageResultV1 {
108 fn default() -> Self {
109 Self {
110 schema_version: 1,
111 triage_result_id: String::new(),
112 task_id: String::new(),
113 profile: TaskProfileV1::default(),
114 confidence_milli: 0,
115 backend: TriageBackend::Rules,
116 analyzer_version: String::new(),
117 model_ref: None,
118 degraded_reason: None,
119 }
120 }
121}
122
123impl TriageResultV1 {
124 pub const SCHEMA_VERSION: u32 = 1;
126
127 pub fn validate(&self) -> Result<(), ValidationError> {
129 validate_schema_version(self.schema_version)?;
130 self.profile.validate()?;
131 validate_milliunit(self.confidence_milli, "confidence_milli")
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 fn valid_profile() -> TaskProfileV1 {
140 TaskProfileV1 {
141 primary_intent: "implement".to_owned(),
142 task_class: "coding".to_owned(),
143 complexity: TaskComplexity::Medium,
144 scope: TaskScope::CrossModule,
145 context_need_milli: 700,
146 reasoning_need_milli: 800,
147 risk_signal: RiskClass::Low,
148 confidence_milli: 900,
149 capability_id: Some("capability://leanctx/triage".to_owned()),
150 capability_version: Some("1.0.0".to_owned()),
151 keywords: vec!["rust".to_owned(), "protocol".to_owned()],
152 language_hints: vec!["rust".to_owned()],
153 }
154 }
155
156 fn valid_result() -> TriageResultV1 {
157 TriageResultV1 {
158 schema_version: 1,
159 triage_result_id: "triage-1".to_owned(),
160 task_id: "task-1".to_owned(),
161 profile: valid_profile(),
162 confidence_milli: 850,
163 backend: TriageBackend::Hybrid,
164 analyzer_version: "rules-1".to_owned(),
165 model_ref: Some("model-1".to_owned()),
166 degraded_reason: None,
167 }
168 }
169
170 #[test]
171 fn serialization_round_trip() {
172 let result = valid_result();
173 let json = serde_json::to_string(&result).expect("triage result should serialize");
174 let decoded: TriageResultV1 =
175 serde_json::from_str(&json).expect("triage result should deserialize");
176 assert_eq!(result, decoded);
177 result.validate().expect("triage result should be valid");
178 }
179
180 #[test]
181 fn profile_capability_metadata_is_optional_and_backward_compatible() {
182 let profile = valid_profile();
183 let json = serde_json::to_value(&profile).expect("profile should serialize");
184 assert_eq!(json["capability_id"], "capability://leanctx/triage");
185 assert_eq!(
186 serde_json::from_value::<TaskProfileV1>(json.clone())
187 .expect("profile with capability metadata should deserialize"),
188 profile
189 );
190
191 let mut legacy = json;
192 let object = legacy
193 .as_object_mut()
194 .expect("serialized profile should be an object");
195 object.remove("capability_id");
196 object.remove("capability_version");
197
198 let decoded: TaskProfileV1 =
199 serde_json::from_value(legacy).expect("legacy profile should deserialize");
200 assert_eq!(decoded.capability_id, None);
201 assert_eq!(decoded.capability_version, None);
202
203 let without_capability = serde_json::to_value(decoded)
204 .expect("profile without capability metadata should serialize");
205 assert!(without_capability.get("capability_id").is_none());
206 assert!(without_capability.get("capability_version").is_none());
207 }
208
209 #[test]
210 fn validation_rejects_invalid_schema_and_milliunits() {
211 let mut result = valid_result();
212 result.schema_version = 2;
213 assert!(result.validate().is_err());
214
215 result.schema_version = 1;
216 result.confidence_milli = 1001;
217 assert!(result.validate().is_err());
218
219 let mut profile = valid_profile();
220 profile.context_need_milli = 1001;
221 assert!(profile.validate().is_err());
222 }
223
224 #[test]
225 fn defaults_are_stable_and_valid() {
226 let profile = TaskProfileV1::default();
227 assert_eq!(profile, TaskProfileV1::default());
228 profile.validate().expect("default profile should be valid");
229
230 let result = TriageResultV1::default();
231 assert_eq!(result, TriageResultV1::default());
232 result.validate().expect("default result should be valid");
233 assert_eq!(TaskScope::default(), TaskScope::SingleFile);
234 assert_eq!(TriageBackend::default(), TriageBackend::Rules);
235 }
236}