Procházet zdrojové kódy

add LLM-based enterprise lead scoring and auto-replies

Extract delivery logic from the enterprise route into a dedicated
module that grades inquiries via the Zen responses API, falls back
to simple regex scoring on failure, and sends templated auto-replies
for generic and procurement-blocked leads.

Made-with: Cursor
Ryan Vogel před 4 týdny
rodič
revize
bc3fe8f4f5

+ 2 - 0
packages/console/app/package.json

@@ -11,6 +11,7 @@
     "start": "vite start"
   },
   "dependencies": {
+    "@ai-sdk/openai": "2.0.89",
     "@cloudflare/vite-plugin": "1.15.2",
     "@ibm/plex": "6.4.1",
     "@jsx-email/render": "1.1.1",
@@ -26,6 +27,7 @@
     "@solidjs/router": "catalog:",
     "@solidjs/start": "catalog:",
     "@stripe/stripe-js": "8.6.1",
+    "ai": "catalog:",
     "chart.js": "4.5.1",
     "nitro": "3.0.1-alpha.1",
     "solid-js": "catalog:",

+ 309 - 0
packages/console/app/src/lib/enterprise.ts

@@ -0,0 +1,309 @@
+import { createOpenAI } from "@ai-sdk/openai"
+import { AWS } from "@opencode-ai/console-core/aws.js"
+import { generateObject } from "ai"
+import { z } from "zod"
+import { createLead } from "./salesforce"
+
+const links = [
+  { label: "Docs", url: "https://opencode.ai/docs" },
+  { label: "Discord Community", url: "https://discord.gg/scN9YX6Fdd" },
+  { label: "GitHub", url: "https://github.com/anomalyco/opencode" },
+]
+
+const from = "Stefan <[email protected]>"
+const sign = "Stefan"
+
+const shape = z.object({
+  company: z.string().nullable().describe("Company name. Use null when unknown."),
+  size: z
+    .enum(["1-50", "51-250", "251-1000", "1001+"])
+    .nullable()
+    .describe("Company size bucket. Use null when unknown."),
+  first: z.string().nullable().describe("First name only. Use null when unknown."),
+  title: z.string().nullable().describe("Job title or role. Use null when unknown."),
+  seats: z.number().int().positive().nullable().describe("Approximate seat count. Use null when unknown."),
+  procurement: z
+    .boolean()
+    .describe("True when the inquiry is blocked on procurement, legal, vendor, security, or compliance review."),
+  effort: z
+    .enum(["low", "medium", "high"])
+    .describe("Lead quality based on how specific and commercially relevant the inquiry is."),
+  summary: z.string().nullable().describe("One sentence summary for the sales team. Use null when unnecessary."),
+})
+
+const system = [
+  "You triage inbound enterprise inquiries for OpenCode.",
+  "Extract the fields from the form data and message.",
+  "Do not invent facts. Use null when a field is unknown.",
+  "First name should only contain the given name.",
+  "Seats should only be set when the inquiry mentions or strongly implies a team, user, developer, or seat count.",
+  "Procurement should be true when the inquiry mentions approval, review, legal, vendor, security, or compliance processes.",
+  "Effort is low for vague or generic inquiries, medium for some business context, and high for strong buying intent, rollout scope, or blockers.",
+].join("\n")
+
+export interface Inquiry {
+  name: string
+  role: string
+  company?: string
+  email: string
+  phone?: string
+  alias?: string
+  message: string
+}
+
+export type Score = z.infer<typeof shape>
+
+type Kind = "generic" | "procurement"
+type Mail = {
+  subject: string
+  text: string
+  html: string
+}
+
+function field(text?: string | null) {
+  const value = text?.trim()
+  if (!value) return null
+  return value
+}
+
+function safe(text: string) {
+  return text
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/\"/g, "&quot;")
+    .replace(/'/g, "&#39;")
+}
+
+function html(text: string) {
+  return safe(text).replace(/\n/g, "<br>")
+}
+
+export function fallback(input: Inquiry): Score {
+  const text = [input.role, input.company, input.message].filter(Boolean).join("\n").toLowerCase()
+  const procurement = /procurement|security|vendor|legal|approval|questionnaire|compliance/.test(text)
+  const words = input.message.trim().split(/\s+/).filter(Boolean).length
+  return {
+    company: field(input.company),
+    size: null,
+    first: input.name.split(/\s+/)[0] ?? null,
+    title: field(input.role),
+    seats: null,
+    procurement,
+    effort: procurement ? "high" : words < 18 ? "low" : "medium",
+    summary: null,
+  }
+}
+
+async function grade(input: Inquiry): Promise<Score> {
+  const zen = createOpenAI({
+    apiKey: "public",
+    baseURL: "https://opencode.ai/zen/v1",
+  })
+
+  return generateObject({
+    model: zen.responses("gpt-5"),
+    schema: shape,
+    system,
+    prompt: JSON.stringify(
+      {
+        name: input.name,
+        role: input.role,
+        company: field(input.company),
+        email: input.email,
+        phone: field(input.phone),
+        message: input.message,
+      },
+      null,
+      2,
+    ),
+  })
+    .then((result) => result.object)
+    .catch((err) => {
+      console.error("Failed to grade enterprise inquiry:", err)
+      return fallback(input)
+    })
+}
+
+export function kind(score: Score): Kind | null {
+  if (score.procurement) return "procurement"
+  if (score.effort === "low") return "generic"
+  return null
+}
+
+function refs(kind: Kind) {
+  const text = links.map(
+    (item) => `${item.label}: ${item.url}${kind === "procurement" && item.label === "GitHub" ? " (MIT licensed)" : ""}`,
+  )
+  const markup = links
+    .map(
+      (item) =>
+        `<li><a href="${item.url}">${safe(item.label)}</a>${kind === "procurement" && item.label === "GitHub" ? " (MIT licensed)" : ""}</li>`,
+    )
+    .join("")
+  return { text, markup }
+}
+
+export function reply(kind: Kind, name: string | null): Mail {
+  const who = name ?? "there"
+  const list = refs(kind)
+
+  if (kind === "generic") {
+    return {
+      subject: "Thanks for reaching out to OpenCode",
+      text: [
+        `Hi ${who},`,
+        "",
+        "Thanks for reaching out, we're happy to hear from you! We've received your message and are working through it. We're a small team doing our best to get back to everyone, so thank you for bearing with us.",
+        "",
+        "To help while you wait, here are some great places to start:",
+        ...list.text,
+        "",
+        "Hope you find what you need in there! Don't hesitate to reply if you have something more specific in mind.",
+        "",
+        "Best,",
+        sign,
+      ].join("\n"),
+      html: [
+        `<p>Hi ${safe(who)},</p>`,
+        "<p>Thanks for reaching out, we're happy to hear from you! We've received your message and are working through it. We're a small team doing our best to get back to everyone, so thank you for bearing with us.</p>",
+        "<p>To help while you wait, here are some great places to start:</p>",
+        `<ul>${list.markup}</ul>`,
+        "<p>Hope you find what you need in there! Don&#39;t hesitate to reply if you have something more specific in mind.</p>",
+        `<p>Best,<br>${safe(sign)}</p>`,
+      ].join(""),
+    }
+  }
+
+  return {
+    subject: "OpenCode security and procurement notes",
+    text: [
+      `Hi ${who},`,
+      "",
+      "Thanks for reaching out! We're a small team working through messages as fast as we can, so thanks for bearing with us.",
+      "",
+      "A few notes that may help while this moves through security or procurement:",
+      "- OpenCode is open source and MIT licensed.",
+      "- Our managed offering is SOC 1 compliant.",
+      "- Our managed offering is currently in the observation period for SOC 2.",
+      "",
+      "If anything is held up on the procurement or legal side, just reply and I'll get you whatever you need to keep things moving.",
+      "",
+      "To help while you wait, here are some great places to start:",
+      ...list.text,
+      "",
+      "Best,",
+      sign,
+    ].join("\n"),
+    html: [
+      `<p>Hi ${safe(who)},</p>`,
+      "<p>Thanks for reaching out! We&#39;re a small team working through messages as fast as we can, so thanks for bearing with us.</p>",
+      "<p>A few notes that may help while this moves through security or procurement:</p>",
+      "<ul><li>OpenCode is open source and MIT licensed.</li><li>Our managed offering is SOC 1 compliant.</li><li>Our managed offering is currently in the observation period for SOC 2.</li></ul>",
+      "<p>If anything is held up on the procurement or legal side, just reply and I&#39;ll get you whatever you need to keep things moving.</p>",
+      "<p>To help while you wait, here are some great places to start:</p>",
+      `<ul>${list.markup}</ul>`,
+      `<p>Best,<br>${safe(sign)}</p>`,
+    ].join(""),
+  }
+}
+
+function rows(input: Inquiry, score: Score, kind: Kind | null) {
+  return [
+    { label: "Name", value: input.name },
+    { label: "Email", value: input.email },
+    { label: "Phone", value: field(input.phone) ?? "Unknown" },
+    { label: "Auto Reply", value: kind ?? "manual" },
+    { label: "Company", value: score.company ?? "Unknown" },
+    { label: "Company Size", value: score.size ?? "Unknown" },
+    { label: "First Name", value: score.first ?? "Unknown" },
+    { label: "Title", value: score.title ?? "Unknown" },
+    { label: "Seats", value: score.seats ? String(score.seats) : "Unknown" },
+    { label: "Procurement", value: score.procurement ? "Yes" : "No" },
+    { label: "Effort", value: score.effort },
+    { label: "Summary", value: score.summary ?? "None" },
+  ]
+}
+
+function report(input: Inquiry, score: Score, kind: Kind | null): Mail {
+  const list = rows(input, score, kind)
+  return {
+    subject: `Enterprise Inquiry from ${input.name}${kind ? ` (${kind})` : ""}`,
+    text: [
+      "New enterprise inquiry",
+      "",
+      ...list.map((item) => `${item.label}: ${item.value}`),
+      "",
+      "Message:",
+      input.message,
+    ].join("\n"),
+    html: [
+      "<p><strong>New enterprise inquiry</strong></p>",
+      ...list.map((item) => `<p><strong>${safe(item.label)}:</strong> ${html(item.value)}</p>`),
+      `<p><strong>Message:</strong><br>${html(input.message)}</p>`,
+    ].join(""),
+  }
+}
+
+function note(input: Inquiry, score: Score, kind: Kind | null) {
+  return [input.message, "", "---", ...rows(input, score, kind).map((item) => `${item.label}: ${item.value}`)].join(
+    "\n",
+  )
+}
+
+export async function deliver(input: Inquiry) {
+  const score = await grade(input)
+  const next = kind(score)
+  const msg = report(input, score, next)
+  const auto = next ? reply(next, score.first) : null
+  const jobs = [
+    {
+      name: "salesforce",
+      job: createLead({
+        name: input.name,
+        role: score.title ?? input.role,
+        company: score.company ?? field(input.company) ?? undefined,
+        email: input.email,
+        phone: field(input.phone) ?? undefined,
+        message: note(input, score, next),
+      }),
+    },
+    {
+      name: "internal",
+      job: AWS.sendEmail({
+        from,
+        to: "[email protected]",
+        subject: msg.subject,
+        body: msg.text,
+        html: msg.html,
+        replyTo: input.email,
+      }),
+    },
+    ...(auto
+      ? [
+          {
+            name: "reply",
+            job: AWS.sendEmail({
+              from,
+              to: input.email,
+              subject: auto.subject,
+              body: auto.text,
+              html: auto.html,
+            }),
+          },
+        ]
+      : []),
+  ]
+
+  const out = await Promise.allSettled(jobs.map((item) => item.job))
+  out.forEach((item, index) => {
+    const name = jobs[index]!.name
+    if (item.status === "rejected") {
+      console.error(`Enterprise ${name} failed:`, item.reason)
+      return
+    }
+    if (name === "salesforce" && !item.value) {
+      console.error("Enterprise salesforce lead failed")
+    }
+  })
+}

+ 8 - 49
packages/console/app/src/routes/api/enterprise.ts

@@ -1,23 +1,13 @@
 import type { APIEvent } from "@solidjs/start/server"
-import { AWS } from "@opencode-ai/console-core/aws.js"
+import { waitUntil } from "@opencode-ai/console-resource"
 import { i18n } from "~/i18n"
 import { localeFromRequest } from "~/lib/language"
-import { createLead } from "~/lib/salesforce"
-
-interface EnterpriseFormData {
-  name: string
-  role: string
-  company?: string
-  email: string
-  phone?: string
-  alias?: string
-  message: string
-}
+import { deliver, type Inquiry } from "~/lib/enterprise"
 
 export async function POST(event: APIEvent) {
   const dict = i18n(localeFromRequest(event.request))
   try {
-    const body = (await event.request.json()) as EnterpriseFormData
+    const body = (await event.request.json()) as Inquiry
     const trap = typeof body.alias === "string" ? body.alias.trim() : ""
 
     if (trap) {
@@ -33,45 +23,14 @@ export async function POST(event: APIEvent) {
       return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
     }
 
-    const emailContent = `
-${body.message}<br><br>
---<br>
-${body.name}<br>
-${body.role}<br>
-${body.company ? `${body.company}<br>` : ""}${body.email}<br>
-${body.phone ? `${body.phone}<br>` : ""}`.trim()
-
-    const [lead, mail] = await Promise.all([
-      createLead({
-        name: body.name,
-        role: body.role,
-        company: body.company,
-        email: body.email,
-        phone: body.phone,
-        message: body.message,
-      }),
-      AWS.sendEmail({
-        to: "[email protected]",
-        subject: `Enterprise Inquiry from ${body.name}`,
-        body: emailContent,
-        replyTo: body.email,
-      }).then(
-        () => true,
-        (err) => {
-          console.error("Failed to send enterprise email:", err)
-          return false
-        },
-      ),
-    ])
-
-    if (!lead && !mail) {
-      console.error("Enterprise inquiry delivery failed", { email: body.email })
-      return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
-    }
+    const job = deliver(body).catch((error) => {
+      console.error("Error processing enterprise form:", error)
+    })
+    void waitUntil(job)
 
     return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
   } catch (error) {
-    console.error("Error processing enterprise form:", error)
+    console.error("Error reading enterprise form:", error)
     return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
   }
 }

+ 53 - 0
packages/console/app/test/enterprise.test.ts

@@ -0,0 +1,53 @@
+import { describe, expect, test } from "bun:test"
+import { fallback, kind, reply, type Score } from "../src/lib/enterprise"
+
+describe("enterprise lead routing", () => {
+  test("routes procurement blockers to procurement reply", () => {
+    const score = fallback({
+      name: "Jane Doe",
+      role: "CTO",
+      company: "Acme",
+      email: "[email protected]",
+      message: "We're stuck in procurement, security review, and vendor approval through Coupa.",
+    })
+
+    expect(score.procurement).toBe(true)
+    expect(kind(score)).toBe("procurement")
+  })
+
+  test("routes vague inquiries to the generic reply", () => {
+    const score = fallback({
+      name: "Jane Doe",
+      role: "Engineer",
+      email: "[email protected]",
+      message: "Can you tell me more about enterprise pricing?",
+    })
+
+    expect(score.effort).toBe("low")
+    expect(kind(score)).toBe("generic")
+  })
+
+  test("keeps high intent leads for manual follow-up", () => {
+    const score: Score = {
+      company: "Acme",
+      size: "1001+",
+      first: "Jane",
+      title: "CTO",
+      seats: 500,
+      procurement: false,
+      effort: "high",
+      summary: "Large rollout with clear buying intent.",
+    }
+
+    expect(kind(score)).toBeNull()
+  })
+
+  test("renders the procurement reply with security notes", () => {
+    const mail = reply("procurement", "Jane")
+
+    expect(mail.subject).toContain("security")
+    expect(mail.text).toContain("SOC 1 compliant")
+    expect(mail.text).toContain("MIT licensed")
+    expect(mail.html).toContain("Stefan")
+  })
+})

+ 8 - 3
packages/console/core/src/aws.ts

@@ -19,12 +19,17 @@ export namespace AWS {
 
   export const sendEmail = fn(
     z.object({
+      from: z.string().optional(),
       to: z.string(),
       subject: z.string(),
       body: z.string(),
+      text: z.string().optional(),
+      html: z.string().optional(),
       replyTo: z.string().optional(),
     }),
     async (input) => {
+      const text = input.text ?? input.body
+      const html = input.html ?? input.body
       const res = await createClient().fetch("https://email.us-east-1.amazonaws.com/v2/email/outbound-emails", {
         method: "POST",
         headers: {
@@ -32,7 +37,7 @@ export namespace AWS {
           "Content-Type": "application/json",
         },
         body: JSON.stringify({
-          FromEmailAddress: `OpenCode Zen <[email protected]>`,
+          FromEmailAddress: input.from ?? "OpenCode Zen <[email protected]>",
           Destination: {
             ToAddresses: [input.to],
           },
@@ -46,11 +51,11 @@ export namespace AWS {
               Body: {
                 Text: {
                   Charset: "UTF-8",
-                  Data: input.body,
+                  Data: text,
                 },
                 Html: {
                   Charset: "UTF-8",
-                  Data: input.body,
+                  Data: html,
                 },
               },
             },