Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 7 additions & 2 deletions backend/api/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,15 @@ async def inventory_part_delete(part_id: str, req: Request):
return {"ok": True}

@router.get("/api/inventory/project-candidates")
async def inventory_project_candidates(req: Request, limit: int = 24):
async def inventory_project_candidates(req: Request, limit: int = 32, offset: int = 0, filter: str = "all"):
user, error = deps.require_authenticated_user(req)
if error:
return error
return project_finder_store.find(deps.user_id_for_user(user), limit=max(1, min(int(limit), 80)))
return project_finder_store.find(
deps.user_id_for_user(user),
limit=max(1, min(int(limit), 50)),
offset=max(0, int(offset)),
candidate_filter=filter,
)

return router
68 changes: 61 additions & 7 deletions db/lab_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
)
CODE_SAMPLE_TITLE_RE = re.compile(r"\bcode\s+sample\b", re.IGNORECASE)
MIN_PROJECT_TEXT_CHARS = 90
PROJECT_FINDER_CHUNK_SOURCE_LIMIT = 1200
PROJECT_FINDER_INTELLIGENCE_SOURCE_LIMIT = 400

GENERIC_PART_PATTERNS: tuple[tuple[re.Pattern[str], str, str], ...] = (
(re.compile(r"\b(?:ne|lm)?555\b|\b555\s+timer\b", re.IGNORECASE), "NE555 timer", "timer"),
Expand Down Expand Up @@ -249,21 +251,30 @@ def __init__(self, database: Database, inventory_store: LabInventoryStore, logge
self.inventory_store = inventory_store
self.logger = logger

def find(self, user_id: int, *, limit: int = 24) -> dict[str, Any]:
def find(self, user_id: int, *, limit: int = 24, offset: int = 0, candidate_filter: str = "all") -> dict[str, Any]:
limit = max(1, int(limit))
offset = max(0, int(offset))
inventory = self.inventory_store.list_parts(user_id)
term_rows = self.inventory_store.inventory_terms(user_id)
terms = self._search_terms(term_rows)
if not inventory or not terms:
return {"inventoryCount": len(inventory), "candidates": []}
return self._finder_response(
inventory_count=len(inventory),
term_count=len(terms),
ranked=[],
limit=limit,
offset=offset,
candidate_filter=candidate_filter,
)

with self.database.connection() as conn:
chunk_rows = conn.execute(
load_query("project_finder_chunk_candidates.sql"),
(terms, min(max(limit * 6, 20), 200)),
(terms, PROJECT_FINDER_CHUNK_SOURCE_LIMIT),
).fetchall()
intelligence_rows = conn.execute(
load_query("project_finder_intelligence_candidates.sql"),
(terms, min(max(limit, 10), 80)),
(terms, PROJECT_FINDER_INTELLIGENCE_SOURCE_LIMIT),
).fetchall()

inventory_index = self._inventory_index(inventory, term_rows)
Expand All @@ -277,15 +288,58 @@ def find(self, user_id: int, *, limit: int = 24) -> dict[str, Any]:
key=lambda item: (item["buildable"], item["score"], item["matchedPartCount"]),
reverse=True,
)
return self._finder_response(
inventory_count=len(inventory),
term_count=len(terms),
ranked=ranked,
limit=limit,
offset=offset,
candidate_filter=candidate_filter,
)

def _finder_response(
self,
*,
inventory_count: int,
term_count: int,
ranked: list[dict[str, Any]],
limit: int,
offset: int,
candidate_filter: str,
) -> dict[str, Any]:
normalized_filter = self._normalize_candidate_filter(candidate_filter)
filtered = self._filter_candidates(ranked, normalized_filter)
page = filtered[offset:offset + limit]
return {
"inventoryCount": len(inventory),
"termCount": len(terms),
"inventoryCount": inventory_count,
"termCount": term_count,
"candidateCount": len(ranked),
"buildableCount": sum(1 for candidate in ranked if candidate["buildable"]),
"needsPartsCount": sum(1 for candidate in ranked if not candidate["buildable"]),
"missingPartSummary": self._missing_part_summary(ranked),
"candidates": ranked[:limit],
"filter": normalized_filter,
"filterCount": len(filtered),
"offset": offset,
"limit": limit,
"returnedCount": len(page),
"hasMore": offset + len(page) < len(filtered),
"candidates": page,
}

@staticmethod
def _normalize_candidate_filter(candidate_filter: str) -> str:
if candidate_filter in {"buildable", "needs-parts"}:
return candidate_filter
return "all"

@staticmethod
def _filter_candidates(ranked: list[dict[str, Any]], candidate_filter: str) -> list[dict[str, Any]]:
if candidate_filter == "buildable":
return [candidate for candidate in ranked if candidate["buildable"]]
if candidate_filter == "needs-parts":
return [candidate for candidate in ranked if not candidate["buildable"]]
return ranked

def _search_terms(self, rows: list[dict[str, Any]]) -> list[str]:
terms = OrderedDict()
for row in rows:
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ProjectCandidateFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ProjectCandidateFilter = "all" | "buildable" | "needs-parts";
import type { ProjectCandidateFilter } from "../types";

const filters: Array<{ id: ProjectCandidateFilter; label: string }> = [
{ id: "all", label: "All" },
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/ProjectCandidateList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { ProjectCandidateCard } from "./projectFinder/ProjectCandidateCard";
export function ProjectCandidateList({
candidates,
finding,
emptyLabel = "Run the finder after adding parts.",
buildingId,
buildingElapsedSeconds,
onBuild
}: {
candidates: ProjectCandidate[];
finding: boolean;
emptyLabel?: string;
buildingId: string;
buildingElapsedSeconds: number;
onBuild: (candidate: ProjectCandidate) => void;
Expand All @@ -19,7 +21,7 @@ export function ProjectCandidateList({
}

if (!candidates.length) {
return <div className="empty-state compact">Run the finder after adding parts.</div>;
return <div className="empty-state compact">{emptyLabel}</div>;
}

return (
Expand Down
76 changes: 50 additions & 26 deletions frontend/src/components/ProjectFinderView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { buildAssemblyPlan } from "../libs/api";
import { rememberBenchPlanSelection } from "../libs/benchSelection";
import { errorMessage } from "../libs/errors";
import { formatNumber } from "../libs/format";
import { useElapsedSeconds } from "../hooks/useElapsedSeconds";
import { useInventory } from "../hooks/useInventory";
import type { AppConfig, ProjectCandidate, View } from "../types";
import type { AppConfig, ProjectCandidate, ProjectCandidateFilter, View } from "../types";
import { ErrorMessage } from "./ErrorMessage";
import { ProjectCandidateFilter, ProjectCandidateFilters } from "./ProjectCandidateFilters";
import { ProjectCandidateFilters } from "./ProjectCandidateFilters";
import { ProjectCandidateList } from "./ProjectCandidateList";
import { ProjectFinderSummary } from "./ProjectFinderSummary";
import { SectionHeader } from "./SectionHeader";
Expand All @@ -25,6 +25,9 @@ export function ProjectFinderView({
parts,
candidates,
inventoryCount,
candidateCount,
filterCount,
candidateHasMore,
buildableCount,
needsPartsCount,
missingPartSummary,
Expand All @@ -39,24 +42,24 @@ export function ProjectFinderView({
const [candidateFilter, setCandidateFilter] = useState<ProjectCandidateFilter>("all");
const buildingElapsedSeconds = useElapsedSeconds(Boolean(buildingId));

const filteredCandidates = useMemo(() => {
if (candidateFilter === "buildable") {
return candidates.filter((candidate) => candidate.buildable);
}
if (candidateFilter === "needs-parts") {
return candidates.filter((candidate) => !candidate.buildable);
}
return candidates;
}, [candidateFilter, candidates]);
const candidateCounts: Record<ProjectCandidateFilter, number> = {
all: candidateCount,
buildable: buildableCount,
"needs-parts": needsPartsCount
};
const activeFilterTotal = candidateCounts[candidateFilter] || filterCount || candidates.length;
const hasFinderRun = candidateCount > 0 || buildableCount > 0 || needsPartsCount > 0 || candidates.length > 0;

const candidateCounts = useMemo(
() => ({
all: candidates.length,
buildable: candidates.filter((candidate) => candidate.buildable).length,
"needs-parts": candidates.filter((candidate) => !candidate.buildable).length
}),
[candidates]
);
function runFinder(filter = candidateFilter, append = false) {
void findProjects(filter, { append });
}

function changeCandidateFilter(filter: ProjectCandidateFilter) {
setCandidateFilter(filter);
if (hasFinderRun) {
runFinder(filter);
}
}

async function createBenchPlan(candidate: ProjectCandidate) {
setBuildingId(candidate.id);
Expand Down Expand Up @@ -91,11 +94,11 @@ export function ProjectFinderView({
<SectionHeader title="Project finder" description="Scan indexed books against your lab inventory and turn matching ideas into Bench plans." />
<div className="finder-readiness">
<FinderMetric label="Inventory" value={parts.length} suffix="parts" />
<FinderMetric label="Candidates" value={candidates.length} suffix="found" />
<FinderMetric label="Buildable" value={buildableCount || candidateCounts.buildable} suffix="ready" />
<FinderMetric label="Candidates" value={candidateCount || candidates.length} suffix="found" />
<FinderMetric label="Buildable" value={buildableCount} suffix="ready" />
</div>
<div className="finder-actions">
<button className="primary-button" disabled={finding || loading || !parts.length} onClick={() => void findProjects()}>
<button className="primary-button" disabled={finding || loading || !parts.length} onClick={() => runFinder()}>
{finding ? "Finding projects..." : "Find projects"}
</button>
<button className="ghost-button" type="button" onClick={() => setActiveView("inventory")}>
Expand All @@ -114,21 +117,42 @@ export function ProjectFinderView({

<section className="finder-results-panel">
<div className="inventory-results-heading">
<SectionHeader title="Build candidates" description={`${formatNumber(filteredCandidates.length)} visible project matches`} />
{candidates.length ? <ProjectCandidateFilters active={candidateFilter} counts={candidateCounts} onChange={setCandidateFilter} /> : null}
<SectionHeader
title="Build candidates"
description={`${formatNumber(candidates.length)} visible of ${formatNumber(activeFilterTotal)} ${filterDescription(candidateFilter)} matches`}
/>
{hasFinderRun ? <ProjectCandidateFilters active={candidateFilter} counts={candidateCounts} onChange={changeCandidateFilter} /> : null}
</div>
<ProjectCandidateList
candidates={filteredCandidates}
candidates={candidates}
finding={finding}
emptyLabel={hasFinderRun ? `No ${filterDescription(candidateFilter)} candidates found.` : "Run the finder after adding parts."}
buildingId={buildingId}
buildingElapsedSeconds={buildingElapsedSeconds}
onBuild={(candidate) => void createBenchPlan(candidate)}
/>
{candidateHasMore ? (
<div className="project-candidate-pagination">
<button className="ghost-button" type="button" disabled={finding} onClick={() => runFinder(candidateFilter, true)}>
{finding ? "Loading..." : `Load more ${filterDescription(candidateFilter)} candidates`}
</button>
</div>
) : null}
</section>
</section>
);
}

function filterDescription(filter: ProjectCandidateFilter) {
if (filter === "buildable") {
return "buildable";
}
if (filter === "needs-parts") {
return "needs-parts";
}
return "project";
}

function FinderMetric({ label, value, suffix }: { label: string; value: number; suffix: string }) {
return (
<div className="finder-metric">
Expand Down
40 changes: 35 additions & 5 deletions frontend/src/hooks/useInventory.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { deleteInventoryPart, getInventoryLocations, getInventoryParts, getProjectCandidates, saveInventoryPart } from "../libs/api";
import { errorMessage } from "../libs/errors";
import type { InventoryLocation, InventoryPart, InventoryPartInput, ProjectCandidate, ProjectMissingPartSummary } from "../types";
import type { InventoryLocation, InventoryPart, InventoryPartInput, ProjectCandidate, ProjectCandidateFilter, ProjectMissingPartSummary } from "../types";

const emptyCandidates: ProjectCandidate[] = [];
const projectFinderPageSize = 32;

export function useInventory(isActive: boolean) {
const [parts, setParts] = useState<InventoryPart[]>([]);
Expand All @@ -12,6 +13,12 @@ export function useInventory(isActive: boolean) {
const [loading, setLoading] = useState(false);
const [finding, setFinding] = useState(false);
const [inventoryCount, setInventoryCount] = useState(0);
const [candidateCount, setCandidateCount] = useState(0);
const [filterCount, setFilterCount] = useState(0);
const [candidateOffset, setCandidateOffset] = useState(0);
const [candidateLimit, setCandidateLimit] = useState(projectFinderPageSize);
const [candidateHasMore, setCandidateHasMore] = useState(false);
const [candidateFilter, setCandidateFilter] = useState<ProjectCandidateFilter>("all");
const [buildableCount, setBuildableCount] = useState(0);
const [needsPartsCount, setNeedsPartsCount] = useState(0);
const [missingPartSummary, setMissingPartSummary] = useState<ProjectMissingPartSummary[]>([]);
Expand Down Expand Up @@ -43,22 +50,34 @@ export function useInventory(isActive: boolean) {
await Promise.all([loadParts(), loadLocations()]);
}, [loadLocations, loadParts]);

const findProjects = useCallback(async () => {
const findProjects = useCallback(async (filter: ProjectCandidateFilter = "all", options: { append?: boolean } = {}) => {
setFinding(true);
setError("");
const append = Boolean(options.append);
const nextOffset = append ? candidateOffset + candidateLimit : 0;
try {
const response = await getProjectCandidates(32);
const response = await getProjectCandidates({
limit: projectFinderPageSize,
offset: nextOffset,
filter
});
setInventoryCount(response.inventoryCount);
setCandidateCount(response.candidateCount || 0);
setFilterCount(response.filterCount ?? response.candidates.length);
setCandidateOffset(response.offset || 0);
setCandidateLimit(response.limit || projectFinderPageSize);
setCandidateHasMore(Boolean(response.hasMore));
setCandidateFilter(response.filter || filter);
setBuildableCount(response.buildableCount || 0);
setNeedsPartsCount(response.needsPartsCount || 0);
setMissingPartSummary(response.missingPartSummary || []);
setCandidates(response.candidates);
setCandidates((current) => append ? [...current, ...response.candidates] : response.candidates);
} catch (err) {
setError(errorMessage(err, "Could not find project candidates"));
} finally {
setFinding(false);
}
}, []);
}, [candidateLimit, candidateOffset]);

const savePart = useCallback(
async (part: InventoryPartInput) => {
Expand All @@ -85,6 +104,11 @@ export function useInventory(isActive: boolean) {
await deleteInventoryPart(partId);
setParts((current) => current.filter((part) => part.id !== partId));
setCandidates(emptyCandidates);
setCandidateCount(0);
setFilterCount(0);
setCandidateOffset(0);
setCandidateHasMore(false);
setCandidateFilter("all");
setBuildableCount(0);
setNeedsPartsCount(0);
setMissingPartSummary([]);
Expand Down Expand Up @@ -126,6 +150,12 @@ export function useInventory(isActive: boolean) {
locations,
candidates,
inventoryCount,
candidateCount,
filterCount,
candidateOffset,
candidateLimit,
candidateHasMore,
candidateFilter,
buildableCount,
needsPartsCount,
missingPartSummary,
Expand Down
Loading