mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
Use MutableHashMap for prompt templates
This commit is contained in:
parent
7d77a5c1de
commit
338232efa8
3 changed files with 247 additions and 8 deletions
|
|
@ -2254,6 +2254,24 @@ Notes:
|
||||||
- `cd ts && bun run lint`
|
- `cd ts && bun run lint`
|
||||||
- `git diff --check`
|
- `git diff --check`
|
||||||
|
|
||||||
|
### 2026-06-04: Prompt Template MutableHashMap Cache Slice
|
||||||
|
|
||||||
|
- Status: migrated and package-verified.
|
||||||
|
- Completed:
|
||||||
|
- `ts/packages/flow/src/prompt/template.ts` now stores loaded prompt
|
||||||
|
templates in `MutableHashMap` instead of a native `Map`.
|
||||||
|
- Config reload clears and repopulates the Effect collection, request lookup
|
||||||
|
narrows through `Option`, and logging uses `MutableHashMap.size` / `keys`.
|
||||||
|
- New focused coverage verifies a config-loaded prompt template renders
|
||||||
|
through the service request/response flow.
|
||||||
|
- Verification:
|
||||||
|
- `cd ts/packages/flow && bunx --bun vitest run src/__tests__/prompt-template.test.ts`
|
||||||
|
- `cd ts && bun run check:tsgo`
|
||||||
|
- `cd ts && bun run build`
|
||||||
|
- `cd ts && bun run test`
|
||||||
|
- `cd ts && bun run lint`
|
||||||
|
- `git diff --check`
|
||||||
|
|
||||||
## Subagent Findings To Preserve
|
## Subagent Findings To Preserve
|
||||||
|
|
||||||
- MCP/workbench:
|
- MCP/workbench:
|
||||||
|
|
@ -2440,9 +2458,9 @@ Notes:
|
||||||
The workbench random id helper is complete; the remaining workbench
|
The workbench random id helper is complete; the remaining workbench
|
||||||
`Effect.gen` match is a local one-shot command effect value.
|
`Effect.gen` match is a local one-shot command effect value.
|
||||||
- Remaining real long-lived native collection targets include base processor
|
- Remaining real long-lived native collection targets include base processor
|
||||||
registries, Librarian service state, prompt template cache, and a workbench
|
registries, Librarian service state, and a workbench module cache. The
|
||||||
module cache. The standalone Librarian collection manager is complete.
|
standalone Librarian collection manager and prompt template cache are
|
||||||
Local traversal sets and test fakes remain no-op boundaries.
|
complete. Local traversal sets and test fakes remain no-op boundaries.
|
||||||
|
|
||||||
## Ranked Findings
|
## Ranked Findings
|
||||||
|
|
||||||
|
|
|
||||||
219
ts/packages/flow/src/__tests__/prompt-template.test.ts
Normal file
219
ts/packages/flow/src/__tests__/prompt-template.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { describe, expect, it } from "@effect/vitest";
|
||||||
|
import { ConfigProvider, Effect, Fiber } from "effect";
|
||||||
|
import * as S from "effect/Schema";
|
||||||
|
import {
|
||||||
|
MessagingRuntimeLive,
|
||||||
|
PubSub,
|
||||||
|
runProcessorScoped,
|
||||||
|
topics,
|
||||||
|
type BackendConsumer,
|
||||||
|
type BackendProducer,
|
||||||
|
type CreateConsumerOptions,
|
||||||
|
type CreateProducerOptions,
|
||||||
|
type Message,
|
||||||
|
type PromptRequest,
|
||||||
|
type PromptResponse,
|
||||||
|
type PubSubBackend,
|
||||||
|
} from "@trustgraph/base";
|
||||||
|
import { PromptTemplateService } from "../prompt/template.js";
|
||||||
|
|
||||||
|
class WaitForTimeout extends S.TaggedErrorClass<WaitForTimeout>()(
|
||||||
|
"WaitForTimeout",
|
||||||
|
{ label: S.String },
|
||||||
|
) {}
|
||||||
|
|
||||||
|
const isWaitForTimeout = S.is(WaitForTimeout);
|
||||||
|
|
||||||
|
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
|
||||||
|
return {
|
||||||
|
value: () => value,
|
||||||
|
properties: () => properties,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitFor = (condition: () => boolean, label: string) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
const check = () => {
|
||||||
|
if (condition()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
reject(WaitForTimeout.make({ label }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(check, 5);
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
}),
|
||||||
|
catch: (error) => isWaitForTimeout(error) ? error : WaitForTimeout.make({ label }),
|
||||||
|
});
|
||||||
|
|
||||||
|
class RecordingProducer<T> implements BackendProducer<T> {
|
||||||
|
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
|
||||||
|
|
||||||
|
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||||
|
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush(): Promise<void> {}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PushConsumer<T> implements BackendConsumer<T> {
|
||||||
|
readonly acknowledged: Array<Message<T>> = [];
|
||||||
|
private readonly messages: Array<Message<T>> = [];
|
||||||
|
private readonly waiters: Array<(message: Message<T> | null) => void> = [];
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
push(message: Message<T>): void {
|
||||||
|
const waiter = this.waiters.shift();
|
||||||
|
if (waiter !== undefined) {
|
||||||
|
waiter(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async receive(): Promise<Message<T> | null> {
|
||||||
|
const message = this.messages.shift();
|
||||||
|
if (message !== undefined || this.closed) {
|
||||||
|
return message ?? null;
|
||||||
|
}
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
this.waiters.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async acknowledge(message: Message<T>): Promise<void> {
|
||||||
|
this.acknowledged.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async negativeAcknowledge(): Promise<void> {}
|
||||||
|
|
||||||
|
async unsubscribe(): Promise<void> {}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.closed = true;
|
||||||
|
for (const waiter of this.waiters.splice(0)) {
|
||||||
|
waiter(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PromptBackend implements PubSubBackend {
|
||||||
|
readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record<string, unknown> }>();
|
||||||
|
readonly consumersByTopic = new Map<string, PushConsumer<unknown>>();
|
||||||
|
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
|
||||||
|
|
||||||
|
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||||
|
const producer = new RecordingProducer<unknown>();
|
||||||
|
this.producersByTopic.set(options.topic, producer);
|
||||||
|
return producer as BackendProducer<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||||
|
if (options.topic === topics.configPush) {
|
||||||
|
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||||
|
}
|
||||||
|
const consumer = new PushConsumer<unknown>();
|
||||||
|
this.consumersByTopic.set(options.topic, consumer);
|
||||||
|
return consumer as BackendConsumer<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
|
||||||
|
pushPromptConfig(): void {
|
||||||
|
this.configConsumer.push(createMessage({
|
||||||
|
version: 1,
|
||||||
|
config: {
|
||||||
|
flows: {
|
||||||
|
default: {
|
||||||
|
topics: {
|
||||||
|
"prompt-request": "prompt-request-topic",
|
||||||
|
"prompt-response": "prompt-response-topic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
greeting: {
|
||||||
|
system: "System for {name}",
|
||||||
|
prompt: "Hello {name} from {place}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fastMessagingConfig = ConfigProvider.layer(
|
||||||
|
ConfigProvider.fromEnv({
|
||||||
|
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
|
||||||
|
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
|
||||||
|
TG_RATE_LIMIT_RETRY_MS: "1",
|
||||||
|
TG_REQUEST_TIMEOUT_MS: "250",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("PromptTemplateService", () => {
|
||||||
|
it.effect(
|
||||||
|
"renders prompt templates loaded from config through MutableHashMap state",
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
const backend = new PromptBackend();
|
||||||
|
|
||||||
|
yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fiber = yield* runProcessorScoped(
|
||||||
|
{
|
||||||
|
id: "prompt",
|
||||||
|
pubsubUrl: "nats://unused:4222",
|
||||||
|
metricsPort: 8000,
|
||||||
|
manageProcessSignals: true,
|
||||||
|
},
|
||||||
|
(config) => new PromptTemplateService(config),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(MessagingRuntimeLive),
|
||||||
|
Effect.provide(PubSub.layer(backend)),
|
||||||
|
Effect.provide(fastMessagingConfig),
|
||||||
|
Effect.forkChild,
|
||||||
|
);
|
||||||
|
|
||||||
|
backend.pushPromptConfig();
|
||||||
|
yield* waitFor(() => backend.consumersByTopic.has("prompt-request-topic"), "prompt consumer");
|
||||||
|
yield* waitFor(() => backend.producersByTopic.has("prompt-response-topic"), "prompt producer");
|
||||||
|
yield* waitFor(() => backend.configConsumer.acknowledged.length === 1, "config ack");
|
||||||
|
|
||||||
|
const inputConsumer = backend.consumersByTopic.get("prompt-request-topic") as PushConsumer<PromptRequest>;
|
||||||
|
const outputProducer = backend.producersByTopic.get("prompt-response-topic") as RecordingProducer<PromptResponse>;
|
||||||
|
|
||||||
|
inputConsumer.push(createMessage({
|
||||||
|
name: "greeting",
|
||||||
|
variables: {
|
||||||
|
name: "Ada",
|
||||||
|
place: "TrustGraph",
|
||||||
|
},
|
||||||
|
}, { id: "request-1" }));
|
||||||
|
|
||||||
|
yield* waitFor(() => outputProducer.sent.length === 1, "prompt response");
|
||||||
|
|
||||||
|
expect(inputConsumer.acknowledged.length).toBe(1);
|
||||||
|
expect(outputProducer.sent).toEqual([
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
system: "System for Ada",
|
||||||
|
prompt: "Hello Ada from TrustGraph",
|
||||||
|
},
|
||||||
|
properties: { id: "request-1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
yield* Fiber.interrupt(fiber);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -41,6 +41,8 @@ import {
|
||||||
import { NodeRuntime } from "@effect/platform-node";
|
import { NodeRuntime } from "@effect/platform-node";
|
||||||
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
import { makeFlowProcessorProgram } from "@trustgraph/base";
|
||||||
import { Effect, Layer, ManagedRuntime } from "effect";
|
import { Effect, Layer, ManagedRuntime } from "effect";
|
||||||
|
import * as MutableHashMap from "effect/MutableHashMap";
|
||||||
|
import * as O from "effect/Option";
|
||||||
import * as S from "effect/Schema";
|
import * as S from "effect/Schema";
|
||||||
|
|
||||||
export interface PromptTemplate {
|
export interface PromptTemplate {
|
||||||
|
|
@ -67,7 +69,7 @@ interface PromptTemplateRuntime {
|
||||||
const programRuntimes = new WeakMap<PromptTemplateConfig, PromptTemplateRuntime>();
|
const programRuntimes = new WeakMap<PromptTemplateConfig, PromptTemplateRuntime>();
|
||||||
|
|
||||||
const makePromptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplateRuntime => {
|
const makePromptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplateRuntime => {
|
||||||
const templates = new Map<string, PromptTemplate>();
|
const templates = MutableHashMap.empty<string, PromptTemplate>();
|
||||||
const configKey = config.configKey ?? "prompt";
|
const configKey = config.configKey ?? "prompt";
|
||||||
const PromptResponseProducer = makeProducerSpec<PromptResponse>("prompt-response");
|
const PromptResponseProducer = makeProducerSpec<PromptResponse>("prompt-response");
|
||||||
|
|
||||||
|
|
@ -93,17 +95,17 @@ const makePromptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplate
|
||||||
);
|
);
|
||||||
if (decoded === null) return;
|
if (decoded === null) return;
|
||||||
|
|
||||||
templates.clear();
|
MutableHashMap.clear(templates);
|
||||||
|
|
||||||
for (const [name, template] of Object.entries(decoded)) {
|
for (const [name, template] of Object.entries(decoded)) {
|
||||||
templates.set(name, {
|
MutableHashMap.set(templates, name, {
|
||||||
system: template.system ?? "",
|
system: template.system ?? "",
|
||||||
prompt: template.prompt ?? "",
|
prompt: template.prompt ?? "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.log(
|
yield* Effect.log(
|
||||||
`[PromptTemplate] Loaded ${templates.size} template(s): ${[...templates.keys()].join(", ")}`,
|
`[PromptTemplate] Loaded ${MutableHashMap.size(templates)} template(s): ${Array.from(MutableHashMap.keys(templates)).join(", ")}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -116,7 +118,7 @@ const makePromptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplate
|
||||||
if (requestId === undefined || requestId.length === 0) return;
|
if (requestId === undefined || requestId.length === 0) return;
|
||||||
|
|
||||||
const responseProducer = yield* flowCtx.flow.producerEffect(PromptResponseProducer);
|
const responseProducer = yield* flowCtx.flow.producerEffect(PromptResponseProducer);
|
||||||
const template = templates.get(msg.name);
|
const template = O.getOrUndefined(MutableHashMap.get(templates, msg.name));
|
||||||
if (template === undefined) {
|
if (template === undefined) {
|
||||||
yield* responseProducer.send(requestId, {
|
yield* responseProducer.send(requestId, {
|
||||||
system: "",
|
system: "",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue