Skip to content

Commit e600c8d

Browse files
dmealingclaude
andcommitted
feat(ai,java): preFreeze loader hook + deriveTraceFields auto-derivation (Slice 3)
Port the TS reference's generic `preFreeze` loader option to the Java MetaDataLoader, and add LlmTraceFieldDeriver as its first consumer: for any entity extending LlmCallBase with a nested template.prompt carrying @payloadRef/@responseRef, inject voRequest/voResponse field.object jsonb columns (idempotent, own-field-wins) so authors don't restate them. Cross-port divergence (justified): the TS reference wires deriveTraceFields codegen-only because the TS runtime persists via a direct row-write (no runtime metadata). The Java OMDB runtime is metadata-driven — the generated record<Entity> helper calls setObject("voResponse", ...) which OMDB maps to a jsonb column by reading the runtime-loaded MetaObject. So in Java the derivation must also reach the runtime load path; it is exposed as a MetaDataLoader preFreeze hook (Tier-3 mechanism) usable by BOTH codegen and runtime loaders. - metadata: generic preFreeze hook (setPreFreeze + fromUris overload + in-load invocation after extends-resolution, before validation so derived nodes are validated like authored ones); LlmTraceFieldDeriver; PromptTemplate.getPayloadRef(). - maven-plugin: meta:gen runs deriveTraceFields post-load (no hard freeze in Java). - CI gate: LlmTraceFieldDeriverTest (4/4) proves derivation + strict-validation survival (ADR-0023) + idempotency + negative cases. Full metadata suite green (989/991; the 2 fails are the pre-existing template-source-conformance issue). - Integration: the PG round-trip fixture now DERIVES voRequest/voResponse from a template.prompt instead of hand-declaring them, proving derivation reaches the OMDB runtime end-to-end (2/2 against real Postgres). call<Entity> still deferred (BYO vendor-neutral caller; ADR-0024). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0997ffb commit e600c8d

8 files changed

Lines changed: 383 additions & 10 deletions

File tree

server/java/integration-tests/src/test/java/com/metaobjects/integration/LlmCallTraceRoundTripTest.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.metaobjects.integration;
22

33
import com.metaobjects.loader.MetaDataLoader;
4+
import com.metaobjects.loader.ai.LlmTraceFieldDeriver;
45
import com.metaobjects.loader.uri.URIHelper;
56
import com.metaobjects.manager.ObjectConnection;
67
import com.metaobjects.manager.db.ObjectManagerDB;
@@ -208,7 +209,12 @@ private static MetaDataLoader loadAiTraceMetadata() {
208209
Path libraryYaml = findRepoFile("library/ai/llm-call.yaml");
209210
URI libUri = URIHelper.toURI("model:file:" + libraryYaml.toAbsolutePath());
210211
URI entityUri = URIHelper.toURI("model:resource:meta.ai-trace.yaml");
211-
return MetaDataLoader.fromUris("test-ai-trace", List.of(libUri, entityUri));
212+
// Wire the AI-trace deriver as a preFreeze hook: voRequest/voResponse are
213+
// NOT authored in the fixture — they are derived in-load from the prompt's
214+
// @payloadRef/@responseRef, so the OMDB runtime sees the typed jsonb
215+
// columns (Slice 3, the metadata-driven-runtime divergence from TS).
216+
return MetaDataLoader.fromUris("test-ai-trace", List.of(libUri, entityUri),
217+
null, LlmTraceFieldDeriver::deriveTraceFields);
212218
}
213219

214220
/** Walk up from the working dir to locate a repo-relative file. */
@@ -245,6 +251,7 @@ private static void provisionSchema(PostgresContainer pg) throws SQLException {
245251
+ " \"startedAt\" timestamp,\n"
246252
+ " \"llmRequest\" jsonb,\n"
247253
+ " \"llmResponse\" jsonb,\n"
254+
+ " \"voRequest\" jsonb,\n"
248255
+ " \"voResponse\" jsonb\n"
249256
+ ")");
250257
}
Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
# Test-only trace entity for the AI-trace recorder PG round-trip.
22
# Extends the SHIPPED metaobjects::ai::LlmCallBase (loaded from
3-
# library/ai/llm-call.yaml) and adds a typed voResponse jsonb column.
3+
# library/ai/llm-call.yaml). The typed voRequest/voResponse jsonb columns are
4+
# NOT declared here — they are DERIVED by LlmTraceFieldDeriver (Slice 3) from the
5+
# nested template.prompt's @payloadRef/@responseRef when the loader runs with the
6+
# deriver wired as a preFreeze hook. This proves derivation reaches the
7+
# metadata-driven OMDB runtime (the Java-specific divergence from the TS
8+
# codegen-only reference).
49
metadata:
510
package: metaobjects::ai
611
children:
7-
# Typed value object stored in the voResponse jsonb column.
12+
# Typed request payload VO (referenced by @payloadRef → derived voRequest).
13+
- object.value:
14+
name: GreetingRequest
15+
object: com.metaobjects.object.value.ValueObject
16+
children:
17+
- field.string: { name: prompt }
18+
19+
# Typed response VO (referenced by @responseRef → derived voResponse).
820
- object.value:
921
name: GreetingResponse
1022
object: com.metaobjects.object.value.ValueObject
@@ -13,16 +25,15 @@ metadata:
1325
- field.int: { name: score, column: score }
1426

1527
# Concrete trace entity: inherits the 18 LlmCallBase fields, declares its
16-
# table + primary identity + a typed voResponse jsonb column (@objectRef +
17-
# @storage:jsonb — the owned-object typed-jsonb codec path).
28+
# table + primary identity + a template.prompt. The deriver injects
29+
# voRequest/voResponse field.object jsonb columns from the prompt refs.
1830
- object.entity:
1931
name: GreetingCall
2032
extends: metaobjects::ai::LlmCallBase
2133
children:
2234
- source.rdb: { table: llm_call, role: primary }
2335
- identity.primary: { name: primary, fields: ["spanId"] }
24-
- field.object:
25-
name: voResponse
26-
column: voResponse
27-
storage: jsonb
28-
objectRef: metaobjects::ai::GreetingResponse
36+
- template.prompt:
37+
name: greet
38+
payloadRef: metaobjects::ai::GreetingRequest
39+
responseRef: metaobjects::ai::GreetingResponse

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ public void execute() throws MojoExecutionException, MojoFailureException
8686

8787
MetaDataLoader loader = createLoader(projectClassLoader);
8888

89+
// AI-trace pre-pass: derive typed voRequest/voResponse jsonb columns onto
90+
// LlmCallBase-derived entities so the trace-helper generator (and any
91+
// entity/schema codegen) sees them without the author restating them.
92+
// Idempotent + a no-op for projects with no trace entities. The loader is
93+
// already loaded here (no hard freeze in Java); generators re-read children
94+
// fresh, so post-load derivation is sufficient for codegen.
95+
com.metaobjects.loader.ai.LlmTraceFieldDeriver.deriveTraceFields( loader );
96+
8997
List<Generator> generatorImpls = buildGenerators( projectClassLoader, null );
9098

9199
executeGenerators( loader, generatorImpls );

server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ public class MetaDataLoader implements LoaderConfigurable {
110110
// ClassLoader used for resolving metadata-referenced Java classes.
111111
private ClassLoader metaDataClassLoader = null;
112112

113+
// Optional pre-freeze enrichment hook. Mirrors the TypeScript loader's
114+
// generic `preFreeze` option: a callback that runs inside {@link #load} once
115+
// the tree is parsed and {@code extends} refs are resolved, but before the
116+
// validation passes. This is the designated injection point for programmatic
117+
// tree enrichment (e.g. codegen/runtime pre-passes that DERIVE additional
118+
// nodes from validated metadata). The injected nodes participate in the
119+
// subsequent validation passes. Null by default (no enrichment).
120+
private java.util.function.Consumer<MetaDataLoader> preFreeze = null;
121+
113122
// v6.0.0: Unified registry
114123
private MetaDataRegistry typeRegistry = null;
115124
private MetaDataLoaderRegistry loaderRegistry = null;
@@ -391,9 +400,26 @@ public static MetaDataLoader fromUris(String name, List<URI> uris) {
391400
* @return a fully-initialized loader with all URIs loaded
392401
*/
393402
public static MetaDataLoader fromUris(String name, List<URI> uris, LoaderOptions opts) {
403+
return fromUris(name, uris, opts, null);
404+
}
405+
406+
/**
407+
* Build + load with a pre-freeze enrichment hook (see {@link #setPreFreeze}).
408+
* The hook runs in-load (after extends-resolution, before validation), so any
409+
* derived nodes are validated and registered atomically with this build.
410+
*
411+
* @param name the loader name
412+
* @param uris model URIs to load
413+
* @param opts loader options (may be {@code null} for defaults)
414+
* @param preFreeze pre-freeze enrichment hook (may be {@code null})
415+
* @return a fully-initialized loader with all URIs loaded
416+
*/
417+
public static MetaDataLoader fromUris(String name, List<URI> uris, LoaderOptions opts,
418+
java.util.function.Consumer<MetaDataLoader> preFreeze) {
394419
MetaDataLoader loader = (opts == null)
395420
? createManual(false, name)
396421
: new MetaDataLoader(opts, SUBTYPE_MANUAL, name);
422+
if (preFreeze != null) loader.setPreFreeze(preFreeze);
397423
try {
398424
loader.init();
399425
List<MetaDataSource> sources = new ArrayList<>(uris.size());
@@ -672,6 +698,19 @@ public ClassLoader getMetaDataClassLoader() {
672698
return getDefaultMetaDataClassLoader();
673699
}
674700

701+
/**
702+
* Register a pre-freeze enrichment hook (mirrors the TS loader's generic
703+
* {@code preFreeze} option). The hook runs inside {@link #load} after
704+
* {@code extends} refs are resolved and before the validation passes, so any
705+
* nodes it injects are validated like hand-authored ones. Fluent; pass
706+
* {@code null} to clear. Must be set before {@link #load} runs.
707+
*/
708+
@SuppressWarnings("unchecked")
709+
public <T extends MetaDataLoader> T setPreFreeze(java.util.function.Consumer<MetaDataLoader> hook) {
710+
this.preFreeze = hook;
711+
return (T) this;
712+
}
713+
675714
///////////////////////////////////////////////////////////////////////
676715
// Source URIs (H3a Task 5: lifted from SimpleLoader)
677716

@@ -1250,6 +1289,14 @@ public MetaDataLoader load(List<MetaDataSource> sources) {
12501289
// becomes ERR_UNRESOLVED_SUPER.
12511290
resolvePendingExtends();
12521291

1292+
// Pre-freeze enrichment hook (mirrors TS): runs after extends-resolution
1293+
// and before validation, so DERIVED nodes (e.g. AI-trace voRequest/
1294+
// voResponse jsonb columns) are visible to the validation passes and to
1295+
// every downstream reader (codegen + runtime) just like authored nodes.
1296+
if (preFreeze != null) {
1297+
preFreeze.accept(this);
1298+
}
1299+
12531300
// Run post-load validation passes after all sources in this batch are parsed.
12541301
// Fires both when called from init() (via loadSourceURIsIfPresent) and when
12551302
// called directly by tests or the conformance runner. The loader handle is
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* Copyright 2003 Doug Mealing LLC dba Meta Objects
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.metaobjects.loader.ai;
17+
18+
import com.metaobjects.MetaData;
19+
import com.metaobjects.attr.StringAttribute;
20+
import com.metaobjects.field.MetaField;
21+
import com.metaobjects.field.ObjectField;
22+
import com.metaobjects.loader.MetaDataLoader;
23+
import com.metaobjects.object.MetaObject;
24+
import com.metaobjects.template.PromptTemplate;
25+
26+
import java.util.List;
27+
28+
/**
29+
* AI-trace pre-freeze pass: inject typed {@code voRequest}/{@code voResponse}
30+
* {@code field.object} columns onto entities that extend {@code LlmCallBase} and
31+
* carry a nested {@code template.prompt} with {@code @payloadRef}/{@code @responseRef}.
32+
*
33+
* <p>Cross-port mirror of the TypeScript reference
34+
* ({@code codegen-ts/src/ai/derive-trace-fields.ts}). The TS reference wires this
35+
* as a codegen-only {@code preFreeze} hook because the TS runtime persists via a
36+
* direct row-write (no runtime metadata needed). The Java OMDB runtime is
37+
* <em>metadata-driven</em> — the generated {@code record<Entity>} helper calls
38+
* {@code setObject("voResponse", ...)} which OMDB maps to a jsonb column by reading
39+
* the <em>runtime-loaded</em> MetaObject. So in Java this derivation must also reach
40+
* the runtime load path; it is therefore exposed as a {@link MetaDataLoader}
41+
* {@code preFreeze} hook usable by BOTH codegen and runtime loaders.</p>
42+
*
43+
* <p>The injected fields carry {@code @objectRef} + {@code @storage="jsonb"} so the
44+
* existing owned-object typed-jsonb codec path handles them — identical to a
45+
* hand-authored {@code field.object}. The pass is idempotent: an own field of the
46+
* same name is left untouched, so explicit authoring still wins.</p>
47+
*/
48+
public final class LlmTraceFieldDeriver {
49+
50+
/** Short name of the shipped abstract base every trace entity extends. */
51+
public static final String LLM_CALL_BASE = "LlmCallBase";
52+
53+
/** Derived field name for the typed request payload VO. */
54+
public static final String VO_REQUEST = "voRequest";
55+
56+
/** Derived field name for the typed extracted-response VO. */
57+
public static final String VO_RESPONSE = "voResponse";
58+
59+
/** {@code @storage} value selecting the typed-jsonb owned-object codec. */
60+
private static final String STORAGE_JSONB = "jsonb";
61+
62+
private LlmTraceFieldDeriver() {}
63+
64+
/**
65+
* For every concrete entity in {@code loader} that (1) extends
66+
* {@code LlmCallBase} (directly or transitively) and (2) has an own
67+
* {@code template.prompt} carrying {@code @payloadRef}/{@code @responseRef},
68+
* inject {@code field.object} children named {@code voRequest}/{@code voResponse}
69+
* (respectively) with {@code @storage="jsonb"}. Idempotent.
70+
*
71+
* <p>Designed to be passed to {@link MetaDataLoader#setPreFreeze}.</p>
72+
*/
73+
public static void deriveTraceFields(MetaDataLoader loader) {
74+
for (MetaObject obj : loader.getChildren(MetaObject.class)) {
75+
if (!extendsBase(obj, LLM_CALL_BASE)) continue;
76+
77+
PromptTemplate prompt = findOwnPrompt(obj);
78+
if (prompt == null) continue;
79+
80+
String payloadRef = prompt.getPayloadRef();
81+
String responseRef = prompt.getResponseRef();
82+
83+
if (payloadRef != null && !payloadRef.isEmpty()) {
84+
injectObjField(obj, VO_REQUEST, payloadRef);
85+
}
86+
if (responseRef != null && !responseRef.isEmpty()) {
87+
injectObjField(obj, VO_RESPONSE, responseRef);
88+
}
89+
}
90+
}
91+
92+
/** Walk the resolved super chain for a node whose short name equals {@code baseName}. */
93+
private static boolean extendsBase(MetaData obj, String baseName) {
94+
MetaData cur = obj.getSuperData();
95+
while (cur != null) {
96+
if (baseName.equals(cur.getShortName())) return true;
97+
cur = cur.getSuperData();
98+
}
99+
return false;
100+
}
101+
102+
/** First OWN {@code template.prompt} child of {@code obj}, or {@code null}. */
103+
private static PromptTemplate findOwnPrompt(MetaObject obj) {
104+
List<PromptTemplate> prompts = obj.getChildren(PromptTemplate.class, false);
105+
return prompts.isEmpty() ? null : prompts.get(0);
106+
}
107+
108+
/**
109+
* Inject a {@code field.object} child onto {@code entity} with {@code @objectRef}
110+
* + {@code @storage="jsonb"}, unless an own field of that name already exists.
111+
*/
112+
private static void injectObjField(MetaObject entity, String fieldName, String objectRef) {
113+
for (MetaField existing : entity.getChildren(MetaField.class, false)) {
114+
if (fieldName.equals(existing.getShortName())) return; // idempotent
115+
}
116+
ObjectField f = new ObjectField(fieldName);
117+
f.addChild(StringAttribute.create(ObjectField.ATTR_OBJECTREF, objectRef));
118+
f.addChild(StringAttribute.create(ObjectField.ATTR_STORAGE, STORAGE_JSONB));
119+
entity.addChild(f);
120+
}
121+
}

server/java/metadata/src/main/java/com/metaobjects/template/PromptTemplate.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,11 @@ public String getResponseRef() {
7777
? getMetaAttr(ATTR_RESPONSE_REF, false).getValueAsString()
7878
: null;
7979
}
80+
81+
/** Returns the raw value of {@code @payloadRef}, or {@code null} if absent. */
82+
public String getPayloadRef() {
83+
return hasMetaAttr(ATTR_PAYLOAD_REF, false)
84+
? getMetaAttr(ATTR_PAYLOAD_REF, false).getValueAsString()
85+
: null;
86+
}
8087
}

0 commit comments

Comments
 (0)