Spaces:
Sleeping
Sleeping
Upload 41 files
Browse files- .gitattributes +1 -0
- README.md +1 -0
- app.py +6 -10
- app/__pycache__/gradio_app.cpython-312.pyc +0 -0
- app/catalog.py +89 -39
- app/gradio_app.py +51 -42
- app/intent_schema.py +48 -4
- app/orchestrator.py +117 -15
- app/policy.py +105 -18
- app/sim_api.py +92 -53
- app/sim_api_bridge.py +28 -0
- app/tools.py +129 -16
- models/.DS_Store +0 -0
- models/__pycache__/asr_whisper.cpython-312.pyc +0 -0
- models/__pycache__/llm_chat.cpython-312.pyc +0 -0
- models/__pycache__/tts_router.cpython-312.pyc +0 -0
- models/asr_whisper.py +50 -15
- models/llm_chat.py +85 -39
- models/llm_router.py +27 -10
- models/tts_router.py +34 -42
- requirements.txt +6 -7
- runtime/audio/tts_8eda72f9b61c4b13a04c70a4b1f1a997.wav +3 -0
- utils/__pycache__/config.cpython-312.pyc +0 -0
- utils/config.py +31 -14
.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 |
-
|
|
|
|
| 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
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
global _CATALOG
|
| 15 |
-
if _CATALOG is not None:
|
| 16 |
return _CATALOG
|
| 17 |
path = get_catalog_path()
|
| 18 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 25 |
for it in c["items"]:
|
| 26 |
-
|
| 27 |
-
|
| 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
|
| 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 |
-
|
|
|
|
| 44 |
|
| 45 |
def optional_fields_for_category(category: str) -> List[str]:
|
| 46 |
c = load_catalog()
|
| 47 |
schema = c["schema"].get(category) or {}
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
def compute_missing_fields(order_item: Dict[str, Any]) -> List[str]:
|
| 51 |
"""
|
| 52 |
-
order_item
|
|
|
|
| 53 |
Uses catalog schema to see which fields are missing.
|
| 54 |
"""
|
| 55 |
-
it =
|
| 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 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
else:
|
| 72 |
-
present
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 37 |
"""
|
| 38 |
-
if llm_respond_chat is
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
print("[LLM]
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 290 |
stable_user = _persist_copy(aud_path)
|
| 291 |
|
| 292 |
# 2) Transcribe
|
|
|
|
| 293 |
try:
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
-
# 4) TTS the bot reply into runtime/audio
|
| 315 |
-
new_tts = tts_synthesize(bot_text) #
|
| 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,
|
| 338 |
-
return new_hist, new_tts, diag
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
from
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
from utils.phone import extract_phone, looks_valid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
def llm_route_and_execute(user_text: str) -> Dict[str, Any]:
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
tool = route.get("tool")
|
| 9 |
-
args = route.get("args") or {}
|
| 10 |
|
| 11 |
-
#
|
| 12 |
if tool == "create_reservation":
|
| 13 |
-
phone = extract_phone(
|
| 14 |
-
if looks_valid(phone):
|
| 15 |
args["phone"] = phone
|
|
|
|
| 16 |
if not args.get("name"):
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
if m: args["name"] = m.group(1)
|
| 21 |
|
|
|
|
| 22 |
tool_result = None
|
| 23 |
if tool:
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
return {
|
| 29 |
-
"intent": tool or
|
| 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 |
-
#
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 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 |
-
|
| 32 |
-
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
it = None
|
| 8 |
-
if "sku"
|
| 9 |
-
it = find_item_by_sku(order_it["sku"])
|
| 10 |
-
if not it and "name"
|
| 11 |
-
it = find_item_by_name(order_it["name"])
|
| 12 |
return it
|
| 13 |
|
| 14 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
Returns (is_available, info)
|
| 17 |
-
info
|
| 18 |
-
|
| 19 |
"""
|
| 20 |
it = _pick_item(order_it)
|
| 21 |
if not it:
|
| 22 |
-
return False, {"reason": "unknown_item", "
|
| 23 |
|
| 24 |
-
qty =
|
| 25 |
-
if qty
|
| 26 |
-
return False, {"reason": "
|
| 27 |
|
| 28 |
-
# size
|
| 29 |
size = order_it.get("size")
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
|
|
|
|
| 32 |
if "one_size" in stock_map:
|
| 33 |
-
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
"""
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
"""
|
| 59 |
-
ok = True
|
| 60 |
-
lines = []
|
| 61 |
total = 0.0
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
line_total = unit * qty
|
| 72 |
total += line_total
|
|
|
|
|
|
|
|
|
|
| 73 |
lines.append({
|
| 74 |
-
"sku":
|
| 75 |
-
"name":
|
| 76 |
"qty": qty,
|
| 77 |
-
"options":
|
| 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 |
-
|
| 2 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
def dispatch_tool(tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
self.
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ---
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 56 |
-
n_threads=
|
| 57 |
-
n_gpu_layers=
|
| 58 |
verbose=False,
|
| 59 |
)
|
| 60 |
return _llm
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 64 |
for m in messages:
|
| 65 |
role = m.get("role", "user")
|
| 66 |
-
content = m.get("content", "")
|
| 67 |
if role == "system":
|
| 68 |
-
|
| 69 |
-
elif role == "
|
| 70 |
-
|
| 71 |
else:
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
return "\n".join(
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
llm = _get_local_llm()
|
| 78 |
-
prompt =
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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, "
|
|
|
|
| 25 |
if keep_latest and os.path.abspath(f) == os.path.abspath(keep_latest):
|
| 26 |
continue
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 57 |
-
-
|
| 58 |
-
-
|
| 59 |
"""
|
| 60 |
model = os.getenv("PIPER_MODEL")
|
| 61 |
if not model or not os.path.exists(model):
|
| 62 |
return None
|
| 63 |
-
|
|
|
|
|
|
|
| 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 |
-
[
|
| 75 |
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
| 76 |
)
|
| 77 |
-
p.communicate(input=safe_text.encode("utf-8"), timeout=
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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=
|
| 9 |
|
|
|
|
| 10 |
N_CTX: int = 4096
|
| 11 |
N_THREADS: int = 4
|
| 12 |
N_GPU_LAYERS: int = 0
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
|
|
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
IS_HF_SPACE: bool = False
|
| 21 |
DEBUG: bool = True
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
env_file = ".env"
|
| 25 |
-
extra = "ignore"
|
| 26 |
|
| 27 |
def pretty(self) -> dict:
|
| 28 |
d = self.model_dump()
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
| 33 |
return d
|
| 34 |
|
| 35 |
-
|
|
|
|
| 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
|