@johnhenry/graceholder 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +492 -2
- package/STABILITY.md +24 -0
- package/package.json +126 -4
- package/src/adapters/apollo.d.ts +13 -0
- package/src/adapters/apollo.js +48 -0
- package/src/adapters/fetch.d.ts +15 -0
- package/src/adapters/fetch.js +150 -0
- package/src/adapters/shared.d.ts +14 -0
- package/src/adapters/shared.js +65 -0
- package/src/adapters/urql.d.ts +8 -0
- package/src/adapters/urql.js +53 -0
- package/src/anchor.js +13 -0
- package/src/args.js +41 -0
- package/src/bulk/csv.js +118 -0
- package/src/bulk/ndjson.js +10 -0
- package/src/bulk/plan.js +195 -0
- package/src/bulk/shape.js +67 -0
- package/src/bulk/source.js +24 -0
- package/src/bulk/sql.js +366 -0
- package/src/bulk/stream.js +55 -0
- package/src/cursor.js +25 -0
- package/src/dbg.d.ts +720 -0
- package/src/detmath.js +74 -0
- package/src/directives.js +113 -0
- package/src/dist.js +90 -0
- package/src/errors.js +17 -0
- package/src/execute.js +577 -0
- package/src/explain.js +473 -0
- package/src/fast-check.d.ts +31 -0
- package/src/fast-check.js +58 -0
- package/src/generate.js +84 -0
- package/src/generator-types.d.ts +90 -0
- package/src/generators.js +361 -0
- package/src/image.js +55 -0
- package/src/index.d.ts +507 -0
- package/src/index.js +62 -0
- package/src/infer.js +888 -0
- package/src/key.js +30 -0
- package/src/learn.js +397 -0
- package/src/naming.js +74 -0
- package/src/pack-registry.js +59 -0
- package/src/packs/PROVENANCE.md +26 -0
- package/src/packs/commerce.d.ts +4 -0
- package/src/packs/commerce.js +83 -0
- package/src/packs/finance.d.ts +4 -0
- package/src/packs/finance.js +180 -0
- package/src/packs/geo.d.ts +4 -0
- package/src/packs/geo.js +227 -0
- package/src/packs/health.d.ts +4 -0
- package/src/packs/health.js +76 -0
- package/src/packs/index.d.ts +7 -0
- package/src/packs/index.js +9 -0
- package/src/rng.js +89 -0
- package/src/rules.js +228 -0
- package/src/scenario.js +120 -0
- package/src/schema.js +148 -0
- package/src/spec.js +235 -0
- package/src/suggest.js +138 -0
- package/src/typed.d.ts +723 -0
- package/src/vocab/PROVENANCE.md +12 -0
- package/src/vocab/en.js +106 -0
- package/src/world.js +461 -0
- package/src/xxh64.js +87 -0
package/src/spec.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Side-car spec: validation, path patterns and precedence (docs/design.md section 5).
|
|
2
|
+
import { GraceholderError } from "./errors.js";
|
|
3
|
+
import { normalizeScenarios } from "./scenario.js";
|
|
4
|
+
|
|
5
|
+
const SPEC_PROPS = new Set(["list", "one", "count", "gen", "pattern", "pick", "nullRate", "derive", "type", "seed", "key", "shared", "own", "scenario"]);
|
|
6
|
+
const SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
const bad = (msg, path) => new GraceholderError("bad-spec", msg, { path });
|
|
8
|
+
|
|
9
|
+
/** Normalise `count` (n, [min, max] or {min, max}) to { n } or { min, max }. */
|
|
10
|
+
export function normalizeCount(value, fail) {
|
|
11
|
+
const intOk = (x) => Number.isInteger(x) && x >= 0;
|
|
12
|
+
if (intOk(value)) return { n: value };
|
|
13
|
+
if (Array.isArray(value) && value.length === 2 && value.every(intOk) && value[0] <= value[1]) return { min: value[0], max: value[1] };
|
|
14
|
+
if (value && typeof value === "object" && !Array.isArray(value) && intOk(value.min) && intOk(value.max) && value.min <= value.max) return { min: value.min, max: value.max };
|
|
15
|
+
throw fail(`count must be a non-negative integer, [min, max] or {min, max} with min <= max, got ${JSON.stringify(value)}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Normalise `pick` values to a non-empty array. */
|
|
19
|
+
export function checkPick(values, fail) {
|
|
20
|
+
if (!Array.isArray(values) || values.length === 0) throw fail("pick must be a non-empty array of values or {value, weight} objects");
|
|
21
|
+
const weighted = values.map((v) => v !== null && typeof v === "object" && "weight" in v);
|
|
22
|
+
if (weighted.some(Boolean) && !weighted.every(Boolean)) throw fail("pick cannot mix plain values and {value, weight} objects");
|
|
23
|
+
for (const v of values) if (v !== null && typeof v === "object" && "weight" in v && !(v.weight > 0)) throw fail("pick weights must be positive");
|
|
24
|
+
return values;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Validate one FieldSpec and return its canonical override properties. */
|
|
28
|
+
function fieldProps(key, f) {
|
|
29
|
+
const fail = (m) => bad(`spec ${JSON.stringify(key)}: ${m}`, key);
|
|
30
|
+
if (f === null || typeof f !== "object" || Array.isArray(f)) throw fail("must be an object");
|
|
31
|
+
for (const k of Object.keys(f)) if (!SPEC_PROPS.has(k)) throw fail(`unknown property ${JSON.stringify(k)} (known: ${[...SPEC_PROPS].join(", ")})`);
|
|
32
|
+
const props = {};
|
|
33
|
+
if (f.list !== undefined || f.one !== undefined) {
|
|
34
|
+
if (f.list !== undefined && typeof f.list !== "boolean") throw fail("list must be a boolean");
|
|
35
|
+
if (f.one !== undefined && typeof f.one !== "boolean") throw fail("one must be a boolean");
|
|
36
|
+
if (f.list && f.one) throw new GraceholderError("conflicting-overrides", `spec ${JSON.stringify(key)}: list and one are both set`, { path: key });
|
|
37
|
+
if (f.list) props.list = true;
|
|
38
|
+
else if (f.one) props.list = false;
|
|
39
|
+
}
|
|
40
|
+
if (f.count !== undefined) props.count = normalizeCount(f.count, fail);
|
|
41
|
+
const gens = ["gen", "pattern", "pick"].filter((k) => f[k] !== undefined);
|
|
42
|
+
if (gens.length > 1) throw new GraceholderError("conflicting-overrides", `spec ${JSON.stringify(key)}: ${gens.join(" and ")} are both set`, { path: key });
|
|
43
|
+
if (f.gen !== undefined) {
|
|
44
|
+
const g = typeof f.gen === "string" ? { name: f.gen, args: {} } : f.gen;
|
|
45
|
+
if (!g || typeof g.name !== "string" || (g.args !== undefined && (g.args === null || typeof g.args !== "object" || Array.isArray(g.args)))) {
|
|
46
|
+
throw fail('gen must be a name or { name, args }');
|
|
47
|
+
}
|
|
48
|
+
props.gen = { name: g.name, args: g.args ?? {} };
|
|
49
|
+
}
|
|
50
|
+
if (f.pattern !== undefined) {
|
|
51
|
+
if (typeof f.pattern !== "string") throw fail("pattern must be a string");
|
|
52
|
+
props.gen = { name: "pattern", args: { p: f.pattern } };
|
|
53
|
+
}
|
|
54
|
+
if (f.pick !== undefined) props.gen = { name: "oneOf", args: { values: checkPick(f.pick, fail) } };
|
|
55
|
+
if (f.scenario !== undefined) props.scenario = normalizeScenarios(f.scenario, fail);
|
|
56
|
+
if (f.nullRate !== undefined) {
|
|
57
|
+
if (typeof f.nullRate !== "number" || !(f.nullRate >= 0 && f.nullRate <= 1)) throw fail("nullRate must be a number between 0 and 1");
|
|
58
|
+
props.nullRate = f.nullRate;
|
|
59
|
+
}
|
|
60
|
+
if (f.derive !== undefined) {
|
|
61
|
+
if (!f.derive || typeof f.derive.from !== "string" || !f.derive.from) throw fail("derive must be { from: fieldName }");
|
|
62
|
+
props.derive = f.derive.from;
|
|
63
|
+
}
|
|
64
|
+
if (f.type !== undefined) {
|
|
65
|
+
if (typeof f.type !== "string" || !SEGMENT.test(f.type)) throw fail("type must be a GraphQL type name");
|
|
66
|
+
props.type = f.type;
|
|
67
|
+
}
|
|
68
|
+
if (f.seed !== undefined) {
|
|
69
|
+
if (typeof f.seed !== "string" && typeof f.seed !== "number") throw fail("seed must be a string or number");
|
|
70
|
+
props.seed = String(f.seed);
|
|
71
|
+
}
|
|
72
|
+
if (f.key !== undefined) {
|
|
73
|
+
const keys = typeof f.key === "string" ? [f.key] : f.key;
|
|
74
|
+
if (!Array.isArray(keys) || keys.length === 0 || !keys.every((k) => typeof k === "string" && SEGMENT.test(k))) throw fail("key must be an argument name or a non-empty array of argument names");
|
|
75
|
+
props.key = keys;
|
|
76
|
+
}
|
|
77
|
+
for (const k of ["shared", "own"]) {
|
|
78
|
+
if (f[k] !== undefined) {
|
|
79
|
+
if (typeof f[k] !== "boolean") throw fail(`${k} must be a boolean`);
|
|
80
|
+
props[k] = f[k];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (props.own && props.shared) throw new GraceholderError("conflicting-overrides", `spec ${JSON.stringify(key)}: shared and own are both set`, { path: key });
|
|
84
|
+
return props;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const MUTATION_OPS = ["create", "update", "delete"];
|
|
88
|
+
|
|
89
|
+
/** Validate the `$mutations` block (docs/design.md section 16, World) and return its canonical form. */
|
|
90
|
+
function compileMutations(value) {
|
|
91
|
+
const fail = (m) => bad(`$mutations: ${m}`, "$mutations");
|
|
92
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw fail("must be an object");
|
|
93
|
+
const out = { verbs: {}, fields: {}, relations: {}, position: undefined };
|
|
94
|
+
for (const [k, v] of Object.entries(value)) {
|
|
95
|
+
if (k === "verbs") {
|
|
96
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) throw fail("verbs must be an object of verb -> create | update | delete");
|
|
97
|
+
for (const [verb, op] of Object.entries(v)) {
|
|
98
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(verb) || /[A-Z]/.test(verb)) throw fail(`verb ${JSON.stringify(verb)} must be one lowercase word`);
|
|
99
|
+
if (!MUTATION_OPS.includes(op)) throw fail(`verbs.${verb} must be one of ${MUTATION_OPS.join(", ")}`);
|
|
100
|
+
out.verbs[verb] = op;
|
|
101
|
+
}
|
|
102
|
+
} else if (k === "position") {
|
|
103
|
+
if (v !== "start" && v !== "end") throw fail('position must be "start" or "end"');
|
|
104
|
+
out.position = v;
|
|
105
|
+
} else if (k === "relations") {
|
|
106
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) throw fail("relations must be an object of Type.inputField -> OwnerType");
|
|
107
|
+
for (const [rel, owner] of Object.entries(v)) {
|
|
108
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/.test(rel) || typeof owner !== "string" || !SEGMENT.test(owner)) throw fail(`relations.${rel} must map Type.inputField to an owner type name`);
|
|
109
|
+
out.relations[rel] = owner;
|
|
110
|
+
}
|
|
111
|
+
} else if (k === "fields") {
|
|
112
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) throw fail("fields must be an object keyed by mutation field name");
|
|
113
|
+
for (const [name, cfg] of Object.entries(v)) {
|
|
114
|
+
if (!SEGMENT.test(name)) throw fail(`fields.${name}: not a field name`);
|
|
115
|
+
if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) throw fail(`fields.${name} must be an object`);
|
|
116
|
+
for (const key of Object.keys(cfg)) if (!["op", "type", "idArg", "input", "read"].includes(key)) throw fail(`fields.${name}: unknown property ${JSON.stringify(key)}`);
|
|
117
|
+
if (!MUTATION_OPS.includes(cfg.op)) throw fail(`fields.${name}.op must be one of ${MUTATION_OPS.join(", ")}`);
|
|
118
|
+
for (const key of ["type", "idArg", "read"]) if (cfg[key] !== undefined && (typeof cfg[key] !== "string" || !SEGMENT.test(cfg[key]))) throw fail(`fields.${name}.${key} must be a name`);
|
|
119
|
+
if (cfg.input !== undefined && (typeof cfg.input !== "string" || !SEGMENT.test(cfg.input))) throw fail(`fields.${name}.input must be "args" or an argument name`);
|
|
120
|
+
out.fields[name] = { ...cfg };
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
throw fail(`unknown property ${JSON.stringify(k)} (known: verbs, fields, relations, position)`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate and compile a side-car spec.
|
|
131
|
+
* @param {unknown} spec
|
|
132
|
+
* @returns {{ entries: any[], options: { poolSize?: number, endlessTotal?: number }, mutations: any }}
|
|
133
|
+
*/
|
|
134
|
+
export function compileSpec(spec) {
|
|
135
|
+
if (spec === undefined || spec === null) return { entries: [], options: {}, mutations: compileMutations({}) };
|
|
136
|
+
if (typeof spec !== "object" || Array.isArray(spec)) throw bad("spec must be an object keyed by path pattern");
|
|
137
|
+
const options = {};
|
|
138
|
+
const entries = [];
|
|
139
|
+
let mutations = compileMutations({});
|
|
140
|
+
for (const [key, value] of Object.entries(spec)) {
|
|
141
|
+
if (key === "$mutations") {
|
|
142
|
+
mutations = compileMutations(value);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (key === "$options") {
|
|
146
|
+
if (value === null || typeof value !== "object") throw bad("$options must be an object");
|
|
147
|
+
for (const [k, v] of Object.entries(value)) {
|
|
148
|
+
if (k !== "poolSize" && k !== "endlessTotal") throw bad(`unknown $options property ${JSON.stringify(k)}`, key);
|
|
149
|
+
if (!Number.isInteger(v) || v < 1) throw bad(`$options.${k} must be a positive integer`, key);
|
|
150
|
+
options[k] = v;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const props = fieldProps(key, value);
|
|
155
|
+
/** @type {any} */
|
|
156
|
+
let entry;
|
|
157
|
+
if (key.startsWith("@")) {
|
|
158
|
+
const m = /^@([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(key);
|
|
159
|
+
if (!m) throw bad(`invalid type-scoped key ${JSON.stringify(key)}; use @Type.field`, key);
|
|
160
|
+
entry = { key, typeName: m[1], fieldName: m[2], props, klass: 2, exact: 0, prefix: 0 };
|
|
161
|
+
} else {
|
|
162
|
+
const segments = key.split(".");
|
|
163
|
+
if (!segments.every((s) => s === "*" || s === "**" || SEGMENT.test(s))) throw bad(`invalid path pattern ${JSON.stringify(key)}`, key);
|
|
164
|
+
const literals = segments.filter((s) => s !== "*" && s !== "**").length;
|
|
165
|
+
const klass = segments.includes("**") ? 0 : segments.includes("*") ? 1 : 2;
|
|
166
|
+
let prefix = 0;
|
|
167
|
+
while (prefix < segments.length && segments[prefix] !== "*" && segments[prefix] !== "**") prefix++;
|
|
168
|
+
entry = { key, segments, props, klass, exact: klass === 2 ? 1 : 0, prefix, literals };
|
|
169
|
+
}
|
|
170
|
+
entry.index = entries.length;
|
|
171
|
+
entry.used = false;
|
|
172
|
+
entries.push(entry);
|
|
173
|
+
}
|
|
174
|
+
return { entries, options, mutations };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function matchSegments(pattern, path, pi = 0, si = 0) {
|
|
178
|
+
if (pi === pattern.length) return si === path.length;
|
|
179
|
+
const p = pattern[pi];
|
|
180
|
+
if (p === "**") {
|
|
181
|
+
for (let k = si; k <= path.length; k++) if (matchSegments(pattern, path, pi + 1, k)) return true;
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (si >= path.length) return false;
|
|
185
|
+
if (p !== "*" && p !== path[si]) return false;
|
|
186
|
+
return matchSegments(pattern, path, pi + 1, si + 1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Does a compiled entry address the field reached by `path` (dot-joined field names) on `typeName`? */
|
|
190
|
+
export function matchPath(entry, path, typeName, fieldName) {
|
|
191
|
+
if (entry.typeName !== undefined) return entry.typeName === typeName && entry.fieldName === fieldName;
|
|
192
|
+
return matchSegments(entry.segments, path.split("."));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const better = (a, b) =>
|
|
196
|
+
a.klass !== b.klass ? a.klass > b.klass :
|
|
197
|
+
a.exact !== b.exact ? a.exact > b.exact :
|
|
198
|
+
a.prefix !== b.prefix ? a.prefix > b.prefix :
|
|
199
|
+
a.index > b.index;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Spec properties for one field occurrence: for each property, the value from the most
|
|
203
|
+
* specific matching entry that defines it (exact > `*` > `**`, then longer literal prefix,
|
|
204
|
+
* then the later declaration).
|
|
205
|
+
* @returns {Record<string, any>}
|
|
206
|
+
*/
|
|
207
|
+
export function specProps(compiled, path, typeName, fieldName) {
|
|
208
|
+
const best = {};
|
|
209
|
+
for (const entry of compiled.entries) {
|
|
210
|
+
if (!matchPath(entry, path, typeName, fieldName)) continue;
|
|
211
|
+
entry.used = true;
|
|
212
|
+
for (const prop of Object.keys(entry.props)) if (!best[prop] || better(entry, best[prop].entry)) best[prop] = { entry, value: entry.props[prop] };
|
|
213
|
+
}
|
|
214
|
+
return Object.fromEntries(Object.entries(best).map(([k, v]) => [k, v.value]));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Every spec entry that addresses a field, per property, best first (the first one is the winner of
|
|
219
|
+
* `specProps`). Unlike `specProps` it marks nothing as used. For explain().
|
|
220
|
+
* @returns {Record<string, Array<{ key: string, value: any, klass: "exact"|"*"|"**"|"@Type", index: number }>>}
|
|
221
|
+
*/
|
|
222
|
+
export function specTrace(compiled, path, typeName, fieldName) {
|
|
223
|
+
const byProp = {};
|
|
224
|
+
for (const entry of compiled.entries) {
|
|
225
|
+
if (!matchPath(entry, path, typeName, fieldName)) continue;
|
|
226
|
+
for (const prop of Object.keys(entry.props)) (byProp[prop] ??= []).push(entry);
|
|
227
|
+
}
|
|
228
|
+
/** @type {Record<string, any[]>} */
|
|
229
|
+
const out = {};
|
|
230
|
+
for (const [prop, entries] of Object.entries(byProp)) {
|
|
231
|
+
entries.sort((a, b) => (better(a, b) ? -1 : 1));
|
|
232
|
+
out[prop] = entries.map((e) => ({ key: e.key, value: e.props[prop], klass: e.typeName !== undefined ? "@Type" : ["**", "*", "exact"][e.klass], index: e.index }));
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
package/src/suggest.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// suggestSpec: an authoring-time helper (docs/design.md section 10). The core never calls it,
|
|
2
|
+
// imports no LLM SDK, and the model is an injected function.
|
|
3
|
+
import { GraceholderError } from "./errors.js";
|
|
4
|
+
import { inferSchema } from "./index.js";
|
|
5
|
+
import { generatorNames } from "./generators.js";
|
|
6
|
+
import { resolvePacks } from "./pack-registry.js";
|
|
7
|
+
|
|
8
|
+
/** Bumped when the prompt wording changes. The wording is not part of the output-stability guarantee. */
|
|
9
|
+
export const SUGGEST_PROMPT_VERSION = "suggest-spec/1";
|
|
10
|
+
|
|
11
|
+
const GRAMMAR = `A spec is a JSON object. Each key is a path pattern, each value a field spec.
|
|
12
|
+
Path patterns use schema field names (never aliases), dot separated from the root field:
|
|
13
|
+
users.name exactly that path
|
|
14
|
+
*.status "*" is exactly one segment
|
|
15
|
+
**.createdAt "**" is zero or more segments (patterns are anchored at the root unless they start with "**")
|
|
16
|
+
@Type.field the field on that type wherever it appears (for example @User.email)
|
|
17
|
+
Precedence per property: exact path (or @Type.field) over "*" over "**"; heuristics apply to anything you leave out.
|
|
18
|
+
Field spec properties (all optional):
|
|
19
|
+
list / one boolean, force a list or a single object
|
|
20
|
+
count n, [min, max] or {"min": 1, "max": 5}, the size of a list (and its declared total)
|
|
21
|
+
gen "name" or {"name": "int", "args": {"min": 1, "max": 9}}, a generator for a scalar;
|
|
22
|
+
int, float and money also take "dist": "uniform" | "normal" | "lognormal" | "zipf" | "powerlaw"
|
|
23
|
+
pattern string; # digit, ? lowercase letter, A uppercase letter, * letter or digit, \\ escapes
|
|
24
|
+
pick array of values or {"value": v, "weight": n} objects, chosen from deterministically
|
|
25
|
+
nullRate number from 0 to 1
|
|
26
|
+
derive {"from": "otherFieldOnTheSameType"}
|
|
27
|
+
type a type name; types with the same name share one pool of entities
|
|
28
|
+
seed string or number, a different seed for this subtree
|
|
29
|
+
key identifying argument name(s), for example ["orgId", "userId"]
|
|
30
|
+
own / shared boolean; own = not drawn from the shared pool of its type
|
|
31
|
+
Top-level "$options" may set {"poolSize": n, "endlessTotal": n}.`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} query
|
|
35
|
+
* @param {Record<string, any>} [spec] an existing spec the model should extend
|
|
36
|
+
* @param {{ packs?: any[], distributions?: "uniform"|"realistic", images?: "url"|"svg" }} [options] the options generation will use
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function buildSuggestPrompt(query, spec, options = {}) {
|
|
40
|
+
const { sdl, decisions } = inferSchema(query, spec, options);
|
|
41
|
+
const lines = decisions
|
|
42
|
+
.filter((d) => d.kind !== "arg" && d.kind !== "unused")
|
|
43
|
+
.map((d) => `${d.path} | ${d.kind} | ${JSON.stringify(d.value)} | ${d.source}${d.rule ? ` | ${d.rule}` : ""}`);
|
|
44
|
+
return [
|
|
45
|
+
`graceholder ${SUGGEST_PROMPT_VERSION}`,
|
|
46
|
+
"",
|
|
47
|
+
"graceholder generates repeatable mock data for a GraphQL query, inferring a schema from field names with heuristics.",
|
|
48
|
+
"Review the inferred schema below and return a spec that corrects only the places where the heuristics are probably wrong",
|
|
49
|
+
"(wrong list/object shape, wrong type, a better generator, realistic counts or value sets).",
|
|
50
|
+
"",
|
|
51
|
+
"## Query",
|
|
52
|
+
query.trim(),
|
|
53
|
+
"",
|
|
54
|
+
"## Inferred schema",
|
|
55
|
+
sdl.trim(),
|
|
56
|
+
"",
|
|
57
|
+
"## Inferred decisions (path | kind | value | source | rule)",
|
|
58
|
+
...lines,
|
|
59
|
+
"",
|
|
60
|
+
...(spec && Object.keys(spec).length ? ["## Existing spec (keep the entries you agree with)", JSON.stringify(spec, null, 2), ""] : []),
|
|
61
|
+
"## Spec grammar",
|
|
62
|
+
GRAMMAR,
|
|
63
|
+
"",
|
|
64
|
+
"## Known generators",
|
|
65
|
+
generatorNames(resolvePacks(options.packs)).join(", "),
|
|
66
|
+
"",
|
|
67
|
+
"## Reply",
|
|
68
|
+
"Return ONLY a JSON object that is a valid spec (no commentary). Use only the generators listed above and only paths that exist in the query.",
|
|
69
|
+
"",
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildRepairPrompt(original, reply, problems) {
|
|
74
|
+
return [
|
|
75
|
+
original.trimEnd(),
|
|
76
|
+
"",
|
|
77
|
+
"## Your previous reply",
|
|
78
|
+
reply,
|
|
79
|
+
"",
|
|
80
|
+
"## Problems found",
|
|
81
|
+
...problems.map((p) => `- ${p}`),
|
|
82
|
+
"",
|
|
83
|
+
"Return ONLY a corrected JSON spec object.",
|
|
84
|
+
"",
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractJson(text) {
|
|
89
|
+
const fenced = /```(?:json|JSON)?\s*\n([\s\S]*?)\n?```/.exec(text);
|
|
90
|
+
if (fenced) return fenced[1];
|
|
91
|
+
const trimmed = text.trim();
|
|
92
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"')) return trimmed;
|
|
93
|
+
const a = trimmed.indexOf("{");
|
|
94
|
+
const b = trimmed.lastIndexOf("}");
|
|
95
|
+
return a >= 0 && b > a ? trimmed.slice(a, b + 1) : trimmed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @returns {{ spec: Record<string, any> } | { problems: string[] }} */
|
|
99
|
+
function validate(reply, query, options) {
|
|
100
|
+
if (typeof reply !== "string" || reply.trim() === "") return { problems: ["the reply was empty or not text; return a JSON object"] };
|
|
101
|
+
let spec;
|
|
102
|
+
try {
|
|
103
|
+
spec = JSON.parse(extractJson(reply));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return { problems: [`the reply could not be parsed as JSON (${/** @type {Error} */ (e).message})`] };
|
|
106
|
+
}
|
|
107
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
108
|
+
return { problems: ["the spec must be a JSON object keyed by path pattern"] };
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const { decisions } = inferSchema(query, spec, options);
|
|
112
|
+
const unused = decisions.filter((d) => d.kind === "unused").map((d) => `spec path ${JSON.stringify(d.path)} matches no field in the query`);
|
|
113
|
+
if (unused.length) return { problems: unused };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (!(e instanceof GraceholderError)) throw e;
|
|
116
|
+
return { problems: [`${e.message}${e.hint ? ` (${e.hint})` : ""}`] };
|
|
117
|
+
}
|
|
118
|
+
return { spec };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ask an injected model for a spec, validate it, and offer one repair round.
|
|
123
|
+
* @param {string} query
|
|
124
|
+
* @param {{ llm: (prompt: string) => Promise<string>, spec?: Record<string, any>, packs?: any[], distributions?: "uniform"|"realistic", images?: "url"|"svg" }} opts
|
|
125
|
+
* @returns {Promise<Record<string, any>>}
|
|
126
|
+
*/
|
|
127
|
+
export async function suggestSpec(query, { llm, spec, packs, distributions, images }) {
|
|
128
|
+
const options = { packs, distributions, images };
|
|
129
|
+
if (typeof llm !== "function") throw new GraceholderError("bad-options", "suggestSpec needs an llm function: (prompt) => Promise<string>");
|
|
130
|
+
const prompt = buildSuggestPrompt(query, spec, options);
|
|
131
|
+
const first = await llm(prompt);
|
|
132
|
+
let result = validate(first, query, options);
|
|
133
|
+
if ("spec" in result) return result.spec;
|
|
134
|
+
const second = await llm(buildRepairPrompt(prompt, typeof first === "string" ? first : String(first), result.problems));
|
|
135
|
+
result = validate(second, query, options);
|
|
136
|
+
if ("spec" in result) return result.spec;
|
|
137
|
+
throw new GraceholderError("bad-suggestion", "the model did not return a valid spec after one repair round", { hint: result.problems.join("\n") });
|
|
138
|
+
}
|