@llm4ts/flow 2.5.0 → 2.7.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/dist/Classified.d.ts +2 -0
- package/dist/Classified.d.ts.map +1 -1
- package/dist/Classified.js +4 -0
- package/dist/Classified.js.map +1 -1
- package/dist/CostReport.d.ts +110 -0
- package/dist/CostReport.d.ts.map +1 -0
- package/dist/CostReport.js +313 -0
- package/dist/CostReport.js.map +1 -0
- package/dist/EstimatedUsage.d.ts.map +1 -1
- package/dist/EstimatedUsage.js +33 -1
- package/dist/EstimatedUsage.js.map +1 -1
- package/dist/Flow.d.ts +14 -0
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +45 -2
- package/dist/Flow.js.map +1 -1
- package/dist/FlowContext.d.ts +7 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowEvents.d.ts +44 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +35 -0
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GitTool.d.ts +7 -0
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +15 -1
- package/dist/GitTool.js.map +1 -1
- package/dist/Judgment.d.ts +88 -0
- package/dist/Judgment.d.ts.map +1 -0
- package/dist/Judgment.js +281 -0
- package/dist/Judgment.js.map +1 -0
- package/dist/JudgmentDataset.d.ts +79 -0
- package/dist/JudgmentDataset.d.ts.map +1 -0
- package/dist/JudgmentDataset.js +164 -0
- package/dist/JudgmentDataset.js.map +1 -0
- package/dist/JudgmentEval.d.ts +143 -0
- package/dist/JudgmentEval.d.ts.map +1 -0
- package/dist/JudgmentEval.js +242 -0
- package/dist/JudgmentEval.js.map +1 -0
- package/dist/JudgmentLog.d.ts +46 -0
- package/dist/JudgmentLog.d.ts.map +1 -0
- package/dist/JudgmentLog.js +62 -0
- package/dist/JudgmentLog.js.map +1 -0
- package/dist/JudgmentTypes.d.ts +13 -0
- package/dist/JudgmentTypes.d.ts.map +1 -0
- package/dist/JudgmentTypes.js +11 -0
- package/dist/JudgmentTypes.js.map +1 -0
- package/dist/ProgramJudge.d.ts +15 -2
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/ProgramJudge.js +58 -3
- package/dist/ProgramJudge.js.map +1 -1
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Replay.js +9 -4
- package/dist/Replay.js.map +1 -1
- package/dist/Review.d.ts +35 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +92 -13
- package/dist/Review.js.map +1 -1
- package/dist/Reviewer.d.ts +8 -0
- package/dist/Reviewer.d.ts.map +1 -1
- package/dist/Reviewer.js +12 -1
- package/dist/Reviewer.js.map +1 -1
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +1 -0
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +8 -3
- package/src/Classified.ts +5 -0
- package/src/CostReport.ts +402 -0
- package/src/EstimatedUsage.ts +81 -10
- package/src/Flow.ts +77 -2
- package/src/FlowContext.ts +7 -0
- package/src/FlowEvents.ts +42 -0
- package/src/GitTool.ts +19 -1
- package/src/Judgment.ts +418 -0
- package/src/JudgmentDataset.ts +254 -0
- package/src/JudgmentEval.ts +306 -0
- package/src/JudgmentLog.ts +96 -0
- package/src/JudgmentTypes.ts +15 -0
- package/src/ProgramJudge.ts +89 -4
- package/src/Replay.ts +23 -17
- package/src/Review.ts +163 -13
- package/src/Reviewer.ts +15 -1
- package/src/TransientRetry.ts +1 -0
package/src/FlowContext.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as Context from "effect/Context"
|
|
2
2
|
import type * as Effect from "effect/Effect"
|
|
3
3
|
import type * as Scope from "effect/Scope"
|
|
4
|
+
import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
|
|
4
5
|
import type { ConnectorCapabilities } from "@llm4ts/core/Models"
|
|
5
6
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
6
7
|
import type { FlowError } from "./FlowError.ts"
|
|
@@ -19,6 +20,12 @@ export interface FlowContextShape {
|
|
|
19
20
|
readonly userPrompt: string
|
|
20
21
|
readonly workDir: string
|
|
21
22
|
readonly workspace: string
|
|
23
|
+
/**
|
|
24
|
+
* Typed judgments (ADR 0017), over the run's `judgment` seat or the hosted
|
|
25
|
+
* TypeSafe backend. Absent on a context built by hand; `judgmentOf` in
|
|
26
|
+
* `Judgment.ts` then derives one from the reasoning seat.
|
|
27
|
+
*/
|
|
28
|
+
readonly judgment?: JudgmentShape
|
|
22
29
|
/**
|
|
23
30
|
* The same seats rebound to another directory (a story worktree, ADR
|
|
24
31
|
* 0013): every CLI seat launched there, git rooted there, the run's
|
package/src/FlowEvents.ts
CHANGED
|
@@ -7,6 +7,33 @@ import * as Schema from "effect/Schema"
|
|
|
7
7
|
import type * as Scope from "effect/Scope"
|
|
8
8
|
import * as Stream from "effect/Stream"
|
|
9
9
|
import { TokenUsage } from "@llm4ts/core/Models"
|
|
10
|
+
import { Answer, AnswerOrigin, Question, State } from "@llm4ts/core/judgment/Schemas"
|
|
11
|
+
import { Decision, JudgmentMode } from "./JudgmentTypes.ts"
|
|
12
|
+
|
|
13
|
+
export const JudgmentOutcome = Schema.Union([
|
|
14
|
+
Schema.TaggedStruct("ReviewPrescreen", {
|
|
15
|
+
lens: Schema.String,
|
|
16
|
+
issues: Schema.Struct({ Critical: Schema.Int, Warning: Schema.Int, Info: Schema.Int })
|
|
17
|
+
}),
|
|
18
|
+
Schema.TaggedStruct("SatisfiedProbe", { literalMatch: Schema.Boolean }),
|
|
19
|
+
Schema.TaggedStruct("ProgramJudge", { score: Schema.Number })
|
|
20
|
+
])
|
|
21
|
+
export type JudgmentOutcome = typeof JudgmentOutcome.Type
|
|
22
|
+
|
|
23
|
+
export class JudgmentObserved extends Schema.TaggedClass<JudgmentObserved>()("JudgmentObserved", {
|
|
24
|
+
consumer: Schema.Literals(["review-prescreen", "satisfied-probe", "program-judge"]),
|
|
25
|
+
key: Schema.String,
|
|
26
|
+
state: State,
|
|
27
|
+
question: Question,
|
|
28
|
+
answer: Answer,
|
|
29
|
+
judgmentIdentity: Schema.String,
|
|
30
|
+
decision: Decision,
|
|
31
|
+
certainty: Schema.Number,
|
|
32
|
+
support: Schema.Number,
|
|
33
|
+
origin: AnswerOrigin,
|
|
34
|
+
outcome: JudgmentOutcome,
|
|
35
|
+
mode: JudgmentMode
|
|
36
|
+
}) {}
|
|
10
37
|
|
|
11
38
|
export class StageStarted extends Schema.TaggedClass<StageStarted>()("StageStarted", {
|
|
12
39
|
stage: Schema.String
|
|
@@ -72,6 +99,7 @@ export class Declassified extends Schema.TaggedClass<Declassified>()("Declassifi
|
|
|
72
99
|
}) {}
|
|
73
100
|
|
|
74
101
|
export const FlowEvent = Schema.Union([
|
|
102
|
+
JudgmentObserved,
|
|
75
103
|
StageStarted,
|
|
76
104
|
StageCompleted,
|
|
77
105
|
StageFailed,
|
|
@@ -101,6 +129,20 @@ export interface FlowEventsShape {
|
|
|
101
129
|
readonly publish: (event: FlowEvent) => Effect.Effect<void>
|
|
102
130
|
}
|
|
103
131
|
|
|
132
|
+
/** Publish an observation, rendering the same decision and outcome in advise mode. */
|
|
133
|
+
export const publishJudgmentObserved = Effect.fn("@llm4ts/flow/FlowEvents.publishJudgmentObserved")(
|
|
134
|
+
function* (events: FlowEventsShape, observation: JudgmentObserved): Effect.fn.Return<void> {
|
|
135
|
+
yield* events.publish(observation)
|
|
136
|
+
if (observation.mode === "advise") {
|
|
137
|
+
yield* events.publish(
|
|
138
|
+
Info.make({
|
|
139
|
+
message: `judgment ${observation.consumer} '${observation.key}': ${observation.decision}; outcome ${JSON.stringify(observation.outcome)}`
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
104
146
|
export class FlowEvents extends Context.Service<FlowEvents, FlowEventsShape>()(
|
|
105
147
|
"@llm4ts/flow/FlowEvents"
|
|
106
148
|
) {}
|
package/src/GitTool.ts
CHANGED
|
@@ -90,6 +90,17 @@ const problem = (result: ProcessResult): string => {
|
|
|
90
90
|
return detail.length === 0 ? `process exited with code ${result.exitCode}` : detail
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* The runner's own bookkeeping under `workDir/.llm4ts/`: the run trace and
|
|
95
|
+
* the cost ledger. `commitAll` never stages them — they grow while the run
|
|
96
|
+
* commits and belong to the machine, not the repository — while the rest of
|
|
97
|
+
* `.llm4ts/` (plans, forked packs) is committed as before.
|
|
98
|
+
*/
|
|
99
|
+
export const runnerBookkeeping: ReadonlyArray<string> = [
|
|
100
|
+
".llm4ts/trace-*.jsonl",
|
|
101
|
+
".llm4ts/costs.jsonl"
|
|
102
|
+
]
|
|
103
|
+
|
|
93
104
|
export const makeGitTool = (
|
|
94
105
|
process: ProcessExecutorShape,
|
|
95
106
|
workDir: string,
|
|
@@ -200,7 +211,14 @@ export const makeGitTool = (
|
|
|
200
211
|
const checkout = (name: string): Effect.Effect<void, FlowError> =>
|
|
201
212
|
write("git checkout", runOrFail(["checkout", name]).pipe(Effect.asVoid))
|
|
202
213
|
|
|
203
|
-
|
|
214
|
+
// Stage everything, then take the runner's bookkeeping back out of the
|
|
215
|
+
// index: `reset -- <glob>` leaves an ignored or unmatched glob alone and
|
|
216
|
+
// keeps a previously committed trace at its HEAD version, whereas an
|
|
217
|
+
// `:(exclude)` pathspec makes `add` refuse the ignored files outright.
|
|
218
|
+
const addAll = runOrFail(["add", "-A"]).pipe(
|
|
219
|
+
Effect.andThen(runOrFail(["reset", "-q", "--", ...runnerBookkeeping])),
|
|
220
|
+
Effect.asVoid
|
|
221
|
+
)
|
|
204
222
|
|
|
205
223
|
const commitStaged = (message: string): Effect.Effect<CommitResult, FlowError> =>
|
|
206
224
|
Effect.gen(function* () {
|
package/src/Judgment.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { ProviderError } from "@llm4ts/core/Errors"
|
|
4
|
+
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
5
|
+
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
6
|
+
import type {
|
|
7
|
+
JudgmentBackendError,
|
|
8
|
+
JudgmentInput,
|
|
9
|
+
JudgmentShape
|
|
10
|
+
} from "@llm4ts/core/judgment/Judgment"
|
|
11
|
+
import { makeLlmJudgment } from "@llm4ts/core/judgment/LlmJudgment"
|
|
12
|
+
import {
|
|
13
|
+
ChoiceAnswer,
|
|
14
|
+
JudgmentResult,
|
|
15
|
+
ScoreAnswer,
|
|
16
|
+
TruthAnswer,
|
|
17
|
+
confidenceOf,
|
|
18
|
+
expectedScore,
|
|
19
|
+
origins,
|
|
20
|
+
renderState,
|
|
21
|
+
type Answer,
|
|
22
|
+
type AnswerOrigin,
|
|
23
|
+
type JudgmentBackend,
|
|
24
|
+
type Question
|
|
25
|
+
} from "@llm4ts/core/judgment/Schemas"
|
|
26
|
+
import { ParseError } from "@llm4ts/core/Errors"
|
|
27
|
+
import type { FlowContextShape } from "./FlowContext.ts"
|
|
28
|
+
import { FlowLlmError, PersistenceError, type FlowError } from "./FlowError.ts"
|
|
29
|
+
import { Info, type FlowEventsShape } from "./FlowEvents.ts"
|
|
30
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
31
|
+
import { type Decision } from "./JudgmentTypes.ts"
|
|
32
|
+
|
|
33
|
+
export { Decision, JudgmentMode } from "./JudgmentTypes.ts"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Judgment policy (ADR 0017): core answers questions and reports their
|
|
37
|
+
* origin; this module decides what a flow does with the number. Thresholds
|
|
38
|
+
* are keyed first by calibration evidence (`measured`, then `claimed`) and
|
|
39
|
+
* otherwise by extraction method, because a model that writes its own
|
|
40
|
+
* probabilities (`verbalized`) is held to a higher bar than one whose
|
|
41
|
+
* log-probabilities were read (`logprobs`), and a reasoning model's typed
|
|
42
|
+
* reply (`reasoning`) sits between. An answer whose `support` (mass the
|
|
43
|
+
* backend actually placed on the offered options) is below `minSupport` is
|
|
44
|
+
* held whatever its confidence. Jev's advice, kept: questions and
|
|
45
|
+
* thresholds in one place.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export class CertaintyBands extends Schema.Class<CertaintyBands>("CertaintyBands")({
|
|
49
|
+
/** At or above: act automatically. */
|
|
50
|
+
act: Schema.Number,
|
|
51
|
+
/** Below: do not act; escalate or hold. Between the two: proceed with caution. */
|
|
52
|
+
hold: Schema.Number
|
|
53
|
+
}) {}
|
|
54
|
+
|
|
55
|
+
const thresholds = (act: number, hold: number) =>
|
|
56
|
+
CertaintyBands.pipe(
|
|
57
|
+
Schema.withConstructorDefault(Effect.succeed(CertaintyBands.make({ act, hold })))
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
export class JudgmentPolicy extends Schema.Class<JudgmentPolicy>("JudgmentPolicy")({
|
|
61
|
+
/** Calibration evidence produced in this project: trusted most. */
|
|
62
|
+
measured: thresholds(0.8, 0.5),
|
|
63
|
+
/** The provider claims calibration (TypeSafe). */
|
|
64
|
+
claimed: thresholds(0.85, 0.5),
|
|
65
|
+
/** No evidence; keyed by how the numbers were extracted. */
|
|
66
|
+
logprobs: thresholds(0.9, 0.6),
|
|
67
|
+
sampled: thresholds(0.9, 0.6),
|
|
68
|
+
reasoning: thresholds(0.9, 0.6),
|
|
69
|
+
verbalized: thresholds(0.95, 0.7),
|
|
70
|
+
/** Below this share of mass on the offered options, hold regardless. */
|
|
71
|
+
minSupport: Schema.Number.pipe(Schema.withConstructorDefault(Effect.succeed(0.5)))
|
|
72
|
+
}) {
|
|
73
|
+
thresholds(origin: AnswerOrigin): CertaintyBands {
|
|
74
|
+
switch (origin.calibration) {
|
|
75
|
+
case "measured":
|
|
76
|
+
return this.measured
|
|
77
|
+
case "claimed":
|
|
78
|
+
return this.claimed
|
|
79
|
+
case "none":
|
|
80
|
+
return origin.method === "hosted" ? this.claimed : this[origin.method]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const defaultJudgmentPolicy = JudgmentPolicy.make({})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How sure an answer is, whatever its kind. A Choice or Score carries its
|
|
89
|
+
* confidence (the peak probability). A Truth's certainty is its distance
|
|
90
|
+
* from even, scaled so 0.5 is 0 and either extreme is 1: `|2·truth − 1|`.
|
|
91
|
+
*/
|
|
92
|
+
export const certaintyOf = (answer: Answer): number =>
|
|
93
|
+
answer.type === "truth" ? Math.abs(2 * answer.truth - 1) : answer.confidence
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The three bands Jev documents: act, proceed with caution, or hold. An
|
|
97
|
+
* answer rebuilt from too little mass on the offered options is always held.
|
|
98
|
+
*/
|
|
99
|
+
export const decide = (
|
|
100
|
+
answer: Answer,
|
|
101
|
+
policy: JudgmentPolicy = defaultJudgmentPolicy
|
|
102
|
+
): Decision => {
|
|
103
|
+
if (answer.support < policy.minSupport) {
|
|
104
|
+
return "hold"
|
|
105
|
+
}
|
|
106
|
+
const certainty = certaintyOf(answer)
|
|
107
|
+
const bands = policy.thresholds(answer.origin)
|
|
108
|
+
return certainty >= bands.act ? "act" : certainty < bands.hold ? "hold" : "caution"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The context's judgment service, or one derived from its reasoning seat. */
|
|
112
|
+
export const judgmentOf = (context: FlowContextShape): JudgmentShape =>
|
|
113
|
+
context.judgment ?? makeLlmJudgment(context.reasoning)
|
|
114
|
+
|
|
115
|
+
const backendFailure = (error: JudgmentBackendError): FlowError =>
|
|
116
|
+
FlowLlmError.from(ProviderError.make({ message: error.message, cause: error }))
|
|
117
|
+
|
|
118
|
+
// Escalation asks a reasoning model the same question in its own words and
|
|
119
|
+
// decodes a typed reply, so the schema per kind mirrors the answer shape.
|
|
120
|
+
class EscalatedChoice extends Schema.Class<EscalatedChoice>("EscalatedChoice")({
|
|
121
|
+
choice: Schema.String,
|
|
122
|
+
probabilities: Schema.Record(Schema.String, Schema.Number)
|
|
123
|
+
}) {}
|
|
124
|
+
class EscalatedScore extends Schema.Class<EscalatedScore>("EscalatedScore")({
|
|
125
|
+
probabilities: Schema.Record(Schema.String, Schema.Number)
|
|
126
|
+
}) {}
|
|
127
|
+
class EscalatedTruth extends Schema.Class<EscalatedTruth>("EscalatedTruth")({
|
|
128
|
+
truth: Schema.Number
|
|
129
|
+
}) {}
|
|
130
|
+
|
|
131
|
+
const numberMap = (keys: ReadonlyArray<string>): JsonSchema => ({
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: Object.fromEntries(keys.map((key) => [key, { type: "number" }])),
|
|
134
|
+
required: [...keys],
|
|
135
|
+
additionalProperties: false
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const escalationPrompt = (state: JudgmentInput["state"], question: Question): string => {
|
|
139
|
+
const header = `State:\n${renderState(state)}\n\nThink carefully, then answer with JSON only.\n`
|
|
140
|
+
switch (question.type) {
|
|
141
|
+
case "choice":
|
|
142
|
+
return (
|
|
143
|
+
`${header}Question: ${question.instructions}\nOptions:\n` +
|
|
144
|
+
Object.entries(question.criteria)
|
|
145
|
+
.map(([key, description]) => `- ${key}: ${JSON.stringify(description)}`)
|
|
146
|
+
.join("\n") +
|
|
147
|
+
'\nReply: {"choice": <option key>, "probabilities": {<a probability per option, summing to 1>}}'
|
|
148
|
+
)
|
|
149
|
+
case "score":
|
|
150
|
+
return (
|
|
151
|
+
`${header}Rate: ${question.instructions}\nLevels:\n` +
|
|
152
|
+
question.criteria.map((level, index) => `- ${index}: ${JSON.stringify(level)}`).join("\n") +
|
|
153
|
+
'\nReply: {"probabilities": {<a probability per level index, summing to 1>}}'
|
|
154
|
+
)
|
|
155
|
+
case "truth":
|
|
156
|
+
return (
|
|
157
|
+
`${header}Statement: ${question.instructions}\n` +
|
|
158
|
+
(question.criteria === undefined
|
|
159
|
+
? ""
|
|
160
|
+
: `True means: ${JSON.stringify(question.criteria.true)}\nFalse means: ${JSON.stringify(question.criteria.false)}\n`) +
|
|
161
|
+
'Reply: {"truth": <probability between 0 and 1 that the statement holds>}'
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Renormalize over the offered keys; fails typed when nothing was placed on them. */
|
|
167
|
+
const normalized = (
|
|
168
|
+
keys: ReadonlyArray<string>,
|
|
169
|
+
raw: Readonly<Record<string, number>>
|
|
170
|
+
): Effect.Effect<Record<string, number>, ParseError> => {
|
|
171
|
+
const kept = keys.map((key) => [key, Math.max(0, raw[key] ?? 0)] as const)
|
|
172
|
+
const total = kept.reduce((sum, [, value]) => sum + value, 0)
|
|
173
|
+
return total > 0
|
|
174
|
+
? Effect.succeed(Object.fromEntries(kept.map(([key, value]) => [key, value / total])))
|
|
175
|
+
: Effect.fail(
|
|
176
|
+
ParseError.make({
|
|
177
|
+
message: "the reasoning seat placed no probability on any offered option",
|
|
178
|
+
raw: JSON.stringify(raw)
|
|
179
|
+
})
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const escalate = (
|
|
184
|
+
reasoning: LlmServiceShape,
|
|
185
|
+
backend: JudgmentBackend,
|
|
186
|
+
state: JudgmentInput["state"],
|
|
187
|
+
question: Question
|
|
188
|
+
): Effect.Effect<Answer, FlowError> => {
|
|
189
|
+
const prompt = escalationPrompt(state, question)
|
|
190
|
+
const origin = origins.escalated(backend)
|
|
191
|
+
switch (question.type) {
|
|
192
|
+
case "choice": {
|
|
193
|
+
const keys = Object.keys(question.criteria)
|
|
194
|
+
return reasoning
|
|
195
|
+
.executeStructured(prompt, EscalatedChoice, {
|
|
196
|
+
type: "object",
|
|
197
|
+
properties: {
|
|
198
|
+
choice: { type: "string", enum: [...keys] },
|
|
199
|
+
probabilities: numberMap(keys)
|
|
200
|
+
},
|
|
201
|
+
required: ["choice", "probabilities"]
|
|
202
|
+
})
|
|
203
|
+
.pipe(
|
|
204
|
+
Effect.flatMap((reply) =>
|
|
205
|
+
// A choice the model gave no mass contradicts itself: fail rather
|
|
206
|
+
// than invent certainty, and the caller keeps its earlier answer.
|
|
207
|
+
keys.includes(reply.choice) && !((reply.probabilities[reply.choice] ?? 0) > 0)
|
|
208
|
+
? Effect.fail(
|
|
209
|
+
ParseError.make({
|
|
210
|
+
message: `the reasoning seat chose "${reply.choice}" but gave it no probability`,
|
|
211
|
+
raw: JSON.stringify(reply.probabilities)
|
|
212
|
+
})
|
|
213
|
+
)
|
|
214
|
+
: Effect.map(normalized(keys, reply.probabilities), (probabilities) =>
|
|
215
|
+
ChoiceAnswer.make({
|
|
216
|
+
type: "choice",
|
|
217
|
+
choice: keys.includes(reply.choice) ? reply.choice : (keys[0] ?? ""),
|
|
218
|
+
probabilities,
|
|
219
|
+
confidence: confidenceOf(probabilities),
|
|
220
|
+
origin
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
),
|
|
224
|
+
Effect.mapError(FlowLlmError.from)
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
case "score": {
|
|
228
|
+
const keys = question.criteria.map((_, index) => String(index))
|
|
229
|
+
return reasoning
|
|
230
|
+
.executeStructured(prompt, EscalatedScore, {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: { probabilities: numberMap(keys) },
|
|
233
|
+
required: ["probabilities"]
|
|
234
|
+
})
|
|
235
|
+
.pipe(
|
|
236
|
+
Effect.flatMap((reply) =>
|
|
237
|
+
Effect.map(normalized(keys, reply.probabilities), (probabilities) =>
|
|
238
|
+
ScoreAnswer.make({
|
|
239
|
+
type: "score",
|
|
240
|
+
score: expectedScore(probabilities),
|
|
241
|
+
legend: Object.fromEntries(
|
|
242
|
+
question.criteria.map((level, index) => [String(index), level])
|
|
243
|
+
),
|
|
244
|
+
probabilities,
|
|
245
|
+
confidence: confidenceOf(probabilities),
|
|
246
|
+
origin
|
|
247
|
+
})
|
|
248
|
+
)
|
|
249
|
+
),
|
|
250
|
+
Effect.mapError(FlowLlmError.from)
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
case "truth":
|
|
254
|
+
return reasoning
|
|
255
|
+
.executeStructured(prompt, EscalatedTruth, {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: { truth: { type: "number" } },
|
|
258
|
+
required: ["truth"]
|
|
259
|
+
})
|
|
260
|
+
.pipe(
|
|
261
|
+
Effect.map((reply) =>
|
|
262
|
+
TruthAnswer.make({
|
|
263
|
+
type: "truth",
|
|
264
|
+
truth: Math.max(0, Math.min(1, reply.truth)),
|
|
265
|
+
origin
|
|
266
|
+
})
|
|
267
|
+
),
|
|
268
|
+
Effect.mapError(FlowLlmError.from)
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface JudgeOrEscalateOptions {
|
|
274
|
+
readonly judgment: JudgmentShape
|
|
275
|
+
readonly reasoning: LlmServiceShape
|
|
276
|
+
readonly events: FlowEventsShape
|
|
277
|
+
readonly request: JudgmentInput
|
|
278
|
+
readonly policy?: JudgmentPolicy
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Run the judgment; every answer the policy would `hold`, and every question
|
|
283
|
+
* that failed, is asked again of the reasoning seat and replaced with an
|
|
284
|
+
* `escalated` answer. An escalation that fails keeps what it had, so the
|
|
285
|
+
* caller always sees the most informed answer available.
|
|
286
|
+
*/
|
|
287
|
+
export const judgeOrEscalate = Effect.fn("@llm4ts/flow/Judgment.judgeOrEscalate")(function* (
|
|
288
|
+
options: JudgeOrEscalateOptions
|
|
289
|
+
): Effect.fn.Return<JudgmentResult, FlowError> {
|
|
290
|
+
const policy = options.policy ?? defaultJudgmentPolicy
|
|
291
|
+
const result = yield* options.judgment
|
|
292
|
+
.judge(options.request)
|
|
293
|
+
.pipe(Effect.mapError(backendFailure))
|
|
294
|
+
const answers: Record<string, Answer> = { ...result.answers }
|
|
295
|
+
const failures = [...result.failures]
|
|
296
|
+
for (const [key, question] of Object.entries(options.request.questions)) {
|
|
297
|
+
const current = answers[key]
|
|
298
|
+
const reason =
|
|
299
|
+
current === undefined
|
|
300
|
+
? (failures.find((failure) => failure.key === key)?.reason ?? "no answer")
|
|
301
|
+
: decide(current, policy) === "hold"
|
|
302
|
+
? current.support < policy.minSupport
|
|
303
|
+
? `${current.origin.method} support ${current.support.toFixed(2)} below ${policy.minSupport}`
|
|
304
|
+
: `${current.origin.method} certainty ${certaintyOf(current).toFixed(2)} below hold`
|
|
305
|
+
: undefined
|
|
306
|
+
if (reason === undefined) {
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
yield* options.events.publish(
|
|
310
|
+
Info.make({ message: `judgment '${key}' escalated to the reasoning seat: ${reason}` })
|
|
311
|
+
)
|
|
312
|
+
const escalated = yield* escalate(
|
|
313
|
+
options.reasoning,
|
|
314
|
+
result.backend,
|
|
315
|
+
options.request.state,
|
|
316
|
+
question
|
|
317
|
+
).pipe(
|
|
318
|
+
Effect.map((answer): Answer | undefined => answer),
|
|
319
|
+
Effect.catch((error) =>
|
|
320
|
+
options.events
|
|
321
|
+
.publish(
|
|
322
|
+
Info.make({
|
|
323
|
+
message: `judgment '${key}' escalation failed (${error._tag}); keeping the original answer`
|
|
324
|
+
})
|
|
325
|
+
)
|
|
326
|
+
.pipe(Effect.as<Answer | undefined>(undefined))
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
if (escalated !== undefined) {
|
|
330
|
+
answers[key] = escalated
|
|
331
|
+
const index = failures.findIndex((failure) => failure.key === key)
|
|
332
|
+
if (index >= 0) {
|
|
333
|
+
failures.splice(index, 1)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return JudgmentResult.make({
|
|
338
|
+
answers,
|
|
339
|
+
failures,
|
|
340
|
+
backend: result.backend,
|
|
341
|
+
...(result.usage === undefined ? {} : { usage: result.usage }),
|
|
342
|
+
...(result.model === undefined ? {} : { model: result.model })
|
|
343
|
+
})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
class JudgmentCacheEntry extends Schema.Class<JudgmentCacheEntry>("JudgmentCacheEntry")({
|
|
347
|
+
fingerprint: Schema.String,
|
|
348
|
+
result: JudgmentResult
|
|
349
|
+
}) {}
|
|
350
|
+
|
|
351
|
+
const hexDigest = (buffer: ArrayBuffer): string =>
|
|
352
|
+
Array.from(new Uint8Array(buffer))
|
|
353
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
354
|
+
.join("")
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A stable digest of state, questions and the judgment's identity (backend
|
|
358
|
+
* plus checkpoint): the cache key for one request. A retrained or swapped
|
|
359
|
+
* local model therefore never reuses its predecessor's answers.
|
|
360
|
+
*/
|
|
361
|
+
export const judgmentFingerprint = (
|
|
362
|
+
request: JudgmentInput,
|
|
363
|
+
identity: string
|
|
364
|
+
): Effect.Effect<string, PersistenceError> =>
|
|
365
|
+
Effect.tryPromise({
|
|
366
|
+
try: async () =>
|
|
367
|
+
hexDigest(
|
|
368
|
+
await globalThis.crypto.subtle.digest(
|
|
369
|
+
"SHA-256",
|
|
370
|
+
new TextEncoder().encode(
|
|
371
|
+
JSON.stringify({ identity, state: request.state, questions: request.questions })
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
),
|
|
375
|
+
catch: (error) =>
|
|
376
|
+
PersistenceError.make({
|
|
377
|
+
message: `failed to fingerprint judgment: ${error instanceof Error ? error.message : String(error)}`,
|
|
378
|
+
cause: error
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Answers persisted like `cachedReview`: a re-run re-judges only what
|
|
384
|
+
* changed (state, questions, or backend). A cached result carries the
|
|
385
|
+
* origin it was answered with, so policy still applies as before.
|
|
386
|
+
*/
|
|
387
|
+
export const cachedJudgment = Effect.fn("@llm4ts/flow/Judgment.cached")(function* (
|
|
388
|
+
files: PlainFileStoreShape,
|
|
389
|
+
path: string,
|
|
390
|
+
judgment: JudgmentShape,
|
|
391
|
+
request: JudgmentInput
|
|
392
|
+
): Effect.fn.Return<JudgmentResult, FlowError> {
|
|
393
|
+
const fingerprint = yield* judgmentFingerprint(request, judgment.identity)
|
|
394
|
+
const contents = yield* files.read(path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
395
|
+
const entry =
|
|
396
|
+
contents === undefined
|
|
397
|
+
? undefined
|
|
398
|
+
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(JudgmentCacheEntry))(contents).pipe(
|
|
399
|
+
Effect.option,
|
|
400
|
+
Effect.map((option) => (option._tag === "Some" ? option.value : undefined))
|
|
401
|
+
)
|
|
402
|
+
if (entry?.fingerprint === fingerprint) {
|
|
403
|
+
return entry.result
|
|
404
|
+
}
|
|
405
|
+
const result = yield* judgment.judge(request).pipe(Effect.mapError(backendFailure))
|
|
406
|
+
const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(JudgmentCacheEntry))(
|
|
407
|
+
JudgmentCacheEntry.make({ fingerprint, result })
|
|
408
|
+
).pipe(
|
|
409
|
+
Effect.mapError((error) =>
|
|
410
|
+
PersistenceError.make({
|
|
411
|
+
message: `failed to encode judgment cache: ${String(error)}`,
|
|
412
|
+
cause: error
|
|
413
|
+
})
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
yield* files.writeAtomic(path, encoded)
|
|
417
|
+
return result
|
|
418
|
+
})
|