super-stt-app/src/state/language.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | |
3 | | //! Transcription-language UI state, extracted from `AppModel` following the |
4 | | //! `RegistryState` template (App Tier 3 #15). |
5 | | |
6 | | use crate::state::LanguageResolution; |
7 | | |
8 | | /// The resolution blocks the daemon has answered for, keyed by |
9 | | /// `(source, model)` — the blocks behind |
10 | | /// `GET /pipeline/{stage}/model/{model}/language`. |
11 | | /// |
12 | | /// A single slot held one block while only the transcription card had a |
13 | | /// language control. Both pipeline stages have one now, and a stage-1 fetch |
14 | | /// would overwrite the block stage 2 is rendering (and the other way round), |
15 | | /// so answers are keyed by the pair they describe — the same shape, and for |
16 | | /// the same reason, as [`crate::state::device_offers::DeviceOffers`]. |
17 | | /// |
18 | | /// A miss means "not asked yet, or the answer hasn't landed", which is what |
19 | | /// makes a stale answer harmless: a lookup for a pair simply misses until its |
20 | | /// own answer arrives. |
21 | | #[derive(Debug, Clone, Default)] |
22 | | pub struct ModelLanguages { |
23 | | blocks: Vec<((u32, String, String), LanguageResolution)>, |
24 | | /// The tags each answered-for model can be pinned to — what |
25 | | /// `GET /pipeline/{stage}/model/{model}/language/list` returns. |
26 | | /// |
27 | | /// Held beside the blocks rather than inside them because the two arrive |
28 | | /// from different requests and have different lifetimes: the block changes |
29 | | /// every time the user picks a language, the offer never changes for a |
30 | | /// given model. A single record would make every pick re-fetch a list that |
31 | | /// cannot have moved. |
32 | | offers: Vec<((u32, String, String), Vec<String>)>, |
33 | | } |
34 | | |
35 | | impl ModelLanguages { |
36 | | /// The block for `(stage, source, model)`, or `None` if the daemon hasn't |
37 | | /// answered for it. |
38 | | #[must_use] |
39 | 6 | pub fn get(&self, stage: u32, source: &str, model: &str) -> Option<&LanguageResolution> { |
40 | 6 | self.blocks |
41 | 6 | .iter() |
42 | 7 | .find6 (|((st, s, m), _)| *st == stage && s == source5 && m == model5 ) |
43 | 6 | .map(|(_, block)| block) |
44 | 6 | } |
45 | | |
46 | | /// Record an answer, replacing any it supersedes. |
47 | 6 | pub fn record(&mut self, stage: u32, source: String, model: String, block: LanguageResolution) { |
48 | 6 | let key = (stage, source, model); |
49 | 6 | if let Some(existing1 ) = self.blocks.iter_mut().find(|(k, _)| *k3 == key3 ) { |
50 | 1 | existing.1 = block; |
51 | 1 | return; |
52 | 5 | } |
53 | 5 | self.blocks.push((key, block)); |
54 | 6 | } |
55 | | |
56 | | /// Every pair answered for, so a global language change can refresh all of |
57 | | /// them: a per-model block that follows the global value goes stale the |
58 | | /// moment that value changes, whichever card is showing it. |
59 | 2 | pub fn pairs(&self) -> impl Iterator<Item = (u32, String, String)> + '_ { |
60 | 3 | self.blocks.iter()2 .map2 (|(key, _)| key.clone()) |
61 | 2 | } |
62 | | |
63 | | /// The tags `(stage, source, model)` can be pinned to, or `None` if the |
64 | | /// daemon has not answered for it. |
65 | | /// |
66 | | /// A miss and an empty answer are different, and a picker reads them |
67 | | /// differently: not asked yet is a control still loading, while an empty |
68 | | /// offer is a monolingual model with nothing to choose. |
69 | | #[must_use] |
70 | 4 | pub fn offered(&self, stage: u32, source: &str, model: &str) -> Option<&[String]> { |
71 | 4 | self.offers |
72 | 4 | .iter() |
73 | 4 | .find(|((st, s, m), _)| *st == stage && s == source3 && m == model3 ) |
74 | 4 | .map(|(_, tags)| tags3 .as_slice3 ()) |
75 | 4 | } |
76 | | |
77 | | /// Record what a model can be pinned to. |
78 | 3 | pub fn record_offer(&mut self, stage: u32, source: String, model: String, tags: Vec<String>) { |
79 | 3 | let key = (stage, source, model); |
80 | 3 | if let Some(existing0 ) = self.offers.iter_mut().find(|(k, _)| *k1 == key1 ) { |
81 | 0 | existing.1 = tags; |
82 | 0 | return; |
83 | 3 | } |
84 | 3 | self.offers.push((key, tags)); |
85 | 3 | } |
86 | | } |
87 | | |
88 | | /// The global Primary Language plus the per-model override picker state. |
89 | | #[derive(Debug, Clone, Default)] |
90 | | pub struct LanguageState { |
91 | | /// Global Primary Language from the daemon (`None` = unset). Display-only cache. |
92 | | pub primary_language: Option<String>, |
93 | | /// The tags the global setting accepts, from |
94 | | /// `GET /settings/language/list`. |
95 | | /// |
96 | | /// The daemon's vocabulary rather than one this client curates: which of |
97 | | /// `en` and `en-US` a model wants is a rule only the daemon's resolver |
98 | | /// knows, so a list of our own would offer tags the setter now refuses. |
99 | | /// Empty until the answer lands, which renders as a picker still loading. |
100 | | pub global_offers: Vec<String>, |
101 | | /// Per-model resolution blocks, one per `(source, model)` asked about. |
102 | | pub model_languages: ModelLanguages, |
103 | | /// The `(source, model)` pair the open per-model language sheet configures. |
104 | | /// `None` when the sheet is in global mode. |
105 | | pub language_picker_target: Option<(u32, String, String)>, |
106 | | /// Live query text for the language search sheet. |
107 | | pub language_picker_query: String, |
108 | | } |
109 | | |
110 | | #[cfg(test)] |
111 | | mod tests { |
112 | | use super::ModelLanguages; |
113 | | use crate::state::LanguageResolution; |
114 | | |
115 | 6 | fn block(primary: &str) -> LanguageResolution { |
116 | 6 | LanguageResolution { |
117 | 6 | effective: Some(primary.to_string()), |
118 | 6 | source: "default".to_string(), |
119 | 6 | primary: primary.to_string(), |
120 | 6 | } |
121 | 6 | } |
122 | | |
123 | | /// The regression the store exists for: two stages each showing a language |
124 | | /// control must not overwrite each other's block. A single slot meant the |
125 | | /// second fetch to land won and the other card fell back to a neutral |
126 | | /// label. |
127 | | #[test] |
128 | 1 | fn two_models_are_held_at_once() { |
129 | 1 | let mut langs = ModelLanguages::default(); |
130 | 1 | langs.record(1, "src/a".into(), "whisper".into(), block("es")); |
131 | 1 | langs.record(2, "src/b".into(), "s1-mini".into(), block("en")); |
132 | | |
133 | 1 | assert_eq!(langs.get(1, "src/a", "whisper").unwrap().primary, "es"); |
134 | 1 | assert_eq!(langs.get(2, "src/b", "s1-mini").unwrap().primary, "en"); |
135 | 1 | } |
136 | | |
137 | | /// The same `(source, model)` in two stages is two entries, not one. |
138 | | /// |
139 | | /// A backend serving a model both stages can run is the case that needs it: |
140 | | /// keyed on the pair alone, staging it in one card would answer for the |
141 | | /// other, and a language change in stage 1 would silently relabel stage 2. |
142 | | #[test] |
143 | 1 | fn one_pair_in_two_stages_is_two_entries() { |
144 | 1 | let mut langs = ModelLanguages::default(); |
145 | 1 | langs.record(1, "src/a".into(), "shared".into(), block("es")); |
146 | 1 | langs.record(2, "src/a".into(), "shared".into(), block("en")); |
147 | | |
148 | 1 | assert_eq!(langs.get(1, "src/a", "shared").unwrap().primary, "es"); |
149 | 1 | assert_eq!(langs.get(2, "src/a", "shared").unwrap().primary, "en"); |
150 | 1 | assert_eq!(langs.pairs().count(), 2); |
151 | 1 | } |
152 | | |
153 | | /// A second answer for the same pair replaces the first rather than |
154 | | /// stacking, or a language change would never be reflected. |
155 | | #[test] |
156 | 1 | fn a_new_answer_replaces_the_pairs_old_one() { |
157 | 1 | let mut langs = ModelLanguages::default(); |
158 | 1 | langs.record(1, "src/a".into(), "whisper".into(), block("es")); |
159 | 1 | langs.record(1, "src/a".into(), "whisper".into(), block("de")); |
160 | | |
161 | 1 | assert_eq!(langs.get(1, "src/a", "whisper").unwrap().primary, "de"); |
162 | 1 | assert_eq!(langs.pairs().count(), 1); |
163 | 1 | } |
164 | | |
165 | | /// A pair never asked about misses, which is what the callers render as |
166 | | /// "not answered yet" rather than as an absent language. |
167 | | #[test] |
168 | 1 | fn an_unasked_pair_misses() { |
169 | 1 | let langs = ModelLanguages::default(); |
170 | 1 | assert!(langs.get(1, "src/a", "whisper").is_none()); |
171 | 1 | } |
172 | | |
173 | | /// An offer of nothing is not the same as no offer, and the picker draws |
174 | | /// them differently: a monolingual model has nothing to choose, while an |
175 | | /// unanswered one is still loading. |
176 | | #[test] |
177 | 1 | fn an_empty_offer_is_not_a_missing_one() { |
178 | 1 | let mut langs = ModelLanguages::default(); |
179 | 1 | assert!(langs.offered(1, "src/a", "mono").is_none()); |
180 | | |
181 | 1 | langs.record_offer(1, "src/a".into(), "mono".into(), Vec::new()); |
182 | 1 | assert_eq!(langs.offered(1, "src/a", "mono"), Some(&[][..])); |
183 | 1 | } |
184 | | |
185 | | /// Offers are keyed like blocks, so two stages showing the same model do |
186 | | /// not answer for each other. |
187 | | #[test] |
188 | 1 | fn offers_are_keyed_by_stage_too() { |
189 | 1 | let mut langs = ModelLanguages::default(); |
190 | 1 | langs.record_offer(1, "src/a".into(), "shared".into(), vec!["auto".into()]); |
191 | 1 | langs.record_offer(2, "src/a".into(), "shared".into(), Vec::new()); |
192 | | |
193 | 1 | assert_eq!( |
194 | 1 | langs.offered(1, "src/a", "shared").map(<[String]>::len), |
195 | | Some(1) |
196 | | ); |
197 | 1 | assert_eq!( |
198 | 1 | langs.offered(2, "src/a", "shared").map(<[String]>::len), |
199 | | Some(0) |
200 | | ); |
201 | 1 | } |
202 | | } |