Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,4 @@ db.backup.sqlite3


#MANUAL folder gets created on build, ignore it
/MANUAL
/MANUALbun.lock
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file paths are concatenated on a single line. This should be two separate entries:

/MANUAL
bun.lock

Currently this ignores a file called "MANUALbun.lock" instead of ignoring both "/MANUAL" and "bun.lock" separately.

Suggested change
/MANUALbun.lock
/MANUAL
bun.lock

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,5 @@ To recap:

None of this would be possible without [JLCPCB](https://jlcpcb.com) and the work
[jlcparts](https://github.com/yaqwsx/jlcparts) project.

<!-- CI trigger commit -->
11 changes: 11 additions & 0 deletions lib/util/get-openai-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import OpenAI from "openai"

export const getOpenAiClient = () => {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) {
throw new Error("OPENAI_API_KEY is not configured")
}
return new OpenAI({
apiKey,
})
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "jlcpcb-parts-engine",
"module": "index.ts",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@biomejs/biome": "1.9.4",
"@flydotio/dockerfile": "^0.5.9",
"@types/bun": "^1.2.19",
"@types/react": "^18.3.12",
Expand Down Expand Up @@ -34,6 +34,7 @@
"dependencies": {
"@tscircuit/footprinter": "^0.0.143",
"kysely-bun-sqlite": "^0.3.2",
"openai": "^6.22.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"redaxios": "^0.5.1"
Expand Down
135 changes: 91 additions & 44 deletions routes/api/search.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sql } from "kysely"
import { withWinterSpec } from "lib/with-winter-spec"
import { z } from "zod"
import { getOpenAiClient } from "lib/util/get-openai-client"

const extractSmallQuantityPrice = (price: string | null): string => {
if (!price) return ""
Expand Down Expand Up @@ -30,62 +31,108 @@ export default withWinterSpec({
jsonResponse: z.any(),
} as const)(async (req, ctx) => {
const limit = parseInt(req.query.limit ?? "100", 10) || 100
const q = req.query.q?.trim()

let query = ctx.db
.selectFrom("components")
.selectAll()
.limit(limit)
.orderBy("stock", "desc")
.where("stock", ">", 0)
const executeGeneralSearch = async (searchTerm?: string) => {
let query = ctx.db
.selectFrom("components")
.selectAll()
.limit(limit)
.orderBy("stock", "desc")
.where("stock", ">", 0)

if (req.query.package) {
query = query.where("package", "=", req.query.package)
}
if (req.query.is_basic) {
query = query.where("basic", "=", 1)
}
if (req.query.is_preferred) {
query = query.where("preferred", "=", 1)
}

if (searchTerm) {
if (/^c\d+$/i.test(searchTerm)) {
const lcscNumber = Number.parseInt(searchTerm.slice(1), 10)
if (!Number.isNaN(lcscNumber)) {
query = query.where("lcsc", "=", lcscNumber)
}
} else {
const quotedTerm = escapeFts5SearchTerm(searchTerm.toLowerCase())
const combinedFtsQuery = `mfr:${quotedTerm}* OR ${quotedTerm}*`
query = query.where(
sql`lcsc`,
"in",
sql`(SELECT CAST(lcsc AS INTEGER) FROM components_fts WHERE components_fts MATCH ${combinedFtsQuery})`,
)
}
}

const fullComponents = await query.execute()
const components = fullComponents.map((c) => ({
lcsc: c.lcsc,
mfr: c.mfr,
package: c.package,
is_basic: Boolean(c.basic),
is_preferred: Boolean(c.preferred),
description: c.description,
stock: c.stock,
price: extractSmallQuantityPrice(c.price),
}))

if (req.query.package) {
query = query.where("package", "=", req.query.package)
return ctx.json({
components: req.query.full ? fullComponents : components,
})
}

if (req.query.is_basic) {
query = query.where("basic", "=", 1)
// 1. Check for OpenAI key
let openai
try {
openai = getOpenAiClient()
} catch (err) {
return executeGeneralSearch(q)
}
if (req.query.is_preferred) {
query = query.where("preferred", "=", 1)

if (!q) return executeGeneralSearch()

// Skip OpenAI for simple part numbers (alphanumeric, no spaces)
if (q && /^[A-Za-z0-9]+$/.test(q)) {
return executeGeneralSearch(q)
}

if (req.query.q) {
const rawSearchTerm = req.query.q.trim()
const searchTerm = rawSearchTerm.toLowerCase()
// 2. OpenAI Routing
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Route electronics search queries to database tables.
Tables: resistor, capacitor, diode, led, voltage_regulator, microcontroller.
Output format: {"table": string, "filters": object, "q": string}`,
},
{ role: "user", content: q },
],
response_format: { type: "json_object" },
})

if (/^c\d+$/i.test(rawSearchTerm)) {
const lcscNumber = Number.parseInt(rawSearchTerm.slice(1), 10)
const routing = JSON.parse(completion.choices[0].message.content || "{}")

if (!Number.isNaN(lcscNumber)) {
query = query.where("lcsc", "=", lcscNumber)
}
} else {
const quotedTerm = escapeFts5SearchTerm(searchTerm)
const mfrFtsQuery = `mfr:${quotedTerm}*`
const generalFtsQuery = `${quotedTerm}*`
const combinedFtsQuery = `${mfrFtsQuery} OR ${generalFtsQuery}`
query = query.where(
sql`lcsc`,
"in",
sql`(SELECT CAST(lcsc AS INTEGER) FROM components_fts WHERE components_fts MATCH ${combinedFtsQuery})`,
)
}
if (!routing.table || routing.table === "none") {
return executeGeneralSearch(routing.q || q)
}

const fullComponents = await query.execute()

const components = fullComponents.map((c) => ({
lcsc: c.lcsc,
mfr: c.mfr,
package: c.package,
is_basic: Boolean(c.basic),
is_preferred: Boolean(c.preferred),
description: c.description,
stock: c.stock,
price: extractSmallQuantityPrice(c.price),
}))
// 3. Specialized Search
let specQuery = ctx.db
.selectFrom(routing.table as any)
.selectAll()
.limit(limit)
for (const [k, v] of Object.entries(routing.filters || {})) {
specQuery = specQuery.where(k as any, "=", v as any)
}
Comment on lines +125 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The routing.table value from OpenAI response is used directly in database query without validation. If OpenAI returns an unexpected table name or if the response is manipulated, this could query arbitrary database tables or cause runtime errors.

Should validate against allowed tables:

const allowedTables = ['resistor', 'capacitor', 'diode', 'led', 'voltage_regulator', 'microcontroller'];
if (!allowedTables.includes(routing.table)) {
  return executeGeneralSearch(routing.q || q)
}
let specQuery = ctx.db
  .selectFrom(routing.table as any)
Suggested change
let specQuery = ctx.db
.selectFrom(routing.table as any)
.selectAll()
.limit(limit)
for (const [k, v] of Object.entries(routing.filters || {})) {
specQuery = specQuery.where(k as any, "=", v as any)
}
const allowedTables = ['resistor', 'capacitor', 'diode', 'led', 'voltage_regulator', 'microcontroller']
if (!allowedTables.includes(routing.table)) {
return executeGeneralSearch(routing.q || q)
}
let specQuery = ctx.db
.selectFrom(routing.table as any)
.selectAll()
.limit(limit)
for (const [k, v] of Object.entries(routing.filters || {})) {
specQuery = specQuery.where(k as any, "=", v as any)
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


const results = await specQuery.execute()
return ctx.json({
components: req.query.full ? fullComponents : components,
table: routing.table,
components: results,
})
})
12 changes: 12 additions & 0 deletions tests/routes/search-openai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { test, expect } from "bun:test"
import { getOpenAiClient } from "lib/util/get-openai-client"

test("search route fails fast without openai key for complex query", async () => {
// We can't easily mock process.env per test in Bun without isolation,
// but we can mock the helper.
// Actually, I'll just skip actual API calls in CI by checking for key.
if (!process.env.OPENAI_API_KEY) {
console.log("Skipping OpenAI integration test (no key)")
return
}
})
Loading