Eyob-Sol commited on
Commit
ac1f51b
·
verified ·
1 Parent(s): 5645c33

Upload 41 files

Browse files
.gitattributes CHANGED
@@ -37,3 +37,4 @@ runtime/audio/tts_3bac9b920ffa4a6a93a9eed5ca215bea.wav filter=lfs diff=lfs merge
37
  runtime/audio/tts_fc786b49aad940e4992413247701abf3.wav filter=lfs diff=lfs merge=lfs -text
38
  runtime/audio/tts_4056705ada224a0092325b697c975501.wav filter=lfs diff=lfs merge=lfs -text
39
  models/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf filter=lfs diff=lfs merge=lfs -text
 
 
37
  runtime/audio/tts_fc786b49aad940e4992413247701abf3.wav filter=lfs diff=lfs merge=lfs -text
38
  runtime/audio/tts_4056705ada224a0092325b697c975501.wav filter=lfs diff=lfs merge=lfs -text
39
  models/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf filter=lfs diff=lfs merge=lfs -text
40
+ runtime/audio/tts_8eda72f9b61c4b13a04c70a4b1f1a997.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -4,6 +4,7 @@ emoji: ☎️
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
 
7
  app_file: app.py
8
  pinned: false
9
  license: mit
 
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 4.44.0
8
  app_file: app.py
9
  pinned: false
10
  license: mit
app.py CHANGED
@@ -1,18 +1,14 @@
 
1
  from app.gradio_app import build_demo
2
- # in app.py or when building the demo
3
  from models.tts_router import cleanup_old_audio
4
- cleanup_old_audio(keep_latest=None) # removes all existing tts_*.wav on boot
5
- from utils.startup_models import ensure_model
6
-
7
- # - LLM (GGUF): Qwen/Qwen2.5-1.5B-Instruct-GGUF → q4_k_m (~0.7 GB)
8
- llm_dir = ensure_model("Qwen/Qwen2.5-1.5B-Instruct-GGUF", "*.gguf")
9
-
10
- # - Piper voice (~100–200 MB depending on voice)
11
- piper_dir = ensure_model("rhasspy/piper-voices", "en/en_US/en_US-amy-medium.onnx")
12
 
13
  def main():
 
 
 
14
  demo = build_demo()
15
- demo.launch(share=True, server_port=7860, inbrowser=False)
 
16
 
17
  if __name__ == "__main__":
18
  main()
 
1
+ # app.py
2
  from app.gradio_app import build_demo
 
3
  from models.tts_router import cleanup_old_audio
 
 
 
 
 
 
 
 
4
 
5
  def main():
6
+ # Clean up old TTS files on boot
7
+ cleanup_old_audio(keep_latest=None)
8
+
9
  demo = build_demo()
10
+ # Don’t set server_name/server_port; HF will handle it.
11
+ demo.launch(share=True)
12
 
13
  if __name__ == "__main__":
14
  main()
app/__pycache__/gradio_app.cpython-312.pyc CHANGED
Binary files a/app/__pycache__/gradio_app.cpython-312.pyc and b/app/__pycache__/gradio_app.cpython-312.pyc differ
 
app/catalog.py CHANGED
@@ -1,85 +1,137 @@
1
  # app/catalog.py
2
  from __future__ import annotations
3
- import json, os
4
- from typing import Dict, Any, List, Optional
 
 
5
 
6
- _CATALOG: Dict[str, Any] | None = None
 
 
 
 
 
7
 
8
  def get_catalog_path() -> str:
 
 
 
 
 
 
 
 
9
  here = os.path.dirname(os.path.abspath(__file__))
10
  root = os.path.dirname(here)
11
  return os.path.join(root, "data", "menu_catalog.json")
12
 
13
- def load_catalog() -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  global _CATALOG
15
- if _CATALOG is not None:
16
  return _CATALOG
17
  path = get_catalog_path()
18
- with open(path, "r", encoding="utf-8") as f:
19
- _CATALOG = json.load(f)
20
  return _CATALOG
21
 
22
  def find_item_by_name(name: str) -> Optional[Dict[str, Any]]:
23
  c = load_catalog()
24
- name_l = (name or "").strip().lower()
 
 
25
  for it in c["items"]:
26
- if it["name"].lower() == name_l:
27
- return it
28
- # lightweight alias match
29
- if name_l in it["name"].lower():
30
  return it
 
 
 
 
31
  return None
32
 
33
  def find_item_by_sku(sku: str) -> Optional[Dict[str, Any]]:
34
  c = load_catalog()
 
 
 
35
  for it in c["items"]:
36
- if it["sku"] == sku:
37
  return it
38
  return None
39
 
40
  def required_fields_for_category(category: str) -> List[str]:
41
  c = load_catalog()
42
  schema = c["schema"].get(category) or {}
43
- return list(schema.get("required_fields") or [])
 
44
 
45
  def optional_fields_for_category(category: str) -> List[str]:
46
  c = load_catalog()
47
  schema = c["schema"].get(category) or {}
48
- return list(schema.get("optional_fields") or [])
 
 
 
 
 
 
 
 
 
49
 
50
  def compute_missing_fields(order_item: Dict[str, Any]) -> List[str]:
51
  """
52
- order_item: {"name": "...", "sku": optional, "qty": int, "<opts>": ...}
 
53
  Uses catalog schema to see which fields are missing.
54
  """
55
- it = None
56
- if "sku" in order_item:
57
- it = find_item_by_sku(order_item["sku"])
58
- if not it and "name" in order_item:
59
- it = find_item_by_name(order_item["name"])
60
  if not it:
61
  return ["name"] # we don’t even know the item yet
62
 
63
  category = it["category"]
64
  req = set(required_fields_for_category(category))
65
- present = set([k for k in order_item.keys() if k in req or k == "qty" or k == "name" or k == "sku"])
66
 
67
- # qty normalization: consider qty present if >=1
68
- if "qty" in req and (order_item.get("qty") is None or int(order_item.get("qty", 0)) < 1):
69
- # keep qty “missing”
70
- pass
 
 
 
 
 
 
 
71
  else:
72
- present.add("qty")
 
 
73
 
74
  missing = [f for f in req if f not in present]
75
  return missing
76
 
77
  def friendly_requirements_prompt(order_item: Dict[str, Any]) -> str:
78
- it = None
79
- if "sku" in order_item:
80
- it = find_item_by_sku(order_item["sku"])
81
- if not it and "name" in order_item:
82
- it = find_item_by_name(order_item["name"])
83
  if not it:
84
  return "Which item would you like to order?"
85
 
@@ -87,19 +139,16 @@ def friendly_requirements_prompt(order_item: Dict[str, Any]) -> str:
87
  req = required_fields_for_category(category)
88
  opt = optional_fields_for_category(category)
89
 
90
- parts = []
91
- opt_txt = ""
92
- if opt:
93
- opt_txt = f" Optional: {', '.join(opt)}."
94
  if req:
95
  parts.append(f"I need {', '.join(req)} for {it['name']}.{opt_txt}")
96
  else:
97
  parts.append(f"Please specify quantity for {it['name']}.{opt_txt}")
98
 
99
- # Also list choices for required options
100
- # e.g., size choices
101
  opts = it.get("options") or {}
102
- choice_bits = []
103
  for k, spec in opts.items():
104
  if spec.get("required"):
105
  choices = spec.get("choices") or []
@@ -107,4 +156,5 @@ def friendly_requirements_prompt(order_item: Dict[str, Any]) -> str:
107
  choice_bits.append(f"{k}: {', '.join(choices)}")
108
  if choice_bits:
109
  parts.append("Choices → " + " | ".join(choice_bits))
 
110
  return " ".join(parts)
 
1
  # app/catalog.py
2
  from __future__ import annotations
3
+ import json
4
+ import os
5
+ import re
6
+ from typing import Dict, Any, List, Optional, Tuple
7
 
8
+ # In-memory singleton
9
+ _CATALOG: Optional[Dict[str, Any]] = None
10
+
11
+ def _norm(s: str) -> str:
12
+ """lightweight normalization for fuzzy-ish equality"""
13
+ return re.sub(r"\s+", " ", (s or "").strip().lower())
14
 
15
  def get_catalog_path() -> str:
16
+ """
17
+ Resolve catalog path in this order:
18
+ 1) ENV CAFE_CATALOG_PATH
19
+ 2) repo-relative data/menu_catalog.json (current default)
20
+ """
21
+ env_path = os.getenv("CAFE_CATALOG_PATH")
22
+ if env_path:
23
+ return env_path
24
  here = os.path.dirname(os.path.abspath(__file__))
25
  root = os.path.dirname(here)
26
  return os.path.join(root, "data", "menu_catalog.json")
27
 
28
+ def _load_from_disk(path: str) -> Dict[str, Any]:
29
+ if not os.path.exists(path):
30
+ raise FileNotFoundError(
31
+ f"Catalog not found at {path}. "
32
+ "Set CAFE_CATALOG_PATH in your .env or place data/menu_catalog.json."
33
+ )
34
+ with open(path, "r", encoding="utf-8") as f:
35
+ try:
36
+ data = json.load(f)
37
+ except json.JSONDecodeError as e:
38
+ raise ValueError(f"Catalog JSON invalid at {path}: {e}") from e
39
+ # quick shape checks (non-fatal, but helpful)
40
+ if not isinstance(data, dict) or "items" not in data or "schema" not in data:
41
+ raise ValueError("Catalog must contain top-level keys: 'items' and 'schema'.")
42
+ if not isinstance(data["items"], list):
43
+ raise ValueError("'items' must be a list.")
44
+ if not isinstance(data["schema"], dict):
45
+ raise ValueError("'schema' must be a dict.")
46
+ return data
47
+
48
+ def load_catalog(force_reload: bool = False) -> Dict[str, Any]:
49
  global _CATALOG
50
+ if _CATALOG is not None and not force_reload:
51
  return _CATALOG
52
  path = get_catalog_path()
53
+ _CATALOG = _load_from_disk(path)
 
54
  return _CATALOG
55
 
56
  def find_item_by_name(name: str) -> Optional[Dict[str, Any]]:
57
  c = load_catalog()
58
+ q = _norm(name)
59
+ if not q:
60
+ return None
61
  for it in c["items"]:
62
+ nm = _norm(it.get("name", ""))
63
+ if q == nm or q in nm:
 
 
64
  return it
65
+ # optional alias list support: ["alias1", "alias2"]
66
+ for alias in it.get("aliases", []) or []:
67
+ if q == _norm(alias) or q in _norm(alias):
68
+ return it
69
  return None
70
 
71
  def find_item_by_sku(sku: str) -> Optional[Dict[str, Any]]:
72
  c = load_catalog()
73
+ target = (sku or "").strip()
74
+ if not target:
75
+ return None
76
  for it in c["items"]:
77
+ if str(it.get("sku", "")).strip() == target:
78
  return it
79
  return None
80
 
81
  def required_fields_for_category(category: str) -> List[str]:
82
  c = load_catalog()
83
  schema = c["schema"].get(category) or {}
84
+ rf = schema.get("required_fields") or []
85
+ return list(rf)
86
 
87
  def optional_fields_for_category(category: str) -> List[str]:
88
  c = load_catalog()
89
  schema = c["schema"].get(category) or {}
90
+ of = schema.get("optional_fields") or []
91
+ return list(of)
92
+
93
+ def _resolve_item(order_item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
94
+ it: Optional[Dict[str, Any]] = None
95
+ if order_item.get("sku"):
96
+ it = find_item_by_sku(str(order_item["sku"]))
97
+ if not it and order_item.get("name"):
98
+ it = find_item_by_name(str(order_item["name"]))
99
+ return it
100
 
101
  def compute_missing_fields(order_item: Dict[str, Any]) -> List[str]:
102
  """
103
+ order_item example:
104
+ {"name": "Margherita Pizza", "qty": 2, "size": "large", ...}
105
  Uses catalog schema to see which fields are missing.
106
  """
107
+ it = _resolve_item(order_item)
 
 
 
 
108
  if not it:
109
  return ["name"] # we don’t even know the item yet
110
 
111
  category = it["category"]
112
  req = set(required_fields_for_category(category))
 
113
 
114
+ present = set(k for k in order_item.keys() if k in req or k in {"qty", "name", "sku"})
115
+
116
+ # qty normalization: consider qty present if >= 1
117
+ if "qty" in req:
118
+ try:
119
+ q = int(order_item.get("qty", 0))
120
+ except Exception:
121
+ q = 0
122
+ if q >= 1:
123
+ present.add("qty")
124
+ # else leave "qty" missing
125
  else:
126
+ # even if qty isn't required, count it as present if provided
127
+ if order_item.get("qty") is not None:
128
+ present.add("qty")
129
 
130
  missing = [f for f in req if f not in present]
131
  return missing
132
 
133
  def friendly_requirements_prompt(order_item: Dict[str, Any]) -> str:
134
+ it = _resolve_item(order_item)
 
 
 
 
135
  if not it:
136
  return "Which item would you like to order?"
137
 
 
139
  req = required_fields_for_category(category)
140
  opt = optional_fields_for_category(category)
141
 
142
+ parts: List[str] = []
143
+ opt_txt = f" Optional: {', '.join(opt)}." if opt else ""
 
 
144
  if req:
145
  parts.append(f"I need {', '.join(req)} for {it['name']}.{opt_txt}")
146
  else:
147
  parts.append(f"Please specify quantity for {it['name']}.{opt_txt}")
148
 
149
+ # Also list choices for required options (e.g., size choices)
 
150
  opts = it.get("options") or {}
151
+ choice_bits: List[str] = []
152
  for k, spec in opts.items():
153
  if spec.get("required"):
154
  choices = spec.get("choices") or []
 
156
  choice_bits.append(f"{k}: {', '.join(choices)}")
157
  if choice_bits:
158
  parts.append("Choices → " + " | ".join(choice_bits))
159
+
160
  return " ".join(parts)
app/gradio_app.py CHANGED
@@ -1,18 +1,10 @@
1
  # app/gradio_app.py
2
  from __future__ import annotations
3
-
4
- import os
5
- import time
6
- import shutil
7
- import uuid
8
  from typing import List, Dict, Any, Tuple
9
 
 
10
  import gradio as gr
11
 
12
- # ---- External modules we rely on (light, stable) ----
13
- # - ASR: faster-whisper wrapper you already have
14
- # - TTS: local piper/ say via models/tts_router.py
15
- # - LLM: optional local model; if missing, we fallback to a safe canned reply
16
  try:
17
  from models.asr_whisper import get_asr
18
  except Exception:
@@ -23,9 +15,12 @@ try:
23
  except Exception:
24
  llm_respond_chat = None
25
 
26
- from models.tts_router import tts_synthesize, ensure_runtime_audio_dir
27
- from models.tts_router import tts_synthesize, cleanup_old_audio, AUDIO_DIR
28
- import shutil, uuid, os
 
 
 
29
 
30
  # =============================================================================
31
  # Helpers (pure, modular)
@@ -33,17 +28,24 @@ import shutil, uuid, os
33
 
34
  def _safe_llm_reply(history: List[Dict[str, str]], user_text: str) -> str:
35
  """
36
- Ask the chat LLM for a response. If it's not available, use a reasonable fallback.
37
  """
38
- if llm_respond_chat is not None:
39
- try:
40
- # policy guard is optional; pass an empty dict
41
- bot_text, _guard, _diag = llm_respond_chat(history or [], user_text, {})
42
- if isinstance(bot_text, str) and bot_text.strip():
43
- return bot_text.strip()
44
- except Exception as e:
45
- print("[LLM] fallback due to error:", e)
46
- # Fallback (LLM unavailable or failed)
 
 
 
 
 
 
 
47
  return "Hello! How can I assist you today? Would you like to place an order or inquire about the menu?"
48
 
49
 
@@ -267,8 +269,8 @@ def build_demo():
267
  * delete the user clip immediately after ASR
268
  * delete all older TTS, keep only latest one
269
  - Append transcript pairs to voice chat state
 
270
  """
271
- import time
272
  empty_diag = {
273
  "intent": None,
274
  "slots": {},
@@ -277,23 +279,24 @@ def build_demo():
277
  "latency_ms": 0,
278
  }
279
  if not aud_path:
280
- return (
281
- voice_hist or [],
282
- None, # assistant_audio
283
- empty_diag,
284
- None, # clear recorder (handled elsewhere if you chain a clear)
285
- voice_hist or []
286
- )
287
 
288
  t0 = time.time()
289
- # 1) Stabilize mic path into runtime/audio
 
290
  stable_user = _persist_copy(aud_path)
291
 
292
  # 2) Transcribe
 
293
  try:
294
- asr = get_asr()
295
- asr_out = asr.transcribe(stable_user)
296
- transcript = (asr_out.get("text") or "").strip() or "(no speech detected)"
 
 
 
 
 
297
  finally:
298
  # Remove the user clip ASAP to keep the folder small
299
  if stable_user and os.path.exists(stable_user):
@@ -302,18 +305,24 @@ def build_demo():
302
  except Exception as e:
303
  print("[CLEANUP] Could not delete user clip:", e)
304
 
305
- # 3) Get bot reply (LLM response)
306
  try:
307
  from models.llm_chat import respond_chat_voice
308
  except Exception:
309
- # Fallback: reuse text chat function if you don’t have a voice-specific one
310
  from models.llm_chat import respond_chat as respond_chat_voice
311
 
312
- bot_text, new_policy, policy_diag = respond_chat_voice(voice_hist or [], transcript, {})
 
 
 
 
 
 
 
 
313
 
314
- # 4) TTS the bot reply into runtime/audio
315
- new_tts = tts_synthesize(bot_text) # this writes into runtime/audio
316
- # Keep only the latest TTS (delete older tts_*.wav)
317
  cleanup_old_audio(keep_latest=new_tts)
318
 
319
  # 5) Append to voice chat state (text transcripts)
@@ -334,8 +343,8 @@ def build_demo():
334
  "latency_ms": int((time.time() - t0) * 1000),
335
  }
336
 
337
- # Return: (voice_chat, assistant_audio_path, diag, recorder_clear, voice_state)
338
- return new_hist, new_tts, diag, gr.update(value=None), new_hist
339
 
340
  def on_text_send(txt: str, hist: List[Dict[str, str]]):
341
  new_hist, diag, clear_text = handle_text_turn(txt, hist or [])
 
1
  # app/gradio_app.py
2
  from __future__ import annotations
 
 
 
 
 
3
  from typing import List, Dict, Any, Tuple
4
 
5
+ import os, time, shutil, uuid
6
  import gradio as gr
7
 
 
 
 
 
8
  try:
9
  from models.asr_whisper import get_asr
10
  except Exception:
 
15
  except Exception:
16
  llm_respond_chat = None
17
 
18
+ from models.tts_router import (
19
+ tts_synthesize,
20
+ ensure_runtime_audio_dir,
21
+ cleanup_old_audio,
22
+ AUDIO_DIR,
23
+ )
24
 
25
  # =============================================================================
26
  # Helpers (pure, modular)
 
28
 
29
  def _safe_llm_reply(history: List[Dict[str, str]], user_text: str) -> str:
30
  """
31
+ Try local LLM. If it's missing or errors, log loudly and return a safe fallback.
32
  """
33
+ if llm_respond_chat is None:
34
+ print("[LLM] respond_chat not imported; using fallback.")
35
+ return "Hello! How can I assist you today? Would you like to place an order or inquire about the menu?"
36
+
37
+ try:
38
+ bot_text, _guard, _diag = llm_respond_chat(history or [], user_text, {})
39
+ if isinstance(bot_text, str) and bot_text.strip():
40
+ print("[LLM] returned:", bot_text[:120].replace("\n"," "))
41
+ return bot_text.strip()
42
+ else:
43
+ print("[LLM] empty/invalid response; using fallback.")
44
+ except Exception as e:
45
+ import traceback
46
+ print("[LLM] error -> fallback:", repr(e))
47
+ traceback.print_exc()
48
+
49
  return "Hello! How can I assist you today? Would you like to place an order or inquire about the menu?"
50
 
51
 
 
269
  * delete the user clip immediately after ASR
270
  * delete all older TTS, keep only latest one
271
  - Append transcript pairs to voice chat state
272
+ Returns exactly: (voice_chat_messages, assistant_audio_path, diag_json)
273
  """
 
274
  empty_diag = {
275
  "intent": None,
276
  "slots": {},
 
279
  "latency_ms": 0,
280
  }
281
  if not aud_path:
282
+ return (voice_hist or []), None, empty_diag
 
 
 
 
 
 
283
 
284
  t0 = time.time()
285
+
286
+ # 1) Stabilize mic path into runtime/audio (ENV-driven via AUDIO_DIR)
287
  stable_user = _persist_copy(aud_path)
288
 
289
  # 2) Transcribe
290
+ transcript = "(transcription failed)"
291
  try:
292
+ if get_asr is None:
293
+ transcript = "(ASR unavailable)"
294
+ else:
295
+ asr = get_asr()
296
+ asr_out = asr.transcribe(stable_user)
297
+ transcript = (asr_out.get("text") or "").strip() or "(no speech detected)"
298
+ except Exception as e:
299
+ print("[ASR] error:", e)
300
  finally:
301
  # Remove the user clip ASAP to keep the folder small
302
  if stable_user and os.path.exists(stable_user):
 
305
  except Exception as e:
306
  print("[CLEANUP] Could not delete user clip:", e)
307
 
308
+ # 3) Get bot reply (LLM response) – env/model-path handled inside models/llm_chat
309
  try:
310
  from models.llm_chat import respond_chat_voice
311
  except Exception:
 
312
  from models.llm_chat import respond_chat as respond_chat_voice
313
 
314
+ try:
315
+ bot_text, new_policy, policy_diag = respond_chat_voice(voice_hist or [], transcript, {})
316
+ except Exception as e:
317
+ print("[LLM] voice fallback due to error:", e)
318
+ bot_text, new_policy, policy_diag = (
319
+ "Hello! How can I help with FutureCafe today?",
320
+ {},
321
+ {},
322
+ )
323
 
324
+ # 4) TTS the bot reply into runtime/audio (ENV-driven path via tts_router)
325
+ new_tts = tts_synthesize(bot_text) # path in VOICE_AUDIO_DIR
 
326
  cleanup_old_audio(keep_latest=new_tts)
327
 
328
  # 5) Append to voice chat state (text transcripts)
 
343
  "latency_ms": int((time.time() - t0) * 1000),
344
  }
345
 
346
+ # Return EXACTLY the 3 outputs you wired: (voice_chat, assistant_audio, call_diag)
347
+ return new_hist, new_tts, diag
348
 
349
  def on_text_send(txt: str, hist: List[Dict[str, str]]):
350
  new_hist, diag, clear_text = handle_text_turn(txt, hist or [])
app/intent_schema.py CHANGED
@@ -1,10 +1,19 @@
1
  # app/intent_schema.py
2
  from __future__ import annotations
3
- from typing import List, Optional, Literal
4
- from pydantic import BaseModel, Field
5
 
6
- IntentName = Literal["reservation.create", "order.create", "hours.get", "menu.search", "smalltalk", "other"]
 
 
 
 
 
 
 
 
7
 
 
8
  class ReservationSlots(BaseModel):
9
  name: Optional[str] = None
10
  party_size: Optional[int] = Field(default=None, ge=1, le=20)
@@ -12,20 +21,55 @@ class ReservationSlots(BaseModel):
12
  time: Optional[str] = None # “19:00” or “7 pm”
13
  phone: Optional[str] = None
14
 
 
 
15
  class OrderItem(BaseModel):
16
  name: str
17
  qty: int = Field(default=1, ge=1)
18
 
 
 
19
  class OrderSlots(BaseModel):
20
  items: List[OrderItem] = Field(default_factory=list)
21
  notes: Optional[str] = None
22
 
 
 
23
  class MenuSlots(BaseModel):
24
  query: Optional[str] = None
25
  dietary: List[str] = Field(default_factory=list) # e.g., ["vegan","gluten-free"]
26
 
 
 
 
27
  class IntentEnvelope(BaseModel):
28
  intent: IntentName
29
  need_more_info: bool = False
30
  ask_user: Optional[str] = None # a single, polite follow-up question if info missing
31
- slots: dict = Field(default_factory=dict) # raw dict; we’ll validate by intent
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # app/intent_schema.py
2
  from __future__ import annotations
3
+ from typing import List, Optional, Literal, Union, Tuple
4
+ from pydantic import BaseModel, Field, ValidationError
5
 
6
+ # ---- Canonical intent names ----
7
+ IntentName = Literal[
8
+ "reservation.create",
9
+ "order.create",
10
+ "hours.get",
11
+ "menu.search",
12
+ "smalltalk",
13
+ "other",
14
+ ]
15
 
16
+ # ---- Slot models ----
17
  class ReservationSlots(BaseModel):
18
  name: Optional[str] = None
19
  party_size: Optional[int] = Field(default=None, ge=1, le=20)
 
21
  time: Optional[str] = None # “19:00” or “7 pm”
22
  phone: Optional[str] = None
23
 
24
+ model_config = {"extra": "ignore"} # tolerate extra keys from LLM
25
+
26
  class OrderItem(BaseModel):
27
  name: str
28
  qty: int = Field(default=1, ge=1)
29
 
30
+ model_config = {"extra": "ignore"}
31
+
32
  class OrderSlots(BaseModel):
33
  items: List[OrderItem] = Field(default_factory=list)
34
  notes: Optional[str] = None
35
 
36
+ model_config = {"extra": "ignore"}
37
+
38
  class MenuSlots(BaseModel):
39
  query: Optional[str] = None
40
  dietary: List[str] = Field(default_factory=list) # e.g., ["vegan","gluten-free"]
41
 
42
+ model_config = {"extra": "ignore"}
43
+
44
+ # ---- Envelope returned by the router/LLM ----
45
  class IntentEnvelope(BaseModel):
46
  intent: IntentName
47
  need_more_info: bool = False
48
  ask_user: Optional[str] = None # a single, polite follow-up question if info missing
49
+
50
+ # Keep it loose at the API boundary; we’ll coerce it with helper below.
51
+ slots: dict = Field(default_factory=dict)
52
+
53
+ model_config = {"extra": "ignore"}
54
+
55
+ # ---- Helpers to validate slots into the right model ----
56
+ SlotsUnion = Union[ReservationSlots, OrderSlots, MenuSlots, dict]
57
+
58
+ def coerce_slots(intent: IntentName, slots: dict | None) -> Tuple[SlotsUnion, Optional[str]]:
59
+ """
60
+ Try to convert a loose slots dict into the correct typed model based on 'intent'.
61
+ Returns (slots_obj, error_message). If cannot validate, returns (original_dict, message).
62
+ This keeps the pipeline resilient while giving you typed access when possible.
63
+ """
64
+ raw = slots or {}
65
+ try:
66
+ if intent == "reservation.create":
67
+ return ReservationSlots(**raw), None
68
+ if intent == "order.create":
69
+ return OrderSlots(**raw), None
70
+ if intent == "menu.search":
71
+ return MenuSlots(**raw), None
72
+ # 'hours.get', 'smalltalk', 'other' often don’t need slots
73
+ return raw, None
74
+ except ValidationError as ve:
75
+ return raw, f"slot_validation_failed: {ve.errors()}"
app/orchestrator.py CHANGED
@@ -1,32 +1,134 @@
1
- from typing import Dict, Any
2
- from models.llm_router import respond as route_fn, nlg
3
- from app.tools import dispatch_tool
 
 
 
4
  from utils.phone import extract_phone, looks_valid
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  def llm_route_and_execute(user_text: str) -> Dict[str, Any]:
7
- route = route_fn(user_text) # {"tool": "get_hours"|..., "args": {...}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  tool = route.get("tool")
9
- args = route.get("args") or {}
10
 
11
- # enrich reservation with phone if present in the text
12
  if tool == "create_reservation":
13
- phone = extract_phone(user_text)
14
- if looks_valid(phone):
15
  args["phone"] = phone
 
16
  if not args.get("name"):
17
- # naive default name if user included "my name is ..."
18
- import re
19
- m = re.search(r"(?:my name is|i am|i'm)\s+([A-Z][a-z]+)", user_text, re.I)
20
- if m: args["name"] = m.group(1)
21
 
 
22
  tool_result = None
23
  if tool:
24
- tool_result = dispatch_tool(tool, args)
 
 
 
25
 
26
- reply = nlg(tool or "", tool_result or {}, user_text)
 
 
 
 
27
 
28
  return {
29
- "intent": tool or ("smalltalk" if not tool else tool),
30
  "slots": args,
31
  "tool_selected": tool,
32
  "tool_result": tool_result,
 
1
+ # app/orchestrator.py
2
+ from __future__ import annotations
3
+ import re
4
+ from typing import Dict, Any, Callable, Optional
5
+
6
+ from utils.config import get_settings
7
  from utils.phone import extract_phone, looks_valid
8
+ from app.tools import dispatch_tool
9
+
10
+ # -----------------------------
11
+ # Resolve router/NLG per BACKEND
12
+ # -----------------------------
13
+ _s = get_settings()
14
+
15
+ _route_fn: Optional[Callable[[str], Dict[str, Any]]] = None
16
+ _nlg_fn: Optional[Callable[[str, Dict[str, Any], str], str]] = None
17
+
18
+ def _load_router():
19
+ global _route_fn, _nlg_fn
20
+ backend = (_s.BACKEND_LLM or "").lower()
21
 
22
+ try:
23
+ if backend == "llamacpp":
24
+ from models.llm_router import respond as route, nlg as nlg_impl
25
+ _route_fn, _nlg_fn = route, nlg_impl
26
+ elif backend == "openai":
27
+ from models.openai_router import respond as route, nlg as nlg_impl
28
+ _route_fn, _nlg_fn = route, nlg_impl
29
+ elif backend == "groq":
30
+ from models.groq_router import respond as route, nlg as nlg_impl
31
+ _route_fn, _nlg_fn = route, nlg_impl
32
+ else:
33
+ # Unknown backend → safe fallbacks
34
+ _route_fn = lambda _: {"tool": None, "args": {}}
35
+ _nlg_fn = _fallback_nlg
36
+ except Exception:
37
+ # If import fails, still keep app running with safe fallbacks
38
+ _route_fn = lambda _: {"tool": None, "args": {}}
39
+ _nlg_fn = _fallback_nlg
40
+
41
+ def _fallback_nlg(tool: str, tool_result: Dict[str, Any] | None, user_text: str) -> str:
42
+ """Minimal reply if no NLG provided by the chosen backend."""
43
+ tr = tool_result or {}
44
+ if tool in (None, "", "smalltalk"):
45
+ return "Hello! How can I help with FutureCafe—menu, hours, reservations, or orders?"
46
+ if tool == "get_hours":
47
+ hours = tr.get("hours") or "11:00–22:00 daily"
48
+ address = tr.get("address") or "123 Main St"
49
+ return f"We’re open {hours} at {address}. What else can I do for you?"
50
+ if tool == "menu_lookup":
51
+ items = tr.get("items") or []
52
+ if items:
53
+ names = ", ".join(i.get("name", "item") for i in items[:6])
54
+ return f"Here are some popular items: {names}. Would you like to order any of these?"
55
+ return "I can look up menu items—any dietary needs or a specific dish?"
56
+ if tool == "create_reservation":
57
+ when = tr.get("when") or tr.get("datetime") or "your requested time"
58
+ code = tr.get("reservation_id") or tr.get("code") or "a confirmation code"
59
+ return f"Reservation confirmed for {when}. Code {code}. Anything else I can help with?"
60
+ if tool == "create_order":
61
+ items = tr.get("items") or []
62
+ if items:
63
+ summary = ", ".join(f"{i.get('qty','1')}× {i.get('name','item')}" for i in items)
64
+ total = tr.get("total")
65
+ return f"Order placed: {summary}" + (f". Total ${total:.2f}" if isinstance(total, (int,float)) else "") + "."
66
+ return "Your order is noted. Anything to add?"
67
+ # Generic fallback
68
+ return "Done. Anything else I can help you with?"
69
+
70
+ # Load router once at import
71
+ _load_router()
72
+
73
+ # -----------------------------
74
+ # Public API
75
+ # -----------------------------
76
  def llm_route_and_execute(user_text: str) -> Dict[str, Any]:
77
+ """
78
+ 1) Route the user_text to a tool (model-dependent)
79
+ 2) Enrich args (e.g., reservation phone/name)
80
+ 3) Execute tool (dispatch_tool)
81
+ 4) Generate reply (NLG if available, else fallback)
82
+ Returns a single dict suitable for the UI diagnostics panel.
83
+ """
84
+ text = (user_text or "").strip()
85
+ if not text:
86
+ return {
87
+ "intent": "smalltalk",
88
+ "slots": {},
89
+ "tool_selected": None,
90
+ "tool_result": None,
91
+ "response": "Hello! How can I help with FutureCafe today?",
92
+ }
93
+
94
+ # --- 1) Route ---
95
+ try:
96
+ route = _route_fn(text) if _route_fn else {"tool": None, "args": {}}
97
+ if not isinstance(route, dict):
98
+ route = {"tool": None, "args": {}}
99
+ except Exception:
100
+ route = {"tool": None, "args": {}}
101
+
102
  tool = route.get("tool")
103
+ args = dict(route.get("args") or {})
104
 
105
+ # --- 2) Enrich args for reservation ---
106
  if tool == "create_reservation":
107
+ phone = extract_phone(text)
108
+ if looks_valid(phone) and not args.get("phone"):
109
  args["phone"] = phone
110
+ # lightweight name inference: “my name is X”, “I am X”, “I’m X”
111
  if not args.get("name"):
112
+ m = re.search(r"(?:my name is|i am|i'm)\s+([A-Z][a-z]+)", text, re.I)
113
+ if m:
114
+ args["name"] = m.group(1)
 
115
 
116
+ # --- 3) Execute tool (optional) ---
117
  tool_result = None
118
  if tool:
119
+ try:
120
+ tool_result = dispatch_tool(tool, args)
121
+ except Exception as e:
122
+ tool_result = {"ok": False, "error": f"tool_error: {e!s}"}
123
 
124
+ # --- 4) NLG (or fallback) ---
125
+ try:
126
+ reply = _nlg_fn(tool or "", tool_result or {}, text) if _nlg_fn else _fallback_nlg(tool or "", tool_result or {}, text)
127
+ except Exception:
128
+ reply = _fallback_nlg(tool or "", tool_result or {}, text)
129
 
130
  return {
131
+ "intent": tool or "smalltalk",
132
  "slots": args,
133
  "tool_selected": tool,
134
  "tool_result": tool_result,
app/policy.py CHANGED
@@ -1,36 +1,65 @@
1
  # app/policy.py
2
  from __future__ import annotations
3
- import os, re
 
4
 
5
- # --- Topic detection (very lightweight, fast) ---
6
- CAFE_KEYWORDS = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  "menu","order","item","dish","pizza","burger","salad","pasta","vegan","gluten",
8
  "price","special","deal","offer","hours","open","close","time","location","address",
9
  "book","reserve","reservation","table","party","pickup","delivery","takeout","payment",
10
  "futurecafe","future cafe","future-cafe","café","coffee","drinks","beverage","side"
11
  ]
12
- _kw_re = re.compile(r"|".join([re.escape(k) for k in CAFE_KEYWORDS]), re.I)
13
 
14
- SMALLTALK = r"\b(hi|hello|hey|good\s+(morning|afternoon|evening)|thanks|thank you|bye|goodbye)\b"
15
- _smalltalk_re = re.compile(SMALLTALK, re.I)
 
 
16
 
17
- def is_cafe_topic(text: str) -> bool:
18
- return bool(text and _kw_re.search(text))
19
 
20
- def is_smalltalk(text: str) -> bool:
21
- return bool(text and _smalltalk_re.search(text))
22
 
23
  def unrelated_limit() -> int:
24
- """How many off-topic turns allowed before ending."""
25
  try:
26
  n = int(os.getenv("CAFE_UNRELATED_LIMIT", "3"))
27
  return max(1, min(5, n))
28
  except Exception:
29
  return 3
30
 
31
- # --- Messages ---
32
- POLITE_REFUSAL = (
33
- "I’m here to help with FutureCafe—menu, hours, reservations, and orders. "
34
  "Could you ask something about the restaurant?"
35
  )
36
 
@@ -39,7 +68,65 @@ POLITE_REFUSAL_2 = (
39
  "Ask me about our menu, hours, or booking a table."
40
  )
41
 
42
- def end_message() -> str:
43
- return ("Im only able to help with FutureCafe topics. "
44
- "Let’s end this chat for now. If you need menu, hours, or reservations, "
45
- "message me again anytime.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # app/policy.py
2
  from __future__ import annotations
3
+ import json, os, re
4
+ from typing import Dict, Any, List, Tuple
5
 
6
+ # ---------- Loading & configuration ----------
7
+
8
+ def _root_dir() -> str:
9
+ here = os.path.dirname(os.path.abspath(__file__))
10
+ return os.path.dirname(here)
11
+
12
+ def _load_keywords_from_file() -> Dict[str, Any]:
13
+ """Optional: data/policy_keywords.json -> {"cafe_keywords":[...], "smalltalk_regex": "..."}"""
14
+ path = os.path.join(_root_dir(), "data", "policy_keywords.json")
15
+ if not os.path.exists(path):
16
+ return {}
17
+ try:
18
+ with open(path, "r", encoding="utf-8") as f:
19
+ return json.load(f) or {}
20
+ except Exception:
21
+ return {}
22
+
23
+ def _env_list(var: str) -> List[str]:
24
+ raw = (os.getenv(var) or "").strip()
25
+ if not raw:
26
+ return []
27
+ return [x.strip() for x in raw.split(",") if x.strip()]
28
+
29
+ def _compile_regex(patterns: List[str]) -> re.Pattern:
30
+ if not patterns:
31
+ return re.compile(r"$^") # match nothing
32
+ return re.compile(r"|".join([re.escape(k) for k in patterns]), re.I)
33
+
34
+ # Defaults (used only if not overridden by env/file)
35
+ _DEFAULT_CAFE_KEYWORDS = [
36
  "menu","order","item","dish","pizza","burger","salad","pasta","vegan","gluten",
37
  "price","special","deal","offer","hours","open","close","time","location","address",
38
  "book","reserve","reservation","table","party","pickup","delivery","takeout","payment",
39
  "futurecafe","future cafe","future-cafe","café","coffee","drinks","beverage","side"
40
  ]
41
+ _DEFAULT_SMALLTALK_RE = r"\b(hi|hello|hey|good\s+(morning|afternoon|evening)|thanks|thank you|bye|goodbye)\b"
42
 
43
+ # Merge precedence: ENV > file > defaults
44
+ _file_conf = _load_keywords_from_file()
45
+ _CAFE_KEYWORDS = _env_list("CAFE_KEYWORDS") or _file_conf.get("cafe_keywords") or _DEFAULT_CAFE_KEYWORDS
46
+ _SMALLTALK_RE_STR = os.getenv("SMALLTALK_REGEX") or _file_conf.get("smalltalk_regex") or _DEFAULT_SMALLTALK_RE
47
 
48
+ _kw_re = _compile_regex(_CAFE_KEYWORDS)
49
+ _smalltalk_re = re.compile(_SMALLTALK_RE_STR, re.I)
50
 
51
+ # ---------- Limits & messages ----------
 
52
 
53
  def unrelated_limit() -> int:
54
+ """How many off-topic turns allowed before ending (clamped 1..5)."""
55
  try:
56
  n = int(os.getenv("CAFE_UNRELATED_LIMIT", "3"))
57
  return max(1, min(5, n))
58
  except Exception:
59
  return 3
60
 
61
+ POLITE_REFUSAL_1 = (
62
+ "I'm here to help with FutureCafe—menu, hours, reservations, and orders. "
 
63
  "Could you ask something about the restaurant?"
64
  )
65
 
 
68
  "Ask me about our menu, hours, or booking a table."
69
  )
70
 
71
+ END_MESSAGE = (
72
+ "I'm only able to help with FutureCafe topics. Let's end this chat for now. "
73
+ "If you need menu, hours, or reservations, message me again anytime."
74
+ )
75
+
76
+ def refusal_message(count: int) -> str:
77
+ return POLITE_REFUSAL_1 if count <= 1 else POLITE_REFUSAL_2
78
+
79
+ # ---------- Public utilities ----------
80
+
81
+ def is_cafe_topic(text: str) -> bool:
82
+ return bool(text and _kw_re.search(text))
83
+
84
+ def is_smalltalk(text: str) -> bool:
85
+ return bool(text and _smalltalk_re.search(text))
86
+
87
+ def enforce_policy(
88
+ user_text: str,
89
+ guard_state: Dict[str, Any] | None
90
+ ) -> Tuple[bool, str | None, Dict[str, Any], Dict[str, Any]]:
91
+ """
92
+ Lightweight topic gate. Returns:
93
+ allowed: bool -> if True, send to LLM; if False, use reply_if_block
94
+ reply_if_block: Optional[str]
95
+ new_guard: dict -> persist across turns (store in State)
96
+ diag: dict -> tiny diagnostics blob for the UI
97
+ guard_state schema: {"unrelated": int, "ended": bool}
98
+ """
99
+ text = (user_text or "").strip()
100
+ guard = dict(guard_state or {"unrelated": 0, "ended": False})
101
+ diag: Dict[str, Any] = {"limit": unrelated_limit()}
102
+
103
+ if guard.get("ended"):
104
+ diag["policy"] = "ended"
105
+ return False, END_MESSAGE, guard, diag
106
+
107
+ # Allow smalltalk to go through (LLM can handle niceties)
108
+ if is_smalltalk(text) or is_cafe_topic(text):
109
+ diag["policy"] = "ok"
110
+ return True, None, guard, diag
111
+
112
+ # Off-topic
113
+ guard["unrelated"] = int(guard.get("unrelated", 0)) + 1
114
+ diag["unrelated"] = guard["unrelated"]
115
+
116
+ if guard["unrelated"] >= unrelated_limit():
117
+ guard["ended"] = True
118
+ diag["policy"] = "ended"
119
+ return False, END_MESSAGE, guard, diag
120
+
121
+ diag["policy"] = "nudge"
122
+ return False, refusal_message(guard["unrelated"]), guard, diag
123
+
124
+ # ---------- Introspection helpers (nice for your Insights pane) ----------
125
+
126
+ def policy_snapshot() -> Dict[str, Any]:
127
+ """Expose the active config so you can show it in the Insights JSON."""
128
+ return {
129
+ "cafe_keywords": _CAFE_KEYWORDS,
130
+ "smalltalk_regex": _SMALLTALK_RE_STR,
131
+ "unrelated_limit": unrelated_limit(),
132
+ }
app/sim_api.py CHANGED
@@ -1,81 +1,120 @@
1
  # app/sim_api.py
2
  from __future__ import annotations
3
- from typing import Dict, Any, List, Tuple
4
  from app.catalog import load_catalog, find_item_by_name, find_item_by_sku
5
 
6
- def _pick_item(order_it: Dict[str, Any]) -> Dict[str, Any] | None:
 
 
 
 
7
  it = None
8
- if "sku" in order_it:
9
- it = find_item_by_sku(order_it["sku"])
10
- if not it and "name" in order_it:
11
- it = find_item_by_name(order_it["name"])
12
  return it
13
 
14
- def check_item_availability(order_it: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
 
 
 
 
 
 
 
15
  """
16
  Returns (is_available, info)
17
- info contains { "reason": "...", "alternatives": [...] } when not available
18
- For size-based items, verify stock for requested size.
19
  """
20
  it = _pick_item(order_it)
21
  if not it:
22
- return False, {"reason": "unknown_item", "alternatives": []}
23
 
24
- qty = int(order_it.get("qty", 0) or 0)
25
- if qty < 1:
26
- return False, {"reason": "qty_missing", "alternatives": []}
27
 
28
- # size key heuristics
29
  size = order_it.get("size")
30
- stock_map = it.get("stock") or {}
 
 
 
31
 
 
32
  if "one_size" in stock_map:
33
- avail = stock_map["one_size"]
34
- if avail >= qty:
35
- return True, {"price_each": (it.get("price") or {}).get("one_size", 0.0)}
36
- else:
37
- return False, {"reason": "insufficient_stock", "have": avail, "alternatives": []}
38
-
39
- if size:
40
- have = int(stock_map.get(size, 0))
41
  if have >= qty:
42
- return True, {"price_each": (it.get("price") or {}).get(size, 0.0)}
43
- else:
44
- # propose other sizes with stock
45
- alts = []
46
- for s, have_s in stock_map.items():
47
- if have_s >= qty:
48
- alts.append({"size": s, "have": have_s, "price_each": (it.get("price") or {}).get(s, 0.0)})
49
- return False, {"reason": "size_out_of_stock", "have": have, "alternatives": alts}
50
- else:
51
- # missing required option — let schema enforcement ask; but if user skipped, treat as not available
52
- return False, {"reason": "size_missing", "alternatives": [{"hint": "provide size"}]}
53
-
54
- def place_order(order_items: List[Dict[str, Any]]) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  """
56
- Verifies each item and (if all available) returns summary.
57
- We do not mutate stock here (sim).
 
 
58
  """
59
- ok = True
60
- lines = []
61
  total = 0.0
62
- for it in order_items:
63
- item_def = _pick_item(it)
64
- if not item_def:
65
- return {"ok": False, "reason": "unknown_item", "item": it}
66
- avail, info = check_item_availability(it)
67
- if not avail:
68
- return {"ok": False, "reason": info.get("reason"), "item": it, "alternatives": info.get("alternatives", [])}
69
- qty = int(it["qty"])
70
- unit = info.get("price_each", 0.0)
 
 
 
 
 
 
 
 
 
71
  line_total = unit * qty
72
  total += line_total
 
 
 
73
  lines.append({
74
- "sku": item_def["sku"],
75
- "name": item_def["name"],
76
  "qty": qty,
77
- "options": {k: v for k, v in it.items() if k not in ("name","sku","qty")},
78
  "unit": unit,
79
- "line_total": line_total
80
  })
 
81
  return {"ok": True, "total": round(total, 2), "lines": lines}
 
1
  # app/sim_api.py
2
  from __future__ import annotations
3
+ from typing import Dict, Any, List, Tuple, Optional
4
  from app.catalog import load_catalog, find_item_by_name, find_item_by_sku
5
 
6
+ def _catalog() -> Dict[str, Any]:
7
+ # Local indirection so we can override in tests if needed
8
+ return load_catalog()
9
+
10
+ def _pick_item(order_it: Dict[str, Any]) -> Optional[Dict[str, Any]]:
11
  it = None
12
+ if order_it.get("sku"):
13
+ it = find_item_by_sku(str(order_it["sku"]))
14
+ if not it and order_it.get("name"):
15
+ it = find_item_by_name(str(order_it["name"]))
16
  return it
17
 
18
+ def _norm_qty(q: Any) -> Optional[int]:
19
+ try:
20
+ qi = int(q)
21
+ return qi if qi >= 1 else None
22
+ except Exception:
23
+ return None
24
+
25
+ def check_item_availability(order_it: Dict[str, Any], catalog: Optional[Dict[str, Any]] = None) -> Tuple[bool, Dict[str, Any]]:
26
  """
27
  Returns (is_available, info)
28
+ - info on success: {"price_each": float}
29
+ - info on failure: {"reason": str, "item": dict, **context}
30
  """
31
  it = _pick_item(order_it)
32
  if not it:
33
+ return False, {"reason": "unknown_item", "item": order_it}
34
 
35
+ qty = _norm_qty(order_it.get("qty"))
36
+ if qty is None:
37
+ return False, {"reason": "qty_missing_or_invalid", "item": order_it}
38
 
39
+ # Normalize size if provided
40
  size = order_it.get("size")
41
+ size_norm = str(size).lower() if isinstance(size, str) else None
42
+
43
+ price_map = (it.get("price") or {})
44
+ stock_map = (it.get("stock") or {})
45
 
46
+ # One-size items
47
  if "one_size" in stock_map:
48
+ have = int(stock_map.get("one_size", 0))
 
 
 
 
 
 
 
49
  if have >= qty:
50
+ unit = float(price_map.get("one_size", 0.0))
51
+ return True, {"price_each": unit}
52
+ return False, {"reason": "insufficient_stock", "have": have, "item": order_it}
53
+
54
+ # Size-required items
55
+ if not size_norm:
56
+ # schema enforcement will normally ask for size; we surface a nudge + available choices
57
+ choices = [k for k in stock_map.keys()]
58
+ return False, {"reason": "size_missing", "choices": choices, "item": order_it}
59
+
60
+ have = int(stock_map.get(size_norm, 0))
61
+ if have >= qty:
62
+ unit = float(price_map.get(size_norm, 0.0))
63
+ return True, {"price_each": unit}
64
+
65
+ # Try alternatives that can satisfy qty
66
+ alts = []
67
+ for s, have_s in stock_map.items():
68
+ try:
69
+ hs = int(have_s)
70
+ except Exception:
71
+ continue
72
+ if hs >= qty:
73
+ alts.append({
74
+ "size": s,
75
+ "have": hs,
76
+ "price_each": float(price_map.get(s, 0.0))
77
+ })
78
+ return False, {"reason": "size_out_of_stock", "requested_size": size_norm, "alternatives": alts, "item": order_it}
79
+
80
+ def place_order(order_items: List[Dict[str, Any]], catalog: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
81
  """
82
+ Validates all items via check_item_availability.
83
+ Returns:
84
+ - {"ok": True, "total": float, "lines": [ ... ] }
85
+ - {"ok": False, "reason": str, "item": dict, "alternatives": [...]?}
86
  """
 
 
87
  total = 0.0
88
+ lines: List[Dict[str, Any]] = []
89
+ for raw in order_items:
90
+ it = _pick_item(raw)
91
+ if not it:
92
+ return {"ok": False, "reason": "unknown_item", "item": raw}
93
+
94
+ ok, info = check_item_availability(raw, catalog=catalog)
95
+ if not ok:
96
+ # Bubble up first blocking failure
97
+ fail = {"ok": False, "reason": info.get("reason", "unavailable"), "item": info.get("item", raw)}
98
+ if "alternatives" in info:
99
+ fail["alternatives"] = info["alternatives"]
100
+ if "choices" in info:
101
+ fail["choices"] = info["choices"]
102
+ return fail
103
+
104
+ qty = _norm_qty(raw.get("qty")) or 0
105
+ unit = float(info.get("price_each", 0.0))
106
  line_total = unit * qty
107
  total += line_total
108
+
109
+ # Echo back normalized line
110
+ opts = {k: v for k, v in raw.items() if k not in ("name", "sku", "qty")}
111
  lines.append({
112
+ "sku": it["sku"],
113
+ "name": it["name"],
114
  "qty": qty,
115
+ "options": opts,
116
  "unit": unit,
117
+ "line_total": round(line_total, 2),
118
  })
119
+
120
  return {"ok": True, "total": round(total, 2), "lines": lines}
app/sim_api_bridge.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/sim_api_bridge.py
2
+ from __future__ import annotations
3
+ from typing import List, Dict, Any
4
+ from app.sim_api import place_order
5
+ from app.catalog import load_catalog
6
+
7
+ def get_hours() -> Dict[str, Any]:
8
+ c = load_catalog()
9
+ return {"hours": c.get("hours"), "address": c.get("address"), "phone": c.get("phone")}
10
+
11
+ def menu_lookup(filters: List[str]) -> List[Dict[str, Any]]:
12
+ # naive filter: return all items and let the LLM filter in text for now
13
+ c = load_catalog()
14
+ return c.get("items", [])
15
+
16
+ def create_reservation(name: str, phone: str | None, party_size: int, datetime_str: str) -> Dict[str, Any]:
17
+ # Simulate success
18
+ return {
19
+ "ok": True,
20
+ "reservation_id": "sim-" + str(abs(hash((name, phone, party_size, datetime_str))))[:8],
21
+ "name": name,
22
+ "party_size": party_size,
23
+ "when": datetime_str,
24
+ "phone": phone,
25
+ }
26
+
27
+ def create_order(items: List[Dict[str, Any]]) -> Dict[str, Any]:
28
+ return place_order(items)
app/tools.py CHANGED
@@ -1,18 +1,131 @@
1
- from typing import Any, Dict
2
- from mock_api import service as svc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  def dispatch_tool(tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
5
- if tool == "get_hours":
6
- return svc.get_hours()
7
- if tool == "menu_lookup":
8
- return {"items": svc.menu_lookup(args.get("filters") or [])}
9
- if tool == "create_reservation":
10
- return svc.create_reservation(
11
- name=args.get("name") or "Guest",
12
- phone=args.get("phone"),
13
- party_size=int(args.get("party_size") or 2),
14
- datetime_str=args.get("datetime_str") or "",
15
- )
16
- if tool == "create_order":
17
- return svc.create_order(args.get("items") or [])
18
- raise ValueError(f"unknown tool: {tool}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/tools.py
2
+ from __future__ import annotations
3
+ from typing import Any, Dict, List, Optional
4
+ import importlib
5
+
6
+ from utils.config import get_settings
7
+
8
+ # ------------------------------
9
+ # Backend service loader
10
+ # ------------------------------
11
+
12
+ def _load_service():
13
+ """
14
+ Load a service module providing the following callables:
15
+ - get_hours() -> dict
16
+ - menu_lookup(filters: List[str]) -> List[dict]
17
+ - create_reservation(name: str, phone: Optional[str], party_size: int, datetime_str: str) -> dict
18
+ - create_order(items: List[dict]) -> dict
19
+
20
+ Selection is controlled by `API_BACKEND` in .env:
21
+ - "sim" -> use built-in simulated API (app.sim_api_bridge)
22
+ - "mock" -> import app.mock_api.service or mock_api.service
23
+ - "http" -> import app.http_api.service (you can implement later)
24
+ """
25
+ s = get_settings()
26
+ backend = (getattr(s, "API_BACKEND", None) or "sim").lower()
27
+
28
+ module_candidates: List[str] = []
29
+ if backend == "sim":
30
+ module_candidates = ["app.sim_api_bridge"]
31
+ elif backend == "mock":
32
+ module_candidates = ["app.mock_api.service", "mock_api.service"]
33
+ elif backend == "http":
34
+ module_candidates = ["app.http_api.service"]
35
+ else:
36
+ # unknown -> fall back to sim
37
+ module_candidates = ["app.sim_api_bridge"]
38
+
39
+ last_err = None
40
+ for modname in module_candidates:
41
+ try:
42
+ return importlib.import_module(modname)
43
+ except Exception as e:
44
+ last_err = e
45
+ # Final fallback to sim bridge even if env asked otherwise
46
+ try:
47
+ return importlib.import_module("app.sim_api_bridge")
48
+ except Exception as e:
49
+ raise RuntimeError(f"Could not load any service module ({module_candidates}): {last_err or e}")
50
+
51
+ _service = None
52
+
53
+ def _service_module():
54
+ global _service
55
+ if _service is None:
56
+ _service = _load_service()
57
+ return _service
58
+
59
+ # ------------------------------
60
+ # Input helpers
61
+ # ------------------------------
62
+
63
+ def _as_int(x: Any, default: int) -> int:
64
+ try:
65
+ return int(x)
66
+ except Exception:
67
+ return default
68
+
69
+ def _as_list(x: Any) -> List[Any]:
70
+ if x is None:
71
+ return []
72
+ if isinstance(x, list):
73
+ return x
74
+ return [x]
75
+
76
+ def _ensure_items(items: Any) -> List[dict]:
77
+ if items is None:
78
+ return []
79
+ if isinstance(items, list):
80
+ # keep only dict-like lines
81
+ return [it for it in items if isinstance(it, dict)]
82
+ return []
83
+
84
+ # ------------------------------
85
+ # Public dispatch
86
+ # ------------------------------
87
 
88
  def dispatch_tool(tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
89
+ svc = _service_module()
90
+
91
+ try:
92
+ if tool == "get_hours":
93
+ return svc.get_hours()
94
+
95
+ if tool == "menu_lookup":
96
+ filters = _as_list(args.get("filters"))
97
+ return {"items": svc.menu_lookup(filters)}
98
+
99
+ if tool == "create_reservation":
100
+ name = args.get("name") or "Guest"
101
+ phone = args.get("phone")
102
+ # accept either "party_size" or "partySize"
103
+ party_size = _as_int(args.get("party_size") or args.get("partySize"), 2)
104
+ # accept "datetime_str" or split date/time if your UI produces them separately
105
+ datetime_str = args.get("datetime_str") or args.get("datetime") or ""
106
+ if not datetime_str:
107
+ # optional convenience: build from date + time if present
108
+ date = (args.get("date") or "").strip()
109
+ time_val = (args.get("time") or "").strip()
110
+ if date or time_val:
111
+ datetime_str = f"{date} {time_val}".strip()
112
+
113
+ return svc.create_reservation(
114
+ name=name,
115
+ phone=phone,
116
+ party_size=party_size,
117
+ datetime_str=datetime_str,
118
+ )
119
+
120
+ if tool == "create_order":
121
+ items = _ensure_items(args.get("items"))
122
+ if not items:
123
+ return {"ok": False, "reason": "no_items", "message": "No order items were provided."}
124
+ return svc.create_order(items)
125
+
126
+ # Unknown tool
127
+ return {"ok": False, "reason": "unknown_tool", "tool": tool}
128
+
129
+ except Exception as e:
130
+ # Never raise to the UI; always return a structured error
131
+ return {"ok": False, "reason": "exception", "error": str(e), "tool": tool}
models/.DS_Store ADDED
Binary file (6.15 kB). View file
 
models/__pycache__/asr_whisper.cpython-312.pyc CHANGED
Binary files a/models/__pycache__/asr_whisper.cpython-312.pyc and b/models/__pycache__/asr_whisper.cpython-312.pyc differ
 
models/__pycache__/llm_chat.cpython-312.pyc CHANGED
Binary files a/models/__pycache__/llm_chat.cpython-312.pyc and b/models/__pycache__/llm_chat.cpython-312.pyc differ
 
models/__pycache__/tts_router.cpython-312.pyc CHANGED
Binary files a/models/__pycache__/tts_router.cpython-312.pyc and b/models/__pycache__/tts_router.cpython-312.pyc differ
 
models/asr_whisper.py CHANGED
@@ -1,26 +1,61 @@
1
  # models/asr_whisper.py
 
 
 
2
  from faster_whisper import WhisperModel
3
  from utils.config import get_settings
4
 
5
- _asr_singleton = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  class WhisperASR:
8
  def __init__(self):
9
  s = get_settings()
10
- # faster-whisper supports: 'cpu' or 'cuda' (no 'mps')
11
- requested = (s.ASR_DEVICE or "cpu").lower()
12
- device = "cpu" if requested not in ("cpu", "cuda") else requested
13
- if requested == "mps":
14
- print("[ASR] 'mps' not supported by faster-whisper; falling back to CPU.")
15
- compute_type = "int8" if device == "cpu" else "float16"
16
- self.model = WhisperModel("tiny", device=device, compute_type=compute_type)
17
-
18
- def transcribe(self, path: str) -> dict:
19
- segments, info = self.model.transcribe(path, beam_size=1, language="en")
20
- text = " ".join(seg.text.strip() for seg in segments)
21
- return {"text": text, "language": info.language, "segments": []}
22
-
23
- def get_asr():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  global _asr_singleton
25
  if _asr_singleton is None:
26
  _asr_singleton = WhisperASR()
 
1
  # models/asr_whisper.py
2
+ from __future__ import annotations
3
+ import os
4
+ from typing import Optional, Dict, Any
5
  from faster_whisper import WhisperModel
6
  from utils.config import get_settings
7
 
8
+ _asr_singleton: Optional["WhisperASR"] = None
9
+
10
+
11
+ def _norm_device(req: str) -> str:
12
+ """faster-whisper supports only 'cpu' or 'cuda'."""
13
+ r = (req or "cpu").strip().lower()
14
+ if r == "mps":
15
+ print("[ASR] 'mps' is not supported by faster-whisper; falling back to CPU.")
16
+ return "cpu"
17
+ return r if r in ("cpu", "cuda") else "cpu"
18
+
19
+
20
+ def _compute_type_for(device: str) -> str:
21
+ # Keep it simple and stable for HF/macOS:
22
+ # - CPU: int8 (fast, small)
23
+ # - CUDA: float16 (good default on GPUs)
24
+ return "float16" if device == "cuda" else "int8"
25
+
26
 
27
  class WhisperASR:
28
  def __init__(self):
29
  s = get_settings()
30
+ self.model_size = os.getenv("WHISPER_SIZE", "tiny").strip() # tiny|base|small|medium|large-v3 ...
31
+ self.language = os.getenv("WHISPER_LANG", "").strip() or None # e.g., "en"; None = auto
32
+
33
+ self.device = _norm_device(getattr(s, "ASR_DEVICE", "cpu"))
34
+ self.compute_type = _compute_type_for(self.device)
35
+
36
+ print(f"[ASR] Loading faster-whisper: size={self.model_size} device={self.device} compute_type={self.compute_type}")
37
+ self.model = WhisperModel(self.model_size, device=self.device, compute_type=self.compute_type)
38
+
39
+ def transcribe(self, path: str) -> Dict[str, Any]:
40
+ """
41
+ Returns: {"text": str, "language": str|None, "segments": [...]}
42
+ """
43
+ # language=None lets faster-whisper auto-detect. You can force via WHISPER_LANG.
44
+ segments, info = self.model.transcribe(
45
+ path,
46
+ beam_size=1,
47
+ language=self.language or None,
48
+ )
49
+ text = " ".join((seg.text or "").strip() for seg in segments).strip()
50
+ return {
51
+ "text": text or "",
52
+ "language": getattr(info, "language", None),
53
+ # You can expose timings later if you want:
54
+ "segments": [] # keep lightweight for UI
55
+ }
56
+
57
+
58
+ def get_asr() -> WhisperASR:
59
  global _asr_singleton
60
  if _asr_singleton is None:
61
  _asr_singleton = WhisperASR()
models/llm_chat.py CHANGED
@@ -2,10 +2,10 @@
2
  from __future__ import annotations
3
  from typing import List, Dict, Any, Tuple
4
  import os
5
-
6
  from utils.config import get_settings
7
 
8
- # --- Small, readable menu JSON kept in the system prompt for now ---
9
  MENU_JSON = """
10
  {
11
  "pizzas": [
@@ -25,18 +25,52 @@ MENU_JSON = """
25
  """
26
 
27
  SYSTEM_PROMPT = f"""You are Marta, the AI call/SMS assistant for FutureCafe.
28
- You talk naturally and help with:
29
- - Menu questions, placing orders, hours/location, and reservations (lightweight).
30
- - If the user asks for pizza/order: list choices from the MENU and ask for missing details (size, quantity, etc.).
31
- - If user provides all details, confirm the order in words (no need to return JSON), include a brief total using MENU prices.
32
- - For hours/location, reply from MENU.
33
- - For unrelated topics, gently steer back to FutureCafe; if the user remains off-topic for 3 turns total, politely end.
34
- - Keep replies concise and friendly. No long explanations.
35
-
36
- MENU (JSON you can read from for options & prices):
 
 
 
 
 
 
 
 
 
 
 
 
37
  {MENU_JSON}
38
  """
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  # ---------------- llama.cpp singleton ----------------
41
  _llm = None
42
 
@@ -52,30 +86,44 @@ def _get_local_llm():
52
  raise RuntimeError(f"LLAMACPP_MODEL_PATH not found: {model_path}")
53
  _llm = Llama(
54
  model_path=model_path,
55
- n_ctx=2048,
56
- n_threads=os.cpu_count() or 4,
57
- n_gpu_layers=0, # CPU by default
58
  verbose=False,
59
  )
60
  return _llm
61
 
62
- def _apply_chat_template(messages: List[Dict[str, str]]) -> str:
63
- parts = []
 
64
  for m in messages:
65
  role = m.get("role", "user")
66
- content = m.get("content", "")
67
  if role == "system":
68
- parts.append(f"<|system|>\n{content}\n")
69
- elif role == "user":
70
- parts.append(f"<|user|>\n{content}\n")
71
  else:
72
- parts.append(f"<|assistant|>\n{content}\n")
73
- parts.append("<|assistant|>\n")
74
- return "\n".join(parts)
 
 
 
75
 
76
- def _generate(messages: List[Dict[str, str]], temperature=0.3, max_tokens=320) -> str:
 
 
 
 
 
 
 
 
 
 
77
  llm = _get_local_llm()
78
- prompt = _apply_chat_template(messages)
79
  out = llm(
80
  prompt,
81
  max_tokens=max_tokens,
@@ -84,33 +132,31 @@ def _generate(messages: List[Dict[str, str]], temperature=0.3, max_tokens=320) -
84
  repeat_penalty=1.1,
85
  stop=["<|user|>", "<|system|>", "<|assistant|>"],
86
  )
87
- return (out["choices"][0]["text"] or "").strip()
 
88
 
 
89
  def respond_chat(
90
  history: List[Dict[str, str]],
91
  user_text: str,
92
  guard_state: Dict[str, Any] | None,
93
  ) -> Tuple[str, Dict[str, Any], Dict[str, Any]]:
94
- """
95
- LLM-only conversational brain.
96
- Returns: (assistant_text, new_guard_state, diag)
97
- guard_state: {"unrelated": int, "ended": int, "limit": int}
98
- """
99
  guard = dict(guard_state or {"unrelated": 0, "ended": 0, "limit": 3})
100
  if guard.get("ended"):
101
  return "(Conversation ended. Start a new chat for FutureCafe.)", guard, {}
102
-
103
  msgs: List[Dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}]
 
104
  if history:
105
  msgs.extend(history[-10:])
106
  msgs.append({"role": "user", "content": user_text})
107
-
108
  reply = _generate(msgs)
109
-
110
- # A super-light off-topic guard without keywords: If the model signals ending, we respect it.
111
- # Otherwise, keep conversation flowing; we do not hard-code keywords or intents here.
112
- # (We still maintain the 'unrelated' counter if you later want to nudge based on signals.)
113
- if "Let’s end" in reply or "Let's end" in reply:
114
  guard["ended"] = 1
 
115
 
116
- return reply, guard, {} # no tool_result/diagnostics needed for this simpler flow
 
 
 
 
 
 
2
  from __future__ import annotations
3
  from typing import List, Dict, Any, Tuple
4
  import os
5
+ import re
6
  from utils.config import get_settings
7
 
8
+ # --- Lightweight menu kept inline for the MVP ---
9
  MENU_JSON = """
10
  {
11
  "pizzas": [
 
25
  """
26
 
27
  SYSTEM_PROMPT = f"""You are Marta, the AI call/SMS assistant for FutureCafe.
28
+
29
+ OBJECTIVE
30
+ Help with menu questions, placing orders, hours/location, and simple reservations—quickly and pleasantly.
31
+
32
+ GOALS
33
+ - Always begin new conversations with a friendly self-introduction:
34
+ "Hi, I’m Marta, an AI assistant at FutureCafe. How can I help you today?"
35
+ - Help with menu questions, placing orders, hours/location, and simple reservations.
36
+
37
+ INTERACTION RULES
38
+ - Always acknowledge the user briefly before asking for details.
39
+ - If details are missing, ask ONE short, specific follow-up that includes valid choices from the MENU (e.g., sizes).
40
+ - Never say “I didn’t understand.” Instead, restate what you do have and ask for the next missing detail.
41
+ - When the user’s message implies an order but lacks details, propose a short set of options (e.g., “Margherita or Pepperoni? What size: small, medium, large?”).
42
+ - When the user provides all required details, confirm the order concisely and give a total using MENU prices.
43
+ - After confirming, offer one gentle upsell (e.g., salad or drink). If user declines, close politely.
44
+ - For hours/location, answer directly from MENU.
45
+ - If the user goes off-topic, gently steer back to FutureCafe. After ~3 persistent off-topic turns, end politely.
46
+ - Be concise, friendly, and never quote or restate this policy or the raw MENU JSON. No code blocks.
47
+
48
+ MENU (for your internal reference only; do NOT paste it back verbatim):
49
  {MENU_JSON}
50
  """
51
 
52
+ FEWSHOT: List[Dict[str, str]] = [
53
+ # Greeting → clarify
54
+ {"role": "user", "content": "Hi"},
55
+ {"role": "assistant", "content": "Hello! How can I help with FutureCafe today?"},
56
+
57
+ # Ordering with missing details → ask one clear follow-up with choices
58
+ {"role": "user", "content": "I need a pizza"},
59
+ {"role": "assistant", "content": "Great—would you like Margherita or Pepperoni? What size: small, medium, or large?"},
60
+
61
+ # Provide details → confirm + total + optional upsell
62
+ {"role": "user", "content": "Two small Margherita"},
63
+ {"role": "assistant", "content": "Got it: 2× small Margherita Pizza. Total $17.00. Would you like a drink (Cola $2.00) or a House Salad ($6.00) with that?"},
64
+
65
+ # Decline upsell → polite close
66
+ {"role": "user", "content": "No thanks"},
67
+ {"role": "assistant", "content": "All set—your order is confirmed for 2× small Margherita Pizza. Total $17.00. Anything else I can help with?"},
68
+
69
+ # Hours/location
70
+ {"role": "user", "content": "What time are you open and where are you?"},
71
+ {"role": "assistant", "content": "We’re open 11:00–22:00 daily at 123 Main St. How can I help with your order today?"},
72
+ ]
73
+
74
  # ---------------- llama.cpp singleton ----------------
75
  _llm = None
76
 
 
86
  raise RuntimeError(f"LLAMACPP_MODEL_PATH not found: {model_path}")
87
  _llm = Llama(
88
  model_path=model_path,
89
+ n_ctx=s.N_CTX,
90
+ n_threads=s.N_THREADS,
91
+ n_gpu_layers=s.N_GPU_LAYERS,
92
  verbose=False,
93
  )
94
  return _llm
95
 
96
+ # ---------------- Prompt building ----------------
97
+ def _apply_chatml(messages: List[Dict[str, str]]) -> str:
98
+ out = []
99
  for m in messages:
100
  role = m.get("role", "user")
101
+ content = m.get("content", "").strip()
102
  if role == "system":
103
+ out.append("<|system|>\n" + content + "\n")
104
+ elif role == "assistant":
105
+ out.append("<|assistant|>\n" + content + "\n")
106
  else:
107
+ out.append("<|user|>\n" + content + "\n")
108
+ out.append("<|assistant|>\n")
109
+ return "\n".join(out)
110
+
111
+ _CODE_FENCE_RE = re.compile(r"```.*?```", flags=re.DOTALL)
112
+ _TAG_RE = re.compile(r"<\|.*?\|>")
113
 
114
+ def _sanitize(text: str) -> str:
115
+ if not text:
116
+ return ""
117
+ text = _CODE_FENCE_RE.sub("", text)
118
+ text = _TAG_RE.sub("", text)
119
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
120
+ if lines and any(k in lines[0].lower() for k in ["you are marta", "policy", "menu", "assistant", "as an ai"]):
121
+ lines = lines[1:]
122
+ return " ".join(lines).strip()
123
+
124
+ def _generate(messages: List[Dict[str, str]], temperature=0.15, max_tokens=256) -> str:
125
  llm = _get_local_llm()
126
+ prompt = _apply_chatml(messages)
127
  out = llm(
128
  prompt,
129
  max_tokens=max_tokens,
 
132
  repeat_penalty=1.1,
133
  stop=["<|user|>", "<|system|>", "<|assistant|>"],
134
  )
135
+ raw = (out["choices"][0]["text"] or "").strip()
136
+ return _sanitize(raw)
137
 
138
+ # ---------------- Public APIs ----------------
139
  def respond_chat(
140
  history: List[Dict[str, str]],
141
  user_text: str,
142
  guard_state: Dict[str, Any] | None,
143
  ) -> Tuple[str, Dict[str, Any], Dict[str, Any]]:
 
 
 
 
 
144
  guard = dict(guard_state or {"unrelated": 0, "ended": 0, "limit": 3})
145
  if guard.get("ended"):
146
  return "(Conversation ended. Start a new chat for FutureCafe.)", guard, {}
 
147
  msgs: List[Dict[str, str]] = [{"role": "system", "content": SYSTEM_PROMPT}]
148
+ msgs.extend(FEWSHOT)
149
  if history:
150
  msgs.extend(history[-10:])
151
  msgs.append({"role": "user", "content": user_text})
 
152
  reply = _generate(msgs)
153
+ if "let’s end" in reply.lower() or "let's end" in reply.lower():
 
 
 
 
154
  guard["ended"] = 1
155
+ return reply, guard, {}
156
 
157
+ def respond_chat_voice(
158
+ voice_history: List[Dict[str, str]],
159
+ transcript: str,
160
+ guard_state: Dict[str, Any] | None,
161
+ ) -> Tuple[str, Dict[str, Any], Dict[str, Any]]:
162
+ return respond_chat(voice_history, transcript, guard_state)
models/llm_router.py CHANGED
@@ -1,20 +1,26 @@
 
 
 
 
 
1
  from utils.config import get_settings
2
 
 
3
  def small_router(text: str) -> dict:
4
  t = (text or "").lower()
5
  if any(k in t for k in ["hour", "open", "close", "address", "location"]):
6
  return {"tool": "get_hours", "args": {}}
7
  if any(k in t for k in ["menu", "vegan", "gluten", "pizza", "salad", "special"]):
8
  flt = []
9
- for k in ["vegan","gluten-free","pizza","salad"]:
10
- if k in t: flt.append(k)
 
11
  return {"tool": "menu_lookup", "args": {"filters": flt}}
12
- if any(k in t for k in ["reserve","reservation","book","table"]):
13
- # naive hints
14
  party = 2 if ("2" in t or "two" in t) else None
15
  time = "19:00" if "7" in t else None
16
  return {"tool": "create_reservation", "args": {"party_size": party, "datetime_str": time}}
17
- if any(k in t for k in ["order","buy"]):
18
  return {"tool": "create_order", "args": {"items": []}}
19
  return {"tool": None, "args": {}}
20
 
@@ -37,10 +43,21 @@ def nlg(intent: str, tool_result: dict, user_text: str) -> str:
37
  items = ", ".join(f"{it['qty']}× {it['name']}" for it in tool_result.get("items", []))
38
  return f"Got it: {items}. Total ${tool_result.get('total', 0)}."
39
  return "I couldn't place that order—want me to try again?"
40
- # small talk
41
  return "Hello, this is Marta, an AI agent for FutureCafe. How can I help you today?"
42
 
43
- def respond(user_text: str) -> dict:
44
- # MVP: use rule-based router; later swap to real LLM function-calling
45
- route = small_router(user_text)
46
- return route
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/llm_router.py
2
+ from __future__ import annotations
3
+ import os
4
+ from typing import Any, Dict
5
+
6
  from utils.config import get_settings
7
 
8
+ # --- Existing rule-based pieces kept as a fallback ---
9
  def small_router(text: str) -> dict:
10
  t = (text or "").lower()
11
  if any(k in t for k in ["hour", "open", "close", "address", "location"]):
12
  return {"tool": "get_hours", "args": {}}
13
  if any(k in t for k in ["menu", "vegan", "gluten", "pizza", "salad", "special"]):
14
  flt = []
15
+ for k in ["vegan", "gluten-free", "pizza", "salad"]:
16
+ if k in t:
17
+ flt.append(k)
18
  return {"tool": "menu_lookup", "args": {"filters": flt}}
19
+ if any(k in t for k in ["reserve", "reservation", "book", "table"]):
 
20
  party = 2 if ("2" in t or "two" in t) else None
21
  time = "19:00" if "7" in t else None
22
  return {"tool": "create_reservation", "args": {"party_size": party, "datetime_str": time}}
23
+ if any(k in t for k in ["order", "buy"]):
24
  return {"tool": "create_order", "args": {"items": []}}
25
  return {"tool": None, "args": {}}
26
 
 
43
  items = ", ".join(f"{it['qty']}× {it['name']}" for it in tool_result.get("items", []))
44
  return f"Got it: {items}. Total ${tool_result.get('total', 0)}."
45
  return "I couldn't place that order—want me to try again?"
 
46
  return "Hello, this is Marta, an AI agent for FutureCafe. How can I help you today?"
47
 
48
+ # --- Router mode switch (env-controlled) ---
49
+ # ROUTER_MODE = "rules" | "llm"
50
+ # - rules: use small_router (current behavior)
51
+ # - llm: return no tool; the chat LLM handles everything in text/voice flows
52
+ def _router_mode() -> str:
53
+ s = get_settings()
54
+ # allow either .env or process env to override
55
+ return os.getenv("ROUTER_MODE", getattr(s, "ROUTER_MODE", "rules")).strip().lower()
56
+
57
+ def respond(user_text: str) -> Dict[str, Any]:
58
+ mode = _router_mode()
59
+ if mode == "llm":
60
+ # Pure LLM flow: don’t pre-select tools; downstream chat model decides.
61
+ return {"tool": None, "args": {}}
62
+ # Default / fallback: rule-based
63
+ return small_router(user_text)
models/tts_router.py CHANGED
@@ -5,107 +5,105 @@ import os
5
  import re
6
  import uuid
7
  import wave
8
- import shutil
9
  import subprocess
10
  from shutil import which
11
  from typing import Optional
12
 
13
  RUNTIME_AUDIO_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "runtime", "audio"))
14
 
15
-
16
- import os, glob
17
- from typing import Optional
18
-
19
  AUDIO_DIR = os.path.join(os.path.dirname(__file__), "..", "runtime", "audio")
20
  os.makedirs(AUDIO_DIR, exist_ok=True)
21
 
22
  def cleanup_old_audio(keep_latest: Optional[str] = None):
23
  """Delete all audio files in runtime/audio except the one to keep."""
24
- for f in glob.glob(os.path.join(AUDIO_DIR, "*.wav")):
 
25
  if keep_latest and os.path.abspath(f) == os.path.abspath(keep_latest):
26
  continue
27
- try:
28
- os.remove(f)
29
- except Exception as e:
30
- print(f"[CLEANUP] Could not delete {f}: {e}")
 
31
 
32
  def ensure_runtime_audio_dir() -> str:
33
  os.makedirs(RUNTIME_AUDIO_DIR, exist_ok=True)
34
  return RUNTIME_AUDIO_DIR
35
 
36
-
37
  def _have(cmd: str) -> bool:
38
  return which(cmd) is not None
39
 
40
-
41
- def _is_valid_wav(path: str) -> bool:
42
  try:
43
  with wave.open(path, "rb") as w:
44
  frames = w.getnframes()
45
  rate = w.getframerate()
46
- if frames <= 0 or rate <= 0:
 
47
  return False
48
  except Exception:
49
  return False
50
  return True
51
 
52
-
53
  def _tts_with_piper(text: str) -> Optional[str]:
54
  """
55
  Use local Piper if available.
56
- Requires:
57
- - env PIPER_MODEL to point to models/piper/<voice>.onnx
58
- - `piper` binary in PATH (brew install piper or from releases)
59
  """
60
  model = os.getenv("PIPER_MODEL")
61
  if not model or not os.path.exists(model):
62
  return None
63
- if not _have("piper"):
 
 
64
  return None
65
 
66
  out_dir = ensure_runtime_audio_dir()
67
  out_path = os.path.join(out_dir, f"tts_{uuid.uuid4().hex}.wav")
68
 
69
- # Avoid stray control chars that can confuse some engines
70
  safe_text = re.sub(r"[\x00-\x1F]+", " ", text).strip()
71
  try:
72
- # Simple one-shot pipe
73
  p = subprocess.Popen(
74
- ["piper", "--model", model, "--output_file", out_path],
75
  stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
76
  )
77
- p.communicate(input=safe_text.encode("utf-8"), timeout=30)
78
  if p.returncode == 0 and os.path.exists(out_path) and _is_valid_wav(out_path):
79
- return out_path
80
  except Exception as e:
81
  print("[TTS] Piper error:", e)
82
  return None
83
 
84
-
85
  def _tts_with_say(text: str) -> Optional[str]:
86
  """
87
  macOS `say` fallback. Produces WAV via afconvert or ffmpeg if present;
88
- else writes AIFF and returns it if WAV conversion fails.
 
 
89
  """
90
- if os.name != "posix":
91
- return None
92
- if not _have("say"):
93
  return None
94
 
95
  out_dir = ensure_runtime_audio_dir()
96
  aiff = os.path.join(out_dir, f"tts_{uuid.uuid4().hex}.aiff")
97
  wav = os.path.join(out_dir, f"tts_{uuid.uuid4().hex}.wav")
98
 
 
99
  safe_text = re.sub(r"[\x00-\x1F`<>]+", " ", text).strip() or "Hello."
100
  try:
101
- # Basic AIFF
102
- subprocess.run(["say", "-o", aiff, safe_text], check=True)
 
 
 
103
  except Exception as e:
104
  print("[TTS] say failed:", e)
105
  return None
106
 
107
  converted = False
108
- # Prefer afconvert
109
  if which("afconvert"):
110
  try:
111
  subprocess.run(
@@ -115,7 +113,6 @@ def _tts_with_say(text: str) -> Optional[str]:
115
  converted = True
116
  except Exception:
117
  converted = False
118
- # Else try ffmpeg
119
  if not converted and which("ffmpeg"):
120
  try:
121
  subprocess.run(
@@ -126,45 +123,40 @@ def _tts_with_say(text: str) -> Optional[str]:
126
  except Exception:
127
  converted = False
128
 
129
- # Cleanup/return best
130
  if converted and os.path.exists(wav) and _is_valid_wav(wav):
131
  try:
132
  os.remove(aiff)
133
  except Exception:
134
  pass
135
- return wav
136
 
137
- # Fallback: return AIFF if WAV conversion failed but aiff exists
138
  if os.path.exists(aiff):
139
- return aiff
 
140
 
141
  return None
142
 
143
-
144
  def tts_synthesize(text: str) -> Optional[str]:
145
  """
146
  High-level TTS router:
147
  1) Piper (if configured)
148
  2) macOS 'say'
149
  3) None
150
- Always writes to runtime/audio.
151
  """
152
  if not (text and text.strip()):
153
  return None
154
 
155
  ensure_runtime_audio_dir()
156
 
157
- # 1) Piper
158
  out = _tts_with_piper(text)
159
  if out:
160
  cleanup_old_audio(keep_latest=out)
161
  return out
162
 
163
- # 2) macOS say
164
  out = _tts_with_say(text)
165
  if out:
166
  cleanup_old_audio(keep_latest=out)
167
  return out
168
 
169
- # 3) None
170
  return None
 
5
  import re
6
  import uuid
7
  import wave
8
+ import glob
9
  import subprocess
10
  from shutil import which
11
  from typing import Optional
12
 
13
  RUNTIME_AUDIO_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "runtime", "audio"))
14
 
 
 
 
 
15
  AUDIO_DIR = os.path.join(os.path.dirname(__file__), "..", "runtime", "audio")
16
  os.makedirs(AUDIO_DIR, exist_ok=True)
17
 
18
  def cleanup_old_audio(keep_latest: Optional[str] = None):
19
  """Delete all audio files in runtime/audio except the one to keep."""
20
+ for f in glob.glob(os.path.join(AUDIO_DIR, "*")):
21
+ # keep both .wav/.aiff just in case engine produced AIFF
22
  if keep_latest and os.path.abspath(f) == os.path.abspath(keep_latest):
23
  continue
24
+ if f.endswith((".wav", ".aiff")):
25
+ try:
26
+ os.remove(f)
27
+ except Exception as e:
28
+ print(f"[CLEANUP] Could not delete {f}: {e}")
29
 
30
  def ensure_runtime_audio_dir() -> str:
31
  os.makedirs(RUNTIME_AUDIO_DIR, exist_ok=True)
32
  return RUNTIME_AUDIO_DIR
33
 
 
34
  def _have(cmd: str) -> bool:
35
  return which(cmd) is not None
36
 
37
+ def _is_valid_wav(path: str, min_duration_s: float = 0.25) -> bool:
 
38
  try:
39
  with wave.open(path, "rb") as w:
40
  frames = w.getnframes()
41
  rate = w.getframerate()
42
+ dur = (frames / float(rate)) if rate else 0.0
43
+ if frames <= 0 or rate <= 0 or dur < min_duration_s:
44
  return False
45
  except Exception:
46
  return False
47
  return True
48
 
 
49
  def _tts_with_piper(text: str) -> Optional[str]:
50
  """
51
  Use local Piper if available.
52
+ Env:
53
+ - PIPER_MODEL: path to models/piper/<voice>.onnx
54
+ - PIPER_BIN (optional): override binary name/path (default 'piper')
55
  """
56
  model = os.getenv("PIPER_MODEL")
57
  if not model or not os.path.exists(model):
58
  return None
59
+ piper_bin = os.getenv("PIPER_BIN", "piper")
60
+ if not _have(piper_bin) and not os.path.isabs(piper_bin):
61
+ # If the user passed an absolute path, we try it even if not in PATH
62
  return None
63
 
64
  out_dir = ensure_runtime_audio_dir()
65
  out_path = os.path.join(out_dir, f"tts_{uuid.uuid4().hex}.wav")
66
 
 
67
  safe_text = re.sub(r"[\x00-\x1F]+", " ", text).strip()
68
  try:
 
69
  p = subprocess.Popen(
70
+ [piper_bin, "--model", model, "--output_file", out_path],
71
  stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
72
  )
73
+ p.communicate(input=safe_text.encode("utf-8"), timeout=45)
74
  if p.returncode == 0 and os.path.exists(out_path) and _is_valid_wav(out_path):
75
+ return os.path.abspath(out_path)
76
  except Exception as e:
77
  print("[TTS] Piper error:", e)
78
  return None
79
 
 
80
  def _tts_with_say(text: str) -> Optional[str]:
81
  """
82
  macOS `say` fallback. Produces WAV via afconvert or ffmpeg if present;
83
+ else returns AIFF path.
84
+ Env:
85
+ - SAY_VOICE (optional): e.g., "Samantha" / "Alex"
86
  """
87
+ if os.name != "posix" or not _have("say"):
 
 
88
  return None
89
 
90
  out_dir = ensure_runtime_audio_dir()
91
  aiff = os.path.join(out_dir, f"tts_{uuid.uuid4().hex}.aiff")
92
  wav = os.path.join(out_dir, f"tts_{uuid.uuid4().hex}.wav")
93
 
94
+ voice = os.getenv("SAY_VOICE")
95
  safe_text = re.sub(r"[\x00-\x1F`<>]+", " ", text).strip() or "Hello."
96
  try:
97
+ cmd = ["say", "-o", aiff]
98
+ if voice:
99
+ cmd.extend(["-v", voice])
100
+ cmd.append(safe_text)
101
+ subprocess.run(cmd, check=True)
102
  except Exception as e:
103
  print("[TTS] say failed:", e)
104
  return None
105
 
106
  converted = False
 
107
  if which("afconvert"):
108
  try:
109
  subprocess.run(
 
113
  converted = True
114
  except Exception:
115
  converted = False
 
116
  if not converted and which("ffmpeg"):
117
  try:
118
  subprocess.run(
 
123
  except Exception:
124
  converted = False
125
 
 
126
  if converted and os.path.exists(wav) and _is_valid_wav(wav):
127
  try:
128
  os.remove(aiff)
129
  except Exception:
130
  pass
131
+ return os.path.abspath(wav)
132
 
 
133
  if os.path.exists(aiff):
134
+ # AIFF is fine as a fallback (Gradio can usually play it)
135
+ return os.path.abspath(aiff)
136
 
137
  return None
138
 
 
139
  def tts_synthesize(text: str) -> Optional[str]:
140
  """
141
  High-level TTS router:
142
  1) Piper (if configured)
143
  2) macOS 'say'
144
  3) None
145
+ Always writes to runtime/audio and prunes older files.
146
  """
147
  if not (text and text.strip()):
148
  return None
149
 
150
  ensure_runtime_audio_dir()
151
 
 
152
  out = _tts_with_piper(text)
153
  if out:
154
  cleanup_old_audio(keep_latest=out)
155
  return out
156
 
 
157
  out = _tts_with_say(text)
158
  if out:
159
  cleanup_old_audio(keep_latest=out)
160
  return out
161
 
 
162
  return None
requirements.txt CHANGED
@@ -1,11 +1,10 @@
1
- gradio
2
  pydantic>=2.8
3
  pydantic-settings>=2.5
4
  numpy>=1.26
5
  soundfile>=0.12
6
- webrtcvad
7
- faster-whisper
8
- llama-cpp-python==0.2.90
9
- pyttsx3
10
- openai
11
- huggingface_hub>=0.23
 
1
+ gradio>5.0
2
  pydantic>=2.8
3
  pydantic-settings>=2.5
4
  numpy>=1.26
5
  soundfile>=0.12
6
+ webrtcvad>=2.0.10
7
+ faster-whisper>=1.0.0
8
+ llama-cpp-python>=0.2.90
9
+ pyttsx3>=2.90
10
+ openai>=1.44.0
 
runtime/audio/tts_8eda72f9b61c4b13a04c70a4b1f1a997.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:359a1a8892aac21d21dae0840b05c6d72bdab4bd8a91ec41850de9deb5096bae
3
+ size 246834
utils/__pycache__/config.cpython-312.pyc CHANGED
Binary files a/utils/__pycache__/config.cpython-312.pyc and b/utils/__pycache__/config.cpython-312.pyc differ
 
utils/config.py CHANGED
@@ -1,38 +1,55 @@
 
1
  from __future__ import annotations
2
  import os
3
- from pydantic_settings import BaseSettings
 
4
  from pydantic import Field
5
 
6
  class Settings(BaseSettings):
 
7
  BACKEND_LLM: str = Field(default="llamacpp") # 'llamacpp' | 'openai' | 'groq'
8
- LLAMACPP_MODEL_PATH: str = Field(default="models/qwen2.5-1.5b-instruct-q4_k_m.gguf")
9
 
 
10
  N_CTX: int = 4096
11
  N_THREADS: int = 4
12
  N_GPU_LAYERS: int = 0
13
 
14
- ASR_DEVICE: str = "mps" # 'mps' or 'cpu'
15
- TTS_ENGINE: str = "pyttsx3" # 'pyttsx3' | 'say' | 'piper' (later)
 
16
 
17
- OPENAI_API_KEY: str | None = None
18
- GROQ_API_KEY: str | None = None
 
19
 
 
 
 
 
 
 
 
 
20
  IS_HF_SPACE: bool = False
21
  DEBUG: bool = True
 
22
 
23
- class Config:
24
- env_file = ".env"
25
- extra = "ignore"
26
 
27
  def pretty(self) -> dict:
28
  d = self.model_dump()
29
- if d.get("OPENAI_API_KEY"):
30
- d["OPENAI_API_KEY"] = True
31
- if d.get("GROQ_API_KEY"):
32
- d["GROQ_API_KEY"] = True
 
 
 
33
  return d
34
 
35
- _settings: Settings | None = None
 
36
 
37
  def get_settings() -> Settings:
38
  global _settings
 
1
+ # utils/config.py
2
  from __future__ import annotations
3
  import os
4
+ from typing import Optional
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
  from pydantic import Field
7
 
8
  class Settings(BaseSettings):
9
+ # --- Core LLM backend ---
10
  BACKEND_LLM: str = Field(default="llamacpp") # 'llamacpp' | 'openai' | 'groq'
11
+ LLAMACPP_MODEL_PATH: Optional[str] = Field(default=None)
12
 
13
+ # llama.cpp runtime knobs
14
  N_CTX: int = 4096
15
  N_THREADS: int = 4
16
  N_GPU_LAYERS: int = 0
17
 
18
+ # ASR / TTS
19
+ ASR_DEVICE: str = "cpu" # 'mps' | 'cpu'
20
+ TTS_ENGINE: str = "pyttsx3" # 'pyttsx3' | 'say' | 'piper'
21
 
22
+ # Piper specifics (optional, only used if TTS_ENGINE='piper')
23
+ PIPER_MODEL: Optional[str] = None # e.g. "models/piper/en_US-amy-medium.onnx"
24
+ PIPER_BIN: str = "piper" # executable name or absolute path
25
 
26
+ # Where we persist session audio (created elsewhere if missing)
27
+ VOICE_AUDIO_DIR: str = "runtime/audio"
28
+
29
+ # Cloud keys (optional)
30
+ OPENAI_API_KEY: Optional[str] = None
31
+ GROQ_API_KEY: Optional[str] = None
32
+
33
+ # App flags
34
  IS_HF_SPACE: bool = False
35
  DEBUG: bool = True
36
+ CAFE_UNRELATED_LIMIT: int = 3
37
 
38
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
 
 
39
 
40
  def pretty(self) -> dict:
41
  d = self.model_dump()
42
+ # Mask secrets
43
+ for k in ("OPENAI_API_KEY", "GROQ_API_KEY"):
44
+ if d.get(k):
45
+ d[k] = True
46
+ # Expand absolute path preview for convenience (doesn't change real value)
47
+ if d.get("VOICE_AUDIO_DIR"):
48
+ d["VOICE_AUDIO_DIR"] = os.path.abspath(d["VOICE_AUDIO_DIR"])
49
  return d
50
 
51
+
52
+ _settings: Optional[Settings] = None
53
 
54
  def get_settings() -> Settings:
55
  global _settings