09883cfe by Ean Schuessler

Add UI narrative for LLM action guidance and unify render modes

- Create UiNarrativeBuilder class for structured, story-like UI descriptions
  - screen: 50-80 words describing current state
  - actions: 80-120 words with exact tool invocation examples
  - navigation: 30-40 words for navigating to related screens
  - notes: 30-50 words for truncation, permissions, constraints

- Enhance mcp.ftl macros to capture semantic metadata
  - form-single: Track field names, types, validation rules
  - form-list: Capture totalItems, displayedItems, truncated flags
  - Store metadata in mcpSemanticData for narrative generation

- Deprecate DefaultScreenMacros.json.ftl
  - Update MoquiConf.xml to map both mcp and json to mcp.ftl
  - Remove redundant 218-line template with no semantic capture

- Integrate uiNarrative into BrowseScreens service
  - Generate narrative using UiNarrativeBuilder
  - Include uiNarrative in result map
  - Provide screenDef for context

- Remove redundant semantic state extraction
  - Delete fallback logic that extracted forms/lists from screen definition
  - Rely exclusively on mcpSemanticData captured by macros

- Improve smart truncation in serializeMoquiObject
  - terse mode: 10 items, 200 char strings
  - non-terse mode: 50 items, no string truncation (fixed bug)
  - Add _hasMore flag to truncated data metadata

- Fix CustomScreenTestImpl postRenderContext capture
  - Capture context before pop to preserve mcpSemanticData
1 parent 61c34e23
#Wed Nov 26 15:36:44 CST 2025
gradle.version=8.9
#Fri Jan 02 17:15:36 CST 2026
gradle.version=9.2.0
......
......@@ -2,7 +2,7 @@ arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/o
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
connection.project.dir=
connection.project.dir=../../../framework
eclipse.preferences.version=1
gradle.user.home=
java.home=/usr/lib/jvm/java-21-openjdk-amd64
......
......@@ -28,10 +28,11 @@
</webapp-list>
<screen-facade>
<!-- DEPRECATED: json mode now uses mcp template for unified semantic capture -->
<screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true"
macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/>
<screen-text-output type="json" mime-type="application/json" always-standalone="true"
macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.json.ftl"/>
macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/>
<widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/>
<widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/>
</screen-facade>
......
<#--
Moqui MCP JSON Macros
Renders screens in structured JSON format for LLM consumption.
-->
<#include "DefaultScreenMacros.any.ftl"/>
<#macro @element></#macro>
<#macro screen>{"screen": {<#recurse>}}</#macro>
<#macro widgets>
"widgets": [<#recurse>]
</#macro>
<#macro "fail-widgets"><#recurse></#macro>
<#-- ================ Subscreens ================ -->
<#macro "subscreens-menu"></#macro>
<#macro "subscreens-active">{"type": "subscreens-active", "content": ${sri.renderSubscreen()}}</#macro>
<#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro>
<#-- ================ Section ================ -->
<#macro section>{"type": "section", "name": ${(.node["@name"]!"")?json_string}, "content": ${sri.renderSection(.node["@name"])}}</#macro>
<#macro "section-iterate">${sri.renderSection(.node["@name"])}</#macro>
<#macro "section-include">${sri.renderSectionInclude(.node)}</#macro>
<#-- ================ Containers ================ -->
<#macro container>
<#assign children = []>
<#list .node?children as child>
<#assign rendered><#recurse child></#assign>
<#if rendered?has_content && !(rendered?starts_with("{\"widgets\""))>
<#assign children = children + [rendered]>
</#if>
</#list>
{"type": "container", "children": [${children?join(",")}]}
</#macro>
<#macro "container-box">
{"type": "container-box"<#if .node["box-header"]?has_content>, "header": ${.node["box-header"][0]["@label"]!?json_string}</#if><#if .node["box-body"]?has_content>, "body": ${.node["box-body"][0]["@label"]!?json_string}</#if>}
</#macro>
<#macro "container-row"><#list .node["row-col"] as rowColNode><#recurse rowColNode></#list></#macro>
<#macro "container-panel">
{"type": "container-panel"<#if .node["panel-header"]?has_content>, "header": ${.node["panel-header"][0]["@label"]!?json_string}</#if>}
</#macro>
<#macro "container-dialog">
{"type": "container-dialog", "buttonText": ${ec.resource.expand(.node["@button-text"], "")?json_string}}
</#macro>
<#-- ================== Standalone Fields ==================== -->
<#macro link>
<#assign linkNode = .node>
<#if linkNode["@condition"]?has_content><#assign conditionResult = ec.getResource().condition(linkNode["@condition"], "")><#else><#assign conditionResult = true></#if>
<#if conditionResult>
<#assign urlInstance = sri.makeUrlByType(linkNode["@url"]!"", linkNode["@url-type"]!"transition", linkNode, "true")>
<#assign linkText = "">
<#if linkNode["@text"]?has_content>
<#assign linkText = ec.getResource().expand(linkNode["@text"], "")>
<#elseif linkNode["@entity-name"]?has_content>
<#assign linkText = sri.getFieldEntityValue(linkNode)!""?string>
</#if>
<#if !(linkText?has_content) && .node?parent?node_name?ends_with("-field")>
<#assign linkText = sri.getFieldValueString(.node?parent?parent)!>
</#if>
<#-- Convert path to dot notation for moqui_render_screen -->
<#assign fullPath = urlInstance.sui.fullPathNameList![]>
<#assign dotPath = "">
<#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list>
<#assign paramStr = urlInstance.getParameterString()!"">
<#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if>
{"type": "link", "text": ${linkText?json_string}, "path": ${dotPath?json_string}}
</#if>
</#macro>
<#macro image>{"type": "image", "alt": ${(.node["@alt"]!"")?json_string}, "url": ${(.node["@url"]!"")?json_string}}</#macro>
<#macro label>
<#assign text = ec.resource.expand(.node["@text"], "")>
<#assign type = .node["@type"]!"span">
{"type": "label", "text": ${text?json_string}, "labelType": ${type?json_string}}
</#macro>
<#-- ======================= Form ========================= -->
<#macro "form-single">
<#assign formNode = sri.getFormNode(.node["@name"])>
<#assign mapName = formNode["@map"]!"fieldValues">
<#assign fields = []>
<#t>${sri.pushSingleFormMapContext(mapName)}
<#list formNode["field"] as fieldNode>
<#assign fieldSubNode = "">
<#list fieldNode["conditional-field"] as csf><#if ec.resource.condition(csf["@condition"], "")><#assign fieldSubNode = csf><#break></#if></#list>
<#if !(fieldSubNode?has_content)><#assign fieldSubNode = fieldNode["default-field"][0]!></#if>
<#if fieldSubNode?has_content && !(fieldSubNode["ignored"]?has_content) && !(fieldSubNode["hidden"]?has_content) && !(fieldSubNode["submit"]?has_content) && fieldSubNode?parent["@hide"]! != "true">
<#assign fieldValue = ec.context.get(fieldSubNode?parent["@name"])!"">
<#if fieldValue?has_content>
<#assign fieldInfo = {"name": (fieldSubNode?parent["@name"]!"")?json_string, "value": (fieldValue!?json_string)}>
<#assign fields = fields + [fieldInfo]>
</#if>
</#if>
</#list>
<#t>${sri.popContext()}
{"type": "form-single", "name": ${formNode["@name"]?json_string}, "map": ${mapName?json_string}, "fields": [${fields?join(",")}]}
</#macro>
<#macro "form-list">
<#assign formInstance = sri.getFormInstance(.node["@name"])>
<#assign formListInfo = formInstance.makeFormListRenderInfo()>
<#assign formNode = formListInfo.getFormNode()>
<#assign formListColumnList = formListInfo.getAllColInfo()>
<#assign listObject = formListInfo.getListObject(false)!>
{"type": "form-list", "name": ${.node["@name"]?json_string}}
</#macro>
<#macro formListSubField fieldNode>
<#list fieldNode["conditional-field"] as fieldSubNode>
<#if ec.resource.condition(fieldSubNode["@condition"], "")>
{"type": "field", "name": ${fieldSubNode["@name"]?json_string}}
<#return>
</#if>
</#list>
</#macro>
<#macro formListWidget fieldSubNode>
<#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if>
<#if fieldSubNode["submit"]?has_content>
<#assign submitText = sri.getFieldValueString(fieldSubNode)!""?json_string>
<#assign screenName = sri.getEffectiveScreen().name!""?string>
<#assign formNodeObj = sri.getFormNode(.node["@name"])!"">
<#assign formName = formNodeObj["@name"]!?string>
<#assign fieldName = fieldSubNode["@name"]!""?string>
{"type": "submit", "text": ${submitText}, "action": "${screenName}.${formName}.${fieldName}"}
</#if>
<#recurse fieldSubNode>
</#macro>
<#macro fieldTitle fieldSubNode>
<#assign titleValue><#if fieldSubNode["@title"]?has_content>${fieldSubNode["@title"]}<#else><#list fieldSubNode?parent["@name"]?split("(?=[A-Z])", "r") as nameWord>${nameWord?cap_first?replace("Id", "ID")}<#if nameWord_has_next> </#if></#list></#if></#assign>
${ec.l10n.localize(titleValue)?json_string}
</#macro>
<#-- ================== Form Field Widgets ==================== -->
<#macro "check">
<#assign options = sri.getFieldOptions(.node)!>
<#assign currentValue = sri.getFieldValueString(.node)!"">
{"type": "check", "value": ${(options.get(currentValue)!currentValue)?json_string}}
</#macro>
<#macro "date-find"></#macro>
<#macro "date-time">
<#assign javaFormat = .node["@format"]!"">
<#if !(javaFormat?has_content)>
<#if .node["@type"]! == "time"><#assign javaFormat="HH:mm">
<#elseif .node["@type"]! == "date"><#assign javaFormat="yyyy-MM-dd">
<#else><#assign javaFormat="yyyy-MM-dd HH:mm"></#if>
</#if>
<#assign fieldValue = sri.getFieldValueString(.node?parent?parent, .node["@default-value"]!"", javaFormat)!"">
{"type": "date-time", "name": ${(.node["@name"]!"")?json_string}, "format": ${javaFormat?json_string}, "value": ${fieldValue?json_string!"null"}}
</#macro>
<#macro "display">
<#assign fieldValue = "">
<#assign dispFieldNode = .node?parent?parent>
<#if .node["@text"]?has_content>
<#assign textMap = {}>
<#if .node["@text-map"]?has_content><#assign textMap = ec.getResource().expression(.node["@text-map"], {})!></#if>
<#assign fieldValue = ec.getResource().expand(.node["@text"], "", textMap, false)!>
<#if .node["@currency-unit-field"]?has_content>
<#assign fieldValue = ec.getL10n().formatCurrency(fieldValue, ec.getResource().expression(.node["@currency-unit-field"], ""))!"">
</#if>
<#else>
<#assign fieldValue = sri.getFieldValueString(.node)!"">
</#if>
{"type": "display", "value": ${fieldValue?json_string}}
</#macro>
<#macro "display-entity">
<#assign entityValue = sri.getFieldEntityValue(.node)!"">
{"type": "display-entity", "value": ${entityValue?json_string}}
</#macro>
<#macro "drop-down">
<#assign options = sri.getFieldOptions(.node)!>
<#assign currentValue = sri.getFieldValueString(.node)!"">
{"type": "drop-down", "value": ${(options.get(currentValue)!currentValue)?json_string}}
</#macro>
<#macro "text-area">
<#assign fieldValue = sri.getFieldValueString(.node)!"">
{"type": "text-area", "value": ${fieldValue?json_string}}
</#macro>
<#macro "text-line">
<#assign fieldValue = sri.getFieldValueString(.node)!"">
{"type": "text-line", "value": ${fieldValue?json_string}}
</#macro>
<#macro "text-find">
<#assign fieldValue = sri.getFieldValueString(.node)!"">
{"type": "text-find", "value": ${fieldValue?json_string}}
</#macro>
<#macro "submit">
<#assign text = ec.resource.expand(.node["@text"], "")!"">
{"type": "submit", "text": ${text?json_string}}
</#macro>
<#macro "password"></#macro>
<#macro "hidden"></#macro>
......@@ -75,6 +75,11 @@
<#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if>
[${linkText}](${dotPath})<#t>
<#if mcpSemanticData??>
<#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if>
<#assign linkInfo = {"text": linkText, "path": dotPath, "type": "navigation"}>
<#assign dummy = mcpSemanticData.links.add(linkInfo)>
</#if>
</#if>
</#macro>
......@@ -94,6 +99,19 @@
<#macro "form-single">
<#assign formNode = sri.getFormNode(.node["@name"])>
<#assign mapName = formNode["@map"]!"fieldValues">
<#assign formMap = ec.resource.expression(mapName, "")!>
<#if mcpSemanticData??>
<#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})</#if>
<#assign formMeta = {}>
<#assign formMeta = formMeta + {"name": .node["@name"]!"", "map": mapName}>
<#assign fieldMetaList = []>
<#assign dummy = mcpSemanticData.formMeta.put(.node["@name"], formMeta)>
</#if>
<#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if>
<#t>${sri.pushSingleFormMapContext(mapName)}
<#list formNode["field"] as fieldNode>
<#assign fieldSubNode = "">
......@@ -101,9 +119,28 @@
<#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if>
<#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true">
<#assign title><@fieldTitle fieldSubNode/></#assign>
<#if mcpSemanticData??>
<#assign fieldMeta = {}>
<#assign fieldMeta = fieldMeta + {"name": fieldNode["@name"]!"", "title": title!"", "required": (fieldNode["@required"]! == "true")}>
<#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if>
<#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if>
<#if fieldSubNode["drop-down"]?has_content><#assign dummy = fieldMeta.put("type", "dropdown")></#if>
<#if fieldSubNode["check"]?has_content><#assign dummy = fieldMeta.put("type", "checkbox")></#if>
<#if fieldSubNode["date-find"]?has_content><#assign dummy = fieldMeta.put("type", "date")></#if>
<#assign dummy = fieldMetaList.add(fieldMeta)>
</#if>
* **${title}**: <#recurse fieldSubNode>
</#if>
</#list>
<#if mcpSemanticData?? && fieldMetaList?has_content>
<#assign dummy = mcpSemanticData.formMeta[.node["@name"]!].put("fields", fieldMetaList)>
</#if>
<#t>${sri.popContext()}
</#macro>
......@@ -113,6 +150,31 @@
<#assign formNode = formListInfo.getFormNode()>
<#assign formListColumnList = formListInfo.getAllColInfo()>
<#assign listObject = formListInfo.getListObject(false)!>
<#assign totalItems = listObject?size>
<#if mcpSemanticData?? && listObject?has_content>
<#assign truncatedList = listObject>
<#if listObject?size > 50>
<#assign truncatedList = listObject?take(50)>
</#if>
<#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)>
<#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})</#if>
<#assign columnNames = []>
<#list formListColumnList as columnFieldList>
<#assign fieldNode = columnFieldList[0]>
<#assign dummy = columnNames.add(fieldNode["@name"]!"")>
</#list>
<#assign dummy = mcpSemanticData.listMeta.put(.node["@name"]!"", {
"name": .node["@name"]!"",
"totalItems": totalItems,
"displayedItems": truncatedList?size,
"truncated": (listObject?size > 50),
"columns": columnNames
})>
</#if>
<#-- Header Row -->
<#list formListColumnList as columnFieldList>
......@@ -123,7 +185,7 @@
|
<#list formListColumnList as columnFieldList>| --- </#list>|
<#-- Data Rows -->
<#list listObject as listEntry>
<#list (truncatedList?? && truncatedList?size > 0)!listObject as listEntry>
<#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)}
<#list formListColumnList as columnFieldList>
<#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t>
......
......@@ -175,15 +175,19 @@
try {
// Consolidated Tool Dispatching
ec.logger.info("MCP ToolsCall: Dispatching tool name=${name}, arguments=${arguments}")
ec.logger.info("MCP ToolsCall: CODE VERSION: 2025-01-09 - FIXED NULL CHECK")
if (name == "moqui_render_screen") {
def screenPath = arguments?.path
def parameters = arguments?.parameters ?: [:]
def renderMode = arguments?.renderMode ?: "mcp"
def subscreenName = arguments?.subscreenName
def terse = arguments?.terse ?: false
if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter")
ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}")
ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}, terse=${terse}")
// Strip query parameters from path if present
if (screenPath.contains("?")) {
......@@ -194,7 +198,9 @@
def resolvedPath = screenPath
def resolvedSubscreen = subscreenName
if (!resolvedPath.startsWith("component://")) {
ec.logger.info("MCP ToolsCall: Starting path resolution, screenPath=${screenPath}, resolvedPath=${resolvedPath}")
if (resolvedPath && !resolvedPath.startsWith("component://")) {
// Simple dot notation or path conversion
// Longest prefix match for XML screen files
def pathParts = resolvedPath.split('\\.')
......@@ -229,10 +235,11 @@
}
def screenCallParams = [
screenPath: resolvedPath,
path: resolvedPath,
parameters: parameters,
renderMode: renderMode,
sessionId: sessionId
sessionId: sessionId,
terse: terse
]
if (resolvedSubscreen) screenCallParams.subscreenName = resolvedSubscreen
......@@ -706,12 +713,13 @@
<service verb="execute" noun="ScreenAsMcpTool" authenticate="true" allow-remote="true" transaction-timeout="120">
<description>Execute a screen as an MCP tool</description>
<in-parameters>
<parameter name="screenPath" required="true"/>
<parameter name="path" required="true"/>
<parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter>
<parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter>
<parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter>
<parameter name="sessionId"><description>Session ID for user context restoration</description></parameter>
<parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter>
<parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
......@@ -778,6 +786,94 @@ def getWikiInstructions = { screenPath ->
return null
}
// Optimized recursive serializer for Moqui/Java objects to JSON-friendly Map/List
// When terse=true, returns minimal data with truncation metadata for easy access to full version
def serializeMoquiObject
serializeMoquiObject = { obj, depth = 0, isTerse = false ->
if (depth > 5) return "..." // Prevent deep recursion
if (obj == null) return null
if (obj instanceof Map) {
def newMap = [:]
obj.each { k, v ->
def keyStr = k.toString()
// Skip internal framework keys and metadata fields
if (keyStr.startsWith("_") || keyStr == "ec" || keyStr == "sri") return
// Skip audit fields to reduce payload
if (keyStr in ["lastUpdatedStamp", "lastUpdatedTxStamp", "createdDate", "createdTxStamp", "createdByUserLogin"]) return
def value = serializeMoquiObject(v, depth + 1, isTerse)
if (value != null) newMap[keyStr] = value
}
return newMap
}
if (obj instanceof Iterable) {
def list = obj.collect()
// Apply truncation only if terse mode is enabled
if (isTerse && list.size() > 10) {
ec.logger.info("serializeMoquiObject: Terse mode - truncating list from ${list.size()} to 10 items")
def truncated = list.take(10)
def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
return [
_items: resultList,
_totalCount: list.size(),
_truncated: true,
_hasMore: true,
_message: "Terse mode: showing first 10 of ${list.size()} items. Set terse=false to get full data."
]
}
// Full data in non-terse mode (but still apply depth protection)
def maxItems = isTerse ? 10 : 50
def truncated = list.take(maxItems)
def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) }
if (!isTerse && list.size() > 50) {
ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to 50 items for size limits")
return [
_items: resultList,
_totalCount: list.size(),
_truncated: true,
_hasMore: true,
_message: "Non-terse mode: showing first 50 of ${list.size()} items (size limit reached)"
]
}
return resultList
}
if (obj instanceof org.moqui.entity.EntityValue) {
return serializeMoquiObject(obj.getMap(), depth + 1, isTerse)
}
if (obj instanceof java.sql.Timestamp || obj instanceof java.util.Date) {
return obj.toString()
}
if (obj instanceof Number || obj instanceof Boolean) {
return obj
}
if (obj instanceof String) {
// Apply truncation only if terse mode is enabled
if (isTerse && obj.length() > 200) {
return [
_value: obj.substring(0, 200) + "...",
_fullLength: obj.length(),
_truncated: true,
_message: "Terse mode: truncated to 200 chars. Set terse=false to get full data."
]
}
// Full data in non-terse mode (no string truncation)
return obj
}
if (obj.getClass().getName().startsWith("org.moqui.impl.screen.ScreenDefinition")) {
return [location: obj.location]
}
// Fallback for unknown types - truncate if too long
def str = obj.toString()
if (str.length() > 200) {
return [
_value: str.substring(0, 200) + "...",
_fullLength: str.length(),
_truncated: true
]
}
return str
}
// Resolve input screen path to simple path for lookup
def inputScreenPath = screenPath
if (screenPath.startsWith("component://")) {
......@@ -788,16 +884,13 @@ ec.logger.info("MCP Screen Execution: Looking up wiki docs for ${inputScreenPath
// Try to get wiki instructions
def wikiInstructions = getWikiInstructions(inputScreenPath)
// Check if action is being processed - use real screen rendering if so
def isActionExecution = parameters?.action != null
// Try to render screen content for LLM consumption
def output = null
def screenUrl = "http://localhost:8080/${screenPath}"
def isError = false
try {
ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen, action=${parameters?.action}")
ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen")
def testScreenPath = screenPath
def rootScreen = "component://webroot/screen/webroot.xml"
......@@ -806,10 +899,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
def isStandalone = false
if (screenPath.startsWith("component://")) {
def pathAfterComponent = screenPath.substring(12).replace('.xml','') // Remove "component://"
def pathParts = pathAfterComponent.split("/")
def pathAfterComponent = screenPath.substring(12).replace('.xml','')
// Check if target screen itself is standalone
try {
targetScreenDef = ec.screen.getScreenDefinition(screenPath)
if (targetScreenDef?.screenNode) {
......@@ -826,13 +917,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
}
if (!isStandalone) {
// Check if screen path itself is a valid screen definition
try {
if (ec.screen.getScreenDefinition(screenPath)) {
rootScreen = screenPath
testScreenPath = ""
} else {
// Original component root logic
if (pathAfterComponent.startsWith("webroot/screen/")) {
rootScreen = "component://webroot/screen/webroot.xml"
testScreenPath = pathAfterComponent.substring("webroot/screen/".length())
......@@ -852,10 +941,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
testScreenPath = ""
}
// Get final screen definition for MCP data extraction
// Get final screen definition for data extraction
def finalScreenDef = rootScreen ? ec.screen.getScreenDefinition(rootScreen) : null
if (finalScreenDef && testScreenPath) {
// Navigate to subscreen
def pathSegments = testScreenPath.split('/')
for (segment in pathSegments) {
if (finalScreenDef) {
......@@ -869,63 +957,6 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
}
}
// Extract MCP-specific data when renderMode is "mcp" or "json"
def mcpData = [:]
if ((renderMode == "mcp" || renderMode == "json") && finalScreenDef) {
ec.logger.info("MCP Screen Execution: Extracting MCP data for ${screenPath}")
// Extract parameters
if (finalScreenDef.parameterByName) {
mcpData.parameters = [:]
finalScreenDef.parameterByName.each { name, param ->
def value = ec.context.get(name) ?: parameters?.get(name)
mcpData.parameters[name] = [name: name, value: value, type: "parameter"]
}
}
// Extract forms and their fields
if (finalScreenDef.formByName) {
mcpData.forms = []
finalScreenDef.formByName.each { formName, form ->
def formInfo = [name: formName, fields: []]
def formNode = form.internalFormNode
if (formNode) {
// Extract field elements
def fields = formNode.children('field')
fields.each { field ->
def fieldName = field.attribute('name')
if (fieldName && field) {
def fieldInfo = [name: fieldName]
if (field.getName()) fieldInfo.type = field.getName()
def value = ec.context.get(fieldName) ?: parameters?.get(fieldName)
if (value) fieldInfo.value = value
// Check if it's a widget with options
if (field.'drop-down' || field.'check' || field.'radio') {
fieldInfo.widgetType = "selection"
}
formInfo.fields << fieldInfo
}
}
}
if (formInfo.fields) mcpData.forms << formInfo
}
}
// Extract transitions (actions like "Update" buttons)
ec.logger.info("MCP Data Extraction: renderMode=${renderMode}, hasScreenDef=${finalScreenDef != null}, hasTransition=${finalScreenDef?.hasTransition(null)}")
if (finalScreenDef.hasTransition(null)) {
mcpData.actions = []
finalScreenDef.getTransitionList().each { trans ->
mcpData.actions << [
name: trans.name,
service: trans.xmlTransition ? trans.xmlTransition.attribute('service') : null,
description: trans.description
]
}
}
}
// Regular screen rendering with current user context - use our custom ScreenTestImpl
def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi)
.rootScreen(rootScreen)
......@@ -941,30 +972,68 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
def testRender = screenTest.render(relativePath, renderParams, "POST")
output = testRender.getOutput()
ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
} catch (Exception e) {
isError = true
ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
output = "SCREEN RENDERING ERROR: ${e.message}\n\nTroubleshooting Suggestions:\n1. Check if the screen path is correct\n2. Verify user permissions\n3. Check server logs"
// --- NEW: Semantic State Extraction ---
def postContext = testRender.getPostRenderContext()
def semanticState = [:]
def isTerse = context.terse == true
if (finalScreenDef && postContext) {
semanticState.screenPath = inputScreenPath
semanticState.terse = isTerse
semanticState.data = [:]
// Use the explicit semantic data captured by macros if available
def explicitData = postContext.get("mcpSemanticData")
if (explicitData instanceof Map) {
explicitData.each { k, v ->
semanticState.data[k] = serializeMoquiObject(v, 0, isTerse)
}
}
// Extract transitions (Actions) with metadata (from screen definition, not macros)
semanticState.actions = []
finalScreenDef.getAllTransitions().each { trans ->
def actionInfo = [
name: trans.getName(),
service: trans.getSingleServiceName()
]
semanticState.actions << actionInfo
}
// 3. Extract parameters that are currently set
semanticState.parameters = [:]
if (finalScreenDef.parameterByName) {
finalScreenDef.parameterByName.each { name, param ->
def value = postContext.get(name) ?: parameters?.get(name)
if (value != null) semanticState.parameters[name] = serializeMoquiObject(value, 0, isTerse)
}
}
// Log semantic state size for optimization tracking
def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString()
def semanticStateSize = semanticStateJson.length()
ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}")
}
ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}")
def executionTime = (System.currentTimeMillis() - startTime) / 1000.0
// Build result based on renderMode
def content = []
if ((renderMode == "mcp" || renderMode == "json") && mcpData) {
if ((renderMode == "mcp" || renderMode == "json") && semanticState) {
// Return structured MCP data
def mcpResult = [
screenPath: screenPath,
screenUrl: screenUrl,
executionTime: executionTime,
isError: isError
isError: isError,
semanticState: semanticState
]
if (mcpData.parameters) mcpResult.parameters = mcpData.parameters
if (mcpData.forms) mcpResult.forms = mcpData.forms
if (mcpData.actions) mcpResult.actions = mcpData.actions
if (output) mcpResult.htmlPreview = output.take(2000) + (output.length() > 2000 ? "..." : "")
// Truncate text preview to 500 chars to save tokens, since we have structured data
if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "")
if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions
content << [
......@@ -972,7 +1041,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
text: new groovy.json.JsonBuilder(mcpResult).toString()
]
} else {
// Return raw output for other modes
// Return raw output for other modes (text, html, etc)
def textOutput = output
if (wikiInstructions) {
textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}"
......@@ -991,11 +1060,20 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
content: content,
isError: false
]
return // Success!
ec.logger.info("MCP Screen Execution: Returned result for screen ${screenPath} in ${executionTime}s")
} catch (Exception e) {
isError = true
ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e)
output = "SCREEN RENDERING ERROR: ${e.message}"
result = [
isError: true,
content: [[type: "text", text: output]]
]
}
]]></script>
</actions>
</service>
</actions>
</service>
<service verb="mcp" noun="ResourcesTemplatesList" authenticate="false" allow-remote="true" transaction-timeout="30">
<description>Handle MCP resources/templates/list request</description>
......@@ -1274,6 +1352,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
<parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter>
<parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter>
<parameter name="sessionId"/>
<parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter>
</in-parameters>
<out-parameters>
<parameter name="result" type="Map"/>
......@@ -1667,7 +1746,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
screenPath: screenPath,
parameters: renderParams,
renderMode: actualRenderMode,
sessionId: sessionId
sessionId: sessionId,
terse: context.terse == true
]
if (subscreenName) screenCallParams.subscreenName = subscreenName
......@@ -1676,18 +1756,51 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
.parameters(screenCallParams)
.call()
// Extract rendered content from result
// ScreenAsMcpTool returns {content: [{type: "text", text: "...", ...}]}
ec.logger.info("BrowseScreens: serviceResult keys: ${serviceResult?.keySet()}, has content: ${serviceResult?.containsKey('content')}, has result: ${serviceResult?.containsKey('result')}")
// Extract rendered content and semantic state from result
if (serviceResult) {
def resultObj = null
if (serviceResult.containsKey('content') && serviceResult.content && serviceResult.content.size() > 0) {
renderedContent = serviceResult.content[0].text
ec.logger.info("BrowseScreens: Extracted content from serviceResult.content[0].text")
def rawText = serviceResult.content[0].text
if (rawText && rawText.startsWith("{")) {
try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {}
}
renderedContent = rawText
} else if (serviceResult.containsKey('result') && serviceResult.result && serviceResult.result.content && serviceResult.result.content.size() > 0) {
renderedContent = serviceResult.result.content[0].text
ec.logger.info("BrowseScreens: Extracted content from serviceResult.result.content[0].text")
} else {
ec.logger.info("BrowseScreens: serviceResult structure: ${serviceResult}, result content size: ${serviceResult?.result?.content?.size()}")
def rawText = serviceResult.result.content[0].text
if (rawText && rawText.startsWith("{")) {
try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {}
}
renderedContent = rawText
}
if (resultObj && resultObj.semanticState) {
resultMap.semanticState = resultObj.semanticState
// Build UI narrative for LLM guidance
try {
def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder()
// Get screen definition for narrative building
def screenDefForNarrative = null
if (screenPath) {
screenDefForNarrative = ec.screen.getScreenDefinition(screenPath)
}
def uiNarrative = narrativeBuilder.buildNarrative(
screenDefForNarrative,
resultObj.semanticState,
currentPath,
context.terse == true
)
resultMap.uiNarrative = uiNarrative
ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}")
} catch (Exception e) {
ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}")
}
// If we have semantic state, we can truncate the rendered content to save tokens
if (renderedContent && renderedContent.length() > 500) {
renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)"
}
}
}
......@@ -1947,7 +2060,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
properties: [
"path": [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"],
"parameters": [type: "object", description: "Parameters for the screen"],
"renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"]
"renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"],
"terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"]
],
required: ["path"]
]
......@@ -1962,7 +2076,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
"path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"],
"action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"],
"renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"],
"parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"]
"parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"],
"terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"]
]
]
],
......
......@@ -291,6 +291,11 @@ class CustomScreenTestImpl implements McpScreenTest {
// push the context
ContextStack cs = eci.getContext()
cs.push()
// Create a persistent map for semantic data that survives nested pops
Map<String, Object> mcpSemanticData = new HashMap<>()
cs.put("mcpSemanticData", mcpSemanticData)
// create the WebFacadeStub using our custom method
org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath)
// set stub on eci, will also put parameters in the context
......@@ -336,8 +341,10 @@ class CustomScreenTestImpl implements McpScreenTest {
// calc renderTime
stri.renderTime = System.currentTimeMillis() - startTime
// capture everything currently in the context stack before popping
stri.postRenderContext = new HashMap<>(cs)
// pop the context stack, get rid of var space
stri.postRenderContext = cs.pop()
cs.pop()
// check, pass through, error messages
if (eci.message.hasError()) {
......
/*
* This software is in public domain under CC0 1.0 Universal plus a
* Grant of Patent License.
*
* To the extent possible under law, author(s) have dedicated all
* copyright and related and neighboring rights to this software to the
* public domain worldwide. This software is distributed without any
* warranty.
*
* You should have received a copy of the CC0 Public Domain Dedication
* along with this software (see the LICENSE.md file). If not, see
* <http://creativecommons.org/publicdomain/zero/1.0/>.
*/
package org.moqui.mcp
import org.moqui.impl.screen.ScreenDefinition
import org.slf4j.Logger
import org.slf4j.LoggerFactory
/**
* Builds UI narrative for MCP screen responses.
* Creates structured, story-like descriptions that guide LLM on how to invoke actions.
*/
class UiNarrativeBuilder {
protected final static Logger logger = LoggerFactory.getLogger(UiNarrativeBuilder.class)
private int countForms(Map semanticState) {
if (!semanticState?.data) return 0
int count = 0
semanticState.data.keySet().each { k ->
if (k.toString().toLowerCase().contains('form')) {
count++
}
}
return count
}
private int countLists(Map semanticState) {
if (!semanticState?.data) return 0
int count = 0
semanticState.data.keySet().each { k ->
if (k.toString().toLowerCase().contains('list')) {
count++
}
}
return count
}
Map<String, Object> buildNarrative(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) {
def narrative = [:]
narrative.screen = describeScreen(screenDef, semanticState, isTerse)
narrative.actions = describeActions(screenDef, semanticState, currentPath, isTerse)
narrative.navigation = describeLinks(semanticState, currentPath, isTerse)
narrative.notes = describeNotes(semanticState, isTerse)
return narrative
}
String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState, boolean isTerse) {
def screenName = screenDef?.name ?: "Screen"
def sb = new StringBuilder()
sb.append("${screenName} displays ")
def formCount = countForms(semanticState)
def listCount = countLists(semanticState)
def itemCount = countItems(semanticState)
if (listCount > 0 && itemCount > 0) {
sb.append("${itemCount} item${itemCount > 1 ? 's' : ''} in ${listCount} list${listCount > 1 ? 's' : ''}")
if (formCount > 0) sb.append(" with a search form")
} else if (formCount > 0) {
sb.append("a form with ${formCount} field${formCount > 1 ? 's' : ''}")
} else {
sb.append("information")
}
sb.append(". ")
def forms = semanticState?.data
if (forms) {
def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }[0..2]
if (formNames) {
def fields = getFormFieldNames(forms, formNames[0])
if (fields) {
sb.append("Form contains: ${fields.join(', ')}. ")
}
}
}
def links = semanticState?.data?.links
if (links && links.size() > 0) {
def linkTypes = links.collect { l -> l.type?.toString() ?: 'navigation' }.unique()
if (linkTypes) {
sb.append("Available links: ${linkTypes.take(3).join(', ')}. ")
}
}
return sb.toString()
}
List<String> describeActions(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) {
def actions = []
def transitions = semanticState?.actions
if (transitions) {
transitions.each { trans ->
def transName = trans.name?.toString()
def service = trans.service?.toString()
if (transName) {
if (service) {
actions << buildServiceActionNarrative(transName, service, currentPath, semanticState)
} else if (transName.toLowerCase().startsWith('create') || transName.toLowerCase().startsWith('update')) {
actions << buildTransitionActionNarrative(transName, currentPath, semanticState)
}
}
}
}
def forms = semanticState?.data
if (forms) {
def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }
formNames.each { formName ->
actions << buildFormSubmitNarrative(formName, currentPath, semanticState)
}
}
if (actions.isEmpty()) {
actions << "No explicit actions available on this screen. Use navigation links to explore."
}
return actions
}
List<String> describeLinks(Map<String, Object> semanticState, String currentPath, boolean isTerse) {
def navigation = []
def links = semanticState?.data?.links
if (links && links.size() > 0) {
def sortedLinks = links.sort { a, b -> (a.text <=> b.text) }
sortedLinks.take(5).each { link ->
def linkText = link.text?.toString()
def linkPath = link.path?.toString()
def linkType = link.type?.toString() ?: 'navigation'
if (linkPath) {
if (linkPath.startsWith('#')) {
def actionName = linkPath.substring(1)
navigation << "To ${linkText.toLowerCase()}, use the '${actionName}' action (see actions section)."
} else {
navigation << "To ${linkText.toLowerCase()}, call moqui_render_screen(path='${linkPath}')."
}
}
}
}
if (navigation.isEmpty()) {
def parentPath = getParentPath(currentPath)
if (parentPath) {
navigation << "To go back, call moqui_browse_screens(path='${parentPath}')."
}
}
return navigation
}
List<String> describeNotes(Map<String, Object> semanticState, boolean isTerse) {
def notes = []
def data = semanticState?.data
if (data) {
data.each { key, value ->
if (value instanceof Map && value.containsKey('_truncated') && value._truncated == true) {
def total = value._totalCount ?: 0
def shown = value._items?.size() ?: 0
notes << "List truncated: showing ${shown} of ${total} item${total > 1 ? 's' : ''}. Set terse=false to view all."
}
}
}
def actions = semanticState?.actions
if (actions && actions.size() > 5) {
notes << "This screen has ${actions.size()} actions. Use semanticState.actions for complete list."
}
def parameters = semanticState?.parameters
if (parameters && parameters.size() > 0) {
def requiredParams = parameters.findAll { k, v -> k.toString().toLowerCase().contains('id') }
if (requiredParams.size() > 0) {
notes << "Required parameters: ${requiredParams.keySet().join(', ')}."
}
}
return notes
}
private String buildServiceActionNarrative(String actionName, String service, String currentPath, Map semanticState) {
def actionLower = actionName.toLowerCase()
def verb = actionLower.startsWith('create') ? 'create' : actionLower.startsWith('update') ? 'update' : actionLower.startsWith('delete') ? 'delete' : 'execute'
def params = extractServiceParameters(service, semanticState)
def sb = new StringBuilder()
sb.append("To ${verb} ")
def object = extractObjectFromAction(actionName)
sb.append(object.toLowerCase())
sb.append(", call moqui_render_screen(path='${currentPath}', action='${actionName}'")
if (params) {
sb.append(", parameters={${params}}")
}
sb.append("). ")
sb.append("This invokes service '${service}' via transition.")
return sb.toString()
}
private String buildTransitionActionNarrative(String actionName, String currentPath, Map semanticState) {
def actionLower = actionName.toLowerCase()
def verb = actionLower.startsWith('create') ? 'create' : actionLower.startsWith('update') ? 'update' : actionLower.startsWith('delete') ? 'delete' : 'process'
def params = extractTransitionParameters(actionName, semanticState)
def sb = new StringBuilder()
sb.append("To ${verb} ")
def object = extractObjectFromAction(actionName)
sb.append(object.toLowerCase())
sb.append(", call moqui_render_screen(path='${currentPath}', action='${actionName}'")
if (params) {
sb.append(", parameters={${params}}")
}
sb.append("). ")
sb.append("This triggers the '${actionName}' transition on this screen.")
return sb.toString()
}
private String buildFormSubmitNarrative(String formName, String currentPath, Map semanticState) {
def formFriendly = formFriendlyName(formName)
def params = extractFormParameters(formName, semanticState)
def sb = new StringBuilder()
sb.append("To submit ${formFriendly.toLowerCase()}, call moqui_render_screen(path='${currentPath}', parameters={${params}}). ")
sb.append("This filters or processes the ${formFriendly.toLowerCase()} form.")
return sb.toString()
}
private int countItems(Map semanticState) {
if (!semanticState?.data) return 0
def total = 0
semanticState.data.each { k, v ->
if (v instanceof Map && v.containsKey('_totalCount')) {
total += v._totalCount as Integer
} else if (v instanceof List) {
total += v.size()
}
}
return total
}
private List<String> getFormFieldNames(Map forms, String formName) {
def form = forms[formName]
if (!form) return []
if (form instanceof Map) {
def result = []
form.keySet().each { k ->
if (!k.toString().startsWith('_') && result.size() < 5) {
result.add(k.toString())
}
}
return result
}
return []
}
private String extractServiceParameters(String service, Map semanticState) {
def params = []
def allParams = semanticState?.parameters
if (allParams) {
def paramKeys = []
allParams.keySet().each { k ->
if (paramKeys.size() < 3) {
paramKeys.add(k.toString())
}
}
paramKeys.each { key ->
def value = allParams[key]
if (value != null) {
def valStr = value instanceof String ? "'${value}'" : value.toString()
params << "${key}: ${valStr}"
}
}
}
return params.join(', ')
}
private String extractTransitionParameters(String actionName, Map semanticState) {
def params = []
def allParams = semanticState?.parameters
if (allParams) {
def paramKeys = allParams.keySet().take(3)
paramKeys.each { key ->
def value = allParams[key]
if (value != null) {
def valStr = value instanceof String ? "'${value}'" : value.toString()
params << "${key}: ${valStr}"
}
}
}
return params.join(', ')
}
private String extractFormParameters(String formName, Map semanticState) {
def form = semanticState?.data?.get(formName)
if (!form) return '...'
def params = []
if (form instanceof Map) {
def fieldNames = []
form.keySet().each { k ->
if (!k.toString().startsWith('_') && fieldNames.size() < 3) {
fieldNames.add(k.toString())
}
}
fieldNames.each { key ->
def value = form[key]
if (value != null) {
def valStr = value instanceof String ? "'${value}'" : value.toString()
params << "${key}: ${valStr}"
}
}
}
if (params.isEmpty()) params << '...'
return params.join(', ')
}
private String extractObjectFromAction(String actionName) {
def actionLower = actionName.toLowerCase()
def patterns = [
/create(.+)/,
/update(.+)/,
/delete(.+)/,
/find(.+)/,
/search(.+)/
]
for (pattern in patterns) {
def m = actionLower =~ pattern
if (m.find()) {
def object = m.group(1)
if (object) {
def words = object.split('(?=[A-Z])')
def cleaned = words.findAll { w -> w.length() > 0 }.join(' ')
return cleaned ?: 'item'
}
}
}
return 'item'
}
private String formFriendlyName(String formName) {
def name = formName.replace('Form', '').replace('form', '')
def words = name.split('(?=[A-Z])')
return words.findAll { w -> w.length() > 0 }.join(' ') ?: 'Form'
}
private String getParentPath(String path) {
if (!path || path == 'root') return null
def parts = path.split('\\.')
if (parts.length > 1) {
return parts[0..-2].join('.')
}
return 'root'
}
}