@pi-in-go/pigpen-pi-typesafe-api 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/schema.go ADDED
@@ -0,0 +1,473 @@
1
+ package pitypesafe
2
+
3
+ import (
4
+ _ "embed"
5
+ "encoding/json"
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
10
+ )
11
+
12
+ // DefaultMaxInputBytes is the default UTF-8 JSON byte budget for one evaluation request; the tool and the client share it.
13
+ const DefaultMaxInputBytes = 65_536
14
+
15
+ // DefaultMaxQuestions is the number of questions one request may ask. More than this needs ChunkRequest, which splits and fans out.
16
+ const DefaultMaxQuestions = 32
17
+
18
+ // Request is an admitted evaluation request: state, questions in order, and an optional model id.
19
+ type Request struct {
20
+ State any
21
+ Questions *Object
22
+ // Model is empty when the request names none.
23
+ Model string
24
+ }
25
+
26
+ // PrepareOptions configure admission.
27
+ type PrepareOptions struct {
28
+ // MaxInputBytes is the UTF-8 JSON bytes of the serialized request. Default: DefaultMaxInputBytes.
29
+ MaxInputBytes int
30
+ }
31
+
32
+ var usageText = fmt.Sprintf(`Expected { state, questions: { <id>: { type: "choice", instructions, criteria: { label: description|null } } | { type: "score", instructions, criteria: [level0, level1, ...] } | { type: "noul", instructions } } }; 1–%d questions, Choice 1–64 options, Score 2–32 levels.`, DefaultMaxQuestions)
33
+
34
+ const jsonSafetyMessage = "state and questions must be plain JSON."
35
+
36
+ // tree returns the request as an *Object tree with the model that was asked for.
37
+ func (r *Request) tree() *Object {
38
+ o := NewObject()
39
+ o.Set("state", r.State)
40
+ o.Set("questions", r.Questions)
41
+ if r.Model != "" {
42
+ o.Set("model", r.Model)
43
+ }
44
+ return o
45
+ }
46
+
47
+ // MarshalJSON serializes state, questions and model in that order, questions in their order.
48
+ func (r *Request) MarshalJSON() ([]byte, error) { return EncodeJSON(r.tree()) }
49
+
50
+ // WithModel returns a copy carrying model.
51
+ func (r *Request) WithModel(model string) *Request {
52
+ c := *r
53
+ c.Model = model
54
+ return &c
55
+ }
56
+
57
+ // Typed converts the admitted request to the shared client's request type, keeping order.
58
+ func (r *Request) Typed() (typesafe.SystemOneRequest, error) {
59
+ var state typesafe.Entry
60
+ switch s := r.State.(type) {
61
+ case nil:
62
+ state = typesafe.Null
63
+ case string:
64
+ state = typesafe.Text(s)
65
+ default:
66
+ raw, err := EncodeJSON(s)
67
+ if err != nil {
68
+ return typesafe.SystemOneRequest{}, err
69
+ }
70
+ state = typesafe.Value(json.RawMessage(raw))
71
+ }
72
+ rawQuestions, err := EncodeJSON(r.Questions)
73
+ if err != nil {
74
+ return typesafe.SystemOneRequest{}, err
75
+ }
76
+ questions, err := typesafe.ParseQuestions(rawQuestions)
77
+ if err != nil {
78
+ return typesafe.SystemOneRequest{}, err
79
+ }
80
+ return typesafe.SystemOneRequest{State: state, Questions: questions, Model: r.Model}, nil
81
+ }
82
+
83
+ // depth limit of the JSON-safety walk.
84
+ const maxDepth = 64
85
+
86
+ func jsonSafe(v any, depth int) bool {
87
+ if depth > maxDepth {
88
+ return false
89
+ }
90
+ switch t := v.(type) {
91
+ case nil, bool, string, float64:
92
+ return true
93
+ case *Object:
94
+ for _, k := range t.keys {
95
+ if !jsonSafe(t.vals[k], depth+1) {
96
+ return false
97
+ }
98
+ }
99
+ return true
100
+ case []any:
101
+ for _, e := range t {
102
+ if !jsonSafe(e, depth+1) {
103
+ return false
104
+ }
105
+ }
106
+ return true
107
+ }
108
+ return false
109
+ }
110
+
111
+ // asTree converts input to a tree, or reports that it is not plain JSON.
112
+ func asTree(value any) (any, error) {
113
+ tree, err := FromGo(value)
114
+ if err != nil {
115
+ return nil, errorf(CodeValidation, "Invalid evaluation request: %s %s", jsonSafetyMessage, usageText)
116
+ }
117
+ if obj, ok := tree.(*Object); ok && !jsonSafe(obj, 0) {
118
+ return nil, errorf(CodeValidation, "Invalid evaluation request: %s %s", jsonSafetyMessage, usageText)
119
+ }
120
+ return tree, nil
121
+ }
122
+
123
+ func isEntry(v any) bool {
124
+ switch v.(type) {
125
+ case nil, string, []any, *Object:
126
+ return true
127
+ }
128
+ return false
129
+ }
130
+
131
+ type issues struct{ list []string }
132
+
133
+ func (i *issues) add(path, message string) {
134
+ if len(i.list) >= 3 {
135
+ return
136
+ }
137
+ if path == "" {
138
+ path = "request"
139
+ }
140
+ if len(path) > 120 {
141
+ path = path[:120]
142
+ }
143
+ i.list = append(i.list, path+": "+message)
144
+ }
145
+
146
+ func (i *issues) full() bool { return len(i.list) >= 3 }
147
+
148
+ func join(path, key string) string {
149
+ if path == "" {
150
+ return key
151
+ }
152
+ return path + "." + key
153
+ }
154
+
155
+ func validateEntry(is *issues, path string, v any) {
156
+ if !isEntry(v) {
157
+ // TypeBox lists the first three branches of the union it failed.
158
+ is.add(path, "must be string")
159
+ is.add(path, "must be null")
160
+ is.add(path, "must be array")
161
+ }
162
+ }
163
+
164
+ func validateQuestion(is *issues, path string, v any) {
165
+ q, ok := v.(*Object)
166
+ if !ok {
167
+ // TypeBox reports each of the three branches.
168
+ is.add(path, "must be object")
169
+ is.add(path, "must be object")
170
+ is.add(path, "must be object")
171
+ return
172
+ }
173
+ typeValue, _ := q.Get("type")
174
+ kind, _ := typeValue.(string)
175
+ var allowed map[string]bool
176
+ switch kind {
177
+ case "noul":
178
+ allowed = map[string]bool{"type": true, "instructions": true, "criteria": true}
179
+ case "choice", "score":
180
+ allowed = map[string]bool{"type": true, "instructions": true, "criteria": true}
181
+ default:
182
+ is.add(join(path, "type"), "must be one of noul, choice, score")
183
+ return
184
+ }
185
+ for _, k := range q.keys {
186
+ if !allowed[k] {
187
+ is.add(join(path, k), "must not have additional properties")
188
+ }
189
+ }
190
+ if ins, ok := q.Get("instructions"); ok {
191
+ validateEntry(is, join(path, "instructions"), ins)
192
+ }
193
+ criteria, hasCriteria := q.Get("criteria")
194
+ cpath := join(path, "criteria")
195
+ switch kind {
196
+ case "noul":
197
+ if !hasCriteria || criteria == nil {
198
+ return
199
+ }
200
+ obj, ok := criteria.(*Object)
201
+ if !ok {
202
+ is.add(cpath, "must be null or object")
203
+ return
204
+ }
205
+ for _, k := range obj.keys {
206
+ if k != "true" && k != "false" {
207
+ is.add(join(cpath, k), "must not have additional properties")
208
+ continue
209
+ }
210
+ validateEntry(is, join(cpath, k), obj.vals[k])
211
+ }
212
+ case "choice":
213
+ if !hasCriteria {
214
+ is.add(path, "must have required property 'criteria'")
215
+ return
216
+ }
217
+ obj, ok := criteria.(*Object)
218
+ if !ok {
219
+ is.add(cpath, "must be object")
220
+ return
221
+ }
222
+ if obj.Len() < 1 || obj.Len() > 64 {
223
+ is.add(cpath, countMessage(obj.Len(), 1, 64, "properties"))
224
+ return
225
+ }
226
+ for _, k := range obj.keys {
227
+ if n := utf16Len(k); n < 1 || n > 200 {
228
+ is.add(join(cpath, k), countMessage(n, 1, 200, "characters"))
229
+ continue
230
+ }
231
+ validateEntry(is, join(cpath, k), obj.vals[k])
232
+ }
233
+ case "score":
234
+ if !hasCriteria {
235
+ is.add(path, "must have required property 'criteria'")
236
+ return
237
+ }
238
+ arr, ok := criteria.([]any)
239
+ if !ok {
240
+ is.add(cpath, "must be array")
241
+ return
242
+ }
243
+ if len(arr) < 2 || len(arr) > 32 {
244
+ is.add(cpath, countMessage(len(arr), 2, 32, "items"))
245
+ return
246
+ }
247
+ for i, e := range arr {
248
+ validateEntry(is, fmt.Sprintf("%s.%d", cpath, i), e)
249
+ }
250
+ }
251
+ }
252
+
253
+ // validateRequest returns up to three "path: message" issues; paths and messages only, never submitted values.
254
+ func validateRequest(v any) []string {
255
+ is := &issues{}
256
+ req, ok := v.(*Object)
257
+ if !ok {
258
+ is.add("", "must be object")
259
+ return is.list
260
+ }
261
+ extra := false
262
+ for _, k := range req.keys {
263
+ if k != "state" && k != "questions" && k != "model" {
264
+ is.add(k, "schema is false")
265
+ extra = true
266
+ }
267
+ }
268
+ if extra {
269
+ is.add("", "must not have additional properties")
270
+ }
271
+ state, hasState := req.Get("state")
272
+ qv, hasQuestions := req.Get("questions")
273
+ var missing []string
274
+ if !hasState {
275
+ missing = append(missing, "state")
276
+ }
277
+ if !hasQuestions {
278
+ missing = append(missing, "questions")
279
+ }
280
+ if len(missing) > 0 {
281
+ is.add("", "must have required properties "+strings.Join(missing, ", "))
282
+ }
283
+ if hasState {
284
+ validateEntry(is, "state", state)
285
+ }
286
+ if hasQuestions {
287
+ validateQuestions(is, qv)
288
+ }
289
+ if m, ok := req.Get("model"); ok {
290
+ s, isStr := m.(string)
291
+ if !isStr {
292
+ is.add("model", "must be string")
293
+ } else if n := utf16Len(s); n < 1 || n > 100 {
294
+ is.add("model", countMessage(n, 1, 100, "characters"))
295
+ }
296
+ }
297
+ return is.list
298
+ }
299
+
300
+ func validateQuestions(is *issues, qv any) {
301
+ if qs, ok := qv.(*Object); !ok {
302
+ is.add("questions", "must be object")
303
+ } else {
304
+ if qs.Len() < 1 || qs.Len() > DefaultMaxQuestions {
305
+ is.add("questions", countMessage(qs.Len(), 1, DefaultMaxQuestions, "properties"))
306
+ }
307
+ for _, id := range qs.keys {
308
+ if n := utf16Len(id); n < 1 || n > 100 {
309
+ is.add("questions", countMessage(n, 1, 100, "characters"))
310
+ continue
311
+ }
312
+ validateQuestion(is, join("questions", id), qs.vals[id])
313
+ if is.full() {
314
+ break
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ // ParseEvaluationRequest validates without including submitted content in validation errors. Prefer
321
+ // PrepareEvaluationRequest, which also accepts near-misses and enforces the byte budget.
322
+ func ParseEvaluationRequest(value any) (*Request, error) {
323
+ tree, err := asTree(value)
324
+ if err != nil {
325
+ return nil, err
326
+ }
327
+ if problems := validateRequest(tree); len(problems) > 0 {
328
+ return nil, errorf(CodeValidation, "Invalid evaluation request at %s. %s", strings.Join(problems, "; "), usageText)
329
+ }
330
+ req := tree.(*Object)
331
+ state, _ := req.Get("state")
332
+ questions, _ := req.Get("questions")
333
+ out := &Request{State: state, Questions: questions.(*Object)}
334
+ if m, ok := req.Get("model"); ok {
335
+ out.Model = m.(string)
336
+ }
337
+ return out, nil
338
+ }
339
+
340
+ // NormalizeEvaluationRequest accepts common near-misses from language models without loosening the schema
341
+ // itself. Prefer PrepareEvaluationRequest, which applies this before validating. Input that is not a request
342
+ // object with a questions object is returned unchanged.
343
+ func NormalizeEvaluationRequest(value any) any {
344
+ tree, err := FromGo(value)
345
+ if err != nil {
346
+ return value
347
+ }
348
+ request, ok := tree.(*Object)
349
+ if !ok {
350
+ return tree
351
+ }
352
+ qv, _ := request.Get("questions")
353
+ questions, ok := qv.(*Object)
354
+ if !ok {
355
+ return tree
356
+ }
357
+ normalized := NewObject()
358
+ for _, id := range questions.keys {
359
+ question, ok := questions.vals[id].(*Object)
360
+ if !ok {
361
+ normalized.Set(id, questions.vals[id])
362
+ continue
363
+ }
364
+ item := NewObject()
365
+ for _, k := range question.keys {
366
+ if k == "options" || k == "levels" || k == "choices" {
367
+ continue
368
+ }
369
+ item.Set(k, question.vals[k])
370
+ }
371
+ if !item.Has("criteria") {
372
+ options, _ := question.Get("options")
373
+ levels, _ := question.Get("levels")
374
+ choices, hasChoices := question.Get("choices")
375
+ var alias any
376
+ present := false
377
+ switch {
378
+ case options != nil:
379
+ alias, present = options, true
380
+ case levels != nil:
381
+ alias, present = levels, true
382
+ case hasChoices:
383
+ alias, present = choices, true
384
+ }
385
+ if present {
386
+ item.Set("criteria", alias)
387
+ }
388
+ }
389
+ kind, _ := item.vals["type"].(string)
390
+ criteria, _ := item.Get("criteria")
391
+ if arr, ok := criteria.([]any); ok && kind == "choice" {
392
+ labels := NewObject()
393
+ all := true
394
+ for _, l := range arr {
395
+ if s, ok := l.(string); !ok || s == "" {
396
+ all = false
397
+ break
398
+ }
399
+ }
400
+ if all {
401
+ for _, l := range arr {
402
+ labels.Set(l.(string), nil)
403
+ }
404
+ item.Set("criteria", labels)
405
+ }
406
+ }
407
+ if s, ok := criteria.(string); ok && kind == "noul" {
408
+ c := NewObject()
409
+ c.Set("true", s)
410
+ item.Set("criteria", c)
411
+ }
412
+ normalized.Set(id, item)
413
+ }
414
+ out := request.Clone()
415
+ out.Set("questions", normalized)
416
+ return out
417
+ }
418
+
419
+ // AssertWithinByteLimit measures the serialized request and names the configured limit: one byte rule for every limit check.
420
+ func AssertWithinByteLimit(text []byte, maxInputBytes int) error {
421
+ if len(text) > maxInputBytes {
422
+ return errorf(CodeValidation, "Evaluation exceeds the %d-byte input limit.", maxInputBytes)
423
+ }
424
+ return nil
425
+ }
426
+
427
+ // PrepareEvaluationRequest is the one admission rule: normalize known near-miss aliases, validate the schema
428
+ // and JSON-safety, then enforce the byte budget, always in that order. The tool, the playground, and Evaluate
429
+ // all pass through here, so what one accepts the others accept.
430
+ func PrepareEvaluationRequest(value any, opts PrepareOptions) (*Request, error) {
431
+ tree, err := asTree(value)
432
+ if err != nil {
433
+ return nil, err
434
+ }
435
+ req, err := ParseEvaluationRequest(NormalizeEvaluationRequest(tree))
436
+ if err != nil {
437
+ return nil, err
438
+ }
439
+ limit := opts.MaxInputBytes
440
+ if limit == 0 {
441
+ limit = DefaultMaxInputBytes
442
+ }
443
+ body, err := req.MarshalJSON()
444
+ if err != nil {
445
+ return nil, errorf(CodeValidation, "Invalid evaluation request: %s", jsonSafetyMessage)
446
+ }
447
+ if err := AssertWithinByteLimit(body, limit); err != nil {
448
+ return nil, err
449
+ }
450
+ return req, nil
451
+ }
452
+
453
+ //go:embed evaluation_schema.json
454
+ var evaluationSchemaJSON []byte
455
+
456
+ // EvaluationSchema is the JSON Schema used by the tool and the programmatic interface: exactly what the
457
+ // original's TypeBox schema serializes to (generated from port/oracle/src/schema.ts, see port/PORT.md). Every field the agent authors says what it means, because
458
+ // the schema is the only shape guidance the model gets before its first call. Each call returns a fresh copy.
459
+ func EvaluationSchema() map[string]any {
460
+ var schema map[string]any
461
+ if err := json.Unmarshal(evaluationSchemaJSON, &schema); err != nil {
462
+ panic("pitypesafe: embedded evaluation schema is invalid: " + err.Error())
463
+ }
464
+ return schema
465
+ }
466
+
467
+ // countMessage is TypeBox's wording for a size outside its bounds.
468
+ func countMessage(n, minimum, maximum int, unit string) string {
469
+ if n < minimum {
470
+ return fmt.Sprintf("must not have fewer than %d %s", minimum, unit)
471
+ }
472
+ return fmt.Sprintf("must not have more than %d %s", maximum, unit)
473
+ }