f000a68c by Ean Schuessler

Expand type system for semantic state extraction.

- Add field types: radio, display, link, file-upload, hidden
- Add link types: action, delete, external, button
- Add action types: service-action, form-action, screen-transition, delete-action
- Enhance parameter metadata with type, required, defaultValue
- Classify actions automatically by service/name patterns
- Update narrative builder to describe action/link types
- Defensive parameter extraction with reflection fallbacks
1 parent e781740e
......@@ -79,7 +79,11 @@
[${linkText}](${slashPath})<#t>
<#if mcpSemanticData??>
<#assign dummy = ec.resource.expression("mcpSemanticData.links.add([text: '" + (linkText!"")?js_string + "', path: '" + (slashPath!"")?js_string + "', type: 'navigation'])", "")!>
<#assign linkType = "navigation">
<#if slashPath?starts_with("#")><#assign linkType = "action"></#if>
<#if slashPath?starts_with("http://") || slashPath?starts_with("https://")><#assign linkType = "external"></#if>
<#if .node["@icon"]?has_content && .node["@icon"]?contains("trash")><#assign linkType = "delete"></#if>
<#assign dummy = ec.resource.expression("mcpSemanticData.links.add([text: '" + (linkText!"")?js_string + "', path: '" + (slashPath!"")?js_string + "', type: '" + linkType + "'])", "")!>
</#if>
</#if>
</#macro>
......@@ -122,7 +126,12 @@
<#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if>
<#if fieldSubNode["drop-down"]?has_content><#assign fieldMeta = fieldMeta + {"type": "dropdown"}></#if>
<#if fieldSubNode["check"]?has_content><#assign fieldMeta = fieldMeta + {"type": "checkbox"}></#if>
<#if fieldSubNode["radio"]?has_content><#assign fieldMeta = fieldMeta + {"type": "radio"}></#if>
<#if fieldSubNode["date-find"]?has_content><#assign fieldMeta = fieldMeta + {"type": "date"}></#if>
<#if fieldSubNode["display"]?has_content || fieldSubNode["display-entity"]?has_content><#assign fieldMeta = fieldMeta + {"type": "display"}></#if>
<#if fieldSubNode["link"]?has_content><#assign fieldMeta = fieldMeta + {"type": "link"}></#if>
<#if fieldSubNode["file"]?has_content><#assign fieldMeta = fieldMeta + {"type": "file-upload"}></#if>
<#if fieldSubNode["hidden"]?has_content><#assign fieldMeta = fieldMeta + {"type": "hidden"}></#if>
<#assign fieldMetaList = fieldMetaList + [fieldMeta]>
</#if>
......
......@@ -891,22 +891,94 @@ def wikiInstructions = getWikiInstructions(inputScreenPath)
}
}
// Extract transitions (Actions) with metadata (from screen definition, not macros)
// Extract transitions (Actions) with type classification and metadata
semanticState.actions = []
finalScreenDef.getAllTransitions().each { trans ->
def transName = trans.getName()
def service = trans.getSingleServiceName()
// Classify action type
def actionType = "screen-transition"
def transNameLower = transName?.toString()?.toLowerCase() ?: ''
if (service) {
actionType = "service-action"
} else if (transNameLower.contains('delete')) {
actionType = "delete-action"
} else if (transNameLower.startsWith('form') || transNameLower == 'find' || transNameLower == 'search') {
actionType = "form-action"
}
def actionInfo = [
name: trans.getName(),
service: trans.getSingleServiceName()
name: transName,
service: service,
type: actionType
]
semanticState.actions << actionInfo
}
// 3. Extract parameters that are currently set
// 3. Extract parameters with metadata
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)
// Build parameter metadata
def paramInfo = [:]
// Add value if exists
if (value != null) {
paramInfo.value = serializeMoquiObject(value, 0, isTerse)
}
// Extract parameter type - try multiple approaches
def type = "string"
try {
// Try to get type via reflection or known properties
if (param.hasProperty('type')) {
def typeObj = param.type
if (typeObj != null) type = typeObj.toString().toLowerCase()
} else if (param.hasProperty('parameterType')) {
def typeObj = param.parameterType
if (typeObj != null) type = typeObj.toString().toLowerCase()
}
} catch (Exception e) {
// Fall back to type inference from value
}
// Infer type from value if type couldn't be extracted
if (type == "string" && value != null) {
if (value instanceof Number) {
type = (value instanceof Integer || value instanceof Long) ? "long" : "decimal"
} else if (value instanceof Boolean) {
type = "boolean"
} else if (value instanceof Collection || value instanceof Map) {
type = (value instanceof Collection) ? "list" : "map"
}
}
paramInfo.type = type
// Extract required flag - defensive check
paramInfo.required = false
try {
if (param.hasProperty('required')) {
paramInfo.required = (param.required == true)
}
} catch (Exception e) {
// Skip if property doesn't exist
}
// Extract default value - defensive check
try {
if (param.hasProperty('defaultValue') && param.defaultValue != null) {
paramInfo.defaultValue = param.defaultValue.toString()
}
} catch (Exception e) {
// Skip if property doesn't exist
}
semanticState.parameters[name] = paramInfo
}
}
......
......@@ -105,23 +105,28 @@ class UiNarrativeBuilder {
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()
def actionType = classifyActionType(trans, semanticState)
if (transName) {
if (service) {
if (actionType == 'service-action' && service) {
actions << buildServiceActionNarrative(transName, service, currentPath, semanticState)
} else if (actionType == 'form-action') {
actions << buildFormActionNarrative(transName, currentPath, semanticState)
} else if (actionType == 'screen-transition') {
actions << buildScreenTransitionNarrative(transName, currentPath, semanticState)
} else 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') }
......@@ -129,14 +134,33 @@ class UiNarrativeBuilder {
actions << buildFormSubmitNarrative(formName, currentPath, semanticState)
}
}
if (actions.isEmpty()) {
actions << "No explicit actions available on this screen. Use navigation links to explore."
}
return actions
}
private String classifyActionType(def trans, Map semanticState) {
def transName = trans.name?.toString()?.toLowerCase() ?: ''
def service = trans.service?.toString()
// Delete actions are special
if (transName.contains('delete')) return 'delete-action'
// Service actions
if (service) return 'service-action'
// Form actions (built-in)
if (transName.startsWith('form') || transName == 'find' || transName == 'search') {
return 'form-action'
}
// Screen transitions
return 'screen-transition'
}
List<String> describeLinks(Map<String, Object> semanticState, String currentPath, boolean isTerse) {
def navigation = []
......@@ -151,11 +175,17 @@ class UiNarrativeBuilder {
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)."
if (linkType == 'action' || linkPath.startsWith('#')) {
def actionName = linkPath.startsWith('#') ? linkPath.substring(1) : linkPath
navigation << "To ${linkText.toLowerCase()}, use action '${actionName}' (type: action)."
} else if (linkType == 'delete') {
navigation << "To ${linkText.toLowerCase() ?: 'delete'}, call moqui_render_screen(path='${linkPath}') (type: delete)."
} else if (linkType == 'external') {
navigation << "To ${linkText.toLowerCase() ?: 'external link'}, visit ${linkPath} (type: external)."
} else if (linkType == 'button') {
navigation << "To ${linkText.toLowerCase()}, click button action (type: button)."
} else {
navigation << "To ${linkText.toLowerCase()}, call moqui_render_screen(path='${linkPath}')."
navigation << "To ${linkText.toLowerCase()}, call moqui_render_screen(path='${linkPath}') (type: navigation)."
}
}
}
......@@ -164,6 +194,19 @@ class UiNarrativeBuilder {
if (navigation.isEmpty()) {
def parentPath = getParentPath(currentPath)
if (parentPath) {
navigation << "To go back, call moqui_render_screen(path='${parentPath}')."
}
}
return navigation
}
}
}
}
if (navigation.isEmpty()) {
def parentPath = getParentPath(currentPath)
if (parentPath) {
navigation << "To go back, call moqui_browse_screens(path='${parentPath}')."
}
}
......@@ -250,11 +293,47 @@ class UiNarrativeBuilder {
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.")
sb.append("This filters or processes ${formFriendly.toLowerCase()} form.")
return sb.toString()
}
private String buildFormActionNarrative(String actionName, String currentPath, Map semanticState) {
def actionLower = actionName.toLowerCase()
def verb = actionLower.startsWith('find') ? 'find' : actionLower.startsWith('search') ? 'search' : 'filter'
def params = extractTransitionParameters(actionName, semanticState)
def sb = new StringBuilder()
sb.append("To ${verb} ")
sb.append("results, call moqui_render_screen(path='${currentPath}', action='${actionName}'")
if (params) {
sb.append(", parameters={${params}}")
}
sb.append("). ")
sb.append("This is a built-in form action (type: form-action).")
return sb.toString()
}
private String buildScreenTransitionNarrative(String actionName, String currentPath, Map semanticState) {
def params = extractTransitionParameters(actionName, semanticState)
def sb = new StringBuilder()
sb.append("To execute '${actionName}', call moqui_render_screen(path='${currentPath}', action='${actionName}'")
if (params) {
sb.append(", parameters={${params}}")
}
sb.append("). ")
sb.append("This triggers a screen transition (type: screen-transition).")
return sb.toString()
}
......