File size: 32,113 Bytes
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9c247c
f0fae3f
 
 
 
 
 
 
 
bbd6411
 
 
 
 
 
f0fae3f
bbd6411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9c247c
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
 
 
f0fae3f
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
f0fae3f
 
 
 
 
 
 
 
 
8f9afd8
 
f0fae3f
8f9afd8
 
 
 
f0fae3f
8f9afd8
 
 
 
 
f0fae3f
8f9afd8
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
8f9afd8
f0fae3f
 
8f9afd8
 
f0fae3f
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
8f9afd8
f0fae3f
 
8f9afd8
 
 
 
 
 
 
f0fae3f
 
8f9afd8
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
8f9afd8
f0fae3f
 
8f9afd8
 
f0fae3f
 
8f9afd8
 
f0fae3f
 
 
 
 
8f9afd8
 
f0fae3f
 
8f9afd8
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9c247c
f0fae3f
f9c247c
 
f0fae3f
 
 
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
8f9afd8
f0fae3f
 
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
672
673
"""
Smart Warehouse AI Assistant
=============================
A Daifuku-style 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 an application project for Daifuku Co., Ltd.
"""

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")

    def _try_load():
        pipeline = load_pipeline(intent_path)
        model, scaler = load_anomaly_artifacts(anomaly_model_path, anomaly_scaler_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})
        return pipeline, model, scaler

    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 = _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 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?",
]


# ==========================================================================
# 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)
    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():
    cls_report = intent_eval.get("classification_report", {})
    per_class_rows = []
    for cls in intent_eval.get("classes", []):
        stats = cls_report.get(cls, {})
        per_class_rows.append(
            f"| {cls} | {stats.get('precision', 0):.2f} | {stats.get('recall', 0):.2f} | "
            f"{stats.get('f1-score', 0):.2f} | {int(stats.get('support', 0))} |"
        )
    per_class_table = "\n".join(per_class_rows)
    return f"""
## 1. Intent Classifier (TF-IDF + Logistic Regression)

**What it does:** routes a free-text query (e.g. *"The conveyor belt in Zone C
is making noise"*) into one of 8 operational categories, used by both the
AI Assistant and Inventory & Order Query tabs to decide how to handle a
request.

**Training data:** {intent_eval.get('n_train', '?') } synthetically generated
example queries (see chart below), built from ~8 hand-written templates per
category with randomised SKU codes, zone names, order IDs, and equipment IDs
slotted in -- e.g. *"How many units of {{sku}} are in {{zone}}?"*. This keeps
the language varied while being fully reproducible (`src/data_generation.py`).

Evaluated on a **held-out stratified test split** of {intent_eval.get('n_test', '?')}
examples the model never saw during training.

| Metric | Score |
|---|---|
| **Accuracy** | **{intent_eval.get('accuracy', 0):.2%}** |
| **Macro F1** | **{intent_eval.get('macro_f1', 0):.2%}** |

**Per-class performance:**

| Intent | Precision | Recall | F1 | Support |
|---|---|---|---|---|
{per_class_table}
"""


def eval_anomaly_section_md():
    return f"""
## 2. Predictive Maintenance Anomaly Detector (Isolation Forest)

**What it does:** flags abnormal conveyor/crane motor sensor readings
(temperature, vibration, current, belt speed) before they cause an
unplanned stoppage -- powers the Predictive Maintenance tab.

**Training data:** {900 + 100} synthetic sensor readings (900 "normal"
+ 100 "anomaly" patterns), each with 4 features. Normal readings are drawn
from realistic operating ranges (e.g. ~55Β°C motor temp, ~2.2 mm/s vibration);
anomalies simulate bearing wear / misalignment / overload (elevated temp,
vibration, and current with reduced belt speed). See the distribution chart
below for exactly how these two classes differ.

The model itself is trained **unsupervised** (Isolation Forest never sees
the anomaly label during fitting) -- labels are used only to *evaluate* it
afterward, on a held-out test split of {anomaly_eval.get('n_test', '?')}
readings ({anomaly_eval.get('test_anomaly_rate', 0):.1%} true anomaly rate).

| Metric | Score |
|---|---|
| **Precision** | **{anomaly_eval.get('precision', 0):.2%}** |
| **Recall** | **{anomaly_eval.get('recall', 0):.2%}** |
| **F1 Score** | **{anomaly_eval.get('f1', 0):.2%}** |
| **ROC-AUC** | **{anomaly_eval.get('roc_auc', 0):.3f}** |
| Accuracy | {anomaly_eval.get('accuracy', 0):.2%} |
"""


def eval_retrieval_section_md():
    retrieval_rows = "\n".join(
        f"| {r['query']} | {r['expected']} | {r['retrieved_top1']} | "
        f"{'βœ…' if r['hit@1'] else ('〰️' if r['hit@2'] else '❌')} | {r['top1_score']:.2f} |"
        for r in retrieval_eval.get("rows", [])
    )
    return f"""
## 3. Retrieval (RAG) Evaluation

**What it does:** before the LLM answers a question, this component finds
the most relevant passages from a 10-article warehouse-operations knowledge
base (AS/RS, AGV/AMR, WMS, sortation, picking strategy, safety, etc. -- see
`src/knowledge_base.py`) using TF-IDF + cosine similarity, so the LLM answers
from real context rather than guessing.

**Evaluation data:** {retrieval_eval.get('n_queries', '?')} hand-labelled
(query, expected-article) pairs -- a small ground-truth set built by hand to
check the retriever finds the *right* article, not just *an* article.

| Metric | Score |
|---|---|
| **Hit Rate @ 1** | **{retrieval_eval.get('hit_rate_at_1', 0):.0%}** |
| **Hit Rate @ 2** | **{retrieval_eval.get('hit_rate_at_2', 0):.0%}** |

| Query | Expected Doc | Retrieved (top-1) | Hit | Score |
|---|---|---|---|---|
{retrieval_rows}
"""


def eval_latency_section_md():
    return f"""
## 4. Latency Benchmark (per-request, CPU)

Average of 50 runs each, measured on the same CPU hardware the Space runs on.

| Component | Avg. latency |
|---|---|
| Intent classification | {latency_eval.get('intent_classifier_ms', '?')} ms |
| Anomaly scoring | {latency_eval.get('anomaly_detector_ms', '?')} ms |
| KB retrieval (TF-IDF) | {latency_eval.get('kb_retrieval_ms', '?')} ms |
| LLM generation | Depends on the hosted Inference API (measured live per-request in the Assistant tab, not benchmarked here) |
"""


EVAL_METHODOLOGY_MD = """
### Evaluation methodology notes

- All datasets are **synthetically generated** (see `src/data_generation.py`) using
  templated-but-varied natural language and randomised sensor distributions with a
  fixed seed, so results are fully reproducible via `python build_artifacts.py`.
- The intent classifier and anomaly detector are evaluated on a **held-out test
  split** they never saw during training (stratified, 25% / 30% respectively).
- The anomaly detector itself is trained **unsupervised** (Isolation Forest never
  sees the `label` column during `.fit()`); labels are used only to *evaluate* it,
  mirroring how you'd validate an anomaly model against a small set of confirmed
  historical incidents in production.
- In a production deployment, all three components would be continuously
  re-evaluated against real WMS/WCS/sensor logs rather than synthetic data.
"""




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

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

**A portfolio project demonstrating an applied-AI approach to intralogistics
operations, built as part of a job application to Daifuku Co., Ltd.**

## What this demonstrates

Daifuku builds material handling and automation systems -- AS/RS, conveyors
and sortation, AGVs/AMRs, and the software (WMS/WCS) that orchestrates them.
This project is a compact but end-to-end example of how an AI layer can sit
on top of that kind of system:

| Capability | Where |
|---|---|
| **LLM-powered natural-language assistant**, grounded with retrieval (RAG) so it answers from real warehouse-ops knowledge rather than hallucinating | *AI Assistant* tab |
| **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 |
| **Predictive maintenance** via unsupervised anomaly detection on conveyor/crane sensor streams -- catching bearing wear or misalignment before an unplanned stoppage | *Predictive Maintenance* tab |
| **NL-to-structured-query** over inventory/order data, a lightweight stand-in for a WMS query tool | *Inventory & Order Query* tab |
| **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 |

## 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`), configurable via the `LLM_MODEL_ID`
  env var. Falls back gracefully to a retrieval-only answer if no `HF_TOKEN`
  is configured, so the public demo never 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).

## 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 matters for Daifuku

Modern intralogistics platforms generate huge volumes of operational data --
equipment telemetry, WMS transactions, safety logs. The value of AI here 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. This
project tries to demonstrate that mindset in miniature, with honest,
reproducible evaluation numbers rather than cherry-picked demo runs.

## 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 an application project. Source code available on request / 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; }
"""

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"):
        gr.Markdown(
            "Ask about equipment status, maintenance, safety, inventory, order status, "
            "AGV routing, picking strategy, or general warehouse-automation concepts. "
            "Answers are grounded (RAG) in a small warehouse-operations knowledge base."
        )
        with gr.Accordion("ℹ️ About the data behind this tab", open=False):
            gr.Markdown(
                "- **Knowledge base (RAG source):** 10 original, hand-written articles "
                "covering AS/RS, AGV/AMR, WMS, conveyor/sortation, picking strategy, "
                "predictive maintenance, safety protocol, inventory accuracy, KPIs, and "
                "energy efficiency (`src/knowledge_base.py`). Every answer's *Retrieved "
                "context* line shows exactly which article(s) it drew on.\n"
                "- **Intent classifier:** trained on ~480 synthetically generated example "
                "queries across 8 categories (see the Model Evaluation tab for accuracy).\n"
                "- **LLM:** a hosted instruct model called via the Hugging Face Inference "
                "API β€” not run locally. If no `HF_TOKEN` is configured, or the API call "
                "fails, this tab automatically falls back to showing the retrieved "
                "knowledge-base passages directly, so it never just breaks."
            )
        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"):
        gr.Markdown(
            "Type a natural-language inventory or order question. The intent classifier "
            "decides whether to query the inventory table or the orders table, then "
            "extracts SKU / order-id / zone slots to filter the result."
        )
        with gr.Accordion("ℹ️ About the data behind this tab", open=False):
            gr.Markdown(
                f"- **Inventory table:** {len(inventory_df)} synthetic SKUs across 5 "
                "categories (Electronics, Apparel, Automotive Parts, Food & Beverage, "
                "Household) and 4 warehouse zones, with randomised on-hand quantities, "
                "reorder points, and unit costs.\n"
                f"- **Orders table:** {len(orders_df)} synthetic orders with randomised "
                "status (Received / Picking / Packed / Shipped / Delayed), line count, "
                "priority, and zone.\n"
                "- Both tables are generated by `src/data_generation.py` with a fixed "
                "random seed, so they're reproducible but **not real operational data** "
                "-- this is a stand-in for a live WMS/WCS query interface.\n"
                "- Query parsing is regex-based slot extraction (SKU codes like `SKU-1042`, "
                "order IDs like `#10007`, zone names) combined with the same intent "
                "classifier used in the AI Assistant tab (`src/inventory_db.py`)."
            )
        with gr.Row():
            inv_input = gr.Textbox(label="Query", placeholder="How many units of SKU-1042 are in Zone B?", 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 B?",
                "What's the status of order #10007?",
                "Show me low stock items",
                "Any delayed orders?",
            ],
            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"):
        gr.Markdown(
            "Enter live (or hypothetical) conveyor/crane motor sensor readings to check "
            "for anomalous behaviour using an Isolation Forest model trained on "
            "historical sensor patterns."
        )
        with gr.Accordion("ℹ️ About the data behind this tab", open=False):
            gr.Markdown(
                "- **Training data:** 1,000 synthetic sensor readings (900 normal + 100 "
                "anomalous) across 4 features -- motor temperature, vibration, motor "
                "current, and belt speed -- generated by `src/data_generation.py`. "
                "Anomalies simulate realistic failure signatures: elevated temperature, "
                "vibration, and current combined with reduced/erratic belt speed (the "
                "pattern of bearing wear, belt misalignment, or motor overload).\n"
                "- **Model:** Isolation Forest (unsupervised) trained on scaled features "
                "-- it never sees a 'this is an anomaly' label during training, only "
                "learns what 'normal' looks like and flags deviations from it.\n"
                "- See the **Model Evaluation** tab for the feature-distribution chart "
                "showing exactly how normal vs. anomalous readings differ, plus "
                "precision/recall/F1/ROC-AUC on held-out data."
            )
        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"):
        gr.Markdown(
            "Every metric on this tab is computed on **held-out test data** by "
            "`build_artifacts.py` (not cherry-picked from a live demo run) -- "
            "re-run that script any time to reproduce these numbers from scratch."
        )

        gr.Markdown(eval_intent_section_md())
        gr.Markdown("**Dataset composition** (how many training examples per intent):")
        gr.Image(os.path.join(ASSETS_DIR, "intent_dataset_composition.png"), show_label=False, container=False)
        gr.Markdown("**Per-class precision / recall / F1:**")
        gr.Image(os.path.join(ASSETS_DIR, "intent_per_class_bar.png"), show_label=False, container=False)
        gr.Markdown("**Confusion matrix:**")
        gr.Image(os.path.join(ASSETS_DIR, "intent_confusion_matrix.png"), show_label=False, container=False)

        gr.Markdown("---")
        gr.Markdown(eval_anomaly_section_md())
        gr.Markdown("**Sensor feature distributions (normal vs. anomaly):**")
        gr.Image(os.path.join(ASSETS_DIR, "sensor_distributions.png"), show_label=False, container=False)
        with gr.Row():
            with gr.Column():
                gr.Markdown("**Evaluation metrics:**")
                gr.Image(os.path.join(ASSETS_DIR, "anomaly_metrics_bar.png"), show_label=False, container=False)
            with gr.Column():
                gr.Markdown("**Confusion matrix:**")
                gr.Image(os.path.join(ASSETS_DIR, "anomaly_confusion_matrix.png"), show_label=False, container=False)
        gr.Markdown("**ROC curve:**")
        gr.Image(os.path.join(ASSETS_DIR, "anomaly_roc_curve.png"), show_label=False, container=False)

        gr.Markdown("---")
        gr.Markdown(eval_retrieval_section_md())
        gr.Image(os.path.join(ASSETS_DIR, "retrieval_hitrate_bar.png"), show_label=False, container=False)

        gr.Markdown("---")
        gr.Markdown(eval_latency_section_md())
        gr.Image(os.path.join(ASSETS_DIR, "latency_bar.png"), show_label=False, container=False)

        gr.Markdown("---")
        gr.Markdown(EVAL_METHODOLOGY_MD)

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


if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))