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 => "badge-success",
56 Confidence::Medium => "badge-warning",
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 // TODO(phase-5-followup): real implementation is deferred until the
185 // entry_builder module is fully ported from OLD. The stub returns an
186 // empty Vec so callers compile and behave as "no suggestions" — same
187 // outcome as the no-context branch of derive_suggestions. Unblocks
188 // once DynamicAdminEntry gains its real shape + build_admin_entries
189 // reads a live Schema.
190 Vec::new()
191}
192
193/// Schema-backed counterpart to [`find_suggestion`].
194pub fn find_suggestion_from_entries(
195 _entries: &[DynamicAdminEntry],
196 _context: Option<&ContextConfig>,
197 _admin_name: &str,
198 _field: &str,
199) -> Option<Suggestion> {
200 // TODO(phase-5-followup): stubbed for the same reason as
201 // derive_suggestions_from_entries — returns None until entry_builder
202 // lands.
203 None
204}
205
206/// 0.8.0 — propose linking an orphan `<thing>_id` column to a known
207/// model when the schema has no [`Relation`](crate::schema::Relation)
208/// recorded for it. Fired from `&Schema` directly because relation
209/// metadata lives there (not on admin entries).
210///
211/// Matching rule: take the column name, strip the trailing `_id`, and
212/// look for a model whose `singular_name` (case-insensitively) matches
213/// the singularised remainder. If multiple models match — or none do —
214/// skip: refusing is safer than guessing.
215///
216/// Deterministic: iteration follows `schema.models` then
217/// `model.fields` order. No I/O, no allocation beyond the returned vec.
218pub fn derive_relation_suggestions(schema: &crate::schema::Schema) -> Vec<Suggestion> {
219 let mut out: Vec<Suggestion> = Vec::new();
220 for model in schema.models.iter().filter(|m| !m.core) {
221 for field in &model.fields {
222 if field.name == "id" || !field.name.ends_with("_id") {
223 continue;
224 }
225 if field.relation.is_some() {
226 continue;
227 }
228 let stem = &field.name[..field.name.len() - 3];
229 if stem.is_empty() {
230 continue;
231 }
232 // Find the target: prefer a singular_name match; fall back
233 // to the model name. Refuse on ambiguity or no match.
234 let mut candidates: Vec<&crate::schema::SchemaModel> = schema
235 .models
236 .iter()
237 .filter(|m| {
238 m.singular_name.eq_ignore_ascii_case(stem) || m.name.eq_ignore_ascii_case(stem)
239 })
240 .collect();
241 candidates.dedup_by(|a, b| a.name == b.name);
242 if candidates.len() != 1 {
243 continue;
244 }
245 let target = candidates[0];
246 if target.name == model.name {
247 // Self-joins ("parent_id" on Category → Category) are
248 // legitimate but easy to propose by accident. Skip in
249 // 0.8.0 — the user can still type the prompt by hand.
250 continue;
251 }
252 out.push(Suggestion {
253 model_display: model.display_name.clone(),
254 model_singular: model.singular_name.clone(),
255 admin_name: model.admin_name.clone(),
256 field: field.name.clone(),
257 prompt: format!(
258 "link {from} to {to}",
259 from = model.singular_name,
260 to = target.singular_name,
261 ),
262 reason: format!(
263 "`{}` looks like a foreign key to `{}` but no relation is recorded.",
264 field.name, target.singular_name,
265 ),
266 action: "add_relation",
267 // We inferred the target from a naming convention,
268 // not from an explicit schema link. That's Medium —
269 // the operator sees the confidence pill and decides.
270 confidence: Confidence::Medium,
271 });
272 }
273 }
274 out
275}
276
277/// Companion to [`derive_relation_suggestions`] — locate one by
278/// `(admin_name, field)`. Same rejection-of-crafted-URLs story as
279/// [`find_suggestion`].
280pub fn find_relation_suggestion(
281 schema: &crate::schema::Schema,
282 admin_name: &str,
283 field: &str,
284) -> Option<Suggestion> {
285 derive_relation_suggestions(schema)
286 .into_iter()
287 .find(|s| s.admin_name == admin_name && s.field == field)
288}