supafone-labs 0.4.1 → 0.4.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.
package/README.md CHANGED
@@ -269,6 +269,37 @@ await supafone.nudges(); // structured whisper feed
269
269
  await supafone.metrics(7); // injection rate, latency, by-dimension
270
270
  ```
271
271
 
272
+ ### Automatic post-call analysis (`postCallAnalysis: true`)
273
+
274
+ Turn on `postCallAnalysis` and every `reportCall()` that carries a transcript
275
+ (or structured `messages`) is automatically classified against the agent's
276
+ objective before the report is filed — generating labels: achieved/missed,
277
+ per-criterion verdicts, failure reasons, and the blended objective value.
278
+
279
+ ```ts
280
+ const supafone = new Supafone({
281
+ apiKey: process.env.SUPAFONE_LABS_API_KEY!,
282
+ postCallAnalysis: true,
283
+ });
284
+
285
+ const { analysis } = await supafone.reportCall({
286
+ session_id: "call-1",
287
+ agent: "intake",
288
+ transcript: "agent: Hi, how can I help?\ncaller: I want to book...\nagent: Booked for 3pm.",
289
+ ground_truth: { booking_requested: true, booking_verified: true },
290
+ });
291
+ // analysis.achieved -> true
292
+ // analysis.criteria -> { intent_satisfied: true, actions_verified: true, … }
293
+ // analysis.failure_reasons -> []
294
+ // analysis.objective_value -> 0.93 (LLM score blended with ground truth)
295
+ ```
296
+
297
+ The enriched report is filed server-side (feeding `optimizer.improve()` and
298
+ `/v1/optimizer/objective/stats`); billed one oracle call per analyzed call.
299
+ Reports without a transcript — or any analysis failure — fall back to the
300
+ plain zero-billed report. You can also classify explicitly with
301
+ `supafone.classifyCall({ transcript, agent })`.
302
+
272
303
  ## Agent builder & QA (session-scoped — call `login()` first)
273
304
 
274
305
  The builder and `qa.run` are account features, so authenticate with a console
@@ -289,11 +320,25 @@ await supafone.builder.saveConfig({ agent_prompt: "…", agent_label: "intake" }
289
320
  const qa = await supafone.qa.run({ turns: 2 });
290
321
  console.log(`+${Math.round(qa.summary.avg_lift * 100)} avg lift`);
291
322
 
323
+ // One-call auto suite: scenarios generated from the agent's OWN objective,
324
+ // each played as a mock call vs the real config, judged twice — pass/fail on
325
+ // the scenario's assertion AND an SSR grade (poorly/ok/good/great/perfectly).
326
+ const suite = await supafone.qa.suite({ count: 4, turns: 2 });
327
+ console.log(suite.summary.ssr_histogram); // { poorly: 0, ok: 1, good: 2, great: 1, perfectly: 0 }
328
+ console.log(suite.summary.avg_ssr_score); // 0.57
329
+
330
+ // Or just generate scenarios from any prompt (key-scoped, no login needed).
331
+ const { scenarios } = await supafone.qa.generate({ agentPrompt: "You are…", count: 5 });
332
+
292
333
  // Improve the standing directive from graded calls (OPRO-style).
293
334
  const better = await supafone.optimizer.improve("builder");
294
335
  const reports = await supafone.optimizer.reports("builder");
295
336
  ```
296
337
 
338
+ How this compares to Hamming, Coval, Roark, Cekura, and the rest of the 2026
339
+ voice-QA field — and the roadmap it drives — lives in the docs:
340
+ [Testing Voice Agents (QA)](../gitbook/voice-qa-landscape.md).
341
+
297
342
  All errors throw `SupafoneLabsError` (with `.status` and `.body`); catch it to
298
343
  inspect gateway responses.
299
344
 
@@ -355,8 +400,9 @@ and `const { Supafone } = require("supafone-labs")` both work, with full types.
355
400
  | `stt(audio, opts?)` | `POST /v1/stt` |
356
401
  | `liveTranscribe(opts?)` | `WS /v1/stt/live` |
357
402
  | `balance()` · `models()` · `voices()` · `usage()` | reads |
403
+ | `reportCall(report)` · `classifyCall(input)` | `/v1/events/call_report` · `/v1/calls/classify` (auto with `postCallAnalysis: true`) |
358
404
  | `builder.chat/finish/config` | `/v1/builder/*` |
359
- | `qa.run/history` | `/v1/qa/*` |
405
+ | `qa.run/generate/suite/history` | `/v1/qa/*` |
360
406
  | `optimizer.improve/standing` | `/v1/optimizer/*` |
361
407
 
362
408
  Get a key (5 free minutes, no card): <https://labs.supafone.ai/get-key.html>
@@ -43,6 +43,15 @@ export interface SupafoneLabsOptions {
43
43
  accountPassword?: string;
44
44
  /** Portal base for listen/monitor links (default https://app.supafone.ai). */
45
45
  appUrl?: string;
46
+ /**
47
+ * Automatic post-call analysis. When true, reportCall() with a transcript
48
+ * (or structured messages) first classifies the finished call against the
49
+ * agent's objective — generating labels (achieved/missed, per-criterion
50
+ * verdicts, failure reasons) — and files the enriched report server-side.
51
+ * Billed one oracle call per analyzed call; reports without a transcript
52
+ * fall back to the plain zero-billed report.
53
+ */
54
+ postCallAnalysis?: boolean;
46
55
  }
47
56
  export interface ChatMessage {
48
57
  role: "system" | "user" | "assistant";
@@ -766,6 +775,59 @@ export interface CallReportInput {
766
775
  nudges?: number;
767
776
  turns?: number;
768
777
  language?: string;
778
+ /** Full "role: text" transcript — enables automatic post-call analysis. */
779
+ transcript?: string;
780
+ /** Structured turns (alternative to transcript) for post-call analysis. */
781
+ messages?: ClassifyMessage[];
782
+ /** Deterministic runtime signals blended into the objective value. */
783
+ ground_truth?: GroundTruthInput;
784
+ [extra: string]: unknown;
785
+ }
786
+ /** One turn of a finished call handed to the classifier. */
787
+ export interface ClassifyMessage {
788
+ role: "caller" | "agent" | "whisper" | string;
789
+ text: string;
790
+ }
791
+ /** Deterministic runtime ground truth (the same signals postcall scoring reads). */
792
+ export interface GroundTruthInput {
793
+ booking_requested?: boolean;
794
+ booking_verified?: boolean;
795
+ delivery_requested?: boolean;
796
+ delivery_verified?: boolean;
797
+ end_call_claims_verified?: boolean;
798
+ unverified_claims?: string[];
799
+ }
800
+ export interface ClassifyCallInput {
801
+ sessionId?: string;
802
+ agent?: string;
803
+ transcript?: string;
804
+ messages?: ClassifyMessage[];
805
+ groundTruth?: GroundTruthInput;
806
+ nudges?: number;
807
+ }
808
+ /** The labels post-call analysis generates for one finished call. */
809
+ export interface CallClassification {
810
+ /** Did the call achieve the agent's objective? */
811
+ achieved: boolean;
812
+ objective_achieved: boolean;
813
+ /** LLM objective score in [0,1] (before ground-truth blending). */
814
+ objective_score: number;
815
+ /** Blended objective value: (1-w)*LLM + w*ground-truth when supplied. */
816
+ objective_value: number;
817
+ ground_truth_score: number | null;
818
+ /** Per-criterion verdicts keyed by the objective's criterion names. */
819
+ criteria: Record<string, boolean | string>;
820
+ failure_reasons: string[];
821
+ summary: string;
822
+ /** Standing-directive version in force during the call (for A/B trends). */
823
+ directive_version: number;
824
+ agent: string;
825
+ [extra: string]: unknown;
826
+ }
827
+ export interface CallReportResult {
828
+ recorded: boolean;
829
+ /** Present when automatic post-call analysis ran — the generated labels. */
830
+ analysis?: CallClassification;
769
831
  [extra: string]: unknown;
770
832
  }
771
833
  export interface BuilderTurn {
@@ -781,6 +843,47 @@ export interface BuilderChatResult {
781
843
  oracle_ms?: number;
782
844
  standing_version?: number;
783
845
  }
846
+ /** One auto-generated adversarial scenario (from the agent's own prompt). */
847
+ export interface QAScenario {
848
+ title: string;
849
+ persona: string;
850
+ opener: string;
851
+ assertion: string;
852
+ }
853
+ /** The 5-level SSR nominal scale. */
854
+ export type SSRLabel = "poorly" | "ok" | "good" | "great" | "perfectly";
855
+ /** An SSR grade: nominal label -> deterministic score + bucket distribution. */
856
+ export interface SSRGrade {
857
+ label: SSRLabel;
858
+ score: number;
859
+ /** Probability mass over 10 score buckets [0-0.1, ..., 0.9-1.0]. */
860
+ distribution: number[];
861
+ rationale: string;
862
+ }
863
+ /** Result of qa.suite(): generated scenarios played vs the real agent config. */
864
+ export interface QASuiteResult {
865
+ agent: string;
866
+ objective: string;
867
+ supervised: boolean;
868
+ turns: number;
869
+ results: Array<{
870
+ scenario: string;
871
+ title: string;
872
+ persona: string;
873
+ assertion: string;
874
+ passed: boolean;
875
+ evidence: string;
876
+ ssr: SSRGrade;
877
+ transcript: BuilderTurn[];
878
+ }>;
879
+ summary: {
880
+ tests: number;
881
+ passed: number;
882
+ avg_ssr_score: number;
883
+ ssr_histogram: Record<SSRLabel, number>;
884
+ oracle_calls_billed: number;
885
+ };
886
+ }
784
887
  export interface QAResult {
785
888
  agent: string;
786
889
  turns: number;
@@ -822,6 +925,7 @@ export declare class SupafoneLabs {
822
925
  private readonly apiKey;
823
926
  private readonly supafoneApiKey;
824
927
  private readonly timeoutMs;
928
+ private readonly postCallAnalysis;
825
929
  private sessionToken?;
826
930
  private accountToken?;
827
931
  private accountSessionToken?;
@@ -856,6 +960,8 @@ export declare class SupafoneLabs {
856
960
  * re-login; an explicit accountToken is the caller's to refresh.
857
961
  */
858
962
  requestAccountApi<T>(method: string, path: string, body?: unknown): Promise<T>;
963
+ /** @internal Multipart file POST with the same auth/re-login as requestAccountApi. */
964
+ requestAccountUpload<T>(path: string, file: Uint8Array | ArrayBuffer | Blob, filename: string): Promise<T>;
859
965
  /** @internal Raw product-API request with an explicit bearer ("" = none). */
860
966
  private accountHttp;
861
967
  /**
@@ -912,8 +1018,25 @@ export declare class SupafoneLabs {
912
1018
  reportNudge(event: NudgeEvent): Promise<{
913
1019
  ok?: boolean;
914
1020
  }>;
915
- /** File a post-call report — the fuel optimizer.improve() learns from. */
916
- reportCall(report: CallReportInput): Promise<Record<string, unknown>>;
1021
+ /**
1022
+ * File a post-call report — the fuel optimizer.improve() learns from.
1023
+ *
1024
+ * With `postCallAnalysis: true` on the client and a transcript (or
1025
+ * messages) present, the call is automatically classified first: the
1026
+ * oracle labels it against the agent's objective (achieved/missed,
1027
+ * per-criterion verdicts, failure reasons) and files the enriched report
1028
+ * server-side. The generated labels come back on `analysis`. Analysis is
1029
+ * best-effort — on any failure the plain zero-billed report still lands.
1030
+ */
1031
+ reportCall(report: CallReportInput): Promise<CallReportResult>;
1032
+ /**
1033
+ * Post-call analysis for one finished call: classify it against the agent's
1034
+ * objective and get labels back — achieved/missed, per-criterion verdicts,
1035
+ * failure reasons, and the blended objective value. Files an enriched call
1036
+ * report server-side (feeding optimizer.improve() and objective stats).
1037
+ * Billed one oracle call.
1038
+ */
1039
+ classifyCall(input: ClassifyCallInput): Promise<CallClassification>;
917
1040
  /** Available oracle model ids (live vendor catalog). */
918
1041
  models(): Promise<string[]>;
919
1042
  /** Available TTS voice ids. */
@@ -1080,6 +1203,29 @@ declare class CampaignsNamespace {
1080
1203
  }): Promise<{
1081
1204
  link: Record<string, unknown>;
1082
1205
  }>;
1206
+ /**
1207
+ * Upload the PDF this campaign sends for e-signature. Pass raw bytes
1208
+ * (Uint8Array/ArrayBuffer/Blob — in Node: fs.readFileSync(path)). The server
1209
+ * auto-detects signature/date/initials lines and returns their placements —
1210
+ * apply them with setSignatureFields.
1211
+ */
1212
+ uploadSigningDocument(campaignId: string, file: Uint8Array | ArrayBuffer | Blob, filename?: string): Promise<{
1213
+ campaign: CampaignSummary;
1214
+ asset: Record<string, unknown>;
1215
+ detected_fields: Record<string, unknown>[];
1216
+ }>;
1217
+ detectSignatureFields(campaignId: string): Promise<{
1218
+ fields: Record<string, unknown>[];
1219
+ detected: boolean;
1220
+ }>;
1221
+ /**
1222
+ * Place the signing fields: [{key, type: "signature"|"date"|"initials"|"text",
1223
+ * label, required, placement: {page, x, y, width, height}}] in PDF points
1224
+ * (origin bottom-left, 612x792 page). Merges onto the stored doc config.
1225
+ */
1226
+ setSignatureFields(campaignId: string, fields: Record<string, unknown>[]): Promise<{
1227
+ campaign: CampaignSummary;
1228
+ }>;
1083
1229
  }
1084
1230
  declare class LabsNamespace {
1085
1231
  private sm;
@@ -1234,6 +1380,29 @@ declare class QANamespace {
1234
1380
  scenarios?: string[];
1235
1381
  turns?: number;
1236
1382
  }): Promise<QAResult>;
1383
+ /**
1384
+ * Auto-generate adversarial test scenarios from the agent's own prompt
1385
+ * (key-scoped). Each scenario carries a persona, an opener, and the one
1386
+ * assertion the agent must (or must not) satisfy.
1387
+ */
1388
+ generate(opts: {
1389
+ agentPrompt: string;
1390
+ count?: number;
1391
+ }): Promise<{
1392
+ scenarios: QAScenario[];
1393
+ }>;
1394
+ /**
1395
+ * Build + run a bespoke adversarial suite in one call: scenarios are
1396
+ * generated from the agent's own objective, each is played as a mock call
1397
+ * against the REAL configured agent, and every call is judged twice —
1398
+ * pass/fail on the scenario's assertion AND an SSR grade (poorly/ok/good/
1399
+ * great/perfectly) against the objective. Session-scoped — login() first.
1400
+ */
1401
+ suite(opts?: {
1402
+ count?: number;
1403
+ turns?: number;
1404
+ supervised?: boolean;
1405
+ }): Promise<QASuiteResult>;
1237
1406
  /** Past QA runs (works with the API key). */
1238
1407
  history(agent?: string, limit?: number): Promise<unknown>;
1239
1408
  }
package/dist/cjs/index.js CHANGED
@@ -47,6 +47,7 @@ class SupafoneLabs {
47
47
  apiKey;
48
48
  supafoneApiKey;
49
49
  timeoutMs;
50
+ postCallAnalysis;
50
51
  sessionToken;
51
52
  accountToken;
52
53
  accountSessionToken;
@@ -68,6 +69,7 @@ class SupafoneLabs {
68
69
  this.supafoneApiBaseUrl = (opts.supafoneApiBaseUrl ?? DEFAULT_SUPAFONE_API_BASE).replace(/\/$/, "");
69
70
  this.appUrl = (opts.appUrl ?? "https://app.supafone.ai").replace(/\/$/, "");
70
71
  this.timeoutMs = opts.timeoutMs ?? 30_000;
72
+ this.postCallAnalysis = opts.postCallAnalysis ?? false;
71
73
  this.sessionToken = opts.sessionToken;
72
74
  this.accountToken = opts.accountToken;
73
75
  this.accountEmail = opts.accountEmail;
@@ -176,6 +178,38 @@ class SupafoneLabs {
176
178
  throw err;
177
179
  }
178
180
  }
181
+ /** @internal Multipart file POST with the same auth/re-login as requestAccountApi. */
182
+ async requestAccountUpload(path, file, filename) {
183
+ const token = this.accountToken || this.accountSessionToken || (await this.accountLogin());
184
+ const send = async (bearer) => {
185
+ const blob = file instanceof Blob ? file : new Blob([file], { type: "application/pdf" });
186
+ const form = new FormData();
187
+ form.append("file", blob, filename);
188
+ const res = await fetch(this.supafoneApiBaseUrl + path, {
189
+ method: "POST",
190
+ headers: { Authorization: `Bearer ${bearer}` },
191
+ body: form,
192
+ });
193
+ const text = await res.text();
194
+ const parsed = text ? safeJson(text) : {};
195
+ if (!res.ok) {
196
+ const detail = parsed?.detail ?? text ?? `HTTP ${res.status}`;
197
+ throw new SupafoneLabsError(`POST ${path}: ${detail}`, res.status, parsed);
198
+ }
199
+ return parsed;
200
+ };
201
+ try {
202
+ return await send(token);
203
+ }
204
+ catch (err) {
205
+ const expired = err instanceof SupafoneLabsError && err.status === 401;
206
+ if (expired && !this.accountToken && this.accountEmail && this.accountPassword) {
207
+ this.accountSessionToken = undefined;
208
+ return send(await this.accountLogin());
209
+ }
210
+ throw err;
211
+ }
212
+ }
179
213
  /** @internal Raw product-API request with an explicit bearer ("" = none). */
180
214
  async accountHttp(method, path, body, token) {
181
215
  const ctrl = new AbortController();
@@ -369,9 +403,55 @@ class SupafoneLabs {
369
403
  reportNudge(event) {
370
404
  return this.request("POST", "/v1/events/nudge", event);
371
405
  }
372
- /** File a post-call report — the fuel optimizer.improve() learns from. */
373
- reportCall(report) {
374
- return this.request("POST", "/v1/events/call_report", report);
406
+ /**
407
+ * File a post-call report — the fuel optimizer.improve() learns from.
408
+ *
409
+ * With `postCallAnalysis: true` on the client and a transcript (or
410
+ * messages) present, the call is automatically classified first: the
411
+ * oracle labels it against the agent's objective (achieved/missed,
412
+ * per-criterion verdicts, failure reasons) and files the enriched report
413
+ * server-side. The generated labels come back on `analysis`. Analysis is
414
+ * best-effort — on any failure the plain zero-billed report still lands.
415
+ */
416
+ async reportCall(report) {
417
+ const { transcript, messages, ground_truth, ...plain } = report;
418
+ const analyzable = !!transcript || !!(messages && messages.length);
419
+ if (this.postCallAnalysis && analyzable) {
420
+ try {
421
+ const analysis = await this.classifyCall({
422
+ sessionId: report.session_id,
423
+ agent: report.agent,
424
+ transcript,
425
+ messages,
426
+ groundTruth: ground_truth,
427
+ nudges: report.nudges,
428
+ });
429
+ // classifyCall files the enriched report server-side — don't double-file.
430
+ return { recorded: true, analysis };
431
+ }
432
+ catch {
433
+ /* analysis is best-effort — fall through to the plain report */
434
+ }
435
+ }
436
+ const out = await this.request("POST", "/v1/events/call_report", plain);
437
+ return { recorded: true, ...out };
438
+ }
439
+ /**
440
+ * Post-call analysis for one finished call: classify it against the agent's
441
+ * objective and get labels back — achieved/missed, per-criterion verdicts,
442
+ * failure reasons, and the blended objective value. Files an enriched call
443
+ * report server-side (feeding optimizer.improve() and objective stats).
444
+ * Billed one oracle call.
445
+ */
446
+ classifyCall(input) {
447
+ return this.request("POST", "/v1/calls/classify", compact({
448
+ session_id: input.sessionId,
449
+ agent: input.agent ?? "builder",
450
+ transcript: input.transcript,
451
+ messages: input.messages,
452
+ ground_truth: input.groundTruth,
453
+ nudges: input.nudges,
454
+ }));
375
455
  }
376
456
  /** Available oracle model ids (live vendor catalog). */
377
457
  async models() {
@@ -559,6 +639,37 @@ class CampaignsNamespace {
559
639
  createSignLink(campaignId, recipientId, opts = {}) {
560
640
  return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/recipients/${encodeURIComponent(recipientId)}/sign-link`, compact({ title: opts.title, message: opts.message }));
561
641
  }
642
+ /**
643
+ * Upload the PDF this campaign sends for e-signature. Pass raw bytes
644
+ * (Uint8Array/ArrayBuffer/Blob — in Node: fs.readFileSync(path)). The server
645
+ * auto-detects signature/date/initials lines and returns their placements —
646
+ * apply them with setSignatureFields.
647
+ */
648
+ uploadSigningDocument(campaignId, file, filename = "document.pdf") {
649
+ return this.sm.requestAccountUpload(`/api/v1/campaigns/${encodeURIComponent(campaignId)}/signing/document`, file, filename);
650
+ }
651
+ detectSignatureFields(campaignId) {
652
+ return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/signing/detect-fields`, {});
653
+ }
654
+ /**
655
+ * Place the signing fields: [{key, type: "signature"|"date"|"initials"|"text",
656
+ * label, required, placement: {page, x, y, width, height}}] in PDF points
657
+ * (origin bottom-left, 612x792 page). Merges onto the stored doc config.
658
+ */
659
+ async setSignatureFields(campaignId, fields) {
660
+ if (!Array.isArray(fields) || !fields.length) {
661
+ throw new SupafoneLabsError("fields must be a non-empty array of placed fields");
662
+ }
663
+ const { campaign } = await this.get(campaignId);
664
+ const settings = { ...(campaign.settings ?? {}) };
665
+ const native = { ...(settings.native_signing ?? {}) };
666
+ if (!native.pdfUrl && !native.storedName) {
667
+ throw new SupafoneLabsError("Upload the signing PDF first (uploadSigningDocument)");
668
+ }
669
+ native.enabled = true;
670
+ native.fields = fields;
671
+ return this.update(campaignId, { settings: { ...settings, native_signing: native } });
672
+ }
562
673
  }
563
674
  class LabsNamespace {
564
675
  sm;
@@ -926,6 +1037,31 @@ class QANamespace {
926
1037
  run(opts = {}) {
927
1038
  return this.sm.request("POST", "/v1/qa/run", { scenarios: opts.scenarios ?? [], turns: opts.turns ?? 2 }, true);
928
1039
  }
1040
+ /**
1041
+ * Auto-generate adversarial test scenarios from the agent's own prompt
1042
+ * (key-scoped). Each scenario carries a persona, an opener, and the one
1043
+ * assertion the agent must (or must not) satisfy.
1044
+ */
1045
+ generate(opts) {
1046
+ return this.sm.request("POST", "/v1/qa/generate", {
1047
+ agent_prompt: opts.agentPrompt,
1048
+ count: opts.count ?? 5,
1049
+ });
1050
+ }
1051
+ /**
1052
+ * Build + run a bespoke adversarial suite in one call: scenarios are
1053
+ * generated from the agent's own objective, each is played as a mock call
1054
+ * against the REAL configured agent, and every call is judged twice —
1055
+ * pass/fail on the scenario's assertion AND an SSR grade (poorly/ok/good/
1056
+ * great/perfectly) against the objective. Session-scoped — login() first.
1057
+ */
1058
+ suite(opts = {}) {
1059
+ return this.sm.request("POST", "/v1/qa/suite", {
1060
+ count: opts.count ?? 4,
1061
+ turns: opts.turns ?? 2,
1062
+ supervised: opts.supervised ?? false,
1063
+ }, true);
1064
+ }
929
1065
  /** Past QA runs (works with the API key). */
930
1066
  history(agent = "builder", limit = 40) {
931
1067
  return this.sm.request("GET", `/v1/qa/runs?agent=${encodeURIComponent(agent)}&limit=${limit}`);