Skip to main content

hs_predict/session/
flow.rs

1//! Question decision tree.
2//!
3//! Reads the current [`ClassificationState`] and returns the next
4//! [`Question`] + [`QuestionStep`] pair to present to the user.
5//! Pure functions; no side-effects.
6
7use crate::session::messages as msg;
8use crate::session::question::{Question, QuestionStep};
9use crate::session::state::ClassificationState;
10use crate::types::{Language, OrganicInorganic, PhysicalForm};
11
12/// Return the next question to ask (and its step tag), or `None` when all
13/// information has been collected and the pipeline can be invoked.
14pub fn next_question(
15    state: &ClassificationState,
16    lang: Language,
17) -> Option<(Question, QuestionStep)> {
18    // ── Step 1: identifier ───────────────────────────────────────────
19    if !state.has_identifier() {
20        let (prompt, example) = msg::q_identifier(lang);
21        return Some((
22            Question::Text { prompt, example: Some(example) },
23            QuestionStep::Identifier,
24        ));
25    }
26
27    // ── Step 2: is it a mixture? ────────────────────────────────────
28    if state.is_mixture.is_none() {
29        return Some((
30            Question::YesNo { prompt: msg::q_is_mixture(lang) },
31            QuestionStep::IsMixture,
32        ));
33    }
34
35    // ── Step 3a: mixture branch ─────────────────────────────────────
36    if state.is_mixture == Some(true) {
37        if state.component_count.is_none() {
38            let (prompt, unit) = msg::q_component_count(lang);
39            return Some((
40                Question::Number { prompt, unit, min: 2.0, max: 20.0 },
41                QuestionStep::ComponentCount,
42            ));
43        }
44
45        let expected = state.component_count.unwrap_or(0);
46        let idx = state.current_component_index;
47
48        // Current component — identifier missing
49        if idx < expected && !state.current_component_has_identifier() {
50            let (prompt, example) = msg::q_component_identifier(lang, idx + 1);
51            return Some((
52                Question::Text { prompt, example: Some(example) },
53                QuestionStep::ComponentIdentifier,
54            ));
55        }
56
57        // Current component — weight fraction missing
58        if idx < expected && !state.current_component_has_fraction() {
59            let name = state
60                .components
61                .get(idx)
62                .and_then(|c| c.identifier.iupac_name.clone())
63                .or_else(|| state.components.get(idx).and_then(|c| c.identifier.cas.clone()))
64                .unwrap_or_else(|| format!("component {}", idx + 1));
65            let (prompt, unit) = msg::q_component_fraction(lang, &name);
66            return Some((
67                Question::Number { prompt, unit, min: 0.0, max: 100.0 },
68                QuestionStep::ComponentFraction,
69            ));
70        }
71
72        // Still more components to collect
73        if state.components.len() < expected {
74            let next_idx = state.components.len();
75            let (prompt, example) = msg::q_next_component_identifier(lang, next_idx + 1);
76            return Some((
77                Question::Text { prompt, example: Some(example) },
78                QuestionStep::ComponentIdentifier,
79            ));
80        }
81
82        // All components entered → hand off to pipeline
83        return None;
84    }
85
86    // ── Step 3b: pure substance branch ──────────────────────────────
87
88    // Physical form
89    if state.physical_form.is_none() {
90        let (prompt, options) = msg::q_physical_form(lang);
91        return Some((
92            Question::Choice { prompt, options },
93            QuestionStep::PhysicalForm,
94        ));
95    }
96
97    // Solution concentration (when form is Solution with unknown concentration)
98    if let Some(PhysicalForm::Solution { concentration_pct_ww: None, .. }) = &state.physical_form {
99        let (prompt, unit) = msg::q_solution_concentration(lang);
100        return Some((
101            Question::Number { prompt, unit, min: 0.0, max: 100.0 },
102            QuestionStep::SolutionConcentration,
103        ));
104    }
105
106    // Intended use
107    if state.intended_use.is_none() {
108        let (prompt, options) = msg::q_intended_use(lang);
109        return Some((
110            Question::Choice { prompt, options },
111            QuestionStep::IntendedUse,
112        ));
113    }
114
115    // Organic / inorganic (only when SMILES is unknown)
116    if state.organic_inorganic.is_none() && state.identifier.smiles.is_none() {
117        let (prompt, options) = msg::q_organic_inorganic(lang);
118        return Some((
119            Question::Choice { prompt, options },
120            QuestionStep::OrganicInorganic,
121        ));
122    }
123
124    // Functional groups (only for organic compounds without SMILES)
125    if state.identifier.smiles.is_none()
126        && matches!(state.organic_inorganic, Some(OrganicInorganic::Organic))
127        && state.detected_functional_groups.is_empty()
128        && state.chapter_hint.is_none()
129    {
130        let (prompt, options) = msg::q_functional_groups(lang);
131        return Some((
132            Question::MultiChoice { prompt, options, include_unknown: true },
133            QuestionStep::FunctionalGroups,
134        ));
135    }
136
137    // Nothing more to ask → pipeline
138    None
139}
140
141// ─── Index-to-value converters ────────────────────────────────────────────────
142// These are language-independent; indices map to the option order defined in
143// messages.rs (both EN and JA share the same ordering).
144
145/// Map physical-form choice index to [`PhysicalForm`].
146pub fn choice_index_to_physical_form(index: usize) -> PhysicalForm {
147    match index {
148        0 => PhysicalForm::Solid,
149        1 => PhysicalForm::Powder { particle_size_um: None },
150        2 => PhysicalForm::Granules,
151        3 => PhysicalForm::Liquid,
152        4 => PhysicalForm::Solution { solvent: None, concentration_pct_ww: None },
153        5 => PhysicalForm::Gas,
154        6 => PhysicalForm::Foil { thickness_mm: None },
155        7 => PhysicalForm::Ingot,
156        _ => PhysicalForm::Unknown,
157    }
158}
159
160/// Map intended-use choice index to [`IntendedUse`](crate::types::IntendedUse).
161pub fn choice_index_to_intended_use(index: usize) -> crate::types::IntendedUse {
162    use crate::types::IntendedUse;
163    match index {
164        0 => IntendedUse::Industrial,
165        1 => IntendedUse::Pharmaceutical,
166        2 => IntendedUse::Agricultural,
167        3 => IntendedUse::Food,
168        4 => IntendedUse::Cosmetic,
169        _ => IntendedUse::Other("unknown".to_string()),
170    }
171}
172
173/// Map organic/inorganic choice index to [`OrganicInorganic`].
174pub fn choice_index_to_organic_inorganic(index: usize) -> OrganicInorganic {
175    match index {
176        0 => OrganicInorganic::Organic,
177        1 => OrganicInorganic::Inorganic,
178        _ => OrganicInorganic::Unknown,
179    }
180}
181
182/// Map functional-group multi-choice indices to string keys.
183pub fn multi_choice_indices_to_functional_groups(indices: &[usize]) -> Vec<String> {
184    const GROUPS: &[&str] = &[
185        "carboxylic_acid",
186        "alcohol",
187        "phenol",
188        "aldehyde",
189        "ketone",
190        "amine",
191        "amide",
192        "nitrile",
193        "halide",
194        "ester",
195        "aromatic",
196    ];
197    indices
198        .iter()
199        .filter_map(|&i| GROUPS.get(i).map(|s| s.to_string()))
200        .collect()
201}