|
| 1 | +package com.metaobjects.generator.spring; |
| 2 | + |
| 3 | +import com.metaobjects.MetaData; |
| 4 | +import com.metaobjects.generator.GeneratorException; |
| 5 | +import com.metaobjects.generator.GeneratorIOWriter; |
| 6 | +import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase; |
| 7 | +import com.metaobjects.loader.MetaDataLoader; |
| 8 | +import com.metaobjects.object.MetaObject; |
| 9 | +import com.metaobjects.template.MetaTemplate; |
| 10 | +import com.metaobjects.template.TemplateConstants; |
| 11 | + |
| 12 | +import java.io.IOException; |
| 13 | +import java.io.OutputStream; |
| 14 | +import java.io.PrintWriter; |
| 15 | +import java.nio.file.Files; |
| 16 | +import java.nio.file.Path; |
| 17 | +import java.nio.file.Paths; |
| 18 | +import java.util.ArrayList; |
| 19 | +import java.util.Collection; |
| 20 | +import java.util.List; |
| 21 | + |
| 22 | +/** |
| 23 | + * Generator: one {@code <TemplateShortName>OutputPrompt} Java class per |
| 24 | + * {@code template.output} declaration (where {@code @format} is {@code json} |
| 25 | + * or {@code xml}), emitting a static {@code renderFormat()} / {@code renderFormat(PromptOverrides)} |
| 26 | + * pair backed by {@code OutputFormatRenderer} from the {@code metaobjects-render} module. |
| 27 | + * |
| 28 | + * <p>FR-010 Plan 3 — the Java prompt-fragment codegen. Mirrors the structure of |
| 29 | + * {@link SpringOutputParserGenerator}: same base class, same package derivation, |
| 30 | + * same stable-name-order iteration, same {@code @payloadRef} resolution, same |
| 31 | + * defensive skip rules. |
| 32 | + * |
| 33 | + * <p>Emitted class shape: |
| 34 | + * <pre> |
| 35 | + * // GENERATED — DO NOT EDIT — output-format prompt for template.output `AnswerOutput` |
| 36 | + * package acme.ai.prompts; |
| 37 | + * |
| 38 | + * public final class AnswerOutputPrompt { |
| 39 | + * |
| 40 | + * private static final com.metaobjects.render.prompt.OutputFormatSpec SPEC = ...; |
| 41 | + * |
| 42 | + * private AnswerOutputPrompt() { /* no instances */ } |
| 43 | + * |
| 44 | + * /** The output-format instruction fragment. */ |
| 45 | + * public static String renderFormat() { |
| 46 | + * return com.metaobjects.render.prompt.OutputFormatRenderer.render( |
| 47 | + * SPEC, com.metaobjects.render.prompt.PromptOverrides.none()); |
| 48 | + * } |
| 49 | + * |
| 50 | + * public static String renderFormat(com.metaobjects.render.prompt.PromptOverrides overrides) { |
| 51 | + * return com.metaobjects.render.prompt.OutputFormatRenderer.render(SPEC, overrides); |
| 52 | + * } |
| 53 | + * } |
| 54 | + * </pre> |
| 55 | + * |
| 56 | + * <p>Skips: |
| 57 | + * <ul> |
| 58 | + * <li>{@code template.prompt} nodes — only outputs need prompt-fragment codegen.</li> |
| 59 | + * <li>Missing or non-VO {@code @payloadRef}.</li> |
| 60 | + * <li>{@code @format} values other than {@code json} or {@code xml}.</li> |
| 61 | + * </ul> |
| 62 | + * |
| 63 | + * <p>The {@code SPEC}'s {@code rootName} is the capitalized payload class name |
| 64 | + * (e.g. {@code "AnswerOutputPayload"}) — matches the convention used by Plan 2's |
| 65 | + * recover codegen so both artifacts agree on the root name. |
| 66 | + * |
| 67 | + * <p>Args: |
| 68 | + * <ul> |
| 69 | + * <li>{@code outputDir} (required): output directory root.</li> |
| 70 | + * </ul> |
| 71 | + */ |
| 72 | +public class SpringOutputPromptGenerator extends MultiFileDirectGeneratorBase<MetaObject> { |
| 73 | + |
| 74 | + @Override |
| 75 | + protected Class<MetaObject> getFilterClass() { |
| 76 | + return MetaObject.class; |
| 77 | + } |
| 78 | + |
| 79 | + @Override |
| 80 | + public void execute(MetaDataLoader loader) { |
| 81 | + parseArgs(); |
| 82 | + Path outRoot = Paths.get(outDir.getAbsolutePath()); |
| 83 | + |
| 84 | + // Stable name order — matches the other ports' deterministic emission. |
| 85 | + List<MetaTemplate> outputs = new ArrayList<>(); |
| 86 | + for (MetaData child : loader.getRoot().getChildren()) { |
| 87 | + if (child instanceof MetaTemplate t && TemplateConstants.SUBTYPE_OUTPUT.equals(t.getSubType())) { |
| 88 | + outputs.add(t); |
| 89 | + } |
| 90 | + } |
| 91 | + outputs.sort((a, b) -> a.getName().compareTo(b.getName())); |
| 92 | + |
| 93 | + for (MetaTemplate tmpl : outputs) { |
| 94 | + emit(tmpl, loader, outRoot); |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + private void emit(MetaTemplate template, MetaDataLoader loader, Path outRoot) { |
| 99 | + // Only emit for json/xml formats. |
| 100 | + String format = template.getFormat(); |
| 101 | + boolean supported = TemplateConstants.FORMAT_JSON.equalsIgnoreCase(format) |
| 102 | + || TemplateConstants.FORMAT_XML.equalsIgnoreCase(format); |
| 103 | + if (!supported) { |
| 104 | + return; |
| 105 | + } |
| 106 | + |
| 107 | + String payloadRef = template.getPayloadRef(); |
| 108 | + if (payloadRef == null || payloadRef.isEmpty()) { |
| 109 | + return; // loader validation normally catches this first |
| 110 | + } |
| 111 | + MetaObject payloadVo = resolveValueObject(loader, payloadRef); |
| 112 | + if (payloadVo == null) { |
| 113 | + return; // not a VO — same contract as SpringPayloadGenerator |
| 114 | + } |
| 115 | + |
| 116 | + String[] split = SpringNaming.splitFqn(template.getName()); |
| 117 | + String templatePkg = split[0]; |
| 118 | + String templateShort = split[1]; |
| 119 | + String outPkg = templatePkg.isEmpty() ? "prompts" : templatePkg + ".prompts"; |
| 120 | + String capitalized = capitalizeFirst(templateShort); |
| 121 | + String promptClass = capitalized + "Prompt"; |
| 122 | + String payloadClass = capitalized + "Payload"; |
| 123 | + |
| 124 | + // The SPEC rootName agrees with the payload class name so both prompt and |
| 125 | + // recover artifacts share the same root element name. |
| 126 | + String specLiteral = OutputFormatSpecEmitter.specLiteral(payloadVo, template, payloadClass); |
| 127 | + |
| 128 | + StringBuilder src = new StringBuilder(); |
| 129 | + src.append("// GENERATED — DO NOT EDIT — output-format prompt for template.output `") |
| 130 | + .append(template.getName()).append("`\n"); |
| 131 | + src.append("package ").append(outPkg).append(";\n\n"); |
| 132 | + src.append("import com.metaobjects.render.prompt.OutputFormatSpec;\n"); |
| 133 | + src.append("import com.metaobjects.render.prompt.OutputFormatRenderer;\n"); |
| 134 | + src.append("import com.metaobjects.render.prompt.PromptField;\n"); |
| 135 | + src.append("import com.metaobjects.render.prompt.PromptOverrides;\n"); |
| 136 | + src.append("import com.metaobjects.render.prompt.PromptStyle;\n"); |
| 137 | + src.append("import com.metaobjects.render.recover.FieldKind;\n"); |
| 138 | + src.append("import com.metaobjects.render.recover.Format;\n"); |
| 139 | + src.append("\n"); |
| 140 | + src.append("/** Output-format prompt fragment for the `") |
| 141 | + .append(templateShort).append("` template.output. */\n"); |
| 142 | + src.append("public final class ").append(promptClass).append(" {\n\n"); |
| 143 | + src.append(" private static final OutputFormatSpec SPEC =\n"); |
| 144 | + src.append(" ").append(specLiteral).append(";\n\n"); |
| 145 | + src.append(" private ").append(promptClass).append("() { /* no instances */ }\n\n"); |
| 146 | + src.append(" /** The output-format instruction fragment (\"produce your answer like this\"). */\n"); |
| 147 | + src.append(" public static String renderFormat() {\n"); |
| 148 | + src.append(" return OutputFormatRenderer.render(SPEC, PromptOverrides.none());\n"); |
| 149 | + src.append(" }\n\n"); |
| 150 | + src.append(" public static String renderFormat(PromptOverrides overrides) {\n"); |
| 151 | + src.append(" return OutputFormatRenderer.render(SPEC, overrides);\n"); |
| 152 | + src.append(" }\n"); |
| 153 | + src.append("}\n"); |
| 154 | + |
| 155 | + try { |
| 156 | + Path outFile = outRoot.resolve(outPkg.replace('.', '/')).resolve(promptClass + ".java"); |
| 157 | + if (outFile.getParent() != null) Files.createDirectories(outFile.getParent()); |
| 158 | + Files.writeString(outFile, src.toString()); |
| 159 | + } catch (IOException e) { |
| 160 | + throw new GeneratorException( |
| 161 | + "failed writing " + promptClass + ".java for template " + template.getName() + ": " + e, e); |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + /** |
| 166 | + * Uppercase the first character of {@code s}; pass through unchanged when |
| 167 | + * empty or already capitalised. Mirrors {@link SpringOutputParserGenerator}'s |
| 168 | + * matching helper. |
| 169 | + */ |
| 170 | + private static String capitalizeFirst(String s) { |
| 171 | + if (s == null || s.isEmpty()) return s; |
| 172 | + char c0 = s.charAt(0); |
| 173 | + if (Character.isUpperCase(c0)) return s; |
| 174 | + return Character.toUpperCase(c0) + s.substring(1); |
| 175 | + } |
| 176 | + |
| 177 | + /** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */ |
| 178 | + private static MetaObject resolveValueObject(MetaDataLoader loader, String ref) { |
| 179 | + for (MetaObject obj : loader.getMetaObjects()) { |
| 180 | + if (!MetaObject.SUBTYPE_VALUE.equals(obj.getSubType())) continue; |
| 181 | + if (obj.getName().equals(ref)) return obj; |
| 182 | + String[] split = SpringNaming.splitFqn(obj.getName()); |
| 183 | + if (split[1].equals(ref)) return obj; |
| 184 | + } |
| 185 | + return null; |
| 186 | + } |
| 187 | + |
| 188 | + // === MultiFileDirectGeneratorBase abstract-method stubs ==================== |
| 189 | + @Override |
| 190 | + protected void writeSingleFile(MetaObject md, GeneratorIOWriter<?> writer) { /* unused */ } |
| 191 | + |
| 192 | + @Override |
| 193 | + @SuppressWarnings({ "unchecked", "rawtypes" }) |
| 194 | + protected <T extends GeneratorIOWriter> T getSingleWriter( |
| 195 | + MetaDataLoader loader, MetaObject md, PrintWriter pw) { |
| 196 | + return null; |
| 197 | + } |
| 198 | + |
| 199 | + @Override |
| 200 | + @SuppressWarnings({ "unchecked", "rawtypes" }) |
| 201 | + protected <T extends GeneratorIOWriter> T getFinalWriter( |
| 202 | + MetaDataLoader loader, OutputStream out) { |
| 203 | + return null; |
| 204 | + } |
| 205 | + |
| 206 | + @Override |
| 207 | + protected void writeFinalFile(Collection<MetaObject> metadata, GeneratorIOWriter<?> writer) { /* none */ } |
| 208 | + |
| 209 | + @Override |
| 210 | + protected String getSingleOutputFilePath(MetaObject md) { |
| 211 | + return SpringNaming.splitFqn(md.getName())[0].replace('.', '/'); |
| 212 | + } |
| 213 | + |
| 214 | + @Override |
| 215 | + protected String getSingleOutputFilename(MetaObject md) { |
| 216 | + return capitalizeFirst(SpringNaming.splitFqn(md.getName())[1]) + "Prompt.java"; |
| 217 | + } |
| 218 | +} |
0 commit comments