@saltcorn/copilot 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ const { RelationsFinder, RelationType } = require("@saltcorn/common-code");
2
+
3
+ /**
4
+ * Relation path documentation included in LLM system prompts (viewgen, builder-gen).
5
+ *
6
+ * TWO FORMATS EXIST:
7
+ *
8
+ * New format (always generate this):
9
+ * view: "viewname" + relation: ".sourcetable.segment1.segment2..."
10
+ *
11
+ * Segment types:
12
+ * Outbound FK (to parent): FK field name alone, e.g. trip_id
13
+ * Inbound FK (child rows): childtable$fkfield, e.g. packing_items$trip_id
14
+ *
15
+ * Examples:
16
+ * .trips.packing_items$trip_id ChildList: all packing_items for a trip
17
+ * .packing_items.trip_id ParentShow: the trip that owns a packing item
18
+ * .artists.artist_plays_on_album$artist.album ChildList through a join table
19
+ * .users.orders$user_id.order_lines$order_id RelationPath: multi-level
20
+ *
21
+ * Legacy format (may appear in existing configs — do not generate, understand only):
22
+ * The type and path are encoded together in the view field, no separate relation field.
23
+ * "Own:viewname" → same table, no relation
24
+ * "ParentShow:viewname.table.fkfield" → outbound FK to parent
25
+ * "ChildList:viewname.table.inbkey" → inbound FK, one-to-many
26
+ * "OneToOneShow:viewname.table.inbkey" → inbound FK, unique
27
+ * "Independent:viewname" → no FK relationship
28
+ *
29
+ * Relation types by new-format path structure:
30
+ * Own – zero segments, source and target are the same table
31
+ * ParentShow – single outbound-FK segment
32
+ * OneToOneShow – single inbound-FK segment on a unique field
33
+ * ChildList – one or more inbound-FK segments (may mix outbound for join tables)
34
+ * RelationPath – complex multi-level path mixing both segment types
35
+ */
36
+ const RELATION_PATH_DOC = `
37
+ ## Relation paths
38
+
39
+ Relation paths connect a view_link column or embedded view (type "view") segment to its
40
+ target view. There are two formats — **new** (preferred) and **legacy** (read-only).
41
+
42
+ ---
43
+
44
+ ### New format (always use this when generating or updating)
45
+
46
+ Two separate fields:
47
+ - \`view\`: just the view name, e.g. \`"packing_items_list"\`
48
+ - \`relation\`: a dot-separated path string, e.g. \`".trips.packing_items$trip_id"\`
49
+
50
+ **Path string format:** \`.sourcetable.segment1.segment2...\`
51
+
52
+ Segment types:
53
+ - **Outbound FK** (navigate to a parent): FK field name alone — e.g. \`trip_id\`
54
+ - **Inbound FK** (collect child rows): \`childtable$fkfield\` — e.g. \`packing_items$trip_id\`
55
+
56
+ Examples:
57
+ | relation string | type | meaning |
58
+ |---|---|---|
59
+ | \`.trips.packing_items$trip_id\` | ChildList | all packing_items for a trip |
60
+ | \`.packing_items.trip_id\` | ParentShow | the trip that owns a packing_item |
61
+ | \`.artists.artist_plays_on_album$artist.album\` | ChildList | albums via join table |
62
+ | \`.users.orders$user_id.order_lines$order_id\` | RelationPath | multi-level |
63
+
64
+ ---
65
+
66
+ ### Legacy format (you may encounter this in existing configs — do not generate it)
67
+
68
+ The type and path are encoded together inside the \`view\` field as a colon-prefixed string.
69
+ There is no separate \`relation\` field.
70
+
71
+ | legacy view field | equivalent new relation |
72
+ |---|---|
73
+ | \`"Own:viewname"\` | \`view: "viewname"\`, no relation |
74
+ | \`"ParentShow:viewname.table.fkfield"\` | \`relation: ".sourcetable.fkfield"\` |
75
+ | \`"ChildList:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
76
+ | \`"OneToOneShow:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
77
+ | \`"Independent:viewname"\` | \`view: "viewname"\`, no relation |
78
+
79
+ When you read an existing view config and see a legacy \`view\` value like \`"ChildList:trips_list.packing_items.trip_id"\`, parse it as: type=ChildList, view=trips_list, relation=.sourcetable.packing_items$trip_id. When writing back, convert to the new format.
80
+
81
+ ---
82
+
83
+ ### Using get_relation_paths
84
+
85
+ Call it **once** with all source_table/target_view pairs you need — do not make separate calls per pair. The tool returns paths in the new RelationPath format; always write back in the new format, even if you read legacy strings from an existing config.
86
+
87
+ When multiple paths are returned for one pair, pick by matching the relation type to what the target view is meant to show:
88
+ - **ChildList** — target view shows multiple rows belonging to the current row (e.g. packing items for a trip).
89
+ - **ParentShow** — target view shows the single parent the current row belongs to (e.g. the trip for a packing item).
90
+ - **OneToOneShow** — exactly one related child row via a unique FK.
91
+ - **Own** — target view is on the same table (no FK traversal needed).
92
+ - If multiple paths of the same type exist (e.g. a table has two FKs pointing to the same target), pick the one whose FK field name best matches the semantic relationship in the task.
93
+ - Prefer shorter paths (fewer segments) unless a longer one is clearly more appropriate.
94
+ `;
95
+
96
+ const typeToLabel = (type) => {
97
+ if (type === RelationType.OWN)
98
+ return "Own – source and target are the same table (no relation needed)";
99
+ if (type === RelationType.INDEPENDENT)
100
+ return "Independent – no FK relationship exists";
101
+ if (type === RelationType.PARENT_SHOW)
102
+ return "ParentShow – outbound FK to a parent record (many-to-one)";
103
+ if (type === RelationType.ONE_TO_ONE_SHOW)
104
+ return "OneToOneShow – unique inbound FK (one-to-one)";
105
+ if (type === RelationType.CHILD_LIST)
106
+ return "ChildList – inbound FK, one parent → many child rows";
107
+ return "RelationPath – complex multi-level path";
108
+ };
109
+
110
+ /**
111
+ * @param {string} sourceTableName
112
+ * @param {string} targetViewName
113
+ * @param {{ tables, views }} schemaData pre-fetched via build_schema_data()
114
+ * @returns {Array<Relation>} raw Relation objects from RelationsFinder
115
+ */
116
+ function getRelationPaths(sourceTableName, targetViewName, schemaData) {
117
+ if (!schemaData) return [];
118
+ try {
119
+ const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
120
+ return finder.findRelations(sourceTableName, targetViewName, []);
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolve multiple source_table/target_view pairs against pre-fetched schema data.
128
+ * All per-pair work is synchronous — call build_schema_data() once before invoking this.
129
+ * @param {Array<{source_table: string, target_view: string}>} pairs
130
+ * @param {{ tables, views }} schemaData
131
+ * @returns {Array<string>} one formatted result string per pair
132
+ */
133
+ function getRelationPathsForPairs(pairs, schemaData) {
134
+ if (!schemaData) return pairs.map(({ source_table, target_view }) =>
135
+ formatRelationPathResult(source_table, target_view, { error: "Schema data unavailable" })
136
+ );
137
+ const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
138
+ return pairs.map(({ source_table, target_view }) => {
139
+ const targetView = (schemaData.views || []).find((v) => v.name === target_view);
140
+ if (!targetView)
141
+ return formatRelationPathResult(source_table, target_view, {
142
+ error: `View "${target_view}" not found in current schema`,
143
+ });
144
+ let relations;
145
+ try {
146
+ relations = finder.findRelations(source_table, target_view, []);
147
+ } catch (e) {
148
+ return formatRelationPathResult(source_table, target_view, {
149
+ error: `Failed to find relations: ${e.message}`,
150
+ });
151
+ }
152
+ return formatRelationPathResult(source_table, target_view, {
153
+ paths: relations.map((r) => ({
154
+ relation_string: r.relationString,
155
+ type: String(r.type),
156
+ label: typeToLabel(r.type),
157
+ })),
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Pick the most useful relation from a list: Own > Parent > Child > first.
164
+ * Used as a fallback in builder-gen when the model doesn't specify a relation.
165
+ */
166
+ function pickBestRelation(relations) {
167
+ if (!relations.length) return null;
168
+ let own = null,
169
+ parent = null,
170
+ child = null;
171
+ for (const r of relations) {
172
+ if (r.type === RelationType.OWN) own = r;
173
+ else if (r.type === RelationType.PARENT_SHOW) parent = r;
174
+ else if (
175
+ r.type === RelationType.CHILD_LIST ||
176
+ r.type === RelationType.ONE_TO_ONE_SHOW
177
+ )
178
+ child = r;
179
+ }
180
+ return own || parent || child || relations[0];
181
+ }
182
+
183
+ /**
184
+ * Format the result of getRelationPaths into a human-readable string for the model.
185
+ * Handles both found and not-found cases for one source_table/target_view pair.
186
+ */
187
+ function formatRelationPathResult(source_table, target_view, result) {
188
+ if (result.error) return `${source_table} → ${target_view}: ${result.error}`;
189
+ if (!result.paths.length)
190
+ return `${source_table} → ${target_view}: no relation paths found (no FK relationship)`;
191
+ const lines = result.paths
192
+ .map((p) => ` "${p.relation_string}" — ${p.label}`)
193
+ .join("\n");
194
+ return `${source_table} → ${target_view}:\n${lines}`;
195
+ }
196
+
197
+ const GET_RELATION_PATHS_FUNCTION = {
198
+ name: "get_relation_paths",
199
+ description:
200
+ "Get all valid relation path strings for one or more source_table/target_view pairs. " +
201
+ "Call this ONCE with all pairs you need before setting any 'relation' property on " +
202
+ "view_link columns or embedded view (type 'view') segments.",
203
+ parameters: {
204
+ type: "object",
205
+ required: ["pairs"],
206
+ properties: {
207
+ pairs: {
208
+ type: "array",
209
+ description:
210
+ "All source_table/target_view pairs you need relation paths for. Include every pair in one call.",
211
+ items: {
212
+ type: "object",
213
+ required: ["source_table", "target_view"],
214
+ properties: {
215
+ source_table: {
216
+ type: "string",
217
+ description: "The table of the view being built or updated.",
218
+ },
219
+ target_view: {
220
+ type: "string",
221
+ description: "The view to link to or embed.",
222
+ },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ },
228
+ };
229
+
230
+ module.exports = {
231
+ RELATION_PATH_DOC,
232
+ GET_RELATION_PATHS_FUNCTION,
233
+ getRelationPaths,
234
+ getRelationPathsForPairs,
235
+ pickBestRelation,
236
+ };
@@ -1,25 +0,0 @@
1
- const user_registration_hints = `User registration notes:
2
- * The "users" table is built-in. Passwords are platform-managed — never add a password field to a view.
3
- * Signup uses a built-in form, not an Edit view.
4
- * For verification/confirmation emails after registration, create a Trigger with event "Insert" on "users".
5
- The trigger must use a Workflow action with a single step of type "run_js_code". Do NOT use the send_email action — it will not work for verification. The run_js_code step must contain exactly:
6
- const { send_verification_email } = require("@saltcorn/data/models/email");
7
- await send_verification_email(row, req);`;
8
-
9
- class AppConstructorContextSkill {
10
- static skill_name = "AppConstructor Context";
11
-
12
- get skill_label() {
13
- return "AppConstructor Context";
14
- }
15
-
16
- constructor(cfg) {
17
- Object.assign(this, cfg);
18
- }
19
-
20
- async systemPrompt() {
21
- return user_registration_hints;
22
- }
23
- }
24
-
25
- module.exports = AppConstructorContextSkill;