'
+ ).join('');
+ new bootstrap.Modal(document.getElementById('specDepsModal')).show();
+ });
+});
+
+document.getElementById('specDepsSaveBtn').addEventListener('click', () => {
+ const modal = bootstrap.Modal.getInstance(document.getElementById('specDepsModal'));
+ const clearData = {};
+ document.querySelectorAll('#spec-deps-checks input:checked').forEach((cb) => {
+ clearData[cb.value] = '1';
+ });
+ modal.hide();
+ if (Object.keys(clearData).length) {
+ const formData = {};
+ for (const [k, v] of new FormData(form)) formData[k] = v;
+ view_post(_specVn, 'clear_and_save_spec', Object.assign(formData, clearData));
+ } else {
+ doSaveSpec();
+ }
+});
+
+document.getElementById('specDepsKeepBtn').addEventListener('click', () => {
+ bootstrap.Modal.getInstance(document.getElementById('specDepsModal')).hide();
+ doSaveSpec();
+});
+`)
+ );
+
const run = async (table_id, viewname, cfg, state, { req, res }) => {
const specForm = await makeSpecForm(req);
const research = await researchPanel(req);
@@ -102,7 +184,12 @@ const run = async (table_id, viewname, cfg, state, { req, res }) => {
contents: [
{
type: "blank",
- contents: div({ class: "mt-2" }, renderForm(specForm, req.csrfToken())),
+ contents: div(
+ { class: "mt-2" },
+ renderForm(specForm, req.csrfToken()),
+ specDepsModal,
+ specDepsScript(viewname)
+ ),
},
{ type: "blank", contents: research },
{ type: "blank", contents: reqList },
@@ -125,6 +212,102 @@ const run = async (table_id, viewname, cfg, state, { req, res }) => {
});
};
+const check_spec_dependencies = async (
+ table_id,
+ viewname,
+ config,
+ body,
+ { req, res }
+) => {
+ const hasResearch =
+ !!(await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "research_questions",
+ })) ||
+ !!(await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "research_answers",
+ }));
+ const hasRequirements =
+ (
+ await MetaData.find({
+ type: "CopilotConstructMgr",
+ name: "requirement",
+ })
+ ).length > 0;
+ const hasSchema = !!(await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "schema",
+ }));
+ const hasTasks =
+ (
+ await MetaData.find({
+ type: "CopilotConstructMgr",
+ name: "task",
+ })
+ ).length > 0;
+ return { json: { hasResearch, hasRequirements, hasSchema, hasTasks } };
+};
+
+// Clears selected dependencies, saves the spec, and reloads — all in one round trip
+const clear_and_save_spec = async (
+ table_id,
+ viewname,
+ config,
+ body,
+ { req, res }
+) => {
+ const {
+ _csrf,
+ clearResearch,
+ clearRequirements,
+ clearSchema,
+ clearTasks,
+ ...specBody
+ } = body;
+ if (clearResearch) {
+ for (const name of ["research_questions", "research_answers"]) {
+ const md = await MetaData.findOne({ type: "CopilotConstructMgr", name });
+ if (md) await md.delete();
+ }
+ }
+ if (clearRequirements) {
+ const rs = await MetaData.find({
+ type: "CopilotConstructMgr",
+ name: "requirement",
+ });
+ for (const r of rs) await r.delete();
+ }
+ if (clearSchema) {
+ const md = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "schema",
+ });
+ if (md) await md.delete();
+ }
+ if (clearTasks) {
+ const ts = await MetaData.find({
+ type: "CopilotConstructMgr",
+ name: "task",
+ });
+ for (const t of ts) await t.delete();
+ }
+ const existing = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "spec",
+ });
+ if (existing)
+ await db.update("_sc_metadata", { body: specBody }, existing.id);
+ else
+ await MetaData.create({
+ type: "CopilotConstructMgr",
+ name: "spec",
+ user_id: req.user?.id || undefined,
+ body: specBody,
+ });
+ return { json: { reload_page: true } };
+};
+
const submit_specs = async (table_id, viewname, config, body, { req, res }) => {
const { _csrf, ...spec } = body;
const existing = await MetaData.findOne({
@@ -186,6 +369,8 @@ module.exports = {
run,
routes: {
submit_specs,
+ check_spec_dependencies,
+ clear_and_save_spec,
...req_routes,
...research_routes,
...task_routes,
From 20b8a45321e9fb8efceb4e2c4039abd3d40cdf98 Mon Sep 17 00:00:00 2001
From: Christian Hugo
Date: Sat, 16 May 2026 14:53:34 +0200
Subject: [PATCH 2/6] more prompt ctx in feedback-action, Q+A for user feedback
---
app-constructor/feedback-action.js | 87 ++++-
app-constructor/feedback.js | 534 +++++++++++++++++++++++++----
app-constructor/prompts.js | 87 +++++
app-constructor/requirements.js | 3 +-
app-constructor/research.js | 230 ++++++++++++-
app-constructor/tasks.js | 110 ++----
6 files changed, 876 insertions(+), 175 deletions(-)
diff --git a/app-constructor/feedback-action.js b/app-constructor/feedback-action.js
index fcfba59..d512a24 100644
--- a/app-constructor/feedback-action.js
+++ b/app-constructor/feedback-action.js
@@ -1,9 +1,24 @@
-const { eval_expression } = require("@saltcorn/data/models/expression");
const MetaData = require("@saltcorn/data/models/metadata");
+const Table = require("@saltcorn/data/models/table");
+const View = require("@saltcorn/data/models/view");
+const Trigger = require("@saltcorn/data/models/trigger");
+const Page = require("@saltcorn/data/models/page");
+const Plugin = require("@saltcorn/data/models/plugin");
const { interpolate } = require("@saltcorn/data/utils");
const { getState } = require("@saltcorn/data/db/state");
const { requirements_tool, task_tool } = require("./tools");
const { tool_choice } = require("./common");
+const { getResearchAnswersText } = require("./research");
+const {
+ saltcorn_description,
+ existing_tables_list,
+ existing_entities_list,
+ installed_plugins_list,
+ available_plugins_list,
+ task_planning_rules,
+ task_planning_closing,
+ research_answers_section,
+} = require("./prompts");
module.exports = {
description: "Provide user feedback to the AppConstructor",
@@ -71,6 +86,7 @@ module.exports = {
title_field,
description_field,
url_field,
+ research_context,
},
}) => {
const use_title =
@@ -92,17 +108,25 @@ module.exports = {
name: "spec",
});
if (!spec) return;
+
+ const researchText = await getResearchAnswersText();
+ const feedbackResearchSection = research_context
+ ? `\nThe user also answered clarifying questions about this feedback:\n\n${research_context}\n`
+ : "";
+
const reqAnswer = await getState().functions.llm_generate.run(
`The following application is being built:
${spec.body.specification}
-
+${research_answers_section(researchText)}
A new piece of feedback has come in from a user:
Title: ${use_title}
Description: ${use_description}
+${feedbackResearchSection}
+Now use the make_requirements tool to create a single or several (a single is preferred) new requirements that captures this new piece of feedback.
-Now use the make_requirements tool to create a single or several (a single is prefered) new requirements that captures this new piece of feedback.
+* Priority reflects how central the feature is to the core purpose of the application. Assign 5 to features without which the application cannot function at all, 3-4 to features that are important but not blocking, 1-2 to minor convenience features. Do not assign 5 to everything.
`,
{
tools: [requirements_tool],
@@ -114,6 +138,11 @@ Now use the make_requirements tool to create a single or several (a single is pr
const tc = reqAnswer.getToolCalls()[0];
console.log("gotr new requiremenrts", tc.input.requirements);
+ const allReqs = await MetaData.find({
+ type: "CopilotConstructMgr",
+ name: "requirement",
+ });
+
for (const reqm of tc.input.requirements)
await MetaData.create({
type: "CopilotConstructMgr",
@@ -122,29 +151,59 @@ Now use the make_requirements tool to create a single or several (a single is pr
user_id: req.user?.id,
});
+ const tables = await Table.find({});
+ const tableById = Object.fromEntries(tables.map((t) => [t.id, t.name]));
+ const views = await View.find({});
+ const triggers = await Trigger.find({});
+ const pages = await Page.find({});
+ const entitiesSection = existing_entities_list({
+ views,
+ triggers,
+ pages,
+ tableById,
+ });
+ const installedPlugins = await Plugin.find({});
+ const installedNames = new Set(installedPlugins.map((p) => p.name));
+ let storePlugins = [];
+ try {
+ storePlugins = await Plugin.store_plugins_available();
+ } catch (_) {}
+ const installedPluginsSection = installed_plugins_list(installedNames);
+ const pluginsSection = available_plugins_list(storePlugins, installedNames);
+
const taskAnswer = await getState().functions.llm_generate.run(
- `The following application is being built:
+ `Generate implementation tasks for a new piece of feedback for this application:
${spec.body.specification}
-
-This application will be implemented in Saltcorn, a database application development
-environment.
-
+${research_answers_section(researchText)}
A new piece of feedback has come in from a user:
Title: ${use_title}
Description: ${use_description}
+${feedbackResearchSection}
+The existing application requirements are:
-A product manager has determined that the following requirements should be added to the list of application requirements:
+${allReqs.map((r) => `* ${r.body.requirement}`).join("\n")}
+
+A product manager has determined that the following new requirements should be added to implement this feedback:
${tc.input.requirements.map((r) => " * " + r.requirement).join("\n")}
-Your plan for implementing this new fedback and requirements should not include any clarification or questions to the product owner. The
-information you have been given so far is all that is available. Every step in the plan
-should be immediately implementable in Saltcorn. You are writing the steps in the plan
-for a person who is competent in using saltcorn but has no other business knowledge.
+${saltcorn_description}
+
+The database has already been built. The following tables are now present in the database:
+
+${existing_tables_list(tables)}
+
+The plan should outline continued development of the application on top of this database.
+Your plan can add additional tables if needed or adjust the table fields, but normally the tables
+should be designed optimally for this application.
+
+${entitiesSection ? entitiesSection + "\n\n" : ""}${installedPluginsSection ? installedPluginsSection + "\n\n" : ""}${pluginsSection ? pluginsSection + "\n\n" : ""}${task_planning_rules}
+
+${task_planning_closing}
-Now use the plan_tasks tool to create the tasks to implement this new feedback
+Now use the plan_tasks tool to create the tasks to implement this new feedback.
`,
{
tools: [task_tool],
diff --git a/app-constructor/feedback.js b/app-constructor/feedback.js
index 2c3f27d..6561b51 100644
--- a/app-constructor/feedback.js
+++ b/app-constructor/feedback.js
@@ -1,7 +1,9 @@
+const feedbackAction = require("./feedback-action.js");
const Field = require("@saltcorn/data/models/field");
const Table = require("@saltcorn/data/models/table");
const MetaData = require("@saltcorn/data/models/metadata");
const View = require("@saltcorn/data/models/view");
+const Page = require("@saltcorn/data/models/page");
const { mkTable } = require("@saltcorn/markup");
const {
div,
@@ -12,15 +14,250 @@ const {
i,
p,
a,
+ input,
+ label,
+ textarea,
+ small,
} = require("@saltcorn/markup/tags");
-const { viewname } = require("./common");
+const { getState } = require("@saltcorn/data/db/state");
+const { viewname, tool_choice } = require("./common");
+const { questions_tool } = require("./research");
const FEEDBACK_TABLE = "app_constructor_feedback";
+const feedbackFormHtml = () =>
+ div(
+ { id: "feedback-form-area", class: "mt-3 border p-3 rounded", style: "display:none" },
+ div(
+ { id: "feedback-form-step1" },
+ p({ class: "fw-semibold mb-3" }, "Add feedback"),
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label", for: "fb-title" }, "Title"),
+ input({ type: "text", class: "form-control", id: "fb-title" })
+ ),
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label", for: "fb-desc" }, "Description"),
+ textarea({ class: "form-control", id: "fb-desc", rows: 3 }, "")
+ ),
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label", for: "fb-url" }, "URL"),
+ input({ type: "text", class: "form-control", id: "fb-url" })
+ ),
+ div(
+ { id: "fb-step1-spinner", class: "my-2 text-muted", style: "display:none" },
+ i({ class: "fas fa-spinner fa-spin me-2" }),
+ "Generating questions..."
+ ),
+ div(
+ { class: "mt-2" },
+ button(
+ {
+ type: "button",
+ class: "btn btn-primary me-2",
+ id: "fb-gen-btn",
+ onclick: "copilotGenQuestionsForForm()",
+ },
+ "Generate questions"
+ ),
+ button(
+ {
+ type: "button",
+ class: "btn btn-outline-secondary",
+ onclick: "copilotCancelFeedbackForm()",
+ },
+ "Cancel"
+ )
+ )
+ ),
+ div(
+ { id: "feedback-form-step2", style: "display:none" },
+ p({ class: "fw-semibold mb-2" }, "Answer questions"),
+ small(
+ { class: "text-muted d-block mb-3" },
+ "Answer these to help understand your feedback better. You can skip any."
+ ),
+ div({ id: "fb-questions-area" }),
+ div(
+ { class: "mt-3" },
+ button(
+ {
+ type: "button",
+ class: "btn btn-primary me-2",
+ onclick: "copilotSubmitFeedbackForm()",
+ },
+ "Submit feedback"
+ ),
+ button(
+ {
+ type: "button",
+ class: "btn btn-outline-secondary",
+ onclick: "copilotBackToStep1()",
+ },
+ "Back"
+ )
+ )
+ )
+ );
+
+const feedbackStandalonePageContent = () => {
+ const safeViewNameJson = JSON.stringify(viewname);
+
+ const formHtml =
+ div(
+ { id: "fbs-step1" },
+ h5({ class: "mb-3" }, "Submit feedback"),
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label", for: "fbs-title" }, "Title"),
+ input({ type: "text", class: "form-control", id: "fbs-title" })
+ ),
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label", for: "fbs-desc" }, "Description"),
+ textarea({ class: "form-control", id: "fbs-desc", rows: 3 }, "")
+ ),
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label", for: "fbs-url" }, "URL"),
+ input({ type: "text", class: "form-control", id: "fbs-url" })
+ ),
+ div(
+ { id: "fbs-spinner", class: "my-2 text-muted", style: "display:none" },
+ i({ class: "fas fa-spinner fa-spin me-2" }),
+ "Generating questions..."
+ ),
+ div(
+ { class: "mt-2" },
+ button(
+ {
+ type: "button",
+ class: "btn btn-primary",
+ id: "fbs-gen-btn",
+ onclick: "fbsGenQuestions()",
+ },
+ "Generate questions"
+ )
+ )
+ ) +
+ div(
+ { id: "fbs-step2", style: "display:none" },
+ p({ class: "fw-semibold mb-2" }, "Answer questions"),
+ small(
+ { class: "text-muted d-block mb-3" },
+ "Answer these to help understand your feedback. You can skip any."
+ ),
+ div({ id: "fbs-questions-area" }),
+ div(
+ { class: "mt-3" },
+ button(
+ {
+ type: "button",
+ class: "btn btn-primary me-2",
+ onclick: "fbsSubmit()",
+ },
+ "Submit feedback"
+ ),
+ button(
+ {
+ type: "button",
+ class: "btn btn-outline-secondary",
+ onclick: "fbsBackToStep1()",
+ },
+ "Back"
+ )
+ )
+ ) +
+ div(
+ { id: "fbs-success", class: "text-success mt-3", style: "display:none" },
+ i({ class: "fas fa-check-circle me-2" }),
+ "Thank you! Your feedback has been submitted."
+ );
+
+ const clientScript = script(
+ domReady(`
+const safeViewName = ${safeViewNameJson};
+let _fbsQuestions = [];
+window.fbsGenQuestions = () => {
+ const title = document.getElementById('fbs-title').value.trim();
+ if (!title) {
+ document.getElementById('fbs-title').classList.add('is-invalid');
+ return;
+ }
+ document.getElementById('fbs-title').classList.remove('is-invalid');
+ const description = document.getElementById('fbs-desc').value.trim();
+ document.getElementById('fbs-spinner').style.display = '';
+ const btn = document.getElementById('fbs-gen-btn');
+ if (btn) btn.disabled = true;
+ view_post(safeViewName, 'gen_questions_for_form', { title, description }, (resp) => {
+ document.getElementById('fbs-spinner').style.display = 'none';
+ if (btn) btn.disabled = false;
+ if (!resp || resp.error) return;
+ const questions = resp.questions || [];
+ _fbsQuestions = questions;
+ if (questions.length === 0) { fbsSubmit(); return; }
+ const area = document.getElementById('fbs-questions-area');
+ area.innerHTML = '';
+ for (const [idx, q] of questions.entries()) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'mb-3';
+ const lbl = document.createElement('label');
+ lbl.className = 'form-label';
+ lbl.textContent = q;
+ lbl.htmlFor = 'fbs-answer-' + idx;
+ const ta = document.createElement('textarea');
+ ta.className = 'form-control';
+ ta.id = 'fbs-answer-' + idx;
+ ta.rows = 2;
+ wrapper.appendChild(lbl);
+ wrapper.appendChild(ta);
+ area.appendChild(wrapper);
+ }
+ document.getElementById('fbs-step1').style.display = 'none';
+ document.getElementById('fbs-step2').style.display = '';
+ });
+};
+window.fbsBackToStep1 = () => {
+ document.getElementById('fbs-step1').style.display = '';
+ document.getElementById('fbs-step2').style.display = 'none';
+};
+window.fbsSubmit = () => {
+ const title = document.getElementById('fbs-title').value.trim();
+ const description = document.getElementById('fbs-desc').value.trim();
+ const url = document.getElementById('fbs-url').value.trim();
+ const payload = { title, description, url };
+ for (const [idx, q] of _fbsQuestions.entries()) {
+ payload['question_' + (idx + 1)] = q;
+ const ta = document.getElementById('fbs-answer-' + idx);
+ payload['a' + (idx + 1)] = ta ? ta.value : '';
+ }
+ view_post(safeViewName, 'submit_feedback_with_answers', payload, (resp) => {
+ if (resp && !resp.error) {
+ document.getElementById('fbs-step1').style.display = 'none';
+ document.getElementById('fbs-step2').style.display = 'none';
+ document.getElementById('fbs-success').style.display = '';
+ }
+ });
+};
+`)
+ );
+
+ return div(
+ { class: "row" },
+ div({ class: "col-md-8" }, formHtml + clientScript)
+ );
+};
+
// Pure HTML for both pending and processed sections — no scripts, safe for innerHTML injection
const feedbackViewsContent = async () => {
- const _vn = JSON.stringify(viewname);
+ const safeViewName = JSON.stringify(viewname);
const table = Table.findOne({ name: FEEDBACK_TABLE });
+ const genFeedbackResearch = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "generating_feedback_research",
+ });
let pendingSection = "";
if (table) {
@@ -30,7 +267,7 @@ const feedbackViewsContent = async () => {
{
class: "btn btn-outline-primary btn-sm mt-1",
title: "Submit feedback",
- onclick: "copilotOpenFeedbackForm();return false;",
+ onclick: "copilotShowFeedbackForm();return false;",
},
i({ class: "fas fa-plus me-1" }),
"Add feedback"
@@ -81,6 +318,13 @@ const feedbackViewsContent = async () => {
pendingSection =
h5({ class: "mb-2" }, "Pending feedback") + tableHtml + addButton;
}
+ if (genFeedbackResearch) {
+ pendingSection += p(
+ { class: "text-muted small mt-2" },
+ i({ class: "fas fa-spinner fa-spin me-2" }),
+ "Generating feedback questions..."
+ );
+ }
}
const processed = await MetaData.find(
@@ -102,7 +346,7 @@ const feedbackViewsContent = async () => {
button(
{
class: "btn btn-outline-danger btn-sm",
- onclick: `view_post(${_vn}, "del_feedback", {id:${r.id}}, refreshFeedbackViews)`,
+ onclick: `view_post(${safeViewName}, "del_feedback", {id:${r.id}}, refreshFeedbackViews)`,
},
i({ class: "fas fa-trash-alt" })
),
@@ -113,7 +357,7 @@ const feedbackViewsContent = async () => {
button(
{
class: "btn btn-outline-danger btn-sm",
- onclick: `view_post(${_vn}, "del_all_feedback", {}, refreshFeedbackViews)`,
+ onclick: `view_post(${safeViewName}, "del_all_feedback", {}, refreshFeedbackViews)`,
},
"Delete all"
)
@@ -124,9 +368,9 @@ const feedbackViewsContent = async () => {
return pendingSection + processedSection;
};
-const feedbackList = async (req, res) => {
+const feedbackList = async () => {
const table = Table.findOne({ name: FEEDBACK_TABLE });
- const _vn = JSON.stringify(viewname);
+ const safeViewName = JSON.stringify(viewname);
let topSection;
if (table) {
@@ -147,16 +391,16 @@ const feedbackList = async (req, res) => {
const clientScript = script(
domReady(`
-const _vn = ${_vn};
+const safeViewName = ${safeViewName};
window.refreshFeedbackViews = () => {
- view_post(_vn, 'feedback_views_html', {}, (r) => {
+ view_post(safeViewName, 'feedback_views_html', {}, (r) => {
if (r && r.html) document.getElementById('feedback-views-area').innerHTML = r.html;
});
};
window.copilotSetupFeedback = () => {
const area = document.getElementById('feedback-views-area');
area.innerHTML = '
Setting up...
';
- view_post(_vn, 'setup_feedback_system', {}, (resp) => {
+ view_post(safeViewName, 'setup_feedback_system', {}, (resp) => {
if (resp && !resp.error) refreshFeedbackViews();
else area.innerHTML = '';
@@ -165,9 +409,9 @@ window.copilotSetupFeedback = () => {
window.copilotApprove = (id) => {
const btn = document.getElementById('approve-btn-' + id);
if (btn) { btn.disabled = true; btn.innerHTML = ''; }
- view_post(_vn, 'start_approve_feedback', { id }, () => {
+ view_post(safeViewName, 'start_approve_feedback', { id }, () => {
const poll = () => {
- view_post(_vn, 'approval_status', { id }, (resp) => {
+ view_post(safeViewName, 'approval_status', { id }, (resp) => {
if (resp && !resp.approving) refreshFeedbackViews();
else setTimeout(poll, 3000);
});
@@ -176,15 +420,11 @@ window.copilotApprove = (id) => {
});
};
window.copilotDeleteFeedback = (id) => {
- view_post(_vn, 'delete_feedback_row', { id }, (resp) => {
+ view_post(safeViewName, 'delete_feedback_row', { id }, (resp) => {
if (resp && !resp.error) refreshFeedbackViews();
});
};
let _feedbackModalPending = false;
-window.copilotOpenFeedbackForm = () => {
- _feedbackModalPending = true;
- ajax_modal('/view/app_constructor_feedback_form');
-};
window.copilotOpenFeedbackEdit = (id) => {
_feedbackModalPending = true;
ajax_modal('/view/app_constructor_feedback_edit?id=' + id);
@@ -195,10 +435,91 @@ document.addEventListener('hidden.bs.modal', () => {
refreshFeedbackViews();
}
});
+window.copilotShowFeedbackForm = () => {
+ const area = document.getElementById('feedback-form-area');
+ const step1 = document.getElementById('feedback-form-step1');
+ const step2 = document.getElementById('feedback-form-step2');
+ area.style.display = '';
+ step1.style.display = '';
+ step2.style.display = 'none';
+ document.getElementById('fb-title').value = '';
+ document.getElementById('fb-desc').value = '';
+ document.getElementById('fb-url').value = '';
+ document.getElementById('fb-step1-spinner').style.display = 'none';
+ const genBtn = document.getElementById('fb-gen-btn');
+ if (genBtn) genBtn.disabled = false;
+};
+window.copilotCancelFeedbackForm = () => {
+ document.getElementById('feedback-form-area').style.display = 'none';
+};
+window.copilotGenQuestionsForForm = () => {
+ const title = document.getElementById('fb-title').value.trim();
+ if (!title) {
+ document.getElementById('fb-title').classList.add('is-invalid');
+ return;
+ }
+ document.getElementById('fb-title').classList.remove('is-invalid');
+ const description = document.getElementById('fb-desc').value.trim();
+ document.getElementById('fb-step1-spinner').style.display = '';
+ const genBtn = document.getElementById('fb-gen-btn');
+ if (genBtn) genBtn.disabled = true;
+ view_post(safeViewName, 'gen_questions_for_form', { title, description }, (resp) => {
+ document.getElementById('fb-step1-spinner').style.display = 'none';
+ if (genBtn) genBtn.disabled = false;
+ if (!resp || resp.error) return;
+ const questions = resp.questions || [];
+ window._fbFormQuestions = questions;
+ if (questions.length === 0) {
+ copilotSubmitFeedbackForm();
+ return;
+ }
+ const qArea = document.getElementById('fb-questions-area');
+ qArea.innerHTML = '';
+ for (const [idx, q] of questions.entries()) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'mb-3';
+ const lbl = document.createElement('label');
+ lbl.className = 'form-label';
+ lbl.textContent = q;
+ lbl.htmlFor = 'fb-answer-' + idx;
+ const ta = document.createElement('textarea');
+ ta.className = 'form-control';
+ ta.id = 'fb-answer-' + idx;
+ ta.rows = 2;
+ wrapper.appendChild(lbl);
+ wrapper.appendChild(ta);
+ qArea.appendChild(wrapper);
+ }
+ document.getElementById('feedback-form-step1').style.display = 'none';
+ document.getElementById('feedback-form-step2').style.display = '';
+ });
+};
+window.copilotBackToStep1 = () => {
+ document.getElementById('feedback-form-step1').style.display = '';
+ document.getElementById('feedback-form-step2').style.display = 'none';
+};
+window.copilotSubmitFeedbackForm = () => {
+ const title = document.getElementById('fb-title').value.trim();
+ const description = document.getElementById('fb-desc').value.trim();
+ const url = document.getElementById('fb-url').value.trim();
+ const questions = window._fbFormQuestions || [];
+ const payload = { title, description, url };
+ for (const [idx, q] of questions.entries()) {
+ payload['question_' + (idx + 1)] = q;
+ const ta = document.getElementById('fb-answer-' + idx);
+ payload['a' + (idx + 1)] = ta ? ta.value : '';
+ }
+ view_post(safeViewName, 'submit_feedback_with_answers', payload, (resp) => {
+ if (resp && !resp.error) {
+ document.getElementById('feedback-form-area').style.display = 'none';
+ refreshFeedbackViews();
+ }
+ });
+};
`)
);
- return div({ class: "mt-2" }, topSection, clientScript);
+ return div({ class: "mt-2" }, topSection, table ? feedbackFormHtml() : "", clientScript);
};
// AJAX route — returns the views content HTML for in-place refresh
@@ -233,7 +554,22 @@ const start_approve_feedback = async (
body: { id },
});
- const feedbackAction = require("./feedback-action.js");
+ const researchMd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ let research_context = null;
+ if (researchMd) {
+ const questions = researchMd.body.questions || [];
+ const answers = researchMd.body.answers || {};
+ const pairs = questions
+ .map((q, idx) => {
+ const a = answers[`q${idx + 1}`];
+ return `Q: ${q}\nA: ${a && a.trim() ? a.trim() : "(no answer)"}`;
+ });
+ if (pairs.length) research_context = pairs.join("\n\n");
+ }
+
feedbackAction
.run({
row,
@@ -245,6 +581,7 @@ const start_approve_feedback = async (
title_field: "title",
description_field: "description",
url_field: "url",
+ research_context,
},
})
.then(async () => {
@@ -254,6 +591,11 @@ const start_approve_feedback = async (
});
if (md) await md.delete();
await table.deleteRows({ id });
+ const rmd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ if (rmd) await rmd.delete();
})
.catch(async (e) => {
console.error("approve_feedback error", e);
@@ -286,6 +628,11 @@ const delete_feedback_row = async (
const id = parseInt(body.id);
const table = Table.findOne({ name: FEEDBACK_TABLE });
await table.deleteRows({ id });
+ const rmd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ if (rmd) await rmd.delete();
return { json: { success: true } };
};
@@ -389,69 +736,22 @@ const setup_feedback_system = async (
],
});
- // User-facing feedback submission form
- await View.create({
+ // User-facing feedback submission page (two-step: generate questions then save)
+ await Page.create({
name: "app_constructor_feedback_form",
- viewtemplate: "Edit",
- table_id: table.id,
+ title: "Submit feedback",
+ description: "",
min_role: 80,
- configuration: {
- layout: {
- above: [
- labelFieldRow("Title", "title"),
- labelFieldRow("Description", "description", "textarea"),
- labelFieldRow("URL", "url"),
- saveButtonRow("Submit feedback"),
- ],
- },
- columns: [
+ layout: {
+ above: [
{
- type: "Field",
+ type: "blank",
+ contents: feedbackStandalonePageContent(),
block: false,
- fieldview: "edit",
- textStyle: "",
- field_name: "title",
- configuration: {},
- },
- {
- type: "Field",
- block: false,
- fieldview: "textarea",
- textStyle: "",
- field_name: "description",
- configuration: {},
- },
- {
- type: "Field",
- block: false,
- fieldview: "edit",
- textStyle: "",
- field_name: "url",
- configuration: {},
- },
- {
- type: "Action",
- rndid: "a1b2c3",
- nsteps: "",
- minRole: 100,
- isFormula: {},
- run_async: false,
- action_icon: "",
- action_name: "Save",
- action_size: "",
- action_bgcol: "",
- action_class: "",
- action_label: "Submit feedback",
- action_style: "btn-primary",
- action_title: "",
- configuration: {},
- step_only_ifs: "",
- action_textcol: "",
- action_bordercol: "",
- step_action_names: "",
},
],
},
+ fixed_states: {},
});
// Admin edit view — opened as popup from the feedback tab
@@ -526,6 +826,88 @@ const del_all_feedback = async (table_id, vn, config, body, { req, res }) => {
return { json: { success: true } };
};
+const gen_questions_for_form = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const { title, description } = body;
+ const spec = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "spec",
+ });
+ const answer = await getState().functions.llm_generate.run(
+ `${
+ spec?.body?.specification
+ ? `The following application is being built:\n\n${spec.body.specification}\n\n`
+ : ""
+ }A user wants to submit the following feedback:
+
+Title: ${title}
+${description ? `Description: ${description}\n` : ""}
+Generate clarifying questions about this feedback that would help understand
+what specific changes or additions are needed.
+Ask only about genuinely ambiguous or underspecified aspects.
+Keep questions clear and concise. 5 is a hard maximum — ask fewer if the feedback is already clear.
+
+Now call the ask_questions tool with your questions.`,
+ {
+ tools: [questions_tool],
+ ...tool_choice("ask_questions"),
+ systemPrompt:
+ "You are a requirements analyst helping to clarify user feedback. " +
+ "Ask only what is truly needed to understand the feedback — fewer is better.",
+ }
+ );
+ const tc = answer.getToolCalls()[0];
+ return { json: { questions: tc.input.questions } };
+};
+
+const submit_feedback_with_answers = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const { _csrf, title, description, url, ...rest } = body;
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ if (!table) return { json: { error: "Feedback table not found" } };
+
+ const rowId = await table.insertRow({
+ title,
+ description: description || null,
+ url: url || null,
+ status: "Pending",
+ });
+
+ const questions = [];
+ const answers = {};
+ let idx = 1;
+ while (rest[`question_${idx}`] !== undefined) {
+ questions.push(rest[`question_${idx}`]);
+ answers[`q${idx}`] = rest[`a${idx}`] || "";
+ idx++;
+ }
+
+ if (questions.length) {
+ await MetaData.create({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${rowId}`,
+ body: {
+ feedback_id: rowId,
+ title,
+ questions,
+ answers,
+ },
+ });
+ }
+
+ return { json: { success: true } };
+};
+
const feedback_routes = {
del_feedback,
del_all_feedback,
@@ -534,6 +916,8 @@ const feedback_routes = {
start_approve_feedback,
approval_status,
delete_feedback_row,
+ gen_questions_for_form,
+ submit_feedback_with_answers,
};
module.exports = { feedbackList, feedback_routes };
diff --git a/app-constructor/prompts.js b/app-constructor/prompts.js
index a755963..47cf4b9 100644
--- a/app-constructor/prompts.js
+++ b/app-constructor/prompts.js
@@ -145,10 +145,97 @@ const available_plugins_list = (storePlugins, installedNames) => {
);
};
+const research_answers_section = (text) =>
+ text
+ ? `\nThe user was asked clarifying questions about the application. Here are the questions and their answers:\n\n${text}\n`
+ : "";
+
+const task_planning_rules = `The plan should focus on building views, triggers (including workflows) and pages.
+
+Important trigger planning rules:
+* When a task involves a simple field update (e.g. marking an item complete or incomplete), plan it as a trigger using modify_row — NOT a workflow. Use a workflow only when multiple steps, branching, or looping are genuinely required.
+* If multiple independent single-step actions are needed (e.g. "mark complete" and "mark incomplete"), describe them as separate triggers in the task description — do not describe them as one combined workflow.
+* Do NOT mention "navigate back" or "return to context" in trigger task descriptions. Navigation is configured at the view level (GoBack button), not inside a trigger.
+* If a trigger should be accessible as a button in a view, the task description must name the target view and say to add an action segment with action_name set to the trigger's name. If the view already exists, combine trigger creation and view update in the same task. If the view is created in a later task, that task's description must mention adding the trigger button, and it must depend on the trigger task.
+* Do NOT plan any task that writes to a virtual (read-only) calculated field. Virtual fields are computed automatically and cannot be stored — any trigger or workflow that tries to update them will be refused. If you find yourself planning a trigger to keep a calculated field "current", delete that task — the field already updates itself.
+
+Important existing-entity rules:
+* Before planning any view or page task, check the list of already-implemented views and pages above. If an existing view or page already covers the required functionality — even under a slightly different name — do NOT create a new one. Reference the existing entity by its exact name in dependent tasks.
+* Never create a new view that is a renamed variant of an existing one (e.g. prefixing with "my_", "user_", "filtered_"). If the existing view needs filtering for a specific context, embed it as-is and describe the filtering in the embedding page or view task.
+* For every role's required dashboard or key page, verify it is either in the existing pages list or has a task planned for it. A requirement that mentions a dashboard or home screen for a role and has no corresponding existing page MUST have a task.
+* If a page was previously created under one name and a requirement refers to the same concept under a different name, use the existing page's actual name — do not plan a second page for the same purpose.
+
+Important view planning rules:
+* Each task must create exactly one view. Never put two or more views in the same task. Edit, Show, and List for the same table are always three separate tasks with three separate names, descriptions, and dependencies.
+* Do NOT plan separate tasks for "create" and "edit" on the same table. In Saltcorn, a single Edit view handles both (no id = create, id present = edit). One task, one Edit view, description says "create and edit".
+* Edit, Show, and List views for a table always go together as three separate tasks. Whenever you plan a List view AND a Show view for the same table, you MUST also plan an Edit view for that table — a List without an Edit leaves users unable to create or modify records. Only omit the Edit view when the requirements explicitly say the data is read-only.
+* The three tasks must be ordered: Edit and Show first (independent of each other, in any order), List last. The List task MUST list both the Edit task and the Show task in its depends_on — without exception. If you plan a List that depends on neither, that is a bug in the plan.
+* Before finalising the plan, for every List view task, verify that its depends_on includes the corresponding Edit task and the corresponding Show task (if they exist). If either is missing, add it.
+* When a List view links to a Show view or Edit view, the task description must say: "Add a viewlink column to [view_name] for the current row" — not just "link each row". This wording makes it unambiguous that a viewlink column must be added to the list for each target view.
+* Every List view task description must include a delete action column unless the table is explicitly read-only. State it explicitly: "Add a delete action column."
+* In general, if a view embeds or links to another view, the linked view's task must be listed as a dependency.
+* When a table has foreign key fields referencing the users table, the task description must explicitly state for each one whether it is an ownership field (automatically set from the logged-in user, omit from the form) or a selector field (the user picks a value, include a selector in the form). Example: "user_id records the owner and is set automatically; shared_with_user_id must have a user selector."
+* For FK fields that represent a parent context (e.g. trip_id on packing_items), always include the field as a normal selector in the Edit view form. Do NOT say to omit it. Saltcorn automatically pre-fills the selector from the URL query parameter when the view is opened from a parent context, and the user can select it manually when the view is used standalone.
+* For every task that creates a view, include the exact view name in the task description. View names must be lowercase, snake_case, unique across all tasks in the plan, and descriptive enough to identify the table and purpose — for example 'packing_items_edit' rather than just 'edit'.
+* Do NOT plan an Edit view for any table whose description says it is auto-populated or not editable by users (e.g. audit logs, import/export job tracking tables). These tables may have List and Show views for read-only visibility, but never an Edit view.
+Important user account rules:
+* The platform (Saltcorn) provides a built-in user account system with login, registration, and session management. Do NOT plan any tasks for user registration, login pages, password management, authentication flows, or email verification — these are already handled by the platform. Users register at /auth/signup and log in at /auth/login.
+* User identity is always available as the logged-in user. Ownership fields (FK to users) are set automatically from the session; no custom logic is needed.
+* If a requirement mentions "user accounts", "secure login", "saving data per user", "user-specific data", or "sharing between users", treat it as already satisfied by the platform's built-in user system. Do not generate any task in response to such a requirement.
+
+Important role rules:
+* Every view and page task description MUST state the min_role explicitly, e.g. "Set min_role to admin (1)." or "Set min_role to user (80).". Never omit it.
+* Role values: admin=1, staff=40, user=80, public=100. Use the value that matches who will use the view or page — admin for management, staff for staff-only, user for logged-in users (clients, members, etc.), public only when the view or page must be accessible without login.
+
+Important dashboard rules:
+* A dashboard page that shows aggregate statistics (totals, counts, revenue, etc.) must NEVER use client-side JavaScript fetch stubs or placeholder values. Every stat card must be backed by a real Saltcorn Statistic view embedded with an embed-view tag.
+* For each statistic shown on a dashboard, plan a separate Statistic view task (e.g. "total_billable_hours_stat", "revenue_by_client_stat"). The dashboard page task must list all these Statistic view tasks in its depends_on.
+* Statistic view tasks must be planned before the dashboard page task and have descriptive names that make their metric clear.
+
+Important home page rules:
+* Every role should land on the right page after visiting /. Plan a single task "Set home pages by role" that depends on all relevant page tasks and configures home_page_by_role for every role in one step.
+* Role IDs: public=100, user=80, staff=40, admin=1.
+* Landing/marketing page (public-facing intro): min_role must be 100 (public). It MUST include visible links to /auth/login (Log in) and /auth/signup (Create an account). Set as home for role 100 (public).
+* If there is an admin dashboard page, set it as home for role 1 (admin).
+* If there is a dashboard or main page for regular users or staff, set it as home for role 80 (user) and/or role 40 (staff) as appropriate.
+* The "Set home pages by role" task description must list every role→page mapping explicitly using the exact page names planned in this task list, e.g.: "Set home_page_by_role: public (100) → landing, user (80) → client_dashboard, staff (40) → staff_dashboard, admin (1) → app_admin_dashboard." Never use "admin_dashboard" as a page name — it is reserved by the platform.
+
+Important bulk import/export rules:
+* A plain Edit view creates or edits a single record — it is NOT a bulk import tool. Never plan an Edit view as a solution for bulk data import.
+* List views have no built-in export feature — do not plan an export button or column as part of a list view.
+* Bulk import and export functionality (e.g. CSV) must always be placed on a dedicated management or admin page as embedded views, using whatever import/export viewtemplate is available from an installed plugin.
+* Bulk import and bulk export for the same table are always two separate tasks with two separate view names. Never combine them into a single task.
+
+Important plugin rules:
+* If multiple plugins need to be installed, combine them ALL into a single task named "Install plugins" that lists every required plugin name. Do NOT create a separate task per plugin.
+
+Important dependency rules:
+* Every name in a task's depends_on MUST exactly match the name field of another task in the same plan_tasks call. Never reference a name that is not present in the tasks array — not a concept, not a table name, not a made-up label. If you find yourself writing a depends_on entry whose name does not appear as a task name in the list, either add the missing task or remove the dependency.
+* Before calling plan_tasks, mentally verify: for every task, every name in its depends_on array appears as the name of another task in the array.
+* Before calling plan_tasks, check for circular dependencies. A circular dependency means task A depends on B, and B depends on A (directly or transitively). A circular dependency causes a deadlock — neither task can ever start. To fix it: identify which dependency in the cycle is the weakest (i.e. view A only needs to embed view B, but B does not strictly require A to exist). Remove that dependency from A's depends_on so A can be created first. Then decide whether B's content is still useful without being embedded in A at creation time. If the embed is important, add a separate update task (e.g. "update_A_embed_B") whose description says to update view A to embed view B, and whose depends_on lists both A and B. Only add this extra update task when the embed is genuinely important for the finished product — do not create update tasks for minor or optional embeds, as each extra task is expensive. A good rule of thumb: add an update task only if omitting the embed from the final view would visibly break a user workflow.
+
+Important schema/table rules:
+* The database schema is already fully designed and implemented before task planning begins. ALL tables and fields needed by the application already exist. Do NOT plan any tasks that create tables, add fields, modify fields, or change the schema in any way. If you find yourself writing a task whose output is a table or a field, delete it — that work is already done.
+* Ownership behaviour (auto-setting a FK-to-users field from the logged-in user) is configured in the Edit view, not in the database. Do not create tasks for it at the schema level.
+* Do NOT plan tasks to add uniqueness constraints or validation to existing fields — those are already in the schema.
+* Do NOT plan a standalone task for "access control", "row-level security", "permissions", or "roles". These are schema-level concerns already handled during schema design, or view-level concerns handled when building each view. The ownership field and sharing logic are already in the schema — there is nothing extra to configure as a separate task.`;
+
+const task_planning_closing = `Your plan should not include any clarification or questions to the product owner. The
+information you have been given so far is all that is available. Every step in the plan
+should be immediately implementable in Saltcorn. You are writing the steps in the plan
+for a person who is competent in using saltcorn but has no other business knowledge.
+
+Do not include any steps that contain planning, design or review instructions. You are only writing a
+plan for the engineer building the application. Every step in the plan should have the construction or the modification
+of one or several application entity types.`;
+
module.exports = {
saltcorn_description,
existing_tables_list,
existing_entities_list,
installed_plugins_list,
available_plugins_list,
+ research_answers_section,
+ task_planning_rules,
+ task_planning_closing,
};
diff --git a/app-constructor/requirements.js b/app-constructor/requirements.js
index c88a928..8e3726c 100644
--- a/app-constructor/requirements.js
+++ b/app-constructor/requirements.js
@@ -38,6 +38,7 @@ const renderLayout = require("@saltcorn/markup/layout");
const { viewname, tool_choice } = require("./common");
const { requirements_tool } = require("./tools");
const { getResearchAnswersText } = require("./research");
+const { research_answers_section } = require("./prompts");
const requirementsList = async (req) => {
const rs = await MetaData.find(
@@ -148,7 +149,7 @@ const doGenReqs = async (spec, userId) => {
`Generate the requirements for this application:
${spec.body.specification}
-${researchText ? `\nThe user was asked clarifying questions about the application. Here are the questions and their answers:\n\n${researchText}\n` : ""}
+${research_answers_section(researchText)}
Important rules for generating requirements:
* Every requirement must be directly traceable to something stated in the description, audience, or core features above. Do not infer, invent, or add features that are not explicitly mentioned — even if they seem like an obvious addition.
* Do not generate any requirement that falls under the Out of scope section above.
diff --git a/app-constructor/research.js b/app-constructor/research.js
index 714e11f..c1a34e1 100644
--- a/app-constructor/research.js
+++ b/app-constructor/research.js
@@ -1,4 +1,5 @@
const MetaData = require("@saltcorn/data/models/metadata");
+const Table = require("@saltcorn/data/models/table");
const {
div,
script,
@@ -41,6 +42,89 @@ const spinnerHtml =
i({ class: "fas fa-spinner fa-spin me-2" }) +
"Generating questions, please wait...
";
+const FEEDBACK_TABLE = "app_constructor_feedback";
+
+// Pure HTML for the feedback questions section — safe for innerHTML injection
+const feedbackResearchHtml = async () => {
+ const generating = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "generating_feedback_research",
+ });
+ if (generating) {
+ return div(
+ { class: "mt-4 border-top pt-3" },
+ p(i({ class: "fas fa-spinner fa-spin me-2" }), "Generating feedback questions...")
+ );
+ }
+
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ if (!table) return "";
+
+ const feedbackRows = await table.getRows({}, { orderBy: "id" });
+ if (!feedbackRows.length) return "";
+
+ const items = [];
+ for (const row of feedbackRows) {
+ const md = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${row.id}`,
+ });
+ if (md) items.push({ row, md });
+ }
+ if (!items.length) return "";
+
+ const sections = items
+ .map(({ row, md }) => {
+ const questions = md.body.questions || [];
+ const answers = md.body.answers || {};
+ const fieldRows = questions
+ .map((q, idx) => {
+ const fname = `q${idx + 1}`;
+ return div(
+ { class: "mb-2" },
+ label(
+ { class: "form-label small fw-semibold", for: `fbq_${row.id}_${fname}` },
+ q
+ ),
+ textarea(
+ {
+ class: "form-control form-control-sm",
+ id: `fbq_${row.id}_${fname}`,
+ name: fname,
+ rows: 2,
+ },
+ answers[fname] || ""
+ )
+ );
+ })
+ .join("");
+ return div(
+ { class: "mb-4" },
+ p({ class: "fw-semibold mb-2" }, row.title),
+ form({ id: `fbr-form-${row.id}` }, fieldRows),
+ button(
+ {
+ type: "button",
+ class: "btn btn-sm btn-primary",
+ onclick: `copilotSaveFeedbackResearch(${row.id})`,
+ },
+ "Save answers"
+ )
+ );
+ })
+ .join("");
+
+ return div(
+ { class: "mt-4 border-top pt-3" },
+ h5("Feedback questions"),
+ small(
+ { class: "text-muted d-block mb-3" },
+ "Answer these questions about each piece of feedback to provide better context for approval."
+ ),
+ sections
+ );
+};
+
// Pure HTML for each state — no embedded scripts
const researchPanelHtml = async (req) => {
const generating = await MetaData.findOne({
@@ -121,11 +205,17 @@ const researchPanel = async (req) => {
type: "CopilotConstructMgr",
name: "generating_research",
});
+ const genFeedbackResearch = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "generating_feedback_research",
+ });
const innerHtml = await researchPanelHtml(req);
+ const feedbackResearchInner = await feedbackResearchHtml();
return div(
{ class: "mt-2" },
div({ id: "research-panel" }, innerHtml),
+ div({ id: "feedback-research-panel" }, feedbackResearchInner),
script(
domReady(`
const _vn = ${JSON.stringify(viewname)};
@@ -154,7 +244,27 @@ window.copilotSubmitResearch = () => {
for (const el of f.querySelectorAll('textarea')) data[el.name] = el.value;
view_post(_vn, 'submit_research', data);
};
+window.feedbackResearchStartPoll = () => {
+ const poll = () => {
+ view_post(_vn, 'feedback_research_status', {}, (resp) => {
+ if (resp && !resp.generating) {
+ view_post(_vn, 'feedback_research_html', {}, (r) => {
+ if (r && r.html)
+ document.getElementById('feedback-research-panel').innerHTML = r.html;
+ });
+ } else setTimeout(poll, 3000);
+ });
+ };
+ setTimeout(poll, 3000);
+};
+window.copilotSaveFeedbackResearch = (feedbackId) => {
+ const data = { feedback_id: feedbackId };
+ const f = document.getElementById('fbr-form-' + feedbackId);
+ for (const el of f.querySelectorAll('textarea')) data[el.name] = el.value;
+ view_post(_vn, 'save_feedback_research_answers', data);
+};
${generating ? "researchStartPoll();" : ""}
+${genFeedbackResearch ? "feedbackResearchStartPoll();" : ""}
`)
)
);
@@ -300,11 +410,129 @@ const getResearchAnswersText = async () => {
return pairs.join("\n\n");
};
+const doGenFeedbackResearch = async (rows) => {
+ try {
+ const spec = await MetaData.findOne({ type: "CopilotConstructMgr", name: "spec" });
+ for (const row of rows) {
+ const alreadyHas = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${row.id}`,
+ });
+ if (alreadyHas) continue;
+ const answer = await getState().functions.llm_generate.run(
+ `${spec?.body?.specification
+ ? `The following application is being built:\n\n${spec.body.specification}\n\n`
+ : ""
+ }A user has submitted the following feedback:
+
+Title: ${row.title}
+${row.description ? `Description: ${row.description}\n` : ""}
+Generate clarifying questions about this feedback that would help understand
+what specific changes or additions are needed.
+Ask only about genuinely ambiguous or underspecified aspects.
+Keep questions clear and concise. 5 is a hard maximum — ask fewer if the feedback is already clear.
+
+Now call the ask_questions tool with your questions.`,
+ {
+ tools: [questions_tool],
+ ...tool_choice("ask_questions"),
+ systemPrompt:
+ "You are a requirements analyst helping to clarify user feedback. " +
+ "Ask only what is truly needed to understand the feedback — fewer is better.",
+ }
+ );
+ const tc = answer.getToolCalls()[0];
+ await MetaData.create({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${row.id}`,
+ body: {
+ feedback_id: row.id,
+ title: row.title,
+ questions: tc.input.questions,
+ answers: {},
+ },
+ });
+ }
+ } finally {
+ const md = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "generating_feedback_research",
+ });
+ if (md) await md.delete();
+ }
+};
+
+const gen_feedback_research = async (table_id, vn, config, body, { req, res }) => {
+ // If the Insert virtual trigger already started generation, just signal the client to poll
+ const alreadyRunning = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "generating_feedback_research",
+ });
+ if (alreadyRunning) return { json: { generating: true } };
+
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ if (!table) return { json: { generating: false } };
+
+ const feedbackRows = await table.getRows({}, { orderBy: "id" });
+ if (!feedbackRows.length) return { json: { generating: false } };
+
+ const newRows = [];
+ for (const row of feedbackRows) {
+ const existing = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${row.id}`,
+ });
+ if (!existing) newRows.push(row);
+ }
+ if (!newRows.length) return { json: { generating: false } };
+
+ doGenFeedbackResearch(newRows).catch((e) =>
+ console.error("gen_feedback_research error", e)
+ );
+ return { json: { generating: true } };
+};
+
+const feedback_research_status = async (table_id, vn, config, body, { req, res }) => {
+ const generating = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "generating_feedback_research",
+ });
+ return { json: { generating: !!generating } };
+};
+
+const feedback_research_html = async (table_id, vn, config, body, { req, res }) => {
+ const html = await feedbackResearchHtml();
+ return { json: { html } };
+};
+
+const save_feedback_research_answers = async (
+ table_id, vn, config, body, { req, res }
+) => {
+ const { _csrf, feedback_id, ...answers } = body;
+ const id = parseInt(feedback_id);
+ const md = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ if (!md) return { json: { error: "Not found" } };
+ await md.update({ body: { ...md.body, answers } });
+ return { json: { success: true, notify_success: "Answers saved" } };
+};
+
const research_routes = {
gen_research,
research_status,
research_html,
submit_research,
+ gen_feedback_research,
+ feedback_research_status,
+ feedback_research_html,
+ save_feedback_research_answers,
};
-module.exports = { researchPanel, research_routes, getResearchAnswersText };
+module.exports = {
+ researchPanel,
+ research_routes,
+ getResearchAnswersText,
+ questions_tool,
+};
diff --git a/app-constructor/tasks.js b/app-constructor/tasks.js
index 07afa0d..68d4af2 100644
--- a/app-constructor/tasks.js
+++ b/app-constructor/tasks.js
@@ -49,6 +49,9 @@ const {
existing_entities_list,
installed_plugins_list,
available_plugins_list,
+ task_planning_rules,
+ task_planning_closing,
+ research_answers_section,
} = require("./prompts");
const doneTaskRowHtml = (task) =>
@@ -175,13 +178,27 @@ const makeTaskList = async (req) => {
.map((dep) =>
doneNames.has(dep)
? span(
- { class: "dep-indicator text-success me-2", style: "white-space:nowrap", title: dep },
- i({ class: "fas fa-check-circle me-1", style: "font-size:0.75em" }),
+ {
+ class: "dep-indicator text-success me-2",
+ style: "white-space:nowrap",
+ title: dep,
+ },
+ i({
+ class: "fas fa-check-circle me-1",
+ style: "font-size:0.75em",
+ }),
dep
)
: span(
- { class: "dep-indicator text-danger me-2", style: "white-space:nowrap", title: dep },
- i({ class: "fas fa-circle me-1", style: "font-size:0.75em" }),
+ {
+ class: "dep-indicator text-danger me-2",
+ style: "white-space:nowrap",
+ title: dep,
+ },
+ i({
+ class: "fas fa-circle me-1",
+ style: "font-size:0.75em",
+ }),
dep
)
)
@@ -675,7 +692,7 @@ const doGenTasks = async (spec, rs, schema, userId) => {
`Generate a plan for building this application:
${spec.body.specification}
-${researchText ? `\nThe user was asked clarifying questions about the application. Here are the questions and their answers:\n\n${researchText}\n` : ""}
+${research_answers_section(researchText)}
These are the requirements of the application:
${rs.map((r) => `* ${r.body.requirement}`).join("\n")}
@@ -690,86 +707,11 @@ The plan should outline the continued development of the application on top of t
Your plan can add additional tables if needed or adjust the table fields, but normally the tables
should be designed optimally for this application.
-${entitiesSection ? entitiesSection + "\n\n" : ""}${installedPluginsSection ? installedPluginsSection + "\n\n" : ""}${
- pluginsSection ? pluginsSection + "\n\n" : ""
- }The plan should focus on building views, triggers (including workflows) and pages.
+${entitiesSection ? entitiesSection + "\n\n" : ""}${
+ installedPluginsSection ? installedPluginsSection + "\n\n" : ""
+ }${pluginsSection ? pluginsSection + "\n\n" : ""}${task_planning_rules}
-Important trigger planning rules:
-* When a task involves a simple field update (e.g. marking an item complete or incomplete), plan it as a trigger using modify_row — NOT a workflow. Use a workflow only when multiple steps, branching, or looping are genuinely required.
-* If multiple independent single-step actions are needed (e.g. "mark complete" and "mark incomplete"), describe them as separate triggers in the task description — do not describe them as one combined workflow.
-* Do NOT mention "navigate back" or "return to context" in trigger task descriptions. Navigation is configured at the view level (GoBack button), not inside a trigger.
-* If a trigger should be accessible as a button in a view, the task description must name the target view and say to add an action segment with action_name set to the trigger's name. If the view already exists, combine trigger creation and view update in the same task. If the view is created in a later task, that task's description must mention adding the trigger button, and it must depend on the trigger task.
-* Do NOT plan any task that writes to a virtual (read-only) calculated field. Virtual fields are computed automatically and cannot be stored — any trigger or workflow that tries to update them will be refused. If you find yourself planning a trigger to keep a calculated field "current", delete that task — the field already updates itself.
-
-Important existing-entity rules:
-* Before planning any view or page task, check the list of already-implemented views and pages above. If an existing view or page already covers the required functionality — even under a slightly different name — do NOT create a new one. Reference the existing entity by its exact name in dependent tasks.
-* Never create a new view that is a renamed variant of an existing one (e.g. prefixing with "my_", "user_", "filtered_"). If the existing view needs filtering for a specific context, embed it as-is and describe the filtering in the embedding page or view task.
-* For every role's required dashboard or key page, verify it is either in the existing pages list or has a task planned for it. A requirement that mentions a dashboard or home screen for a role and has no corresponding existing page MUST have a task.
-* If a page was previously created under one name and a requirement refers to the same concept under a different name, use the existing page's actual name — do not plan a second page for the same purpose.
-
-Important view planning rules:
-* Each task must create exactly one view. Never put two or more views in the same task. Edit, Show, and List for the same table are always three separate tasks with three separate names, descriptions, and dependencies.
-* Do NOT plan separate tasks for "create" and "edit" on the same table. In Saltcorn, a single Edit view handles both (no id = create, id present = edit). One task, one Edit view, description says "create and edit".
-* Edit, Show, and List views for a table always go together as three separate tasks. Whenever you plan a List view AND a Show view for the same table, you MUST also plan an Edit view for that table — a List without an Edit leaves users unable to create or modify records. Only omit the Edit view when the requirements explicitly say the data is read-only.
-* The three tasks must be ordered: Edit and Show first (independent of each other, in any order), List last. The List task MUST list both the Edit task and the Show task in its depends_on — without exception. If you plan a List that depends on neither, that is a bug in the plan.
-* Before finalising the plan, for every List view task, verify that its depends_on includes the corresponding Edit task and the corresponding Show task (if they exist). If either is missing, add it.
-* When a List view links to a Show view or Edit view, the task description must say: "Add a viewlink column to [view_name] for the current row" — not just "link each row". This wording makes it unambiguous that a viewlink column must be added to the list for each target view.
-* Every List view task description must include a delete action column unless the table is explicitly read-only. State it explicitly: "Add a delete action column."
-* In general, if a view embeds or links to another view, the linked view's task must be listed as a dependency.
-* When a table has foreign key fields referencing the users table, the task description must explicitly state for each one whether it is an ownership field (automatically set from the logged-in user, omit from the form) or a selector field (the user picks a value, include a selector in the form). Example: "user_id records the owner and is set automatically; shared_with_user_id must have a user selector."
-* For FK fields that represent a parent context (e.g. trip_id on packing_items), always include the field as a normal selector in the Edit view form. Do NOT say to omit it. Saltcorn automatically pre-fills the selector from the URL query parameter when the view is opened from a parent context, and the user can select it manually when the view is used standalone.
-* For every task that creates a view, include the exact view name in the task description. View names must be lowercase, snake_case, unique across all tasks in the plan, and descriptive enough to identify the table and purpose — for example 'packing_items_edit' rather than just 'edit'.
-* Do NOT plan an Edit view for any table whose description says it is auto-populated or not editable by users (e.g. audit logs, import/export job tracking tables). These tables may have List and Show views for read-only visibility, but never an Edit view.
-Important user account rules:
-* The platform (Saltcorn) provides a built-in user account system with login, registration, and session management. Do NOT plan any tasks for user registration, login pages, password management, authentication flows, or email verification — these are already handled by the platform. Users register at /auth/signup and log in at /auth/login.
-* User identity is always available as the logged-in user. Ownership fields (FK to users) are set automatically from the session; no custom logic is needed.
-* If a requirement mentions "user accounts", "secure login", "saving data per user", "user-specific data", or "sharing between users", treat it as already satisfied by the platform's built-in user system. Do not generate any task in response to such a requirement.
-
-Important role rules:
-* Every view and page task description MUST state the min_role explicitly, e.g. "Set min_role to admin (1)." or "Set min_role to user (80).". Never omit it.
-* Role values: admin=1, staff=40, user=80, public=100. Use the value that matches who will use the view or page — admin for management, staff for staff-only, user for logged-in users (clients, members, etc.), public only when the view or page must be accessible without login.
-
-Important dashboard rules:
-* A dashboard page that shows aggregate statistics (totals, counts, revenue, etc.) must NEVER use client-side JavaScript fetch stubs or placeholder values. Every stat card must be backed by a real Saltcorn Statistic view embedded with an embed-view tag.
-* For each statistic shown on a dashboard, plan a separate Statistic view task (e.g. "total_billable_hours_stat", "revenue_by_client_stat"). The dashboard page task must list all these Statistic view tasks in its depends_on.
-* Statistic view tasks must be planned before the dashboard page task and have descriptive names that make their metric clear.
-
-Important home page rules:
-* Every role should land on the right page after visiting /. Plan a single task "Set home pages by role" that depends on all relevant page tasks and configures home_page_by_role for every role in one step.
-* Role IDs: public=100, user=80, staff=40, admin=1.
-* Landing/marketing page (public-facing intro): min_role must be 100 (public). It MUST include visible links to /auth/login (Log in) and /auth/signup (Create an account). Set as home for role 100 (public).
-* If there is an admin dashboard page, set it as home for role 1 (admin).
-* If there is a dashboard or main page for regular users or staff, set it as home for role 80 (user) and/or role 40 (staff) as appropriate.
-* The "Set home pages by role" task description must list every role→page mapping explicitly using the exact page names planned in this task list, e.g.: "Set home_page_by_role: public (100) → landing, user (80) → client_dashboard, staff (40) → staff_dashboard, admin (1) → app_admin_dashboard." Never use "admin_dashboard" as a page name — it is reserved by the platform.
-
-Important bulk import/export rules:
-* A plain Edit view creates or edits a single record — it is NOT a bulk import tool. Never plan an Edit view as a solution for bulk data import.
-* List views have no built-in export feature — do not plan an export button or column as part of a list view.
-* Bulk import and export functionality (e.g. CSV) must always be placed on a dedicated management or admin page as embedded views, using whatever import/export viewtemplate is available from an installed plugin.
-* Bulk import and bulk export for the same table are always two separate tasks with two separate view names. Never combine them into a single task.
-
-Important plugin rules:
-* If multiple plugins need to be installed, combine them ALL into a single task named "Install plugins" that lists every required plugin name. Do NOT create a separate task per plugin.
-
-Important dependency rules:
-* Every name in a task's depends_on MUST exactly match the name field of another task in the same plan_tasks call. Never reference a name that is not present in the tasks array — not a concept, not a table name, not a made-up label. If you find yourself writing a depends_on entry whose name does not appear as a task name in the list, either add the missing task or remove the dependency.
-* Before calling plan_tasks, mentally verify: for every task, every name in its depends_on array appears as the name of another task in the array.
-* Before calling plan_tasks, check for circular dependencies. A circular dependency means task A depends on B, and B depends on A (directly or transitively). A circular dependency causes a deadlock — neither task can ever start. To fix it: identify which dependency in the cycle is the weakest (i.e. view A only needs to embed view B, but B does not strictly require A to exist). Remove that dependency from A's depends_on so A can be created first. Then decide whether B's content is still useful without being embedded in A at creation time. If the embed is important, add a separate update task (e.g. "update_A_embed_B") whose description says to update view A to embed view B, and whose depends_on lists both A and B. Only add this extra update task when the embed is genuinely important for the finished product — do not create update tasks for minor or optional embeds, as each extra task is expensive. A good rule of thumb: add an update task only if omitting the embed from the final view would visibly break a user workflow.
-
-Important schema/table rules:
-* The database schema is already fully designed and implemented before task planning begins. ALL tables and fields needed by the application already exist. Do NOT plan any tasks that create tables, add fields, modify fields, or change the schema in any way. If you find yourself writing a task whose output is a table or a field, delete it — that work is already done.
-* Ownership behaviour (auto-setting a FK-to-users field from the logged-in user) is configured in the Edit view, not in the database. Do not create tasks for it at the schema level.
-* Do NOT plan tasks to add uniqueness constraints or validation to existing fields — those are already in the schema.
-* Do NOT plan a standalone task for "access control", "row-level security", "permissions", or "roles". These are schema-level concerns already handled during schema design, or view-level concerns handled when building each view. The ownership field and sharing logic are already in the schema — there is nothing extra to configure as a separate task.
-
-Your plan should not include any clarification or questions to the product owner. The
-information you have been given so far is all that is available. Every step in the plan
-should be immediately implementable in Saltcorn. You are writing the steps in the plan
-for a person who is competent in using saltcorn but has no other business knowledge.
-
-Do not include any steps that contain planning, design or review instructions. You are only writing a
-plan for the engineer building the application. Every step in the plan should have the construction or the modification
-of one or several application entity types.
+${task_planning_closing}
Before finalising the plan, you may call get_view_config for any existing view you are unsure about — to inspect its configuration and decide whether a task should reuse it (updating it) or create a new one. Once you have gathered all necessary information, call plan_tasks to submit the complete task list.
`,
From 2a60679a32e1cf547fa796aba76c6ea95193612a Mon Sep 17 00:00:00 2001
From: Christian Hugo
Date: Sat, 16 May 2026 20:16:21 +0200
Subject: [PATCH 3/6] ask for Q+A in a popup, structure Research in collapse
items
---
app-constructor/feedback.js | 109 ++++++++++++++++----
app-constructor/research.js | 194 ++++++++++++++++++++++++++----------
2 files changed, 230 insertions(+), 73 deletions(-)
diff --git a/app-constructor/feedback.js b/app-constructor/feedback.js
index 6561b51..fb9fe14 100644
--- a/app-constructor/feedback.js
+++ b/app-constructor/feedback.js
@@ -25,12 +25,40 @@ const { questions_tool } = require("./research");
const FEEDBACK_TABLE = "app_constructor_feedback";
+const feedbackClarifyModal = (idPrefix) =>
+ `
+
+
+
+
Generate clarifying questions?
+
Before saving, I can analyse your feedback and ask a few short questions to help clarify the requirements. Answering them produces more accurate tasks — but you can also save right away.
Before saving, I can analyse your feedback and ask a few short questions to help clarify the requirements. Answering them produces more accurate tasks — but you can also save right away.