eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3// Card validation — the Go third of the card contract (web/static/cards.js ·
4// LChatCore Card). The task runner is where cards get AUTHORED (the model
5// calls the card tool, task.go), so validating here turns a malformed card
6// into a tool_result the model can fix, instead of a card that silently falls
7// back to text on every device. Same rules, same vectors
8// (test/event-vectors/cards.json), fail closed on anything unknown.
9
10import (
11 "fmt"
12 "strings"
13)
14
15const cardSchema = 1
16
17const (
18 maxCardBlocks = 32
19 maxCardListItems = 32
20 maxCardKVRows = 16
21 maxCardChips = 4
22 maxCardText = 4000
23 maxCardLabel = 200
24 maxCardURL = 2048
25)
26
27func cardStr(v any, max int) (string, bool) {
28 s, ok := v.(string)
29 if !ok || s == "" || len(s) > max {
30 return "", false
31 }
32 return s, true
33}
34
35// cardOptStr distinguishes "absent" (ok, empty) from "present but wrong".
36func cardOptStr(m map[string]any, key string, max int) (string, bool) {
37 v, present := m[key]
38 if !present || v == nil {
39 return "", true
40 }
41 s, ok := v.(string)
42 if !ok || len(s) > max {
43 return "", false
44 }
45 return s, true
46}
47
48func cardHTTPSURL(v any) (string, bool) {
49 s, ok := cardStr(v, maxCardURL)
50 if !ok {
51 return "", false
52 }
53 lower := strings.ToLower(s)
54 if !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "http://") {
55 return "", false
56 }
57 return s, true
58}
59
60// normalizeCard validates a card value (decoded JSON) into its canonical
61// form, or errors — the fail-closed contract shared with both renderers.
62func normalizeCard(v any) (map[string]any, error) {
63 m, ok := v.(map[string]any)
64 if !ok {
65 return nil, fmt.Errorf("card must be an object")
66 }
67 if s, ok := m["schema"].(float64); !ok || s != cardSchema {
68 return nil, fmt.Errorf("unknown card schema (want %d)", cardSchema)
69 }
70 blocks, err := normalizeCardBlocks(m["blocks"], true)
71 if err != nil {
72 return nil, err
73 }
74 return map[string]any{"schema": cardSchema, "blocks": blocks}, nil
75}
76
77func normalizeCardBlocks(v any, allowSection bool) ([]any, error) {
78 list, ok := v.([]any)
79 if !ok || len(list) == 0 || len(list) > maxCardBlocks {
80 return nil, fmt.Errorf("blocks must be a non-empty array of at most %d", maxCardBlocks)
81 }
82 out := make([]any, 0, len(list))
83 for i, b := range list {
84 n, err := normalizeCardBlock(b, allowSection)
85 if err != nil {
86 return nil, fmt.Errorf("block %d: %w", i, err)
87 }
88 out = append(out, n)
89 }
90 return out, nil
91}
92
93func normalizeCardBlock(v any, allowSection bool) (map[string]any, error) {
94 m, ok := v.(map[string]any)
95 if !ok {
96 return nil, fmt.Errorf("not an object")
97 }
98 kind, _ := m["b"].(string)
99 switch kind {
100 case "heading":
101 text, ok := cardStr(m["text"], maxCardLabel)
102 if !ok {
103 return nil, fmt.Errorf("heading needs text")
104 }
105 sub, ok := cardOptStr(m, "sub", maxCardLabel)
106 if !ok {
107 return nil, fmt.Errorf("bad heading sub")
108 }
109 out := map[string]any{"b": "heading", "text": text}
110 if sub != "" {
111 out["sub"] = sub
112 }
113 return out, nil
114 case "text":
115 text, ok := cardStr(m["text"], maxCardText)
116 if !ok {
117 return nil, fmt.Errorf("text block needs text")
118 }
119 return map[string]any{"b": "text", "text": text}, nil
120 case "item":
121 text, ok := cardStr(m["text"], maxCardText)
122 if !ok {
123 return nil, fmt.Errorf("item needs text")
124 }
125 source, ok := cardOptStr(m, "source", maxCardLabel)
126 if !ok {
127 return nil, fmt.Errorf("bad item source")
128 }
129 out := map[string]any{"b": "item", "text": text}
130 if source != "" {
131 out["source"] = source
132 }
133 if u, present := m["url"]; present && u != nil {
134 url, ok := cardHTTPSURL(u)
135 if !ok {
136 return nil, fmt.Errorf("item url must be https")
137 }
138 out["url"] = url
139 }
140 return out, nil
141 case "list":
142 items, ok := m["items"].([]any)
143 if !ok || len(items) == 0 || len(items) > maxCardListItems {
144 return nil, fmt.Errorf("list needs 1-%d items", maxCardListItems)
145 }
146 out := make([]any, 0, len(items))
147 for _, it := range items {
148 s, ok := cardStr(it, maxCardText)
149 if !ok {
150 return nil, fmt.Errorf("list items must be strings")
151 }
152 out = append(out, s)
153 }
154 return map[string]any{"b": "list", "items": out}, nil
155 case "kv":
156 rows, ok := m["rows"].([]any)
157 if !ok || len(rows) == 0 || len(rows) > maxCardKVRows {
158 return nil, fmt.Errorf("kv needs 1-%d rows", maxCardKVRows)
159 }
160 out := make([]any, 0, len(rows))
161 for _, r := range rows {
162 pair, ok := r.([]any)
163 if !ok || len(pair) != 2 {
164 return nil, fmt.Errorf("kv rows are [key, value] pairs")
165 }
166 k, ok1 := cardStr(pair[0], maxCardLabel)
167 val, ok2 := cardStr(pair[1], maxCardText)
168 if !ok1 || !ok2 {
169 return nil, fmt.Errorf("kv rows are [key, value] pairs")
170 }
171 out = append(out, []any{k, val})
172 }
173 return map[string]any{"b": "kv", "rows": out}, nil
174 case "divider":
175 return map[string]any{"b": "divider"}, nil
176 case "section":
177 if !allowSection {
178 return nil, fmt.Errorf("sections don't nest")
179 }
180 title, ok := cardOptStr(m, "title", maxCardLabel)
181 if !ok {
182 return nil, fmt.Errorf("bad section title")
183 }
184 blocks, err := normalizeCardBlocks(m["blocks"], false)
185 if err != nil {
186 return nil, err
187 }
188 out := map[string]any{"b": "section", "blocks": blocks}
189 if title != "" {
190 out["title"] = title
191 }
192 return out, nil
193 case "chips":
194 chips, ok := m["chips"].([]any)
195 if !ok || len(chips) == 0 || len(chips) > maxCardChips {
196 return nil, fmt.Errorf("chips needs 1-%d entries", maxCardChips)
197 }
198 out := make([]any, 0, len(chips))
199 for _, c := range chips {
200 cm, ok := c.(map[string]any)
201 if !ok {
202 return nil, fmt.Errorf("chips are objects")
203 }
204 label, ok := cardStr(cm["label"], maxCardLabel)
205 if !ok {
206 return nil, fmt.Errorf("chip needs a label")
207 }
208 url, ok := cardHTTPSURL(cm["url"])
209 if !ok {
210 return nil, fmt.Errorf("chip needs an https url (v1 chips are links)")
211 }
212 out = append(out, map[string]any{"label": label, "url": url})
213 }
214 return map[string]any{"b": "chips", "chips": out}, nil
215 default:
216 return nil, fmt.Errorf("unknown block type %q", kind)
217 }
218}
219