supafone-labs 0.4.1 → 0.4.3

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
  /**
@@ -870,6 +976,22 @@ export declare class SupafoneLabs {
870
976
  listVoiceAgents(): Promise<{
871
977
  agents: Record<string, unknown>[];
872
978
  }>;
979
+ /**
980
+ * Scan a website for its branding: business name, brand colors, logo,
981
+ * favicon, Open Graph metadata, page images, and key same-domain pages.
982
+ */
983
+ scanBrand(url: string): Promise<BrandScanResult>;
984
+ /**
985
+ * Generate a guided intake form (IntakeConfig) from a plain-language
986
+ * description. Pass agentId to ground it in that agent's business, and
987
+ * apply:true to write it onto the agent.
988
+ */
989
+ generateIntakeForm(opts: {
990
+ description: string;
991
+ agentId?: string;
992
+ industry?: string;
993
+ apply?: boolean;
994
+ }): Promise<GenerateIntakeResult>;
873
995
  /** Raw oracle completion — full control over messages and model. */
874
996
  oracle(req: OracleRequest): Promise<OracleResult>;
875
997
  /**
@@ -912,8 +1034,25 @@ export declare class SupafoneLabs {
912
1034
  reportNudge(event: NudgeEvent): Promise<{
913
1035
  ok?: boolean;
914
1036
  }>;
915
- /** File a post-call report — the fuel optimizer.improve() learns from. */
916
- reportCall(report: CallReportInput): Promise<Record<string, unknown>>;
1037
+ /**
1038
+ * File a post-call report — the fuel optimizer.improve() learns from.
1039
+ *
1040
+ * With `postCallAnalysis: true` on the client and a transcript (or
1041
+ * messages) present, the call is automatically classified first: the
1042
+ * oracle labels it against the agent's objective (achieved/missed,
1043
+ * per-criterion verdicts, failure reasons) and files the enriched report
1044
+ * server-side. The generated labels come back on `analysis`. Analysis is
1045
+ * best-effort — on any failure the plain zero-billed report still lands.
1046
+ */
1047
+ reportCall(report: CallReportInput): Promise<CallReportResult>;
1048
+ /**
1049
+ * Post-call analysis for one finished call: classify it against the agent's
1050
+ * objective and get labels back — achieved/missed, per-criterion verdicts,
1051
+ * failure reasons, and the blended objective value. Files an enriched call
1052
+ * report server-side (feeding optimizer.improve() and objective stats).
1053
+ * Billed one oracle call.
1054
+ */
1055
+ classifyCall(input: ClassifyCallInput): Promise<CallClassification>;
917
1056
  /** Available oracle model ids (live vendor catalog). */
918
1057
  models(): Promise<string[]>;
919
1058
  /** Available TTS voice ids. */
@@ -998,6 +1137,48 @@ export interface CampaignUpdateInput {
998
1137
  }[];
999
1138
  settings?: Record<string, unknown>;
1000
1139
  }
1140
+ export interface BrandScanResult {
1141
+ url: string;
1142
+ business_name: string;
1143
+ colors: string[];
1144
+ primary_color: string;
1145
+ theme_color: string;
1146
+ css_brand_colors: string[];
1147
+ logo_url: string;
1148
+ favicon_url: string;
1149
+ og: {
1150
+ title: string;
1151
+ description: string;
1152
+ image: string;
1153
+ };
1154
+ images: string[];
1155
+ key_urls: string[];
1156
+ scrape_source: string;
1157
+ fallback_used: boolean;
1158
+ error?: string | null;
1159
+ }
1160
+ export interface GenerateIntakeResult {
1161
+ intake: Record<string, unknown>;
1162
+ /** false means the deterministic industry-workflow fallback was used. */
1163
+ generated: boolean;
1164
+ applied: boolean;
1165
+ agent?: Record<string, unknown>;
1166
+ }
1167
+ export interface CampaignConfigReport {
1168
+ valid: boolean;
1169
+ errors: string[];
1170
+ warnings: string[];
1171
+ summary: Record<string, unknown>;
1172
+ }
1173
+ export interface CampaignConfigApplyResult {
1174
+ campaign: CampaignSummary;
1175
+ created: boolean;
1176
+ added: number;
1177
+ launched: boolean;
1178
+ /** Branding scan / intake generation results for the doc's agent-facing blocks. */
1179
+ extras?: Record<string, unknown> | null;
1180
+ report: CampaignConfigReport;
1181
+ }
1001
1182
  /**
1002
1183
  * Typical flow:
1003
1184
  * ```ts
@@ -1080,6 +1261,64 @@ declare class CampaignsNamespace {
1080
1261
  }): Promise<{
1081
1262
  link: Record<string, unknown>;
1082
1263
  }>;
1264
+ /**
1265
+ * Upload the PDF this campaign sends for e-signature. Pass raw bytes
1266
+ * (Uint8Array/ArrayBuffer/Blob — in Node: fs.readFileSync(path)). The server
1267
+ * auto-detects signature/date/initials lines and returns their placements —
1268
+ * apply them with setSignatureFields.
1269
+ */
1270
+ uploadSigningDocument(campaignId: string, file: Uint8Array | ArrayBuffer | Blob, filename?: string): Promise<{
1271
+ campaign: CampaignSummary;
1272
+ asset: Record<string, unknown>;
1273
+ detected_fields: Record<string, unknown>[];
1274
+ }>;
1275
+ detectSignatureFields(campaignId: string): Promise<{
1276
+ fields: Record<string, unknown>[];
1277
+ detected: boolean;
1278
+ }>;
1279
+ /**
1280
+ * Place the signing fields: [{key, type: "signature"|"date"|"initials"|"text",
1281
+ * label, required, placement: {page, x, y, width, height}}] in PDF points
1282
+ * (origin bottom-left, 612x792 page). Merges onto the stored doc config.
1283
+ */
1284
+ setSignatureFields(campaignId: string, fields: Record<string, unknown>[]): Promise<{
1285
+ campaign: CampaignSummary;
1286
+ }>;
1287
+ /** Pure dry-run: {valid, errors[], warnings[], summary} — no side effects. */
1288
+ validateConfig(config: string, opts?: {
1289
+ accountId?: string;
1290
+ launch?: boolean;
1291
+ }): Promise<CampaignConfigReport>;
1292
+ /**
1293
+ * Upsert a campaign from a campaign-as-code YAML/JSON document (by slug).
1294
+ * The doc's branding:/intake_form: blocks restyle the campaign's agent and
1295
+ * generate its intake form on apply. launch:true starts REAL calls/emails.
1296
+ */
1297
+ applyConfig(config: string, opts?: {
1298
+ accountId?: string;
1299
+ launch?: boolean;
1300
+ }): Promise<CampaignConfigApplyResult>;
1301
+ /** The campaign as its canonical YAML document — round-trips through applyConfig. */
1302
+ exportConfig(campaignId: string): Promise<{
1303
+ config: string;
1304
+ format: string;
1305
+ slug: string;
1306
+ }>;
1307
+ /**
1308
+ * Draft a campaign-as-code YAML document from a plain-language description
1309
+ * (+ optional CSV of leads). No side effects — review, then applyConfig.
1310
+ */
1311
+ generateConfig(opts: {
1312
+ prompt: string;
1313
+ csv?: string;
1314
+ agentId?: string;
1315
+ accountId?: string;
1316
+ }): Promise<{
1317
+ config: string;
1318
+ format: string;
1319
+ recipients_parsed: number;
1320
+ generated: boolean;
1321
+ }>;
1083
1322
  }
1084
1323
  declare class LabsNamespace {
1085
1324
  private sm;
@@ -1234,6 +1473,29 @@ declare class QANamespace {
1234
1473
  scenarios?: string[];
1235
1474
  turns?: number;
1236
1475
  }): Promise<QAResult>;
1476
+ /**
1477
+ * Auto-generate adversarial test scenarios from the agent's own prompt
1478
+ * (key-scoped). Each scenario carries a persona, an opener, and the one
1479
+ * assertion the agent must (or must not) satisfy.
1480
+ */
1481
+ generate(opts: {
1482
+ agentPrompt: string;
1483
+ count?: number;
1484
+ }): Promise<{
1485
+ scenarios: QAScenario[];
1486
+ }>;
1487
+ /**
1488
+ * Build + run a bespoke adversarial suite in one call: scenarios are
1489
+ * generated from the agent's own objective, each is played as a mock call
1490
+ * against the REAL configured agent, and every call is judged twice —
1491
+ * pass/fail on the scenario's assertion AND an SSR grade (poorly/ok/good/
1492
+ * great/perfectly) against the objective. Session-scoped — login() first.
1493
+ */
1494
+ suite(opts?: {
1495
+ count?: number;
1496
+ turns?: number;
1497
+ supervised?: boolean;
1498
+ }): Promise<QASuiteResult>;
1237
1499
  /** Past QA runs (works with the API key). */
1238
1500
  history(agent?: string, limit?: number): Promise<unknown>;
1239
1501
  }
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();
@@ -220,6 +254,36 @@ class SupafoneLabs {
220
254
  async listVoiceAgents() {
221
255
  return this.requestAccountApi("GET", "/api/v1/agents");
222
256
  }
257
+ /**
258
+ * Scan a website for its branding: business name, brand colors, logo,
259
+ * favicon, Open Graph metadata, page images, and key same-domain pages.
260
+ */
261
+ async scanBrand(url) {
262
+ if (!url?.trim())
263
+ throw new SupafoneLabsError("url is required (the website to scan)");
264
+ return this.requestAccountApi("POST", "/api/v1/agents/brand-scan", { url: url.trim() });
265
+ }
266
+ /**
267
+ * Generate a guided intake form (IntakeConfig) from a plain-language
268
+ * description. Pass agentId to ground it in that agent's business, and
269
+ * apply:true to write it onto the agent.
270
+ */
271
+ async generateIntakeForm(opts) {
272
+ if (!opts?.description?.trim()) {
273
+ throw new SupafoneLabsError("description is required — what should the form collect?");
274
+ }
275
+ if (opts.apply && !opts.agentId) {
276
+ throw new SupafoneLabsError("apply:true needs an agentId (see listVoiceAgents())");
277
+ }
278
+ const payload = { description: opts.description.trim() };
279
+ if (opts.industry)
280
+ payload.industry = opts.industry;
281
+ if (opts.agentId) {
282
+ payload.apply = Boolean(opts.apply);
283
+ return this.requestAccountApi("POST", `/api/v1/agents/${encodeURIComponent(opts.agentId)}/generate-intake`, payload);
284
+ }
285
+ return this.requestAccountApi("POST", "/api/v1/agents/generate-intake", payload);
286
+ }
223
287
  /** Raw oracle completion — full control over messages and model. */
224
288
  async oracle(req) {
225
289
  return this.request("POST", "/v1/oracle/complete", {
@@ -369,9 +433,55 @@ class SupafoneLabs {
369
433
  reportNudge(event) {
370
434
  return this.request("POST", "/v1/events/nudge", event);
371
435
  }
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);
436
+ /**
437
+ * File a post-call report — the fuel optimizer.improve() learns from.
438
+ *
439
+ * With `postCallAnalysis: true` on the client and a transcript (or
440
+ * messages) present, the call is automatically classified first: the
441
+ * oracle labels it against the agent's objective (achieved/missed,
442
+ * per-criterion verdicts, failure reasons) and files the enriched report
443
+ * server-side. The generated labels come back on `analysis`. Analysis is
444
+ * best-effort — on any failure the plain zero-billed report still lands.
445
+ */
446
+ async reportCall(report) {
447
+ const { transcript, messages, ground_truth, ...plain } = report;
448
+ const analyzable = !!transcript || !!(messages && messages.length);
449
+ if (this.postCallAnalysis && analyzable) {
450
+ try {
451
+ const analysis = await this.classifyCall({
452
+ sessionId: report.session_id,
453
+ agent: report.agent,
454
+ transcript,
455
+ messages,
456
+ groundTruth: ground_truth,
457
+ nudges: report.nudges,
458
+ });
459
+ // classifyCall files the enriched report server-side — don't double-file.
460
+ return { recorded: true, analysis };
461
+ }
462
+ catch {
463
+ /* analysis is best-effort — fall through to the plain report */
464
+ }
465
+ }
466
+ const out = await this.request("POST", "/v1/events/call_report", plain);
467
+ return { recorded: true, ...out };
468
+ }
469
+ /**
470
+ * Post-call analysis for one finished call: classify it against the agent's
471
+ * objective and get labels back — achieved/missed, per-criterion verdicts,
472
+ * failure reasons, and the blended objective value. Files an enriched call
473
+ * report server-side (feeding optimizer.improve() and objective stats).
474
+ * Billed one oracle call.
475
+ */
476
+ classifyCall(input) {
477
+ return this.request("POST", "/v1/calls/classify", compact({
478
+ session_id: input.sessionId,
479
+ agent: input.agent ?? "builder",
480
+ transcript: input.transcript,
481
+ messages: input.messages,
482
+ ground_truth: input.groundTruth,
483
+ nudges: input.nudges,
484
+ }));
375
485
  }
376
486
  /** Available oracle model ids (live vendor catalog). */
377
487
  async models() {
@@ -559,6 +669,73 @@ class CampaignsNamespace {
559
669
  createSignLink(campaignId, recipientId, opts = {}) {
560
670
  return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/recipients/${encodeURIComponent(recipientId)}/sign-link`, compact({ title: opts.title, message: opts.message }));
561
671
  }
672
+ /**
673
+ * Upload the PDF this campaign sends for e-signature. Pass raw bytes
674
+ * (Uint8Array/ArrayBuffer/Blob — in Node: fs.readFileSync(path)). The server
675
+ * auto-detects signature/date/initials lines and returns their placements —
676
+ * apply them with setSignatureFields.
677
+ */
678
+ uploadSigningDocument(campaignId, file, filename = "document.pdf") {
679
+ return this.sm.requestAccountUpload(`/api/v1/campaigns/${encodeURIComponent(campaignId)}/signing/document`, file, filename);
680
+ }
681
+ detectSignatureFields(campaignId) {
682
+ return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/signing/detect-fields`, {});
683
+ }
684
+ /**
685
+ * Place the signing fields: [{key, type: "signature"|"date"|"initials"|"text",
686
+ * label, required, placement: {page, x, y, width, height}}] in PDF points
687
+ * (origin bottom-left, 612x792 page). Merges onto the stored doc config.
688
+ */
689
+ async setSignatureFields(campaignId, fields) {
690
+ if (!Array.isArray(fields) || !fields.length) {
691
+ throw new SupafoneLabsError("fields must be a non-empty array of placed fields");
692
+ }
693
+ const { campaign } = await this.get(campaignId);
694
+ const settings = { ...(campaign.settings ?? {}) };
695
+ const native = { ...(settings.native_signing ?? {}) };
696
+ if (!native.pdfUrl && !native.storedName) {
697
+ throw new SupafoneLabsError("Upload the signing PDF first (uploadSigningDocument)");
698
+ }
699
+ native.enabled = true;
700
+ native.fields = fields;
701
+ return this.update(campaignId, { settings: { ...settings, native_signing: native } });
702
+ }
703
+ // -- campaign-as-code (one YAML/JSON document per campaign) ----------------
704
+ /** Pure dry-run: {valid, errors[], warnings[], summary} — no side effects. */
705
+ validateConfig(config, opts = {}) {
706
+ return this.sm.requestAccountApi("POST", "/api/v1/campaigns/config/validate", {
707
+ config,
708
+ ...compact({ account_id: opts.accountId, launch: opts.launch }),
709
+ });
710
+ }
711
+ /**
712
+ * Upsert a campaign from a campaign-as-code YAML/JSON document (by slug).
713
+ * The doc's branding:/intake_form: blocks restyle the campaign's agent and
714
+ * generate its intake form on apply. launch:true starts REAL calls/emails.
715
+ */
716
+ applyConfig(config, opts = {}) {
717
+ return this.sm.requestAccountApi("POST", "/api/v1/campaigns/config/apply", {
718
+ config,
719
+ ...compact({ account_id: opts.accountId, launch: opts.launch }),
720
+ });
721
+ }
722
+ /** The campaign as its canonical YAML document — round-trips through applyConfig. */
723
+ exportConfig(campaignId) {
724
+ return this.sm.requestAccountApi("GET", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/config`);
725
+ }
726
+ /**
727
+ * Draft a campaign-as-code YAML document from a plain-language description
728
+ * (+ optional CSV of leads). No side effects — review, then applyConfig.
729
+ */
730
+ generateConfig(opts) {
731
+ if (!opts?.prompt?.trim()) {
732
+ throw new SupafoneLabsError("prompt is required — describe the campaign to draft");
733
+ }
734
+ return this.sm.requestAccountApi("POST", "/api/v1/campaigns/config/generate", {
735
+ prompt: opts.prompt.trim(),
736
+ ...compact({ csv: opts.csv, agent_id: opts.agentId, account_id: opts.accountId }),
737
+ });
738
+ }
562
739
  }
563
740
  class LabsNamespace {
564
741
  sm;
@@ -926,6 +1103,31 @@ class QANamespace {
926
1103
  run(opts = {}) {
927
1104
  return this.sm.request("POST", "/v1/qa/run", { scenarios: opts.scenarios ?? [], turns: opts.turns ?? 2 }, true);
928
1105
  }
1106
+ /**
1107
+ * Auto-generate adversarial test scenarios from the agent's own prompt
1108
+ * (key-scoped). Each scenario carries a persona, an opener, and the one
1109
+ * assertion the agent must (or must not) satisfy.
1110
+ */
1111
+ generate(opts) {
1112
+ return this.sm.request("POST", "/v1/qa/generate", {
1113
+ agent_prompt: opts.agentPrompt,
1114
+ count: opts.count ?? 5,
1115
+ });
1116
+ }
1117
+ /**
1118
+ * Build + run a bespoke adversarial suite in one call: scenarios are
1119
+ * generated from the agent's own objective, each is played as a mock call
1120
+ * against the REAL configured agent, and every call is judged twice —
1121
+ * pass/fail on the scenario's assertion AND an SSR grade (poorly/ok/good/
1122
+ * great/perfectly) against the objective. Session-scoped — login() first.
1123
+ */
1124
+ suite(opts = {}) {
1125
+ return this.sm.request("POST", "/v1/qa/suite", {
1126
+ count: opts.count ?? 4,
1127
+ turns: opts.turns ?? 2,
1128
+ supervised: opts.supervised ?? false,
1129
+ }, true);
1130
+ }
929
1131
  /** Past QA runs (works with the API key). */
930
1132
  history(agent = "builder", limit = 40) {
931
1133
  return this.sm.request("GET", `/v1/qa/runs?agent=${encodeURIComponent(agent)}&limit=${limit}`);