fix: pipeline
This commit is contained in:
parent
ef73a3b539
commit
27a63f766b
11
.github/workflows/deploy-ec2.yml
vendored
11
.github/workflows/deploy-ec2.yml
vendored
@ -148,14 +148,19 @@ jobs:
|
|||||||
echo "=== ENSURE pgvector extension (vector type) ==="
|
echo "=== ENSURE pgvector extension (vector type) ==="
|
||||||
docker compose -f docker-compose.ec2.yml --env-file .env exec -T db sh -lc "psql -U \"\${POSTGRES_USER:-postgres}\" -d \"\${POSTGRES_DB:-dropshipping}\" -c \"CREATE EXTENSION IF NOT EXISTS vector;\""
|
docker compose -f docker-compose.ec2.yml --env-file .env exec -T db sh -lc "psql -U \"\${POSTGRES_USER:-postgres}\" -d \"\${POSTGRES_DB:-dropshipping}\" -c \"CREATE EXTENSION IF NOT EXISTS vector;\""
|
||||||
|
|
||||||
# Start/roll app after pgvector extension is available
|
# Start/roll app after pgvector extension is available.
|
||||||
echo "=== START APP (docker compose up -d) ==="
|
# Force recreate so deploy is visible even when image tag remains :latest.
|
||||||
if ! docker compose -f docker-compose.ec2.yml --env-file .env up -d --remove-orphans app; then
|
echo "=== START APP (docker compose up -d --force-recreate) ==="
|
||||||
|
if ! docker compose -f docker-compose.ec2.yml --env-file .env up -d --force-recreate --remove-orphans app; then
|
||||||
echo "ERROR: Deployment failed"
|
echo "ERROR: Deployment failed"
|
||||||
docker compose -f docker-compose.ec2.yml down || true
|
docker compose -f docker-compose.ec2.yml down || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
APP_CID="$(docker compose -f docker-compose.ec2.yml --env-file .env ps -q app)"
|
||||||
|
echo "App container details:"
|
||||||
|
docker inspect --format='id={{.Id}} startedAt={{.State.StartedAt}} image={{.Image}}' "$APP_CID" || true
|
||||||
|
|
||||||
echo "✅ Deployment successful! Current services:"
|
echo "✅ Deployment successful! Current services:"
|
||||||
docker compose -f docker-compose.ec2.yml --env-file .env ps
|
docker compose -f docker-compose.ec2.yml --env-file .env ps
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,6 +34,9 @@ yarn-error.log*
|
|||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# test artifacts
|
||||||
|
/tests/gmail-auto-reply/artifacts/
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:gmail-auto-reply": "set -e; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml up -d; export $(cat tests/gmail-auto-reply/env.test | grep -v '^#' | xargs); docker compose -f tests/gmail-auto-reply/docker-compose.test.yml exec -T db sh -lc \"psql -U postgres -d dropshipping -c 'CREATE EXTENSION IF NOT EXISTS vector;'\"; npx prisma db push --accept-data-loss; npx tsx tests/gmail-auto-reply/seed.ts; vitest run tests/gmail-auto-reply/run.test.ts; docker compose -f tests/gmail-auto-reply/docker-compose.test.yml down -v",
|
||||||
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
||||||
"db:reset": "prisma migrate reset",
|
"db:reset": "prisma migrate reset",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { looksLikeSensitiveOrderTopic } from "./gmailOrderDisclosure"
|
import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "./gmailOrderDisclosure"
|
||||||
|
|
||||||
describe("looksLikeSensitiveOrderTopic", () => {
|
describe("looksLikeSensitiveOrderTopic", () => {
|
||||||
it("detects shipping and refund language", () => {
|
it("detects shipping and refund language", () => {
|
||||||
@ -13,3 +13,15 @@ describe("looksLikeSensitiveOrderTopic", () => {
|
|||||||
expect(looksLikeSensitiveOrderTopic("Hello")).toBe(false)
|
expect(looksLikeSensitiveOrderTopic("Hello")).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("looksLikeOffTopic", () => {
|
||||||
|
it("detects obvious non-store topics", () => {
|
||||||
|
expect(looksLikeOffTopic("Hey, what's the weather in Islamabad?")).toBe(true)
|
||||||
|
expect(looksLikeOffTopic("Can you tell me today's news headlines?")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not trigger on store-ish questions", () => {
|
||||||
|
expect(looksLikeOffTopic("Where is my package?")).toBe(false)
|
||||||
|
expect(looksLikeOffTopic("I need a refund for order #1002")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -27,3 +27,28 @@ export function looksLikeSensitiveOrderTopic(text: string): boolean {
|
|||||||
]
|
]
|
||||||
return checks.some((re) => re.test(t))
|
return checks.some((re) => re.test(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obvious non-store topics that should route to `off_topic` even when classification fails.
|
||||||
|
* Keep conservative; this should not trigger on normal store support emails.
|
||||||
|
*/
|
||||||
|
export function looksLikeOffTopic(text: string): boolean {
|
||||||
|
const t = text.toLowerCase()
|
||||||
|
if (t.length < 3) return false
|
||||||
|
|
||||||
|
const checks: RegExp[] = [
|
||||||
|
/\bweather\b/,
|
||||||
|
/\btemperature\b/,
|
||||||
|
/\brain\b|\bsnow\b|\bforecast\b/,
|
||||||
|
/\bnews\b|\bheadline(s)?\b/,
|
||||||
|
/\bpolitic(s|al)?\b|\belection\b/,
|
||||||
|
/\bhomework\b|\bmath\b|\bessay\b|\bassignment\b/,
|
||||||
|
/\bjoke\b|\bfunny\b|\bmeme\b/,
|
||||||
|
/\bstock(s)?\b|\bcrypto\b|\bbitcoin\b/,
|
||||||
|
/\bsports?\b|\bscore\b|\bmatch\b/,
|
||||||
|
/\brestaurant\b|\bhotel\b|\bflight\b/,
|
||||||
|
/\bislamabad\b|\blahore\b|\bkarachi\b|\bparis\b|\blondon\b|\bnew york\b/,
|
||||||
|
]
|
||||||
|
|
||||||
|
return checks.some((re) => re.test(t))
|
||||||
|
}
|
||||||
|
|||||||
386
src/lib/gmail/autoReplyPreview.ts
Normal file
386
src/lib/gmail/autoReplyPreview.ts
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { logger } from "@/lib/logger"
|
||||||
|
import { escapeHtmlForEmail, escapeTemplateContextForHtml, renderTemplate } from "@/lib/templates/simpleRenderer"
|
||||||
|
import { classifyCustomerEmail, type ClassifyCustomerEmailResult } from "@/lib/ai/gmailGroq"
|
||||||
|
import { screenInboundEmailForAbuse } from "@/lib/ai/gmailSafety"
|
||||||
|
import {
|
||||||
|
buildOrderSummaryForEmail,
|
||||||
|
extractOrderNumberHints,
|
||||||
|
stripQuotedEmailTrailForHints,
|
||||||
|
} from "@/lib/ai/gmailContext"
|
||||||
|
import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
|
||||||
|
import { parseEnvFloat } from "@/lib/gmail/gmailAutoReplyGuards"
|
||||||
|
import { findTemplateWithFallback, type TemplateRecord } from "@/lib/juno/templatesRepo"
|
||||||
|
import type { GmailClassification } from "@/lib/ai/gmailTaxonomy"
|
||||||
|
|
||||||
|
/** Local-part of email, title-cased for greetings (templates use {{customer_name}}). */
|
||||||
|
function greetingNameFromEmail(email: string): string {
|
||||||
|
const local = email.split("@")[0]?.trim() || ""
|
||||||
|
if (!local) return "there"
|
||||||
|
return local.charAt(0).toUpperCase() + local.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMinimalFallbackHtml(emailBody: string, storeName: string): string {
|
||||||
|
const safeSnippet = escapeHtmlForEmail(emailBody.replace(/<[^>]+>/g, "").slice(0, 400))
|
||||||
|
return `
|
||||||
|
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||||||
|
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||||||
|
<h1 style="margin: 0 0 12px; font-size: 20px; color: #e5e7eb;">Thanks for reaching out</h1>
|
||||||
|
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||||||
|
We have received your message and our team will review it shortly.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 16px; color: #9ca3af;">Summary of what you sent:</p>
|
||||||
|
<blockquote style="margin: 0 0 20px; padding: 12px 16px; border-left: 3px solid #6366f1; background: #020617;">
|
||||||
|
<p style="margin: 0; white-space: pre-wrap; color: #d1d5db; font-size: 14px;">
|
||||||
|
${safeSnippet || "No additional message content was detected."}
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
<p style="margin: 0 0 8px; color: #9ca3af;">
|
||||||
|
You can reply to this email if you have more details to share.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #e5e7eb;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<span style="color: #a5b4fc;">${storeName} Support</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When no template exists but we need an order # before sharing order rows. */
|
||||||
|
export function buildNeedOrderIdFallbackHtml(storeName: string, storeEmail: string): string {
|
||||||
|
const contact = storeEmail.trim()
|
||||||
|
? `Reply with your order number, or email ${storeEmail}.`
|
||||||
|
: `Reply with your order number from your confirmation email.`
|
||||||
|
return `
|
||||||
|
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||||||
|
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||||||
|
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
|
||||||
|
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||||||
|
We didn't see an order number in your message. To look up the right order for <strong style="color:#e5e7eb;">${storeName}</strong>,
|
||||||
|
please reply with your order number (for example <strong style="color:#e5e7eb;">#1002</strong> or <strong style="color:#e5e7eb;">1002</strong>) from your order confirmation.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #9ca3af;">${contact}</p>
|
||||||
|
<p style="margin: 16px 0 0; color: #e5e7eb;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<span style="color: #a5b4fc;">${storeName} Support</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When hints did not match cache — no unrelated order rows. */
|
||||||
|
export function buildHintUnmatchedFallbackHtml(storeName: string, storeEmail: string): string {
|
||||||
|
const contact = storeEmail.trim()
|
||||||
|
? `Reply with the correct order number or email us at ${storeEmail}.`
|
||||||
|
: `Reply with the order number from your confirmation email.`
|
||||||
|
return `
|
||||||
|
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||||||
|
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||||||
|
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
|
||||||
|
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||||||
|
We could not match the order number in your message to an order on file for <strong style="color:#e5e7eb;">${storeName}</strong> under this email address.
|
||||||
|
Please double-check the number or confirm the email you used when placing the order.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #9ca3af;">${contact}</p>
|
||||||
|
<p style="margin: 16px 0 0; color: #e5e7eb;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<span style="color: #a5b4fc;">${storeName} Support</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When no off_topic template exists — polite boundary, no order data. */
|
||||||
|
export function buildOffTopicFallbackHtml(storeName: string, storeEmail: string): string {
|
||||||
|
const contact = storeEmail.trim()
|
||||||
|
? `If you have a question about an order from ${storeName}, reply with details or email us at ${storeEmail}.`
|
||||||
|
: `If you have a question about an order from ${storeName}, reply with your question and order number if you have one.`
|
||||||
|
return `
|
||||||
|
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a; background-color: #0b1220;">
|
||||||
|
<div style="max-width: 640px; margin: 0 auto; background: #020617; border-radius: 16px; border: 1px solid #1f2937; padding: 24px;">
|
||||||
|
<p style="margin: 0 0 12px; color: #e5e7eb;">Hello,</p>
|
||||||
|
<p style="margin: 0 0 12px; color: #9ca3af;">
|
||||||
|
This inbox only handles orders, products, shipping, returns, and billing for <strong style="color:#e5e7eb;">${storeName}</strong>.
|
||||||
|
We can't answer unrelated requests (news, weather, other websites, or general topics).
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #9ca3af;">${contact}</p>
|
||||||
|
<p style="margin: 16px 0 0; color: #e5e7eb;">
|
||||||
|
Best regards,<br/>
|
||||||
|
<span style="color: #a5b4fc;">${storeName} Support</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFromTemplate(
|
||||||
|
template: TemplateRecord,
|
||||||
|
context: Record<string, string | number | boolean | null | undefined>,
|
||||||
|
): string {
|
||||||
|
const safeContext = escapeTemplateContextForHtml(context)
|
||||||
|
return template.isHtml
|
||||||
|
? renderTemplate(template.body, safeContext)
|
||||||
|
: `
|
||||||
|
<div style="font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; line-height: 1.6; color: #0f172a;">
|
||||||
|
<pre style="white-space: pre-wrap; margin: 0;">${renderTemplate(
|
||||||
|
template.body,
|
||||||
|
safeContext,
|
||||||
|
)}</pre>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoReplyPreviewInput = {
|
||||||
|
storeId: string
|
||||||
|
fromEmail: string
|
||||||
|
subject: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoReplyPreviewOutput = {
|
||||||
|
classification: GmailClassification
|
||||||
|
classifySource: ClassifyCustomerEmailResult["source"]
|
||||||
|
orderHints: string[]
|
||||||
|
offTopicByHeuristic: boolean
|
||||||
|
suppressOrdersOnFallbackClassification: boolean
|
||||||
|
needsOrderNumberFirst: boolean
|
||||||
|
hintUnmatched: boolean
|
||||||
|
lowConfidenceSuppressOrders: boolean
|
||||||
|
messageTooSparse: boolean
|
||||||
|
skipOrderCacheForQuestion: boolean
|
||||||
|
shouldLoadOrderCache: boolean
|
||||||
|
sensitiveTopic: boolean
|
||||||
|
orderSummaryRaw: string
|
||||||
|
orderSummaryForContext: string
|
||||||
|
ticketSummary: string
|
||||||
|
chosenTemplateId: string | null
|
||||||
|
usedFallback: "none" | "off_topic" | "need_order_id" | "hint_unmatched" | "minimal"
|
||||||
|
html: string
|
||||||
|
flagged: boolean
|
||||||
|
flaggedReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAutoReplyPreview(
|
||||||
|
input: AutoReplyPreviewInput,
|
||||||
|
): Promise<AutoReplyPreviewOutput> {
|
||||||
|
const senderEmail = input.fromEmail.trim().toLowerCase()
|
||||||
|
const emailSubject = input.subject || "No Subject"
|
||||||
|
const emailBody = input.body || ""
|
||||||
|
|
||||||
|
const plainForAi = emailBody.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
||||||
|
|
||||||
|
const safety = await screenInboundEmailForAbuse(emailSubject, plainForAi)
|
||||||
|
if (safety?.flagged) {
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: input.storeId },
|
||||||
|
select: { businessName: true, email: true },
|
||||||
|
})
|
||||||
|
const storeName = store?.businessName || "Your store"
|
||||||
|
return {
|
||||||
|
classification: { intent: "general", mood: "neutral", confidence: 0.0, short_summary: "" },
|
||||||
|
classifySource: "fallback",
|
||||||
|
orderHints: [],
|
||||||
|
offTopicByHeuristic: false,
|
||||||
|
suppressOrdersOnFallbackClassification: false,
|
||||||
|
needsOrderNumberFirst: false,
|
||||||
|
hintUnmatched: false,
|
||||||
|
lowConfidenceSuppressOrders: false,
|
||||||
|
messageTooSparse: false,
|
||||||
|
skipOrderCacheForQuestion: false,
|
||||||
|
shouldLoadOrderCache: false,
|
||||||
|
sensitiveTopic: false,
|
||||||
|
orderSummaryRaw: "",
|
||||||
|
orderSummaryForContext: "",
|
||||||
|
ticketSummary: "",
|
||||||
|
chosenTemplateId: null,
|
||||||
|
usedFallback: "minimal",
|
||||||
|
html: buildMinimalFallbackHtml(emailBody, storeName),
|
||||||
|
flagged: true,
|
||||||
|
flaggedReason: safety.reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi)
|
||||||
|
const classification = classifyResult.classification
|
||||||
|
|
||||||
|
const hintSourceFull = `${emailSubject}\n${plainForAi}`
|
||||||
|
const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull)
|
||||||
|
|
||||||
|
const extractedHints = extractOrderNumberHints(hintSourceForHints)
|
||||||
|
const offTopicByHeuristic = looksLikeOffTopic(hintSourceFull) && extractedHints.length === 0
|
||||||
|
const isOffTopic = classification.intent === "off_topic" || offTopicByHeuristic
|
||||||
|
const orderHints = isOffTopic ? [] : extractedHints
|
||||||
|
|
||||||
|
const intentsNeedingOrderIdWhenAmbiguous = new Set([
|
||||||
|
"shipping_delay",
|
||||||
|
"refund_request",
|
||||||
|
"account_billing",
|
||||||
|
])
|
||||||
|
const sensitiveTopic = !isOffTopic && looksLikeSensitiveOrderTopic(hintSourceFull)
|
||||||
|
const needsOrderNumberFirst =
|
||||||
|
!isOffTopic &&
|
||||||
|
orderHints.length === 0 &&
|
||||||
|
(intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) || sensitiveTopic)
|
||||||
|
|
||||||
|
const confidenceMin = parseEnvFloat("GMAIL_CLASSIFY_CONFIDENCE_MIN", 0.5)
|
||||||
|
const lowConfidenceSuppressOrders =
|
||||||
|
classifyResult.source === "groq" &&
|
||||||
|
classification.confidence < confidenceMin &&
|
||||||
|
orderHints.length === 0 &&
|
||||||
|
!isOffTopic &&
|
||||||
|
(classification.intent === "general" || classification.intent === "question")
|
||||||
|
|
||||||
|
const messageTooSparse = plainForAi.trim().length < 12 && orderHints.length === 0
|
||||||
|
|
||||||
|
const skipOrderCacheForQuestion =
|
||||||
|
classification.intent === "question" && orderHints.length === 0 && !isOffTopic
|
||||||
|
|
||||||
|
// Default-safe: if Groq classification failed, do not attach CachedOrder unless the customer provided an order number hint.
|
||||||
|
const suppressOrdersOnFallbackClassification =
|
||||||
|
classifyResult.source === "fallback" && extractedHints.length === 0 && !isOffTopic
|
||||||
|
|
||||||
|
const shouldLoadOrderCache =
|
||||||
|
!isOffTopic &&
|
||||||
|
!needsOrderNumberFirst &&
|
||||||
|
!lowConfidenceSuppressOrders &&
|
||||||
|
!messageTooSparse &&
|
||||||
|
!skipOrderCacheForQuestion &&
|
||||||
|
!suppressOrdersOnFallbackClassification
|
||||||
|
|
||||||
|
let orderSummaryRaw = ""
|
||||||
|
let hintUnmatched = false
|
||||||
|
if (shouldLoadOrderCache) {
|
||||||
|
const built = await buildOrderSummaryForEmail(input.storeId, senderEmail, hintSourceForHints)
|
||||||
|
orderSummaryRaw = built.summary
|
||||||
|
hintUnmatched = built.hintUnmatched
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Gmail auto-reply preview", {
|
||||||
|
storeId: input.storeId,
|
||||||
|
senderEmail,
|
||||||
|
intent: classification.intent,
|
||||||
|
mood: classification.mood,
|
||||||
|
confidence: classification.confidence,
|
||||||
|
classifySource: classifyResult.source,
|
||||||
|
orderHints,
|
||||||
|
offTopicByHeuristic,
|
||||||
|
needsOrderNumberFirst,
|
||||||
|
hintUnmatched,
|
||||||
|
lowConfidenceSuppressOrders,
|
||||||
|
messageTooSparse,
|
||||||
|
suppressOrdersOnFallbackClassification,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: input.storeId },
|
||||||
|
select: { businessName: true, email: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const storeName = store?.businessName || "Your store"
|
||||||
|
const ticketSummary = messageTooSparse
|
||||||
|
? "We could not read enough text in your message to look up an order automatically. Please reply with a short description and your order number if you have one."
|
||||||
|
: classification.short_summary?.trim() || plainForAi.slice(0, 500) || emailBody.slice(0, 500)
|
||||||
|
|
||||||
|
const offTopicOrderLine = `We don't include order or purchase history for unrelated topics. For help with ${storeName}, reply with a store-related question and your order number if you have one.`
|
||||||
|
|
||||||
|
const counterOrderIdLine = `No order details are shown yet — we did not see an order number in your message. Please reply with your order number (e.g. #1002 or 1002) from your ${storeName} confirmation email so we can look up the correct order.`
|
||||||
|
|
||||||
|
const hintUnmatchedOrderLine = `We could not match the order number(s) in your message to an order on file for ${storeName} under this email. Please double-check the number or confirm the email address used at checkout.`
|
||||||
|
|
||||||
|
const lowConfidenceOrderLine = `We were not fully confident how to categorize your message. If this is about an order, reply with your order number from your ${storeName} confirmation email so we can pull up the right record.`
|
||||||
|
|
||||||
|
const sparseBodyOrderLine = `We could not read enough detail in your message to look anything up automatically. Please reply with a sentence or two and your order number if your question is about a ${storeName} order.`
|
||||||
|
|
||||||
|
const questionNoOrderLine = `We are replying without attaching order history. If your question relates to a specific purchase, reply with your order number (e.g. #1002).`
|
||||||
|
|
||||||
|
const noOrdersFallback = sensitiveTopic
|
||||||
|
? `No matching recent orders on file for ${storeName} under this email. If you used a different email at checkout, reply from that address or send your order number.`
|
||||||
|
: "No matching recent orders on file for this email."
|
||||||
|
|
||||||
|
let orderSummaryForContext: string
|
||||||
|
if (isOffTopic) {
|
||||||
|
orderSummaryForContext = offTopicOrderLine
|
||||||
|
} else if (needsOrderNumberFirst) {
|
||||||
|
orderSummaryForContext = counterOrderIdLine
|
||||||
|
} else if (hintUnmatched) {
|
||||||
|
orderSummaryForContext = hintUnmatchedOrderLine
|
||||||
|
} else if (lowConfidenceSuppressOrders) {
|
||||||
|
orderSummaryForContext = lowConfidenceOrderLine
|
||||||
|
} else if (messageTooSparse) {
|
||||||
|
orderSummaryForContext = sparseBodyOrderLine
|
||||||
|
} else if (skipOrderCacheForQuestion) {
|
||||||
|
orderSummaryForContext = questionNoOrderLine
|
||||||
|
} else {
|
||||||
|
orderSummaryForContext = orderSummaryRaw || noOrdersFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const context: Record<string, string | number | boolean | null | undefined> = {
|
||||||
|
customer_name: greetingNameFromEmail(senderEmail),
|
||||||
|
customer_email: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
store_name: storeName,
|
||||||
|
store_email: store?.email || "",
|
||||||
|
ticket_summary: ticketSummary,
|
||||||
|
order_summary: orderSummaryForContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
let html: string | null = null
|
||||||
|
let chosenTemplateId: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = await findTemplateWithFallback(
|
||||||
|
input.storeId,
|
||||||
|
classification.intent,
|
||||||
|
classification.mood,
|
||||||
|
)
|
||||||
|
if (template) {
|
||||||
|
chosenTemplateId = template.id
|
||||||
|
html = renderFromTemplate(template, context)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("generateAutoReplyPreview: template load/render failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
let usedFallback: AutoReplyPreviewOutput["usedFallback"] = "none"
|
||||||
|
if (!html) {
|
||||||
|
if (isOffTopic) {
|
||||||
|
usedFallback = "off_topic"
|
||||||
|
html = buildOffTopicFallbackHtml(storeName, store?.email || "")
|
||||||
|
} else if (needsOrderNumberFirst) {
|
||||||
|
usedFallback = "need_order_id"
|
||||||
|
html = buildNeedOrderIdFallbackHtml(storeName, store?.email || "")
|
||||||
|
} else if (hintUnmatched) {
|
||||||
|
usedFallback = "hint_unmatched"
|
||||||
|
html = buildHintUnmatchedFallbackHtml(storeName, store?.email || "")
|
||||||
|
} else {
|
||||||
|
usedFallback = "minimal"
|
||||||
|
html = buildMinimalFallbackHtml(emailBody, storeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
classification,
|
||||||
|
classifySource: classifyResult.source,
|
||||||
|
orderHints,
|
||||||
|
offTopicByHeuristic,
|
||||||
|
suppressOrdersOnFallbackClassification,
|
||||||
|
needsOrderNumberFirst,
|
||||||
|
hintUnmatched,
|
||||||
|
lowConfidenceSuppressOrders,
|
||||||
|
messageTooSparse,
|
||||||
|
skipOrderCacheForQuestion,
|
||||||
|
shouldLoadOrderCache,
|
||||||
|
sensitiveTopic,
|
||||||
|
orderSummaryRaw,
|
||||||
|
orderSummaryForContext,
|
||||||
|
ticketSummary,
|
||||||
|
chosenTemplateId,
|
||||||
|
usedFallback,
|
||||||
|
html,
|
||||||
|
flagged: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -10,7 +10,8 @@ import {
|
|||||||
extractOrderNumberHints,
|
extractOrderNumberHints,
|
||||||
stripQuotedEmailTrailForHints,
|
stripQuotedEmailTrailForHints,
|
||||||
} from "@/lib/ai/gmailContext"
|
} from "@/lib/ai/gmailContext"
|
||||||
import { looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
|
import { looksLikeOffTopic, looksLikeSensitiveOrderTopic } from "@/lib/ai/gmailOrderDisclosure"
|
||||||
|
import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview"
|
||||||
import {
|
import {
|
||||||
checkGmailAutoReplyRateLimits,
|
checkGmailAutoReplyRateLimits,
|
||||||
parseEnvFloat,
|
parseEnvFloat,
|
||||||
@ -312,156 +313,35 @@ export async function processIncomingEmail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const classifyResult = await classifyCustomerEmail(emailSubject, plainForAi)
|
const preview = await generateAutoReplyPreview({
|
||||||
const classification = classifyResult.classification
|
storeId,
|
||||||
const hintSourceFull = `${emailSubject}\n${plainForAi}`
|
fromEmail: senderEmail,
|
||||||
const hintSourceForHints = stripQuotedEmailTrailForHints(hintSourceFull)
|
subject: emailSubject,
|
||||||
|
body: emailBody,
|
||||||
const isOffTopic = classification.intent === "off_topic"
|
})
|
||||||
const orderHints = isOffTopic ? [] : extractOrderNumberHints(hintSourceForHints)
|
|
||||||
|
|
||||||
const intentsNeedingOrderIdWhenAmbiguous = new Set([
|
|
||||||
"shipping_delay",
|
|
||||||
"refund_request",
|
|
||||||
"account_billing",
|
|
||||||
])
|
|
||||||
const sensitiveTopic = !isOffTopic && looksLikeSensitiveOrderTopic(hintSourceFull)
|
|
||||||
const needsOrderNumberFirst =
|
|
||||||
!isOffTopic &&
|
|
||||||
orderHints.length === 0 &&
|
|
||||||
(intentsNeedingOrderIdWhenAmbiguous.has(classification.intent) || sensitiveTopic)
|
|
||||||
|
|
||||||
const confidenceMin = parseEnvFloat("GMAIL_CLASSIFY_CONFIDENCE_MIN", 0.5)
|
|
||||||
const lowConfidenceSuppressOrders =
|
|
||||||
classifyResult.source === "groq" &&
|
|
||||||
classification.confidence < confidenceMin &&
|
|
||||||
orderHints.length === 0 &&
|
|
||||||
!isOffTopic &&
|
|
||||||
(classification.intent === "general" || classification.intent === "question")
|
|
||||||
|
|
||||||
const messageTooSparse = plainForAi.trim().length < 12 && orderHints.length === 0
|
|
||||||
|
|
||||||
const skipOrderCacheForQuestion =
|
|
||||||
classification.intent === "question" && orderHints.length === 0 && !isOffTopic
|
|
||||||
|
|
||||||
const shouldLoadOrderCache =
|
|
||||||
!isOffTopic &&
|
|
||||||
!needsOrderNumberFirst &&
|
|
||||||
!lowConfidenceSuppressOrders &&
|
|
||||||
!messageTooSparse &&
|
|
||||||
!skipOrderCacheForQuestion
|
|
||||||
|
|
||||||
let orderSummary = ""
|
|
||||||
let hintUnmatched = false
|
|
||||||
if (shouldLoadOrderCache) {
|
|
||||||
const built = await buildOrderSummaryForEmail(storeId, senderEmail, hintSourceForHints)
|
|
||||||
orderSummary = built.summary
|
|
||||||
hintUnmatched = built.hintUnmatched
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Gmail classification", {
|
logger.info("Gmail classification", {
|
||||||
userId,
|
userId,
|
||||||
storeId,
|
storeId,
|
||||||
intent: classification.intent,
|
intent: preview.classification.intent,
|
||||||
mood: classification.mood,
|
mood: preview.classification.mood,
|
||||||
confidence: classification.confidence,
|
confidence: preview.classification.confidence,
|
||||||
classifySource: classifyResult.source,
|
classifySource: preview.classifySource,
|
||||||
})
|
})
|
||||||
logger.info("Gmail order context for reply", {
|
logger.info("Gmail order context for reply", {
|
||||||
storeId,
|
storeId,
|
||||||
orderSummaryChars: orderSummary.trim().length,
|
orderSummaryChars: preview.orderSummaryRaw.trim().length,
|
||||||
orderHints,
|
orderHints: preview.orderHints,
|
||||||
offTopic: isOffTopic,
|
offTopic: preview.classification.intent === "off_topic" || preview.offTopicByHeuristic,
|
||||||
needsOrderNumberFirst,
|
offTopicByHeuristic: preview.offTopicByHeuristic,
|
||||||
hintUnmatched,
|
needsOrderNumberFirst: preview.needsOrderNumberFirst,
|
||||||
lowConfidenceSuppressOrders,
|
hintUnmatched: preview.hintUnmatched,
|
||||||
messageTooSparse,
|
lowConfidenceSuppressOrders: preview.lowConfidenceSuppressOrders,
|
||||||
|
messageTooSparse: preview.messageTooSparse,
|
||||||
|
suppressOrdersOnFallbackClassification: preview.suppressOrdersOnFallbackClassification,
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = await prisma.store.findUnique({
|
const finalReply = preview.html
|
||||||
where: { id: storeId },
|
|
||||||
select: {
|
|
||||||
businessName: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const storeName = store?.businessName || "Your store"
|
|
||||||
const ticketSummary = messageTooSparse
|
|
||||||
? "We could not read enough text in your message to look up an order automatically. Please reply with a short description and your order number if you have one."
|
|
||||||
: classification.short_summary?.trim() ||
|
|
||||||
plainForAi.slice(0, 500) ||
|
|
||||||
emailBody.slice(0, 500)
|
|
||||||
|
|
||||||
const offTopicOrderLine = `We don't include order or purchase history for unrelated topics. For help with ${storeName}, reply with a store-related question and your order number if you have one.`
|
|
||||||
|
|
||||||
const counterOrderIdLine = `No order details are shown yet — we did not see an order number in your message. Please reply with your order number (e.g. #1002 or 1002) from your ${storeName} confirmation email so we can look up the correct order.`
|
|
||||||
|
|
||||||
const hintUnmatchedOrderLine = `We could not match the order number(s) in your message to an order on file for ${storeName} under this email. Please double-check the number or confirm the email address used at checkout.`
|
|
||||||
|
|
||||||
const lowConfidenceOrderLine = `We were not fully confident how to categorize your message. If this is about an order, reply with your order number from your ${storeName} confirmation email so we can pull up the right record.`
|
|
||||||
|
|
||||||
const sparseBodyOrderLine = `We could not read enough detail in your message to look anything up automatically. Please reply with a sentence or two and your order number if your question is about a ${storeName} order.`
|
|
||||||
|
|
||||||
const questionNoOrderLine = `We are replying without attaching order history. If your question relates to a specific purchase, reply with your order number (e.g. #1002).`
|
|
||||||
|
|
||||||
const noOrdersFallback = sensitiveTopic
|
|
||||||
? `No matching recent orders on file for ${storeName} under this email. If you used a different email at checkout, reply from that address or send your order number.`
|
|
||||||
: "No matching recent orders on file for this email."
|
|
||||||
|
|
||||||
let orderSummaryForContext: string
|
|
||||||
if (isOffTopic) {
|
|
||||||
orderSummaryForContext = offTopicOrderLine
|
|
||||||
} else if (needsOrderNumberFirst) {
|
|
||||||
orderSummaryForContext = counterOrderIdLine
|
|
||||||
} else if (hintUnmatched) {
|
|
||||||
orderSummaryForContext = hintUnmatchedOrderLine
|
|
||||||
} else if (lowConfidenceSuppressOrders) {
|
|
||||||
orderSummaryForContext = lowConfidenceOrderLine
|
|
||||||
} else if (messageTooSparse) {
|
|
||||||
orderSummaryForContext = sparseBodyOrderLine
|
|
||||||
} else if (skipOrderCacheForQuestion) {
|
|
||||||
orderSummaryForContext = questionNoOrderLine
|
|
||||||
} else {
|
|
||||||
orderSummaryForContext = orderSummary || noOrdersFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const context: Record<string, string | number | boolean | null | undefined> = {
|
|
||||||
customer_name: greetingNameFromEmail(senderEmail),
|
|
||||||
customer_email: senderEmail,
|
|
||||||
subject: emailSubject,
|
|
||||||
store_name: storeName,
|
|
||||||
store_email: store?.email || "",
|
|
||||||
ticket_summary: ticketSummary,
|
|
||||||
order_summary: orderSummaryForContext,
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalReply: string | null = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const template = await findTemplateWithFallback(
|
|
||||||
storeId,
|
|
||||||
classification.intent,
|
|
||||||
classification.mood,
|
|
||||||
)
|
|
||||||
if (template) {
|
|
||||||
finalReply = renderFromTemplate(template, context)
|
|
||||||
}
|
|
||||||
} catch (replyBuildError) {
|
|
||||||
logger.error("Failed to load/render template for auto-reply", replyBuildError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalReply) {
|
|
||||||
if (isOffTopic) {
|
|
||||||
finalReply = buildOffTopicFallbackHtml(storeName, store?.email || "")
|
|
||||||
} else if (needsOrderNumberFirst) {
|
|
||||||
finalReply = buildNeedOrderIdFallbackHtml(storeName, store?.email || "")
|
|
||||||
} else if (hintUnmatched) {
|
|
||||||
finalReply = buildHintUnmatchedFallbackHtml(storeName, store?.email || "")
|
|
||||||
} else {
|
|
||||||
finalReply = buildMinimalFallbackHtml(emailBody, storeName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
|
const replySubject = emailSubject.startsWith("Re:") ? emailSubject : `Re: ${emailSubject}`
|
||||||
|
|
||||||
|
|||||||
22
tests/gmail-auto-reply/docker-compose.test.yml
Normal file
22
tests/gmail-auto-reply/docker-compose.test.yml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_DB: dropshipping
|
||||||
|
ports:
|
||||||
|
- "5544:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_test_data:/var/lib/postgresql/data
|
||||||
|
- ../../prisma/init-pgvector.sql:/docker-entrypoint-initdb.d/init-pgvector.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d dropshipping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_test_data:
|
||||||
|
|
||||||
2
tests/gmail-auto-reply/env.test
Normal file
2
tests/gmail-auto-reply/env.test
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DATABASE_URL=postgresql://postgres:password@localhost:5544/dropshipping?schema=public
|
||||||
|
|
||||||
114
tests/gmail-auto-reply/run.test.ts
Normal file
114
tests/gmail-auto-reply/run.test.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import fs from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { generateAutoReplyPreview } from "@/lib/gmail/autoReplyPreview"
|
||||||
|
import { scenarios, type Scenario } from "./scenarios"
|
||||||
|
|
||||||
|
function projectRoot(): string {
|
||||||
|
return process.cwd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactsDir(): string {
|
||||||
|
return path.join(projectRoot(), "tests", "gmail-auto-reply", "artifacts")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForContains(html: string): string {
|
||||||
|
return html.replace(/\s+/g, " ").toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertScenarioInvariants(s: Scenario, out: Awaited<ReturnType<typeof generateAutoReplyPreview>>) {
|
||||||
|
const html = normalizeForContains(out.html)
|
||||||
|
|
||||||
|
if (s.expect.expectNeedsOrderNumberFirst != null) {
|
||||||
|
expect(out.needsOrderNumberFirst, `${s.id}: needsOrderNumberFirst`).toBe(
|
||||||
|
s.expect.expectNeedsOrderNumberFirst,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.expect.expectHintUnmatched != null) {
|
||||||
|
expect(out.hintUnmatched, `${s.id}: hintUnmatched`).toBe(s.expect.expectHintUnmatched)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.expect.expectOffTopic) {
|
||||||
|
const isOffTopic = out.classification.intent === "off_topic" || out.offTopicByHeuristic
|
||||||
|
expect(isOffTopic, `${s.id}: off_topic`).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order lines presence/absence (raw summary is the safest signal; template HTML varies).
|
||||||
|
if (s.expect.expectOrderLines) {
|
||||||
|
expect(out.orderSummaryRaw.trim().length, `${s.id}: orderSummaryRaw non-empty`).toBeGreaterThan(0)
|
||||||
|
} else {
|
||||||
|
expect(out.orderSummaryRaw.trim().length, `${s.id}: orderSummaryRaw empty`).toBe(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A few “sanity contains” checks for the human-readable copy paths.
|
||||||
|
if (s.expect.expectNeedsOrderNumberFirst) {
|
||||||
|
expect(html, `${s.id}: counter-question copy present`).toContain("order number")
|
||||||
|
}
|
||||||
|
if (s.expect.expectHintUnmatched) {
|
||||||
|
expect(html, `${s.id}: hint-unmatched copy present`).toContain("could not match")
|
||||||
|
}
|
||||||
|
if (s.expect.expectQuestionNoOrders) {
|
||||||
|
expect(html, `${s.id}: question no-order-history copy present`).toContain("without attaching order history")
|
||||||
|
}
|
||||||
|
if (s.expect.expectOffTopic) {
|
||||||
|
expect(html, `${s.id}: off-topic boundary present`).toContain("unrelated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gmail auto-reply scenarios (preview mode)", () => {
|
||||||
|
it("runs scenarios and writes artifacts", async () => {
|
||||||
|
const storeId = process.env.TEST_STORE_ID || "test-store"
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
skipped?: string
|
||||||
|
output?: unknown
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
await fs.mkdir(artifactsDir(), { recursive: true })
|
||||||
|
|
||||||
|
for (const s of scenarios) {
|
||||||
|
if (s.skip) {
|
||||||
|
results.push({ id: s.id, title: s.title, skipped: s.skip })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = await generateAutoReplyPreview({
|
||||||
|
storeId,
|
||||||
|
fromEmail: s.from,
|
||||||
|
subject: s.subject,
|
||||||
|
body: s.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
assertScenarioInvariants(s, out)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
output: {
|
||||||
|
classification: out.classification,
|
||||||
|
classifySource: out.classifySource,
|
||||||
|
orderHints: out.orderHints,
|
||||||
|
offTopicByHeuristic: out.offTopicByHeuristic,
|
||||||
|
needsOrderNumberFirst: out.needsOrderNumberFirst,
|
||||||
|
hintUnmatched: out.hintUnmatched,
|
||||||
|
lowConfidenceSuppressOrders: out.lowConfidenceSuppressOrders,
|
||||||
|
messageTooSparse: out.messageTooSparse,
|
||||||
|
skipOrderCacheForQuestion: out.skipOrderCacheForQuestion,
|
||||||
|
suppressOrdersOnFallbackClassification: out.suppressOrdersOnFallbackClassification,
|
||||||
|
orderSummaryRaw: out.orderSummaryRaw,
|
||||||
|
orderSummaryForContext: out.orderSummaryForContext,
|
||||||
|
usedFallback: out.usedFallback,
|
||||||
|
chosenTemplateId: out.chosenTemplateId,
|
||||||
|
html: out.html,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = path.join(artifactsDir(), "results.json")
|
||||||
|
await fs.writeFile(outPath, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2))
|
||||||
|
}, 120_000)
|
||||||
|
})
|
||||||
|
|
||||||
196
tests/gmail-auto-reply/scenarios.ts
Normal file
196
tests/gmail-auto-reply/scenarios.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
export type ScenarioExpectation = {
|
||||||
|
/** Expect CachedOrder lines to be included in the reply context (orderSummaryRaw non-empty). */
|
||||||
|
expectOrderLines: boolean
|
||||||
|
/** Expect the pipeline to require order number first (counter-question path). */
|
||||||
|
expectNeedsOrderNumberFirst?: boolean
|
||||||
|
/** Expect the hint-unmatched behavior when order # doesn't match cache. */
|
||||||
|
expectHintUnmatched?: boolean
|
||||||
|
/** Expect off-topic boundary behavior (no orders). */
|
||||||
|
expectOffTopic?: boolean
|
||||||
|
/** Expect question w/out order history line (no orders). */
|
||||||
|
expectQuestionNoOrders?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Scenario = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
from: string
|
||||||
|
subject: string
|
||||||
|
body: string
|
||||||
|
notes?: string
|
||||||
|
skip?: string
|
||||||
|
expect: ScenarioExpectation
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_STORE_NAME = "YOUR_STORE"
|
||||||
|
export const DEFAULT_CUSTOMER_EMAIL = "customer@gmail.com"
|
||||||
|
|
||||||
|
export const scenarios: Scenario[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Counter-question (shipping-style, no order number)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Where is my package?",
|
||||||
|
body: "Hi, I still haven't received anything. Can you check the status for me? Thanks.",
|
||||||
|
expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Counter-question (refund language, no order #)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Refund",
|
||||||
|
body: "I need a refund on my last purchase. Please process it.",
|
||||||
|
expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Counter-question (billing language, no order #)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Wrong charge",
|
||||||
|
body: "I see a duplicate charge on my card for your store. Please fix it.",
|
||||||
|
expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
title: "Order context shown (single clear order hint)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Order status",
|
||||||
|
body: "Hi, can you confirm status for order #1002? Thanks.",
|
||||||
|
expect: { expectOrderLines: true, expectHintUnmatched: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
title: "Order context shown (hint in subject only)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Re: #1002 shipping",
|
||||||
|
body: "When will it ship?",
|
||||||
|
expect: { expectOrderLines: true, expectHintUnmatched: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
title: "Hint mismatch (order # not on file for this email)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Order 9999",
|
||||||
|
body: "I need help with order #9999.",
|
||||||
|
expect: { expectOrderLines: false, expectHintUnmatched: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "7",
|
||||||
|
title: "Multiple order numbers, not all match cache",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Orders",
|
||||||
|
body: "Please check orders #1002 and #8888.",
|
||||||
|
expect: { expectOrderLines: false, expectHintUnmatched: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "8",
|
||||||
|
title: "Off-topic (no store-related request)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Weather tomorrow",
|
||||||
|
body: "What is the weather in Paris tomorrow?",
|
||||||
|
expect: { expectOrderLines: false, expectOffTopic: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9",
|
||||||
|
title: "Product/policy question without order number",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Sizing question",
|
||||||
|
body: "Does the medium hoodie run true to size?",
|
||||||
|
expect: { expectOrderLines: false, expectQuestionNoOrders: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "10",
|
||||||
|
title: "General thank-you (may include recent orders by design)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Thanks",
|
||||||
|
body: "Thank you so much for the fast delivery and great product!",
|
||||||
|
expect: { expectOrderLines: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "11",
|
||||||
|
title: "Very short body (sparse message)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Hi",
|
||||||
|
body: "Help",
|
||||||
|
expect: { expectOrderLines: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "12",
|
||||||
|
title: "Low confidence (general/question only)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Hello",
|
||||||
|
body: "Just checking in.",
|
||||||
|
skip: "Hard to force with live Groq; invariants depend on model confidence.",
|
||||||
|
expect: { expectOrderLines: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "13",
|
||||||
|
title: "Quoted thread with stale order # in reply chain",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Still waiting",
|
||||||
|
body: "Still waiting on my new order.\n\nOn Mon, Jan 1, 2026 at 9:00 AM wrote:\n> Order #9999 from old ticket",
|
||||||
|
expect: { expectOrderLines: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "14",
|
||||||
|
title: "Groq unavailable / bad JSON (classification fallback)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Where is my package?",
|
||||||
|
body: "Any update?",
|
||||||
|
skip: "Requires intentionally breaking GROQ_API_KEY; skipped in live-Groq mode.",
|
||||||
|
expect: { expectOrderLines: false, expectNeedsOrderNumberFirst: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "15",
|
||||||
|
title: "No CachedOrder for sender (guest / wrong email)",
|
||||||
|
from: "other.person@gmail.com",
|
||||||
|
subject: "Order help",
|
||||||
|
body: "I placed an order last week, can you find it?",
|
||||||
|
expect: { expectOrderLines: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "16",
|
||||||
|
title: "Safety / abuse screen (no auto-reply)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Test",
|
||||||
|
body: "You are an idiot and I will ruin your life.", // may or may not trigger safety depending on rules
|
||||||
|
skip: "Depends on gmailSafety rules; keep manual unless you confirm a deterministic trigger phrase.",
|
||||||
|
expect: { expectOrderLines: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "17",
|
||||||
|
title: "Duplicate inbound message (idempotency)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Order status",
|
||||||
|
body: "Hi, can you confirm status for order #1002? Thanks.",
|
||||||
|
skip: "Idempotency is enforced via Gmail message id + lease in production; preview mode does not model GmailInboundAutoReply.",
|
||||||
|
expect: { expectOrderLines: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "18",
|
||||||
|
title: "Per-sender rate limit",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Ping",
|
||||||
|
body: "Status?",
|
||||||
|
skip: "Rate limits are enforced in processIncomingEmail; preview mode does not perform rate checks.",
|
||||||
|
expect: { expectOrderLines: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "19",
|
||||||
|
title: "Non-Gmail sender skipped",
|
||||||
|
from: "customer@company.com",
|
||||||
|
subject: "Order help",
|
||||||
|
body: "Where is my order?",
|
||||||
|
skip: "Non-gmail sender skip is enforced in processIncomingEmail; preview mode focuses on reply generation only.",
|
||||||
|
expect: { expectOrderLines: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "20",
|
||||||
|
title: "HTML escaping (security smoke test)",
|
||||||
|
from: DEFAULT_CUSTOMER_EMAIL,
|
||||||
|
subject: "Question",
|
||||||
|
body: "Question: <script>alert(1)</script> and order #1002",
|
||||||
|
expect: { expectOrderLines: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
174
tests/gmail-auto-reply/seed.ts
Normal file
174
tests/gmail-auto-reply/seed.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import "dotenv/config"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { insertTemplate } from "@/lib/juno/templatesRepo"
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const v = process.env[name]
|
||||||
|
if (!v) throw new Error(`Missing required env var: ${name}`)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// This relies on Prisma using DATABASE_URL from env.test (loaded by the runner script).
|
||||||
|
// Create a minimal user + active store, plus a few templates and CachedOrder rows.
|
||||||
|
const userEmail = process.env.TEST_VENDOR_EMAIL || "vendor@example.com"
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email: userEmail },
|
||||||
|
update: { role: "VENDOR" },
|
||||||
|
create: {
|
||||||
|
email: userEmail,
|
||||||
|
role: "VENDOR",
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = await prisma.store.upsert({
|
||||||
|
where: { id: process.env.TEST_STORE_ID || "test-store" },
|
||||||
|
update: {
|
||||||
|
userId: user.id,
|
||||||
|
isActive: true,
|
||||||
|
businessName: process.env.TEST_STORE_NAME || "YOUR_STORE",
|
||||||
|
email: process.env.TEST_STORE_EMAIL || "support@yourstore.test",
|
||||||
|
timezone: "UTC",
|
||||||
|
country: "US",
|
||||||
|
shopifyStoreName: "test",
|
||||||
|
shopifyDomain: "test.myshopify.com",
|
||||||
|
shopifyApiKey: "test",
|
||||||
|
shopifyApiSecret: "test",
|
||||||
|
shopifyAccessToken: "test",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: process.env.TEST_STORE_ID || "test-store",
|
||||||
|
userId: user.id,
|
||||||
|
isActive: true,
|
||||||
|
businessName: process.env.TEST_STORE_NAME || "YOUR_STORE",
|
||||||
|
email: process.env.TEST_STORE_EMAIL || "support@yourstore.test",
|
||||||
|
timezone: "UTC",
|
||||||
|
country: "US",
|
||||||
|
shopifyStoreName: "test",
|
||||||
|
shopifyDomain: "test.myshopify.com",
|
||||||
|
shopifyApiKey: "test",
|
||||||
|
shopifyApiSecret: "test",
|
||||||
|
shopifyAccessToken: "test",
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ensure the app logic sees an active store for the user.
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { activeStoreId: store.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Minimal templates so we get stable rendering without requiring a full catalog.
|
||||||
|
// These templates reference {{ticket_summary}} and {{order_summary}}.
|
||||||
|
const templateBody = `
|
||||||
|
<div>
|
||||||
|
<p>Hello {{customer_name}},</p>
|
||||||
|
<p>{{ticket_summary}}</p>
|
||||||
|
<p>{{order_summary}}</p>
|
||||||
|
<p>— {{store_name}} Support</p>
|
||||||
|
</div>
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
const intents = [
|
||||||
|
["general", "neutral"],
|
||||||
|
["general", "happy"],
|
||||||
|
["shipping_delay", "neutral"],
|
||||||
|
["refund_request", "neutral"],
|
||||||
|
["account_billing", "neutral"],
|
||||||
|
["question", "neutral"],
|
||||||
|
["off_topic", "neutral"],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const [intent, mood] of intents) {
|
||||||
|
await insertTemplate({
|
||||||
|
storeId: store.id,
|
||||||
|
name: `Test template — ${intent}/${mood}`,
|
||||||
|
intent,
|
||||||
|
mood,
|
||||||
|
description: "Test harness template",
|
||||||
|
body: templateBody,
|
||||||
|
variables: { ticket_summary: true, order_summary: true },
|
||||||
|
isHtml: true,
|
||||||
|
}).catch(() => {
|
||||||
|
// If duplicate-ish templates exist, ignore; findTemplateWithFallback selects latest.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerEmail = process.env.TEST_CUSTOMER_EMAIL || "customer@gmail.com"
|
||||||
|
|
||||||
|
const baseLineItems = [
|
||||||
|
{ title: "Hoodie", quantity: 1, product_id: 111 },
|
||||||
|
{ title: "Socks", quantity: 2, product_id: 222 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const orders = [
|
||||||
|
{ orderNumber: "1002", shopifyOrderId: "gid://shopify/Order/1002", totalPrice: "49.99" },
|
||||||
|
{ orderNumber: "1003", shopifyOrderId: "gid://shopify/Order/1003", totalPrice: "19.99" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const o of orders) {
|
||||||
|
await prisma.cachedOrder.upsert({
|
||||||
|
where: {
|
||||||
|
shopifyOrderId_storeId: {
|
||||||
|
shopifyOrderId: o.shopifyOrderId,
|
||||||
|
storeId: store.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
orderNumber: o.orderNumber,
|
||||||
|
email: customerEmail,
|
||||||
|
totalPrice: o.totalPrice,
|
||||||
|
currency: "USD",
|
||||||
|
financialStatus: "paid",
|
||||||
|
fulfillmentStatus: "unfulfilled",
|
||||||
|
lineItems: baseLineItems as unknown as object,
|
||||||
|
customer: { email: customerEmail } as unknown as object,
|
||||||
|
shopifyCreatedAt: now,
|
||||||
|
lastSyncedAt: now,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
shopifyOrderId: o.shopifyOrderId,
|
||||||
|
storeId: store.id,
|
||||||
|
orderNumber: o.orderNumber,
|
||||||
|
email: customerEmail,
|
||||||
|
totalPrice: o.totalPrice,
|
||||||
|
currency: "USD",
|
||||||
|
financialStatus: "paid",
|
||||||
|
fulfillmentStatus: "unfulfilled",
|
||||||
|
lineItems: baseLineItems as unknown as object,
|
||||||
|
customer: { email: customerEmail } as unknown as object,
|
||||||
|
shopifyCreatedAt: now,
|
||||||
|
lastSyncedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
storeId: store.id,
|
||||||
|
userEmail,
|
||||||
|
customerEmail,
|
||||||
|
seededOrders: orders.map((o) => o.orderNumber),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user