Skip to main content

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}