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>
......
......@@ -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()) {
......