@effect/ai 0.8.0 → 0.8.2

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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+
5
+ import { dual } from "effect/Function"
6
+ import * as Predicate from "effect/Predicate"
7
+ import * as String from "effect/String"
8
+ import type { Span } from "effect/Tracer"
9
+ import type { Simplify } from "effect/Types"
10
+
11
+ /**
12
+ * The attributes used to describe telemetry in the context of Generative
13
+ * Artificial Intelligence (GenAI) Models requests and responses.
14
+ *
15
+ * {@see https://opentelemetry.io/docs/specs/semconv/attributes-registry/gen-ai/}
16
+ *
17
+ * @since 1.0.0
18
+ * @category models
19
+ */
20
+ export type GenAITelemetryAttributes = Simplify<
21
+ & GenAI.AttributesWithPrefix<GenAI.BaseAttributes, "gen_ai">
22
+ & GenAI.AttributesWithPrefix<GenAI.OperationAttributes, "gen_ai.operation">
23
+ & GenAI.AttributesWithPrefix<GenAI.TokenAttributes, "gen_ai.token">
24
+ & GenAI.AttributesWithPrefix<GenAI.UsageAttributes, "gen_ai.usage">
25
+ & GenAI.AttributesWithPrefix<GenAI.RequestAttributes, "gen_ai.request">
26
+ & GenAI.AttributesWithPrefix<GenAI.ResponseAttributes, "gen_ai.response">
27
+ >
28
+
29
+ /**
30
+ * @since 1.0.0
31
+ * @category models
32
+ */
33
+ export declare namespace GenAI {
34
+ /**
35
+ * All telemetry attributes which are part of the GenAI specification.
36
+ *
37
+ * @since 1.0.0
38
+ * @category models
39
+ */
40
+ export type AllAttributes =
41
+ & BaseAttributes
42
+ & OperationAttributes
43
+ & TokenAttributes
44
+ & UsageAttributes
45
+ & RequestAttributes
46
+ & ResponseAttributes
47
+
48
+ /**
49
+ * Telemetry attributes which are part of the GenAI specification and are
50
+ * namespaced by `gen_ai`.
51
+ *
52
+ * @since 1.0.0
53
+ * @category models
54
+ */
55
+ export interface BaseAttributes {
56
+ /**
57
+ * The Generative AI product as identified by the client or server
58
+ * instrumentation.
59
+ */
60
+ readonly system?: (string & {}) | WellKnownSystem | null | undefined
61
+ }
62
+
63
+ /**
64
+ * Telemetry attributes which are part of the GenAI specification and are
65
+ * namespaced by `gen_ai.operation`.
66
+ *
67
+ * @since 1.0.0
68
+ * @category models
69
+ */
70
+ export interface OperationAttributes {
71
+ readonly name?: (string & {}) | WellKnownOperationName | null | undefined
72
+ }
73
+
74
+ /**
75
+ * Telemetry attributes which are part of the GenAI specification and are
76
+ * namespaced by `gen_ai.token`.
77
+ *
78
+ * @since 1.0.0
79
+ * @category models
80
+ */
81
+ export interface TokenAttributes {
82
+ readonly type?: string | null | undefined
83
+ }
84
+
85
+ /**
86
+ * Telemetry attributes which are part of the GenAI specification and are
87
+ * namespaced by `gen_ai.usage`.
88
+ *
89
+ * @since 1.0.0
90
+ * @category models
91
+ */
92
+ export interface UsageAttributes {
93
+ readonly inputTokens?: number | null | undefined
94
+ readonly outputTokens?: number | null | undefined
95
+ }
96
+
97
+ /**
98
+ * Telemetry attributes which are part of the GenAI specification and are
99
+ * namespaced by `gen_ai.request`.
100
+ *
101
+ * @since 1.0.0
102
+ * @category models
103
+ */
104
+ export interface RequestAttributes {
105
+ /**
106
+ * The name of the GenAI model a request is being made to.
107
+ */
108
+ readonly model?: string | null | undefined
109
+ /**
110
+ * The temperature setting for the GenAI request.
111
+ */
112
+ readonly temperature?: number | null | undefined
113
+ /**
114
+ * The temperature setting for the GenAI request.
115
+ */
116
+ readonly topK?: number | null | undefined
117
+ /**
118
+ * The top_k sampling setting for the GenAI request.
119
+ */
120
+ readonly topP?: number | null | undefined
121
+ /**
122
+ * The top_p sampling setting for the GenAI request.
123
+ */
124
+ readonly maxTokens?: number | null | undefined
125
+ /**
126
+ * The encoding formats requested in an embeddings operation, if specified.
127
+ */
128
+ readonly encodingFormats?: ReadonlyArray<string> | null | undefined
129
+ /**
130
+ * List of sequences that the model will use to stop generating further
131
+ * tokens.
132
+ */
133
+ readonly stopSequences?: ReadonlyArray<string> | null | undefined
134
+ /**
135
+ * The frequency penalty setting for the GenAI request.
136
+ */
137
+ readonly frequencyPenalty?: number | null | undefined
138
+ /**
139
+ * The presence penalty setting for the GenAI request.
140
+ */
141
+ readonly presencePenalty?: number | null | undefined
142
+ /**
143
+ * The seed setting for the GenAI request. Requests with same seed value
144
+ * are more likely to return same result.
145
+ */
146
+ readonly seed?: number | null | undefined
147
+ }
148
+
149
+ /**
150
+ * Telemetry attributes which are part of the GenAI specification and are
151
+ * namespaced by `gen_ai.response`.
152
+ *
153
+ * @since 1.0.0
154
+ * @category models
155
+ */
156
+ export interface ResponseAttributes {
157
+ /**
158
+ * The unique identifier for the completion.
159
+ */
160
+ readonly id?: string | null | undefined
161
+ /**
162
+ * The name of the model that generated the response.
163
+ */
164
+ readonly model?: string | null | undefined
165
+ /**
166
+ * Array of reasons the model stopped generating tokens, corresponding to
167
+ * each generation received.
168
+ */
169
+ readonly finishReasons?: ReadonlyArray<string> | null | undefined
170
+ }
171
+
172
+ /**
173
+ * The `gen_ai.operation.name` attribute has the following list of well-known
174
+ * values.
175
+ *
176
+ * If one of them applies, then the respective value **MUST** be used;
177
+ * otherwise, a custom value **MAY** be used.
178
+ *
179
+ * @since 1.0.0
180
+ * @category models
181
+ */
182
+ export type WellKnownOperationName = "chat" | "embeddings" | "text_completion"
183
+
184
+ /**
185
+ * The `gen_ai.system` attribute has the following list of well-known values.
186
+ *
187
+ * If one of them applies, then the respective value **MUST** be used;
188
+ * otherwise, a custom value **MAY** be used.
189
+ *
190
+ * @since 1.0.0
191
+ * @category models
192
+ */
193
+ export type WellKnownSystem =
194
+ | "anthropic"
195
+ | "aws.bedrock"
196
+ | "az.ai.inference"
197
+ | "az.ai.openai"
198
+ | "cohere"
199
+ | "deepseek"
200
+ | "gemini"
201
+ | "groq"
202
+ | "ibm.watsonx.ai"
203
+ | "mistral_ai"
204
+ | "openai"
205
+ | "perplexity"
206
+ | "vertex_ai"
207
+ | "xai"
208
+
209
+ /**
210
+ * @since 1.0.0
211
+ * @category models
212
+ */
213
+ export type AttributesWithPrefix<Attributes extends Record<string, any>, Prefix extends string> = {
214
+ [Name in keyof Attributes as `${Prefix}.${FormatAttributeName<Name>}`]: Attributes[Name]
215
+ }
216
+
217
+ /**
218
+ * @since 1.0.0
219
+ * @category models
220
+ */
221
+ export type FormatAttributeName<T extends string | number | symbol> = T extends string ?
222
+ T extends `${infer First}${infer Rest}`
223
+ ? `${First extends Uppercase<First> ? "_" : ""}${Lowercase<First>}${FormatAttributeName<Rest>}`
224
+ : T :
225
+ never
226
+ }
227
+
228
+ /**
229
+ * @since 1.0.0
230
+ * @category utilities
231
+ */
232
+ export const addSpanAttributes = (
233
+ keyPrefix: string,
234
+ transformKey: (key: string) => string
235
+ ) =>
236
+ <Attributes extends Record<string, any>>(span: Span, attributes: Attributes): void => {
237
+ for (const [key, value] of Object.entries(attributes)) {
238
+ if (Predicate.isNotNullable(value)) {
239
+ span.attribute(`${keyPrefix}.${transformKey(key)}`, value)
240
+ }
241
+ }
242
+ }
243
+
244
+ const addSpanBaseAttributes = addSpanAttributes("gen_ai", String.camelToSnake)<GenAI.BaseAttributes>
245
+ const addSpanOperationAttributes = addSpanAttributes("gen_ai.operation", String.camelToSnake)<GenAI.OperationAttributes>
246
+ const addSpanRequestAttributes = addSpanAttributes("gen_ai.request", String.camelToSnake)<GenAI.RequestAttributes>
247
+ const addSpanResponseAttributes = addSpanAttributes("gen_ai.response", String.camelToSnake)<GenAI.ResponseAttributes>
248
+ const addSpanTokenAttributes = addSpanAttributes("gen_ai.token", String.camelToSnake)<GenAI.TokenAttributes>
249
+ const addSpanUsageAttributes = addSpanAttributes("gen_ai.usage", String.camelToSnake)<GenAI.UsageAttributes>
250
+
251
+ /**
252
+ * @since 1.0.0
253
+ * @since models
254
+ */
255
+ export type GenAITelemetryAttributeOptions = GenAI.BaseAttributes & {
256
+ readonly operation?: GenAI.OperationAttributes | undefined
257
+ readonly request?: GenAI.RequestAttributes | undefined
258
+ readonly response?: GenAI.ResponseAttributes | undefined
259
+ readonly token?: GenAI.TokenAttributes | undefined
260
+ readonly usage?: GenAI.UsageAttributes | undefined
261
+ }
262
+
263
+ /**
264
+ * Applies the specified GenAI telemetry attributes to the provided `Span`.
265
+ *
266
+ * **NOTE**: This method will mutate the `Span` **in-place**.
267
+ *
268
+ * @since 1.0.0
269
+ * @since utilities
270
+ */
271
+ export const addGenAIAnnotations = dual<
272
+ /**
273
+ * Applies the specified GenAI telemetry attributes to the provided `Span`.
274
+ *
275
+ * **NOTE**: This method will mutate the `Span` **in-place**.
276
+ *
277
+ * @since 1.0.0
278
+ * @since utilities
279
+ */
280
+ (options: GenAITelemetryAttributeOptions) => (span: Span) => void,
281
+ /**
282
+ * Applies the specified GenAI telemetry attributes to the provided `Span`.
283
+ *
284
+ * **NOTE**: This method will mutate the `Span` **in-place**.
285
+ *
286
+ * @since 1.0.0
287
+ * @since utilities
288
+ */
289
+ (span: Span, options: GenAITelemetryAttributeOptions) => void
290
+ >(2, (span, options) => {
291
+ addSpanBaseAttributes(span, { system: options.system })
292
+ if (Predicate.isNotNullable(options.operation)) addSpanOperationAttributes(span, options.operation)
293
+ if (Predicate.isNotNullable(options.request)) addSpanRequestAttributes(span, options.request)
294
+ if (Predicate.isNotNullable(options.response)) addSpanResponseAttributes(span, options.response)
295
+ if (Predicate.isNotNullable(options.token)) addSpanTokenAttributes(span, options.token)
296
+ if (Predicate.isNotNullable(options.usage)) addSpanUsageAttributes(span, options.usage)
297
+ })
@@ -10,6 +10,7 @@ import * as Option from "effect/Option"
10
10
  import * as Schema from "effect/Schema"
11
11
  import * as AST from "effect/SchemaAST"
12
12
  import * as Stream from "effect/Stream"
13
+ import type { Span } from "effect/Tracer"
13
14
  import type { Concurrency } from "effect/Types"
14
15
  import { AiError } from "./AiError.js"
15
16
  import type { Message } from "./AiInput.js"
@@ -34,26 +35,44 @@ export class Completions extends Context.Tag("@effect/ai/Completions")<
34
35
  export declare namespace Completions {
35
36
  /**
36
37
  * @since 1.0.0
37
- * @models
38
+ * @category models
38
39
  */
39
- export interface StructuredSchema<A, I, R> extends Schema.Schema<A, I, R> {
40
- readonly _tag?: string
40
+ export type StructuredSchema<A, I, R> = TaggedSchema<A, I, R> | IdentifiedSchema<A, I, R>
41
+
42
+ /**
43
+ * @since 1.0.0
44
+ * @category models
45
+ */
46
+ export interface TaggedSchema<A, I, R> extends Schema.Schema<A, I, R> {
47
+ readonly _tag: string
48
+ }
49
+
50
+ /**
51
+ * @since 1.0.0
52
+ * @category models
53
+ */
54
+ export interface IdentifiedSchema<A, I, R> extends Schema.Schema<A, I, R> {
41
55
  readonly identifier: string
42
56
  }
43
57
 
44
58
  /**
45
59
  * @since 1.0.0
46
- * @models
60
+ * @category models
47
61
  */
48
62
  export interface Service {
49
63
  readonly create: (input: AiInput.Input) => Effect.Effect<AiResponse, AiError>
50
64
  readonly stream: (input: AiInput.Input) => Stream.Stream<AiResponse, AiError>
51
- readonly structured: <A, I, R>(
52
- options: {
65
+ readonly structured: {
66
+ <A, I, R>(options: {
53
67
  readonly input: AiInput.Input
54
68
  readonly schema: StructuredSchema<A, I, R>
55
- }
56
- ) => Effect.Effect<WithResolved<A>, AiError, R>
69
+ }): Effect.Effect<WithResolved<A>, AiError, R>
70
+ <A, I, R>(options: {
71
+ readonly input: AiInput.Input
72
+ readonly schema: Schema.Schema<A, I, R>
73
+ readonly toolCallId: string
74
+ }): Effect.Effect<WithResolved<A>, AiError, R>
75
+ }
57
76
  readonly toolkit: <Tools extends AiToolkit.Tool.AnySchema>(
58
77
  options: {
59
78
  readonly input: AiInput.Input
@@ -114,6 +133,7 @@ export const make = (options: {
114
133
  readonly structured: boolean
115
134
  }>
116
135
  readonly required: boolean | string
136
+ readonly span: Span
117
137
  }) => Effect.Effect<AiResponse, AiError>
118
138
  readonly stream: (options: {
119
139
  readonly system: Option.Option<string>
@@ -125,91 +145,103 @@ export const make = (options: {
125
145
  readonly structured: boolean
126
146
  }>
127
147
  readonly required: boolean | string
148
+ readonly span: Span
128
149
  }) => Stream.Stream<AiResponse, AiError>
129
150
  }): Effect.Effect<Completions.Service> =>
130
151
  Effect.map(Effect.serviceOption(AiInput.SystemInstruction), (parentSystem) => {
131
152
  return Completions.of({
132
153
  create(input) {
133
- return Effect.serviceOption(AiInput.SystemInstruction).pipe(
134
- Effect.flatMap((system) =>
135
- options.create({
136
- input: AiInput.make(input) as Chunk.NonEmptyChunk<Message>,
137
- system: Option.orElse(system, () => parentSystem),
138
- tools: [],
139
- required: false
140
- })
141
- ),
142
- Effect.withSpan("Completions.create", { captureStackTrace: false })
154
+ return Effect.useSpan(
155
+ "Completions.create",
156
+ { captureStackTrace: false },
157
+ (span) =>
158
+ Effect.serviceOption(AiInput.SystemInstruction).pipe(
159
+ Effect.flatMap((system) =>
160
+ options.create({
161
+ input: AiInput.make(input) as Chunk.NonEmptyChunk<Message>,
162
+ system: Option.orElse(system, () => parentSystem),
163
+ tools: [],
164
+ required: false,
165
+ span
166
+ })
167
+ )
168
+ )
143
169
  )
144
170
  },
145
171
  stream(input_) {
146
172
  const input = AiInput.make(input_)
147
- return Effect.serviceOption(AiInput.SystemInstruction).pipe(
148
- Effect.map((system) =>
173
+ return Effect.makeSpanScoped("Completions.stream", { captureStackTrace: false }).pipe(
174
+ Effect.zip(Effect.serviceOption(AiInput.SystemInstruction)),
175
+ Effect.map(([span, system]) =>
149
176
  options.stream({
150
177
  input: input as Chunk.NonEmptyChunk<Message>,
151
178
  system: Option.orElse(system, () => parentSystem),
152
179
  tools: [],
153
- required: false
180
+ required: false,
181
+ span
154
182
  })
155
183
  ),
156
- Stream.unwrap,
157
- Stream.withSpan("Completions.stream", { captureStackTrace: false })
184
+ Stream.unwrapScoped
158
185
  )
159
186
  },
160
187
  structured(opts) {
161
188
  const input = AiInput.make(opts.input)
162
- const schema = opts.schema
163
- const decode = Schema.decodeUnknown(schema)
164
- const toolId = schema._tag ?? schema.identifier
165
- return Effect.serviceOption(AiInput.SystemInstruction).pipe(
166
- Effect.flatMap((system) =>
167
- options.create({
168
- input: input as Chunk.NonEmptyChunk<Message>,
169
- system: Option.orElse(system, () => parentSystem),
170
- tools: [convertTool(schema, true)],
171
- required: true
172
- })
173
- ),
174
- Effect.flatMap((response) =>
175
- Chunk.findFirst(
176
- response.parts,
177
- (part): part is ToolCallPart => part._tag === "ToolCall" && part.name === toolId
178
- ).pipe(
179
- Option.match({
180
- onNone: () =>
181
- Effect.fail(
182
- new AiError({
183
- module: "Completions",
184
- method: "structured",
185
- description: `Tool call '${toolId}' not found in response`
186
- })
187
- ),
188
- onSome: (toolCall) =>
189
- Effect.matchEffect(decode(toolCall.params), {
190
- onFailure: (cause) =>
191
- new AiError({
192
- module: "Completions",
193
- method: "structured",
194
- description: `Failed to decode tool call '${toolId}' parameters`,
195
- cause
196
- }),
197
- onSuccess: (resolved) =>
198
- Effect.succeed(
199
- new WithResolved({
200
- response,
201
- resolved: new Map([[toolCall.id, resolved]]),
202
- encoded: new Map([[toolCall.id, toolCall.params]])
189
+ const decode = Schema.decodeUnknown(opts.schema)
190
+ const toolId = "toolCallId" in opts
191
+ ? opts.toolCallId
192
+ : "_tag" in opts.schema
193
+ ? opts.schema._tag
194
+ : opts.schema.identifier
195
+ return Effect.useSpan(
196
+ "Completions.structured",
197
+ { attributes: { toolId }, captureStackTrace: false },
198
+ (span) =>
199
+ Effect.serviceOption(AiInput.SystemInstruction).pipe(
200
+ Effect.flatMap((system) =>
201
+ options.create({
202
+ input: input as Chunk.NonEmptyChunk<Message>,
203
+ system: Option.orElse(system, () => parentSystem),
204
+ tools: [convertTool(toolId, opts.schema, true)],
205
+ required: true,
206
+ span
207
+ })
208
+ ),
209
+ Effect.flatMap((response) =>
210
+ Chunk.findFirst(
211
+ response.parts,
212
+ (part): part is ToolCallPart => part._tag === "ToolCall" && part.name === toolId
213
+ ).pipe(
214
+ Option.match({
215
+ onNone: () =>
216
+ Effect.fail(
217
+ new AiError({
218
+ module: "Completions",
219
+ method: "structured",
220
+ description: `Tool call '${toolId}' not found in response`
203
221
  })
204
- )
222
+ ),
223
+ onSome: (toolCall) =>
224
+ Effect.matchEffect(decode(toolCall.params), {
225
+ onFailure: (cause) =>
226
+ new AiError({
227
+ module: "Completions",
228
+ method: "structured",
229
+ description: `Failed to decode tool call '${toolId}' parameters`,
230
+ cause
231
+ }),
232
+ onSuccess: (resolved) =>
233
+ Effect.succeed(
234
+ new WithResolved({
235
+ response,
236
+ resolved: new Map([[toolCall.id, resolved]]),
237
+ encoded: new Map([[toolCall.id, toolCall.params]])
238
+ })
239
+ )
240
+ })
205
241
  })
206
- }),
207
- Effect.withSpan("Completions.structured", {
208
- attributes: { tool: toolId },
209
- captureStackTrace: false
210
- })
242
+ )
243
+ )
211
244
  )
212
- )
213
245
  )
214
246
  },
215
247
  toolkit({ concurrency, input: inputInput, required = false, tools }) {
@@ -221,26 +253,25 @@ export const make = (options: {
221
253
  structured: boolean
222
254
  }> = []
223
255
  for (const [, tool] of tools.toolkit.tools) {
224
- toolArr.push(convertTool(tool as any))
256
+ toolArr.push(convertTool(tool._tag, tool as any))
225
257
  }
226
- return Effect.serviceOption(AiInput.SystemInstruction).pipe(
227
- Effect.flatMap((system) =>
228
- options.create({
229
- input: input as Chunk.NonEmptyChunk<Message>,
230
- system: Option.orElse(system, () => parentSystem),
231
- tools: toolArr,
232
- required: required as any
233
- })
234
- ),
235
- Effect.flatMap((response) => resolveParts({ response, tools, concurrency, method: "toolkit" })),
236
- Effect.withSpan("Completions.toolkit", {
237
- captureStackTrace: false,
238
- attributes: {
239
- concurrency,
240
- required
241
- }
242
- })
243
- ) as any
258
+ return Effect.useSpan(
259
+ "Completions.toolkit",
260
+ { attributes: { concurrency, required }, captureStackTrace: false },
261
+ (span) =>
262
+ Effect.serviceOption(AiInput.SystemInstruction).pipe(
263
+ Effect.flatMap((system) =>
264
+ options.create({
265
+ input: input as Chunk.NonEmptyChunk<Message>,
266
+ system: Option.orElse(system, () => parentSystem),
267
+ tools: toolArr,
268
+ required: required as any,
269
+ span
270
+ })
271
+ ),
272
+ Effect.flatMap((response) => resolveParts({ response, tools, concurrency, method: "toolkit" }))
273
+ ) as any
274
+ )
244
275
  },
245
276
  toolkitStream({ concurrency, input, required = false, tools }) {
246
277
  const toolArr: Array<{
@@ -250,38 +281,40 @@ export const make = (options: {
250
281
  structured: boolean
251
282
  }> = []
252
283
  for (const [, tool] of tools.toolkit.tools) {
253
- toolArr.push(convertTool(tool as any))
284
+ toolArr.push(convertTool(tool._tag, tool as any))
254
285
  }
255
- return Effect.serviceOption(AiInput.SystemInstruction).pipe(
256
- Effect.map((system) =>
286
+ return Effect.makeSpanScoped("Completions.stream", {
287
+ captureStackTrace: false,
288
+ attributes: { required, concurrency }
289
+ }).pipe(
290
+ Effect.zip(Effect.serviceOption(AiInput.SystemInstruction)),
291
+ Effect.map(([span, system]) =>
257
292
  options.stream({
258
293
  input: AiInput.make(input) as Chunk.NonEmptyChunk<Message>,
259
294
  system: Option.orElse(system, () => parentSystem),
260
295
  tools: toolArr,
261
- required: required as any
296
+ required: required as any,
297
+ span
262
298
  })
263
299
  ),
264
- Stream.unwrap,
300
+ Stream.unwrapScoped,
265
301
  Stream.mapEffect(
266
302
  (chunk) => resolveParts({ response: chunk, tools, concurrency, method: "toolkitStream" }),
267
303
  { concurrency: "unbounded" }
268
- ),
269
- Stream.withSpan("Completions.toolkitStream", {
270
- captureStackTrace: false,
271
- attributes: {
272
- concurrency,
273
- required
274
- }
275
- })
304
+ )
276
305
  ) as any
277
306
  }
278
307
  })
279
308
  })
280
309
 
281
- const convertTool = <A, I, R>(tool: Completions.StructuredSchema<A, I, R>, structured = false) => ({
282
- name: tool._tag ?? tool.identifier,
283
- description: getDescription(tool.ast),
284
- parameters: makeJsonSchema(tool.ast),
310
+ const convertTool = <A, I, R>(
311
+ name: string,
312
+ schema: Schema.Schema<A, I, R>,
313
+ structured = false
314
+ ) => ({
315
+ name,
316
+ description: getDescription(schema.ast),
317
+ parameters: makeJsonSchema(schema.ast),
285
318
  structured
286
319
  })
287
320