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
1 #Wed Nov 26 15:36:44 CST 2025 1 #Fri Jan 02 17:15:36 CST 2026
2 gradle.version=8.9 2 gradle.version=9.2.0
......
...@@ -2,7 +2,7 @@ arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/o ...@@ -2,7 +2,7 @@ arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/o
2 auto.sync=false 2 auto.sync=false
3 build.scans.enabled=false 3 build.scans.enabled=false
4 connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9)) 4 connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9))
5 connection.project.dir= 5 connection.project.dir=../../../framework
6 eclipse.preferences.version=1 6 eclipse.preferences.version=1
7 gradle.user.home= 7 gradle.user.home=
8 java.home=/usr/lib/jvm/java-21-openjdk-amd64 8 java.home=/usr/lib/jvm/java-21-openjdk-amd64
......
...@@ -28,10 +28,11 @@ ...@@ -28,10 +28,11 @@
28 </webapp-list> 28 </webapp-list>
29 29
30 <screen-facade> 30 <screen-facade>
31 <!-- DEPRECATED: json mode now uses mcp template for unified semantic capture -->
31 <screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true" 32 <screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true"
32 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/> 33 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/>
33 <screen-text-output type="json" mime-type="application/json" always-standalone="true" 34 <screen-text-output type="json" mime-type="application/json" always-standalone="true"
34 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.json.ftl"/> 35 macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/>
35 <widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> 36 <widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/>
36 <widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> 37 <widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/>
37 </screen-facade> 38 </screen-facade>
......
1 <#--
2 Moqui MCP JSON Macros
3 Renders screens in structured JSON format for LLM consumption.
4 -->
5
6 <#include "DefaultScreenMacros.any.ftl"/>
7
8 <#macro @element></#macro>
9
10 <#macro screen>{"screen": {<#recurse>}}</#macro>
11
12 <#macro widgets>
13 "widgets": [<#recurse>]
14 </#macro>
15
16 <#macro "fail-widgets"><#recurse></#macro>
17
18 <#-- ================ Subscreens ================ -->
19 <#macro "subscreens-menu"></#macro>
20 <#macro "subscreens-active">{"type": "subscreens-active", "content": ${sri.renderSubscreen()}}</#macro>
21 <#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro>
22
23 <#-- ================ Section ================ -->
24 <#macro section>{"type": "section", "name": ${(.node["@name"]!"")?json_string}, "content": ${sri.renderSection(.node["@name"])}}</#macro>
25 <#macro "section-iterate">${sri.renderSection(.node["@name"])}</#macro>
26 <#macro "section-include">${sri.renderSectionInclude(.node)}</#macro>
27
28 <#-- ================ Containers ================ -->
29 <#macro container>
30 <#assign children = []>
31 <#list .node?children as child>
32 <#assign rendered><#recurse child></#assign>
33 <#if rendered?has_content && !(rendered?starts_with("{\"widgets\""))>
34 <#assign children = children + [rendered]>
35 </#if>
36 </#list>
37 {"type": "container", "children": [${children?join(",")}]}
38 </#macro>
39
40 <#macro "container-box">
41 {"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>}
42 </#macro>
43
44 <#macro "container-row"><#list .node["row-col"] as rowColNode><#recurse rowColNode></#list></#macro>
45
46 <#macro "container-panel">
47 {"type": "container-panel"<#if .node["panel-header"]?has_content>, "header": ${.node["panel-header"][0]["@label"]!?json_string}</#if>}
48 </#macro>
49
50 <#macro "container-dialog">
51 {"type": "container-dialog", "buttonText": ${ec.resource.expand(.node["@button-text"], "")?json_string}}
52 </#macro>
53
54 <#-- ================== Standalone Fields ==================== -->
55 <#macro link>
56 <#assign linkNode = .node>
57 <#if linkNode["@condition"]?has_content><#assign conditionResult = ec.getResource().condition(linkNode["@condition"], "")><#else><#assign conditionResult = true></#if>
58 <#if conditionResult>
59 <#assign urlInstance = sri.makeUrlByType(linkNode["@url"]!"", linkNode["@url-type"]!"transition", linkNode, "true")>
60 <#assign linkText = "">
61 <#if linkNode["@text"]?has_content>
62 <#assign linkText = ec.getResource().expand(linkNode["@text"], "")>
63 <#elseif linkNode["@entity-name"]?has_content>
64 <#assign linkText = sri.getFieldEntityValue(linkNode)!""?string>
65 </#if>
66 <#if !(linkText?has_content) && .node?parent?node_name?ends_with("-field")>
67 <#assign linkText = sri.getFieldValueString(.node?parent?parent)!>
68 </#if>
69
70 <#-- Convert path to dot notation for moqui_render_screen -->
71 <#assign fullPath = urlInstance.sui.fullPathNameList![]>
72 <#assign dotPath = "">
73 <#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list>
74
75 <#assign paramStr = urlInstance.getParameterString()!"">
76 <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if>
77
78 {"type": "link", "text": ${linkText?json_string}, "path": ${dotPath?json_string}}
79 </#if>
80 </#macro>
81
82 <#macro image>{"type": "image", "alt": ${(.node["@alt"]!"")?json_string}, "url": ${(.node["@url"]!"")?json_string}}</#macro>
83
84 <#macro label>
85 <#assign text = ec.resource.expand(.node["@text"], "")>
86 <#assign type = .node["@type"]!"span">
87 {"type": "label", "text": ${text?json_string}, "labelType": ${type?json_string}}
88 </#macro>
89
90 <#-- ======================= Form ========================= -->
91 <#macro "form-single">
92 <#assign formNode = sri.getFormNode(.node["@name"])>
93 <#assign mapName = formNode["@map"]!"fieldValues">
94
95 <#assign fields = []>
96 <#t>${sri.pushSingleFormMapContext(mapName)}
97 <#list formNode["field"] as fieldNode>
98 <#assign fieldSubNode = "">
99 <#list fieldNode["conditional-field"] as csf><#if ec.resource.condition(csf["@condition"], "")><#assign fieldSubNode = csf><#break></#if></#list>
100 <#if !(fieldSubNode?has_content)><#assign fieldSubNode = fieldNode["default-field"][0]!></#if>
101 <#if fieldSubNode?has_content && !(fieldSubNode["ignored"]?has_content) && !(fieldSubNode["hidden"]?has_content) && !(fieldSubNode["submit"]?has_content) && fieldSubNode?parent["@hide"]! != "true">
102 <#assign fieldValue = ec.context.get(fieldSubNode?parent["@name"])!"">
103 <#if fieldValue?has_content>
104 <#assign fieldInfo = {"name": (fieldSubNode?parent["@name"]!"")?json_string, "value": (fieldValue!?json_string)}>
105 <#assign fields = fields + [fieldInfo]>
106 </#if>
107 </#if>
108 </#list>
109 <#t>${sri.popContext()}
110 {"type": "form-single", "name": ${formNode["@name"]?json_string}, "map": ${mapName?json_string}, "fields": [${fields?join(",")}]}
111 </#macro>
112
113 <#macro "form-list">
114 <#assign formInstance = sri.getFormInstance(.node["@name"])>
115 <#assign formListInfo = formInstance.makeFormListRenderInfo()>
116 <#assign formNode = formListInfo.getFormNode()>
117 <#assign formListColumnList = formListInfo.getAllColInfo()>
118 <#assign listObject = formListInfo.getListObject(false)!>
119
120 {"type": "form-list", "name": ${.node["@name"]?json_string}}
121 </#macro>
122
123 <#macro formListSubField fieldNode>
124 <#list fieldNode["conditional-field"] as fieldSubNode>
125 <#if ec.resource.condition(fieldSubNode["@condition"], "")>
126 {"type": "field", "name": ${fieldSubNode["@name"]?json_string}}
127 <#return>
128 </#if>
129 </#list>
130 </#macro>
131
132 <#macro formListWidget fieldSubNode>
133 <#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if>
134 <#if fieldSubNode["submit"]?has_content>
135 <#assign submitText = sri.getFieldValueString(fieldSubNode)!""?json_string>
136 <#assign screenName = sri.getEffectiveScreen().name!""?string>
137 <#assign formNodeObj = sri.getFormNode(.node["@name"])!"">
138 <#assign formName = formNodeObj["@name"]!?string>
139 <#assign fieldName = fieldSubNode["@name"]!""?string>
140 {"type": "submit", "text": ${submitText}, "action": "${screenName}.${formName}.${fieldName}"}
141 </#if>
142 <#recurse fieldSubNode>
143 </#macro>
144
145 <#macro fieldTitle fieldSubNode>
146 <#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>
147 ${ec.l10n.localize(titleValue)?json_string}
148 </#macro>
149
150 <#-- ================== Form Field Widgets ==================== -->
151 <#macro "check">
152 <#assign options = sri.getFieldOptions(.node)!>
153 <#assign currentValue = sri.getFieldValueString(.node)!"">
154 {"type": "check", "value": ${(options.get(currentValue)!currentValue)?json_string}}
155 </#macro>
156
157 <#macro "date-find"></#macro>
158
159 <#macro "date-time">
160 <#assign javaFormat = .node["@format"]!"">
161 <#if !(javaFormat?has_content)>
162 <#if .node["@type"]! == "time"><#assign javaFormat="HH:mm">
163 <#elseif .node["@type"]! == "date"><#assign javaFormat="yyyy-MM-dd">
164 <#else><#assign javaFormat="yyyy-MM-dd HH:mm"></#if>
165 </#if>
166 <#assign fieldValue = sri.getFieldValueString(.node?parent?parent, .node["@default-value"]!"", javaFormat)!"">
167 {"type": "date-time", "name": ${(.node["@name"]!"")?json_string}, "format": ${javaFormat?json_string}, "value": ${fieldValue?json_string!"null"}}
168 </#macro>
169
170 <#macro "display">
171 <#assign fieldValue = "">
172 <#assign dispFieldNode = .node?parent?parent>
173 <#if .node["@text"]?has_content>
174 <#assign textMap = {}>
175 <#if .node["@text-map"]?has_content><#assign textMap = ec.getResource().expression(.node["@text-map"], {})!></#if>
176 <#assign fieldValue = ec.getResource().expand(.node["@text"], "", textMap, false)!>
177 <#if .node["@currency-unit-field"]?has_content>
178 <#assign fieldValue = ec.getL10n().formatCurrency(fieldValue, ec.getResource().expression(.node["@currency-unit-field"], ""))!"">
179 </#if>
180 <#else>
181 <#assign fieldValue = sri.getFieldValueString(.node)!"">
182 </#if>
183 {"type": "display", "value": ${fieldValue?json_string}}
184 </#macro>
185
186 <#macro "display-entity">
187 <#assign entityValue = sri.getFieldEntityValue(.node)!"">
188 {"type": "display-entity", "value": ${entityValue?json_string}}
189 </#macro>
190
191 <#macro "drop-down">
192 <#assign options = sri.getFieldOptions(.node)!>
193 <#assign currentValue = sri.getFieldValueString(.node)!"">
194 {"type": "drop-down", "value": ${(options.get(currentValue)!currentValue)?json_string}}
195 </#macro>
196
197 <#macro "text-area">
198 <#assign fieldValue = sri.getFieldValueString(.node)!"">
199 {"type": "text-area", "value": ${fieldValue?json_string}}
200 </#macro>
201
202 <#macro "text-line">
203 <#assign fieldValue = sri.getFieldValueString(.node)!"">
204 {"type": "text-line", "value": ${fieldValue?json_string}}
205 </#macro>
206
207 <#macro "text-find">
208 <#assign fieldValue = sri.getFieldValueString(.node)!"">
209 {"type": "text-find", "value": ${fieldValue?json_string}}
210 </#macro>
211
212 <#macro "submit">
213 <#assign text = ec.resource.expand(.node["@text"], "")!"">
214 {"type": "submit", "text": ${text?json_string}}
215 </#macro>
216
217 <#macro "password"></#macro>
218 <#macro "hidden"></#macro>
...@@ -75,6 +75,11 @@ ...@@ -75,6 +75,11 @@
75 <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> 75 <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if>
76 76
77 [${linkText}](${dotPath})<#t> 77 [${linkText}](${dotPath})<#t>
78 <#if mcpSemanticData??>
79 <#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if>
80 <#assign linkInfo = {"text": linkText, "path": dotPath, "type": "navigation"}>
81 <#assign dummy = mcpSemanticData.links.add(linkInfo)>
82 </#if>
78 </#if> 83 </#if>
79 </#macro> 84 </#macro>
80 85
...@@ -94,6 +99,19 @@ ...@@ -94,6 +99,19 @@
94 <#macro "form-single"> 99 <#macro "form-single">
95 <#assign formNode = sri.getFormNode(.node["@name"])> 100 <#assign formNode = sri.getFormNode(.node["@name"])>
96 <#assign mapName = formNode["@map"]!"fieldValues"> 101 <#assign mapName = formNode["@map"]!"fieldValues">
102 <#assign formMap = ec.resource.expression(mapName, "")!>
103
104 <#if mcpSemanticData??>
105 <#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})</#if>
106
107 <#assign formMeta = {}>
108 <#assign formMeta = formMeta + {"name": .node["@name"]!"", "map": mapName}>
109 <#assign fieldMetaList = []>
110
111 <#assign dummy = mcpSemanticData.formMeta.put(.node["@name"], formMeta)>
112 </#if>
113
114 <#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if>
97 <#t>${sri.pushSingleFormMapContext(mapName)} 115 <#t>${sri.pushSingleFormMapContext(mapName)}
98 <#list formNode["field"] as fieldNode> 116 <#list formNode["field"] as fieldNode>
99 <#assign fieldSubNode = ""> 117 <#assign fieldSubNode = "">
...@@ -101,9 +119,28 @@ ...@@ -101,9 +119,28 @@
101 <#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if> 119 <#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if>
102 <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true"> 120 <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true">
103 <#assign title><@fieldTitle fieldSubNode/></#assign> 121 <#assign title><@fieldTitle fieldSubNode/></#assign>
122
123 <#if mcpSemanticData??>
124 <#assign fieldMeta = {}>
125 <#assign fieldMeta = fieldMeta + {"name": fieldNode["@name"]!"", "title": title!"", "required": (fieldNode["@required"]! == "true")}>
126
127 <#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if>
128 <#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if>
129 <#if fieldSubNode["drop-down"]?has_content><#assign dummy = fieldMeta.put("type", "dropdown")></#if>
130 <#if fieldSubNode["check"]?has_content><#assign dummy = fieldMeta.put("type", "checkbox")></#if>
131 <#if fieldSubNode["date-find"]?has_content><#assign dummy = fieldMeta.put("type", "date")></#if>
132
133 <#assign dummy = fieldMetaList.add(fieldMeta)>
134 </#if>
135
104 * **${title}**: <#recurse fieldSubNode> 136 * **${title}**: <#recurse fieldSubNode>
105 </#if> 137 </#if>
106 </#list> 138 </#list>
139
140 <#if mcpSemanticData?? && fieldMetaList?has_content>
141 <#assign dummy = mcpSemanticData.formMeta[.node["@name"]!].put("fields", fieldMetaList)>
142 </#if>
143
107 <#t>${sri.popContext()} 144 <#t>${sri.popContext()}
108 </#macro> 145 </#macro>
109 146
...@@ -113,6 +150,31 @@ ...@@ -113,6 +150,31 @@
113 <#assign formNode = formListInfo.getFormNode()> 150 <#assign formNode = formListInfo.getFormNode()>
114 <#assign formListColumnList = formListInfo.getAllColInfo()> 151 <#assign formListColumnList = formListInfo.getAllColInfo()>
115 <#assign listObject = formListInfo.getListObject(false)!> 152 <#assign listObject = formListInfo.getListObject(false)!>
153 <#assign totalItems = listObject?size>
154
155 <#if mcpSemanticData?? && listObject?has_content>
156 <#assign truncatedList = listObject>
157 <#if listObject?size > 50>
158 <#assign truncatedList = listObject?take(50)>
159 </#if>
160 <#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)>
161
162 <#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})</#if>
163
164 <#assign columnNames = []>
165 <#list formListColumnList as columnFieldList>
166 <#assign fieldNode = columnFieldList[0]>
167 <#assign dummy = columnNames.add(fieldNode["@name"]!"")>
168 </#list>
169
170 <#assign dummy = mcpSemanticData.listMeta.put(.node["@name"]!"", {
171 "name": .node["@name"]!"",
172 "totalItems": totalItems,
173 "displayedItems": truncatedList?size,
174 "truncated": (listObject?size > 50),
175 "columns": columnNames
176 })>
177 </#if>
116 178
117 <#-- Header Row --> 179 <#-- Header Row -->
118 <#list formListColumnList as columnFieldList> 180 <#list formListColumnList as columnFieldList>
...@@ -123,7 +185,7 @@ ...@@ -123,7 +185,7 @@
123 | 185 |
124 <#list formListColumnList as columnFieldList>| --- </#list>| 186 <#list formListColumnList as columnFieldList>| --- </#list>|
125 <#-- Data Rows --> 187 <#-- Data Rows -->
126 <#list listObject as listEntry> 188 <#list (truncatedList?? && truncatedList?size > 0)!listObject as listEntry>
127 <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)} 189 <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)}
128 <#list formListColumnList as columnFieldList> 190 <#list formListColumnList as columnFieldList>
129 <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t> 191 <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t>
......
...@@ -291,6 +291,11 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -291,6 +291,11 @@ class CustomScreenTestImpl implements McpScreenTest {
291 // push the context 291 // push the context
292 ContextStack cs = eci.getContext() 292 ContextStack cs = eci.getContext()
293 cs.push() 293 cs.push()
294
295 // Create a persistent map for semantic data that survives nested pops
296 Map<String, Object> mcpSemanticData = new HashMap<>()
297 cs.put("mcpSemanticData", mcpSemanticData)
298
294 // create the WebFacadeStub using our custom method 299 // create the WebFacadeStub using our custom method
295 org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath) 300 org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath)
296 // set stub on eci, will also put parameters in the context 301 // set stub on eci, will also put parameters in the context
...@@ -336,8 +341,10 @@ class CustomScreenTestImpl implements McpScreenTest { ...@@ -336,8 +341,10 @@ class CustomScreenTestImpl implements McpScreenTest {
336 // calc renderTime 341 // calc renderTime
337 stri.renderTime = System.currentTimeMillis() - startTime 342 stri.renderTime = System.currentTimeMillis() - startTime
338 343
344 // capture everything currently in the context stack before popping
345 stri.postRenderContext = new HashMap<>(cs)
339 // pop the context stack, get rid of var space 346 // pop the context stack, get rid of var space
340 stri.postRenderContext = cs.pop() 347 cs.pop()
341 348
342 // check, pass through, error messages 349 // check, pass through, error messages
343 if (eci.message.hasError()) { 350 if (eci.message.hasError()) {
......