@effect-uai/core 0.15.0 → 0.17.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/README.md +30 -16
- package/dist/{AiError-eLV9t1lU.d.mts → AiError-DvhLIbmd.d.mts} +15 -4
- package/dist/{AiError-eLV9t1lU.d.mts.map → AiError-DvhLIbmd.d.mts.map} +1 -1
- package/dist/{Loop-B54AB1ME.d.mts → Loop-ChpaO-ya.d.mts} +2 -2
- package/dist/{Loop-B54AB1ME.d.mts.map → Loop-ChpaO-ya.d.mts.map} +1 -1
- package/dist/{Toolkit-DAjJ9Tvl.d.mts → Toolkit-tlL0F2YR.d.mts} +2 -2
- package/dist/{Toolkit-DAjJ9Tvl.d.mts.map → Toolkit-tlL0F2YR.d.mts.map} +1 -1
- package/dist/chunker/Chunker.d.mts +1 -1
- package/dist/decision-model/Decision.d.mts +155 -0
- package/dist/decision-model/Decision.d.mts.map +1 -0
- package/dist/decision-model/Decision.mjs +118 -0
- package/dist/decision-model/Decision.mjs.map +1 -0
- package/dist/decision-model/Decision.test.mjs +164 -0
- package/dist/decision-model/Decision.test.mjs.map +1 -0
- package/dist/decision-model/DecisionModel.d.mts +45 -0
- package/dist/decision-model/DecisionModel.d.mts.map +1 -0
- package/dist/decision-model/DecisionModel.mjs +25 -0
- package/dist/decision-model/DecisionModel.mjs.map +1 -0
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +9 -2
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Realtime.d.mts +616 -0
- package/dist/domain/Realtime.d.mts.map +1 -0
- package/dist/domain/Realtime.mjs +21 -0
- package/dist/domain/Realtime.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +17 -3
- package/dist/domain/Transcript.d.mts.map +1 -1
- package/dist/domain/Transcript.mjs +30 -1
- package/dist/domain/Transcript.mjs.map +1 -1
- package/dist/domain/Transcript.test.d.mts +1 -0
- package/dist/domain/Transcript.test.mjs +110 -0
- package/dist/domain/Transcript.test.mjs.map +1 -0
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/image-generator/ImageGenerator.d.mts +1 -1
- package/dist/index.d.mts +11 -5
- package/dist/index.mjs +9 -3
- package/dist/job/Job.d.mts +1 -1
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/loop/Loop.d.mts +1 -1
- package/dist/math/Probability.d.mts +21 -0
- package/dist/math/Probability.d.mts.map +1 -0
- package/dist/math/Probability.mjs +31 -0
- package/dist/math/Probability.mjs.map +1 -0
- package/dist/math/Probability.test.d.mts +1 -0
- package/dist/math/Probability.test.mjs +95 -0
- package/dist/math/Probability.test.mjs.map +1 -0
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/realtime/RealtimeSession.d.mts +50 -0
- package/dist/realtime/RealtimeSession.d.mts.map +1 -0
- package/dist/realtime/RealtimeSession.mjs +45 -0
- package/dist/realtime/RealtimeSession.mjs.map +1 -0
- package/dist/realtime/RealtimeSession.test.d.mts +1 -0
- package/dist/realtime/RealtimeSession.test.mjs +95 -0
- package/dist/realtime/RealtimeSession.test.mjs.map +1 -0
- package/dist/reranker/Reranker.d.mts +1 -1
- package/dist/research/DeepResearch.d.mts +1 -1
- package/dist/retry/Retry.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/Settle.d.mts +45 -0
- package/dist/streaming/Settle.d.mts.map +1 -0
- package/dist/streaming/Settle.mjs +95 -0
- package/dist/streaming/Settle.mjs.map +1 -0
- package/dist/streaming/Settle.test.d.mts +1 -0
- package/dist/streaming/Settle.test.mjs +252 -0
- package/dist/streaming/Settle.test.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.d.mts +36 -0
- package/dist/streaming/WebSocketSession.d.mts.map +1 -0
- package/dist/streaming/WebSocketSession.mjs +89 -0
- package/dist/streaming/WebSocketSession.mjs.map +1 -0
- package/dist/streaming/WebSocketSession.test.d.mts +1 -0
- package/dist/streaming/WebSocketSession.test.mjs +51 -0
- package/dist/streaming/WebSocketSession.test.mjs.map +1 -0
- package/dist/testing/FakeWebSocket.d.mts +25 -0
- package/dist/testing/FakeWebSocket.d.mts.map +1 -0
- package/dist/testing/FakeWebSocket.mjs +79 -0
- package/dist/testing/FakeWebSocket.mjs.map +1 -0
- package/dist/testing/MockRealtimeSession.d.mts +30 -0
- package/dist/testing/MockRealtimeSession.d.mts.map +1 -0
- package/dist/testing/MockRealtimeSession.mjs +52 -0
- package/dist/testing/MockRealtimeSession.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/dist/web-read/WebRead.d.mts +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +36 -4
- package/src/decision-model/Decision.test.ts +146 -0
- package/src/decision-model/Decision.ts +273 -0
- package/src/decision-model/DecisionModel.ts +53 -0
- package/src/domain/AiError.ts +11 -0
- package/src/domain/Realtime.ts +101 -0
- package/src/domain/Transcript.test.ts +98 -0
- package/src/domain/Transcript.ts +53 -1
- package/src/index.ts +7 -1
- package/src/math/Probability.test.ts +61 -0
- package/src/math/Probability.ts +44 -0
- package/src/realtime/RealtimeSession.test.ts +100 -0
- package/src/realtime/RealtimeSession.ts +77 -0
- package/src/streaming/Settle.test.ts +344 -0
- package/src/streaming/Settle.ts +196 -0
- package/src/streaming/WebSocketSession.test.ts +52 -0
- package/src/streaming/WebSocketSession.ts +132 -0
- package/src/testing/FakeWebSocket.ts +120 -0
- package/src/testing/MockRealtimeSession.ts +92 -0
- package/dist/inbox/Inbox.d.mts +0 -16
- package/dist/inbox/Inbox.d.mts.map +0 -1
- package/dist/inbox/Inbox.mjs +0 -28
- package/dist/inbox/Inbox.mjs.map +0 -1
- package/dist/inbox/Inbox.test.mjs +0 -42
- package/dist/inbox/Inbox.test.mjs.map +0 -1
- package/src/inbox/Inbox.test.ts +0 -52
- package/src/inbox/Inbox.ts +0 -33
- /package/dist/{inbox/Inbox.test.d.mts → decision-model/Decision.test.d.mts} +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Cause, Effect, Queue, Ref, Stream } from "effect"
|
|
3
|
+
import { TestClock } from "effect/testing"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import { accumulatePartials, type TranscriptEvent } from "./Transcript.js"
|
|
6
|
+
|
|
7
|
+
const collecting = (stream: Stream.Stream<TranscriptEvent, never>) =>
|
|
8
|
+
Effect.gen(function* () {
|
|
9
|
+
const seen = yield* Ref.make<ReadonlyArray<TranscriptEvent>>([])
|
|
10
|
+
yield* Effect.forkChild(Stream.runForEach(stream, (e) => Ref.update(seen, (xs) => [...xs, e])))
|
|
11
|
+
return seen
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
/** Deltas arriving faster than any settle window. */
|
|
15
|
+
const speak = (
|
|
16
|
+
queue: Queue.Queue<TranscriptEvent, Cause.Done>,
|
|
17
|
+
tokens: ReadonlyArray<string>,
|
|
18
|
+
): Effect.Effect<void> =>
|
|
19
|
+
Effect.forEach(
|
|
20
|
+
tokens,
|
|
21
|
+
(text) =>
|
|
22
|
+
Effect.andThen(Queue.offer(queue, { _tag: "partial", text }), TestClock.adjust("100 millis")),
|
|
23
|
+
{ discard: true },
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const opened = Effect.gen(function* () {
|
|
27
|
+
const queue = yield* Queue.unbounded<TranscriptEvent, Cause.Done>()
|
|
28
|
+
const seen = yield* collecting(
|
|
29
|
+
Stream.fromQueue(queue).pipe(accumulatePartials({ silence: "1 second" })),
|
|
30
|
+
)
|
|
31
|
+
return { queue, seen }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("accumulatePartials", () => {
|
|
35
|
+
it.effect("joins deltas into the running hypothesis, forwarding each as it arrives", () =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const { queue, seen } = yield* opened
|
|
38
|
+
|
|
39
|
+
yield* speak(queue, [" Hi", ",", " how"])
|
|
40
|
+
|
|
41
|
+
// Three in, three out, each the whole sentence so far, none delayed.
|
|
42
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
43
|
+
{ _tag: "partial", text: "Hi" },
|
|
44
|
+
{ _tag: "partial", text: "Hi," },
|
|
45
|
+
{ _tag: "partial", text: "Hi, how" },
|
|
46
|
+
])
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it.effect("commits on silence and starts the next utterance from empty", () =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const { queue, seen } = yield* opened
|
|
53
|
+
|
|
54
|
+
yield* speak(queue, [" Hi", " there"])
|
|
55
|
+
yield* TestClock.adjust("3 seconds")
|
|
56
|
+
yield* speak(queue, [" Bye"])
|
|
57
|
+
yield* TestClock.adjust("3 seconds")
|
|
58
|
+
|
|
59
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
60
|
+
{ _tag: "partial", text: "Hi" },
|
|
61
|
+
{ _tag: "partial", text: "Hi there" },
|
|
62
|
+
{ _tag: "final", text: "Hi there" },
|
|
63
|
+
{ _tag: "partial", text: "Bye" },
|
|
64
|
+
{ _tag: "final", text: "Bye" },
|
|
65
|
+
])
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
it.effect("a provider's own final resets the accumulator, so silence adds no duplicate", () =>
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
const { queue, seen } = yield* opened
|
|
72
|
+
|
|
73
|
+
yield* speak(queue, [" Hi"])
|
|
74
|
+
yield* Queue.offer(queue, { _tag: "final", text: "Hi." })
|
|
75
|
+
yield* TestClock.adjust("3 seconds")
|
|
76
|
+
|
|
77
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
78
|
+
{ _tag: "partial", text: "Hi" },
|
|
79
|
+
{ _tag: "final", text: "Hi." },
|
|
80
|
+
])
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
it.effect("commits the pending text when the stream ends mid-utterance", () =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
const { queue, seen } = yield* opened
|
|
87
|
+
|
|
88
|
+
yield* speak(queue, [" Half a sen"])
|
|
89
|
+
yield* Queue.end(queue)
|
|
90
|
+
yield* TestClock.adjust("10 millis")
|
|
91
|
+
|
|
92
|
+
expect(yield* Ref.get(seen)).toEqual([
|
|
93
|
+
{ _tag: "partial", text: "Half a sen" },
|
|
94
|
+
{ _tag: "final", text: "Half a sen" },
|
|
95
|
+
])
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
})
|
package/src/domain/Transcript.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Duration, Effect, Match, Option, Ref, Stream } from "effect"
|
|
2
|
+
import * as Settle from "../streaming/Settle.js"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Per-word timing + metadata. `confidence` and `speakerId` are optional
|
|
@@ -87,3 +88,54 @@ export const isMetadata = (
|
|
|
87
88
|
): e is Extract<TranscriptEvent, { _tag: "metadata" }> => e._tag === "metadata"
|
|
88
89
|
export const isError = (e: TranscriptEvent): e is Extract<TranscriptEvent, { _tag: "error" }> =>
|
|
89
90
|
e._tag === "error"
|
|
91
|
+
|
|
92
|
+
export type AccumulatePartialsOptions = {
|
|
93
|
+
/** Silence after the last `partial` before the text so far is committed. */
|
|
94
|
+
readonly silence?: Duration.Input
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_SILENCE = "700 millis"
|
|
98
|
+
|
|
99
|
+
const finalOf = (text: string): Option.Option<TranscriptEvent> =>
|
|
100
|
+
text.trim().length === 0 ? Option.none() : Option.some({ _tag: "final", text: text.trim() })
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Join fragment `partial`s into the running hypothesis and commit it as a
|
|
104
|
+
* `final` after `silence`. For providers that stream token-sized deltas
|
|
105
|
+
* (OpenAI Realtime) or never segment; piping one that already accumulates
|
|
106
|
+
* repeats its text.
|
|
107
|
+
*
|
|
108
|
+
* Partials are forwarded as they arrive; only the synthetic `final` waits.
|
|
109
|
+
* A real `final` resets the accumulator, and the stream end flushes it.
|
|
110
|
+
*/
|
|
111
|
+
export const accumulatePartials =
|
|
112
|
+
(options?: AccumulatePartialsOptions) =>
|
|
113
|
+
<E, R>(self: Stream.Stream<TranscriptEvent, E, R>): Stream.Stream<TranscriptEvent, E, R> =>
|
|
114
|
+
Stream.unwrap(
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const acc = yield* Ref.make("")
|
|
117
|
+
const flush = Effect.map(Ref.getAndSet(acc, ""), finalOf)
|
|
118
|
+
|
|
119
|
+
const rewrite = (event: TranscriptEvent): Effect.Effect<TranscriptEvent> =>
|
|
120
|
+
Match.value(event).pipe(
|
|
121
|
+
Match.when({ _tag: "partial" }, (e) =>
|
|
122
|
+
Effect.map(
|
|
123
|
+
Ref.updateAndGet(acc, (text) => text + e.text),
|
|
124
|
+
(text): TranscriptEvent => ({ ...e, text: text.trim() }),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
Match.when({ _tag: "final" }, (e) => Effect.as(Ref.set(acc, ""), e as TranscriptEvent)),
|
|
128
|
+
Match.orElse((e) => Effect.succeed(e as TranscriptEvent)),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return self.pipe(
|
|
132
|
+
Stream.mapEffect(rewrite),
|
|
133
|
+
Settle.onQuiet(options?.silence ?? DEFAULT_SILENCE, flush),
|
|
134
|
+
Stream.concat(
|
|
135
|
+
Stream.unwrap(
|
|
136
|
+
Effect.map(flush, Option.match({ onNone: () => Stream.empty, onSome: Stream.make })),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
}),
|
|
141
|
+
)
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ export * as BrowserTool from "./browser/BrowserTool.js"
|
|
|
6
6
|
export * as Capabilities from "./capabilities/Capabilities.js"
|
|
7
7
|
export * as Chunker from "./chunker/Chunker.js"
|
|
8
8
|
export * as Citation from "./domain/Citation.js"
|
|
9
|
+
export * as Decision from "./decision-model/Decision.js"
|
|
10
|
+
export * as DecisionModel from "./decision-model/DecisionModel.js"
|
|
9
11
|
export * as DeepResearch from "./research/DeepResearch.js"
|
|
10
12
|
export * as Image from "./domain/Image.js"
|
|
11
13
|
export * as Items from "./domain/Items.js"
|
|
@@ -13,6 +15,8 @@ export * as Job from "./job/Job.js"
|
|
|
13
15
|
export * as Media from "./domain/Media.js"
|
|
14
16
|
export * as Music from "./domain/Music.js"
|
|
15
17
|
export * as Research from "./domain/Research.js"
|
|
18
|
+
export * as Realtime from "./domain/Realtime.js"
|
|
19
|
+
export * as RealtimeSession from "./realtime/RealtimeSession.js"
|
|
16
20
|
export * as Transcript from "./domain/Transcript.js"
|
|
17
21
|
export * as Turn from "./domain/Turn.js"
|
|
18
22
|
export * as Embedding from "./embedding-model/Embedding.js"
|
|
@@ -35,9 +39,11 @@ export * as WebRead from "./web-read/WebRead.js"
|
|
|
35
39
|
export * as WebReadTool from "./web-read/WebReadTool.js"
|
|
36
40
|
export * as WebSearch from "./web-search/WebSearch.js"
|
|
37
41
|
export * as WebSearchTool from "./web-search/WebSearchTool.js"
|
|
42
|
+
export * as Probability from "./math/Probability.js"
|
|
38
43
|
export * as Vector from "./math/Vector.js"
|
|
39
44
|
export * as Tokenizer from "./tokenizer/Tokenizer.js"
|
|
40
|
-
export * as
|
|
45
|
+
export * as Settle from "./streaming/Settle.js"
|
|
46
|
+
export * as WebSocketSession from "./streaming/WebSocketSession.js"
|
|
41
47
|
export * as Loop from "./loop/Loop.js"
|
|
42
48
|
export * as Retry from "./retry/Retry.js"
|
|
43
49
|
export * as Tool from "./tool/Tool.js"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { confidence, margin } from "./Probability.js"
|
|
3
|
+
|
|
4
|
+
describe("confidence", () => {
|
|
5
|
+
it("is 1 when all mass sits on one outcome", () => {
|
|
6
|
+
expect(confidence([1, 0, 0])).toBeCloseTo(1, 10)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("is 0 when mass is spread evenly, at any length", () => {
|
|
10
|
+
expect(confidence([0.5, 0.5])).toBeCloseTo(0, 10)
|
|
11
|
+
expect(confidence([0.25, 0.25, 0.25, 0.25])).toBeCloseTo(0, 10)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("is 1 for fewer than two outcomes, which have nothing to be uncertain between", () => {
|
|
15
|
+
expect(confidence([])).toBe(1)
|
|
16
|
+
expect(confidence([1])).toBe(1)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("rises as mass concentrates", () => {
|
|
20
|
+
const spread = confidence([0.4, 0.35, 0.25])
|
|
21
|
+
const leaning = confidence([0.6, 0.25, 0.15])
|
|
22
|
+
const decided = confidence([0.9, 0.07, 0.03])
|
|
23
|
+
expect(spread).toBeLessThan(leaning)
|
|
24
|
+
expect(leaning).toBeLessThan(decided)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("is conservative on two outcomes, so a threshold does not transfer across lengths", () => {
|
|
28
|
+
expect(confidence([0.94, 0.06])).toBeCloseTo(0.67, 2)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe("margin", () => {
|
|
33
|
+
it("is the gap between the top two, whatever order they arrive in", () => {
|
|
34
|
+
expect(margin([0.6, 0.3, 0.1])).toBeCloseTo(0.3, 10)
|
|
35
|
+
expect(margin([0.1, 0.6, 0.3])).toBeCloseTo(0.3, 10)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("is 0 on a tie and 0 when empty", () => {
|
|
39
|
+
expect(margin([0.5, 0.5])).toBeCloseTo(0, 10)
|
|
40
|
+
expect(margin([])).toBe(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("yields the lone probability for a single outcome", () => {
|
|
44
|
+
expect(margin([0.7])).toBeCloseTo(0.7, 10)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe("confidence and margin disagree, which is why both exist", () => {
|
|
49
|
+
// Two plausible answers versus no idea. Same winner, same top mass.
|
|
50
|
+
const closeSecond = [0.45, 0.42, 0.13]
|
|
51
|
+
const flatSpread = [0.45, 0.11, 0.11, 0.11, 0.11, 0.11]
|
|
52
|
+
|
|
53
|
+
it("confidence cannot separate them", () => {
|
|
54
|
+
expect(Math.abs(confidence(closeSecond) - confidence(flatSpread))).toBeLessThan(0.05)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("margin separates them sharply", () => {
|
|
58
|
+
expect(margin(closeSecond)).toBeCloseTo(0.03, 10)
|
|
59
|
+
expect(margin(flatSpread)).toBeCloseTo(0.34, 10)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Certainty measures over a probability vector. */
|
|
2
|
+
import { Array as A, Number as N, Option, Order, pipe } from "effect"
|
|
3
|
+
|
|
4
|
+
const descending = Order.flip(Order.Number)
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 1 minus normalized Shannon entropy: 1 when all mass sits on one outcome, 0
|
|
8
|
+
* when spread evenly. Fewer than two outcomes yields 1.
|
|
9
|
+
*
|
|
10
|
+
* Conservative on two outcomes, where `[0.94, 0.06]` scores ~0.67, so a
|
|
11
|
+
* threshold tuned on a long vector does not transfer. {@link margin} is the
|
|
12
|
+
* more stable gate across vectors of different length.
|
|
13
|
+
*/
|
|
14
|
+
export const confidence = (probabilities: ReadonlyArray<number>): number =>
|
|
15
|
+
probabilities.length < 2
|
|
16
|
+
? 1
|
|
17
|
+
: 1 -
|
|
18
|
+
pipe(
|
|
19
|
+
probabilities,
|
|
20
|
+
A.filter((p) => p > 0),
|
|
21
|
+
A.map((p) => -p * Math.log(p)),
|
|
22
|
+
N.sumAll,
|
|
23
|
+
) /
|
|
24
|
+
Math.log(probabilities.length)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Top mass minus runner-up mass, which unlike {@link confidence} separates a
|
|
28
|
+
* close second from a flat spread. A single outcome yields its own
|
|
29
|
+
* probability; empty yields 0.
|
|
30
|
+
*/
|
|
31
|
+
export const margin = (probabilities: ReadonlyArray<number>): number => {
|
|
32
|
+
const top = pipe(probabilities, A.sort(descending), A.take(2))
|
|
33
|
+
return pipe(
|
|
34
|
+
A.get(top, 0),
|
|
35
|
+
Option.map((first) =>
|
|
36
|
+
pipe(
|
|
37
|
+
A.get(top, 1),
|
|
38
|
+
Option.getOrElse(() => 0),
|
|
39
|
+
(second) => first - second,
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
Option.getOrElse(() => 0),
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Stream } from "effect"
|
|
3
|
+
import { expect, expectTypeOf } from "vitest"
|
|
4
|
+
import type * as AiError from "../domain/AiError.js"
|
|
5
|
+
import { imageBase64 } from "../domain/Image.js"
|
|
6
|
+
import { type CommonSessionRequest, RealtimeEvent, RealtimeInput } from "../domain/Realtime.js"
|
|
7
|
+
import * as MockRealtimeSession from "../testing/MockRealtimeSession.js"
|
|
8
|
+
import { RealtimeVideoInput, open, sendVideoFrame, toolCalls } from "./RealtimeSession.js"
|
|
9
|
+
|
|
10
|
+
const pcm = { container: "raw", encoding: "pcm_s16le", sampleRate: 24000, channels: 1 } as const
|
|
11
|
+
|
|
12
|
+
const request: CommonSessionRequest = {
|
|
13
|
+
model: "gpt-realtime-2.1",
|
|
14
|
+
inputFormat: pcm,
|
|
15
|
+
outputFormat: pcm,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const call = {
|
|
19
|
+
type: "function_call",
|
|
20
|
+
call_id: "call_1",
|
|
21
|
+
name: "get_time",
|
|
22
|
+
arguments: "{}",
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
describe("RealtimeSession against a scripted session", () => {
|
|
26
|
+
it.effect("answers each input and ends `events` when the scope closes", () =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const mock = MockRealtimeSession.layer({
|
|
29
|
+
initial: [RealtimeEvent.ResponseStarted({ responseId: "r1" })],
|
|
30
|
+
onInput: (input) =>
|
|
31
|
+
RealtimeInput.$is("Audio")(input)
|
|
32
|
+
? [RealtimeEvent.AudioDelta({ responseId: "r1", bytes: new Uint8Array([7]) })]
|
|
33
|
+
: [RealtimeEvent.ResponseDone({ responseId: "r1", reason: "interrupted" })],
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const handle = yield* Effect.scoped(
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const handle = yield* open(request)
|
|
39
|
+
yield* handle.send(RealtimeInput.Audio({ bytes: new Uint8Array([1, 2]) }))
|
|
40
|
+
yield* handle.send(RealtimeInput.Interrupt())
|
|
41
|
+
return handle
|
|
42
|
+
}),
|
|
43
|
+
).pipe(Effect.provide(mock.layer))
|
|
44
|
+
|
|
45
|
+
// The scope is closed, so the stream drains what is queued and ends
|
|
46
|
+
// rather than hanging.
|
|
47
|
+
const events = yield* Stream.runCollect(handle.events)
|
|
48
|
+
expect(events).toEqual([
|
|
49
|
+
RealtimeEvent.ResponseStarted({ responseId: "r1" }),
|
|
50
|
+
RealtimeEvent.AudioDelta({ responseId: "r1", bytes: new Uint8Array([7]) }),
|
|
51
|
+
RealtimeEvent.ResponseDone({ responseId: "r1", reason: "interrupted" }),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const recorder = yield* mock.recorder
|
|
55
|
+
expect(recorder.openCalls.map((r) => r.model)).toEqual(["gpt-realtime-2.1"])
|
|
56
|
+
expect(recorder.sent.map((i) => i._tag)).toEqual(["Audio", "Interrupt"])
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
it.effect("narrows `toolCalls` to the calls the model made", () =>
|
|
61
|
+
Effect.gen(function* () {
|
|
62
|
+
const mock = MockRealtimeSession.layer({
|
|
63
|
+
initial: [
|
|
64
|
+
RealtimeEvent.ResponseStarted({ responseId: "r1" }),
|
|
65
|
+
RealtimeEvent.ToolCall({ responseId: "r1", call }),
|
|
66
|
+
RealtimeEvent.OutputTranscriptDelta({ responseId: "r1", text: "one moment" }),
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const handle = yield* Effect.scoped(open(request)).pipe(Effect.provide(mock.layer))
|
|
71
|
+
const calls = yield* Stream.runCollect(toolCalls(handle.events))
|
|
72
|
+
|
|
73
|
+
expect(calls.map((e) => e.call.call_id)).toEqual(["call_1"])
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("RealtimeVideoInput marker", () => {
|
|
79
|
+
const frame = imageBase64("aW1n", "image/jpeg")
|
|
80
|
+
|
|
81
|
+
it("leaves the marker unsatisfied against an audio-only Layer", () => {
|
|
82
|
+
const mock = MockRealtimeSession.layerAudioOnly({})
|
|
83
|
+
const program = Effect.scoped(
|
|
84
|
+
Effect.flatMap(open(request), (handle) => sendVideoFrame(handle, frame)),
|
|
85
|
+
).pipe(Effect.provide(mock.layer))
|
|
86
|
+
|
|
87
|
+
// `RealtimeSession` is provided, `RealtimeVideoInput` is not, so running
|
|
88
|
+
// this would be a type error.
|
|
89
|
+
expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, RealtimeVideoInput>>()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("clears R to never against a Layer that ships the marker", () => {
|
|
93
|
+
const mock = MockRealtimeSession.layer({})
|
|
94
|
+
const program = Effect.scoped(
|
|
95
|
+
Effect.flatMap(open(request), (handle) => sendVideoFrame(handle, frame)),
|
|
96
|
+
).pipe(Effect.provide(mock.layer))
|
|
97
|
+
|
|
98
|
+
expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A duplex speech-to-speech session. The server drives turn-taking, so this is
|
|
3
|
+
* its own primitive rather than a variant of `Loop`.
|
|
4
|
+
*
|
|
5
|
+
* The session is deliberately low level: tool execution, playback, barge-in
|
|
6
|
+
* bookkeeping and reconnection are the caller's, built from `send` and
|
|
7
|
+
* `events`. There is no agent wrapper, no hidden queue and no automatic
|
|
8
|
+
* reconnect here.
|
|
9
|
+
*/
|
|
10
|
+
import { Context, Effect, type Scope, Stream } from "effect"
|
|
11
|
+
import type * as AiError from "../domain/AiError.js"
|
|
12
|
+
import type { ImageSource } from "../domain/Image.js"
|
|
13
|
+
import { type CommonSessionRequest, RealtimeInput, RealtimeEvent } from "../domain/Realtime.js"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The live connection. `events` ends when the socket closes cleanly; a close
|
|
17
|
+
* during a response ends it with `AiError.IncompleteTurn` instead, and never
|
|
18
|
+
* synthesizes a `ResponseDone`. `send` after close fails `Unavailable`.
|
|
19
|
+
*/
|
|
20
|
+
export type RealtimeSessionHandle = {
|
|
21
|
+
readonly send: (input: RealtimeInput) => Effect.Effect<void, AiError.AiError>
|
|
22
|
+
readonly events: Stream.Stream<RealtimeEvent, AiError.AiError>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RealtimeSessionService = {
|
|
26
|
+
/**
|
|
27
|
+
* Connect and complete the provider handshake before succeeding. Closing the
|
|
28
|
+
* `Scope` closes the socket.
|
|
29
|
+
*/
|
|
30
|
+
readonly open: (
|
|
31
|
+
request: CommonSessionRequest,
|
|
32
|
+
) => Effect.Effect<RealtimeSessionHandle, AiError.AiError, Scope.Scope>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class RealtimeSession extends Context.Service<RealtimeSession, RealtimeSessionService>()(
|
|
36
|
+
"@betalyra/effect-uai/RealtimeSession",
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Capability marker for sessions that accept video frames. Phantom: providers
|
|
41
|
+
* register with `Layer.succeed(RealtimeVideoInput, undefined)`. Only Gemini
|
|
42
|
+
* ships it, so `sendVideoFrame` against an OpenAI-only Layer is a type error.
|
|
43
|
+
*/
|
|
44
|
+
export class RealtimeVideoInput extends Context.Service<RealtimeVideoInput, void>()(
|
|
45
|
+
"@betalyra/effect-uai/capability/RealtimeVideoInput",
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
/** Open a session for the lifetime of the surrounding `Scope`. */
|
|
49
|
+
export const open = (
|
|
50
|
+
request: CommonSessionRequest,
|
|
51
|
+
): Effect.Effect<RealtimeSessionHandle, AiError.AiError, RealtimeSession | Scope.Scope> =>
|
|
52
|
+
Effect.flatMap(RealtimeSession, (s) => s.open(request))
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Push one video frame. Requires `RealtimeVideoInput`, so a provider without
|
|
56
|
+
* video fails at `Effect.provide` rather than at runtime.
|
|
57
|
+
*/
|
|
58
|
+
export const sendVideoFrame = (
|
|
59
|
+
handle: RealtimeSessionHandle,
|
|
60
|
+
frame: ImageSource,
|
|
61
|
+
): Effect.Effect<void, AiError.AiError, RealtimeVideoInput> =>
|
|
62
|
+
Effect.flatMap(RealtimeVideoInput, () => handle.send(RealtimeInput.VideoFrame({ frame })))
|
|
63
|
+
|
|
64
|
+
const isAudioDelta = RealtimeEvent.$is("AudioDelta")
|
|
65
|
+
const isToolCall = RealtimeEvent.$is("ToolCall")
|
|
66
|
+
|
|
67
|
+
/** Just the audio, for wiring straight to playback. */
|
|
68
|
+
export const audioDeltas = <E, R>(
|
|
69
|
+
events: Stream.Stream<RealtimeEvent, E, R>,
|
|
70
|
+
): Stream.Stream<Extract<RealtimeEvent, { readonly _tag: "AudioDelta" }>, E, R> =>
|
|
71
|
+
Stream.filter(events, isAudioDelta)
|
|
72
|
+
|
|
73
|
+
/** Just the tool calls. Answer each with a `ToolResult`; the adapter resumes. */
|
|
74
|
+
export const toolCalls = <E, R>(
|
|
75
|
+
events: Stream.Stream<RealtimeEvent, E, R>,
|
|
76
|
+
): Stream.Stream<Extract<RealtimeEvent, { readonly _tag: "ToolCall" }>, E, R> =>
|
|
77
|
+
Stream.filter(events, isToolCall)
|