From e9de35b3c9e13189139e667d159580e5dd01c6c8 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Tue, 4 Mar 2025 10:03:14 +1000 Subject: [PATCH 1/2] Refactor spellcaster routes Implement fast path: imagine --- .../routes/ai/spell/{ => handlers}/fulfill.ts | 131 ++++++-- .../routes/ai/spell/handlers/imagine.ts | 176 +++++++++++ .../routes/ai/spell/handlers/recast.ts | 105 +++++++ .../routes/ai/spell/handlers/reuse.ts | 110 +++++++ .../packages/toolshed/routes/ai/spell/json.ts | 22 ++ .../routes/ai/spell/spell.handlers.ts | 289 +----------------- .../toolshed/routes/ai/spell/spell.index.ts | 12 +- .../toolshed/routes/ai/spell/spell.routes.ts | 67 +++- 8 files changed, 579 insertions(+), 333 deletions(-) rename typescript/packages/toolshed/routes/ai/spell/{ => handlers}/fulfill.ts (72%) create mode 100644 typescript/packages/toolshed/routes/ai/spell/handlers/imagine.ts create mode 100644 typescript/packages/toolshed/routes/ai/spell/handlers/recast.ts create mode 100644 typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts create mode 100644 typescript/packages/toolshed/routes/ai/spell/json.ts diff --git a/typescript/packages/toolshed/routes/ai/spell/fulfill.ts b/typescript/packages/toolshed/routes/ai/spell/handlers/fulfill.ts similarity index 72% rename from typescript/packages/toolshed/routes/ai/spell/fulfill.ts rename to typescript/packages/toolshed/routes/ai/spell/handlers/fulfill.ts index 40dcbe814..603536643 100644 --- a/typescript/packages/toolshed/routes/ai/spell/fulfill.ts +++ b/typescript/packages/toolshed/routes/ai/spell/handlers/fulfill.ts @@ -1,12 +1,19 @@ -import { getAllBlobs, getBlob } from "@/routes/ai/spell/behavior/effects.ts"; -import { generateText } from "@/lib/llm.ts"; -import { performSearch } from "@/routes/ai/spell/behavior/search.ts"; -import { checkSchemaMatch } from "@/lib/schema-match.ts"; +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { z } from "zod"; +import { getAllBlobs } from "@/routes/ai/spell/behavior/effects.ts"; + +import type { AppRouteHandler } from "@/lib/types.ts"; +import type { FulfillSchemaRoute } from "@/routes/ai/spell/spell.routes.ts"; +import { Spell } from "@/routes/ai/spell/spell.ts"; +import { performSearch } from "../behavior/search.ts"; import { Logger } from "@/lib/prefixed-logger.ts"; -import { - ProcessSchemaRequest, - ProcessSchemaResponse, -} from "@/routes/ai/spell/spell.handlers.ts"; +import { candidates } from "@/routes/ai/spell/caster.ts"; +import { CasterSchemaRoute } from "@/routes/ai/spell/spell.routes.ts"; +import { processSpellSearch } from "@/routes/ai/spell/behavior/spell-search.ts"; +import { captureException } from "@sentry/deno"; +import { areSchemaCompatible } from "@/routes/ai/spell/schema-compatibility.ts"; + +import { generateText } from "@/lib/llm.ts"; import { decomposeSchema, findExactMatches, @@ -14,6 +21,65 @@ import { reassembleFragments, SchemaFragment, } from "@/routes/ai/spell/schema.ts"; +import { extractJSON } from "@/routes/ai/spell/json.ts"; + +export const FulfillSchemaRequestSchema = z.object({ + schema: z.record( + z + .string() + .or( + z.number().or(z.boolean().or(z.array(z.any()).or(z.record(z.any())))), + ), + ).openapi({ + example: { + title: { type: "string" }, + url: { type: "string" }, + }, + }), + tags: z.array(z.string()).optional(), + many: z.boolean().optional(), + prompt: z.string().optional(), + options: z + .object({ + format: z.enum(["json", "yaml"]).optional(), + validate: z.boolean().optional(), + maxExamples: z.number().default(5).optional(), + exact: z.boolean().optional(), + }) + .optional(), +}); + +export const FulfillSchemaResponseSchema = z.object({ + result: z.union([z.record(z.any()), z.array(z.record(z.any()))]), + metadata: z.object({ + processingTime: z.number(), + schemaFormat: z.string(), + fragments: z.array( + z.object({ + matches: z.array( + z.object({ + key: z.string(), + data: z.record(z.any()), + similarity: z.number(), + }), + ), + path: z.array(z.string()), + schema: z.record(z.any()), + }), + ), + reassembledExample: z.record(z.any()), + tagMatchInfo: z.object({ + usedTags: z.any(), + matchRanks: z.array(z.object({ + path: z.any(), + matches: z.any(), + })), + }), + }), +}); + +export type FulfillSchemaRequest = z.infer; +export type FulfillSchemaResponse = z.infer; function calculateTagRank( data: Record, @@ -34,10 +100,10 @@ function calculateTagRank( } export async function processSchema( - body: ProcessSchemaRequest, + body: FulfillSchemaRequest, logger: Logger, startTime: number, -): Promise { +): Promise { const tags = body.tags || []; logger.info( { schema: body.schema, many: body.many, options: body.options, tags }, @@ -162,28 +228,6 @@ export async function processSchema( ); let result: Record | Array>; - function extractJSON( - text: string, - ): Record | Array> { - try { - // Try to extract from markdown code block first - const markdownMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); - if (markdownMatch) { - return JSON.parse(markdownMatch[1].trim()); - } - - // If not in markdown, try to find JSON-like content - const jsonMatch = text.match(/\{[\s\S]*\}|\[[\s\S]*\]/); - if (jsonMatch) { - return JSON.parse(jsonMatch[0].trim()); - } - - // If no special formatting, try parsing the original text - return JSON.parse(text.trim()); - } catch (error) { - return {}; - } - } try { logger.debug("Parsing LLM response"); @@ -318,3 +362,26 @@ Respond with ${ many ? "an array of valid JSON objects" : "a single valid JSON object" }.`; } + +export const fulfill: AppRouteHandler = async (c) => { + const logger: Logger = c.get("logger"); + const body = (await c.req.json()) as FulfillSchemaRequest; + const startTime = performance.now(); + + try { + const response = await processSchema(body, logger, startTime); + + logger.info( + { processingTime: response.metadata.processingTime }, + "Request completed", + ); + return c.json(response, HttpStatusCodes.OK); + } catch (error) { + logger.error({ error }, "Error processing schema"); + captureException(error); + return c.json( + { error: "Failed to process schema" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR, + ); + } +}; diff --git a/typescript/packages/toolshed/routes/ai/spell/handlers/imagine.ts b/typescript/packages/toolshed/routes/ai/spell/handlers/imagine.ts new file mode 100644 index 000000000..9ebc94f21 --- /dev/null +++ b/typescript/packages/toolshed/routes/ai/spell/handlers/imagine.ts @@ -0,0 +1,176 @@ +import { generateText } from "@/lib/llm.ts"; +import { Logger } from "@/lib/prefixed-logger.ts"; +import { extractJSON } from "@/routes/ai/spell/json.ts"; +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { z } from "zod"; +import { captureException } from "@sentry/deno"; +import type { AppRouteHandler } from "@/lib/types.ts"; +import type { ImagineDataRoute } from "@/routes/ai/spell/spell.routes.ts"; + +export const ImagineDataRequestSchema = z.object({ + schema: z.record( + z + .string() + .or( + z.number().or(z.boolean().or(z.array(z.any()).or(z.record(z.any())))), + ), + ) + .describe("JSON schema format to conform to") + .openapi({ + example: { + title: { type: "string" }, + url: { type: "string" }, + }, + }), + model: z.string().default("claude-3-7-sonnet").describe( + "The LLM to use for data generation", + ).openapi({ example: "claude-3-7-sonnet" }), + prompt: z.string().optional().describe( + "Guide data generation with a prompt", + ).openapi({ example: "Make it about cats" }), + options: z + .object({ + many: z.boolean().default(false).describe( + "Whether to generate multiple results", + ), + }) + .optional(), +}); + +export const ImagineDataResponseSchema = z.object({ + result: z.union([z.record(z.any()), z.array(z.record(z.any()))]), + metadata: z.object({ + processingTime: z.number(), + }), +}); + +export type ImagineDataRequest = z.infer; +export type ImagineDataResponse = z.infer; + +export async function processSchema( + body: ImagineDataRequest, + logger: Logger, + startTime: number, +): Promise { + logger.info( + { schema: body.schema, options: body.options }, + "Starting schema processing request", + ); + + logger.debug("Constructing prompt with reassembled examples"); + const prompt = constructSchemaPrompt( + body.schema, + body.prompt, + body?.options?.many, + ); + + logger.info({ prompt }, "Sending request to LLM"); + const llmStartTime = performance.now(); + const llmResponse = await generateText({ + model: "claude-3-7-sonnet", + system: body?.options?.many + ? "Generate realistic example data that fits the provided schema. Return valid JSON array with multiple objects. Each object must match the schema exactly and respect all descriptions and constraints." + : "Generate realistic example data that fits the provided schema. Return a valid JSON object that matches the schema exactly and respects all descriptions and constraints.", + stream: false, + messages: [{ role: "user", content: prompt }], + }); + logger.info( + { llmTime: Math.round(performance.now() - llmStartTime) }, + "Received LLM response", + ); + + let result: Record | Array>; + + try { + logger.debug("Parsing LLM response"); + result = extractJSON(llmResponse); + logger.debug({ extractedJSON: result }, "Extracted JSON from response"); + + if (body?.options?.many && !Array.isArray(result)) { + logger.debug("Converting single object to array for many=true"); + result = [result]; + } + logger.info( + { + resultType: body?.options?.many ? "array" : "object", + resultSize: body?.options?.many ? (result as Array).length : 1, + }, + "Successfully parsed LLM response", + ); + } catch (error) { + logger.error( + { error, response: llmResponse }, + "Failed to parse LLM response", + ); + throw new Error("Failed to parse LLM response as JSON"); + } + + const totalTime = Math.round(performance.now() - startTime); + logger.info( + { totalTime }, + "Completed schema processing request", + ); + + return { + result, + metadata: { + processingTime: totalTime, + }, + }; +} + +function constructSchemaPrompt( + schema: Record, + userPrompt?: string, + many?: boolean, +): string { + const schemaStr = JSON.stringify(schema, null, 2); + + return `# TASK + ${ + many + ? `Generate multiple objects that fit the requested schema based on the references provided.` + : `Fit data into the requested schema based on the references provided.` + } + +# SCHEMA +${schemaStr} + +# INSTRUCTIONS +1. ${ + many + ? `Generate an array of objects that strictly follow the schema structure` + : `Generate an object that strictly follows the schema structure` + } +2. Return ONLY valid JSON ${many ? "array" : "object"} matching the schema + +${userPrompt ? `# ADDITIONAL REQUIREMENTS\n${userPrompt}\n\n` : ""} + +# RESPONSE FORMAT +Respond with ${ + many ? "an array of valid JSON objects" : "a single valid JSON object" + }.`; +} + +export const imagine: AppRouteHandler = async (c) => { + const logger: Logger = c.get("logger"); + const body = (await c.req.json()) as ImagineDataRequest; + const startTime = performance.now(); + + try { + const response = await processSchema(body, logger, startTime); + + logger.info( + { processingTime: response.metadata.processingTime }, + "Request completed", + ); + return c.json(response, HttpStatusCodes.OK); + } catch (error) { + logger.error({ error }, "Error processing schema"); + captureException(error); + return c.json( + { error: "Failed to process schema" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR, + ); + } +}; diff --git a/typescript/packages/toolshed/routes/ai/spell/handlers/recast.ts b/typescript/packages/toolshed/routes/ai/spell/handlers/recast.ts new file mode 100644 index 000000000..da71b9915 --- /dev/null +++ b/typescript/packages/toolshed/routes/ai/spell/handlers/recast.ts @@ -0,0 +1,105 @@ +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { z } from "zod"; +import { + getAllMemories, + getBlob, + getMemory, +} from "@/routes/ai/spell/behavior/effects.ts"; + +import type { AppRouteHandler } from "@/lib/types.ts"; +import type { RecastRoute } from "@/routes/ai/spell/spell.routes.ts"; +import { Spell } from "@/routes/ai/spell/spell.ts"; +import { Logger } from "@/lib/prefixed-logger.ts"; +import { captureException } from "@sentry/deno"; +import { areSchemaCompatible } from "@/routes/ai/spell/schema-compatibility.ts"; + +export const RecastRequestSchema = z.object({ + charmId: z.string().describe("The ID of the charm to reuse the spell from"), + replica: z.string().describe("The space the charm is stored in"), +}); + +export const RecastResponseSchema = z.object({ + spellId: z.string(), + cells: z.record(z.any()), +}); + +export type RecastRequest = z.infer; +export type RecastResponse = z.infer; + +async function processRecast( + charmId: string, + replica: string, + logger: Logger, +): Promise { + const charm = await getMemory(charmId, replica); + logger.info( + { charmId, replica }, + "Retrieved charm", + ); + + const source = await getMemory(charm.source["/"], replica); + logger.info({ sourceId: charm.source["/"] }, "Retrieved source charm"); + + const argument = source.value.argument; + const type = source.value.$TYPE; + logger.debug({ type, argument }, "Extracted argument and type from source"); + const spellId = "spell-" + type; + logger.info({ spellId }, "Looking up spell"); + + const spell = await getBlob(spellId); + logger.info({ spellId }, "Retrieved spell"); + if (!spell) { + throw new Error("No spell found for id: " + spellId); + } + + const schema = spell.recipe.argumentSchema; + logger.debug({ schema }, "Extracted argument schema from spell"); + + const cells = await getAllMemories(replica); + // First get all charms that have a $TYPE and their IDs + const typedCharms = Object.entries(cells) + .filter(([_, cell]) => cell?.value?.$TYPE) + .map(([id, cell]) => ({ id, cell })); + // Then filter to matching schemas and build record + const matchingCharms = await typedCharms.reduce( + async (accPromise, { id, cell }) => { + const acc = await accPromise; + const charmSpellId = "spell-" + cell.value.$TYPE; + try { + const charmSpell = await getBlob(charmSpellId); + const charmSchema = charmSpell.recipe.argumentSchema; + if (await areSchemaCompatible(schema, charmSchema)) { + acc[id] = cell.value; + } + } catch (e) { + logger.error({ error: e, charmSpellId }, "Error loading spell"); + // Skip charms where we can't load the spell + } + return acc; + }, + Promise.resolve({} as Record), + ); + + return { + spellId: spellId.replace("spell-", ""), + cells: matchingCharms, + }; +} + +export const recast: AppRouteHandler = async (c) => { + const logger: Logger = c.get("logger"); + const body = (await c.req.json()) as RecastRequest; + const startTime = performance.now(); + + try { + const response = await processRecast(body.charmId, body.replica, logger); + return c.json(response, HttpStatusCodes.OK); + } catch (error) { + logger.error({ error }, "Error processing recast"); + captureException(error); + return c.json( + { error: "Failed to process recast" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR, + ); + } +}; diff --git a/typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts b/typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts new file mode 100644 index 000000000..a386fd117 --- /dev/null +++ b/typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts @@ -0,0 +1,110 @@ +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { z } from "zod"; +import { + getAllBlobs, + getBlob, + getMemory, +} from "@/routes/ai/spell/behavior/effects.ts"; + +import type { AppRouteHandler } from "@/lib/types.ts"; +import type { ReuseRoute } from "@/routes/ai/spell/spell.routes.ts"; +import { Spell } from "@/routes/ai/spell/spell.ts"; +import { Logger } from "@/lib/prefixed-logger.ts"; +import { captureException } from "@sentry/deno"; +import { areSchemaCompatible } from "@/routes/ai/spell/schema-compatibility.ts"; + +export const ReuseRequestSchema = z.object({ + charmId: z.string().describe('The ID of the charm to reuse data from'), + replica: z.string().describe('The space the charm is stored in'), +}); + +export const ReuseResponseSchema = z.object({ + charm: z.record(z.any()), + schema: z.record(z.any()), + argument: z.record(z.any()), + compatibleSpells: z.record(z.any()), +}); + +export type ReuseRequest = z.infer; +export type ReuseResponse = z.infer; + +async function processReuse( + charmId: string, + replica: string, + logger: Logger, +): Promise { + const charm = await getMemory(charmId, replica); + logger.info( + { charmId, replica }, + "Retrieved charm", + ); + + const source = await getMemory(charm.source["/"], replica); + logger.info({ sourceId: charm.source["/"] }, "Retrieved source charm"); + + const argument = source.value.argument; + const type = source.value.$TYPE; + logger.debug({ type, argument }, "Extracted argument and type from source"); + + const spellId = "spell-" + type; + logger.info({ spellId }, "Looking up spell"); + + const spell = await getBlob(spellId); + logger.info({ spellId }, "Retrieved spell"); + + const schema = spell.recipe.argumentSchema; + logger.debug({ schema }, "Extracted argument schema from spell"); + + const spells = await getAllBlobs({ + prefix: "spell-", + allWithData: true, + }); + + if (Array.isArray(spells)) { + throw new Error("Unexpected response format"); + } + const spellEntries = Object.entries(spells) + .filter(([id]) => id !== spellId); + + const compatibilityChecks = await Promise.all( + spellEntries.map(async ([id, spell]) => { + const spellSchema = spell.recipe.argumentSchema; + const isCompatible = await areSchemaCompatible(schema, spellSchema); + return isCompatible ? id : null; + }), + ); + + const candidates = compatibilityChecks.filter((id): id is string => + id !== null + ); + + const compatibleSpells = candidates.reduce((acc, id) => { + acc[id] = spells[id]; + return acc; + }, {} as Record); + + return { + charm, + schema, + argument, + compatibleSpells, + }; +} + +export const reuse: AppRouteHandler = async (c) => { + const logger: Logger = c.get("logger"); + const body = (await c.req.json()) as ReuseRequest; + const startTime = performance.now(); + + try { + const response = await processReuse(body.charmId, body.replica, logger); + return c.json(response, HttpStatusCodes.OK); + } catch (error) { + logger.error({ error }, "Error processing reuse"); + captureException(error); + return c.json( + { error: "Failed to process reuse" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR, + ); + } +}; diff --git a/typescript/packages/toolshed/routes/ai/spell/json.ts b/typescript/packages/toolshed/routes/ai/spell/json.ts new file mode 100644 index 000000000..579d77069 --- /dev/null +++ b/typescript/packages/toolshed/routes/ai/spell/json.ts @@ -0,0 +1,22 @@ +export function extractJSON( + text: string, +): Record | Array> { + try { + // Try to extract from markdown code block first + const markdownMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (markdownMatch) { + return JSON.parse(markdownMatch[1].trim()); + } + + // If not in markdown, try to find JSON-like content + const jsonMatch = text.match(/\{[\s\S]*\}|\[[\s\S]*\]/); + if (jsonMatch) { + return JSON.parse(jsonMatch[0].trim()); + } + + // If no special formatting, try parsing the original text + return JSON.parse(text.trim()); + } catch (error) { + return {}; + } +} diff --git a/typescript/packages/toolshed/routes/ai/spell/spell.handlers.ts b/typescript/packages/toolshed/routes/ai/spell/spell.handlers.ts index c7b49b5ec..c418fb206 100644 --- a/typescript/packages/toolshed/routes/ai/spell/spell.handlers.ts +++ b/typescript/packages/toolshed/routes/ai/spell/spell.handlers.ts @@ -1,88 +1,18 @@ import * as HttpStatusCodes from "stoker/http-status-codes"; import { z } from "zod"; -import { - getAllBlobs, - getAllMemories, - getBlob, - getMemory, -} from "./behavior/effects.ts"; +import { getAllBlobs, getAllMemories } from "./behavior/effects.ts"; import type { AppRouteHandler } from "@/lib/types.ts"; -import type { - ProcessSchemaRoute, - RecastRoute, - ReuseRoute, - SearchSchemaRoute, - SpellSearchRoute, -} from "./spell.routes.ts"; +import type { SearchSchemaRoute, SpellSearchRoute } from "./spell.routes.ts"; import { Spell } from "./spell.ts"; import { performSearch } from "./behavior/search.ts"; import { Logger } from "@/lib/prefixed-logger.ts"; -import { processSchema } from "@/routes/ai/spell/fulfill.ts"; import { candidates } from "@/routes/ai/spell/caster.ts"; import { CasterSchemaRoute } from "@/routes/ai/spell/spell.routes.ts"; import { processSpellSearch } from "@/routes/ai/spell/behavior/spell-search.ts"; import { captureException } from "@sentry/deno"; import { areSchemaCompatible } from "./schema-compatibility.ts"; -export const ProcessSchemaRequestSchema = z.object({ - schema: z.record( - z - .string() - .or( - z.number().or(z.boolean().or(z.array(z.any()).or(z.record(z.any())))), - ), - ).openapi({ - example: { - title: { type: "string" }, - url: { type: "string" }, - }, - }), - tags: z.array(z.string()).optional(), - many: z.boolean().optional(), - prompt: z.string().optional(), - options: z - .object({ - format: z.enum(["json", "yaml"]).optional(), - validate: z.boolean().optional(), - maxExamples: z.number().default(5).optional(), - exact: z.boolean().optional(), - }) - .optional(), -}); - -export const ProcessSchemaResponseSchema = z.object({ - result: z.union([z.record(z.any()), z.array(z.record(z.any()))]), - metadata: z.object({ - processingTime: z.number(), - schemaFormat: z.string(), - fragments: z.array( - z.object({ - matches: z.array( - z.object({ - key: z.string(), - data: z.record(z.any()), - similarity: z.number(), - }), - ), - path: z.array(z.string()), - schema: z.record(z.any()), - }), - ), - reassembledExample: z.record(z.any()), - tagMatchInfo: z.object({ - usedTags: z.any(), - matchRanks: z.array(z.object({ - path: z.any(), - matches: z.any(), - })), - }), - }), -}); - -export type ProcessSchemaRequest = z.infer; -export type ProcessSchemaResponse = z.infer; - export const SearchSchemaRequestSchema = z.object({ query: z.string(), options: z @@ -185,29 +115,6 @@ export const SpellSearchResponseSchema = z.object({ export type SpellSearchRequest = z.infer; export type SpellSearchResponse = z.infer; -export const fulfill: AppRouteHandler = async (c) => { - const logger: Logger = c.get("logger"); - const body = (await c.req.json()) as ProcessSchemaRequest; - const startTime = performance.now(); - - try { - const response = await processSchema(body, logger, startTime); - - logger.info( - { processingTime: response.metadata.processingTime }, - "Request completed", - ); - return c.json(response, HttpStatusCodes.OK); - } catch (error) { - logger.error({ error }, "Error processing schema"); - captureException(error); - return c.json( - { error: "Failed to process schema" }, - HttpStatusCodes.INTERNAL_SERVER_ERROR, - ); - } -}; - export const search: AppRouteHandler = async (c) => { const logger: Logger = c.get("logger"); const startTime = performance.now(); @@ -314,195 +221,3 @@ export const spellSearch: AppRouteHandler = async (c) => { ); } }; -export const RecastRequestSchema = z.object({ - charmId: z.string(), - replica: z.string(), -}); - -export const ReuseRequestSchema = z.object({ - charmId: z.string(), - replica: z.string(), -}); - -const CharmDataSchema = z.object({ - id: z.string(), - data: z.record(z.any()), - spell: z.record(z.any()), - schema: z.record(z.any()), -}); - -export const RecastResponseSchema = z.object({ - spellId: z.string(), - cells: z.record(z.any()), -}); - -export const ReuseResponseSchema = z.object({ - charm: z.record(z.any()), - schema: z.record(z.any()), - argument: z.record(z.any()), - compatibleSpells: z.record(z.any()), -}); - -export type RecastRequest = z.infer; -export type ReuseRequest = z.infer; -export type RecastResponse = z.infer; -export type ReuseResponse = z.infer; - -export const recast: AppRouteHandler = async (c) => { - const logger: Logger = c.get("logger"); - const body = (await c.req.json()) as RecastRequest; - const startTime = performance.now(); - - try { - const response = await processRecast(body.charmId, body.replica, logger); - return c.json(response, HttpStatusCodes.OK); - } catch (error) { - logger.error({ error }, "Error processing recast"); - captureException(error); - return c.json( - { error: "Failed to process recast" }, - HttpStatusCodes.INTERNAL_SERVER_ERROR, - ); - } -}; - -async function processReuse( - charmId: string, - replica: string, - logger: Logger, -): Promise { - const charm = await getMemory(charmId, replica); - logger.info( - { charmId, replica }, - "Retrieved charm", - ); - - const source = await getMemory(charm.source["/"], replica); - logger.info({ sourceId: charm.source["/"] }, "Retrieved source charm"); - - const argument = source.value.argument; - const type = source.value.$TYPE; - logger.debug({ type, argument }, "Extracted argument and type from source"); - - const spellId = "spell-" + type; - logger.info({ spellId }, "Looking up spell"); - - const spell = await getBlob(spellId); - logger.info({ spellId }, "Retrieved spell"); - - const schema = spell.recipe.argumentSchema; - logger.debug({ schema }, "Extracted argument schema from spell"); - - const spells = await getAllBlobs({ - prefix: "spell-", - allWithData: true, - }); - - if (Array.isArray(spells)) { - throw new Error("Unexpected response format"); - } - const spellEntries = Object.entries(spells) - .filter(([id]) => id !== spellId); - - const compatibilityChecks = await Promise.all( - spellEntries.map(async ([id, spell]) => { - const spellSchema = spell.recipe.argumentSchema; - const isCompatible = await areSchemaCompatible(schema, spellSchema); - return isCompatible ? id : null; - }), - ); - - const candidates = compatibilityChecks.filter((id): id is string => - id !== null - ); - - const compatibleSpells = candidates.reduce((acc, id) => { - acc[id] = spells[id]; - return acc; - }, {} as Record); - - return { - charm, - schema, - argument, - compatibleSpells, - }; -} - -async function processRecast( - charmId: string, - replica: string, - logger: Logger, -): Promise { - const charm = await getMemory(charmId, replica); - logger.info( - { charmId, replica }, - "Retrieved charm", - ); - - const source = await getMemory(charm.source["/"], replica); - logger.info({ sourceId: charm.source["/"] }, "Retrieved source charm"); - - const argument = source.value.argument; - const type = source.value.$TYPE; - logger.debug({ type, argument }, "Extracted argument and type from source"); - const spellId = "spell-" + type; - logger.info({ spellId }, "Looking up spell"); - - const spell = await getBlob(spellId); - logger.info({ spellId }, "Retrieved spell"); - if (!spell) { - throw new Error("No spell found for id: " + spellId); - } - - const schema = spell.recipe.argumentSchema; - logger.debug({ schema }, "Extracted argument schema from spell"); - - const cells = await getAllMemories(replica); - // First get all charms that have a $TYPE and their IDs - const typedCharms = Object.entries(cells) - .filter(([_, cell]) => cell?.value?.$TYPE) - .map(([id, cell]) => ({ id, cell })); - // Then filter to matching schemas and build record - const matchingCharms = await typedCharms.reduce( - async (accPromise, { id, cell }) => { - const acc = await accPromise; - const charmSpellId = "spell-" + cell.value.$TYPE; - try { - const charmSpell = await getBlob(charmSpellId); - const charmSchema = charmSpell.recipe.argumentSchema; - if (await areSchemaCompatible(schema, charmSchema)) { - acc[id] = cell.value; - } - } catch (e) { - logger.error({ error: e, charmSpellId }, "Error loading spell"); - // Skip charms where we can't load the spell - } - return acc; - }, - Promise.resolve({} as Record), - ); - - return { - spellId: spellId.replace("spell-", ""), - cells: matchingCharms, - }; -} - -export const reuse: AppRouteHandler = async (c) => { - const logger: Logger = c.get("logger"); - const body = (await c.req.json()) as ReuseRequest; - const startTime = performance.now(); - - try { - const response = await processReuse(body.charmId, body.replica, logger); - return c.json(response, HttpStatusCodes.OK); - } catch (error) { - logger.error({ error }, "Error processing reuse"); - captureException(error); - return c.json( - { error: "Failed to process reuse" }, - HttpStatusCodes.INTERNAL_SERVER_ERROR, - ); - } -}; diff --git a/typescript/packages/toolshed/routes/ai/spell/spell.index.ts b/typescript/packages/toolshed/routes/ai/spell/spell.index.ts index 3faafbea8..c56ae4445 100644 --- a/typescript/packages/toolshed/routes/ai/spell/spell.index.ts +++ b/typescript/packages/toolshed/routes/ai/spell/spell.index.ts @@ -6,6 +6,10 @@ import { createRouter } from "@/lib/create-app.ts"; import { cors } from "@hono/hono/cors"; import * as handlers from "./spell.handlers.ts"; import * as routes from "./spell.routes.ts"; +import { fulfill } from "./handlers/fulfill.ts"; +import { imagine } from "./handlers/imagine.ts"; +import { recast } from "./handlers/recast.ts"; +import { reuse } from "./handlers/reuse.ts"; const router = createRouter(); @@ -22,9 +26,9 @@ router.use( ); const Router = router - .openapi(routes.recast, handlers.recast) - .openapi(routes.reuse, handlers.reuse) - .openapi(routes.fulfill, handlers.fulfill) - .openapi(routes.spellSearch, handlers.spellSearch); + .openapi(routes.recast, recast) + .openapi(routes.reuse, reuse) + .openapi(routes.imagine, imagine) + .openapi(routes.fulfill, fulfill); export default Router; diff --git a/typescript/packages/toolshed/routes/ai/spell/spell.routes.ts b/typescript/packages/toolshed/routes/ai/spell/spell.routes.ts index aec21c66b..f91353272 100644 --- a/typescript/packages/toolshed/routes/ai/spell/spell.routes.ts +++ b/typescript/packages/toolshed/routes/ai/spell/spell.routes.ts @@ -4,18 +4,28 @@ import { jsonContent } from "stoker/openapi/helpers"; import { CasterRequestSchema, CasterResponseSchema, - ProcessSchemaRequestSchema, - ProcessSchemaResponseSchema, - RecastRequestSchema, - RecastResponseSchema, - ReuseRequestSchema, - ReuseResponseSchema, SearchSchemaRequestSchema, SearchSchemaResponseSchema, SpellSearchRequestSchema, SpellSearchResponseSchema, -} from "./spell.handlers.ts"; +} from "@/routes/ai/spell/spell.handlers.ts"; import { z } from "zod"; +import { + FulfillSchemaRequestSchema, + FulfillSchemaResponseSchema, +} from "@/routes/ai/spell/handlers/fulfill.ts"; +import { + ImagineDataRequestSchema, + ImagineDataResponseSchema, +} from "@/routes/ai/spell/handlers/imagine.ts"; +import { + RecastRequestSchema, + RecastResponseSchema, +} from "@/routes/ai/spell/handlers/recast.ts"; +import { + ReuseRequestSchema, + ReuseResponseSchema, +} from "@/routes/ai/spell/handlers/reuse.ts"; const tags = ["Spellcaster"]; @@ -24,6 +34,8 @@ const ErrorResponseSchema = z.object({ }); export const fulfill = createRoute({ + description: + "Search blobs to find real data fragments that can be stitched together to fulfill the passed schema. Extremely slow.", path: "/api/ai/spell/fulfill", method: "post", tags, @@ -31,14 +43,43 @@ export const fulfill = createRoute({ body: { content: { "application/json": { - schema: ProcessSchemaRequestSchema, + schema: FulfillSchemaRequestSchema, + }, + }, + }, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + FulfillSchemaResponseSchema, + "The processed schema result", + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + ErrorResponseSchema, + "An error occurred", + ), + }, +}); + +export type FulfillSchemaRoute = typeof fulfill; + +export const imagine = createRoute({ + description: + "Hallucinate JSON data that conforms to a JSON schema, using an LLM.", + path: "/api/ai/spell/imagine", + method: "post", + tags, + request: { + body: { + content: { + "application/json": { + schema: ImagineDataRequestSchema, }, }, }, }, responses: { [HttpStatusCodes.OK]: jsonContent( - ProcessSchemaResponseSchema, + ImagineDataResponseSchema, "The processed schema result", ), [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( @@ -48,9 +89,10 @@ export const fulfill = createRoute({ }, }); -export type ProcessSchemaRoute = typeof fulfill; +export type ImagineDataRoute = typeof imagine; export const search = createRoute({ + description: "OBSELETE: will be removed.", path: "/api/ai/spell/smart-search", method: "post", tags, @@ -78,6 +120,7 @@ export const search = createRoute({ export type SearchSchemaRoute = typeof search; export const caster = createRoute({ + description: "OBSELETE: will be removed.", path: "/ai/spell/caster", method: "post", tags, @@ -105,6 +148,7 @@ export const caster = createRoute({ export type CasterSchemaRoute = typeof caster; export const spellSearch = createRoute({ + description: "OBSELETE: will be removed.", path: "/api/ai/spell/search", method: "post", tags, @@ -132,6 +176,8 @@ export const spellSearch = createRoute({ export type SpellSearchRoute = typeof spellSearch; export const recast = createRoute({ + description: + "Cast the spell of a given charm on a (compatible) candidate cell.", path: "/api/ai/spell/recast", method: "post", tags, @@ -157,6 +203,7 @@ export const recast = createRoute({ }); export const reuse = createRoute({ + description: "Cast a compatible spell using this charm's data.", path: "/api/ai/spell/reuse", method: "post", tags, From a81e25f7838a398805d6dedd972d2d1359716999 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Tue, 4 Mar 2025 10:04:50 +1000 Subject: [PATCH 2/2] Format pass --- .../packages/toolshed/routes/ai/spell/handlers/reuse.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts b/typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts index a386fd117..d59054801 100644 --- a/typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts +++ b/typescript/packages/toolshed/routes/ai/spell/handlers/reuse.ts @@ -14,8 +14,8 @@ import { captureException } from "@sentry/deno"; import { areSchemaCompatible } from "@/routes/ai/spell/schema-compatibility.ts"; export const ReuseRequestSchema = z.object({ - charmId: z.string().describe('The ID of the charm to reuse data from'), - replica: z.string().describe('The space the charm is stored in'), + charmId: z.string().describe("The ID of the charm to reuse data from"), + replica: z.string().describe("The space the charm is stored in"), }); export const ReuseResponseSchema = z.object({