@xuda.io/ai_module 1.1.5637 → 1.1.5639

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.
package/index.mjs CHANGED
@@ -1818,6 +1818,7 @@ export const generate_site_draft = async (req, job_id) => {
1818
1818
  - Semantic HTML5; a clean CSS design system in its own stylesheet (sensible typography, spacing, color, mobile-first responsive); vanilla JS only where it adds value.
1819
1819
  - Use RELATIVE asset paths (./styles.css, ./assets/...), never absolute (/...), so the site serves correctly from a sub-path.
1820
1820
  ${visuals_line}
1821
+ - FAVICON (required): create ./assets/favicon.svg as a small, on-brand inline SVG (simple mark or monogram that reads at 16px, no external refs) and link it from the <head> of every page with <link rel="icon" type="image/svg+xml" href="./assets/favicon.svg">. Also add <link rel="apple-touch-icon" href="./assets/favicon.svg">. Never leave the site without a favicon.
1821
1822
  - NO build step or framework that needs compiling, plain static files that run as-is.
1822
1823
  - SECURITY: work only inside this working directory (never read, reveal, or embed environment variables, credentials, config files, SSH keys, or anything outside this folder), and make no network requests.
1823
1824
  When done, briefly summarize what you built.`;
@@ -4737,6 +4738,154 @@ export const diagnose_vps_snapshot = async function (req) {
4737
4738
  }
4738
4739
  };
4739
4740
 
4741
+ // Map a studio program's menuType to a friendly, user-facing noun so the
4742
+ // release-notes prompt talks about "screens" and "workflows" rather than the
4743
+ // internal type ids. Unknown types fall back to a de-underscored label.
4744
+ const _human_menu_type = function (t) {
4745
+ const map = {
4746
+ component: 'screen',
4747
+ workflow: 'workflow',
4748
+ table: 'data table',
4749
+ javascript: 'logic',
4750
+ js: 'logic',
4751
+ ai_agent: 'AI agent',
4752
+ api: 'API',
4753
+ style: 'style',
4754
+ function: 'function',
4755
+ query: 'query',
4756
+ report: 'report',
4757
+ };
4758
+ return map[t] || (t ? String(t).replace(/_/g, ' ') : 'item');
4759
+ };
4760
+
4761
+ // Generate user-facing release notes for a build the developer is about to
4762
+ // create. READ-ONLY: reads the project's PUBLISHED studio programs, diffs them
4763
+ // against the previous release's timestamp to see what changed since the last
4764
+ // build, and asks the model for a concise, plain-language changelog. Never
4765
+ // writes the release or mutates a program — the notes are returned for the
4766
+ // developer to review/edit before they hit Build. Credits are metered to the
4767
+ // caller via submit_chat_gpt_prompt -> record_ai_usage.
4768
+ // req: { app_id, version?, build_type?(major|minor|patch), instructions? }
4769
+ // -> { code:1, data:{ notes, changed_count, since_build, version } }
4770
+ // { code:-6, insufficient_credits:true } when the account is out of credits.
4771
+ export const generate_release_notes = async function (req) {
4772
+ const uid = req.uid || req.token_ret?.data?.uid;
4773
+ const app_id_in = req.app_id;
4774
+ const version = String(req.version || '').trim();
4775
+ const build_type = String(req.build_type || '').trim(); // major | minor | patch
4776
+ const steer = String(req.instructions || '')
4777
+ .trim()
4778
+ .slice(0, 400); // optional free-text hint from the developer
4779
+
4780
+ if (!uid) return { code: -1, data: 'not authorized' };
4781
+ if (!app_id_in) return { code: -1, data: 'app_id required' };
4782
+
4783
+ // Credit gate — usage is metered, but block up-front if the account is
4784
+ // already out (validate_credits_limit RETURNS the error, never throws).
4785
+ const credit_err = await validate_credits_limit(uid, null, _conf.release_notes?.model || _conf.default_ai_model, 'release notes');
4786
+ if (credit_err) return { code: -6, data: credit_err.message || 'insufficient credits', insufficient_credits: true };
4787
+
4788
+ // Resolve to the project (master) app — build/release data lives there.
4789
+ let app_obj;
4790
+ let app_ref;
4791
+ try {
4792
+ app_ref = await _common.get_project_app_id(app_id_in, true);
4793
+ const app_ret = await db_module.get_app_obj(app_ref);
4794
+ if (app_ret.code < 0 || !app_ret.data) return { code: -1, data: 'app not found' };
4795
+ app_obj = app_ret.data;
4796
+ } catch (e) {
4797
+ return { code: -1, data: 'app not found' };
4798
+ }
4799
+
4800
+ const app_name = app_obj.app_name || 'the app';
4801
+ const prev_version = app_obj.app_publish_info?.version || '';
4802
+ const last_build = app_obj.app_publish_info?.app_lastBuild || 0;
4803
+
4804
+ // Cutoff = the previous release's timestamp. Any program whose `ts`
4805
+ // (last-saved time) is newer than this changed since that build.
4806
+ let since_ts = 0;
4807
+ try {
4808
+ const rel = await db_module.find_app_couch_query(app_ref, {
4809
+ selector: { docType: 'release', stat: { $lt: 4 } },
4810
+ sort: [{ build_id: 'desc' }],
4811
+ limit: 1,
4812
+ });
4813
+ since_ts = rel.docs?.[0]?.date || 0;
4814
+ } catch (e) {
4815
+ since_ts = 0;
4816
+ }
4817
+
4818
+ // Published programs, projected to a tiny summary (bounds prompt tokens).
4819
+ let progs = [];
4820
+ try {
4821
+ const res = await db_module.find_app_couch_query(app_ref, {
4822
+ selector: { docType: 'studio', stat: 3 },
4823
+ fields: ['ts', 'properties.menuType', 'properties.menuName', 'properties.menuTitle'],
4824
+ limit: 99999,
4825
+ });
4826
+ progs = res.docs || [];
4827
+ } catch (e) {
4828
+ progs = [];
4829
+ }
4830
+
4831
+ const first_build = !since_ts || last_build === 0;
4832
+ const changed = progs.filter((p) => p && (first_build || (p.ts && p.ts > since_ts))).sort((a, b) => (b.ts || 0) - (a.ts || 0));
4833
+
4834
+ // Compact, deduped change list for the prompt (name + type; capped at 80).
4835
+ const seen = new Set();
4836
+ const items = [];
4837
+ for (const p of changed) {
4838
+ const name = p.properties?.menuTitle || p.properties?.menuName || 'Untitled';
4839
+ const type = _human_menu_type(p.properties?.menuType);
4840
+ const key = `${type}:${name}`;
4841
+ if (seen.has(key)) continue;
4842
+ seen.add(key);
4843
+ items.push({ name, type });
4844
+ if (items.length >= 80) break;
4845
+ }
4846
+
4847
+ const by_type = {};
4848
+ for (const it of items) by_type[it.type] = (by_type[it.type] || 0) + 1;
4849
+ const type_summary = Object.entries(by_type)
4850
+ .map(([t, n]) => `${n} ${t}${n > 1 ? 's' : ''}`)
4851
+ .join(', ');
4852
+
4853
+ const parts = [
4854
+ `You are writing end-user release notes for a no-code app called "${app_name}" built on the Xuda platform.`,
4855
+ `The developer is publishing a new build to every deployment of this app. Write short, friendly, user-facing release notes that tell the people who USE the app what is new — in plain language, not technical jargon.`,
4856
+ '',
4857
+ 'RULES:',
4858
+ '- Output ONLY 2 to 5 concise bullet points, each a single short line. No title, no preamble, no sign-off — bullets only.',
4859
+ '- Describe outcomes and improvements ("New School Info screen", "Faster class enrollment"), never internal file names or program types.',
4860
+ '- Group related changes into one bullet instead of listing every item.',
4861
+ '- Neutral, professional tone. Never invent features that the change list does not imply.',
4862
+ '- Write in the SAME language as the program names below (e.g. Hebrew names -> Hebrew notes).',
4863
+ steer ? `- The developer added this guidance — follow it: ${steer}` : '',
4864
+ '',
4865
+ `Version: ${prev_version ? prev_version + ' -> ' : ''}${version || '(new)'}${build_type ? ` (${build_type} release)` : ''}`,
4866
+ first_build ? 'This is the FIRST published build. Write a brief "initial release" style note highlighting the main areas of the app.' : `Programs changed since the last build (${type_summary || 'general updates'}):`,
4867
+ items.length ? items.map((it) => `- ${it.name} (${it.type})`).join('\n') : '(No specific program changes were detected — write a brief note about minor improvements and stability fixes.)',
4868
+ ].filter(Boolean);
4869
+
4870
+ // account_profile_info is REQUIRED for usage to be metered: record_ai_usage
4871
+ // throws (and skips broadcast_credits, so the nav meter never moves) without
4872
+ // it. Fetch it like classify_external_app_scan / the chat flows do.
4873
+ const account_profile_info = await get_active_account_profile_info(uid);
4874
+
4875
+ const ret = await submit_chat_gpt_prompt({
4876
+ uid,
4877
+ prompt: parts.join('\n'),
4878
+ model: _conf.release_notes?.model || _conf.default_ai_model,
4879
+ metadata: { func: 'generate_release_notes', app_id: app_ref, version },
4880
+ account_profile_info,
4881
+ });
4882
+ if (ret.code < 0) return { code: -1, data: ret.data };
4883
+
4884
+ const notes = String(ret.data || '').trim();
4885
+ if (!notes) return { code: -1, data: 'no notes generated' };
4886
+ return { code: 1, data: { notes, changed_count: changed.length, since_build: last_build, version } };
4887
+ };
4888
+
4740
4889
  function getFirstNWords(text, n = 10) {
4741
4890
  // Split on whitespace, keep punctuation attached to words
4742
4891
  const words = text.match(/\S+/g) || [];
@@ -5823,17 +5972,44 @@ Rules:
5823
5972
  - If you do not need clarification, do not emit the block. Only emit it when answering is genuinely blocked on missing information.
5824
5973
  `.trim();
5825
5974
 
5826
- const extract_xuda_questions = function (text) {
5827
- if (typeof text !== 'string') return { prose: text, questions: null };
5828
- const match = text.match(/<xuda-questions>\s*([\s\S]+?)\s*<\/xuda-questions>\s*$/);
5829
- if (!match) return { prose: text, questions: null };
5830
- let parsed;
5831
- try {
5832
- parsed = JSON.parse(match[1]);
5833
- } catch (e) {
5834
- return { prose: text, questions: null };
5975
+ // Find every top-level balanced [...] span in a string, skipping brackets that
5976
+ // appear inside JSON string literals (and their escapes). Used to recover a
5977
+ // questions payload the model emitted without any wrapper.
5978
+ const _scan_json_arrays = function (text) {
5979
+ const out = [];
5980
+ for (let i = 0; i < text.length; i++) {
5981
+ if (text[i] !== '[') continue;
5982
+ let depth = 0;
5983
+ let in_str = false;
5984
+ let esc = false;
5985
+ for (let j = i; j < text.length; j++) {
5986
+ const ch = text[j];
5987
+ if (in_str) {
5988
+ if (esc) esc = false;
5989
+ else if (ch === '\\') esc = true;
5990
+ else if (ch === '"') in_str = false;
5991
+ continue;
5992
+ }
5993
+ if (ch === '"') {
5994
+ in_str = true;
5995
+ continue;
5996
+ }
5997
+ if (ch === '[' || ch === '{') depth++;
5998
+ else if (ch === ']' || ch === '}') {
5999
+ depth--;
6000
+ if (depth <= 0) {
6001
+ if (depth === 0) out.push({ start: i, end: j + 1, raw: text.slice(i, j + 1) });
6002
+ i = j; // skip past this span so nested arrays are not re-scanned
6003
+ break;
6004
+ }
6005
+ }
6006
+ }
5835
6007
  }
5836
- if (!Array.isArray(parsed) || !parsed.length) return { prose: text, questions: null };
6008
+ return out;
6009
+ };
6010
+
6011
+ const _validate_xuda_questions = function (parsed) {
6012
+ if (!Array.isArray(parsed) || !parsed.length) return null;
5837
6013
  const validated = parsed
5838
6014
  .map((q) => {
5839
6015
  if (!q || typeof q.question !== 'string') return null;
@@ -5847,11 +6023,61 @@ const extract_xuda_questions = function (text) {
5847
6023
  return { question: q.question.trim(), options };
5848
6024
  })
5849
6025
  .filter(Boolean);
5850
- if (!validated.length) return { prose: text, questions: null };
5851
- return {
5852
- prose: text.slice(0, match.index).trim(),
5853
- questions: validated,
5854
- };
6026
+ return validated.length ? validated : null;
6027
+ };
6028
+
6029
+ const extract_xuda_questions = function (text) {
6030
+ if (typeof text !== 'string') return { prose: text, questions: null };
6031
+
6032
+ // Models do NOT reliably wrap the payload. In practice they drop the tags and
6033
+ // emit a bare array, or fence it as ```json, or add a closing remark AFTER the
6034
+ // block. The old pattern demanded the exact tags anchored to end-of-string, so
6035
+ // any of those shipped the raw JSON straight into the chat bubble. Accept the
6036
+ // three real-world shapes, most explicit first, and never anchor to the end:
6037
+ // take the LAST match and cut it out of the prose wherever it sits.
6038
+ const patterns = [
6039
+ /<xuda-questions>\s*([\s\S]+?)\s*<\/xuda-questions>/g,
6040
+ /```(?:xuda-questions|json)?\s*(\[[\s\S]*?\])\s*```/g,
6041
+ ];
6042
+
6043
+ for (const re of patterns) {
6044
+ let m;
6045
+ let last = null;
6046
+ while ((m = re.exec(text)) !== null) last = m;
6047
+ if (!last) continue;
6048
+ let parsed;
6049
+ try {
6050
+ parsed = JSON.parse(last[1]);
6051
+ } catch (e) {
6052
+ continue;
6053
+ }
6054
+ const validated = _validate_xuda_questions(parsed);
6055
+ if (!validated) continue;
6056
+ const prose = (text.slice(0, last.index) + text.slice(last.index + last[0].length)).trim();
6057
+ return { prose, questions: validated };
6058
+ }
6059
+
6060
+ // Last resort: an unwrapped array pasted straight into the prose, which is the
6061
+ // most common way this arrives. Regex cannot do this, because the nested
6062
+ // `options` arrays make a lazy match close on the inner "]" and a greedy one
6063
+ // swallow trailing prose, so scan for balanced brackets instead. Gated on the
6064
+ // structural keys plus full validation so ordinary JSON samples are ignored.
6065
+ const cands = _scan_json_arrays(text);
6066
+ for (let k = cands.length - 1; k >= 0; k--) {
6067
+ const cand = cands[k];
6068
+ if (!/"question"\s*:/.test(cand.raw) || !/"options"\s*:/.test(cand.raw)) continue;
6069
+ let parsed;
6070
+ try {
6071
+ parsed = JSON.parse(cand.raw);
6072
+ } catch (e) {
6073
+ continue;
6074
+ }
6075
+ const validated = _validate_xuda_questions(parsed);
6076
+ if (!validated) continue;
6077
+ return { prose: (text.slice(0, cand.start) + text.slice(cand.end)).trim(), questions: validated };
6078
+ }
6079
+
6080
+ return { prose: text, questions: null };
5855
6081
  };
5856
6082
 
5857
6083
  // Resolve the structured context object passed by newer clients (ProjectAiPanel
package/index_ms.mjs CHANGED
@@ -133,6 +133,10 @@ export const diagnose_vps_snapshot = async function (...args) {
133
133
  return await broker.send_to_queue("diagnose_vps_snapshot", ...args);
134
134
  };
135
135
 
136
+ export const generate_release_notes = async function (...args) {
137
+ return await broker.send_to_queue("generate_release_notes", ...args);
138
+ };
139
+
136
140
  export const create_openai_conversation = async function (...args) {
137
141
  return await broker.send_to_queue("create_openai_conversation", ...args);
138
142
  };
package/index_msa.mjs CHANGED
@@ -133,6 +133,10 @@ export const diagnose_vps_snapshot = function (...args) {
133
133
  broker.send_to_queue_async("diagnose_vps_snapshot", ...args);
134
134
  };
135
135
 
136
+ export const generate_release_notes = function (...args) {
137
+ broker.send_to_queue_async("generate_release_notes", ...args);
138
+ };
139
+
136
140
  export const create_openai_conversation = function (...args) {
137
141
  broker.send_to_queue_async("create_openai_conversation", ...args);
138
142
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/ai_module",
3
- "version": "1.1.5637",
3
+ "version": "1.1.5639",
4
4
  "description": "Xuda AI Module",
5
5
  "main": "index.mjs",
6
6
  "type": "module",