Skip to content

Commit bf173a8

Browse files
dmealingclaude
andcommitted
feat(ai): generated trace helper owns extract + writes base+typed row (record/call<Entity>)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 718b111 commit bf173a8

3 files changed

Lines changed: 89 additions & 45 deletions

File tree

server/typescript/packages/codegen-ts/src/generators/trace-helper-file.ts

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
// template.prompt with @payloadRef and/or @responseRef.
66
//
77
// The emitted helper exports an async function `record<Entity>(om, responseMo, input)`
8-
// that calls `recordLlmCall` from @metaobjectsdev/runtime-ts. The helper is typed
9-
// against the generated payload interfaces (request + response VOs) so call-sites
10-
// get compile-time checks.
8+
// that EXTRACTS the typed response VO itself and persists ONE row = base envelope +
9+
// raw I/O (via buildLlmCallRow) PLUS the typed voRequest/voResponse columns. The
10+
// helper is typed against the generated payload interfaces (request + response VOs)
11+
// so call-sites get compile-time checks.
1112
//
1213
// NOTE: ObjectManager does not expose its loaded metadata root, so the caller must
1314
// pass the resolved `responseMo: MetaObject` explicitly. The generated helper
@@ -90,10 +91,17 @@ export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts):
9091
const sti = tphPin !== undefined;
9192
const callTypeValue = sti ? tphPin.value : entityName;
9293

93-
// Emitted record<Entity> fragments: the keys Omit'd from the caller input,
94-
// and the first argument passed to recordLlmCall.
95-
const recordInputOmit = sti ? `"llmRequest" | "callType"` : `"llmRequest"`;
96-
const recordArg = sti ? `{ ...input, callType: ${JSON.stringify(callTypeValue)} }` : `input`;
94+
// Emitted record<Entity> fragments. The caller never supplies the derived
95+
// status/errorDetail (the helper computes them from extraction), and an STI
96+
// subtype additionally drops the framework-managed callType discriminator.
97+
const recordInputOmit = sti
98+
? `"llmRequest" | "status" | "errorDetail" | "callType"`
99+
: `"llmRequest" | "status" | "errorDetail"`;
100+
// The object spread passed to buildLlmCallRow: STI subtypes stamp their
101+
// discriminator value, all helpers fold in the derived status/errorDetail.
102+
const recordBuildArg = sti
103+
? `{ ...input, callType: ${JSON.stringify(callTypeValue)}, status, errorDetail }`
104+
: `{ ...input, status, errorDetail }`;
97105

98106
// Derive the parse format from the prompt's @format attr.
99107
// "xml" → Format.XML; absent or any other value → Format.JSON.
@@ -119,26 +127,32 @@ export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts):
119127
const renderFormat = typeof promptFormat === "string" ? promptFormat : "text";
120128

121129
// Build the import block — all imports MUST stay at the top of the emitted file.
130+
// `extract` + `render` both live in @metaobjectsdev/render: import them together
131+
// (one de-duplicated statement) when the prompt is renderable, else import only
132+
// `extract`.
122133
const importLines: string[] = [
123134
`import type { ObjectManager } from "@metaobjectsdev/runtime-ts";`,
124135
`import {`,
125136
` LlmCallDbRecorder,`,
126-
` recordLlmCall,`,
137+
` buildLlmCallRow,`,
138+
` persistLlmCallRow,`,
139+
` extractSchemaFor,`,
140+
` Format,`,
127141
` type LlmCallInput,`,
128-
` type RecordLlmCallResult,`,
129142
`} from "@metaobjectsdev/runtime-ts";`,
130-
`import { Format } from "@metaobjectsdev/runtime-ts";`,
143+
renderable
144+
? `import { extract, render, type Provider } from "@metaobjectsdev/render";`
145+
: `import { extract } from "@metaobjectsdev/render";`,
131146
`import type { MetaObject } from "@metaobjectsdev/metadata";`,
132147
];
133148
if (renderable) {
134149
importLines.push(
135-
`import { render, type Provider } from "@metaobjectsdev/render";`,
136150
`import {`,
137-
` callLlm,`,
151+
` runLlmCall,`,
152+
` type RunLlmCallInput,`,
153+
` type RunLlmCallDeps,`,
138154
` type LlmClient,`,
139155
` type LlmRequest,`,
140-
` type CallLlmInput,`,
141-
` type CallLlmDeps,`,
142156
` type CostFn,`,
143157
` type Clock,`,
144158
` type IdGen,`,
@@ -157,7 +171,9 @@ export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts):
157171
``,
158172
`// ---- Typed result -----------------------------------------------------------`,
159173
``,
160-
`export interface ${entityName}TraceResult extends RecordLlmCallResult {`,
174+
`export interface ${entityName}TraceResult {`,
175+
` status: "ok" | "error";`,
176+
` errorDetail: string | null;`,
161177
` /** Parsed response VO, or null when extraction reported a lost-required field. */`,
162178
` /** Note: voResponse is the plain extracted record typed as the response shape (structural, not an instance). */`,
163179
` voResponse: ${responseRef} | null;`,
@@ -181,12 +197,15 @@ export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts):
181197
` responseMo: MetaObject,`,
182198
` input: Omit<LlmCallInput, ${recordInputOmit}> & { llmRequest: ${requestType} },`,
183199
`): Promise<${entityName}TraceResult> {`,
184-
` const result = await recordLlmCall(${recordArg}, {`,
185-
` recorder: new LlmCallDbRecorder(om, "${entityName}"),`,
186-
` responseMo,`,
187-
` format: ${formatLiteral},`,
188-
` });`,
189-
` return result as ${entityName}TraceResult;`,
200+
` const schema = extractSchemaFor(responseMo, ${formatLiteral});`,
201+
` const outcome = extract(input.llmResponseText, schema);`,
202+
` const failed = outcome.report.hasLostRequired();`,
203+
` const status = failed ? ("error" as const) : ("ok" as const);`,
204+
' const errorDetail = failed ? `lost required: ${outcome.report.lostRequired().join(", ")}` : null;',
205+
` const base = buildLlmCallRow(${recordBuildArg});`,
206+
` const row = { ...base, voRequest: input.llmRequest, voResponse: failed ? null : outcome.data };`,
207+
` await persistLlmCallRow(new LlmCallDbRecorder(om, "${entityName}"), row);`,
208+
` return { status, errorDetail, voResponse: failed ? null : (outcome.data as ${responseRef}) };`,
190209
`}`,
191210
``,
192211
];
@@ -229,21 +248,30 @@ export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts):
229248
` const request: LlmRequest = { prompt, model: deps.model };`,
230249
` if (deps.system !== undefined) request.system = deps.system;`,
231250
` if (deps.params !== undefined) request.params = deps.params;`,
232-
` const callInput: CallLlmInput = { callType: ${JSON.stringify(callTypeValue)}, payload, request };`,
233-
` if (deps.traceId !== undefined) callInput.traceId = deps.traceId;`,
234-
` if (deps.parentSpanId !== undefined) callInput.parentSpanId = deps.parentSpanId;`,
235-
` if (deps.sessionId !== undefined) callInput.sessionId = deps.sessionId;`,
236-
` const callDeps: CallLlmDeps = {`,
237-
` client: deps.client,`,
238-
` recorder: new LlmCallDbRecorder(deps.om, ${JSON.stringify(entityName)}),`,
239-
` responseMo: deps.responseMo,`,
240-
` format: ${formatLiteral},`,
241-
` };`,
242-
` if (deps.cost !== undefined) callDeps.cost = deps.cost;`,
243-
` if (deps.clock !== undefined) callDeps.clock = deps.clock;`,
244-
` if (deps.ids !== undefined) callDeps.ids = deps.ids;`,
245-
` const result = await callLlm(callInput, callDeps);`,
246-
` return result as ${entityName}TraceResult;`,
251+
` const runInput: RunLlmCallInput = { callType: ${JSON.stringify(callTypeValue)}, request };`,
252+
` if (deps.traceId !== undefined) runInput.traceId = deps.traceId;`,
253+
` if (deps.parentSpanId !== undefined) runInput.parentSpanId = deps.parentSpanId;`,
254+
` if (deps.sessionId !== undefined) runInput.sessionId = deps.sessionId;`,
255+
` const runDeps: RunLlmCallDeps = { client: deps.client };`,
256+
` if (deps.cost !== undefined) runDeps.cost = deps.cost;`,
257+
` if (deps.clock !== undefined) runDeps.clock = deps.clock;`,
258+
` if (deps.ids !== undefined) runDeps.ids = deps.ids;`,
259+
` const { input: recInput, completion } = await runLlmCall(runInput, runDeps);`,
260+
` let voResponse: ${responseRef} | null = null;`,
261+
` let status = recInput.status;`,
262+
` let errorDetail = recInput.errorDetail;`,
263+
` if (completion !== undefined) {`,
264+
` const outcome = extract(completion.body, extractSchemaFor(deps.responseMo, ${formatLiteral}));`,
265+
` if (outcome.report.hasLostRequired()) {`,
266+
` status = "error";`,
267+
' errorDetail = `lost required: ${outcome.report.lostRequired().join(", ")}`;',
268+
` } else {`,
269+
` voResponse = outcome.data as ${responseRef};`,
270+
` }`,
271+
` }`,
272+
` const row = { ...buildLlmCallRow({ ...recInput, status, errorDetail }), voRequest: payload, voResponse };`,
273+
` await persistLlmCallRow(new LlmCallDbRecorder(deps.om, ${JSON.stringify(entityName)}), row);`,
274+
` return { status, errorDetail, voResponse };`,
247275
`}`,
248276
);
249277
}

server/typescript/packages/codegen-ts/test/ai-trace-sti.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ describe("ai-trace #1c — STI callType stamping", () => {
5353
test("record<Entity> omits callType from input and stamps the discriminator value", async () => {
5454
const { classify, summarize } = await genTrace();
5555
// both subtypes drop callType from the caller input and stamp their own value
56-
expect(classify).toContain('Omit<LlmCallInput, "llmRequest" | "callType">');
57-
expect(classify).toContain('{ ...input, callType: "classify" }');
58-
expect(summarize).toContain('Omit<LlmCallInput, "llmRequest" | "callType">');
59-
expect(summarize).toContain('{ ...input, callType: "summarize" }');
56+
expect(classify).toContain('Omit<LlmCallInput, "llmRequest" | "status" | "errorDetail" | "callType">');
57+
expect(classify).toContain('buildLlmCallRow({ ...input, callType: "classify", status, errorDetail })');
58+
expect(summarize).toContain('Omit<LlmCallInput, "llmRequest" | "status" | "errorDetail" | "callType">');
59+
expect(summarize).toContain('buildLlmCallRow({ ...input, callType: "summarize", status, errorDetail })');
6060
});
6161

6262
test("call<Entity> stamps the discriminator value (not the entity name)", async () => {

server/typescript/packages/codegen-ts/test/derive-trace-fields.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,19 @@ describe("derive trace fields", () => {
5959
expect(files).toContain("ClassifyCall.trace.ts");
6060
const h = readFileSync(join(tmp, "ClassifyCall.trace.ts"), "utf-8");
6161
expect(h).toContain("recordClassifyCall");
62-
expect(h).toContain("recordLlmCall");
62+
// The generated record helper now owns extract + writes base + typed row.
63+
expect(h).toContain("extractSchemaFor(");
64+
expect(h).toContain("const outcome = extract(input.llmResponseText, schema);");
65+
expect(h).toContain("buildLlmCallRow(");
66+
expect(h).toContain("voRequest: input.llmRequest");
67+
expect(h).toContain("voResponse:");
68+
expect(h).toContain('persistLlmCallRow(new LlmCallDbRecorder(om, "ClassifyCall"), row)');
69+
// stale generic-path entry point must be gone.
70+
expect(h).not.toContain("recordLlmCall(");
6371
// non-STI invariant: a trace entity that is NOT a TPH subtype keeps callType
6472
// caller-supplied (no "| callType" omission, no spread-injection).
65-
expect(h).toContain('Omit<LlmCallInput, "llmRequest">');
66-
expect(h).not.toContain('"llmRequest" | "callType"');
73+
expect(h).toContain('Omit<LlmCallInput, "llmRequest" | "status" | "errorDetail">');
74+
expect(h).not.toContain('| "callType"');
6775
});
6876

6977
test("trace-helper emits both record and call helpers for a renderable prompt", async () => {
@@ -84,11 +92,19 @@ describe("derive trace fields", () => {
8492
expect(h).toContain('from "@metaobjectsdev/ai-runtime"');
8593
expect(h).toContain('from "@metaobjectsdev/render"');
8694
expect(h).toContain('render({ ref: "p/x"');
95+
// call<Entity> now drives the CALL via runLlmCall, then extracts itself.
96+
expect(h).toContain("runLlmCall(");
97+
expect(h).toContain("voRequest: payload");
8798
// exactOptionalPropertyTypes-safe shape: request built then optionals added.
8899
expect(h).toContain("const request: LlmRequest = { prompt, model: deps.model };");
89-
expect(h).toContain("const callInput: CallLlmInput = { callType: \"ClassifyCall\", payload, request };");
100+
expect(h).toContain('const runInput: RunLlmCallInput = { callType: "ClassifyCall", request };');
90101
expect(h).toContain("if (deps.system !== undefined) request.system = deps.system;");
91-
expect(h).toContain("const result = await callLlm(callInput, callDeps);");
102+
expect(h).toContain("const { input: recInput, completion } = await runLlmCall(runInput, runDeps);");
103+
// stale generic call-loop entry point must be gone.
104+
expect(h).not.toContain("callLlm(");
105+
expect(h).not.toContain("CallLlmInput");
106+
// extract + render must be a single de-duplicated import from render.
107+
expect(h).toContain('import { extract, render, type Provider } from "@metaobjectsdev/render";');
92108
// imports must all be at the top: no `import ` after the first export.
93109
const firstExport = h.indexOf("export ");
94110
const lastImport = h.lastIndexOf("\nimport ");

0 commit comments

Comments
 (0)