fix: make schema types root-level object

This commit is contained in:
2026-06-07 14:47:10 +09:00
parent 94cf3572f1
commit bc08fccf10
7 changed files with 77 additions and 43 deletions

View File

@@ -1,16 +1,17 @@
import { llm } from "@/openrouter"; import { llm } from "@/openrouter";
import { extractedFactSchema } from "@/openrouter/schema"; import { extractedFactSchema, type ExtractedFactResult } from "@/openrouter/schema";
import { type ExtractedFact, LlmFactExtractor } from "identitydb"; import { type ExtractedFact, LlmFactExtractor } from "identitydb";
export const factExtractor = new LlmFactExtractor({ export const factExtractor = new LlmFactExtractor({
model: { model: {
async generateText({ instruction, input }) { async generateText({ instruction, input }) {
return await llm.call<ExtractedFact[]>(llm.models.identity, { const result = await llm.call<ExtractedFactResult>(llm.models.identity, {
instruction, instruction,
message: input, message: input,
jsonSchemaName: "fact-extractor", jsonSchemaName: "fact-extractor",
jsonSchema: extractedFactSchema, jsonSchema: extractedFactSchema,
}); });
return result.items;
}, },
}, },
}); });

View File

@@ -80,16 +80,20 @@ const mockCall = mock(async <T>(model: unknown, options: any): Promise<T> => {
return { items: customDailySlots ?? build48Slots() } as unknown as T; return { items: customDailySlots ?? build48Slots() } as unknown as T;
} }
if (options.jsonSchemaName === "monthly-schedule") { if (options.jsonSchemaName === "monthly-schedule") {
if (customMonthlyDays) return customMonthlyDays as unknown as T; if (customMonthlyDays) {
return { items: customMonthlyDays } as unknown as T;
}
const match = options.message.match(/\((\d+) days\)/); const match = options.message.match(/\((\d+) days\)/);
const days = match ? parseInt(match[1]!, 10) : 30; const days = match ? parseInt(match[1]!, 10) : 30;
return Array.from({ length: days }, (_, i) => ({ return {
items: Array.from({ length: days }, (_, i) => ({
day: i + 1, day: i + 1,
summary: `Day ${i + 1} summary`, summary: `Day ${i + 1} summary`,
})) as unknown as T; })),
} as unknown as T;
} }
if (options.jsonSchemaName === "availability") { if (options.jsonSchemaName === "availability") {
return (customAvailability ?? buildAvailability()) as unknown as T; return { items: customAvailability ?? buildAvailability() } as unknown as T;
} }
throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`); throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`);
}); });
@@ -267,9 +271,11 @@ describe("Brain.createMonthlySchedule", () => {
const result = await brain.createMonthlySchedule(today, "study for GRE"); const result = await brain.createMonthlySchedule(today, "study for GRE");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result).toHaveLength(expected.daysInMonth); expect(result!.items).toHaveLength(expected.daysInMonth);
expect(result![0]!.day).toBe(1); expect(result!.items[0]!.day).toBe(1);
expect(result![result!.length - 1]!.day).toBe(expected.daysInMonth); expect(result!.items[result!.items.length - 1]!.day).toBe(
expected.daysInMonth,
);
const llmCall = llmCalls.find( const llmCall = llmCalls.find(
(c) => c.options.jsonSchemaName === "monthly-schedule", (c) => c.options.jsonSchemaName === "monthly-schedule",
@@ -285,7 +291,9 @@ describe("Brain.createMonthlySchedule", () => {
}, },
); );
expect(facts).toHaveLength(1); expect(facts).toHaveLength(1);
expect(JSON.parse(facts[0]!.statement)).toHaveLength(expected.daysInMonth); expect(JSON.parse(facts[0]!.statement).items).toHaveLength(
expected.daysInMonth,
);
}); });
test("S5: year wrap (December 15 -> January next year)", async () => { test("S5: year wrap (December 15 -> January next year)", async () => {
@@ -296,7 +304,7 @@ describe("Brain.createMonthlySchedule", () => {
const result = await brain.createMonthlySchedule(today, ""); const result = await brain.createMonthlySchedule(today, "");
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result).toHaveLength(31); expect(result!.items).toHaveLength(31);
const facts = await brain.db.getTopicFacts( const facts = await brain.db.getTopicFacts(
`monthly-schedule:${expectedKey}`, `monthly-schedule:${expectedKey}`,
@@ -337,8 +345,8 @@ describe("Brain.getTodayScheduledAvailability", () => {
const result = await brain.getTodayScheduledAvailability(today); const result = await brain.getTodayScheduledAvailability(today);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.length).toBeGreaterThan(0); expect(result!.items.length).toBeGreaterThan(0);
for (const w of result!) { for (const w of result!.items) {
expect(["online", "do-not-disturb", "offline"]).toContain(w.status); expect(["online", "do-not-disturb", "offline"]).toContain(w.status);
expect(w.start).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$/); expect(w.start).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$/);
expect(w.end).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$|^24:00$/); expect(w.end).toMatch(/^([01][0-9]|2[0-3]):[0-5][0-9]$|^24:00$/);
@@ -451,7 +459,7 @@ describe("Brain.createDebug", () => {
const schedule = await brain.createMonthlySchedule(today, "msg"); const schedule = await brain.createMonthlySchedule(today, "msg");
expect(schedule).not.toBeNull(); expect(schedule).not.toBeNull();
expect(schedule).toHaveLength(expected.daysInMonth); expect(schedule!.items).toHaveLength(expected.daysInMonth);
const facts = await brain.db.getTopicFacts( const facts = await brain.db.getTopicFacts(
`monthly-schedule:${monthKey}`, `monthly-schedule:${monthKey}`,

View File

@@ -8,6 +8,7 @@ import {
dailyScheduleSchema, dailyScheduleSchema,
monthlyScheduleSchema, monthlyScheduleSchema,
type Availability, type Availability,
type AvailabilityWindows,
type DailySchedule, type DailySchedule,
type MonthlySchedule, type MonthlySchedule,
} from "@/openrouter/schema"; } from "@/openrouter/schema";
@@ -28,7 +29,7 @@ export interface DebugOptions {
} }
export class Brain { export class Brain {
private availabilityCache: Map<string, Availability[]> = new Map(); private availabilityCache: Map<string, AvailabilityWindows> = new Map();
constructor( constructor(
public db: IdentityDB, public db: IdentityDB,
@@ -142,7 +143,7 @@ export class Brain {
await this.db.addFact({ await this.db.addFact({
spaceName: this.space.name, spaceName: this.space.name,
statement: JSON.stringify(schedule), statement: JSON.stringify(schedule),
summary: `Monthly schedule for ${monthKey} (${schedule.length} days)`, summary: `Monthly schedule for ${monthKey} (${schedule.items.length} days)`,
source: "createMonthlySchedule", source: "createMonthlySchedule",
confidence: 1.0, confidence: 1.0,
topics: [ topics: [
@@ -178,7 +179,7 @@ export class Brain {
async getTodayScheduledAvailability( async getTodayScheduledAvailability(
datetime: Date, datetime: Date,
): Promise<Availability[] | null> { ): Promise<AvailabilityWindows | null> {
try { try {
const dateKey = formatDateKey(datetime); const dateKey = formatDateKey(datetime);
const cached = this.availabilityCache.get(dateKey); const cached = this.availabilityCache.get(dateKey);
@@ -212,7 +213,7 @@ export class Brain {
async deriveAvailabilityFromSchedule( async deriveAvailabilityFromSchedule(
schedule: DailySchedule, schedule: DailySchedule,
): Promise<Availability[]> { ): Promise<AvailabilityWindows> {
try { try {
const instruction = await loadPrompt("SCHEDULE_AVAILABILITY"); const instruction = await loadPrompt("SCHEDULE_AVAILABILITY");
const promptMessage = JSON.stringify({ const promptMessage = JSON.stringify({
@@ -220,7 +221,7 @@ export class Brain {
personality: this.brainbase.baseSystemPrompt, personality: this.brainbase.baseSystemPrompt,
}); });
return await llm.call<Availability[]>(llm.models.identity, { return await llm.call<AvailabilityWindows>(llm.models.identity, {
instruction, instruction,
message: promptMessage, message: promptMessage,
jsonSchemaName: "availability", jsonSchemaName: "availability",
@@ -249,7 +250,7 @@ export class Brain {
const monthly = JSON.parse(facts[0]!.statement) as MonthlySchedule; const monthly = JSON.parse(facts[0]!.statement) as MonthlySchedule;
const day = target.getDate(); const day = target.getDate();
const entry = monthly.find((d) => d.day === day); const entry = monthly.items.find((d) => d.day === day);
return entry?.summary ?? null; return entry?.summary ?? null;
} catch { } catch {
return null; return null;

View File

@@ -73,7 +73,7 @@ const mockCall = mock(async <T>(model: unknown, options: any): Promise<T> => {
return GENERATED_BASE_SYSTEM_PROMPT as unknown as T; return GENERATED_BASE_SYSTEM_PROMPT as unknown as T;
} }
if (options.jsonSchemaName === "fact-extractor") { if (options.jsonSchemaName === "fact-extractor") {
return EXTRACTED_FACTS as unknown as T; return { items: EXTRACTED_FACTS } as unknown as T;
} }
throw new Error( throw new Error(
`unexpected LLM call: model=${model} instruction=${options.instruction?.slice(0, 80)}`, `unexpected LLM call: model=${model} instruction=${options.instruction?.slice(0, 80)}`,

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import type { import type {
Availability, Availability,
DailySlot, DailySlot,
MonthlySchedule, MonthlyDay,
} from "@/openrouter/schema"; } from "@/openrouter/schema";
interface RecordedCall { interface RecordedCall {
@@ -39,7 +39,7 @@ function buildAvailability(): Availability[] {
]; ];
} }
function buildMonthly(): MonthlySchedule { function buildMonthlyDays(): MonthlyDay[] {
return Array.from({ length: 30 }, (_, i) => ({ return Array.from({ length: 30 }, (_, i) => ({
day: i + 1, day: i + 1,
summary: `Day ${i + 1} summary`, summary: `Day ${i + 1} summary`,
@@ -53,12 +53,15 @@ const mockCall = mock(async (_model: unknown, options: any) => {
if (options.jsonSchemaName === "monthly-schedule") { if (options.jsonSchemaName === "monthly-schedule") {
const match = (options.message as string).match(/\((\d+) days\)/); const match = (options.message as string).match(/\((\d+) days\)/);
const days = match ? parseInt(match[1]!, 10) : 30; const days = match ? parseInt(match[1]!, 10) : 30;
return Array.from({ length: days }, (_, i) => ({ return {
items: Array.from({ length: days }, (_, i) => ({
day: i + 1, day: i + 1,
summary: `Day ${i + 1} summary`, summary: `Day ${i + 1} summary`,
})); })),
};
} }
if (options.jsonSchemaName === "availability") return buildAvailability(); if (options.jsonSchemaName === "availability")
return { items: buildAvailability() };
throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`); throw new Error(`unexpected jsonSchemaName: ${options.jsonSchemaName}`);
}); });
@@ -87,7 +90,6 @@ beforeEach(() => {
}); });
afterEach(async () => { afterEach(async () => {
// ensure no on-disk braindb was created
const { unlink } = await import("fs/promises"); const { unlink } = await import("fs/promises");
try { try {
await unlink("/tmp/brainbox-test-braindb-debug-schedule.json"); await unlink("/tmp/brainbox-test-braindb-debug-schedule.json");
@@ -106,7 +108,7 @@ describe("runDebugScheduleDaily", () => {
expect(result.kind).toBe("daily"); expect(result.kind).toBe("daily");
expect(result.schedule.items).toHaveLength(48); expect(result.schedule.items).toHaveLength(48);
expect(result.availability.length).toBeGreaterThan(0); expect(result.availability.items.length).toBeGreaterThan(0);
expect(result.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(result.dateKey).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const dailyCall = llmCalls.find( const dailyCall = llmCalls.find(
@@ -145,7 +147,7 @@ describe("runDebugScheduleMonthly", () => {
if (!result.ok) throw new Error("expected ok"); if (!result.ok) throw new Error("expected ok");
expect(result.kind).toBe("monthly"); expect(result.kind).toBe("monthly");
expect(result.schedule).toHaveLength(result.daysInMonth); expect(result.schedule.items).toHaveLength(result.daysInMonth);
expect(result.monthKey).toMatch(/^\d{4}-\d{2}$/); expect(result.monthKey).toMatch(/^\d{4}-\d{2}$/);
const call = llmCalls.find( const call = llmCalls.find(
@@ -187,3 +189,4 @@ describe("debug schedule no-disk invariant", () => {
expect(afterJson).toBe(false); expect(afterJson).toBe(false);
}); });
}); });

View File

@@ -2,7 +2,7 @@ import type { Command } from "commander";
import ora from "ora"; import ora from "ora";
import { Brain } from "@/brain"; import { Brain } from "@/brain";
import { import {
type Availability, type AvailabilityWindows,
type DailySchedule, type DailySchedule,
type MonthlySchedule, type MonthlySchedule,
} from "@/openrouter/schema"; } from "@/openrouter/schema";
@@ -21,7 +21,7 @@ export type DailyRunResult =
dateKey: string; dateKey: string;
tomorrow: Date; tomorrow: Date;
schedule: DailySchedule; schedule: DailySchedule;
availability: Availability[]; availability: AvailabilityWindows;
} }
| { ok: false; error: string }; | { ok: false; error: string };
@@ -72,7 +72,7 @@ export async function runDebugScheduleDaily(
return { ok: false, error: "Availability derivation failed" }; return { ok: false, error: "Availability derivation failed" };
} }
availSpinner.succeed( availSpinner.succeed(
`Availability derived (${availability.length} windows)`, `Availability derived (${availability.items.length} windows)`,
); );
printSection(`Availability — ${dateKey}`); printSection(`Availability — ${dateKey}`);
@@ -108,7 +108,7 @@ export async function runDebugScheduleMonthly(
return { ok: false, error: "Monthly schedule generation failed" }; return { ok: false, error: "Monthly schedule generation failed" };
} }
scheduleSpinner.succeed( scheduleSpinner.succeed(
`Monthly schedule generated (${schedule.length} day summaries)`, `Monthly schedule generated (${schedule.items.length} day summaries)`,
); );
printSection(`Monthly Schedule — ${monthKey} (${next.daysInMonth} days)`); printSection(`Monthly Schedule — ${monthKey} (${next.daysInMonth} days)`);

View File

@@ -133,6 +133,8 @@ export const availabilitySchema = {
// Types — co-located with their schemas. // Types — co-located with their schemas.
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
import type { ExtractedFact } from "identitydb";
/** A single 30-minute slot in a daily schedule. Matches `dailyScheduleSchema.items.items`. */ /** A single 30-minute slot in a daily schedule. Matches `dailyScheduleSchema.items.items`. */
export type DailySlot = { export type DailySlot = {
start: string; start: string;
@@ -150,24 +152,43 @@ export type DailySchedule = {
items: DailySlot[]; items: DailySlot[];
}; };
/** A single day's summary inside a monthly schedule. Matches `monthlyScheduleSchema.items`. */ /** A single day's summary inside a monthly schedule. Matches `monthlyScheduleSchema.items.items`. */
export type MonthlyDay = { export type MonthlyDay = {
day: number; day: number;
summary: string; summary: string;
}; };
/** A complete monthly schedule. Matches `monthlyScheduleSchema`. */ /**
export type MonthlySchedule = MonthlyDay[]; * A complete monthly schedule: a wrapped object containing one entry per day
* of the month. Matches `monthlyScheduleSchema`.
*/
export type MonthlySchedule = {
items: MonthlyDay[];
};
/** Reachability status for a single availability window. */ /** Reachability status for a single availability window. */
export type AvailabilityStatus = "online" | "do-not-disturb" | "offline"; export type AvailabilityStatus = "online" | "do-not-disturb" | "offline";
/** A single availability window. Matches `availabilitySchema.items`. */ /** A single availability window. Matches `availabilitySchema.items.items`. */
export type Availability = { export type Availability = {
start: string; start: string;
end: string; end: string;
status: AvailabilityStatus; status: AvailabilityStatus;
}; };
/** The full set of availability windows for a day. Matches `availabilitySchema`. */ /**
export type AvailabilityWindows = Availability[]; * The full set of availability windows for a day: a wrapped object containing
* one or more windows. Matches `availabilitySchema`.
*/
export type AvailabilityWindows = {
items: Availability[];
};
/**
* The wrapped envelope the LLM returns for `extractedFactSchema`. The inner
* `items` array is the list of `ExtractedFact` (which is defined in the
* external `identitydb` package).
*/
export type ExtractedFactResult = {
items: ExtractedFact[];
};