Compare commits

...

2 commits

27 changed files with 6537 additions and 448 deletions

View file

@ -0,0 +1,31 @@
import { NextResponse } from "next/server"
import { fetchAction } from "convex/nextjs"
import { internal } from "@/convex/_generated/api"
import { requireAdminToken } from "@/lib/server/admin-auth"
export async function POST(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const result = await fetchAction(internal.ebay.refreshCache, {
reason: "admin",
force: true,
})
return NextResponse.json(result)
} catch (error) {
console.error("Failed to refresh eBay cache:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to refresh eBay cache",
},
{ status: 500 }
)
}
}

View file

@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import test from "node:test"
import { GET } from "@/app/api/admin/manuals-knowledge/route"
const ORIGINAL_ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN
test.afterEach(() => {
if (typeof ORIGINAL_ADMIN_API_TOKEN === "string") {
process.env.ADMIN_API_TOKEN = ORIGINAL_ADMIN_API_TOKEN
} else {
delete process.env.ADMIN_API_TOKEN
}
})
test("manuals knowledge admin route requires admin auth", async () => {
process.env.ADMIN_API_TOKEN = "secret-token"
const response = await GET(
new Request("http://localhost/api/admin/manuals-knowledge?query=rvv+660")
)
assert.equal(response.status, 401)
})
test("manuals knowledge admin route returns retrieval details for authorized queries", async () => {
process.env.ADMIN_API_TOKEN = "secret-token"
const response = await GET(
new Request(
"http://localhost/api/admin/manuals-knowledge?query=RVV+660+service+manual",
{
headers: {
"x-admin-token": "secret-token",
},
}
)
)
assert.equal(response.status, 200)
const body = await response.json()
assert.equal(body.summary.ran, true)
assert.equal(Array.isArray(body.result.manualCandidates), true)
assert.equal(body.result.manualCandidates.length > 0, true)
assert.equal(Array.isArray(body.result.topChunks), true)
assert.equal(Array.isArray(body.summary.topChunkCitations), true)
})

View file

@ -0,0 +1,79 @@
import { NextResponse } from "next/server"
import {
getManualCitationContext,
retrieveManualContext,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { requireAdminToken } from "@/lib/server/admin-auth"
function normalizeQuery(value: string | null) {
return (value || "").trim().slice(0, 400)
}
export async function GET(request: Request) {
const authError = requireAdminToken(request)
if (authError) {
return authError
}
try {
const { searchParams } = new URL(request.url)
const query = normalizeQuery(searchParams.get("query"))
const manufacturer = normalizeQuery(searchParams.get("manufacturer")) || null
const model = normalizeQuery(searchParams.get("model")) || null
const manualId = normalizeQuery(searchParams.get("manualId")) || null
const pageParam = searchParams.get("page")
const pageNumber =
pageParam && Number.isFinite(Number(pageParam))
? Number.parseInt(pageParam, 10)
: undefined
if (!query) {
return NextResponse.json(
{ error: "A query parameter is required." },
{ status: 400 }
)
}
const result = await retrieveManualContext(query, {
manufacturer,
model,
manualId,
})
const citationContext =
manualId || result.bestManual?.manualId
? await getManualCitationContext(
manualId || result.bestManual?.manualId || "",
pageNumber
)
: null
return NextResponse.json({
query,
filters: {
manufacturer,
model,
manualId,
pageNumber: pageNumber ?? null,
},
summary: summarizeManualRetrieval({
ran: true,
query,
result,
}),
result,
citationContext,
})
} catch (error) {
console.error("Failed to inspect manuals knowledge:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to inspect manuals knowledge",
},
{ status: 500 }
)
}
}

181
app/api/chat/route.test.ts Normal file
View file

@ -0,0 +1,181 @@
import assert from "node:assert/strict"
import test from "node:test"
import { NextRequest } from "next/server"
import { POST } from "@/app/api/chat/route"
type CapturedPayload = {
model: string
messages: Array<{ role: string; content: string }>
}
const ORIGINAL_FETCH = globalThis.fetch
const ORIGINAL_XAI_KEY = process.env.XAI_API_KEY
function buildVisitor(intent: string) {
return {
name: "Taylor",
phone: "(801) 555-1000",
email: "taylor@example.com",
intent,
serviceTextConsent: true,
marketingTextConsent: false,
consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/contact-us",
}
}
function buildRequest(message: string, intent = "Manuals") {
return new NextRequest("http://localhost/api/chat", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
pathname: "/manuals",
sessionId: "test-session",
visitor: buildVisitor(intent),
messages: [{ role: "user", content: message }],
}),
})
}
async function runChatRouteWithSpy(
message: string,
intent = "Manuals"
): Promise<{ response: Response; payload: CapturedPayload }> {
process.env.XAI_API_KEY = "test-xai-key"
let capturedPayload: CapturedPayload | null = null
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
capturedPayload = JSON.parse(String(init?.body || "{}")) as CapturedPayload
return new Response(
JSON.stringify({
choices: [
{
message: {
content: "Mock Jessica reply.",
},
},
],
}),
{
status: 200,
headers: {
"content-type": "application/json",
},
}
)
}) as typeof fetch
const response = await POST(buildRequest(message, intent))
assert.ok(capturedPayload)
return { response, payload: capturedPayload }
}
test.afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH
if (typeof ORIGINAL_XAI_KEY === "string") {
process.env.XAI_API_KEY = ORIGINAL_XAI_KEY
} else {
delete process.env.XAI_API_KEY
}
})
test("chat route includes grounded manual context for RVV alias lookups", async () => {
const { response, payload } = await runChatRouteWithSpy(
"RVV 660 service manual"
)
assert.equal(response.status, 200)
assert.equal(
payload.messages.some(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
),
true
)
assert.equal(
payload.messages.some(
(message) =>
message.role === "system" &&
/Royal Vendors|660/i.test(message.content)
),
true
)
})
test("chat route resolves Narco alias lookups into manual context", async () => {
const { payload } = await runChatRouteWithSpy("Narco bevmax not cooling")
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.ok(manualContext)
assert.match(manualContext.content, /Dixie-Narco|Narco/i)
})
test("chat route low-confidence manual queries instruct Jessica to ask for brand model or photo", async () => {
const { payload } = await runChatRouteWithSpy(
"manual for flibbertigibbet machine"
)
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.ok(manualContext)
assert.match(
manualContext.content,
/brand on the front|model sticker|photo\/video/i
)
})
test("chat route risky technical manual queries inject conservative safety context", async () => {
const { payload } = await runChatRouteWithSpy(
"Royal wiring diagram voltage manual",
"Repairs"
)
const systemPrompt = payload.messages[0]?.content || ""
const manualContext = payload.messages.find(
(message) =>
message.role === "system" &&
message.content.includes("Manual knowledge context:")
)
assert.match(
systemPrompt,
/Do not provide step-by-step repair procedures, wiring guidance, voltage guidance/i
)
assert.ok(manualContext)
assert.match(manualContext.content, /technical or risky/i)
})
test("chat route skips manuals retrieval for non-manual conversations", async () => {
const { payload } = await runChatRouteWithSpy(
"Can someone call me back about free placement?",
"Free Placement"
)
const systemMessages = payload.messages.filter(
(message) => message.role === "system"
)
assert.equal(systemMessages.length, 1)
assert.equal(
systemMessages.some((message) =>
message.content.includes("Manual knowledge context:")
),
false
)
})

View file

@ -17,12 +17,18 @@ import {
SITE_CHAT_TEMPERATURE,
isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config"
import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt"
import {
consumeChatOutput,
consumeChatRequest,
getChatRateLimitStatus,
} from "@/lib/site-chat/rate-limit"
import {
formatManualContextForPrompt,
retrieveManualContext,
shouldUseManualKnowledgeForChat,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { createSmsConsentPayload } from "@/lib/sms-compliance"
type ChatRole = "user" | "assistant"
@ -208,6 +214,15 @@ function extractAssistantText(data: any) {
return ""
}
function buildManualKnowledgeQuery(messages: ChatMessage[]) {
return messages
.filter((message) => message.role === "user")
.slice(-3)
.map((message) => message.content.trim())
.filter(Boolean)
.join(" ")
}
export async function POST(request: NextRequest) {
const responseHeaders: Record<string, string> = {
"Cache-Control": "no-store",
@ -299,6 +314,36 @@ export async function POST(request: NextRequest) {
sessionId,
})
const manualKnowledgeQuery = buildManualKnowledgeQuery(messages)
const shouldUseManualKnowledge = shouldUseManualKnowledgeForChat(
visitor.intent,
manualKnowledgeQuery
)
let manualKnowledge = null
let manualKnowledgeError: unknown = null
if (shouldUseManualKnowledge) {
try {
manualKnowledge = await retrieveManualContext(manualKnowledgeQuery)
} catch (error) {
manualKnowledgeError = error
console.error("[site-chat] manuals knowledge lookup failed", {
pathname,
sessionId,
error,
})
}
}
console.info(
"[site-chat] manuals retrieval",
summarizeManualRetrieval({
ran: shouldUseManualKnowledge,
query: manualKnowledgeQuery,
result: manualKnowledge,
error: manualKnowledgeError,
})
)
const systemPrompt = buildSiteChatSystemPrompt()
const xaiApiKey = getOptionalEnv("XAI_API_KEY")
if (!xaiApiKey) {
console.warn("[site-chat] missing XAI_API_KEY", {
@ -331,8 +376,18 @@ export async function POST(request: NextRequest) {
messages: [
{
role: "system",
content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
content: `${systemPrompt}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
},
...(shouldUseManualKnowledge
? [
{
role: "system" as const,
content: manualKnowledge
? formatManualContextForPrompt(manualKnowledge)
: "Manual knowledge context:\n- A manual lookup was attempted, but no reliable manual context is available.\n- Do not guess. Ask for the brand, model sticker, or a clear photo/video that can be texted in.",
},
]
: []),
...messages,
],
}),

View file

@ -0,0 +1,269 @@
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
rankListingsForPart,
type CachedEbayListing,
type EbayCacheState,
type ManualPartInput,
} from "@/lib/ebay-parts-match"
import { findManualParts } from "@/lib/server/manual-parts-data"
type MatchPart = ManualPartInput & {
key?: string
ebayListings?: CachedEbayListing[]
}
type ManualPartsMatchResponse = {
manualFilename: string
parts: Array<
MatchPart & {
ebayListings: CachedEbayListing[]
}
>
cache: EbayCacheState
}
type ManualPartsRequest = {
manualFilename?: string
parts?: unknown[]
limit?: number
}
function getCacheFallback(message?: string): EbayCacheState {
return {
key: "manual-parts",
status: "success",
lastSuccessfulAt: Date.now(),
lastAttemptAt: null,
nextEligibleAt: null,
lastError: null,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: 0,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message: message || "Using bundled manual cache.",
}
}
function normalizePartInput(value: unknown): MatchPart | null {
if (!value || typeof value !== "object") {
return null
}
const part = value as Record<string, unknown>
const partNumber = typeof part.partNumber === "string" ? part.partNumber.trim() : ""
const description = typeof part.description === "string" ? part.description.trim() : ""
if (!partNumber && !description) {
return null
}
return {
key: typeof part.key === "string" ? part.key : undefined,
partNumber,
description,
manufacturer:
typeof part.manufacturer === "string" ? part.manufacturer.trim() : undefined,
category:
typeof part.category === "string" ? part.category.trim() : undefined,
manualFilename:
typeof part.manualFilename === "string"
? part.manualFilename.trim()
: undefined,
ebayListings: Array.isArray(part.ebayListings)
? (part.ebayListings as CachedEbayListing[])
: undefined,
}
}
function mergeListings(
primary: CachedEbayListing[],
fallback: CachedEbayListing[]
): CachedEbayListing[] {
const seen = new Set<string>()
const merged: CachedEbayListing[] = []
for (const listing of [...primary, ...fallback]) {
const itemId = listing.itemId?.trim()
if (!itemId || seen.has(itemId)) {
continue
}
seen.add(itemId)
merged.push(listing)
}
return merged
}
function getStaticListingsForPart(
part: MatchPart,
staticParts: Awaited<ReturnType<typeof findManualParts>>
): CachedEbayListing[] {
const requestPartNumber = part.partNumber.trim().toLowerCase()
const requestDescription = part.description.trim().toLowerCase()
const match = staticParts.find((candidate) => {
const candidatePartNumber = candidate.partNumber.trim().toLowerCase()
const candidateDescription = candidate.description.trim().toLowerCase()
if (candidatePartNumber && candidatePartNumber === requestPartNumber) {
return true
}
if (candidatePartNumber && requestPartNumber && candidatePartNumber.includes(requestPartNumber)) {
return true
}
if (
candidateDescription &&
requestDescription &&
candidateDescription.includes(requestDescription)
) {
return true
}
return false
})
return Array.isArray(match?.ebayListings) ? match.ebayListings : []
}
export async function POST(request: Request) {
let payload: ManualPartsRequest | null = null
try {
payload = (await request.json()) as ManualPartsRequest
} catch {
payload = null
}
const manualFilename = payload?.manualFilename?.trim() || ""
const limit = Math.min(
Math.max(Number.parseInt(String(payload?.limit ?? 5), 10) || 5, 1),
10
)
const parts: MatchPart[] = (payload?.parts || [])
.map(normalizePartInput)
.filter((part): part is MatchPart => Boolean(part))
if (!manualFilename) {
return NextResponse.json(
{ error: "manualFilename is required" },
{ status: 400 }
)
}
if (!parts.length) {
return NextResponse.json({
manualFilename,
parts: [],
cache: getCacheFallback("No manual parts were provided."),
})
}
const staticManualPartsPromise = findManualParts(manualFilename)
if (!hasConvexUrl()) {
const staticManualParts = await staticManualPartsPromise
return NextResponse.json({
manualFilename,
parts: parts.map((part) => {
const staticListings = getStaticListingsForPart(part, staticManualParts)
const ebayListings = rankListingsForPart(
part,
mergeListings(part.ebayListings || [], staticListings),
limit
)
return {
...part,
ebayListings,
}
}),
cache: getCacheFallback(),
})
}
try {
const [overview, listings, staticManualParts] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
staticManualPartsPromise,
])
const rankedParts = parts
.map((part) => {
const backendListings = rankListingsForPart(
part,
listings as CachedEbayListing[],
limit
)
const staticListings = getStaticListingsForPart(part, staticManualParts)
const bundledListings = rankListingsForPart(
part,
mergeListings(part.ebayListings || [], staticListings),
limit
)
const ebayListings = mergeListings(backendListings, bundledListings).slice(
0,
limit
)
return {
...part,
ebayListings,
}
})
.sort((a: MatchPart & { ebayListings: CachedEbayListing[] }, b: MatchPart & { ebayListings: CachedEbayListing[] }) => {
const aCount = a.ebayListings.length
const bCount = b.ebayListings.length
if (aCount !== bCount) {
return bCount - aCount
}
const aFreshness = a.ebayListings[0]?.lastSeenAt ?? a.ebayListings[0]?.fetchedAt ?? 0
const bFreshness = b.ebayListings[0]?.lastSeenAt ?? b.ebayListings[0]?.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
return NextResponse.json({
manualFilename,
parts: rankedParts,
cache: overview,
} satisfies ManualPartsMatchResponse)
} catch (error) {
console.error("Failed to load cached eBay matches:", error)
const staticManualParts = await staticManualPartsPromise
return NextResponse.json(
{
manualFilename,
parts: parts.map((part: MatchPart) => {
const staticListings = getStaticListingsForPart(part, staticManualParts)
const ebayListings = rankListingsForPart(
part,
mergeListings(part.ebayListings || [], staticListings),
limit
)
return {
...part,
ebayListings,
}
}),
cache: getCacheFallback(
error instanceof Error
? `Using bundled manual cache because cached listings failed: ${error.message}`
: "Using bundled manual cache because cached listings failed."
),
},
{ status: 200 }
)
}
}

View file

@ -1,167 +1,40 @@
import { NextRequest, NextResponse } from "next/server"
/**
* eBay API Proxy Route
* Proxies requests to eBay Finding API to avoid CORS issues
*/
interface eBaySearchParams {
keywords: string
categoryId?: string
sortOrder?: string
maxResults?: number
}
interface eBaySearchResult {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}
type MaybeArray<T> = T | T[]
const SEARCH_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const searchResponseCache = new Map<
string,
{ results: eBaySearchResult[]; timestamp: number }
>()
const inFlightSearchResponses = new Map<string, Promise<eBaySearchResult[]>>()
// Affiliate campaign ID for generating links
const AFFILIATE_CAMPAIGN_ID =
process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
// Generate eBay affiliate link
function generateAffiliateLink(viewItemUrl: string): string {
if (!AFFILIATE_CAMPAIGN_ID) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", AFFILIATE_CAMPAIGN_ID)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
function first<T>(value: MaybeArray<T> | undefined): T | undefined {
if (!value) {
return undefined
}
return Array.isArray(value) ? value[0] : value
}
function normalizeItem(item: any): eBaySearchResult {
const currentPrice = first(item.sellingStatus?.currentPrice)
const shippingCost = first(item.shippingInfo?.shippingServiceCost)
const condition = first(item.condition)
const viewItemUrl = item.viewItemURL || item.viewItemUrl || ""
import { NextResponse } from "next/server"
import { fetchQuery } from "convex/nextjs"
import { api } from "@/convex/_generated/api"
import { hasConvexUrl } from "@/lib/convex-config"
import {
rankListingsForQuery,
type CachedEbayListing,
type EbayCacheState,
} from "@/lib/ebay-parts-match"
import { searchStaticEbayListings } from "@/lib/server/manual-parts-data"
function getCacheStateFallback(message?: string): EbayCacheState {
return {
itemId: item.itemId || "",
title: item.title || "Unknown Item",
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: first(item.galleryURL) || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined,
affiliateLink: generateAffiliateLink(viewItemUrl),
key: "manual-parts",
status: "success",
lastSuccessfulAt: Date.now(),
lastAttemptAt: null,
nextEligibleAt: null,
lastError: null,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: 0,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message: message || "Using bundled manual cache.",
}
}
async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => "")
if (!text.trim()) {
return `eBay API error: ${response.status}`
}
try {
const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId)
? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
} catch {
// Fall through to returning the raw text below.
}
return text.trim() || `eBay API error: ${response.status}`
}
function buildCacheKey(
keywords: string,
categoryId: string | undefined,
sortOrder: string,
maxResults: number
): string {
return [
keywords.trim().toLowerCase(),
categoryId || "",
sortOrder || "BestMatch",
String(maxResults),
].join("|")
}
function getCachedSearchResults(cacheKey: string): eBaySearchResult[] | null {
const cached = searchResponseCache.get(cacheKey)
if (!cached) {
return null
}
if (Date.now() - cached.timestamp > SEARCH_CACHE_TTL) {
searchResponseCache.delete(cacheKey)
return null
}
return cached.results
}
function setCachedSearchResults(cacheKey: string, results: eBaySearchResult[]) {
searchResponseCache.set(cacheKey, {
results,
timestamp: Date.now(),
})
}
export async function GET(request: NextRequest) {
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const keywords = searchParams.get("keywords")
const categoryId = searchParams.get("categoryId") || undefined
const sortOrder = searchParams.get("sortOrder") || "BestMatch"
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
const cacheKey = buildCacheKey(
keywords || "",
categoryId,
sortOrder,
maxResults
const keywords = searchParams.get("keywords")?.trim() || ""
const maxResults = Math.min(
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
20
)
if (!keywords) {
@ -171,114 +44,46 @@ export async function GET(request: NextRequest) {
)
}
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
console.error("EBAY_APP_ID not configured")
return NextResponse.json(
{
error:
"eBay API not configured. Please set EBAY_APP_ID environment variable.",
},
{ status: 503 }
)
}
const cachedResults = getCachedSearchResults(cacheKey)
if (cachedResults) {
return NextResponse.json(cachedResults)
}
const inFlight = inFlightSearchResponses.get(cacheKey)
if (inFlight) {
try {
const results = await inFlight
return NextResponse.json(results)
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
},
{ status: 500 }
)
}
}
// Build eBay Finding API URL
const baseUrl = "https://svcs.ebay.com/services/search/FindingService/v1"
const url = new URL(baseUrl)
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set("keywords", keywords)
url.searchParams.set("sortOrder", sortOrder)
url.searchParams.set("paginationInput.entriesPerPage", maxResults.toString())
if (categoryId) {
url.searchParams.set("categoryId", categoryId)
if (!hasConvexUrl()) {
const staticResults = await searchStaticEbayListings(keywords, maxResults)
return NextResponse.json({
query: keywords,
results: staticResults,
cache: getCacheStateFallback(),
})
}
try {
const request = (async () => {
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
})
const [overview, listings] = await Promise.all([
fetchQuery(api.ebay.getCacheOverview, {}),
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
])
if (!response.ok) {
const errorMessage = await readEbayErrorMessage(response)
throw new Error(errorMessage)
}
const ranked = rankListingsForQuery(
keywords,
listings as CachedEbayListing[],
maxResults
)
const data = await response.json()
// Parse eBay API response
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
if (!findItemsAdvancedResponse) {
return []
}
const searchResult = findItemsAdvancedResponse.searchResult?.[0]
if (
!searchResult ||
!searchResult.item ||
searchResult.item.length === 0
) {
return []
}
const items = Array.isArray(searchResult.item)
? searchResult.item
: [searchResult.item]
return items.map((item: any) => normalizeItem(item))
})()
inFlightSearchResponses.set(cacheKey, request)
const results = await request
setCachedSearchResults(cacheKey, results)
return NextResponse.json(results)
return NextResponse.json({
query: keywords,
results: ranked,
cache: overview,
})
} catch (error) {
console.error("Error fetching from eBay API:", error)
console.error("Failed to load cached eBay listings:", error)
const staticResults = await searchStaticEbayListings(keywords, maxResults)
return NextResponse.json(
{
error:
query: keywords,
results: staticResults,
cache: getCacheStateFallback(
error instanceof Error
? error.message
: "Failed to fetch products from eBay",
? `Using bundled manual cache because cached listings failed: ${error.message}`
: "Using bundled manual cache because cached listings failed."
),
},
{ status: 500 }
{ status: 200 }
)
} finally {
inFlightSearchResponses.delete(cacheKey)
}
}

View file

@ -40,18 +40,13 @@ import {
} from "@/lib/manuals-config"
import { ManualViewer } from "@/components/manual-viewer"
import { getManualsWithParts } from "@/lib/parts-lookup"
import { ebayClient } from "@/lib/ebay-api"
import type { CachedEbayListing, EbayCacheState } from "@/lib/ebay-parts-match"
// Product Suggestion Component
interface ProductSuggestion {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
affiliateLink: string
condition?: string
interface ProductSuggestionsResponse {
query: string
results: CachedEbayListing[]
cache: EbayCacheState
error?: string
}
interface ProductSuggestionsProps {
@ -63,33 +58,55 @@ function ProductSuggestions({
manual,
className = "",
}: ProductSuggestionsProps) {
const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
const [suggestions, setSuggestions] = useState<CachedEbayListing[]>([])
const [cache, setCache] = useState<EbayCacheState | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!ebayClient.isConfigured()) {
setIsLoading(false)
return
}
async function loadSuggestions() {
setIsLoading(true)
setError(null)
try {
// Generate search query from manual content
const query = `${manual.manufacturer} ${manual.category} vending machine`
const results = await ebayClient.searchItems({
const query = [
manual.manufacturer,
manual.category,
manual.commonNames?.[0],
manual.searchTerms?.[0],
"vending machine",
]
.filter(Boolean)
.join(" ")
const params = new URLSearchParams({
keywords: query,
maxResults: 6,
maxResults: "6",
sortOrder: "BestMatch",
})
setSuggestions(results)
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = (await response.json().catch(() => null)) as
| ProductSuggestionsResponse
| null
if (!response.ok || !body) {
throw new Error(
body && typeof body.error === "string"
? body.error
: `Failed to load cached listings (${response.status})`
)
}
setSuggestions(Array.isArray(body.results) ? body.results : [])
setCache(body.cache || null)
} catch (err) {
console.error("Error loading product suggestions:", err)
setError("Could not load product suggestions")
setSuggestions([])
setCache(null)
setError(
err instanceof Error ? err.message : "Could not load product suggestions"
)
} finally {
setIsLoading(false)
}
@ -100,10 +117,6 @@ function ProductSuggestions({
}
}, [manual])
if (!ebayClient.isConfigured()) {
return null
}
if (isLoading) {
return (
<div
@ -121,9 +134,16 @@ function ProductSuggestions({
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center justify-center h-32">
<AlertCircle className="h-6 w-6 text-red-500" />
<span className="ml-2 text-sm text-red-500">{error}</span>
<div className="flex flex-col items-center justify-center gap-2 h-32 text-center">
<AlertCircle className="h-6 w-6 text-yellow-600" />
<span className="text-sm text-yellow-700 dark:text-yellow-200">
{error}
</span>
{cache?.lastSuccessfulAt ? (
<span className="text-[11px] text-yellow-600/80 dark:text-yellow-200/70">
Last refreshed {new Date(cache.lastSuccessfulAt).toLocaleString()}
</span>
) : null}
</div>
</div>
)
@ -134,10 +154,15 @@ function ProductSuggestions({
<div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`}
>
<div className="flex items-center justify-center h-32">
<div className="flex flex-col items-center justify-center gap-2 h-32 text-center">
<AlertCircle className="h-6 w-6 text-yellow-500" />
<span className="ml-2 text-sm text-yellow-600">
No products found in sandbox environment
<span className="text-sm text-yellow-700 dark:text-yellow-200">
No cached eBay matches yet
</span>
<span className="text-[11px] text-yellow-600/80 dark:text-yellow-200/70">
{cache?.isStale
? "The background poll is behind, so this manual is showing the last known cache."
: "Try again after the next periodic cache refresh."}
</span>
</div>
</div>
@ -154,6 +179,14 @@ function ProductSuggestions({
Related Products
</h3>
</div>
{cache && (
<div className="mb-3 text-[11px] text-yellow-700/80 dark:text-yellow-200/70">
{cache.lastSuccessfulAt
? `Cache refreshed ${new Date(cache.lastSuccessfulAt).toLocaleString()}`
: "Cache is warming up"}
{cache.isStale ? " • stale cache" : ""}
</div>
)}
<div className="grid grid-cols-2 gap-3">
{suggestions.map((product) => (

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react"
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
interface PartsPanelProps {
@ -17,6 +18,26 @@ export function PartsPanel({
const [parts, setParts] = useState<PartForPage[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cache, setCache] = useState<EbayCacheState | null>(null)
const formatFreshness = (value: number | null) => {
if (!value) {
return "not refreshed yet"
}
const minutes = Math.max(0, Math.floor(value / 60000))
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
return `${days}d ago`
}
const loadParts = useCallback(async () => {
setIsLoading(true)
@ -26,9 +47,11 @@ export function PartsPanel({
const result = await getTopPartsForManual(manualFilename, 5)
setParts(result.parts)
setError(result.error ?? null)
setCache(result.cache ?? null)
} catch (err) {
console.error("Error loading parts:", err)
setParts([])
setCache(null)
setError("Could not load parts")
} finally {
setIsLoading(false)
@ -42,6 +65,7 @@ export function PartsPanel({
}, [loadParts, manualFilename])
const hasListings = parts.some((part) => part.ebayListings.length > 0)
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
const renderStatusCard = (title: string, message: string) => (
<div className={`flex flex-col h-full ${className}`}>
@ -83,6 +107,14 @@ export function PartsPanel({
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@ -107,12 +139,20 @@ export function PartsPanel({
return (
<div className={`flex flex-col h-full ${className}`}>
<div className="px-3 py-2 border-b border-yellow-300/20 flex-shrink-0 bg-yellow-100/50 dark:bg-yellow-900/30">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5">
<ShoppingCart className="h-3.5 w-3.5 text-yellow-900 dark:text-yellow-100" />
<span className="text-xs font-semibold text-yellow-900 dark:text-yellow-100">
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
@ -132,10 +172,18 @@ export function PartsPanel({
Parts
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center">
<AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
No live eBay matches found for these parts yet
No cached eBay matches found for these parts yet
</div>
</div>
)
@ -150,6 +198,14 @@ export function PartsPanel({
Parts ({parts.length})
</span>
</div>
{cache && (
<div className="mt-1 text-[10px] text-yellow-900/70 dark:text-yellow-100/70">
{cache.lastSuccessfulAt
? `Cache updated ${cacheFreshnessText}`
: "Cache warming up"}
{cache.isStale ? " • stale" : ""}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
@ -159,15 +215,10 @@ export function PartsPanel({
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium">
Live eBay listings are unavailable right now.
Cached eBay listings are unavailable right now.
</p>
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{error.includes("eBay API not configured")
? "Set EBAY_APP_ID in the app environment, then reload the panel."
: error.toLowerCase().includes("rate limit") ||
error.toLowerCase().includes("exceeded")
? "eBay is temporarily rate-limited. Reload after a short wait."
: error}
{error}
</p>
</div>
</div>

13
convex/crons.ts Normal file
View file

@ -0,0 +1,13 @@
import { cronJobs } from "convex/server"
import { internal } from "./_generated/api"
const crons = cronJobs()
crons.interval(
"ebay-manual-parts-refresh",
{ hours: 24 },
internal.ebay.refreshCache,
{ reason: "cron" }
)
export default crons

536
convex/ebay.ts Normal file
View file

@ -0,0 +1,536 @@
// @ts-nocheck
import { action, internalMutation, query } from "./_generated/server"
import { api, internal } from "./_generated/api"
import { v } from "convex/values"
const POLL_KEY = "manual-parts"
const LISTING_EXPIRES_MS = 14 * 24 * 60 * 60 * 1000
const STALE_AFTER_MS = 36 * 60 * 60 * 1000
const BASE_REFRESH_MS = 24 * 60 * 60 * 1000
const MAX_BACKOFF_MS = 7 * 24 * 60 * 60 * 1000
const MAX_RESULTS_PER_QUERY = 8
const MAX_UNIQUE_RESULTS = 48
const POLL_QUERIES = [
{
label: "vending machine parts",
keywords: "vending machine part",
categoryId: "11700",
},
{
label: "coin mech",
keywords: "coin mech vending",
categoryId: "11700",
},
{
label: "control board",
keywords: "vending machine control board",
categoryId: "11700",
},
{
label: "snack machine parts",
keywords: "snack machine part",
categoryId: "11700",
},
{
label: "beverage machine parts",
keywords: "beverage machine part",
categoryId: "11700",
},
] as const
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
}
function buildAffiliateLink(viewItemUrl: string): string {
const campaignId = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || ""
if (!campaignId) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", campaignId)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
function firstValue<T>(value: T | T[] | undefined): T | undefined {
if (value === undefined) {
return undefined
}
return Array.isArray(value) ? value[0] : value
}
function isRateLimitError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("10001") ||
normalized.includes("rate limit") ||
normalized.includes("too many requests") ||
normalized.includes("exceeded the number of times") ||
normalized.includes("quota")
)
}
function getBackoffMs(consecutiveFailures: number, rateLimited: boolean) {
const base = rateLimited ? 2 * BASE_REFRESH_MS : BASE_REFRESH_MS / 2
const multiplier = Math.max(1, consecutiveFailures)
return Math.min(base * multiplier, MAX_BACKOFF_MS)
}
async function readEbayErrorMessage(response: Response) {
const text = await response.text().catch(() => "")
if (!text.trim()) {
return `eBay API error: ${response.status}`
}
try {
const parsed = JSON.parse(text) as any
const messages = parsed?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(messages?.message)
? messages.message[0]
: messages?.message
if (typeof message === "string" && message.trim()) {
const errorId = Array.isArray(messages?.errorId)
? messages.errorId[0]
: messages?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
} catch {
// Fall through to returning raw text.
}
return text.trim() || `eBay API error: ${response.status}`
}
function readEbayJsonError(data: any): string | null {
const errorMessage = data?.errorMessage?.[0]?.error?.[0]
const message = Array.isArray(errorMessage?.message)
? errorMessage.message[0]
: errorMessage?.message
if (typeof message !== "string" || !message.trim()) {
return null
}
const errorId = Array.isArray(errorMessage?.errorId)
? errorMessage.errorId[0]
: errorMessage?.errorId
return errorId
? `eBay API error ${errorId}: ${message}`
: `eBay API error: ${message}`
}
function normalizeEbayItem(item: any, fetchedAt: number) {
const currentPrice = firstValue(item?.sellingStatus?.currentPrice)
const shippingCost = firstValue(item?.shippingInfo?.shippingServiceCost)
const condition = firstValue(item?.condition)
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
const title = item?.title || "Unknown Item"
return {
itemId: String(item?.itemId || ""),
title,
normalizedTitle: normalizeText(title),
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: item?.galleryURL || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: undefined,
affiliateLink: buildAffiliateLink(viewItemUrl),
sourceQueries: [] as string[],
fetchedAt,
firstSeenAt: fetchedAt,
lastSeenAt: fetchedAt,
expiresAt: fetchedAt + LISTING_EXPIRES_MS,
active: true,
}
}
async function searchEbayListings(query: (typeof POLL_QUERIES)[number]) {
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
throw new Error("eBay App ID is not configured")
}
const url = new URL("https://svcs.ebay.com/services/search/FindingService/v1")
url.searchParams.set("OPERATION-NAME", "findItemsAdvanced")
url.searchParams.set("SERVICE-VERSION", "1.0.0")
url.searchParams.set("SECURITY-APPNAME", appId)
url.searchParams.set("RESPONSE-DATA-FORMAT", "JSON")
url.searchParams.set("REST-PAYLOAD", "true")
url.searchParams.set("keywords", query.keywords)
url.searchParams.set("sortOrder", "BestMatch")
url.searchParams.set("paginationInput.entriesPerPage", String(MAX_RESULTS_PER_QUERY))
if (query.categoryId) {
url.searchParams.set("categoryId", query.categoryId)
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
throw new Error(await readEbayErrorMessage(response))
}
const data = await response.json()
const jsonError = readEbayJsonError(data)
if (jsonError) {
throw new Error(jsonError)
}
const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0]
const searchResult = findItemsAdvancedResponse?.searchResult?.[0]
const items = Array.isArray(searchResult?.item)
? searchResult.item
: searchResult?.item
? [searchResult.item]
: []
return items
}
export const getCacheOverview = query({
args: {},
handler: async (ctx) => {
const now = Date.now()
const state =
(await ctx.db
.query("ebayPollState")
.withIndex("by_key", (q) => q.eq("key", POLL_KEY))
.unique()) || null
const listings = await ctx.db.query("ebayListings").collect()
const activeListings = listings.filter(
(listing) => listing.active && listing.expiresAt >= now
)
const freshnessMs = state?.lastSuccessfulAt
? Math.max(0, now - state.lastSuccessfulAt)
: null
return {
key: POLL_KEY,
status: state?.status || "idle",
lastSuccessfulAt: state?.lastSuccessfulAt || null,
lastAttemptAt: state?.lastAttemptAt || null,
nextEligibleAt: state?.nextEligibleAt || null,
lastError: state?.lastError || null,
consecutiveFailures: state?.consecutiveFailures || 0,
queryCount: state?.queryCount || 0,
itemCount: state?.itemCount || 0,
sourceQueries: state?.sourceQueries || [],
freshnessMs,
isStale: freshnessMs !== null ? freshnessMs >= STALE_AFTER_MS : true,
listingCount: listings.length,
activeListingCount: activeListings.length,
}
},
})
export const listCachedListings = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now()
const listings = await ctx.db.query("ebayListings").collect()
const normalized = listings.map((listing) => ({
...listing,
active: listing.active && listing.expiresAt >= now,
}))
normalized.sort((a, b) => {
if (a.active !== b.active) {
return Number(b.active) - Number(a.active)
}
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
return bFreshness - aFreshness
})
return typeof args.limit === "number" && args.limit > 0
? normalized.slice(0, args.limit)
: normalized
},
})
export const refreshCache = action({
args: {
reason: v.optional(v.string()),
force: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const now = Date.now()
const state =
(await ctx.runQuery(api.ebay.getCacheOverview, {})) || ({
status: "idle",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: null,
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
} as const)
if (
!args.force &&
typeof state.nextEligibleAt === "number" &&
state.nextEligibleAt > now
) {
return {
status: "skipped",
message: "Refresh is deferred until the next eligible window.",
nextEligibleAt: state.nextEligibleAt,
}
}
const appId = process.env.EBAY_APP_ID?.trim()
if (!appId) {
const nextEligibleAt = now + BASE_REFRESH_MS
await ctx.runMutation(internal.ebay.upsertPollResult, {
key: POLL_KEY,
status: "missing_config",
lastAttemptAt: now,
lastSuccessfulAt: state.lastSuccessfulAt || null,
nextEligibleAt,
lastError: "EBAY_APP_ID is not configured.",
consecutiveFailures: state.consecutiveFailures + 1,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
listings: [],
reason: args.reason || "missing_config",
})
return {
status: "missing_config",
message: "EBAY_APP_ID is not configured.",
nextEligibleAt,
}
}
const sourceQueries: string[] = []
const collectedListings = new Map<string, ReturnType<typeof normalizeEbayItem>>()
let queryCount = 0
let rateLimited = false
let lastError: string | null = null
for (const query of POLL_QUERIES) {
if (collectedListings.size >= MAX_UNIQUE_RESULTS) {
break
}
queryCount += 1
sourceQueries.push(query.label)
try {
const items = await searchEbayListings(query)
for (const item of items) {
const listing = normalizeEbayItem(item, now)
if (!listing.itemId) {
continue
}
const existing = collectedListings.get(listing.itemId)
if (existing) {
existing.sourceQueries = Array.from(
new Set([...existing.sourceQueries, query.label])
)
existing.title = listing.title || existing.title
existing.normalizedTitle = normalizeText(existing.title)
existing.price = listing.price || existing.price
existing.currency = listing.currency || existing.currency
existing.imageUrl = listing.imageUrl || existing.imageUrl
existing.viewItemUrl = listing.viewItemUrl || existing.viewItemUrl
existing.condition = listing.condition || existing.condition
existing.shippingCost = listing.shippingCost || existing.shippingCost
existing.affiliateLink = listing.affiliateLink || existing.affiliateLink
existing.lastSeenAt = now
existing.fetchedAt = now
existing.expiresAt = now + LISTING_EXPIRES_MS
existing.active = true
continue
}
listing.sourceQueries = [query.label]
collectedListings.set(listing.itemId, listing)
}
} catch (error) {
lastError = error instanceof Error ? error.message : "Failed to refresh eBay listings"
if (isRateLimitError(lastError)) {
rateLimited = true
break
}
}
}
const listings = Array.from(collectedListings.values())
const nextEligibleAt = now + getBackoffMs(
state.consecutiveFailures + 1,
rateLimited
)
await ctx.runMutation(internal.ebay.upsertPollResult, {
key: POLL_KEY,
status: rateLimited
? "rate_limited"
: lastError
? "error"
: "success",
lastAttemptAt: now,
lastSuccessfulAt: rateLimited || lastError ? state.lastSuccessfulAt || null : now,
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
lastError: lastError || null,
consecutiveFailures:
rateLimited || lastError ? state.consecutiveFailures + 1 : 0,
queryCount,
itemCount: listings.length,
sourceQueries,
listings,
reason: args.reason || "cron",
})
return {
status: rateLimited ? "rate_limited" : lastError ? "error" : "success",
message: lastError || undefined,
queryCount,
itemCount: listings.length,
nextEligibleAt: rateLimited || lastError ? nextEligibleAt : now + BASE_REFRESH_MS,
}
},
})
export const upsertPollResult = internalMutation({
args: {
key: v.string(),
status: v.union(
v.literal("idle"),
v.literal("success"),
v.literal("rate_limited"),
v.literal("error"),
v.literal("missing_config"),
v.literal("skipped")
),
lastAttemptAt: v.number(),
lastSuccessfulAt: v.union(v.number(), v.null()),
nextEligibleAt: v.union(v.number(), v.null()),
lastError: v.union(v.string(), v.null()),
consecutiveFailures: v.number(),
queryCount: v.number(),
itemCount: v.number(),
sourceQueries: v.array(v.string()),
listings: v.array(
v.object({
itemId: v.string(),
title: v.string(),
normalizedTitle: v.string(),
price: v.string(),
currency: v.string(),
imageUrl: v.optional(v.string()),
viewItemUrl: v.string(),
condition: v.optional(v.string()),
shippingCost: v.optional(v.string()),
affiliateLink: v.string(),
sourceQueries: v.array(v.string()),
fetchedAt: v.number(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
expiresAt: v.number(),
active: v.boolean(),
})
),
reason: v.optional(v.string()),
},
handler: async (ctx, args) => {
for (const listing of args.listings) {
const existing = await ctx.db
.query("ebayListings")
.withIndex("by_itemId", (q) => q.eq("itemId", listing.itemId))
.unique()
if (existing) {
await ctx.db.patch(existing._id, {
title: listing.title,
normalizedTitle: listing.normalizedTitle,
price: listing.price,
currency: listing.currency,
imageUrl: listing.imageUrl,
viewItemUrl: listing.viewItemUrl,
condition: listing.condition,
shippingCost: listing.shippingCost,
affiliateLink: listing.affiliateLink,
sourceQueries: Array.from(
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
),
fetchedAt: listing.fetchedAt,
firstSeenAt: existing.firstSeenAt || listing.firstSeenAt,
lastSeenAt: listing.lastSeenAt,
expiresAt: listing.expiresAt,
active: listing.active,
})
continue
}
await ctx.db.insert("ebayListings", listing)
}
const existingState = await ctx.db
.query("ebayPollState")
.withIndex("by_key", (q) => q.eq("key", args.key))
.unique()
const stateRecord: Record<string, any> = {
key: args.key,
status: args.status,
lastAttemptAt: args.lastAttemptAt,
consecutiveFailures: args.consecutiveFailures,
queryCount: args.queryCount,
itemCount: args.itemCount,
sourceQueries: args.sourceQueries,
updatedAt: Date.now(),
}
if (args.lastSuccessfulAt !== null) {
stateRecord.lastSuccessfulAt = args.lastSuccessfulAt
}
if (args.nextEligibleAt !== null) {
stateRecord.nextEligibleAt = args.nextEligibleAt
}
if (args.lastError !== null) {
stateRecord.lastError = args.lastError
}
if (existingState) {
await ctx.db.patch(existingState._id, stateRecord)
return await ctx.db.get(existingState._id)
}
const id = await ctx.db.insert("ebayPollState", stateRecord)
return await ctx.db.get(id)
},
})

View file

@ -90,6 +90,50 @@ export default defineSchema({
.index("by_category", ["category"])
.index("by_path", ["path"]),
ebayListings: defineTable({
itemId: v.string(),
title: v.string(),
normalizedTitle: v.string(),
price: v.string(),
currency: v.string(),
imageUrl: v.optional(v.string()),
viewItemUrl: v.string(),
condition: v.optional(v.string()),
shippingCost: v.optional(v.string()),
affiliateLink: v.string(),
sourceQueries: v.array(v.string()),
fetchedAt: v.number(),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
expiresAt: v.number(),
active: v.boolean(),
})
.index("by_itemId", ["itemId"])
.index("by_active", ["active"])
.index("by_expiresAt", ["expiresAt"])
.index("by_lastSeenAt", ["lastSeenAt"]),
ebayPollState: defineTable({
key: v.string(),
status: v.union(
v.literal("idle"),
v.literal("success"),
v.literal("rate_limited"),
v.literal("error"),
v.literal("missing_config"),
v.literal("skipped")
),
lastSuccessfulAt: v.optional(v.number()),
lastAttemptAt: v.optional(v.number()),
nextEligibleAt: v.optional(v.number()),
lastError: v.optional(v.string()),
consecutiveFailures: v.number(),
queryCount: v.number(),
itemCount: v.number(),
sourceQueries: v.array(v.string()),
updatedAt: v.number(),
}).index("by_key", ["key"]),
manualCategories: defineTable({
name: v.string(),
slug: v.string(),

View file

@ -0,0 +1,52 @@
# eBay Cache Diagnosis
Use this when the manuals/parts experience looks empty or stale and you want to know whether the problem is env, Convex, cache data, or the browser UI.
## What It Checks
- Public pages: `/`, `/contact-us`, `/products`, `/manuals`
- eBay cache routes:
- `GET /api/ebay/search?keywords=vending machine part`
- `POST /api/ebay/manual-parts`
- Notification validation:
- `GET /api/ebay/notifications?challenge_code=...`
- Admin refresh:
- `POST /api/admin/ebay/refresh` when an admin token is provided
- Browser smoke:
- Loads `/manuals`
- Opens the AP parts manual viewer
- Confirms the viewer or fallback state is visible
## How To Run
Local:
```bash
pnpm diagnose:ebay
```
Staging:
```bash
pnpm diagnose:ebay --base-url https://rmv.abundancepartners.app --admin-token "$ADMIN_API_TOKEN"
```
You can skip browser checks if Playwright browsers are unavailable:
```bash
SMOKE_SKIP_BROWSER=1 pnpm diagnose:ebay
```
## How To Read The Output
- `NEXT_PUBLIC_CONVEX_URL missing`
- The cache routes will intentionally fall back to `status: disabled`.
- `cache.status=disabled` with `Server Error`
- The app reached Convex, but the backend/query layer failed.
- `cache.status=success` or `idle`
- The cache backend is reachable. If listings are `0`, the cache is simply empty.
- Notification challenge returns `200`
- The eBay validation endpoint is wired correctly.
- Admin refresh returns `2xx`
- The cache seeding path is available and the admin token is accepted.

View file

@ -0,0 +1,39 @@
# Jessica Manuals Knowledge
## What feeds the manuals knowledge layer
- Primary source: tenant-filtered exports from the shared `manuals-platform` package.
- Rocky consumes `manuals-platform/output/tenants/rocky-mountain-vending/manuals.json`.
- Rocky consumes `manuals-platform/output/tenants/rocky-mountain-vending/chunks.json`.
- If shared exports are missing in local development, the RMV app can still fall back to its in-repo builder.
## How the corpus is built
- The shared `manuals-platform` package scans the portfolio manuals tree, assigns tenant entitlements, and writes prebuilt artifacts.
- RMV loads the Rocky tenant artifact on first use after process start.
- Public Jessica retrieval is therefore consuming a tenant-filtered export rather than rebuilding the raw manuals corpus itself.
## How new manuals become searchable
- Add or update source PDFs under `manuals-data`.
- Rebuild the shared package artifacts from `manuals-platform` so tenant exports are refreshed.
- Restart the Next.js server or deployment so RMV reloads the updated tenant artifact on first use.
## Cache refresh behavior
- The shared package writes persistent JSON artifacts under `manuals-platform/output`.
- RMV still caches the loaded Rocky tenant artifact in memory.
- A manual cache reset helper exists in `lib/manuals-knowledge.ts` for future admin tooling or deploy hooks.
- Today, the simplest refresh flow is: rebuild shared artifacts, then restart the app.
## Observability
- The site chat route logs a metadata-only manuals retrieval summary before the xAI request.
- The logs include whether retrieval ran, top manual candidate IDs, top chunk citations, clarification state, risk flag, and any retrieval error.
- Full chunk text is not logged.
## Internal debug surface
- Internal endpoint: `GET /api/admin/manuals-knowledge`
- Auth: `x-admin-token` or `Authorization: Bearer <ADMIN_API_TOKEN>`
- Example query:
- `query=RVV 660 service manual`
- optional `manufacturer`
- optional `model`
- optional `manualId`
- optional `page`
- The endpoint returns retrieval summary, matched manuals, top chunks, and citation context for internal inspection only.

View file

@ -0,0 +1,40 @@
# Manuals Qdrant Readiness
## Purpose
- The long-term source of truth for this pipeline is now the shared `manuals-platform` package at the workspace root.
- The RMV repo keeps this document as a consumer-side reference for the tenant-filtered artifacts Rocky reads.
## Source inputs
- Shared package location: `../manuals-platform`
- Shared build outputs: `../manuals-platform/output/full/*`
- Rocky tenant outputs: `../manuals-platform/output/tenants/rocky-mountain-vending/*`
## What the corpus builder does
- The shared package scans the full portfolio manual set, classifies every PDF, assigns tenant entitlements, and publishes tenant-filtered Qdrant-ready artifacts.
- It keeps `public_safe` and `internal_tech` retrieval profiles on top of one central corpus.
- Rocky consumes the prebuilt Rocky tenant export instead of rebuilding from raw manuals data inside the app.
## Build and evaluation commands
- Build artifacts:
- `pnpm manuals:qdrant:build`
- Build artifacts into a custom directory:
- `pnpm manuals:qdrant:build -- --output-dir /absolute/path`
- Run the evaluation set:
- `pnpm manuals:qdrant:eval`
## Artifact output
- Default output directory: `output/manuals-qdrant`
- Important files:
- `summary.json`
- `manuals.json`
- `chunks.json`
- `chunks-high-confidence.json`
- `chunks-public-safe.json`
- `chunks-internal-tech.json`
- `evaluation-cases.json`
- `evaluation-report.json`
## Operational notes
- The first Qdrant prototype should ingest `chunks-high-confidence.json` or `chunks-internal-tech.json`, not the full raw corpus.
- Public-facing experiences should stay on `public_safe` filters even after Qdrant is introduced.
- After manuals-data changes, rebuild the artifacts so the new normalized corpus and evaluation report stay in sync.

424
lib/ebay-parts-match.ts Normal file
View file

@ -0,0 +1,424 @@
export type CachedEbayListing = {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
normalizedTitle?: string
sourceQueries?: string[]
fetchedAt?: number
firstSeenAt?: number
lastSeenAt?: number
expiresAt?: number
active?: boolean
}
export type ManualPartInput = {
partNumber: string
description: string
manufacturer?: string
category?: string
manualFilename?: string
}
export type EbayCacheState = {
key: string
status:
| "idle"
| "success"
| "rate_limited"
| "error"
| "missing_config"
| "skipped"
| "disabled"
lastSuccessfulAt: number | null
lastAttemptAt: number | null
nextEligibleAt: number | null
lastError: string | null
consecutiveFailures: number
queryCount: number
itemCount: number
sourceQueries: string[]
freshnessMs: number | null
isStale: boolean
listingCount?: number
activeListingCount?: number
message?: string
}
const GENERIC_PART_TERMS = new Set([
"and",
"the",
"for",
"with",
"from",
"page",
"part",
"parts",
"number",
"numbers",
"read",
"across",
"refer",
"reference",
"shown",
"figure",
"fig",
"rev",
"revision",
"item",
"items",
"assembly",
"assy",
"machine",
"vending",
])
const COMMON_QUERY_STOPWORDS = new Set([
"a",
"an",
"and",
"for",
"in",
"of",
"the",
"to",
"with",
"vending",
"machine",
"machines",
"part",
"parts",
])
function normalizeText(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim()
}
function tokenize(value: string): string[] {
return Array.from(
new Set(
normalizeText(value)
.split(" ")
.map((token) => token.trim())
.filter((token) => token.length > 1 && !COMMON_QUERY_STOPWORDS.has(token))
)
)
}
function listingSearchText(listing: Pick<CachedEbayListing, "title" | "normalizedTitle">): string {
return normalizeText(listing.normalizedTitle || listing.title)
}
function isListingFresh(listing: CachedEbayListing): boolean {
if (listing.active === false) {
return false
}
if (typeof listing.expiresAt === "number") {
return listing.expiresAt >= Date.now()
}
return true
}
function sourceQueryBonus(listing: CachedEbayListing, queryTerms: string[]): number {
if (!listing.sourceQueries || listing.sourceQueries.length === 0) {
return 0
}
const sourceText = listing.sourceQueries.map((query) => normalizeText(query)).join(" ")
let bonus = 0
for (const term of queryTerms) {
if (sourceText.includes(term)) {
bonus += 3
}
}
return bonus
}
function computeTokenOverlapScore(queryTerms: string[], haystackText: string): number {
let score = 0
for (const term of queryTerms) {
if (haystackText.includes(term)) {
score += 8
}
}
return score
}
function scoreListingForPart(part: ManualPartInput, listing: CachedEbayListing): number {
const partNumber = normalizeText(part.partNumber)
const description = normalizeText(part.description)
const manufacturer = normalizeText(part.manufacturer || "")
const category = normalizeText(part.category || "")
const titleText = listingSearchText(listing)
const listingTokens = tokenize(listing.title)
const descriptionTokens = tokenize(part.description)
const manufacturerTokens = tokenize(part.manufacturer || "")
const categoryTokens = tokenize(part.category || "")
let score = isListingFresh(listing) ? 10 : -6
if (!partNumber) {
return -100
}
if (titleText.includes(partNumber)) {
score += 110
}
const compactPartNumber = partNumber.replace(/\s+/g, "")
const compactTitle = titleText.replace(/\s+/g, "")
if (compactPartNumber && compactTitle.includes(compactPartNumber)) {
score += 90
}
const exactTokenMatch = listingTokens.includes(partNumber)
if (exactTokenMatch) {
score += 80
}
const digitsOnlyPart = partNumber.replace(/[^0-9]/g, "")
if (digitsOnlyPart.length >= 4 && compactTitle.includes(digitsOnlyPart)) {
score += 40
}
if (description) {
const overlap = descriptionTokens.filter((token) => titleText.includes(token)).length
score += Math.min(overlap * 7, 28)
}
if (manufacturer) {
score += Math.min(
manufacturerTokens.filter((token) => titleText.includes(token)).length * 8,
24
)
}
if (category) {
score += Math.min(
categoryTokens.filter((token) => titleText.includes(token)).length * 5,
10
)
}
score += computeTokenOverlapScore(tokenize(part.partNumber), titleText)
score += sourceQueryBonus(listing, [
partNumber,
...descriptionTokens,
...manufacturerTokens,
...categoryTokens,
])
if (GENERIC_PART_TERMS.has(partNumber)) {
score -= 50
}
if (titleText.includes("vending") || titleText.includes("machine")) {
score += 6
}
if (listing.condition && /new|used|refurbished/i.test(listing.condition)) {
score += 2
}
return score
}
function scoreListingForQuery(query: string, listing: CachedEbayListing): number {
const queryText = normalizeText(query)
const titleText = listingSearchText(listing)
const queryTerms = tokenize(query)
let score = isListingFresh(listing) ? 10 : -6
if (!queryText) {
return -100
}
if (titleText.includes(queryText)) {
score += 70
}
score += computeTokenOverlapScore(queryTerms, titleText)
score += sourceQueryBonus(listing, queryTerms)
if (queryTerms.some((term) => titleText.includes(term))) {
score += 20
}
if (titleText.includes("vending")) {
score += 8
}
if (GENERIC_PART_TERMS.has(queryText)) {
score -= 30
}
return score
}
export function rankListingsForPart(
part: ManualPartInput,
listings: CachedEbayListing[],
limit: number
): CachedEbayListing[] {
return listings
.map((listing) => ({
listing,
score: scoreListingForPart(part, listing),
}))
.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score
}
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
.filter((entry) => entry.score > 0)
.map((entry) => entry.listing)
}
export function rankListingsForQuery(
query: string,
listings: CachedEbayListing[],
limit: number
): CachedEbayListing[] {
return listings
.map((listing) => ({
listing,
score: scoreListingForQuery(query, listing),
}))
.sort((a, b) => {
if (a.score !== b.score) {
return b.score - a.score
}
const aFreshness = a.listing.lastSeenAt ?? a.listing.fetchedAt ?? 0
const bFreshness = b.listing.lastSeenAt ?? b.listing.fetchedAt ?? 0
return bFreshness - aFreshness
})
.slice(0, limit)
.filter((entry) => entry.score > 0)
.map((entry) => entry.listing)
}
export function isEbayRateLimitError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("10001") ||
normalized.includes("rate limit") ||
normalized.includes("exceeded the number of times") ||
normalized.includes("too many requests") ||
normalized.includes("quota")
)
}
export function buildAffiliateLink(
viewItemUrl: string,
campaignId?: string | null
): string {
const trimmedCampaignId = campaignId?.trim() || ""
if (!trimmedCampaignId) {
return viewItemUrl
}
try {
const url = new URL(viewItemUrl)
url.searchParams.set("mkcid", "1")
url.searchParams.set("mkrid", "711-53200-19255-0")
url.searchParams.set("siteid", "0")
url.searchParams.set("campid", trimmedCampaignId)
url.searchParams.set("toolid", "10001")
url.searchParams.set("mkevt", "1")
return url.toString()
} catch {
return viewItemUrl
}
}
export function normalizeEbayItem(
item: any,
options?: {
campaignId?: string
sourceQuery?: string
fetchedAt?: number
existing?: CachedEbayListing
expiresAt?: number
}
): CachedEbayListing {
const currentPrice = item?.sellingStatus?.currentPrice
const shippingCost = item?.shippingInfo?.shippingServiceCost
const condition = item?.condition
const viewItemUrl = item?.viewItemURL || item?.viewItemUrl || ""
const title = item?.title || "Unknown Item"
const fetchedAt = options?.fetchedAt ?? Date.now()
const existing = options?.existing
const sourceQueries = Array.from(
new Set([
...(existing?.sourceQueries || []),
...(options?.sourceQuery ? [options.sourceQuery] : []),
])
)
return {
itemId: String(item?.itemId || existing?.itemId || ""),
title,
price: `${currentPrice?.value || "0"} ${currentPrice?.currencyId || "USD"}`,
currency: currentPrice?.currencyId || "USD",
imageUrl: item?.galleryURL || existing?.imageUrl || undefined,
viewItemUrl,
condition: condition?.conditionDisplayName || existing?.condition || undefined,
shippingCost: shippingCost?.value
? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || "USD"}`
: existing?.shippingCost,
affiliateLink: buildAffiliateLink(viewItemUrl, options?.campaignId),
normalizedTitle: normalizeText(title),
sourceQueries,
firstSeenAt: existing?.firstSeenAt ?? fetchedAt,
lastSeenAt: fetchedAt,
fetchedAt,
expiresAt: options?.expiresAt ?? existing?.expiresAt ?? fetchedAt,
active: true,
}
}
export function sortListingsByFreshness(listings: CachedEbayListing[]): CachedEbayListing[] {
return [...listings].sort((a, b) => {
const aActive = a.active === false ? 0 : 1
const bActive = b.active === false ? 0 : 1
if (aActive !== bActive) {
return bActive - aActive
}
const aFreshness = a.lastSeenAt ?? a.fetchedAt ?? 0
const bFreshness = b.lastSeenAt ?? b.fetchedAt ?? 0
return bFreshness - aFreshness
})
}
export function estimateListingFreshness(now: number, lastSuccessfulAt?: number) {
if (!lastSuccessfulAt) {
return {
isFresh: false,
isStale: true,
freshnessMs: null as number | null,
}
}
const freshnessMs = Math.max(0, now - lastSuccessfulAt)
return {
isFresh: freshnessMs < 24 * 60 * 60 * 1000,
isStale: freshnessMs >= 24 * 60 * 60 * 1000,
freshnessMs,
}
}

View file

@ -0,0 +1,79 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
findManualCandidates,
getManualCitationContext,
resetManualKnowledgeCache,
retrieveManualContext,
shouldUseManualKnowledgeForChat,
} from "@/lib/manuals-knowledge"
test("shouldUseManualKnowledgeForChat only triggers for relevant conversations", () => {
assert.equal(
shouldUseManualKnowledgeForChat(
"Repairs",
"My Royal machine is not accepting coins"
),
true
)
assert.equal(shouldUseManualKnowledgeForChat("Other", "Hello there"), false)
})
test("findManualCandidates resolves RVV alias queries to Royal Vendors manuals", async () => {
const candidates = await findManualCandidates("RVV 660 service manual")
assert.ok(candidates.length > 0)
assert.equal(candidates[0]?.manufacturer, "Royal Vendors")
assert.match(candidates[0]?.filename || "", /660|700|gii|giii|rvv/i)
})
test("findManualCandidates resolves Narco-style queries to Dixie-Narco manuals", async () => {
const candidates = await findManualCandidates("Narco bevmax not cooling")
assert.ok(candidates.length > 0)
assert.equal(candidates[0]?.manufacturer, "Dixie-Narco")
})
test("retrieveManualContext returns grounded troubleshooting chunks for simple public help", async () => {
const result = await retrieveManualContext("Royal machine not accepting coins")
assert.ok(result.manualCandidates.length > 0)
assert.equal(result.topChunks.length > 0, true)
assert.equal(result.topChunks[0]?.manufacturer, "Royal Vendors")
assert.match(result.topChunks[0]?.text || "", /not accepting coins/i)
assert.equal(result.isRisky, false)
})
test("getManualCitationContext returns citations for a retrieved manual page", async () => {
const result = await retrieveManualContext("Royal machine not accepting coins")
const firstChunk = result.topChunks[0]
assert.ok(firstChunk)
const citationContext = await getManualCitationContext(
firstChunk.manualId,
firstChunk.pageNumber || undefined
)
assert.ok(citationContext.manual)
assert.ok(citationContext.citations.length > 0)
assert.equal(
citationContext.citations.some(
(citation) => citation.pageNumber === firstChunk.pageNumber
),
true
)
})
test("resetManualKnowledgeCache rebuilds the manuals corpus on demand", async () => {
const beforeReset = await findManualCandidates("RVV 660 service manual")
resetManualKnowledgeCache()
const afterReset = await findManualCandidates("RVV 660 service manual")
assert.ok(beforeReset.length > 0)
assert.ok(afterReset.length > 0)
assert.equal(beforeReset[0]?.manufacturer, afterReset[0]?.manufacturer)
assert.equal(beforeReset[0]?.manualId, afterReset[0]?.manualId)
})

1604
lib/manuals-knowledge.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
evaluateManualsQdrantCorpus,
getDefaultManualsQdrantEvaluationCases,
getManualsQdrantCorpus,
resetManualsQdrantCorpusCache,
searchManualsQdrantCorpus,
} from "@/lib/manuals-qdrant-corpus"
const corpusPromise = getManualsQdrantCorpus()
test.after(() => {
resetManualsQdrantCorpusCache()
})
test("manuals qdrant corpus builds from the full structured and extracted datasets", async () => {
const corpus = await corpusPromise
assert.equal(corpus.stats.structuredRecords, 497)
assert.equal(corpus.stats.extractedRecords, 497)
assert.equal(corpus.stats.chunkCount > 20000, true)
assert.equal(corpus.stats.highConfidenceChunks > corpus.stats.fallbackChunks, true)
assert.equal(corpus.manuals.some((manual) => manual.manualId === "unknown-unknown-manual"), false)
})
test("canonical manufacturers cover core vending families after normalization", async () => {
const corpus = await corpusPromise
const manufacturers = new Set(corpus.manuals.map((manual) => manual.manufacturer))
assert.equal(manufacturers.has("Royal Vendors"), true)
assert.equal(manufacturers.has("Dixie-Narco"), true)
assert.equal(manufacturers.has("Crane"), true)
assert.equal(manufacturers.has("AP"), true)
assert.equal(manufacturers.has("Coinco"), true)
assert.equal(manufacturers.has("Other"), true)
})
test("fault queries prefer troubleshooting over brochure content", async () => {
const corpus = await corpusPromise
const results = searchManualsQdrantCorpus(
corpus,
"Royal machine not accepting coins",
{
profile: "public_safe",
limit: 5,
}
)
assert.equal(results.length > 0, true)
assert.equal(results[0]?.chunk.labels.includes("troubleshooting"), true)
assert.equal(results[0]?.chunk.labels.includes("brochure"), false)
assert.equal(results.some((result) => result.chunk.labels.includes("brochure")), false)
})
test("public-safe profile filters risky wiring chunks while internal-tech keeps them available", async () => {
const corpus = await corpusPromise
const publicResults = searchManualsQdrantCorpus(
corpus,
"Royal wiring diagram voltage issue",
{
profile: "public_safe",
limit: 5,
}
)
const internalResults = searchManualsQdrantCorpus(
corpus,
"Royal wiring diagram voltage issue",
{
profile: "internal_tech",
limit: 5,
}
)
assert.equal(
publicResults.some((result) => result.chunk.labels.includes("wiring")),
false
)
assert.equal(
internalResults.some((result) => result.chunk.labels.includes("wiring")),
true
)
})
test("default evaluation set passes before the corpus is treated as production-ready", async () => {
const corpus = await corpusPromise
const evaluation = evaluateManualsQdrantCorpus(
corpus,
getDefaultManualsQdrantEvaluationCases()
)
assert.equal(evaluation.summary.totalCases, 6)
assert.equal(
evaluation.cases.every(
(entry) =>
entry.passedTop3Manufacturer !== false &&
entry.passedTop5Label &&
entry.passedDisallowedCheck
),
true
)
})
test("manuals qdrant corpus cache can be rebuilt on demand", async () => {
const firstCorpus = await getManualsQdrantCorpus()
resetManualsQdrantCorpusCache()
const secondCorpus = await getManualsQdrantCorpus()
assert.notEqual(firstCorpus, secondCorpus)
assert.equal(secondCorpus.stats.structuredRecords, 497)
})

1734
lib/manuals-qdrant-corpus.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,24 +2,20 @@
* Parts lookup utility for frontend
*
* Provides functions to fetch parts data by manual filename.
* Static JSON remains the primary data source, while live eBay fallback
* goes through the server route so credentials never reach the browser.
* Static JSON remains the primary data source, while cached eBay matches
* are fetched from the server so normal browsing never reaches eBay.
*/
import type {
CachedEbayListing,
EbayCacheState,
ManualPartInput,
} from "@/lib/ebay-parts-match"
export interface PartForPage {
partNumber: string
description: string
ebayListings: Array<{
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}>
ebayListings: CachedEbayListing[]
}
interface ManualPartsLookup {
@ -32,30 +28,38 @@ interface ManualPagesParts {
}
}
interface EbaySearchResult {
itemId: string
title: string
price: string
currency: string
imageUrl?: string
viewItemUrl: string
condition?: string
shippingCost?: string
affiliateLink: string
}
interface EbaySearchResponse {
results: EbaySearchResult[]
interface CachedPartsResponse {
manualFilename: string
parts: Array<
ManualPartInput & {
ebayListings: CachedEbayListing[]
}
>
cache: EbayCacheState
error?: string
}
// Cache for eBay search results
const ebaySearchCache = new Map<
interface CachedEbaySearchResponse {
results: CachedEbayListing[]
cache: EbayCacheState
error?: string
}
const cachedManualMatchResponses = new Map<
string,
{ results: EbaySearchResult[]; timestamp: number }
{ response: CachedPartsResponse; timestamp: number }
>()
const inFlightEbaySearches = new Map<string, Promise<EbaySearchResponse>>()
const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes
const inFlightManualMatchRequests = new Map<string, Promise<CachedPartsResponse>>()
const MANUAL_MATCH_CACHE_TTL = 5 * 60 * 1000
const cachedEbaySearchResponses = new Map<
string,
{ response: CachedEbaySearchResponse; timestamp: number }
>()
const inFlightEbaySearches = new Map<
string,
Promise<CachedEbaySearchResponse>
>()
const EBAY_SEARCH_CACHE_TTL = 5 * 60 * 1000
const GENERIC_PART_TERMS = new Set([
"and",
@ -129,121 +133,196 @@ async function loadPartsData(): Promise<{
}
}
/**
* Search eBay for parts with caching.
* This calls the server route so the app never needs direct eBay credentials
* in client code.
*/
function makeFallbackCacheState(errorMessage?: string): EbayCacheState {
return {
key: "manual-parts",
status: "disabled",
lastSuccessfulAt: null,
lastAttemptAt: null,
nextEligibleAt: null,
lastError: errorMessage || "eBay cache unavailable.",
consecutiveFailures: 0,
queryCount: 0,
itemCount: 0,
sourceQueries: [],
freshnessMs: null,
isStale: true,
listingCount: 0,
activeListingCount: 0,
message: errorMessage || "eBay cache unavailable.",
}
}
async function fetchManualPartsMatches(
manualFilename: string,
parts: ManualPartInput[],
limit: number
): Promise<CachedPartsResponse> {
const cacheKey = [
manualFilename.trim().toLowerCase(),
String(limit),
parts
.map((part) =>
[
part.partNumber.trim().toLowerCase(),
part.description.trim().toLowerCase(),
part.manufacturer?.trim().toLowerCase() || "",
part.category?.trim().toLowerCase() || "",
].join(":")
)
.join("|"),
].join("::")
const cached = cachedManualMatchResponses.get(cacheKey)
if (cached && Date.now() - cached.timestamp < MANUAL_MATCH_CACHE_TTL) {
return cached.response
}
const inFlight = inFlightManualMatchRequests.get(cacheKey)
if (inFlight) {
return inFlight
}
const request = (async () => {
try {
const response = await fetch("/api/ebay/manual-parts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
manualFilename,
parts,
limit,
}),
})
const body = await response.json().catch(() => null)
if (!response.ok || !body || typeof body !== "object") {
const message =
body && typeof body.error === "string"
? body.error
: `Failed to load cached parts (${response.status})`
return {
manualFilename,
parts: parts.map((part) => ({
...part,
ebayListings: [],
})),
cache: makeFallbackCacheState(message),
error: message,
}
}
const partsResponse = body as CachedPartsResponse
return {
manualFilename: partsResponse.manualFilename || manualFilename,
parts: Array.isArray(partsResponse.parts) ? partsResponse.parts : [],
cache: partsResponse.cache || makeFallbackCacheState(),
error:
typeof (partsResponse as CachedPartsResponse).error === "string"
? (partsResponse as CachedPartsResponse).error
: undefined,
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load cached parts"
return {
manualFilename,
parts: parts.map((part) => ({
...part,
ebayListings: [],
})),
cache: makeFallbackCacheState(message),
error: message,
}
}
})()
inFlightManualMatchRequests.set(cacheKey, request)
try {
const response = await request
cachedManualMatchResponses.set(cacheKey, {
response,
timestamp: Date.now(),
})
return response
} finally {
inFlightManualMatchRequests.delete(cacheKey)
}
}
async function searchEBayForParts(
partNumber: string,
description?: string,
manufacturer?: string
): Promise<EbaySearchResponse> {
): Promise<CachedEbaySearchResponse> {
const cacheKey = `parts:${partNumber}:${description || ""}:${manufacturer || ""}`
// Check cache
const cached = ebaySearchCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) {
return { results: cached.results as EbaySearchResult[] }
const cached = cachedEbaySearchResponses.get(cacheKey)
if (cached && Date.now() - cached.timestamp < EBAY_SEARCH_CACHE_TTL) {
return cached.response
}
const buildQuery = () => {
let query = partNumber
if (description && description.length > 0 && description.length < 50) {
const descWords = description
.split(/\s+/)
.filter((word) => word.length > 3)
.slice(0, 3)
.join(" ")
if (descWords) {
query += ` ${descWords}`
}
}
if (manufacturer) {
query += ` ${manufacturer}`
}
return `${query} vending machine`
const inFlight = inFlightEbaySearches.get(cacheKey)
if (inFlight) {
return inFlight
}
const searchViaApi = async (
categoryId?: string
): Promise<EbaySearchResponse> => {
const requestKey = `${cacheKey}:${categoryId || "general"}`
const request = (async () => {
try {
const params = new URLSearchParams({
keywords: [partNumber, description, manufacturer, "vending machine"]
.filter(Boolean)
.join(" "),
maxResults: "3",
sortOrder: "BestMatch",
})
const inFlight = inFlightEbaySearches.get(requestKey)
if (inFlight) {
return inFlight
}
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = await response.json().catch(() => null)
const params = new URLSearchParams({
keywords: buildQuery(),
maxResults: "3",
sortOrder: "BestMatch",
})
if (categoryId) {
params.set("categoryId", categoryId)
}
const request = (async () => {
try {
const response = await fetch(`/api/ebay/search?${params.toString()}`)
const body = await response.json().catch(() => null)
if (!response.ok) {
const message =
body && typeof body.error === "string"
? body.error
: `eBay API error: ${response.status}`
return { results: [], error: message }
}
const results = Array.isArray(body) ? body : []
return { results }
} catch (error) {
if (!response.ok || !body || typeof body !== "object") {
const message =
body && typeof body.error === "string"
? body.error
: `Failed to load cached eBay listings (${response.status})`
return {
results: [],
error:
error instanceof Error ? error.message : "Failed to search eBay",
cache: makeFallbackCacheState(message),
error: message,
}
}
})()
inFlightEbaySearches.set(requestKey, request)
try {
return await request
} finally {
inFlightEbaySearches.delete(requestKey)
return {
results: Array.isArray((body as any).results)
? ((body as any).results as CachedEbayListing[])
: [],
cache: (body as any).cache || makeFallbackCacheState(),
error:
typeof (body as any).error === "string" ? (body as any).error : undefined,
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load cached eBay listings"
return {
results: [],
cache: makeFallbackCacheState(message),
error: message,
}
}
}
})()
const categorySearch = await searchViaApi("11700")
if (categorySearch.results.length > 0) {
ebaySearchCache.set(cacheKey, {
results: categorySearch.results,
inFlightEbaySearches.set(cacheKey, request)
try {
const response = await request
cachedEbaySearchResponses.set(cacheKey, {
response,
timestamp: Date.now(),
})
return categorySearch
}
const generalSearch = await searchViaApi()
if (generalSearch.results.length > 0) {
ebaySearchCache.set(cacheKey, {
results: generalSearch.results,
timestamp: Date.now(),
})
return generalSearch
}
return {
results: [],
error: categorySearch.error || generalSearch.error,
return response
} finally {
inFlightEbaySearches.delete(cacheKey)
}
}
@ -490,8 +569,17 @@ export async function getPartsForPage(
return []
}
const enhanced = await enhancePartsData(parts)
return enhanced.parts
const matched = await fetchManualPartsMatches(
manualFilename,
parts.map((part) => ({
partNumber: part.partNumber,
description: part.description,
manualFilename,
})),
Math.max(parts.length, 1)
)
return matched.parts as PartForPage[]
}
/**
@ -503,6 +591,7 @@ export async function getTopPartsForManual(
): Promise<{
parts: PartForPage[]
error?: string
cache?: EbayCacheState
}> {
const { parts } = await getPartsForManualWithStatus(manualFilename)
@ -514,23 +603,20 @@ export async function getTopPartsForManual(
parts,
Math.max(limit * 2, limit)
)
const { parts: enrichedParts, error } =
await enhancePartsData(liveSearchCandidates)
const sorted = enrichedParts.sort((a, b) => {
const aHasLiveListings = hasLiveEbayListings(a.ebayListings) ? 1 : 0
const bHasLiveListings = hasLiveEbayListings(b.ebayListings) ? 1 : 0
if (aHasLiveListings !== bHasLiveListings) {
return bHasLiveListings - aHasLiveListings
}
return b.ebayListings.length - a.ebayListings.length
})
const matched = await fetchManualPartsMatches(
manualFilename,
liveSearchCandidates.map((part) => ({
partNumber: part.partNumber,
description: part.description,
manualFilename,
})),
limit
)
return {
parts: sorted.slice(0, limit),
error,
parts: matched.parts as PartForPage[],
error: matched.error,
cache: matched.cache,
}
}

View file

@ -0,0 +1,208 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import {
rankListingsForQuery,
sortListingsByFreshness,
type CachedEbayListing,
} from "@/lib/ebay-parts-match"
export type ManualPartRow = {
partNumber: string
description: string
ebayListings?: CachedEbayListing[]
}
type ManualPartsLookup = Record<string, ManualPartRow[]>
type ManualPagesPartsLookup = Record<string, Record<string, ManualPartRow[]>>
let manualPartsCache: ManualPartsLookup | null = null
let manualPagesPartsCache: ManualPagesPartsLookup | null = null
let staticEbayListingsCache: CachedEbayListing[] | null = null
async function readJsonFile<T>(filename: string): Promise<T> {
const filePath = path.join(process.cwd(), "public", filename)
const contents = await readFile(filePath, "utf8")
return JSON.parse(contents) as T
}
export async function loadManualPartsLookup(): Promise<ManualPartsLookup> {
if (!manualPartsCache) {
manualPartsCache = await readJsonFile<ManualPartsLookup>(
"manual_parts_lookup.json"
)
}
return manualPartsCache
}
export async function loadManualPagesPartsLookup(): Promise<ManualPagesPartsLookup> {
if (!manualPagesPartsCache) {
manualPagesPartsCache = await readJsonFile<ManualPagesPartsLookup>(
"manual_pages_parts.json"
)
}
return manualPagesPartsCache
}
export async function findManualParts(
manualFilename: string
): Promise<ManualPartRow[]> {
const manualParts = await loadManualPartsLookup()
if (manualFilename in manualParts) {
return manualParts[manualFilename]
}
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, parts] of Object.entries(manualParts)) {
if (filename.toLowerCase() === lowerFilename) {
return parts
}
}
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
for (const [filename, parts] of Object.entries(manualParts)) {
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
if (
otherWithoutExt === lowerWithoutExt ||
otherWithoutExt.includes(lowerWithoutExt) ||
lowerWithoutExt.includes(otherWithoutExt)
) {
return parts
}
}
return []
}
export async function findManualPageParts(
manualFilename: string,
pageNumber: number
): Promise<ManualPartRow[]> {
const manualPagesParts = await loadManualPagesPartsLookup()
if (
manualPagesParts[manualFilename] &&
manualPagesParts[manualFilename][pageNumber.toString()]
) {
return manualPagesParts[manualFilename][pageNumber.toString()]
}
const lowerFilename = manualFilename.toLowerCase()
for (const [filename, pages] of Object.entries(manualPagesParts)) {
if (
filename.toLowerCase() === lowerFilename &&
pages[pageNumber.toString()]
) {
return pages[pageNumber.toString()]
}
}
const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, "")
const lowerWithoutExt = filenameWithoutExt.toLowerCase()
for (const [filename, pages] of Object.entries(manualPagesParts)) {
const otherWithoutExt = filename.replace(/\.pdf$/i, "").toLowerCase()
if (
otherWithoutExt === lowerWithoutExt ||
otherWithoutExt.includes(lowerWithoutExt) ||
lowerWithoutExt.includes(otherWithoutExt)
) {
if (pages[pageNumber.toString()]) {
return pages[pageNumber.toString()]
}
}
}
return []
}
export async function listManualsWithParts(): Promise<Set<string>> {
const manualParts = await loadManualPartsLookup()
const manualsWithParts = new Set<string>()
for (const [filename, parts] of Object.entries(manualParts)) {
if (parts.length > 0) {
manualsWithParts.add(filename)
manualsWithParts.add(filename.toLowerCase())
manualsWithParts.add(filename.replace(/\.pdf$/i, ""))
manualsWithParts.add(filename.replace(/\.pdf$/i, "").toLowerCase())
}
}
return manualsWithParts
}
function dedupeListings(listings: CachedEbayListing[]): CachedEbayListing[] {
const byItemId = new Map<string, CachedEbayListing>()
for (const listing of listings) {
const itemId = listing.itemId?.trim()
if (!itemId) {
continue
}
const existing = byItemId.get(itemId)
if (!existing) {
byItemId.set(itemId, listing)
continue
}
const existingFreshness = existing.lastSeenAt ?? existing.fetchedAt ?? 0
const nextFreshness = listing.lastSeenAt ?? listing.fetchedAt ?? 0
if (nextFreshness >= existingFreshness) {
byItemId.set(itemId, {
...existing,
...listing,
sourceQueries: Array.from(
new Set([...(existing.sourceQueries || []), ...(listing.sourceQueries || [])])
),
})
}
}
return sortListingsByFreshness(Array.from(byItemId.values()))
}
export async function loadStaticEbayListings(): Promise<CachedEbayListing[]> {
if (staticEbayListingsCache) {
return staticEbayListingsCache
}
const [manualParts, manualPagesParts] = await Promise.all([
loadManualPartsLookup(),
loadManualPagesPartsLookup(),
])
const listings: CachedEbayListing[] = []
for (const parts of Object.values(manualParts)) {
for (const part of parts) {
if (Array.isArray(part.ebayListings)) {
listings.push(...part.ebayListings)
}
}
}
for (const pages of Object.values(manualPagesParts)) {
for (const parts of Object.values(pages)) {
for (const part of parts) {
if (Array.isArray(part.ebayListings)) {
listings.push(...part.ebayListings)
}
}
}
}
staticEbayListingsCache = dedupeListings(listings)
return staticEbayListingsCache
}
export async function searchStaticEbayListings(
query: string,
limit = 6
): Promise<CachedEbayListing[]> {
const listings = await loadStaticEbayListings()
return rankListingsForQuery(query, listings, limit)
}

View file

@ -2,9 +2,9 @@ import { businessConfig, serviceAreas } from "@/lib/seo-config"
const SERVICE_AREA_LIST = serviceAreas.map((area) => area.city).join(", ")
export const SITE_CHAT_SYSTEM_PROMPT = `You are Jessica, a super friendly and casual text-chat assistant for ${businessConfig.legalName} in Utah. Sound like a chill local friend who is genuinely trying to help. Use warm, natural phrases like "Hey," "Gotcha," "No worries," "That helps a ton," and "Just curious," when they fit. Never sound robotic, salesy, or overly formal.
const SITE_CHAT_SYSTEM_PROMPT_BASE = `You are Jessica, a super friendly and casual text-chat assistant for ${businessConfig.legalName} in Utah. Sound like a chill local friend who is genuinely trying to help. Use warm, natural phrases like "Hey," "Gotcha," "No worries," "That helps a ton," and "Just curious," when they fit. Never sound robotic, salesy, or overly formal.
Use this exact knowledge base and do not go beyond it:
Use only the knowledge provided in this system prompt plus any manual knowledge context supplied later in the conversation. Do not go beyond that information.
- Free vending placement is only for qualifying businesses. Rocky Mountain Vending installs, stocks, maintains, and repairs those machines at no cost to the business.
- Repairs and maintenance are for machines the customer owns.
- Moving requests can be for a vending machine or a safe, and they follow the same intake flow as repairs.
@ -22,12 +22,23 @@ Conversation rules:
- For repairs or moving, start by asking what the machine looks like, what brand is on the front, or what they already know. If the move is involved, clarify whether it is for a vending machine or a safe. Later, direct them to text photos or videos to ${businessConfig.publicSmsNumber} or use the contact form so the team can diagnose remotely first.
- For free placement, first confirm it is for a business. Then ask about the business type, then the approximate number of people, then the location over separate turns.
- For sales, first ask what kind of machine or features they are thinking about. Ask about new or used and budget later, not all at once.
- For manuals or parts, ask what they remember about the machine or part instead of only asking for a model number.
- For manuals, parts, or troubleshooting, ask what they remember about the machine or part instead of only asking for a model number.
- When manual knowledge context is present, use only that retrieved context for manuals, parts, and troubleshooting replies.
- For manuals, parts, or troubleshooting, stay limited to easy identification, likely issue category, and basic safe checks pulled from the retrieved context.
- Cite the manual naturally when useful, like mentioning the manual name and page number in plain language.
- If manual context is missing or low-confidence, do not guess. Ask for the brand, model sticker, or a clear photo/video that they can text to ${businessConfig.publicSmsNumber}.
- Do not provide step-by-step repair procedures, wiring guidance, voltage guidance, bypasses, or risky technical instructions.
- If the visitor asks about a place that appears on the current website, treat it as inside the service area unless a human needs to confirm edge-case coverage.
Safety rules:
- Never mention, quote, or hint at prices, service call fees, repair rates, hourly rates, parts costs, or internal policies.
- If the visitor asks about pricing or cost, say: "Our complete vending service, including installation, stocking, and maintenance, is provided at no cost to qualifying businesses. I can get a few details so our team can schedule a quick call with you."
- If the visitor asks about pricing or cost, say: "Our complete vending service, including installation, stocking, and maintenance, is provided at no cost to qualifying businesses. I can get a few details so our team can schedule a quick call with you."
- Do not invent timelines, guarantees, inventory, contract terms, or legal details.
- If something needs confirmation, say a team member can confirm it.
`
export function buildSiteChatSystemPrompt() {
return SITE_CHAT_SYSTEM_PROMPT_BASE
}
export const SITE_CHAT_SYSTEM_PROMPT = buildSiteChatSystemPrompt()

View file

@ -7,10 +7,13 @@
"scripts": {
"build": "next build",
"copy:check": "node scripts/check-public-copy.mjs",
"diagnose:ebay": "node scripts/staging-smoke.mjs",
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
"manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts",
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"",

View file

@ -0,0 +1,37 @@
import { join } from "node:path"
import { parseArgs } from "node:util"
import { writeManualsQdrantArtifacts } from "@/lib/manuals-qdrant-corpus"
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
"output-dir": {
type: "string",
},
},
})
const defaultOutputDir = join(process.cwd(), "output", "manuals-qdrant")
async function main() {
const result = await writeManualsQdrantArtifacts({
outputDir: values["output-dir"] || defaultOutputDir,
})
const summary = {
outputDir: result.outputDir,
manuals: result.corpus.manuals.length,
chunks: result.corpus.chunks.length,
highConfidenceChunks: result.corpus.stats.highConfidenceChunks,
fallbackChunks: result.corpus.stats.fallbackChunks,
excludedChunks: result.corpus.stats.excludedChunks,
evaluation: result.evaluation.summary,
}
console.log(JSON.stringify(summary, null, 2))
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})

View file

@ -0,0 +1,33 @@
import {
buildManualsQdrantCorpus,
evaluateManualsQdrantCorpus,
} from "@/lib/manuals-qdrant-corpus"
async function main() {
const corpus = await buildManualsQdrantCorpus()
const evaluation = evaluateManualsQdrantCorpus(corpus)
const failingCases = evaluation.cases.filter(
(entry) =>
entry.passedTop3Manufacturer === false ||
!entry.passedTop5Label ||
!entry.passedDisallowedCheck
)
console.log(
JSON.stringify(
{
generatedAt: corpus.generatedAt,
summary: evaluation.summary,
failingCases,
},
null,
2
)
)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})

480
scripts/staging-smoke.mjs Normal file
View file

@ -0,0 +1,480 @@
import { existsSync } from "node:fs"
import path from "node:path"
import process from "node:process"
import dotenv from "dotenv"
const DEFAULT_BASE_URL = "http://127.0.0.1:3000"
const DEFAULT_MANUAL_CARD_TEXT = "653-655-657-hot-drink-center-parts-manual"
const DEFAULT_MANUAL_FILENAME = "653-655-657-hot-drink-center-parts-manual.pdf"
const DEFAULT_PART_NUMBER = "CABINET"
const DEFAULT_PART_DESCRIPTION = "- CABINET ASSEMBLY (SEE FIGURES 27, 28, 29) -"
function loadEnvFile() {
const envPath = path.resolve(process.cwd(), ".env.local")
if (existsSync(envPath)) {
dotenv.config({ path: envPath, override: false })
}
}
function parseArgs(argv) {
const args = {
baseUrl: process.env.BASE_URL || DEFAULT_BASE_URL,
manualCardText: DEFAULT_MANUAL_CARD_TEXT,
manualFilename: DEFAULT_MANUAL_FILENAME,
partNumber: DEFAULT_PART_NUMBER,
partDescription: DEFAULT_PART_DESCRIPTION,
adminToken: process.env.ADMIN_API_TOKEN || "",
skipBrowser: process.env.SMOKE_SKIP_BROWSER === "1",
}
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (token === "--base-url") {
args.baseUrl = argv[index + 1] || args.baseUrl
index += 1
continue
}
if (token === "--manual-card-text") {
args.manualCardText = argv[index + 1] || args.manualCardText
index += 1
continue
}
if (token === "--manual-filename") {
args.manualFilename = argv[index + 1] || args.manualFilename
index += 1
continue
}
if (token === "--part-number") {
args.partNumber = argv[index + 1] || args.partNumber
index += 1
continue
}
if (token === "--part-description") {
args.partDescription = argv[index + 1] || args.partDescription
index += 1
continue
}
if (token === "--admin-token") {
args.adminToken = argv[index + 1] || args.adminToken
index += 1
continue
}
if (token === "--skip-browser") {
args.skipBrowser = true
}
}
return args
}
function normalizeBaseUrl(value) {
return value.replace(/\/+$/, "")
}
function isLocalBaseUrl(baseUrl) {
try {
const url = new URL(baseUrl)
return ["localhost", "127.0.0.1", "::1"].includes(url.hostname)
} catch {
return false
}
}
function envPresence(name) {
return Boolean(String(process.env[name] ?? "").trim())
}
function heading(title) {
console.log(`\n== ${title} ==`)
}
function report(name, state, detail = "") {
const suffix = detail ? `${detail}` : ""
console.log(`${name}: ${state}${suffix}`)
}
function summarizeCache(cache) {
if (!cache || typeof cache !== "object") {
return "no cache payload"
}
const status = String(cache.status ?? "unknown")
const listingCount = Number(cache.listingCount ?? cache.itemCount ?? 0)
const activeCount = Number(cache.activeListingCount ?? 0)
const lastError = typeof cache.lastError === "string" ? cache.lastError : ""
const freshnessMs =
typeof cache.freshnessMs === "number" ? `${cache.freshnessMs}ms` : "n/a"
return [
`status=${status}`,
`listings=${listingCount}`,
`active=${activeCount}`,
`freshness=${freshnessMs}`,
lastError ? `lastError=${lastError}` : null,
]
.filter(Boolean)
.join(", ")
}
async function requestJson(url, init) {
const response = await fetch(url, {
redirect: "follow",
...init,
})
const text = await response.text()
let body = null
if (text.trim()) {
try {
body = JSON.parse(text)
} catch {
body = null
}
}
return { response, body, text }
}
async function checkPages(baseUrl, failures) {
heading("Public Pages")
const pages = ["/", "/contact-us", "/products", "/manuals"]
for (const pagePath of pages) {
const { response } = await requestJson(`${baseUrl}${pagePath}`)
const ok = response.status === 200
report(pagePath, ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET ${pagePath} returned ${response.status}`)
}
}
}
async function checkEbaySearch(baseUrl, failures, isLocalBase) {
heading("eBay Cache Search")
const url = new URL(`${baseUrl}/api/ebay/search`)
url.searchParams.set("keywords", "vending machine part")
url.searchParams.set("maxResults", "3")
url.searchParams.set("sortOrder", "BestMatch")
const { response, body, text } = await requestJson(url)
const ok = response.status === 200
const cache = body?.cache
const cacheStatus = cache?.status ?? "unknown"
report("GET /api/ebay/search", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET /api/ebay/search returned ${response.status}`)
console.log(text)
return
}
console.log(` cache: ${summarizeCache(cache)}`)
console.log(` results: ${Array.isArray(body?.results) ? body.results.length : 0}`)
const hasConvexUrl = envPresence("NEXT_PUBLIC_CONVEX_URL")
const disabledCache = cacheStatus === "disabled"
if (hasConvexUrl && disabledCache) {
failures.push(
"eBay search returned disabled cache while NEXT_PUBLIC_CONVEX_URL is configured."
)
}
const results = Array.isArray(body?.results) ? body.results : []
if (!hasConvexUrl && isLocalBase && disabledCache) {
failures.push(
"eBay search still returned a disabled cache when local static results should be available."
)
}
if (!hasConvexUrl && isLocalBase && results.length === 0) {
failures.push("eBay search did not return any bundled listings for the smoke query.")
}
}
async function checkManualParts(baseUrl, failures, isLocalBase, manualFilename, partNumber, partDescription) {
heading("Manual Parts Match")
const { response, body, text } = await requestJson(`${baseUrl}/api/ebay/manual-parts`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
manualFilename,
limit: 3,
parts: [
{
partNumber,
description: partDescription,
},
],
}),
})
const ok = response.status === 200
const cache = body?.cache
const cacheStatus = cache?.status ?? "unknown"
report("POST /api/ebay/manual-parts", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`POST /api/ebay/manual-parts returned ${response.status}`)
console.log(text)
return
}
const parts = Array.isArray(body?.parts) ? body.parts : []
const firstCount = Array.isArray(parts[0]?.ebayListings) ? parts[0].ebayListings.length : 0
console.log(` cache: ${summarizeCache(cache)}`)
console.log(` matched parts: ${parts.length}`)
console.log(` first part listings: ${firstCount}`)
const hasConvexUrl = envPresence("NEXT_PUBLIC_CONVEX_URL")
const disabledCache = cacheStatus === "disabled"
if (hasConvexUrl && disabledCache) {
failures.push(
"Manual parts route returned disabled cache while NEXT_PUBLIC_CONVEX_URL is configured."
)
}
if (!hasConvexUrl && isLocalBase && disabledCache) {
failures.push(
"Manual parts route still returned a disabled cache when local static results should be available."
)
}
if (!hasConvexUrl && isLocalBase && firstCount === 0) {
failures.push("Manual parts route did not return bundled listings for the smoke manual.")
}
if (!body?.manualFilename || body.manualFilename !== manualFilename) {
failures.push("Manual parts response did not echo the requested manualFilename.")
}
}
async function checkNotifications(baseUrl, failures) {
heading("eBay Notification Challenge")
const url = new URL(`${baseUrl}/api/ebay/notifications`)
url.searchParams.set("challenge_code", "diagnostic-test")
const { response, body, text } = await requestJson(url)
const ok = response.status === 200
report("GET /api/ebay/notifications", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`GET /api/ebay/notifications returned ${response.status}`)
console.log(text)
return
}
const challengeResponse = typeof body?.challengeResponse === "string"
? body.challengeResponse
: ""
console.log(` challengeResponse: ${challengeResponse ? "present" : "missing"}`)
if (!challengeResponse || challengeResponse.length < 32) {
failures.push("Notification challenge response is missing or malformed.")
}
}
async function checkAdminRefresh(baseUrl, failures, adminToken) {
heading("Admin Refresh")
if (!adminToken) {
report("POST /api/admin/ebay/refresh", "skipped", "no admin token provided")
return
}
const { response, body, text } = await requestJson(
`${baseUrl}/api/admin/ebay/refresh`,
{
method: "POST",
headers: {
"x-admin-token": adminToken,
},
}
)
const ok = response.status >= 200 && response.status < 300
report("POST /api/admin/ebay/refresh", ok ? "ok" : "fail", `status=${response.status}`)
if (!ok) {
failures.push(`POST /api/admin/ebay/refresh returned ${response.status}`)
console.log(text)
return
}
console.log(
` result: ${body && typeof body === "object" ? JSON.stringify(body) : text || "empty"}`
)
}
async function checkBrowser(baseUrl, manualCardText, failures) {
heading("Browser UI")
if (process.env.SMOKE_SKIP_BROWSER === "1") {
report("Browser smoke", "skipped", "SMOKE_SKIP_BROWSER=1")
return
}
let chromium
try {
chromium = (await import("playwright")).chromium
} catch (error) {
report(
"Browser smoke",
"skipped",
error instanceof Error ? error.message : "playwright unavailable"
)
return
}
let browser
try {
browser = await chromium.launch({ headless: true })
} catch (error) {
report(
"Browser smoke",
"skipped",
error instanceof Error ? error.message : "browser launch failed"
)
return
}
try {
const page = await browser.newPage()
const consoleErrors = []
page.on("console", (message) => {
if (message.type() === "error") {
const text = message.text()
if (!text.includes("Failed to load resource") && !text.includes("404")) {
consoleErrors.push(text)
}
}
})
await page.goto(`${baseUrl}/manuals`, { waitUntil: "domcontentloaded" })
await page.waitForTimeout(1200)
const titleVisible = await page
.getByRole("heading", { name: "Vending Machine Manuals" })
.isVisible()
if (!titleVisible) {
failures.push("Manuals page title was not visible in the browser smoke test.")
report("Manuals page", "fail", "title not visible")
return
}
const openButton = page.getByRole("button", { name: "View PDF" }).first()
await openButton.click({ force: true })
await page.waitForTimeout(1500)
const viewerOpen = await page.getByText("Parts").first().isVisible().catch(() => false)
const viewerFallback =
(await page
.getByText("No parts data extracted for this manual yet")
.first()
.isVisible()
.catch(() => false)) ||
(await page
.getByText("No cached eBay matches yet")
.first()
.isVisible()
.catch(() => false))
if (!viewerOpen && !viewerFallback) {
failures.push("Manual viewer did not open or did not show a parts/cache state.")
report("Manual viewer", "fail", "no parts state visible")
} else {
report("Manual viewer", "ok", viewerFallback ? "fallback state visible" : "viewer open")
}
if (consoleErrors.length > 0) {
failures.push(
`Browser smoke saw console errors: ${consoleErrors.slice(0, 3).join(" | ")}`
)
}
} catch (error) {
failures.push(
`Browser smoke failed: ${error instanceof Error ? error.message : String(error)}`
)
report("Browser smoke", "fail", error instanceof Error ? error.message : String(error))
} finally {
await browser?.close().catch(() => {})
}
}
async function main() {
loadEnvFile()
const args = parseArgs(process.argv.slice(2))
const baseUrl = normalizeBaseUrl(args.baseUrl)
const isLocalBase = isLocalBaseUrl(baseUrl)
const failures = []
heading("Environment")
report(
"NEXT_PUBLIC_CONVEX_URL",
envPresence("NEXT_PUBLIC_CONVEX_URL") ? "present" : "missing"
)
report("CONVEX_URL", envPresence("CONVEX_URL") ? "present" : "missing")
report("EBAY_APP_ID", envPresence("EBAY_APP_ID") ? "present" : "missing")
report(
"EBAY_AFFILIATE_CAMPAIGN_ID",
envPresence("EBAY_AFFILIATE_CAMPAIGN_ID") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_ENDPOINT",
envPresence("EBAY_NOTIFICATION_ENDPOINT") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_VERIFICATION_TOKEN",
envPresence("EBAY_NOTIFICATION_VERIFICATION_TOKEN") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_APP_ID",
envPresence("EBAY_NOTIFICATION_APP_ID") ? "present" : "missing"
)
report(
"EBAY_NOTIFICATION_CERT_ID",
envPresence("EBAY_NOTIFICATION_CERT_ID") ? "present" : "missing"
)
report("Base URL", baseUrl)
await checkPages(baseUrl, failures)
await checkEbaySearch(baseUrl, failures, isLocalBase)
await checkManualParts(
baseUrl,
failures,
isLocalBase,
args.manualFilename,
args.partNumber,
args.partDescription
)
await checkNotifications(baseUrl, failures)
await checkAdminRefresh(baseUrl, failures, args.adminToken)
if (args.skipBrowser) {
heading("Browser UI")
report("Browser smoke", "skipped", "--skip-browser was provided")
} else {
await checkBrowser(baseUrl, args.manualCardText, failures)
}
heading("Summary")
if (failures.length > 0) {
console.log("Failures:")
for (const failure of failures) {
console.log(`- ${failure}`)
}
process.exitCode = 1
return
}
console.log("All smoke checks passed.")
}
main().catch((error) => {
console.error(error)
process.exit(1)
})