-
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 7 commits
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,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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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
Spotted by Graphite Agent |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const results = await specQuery.execute() | ||||||||||||||||||||||||||||||||||||||
| return ctx.json({ | ||||||||||||||||||||||||||||||||||||||
| components: req.query.full ? fullComponents : components, | ||||||||||||||||||||||||||||||||||||||
| table: routing.table, | ||||||||||||||||||||||||||||||||||||||
| components: results, | ||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||
| 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.