rustio_core/admin/suggestions.rs
1//! Actionable suggestion engine — 0.7.1.
2//!
3//! Derives concrete *add-this-field* suggestions from
4//! `(schema, context)`. Every suggestion is a thin descriptor the
5//! admin UI can turn into a button; clicking the button runs the
6//! planner, the review layer, and finally the executor — the
7//! standard chain. Nothing here bypasses safety gates.
8//!
9//! ## Scope (0.7.1)
10//!
11//! Only `AddField` suggestions for industry-required fields that a
12//! model is missing. Destructive / renaming / type-changing
13//! suggestions are explicitly out of scope — they need their own
14//! review pass and are deferred.
15//!
16//! ## What this module does NOT do
17//!
18//! - It does not call the planner or executor itself. It only
19//! produces structured data (`Suggestion`) that describes what the
20//! user could opt into. Wiring lives in `admin.rs`.
21//! - It does not touch the filesystem or database.
22
23use crate::admin::entry_builder::DynamicAdminEntry;
24use crate::admin::AdminEntry;
25use crate::ai::ContextConfig;
26
27/// How sure the suggestion engine is that this is the right action.
28///
29/// - [`Confidence::High`] — the field is explicitly listed in an
30/// industry convention. We know the name; the type comes from
31/// the planner's deterministic rules.
32/// - [`Confidence::Medium`] — the suggestion was inferred from a
33/// looser signal (heuristic, pattern match). Reserved for
34/// 0.7.x+ when we start producing suggestions from data shape
35/// rather than explicit convention lists.
36///
37/// Rendered as a small badge next to the action button so the
38/// operator sees, before clicking, how trustworthy the proposal is.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Confidence {
41 High,
42 Medium,
43}
44
45impl Confidence {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Confidence::High => "High",
49 Confidence::Medium => "Medium",
50 }
51 }
52 /// CSS pill class reusing the existing status palette.
53 pub fn pill_class(self) -> &'static str {
54 match self {
55 Confidence::High => "rio-pill rio-pill-emerald",
56 Confidence::Medium => "rio-pill rio-pill-amber",
57 }
58 }
59}
60
61/// One proposed action shown next to a dashboard alert.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Suggestion {
64 /// The model's display name (e.g. `"Applicants"`). Used for the
65 /// button label.
66 pub model_display: String,
67 /// The model's singular form (e.g. `"Applicant"`). Used in the
68 /// planner prompt.
69 pub model_singular: String,
70 /// The URL slug under `/admin/<admin_name>` — also used as the
71 /// routing key under `/admin/suggestions/<admin_name>/<field>`.
72 pub admin_name: String,
73 /// Field name the suggestion would add.
74 pub field: String,
75 /// Natural-language prompt handed to the planner when the user
76 /// accepts. Example: `"add annual_income to applicants"`.
77 pub prompt: String,
78 /// One-line human rationale shown beside the button ("Housing
79 /// industry convention", "GDPR retention required", …).
80 pub reason: String,
81 /// Short verb tag for the action type. Today always
82 /// `"add_field"`; reserved so future variants (`"make_required"`
83 /// etc.) can land without changing this struct.
84 pub action: &'static str,
85 /// How confident the engine is that this is the right move.
86 pub confidence: Confidence,
87}
88
89impl Suggestion {
90 /// Stable URL key under `/admin/suggestions/<admin_name>/<field>`.
91 /// Used by the dashboard to render the href and by the route
92 /// handler to re-derive + validate on both GET and POST.
93 pub fn url_path(&self) -> String {
94 format!(
95 "/admin/suggestions/{admin}/{field}",
96 admin = self.admin_name,
97 field = self.field,
98 )
99 }
100}
101
102/// Enumerate every suggestion for the current project. Empty when
103/// no context is loaded or when no model overlaps the industry's
104/// convention list. Deterministic: iteration follows the order of
105/// `entries` then `industry_schema.required_fields`.
106pub fn derive_suggestions(
107 entries: &[AdminEntry],
108 context: Option<&ContextConfig>,
109) -> Vec<Suggestion> {
110 let Some(ctx) = context else {
111 return Vec::new();
112 };
113 let Some(schema) = ctx.industry_schema() else {
114 return Vec::new();
115 };
116 let industry = ctx.industry.as_deref().unwrap_or("").to_string();
117
118 let mut out: Vec<Suggestion> = Vec::new();
119 for entry in entries.iter().filter(|e| !e.core) {
120 let field_names: Vec<&str> = entry.fields.iter().map(|f| f.name).collect();
121
122 // Same gate the dashboard uses: only surface suggestions on a
123 // model that already adopts *some* convention. Otherwise a
124 // `Widget` model under `industry=housing` would nag about
125 // personnummer, which is noise.
126 let covers_any = schema
127 .required_fields
128 .iter()
129 .any(|req| field_names.contains(&req.as_str()));
130 if !covers_any {
131 continue;
132 }
133
134 for req in &schema.required_fields {
135 if field_names.contains(&req.as_str()) {
136 continue;
137 }
138 let prompt = format!("add {req} to {admin}", admin = entry.admin_name);
139 out.push(Suggestion {
140 model_display: entry.display_name.to_string(),
141 model_singular: entry.singular_name.to_string(),
142 admin_name: entry.admin_name.to_string(),
143 field: req.clone(),
144 prompt,
145 reason: format!("{industry} industry convention"),
146 action: "add_field",
147 // Industry-required fields are explicit, named
148 // conventions — the engine isn't guessing. That's
149 // High confidence.
150 confidence: Confidence::High,
151 });
152 }
153 }
154 out
155}
156
157/// Look up a specific suggestion by `(admin_name, field)`. Returns
158/// `None` if the pair isn't in the current derived set — this is how
159/// the route handlers reject crafted URLs. An operator can only
160/// click through suggestions the engine actually produced.
161pub fn find_suggestion(
162 entries: &[AdminEntry],
163 context: Option<&ContextConfig>,
164 admin_name: &str,
165 field: &str,
166) -> Option<Suggestion> {
167 derive_suggestions(entries, context)
168 .into_iter()
169 .find(|s| s.admin_name == admin_name && s.field == field)
170}
171
172/// 0.7.3 schema-backed variant. Same rules as [`derive_suggestions`]
173/// but reads field names from [`DynamicAdminEntry`], which the admin
174/// builds fresh from [`crate::admin::schema_cache`] on every
175/// dashboard render. When the cache sees an updated
176/// `rustio.schema.json` — e.g. after `rustio ai apply` +
177/// `rustio schema` + `[Reload schema]` — the suggestion for the
178/// just-added field disappears on the next response, without
179/// restarting the process.
180pub fn derive_suggestions_from_entries(
181 entries: &[DynamicAdminEntry],
182 context: Option<&ContextConfig>,
183) -> Vec<Suggestion> {
184 let Some(ctx) = context else {
185 return Vec::new();
186 };
187 let Some(schema) = ctx.industry_schema() else {
188 return Vec::new();
189 };
190 let industry = ctx.industry.as_deref().unwrap_or("").to_string();
191
192 let mut out: Vec<Suggestion> = Vec::new();
193 for entry in entries.iter().filter(|e| !e.core) {
194 let field_names: Vec<&str> = entry.fields.iter().map(|f| f.name.as_str()).collect();
195 let covers_any = schema
196 .required_fields
197 .iter()
198 .any(|req| field_names.contains(&req.as_str()));
199 if !covers_any {
200 continue;
201 }
202 for req in &schema.required_fields {
203 if field_names.contains(&req.as_str()) {
204 continue;
205 }
206 let prompt = format!("add {req} to {admin}", admin = entry.admin_name);
207 out.push(Suggestion {
208 model_display: entry.display_name.clone(),
209 model_singular: entry.singular_name.clone(),
210 admin_name: entry.admin_name.clone(),
211 field: req.clone(),
212 prompt,
213 reason: format!("{industry} industry convention"),
214 action: "add_field",
215 confidence: Confidence::High,
216 });
217 }
218 }
219 out
220}
221
222/// Schema-backed counterpart to [`find_suggestion`].
223pub fn find_suggestion_from_entries(
224 entries: &[DynamicAdminEntry],
225 context: Option<&ContextConfig>,
226 admin_name: &str,
227 field: &str,
228) -> Option<Suggestion> {
229 derive_suggestions_from_entries(entries, context)
230 .into_iter()
231 .find(|s| s.admin_name == admin_name && s.field == field)
232}
233
234/// 0.8.0 — propose linking an orphan `<thing>_id` column to a known
235/// model when the schema has no [`Relation`](crate::schema::Relation)
236/// recorded for it. Fired from `&Schema` directly because relation
237/// metadata lives there (not on admin entries).
238///
239/// Matching rule: take the column name, strip the trailing `_id`, and
240/// look for a model whose `singular_name` (case-insensitively) matches
241/// the singularised remainder. If multiple models match — or none do —
242/// skip: refusing is safer than guessing.
243///
244/// Deterministic: iteration follows `schema.models` then
245/// `model.fields` order. No I/O, no allocation beyond the returned vec.
246pub fn derive_relation_suggestions(schema: &crate::schema::Schema) -> Vec<Suggestion> {
247 let mut out: Vec<Suggestion> = Vec::new();
248 for model in schema.models.iter().filter(|m| !m.core) {
249 for field in &model.fields {
250 if field.name == "id" || !field.name.ends_with("_id") {
251 continue;
252 }
253 if field.relation.is_some() {
254 continue;
255 }
256 let stem = &field.name[..field.name.len() - 3];
257 if stem.is_empty() {
258 continue;
259 }
260 // Find the target: prefer a singular_name match; fall back
261 // to the model name. Refuse on ambiguity or no match.
262 let mut candidates: Vec<&crate::schema::SchemaModel> = schema
263 .models
264 .iter()
265 .filter(|m| {
266 m.singular_name.eq_ignore_ascii_case(stem) || m.name.eq_ignore_ascii_case(stem)
267 })
268 .collect();
269 candidates.dedup_by(|a, b| a.name == b.name);
270 if candidates.len() != 1 {
271 continue;
272 }
273 let target = candidates[0];
274 if target.name == model.name {
275 // Self-joins ("parent_id" on Category → Category) are
276 // legitimate but easy to propose by accident. Skip in
277 // 0.8.0 — the user can still type the prompt by hand.
278 continue;
279 }
280 out.push(Suggestion {
281 model_display: model.display_name.clone(),
282 model_singular: model.singular_name.clone(),
283 admin_name: model.admin_name.clone(),
284 field: field.name.clone(),
285 prompt: format!(
286 "link {from} to {to}",
287 from = model.singular_name,
288 to = target.singular_name,
289 ),
290 reason: format!(
291 "`{}` looks like a foreign key to `{}` but no relation is recorded.",
292 field.name, target.singular_name,
293 ),
294 action: "add_relation",
295 // We inferred the target from a naming convention,
296 // not from an explicit schema link. That's Medium —
297 // the operator sees the confidence pill and decides.
298 confidence: Confidence::Medium,
299 });
300 }
301 }
302 out
303}
304
305/// Companion to [`derive_relation_suggestions`] — locate one by
306/// `(admin_name, field)`. Same rejection-of-crafted-URLs story as
307/// [`find_suggestion`].
308pub fn find_relation_suggestion(
309 schema: &crate::schema::Schema,
310 admin_name: &str,
311 field: &str,
312) -> Option<Suggestion> {
313 derive_relation_suggestions(schema)
314 .into_iter()
315 .find(|s| s.admin_name == admin_name && s.field == field)
316}