Add complete end-to-end AI-powered quote extraction workflow: - Disable PB SDK auto-cancellation to support parallel batch requests - Add required runtime dependencies for AI processing and document parsing - Implement AI settings backend, operator UI, and REST API routes - Add quote ingestion, insight population, and manual re-run AI endpoints - Build service layers for quote items, AI settings, and structured item insights - Fix attachment upload handling and quote item history blob parsing - Add supporting migrations, smoke tests, and shared utility functions - Export previously private helper functions for cross-service reuse
152 lines
7.4 KiB
TypeScript
152 lines
7.4 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Smoke test for the new AI email → quote pipeline.
|
|
*
|
|
* Exercises the ingestion service directly (skips the HTTP layer to
|
|
* avoid prod-deploy races during development). Verifies:
|
|
*
|
|
* 1. ingestQuoteFromEmail() writes a quote row stamped
|
|
* ai_status = "pending"
|
|
* 2. A second call with the same message_id is a dedup (no second
|
|
* quote, no second customer)
|
|
* 3. The quote can be picked up by processOneQuote() — which
|
|
* attempts the AI extraction. If ANTHROPIC_API_KEY is unset,
|
|
* the call is expected to throw ANTHROPIC_API_KEY missing and
|
|
* flip ai_status to "failed" with the error captured.
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/smoke/quotes-ai-pipeline.ts
|
|
*
|
|
* Cleanup: deletes the smoke-test customer + company + quote on
|
|
* success. If the script crashes before cleanup, the data is left
|
|
* behind; a follow-up run with the same Message-ID will dedup to
|
|
* the same rows.
|
|
*/
|
|
import "./_env"; // MUST be first — side-effect loads .env / .env.local
|
|
import { ingestQuoteFromEmail } from "../../src/lib/server/quotes-from-email-service";
|
|
import { processOneQuote } from "../../src/lib/server/quotes-ai-processor";
|
|
import { getAdminPocketBase } from "../../src/lib/server/pocketbase-admin";
|
|
|
|
const SMOKE_FROM = "smoke-test-pipeline@example.invalid";
|
|
const SMOKE_SUBJECT = "[SMOKE] AI pipeline end-to-end check";
|
|
const SMOKE_MESSAGE_ID = "smoke-ai-pipeline-2026-08-08-001@wfdash.local";
|
|
|
|
async function main() {
|
|
// Defensive env check before we hit the network — saves a confusing
|
|
// 404 from PB auth if .env.local didn't load (e.g. wrong cwd).
|
|
console.log("=== AI pipeline smoke test ===");
|
|
console.log(` PB URL: ${process.env.NEXT_PUBLIC_POCKETBASE_URL?.slice(0, 60)}…`);
|
|
console.log(` Admin: ${process.env.PB_ADMIN_EMAIL}`);
|
|
console.log(` Pwd len: ${(process.env.PB_ADMIN_PASSWORD || "").length}`);
|
|
console.log(` Anthropic: ${process.env.ANTHROPIC_API_KEY ? "set" : "(unset — failure path expected)"}\n`);
|
|
console.log(`From: ${SMOKE_FROM}`);
|
|
console.log(`Subject: ${SMOKE_SUBJECT}`);
|
|
console.log(`Message-ID: ${SMOKE_MESSAGE_ID}\n`);
|
|
|
|
const pb = await getAdminPocketBase();
|
|
pb.autoCancellation(false);
|
|
|
|
// ── 1. Ingest ─────────────────────────────────────────────────────
|
|
console.log("[1/4] Ingesting email payload…");
|
|
const ingest1 = await ingestQuoteFromEmail({
|
|
from_email: SMOKE_FROM,
|
|
from_name: "Smoke Tester",
|
|
subject: SMOKE_SUBJECT,
|
|
body_plain:
|
|
"Please quote 10 x M16 x 50mm hex bolts grade 8.8 zinc, " +
|
|
"and 20 x M16 wing nuts. Need delivery to Johannesburg.",
|
|
message_id: SMOKE_MESSAGE_ID,
|
|
company_hint: "Smoke Test Co",
|
|
});
|
|
console.log(` ✓ Quote ${ingest1.quote.quote_number} (${ingest1.quote.id})`);
|
|
console.log(` customer=${ingest1.customer.id} company=${ingest1.company?.id ?? "(none)"}`);
|
|
console.log(` deduped=${ingest1.deduped} attachments=${ingest1.attachments}`);
|
|
if (!ingest1.deduped && ingest1.quote.ai_status !== "pending") {
|
|
throw new Error(
|
|
`Expected ai_status="pending" on new quote, got "${ingest1.quote.ai_status}"`,
|
|
);
|
|
}
|
|
console.log(` ai_status="${ingest1.quote.ai_status}" ✓`);
|
|
|
|
// ── 2. Dedup ──────────────────────────────────────────────────────
|
|
console.log("\n[2/4] Re-ingesting with the same Message-ID (expect dedup)…");
|
|
const ingest2 = await ingestQuoteFromEmail({
|
|
from_email: SMOKE_FROM,
|
|
subject: SMOKE_SUBJECT,
|
|
body_plain: "Different body, same Message-ID — should not double-write.",
|
|
message_id: SMOKE_MESSAGE_ID,
|
|
company_hint: "Smoke Test Co",
|
|
});
|
|
if (!ingest2.deduped) throw new Error("Expected dedup=true on second ingest");
|
|
if (ingest2.quote.id !== ingest1.quote.id) {
|
|
throw new Error(`Dedup returned a different quote id!`);
|
|
}
|
|
console.log(` ✓ Same quote id (${ingest2.quote.id}), no duplicate created`);
|
|
|
|
// ── 3. Process (AI step) ──────────────────────────────────────────
|
|
console.log("\n[3/4] Running processOneQuote() — invokes Claude API…");
|
|
let processedOk = false;
|
|
try {
|
|
const result = await processOneQuote({ quoteId: ingest1.quote.id });
|
|
console.log(` → outcome=${result.outcome} itemsWritten=${result.itemsWritten}`);
|
|
if (result.outcome === "processed") {
|
|
processedOk = true;
|
|
} else if (result.outcome === "failed" && result.error.includes("ANTHROPIC_API_KEY")) {
|
|
console.log(` ⚠ ANTHROPIC_API_KEY not configured — that's expected in dev.`);
|
|
console.log(` ai_error captured correctly: "${result.error.slice(0, 80)}…"`);
|
|
} else {
|
|
throw new Error(`Unexpected outcome: ${JSON.stringify(result)}`);
|
|
}
|
|
} catch (e) {
|
|
// processOneQuote catches its own errors and writes ai_error,
|
|
// so a thrown error here would be a code bug, not an API failure.
|
|
throw new Error(`processOneQuote threw unexpectedly: ${(e as Error).message}`);
|
|
}
|
|
|
|
// ── 4. Verify the AI status flipped correctly ────────────────────
|
|
console.log("\n[4/4] Verifying ai_status on the quote row…");
|
|
const after = await pb.collection("quotes").getOne(ingest1.quote.id);
|
|
console.log(` ai_status="${after.ai_status}" ai_error="${(after.ai_error || "").slice(0, 60)}…"`);
|
|
if (processedOk) {
|
|
if (after.ai_status !== "complete") {
|
|
throw new Error(`Expected ai_status="complete" after successful AI run, got "${after.ai_status}"`);
|
|
}
|
|
console.log(` ✓ ai_status="complete" — pipeline ran end-to-end`);
|
|
} else {
|
|
if (after.ai_status !== "failed") {
|
|
throw new Error(`Expected ai_status="failed" after missing API key, got "${after.ai_status}"`);
|
|
}
|
|
console.log(` ✓ ai_status="failed" — failure path captured the missing key correctly`);
|
|
}
|
|
|
|
// ── 5. Cleanup ────────────────────────────────────────────────────
|
|
console.log("\n[cleanup] Deleting smoke-test quote + customer + company…");
|
|
await pb.collection("quotes").delete(ingest1.quote.id);
|
|
// Only delete the smoke customer + company if no other rows still
|
|
// reference them (defensive — should be true after the quote delete).
|
|
try {
|
|
await pb.collection("customers").delete(ingest1.customer.id);
|
|
console.log(` ✓ Deleted smoke customer ${ingest1.customer.id}`);
|
|
} catch (e) {
|
|
console.log(` (skipped customer delete — likely linked to other rows: ${(e as Error).message})`);
|
|
}
|
|
if (ingest1.company) {
|
|
try {
|
|
await pb.collection("companies").delete(ingest1.company.id);
|
|
console.log(` ✓ Deleted smoke company ${ingest1.company.id}`);
|
|
} catch (e) {
|
|
console.log(` (skipped company delete — likely linked to other rows: ${(e as Error).message})`);
|
|
}
|
|
}
|
|
|
|
console.log("\n=== Smoke test PASSED ===");
|
|
console.log(processedOk
|
|
? "Full pipeline ran (quote created → AI extracted → items written → status=complete)."
|
|
: "Ingestion ran (quote created, ai_status=pending) and the failure path correctly captured the missing ANTHROPIC_API_KEY.");
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("\nSmoke test FAILED:", e.message);
|
|
console.error(e.stack);
|
|
process.exit(1);
|
|
});
|