nexus-ai-pro 0.6.0 → 0.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/EXPLANATION.md CHANGED
@@ -269,6 +269,7 @@ Evals:
269
269
  - operational metrics
270
270
  - RAG metrics
271
271
  - safety heuristics
272
+ - optional LLM-as-judge scoring with rubrics and thresholds
272
273
 
273
274
  ## Voice Integrations
274
275
 
@@ -285,6 +286,7 @@ The basic operations are:
285
286
  - `ai.transcribe(...)` for speech-to-text
286
287
  - `ai.speak(...)` for text-to-speech
287
288
  - `ai.voice(...)` for the full voice pipeline
289
+ - `ai.createVoiceSession(...)` for multi-turn call sessions with tools
288
290
  - `registerVoiceProvider(...)` for private, local, or hosted services
289
291
 
290
292
  The full voice turn is:
@@ -299,7 +301,17 @@ audio/transcript
299
301
 
300
302
  Because the completion step calls `ai.complete(...)`, it reuses routing, security, context-window compaction, token optimization, cache, retries, metrics, and tracing.
301
303
 
302
- Realtime sessions are still future work. That should be added as a separate `VoiceSession` abstraction instead of forcing realtime state into normal completion streams.
304
+ `VoiceSession` adds state across caller turns. It accepts transcript or audio, builds prompts from any combination of `prompt`, `instructions`, and conditional `taskPrompts`, executes registered tools when the model requests app data, and can synthesize the final answer.
305
+
306
+ Low-level realtime transport is still future work. That should be added as a bridge from telephony/WebSocket/SIP media events into `VoiceSession` instead of forcing socket state into normal completion streams.
307
+
308
+ Phone-number telephony is a separate optional layer:
309
+
310
+ - `nexus-ai-pro/telephony` provides provider-neutral call, webhook, and media-stream types.
311
+ - `nexus-ai-pro/telephony/twilio` provides a Twilio adapter without adding the Twilio SDK as a required dependency.
312
+ - `NexusAI` exposes helper methods for outbound calls, TwiML webhook responses, webhook validation, and media event normalization.
313
+
314
+ This keeps a small transcription/TTS app small, while larger phone-agent apps can add Twilio or another carrier as needed.
303
315
 
304
316
  ## Choosing a Setup
305
317
 
package/NEXUS.md CHANGED
@@ -83,6 +83,7 @@ Supporting modules:
83
83
  - Chain of Verification
84
84
  - self-consistency sampling
85
85
  - eval runner and metrics
86
+ - optional LLM-as-judge eval scoring
86
87
  - workflow templates
87
88
  - web/search connector tools
88
89
  - provider conformance fixtures
@@ -95,6 +96,8 @@ Voice is implemented as an optional layer. Import size is controlled through sub
95
96
 
96
97
  - `nexus-ai-pro/voice` for provider-neutral types, errors, and `VoiceManager`
97
98
  - `nexus-ai-pro/voice/openai` for the OpenAI voice adapter
99
+ - `nexus-ai-pro/telephony` for provider-neutral phone-call types and `TelephonyManager`
100
+ - `nexus-ai-pro/telephony/twilio` for the Twilio phone-number adapter
98
101
 
99
102
  Current support:
100
103
 
@@ -102,21 +105,30 @@ Current support:
102
105
  - `ai.speak(...)`
103
106
  - `ai.voice(...)`
104
107
  - `ai.voiceTurn(...)`
108
+ - `ai.createVoiceSession(...)`
105
109
  - `registerVoiceProvider(...)`
106
110
  - configurable `voice.providers`
107
111
  - custom voice providers
108
112
  - OpenAI voice provider adapter
109
113
  - full completion pipeline reuse after transcription
114
+ - stateful `VoiceSession` turns with prompts, task prompts, tool calls, history, and optional speech
115
+ - optional telephony providers for calls, webhook responses, webhook validation, and media-stream event parsing
110
116
 
111
117
  Useful test cases:
112
118
 
113
119
  - registered voice provider can transcribe
114
120
  - registered voice provider can synthesize speech
115
121
  - `ai.voice(...)` transcribes, appends transcript, calls `ai.complete(...)`, and optionally speaks
122
+ - `VoiceSession` can accept transcript or audio, select matching task prompts, execute tools, and speak
116
123
  - `nexus-ai-pro/voice` imports without provider adapters
117
124
  - `nexus-ai-pro/voice/openai` imports separately
125
+ - `nexus-ai-pro/voice/session` imports separately
118
126
  - type consumer can configure `VoiceConfig`
119
127
  - missing voice provider errors clearly
128
+ - registered telephony provider can create calls
129
+ - Twilio adapter can create inline-TwiML/media-stream calls
130
+ - Twilio webhook signatures can be validated
131
+ - Twilio media-stream messages can be normalized
120
132
 
121
133
  Example:
122
134
 
@@ -144,9 +156,16 @@ const result = await ai.voice({
144
156
 
145
157
  Still future work:
146
158
 
147
- - realtime `VoiceSession`
148
159
  - audio chunks in `NexusStream`
149
- - WebRTC/WebSocket/SIP session helpers
160
+ - WebRTC/SIP session helpers
161
+ - fully managed Twilio/OpenAI realtime bridge
162
+
163
+ Telephony is intentionally separate from voice:
164
+
165
+ - `voice` handles file/buffer/stream audio turns.
166
+ - `VoiceSession` handles multi-turn call state and app/tool requests during a conversation.
167
+ - `telephony` handles phone numbers, TwiML, provider webhooks, and media-stream protocol messages.
168
+ - apps can use one, both, or neither.
150
169
 
151
170
  ## Context Window Testing
152
171
 
package/README.md CHANGED
@@ -343,6 +343,62 @@ console.log(result.response.content);
343
343
  console.log(result.speech?.audio.data);
344
344
  ```
345
345
 
346
+ ### Voice Session With App Requests
347
+
348
+ Use `VoiceSession` when a call has multiple turns, should keep history, and may need to call your app while talking.
349
+
350
+ The prompt setup is flexible:
351
+
352
+ - `prompt` describes the assistant or business role.
353
+ - `instructions` describe behavior.
354
+ - `taskPrompts` add conditional instructions when the caller asks about something specific.
355
+ - `tools` let the model call your app, such as booking, CRM, billing, or inventory APIs.
356
+
357
+ ```ts
358
+ import { tool } from 'nexus-ai-pro';
359
+
360
+ const session = ai.createVoiceSession({
361
+ model: 'auto',
362
+ prompt: 'You are a phone booking assistant for a small clinic.',
363
+ instructions: [
364
+ 'Keep answers short and natural for a phone call.',
365
+ 'Ask one follow-up question at a time.',
366
+ ],
367
+ taskPrompts: [
368
+ {
369
+ name: 'booking',
370
+ when: ['book', 'appointment', 'free slot'],
371
+ instructions: 'When the caller asks about availability, call check_free_slots before offering times.',
372
+ tools: ['check_free_slots'],
373
+ },
374
+ ],
375
+ tools: [
376
+ tool({
377
+ name: 'check_free_slots',
378
+ description: 'Check available booking slots for a date.',
379
+ parameters: {
380
+ type: 'object',
381
+ properties: { date: { type: 'string' } },
382
+ required: ['date'],
383
+ },
384
+ execute: async ({ date }) => bookingApi.freeSlots(String(date)),
385
+ }),
386
+ ],
387
+ toolSelection: 'task',
388
+ speech: { provider: 'openai', voice: 'alloy', format: 'mp3' },
389
+ });
390
+
391
+ const turn = await session.handleTurn({
392
+ transcript: 'Do you have any free slots tomorrow?',
393
+ });
394
+
395
+ console.log(turn.response.content);
396
+ console.log(turn.toolSteps);
397
+ console.log(turn.speech?.audio.data);
398
+ ```
399
+
400
+ You can use only `prompt`, only `instructions`, only `taskPrompts`, or all of them together. For phone calls through Twilio media streams, parse incoming audio/media events in `telephony`, feed complete caller turns into `session.handleTurn(...)`, then send the returned speech audio back through your call transport.
401
+
346
402
  ### Custom Voice Provider
347
403
 
348
404
  Bring any private, local, or hosted voice service:
@@ -369,7 +425,62 @@ ai.registerVoiceProvider('private-voice', {
369
425
  });
370
426
  ```
371
427
 
372
- Realtime voice sessions and audio chunks inside `NexusStream` are still future work; the current voice layer covers transcription, speech generation, and full request/response voice turns.
428
+ Low-level realtime transport adapters are still future work; the current voice layer covers transcription, speech generation, full request/response voice turns, and stateful voice sessions with tool calls.
429
+
430
+ ## Phone Number Telephony
431
+
432
+ Phone-number support is optional and separate from the voice layer. Use `voice` for audio transcription/TTS, and use `telephony` when calls, webhooks, TwiML, or media-stream events are involved.
433
+
434
+ ```ts
435
+ import { TelephonyManager } from 'nexus-ai-pro/telephony';
436
+ import { TwilioTelephonyProvider } from 'nexus-ai-pro/telephony/twilio';
437
+ ```
438
+
439
+ Configure only the provider you need:
440
+
441
+ ```ts
442
+ const ai = new NexusAI({
443
+ providers: { openai: { apiKey: process.env.OPENAI_API_KEY! } },
444
+ telephony: {
445
+ defaultProvider: 'twilio',
446
+ providers: {
447
+ twilio: new TwilioTelephonyProvider({
448
+ accountSid: process.env.TWILIO_ACCOUNT_SID!,
449
+ authToken: process.env.TWILIO_AUTH_TOKEN!,
450
+ }),
451
+ },
452
+ },
453
+ });
454
+ ```
455
+
456
+ Generate a Twilio webhook response for an inbound phone number:
457
+
458
+ ```ts
459
+ const response = await ai.createTelephonyResponse({
460
+ say: 'Thanks for calling. Connecting you now.',
461
+ stream: {
462
+ url: 'wss://voice.example.com/twilio',
463
+ mode: 'bidirectional',
464
+ parameters: { tenant: 'acme' },
465
+ },
466
+ });
467
+
468
+ return new Response(response.body, {
469
+ headers: { 'content-type': response.contentType },
470
+ });
471
+ ```
472
+
473
+ Start an outbound call with either a webhook, inline TwiML, an application SID, or a media-stream URL:
474
+
475
+ ```ts
476
+ const call = await ai.createCall({
477
+ to: '+15551230000',
478
+ from: '+15557650000',
479
+ mediaStreamUrl: 'wss://voice.example.com/twilio',
480
+ });
481
+ ```
482
+
483
+ For smaller apps, skip Twilio entirely and register your own `TelephonyProvider`.
373
484
 
374
485
  ## Security
375
486
 
@@ -435,9 +546,35 @@ Available runtime helpers:
435
546
  - batch completion with concurrency control
436
547
  - in-memory job queue
437
548
  - Redis and BullMQ queue adapters
438
- - eval runner with quality, operations, RAG, and safety metrics
549
+ - eval runner with quality, operations, RAG, safety metrics, and optional LLM-as-judge scoring
550
+ - voice sessions for multi-turn calls with conditional task prompts and app tools
439
551
  - workflow templates for RAG answers, extraction, classification, comparison, support, sales, legal review, and code review
440
552
 
553
+ Use a plain assertion for small evals, or add an LLM judge when a rubric is more useful than exact matching:
554
+
555
+ ```ts
556
+ import { LLMJudge } from 'nexus-ai-pro/evals';
557
+
558
+ const judge = new LLMJudge({
559
+ client: ai,
560
+ model: 'openai/gpt-4.1-mini',
561
+ rubric: 'Score whether the answer is correct, concise, and grounded in the supplied context.',
562
+ passThreshold: 0.7,
563
+ });
564
+
565
+ const run = await ai.runEvals([
566
+ {
567
+ name: 'support answer quality',
568
+ request: {
569
+ model: 'auto',
570
+ messages: [{ role: 'user', content: 'Explain our refund policy.' }],
571
+ },
572
+ expected: 'Refunds are available within 30 days.',
573
+ judge: judge.asEvalJudge(),
574
+ },
575
+ ]);
576
+ ```
577
+
441
578
  ## Observability and Reliability
442
579
 
443
580
  ```ts
@@ -3,9 +3,11 @@ import type { CompletionRequest } from '../types/messages.js';
3
3
  import type { NexusPlan } from '../types/planning.js';
4
4
  import type { NexusResponse, NexusStream } from '../types/response.js';
5
5
  import type { AgentConfig, AgentResult } from '../types/agent.js';
6
- import type { SpeechRequest, SpeechResponse, TranscriptionRequest, TranscriptionResponse, VoiceProvider, VoiceTurnRequest, VoiceTurnResponse } from '../types/voice.js';
6
+ import type { SpeechRequest, SpeechResponse, TranscriptionRequest, TranscriptionResponse, VoiceProvider, VoiceSessionConfig, VoiceTurnRequest, VoiceTurnResponse } from '../types/voice.js';
7
+ import type { CreateCallRequest, CreateCallResponse, TelephonyMediaStreamEvent, TelephonyOutboundAudioMessage, TelephonyProvider, TelephonyResponseRequest, TelephonyWebhookResponse, TelephonyWebhookValidationRequest } from '../types/telephony.js';
7
8
  import type { PipelineStep } from '../pipeline/types.js';
8
9
  import { BaseProvider } from '../providers/base.js';
10
+ import { VoiceSession } from '../voice/index.js';
9
11
  import { type VerificationOptions } from '../hallucination/verification.js';
10
12
  import { type SelfConsistencyOptions } from '../hallucination/consistency.js';
11
13
  import { type BatchOptions, type BatchItemResult } from '../jobs/batch.js';
@@ -21,6 +23,7 @@ export declare class NexusAI {
21
23
  private security;
22
24
  private contextWindow;
23
25
  private voiceManager;
26
+ private telephonyManager;
24
27
  private optimizer;
25
28
  private cache;
26
29
  private auditLogger;
@@ -40,12 +43,24 @@ export declare class NexusAI {
40
43
  speak(request: SpeechRequest): Promise<SpeechResponse>;
41
44
  voice(request: VoiceTurnRequest): Promise<VoiceTurnResponse>;
42
45
  voiceTurn(request: VoiceTurnRequest): Promise<VoiceTurnResponse>;
46
+ createVoiceSession(config: VoiceSessionConfig): VoiceSession;
47
+ createCall(request: CreateCallRequest): Promise<CreateCallResponse>;
48
+ createTelephonyResponse(request: TelephonyResponseRequest): Promise<TelephonyWebhookResponse>;
49
+ validateTelephonyWebhook(request: TelephonyWebhookValidationRequest): Promise<boolean>;
50
+ parseTelephonyMediaEvent(providerName: string, message: string | Record<string, unknown>): TelephonyMediaStreamEvent | undefined;
51
+ formatTelephonyAudioMessage(providerName: string, streamId: string, payload: string, options?: {
52
+ event?: 'media' | 'mark' | 'clear';
53
+ markName?: string;
54
+ }): TelephonyOutboundAudioMessage;
43
55
  registerProvider(name: string, provider: BaseProvider): this;
44
56
  registerVoiceProvider(name: string, provider: VoiceProvider): this;
57
+ registerTelephonyProvider(name: string, provider: TelephonyProvider): this;
45
58
  hasProvider(name: string): boolean;
46
59
  hasVoiceProvider(name: string): boolean;
60
+ hasTelephonyProvider(name: string): boolean;
47
61
  listProviders(): string[];
48
62
  listVoiceProviders(): string[];
63
+ listTelephonyProviders(): string[];
49
64
  use(step: PipelineStep): this;
50
65
  batchComplete(requests: CompletionRequest[], options?: BatchOptions): Promise<Array<BatchItemResult<NexusResponse>>>;
51
66
  createQueue(options?: QueueOptions): JobQueue<CompletionRequest, NexusResponse>;
@@ -11,6 +11,7 @@ import { Logger } from '../utils/logger.js';
11
11
  import { SecurityPipeline } from '../security/index.js';
12
12
  import { ContextWindowManager } from '../context/index.js';
13
13
  import { VoiceManager } from '../voice/index.js';
14
+ import { TelephonyManager } from '../telephony/index.js';
14
15
  import { TokenOptimizer } from '../optimizer/index.js';
15
16
  import { AgentLoop } from '../agent/loop.js';
16
17
  import { resolveModel } from '../models/registry.js';
@@ -39,6 +40,7 @@ export class NexusAI {
39
40
  security;
40
41
  contextWindow;
41
42
  voiceManager;
43
+ telephonyManager;
42
44
  optimizer;
43
45
  cache;
44
46
  auditLogger;
@@ -57,6 +59,7 @@ export class NexusAI {
57
59
  this.security = new SecurityPipeline(this.config.security || 'standard');
58
60
  this.contextWindow = new ContextWindowManager(this.config.contextWindow || {});
59
61
  this.voiceManager = new VoiceManager(this.config.voice || {});
62
+ this.telephonyManager = new TelephonyManager(this.config.telephony || {});
60
63
  this.optimizer = new TokenOptimizer(this.config.tokenOptimizer || {});
61
64
  this.cache = new MemoryCache(this.config.cache?.maxEntries || 500);
62
65
  this.auditLogger = new AuditLogger(this.config.auditLog);
@@ -320,6 +323,24 @@ export class NexusAI {
320
323
  async voiceTurn(request) {
321
324
  return this.voiceManager.runTurn(request, this);
322
325
  }
326
+ createVoiceSession(config) {
327
+ return this.voiceManager.createSession(config, this);
328
+ }
329
+ async createCall(request) {
330
+ return this.telephonyManager.createCall(request);
331
+ }
332
+ async createTelephonyResponse(request) {
333
+ return this.telephonyManager.createWebhookResponse(request);
334
+ }
335
+ async validateTelephonyWebhook(request) {
336
+ return this.telephonyManager.validateWebhook(request);
337
+ }
338
+ parseTelephonyMediaEvent(providerName, message) {
339
+ return this.telephonyManager.parseMediaStreamEvent(providerName, message);
340
+ }
341
+ formatTelephonyAudioMessage(providerName, streamId, payload, options) {
342
+ return this.telephonyManager.formatAudioMessage(providerName, streamId, payload, options);
343
+ }
323
344
  registerProvider(name, provider) {
324
345
  this.providers.set(name, provider);
325
346
  return this;
@@ -328,18 +349,28 @@ export class NexusAI {
328
349
  this.voiceManager.registerProvider(name, provider);
329
350
  return this;
330
351
  }
352
+ registerTelephonyProvider(name, provider) {
353
+ this.telephonyManager.registerProvider(name, provider);
354
+ return this;
355
+ }
331
356
  hasProvider(name) {
332
357
  return this.providers.has(name);
333
358
  }
334
359
  hasVoiceProvider(name) {
335
360
  return this.voiceManager.hasProvider(name);
336
361
  }
362
+ hasTelephonyProvider(name) {
363
+ return this.telephonyManager.hasProvider(name);
364
+ }
337
365
  listProviders() {
338
366
  return [...this.providers.keys()];
339
367
  }
340
368
  listVoiceProviders() {
341
369
  return this.voiceManager.listProviders();
342
370
  }
371
+ listTelephonyProviders() {
372
+ return this.telephonyManager.listProviders();
373
+ }
343
374
  use(step) {
344
375
  this.pipeline.use(step);
345
376
  return this;
@@ -0,0 +1,3 @@
1
+ export { EvalRunner, type EvalCase, type EvalClient, type EvalJudge, type EvalJudgment, type EvalResult, type EvalRunResult, } from './runner.js';
2
+ export { LLMJudge, createLLMJudgeEval, parseJudgeResponse, type JudgeClient, type LLMJudgeInput, type LLMJudgeInputMapper, type LLMJudgeOptions, type LLMJudgeResult, } from './judge.js';
3
+ export { calculateEvalMetrics, exactMatch, f1Score, semanticSimilarity, passAtK, perplexity, tokensPerSecond, faithfulness, contextualPrecision, contextualRecall, toxicityScore, biasScore, policyAdherence, refusalRate, type EvalMetrics, type MetricInputs, type OperationalMetrics, type QualityMetrics, type RagMetrics, type SafetyMetrics, } from './metrics.js';
@@ -0,0 +1,3 @@
1
+ export { EvalRunner, } from './runner.js';
2
+ export { LLMJudge, createLLMJudgeEval, parseJudgeResponse, } from './judge.js';
3
+ export { calculateEvalMetrics, exactMatch, f1Score, semanticSimilarity, passAtK, perplexity, tokensPerSecond, faithfulness, contextualPrecision, contextualRecall, toxicityScore, biasScore, policyAdherence, refusalRate, } from './metrics.js';
@@ -0,0 +1,50 @@
1
+ import type { CompletionRequest } from '../types/messages.js';
2
+ import type { NexusResponse } from '../types/response.js';
3
+ import type { EvalCase, EvalJudge } from './runner.js';
4
+ export interface JudgeClient<Response = NexusResponse> {
5
+ complete(request: CompletionRequest): Promise<Response>;
6
+ }
7
+ export interface LLMJudgeInput {
8
+ actual: string;
9
+ expected?: string;
10
+ query?: string;
11
+ rubric?: string;
12
+ contexts?: string[];
13
+ metadata?: Record<string, unknown>;
14
+ }
15
+ export interface LLMJudgeOptions<Response = NexusResponse> {
16
+ client: JudgeClient<Response>;
17
+ model: string;
18
+ rubric?: string;
19
+ systemPrompt?: string;
20
+ passThreshold?: number;
21
+ range?: [number, number];
22
+ temperature?: number;
23
+ maxTokens?: number;
24
+ metadata?: Record<string, unknown>;
25
+ }
26
+ export interface LLMJudgeResult {
27
+ score: number;
28
+ passed: boolean;
29
+ rationale?: string;
30
+ labels?: string[];
31
+ raw: unknown;
32
+ providerUsed?: string;
33
+ modelUsed?: string;
34
+ }
35
+ export type LLMJudgeInputMapper<Response> = (response: Response, testCase: EvalCase<Response>) => LLMJudgeInput | Promise<LLMJudgeInput>;
36
+ export declare class LLMJudge<Response = NexusResponse> {
37
+ private options;
38
+ constructor(options: LLMJudgeOptions<Response>);
39
+ evaluate(input: LLMJudgeInput): Promise<LLMJudgeResult>;
40
+ asEvalJudge(mapper?: LLMJudgeInputMapper<Response>): EvalJudge<Response>;
41
+ private messages;
42
+ }
43
+ export declare function createLLMJudgeEval<Response = NexusResponse>(judge: LLMJudge<Response>, mapper?: LLMJudgeInputMapper<Response>): EvalJudge<Response>;
44
+ export declare function parseJudgeResponse(content: string, options?: {
45
+ threshold?: number;
46
+ range?: [number, number];
47
+ raw?: unknown;
48
+ providerUsed?: string;
49
+ modelUsed?: string;
50
+ }): LLMJudgeResult;
@@ -0,0 +1,154 @@
1
+ const DEFAULT_SYSTEM_PROMPT = [
2
+ 'You are a careful evaluation judge.',
3
+ 'Return only JSON with these fields: score, passed, rationale, labels.',
4
+ 'Score must be numeric within the configured range.',
5
+ ].join(' ');
6
+ export class LLMJudge {
7
+ options;
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ async evaluate(input) {
12
+ const range = this.options.range || [0, 1];
13
+ const threshold = this.options.passThreshold ?? midpoint(range);
14
+ const response = await this.options.client.complete({
15
+ model: this.options.model,
16
+ messages: this.messages(input, range, threshold),
17
+ temperature: this.options.temperature ?? 0,
18
+ maxTokens: this.options.maxTokens ?? 512,
19
+ responseFormat: {
20
+ type: 'json_schema',
21
+ schema: {
22
+ type: 'object',
23
+ additionalProperties: false,
24
+ properties: {
25
+ score: { type: 'number' },
26
+ passed: { type: 'boolean' },
27
+ rationale: { type: 'string' },
28
+ labels: { type: 'array', items: { type: 'string' } },
29
+ },
30
+ required: ['score', 'passed'],
31
+ },
32
+ },
33
+ metadata: {
34
+ ...this.options.metadata,
35
+ llmJudge: true,
36
+ },
37
+ });
38
+ return parseJudgeResponse(contentOf(response), {
39
+ threshold,
40
+ range,
41
+ raw: response,
42
+ providerUsed: providerOf(response),
43
+ modelUsed: modelOf(response),
44
+ });
45
+ }
46
+ asEvalJudge(mapper) {
47
+ return async (response, testCase) => {
48
+ const input = mapper
49
+ ? await mapper(response, testCase)
50
+ : {
51
+ actual: contentOf(response),
52
+ expected: testCase.expected,
53
+ rubric: this.options.rubric,
54
+ };
55
+ return this.evaluate(input);
56
+ };
57
+ }
58
+ messages(input, range, threshold) {
59
+ const rubric = input.rubric || this.options.rubric || 'Judge whether the answer satisfies the request.';
60
+ const sections = [
61
+ `Rubric:\n${rubric}`,
62
+ `Score range: ${range[0]} to ${range[1]}`,
63
+ `Pass threshold: ${threshold}`,
64
+ input.query ? `User query:\n${input.query}` : undefined,
65
+ input.expected ? `Reference answer:\n${input.expected}` : undefined,
66
+ input.contexts?.length ? `Context:\n${input.contexts.join('\n\n')}` : undefined,
67
+ `Model answer:\n${input.actual}`,
68
+ ].filter(Boolean);
69
+ return [
70
+ { role: 'system', content: this.options.systemPrompt || DEFAULT_SYSTEM_PROMPT },
71
+ { role: 'user', content: sections.join('\n\n') },
72
+ ];
73
+ }
74
+ }
75
+ export function createLLMJudgeEval(judge, mapper) {
76
+ return judge.asEvalJudge(mapper);
77
+ }
78
+ export function parseJudgeResponse(content, options = {}) {
79
+ const range = options.range || [0, 1];
80
+ const threshold = options.threshold ?? midpoint(range);
81
+ const parsed = parseJsonObject(content);
82
+ const score = clamp(numberField(parsed.score) ?? numberFromText(content) ?? range[0], range);
83
+ const passed = typeof parsed.passed === 'boolean' ? parsed.passed : score >= threshold;
84
+ const labels = Array.isArray(parsed.labels)
85
+ ? parsed.labels.filter((label) => typeof label === 'string')
86
+ : undefined;
87
+ return {
88
+ score,
89
+ passed,
90
+ rationale: typeof parsed.rationale === 'string' ? parsed.rationale : undefined,
91
+ labels,
92
+ raw: options.raw ?? parsed,
93
+ providerUsed: options.providerUsed,
94
+ modelUsed: options.modelUsed,
95
+ };
96
+ }
97
+ function contentOf(response) {
98
+ if (typeof response === 'string')
99
+ return response;
100
+ if (response && typeof response === 'object' && 'content' in response) {
101
+ const content = response.content;
102
+ if (typeof content === 'string')
103
+ return content;
104
+ }
105
+ return JSON.stringify(response);
106
+ }
107
+ function providerOf(response) {
108
+ return response && typeof response === 'object'
109
+ ? response.meta?.providerUsed
110
+ : undefined;
111
+ }
112
+ function modelOf(response) {
113
+ return response && typeof response === 'object'
114
+ ? response.meta?.modelUsed
115
+ : undefined;
116
+ }
117
+ function parseJsonObject(content) {
118
+ const trimmed = content.trim();
119
+ const unfenced = trimmed
120
+ .replace(/^```(?:json)?\s*/i, '')
121
+ .replace(/\s*```$/, '')
122
+ .trim();
123
+ try {
124
+ const parsed = JSON.parse(unfenced);
125
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
126
+ ? parsed
127
+ : {};
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
133
+ function numberField(value) {
134
+ if (typeof value === 'number' && Number.isFinite(value))
135
+ return value;
136
+ if (typeof value === 'string') {
137
+ const parsed = Number(value);
138
+ return Number.isFinite(parsed) ? parsed : undefined;
139
+ }
140
+ return undefined;
141
+ }
142
+ function numberFromText(value) {
143
+ const match = value.match(/-?\d+(?:\.\d+)?/);
144
+ if (!match)
145
+ return undefined;
146
+ const parsed = Number(match[0]);
147
+ return Number.isFinite(parsed) ? parsed : undefined;
148
+ }
149
+ function clamp(value, [min, max]) {
150
+ return Math.min(Math.max(value, min), max);
151
+ }
152
+ function midpoint([min, max]) {
153
+ return min + (max - min) / 2;
154
+ }
@@ -3,10 +3,21 @@ import type { EvalMetrics, MetricInputs } from './metrics.js';
3
3
  export interface EvalClient<Response = unknown> {
4
4
  complete(request: CompletionRequest): Promise<Response>;
5
5
  }
6
+ export interface EvalJudgment {
7
+ score: number;
8
+ passed: boolean;
9
+ rationale?: string;
10
+ labels?: string[];
11
+ raw?: unknown;
12
+ providerUsed?: string;
13
+ modelUsed?: string;
14
+ }
15
+ export type EvalJudge<Response = unknown> = (response: Response, testCase: EvalCase<Response>) => boolean | EvalJudgment | Promise<boolean | EvalJudgment>;
6
16
  export interface EvalCase<Response = unknown> {
7
17
  name: string;
8
18
  request: CompletionRequest;
9
- assert: (response: Response) => boolean | Promise<boolean>;
19
+ assert?: (response: Response) => boolean | Promise<boolean>;
20
+ judge?: EvalJudge<Response>;
10
21
  expected?: string;
11
22
  metrics?: (response: Response) => MetricInputs | Promise<MetricInputs>;
12
23
  tags?: string[];
@@ -17,6 +28,7 @@ export interface EvalResult<Response = unknown> {
17
28
  durationMs: number;
18
29
  response?: Response;
19
30
  metrics?: EvalMetrics;
31
+ judgment?: EvalJudgment;
20
32
  error?: string;
21
33
  tags?: string[];
22
34
  }
@@ -11,7 +11,14 @@ export class EvalRunner {
11
11
  const caseStarted = Date.now();
12
12
  try {
13
13
  const response = await this.client.complete(testCase.request);
14
- const passed = await testCase.assert(response);
14
+ if (!testCase.assert && !testCase.judge) {
15
+ throw new Error(`Eval case "${testCase.name}" requires assert or judge`);
16
+ }
17
+ const assertPassed = testCase.assert ? await testCase.assert(response) : true;
18
+ const judgment = testCase.judge
19
+ ? normalizeJudgment(await testCase.judge(response, testCase))
20
+ : undefined;
21
+ const passed = assertPassed && (judgment?.passed ?? true);
15
22
  const metrics = testCase.metrics
16
23
  ? await calculateEvalMetrics(await testCase.metrics(response))
17
24
  : undefined;
@@ -21,6 +28,7 @@ export class EvalRunner {
21
28
  durationMs: Date.now() - caseStarted,
22
29
  response,
23
30
  metrics,
31
+ judgment,
24
32
  tags: testCase.tags,
25
33
  });
26
34
  }
@@ -45,3 +53,12 @@ export class EvalRunner {
45
53
  };
46
54
  }
47
55
  }
56
+ function normalizeJudgment(value) {
57
+ if (typeof value === 'boolean') {
58
+ return {
59
+ score: value ? 1 : 0,
60
+ passed: value,
61
+ };
62
+ }
63
+ return value;
64
+ }