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/src/index.ts CHANGED
@@ -44,6 +44,15 @@ export interface SupafoneLabsOptions {
44
44
  accountPassword?: string;
45
45
  /** Portal base for listen/monitor links (default https://app.supafone.ai). */
46
46
  appUrl?: string;
47
+ /**
48
+ * Automatic post-call analysis. When true, reportCall() with a transcript
49
+ * (or structured messages) first classifies the finished call against the
50
+ * agent's objective — generating labels (achieved/missed, per-criterion
51
+ * verdicts, failure reasons) — and files the enriched report server-side.
52
+ * Billed one oracle call per analyzed call; reports without a transcript
53
+ * fall back to the plain zero-billed report.
54
+ */
55
+ postCallAnalysis?: boolean;
47
56
  }
48
57
 
49
58
  export interface ChatMessage {
@@ -831,6 +840,64 @@ export interface CallReportInput {
831
840
  nudges?: number;
832
841
  turns?: number;
833
842
  language?: string;
843
+ /** Full "role: text" transcript — enables automatic post-call analysis. */
844
+ transcript?: string;
845
+ /** Structured turns (alternative to transcript) for post-call analysis. */
846
+ messages?: ClassifyMessage[];
847
+ /** Deterministic runtime signals blended into the objective value. */
848
+ ground_truth?: GroundTruthInput;
849
+ [extra: string]: unknown;
850
+ }
851
+
852
+ /** One turn of a finished call handed to the classifier. */
853
+ export interface ClassifyMessage {
854
+ role: "caller" | "agent" | "whisper" | string;
855
+ text: string;
856
+ }
857
+
858
+ /** Deterministic runtime ground truth (the same signals postcall scoring reads). */
859
+ export interface GroundTruthInput {
860
+ booking_requested?: boolean;
861
+ booking_verified?: boolean;
862
+ delivery_requested?: boolean;
863
+ delivery_verified?: boolean;
864
+ end_call_claims_verified?: boolean;
865
+ unverified_claims?: string[];
866
+ }
867
+
868
+ export interface ClassifyCallInput {
869
+ sessionId?: string;
870
+ agent?: string;
871
+ transcript?: string;
872
+ messages?: ClassifyMessage[];
873
+ groundTruth?: GroundTruthInput;
874
+ nudges?: number;
875
+ }
876
+
877
+ /** The labels post-call analysis generates for one finished call. */
878
+ export interface CallClassification {
879
+ /** Did the call achieve the agent's objective? */
880
+ achieved: boolean;
881
+ objective_achieved: boolean;
882
+ /** LLM objective score in [0,1] (before ground-truth blending). */
883
+ objective_score: number;
884
+ /** Blended objective value: (1-w)*LLM + w*ground-truth when supplied. */
885
+ objective_value: number;
886
+ ground_truth_score: number | null;
887
+ /** Per-criterion verdicts keyed by the objective's criterion names. */
888
+ criteria: Record<string, boolean | string>;
889
+ failure_reasons: string[];
890
+ summary: string;
891
+ /** Standing-directive version in force during the call (for A/B trends). */
892
+ directive_version: number;
893
+ agent: string;
894
+ [extra: string]: unknown;
895
+ }
896
+
897
+ export interface CallReportResult {
898
+ recorded: boolean;
899
+ /** Present when automatic post-call analysis ran — the generated labels. */
900
+ analysis?: CallClassification;
834
901
  [extra: string]: unknown;
835
902
  }
836
903
 
@@ -849,6 +916,51 @@ export interface BuilderChatResult {
849
916
  standing_version?: number;
850
917
  }
851
918
 
919
+ /** One auto-generated adversarial scenario (from the agent's own prompt). */
920
+ export interface QAScenario {
921
+ title: string;
922
+ persona: string;
923
+ opener: string;
924
+ assertion: string;
925
+ }
926
+
927
+ /** The 5-level SSR nominal scale. */
928
+ export type SSRLabel = "poorly" | "ok" | "good" | "great" | "perfectly";
929
+
930
+ /** An SSR grade: nominal label -> deterministic score + bucket distribution. */
931
+ export interface SSRGrade {
932
+ label: SSRLabel;
933
+ score: number;
934
+ /** Probability mass over 10 score buckets [0-0.1, ..., 0.9-1.0]. */
935
+ distribution: number[];
936
+ rationale: string;
937
+ }
938
+
939
+ /** Result of qa.suite(): generated scenarios played vs the real agent config. */
940
+ export interface QASuiteResult {
941
+ agent: string;
942
+ objective: string;
943
+ supervised: boolean;
944
+ turns: number;
945
+ results: Array<{
946
+ scenario: string;
947
+ title: string;
948
+ persona: string;
949
+ assertion: string;
950
+ passed: boolean;
951
+ evidence: string;
952
+ ssr: SSRGrade;
953
+ transcript: BuilderTurn[];
954
+ }>;
955
+ summary: {
956
+ tests: number;
957
+ passed: number;
958
+ avg_ssr_score: number;
959
+ ssr_histogram: Record<SSRLabel, number>;
960
+ oracle_calls_billed: number;
961
+ };
962
+ }
963
+
852
964
  export interface QAResult {
853
965
  agent: string;
854
966
  turns: number;
@@ -896,6 +1008,7 @@ export class SupafoneLabs {
896
1008
  private readonly apiKey: string;
897
1009
  private readonly supafoneApiKey: string;
898
1010
  private readonly timeoutMs: number;
1011
+ private readonly postCallAnalysis: boolean;
899
1012
  private sessionToken?: string;
900
1013
  private accountToken?: string;
901
1014
  private accountSessionToken?: string;
@@ -921,6 +1034,7 @@ export class SupafoneLabs {
921
1034
  this.supafoneApiBaseUrl = (opts.supafoneApiBaseUrl ?? DEFAULT_SUPAFONE_API_BASE).replace(/\/$/, "");
922
1035
  this.appUrl = (opts.appUrl ?? "https://app.supafone.ai").replace(/\/$/, "");
923
1036
  this.timeoutMs = opts.timeoutMs ?? 30_000;
1037
+ this.postCallAnalysis = opts.postCallAnalysis ?? false;
924
1038
  this.sessionToken = opts.sessionToken;
925
1039
  this.accountToken = opts.accountToken;
926
1040
  this.accountEmail = opts.accountEmail;
@@ -1038,6 +1152,38 @@ export class SupafoneLabs {
1038
1152
  }
1039
1153
  }
1040
1154
 
1155
+ /** @internal Multipart file POST with the same auth/re-login as requestAccountApi. */
1156
+ async requestAccountUpload<T>(path: string, file: Uint8Array | ArrayBuffer | Blob, filename: string): Promise<T> {
1157
+ const token = this.accountToken || this.accountSessionToken || (await this.accountLogin());
1158
+ const send = async (bearer: string): Promise<T> => {
1159
+ const blob = file instanceof Blob ? file : new Blob([file as BlobPart], { type: "application/pdf" });
1160
+ const form = new FormData();
1161
+ form.append("file", blob, filename);
1162
+ const res = await fetch(this.supafoneApiBaseUrl + path, {
1163
+ method: "POST",
1164
+ headers: { Authorization: `Bearer ${bearer}` },
1165
+ body: form,
1166
+ });
1167
+ const text = await res.text();
1168
+ const parsed = text ? safeJson(text) : {};
1169
+ if (!res.ok) {
1170
+ const detail = (parsed as { detail?: string })?.detail ?? text ?? `HTTP ${res.status}`;
1171
+ throw new SupafoneLabsError(`POST ${path}: ${detail}`, res.status, parsed);
1172
+ }
1173
+ return parsed as T;
1174
+ };
1175
+ try {
1176
+ return await send(token);
1177
+ } catch (err) {
1178
+ const expired = err instanceof SupafoneLabsError && err.status === 401;
1179
+ if (expired && !this.accountToken && this.accountEmail && this.accountPassword) {
1180
+ this.accountSessionToken = undefined;
1181
+ return send(await this.accountLogin());
1182
+ }
1183
+ throw err;
1184
+ }
1185
+ }
1186
+
1041
1187
  /** @internal Raw product-API request with an explicit bearer ("" = none). */
1042
1188
  private async accountHttp<T>(method: string, path: string, body: unknown, token: string): Promise<T> {
1043
1189
  const ctrl = new AbortController();
@@ -1081,6 +1227,45 @@ export class SupafoneLabs {
1081
1227
  return this.requestAccountApi("GET", "/api/v1/agents");
1082
1228
  }
1083
1229
 
1230
+ /**
1231
+ * Scan a website for its branding: business name, brand colors, logo,
1232
+ * favicon, Open Graph metadata, page images, and key same-domain pages.
1233
+ */
1234
+ async scanBrand(url: string): Promise<BrandScanResult> {
1235
+ if (!url?.trim()) throw new SupafoneLabsError("url is required (the website to scan)");
1236
+ return this.requestAccountApi("POST", "/api/v1/agents/brand-scan", { url: url.trim() });
1237
+ }
1238
+
1239
+ /**
1240
+ * Generate a guided intake form (IntakeConfig) from a plain-language
1241
+ * description. Pass agentId to ground it in that agent's business, and
1242
+ * apply:true to write it onto the agent.
1243
+ */
1244
+ async generateIntakeForm(opts: {
1245
+ description: string;
1246
+ agentId?: string;
1247
+ industry?: string;
1248
+ apply?: boolean;
1249
+ }): Promise<GenerateIntakeResult> {
1250
+ if (!opts?.description?.trim()) {
1251
+ throw new SupafoneLabsError("description is required — what should the form collect?");
1252
+ }
1253
+ if (opts.apply && !opts.agentId) {
1254
+ throw new SupafoneLabsError("apply:true needs an agentId (see listVoiceAgents())");
1255
+ }
1256
+ const payload: Record<string, unknown> = { description: opts.description.trim() };
1257
+ if (opts.industry) payload.industry = opts.industry;
1258
+ if (opts.agentId) {
1259
+ payload.apply = Boolean(opts.apply);
1260
+ return this.requestAccountApi(
1261
+ "POST",
1262
+ `/api/v1/agents/${encodeURIComponent(opts.agentId)}/generate-intake`,
1263
+ payload,
1264
+ );
1265
+ }
1266
+ return this.requestAccountApi("POST", "/api/v1/agents/generate-intake", payload);
1267
+ }
1268
+
1084
1269
  /** Raw oracle completion — full control over messages and model. */
1085
1270
  async oracle(req: OracleRequest): Promise<OracleResult> {
1086
1271
  return this.request<OracleResult>("POST", "/v1/oracle/complete", {
@@ -1248,9 +1433,59 @@ export class SupafoneLabs {
1248
1433
  return this.request("POST", "/v1/events/nudge", event);
1249
1434
  }
1250
1435
 
1251
- /** File a post-call report — the fuel optimizer.improve() learns from. */
1252
- reportCall(report: CallReportInput): Promise<Record<string, unknown>> {
1253
- return this.request("POST", "/v1/events/call_report", report);
1436
+ /**
1437
+ * File a post-call report — the fuel optimizer.improve() learns from.
1438
+ *
1439
+ * With `postCallAnalysis: true` on the client and a transcript (or
1440
+ * messages) present, the call is automatically classified first: the
1441
+ * oracle labels it against the agent's objective (achieved/missed,
1442
+ * per-criterion verdicts, failure reasons) and files the enriched report
1443
+ * server-side. The generated labels come back on `analysis`. Analysis is
1444
+ * best-effort — on any failure the plain zero-billed report still lands.
1445
+ */
1446
+ async reportCall(report: CallReportInput): Promise<CallReportResult> {
1447
+ const { transcript, messages, ground_truth, ...plain } = report;
1448
+ const analyzable = !!transcript || !!(messages && messages.length);
1449
+ if (this.postCallAnalysis && analyzable) {
1450
+ try {
1451
+ const analysis = await this.classifyCall({
1452
+ sessionId: report.session_id,
1453
+ agent: report.agent,
1454
+ transcript,
1455
+ messages,
1456
+ groundTruth: ground_truth,
1457
+ nudges: report.nudges,
1458
+ });
1459
+ // classifyCall files the enriched report server-side — don't double-file.
1460
+ return { recorded: true, analysis };
1461
+ } catch {
1462
+ /* analysis is best-effort — fall through to the plain report */
1463
+ }
1464
+ }
1465
+ const out = await this.request<Record<string, unknown>>("POST", "/v1/events/call_report", plain);
1466
+ return { recorded: true, ...out };
1467
+ }
1468
+
1469
+ /**
1470
+ * Post-call analysis for one finished call: classify it against the agent's
1471
+ * objective and get labels back — achieved/missed, per-criterion verdicts,
1472
+ * failure reasons, and the blended objective value. Files an enriched call
1473
+ * report server-side (feeding optimizer.improve() and objective stats).
1474
+ * Billed one oracle call.
1475
+ */
1476
+ classifyCall(input: ClassifyCallInput): Promise<CallClassification> {
1477
+ return this.request<CallClassification>(
1478
+ "POST",
1479
+ "/v1/calls/classify",
1480
+ compact({
1481
+ session_id: input.sessionId,
1482
+ agent: input.agent ?? "builder",
1483
+ transcript: input.transcript,
1484
+ messages: input.messages,
1485
+ ground_truth: input.groundTruth,
1486
+ nudges: input.nudges,
1487
+ }),
1488
+ );
1254
1489
  }
1255
1490
 
1256
1491
  /** Available oracle model ids (live vendor catalog). */
@@ -1400,6 +1635,48 @@ export interface CampaignUpdateInput {
1400
1635
  settings?: Record<string, unknown>;
1401
1636
  }
1402
1637
 
1638
+ export interface BrandScanResult {
1639
+ url: string;
1640
+ business_name: string;
1641
+ colors: string[];
1642
+ primary_color: string;
1643
+ theme_color: string;
1644
+ css_brand_colors: string[];
1645
+ logo_url: string;
1646
+ favicon_url: string;
1647
+ og: { title: string; description: string; image: string };
1648
+ images: string[];
1649
+ key_urls: string[];
1650
+ scrape_source: string;
1651
+ fallback_used: boolean;
1652
+ error?: string | null;
1653
+ }
1654
+
1655
+ export interface GenerateIntakeResult {
1656
+ intake: Record<string, unknown>;
1657
+ /** false means the deterministic industry-workflow fallback was used. */
1658
+ generated: boolean;
1659
+ applied: boolean;
1660
+ agent?: Record<string, unknown>;
1661
+ }
1662
+
1663
+ export interface CampaignConfigReport {
1664
+ valid: boolean;
1665
+ errors: string[];
1666
+ warnings: string[];
1667
+ summary: Record<string, unknown>;
1668
+ }
1669
+
1670
+ export interface CampaignConfigApplyResult {
1671
+ campaign: CampaignSummary;
1672
+ created: boolean;
1673
+ added: number;
1674
+ launched: boolean;
1675
+ /** Branding scan / intake generation results for the doc's agent-facing blocks. */
1676
+ extras?: Record<string, unknown> | null;
1677
+ report: CampaignConfigReport;
1678
+ }
1679
+
1403
1680
  /**
1404
1681
  * Typical flow:
1405
1682
  * ```ts
@@ -1549,6 +1826,103 @@ class CampaignsNamespace {
1549
1826
  compact({ title: opts.title, message: opts.message }),
1550
1827
  );
1551
1828
  }
1829
+
1830
+ /**
1831
+ * Upload the PDF this campaign sends for e-signature. Pass raw bytes
1832
+ * (Uint8Array/ArrayBuffer/Blob — in Node: fs.readFileSync(path)). The server
1833
+ * auto-detects signature/date/initials lines and returns their placements —
1834
+ * apply them with setSignatureFields.
1835
+ */
1836
+ uploadSigningDocument(
1837
+ campaignId: string,
1838
+ file: Uint8Array | ArrayBuffer | Blob,
1839
+ filename = "document.pdf",
1840
+ ): Promise<{ campaign: CampaignSummary; asset: Record<string, unknown>; detected_fields: Record<string, unknown>[] }> {
1841
+ return this.sm.requestAccountUpload(
1842
+ `/api/v1/campaigns/${encodeURIComponent(campaignId)}/signing/document`,
1843
+ file,
1844
+ filename,
1845
+ );
1846
+ }
1847
+
1848
+ detectSignatureFields(campaignId: string): Promise<{ fields: Record<string, unknown>[]; detected: boolean }> {
1849
+ return this.sm.requestAccountApi("POST", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/signing/detect-fields`, {});
1850
+ }
1851
+
1852
+ /**
1853
+ * Place the signing fields: [{key, type: "signature"|"date"|"initials"|"text",
1854
+ * label, required, placement: {page, x, y, width, height}}] in PDF points
1855
+ * (origin bottom-left, 612x792 page). Merges onto the stored doc config.
1856
+ */
1857
+ async setSignatureFields(
1858
+ campaignId: string,
1859
+ fields: Record<string, unknown>[],
1860
+ ): Promise<{ campaign: CampaignSummary }> {
1861
+ if (!Array.isArray(fields) || !fields.length) {
1862
+ throw new SupafoneLabsError("fields must be a non-empty array of placed fields");
1863
+ }
1864
+ const { campaign } = await this.get(campaignId);
1865
+ const settings = { ...((campaign.settings as Record<string, unknown>) ?? {}) };
1866
+ const native = { ...((settings.native_signing as Record<string, unknown>) ?? {}) };
1867
+ if (!native.pdfUrl && !native.storedName) {
1868
+ throw new SupafoneLabsError("Upload the signing PDF first (uploadSigningDocument)");
1869
+ }
1870
+ native.enabled = true;
1871
+ native.fields = fields;
1872
+ return this.update(campaignId, { settings: { ...settings, native_signing: native } });
1873
+ }
1874
+
1875
+ // -- campaign-as-code (one YAML/JSON document per campaign) ----------------
1876
+
1877
+ /** Pure dry-run: {valid, errors[], warnings[], summary} — no side effects. */
1878
+ validateConfig(
1879
+ config: string,
1880
+ opts: { accountId?: string; launch?: boolean } = {},
1881
+ ): Promise<CampaignConfigReport> {
1882
+ return this.sm.requestAccountApi("POST", "/api/v1/campaigns/config/validate", {
1883
+ config,
1884
+ ...compact({ account_id: opts.accountId, launch: opts.launch }),
1885
+ });
1886
+ }
1887
+
1888
+ /**
1889
+ * Upsert a campaign from a campaign-as-code YAML/JSON document (by slug).
1890
+ * The doc's branding:/intake_form: blocks restyle the campaign's agent and
1891
+ * generate its intake form on apply. launch:true starts REAL calls/emails.
1892
+ */
1893
+ applyConfig(
1894
+ config: string,
1895
+ opts: { accountId?: string; launch?: boolean } = {},
1896
+ ): Promise<CampaignConfigApplyResult> {
1897
+ return this.sm.requestAccountApi("POST", "/api/v1/campaigns/config/apply", {
1898
+ config,
1899
+ ...compact({ account_id: opts.accountId, launch: opts.launch }),
1900
+ });
1901
+ }
1902
+
1903
+ /** The campaign as its canonical YAML document — round-trips through applyConfig. */
1904
+ exportConfig(campaignId: string): Promise<{ config: string; format: string; slug: string }> {
1905
+ return this.sm.requestAccountApi("GET", `/api/v1/campaigns/${encodeURIComponent(campaignId)}/config`);
1906
+ }
1907
+
1908
+ /**
1909
+ * Draft a campaign-as-code YAML document from a plain-language description
1910
+ * (+ optional CSV of leads). No side effects — review, then applyConfig.
1911
+ */
1912
+ generateConfig(opts: {
1913
+ prompt: string;
1914
+ csv?: string;
1915
+ agentId?: string;
1916
+ accountId?: string;
1917
+ }): Promise<{ config: string; format: string; recipients_parsed: number; generated: boolean }> {
1918
+ if (!opts?.prompt?.trim()) {
1919
+ throw new SupafoneLabsError("prompt is required — describe the campaign to draft");
1920
+ }
1921
+ return this.sm.requestAccountApi("POST", "/api/v1/campaigns/config/generate", {
1922
+ prompt: opts.prompt.trim(),
1923
+ ...compact({ csv: opts.csv, agent_id: opts.agentId, account_id: opts.accountId }),
1924
+ });
1925
+ }
1552
1926
  }
1553
1927
 
1554
1928
  class LabsNamespace {
@@ -1964,6 +2338,36 @@ class QANamespace {
1964
2338
  true,
1965
2339
  );
1966
2340
  }
2341
+ /**
2342
+ * Auto-generate adversarial test scenarios from the agent's own prompt
2343
+ * (key-scoped). Each scenario carries a persona, an opener, and the one
2344
+ * assertion the agent must (or must not) satisfy.
2345
+ */
2346
+ generate(opts: { agentPrompt: string; count?: number }): Promise<{ scenarios: QAScenario[] }> {
2347
+ return this.sm.request("POST", "/v1/qa/generate", {
2348
+ agent_prompt: opts.agentPrompt,
2349
+ count: opts.count ?? 5,
2350
+ });
2351
+ }
2352
+ /**
2353
+ * Build + run a bespoke adversarial suite in one call: scenarios are
2354
+ * generated from the agent's own objective, each is played as a mock call
2355
+ * against the REAL configured agent, and every call is judged twice —
2356
+ * pass/fail on the scenario's assertion AND an SSR grade (poorly/ok/good/
2357
+ * great/perfectly) against the objective. Session-scoped — login() first.
2358
+ */
2359
+ suite(opts: { count?: number; turns?: number; supervised?: boolean } = {}): Promise<QASuiteResult> {
2360
+ return this.sm.request<QASuiteResult>(
2361
+ "POST",
2362
+ "/v1/qa/suite",
2363
+ {
2364
+ count: opts.count ?? 4,
2365
+ turns: opts.turns ?? 2,
2366
+ supervised: opts.supervised ?? false,
2367
+ },
2368
+ true,
2369
+ );
2370
+ }
1967
2371
  /** Past QA runs (works with the API key). */
1968
2372
  history(agent = "builder", limit = 40): Promise<unknown> {
1969
2373
  return this.sm.request("GET", `/v1/qa/runs?agent=${encodeURIComponent(agent)}&limit=${limit}`);