Skip to content

Repository files navigation

Hardware Hub 📦 Live version

CI

An AI-native internal tool for managing, renting and maintaining company hardware. Built as a recruitment assignment whose brief required an AI-native approach, so the AI features here are part of the spec, not a shortcut.

  • Backend: Python · FastAPI · SQLModel · SQLite · Neo4j (knowledge graph)
  • Frontend: Vue 3 · Vite · Pinia · Vue Router (with custom animated UI components — Vue ports of React Bits effects)
  • AI: Google Gemini via the google-genai SDK (Semantic Search, Inventory Auditor, and tool-calling fixes), with deterministic fallbacks. Default model gemini-3.5-flash — a free-tier model that supports custom function calling (alternatives: gemini-2.5-flash-lite, gemini-3-flash-preview). Run python list_models.py to list models your key can use.
  • Auth: JWT; accounts are created only by an admin (no self-registration)

Quick start

Run with Docker (recommended — single origin, no proxy)

The most reliable way to run everything together. One container builds the Vue SPA and serves it together with the API on a single port, so there is no dev proxy and no localhost communication conflicts.

cp .env.example .env          # optional: add your GEMINI_API_KEY
docker compose up --build     # http://localhost:8000  (login admin / admin123)

This also starts Neo4j (Browser at http://localhost:7474, neo4j / hardwarehub) and builds the knowledge graph on first boot. Leave NEO4J_URI empty to run without it — search falls back to the flat semantic path. The SQLite DB is persisted in a named volume (hub_data). Stop with docker compose down (add -v to also wipe the DB).

Local dev with two servers (Vite + uvicorn) works too (below), but on some Windows setups the Vite dev-proxy intermittently fails to reach uvicorn on localhost. Docker (or the single-server build) sidesteps that entirely.

Backend (local dev)

cd backend
python -m venv .venv
# Windows: .venv\Scripts\activate   |   macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env          # optional; sensible defaults otherwise
python dev.py                 # http://localhost:8000  (docs at /docs)
# dev.py is `uvicorn --reload` with the SQLite files excluded from the
# watcher — otherwise every DB write restarts the server mid-request.

On first start the DB is created, an admin is bootstrapped (admin@example.com / admin123), and the seed is audited & loaded — the audit report is printed to the console.

Frontend

cd frontend
npm install
npm run dev                   # http://localhost:5173 (proxies /api -> :8000)

Tests

cd backend
pytest                        # 72 tests (incl. the 3 required critical ones)

Default login

admin@example.com / admin123 (change via .env). A non-admin user j.doe@example.com exists in the seed as a rental holder but has no password — create real users from the Admin Panel.


⭐ Data Strategy — auditing the (deliberately broken) seed

The provided seed contains intentional data-quality problems. We do not ingest it blindly; backend/app/seed/migrate.py audits every record and emits a report. Philosophy: normalise objective issues, flag subjective ones, never fabricate, and never expose unsafe gear.

Result on the provided seed: 5 clean · 5 repaired · 1 quarantined

# Issue detected Action taken
5 Available but notes say "battery swelling, do not issue" Forced to Repair (safety override)
6 purchaseDate 2027-10-10 (future) Loaded, flagged future_purchase_date
4 (dup) Duplicate id: 4 (Lenovo) ID reassigned → 12 (both rows kept)
9 Brand "Appel"; date "22-05-2023" Date normalised → ISO; typo flagged (not auto-rewritten)
10 Empty brand, null date, status "Unknown" Quarantined (kept, excluded from active inventory)
11 Available but history says "liquid damage" Forced to Repair (safety override)
8 Missing id in sequence Ignored (ids need not be contiguous)

Decisions worth noting:

  • Safety > declared status. A device described as damaged is never rentable.
  • Flag, don't fabricate. Appel → Apple is suspected and surfaced, but the original value is preserved — silently rewriting data hides problems.
  • Quarantine, don't delete. Invalid records stay visible to admins (?include_quarantined=true, admin-only) so a human can fix them, marked as quarantined in the panel rather than looking like ordinary rows.
  • The same logic powers the Inventory Auditor fallback when no AI key is set.

Architecture

backend/app
├─ main.py            # app factory, lifespan: init DB, bootstrap admin, seed
├─ models.py          # Hardware / User / Rental (SQLModel)
├─ auth.py            # bcrypt + JWT, get_current_user / require_admin
├─ routers/           # auth, hardware, rentals, admin, ai
├─ ai/gemini.py       # Gemini wrapper (search + audit) + fallbacks
└─ seed/migrate.py    # ⭐ audit & clean the seed
frontend/src
├─ views/             # Login, HardwareList, MyRentals, AdminPanel
├─ components/        # Sidebar, StatusBadge
└─ stores/auth.js     # Pinia auth state

State machine (rental guards): Available → In Use (rent), In Use → Available (return), * ↔ Repair (admin). Illegal transitions return 409: renting non-available or in-repair gear, returning gear that isn't in use, or returning someone else's rental.

Two states the guards alone did not cover, because they come from outside the state machine rather than from a transition inside it:

  • A seed row can arrive In Use with nobody recorded against it, so there is no rental to close and nobody could return the device. The audit now raises in_use_without_holder, and an admin can release it.
  • Sending a rented device to repair takes it out of someone's hands, so it ends that rental and clears assigned_to. Leaving the rental open left the holder looking at a device whose Return button answered 409 forever.

A rented device also cannot be deleted: deleting it destroys the record of who has it, which is the question the ledger exists to answer.


AI layer

  • Semantic Search (POST /api/ai/search) — the "Ask AI…" bar. Sends the catalogue + query to Gemini, gets back ranked ids. Fallback: keyword match.
  • Inventory Auditor (GET /api/ai/audit) — AI Audit button in the Admin Panel. Gemini flags contradictions/anomalies. Fallback: deterministic rules from the migration flags.
  • AI Fix Tools (function calling) (POST /api/admin/audit/fix/{id}) — each flagged row has a prompt box: the admin types a natural-language instruction (e.g. "set the date to 2024-01-15", "correct the brand", "move to repair") and Gemini decides which tool to call to fix it. Tools live in ai/tools.py and cover every fixable flag: set_brand, set_purchase_date, set_status, set_quarantine, set_name, set_category, set_serial_number, set_assigned_to — each clears the matching audit flag. A contradictory "In Use but unassigned" state can be resolved either by recording a holder (set_assigned_to) or releasing the device (set_status → Available). Every fix is logged to an AuditAction history the admin can review (per-row and via the History panel). Fallback: a deterministic prompt parser routes to the same tools so it works with no API key.
  • Audit notes (PUT /api/admin/audit/note/{id}) — why a row is being left as it is. The flags record what is wrong; only a person can record that the supplier confirmed the serial and nobody should touch it again. Shown with the row on every subsequent audit.

The audit separates what needs a decision from what the import already did. status_overridden_safety and date_format_normalized are an audit trail, not open problems — nothing can clear the first except putting a damaged device back on the floor — so they render as a muted trail instead of sitting in the same red list forever until admins stop reading it.

All degrade gracefully: with no GEMINI_API_KEY (or on any error) the fallbacks run, so the product never breaks because of the AI layer. Failures are logged, never swallowed silently — a broken model looks like a broken model, not like a suspiciously dumb search.

Where the LLM is not allowed to decide

The model is good at reading intent out of "duży ekran do maca" and terrible at being the authority on anything. Every place it touches this app, something deterministic bounds it — and the boundary is the design, not a safety net bolted on afterwards:

The model may It may not Enforced by
add audit findings remove one the rules raised audit_inventory merges; rule_issues is ground truth
propose a category / OS / port invent a value outside the vocabulary closed lists in enrich.py
say a device runs an OS make an accessory a compatibility host category gate in host_os
pick which fix tool to call write the mutation tools own the write and clear their own flag (ai/tools.py)
rank what survived the filter decide what survives hard constraints run in Cypher, before scoring
be retried on another model be retried after a tool already fired allow_rotation in ai/models.py
suggest a brand typo rewrite the brand silently migration flags, never auto-corrects
be absent entirely take the product down with it a deterministic fallback on every path

Two of those lines exist because the missing guard shipped first and produced a confident wrong answer — see What this still gets wrong.

Model pool & quota rotation

Gemini's free tier caps requests per model per day, so an exhausted primary does not mean an exhausted key. ai/models.py keeps a pool, rotates past a model that returns RESOURCE_EXHAUSTED, and puts it on a cooldown; GET /api/ai/models shows who is benched.

Two rules the rotation respects:

  • Only rotate on an error a sibling could survive. Quota (429) and transient overload (503) both mean this model cannot serve the request; a malformed one means none of them can, and rotating would burn the whole pool and bury the real exception. The cooldowns differ because the causes do — a daily quota resets tomorrow, a demand spike passes in a minute, so a 503 benches for 60 s rather than an hour. The 503 case was found by the golden-set run: nothing rotated, and the intent pass silently dropped to the keyword fallback for that query.
  • Never rotate after a tool has fired. The fix flow mutates the device through tool callbacks, so retrying elsewhere would apply the same edit twice.

Every model in the default pool was verified in-session to actually invoke the tools, not merely to generate text — a pool member that can only produce prose would silently break the fix flow. gemini-3.7-flash (503) and gemini-2.5-flash-lite (404) were left out because that could not be confirmed.


Knowledge graph (Neo4j)

The flat semantic search could rank text; it could not answer "a big screen for my Mac", because compatibility is a relationship, not a word in a product name. So the inventory is projected into Neo4j:

(:Item)-[:IN_CATEGORY]->(:Category)     Laptop / Monitor / Phone / Dock / ...
(:Item)-[:MADE_BY]->(:Brand)
(:Item)-[:RUNS_OS]->(:OS)               macOS / iOS / Android / Windows
(:Item)-[:HAS_PORT]->(:Port)            USB-C / Thunderbolt / HDMI / ...
(:Item)-[:HAS_FEATURE]->(:Feature)
(:Item)-[:WORKS_WITH]->(:OS)            derived

WORKS_WITH is the edge that makes the flagship query answerable, and it is derived from the graph rather than hardcoded: a peripheral works with an OS when it shares a physical port with some machine in the inventory that runs it. Add one USB-C MacBook and every USB-C monitor becomes Mac-compatible by itself.

Only a machine may vouch for a platform. A derived edge inherits every error in the node it derives from, multiplied by that node's degree — so RUNS_OS is restricted to Laptop / Desktop / Phone / Tablet (enrich.py). Gemini reads the os field as "platforms this supports" for accessories and returned iOS + macOS + Windows

  • Android for a keyboard; that promoted the keyboard to a compatibility host, and every device sharing its (also invented) headphone jack inherited all four platforms. "monitor for my iPhone" answered with a 24" BenQ at 93% high, evidenced by BenQ →3.5mm →Keychron K2 →iOS. One wrong property on one node, amplified into a confident wrong answer on five. The closed vocabulary (CATEGORIES, PORTS, OSES) was never the whole guard — the shape has to be closed too, and the schema, not the prompt, is what closes it. The rule is also applied when reading attributes back out of the graph, because the cached path never re-asks the model and would otherwise keep the old claim forever.

Cost of the fix: "monitor for my iPhone" 5 hits → 3, "something to test an android app on" 13 → 2 (the two Android phones). Regression-tested in test_graph_search.py.

Where the properties come from. The seed carries only name/brand/date/status, so graph/enrich.py derives category, OS, screen size, wattage and ports from the product name — once, at sync time, cached on the node. Embeddings (gemini-embedding-001, 768d) are computed over the enriched description, not the bare name: that is why "big screen for a mac" lands near a 27" USB-C monitor whose name contains none of those words.

Search: hard constraints filter, soft preferences rank

graph/search.py keeps the two signals separate:

Signal Role Examples
Hard filters in Cypher category, OS compatibility, availability
Soft ranks + scores screen size, features, cosine similarity

confidence = 0.40·cosine + 0.35·graph_evidence + 0.25·soft_match, labelled high / medium / low, and every hit carries the reasons behind it.

A query that constrained nothing scores as the guess it is. soft_match used to default to 1.0 when the user expressed no preference — a free 0.25 for asking nothing. Cosine never abstains: it ranks the whole catalogue whatever you type, so "coś do gotowania obiadu" came back as 25 devices, every one of them labelled medium. When neither a hard nor a soft constraint survived the intent pass, the score is now the vector similarity alone — the same query returns 47% low, "semantic match only". Ranking is not understanding, and a system that cannot say "I don't know" is not calibrated, it is just polite.

> potrzebuję dużego ekranu do maca      →  Monitor · macOS · ≥27"

  94% high    LG UltraFine 32UN880 32" 4K     Monitor | macOS via USB-C, HDMI | 32" ≥ 27"
  93% high    Apple Studio Display 27"        Monitor | macOS via Thunderbolt  | 27" ≥ 27"
  68% medium  BenQ GW2480 24"                 Monitor | macOS via HDMI | 23.8" under the 27" asked for

A 24" screen still answers the question — just less well. Dropping it would hide the only monitor the office has left.

When nothing survives the hard filter. Filtering on category and OS is what stops a request for a screen returning laptops — and it is also what can leave the user with a blank page. So the constraints are relaxed on a miss, strictest first: availability, then platform, then category. Which ones were dropped comes back in relaxed, and the UI says so, because "the closest we have" and "an exact match" are different answers.

Speed. Item vectors are precomputed; a query costs one intent call plus one embedding, both LRU-cached, then a single Cypher round-trip. First run ~2–3 s, repeat run ~6–15 ms.

Staying in sync. Adding, deleting or AI-fixing a device updates the graph immediately; startup rebuilds only when a content fingerprint changes (counting rows missed renames). If enrichment cannot reach a model, the sync keeps the specs already in the graph instead of overwriting them with name-only guesses — a rate-limited re-sync once collapsed HAS_PORT from 87 edges to 2.

The graph is an enhancement, never a hard dependency: with Neo4j unreachable the search falls back to the flat semantic path and the app still runs.

Showing the work

Every hit carries the traversal that produced it, rendered under the confidence score, so "94%" is a claim the user can check rather than one they must trust:

Apple Studio Display 27"  →HAS_PORT  Thunderbolt  →HAS_PORT  Apple MacBook Pro 16 M3  →RUNS_OS  macOS

The drawn hop is ranked by the same port order as the reasons chip. Cypher was returning whichever bridge it found first, so a row explained "via HDMI" could draw a path through a headphone jack — evidence contradicting itself is worse than no evidence, because the path is the part a user actually checks.

Both bugs above were found by reading the paths the UI already renders. That is the argument for the feature: an opaque 93% would have shipped.

Measuring it: the golden set

Every bug above was found by reading results by hand, which does not scale and does not repeat. eval_search.py is 20 hand-labelled queries — Polish and English, real requests and nonsense — scored against both engines:

cd backend && python eval_search.py
query                                                graph    semantic
----------------------------------------------------------------------
duży ekran do maca                                  100%/6      100%/4
monitor for my iphone                               100%/3        FAIL
something to test an android app on                 100%/2      100%/2
a free laptop I can take right now                  100%/3        FAIL
a 32 inch monitor                                   100%/6      100%/2
podłączyć monitor i ethernet do macbooka jedn       100%/2      100%/4
coś do gotowania obiadu                              ok/25        ok/0
a quantum computer                                   ok/25        ok/0
----------------------------------------------------------------------
precision@k (mean)                                    98%         97%
violations                                               0           5

The interesting result is that precision is a tie. The flat engine ranks almost as well — one Gemini call over the whole catalogue is a genuinely good ranker at this size, and pretending otherwise would be the easy lie. What it cannot do is refuse:

  • monitor for my iphone → it returns the BenQ and the AOC. Neither has a USB-C input; no signal reaches them from a phone. The graph knows because compatibility is an edge, not a word.
  • a free laptop I can take right now → three of its six results are in Repair, and it ranks the Dell XPS — "battery swelling, do not issue"second. Availability is a hard filter or it is nothing.

So the graph's value is not accuracy, it is enforceability: a constraint that runs in Cypher either holds or returns nothing, while a constraint written into a prompt is a request. That is the sentence this table exists to earn.

Two asymmetries worth naming, both against the graph:

  • The flat engine abstains better. On a quantum computer it returns nothing; the graph relaxes constraints until something comes back, so it answers with 25 devices at 47% low. Honest labelling, but a blank page would be the better answer and the relaxation ladder cannot produce one.
  • Scoring is precision@k, k = min(3, #right answers), so a query with only two correct answers is not capped at 67%. The labels are one engineer's judgement, written in the file where a reviewer can disagree with them — the laptop do programowania row is deliberately left at 67% with the argument against its own label attached.

It is not part of pytest: a run costs ~40 model calls, and the free tier is capped per model per day. The regressions it found are unit tests, where they cost nothing.

Side by side, in the product

The same argument, live: Compare engines on the hardware list runs the query down both paths and shows the two answers next to each other, marking every row only one of them returned. It is the shortest path from "why not just embeddings?" to an answer, and it is the same two API calls the eval makes — POST /api/ai/search takes engine: "graph" | "semantic" to pin a path.

What this still gets wrong

The honest ceiling of the current model, with what each limit would cost to lift:

Limit Why it is wrong Would take
A shared port is not a working link. HAS_PORT records a connector, not what travels through it. A monitor and a MacBook both have 3.5mm, so the graph will bridge them over a headphone jack when nothing better exists. A port carries a direction (host / device) and a signal class (display, audio, data, power); the edge carries neither. Typing the port nodes and requiring a display-class match for Monitor. Not worth it at 26 items — the ranking already prefers the real link.
Specs are the model's product knowledge, not a datasheet. The Keychron's 3.5mm jack does not exist. Nothing checks a derived spec against reality. An LLM asked for structured facts will fill every field rather than return null, even when told not to. Verified specs on import, or a second model as a cross-check. The cheap 80%: show derived specs as derived in the admin panel so a human can correct one.
SIMILAR_TO is cosine over the same enriched text. Two devices are "alternatives" if their descriptions read alike — it has never seen anyone actually substitute one for the other. Same-category restriction stops a dock posing as a monitor; nothing stops a 24" office panel posing as a colour-grading display. Co-rental data, which the graph already holds — borrowed_together is one traversal away from being a substitution signal once there is real usage.
Confidence is a formula, not a probability. 93% is a weighted blend, calibrated by hand on this catalogue. It is comparable between rows of one query, not across queries. The golden set scores ranking; nothing scores the number itself. Labelled answers with a scored calibration curve, not 20 queries with a right/wrong verdict.
The intent pass is a single LLM call with no ground truth. A misparse silently narrows or widens the whole result set; the relaxation ladder hides a wrong constraint as "no exact match". The only signal that it was wrong is a user shrugging. Logging intent alongside what the user rented afterwards.

What else the graph answers

Each of these is a traversal over data that already exists — no extra API calls, no second model (graph/insights.py):

Endpoint Question
GET /api/ai/similar/{id} "That one is rented — what else would do?" [:SIMILAR_TO] edges are materialised at sync time from the embeddings already on the nodes, restricted to the same category so a dock never poses as a monitor.
GET /api/ai/refresh-candidates "What needs replacing?" Age past the 4-year cycle, weighted by repair status and rental load.
GET /api/ai/rental-insights "Who leans on which brand, what travels together, what has never moved?" over (:Person)-[:RENTED]->(:Item).

The rental ledger stays in SQLite — that is a transactional record and belongs in a relational store. The graph mirrors it as a query layer, updated incrementally: each RENTED edge carries the rental's id, so returning a device updates the edge that renting it created instead of rewriting the whole ledger. Full projection is reserved for startup and explicit rebuilds.

Two traps worth naming, both in the same place:

  • In Cypher NOT null IN [...] evaluates to null, not true, so edges written before the id existed survived every prune and quietly inflated the insight queries. The prune checks IS NULL explicitly.
  • MERGE (p)-[:RENTED {rental_id}]->(i) matches only between that person and that item, so a rental id now pointing somewhere else grew a second edge instead of moving the first — and the prune kept it, because the id was still in the ledger. A rebuilt SQLite DB against a long-lived graph is enough: ids restart at 1 and land on different people. Found by counting — the graph held 14 RENTED edges for 13 rentals, and rental-insights reported a borrower who has never rented anything. The id is the edge's identity, so the write now drops anything else wearing it first. This is what a graph is bad at: SQLite refuses a broken foreign key, a schemaless mirror just grows an edge and answers your query with it.

Costs that were paid twice

  • Embeddings are cached by content hash. A full rebuild used to re-embed all 26 devices to fix one typo; now the hash of the embedded sentence sits on the node and only genuinely changed items are recomputed. Measured: 23 ms and zero API calls when nothing changed, one call when one device changed.
  • The audit streams. Rules are deterministic and instant; the model pass takes 13–31 s over the whole catalogue. GET /api/ai/audit/stream sends the rule findings as an SSE event in ~8 ms and the merged set when the model answers, so nobody watches a spinner for findings we already had.
  • Status changes skip enrichment entirely. Renting a device used to run the full graph upsert, which re-derives that device's specs through a model call — measured at 10.5 s to write one string. Renting cannot change a laptop's screen size, so attr_hash deliberately excludes status and rent / return / repair take a single-property write instead. Measured: 1.53 s → 13 ms.

Waiting, and telling the user about it

Actions show an in-flight marker only after 180 ms (pending.js). A spinner that appears and vanishes inside 50 ms reads as a glitch, not as feedback; above the threshold the user gets told the click landed and a second click is blocked. Verified both ways: at the real 54 ms round trip nothing flashes, and under a throttled connection the row shows Updating… / Renting….


Design decisions vs. the wireframes

The Figma wireframes were used as inspiration; deviations and their reasons:

  • Added Serial Number and Category — present in the wireframe forms but absent from the seed schema; added as optional fields.
  • Rented label = canonical In Use — the source of truth stays In Use (matching the seed); the UI relabels it.
  • Added an "Add User" action in the Admin Panel — the wireframe omits it, but the spec requires admins to be the only way accounts are created.
  • Added an "AI Audit" action to surface the Inventory Auditor.

Visual polish

Icons are inline SVG in components/Icon.vue, drawn in the Iconoir style (MIT): 24px grid, 1.5 stroke, round caps, all inheriting currentColor. No emoji anywhere in the chrome — an emoji is a picture of an icon, not an icon: it renders differently on every platform, cannot take the surrounding text colour, and reads as filler. Inlined rather than packaged, because the whole set is a few hundred bytes and needs no network at runtime.

Colour carries meaning, not decoration. Confidence and audit findings use foreground/background token pairs (--ok-*, --warn-*, --bad-*) that stay legible on white; the first pass reused dark-theme translucency here and the badges washed out completely. Reasons behind a match are tinted by kind, so a satisfied preference and a missed one cannot be confused when they are the difference between 94% and 68%.

Motion is limited to what helps. A few animated components remain under frontend/src/components/visual/, re-implemented in Vue from the open-source React Bits collection (MIT):

  • ClickSpark — spark burst on click (global canvas overlay).
  • Aurora — soft animated gradient backdrop on the login screen.
  • CountUp — inventory stats counting up on load.

The shimmer-sweep heading effect was dropped: it decorated text that carries no state, and once the redesign removed it from two pages the remaining two read as an accident rather than a choice.

The per-row entrance stagger was removed: transition-delay scaled with row index, so a longer list took visibly longer to appear, and the global tbody tr background transition picked the delay up on LEAVE too — swapping search results left the outgoing rows on screen as ghosts.

All of them respect prefers-reduced-motion — ClickSpark never attaches its listener and CountUp jumps straight to the value. Credit: effect designs from React Bits (MIT), icon designs from Iconoir (MIT).


Implementation Status & Trade-offs

✅ Fully implemented

  • Login + JWT auth; admin-only account creation, with a login attempt limit
  • Hardware CRUD (admin) including an edit form, status filter, sortable columns
  • Rent / Return with state-machine guards; a held device always has a rental behind it, and sending one to repair ends that rental
  • Seed audit/migration with report + quarantine, and an idempotent catalogue top-up so a database created before a device was added still receives it
  • Semantic Search + Inventory Auditor + tool-calling fixes (Gemini + fallbacks)
  • Knowledge-graph search with a confidence score and the traversal behind it, plus a Compare engines view that answers the same query down both paths
  • A 20-query golden set scoring both engines, with the labels written down where a reviewer can argue with them
  • 72 backend tests (incl. the 3 required critical: cannot rent broken / in-use gear, seed audit quarantines & dedupes)

⚡ Shortcuts & "hacks"

  • JWT stored in localStorage. Why: fastest path for an internal MVP. Future: httpOnly, SameSite cookies + refresh-token rotation.
  • SQLite, seeded on boot. Why: zero-config, portable, easy to review. Future: Postgres + a real migration tool (Alembic) and a persistent volume.
  • The flat semantic fallback still sends the whole catalogue per query. Why: it only runs when Neo4j is unreachable, and it is fine for ~dozens of items. The graph path does not: item vectors are precomputed at sync time.
  • Login throttling is per-process and in memory. Why: one worker, and it is enough to stop an online guessing run. Future: Redis behind more.
  • Exact cosine over the constraint-filtered set. Correct and instant for an internal inventory; past ~10k items switch to the ANN vector index.

⚠ Partial / missing

  • No pagination; rentals have no due dates.
  • Users can be created but not listed, edited or removed — there is no endpoint for it either.
  • Rental history is kept but only the active loans are exposed.

🔮 Next steps

  1. Let the graph return nothing. The golden set's clearest finding against the graph: the relaxation ladder cannot produce a blank page, so a nonsense query gets 25 devices at 47% instead of "nothing here answers that". A confidence floor below which relaxation stops.
  2. Widen the golden set as the catalogue grows. 20 queries over 26 devices is enough to catch the bugs it caught; it is not enough to trust a change to the scoring weights. The labels are the asset, not the runner.
  3. Device detail/history drawer, and a rental history view.
  4. Harden auth: cookie sessions and password reset.
  5. User management (list / disable / reset) in the Admin Panel.
  6. Pagination once the inventory outgrows one screen.

AI Development Log

  • Tooling: Claude Code for planning, scaffolding, multi-agent parallel work, debugging and runtime verification; Google Gemini (google-genai) for the in-app AI features.
  • Data strategy: see Data Strategy above — the seed audit/migration report is the artifact. AI helped enumerate the seed's edge cases (duplicate ids, future date, DD-MM-YYYY date, brand typo, invalid status, damaged-but- "Available" devices); each got an explicit, reviewable rule rather than a silent rewrite.
  • The "Correction": moments where AI output was wrong and I corrected it:
    • Silent LLM fallback — the subtle one. The tool-calling fixer looked healthy (HTTP 200), but every fix was quietly handled by the deterministic fallback, never the LLM. Root cause: ai/tools.py called json.dumps(...) without importing json; the resulting NameError was swallowed by a broad except Exception in run_fix, which fell back — even reporting "no GEMINI_API_KEY set" while a key was present. Caught by verifying the path at runtime (injecting a fake client) instead of trusting the 200; fixed the missing import, wired up two tools that were defined but never exposed to the model (set_category, set_serial_number), added set_assigned_to, and made the broad except log so the regression can't hide again.
    • passlib + modern bcrypt raised "password cannot be longer than 72 bytes" → replaced with direct bcrypt + explicit 72-byte truncation.
    • Naive semantic-search fallback matched short tokens ("app" ⊂ "Apple") and returned laptops/mice for a phone query → rewritten with device-type inference, use-case mapping and stopword filtering.
    • Deprecated google-generativeai SDK → migrated to google-genai.

Deployment

Live version

One container builds the SPA and serves it beside the API on a single origin. Two things that only show up in that arrangement:

  • Client-side routes are served by a catch-all, not by StaticFiles. A plain html=True mount answers directory paths only, so /login, /hardware and /admin returned a JSON 404 — breaking a refresh, every shared link, and the redirect the API client performs when a token expires. Unknown /api/... paths still 404, so a typo'd endpoint cannot come back as a 200 of HTML.
  • CORS is an allow-list (CORS_ORIGINS), empty by default in the container because the SPA and the API share an origin there. It used to be "*" with allow_credentials, which is the one combination that lets any site on the internet script the API on a signed-in user's behalf.

About

AI-native internal hardware rental & inventory hub (FastAPI + Vue + Gemini). Built as a recruitment assignment whose brief required an AI-native approach.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages