flowEngine.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * Flow Engine — executes saved Workflow flows for incoming chat messages.
  3. *
  4. * Execution model:
  5. * 1. Load all active flows from the DB
  6. * 2. For each flow, find its intent node and check if user message matches
  7. * 3. If matched, traverse the flow graph from intent → response nodes
  8. * 4. Return the response content (or null if no flow matched)
  9. */
  10. import { getDb } from "./db";
  11. import { workflowNodes, workflowEdges } from "../drizzle/schema";
  12. import { eq } from "drizzle-orm";
  13. export interface FlowResult {
  14. content: string;
  15. shouldEscalate?: boolean;
  16. flowId: string;
  17. flowName?: string;
  18. }
  19. // Intent patterns for built-in Support Flows
  20. const FLOW_INTENT_PATTERNS: Record<string, RegExp[]> = {
  21. "check-order-status": [
  22. /\border\s*status\b/i,
  23. /\bcheck.*order\b/i,
  24. /\bwhere.*my order\b/i,
  25. /\border.*update\b/i,
  26. /\bmy order\b/i,
  27. ],
  28. "track-shipment": [
  29. /\btrack.*shipment\b/i,
  30. /\btracking\b/i,
  31. /\bshipment.*status\b/i,
  32. /\bwhere.*package\b/i,
  33. /\bdelivery.*status\b/i,
  34. /\btrack.*package\b/i,
  35. ],
  36. "submit-return": [
  37. /\breturn\b/i,
  38. /\bRMA\b/i,
  39. /\bsend.*back\b/i,
  40. /\breturn.*request\b/i,
  41. /\brefund\b/i,
  42. ],
  43. "cancel-order": [
  44. /\bcancel.*order\b/i,
  45. /\bcancel.*my order\b/i,
  46. /\bstop.*order\b/i,
  47. ],
  48. "faq-deflection": [
  49. /\bpayment\b/i,
  50. /\bwarranty\b/i,
  51. /\bhow.*long.*delivery\b/i,
  52. /\bshipping.*time\b/i,
  53. /\bminimum.*order\b/i,
  54. ],
  55. // ── New flows ─────────────────────────────────────────────────────────────
  56. "leads-capture": [
  57. /\bi('d| would) like to (buy|order|purchase)\b/i,
  58. /\bget a quote\b/i,
  59. /\bbecome a (dealer|retailer|reseller)\b/i,
  60. /\bopen an? account\b/i,
  61. /\binterested in (buying|carrying|stocking)\b/i,
  62. /\bnew (customer|client)\b/i,
  63. /\bhow (do i|can i) (become|sign up|register|apply)\b/i,
  64. ],
  65. "sales-inquiry": [
  66. /\bbulk (order|pricing|discount)\b/i,
  67. /\bwholesale\b/i,
  68. /\bminimum order quantity\b/i,
  69. /\bMOQ\b/i,
  70. /\bprice list\b/i,
  71. /\bcontact.*sales\b/i,
  72. /\bsales rep(resentative)?\b/i,
  73. /\btalk to sales\b/i,
  74. ],
  75. };
  76. // Fallback static responses for flows that don't have DB nodes yet
  77. const FLOW_STATIC_RESPONSES: Record<string, string | null> = {
  78. "check-order-status": "To check your order status, please provide your **Sales Order number** (e.g., SO-12345) and I'll look it up for you right away.",
  79. "track-shipment": "I can help you track your shipment! Please share your **order number** or **tracking number** and I'll pull up the latest status.",
  80. "submit-return": "I can guide you through the return process. Please provide:\n1. Your **order number**\n2. The **item(s)** you want to return\n3. The **reason** for the return\n\nOur return policy allows returns within 30 days of delivery. You can also email **returns@homelegance.com** for assistance.",
  81. "cancel-order": "I can help with order cancellation. Please provide your **order number** and I'll check if it's still within the cancellation window. Note: orders that have already shipped cannot be cancelled.",
  82. "faq-deflection": null, // handled by knowledge base
  83. "leads-capture":
  84. "We'd love to have you as a Homelegance dealer! 🎉\n\nPlease share the following so our sales team can follow up within **1 business day**:\n1. **Company name**\n2. **Your name & title**\n3. **Email address**\n4. **Phone number**\n5. **City / State** you operate in\n\nAlternatively, email us directly at **sales@homelegance.com**.",
  85. "sales-inquiry":
  86. "Our sales team handles bulk orders and wholesale pricing directly.\n\n📞 **Call us:** Contact your assigned sales rep\n📧 **Email:** sales@homelegance.com\n\nOr I can connect you with an agent right now — just say **'Talk to agent'**.",
  87. };
  88. /**
  89. * Detect if the user message matches a flow intent.
  90. * Returns the flowId or null.
  91. */
  92. export function detectFlowIntent(message: string): string | null {
  93. for (const [flowId, patterns] of Object.entries(FLOW_INTENT_PATTERNS)) {
  94. if (patterns.some(p => p.test(message))) {
  95. return flowId;
  96. }
  97. }
  98. return null;
  99. }
  100. /**
  101. * Execute a flow by ID using full graph traversal.
  102. * Tries DB-saved nodes first, falls back to static responses.
  103. */
  104. export async function executeFlow(flowId: string, userMessage: string): Promise<FlowResult | null> {
  105. // Try to load and traverse DB-saved flow graph
  106. try {
  107. const db = await getDb();
  108. if (db) {
  109. const [nodes, edges] = await Promise.all([
  110. db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, flowId)),
  111. db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, flowId)),
  112. ]);
  113. if (nodes.length > 0) {
  114. const result = traverseGraph(nodes, edges, userMessage);
  115. if (result) return { ...result, flowId };
  116. }
  117. }
  118. } catch (err) {
  119. console.error("[FlowEngine] DB error:", err);
  120. }
  121. return fallbackToStatic(flowId);
  122. }
  123. // ── Graph traversal ───────────────────────────────────────────────────────────
  124. type DbNode = { nodeId: string; type: string; label: string; config: unknown };
  125. type DbEdge = { sourceNodeId: string; targetNodeId: string; label: string | null; condition: unknown };
  126. function traverseGraph(
  127. nodes: DbNode[],
  128. edges: DbEdge[],
  129. userMessage: string
  130. ): Omit<FlowResult, "flowId"> | null {
  131. // Build maps for O(1) lookup
  132. const nodeMap = new Map(nodes.map(n => [n.nodeId, n]));
  133. const adjMap = new Map<string, DbEdge[]>();
  134. for (const e of edges) {
  135. if (!adjMap.has(e.sourceNodeId)) adjMap.set(e.sourceNodeId, []);
  136. adjMap.get(e.sourceNodeId)!.push(e);
  137. }
  138. const intentNode = nodes.find(n => n.type === "intent");
  139. if (!intentNode) return null;
  140. const responses: string[] = [];
  141. let shouldEscalate = false;
  142. const visited = new Set<string>();
  143. function traverse(nodeId: string): void {
  144. if (visited.has(nodeId)) return;
  145. visited.add(nodeId);
  146. const node = nodeMap.get(nodeId);
  147. if (!node) return;
  148. const cfg = node.config as Record<string, unknown> | null ?? {};
  149. switch (node.type) {
  150. case "response":
  151. if (cfg.message) responses.push(String(cfg.message));
  152. break;
  153. case "condition": {
  154. // Route based on whether pattern matches the user message
  155. const pattern = cfg.pattern ? new RegExp(String(cfg.pattern), "i") : null;
  156. const conditionMet = pattern ? pattern.test(userMessage) : true;
  157. const outEdges = adjMap.get(nodeId) ?? [];
  158. // Edge label "true"/"false" controls routing; if no labels, follow all
  159. const labeled = outEdges.filter(e => e.label === "true" || e.label === "false");
  160. if (labeled.length > 0) {
  161. const target = outEdges.find(e => e.label === (conditionMet ? "true" : "false"));
  162. if (target) traverse(target.targetNodeId);
  163. } else {
  164. outEdges.forEach(e => traverse(e.targetNodeId));
  165. }
  166. return; // condition handles its own routing
  167. }
  168. case "escalation":
  169. shouldEscalate = true;
  170. if (cfg.message) responses.push(String(cfg.message));
  171. break;
  172. case "tag":
  173. // future: tag conversation; no output
  174. break;
  175. case "delay":
  176. // future: async delay; skip for now
  177. break;
  178. }
  179. // Follow outgoing edges to next nodes
  180. for (const edge of adjMap.get(nodeId) ?? []) {
  181. traverse(edge.targetNodeId);
  182. }
  183. }
  184. traverse(intentNode.nodeId);
  185. if (!responses.length && !shouldEscalate) return null;
  186. return { content: responses.join("\n\n"), shouldEscalate };
  187. }
  188. function fallbackToStatic(flowId: string): FlowResult | null {
  189. const staticResponse = FLOW_STATIC_RESPONSES[flowId];
  190. if (staticResponse) return { content: staticResponse, flowId };
  191. return null;
  192. }