mdi-llmkit 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -34
- package/dist/src/comparison/compareLists.d.ts +97 -0
- package/dist/src/comparison/compareLists.js +375 -0
- package/dist/src/comparison/index.d.ts +1 -0
- package/dist/src/comparison/index.js +1 -0
- package/dist/src/gptApi/functions.d.ts +21 -0
- package/dist/src/gptApi/functions.js +154 -0
- package/dist/src/gptApi/gptConversation.d.ts +43 -0
- package/dist/src/gptApi/gptConversation.js +146 -0
- package/dist/src/gptApi/index.d.ts +3 -0
- package/dist/src/gptApi/index.js +3 -0
- package/dist/src/gptApi/jsonSchemaFormat.d.ts +14 -0
- package/dist/src/gptApi/jsonSchemaFormat.js +198 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/jsonSurgery/jsonSurgery.d.ts +81 -0
- package/dist/src/jsonSurgery/jsonSurgery.js +776 -0
- package/dist/src/jsonSurgery/placemarkedJSON.d.ts +57 -0
- package/dist/src/jsonSurgery/placemarkedJSON.js +151 -0
- package/dist/tests/comparison/compareLists.test.d.ts +1 -0
- package/dist/tests/comparison/compareLists.test.js +434 -0
- package/dist/tests/gptApi/gptConversation.test.d.ts +1 -0
- package/dist/tests/gptApi/gptConversation.test.js +157 -0
- package/dist/tests/gptApi/gptSubmit.test.d.ts +1 -0
- package/dist/tests/gptApi/gptSubmit.test.js +161 -0
- package/dist/tests/gptApi/jsonSchemaFormat.test.d.ts +1 -0
- package/dist/tests/gptApi/jsonSchemaFormat.test.js +372 -0
- package/dist/tests/jsonSurgery/jsonSurgery.test.d.ts +1 -0
- package/dist/tests/jsonSurgery/jsonSurgery.test.js +729 -0
- package/dist/tests/jsonSurgery/placemarkedJSON.test.d.ts +1 -0
- package/dist/tests/jsonSurgery/placemarkedJSON.test.js +209 -0
- package/dist/tests/setupEnv.d.ts +1 -0
- package/dist/tests/setupEnv.js +4 -0
- package/dist/tests/subpathExports.test.d.ts +1 -0
- package/dist/tests/subpathExports.test.js +47 -0
- package/package.json +18 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './compareLists.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './compareLists.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const GPT_MODEL_CHEAP = "gpt-4.1-nano";
|
|
2
|
+
export declare const GPT_MODEL_SMART = "gpt-4.1";
|
|
3
|
+
export interface OpenAIClientLike {
|
|
4
|
+
responses: {
|
|
5
|
+
create: (args: any, options?: any) => Promise<any> | any;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export interface SystemMessage {
|
|
9
|
+
role: "system";
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
export interface GptSubmitOptions {
|
|
13
|
+
model?: string;
|
|
14
|
+
jsonResponse?: boolean | Record<string, unknown> | string;
|
|
15
|
+
systemAnnouncementMessage?: string;
|
|
16
|
+
retryLimit?: number;
|
|
17
|
+
retryBackoffTimeSeconds?: number;
|
|
18
|
+
warningCallback?: (message: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function currentDatetimeSystemMessage(): SystemMessage;
|
|
21
|
+
export declare function gptSubmit(messages: unknown[], openaiClient: OpenAIClientLike, options?: GptSubmitOptions): Promise<string | Record<string, unknown> | unknown[] | number | boolean | null>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export const GPT_MODEL_CHEAP = "gpt-4.1-nano";
|
|
2
|
+
export const GPT_MODEL_SMART = "gpt-4.1";
|
|
3
|
+
const GPT_RETRY_LIMIT_DEFAULT = 5;
|
|
4
|
+
const GPT_RETRY_BACKOFF_TIME_SECONDS_DEFAULT = 30;
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* OpenAI's API, even when called with a JSON schema, will often return text that is not
|
|
13
|
+
* valid JSON. It's often because the model will add extra text after the end of its valid
|
|
14
|
+
* JSON response. E.g. instead of `{"foo":"bar"}`, it will sometimes return
|
|
15
|
+
* `{"foo":"bar"}{"baz":"quux"}`.
|
|
16
|
+
*
|
|
17
|
+
* We intentionally do not implement a custom JSON parser here. Instead:
|
|
18
|
+
* 1) Try to parse the full text first (fast path, most responses).
|
|
19
|
+
* 2) If that fails, scan prefixes from start to end and let JSON.parse decide validity.
|
|
20
|
+
*
|
|
21
|
+
* This keeps JSON semantics delegated to the platform parser while still recovering from
|
|
22
|
+
* trailing junk in model output.
|
|
23
|
+
*
|
|
24
|
+
* @param input The text to parse.
|
|
25
|
+
* @returns The first valid JSON value found at the start of the input text.
|
|
26
|
+
* @throws {SyntaxError} If no valid JSON prefix exists.
|
|
27
|
+
*/
|
|
28
|
+
function parseFirstJsonValue(input) {
|
|
29
|
+
const text = input.trimStart();
|
|
30
|
+
if (!text) {
|
|
31
|
+
throw new SyntaxError("Unexpected end of JSON input");
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
for (let end = 1; end <= text.length; end += 1) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(text.slice(0, end));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Keep scanning until we find a valid JSON prefix.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
throw new SyntaxError("Unexpected token in JSON input");
|
|
47
|
+
}
|
|
48
|
+
function isRetryableOpenAIError(error) {
|
|
49
|
+
if (!(error instanceof Error)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const name = error.name || "";
|
|
53
|
+
return name.includes("OpenAI") || name.includes("APIError");
|
|
54
|
+
}
|
|
55
|
+
export function currentDatetimeSystemMessage() {
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const pad = (value) => value.toString().padStart(2, "0");
|
|
58
|
+
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
|
|
59
|
+
`${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
60
|
+
return {
|
|
61
|
+
role: "system",
|
|
62
|
+
content: `!DATETIME: The current date and time is ${timestamp}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export async function gptSubmit(messages, openaiClient, options = {}) {
|
|
66
|
+
const model = options.model || GPT_MODEL_SMART;
|
|
67
|
+
const retryLimit = options.retryLimit ?? GPT_RETRY_LIMIT_DEFAULT;
|
|
68
|
+
const retryBackoffTimeSeconds = options.retryBackoffTimeSeconds ?? GPT_RETRY_BACKOFF_TIME_SECONDS_DEFAULT;
|
|
69
|
+
let failedError = null;
|
|
70
|
+
let openaiTextParam;
|
|
71
|
+
if (options.jsonResponse) {
|
|
72
|
+
if (typeof options.jsonResponse === "boolean") {
|
|
73
|
+
openaiTextParam = { format: { type: "json_object" } };
|
|
74
|
+
}
|
|
75
|
+
else if (typeof options.jsonResponse === "string") {
|
|
76
|
+
openaiTextParam = JSON.parse(options.jsonResponse);
|
|
77
|
+
}
|
|
78
|
+
else if (isRecord(options.jsonResponse)) {
|
|
79
|
+
openaiTextParam = JSON.parse(JSON.stringify(options.jsonResponse));
|
|
80
|
+
const format = openaiTextParam.format;
|
|
81
|
+
if (isRecord(format) && typeof format.description === "string") {
|
|
82
|
+
format.description =
|
|
83
|
+
`${format.description}\n\nABSOLUTELY NO UNICODE ALLOWED. ` +
|
|
84
|
+
`Only use typeable keyboard characters. Do not try to circumvent this rule ` +
|
|
85
|
+
`with escape sequences, backslashes, or other tricks. Use double dashes (--), ` +
|
|
86
|
+
`straight quotes (") and single quotes (') instead of em-dashes, en-dashes, ` +
|
|
87
|
+
`and curly versions.`.trim();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const filteredMessages = messages.filter((message) => {
|
|
92
|
+
if (!isRecord(message)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const role = message.role;
|
|
96
|
+
const content = message.content;
|
|
97
|
+
return !(role === "system" &&
|
|
98
|
+
typeof content === "string" &&
|
|
99
|
+
content.startsWith("!DATETIME:"));
|
|
100
|
+
});
|
|
101
|
+
let preparedMessages = [currentDatetimeSystemMessage(), ...filteredMessages];
|
|
102
|
+
if (options.systemAnnouncementMessage && options.systemAnnouncementMessage.trim()) {
|
|
103
|
+
preparedMessages = [
|
|
104
|
+
{ role: "system", content: options.systemAnnouncementMessage.trim() },
|
|
105
|
+
...preparedMessages,
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
for (let index = 0; index < retryLimit; index += 1) {
|
|
109
|
+
let llmReply = "";
|
|
110
|
+
try {
|
|
111
|
+
const payload = {
|
|
112
|
+
model,
|
|
113
|
+
input: preparedMessages,
|
|
114
|
+
};
|
|
115
|
+
if (openaiTextParam) {
|
|
116
|
+
payload.text = openaiTextParam;
|
|
117
|
+
}
|
|
118
|
+
const llmResponse = await openaiClient.responses.create(payload);
|
|
119
|
+
if (llmResponse.error && options.warningCallback) {
|
|
120
|
+
options.warningCallback(`ERROR: OpenAI API returned an error: ${llmResponse.error}`);
|
|
121
|
+
}
|
|
122
|
+
if (llmResponse.incomplete_details && options.warningCallback) {
|
|
123
|
+
options.warningCallback(`ERROR: OpenAI API returned incomplete details: ${llmResponse.incomplete_details}`);
|
|
124
|
+
}
|
|
125
|
+
llmReply = llmResponse.output_text.trim();
|
|
126
|
+
if (!options.jsonResponse) {
|
|
127
|
+
return `${llmReply}`;
|
|
128
|
+
}
|
|
129
|
+
return parseFirstJsonValue(llmReply);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof SyntaxError) {
|
|
133
|
+
failedError = error;
|
|
134
|
+
if (options.warningCallback) {
|
|
135
|
+
options.warningCallback(`JSON decode error:\n\n${error}.\n\nRaw text of LLM Reply:\n${llmReply}\n\nRetrying (attempt ${index + 1} of ${retryLimit}) immediately...`);
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (isRetryableOpenAIError(error)) {
|
|
140
|
+
failedError = error;
|
|
141
|
+
if (options.warningCallback) {
|
|
142
|
+
options.warningCallback(`OpenAI API error:\n\n${error}.\n\nRetrying (attempt ${index + 1} of ${retryLimit}) in ${retryBackoffTimeSeconds} seconds...`);
|
|
143
|
+
}
|
|
144
|
+
await sleep(retryBackoffTimeSeconds * 1000);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (failedError) {
|
|
151
|
+
throw failedError;
|
|
152
|
+
}
|
|
153
|
+
throw new Error("Unknown error occurred in gptSubmit");
|
|
154
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type OpenAIClientLike } from './functions.js';
|
|
2
|
+
export interface ConversationMessage {
|
|
3
|
+
role: string;
|
|
4
|
+
content: string;
|
|
5
|
+
}
|
|
6
|
+
export interface GptConversationOptions {
|
|
7
|
+
openaiClient?: OpenAIClientLike;
|
|
8
|
+
model?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SubmitOptions {
|
|
11
|
+
model?: string;
|
|
12
|
+
jsonResponse?: boolean | Record<string, unknown> | string;
|
|
13
|
+
}
|
|
14
|
+
export declare class GptConversation extends Array<ConversationMessage> {
|
|
15
|
+
#private;
|
|
16
|
+
static get [Symbol.species](): ArrayConstructor;
|
|
17
|
+
get openaiClient(): OpenAIClientLike | undefined;
|
|
18
|
+
set openaiClient(value: OpenAIClientLike | undefined);
|
|
19
|
+
get model(): string | undefined;
|
|
20
|
+
set model(value: string | undefined);
|
|
21
|
+
get lastReply(): unknown;
|
|
22
|
+
set lastReply(value: unknown);
|
|
23
|
+
constructor(messages?: ConversationMessage[], options?: GptConversationOptions);
|
|
24
|
+
assignMessages(messages?: ConversationMessage[]): this;
|
|
25
|
+
clone(): GptConversation;
|
|
26
|
+
submit(message?: string | Record<string, unknown>, role?: string | null, options?: SubmitOptions): Promise<unknown>;
|
|
27
|
+
addMessage(role: string, content: unknown): this;
|
|
28
|
+
addUserMessage(content: unknown): this;
|
|
29
|
+
addAssistantMessage(content: unknown): this;
|
|
30
|
+
addSystemMessage(content: unknown): this;
|
|
31
|
+
addDeveloperMessage(content: unknown): this;
|
|
32
|
+
submitMessage(role: string, content: unknown): Promise<unknown>;
|
|
33
|
+
submitUserMessage(content: unknown): Promise<unknown>;
|
|
34
|
+
submitAssistantMessage(content: unknown): Promise<unknown>;
|
|
35
|
+
submitSystemMessage(content: unknown): Promise<unknown>;
|
|
36
|
+
submitDeveloperMessage(content: unknown): Promise<unknown>;
|
|
37
|
+
getLastMessage(): ConversationMessage | null;
|
|
38
|
+
getMessagesByRole(role: string): ConversationMessage[];
|
|
39
|
+
getLastReplyStr(): string;
|
|
40
|
+
getLastReplyDict(): Record<string, unknown>;
|
|
41
|
+
getLastReplyDictField(fieldName: string, defaultValue?: unknown): unknown;
|
|
42
|
+
toDictList(): ConversationMessage[];
|
|
43
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { GPT_MODEL_SMART, gptSubmit, } from './functions.js';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
export class GptConversation extends Array {
|
|
6
|
+
static get [Symbol.species]() {
|
|
7
|
+
return Array;
|
|
8
|
+
}
|
|
9
|
+
#openaiClient;
|
|
10
|
+
#model;
|
|
11
|
+
#lastReply = null;
|
|
12
|
+
get openaiClient() {
|
|
13
|
+
return this.#openaiClient;
|
|
14
|
+
}
|
|
15
|
+
set openaiClient(value) {
|
|
16
|
+
this.#openaiClient = value;
|
|
17
|
+
}
|
|
18
|
+
get model() {
|
|
19
|
+
return this.#model;
|
|
20
|
+
}
|
|
21
|
+
set model(value) {
|
|
22
|
+
this.#model = value;
|
|
23
|
+
}
|
|
24
|
+
get lastReply() {
|
|
25
|
+
return this.#lastReply;
|
|
26
|
+
}
|
|
27
|
+
set lastReply(value) {
|
|
28
|
+
this.#lastReply = value;
|
|
29
|
+
}
|
|
30
|
+
constructor(messages = [], options = {}) {
|
|
31
|
+
super(...messages);
|
|
32
|
+
this.#openaiClient = options.openaiClient;
|
|
33
|
+
this.#model = options.model;
|
|
34
|
+
}
|
|
35
|
+
assignMessages(messages) {
|
|
36
|
+
this.length = 0;
|
|
37
|
+
if (messages?.length) {
|
|
38
|
+
this.push(...messages);
|
|
39
|
+
}
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
clone() {
|
|
43
|
+
return new GptConversation(JSON.parse(JSON.stringify([...this])), {
|
|
44
|
+
openaiClient: this.openaiClient,
|
|
45
|
+
model: this.model,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async submit(message, role = 'user', options = {}) {
|
|
49
|
+
if (!this.openaiClient) {
|
|
50
|
+
throw new Error('OpenAI client is not set. Please provide an OpenAI client.');
|
|
51
|
+
}
|
|
52
|
+
const model = options.model || this.model || GPT_MODEL_SMART;
|
|
53
|
+
let jsonResponse = options.jsonResponse;
|
|
54
|
+
if (message) {
|
|
55
|
+
if (isRecord(message)) {
|
|
56
|
+
if (!jsonResponse && 'format' in message) {
|
|
57
|
+
jsonResponse = message;
|
|
58
|
+
}
|
|
59
|
+
if (!role && typeof message.role === 'string') {
|
|
60
|
+
role = message.role;
|
|
61
|
+
}
|
|
62
|
+
if ('content' in message) {
|
|
63
|
+
message = String(message.content ?? '');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this.addMessage(role || 'user', message);
|
|
67
|
+
}
|
|
68
|
+
const llmReply = await gptSubmit(this.toDictList(), this.openaiClient, {
|
|
69
|
+
jsonResponse,
|
|
70
|
+
model,
|
|
71
|
+
});
|
|
72
|
+
this.addAssistantMessage(llmReply);
|
|
73
|
+
this.lastReply = llmReply;
|
|
74
|
+
return llmReply;
|
|
75
|
+
}
|
|
76
|
+
addMessage(role, content) {
|
|
77
|
+
let normalizedContent;
|
|
78
|
+
if (typeof content === 'string') {
|
|
79
|
+
normalizedContent = content;
|
|
80
|
+
}
|
|
81
|
+
else if (isRecord(content)) {
|
|
82
|
+
normalizedContent = JSON.stringify(content, null, 2);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
normalizedContent = String(content);
|
|
86
|
+
}
|
|
87
|
+
this.push({ role, content: normalizedContent });
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
addUserMessage(content) {
|
|
91
|
+
return this.addMessage('user', content);
|
|
92
|
+
}
|
|
93
|
+
addAssistantMessage(content) {
|
|
94
|
+
return this.addMessage('assistant', content);
|
|
95
|
+
}
|
|
96
|
+
addSystemMessage(content) {
|
|
97
|
+
return this.addMessage('system', content);
|
|
98
|
+
}
|
|
99
|
+
addDeveloperMessage(content) {
|
|
100
|
+
return this.addMessage('developer', content);
|
|
101
|
+
}
|
|
102
|
+
async submitMessage(role, content) {
|
|
103
|
+
this.addMessage(role, content);
|
|
104
|
+
return this.submit();
|
|
105
|
+
}
|
|
106
|
+
async submitUserMessage(content) {
|
|
107
|
+
this.addUserMessage(content);
|
|
108
|
+
return this.submit();
|
|
109
|
+
}
|
|
110
|
+
async submitAssistantMessage(content) {
|
|
111
|
+
this.addAssistantMessage(content);
|
|
112
|
+
return this.submit();
|
|
113
|
+
}
|
|
114
|
+
async submitSystemMessage(content) {
|
|
115
|
+
this.addSystemMessage(content);
|
|
116
|
+
return this.submit();
|
|
117
|
+
}
|
|
118
|
+
async submitDeveloperMessage(content) {
|
|
119
|
+
this.addDeveloperMessage(content);
|
|
120
|
+
return this.submit();
|
|
121
|
+
}
|
|
122
|
+
getLastMessage() {
|
|
123
|
+
return this.length ? this[this.length - 1] : null;
|
|
124
|
+
}
|
|
125
|
+
getMessagesByRole(role) {
|
|
126
|
+
return this.filter((message) => message.role === role);
|
|
127
|
+
}
|
|
128
|
+
getLastReplyStr() {
|
|
129
|
+
return typeof this.lastReply === 'string' ? this.lastReply : '';
|
|
130
|
+
}
|
|
131
|
+
getLastReplyDict() {
|
|
132
|
+
if (!isRecord(this.lastReply)) {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
return JSON.parse(JSON.stringify(this.lastReply));
|
|
136
|
+
}
|
|
137
|
+
getLastReplyDictField(fieldName, defaultValue = null) {
|
|
138
|
+
if (!isRecord(this.lastReply)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return this.lastReply[fieldName] ?? defaultValue;
|
|
142
|
+
}
|
|
143
|
+
toDictList() {
|
|
144
|
+
return [...this];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const JSON_INTEGER: unique symbol;
|
|
2
|
+
export declare const JSON_NUMBER: unique symbol;
|
|
3
|
+
export declare const JSON_STRING: StringConstructor;
|
|
4
|
+
export declare const JSON_BOOLEAN: BooleanConstructor;
|
|
5
|
+
export interface JSONSchemaFormatResult extends Record<string, unknown> {
|
|
6
|
+
format: {
|
|
7
|
+
type: 'json_schema';
|
|
8
|
+
strict: true;
|
|
9
|
+
name?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
schema: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare function JSONSchemaFormat(name: string, schema: unknown, description?: string): JSONSchemaFormatResult;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
export const JSON_INTEGER = Symbol('JSON_INTEGER');
|
|
2
|
+
export const JSON_NUMBER = Symbol('JSON_NUMBER');
|
|
3
|
+
export const JSON_STRING = String;
|
|
4
|
+
export const JSON_BOOLEAN = Boolean;
|
|
5
|
+
const TYPEMAP = new Map([
|
|
6
|
+
[JSON_STRING, 'string'],
|
|
7
|
+
[JSON_INTEGER, 'integer'],
|
|
8
|
+
[JSON_NUMBER, 'number'],
|
|
9
|
+
[JSON_BOOLEAN, 'boolean'],
|
|
10
|
+
[String, 'string'],
|
|
11
|
+
[Boolean, 'boolean'],
|
|
12
|
+
[BigInt, 'integer'],
|
|
13
|
+
[Number, 'number'],
|
|
14
|
+
]);
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function isStringArray(value) {
|
|
19
|
+
return (Array.isArray(value) && value.every((item) => typeof item === 'string'));
|
|
20
|
+
}
|
|
21
|
+
function isNumericRangeArray(value) {
|
|
22
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const [min, max] = value;
|
|
26
|
+
const minValid = min === null || typeof min === 'number';
|
|
27
|
+
const maxValid = max === null || typeof max === 'number';
|
|
28
|
+
return (minValid && maxValid && (typeof min === 'number' || typeof max === 'number'));
|
|
29
|
+
}
|
|
30
|
+
function isTupleMetadataArray(value) {
|
|
31
|
+
if (!Array.isArray(value) || value.length < 2) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (isStringArray(value)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return value.some((item) => typeof item === 'string' || isNumericRangeArray(item));
|
|
38
|
+
}
|
|
39
|
+
function inferPrimitiveType(schemaValue) {
|
|
40
|
+
const direct = TYPEMAP.get(schemaValue);
|
|
41
|
+
if (direct) {
|
|
42
|
+
return direct;
|
|
43
|
+
}
|
|
44
|
+
if (typeof schemaValue === 'string') {
|
|
45
|
+
return 'string';
|
|
46
|
+
}
|
|
47
|
+
if (typeof schemaValue === 'boolean') {
|
|
48
|
+
return 'boolean';
|
|
49
|
+
}
|
|
50
|
+
if (typeof schemaValue === 'bigint') {
|
|
51
|
+
return 'integer';
|
|
52
|
+
}
|
|
53
|
+
if (typeof schemaValue === 'number') {
|
|
54
|
+
return Number.isInteger(schemaValue) ? 'integer' : 'number';
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function convertSchemaRecursive(subschema) {
|
|
59
|
+
let subschemaDescription = '';
|
|
60
|
+
let subschemaEnum = [];
|
|
61
|
+
let subschemaNumrange = [null, null];
|
|
62
|
+
let subschemaValue = subschema;
|
|
63
|
+
if (isTupleMetadataArray(subschema)) {
|
|
64
|
+
for (const item of subschema) {
|
|
65
|
+
if (!item) {
|
|
66
|
+
subschemaValue = item;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (typeof item === 'string') {
|
|
70
|
+
subschemaDescription = item;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (isStringArray(item) && item.length >= 2) {
|
|
74
|
+
subschemaEnum = item;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (isNumericRangeArray(item)) {
|
|
78
|
+
subschemaNumrange = item;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
subschemaValue = item;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if ((Array.isArray(subschemaValue) && isTupleMetadataArray(subschemaValue)) ||
|
|
85
|
+
(Array.isArray(subschemaValue) && subschemaValue.length === 0)) {
|
|
86
|
+
if (subschemaEnum.length > 0) {
|
|
87
|
+
subschemaValue = JSON_STRING;
|
|
88
|
+
}
|
|
89
|
+
const [nr0, nr1] = subschemaNumrange;
|
|
90
|
+
if (nr0 !== null || nr1 !== null) {
|
|
91
|
+
if ((typeof nr0 === 'number' && !Number.isInteger(nr0)) ||
|
|
92
|
+
(typeof nr1 === 'number' && !Number.isInteger(nr1))) {
|
|
93
|
+
subschemaValue = JSON_NUMBER;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
subschemaValue = JSON_INTEGER;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const result = {};
|
|
101
|
+
if (isRecord(subschemaValue)) {
|
|
102
|
+
result.type = 'object';
|
|
103
|
+
if (subschemaDescription) {
|
|
104
|
+
result.description = subschemaDescription;
|
|
105
|
+
}
|
|
106
|
+
result.additionalProperties = false;
|
|
107
|
+
const keys = Object.keys(subschemaValue);
|
|
108
|
+
result.required = keys;
|
|
109
|
+
const properties = {};
|
|
110
|
+
for (const [key, value] of Object.entries(subschemaValue)) {
|
|
111
|
+
if (typeof value === 'string') {
|
|
112
|
+
properties[key] = { type: 'string', description: value };
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
properties[key] = convertSchemaRecursive(value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
result.properties = properties;
|
|
119
|
+
}
|
|
120
|
+
else if (Array.isArray(subschemaValue)) {
|
|
121
|
+
if (subschemaValue.length >= 2 && isStringArray(subschemaValue)) {
|
|
122
|
+
result.type = 'string';
|
|
123
|
+
subschemaEnum = subschemaValue;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
result.type = 'array';
|
|
127
|
+
if (subschemaDescription) {
|
|
128
|
+
result.description = subschemaDescription;
|
|
129
|
+
}
|
|
130
|
+
if (subschemaNumrange[0] !== null) {
|
|
131
|
+
result.minItems = subschemaNumrange[0];
|
|
132
|
+
}
|
|
133
|
+
if (subschemaNumrange[1] !== null) {
|
|
134
|
+
result.maxItems = subschemaNumrange[1];
|
|
135
|
+
}
|
|
136
|
+
const arrayExemplar = subschemaValue[0];
|
|
137
|
+
if (typeof arrayExemplar === 'string') {
|
|
138
|
+
result.items = { type: 'string', description: arrayExemplar };
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
result.items = convertSchemaRecursive(arrayExemplar);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const primitiveType = inferPrimitiveType(subschemaValue);
|
|
147
|
+
if (!primitiveType) {
|
|
148
|
+
throw new Error(`Unrecognized type for schema value: ${String(subschemaValue)}`);
|
|
149
|
+
}
|
|
150
|
+
result.type = primitiveType;
|
|
151
|
+
if (subschemaDescription) {
|
|
152
|
+
result.description = subschemaDescription;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (subschemaEnum.length) {
|
|
156
|
+
result.enum = subschemaEnum;
|
|
157
|
+
}
|
|
158
|
+
if (result.type === 'integer' || result.type === 'number') {
|
|
159
|
+
if (subschemaNumrange[0] !== null) {
|
|
160
|
+
result.minimum = subschemaNumrange[0];
|
|
161
|
+
}
|
|
162
|
+
if (subschemaNumrange[1] !== null) {
|
|
163
|
+
result.maximum = subschemaNumrange[1];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
export function JSONSchemaFormat(name, schema, description) {
|
|
169
|
+
const result = {
|
|
170
|
+
format: {
|
|
171
|
+
type: 'json_schema',
|
|
172
|
+
strict: true,
|
|
173
|
+
name,
|
|
174
|
+
schema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {},
|
|
177
|
+
required: [],
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
if (description) {
|
|
183
|
+
result.format.description = description;
|
|
184
|
+
}
|
|
185
|
+
let converted = convertSchemaRecursive(schema);
|
|
186
|
+
if (converted.type !== 'object') {
|
|
187
|
+
converted = {
|
|
188
|
+
type: 'object',
|
|
189
|
+
required: [name],
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
properties: {
|
|
192
|
+
[name]: converted,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
result.format.schema = converted;
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jsonSurgery` performs iterative, AI-guided edits to a JSON-compatible object.
|
|
3
|
+
*
|
|
4
|
+
* The module exposes:
|
|
5
|
+
* - {@link jsonSurgery}: the main entry point that applies requested modifications.
|
|
6
|
+
* - {@link JSONSurgeryOptions}: callbacks and limits for validation, progress, and retries.
|
|
7
|
+
* - {@link JSONSurgeryError}: enriched error type that includes the last object state.
|
|
8
|
+
*
|
|
9
|
+
* High-level flow:
|
|
10
|
+
* 1. Deep-copy the input object (the original is never mutated).
|
|
11
|
+
* 2. Send the current object state plus user instructions to the model.
|
|
12
|
+
* 3. Receive one or more structured operations targeting JSON paths.
|
|
13
|
+
* 4. Apply operations and continue iteratively until validation succeeds.
|
|
14
|
+
*
|
|
15
|
+
* Supported operation types include assign, append, insert, delete, and rename.
|
|
16
|
+
* Values are transferred through constrained JSON schemas so the model can express
|
|
17
|
+
* primitives, objects, and arrays safely in a structured format.
|
|
18
|
+
*
|
|
19
|
+
* Callers can provide:
|
|
20
|
+
* - `schemaDescription` to describe expected object shape/constraints,
|
|
21
|
+
* - `skippedKeys` to hide sensitive or noisy fields from model context,
|
|
22
|
+
* - `onValidateBeforeReturn` to enforce app-specific validation,
|
|
23
|
+
* - `onWorkInProgress` for per-iteration monitoring/intervention,
|
|
24
|
+
* - `giveUpAfterSeconds` / `giveUpAfterIterations` as soft stop conditions.
|
|
25
|
+
*/
|
|
26
|
+
import { OpenAI } from 'openai';
|
|
27
|
+
/**
|
|
28
|
+
* Optional configuration for {@link jsonSurgery}.
|
|
29
|
+
* @property schemaDescription Optional schema description for the JSON object. This can be written
|
|
30
|
+
* in JSON Schema format, or as a textual explanation. It's passed as a string to the AI and has
|
|
31
|
+
* no direct enforcement semantics.
|
|
32
|
+
* @property skippedKeys Optional array of key names to skip/ignore in the AI-visible JSON. This
|
|
33
|
+
* is useful for omitting large or sensitive fields (IDs, timestamps, binary data, etc.) that are
|
|
34
|
+
* often irrelevant to the requested modifications.
|
|
35
|
+
* @property onValidateBeforeReturn Optional callback that validates and/or corrects the object before
|
|
36
|
+
* final return. It receives the current object and resolves to:
|
|
37
|
+
* - objCorrected: a corrected object to continue with
|
|
38
|
+
* - errors: lingering validation errors that still need to be addressed
|
|
39
|
+
* @property onWorkInProgress Optional callback that's called after each iteration with the
|
|
40
|
+
* current state of the object. Useful for logging or monitoring progress. Can optionally return
|
|
41
|
+
* a promise that resolves into a modified version of the object, in case the caller wants to
|
|
42
|
+
* intervene or adjust the object mid-process. onWorkInProgress is a good place to implement
|
|
43
|
+
* custom logging, metrics, or even dynamic adjustments to the object during processing. It's also
|
|
44
|
+
* a place from whence you can throw an exception if needed to abort processing.
|
|
45
|
+
* @property giveUpAfterSeconds Optional soft-limit for total processing time, in seconds.
|
|
46
|
+
* @property giveUpAfterIterations Optional soft-limit for iteration count.
|
|
47
|
+
*
|
|
48
|
+
* If `objCorrected` is returned, that corrected object is used.
|
|
49
|
+
* If `errors` is missing or empty, the object is treated as valid.
|
|
50
|
+
*/
|
|
51
|
+
export type JSONSurgeryOptions = {
|
|
52
|
+
schemaDescription?: string;
|
|
53
|
+
skippedKeys?: string[];
|
|
54
|
+
onValidateBeforeReturn?: (obj: any) => Promise<{
|
|
55
|
+
objCorrected?: any;
|
|
56
|
+
errors?: string[];
|
|
57
|
+
} | undefined>;
|
|
58
|
+
onWorkInProgress?: (obj: any) => Promise<any | undefined>;
|
|
59
|
+
giveUpAfterSeconds?: number;
|
|
60
|
+
giveUpAfterIterations?: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Error type reserved for failures from {@link jsonSurgery}.
|
|
64
|
+
* `obj` contains the object being modified, captured in whatever state it was
|
|
65
|
+
* left in at the moment the exception was thrown.
|
|
66
|
+
*/
|
|
67
|
+
export declare class JSONSurgeryError extends Error {
|
|
68
|
+
obj: any;
|
|
69
|
+
constructor(message: string, obj: any, options?: ErrorOptions);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Modifies a JSON object based on modification instructions using OpenAI's API.
|
|
73
|
+
* Does NOT modify the original object in place; instead, works with a copy and returns
|
|
74
|
+
* the modified copy.
|
|
75
|
+
* @param openaiClient The OpenAI client to use for modifications
|
|
76
|
+
* @param obj The JSON object to modify
|
|
77
|
+
* @param modificationInstructions Instructions describing the modifications to apply
|
|
78
|
+
* @param options Optional configuration object. See {@link JSONSurgeryOptions}.
|
|
79
|
+
* @returns A copy of the original object, modified according to the instructions.
|
|
80
|
+
*/
|
|
81
|
+
export declare const jsonSurgery: (openaiClient: OpenAI, obj: any, modificationInstructions: string, options?: JSONSurgeryOptions) => Promise<any>;
|