File size: 7,520 Bytes
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
f0fae3f
 
 
 
8f9afd8
 
f0fae3f
 
 
8f9afd8
 
 
 
 
a221c9f
8f9afd8
 
a221c9f
8f9afd8
 
 
 
 
 
 
 
f0fae3f
f9c247c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f9afd8
 
f0fae3f
 
 
 
 
 
 
 
 
 
 
 
 
 
f9c247c
f0fae3f
 
 
 
 
 
 
 
 
f9c247c
 
 
 
 
f0fae3f
 
 
 
8f9afd8
f0fae3f
 
8f9afd8
 
 
f0fae3f
8f9afd8
 
 
 
 
 
 
 
 
a221c9f
 
 
 
 
 
8f9afd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
llm_client.py
-------------
Wraps a hosted LLM (via huggingface_hub's InferenceClient, using HF's
serverless Inference Providers) to answer warehouse-operations questions,
grounded with context retrieved from the local knowledge base (simple RAG).

Design notes
------------
* Reads the HF token from the `HF_TOKEN` environment variable, which should
  be added as a Space "secret" when deployed (Settings -> Variables and
  secrets). The public demo also works without a token: it falls back to a
  deterministic, still-useful extractive answer built from the retrieved
  knowledge-base passages, so the Space never shows a broken demo.
* Serverless model availability on the free HF Inference API changes over
  time (models get gated, deprecated, or moved between providers), so
  rather than hard-depending on a single model id, we try a short list of
  candidates in order and use the first one that responds successfully.
  `LLM_MODEL_ID` (env var) is tried first if set, ahead of the built-in list.
* On failure, the *actual* exception message (not just its type) is
  surfaced back to the UI, so a broken deployment is debuggable from the
  Space itself instead of requiring log access.
"""

import os
import time
from dataclasses import dataclass, field
from typing import List, Optional

from src.retriever import KBRetriever, RetrievedDoc

# Small, widely-available instruct models known to work well on HF's free
# serverless Inference API. Tried in order; first success wins. If
# LLM_MODEL_ID is set as an env var, it is tried first, ahead of this list.
MODEL_CANDIDATES = [
    "Qwen/Qwen2.5-7B-Instruct",
    "meta-llama/Llama-3.1-8B-Instruct",
    "meta-llama/Llama-3.2-3B-Instruct",
    "mistralai/Mistral-7B-Instruct-v0.3",
    "microsoft/Phi-3.5-mini-instruct",
    "HuggingFaceH4/zephyr-7b-beta",
]

_env_model = os.environ.get("LLM_MODEL_ID")
if _env_model:
    MODEL_CANDIDATES = [_env_model] + [m for m in MODEL_CANDIDATES if m != _env_model]

DEFAULT_MODEL_ID = MODEL_CANDIDATES[0]

# The token is normally expected as `HF_TOKEN`, but we also accept a few
# common alternate secret names in case the Space was set up with a
# different name. First one found wins. Add your own name here if needed.
TOKEN_ENV_VAR_CANDIDATES = [
    "HF_TOKEN",
    "Smart_Warehouse",
    "HUGGINGFACE_TOKEN",
    "HUGGINGFACEHUB_API_TOKEN",
    "HF_API_TOKEN",
    "HUGGING_FACE_HUB_TOKEN",
    "HF_ACCESS_TOKEN",
]


def _get_hf_token() -> Optional[str]:
    for var in TOKEN_ENV_VAR_CANDIDATES:
        val = os.environ.get(var)
        if val:
            return val
    return None

SYSTEM_PROMPT = (
    "You are the Smart Warehouse AI Assistant, a helpful operations copilot "
    "for a large automated distribution center (conveyors, AS/RS, AGVs/AMRs, "
    "sortation, and a WMS). Answer concisely and practically, in the tone of "
    "an experienced warehouse operations engineer. Use the provided CONTEXT "
    "when relevant, and say so plainly if the question is outside the "
    "context. Prefer short paragraphs or bullet points over long prose."
)


@dataclass
class AssistantResponse:
    answer: str
    used_llm: bool
    sources: List[RetrievedDoc]
    latency_s: float
    model_id: str
    debug_errors: List[str] = field(default_factory=list)  # non-empty only when used_llm is False due to failures


def _extractive_fallback(query: str, sources: List[RetrievedDoc]) -> str:
    """Deterministic answer used when no HF token / API call fails, so the
    Space always returns something useful instead of an error."""
    if not sources:
        return (
            "I don't have grounded context for that yet. Try asking about "
            "inventory, order status, equipment maintenance, AGV routing, "
            "picking strategy, safety incidents, or general warehouse "
            "automation concepts."
        )
    lead = sources[0]
    bullets = "\n".join(f"- **{s.title}**: {s.text}" for s in sources)
    return (
        f"(Showing retrieved knowledge instead of an LLM-generated answer -- "
        f"see the diagnostics below.)\n\n"
        f"Based on **{lead.title}**, here's the relevant information:\n\n{bullets}"
    )


def answer_query(
    query: str,
    retriever: KBRetriever,
    k: int = 2,
    max_tokens: int = 350,
) -> AssistantResponse:
    start = time.time()
    sources = retriever.retrieve(query, k=k)
    context_block = "\n\n".join(f"[{s.title}]\n{s.text}" for s in sources)

    hf_token = _get_hf_token()

    if not hf_token:
        answer = _extractive_fallback(query, sources)
        return AssistantResponse(
            answer=answer,
            used_llm=False,
            sources=sources,
            latency_s=time.time() - start,
            model_id="extractive-fallback",
            debug_errors=[
                "No HF token secret found. Checked env vars: "
                + ", ".join(TOKEN_ENV_VAR_CANDIDATES)
                + ". Set one of these as a Space secret (Settings -> Variables and secrets)."
            ],
        )

    try:
        from huggingface_hub import InferenceClient
    except ImportError as e:
        answer = _extractive_fallback(query, sources)
        return AssistantResponse(
            answer=answer, used_llm=False, sources=sources,
            latency_s=time.time() - start, model_id="extractive-fallback",
            debug_errors=[f"huggingface_hub not importable: {e}"],
        )

    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": f"CONTEXT:\n{context_block}\n\nQUESTION: {query}"},
    ]

    errors = []
    for candidate in MODEL_CANDIDATES:
        try:
            # provider="auto" (the huggingface_hub default) lets HF's Inference
            # Providers router pick whichever backend (hf-inference, Together,
            # Fireworks, Novita, SambaNova, etc.) actually serves this specific
            # model -- hardcoding a single provider caused "model not supported
            # by provider X" errors for models hosted elsewhere.
            client = InferenceClient(model=candidate, token=hf_token, provider="auto")
            completion = client.chat_completion(messages=messages, max_tokens=max_tokens, temperature=0.3)
            text = completion.choices[0].message.content
            if text and text.strip():
                return AssistantResponse(
                    answer=text,
                    used_llm=True,
                    sources=sources,
                    latency_s=time.time() - start,
                    model_id=candidate,
                )
            errors.append(f"{candidate}: empty response")
        except Exception as e:  # noqa: BLE001 -- try the next candidate model
            errors.append(f"{candidate}: {type(e).__name__}: {e}")

    # All candidates failed -- fall back, but surface the real errors so the
    # deployment is debuggable directly from the UI.
    answer = _extractive_fallback(query, sources)
    return AssistantResponse(
        answer=answer,
        used_llm=False,
        sources=sources,
        latency_s=time.time() - start,
        model_id="extractive-fallback",
        debug_errors=errors,
    )


def test_connection(retriever: Optional[KBRetriever] = None) -> AssistantResponse:
    """Runs a single canned query through the full pipeline -- used by the
    'Test LLM connection' diagnostics button in the app."""
    retriever = retriever or KBRetriever()
    return answer_query("What is a WMS?", retriever, k=1, max_tokens=60)