File size: 34,572 Bytes
f0fae3f
 
 
dbcbe7b
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
dbcbe7b
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
f9c247c
f0fae3f
 
 
 
 
 
 
 
bbd6411
 
 
 
 
 
f0fae3f
bbd6411
 
 
 
 
e1257cf
bbd6411
 
 
e1257cf
bbd6411
 
 
 
 
 
e1257cf
 
bbd6411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1257cf
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9c247c
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
 
 
f0fae3f
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
dbcbe7b
f0fae3f
 
 
 
dbcbe7b
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1257cf
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
23c9b7a
 
 
 
 
 
 
f0fae3f
 
8f9afd8
23c9b7a
 
 
 
 
 
 
f0fae3f
 
8f9afd8
23c9b7a
 
 
 
 
8f9afd8
f0fae3f
 
8f9afd8
23c9b7a
 
 
 
 
 
f0fae3f
 
23c9b7a
 
 
e1257cf
 
 
 
 
 
 
 
23c9b7a
8f9afd8
 
f0fae3f
 
 
 
 
 
 
 
 
 
dbcbe7b
 
 
 
 
 
950b708
f0fae3f
 
 
dbcbe7b
 
 
 
 
 
 
 
 
 
 
 
85dc220
 
 
 
dbcbe7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
dbcbe7b
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
dbcbe7b
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbcbe7b
f0fae3f
dbcbe7b
 
 
 
 
 
 
f0fae3f
85dc220
 
 
 
 
 
 
 
 
 
f0fae3f
dbcbe7b
 
f0fae3f
 
 
 
 
 
 
 
 
 
23c9b7a
 
 
 
 
 
 
 
 
 
f0fae3f
 
23c9b7a
 
 
f0fae3f
 
 
 
 
 
 
 
f9c247c
f0fae3f
f9c247c
 
f0fae3f
 
 
1506736
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
f0fae3f
 
1506736
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
b15a546
f0fae3f
 
 
 
 
 
 
 
e689c24
f0fae3f
 
 
b15a546
dbcbe7b
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
950b708
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1506736
 
 
 
 
23c9b7a
 
 
 
 
 
 
8f9afd8
 
23c9b7a
 
 
8f9afd8
 
23c9b7a
 
8f9afd8
 
23c9b7a
 
 
8f9afd8
 
23c9b7a
8f9afd8
 
23c9b7a
 
 
 
 
 
f0fae3f
 
 
 
8f9afd8
23c9b7a
f0fae3f
e1257cf
 
 
 
 
 
 
 
 
1506736
e1257cf
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
"""
Smart Warehouse AI Assistant
=============================
A warehouse/intralogistics AI copilot demo, built for a Hugging Face
Space. Combines:

  1. An LLM-powered assistant (RAG: TF-IDF retrieval + hosted LLM via the
     HF Inference API) for natural-language warehouse operations Q&A.
  2. A TF-IDF + Logistic Regression intent classifier that routes queries
     into 8 operational categories.
  3. A lightweight NL -> structured query layer over synthetic inventory /
     order tables.
  4. An Isolation Forest anomaly detector for conveyor/crane sensor
     streams (predictive maintenance).
  5. A Model Evaluation tab reporting real accuracy/F1/ROC-AUC metrics
     computed by build_artifacts.py.

Author: (your name here) -- built as a portfolio / job-application project.
"""

import json
import os

import gradio as gr
import pandas as pd

from src.anomaly_model import FEATURES as SENSOR_FEATURES
from src.anomaly_model import load_artifacts as load_anomaly_artifacts
from src.anomaly_model import score_reading
from src.data_generation import generate_inventory_db, generate_orders_db
from src.intent_model import INTENT_DESCRIPTIONS, load_pipeline, predict as intent_predict
from src.inventory_db import query_inventory, query_orders
from src.llm_client import answer_query, test_connection, _get_hf_token, TOKEN_ENV_VAR_CANDIDATES
from src.retriever import KBRetriever

ROOT = os.path.dirname(os.path.abspath(__file__))
MODELS_DIR = os.path.join(ROOT, "models")
DATA_DIR = os.path.join(ROOT, "data")
ASSETS_DIR = os.path.join(ROOT, "assets")

# --------------------------------------------------------------------------
# Load pre-trained artifacts (fast: no training happens at Space startup
# under normal conditions). If unpickling fails -- e.g. the models were
# built with a different scikit-learn version than the one installed in
# this container -- we transparently rebuild everything from scratch. This
# is fast (a few seconds, see build_artifacts.py) and makes the app immune
# to sklearn version-pinning mismatches between build time and deploy time.
# --------------------------------------------------------------------------

def _load_or_rebuild_artifacts():
    intent_path = os.path.join(MODELS_DIR, "intent_pipeline.joblib")
    anomaly_model_path = os.path.join(MODELS_DIR, "anomaly_iforest.joblib")
    anomaly_scaler_path = os.path.join(MODELS_DIR, "anomaly_scaler.joblib")
    anomaly_threshold_path = os.path.join(MODELS_DIR, "anomaly_threshold.joblib")

    def _try_load():
        pipeline = load_pipeline(intent_path)
        model, scaler, threshold = load_anomaly_artifacts(anomaly_model_path, anomaly_scaler_path, threshold_path=anomaly_threshold_path)
        # Smoke-test the loaded pipeline against the exact code path the app
        # uses at request time. On a scikit-learn version mismatch, sklearn
        # sometimes unpickles "successfully" but throws later on first real
        # use (e.g. LogisticRegression missing an internal attribute) --
        # catching that here, at import time, is what makes this self-healing.
        intent_predict(pipeline, "healthcheck")
        score_reading(model, scaler, {"motor_temp_c": 55, "vibration_mm_s": 2.2, "current_amps": 12, "belt_speed_mps": 1.5}, threshold=threshold)
        return pipeline, model, scaler, threshold

    try:
        return _try_load()
    except Exception as e:  # noqa: BLE001 -- any load/version issue triggers a rebuild
        print(f"[startup] Could not load pre-built model artifacts ({type(e).__name__}: {e}). "
              f"Rebuilding from scratch with the installed scikit-learn version...")
        import build_artifacts
        build_artifacts.build_intent_classifier()
        build_artifacts.build_anomaly_detector()
        build_artifacts.build_retrieval_eval()
        build_artifacts.build_inventory_and_orders()
        print("[startup] Rebuild complete.")
        return _try_load()


intent_pipeline, anomaly_model, anomaly_scaler, anomaly_threshold = _load_or_rebuild_artifacts()
retriever = KBRetriever()

# Prefer the pre-generated CSVs (so demo state matches the eval run); fall back to
# regenerating in-memory if they're missing for some reason.
try:
    inventory_df = pd.read_csv(os.path.join(DATA_DIR, "inventory.csv"))
    orders_df = pd.read_csv(os.path.join(DATA_DIR, "orders.csv"))
except FileNotFoundError:
    inventory_df = generate_inventory_db()
    orders_df = generate_orders_db()


def _load_json(name):
    path = os.path.join(DATA_DIR, name)
    if os.path.exists(path):
        with open(path) as f:
            return json.load(f)
    return {}


intent_eval = _load_json("intent_eval.json")
anomaly_eval = _load_json("anomaly_eval.json")
retrieval_eval = _load_json("retrieval_eval.json")
latency_eval = _load_json("latency_eval.json")

HF_TOKEN_SET = bool(_get_hf_token())

# --------------------------------------------------------------------------
# ZeroGPU compatibility shim
# --------------------------------------------------------------------------
# This app is CPU-only by design (scikit-learn locally, LLM calls go to the
# remote HF Inference API). Some Spaces accounts, however, only offer the
# free "ZeroGPU" hardware tier, which requires at least one function
# decorated with `@spaces.GPU` to be present or the platform's startup
# check fails with "No @spaces.GPU function detected". This is a harmless,
# unused health-check function that satisfies that requirement without
# changing any real behaviour -- it is never called on the request path.
try:
    import spaces

    @spaces.GPU(duration=5)
    def _zerogpu_healthcheck():
        return True

except ImportError:
    # Running locally / on CPU-basic hardware where `spaces` isn't installed.
    def _zerogpu_healthcheck():
        return True


# ==========================================================================
# TAB 1 -- AI Assistant (LLM + RAG + intent routing)
# ==========================================================================

def chat_fn(message, history):
    intent = intent_predict(intent_pipeline, message)
    response = answer_query(message, retriever)

    intent_label = INTENT_DESCRIPTIONS.get(intent.intent, intent.intent)
    meta_lines = [f"**Detected intent:** {intent_label} ({intent.confidence:.0%} confidence)"]
    if response.sources:
        src_str = ", ".join(f"{s.title} ({s.score:.2f})" for s in response.sources)
        meta_lines.append(f"**Retrieved context:** {src_str}")
    meta_lines.append(
        f"**Generation:** {'LLM (' + response.model_id + ')' if response.used_llm else 'retrieval-only fallback'}"
        f" Β· {response.latency_s * 1000:.0f} ms"
    )
    if not response.used_llm and response.debug_errors:
        err_lines = "\n".join(f"  - `{e}`" for e in response.debug_errors)
        meta_lines.append(f"**Why the LLM wasn't used:**\n{err_lines}")

    full_reply = response.answer + "\n\n---\n" + "\n".join(meta_lines)
    return full_reply


def test_llm_fn():
    response = test_connection(retriever)
    if response.used_llm:
        return (
            f"βœ… **LLM connection working.** Model: `{response.model_id}` Β· "
            f"{response.latency_s * 1000:.0f} ms\n\nSample answer: {response.answer}"
        )
    err_lines = "\n".join(f"- `{e}`" for e in response.debug_errors) or "(no error detail captured)"
    return (
        "❌ **LLM connection failed** β€” running in retrieval-only fallback mode.\n\n"
        f"**Errors from each candidate model tried:**\n{err_lines}\n\n"
        "**Common causes:** missing/invalid `HF_TOKEN` secret, the token's account "
        "lacking Inference API access, or the candidate models being temporarily "
        "unavailable on HF's free serverless tier. See `src/llm_client.py` to add "
        "or reorder candidate models, or set the `LLM_MODEL_ID` secret to force a "
        "specific one."
    )


ASSISTANT_EXAMPLES = [
    "The conveyor belt in Zone C is making noise",
    "How many units of SKU-1042 are in Zone B?",
    "What's the status of order #10007?",
    "What's the difference between an AGV and an AMR?",
    "A forklift near-miss was reported in Zone A",
    "What's the fastest picking route for a high volume order?",
    "Is Crane-03 operational?",
    "Route AGV-12 to picking station 5",
    "What is cycle counting?",
    "Redirect AGV-07 around the blocked aisle in Zone B",
    "How does an AS/RS crane retrieve a pallet?",
    "What KPIs matter most in warehouse automation?",
    "Schedule maintenance for Sorter-02",
    "Should we batch pick these orders together?",
    "What is predictive maintenance?",
    "Log a safety incident involving AMR-21",
]


# ==========================================================================
# TAB 2 -- Inventory & Order Query
# ==========================================================================

def inventory_query_fn(text):
    intent = intent_predict(intent_pipeline, text)
    if intent.intent == "order_status":
        result = query_orders(orders_df, text)
        note = "Interpreted as an **order status** query."
    else:
        result = query_inventory(inventory_df, text)
        note = "Interpreted as an **inventory** query."
    if result.empty:
        result = pd.DataFrame({"message": ["No matching records found for this query."]})
    return note, result


# ==========================================================================
# TAB 3 -- Predictive Maintenance / Anomaly Detection
# ==========================================================================

def anomaly_fn(motor_temp, vibration, current, belt_speed):
    reading = {
        "motor_temp_c": motor_temp,
        "vibration_mm_s": vibration,
        "current_amps": current,
        "belt_speed_mps": belt_speed,
    }
    result = score_reading(anomaly_model, anomaly_scaler, reading, threshold=anomaly_threshold)
    verdict = "πŸ”΄ ANOMALY DETECTED" if result.is_anomaly else "🟒 Normal operating range"
    detail = (
        f"### {verdict}\n\n"
        f"**Anomaly score:** {result.anomaly_score:.2f} / 1.00\n\n"
        f"| Sensor | Value | Typical normal range |\n"
        f"|---|---|---|\n"
        f"| Motor temperature | {motor_temp:.1f} Β°C | 47–63 Β°C |\n"
        f"| Vibration | {vibration:.2f} mm/s | 1.2–3.2 mm/s |\n"
        f"| Motor current | {current:.1f} A | 9–15 A |\n"
        f"| Belt speed | {belt_speed:.2f} m/s | 1.2–1.8 m/s |\n"
    )
    if result.is_anomaly:
        detail += (
            "\n**Recommended action:** Flag for inspection. Elevated temperature + "
            "vibration + current with reduced belt speed typically indicates bearing "
            "wear, belt misalignment, or motor overload -- schedule maintenance before "
            "the next shift to avoid an unplanned stoppage."
        )
    return detail


ANOMALY_PRESETS = {
    "Normal reading": (55.0, 2.2, 12.0, 1.5),
    "Early bearing wear": (68.0, 3.8, 15.5, 1.3),
    "Severe fault (imminent failure)": (85.0, 6.5, 21.0, 0.6),
}


def load_preset(name):
    return ANOMALY_PRESETS[name]


# ==========================================================================
# TAB 4 -- Model Evaluation
# ==========================================================================

def eval_intent_section_md():
    return (
        f"### 1. Intent Classifier   "
        f"🎯 **{intent_eval.get('accuracy', 0):.0%} accuracy**  Β·  "
        f"**{intent_eval.get('macro_f1', 0):.0%} macro F1**  Β·  "
        f"held-out test set, {intent_eval.get('n_test', '?')} examples, "
        f"{intent_eval.get('n_classes', '?')} classes"
    )


def eval_anomaly_section_md():
    return (
        f"### 2. Predictive Maintenance (Anomaly Detector)   "
        f"🎯 **{anomaly_eval.get('f1', 0):.0%} F1**  Β·  "
        f"**{anomaly_eval.get('roc_auc', 0):.2f} ROC-AUC**  Β·  "
        f"**{anomaly_eval.get('precision', 0):.0%} precision**  Β·  "
        f"**{anomaly_eval.get('recall', 0):.0%} recall**"
    )


def eval_retrieval_section_md():
    return (
        f"### 3. RAG Retrieval   "
        f"🎯 **{retrieval_eval.get('hit_rate_at_1', 0):.0%} hit-rate@1**  Β·  "
        f"**{retrieval_eval.get('hit_rate_at_2', 0):.0%} hit-rate@2**  Β·  "
        f"{retrieval_eval.get('n_queries', '?')} labelled test queries"
    )


def eval_latency_section_md():
    return (
        f"### 4. Latency (CPU, avg of 50 runs)   "
        f"🎯 **{latency_eval.get('intent_classifier_ms', '?')} ms** intent  Β·  "
        f"**{latency_eval.get('anomaly_detector_ms', '?')} ms** anomaly  Β·  "
        f"**{latency_eval.get('kb_retrieval_ms', '?')} ms** retrieval"
    )


EVAL_METHODOLOGY_MD = (
    "*All metrics above are computed on held-out synthetic test data by "
    "`build_artifacts.py` (fully reproducible) β€” see the **About** tab for "
    "dataset details and how each model works. Two safeguards keep these "
    "numbers honest: (1) train/test splits are deduplicated on exact text "
    "with an automated zero-overlap check, so the intent classifier can't "
    "memorize verbatim test examples; (2) the anomaly detector is fit "
    "unsupervised on a training split, then its decision threshold is "
    "calibrated on a separate small *labelled calibration split* (analogous "
    "to a handful of confirmed historical incidents) β€” never on the test "
    "set used to report the metrics above.*"
)




# ==========================================================================
# TAB 5 -- About
# ==========================================================================

ABOUT_MD = f"""
# 🏭 Smart Warehouse AI Assistant

**A portfolio project demonstrating an applied-AI approach to intralogistics
and warehouse automation operations.**

Modern warehouse automation platforms β€” automated storage & retrieval
systems (AS/RS), conveyor & sortation lines, AGVs/AMRs, and the WMS/WCS
software that orchestrates them β€” generate huge volumes of operational
data: equipment telemetry, transactions, safety logs, and ad-hoc questions
from floor staff."

## What this demonstrates

| Capability | Where | Techniques used |
|---|---|---|
| **LLM-powered natural-language assistant**, grounded with retrieval (RAG) so it answers from real domain knowledge rather than hallucinating | *AI Assistant* tab | TF-IDF retrieval + hosted LLM (HF Inference API) |
| **Intent classification** to route free-text requests (maintenance, safety, navigation, inventory, etc.) the way a real ops system would triage tickets | *AI Assistant* / *Inventory* tabs | TF-IDF + Logistic Regression |
| **Predictive maintenance** via unsupervised anomaly detection on conveyor/crane sensor streams β€” catching bearing wear or misalignment before an unplanned stoppage | *Predictive Maintenance* tab | Isolation Forest, unsupervised |
| **NL-to-structured-query** over inventory/order data, a lightweight stand-in for a WMS query tool | *Inventory & Order Query* tab | Regex slot extraction + intent routing |
| **Rigorous, reproducible evaluation** of every ML component (accuracy, F1, ROC-AUC, retrieval hit-rate, latency) rather than just a demo that "looks like it works" | *Model Evaluation* tab | scikit-learn metrics, held-out test splits |

## Dataset overview β€” what the data is and where it's from

**Every dataset in this project is synthetically generated by the project's
own code** (`src/data_generation.py` and `src/knowledge_base.py`), not
scraped, exported, or sourced from any real company's systems. This was a
deliberate choice: it keeps the project fully self-contained, reproducible,
and shareable without any data-privacy or licensing concerns, while still
being realistic enough to demonstrate the underlying ML techniques properly.

| Dataset | What it is | Size | How it's generated |
|---|---|---|---|
| **Knowledge base** | Original, hand-written articles on generic warehouse-automation concepts (AS/RS, AGV/AMR, WMS, sortation, picking strategy, predictive maintenance, safety, inventory accuracy, KPIs, energy efficiency) | 10 articles | Written by the developer specifically for this project |
| **Intent queries** | Example free-text operational questions/requests across 8 categories | ~480 examples | ~8 templates per category with randomised SKU codes, zone names, order IDs, and equipment IDs slotted in |
| **Inventory table** | SKU records with category, zone, on-hand units, reorder point, unit cost | 60 SKUs | Randomised within realistic ranges (fixed seed) |
| **Orders table** | Order records with status, line count, priority, zone | 80 orders | Randomised within realistic ranges (fixed seed) |
| **Sensor readings** | Conveyor/crane motor telemetry: temperature, vibration, current, belt speed | 1,000 readings (900 normal + 100 anomalous) | Normal readings drawn from realistic operating ranges; anomalies simulate known failure signatures (elevated temp/vibration/current + reduced belt speed) |
| **Retrieval eval set** | Hand-labelled (question β†’ expected knowledge-base article) pairs | 10 pairs | Written by the developer to check retrieval accuracy |

## How each model works (function)

| Model | Purpose | Algorithm | Input β†’ Output |
|---|---|---|---|
| **Intent classifier** | Decide what kind of request a query is (maintenance, safety, inventory, order status, navigation, picking, system status, general FAQ) | TF-IDF + Logistic Regression | Free text β†’ intent label + confidence |
| **KB retriever** | Find the most relevant knowledge-base passage(s) for a query, to ground the LLM's answer | TF-IDF + cosine similarity | Free text β†’ top-k ranked passages |
| **LLM assistant** | Generate a natural-language answer grounded in the retrieved context | Hosted instruct LLM (HF Inference API), multi-model fallback chain | Query + context β†’ grounded answer (or an extractive fallback if the LLM is unavailable) |
| **Inventory/order query** | Turn a question into a filtered table lookup | Regex slot extraction (SKU / order ID / zone) + intent routing | Free text β†’ filtered inventory or orders table |
| **Anomaly detector** | Flag abnormal equipment sensor readings before they cause a stoppage | Isolation Forest (unsupervised), StandardScaler | 4 sensor features β†’ anomaly / normal + anomaly score |

## How to test this project

1. **AI Assistant** β€” try one of the example questions, or ask your own
   (e.g. *"The conveyor belt in Zone C is making noise"*). Check the
   metadata line under each answer to see the detected intent, which
   knowledge-base article(s) were retrieved, and whether the LLM or the
   fallback path answered. If the LLM path isn't working, open the
   **LLM connection diagnostics** accordion and click "Test LLM connection"
   for a precise error message.
2. **Inventory & Order Query** β€” try *"How many units of SKU-1042 are in
   Zone B?"* or *"What's the status of order #10007?"*, or browse the full
   synthetic tables in the accordion below the search box.
3. **Predictive Maintenance** β€” load one of the presets (Normal / Early
   bearing wear / Severe fault) or drag the sliders yourself, then click
   *"Check for anomaly"* to see the model's verdict and recommended action.
4. **Model Evaluation** β€” every chart here is generated on **held-out test
   data** by `build_artifacts.py`, not cherry-picked from a live run. Run
   that script yourself to reproduce every number from scratch.

## Tech stack

- **UI / deployment:** [Gradio](https://gradio.app) on Hugging Face Spaces
- **LLM:** Hosted instruct model via the Hugging Face **Inference API**
  (`huggingface_hub.InferenceClient`), with a multi-model fallback chain and
  auto provider routing. Configurable via the `LLM_MODEL_ID` env var. Falls
  back gracefully to a retrieval-only answer if no API token is configured
  or every candidate model fails, so the public demo never just breaks.
- **Retrieval:** TF-IDF + cosine similarity over a small hand-written
  warehouse-operations knowledge base (simple, fast, fully local RAG).
- **Intent classification:** TF-IDF + Logistic Regression (scikit-learn) --
  chosen deliberately over a heavier transformer classifier because it
  trains in under a second and comfortably reaches **{intent_eval.get('accuracy', 0):.0%}
  accuracy** on this task; right-sizing the model to the problem.
- **Anomaly detection:** Isolation Forest (scikit-learn), trained
  unsupervised on scaled sensor features.
- **Evaluation:** scikit-learn metrics + matplotlib, all computed by
  `build_artifacts.py` and saved as static artifacts the app loads at
  startup (fast, reproducible Space boot, self-healing if the deployed
  scikit-learn version ever drifts from the one used to train the models).

## Architecture

```
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   User query  ───► β”‚  Intent Classifier     β”‚  (TF-IDF + LogisticRegression)
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚ intent label
                               β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  KB Retriever (RAG)    β”‚  (TF-IDF cosine similarity)
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚ top-k passages
                               β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  Hosted LLM            β”‚  (HF Inference API)
                    β”‚  (or extractive        β”‚
                    β”‚   fallback if offline) β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β–Ό
                          Grounded answer

   Sensor stream ───► StandardScaler ───► IsolationForest ───► anomaly / normal
```

## Why this approach

The value of AI in a warehouse-automation context isn't a flashy chatbot β€”
it's **routing, grounding, and reliability**: correctly triaging a request,
answering from real operational context instead of guessing, and flagging
equipment problems before they cause downtime. Every component here was
chosen to be as simple as it can be while still doing that job well and
being honestly evaluated, rather than reaching for the biggest available
model by default.

## Limitations & next steps

- All data here is **synthetic**, for portfolio/demo purposes β€” a production
  version would connect to real WMS/WCS APIs and historical sensor logs.
- The intent set (8 classes) and knowledge base (10 articles) are intentionally
  small to keep the demo fast and auditable; both are easy to extend.
- The anomaly detector uses 4 hand-picked features; a production system would
  likely use a richer multivariate sensor set and a supervised or
  semi-supervised model once labelled failure data is available.

---
*Built as a portfolio/application project. Source code available on request
or in the linked repository. Feedback welcome.*
"""


# ==========================================================================
# GRADIO APP
# ==========================================================================

CUSTOM_CSS = """
#title-banner { text-align: center; margin-bottom: 0.5em; }
.gradio-container { max-width: 1150px !important; margin: auto; }
.data-badge {
    background: #eff6ff;
    border-left: 4px solid #3b82f6;
    border-radius: 6px;
    padding: 10px 14px;
    margin-bottom: 10px;
    font-size: 0.92em;
    line-height: 1.5;
}
.data-badge b { color: #1e3a8a; }
"""

def data_badge(html: str) -> str:
    return f'<div class="data-badge">{html}</div>'

with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue"), css=CUSTOM_CSS, title="Smart Warehouse AI Assistant") as demo:
    gr.Markdown(
        "<h1 id='title-banner'>🏭 Smart Warehouse AI Assistant</h1>"
        "<p style='text-align:center; color:gray;'>LLM-powered intralogistics copilot Β· "
        "intent routing Β· predictive maintenance Β· retrieval-grounded Q&A</p>"
    )
    if not HF_TOKEN_SET:
        gr.Markdown(
            "> ⚠️ **No HF token secret detected.** The AI Assistant tab will run in "
            "**retrieval-only fallback mode** (still functional, just not LLM-generated "
            f"prose). Add a secret named `HF_TOKEN` (or one of: {', '.join(TOKEN_ENV_VAR_CANDIDATES[1:])}) "
            "in *Space settings β†’ Variables and secrets* to enable full LLM responses."
        )

    with gr.Tab("πŸ’¬ AI Assistant"):
        with gr.Accordion("ℹ️ About this AI Assistant & Data Source", open=False):
            gr.HTML(data_badge(
                "πŸ§ͺ <b>Source:</b> hand-written by the developer β€” 10 original knowledge-base "
                "articles on warehouse operations (AS/RS, AGV/AMR, WMS, sortation, picking, "
                "maintenance, safety) plus ~480 template-generated example queries. "
                "Not scraped or sourced from any real company.<br>"
                
                "<b>What it describes:</b> generic intralogistics/automation concepts and "
                "operational scenarios (equipment faults, safety incidents, order/inventory "
                "questions) β€” general domain knowledge, not any specific facility's live data.<br>"
                
                "<b>Function:</b> answers free-text warehouse-ops questions by first classifying "
                "the query's intent, retrieving the most relevant knowledge-base passage(s) "
                "(RAG), then generating a grounded answer with a hosted LLM β€” falling back to "
                "showing the retrieved passages directly if no LLM is available."
            ))
        gr.ChatInterface(
            fn=chat_fn,
            type="messages",
            examples=ASSISTANT_EXAMPLES,
            chatbot=gr.Chatbot(height=430, label="Warehouse Assistant", type="messages"),
            textbox=gr.Textbox(placeholder="e.g. The conveyor belt in Zone C is making noise"),
        )
        with gr.Accordion("πŸ”§ LLM connection diagnostics", open=False):
            gr.Markdown(
                "If the assistant keeps answering in retrieval-only fallback mode, "
                "click below to test the LLM connection directly and see the exact "
                "error from each candidate model."
            )
            test_llm_btn = gr.Button("Test LLM connection")
            test_llm_output = gr.Markdown()
            test_llm_btn.click(test_llm_fn, inputs=None, outputs=test_llm_output)

    with gr.Tab("πŸ“¦ Inventory & Order Query"):
        with gr.Accordion("ℹ️ About Data & Query System", open=False):
            gr.HTML(data_badge(
                f"πŸ§ͺ <b>Source:</b> randomly generated by the developer's code "
                f"(<code>src/data_generation.py</code>, fixed seed) β€” "
                f"{len(inventory_df)} synthetic SKU records and "
                f"{len(orders_df)} synthetic orders. "
                "Not exported from any real company's WMS.<br>"
    
                "<b>What it describes:</b> a stand-in inventory table "
                "(SKU, category, zone, on-hand units, reorder point, unit cost) "
                "and orders table (order ID, status, line count, priority, zone) β€” "
                "realistic in shape and ranges, but fictional records, not live "
                "warehouse data.<br>"
    
                "<b>Function:</b> classifies whether a query is about inventory or "
                "order status, extracts SKU/order-ID/zone identifiers with regex, "
                "and filters the corresponding table β€” a lightweight stand-in "
                "for a natural-language WMS query tool."
            ))
        with gr.Row():
            inv_input = gr.Textbox(label="Query", placeholder="How many units of SKU-1042 are in Zone C?", scale=4)
            inv_btn = gr.Button("Search", variant="primary", scale=1)
        inv_note = gr.Markdown()
        inv_result = gr.Dataframe(label="Results", wrap=True)
        inv_btn.click(inventory_query_fn, inputs=inv_input, outputs=[inv_note, inv_result])
        inv_input.submit(inventory_query_fn, inputs=inv_input, outputs=[inv_note, inv_result])

        gr.Examples(
            examples=[
                "How many units of SKU-1042 are in Zone C?",
                "What's the status of order #10007?",
                "Show me low stock items",
                "Any delayed orders?",
                "Is SKU-1015 in stock at Zone D?",
                "Do we have enough SKU-1030 to fulfill 200 units?",
                "Track order #10021 for me",
                "Show the fulfillment status of #10045",
            ],
            inputs=inv_input,
        )
        with gr.Accordion("Browse full tables", open=False):
            gr.Markdown("**Inventory** (synthetic)")
            gr.Dataframe(inventory_df, wrap=True)
            gr.Markdown("**Orders** (synthetic)")
            gr.Dataframe(orders_df, wrap=True)

    with gr.Tab("⚠️ Predictive Maintenance"):
        with gr.Accordion("ℹ️ About Data & Predictive System", open=False):
            gr.HTML(data_badge(
                "πŸ§ͺ <b>Source:</b> randomly generated by the developer's code "
                "(`src/data_generation.py`, fixed seed) β€” 1,000 synthetic sensor readings "
                "(900 normal + 100 simulated-fault). Not logged from real equipment.<br>"
                "<b>What it describes:</b> conveyor/crane motor sensor readings β€” temperature, "
                "vibration, current draw, and belt speed β€” with the 'anomaly' readings modelled "
                "on realistic failure signatures (elevated temp/vibration/current with reduced "
                "belt speed, as seen with bearing wear or motor overload).<br>"
                "<b>Function:</b> an Isolation Forest model trained unsupervised (it never sees "
                "an anomaly label) learns what 'normal' sensor behaviour looks like, then flags "
                "any new reading β€” like the ones you enter below β€” that deviates from it, as an "
                "early-warning signal before an unplanned equipment stoppage."
            ))
        preset_dropdown = gr.Dropdown(
            choices=list(ANOMALY_PRESETS.keys()), label="Load a preset reading", value="Normal reading"
        )
        with gr.Row():
            motor_temp_in = gr.Slider(30, 100, value=55, step=0.5, label="Motor temperature (Β°C)")
            vibration_in = gr.Slider(0, 10, value=2.2, step=0.1, label="Vibration (mm/s)")
        with gr.Row():
            current_in = gr.Slider(5, 30, value=12, step=0.5, label="Motor current (A)")
            belt_speed_in = gr.Slider(0.1, 2.5, value=1.5, step=0.05, label="Belt speed (m/s)")
        check_btn = gr.Button("Check for anomaly", variant="primary")
        anomaly_output = gr.Markdown()

        preset_dropdown.change(
            load_preset, inputs=preset_dropdown,
            outputs=[motor_temp_in, vibration_in, current_in, belt_speed_in],
        )
        check_btn.click(
            anomaly_fn,
            inputs=[motor_temp_in, vibration_in, current_in, belt_speed_in],
            outputs=anomaly_output,
        )

    with gr.Tab("πŸ“Š Model Evaluation"):
        with gr.Accordion("ℹ️ About Model Evaluation", open=False):
            gr.HTML(data_badge(
                "πŸ§ͺ All charts below are computed on <b>held-out test data</b> "
                "by <code>build_artifacts.py</code> β€” fully reproducible."
            ))

        gr.Markdown("### 1. Intent Classifier β€” accuracy: **{:.1%}** Β· macro F1: **{:.1%}**".format(
            intent_eval.get("accuracy", 0), intent_eval.get("macro_f1", 0)
        ))
        with gr.Row():
            gr.Image(os.path.join(ASSETS_DIR, "intent_dataset_composition.png"), show_label=False, container=False)
            gr.Image(os.path.join(ASSETS_DIR, "intent_per_class_bar.png"), show_label=False, container=False)
        gr.Image(os.path.join(ASSETS_DIR, "intent_confusion_matrix.png"), show_label=False, container=False)

        gr.Markdown("### 2. Predictive Maintenance β€” F1: **{:.1%}** Β· ROC-AUC: **{:.2f}**".format(
            anomaly_eval.get("f1", 0), anomaly_eval.get("roc_auc", 0)
        ))
        gr.Image(os.path.join(ASSETS_DIR, "sensor_distributions.png"), show_label=False, container=False)
        with gr.Row():
            gr.Image(os.path.join(ASSETS_DIR, "anomaly_metrics_bar.png"), show_label=False, container=False)
            gr.Image(os.path.join(ASSETS_DIR, "anomaly_confusion_matrix.png"), show_label=False, container=False)
        gr.Image(os.path.join(ASSETS_DIR, "anomaly_roc_curve.png"), show_label=False, container=False)

        gr.Markdown("### 3. RAG Retrieval β€” Hit-rate@1: **{:.0%}** Β· Hit-rate@2: **{:.0%}**".format(
            retrieval_eval.get("hit_rate_at_1", 0), retrieval_eval.get("hit_rate_at_2", 0)
        ))
        gr.Image(os.path.join(ASSETS_DIR, "retrieval_hitrate_bar.png"), show_label=False, container=False)

        gr.Markdown("### 4. Latency Benchmark (CPU, local components)")
        gr.Image(os.path.join(ASSETS_DIR, "latency_bar.png"), show_label=False, container=False)

        with gr.Accordion("πŸ“‹ Full metrics tables & methodology notes", open=False):
            gr.Markdown(eval_intent_section_md())
            gr.Markdown(eval_anomaly_section_md())
            gr.Markdown(eval_retrieval_section_md())
            gr.Markdown(eval_latency_section_md())
            gr.Markdown(EVAL_METHODOLOGY_MD)

    with gr.Tab("ℹ️ About"):
        gr.Markdown(ABOUT_MD)



if __name__ == "__main__":
    # ssr_mode=False: Gradio's experimental server-side rendering mode was
    # causing repeated "SvelteKitError: POST method not allowed" / 405 log
    # spam on Spaces (some requests hit the SSR page route instead of the
    # API route). Disabling it avoids that; this app has no functional need
    # for SSR. Wrapped defensively in case the kwarg name differs across
    # Gradio versions -- falls back to a plain launch() rather than crashing
    # the Space if so.
    launch_kwargs = dict(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))
    try:
        demo.launch(**launch_kwargs, ssr=False)
    except TypeError:
        demo.launch(**launch_kwargs)