Compare commits

..

No commits in common. "8fff380b2419478cd580b30afa6ed5f95e715ced" and "96ad13d6a906b3bb57c553edf0e752a4b3104833" have entirely different histories.

27 changed files with 455 additions and 6544 deletions

View file

@ -1,31 +0,0 @@
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

@ -1,48 +0,0 @@
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

@ -1,79 +0,0 @@
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 }
)
}
}

View file

@ -1,181 +0,0 @@
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,18 +17,12 @@ import {
SITE_CHAT_TEMPERATURE, SITE_CHAT_TEMPERATURE,
isSiteChatSuppressedRoute, isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config" } from "@/lib/site-chat/config"
import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt" import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
import { import {
consumeChatOutput, consumeChatOutput,
consumeChatRequest, consumeChatRequest,
getChatRateLimitStatus, getChatRateLimitStatus,
} from "@/lib/site-chat/rate-limit" } from "@/lib/site-chat/rate-limit"
import {
formatManualContextForPrompt,
retrieveManualContext,
shouldUseManualKnowledgeForChat,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { createSmsConsentPayload } from "@/lib/sms-compliance" import { createSmsConsentPayload } from "@/lib/sms-compliance"
type ChatRole = "user" | "assistant" type ChatRole = "user" | "assistant"
@ -214,15 +208,6 @@ function extractAssistantText(data: any) {
return "" 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) { export async function POST(request: NextRequest) {
const responseHeaders: Record<string, string> = { const responseHeaders: Record<string, string> = {
"Cache-Control": "no-store", "Cache-Control": "no-store",
@ -314,36 +299,6 @@ export async function POST(request: NextRequest) {
sessionId, 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") const xaiApiKey = getOptionalEnv("XAI_API_KEY")
if (!xaiApiKey) { if (!xaiApiKey) {
console.warn("[site-chat] missing XAI_API_KEY", { console.warn("[site-chat] missing XAI_API_KEY", {
@ -376,18 +331,8 @@ export async function POST(request: NextRequest) {
messages: [ messages: [
{ {
role: "system", role: "system",
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"}`, 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"}`,
}, },
...(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, ...messages,
], ],
}), }),

View file

@ -1,269 +0,0 @@
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,40 +1,167 @@
import { NextResponse } from "next/server" import { NextRequest, 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 { * eBay API Proxy Route
key: "manual-parts", * Proxies requests to eBay Finding API to avoid CORS issues
status: "success", */
lastSuccessfulAt: Date.now(),
lastAttemptAt: null, interface eBaySearchParams {
nextEligibleAt: null, keywords: string
lastError: null, categoryId?: string
consecutiveFailures: 0, sortOrder?: string
queryCount: 0, maxResults?: number
itemCount: 0, }
sourceQueries: [],
freshnessMs: 0, interface eBaySearchResult {
isStale: true, itemId: string
listingCount: 0, title: string
activeListingCount: 0, price: string
message: message || "Using bundled manual cache.", 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
} }
} }
export async function GET(request: Request) { 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 || ""
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),
}
}
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) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const keywords = searchParams.get("keywords")?.trim() || ""
const maxResults = Math.min( const keywords = searchParams.get("keywords")
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1), const categoryId = searchParams.get("categoryId") || undefined
20 const sortOrder = searchParams.get("sortOrder") || "BestMatch"
const maxResults = parseInt(searchParams.get("maxResults") || "6", 10)
const cacheKey = buildCacheKey(
keywords || "",
categoryId,
sortOrder,
maxResults
) )
if (!keywords) { if (!keywords) {
@ -44,46 +171,114 @@ export async function GET(request: Request) {
) )
} }
if (!hasConvexUrl()) { const appId = process.env.EBAY_APP_ID?.trim()
const staticResults = await searchStaticEbayListings(keywords, maxResults)
return NextResponse.json({ if (!appId) {
query: keywords, console.error("EBAY_APP_ID not configured")
results: staticResults, return NextResponse.json(
cache: getCacheStateFallback(), {
}) 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)
} }
try { try {
const [overview, listings] = await Promise.all([ const request = (async () => {
fetchQuery(api.ebay.getCacheOverview, {}), const response = await fetch(url.toString(), {
fetchQuery(api.ebay.listCachedListings, { limit: 200 }), method: "GET",
]) headers: {
Accept: "application/json",
const ranked = rankListingsForQuery( },
keywords,
listings as CachedEbayListing[],
maxResults
)
return NextResponse.json({
query: keywords,
results: ranked,
cache: overview,
}) })
if (!response.ok) {
const errorMessage = await readEbayErrorMessage(response)
throw new Error(errorMessage)
}
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)
} catch (error) { } catch (error) {
console.error("Failed to load cached eBay listings:", error) console.error("Error fetching from eBay API:", error)
const staticResults = await searchStaticEbayListings(keywords, maxResults)
return NextResponse.json( return NextResponse.json(
{ {
query: keywords, error:
results: staticResults,
cache: getCacheStateFallback(
error instanceof Error error instanceof Error
? `Using bundled manual cache because cached listings failed: ${error.message}` ? error.message
: "Using bundled manual cache because cached listings failed." : "Failed to fetch products from eBay",
),
}, },
{ status: 200 } { status: 500 }
) )
} finally {
inFlightSearchResponses.delete(cacheKey)
} }
} }

View file

@ -40,13 +40,18 @@ import {
} from "@/lib/manuals-config" } from "@/lib/manuals-config"
import { ManualViewer } from "@/components/manual-viewer" import { ManualViewer } from "@/components/manual-viewer"
import { getManualsWithParts } from "@/lib/parts-lookup" import { getManualsWithParts } from "@/lib/parts-lookup"
import type { CachedEbayListing, EbayCacheState } from "@/lib/ebay-parts-match" import { ebayClient } from "@/lib/ebay-api"
interface ProductSuggestionsResponse { // Product Suggestion Component
query: string interface ProductSuggestion {
results: CachedEbayListing[] itemId: string
cache: EbayCacheState title: string
error?: string price: string
currency: string
imageUrl?: string
viewItemUrl: string
affiliateLink: string
condition?: string
} }
interface ProductSuggestionsProps { interface ProductSuggestionsProps {
@ -58,55 +63,33 @@ function ProductSuggestions({
manual, manual,
className = "", className = "",
}: ProductSuggestionsProps) { }: ProductSuggestionsProps) {
const [suggestions, setSuggestions] = useState<CachedEbayListing[]>([]) const [suggestions, setSuggestions] = useState<ProductSuggestion[]>([])
const [cache, setCache] = useState<EbayCacheState | null>(null) const [isLoading, setIsLoading] = useState(ebayClient.isConfigured())
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!ebayClient.isConfigured()) {
setIsLoading(false)
return
}
async function loadSuggestions() { async function loadSuggestions() {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
try { try {
const query = [ // Generate search query from manual content
manual.manufacturer, const query = `${manual.manufacturer} ${manual.category} vending machine`
manual.category, const results = await ebayClient.searchItems({
manual.commonNames?.[0],
manual.searchTerms?.[0],
"vending machine",
]
.filter(Boolean)
.join(" ")
const params = new URLSearchParams({
keywords: query, keywords: query,
maxResults: "6", maxResults: 6,
sortOrder: "BestMatch", sortOrder: "BestMatch",
}) })
const response = await fetch(`/api/ebay/search?${params.toString()}`) setSuggestions(results)
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) { } catch (err) {
console.error("Error loading product suggestions:", err) console.error("Error loading product suggestions:", err)
setSuggestions([]) setError("Could not load product suggestions")
setCache(null)
setError(
err instanceof Error ? err.message : "Could not load product suggestions"
)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@ -117,6 +100,10 @@ function ProductSuggestions({
} }
}, [manual]) }, [manual])
if (!ebayClient.isConfigured()) {
return null
}
if (isLoading) { if (isLoading) {
return ( return (
<div <div
@ -134,16 +121,9 @@ function ProductSuggestions({
<div <div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`} 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 flex-col items-center justify-center gap-2 h-32 text-center"> <div className="flex items-center justify-center h-32">
<AlertCircle className="h-6 w-6 text-yellow-600" /> <AlertCircle className="h-6 w-6 text-red-500" />
<span className="text-sm text-yellow-700 dark:text-yellow-200"> <span className="ml-2 text-sm text-red-500">{error}</span>
{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>
</div> </div>
) )
@ -154,15 +134,10 @@ function ProductSuggestions({
<div <div
className={`bg-white/60 dark:bg-yellow-900/20 rounded border border-yellow-300/30 dark:border-yellow-700/30 p-4 ${className}`} 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 flex-col items-center justify-center gap-2 h-32 text-center"> <div className="flex items-center justify-center h-32">
<AlertCircle className="h-6 w-6 text-yellow-500" /> <AlertCircle className="h-6 w-6 text-yellow-500" />
<span className="text-sm text-yellow-700 dark:text-yellow-200"> <span className="ml-2 text-sm text-yellow-600">
No cached eBay matches yet No products found in sandbox environment
</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> </span>
</div> </div>
</div> </div>
@ -179,14 +154,6 @@ function ProductSuggestions({
Related Products Related Products
</h3> </h3>
</div> </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"> <div className="grid grid-cols-2 gap-3">
{suggestions.map((product) => ( {suggestions.map((product) => (

View file

@ -3,7 +3,6 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react" import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { EbayCacheState } from "@/lib/ebay-parts-match"
import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup" import { getTopPartsForManual, type PartForPage } from "@/lib/parts-lookup"
interface PartsPanelProps { interface PartsPanelProps {
@ -18,26 +17,6 @@ export function PartsPanel({
const [parts, setParts] = useState<PartForPage[]>([]) const [parts, setParts] = useState<PartForPage[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) 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 () => { const loadParts = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
@ -47,11 +26,9 @@ export function PartsPanel({
const result = await getTopPartsForManual(manualFilename, 5) const result = await getTopPartsForManual(manualFilename, 5)
setParts(result.parts) setParts(result.parts)
setError(result.error ?? null) setError(result.error ?? null)
setCache(result.cache ?? null)
} catch (err) { } catch (err) {
console.error("Error loading parts:", err) console.error("Error loading parts:", err)
setParts([]) setParts([])
setCache(null)
setError("Could not load parts") setError("Could not load parts")
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@ -65,7 +42,6 @@ export function PartsPanel({
}, [loadParts, manualFilename]) }, [loadParts, manualFilename])
const hasListings = parts.some((part) => part.ebayListings.length > 0) const hasListings = parts.some((part) => part.ebayListings.length > 0)
const cacheFreshnessText = formatFreshness(cache?.freshnessMs ?? null)
const renderStatusCard = (title: string, message: string) => ( const renderStatusCard = (title: string, message: string) => (
<div className={`flex flex-col h-full ${className}`}> <div className={`flex flex-col h-full ${className}`}>
@ -107,14 +83,6 @@ export function PartsPanel({
Parts Parts
</span> </span>
</div> </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>
<div className="px-3 py-3 text-sm text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center"> <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" /> <Loader2 className="h-4 w-4 mr-2 animate-spin" />
@ -145,14 +113,6 @@ export function PartsPanel({
Parts Parts
</span> </span>
</div> </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>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center"> <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" /> <AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
@ -172,18 +132,10 @@ export function PartsPanel({
Parts Parts
</span> </span>
</div> </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>
<div className="px-3 py-3 text-xs text-yellow-900/70 dark:text-yellow-100/70 flex items-center justify-center"> <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" /> <AlertCircle className="h-4 w-4 mr-2 text-yellow-700 dark:text-yellow-300" />
No cached eBay matches found for these parts yet No live eBay matches found for these parts yet
</div> </div>
</div> </div>
) )
@ -198,14 +150,6 @@ export function PartsPanel({
Parts ({parts.length}) Parts ({parts.length})
</span> </span>
</div> </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>
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2"> <div className="flex-1 overflow-y-auto px-3 py-2 space-y-2">
@ -215,10 +159,15 @@ export function PartsPanel({
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /> <AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium"> <p className="font-medium">
Cached eBay listings are unavailable right now. Live eBay listings are unavailable right now.
</p> </p>
<p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70"> <p className="mt-0.5 text-yellow-900/70 dark:text-yellow-100/70">
{error} {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}
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,13 +0,0 @@
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

View file

@ -1,536 +0,0 @@
// @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,50 +90,6 @@ export default defineSchema({
.index("by_category", ["category"]) .index("by_category", ["category"])
.index("by_path", ["path"]), .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({ manualCategories: defineTable({
name: v.string(), name: v.string(),
slug: v.string(), slug: v.string(),

View file

@ -1,52 +0,0 @@
# 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

@ -1,39 +0,0 @@
# 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

@ -1,40 +0,0 @@
# 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.

View file

@ -1,424 +0,0 @@
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

@ -1,79 +0,0 @@
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)
})

File diff suppressed because it is too large Load diff

View file

@ -1,114 +0,0 @@
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)
})

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,208 +0,0 @@
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(", ") const SERVICE_AREA_LIST = serviceAreas.map((area) => area.city).join(", ")
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. 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.
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. Use this exact knowledge base and do not go beyond it:
- Free vending placement is only for qualifying businesses. Rocky Mountain Vending installs, stocks, maintains, and repairs those machines at no cost to the business. - 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. - 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. - Moving requests can be for a vending machine or a safe, and they follow the same intake flow as repairs.
@ -22,12 +22,7 @@ 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 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 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 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, parts, or troubleshooting, ask what they remember about the machine or part instead of only asking for a model number. - For manuals or parts, 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. - 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: Safety rules:
@ -36,9 +31,3 @@ Safety rules:
- Do not invent timelines, guarantees, inventory, contract terms, or legal details. - Do not invent timelines, guarantees, inventory, contract terms, or legal details.
- If something needs confirmation, say a team member can confirm it. - 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,13 +7,10 @@
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"copy:check": "node scripts/check-public-copy.mjs", "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:env": "node scripts/deploy-readiness.mjs",
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build", "deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
"deploy:staging:smoke": "node scripts/staging-smoke.mjs", "deploy:staging:smoke": "node scripts/staging-smoke.mjs",
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "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": "tsx scripts/sync-manuals-to-convex.ts",
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run", "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.')\"", "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

@ -1,37 +0,0 @@
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

@ -1,33 +0,0 @@
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
})

View file

@ -1,480 +0,0 @@
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)
})