-
Notifications
You must be signed in to change notification settings - Fork 44
feat: implement OpenAI-powered search routing (#97) #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
3d430c8
84f49ae
0210e0c
c0f4c20
ccc6a30
9fce748
929bd7c
c4b3f47
6c1f34c
5bf9cfd
1617db8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -185,4 +185,4 @@ db.backup.sqlite3 | |
|
|
||
|
|
||
| #MANUAL folder gets created on build, ignore it | ||
| /MANUAL | ||
| /MANUALbun.lock | ||
| 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, | ||
| }) | ||
| } |
| 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 "" | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -30,62 +31,111 @@ 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.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 (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})`, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (req.query.q) { | ||||||||||||||||||||||||||||||||||||||
| const rawSearchTerm = req.query.q.trim() | ||||||||||||||||||||||||||||||||||||||
| const searchTerm = rawSearchTerm.toLowerCase() | ||||||||||||||||||||||||||||||||||||||
| 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 (/^c\d+$/i.test(rawSearchTerm)) { | ||||||||||||||||||||||||||||||||||||||
| const lcscNumber = Number.parseInt(rawSearchTerm.slice(1), 10) | ||||||||||||||||||||||||||||||||||||||
| return ctx.json({ | ||||||||||||||||||||||||||||||||||||||
| components: req.query.full ? fullComponents : components, | ||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| 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})`, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| // 1. Check for OpenAI key | ||||||||||||||||||||||||||||||||||||||
| let openai | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| openai = getOpenAiClient() | ||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||
| // If not configured, only fail for complex queries | ||||||||||||||||||||||||||||||||||||||
| const isComplex = q && (q.split(" ").length > 1 || /[0-9]/.test(q)) | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
| const isComplex = q && (q.split(" ").length > 1 || /[0-9]/.test(q)) | |
| const isComplex = q && !(/^c\d+$/i.test(q)) && (q.split(" ").length > 1 || /[0-9]/.test(q)) |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
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)| 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
Is this helpful? React 👍 or 👎 to let us know.
| 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 | ||
| } | ||
| }) |
There was a problem hiding this comment.
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:
Currently this ignores a file called "MANUALbun.lock" instead of ignoring both "/MANUAL" and "bun.lock" separately.
Spotted by Graphite Agent

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