Spaces:
Paused
Paused
Upload main.py
Browse files
main.py
CHANGED
|
@@ -10,6 +10,7 @@ app = FastAPI()
|
|
| 10 |
# --- EINSTELLUNGEN ---
|
| 11 |
COLLECTION_KNOWLEDGE = "knowledge_base"
|
| 12 |
COLLECTION_INBOX = "inbox"
|
|
|
|
| 13 |
|
| 14 |
# --- GLOBALE VARIABLE (RAM) ---
|
| 15 |
KNOWLEDGE_CACHE = []
|
|
@@ -53,33 +54,68 @@ def reload_knowledge():
|
|
| 53 |
async def startup_event():
|
| 54 |
reload_knowledge()
|
| 55 |
|
| 56 |
-
# ---
|
| 57 |
-
@app.get("/")
|
| 58 |
-
def home():
|
| 59 |
-
return {"status": "Udo Agent API (Stemming Mode) ist bereit.", "docs": len(KNOWLEDGE_CACHE)}
|
| 60 |
-
|
| 61 |
-
@app.get("/refresh_knowledge")
|
| 62 |
-
def refresh_endpoint():
|
| 63 |
-
count = reload_knowledge()
|
| 64 |
-
return {"status": "Cache aktualisiert", "docs_loaded": count}
|
| 65 |
-
|
| 66 |
-
# --- 🧠 HELPER: DEUTSCHE WORTSTÄMME ---
|
| 67 |
def get_stem(word):
|
| 68 |
-
"""
|
| 69 |
-
Ein sehr einfacher 'Stemmer' für Deutsch.
|
| 70 |
-
Schneidet Endungen wie 'en', 'ern', 'te', 's' ab.
|
| 71 |
-
Macht aus 'Preise' -> 'preis', 'kostet' -> 'kost'.
|
| 72 |
-
"""
|
| 73 |
w = word.lower().strip()
|
| 74 |
-
# Reihenfolge wichtig! Längere Endungen zuerst.
|
| 75 |
endings = ["ern", "em", "er", "en", "es", "st", "te", "e", "s", "t"]
|
| 76 |
-
|
| 77 |
for end in endings:
|
| 78 |
-
if w.endswith(end) and len(w) > (len(end) + 2):
|
| 79 |
return w[:-len(end)]
|
| 80 |
return w
|
| 81 |
|
| 82 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
@app.post("/search")
|
| 84 |
async def search_knowledge(request: Request):
|
| 85 |
try:
|
|
@@ -87,102 +123,100 @@ async def search_knowledge(request: Request):
|
|
| 87 |
except:
|
| 88 |
return {"result": "Fehler: Kein JSON."}
|
| 89 |
|
| 90 |
-
#
|
| 91 |
query_text = ""
|
| 92 |
if "query" in data: query_text = data["query"]
|
| 93 |
elif "message" in data:
|
| 94 |
try:
|
|
|
|
| 95 |
args = data["message"]["toolCalls"][0]["function"]["arguments"]
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
| 97 |
except: pass
|
|
|
|
|
|
|
| 98 |
if not query_text and "args" in data: query_text = data["args"].get("query", "")
|
| 99 |
|
| 100 |
print(f"🔎 FRAGE: '{query_text}'")
|
| 101 |
-
|
| 102 |
-
if not query_text: return {"result": "Akustik-Fehler."}
|
| 103 |
|
| 104 |
-
# --- SCORING ALGORITHMUS V3 (Mit "Low Confidence" Inbox) ---
|
| 105 |
best_doc = None
|
| 106 |
best_score = 0
|
| 107 |
|
| 108 |
-
# 1. Query vorbereiten
|
| 109 |
query_words_raw = query_text.lower().replace("?", "").replace(".", "").split()
|
| 110 |
query_stems = [get_stem(w) for w in query_words_raw if len(w) > 2]
|
| 111 |
|
| 112 |
print(f" ⚙️ Suchst��mme: {query_stems}")
|
| 113 |
|
| 114 |
-
# Wir prüfen JEDES Dokument
|
| 115 |
for entry in KNOWLEDGE_CACHE:
|
| 116 |
score = 0
|
| 117 |
-
doc_id = entry.get('id', 'unknown')
|
| 118 |
t_answer = entry.get("answer", "")
|
| 119 |
t_question = entry.get("question", "")
|
| 120 |
t_keywords = entry.get("keywords", [])
|
| 121 |
|
| 122 |
-
if not t_answer
|
| 123 |
|
| 124 |
-
# A) Keyword Treffer
|
| 125 |
if isinstance(t_keywords, list):
|
| 126 |
for k in t_keywords:
|
| 127 |
k_stem = get_stem(k)
|
| 128 |
if k_stem in query_stems:
|
| 129 |
-
|
| 130 |
-
else: score += 20
|
| 131 |
|
| 132 |
-
# B) Frage/Titel Treffer
|
| 133 |
if t_question:
|
| 134 |
-
q_words = t_question.lower().
|
| 135 |
for qw in q_words:
|
| 136 |
if len(qw) < 3: continue
|
| 137 |
-
|
| 138 |
-
if qw_stem in query_stems:
|
| 139 |
score += 15
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
if score > best_score:
|
| 142 |
best_score = score
|
| 143 |
best_doc = entry
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
print(f" Candidate {doc_id}: {score} Pts (Titel: {t_question[:30]}...)")
|
| 147 |
|
| 148 |
-
# --- ERGEBNIS
|
| 149 |
-
|
| 150 |
-
# Schwelle für "Ich bin mir sicher"
|
| 151 |
-
CONFIDENCE_THRESHOLD = 70
|
| 152 |
-
|
| 153 |
-
if best_doc and best_score >= 10:
|
| 154 |
-
print(f"🏆 GEWINNER: Doc {best_doc['id']} mit {best_score} Punkten.")
|
| 155 |
-
|
| 156 |
-
# DEBUG: Zeig uns die ersten 100 Zeichen der Antwort im Log!
|
| 157 |
-
preview = best_doc['answer'][:100].replace("\n", " ")
|
| 158 |
-
print(f"📤 SENDE AN VAPI: '{preview}...'")
|
| 159 |
-
|
| 160 |
-
# LOGIK: Wenn der Score "naja" ist (zwischen 10 und 70), ab in die Inbox zur Prüfung!
|
| 161 |
-
if best_score < CONFIDENCE_THRESHOLD:
|
| 162 |
-
print(f"⚠️ LOW CONFIDENCE ({best_score}). Schreibe Backup in Inbox...")
|
| 163 |
-
if db:
|
| 164 |
-
try:
|
| 165 |
-
db.collection(COLLECTION_INBOX).add({
|
| 166 |
-
"question": query_text,
|
| 167 |
-
"status": "review_needed", # Markierung für dich
|
| 168 |
-
"found_answer": best_doc['answer'], # Was hat er gefunden?
|
| 169 |
-
"score": best_score,
|
| 170 |
-
"timestamp": firestore.SERVER_TIMESTAMP,
|
| 171 |
-
"source": "AI Call (Low Confidence)"
|
| 172 |
-
})
|
| 173 |
-
except: pass
|
| 174 |
|
|
|
|
|
|
|
| 175 |
return {"result": best_doc['answer']}
|
| 176 |
|
|
|
|
| 177 |
else:
|
| 178 |
-
print(f"⚠️
|
| 179 |
-
if db:
|
| 180 |
-
|
| 181 |
db.collection(COLLECTION_INBOX).add({
|
| 182 |
"question": query_text,
|
| 183 |
-
"status": "
|
| 184 |
-
"
|
| 185 |
-
"
|
|
|
|
| 186 |
})
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# --- EINSTELLUNGEN ---
|
| 11 |
COLLECTION_KNOWLEDGE = "knowledge_base"
|
| 12 |
COLLECTION_INBOX = "inbox"
|
| 13 |
+
COLLECTION_RULES = "availability_rules"
|
| 14 |
|
| 15 |
# --- GLOBALE VARIABLE (RAM) ---
|
| 16 |
KNOWLEDGE_CACHE = []
|
|
|
|
| 54 |
async def startup_event():
|
| 55 |
reload_knowledge()
|
| 56 |
|
| 57 |
+
# --- HELPER: DEUTSCHE WORTSTÄMME ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def get_stem(word):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
w = word.lower().strip()
|
|
|
|
| 60 |
endings = ["ern", "em", "er", "en", "es", "st", "te", "e", "s", "t"]
|
|
|
|
| 61 |
for end in endings:
|
| 62 |
+
if w.endswith(end) and len(w) > (len(end) + 2):
|
| 63 |
return w[:-len(end)]
|
| 64 |
return w
|
| 65 |
|
| 66 |
+
# ==========================================
|
| 67 |
+
# 1. NEUER ENDPUNKT: VERFÜGBARKEIT (INJECTION)
|
| 68 |
+
# ==========================================
|
| 69 |
+
@app.post("/vapi-incoming")
|
| 70 |
+
async def handle_vapi_incoming(request: Request):
|
| 71 |
+
"""
|
| 72 |
+
Dieser Endpunkt wird von Vapi aufgerufen, BEVOR der Anruf startet (Server URL).
|
| 73 |
+
Hier prüfen wir das Datum und injizieren die Regel.
|
| 74 |
+
"""
|
| 75 |
+
try:
|
| 76 |
+
data = await request.json()
|
| 77 |
+
message_type = data.get("message", {}).get("type")
|
| 78 |
+
|
| 79 |
+
# Nur reagieren, wenn Vapi nach Konfiguration fragt
|
| 80 |
+
if message_type == "assistant-request":
|
| 81 |
+
print("📞 Eingehender Anruf (Setup) - Prüfe Verfügbarkeit...")
|
| 82 |
+
|
| 83 |
+
today_str = datetime.now().strftime("%Y-%m-%d")
|
| 84 |
+
context_text = ""
|
| 85 |
+
|
| 86 |
+
if db:
|
| 87 |
+
# Hole alle aktiven Regeln
|
| 88 |
+
rules = db.collection(COLLECTION_RULES).where("active", "==", True).stream()
|
| 89 |
+
for r in rules:
|
| 90 |
+
rd = r.to_dict()
|
| 91 |
+
# Prüfen: Ist heute im Zeitraum?
|
| 92 |
+
if rd.get('start_date') <= today_str <= rd.get('end_date'):
|
| 93 |
+
print(f"✅ REGEL AKTIV: {rd.get('name')}")
|
| 94 |
+
context_text = f"WICHTIGE VORGABE ZUR ERREICHBARKEIT: {rd.get('instruction_text')}"
|
| 95 |
+
break # Erste passende Regel gewinnt
|
| 96 |
+
|
| 97 |
+
if not context_text:
|
| 98 |
+
print("ℹ️ Keine Sonderregel für heute.")
|
| 99 |
+
|
| 100 |
+
# Antwort an Vapi: "Fülle die Variable {{seasonal_context}}!"
|
| 101 |
+
return {
|
| 102 |
+
"assistant": {
|
| 103 |
+
"variableValues": {
|
| 104 |
+
"seasonal_context": context_text
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
print(f"❌ Fehler im Vapi-Incoming Handler: {e}")
|
| 111 |
+
|
| 112 |
+
# Fallback / OK für andere Nachrichten
|
| 113 |
+
return {"status": "ok"}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ==========================================
|
| 117 |
+
# 2. OPTIMIERTE SUCHE (JETZT AUCH IM TEXT)
|
| 118 |
+
# ==========================================
|
| 119 |
@app.post("/search")
|
| 120 |
async def search_knowledge(request: Request):
|
| 121 |
try:
|
|
|
|
| 123 |
except:
|
| 124 |
return {"result": "Fehler: Kein JSON."}
|
| 125 |
|
| 126 |
+
# Query holen (egal ob direkt oder via Vapi Tool)
|
| 127 |
query_text = ""
|
| 128 |
if "query" in data: query_text = data["query"]
|
| 129 |
elif "message" in data:
|
| 130 |
try:
|
| 131 |
+
# Vapi schickt Argumente oft als JSON-String
|
| 132 |
args = data["message"]["toolCalls"][0]["function"]["arguments"]
|
| 133 |
+
if isinstance(args, str):
|
| 134 |
+
query_text = json.loads(args).get("search_query", "")
|
| 135 |
+
else:
|
| 136 |
+
query_text = args.get("search_query", "")
|
| 137 |
except: pass
|
| 138 |
+
|
| 139 |
+
# Fallback für dein altes Schema
|
| 140 |
if not query_text and "args" in data: query_text = data["args"].get("query", "")
|
| 141 |
|
| 142 |
print(f"🔎 FRAGE: '{query_text}'")
|
| 143 |
+
if not query_text: return {"result": "Ich habe die Frage akustisch nicht verstanden."}
|
|
|
|
| 144 |
|
|
|
|
| 145 |
best_doc = None
|
| 146 |
best_score = 0
|
| 147 |
|
|
|
|
| 148 |
query_words_raw = query_text.lower().replace("?", "").replace(".", "").split()
|
| 149 |
query_stems = [get_stem(w) for w in query_words_raw if len(w) > 2]
|
| 150 |
|
| 151 |
print(f" ⚙️ Suchst��mme: {query_stems}")
|
| 152 |
|
|
|
|
| 153 |
for entry in KNOWLEDGE_CACHE:
|
| 154 |
score = 0
|
|
|
|
| 155 |
t_answer = entry.get("answer", "")
|
| 156 |
t_question = entry.get("question", "")
|
| 157 |
t_keywords = entry.get("keywords", [])
|
| 158 |
|
| 159 |
+
if not t_answer: continue
|
| 160 |
|
| 161 |
+
# A) Keyword Treffer (Höchste Prio: 20 Pkt)
|
| 162 |
if isinstance(t_keywords, list):
|
| 163 |
for k in t_keywords:
|
| 164 |
k_stem = get_stem(k)
|
| 165 |
if k_stem in query_stems:
|
| 166 |
+
score += 20
|
|
|
|
| 167 |
|
| 168 |
+
# B) Frage/Titel Treffer (Mittlere Prio: 15 Pkt)
|
| 169 |
if t_question:
|
| 170 |
+
q_words = t_question.lower().split()
|
| 171 |
for qw in q_words:
|
| 172 |
if len(qw) < 3: continue
|
| 173 |
+
if get_stem(qw) in query_stems:
|
|
|
|
| 174 |
score += 15
|
| 175 |
|
| 176 |
+
# C) NEU: Antwort-Text Treffer (Niedrige Prio: 5 Pkt pro Wort)
|
| 177 |
+
# Damit findet er "Preis" auch, wenn es nur im Text steht!
|
| 178 |
+
a_words = t_answer.lower().split()
|
| 179 |
+
for aw in a_words:
|
| 180 |
+
if len(aw) < 3: continue
|
| 181 |
+
if get_stem(aw) in query_stems:
|
| 182 |
+
score += 5
|
| 183 |
+
|
| 184 |
+
# --- SCORING ENDE ---
|
| 185 |
+
|
| 186 |
if score > best_score:
|
| 187 |
best_score = score
|
| 188 |
best_doc = entry
|
| 189 |
+
if score > 15:
|
| 190 |
+
print(f" Candidate: {score} Pts -> {t_question}")
|
|
|
|
| 191 |
|
| 192 |
+
# --- ERGEBNIS ---
|
| 193 |
+
CONFIDENCE_THRESHOLD = 25 # Etwas gesenkt, damit er toleranter ist
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
if best_doc and best_score >= CONFIDENCE_THRESHOLD:
|
| 196 |
+
print(f"🏆 GEWINNER: {best_score} Pkt | {best_doc.get('question')}")
|
| 197 |
return {"result": best_doc['answer']}
|
| 198 |
|
| 199 |
+
# Fallback: Low Confidence Loggen
|
| 200 |
else:
|
| 201 |
+
print(f"⚠️ LOW CONFIDENCE ({best_score}).")
|
| 202 |
+
if db and best_doc: # Wenn er zumindest irgendwas gefunden hat (z.B. Score 10)
|
| 203 |
+
try:
|
| 204 |
db.collection(COLLECTION_INBOX).add({
|
| 205 |
"question": query_text,
|
| 206 |
+
"status": "review_needed",
|
| 207 |
+
"found_answer": best_doc['answer'],
|
| 208 |
+
"score": best_score,
|
| 209 |
+
"timestamp": firestore.SERVER_TIMESTAMP
|
| 210 |
})
|
| 211 |
+
except: pass
|
| 212 |
+
|
| 213 |
+
return {"result": "Dazu habe ich leider keine genauen Informationen in meiner Datenbank."}
|
| 214 |
+
|
| 215 |
+
@app.get("/")
|
| 216 |
+
def home():
|
| 217 |
+
return {"status": "Udo API Online", "docs": len(KNOWLEDGE_CACHE)}
|
| 218 |
+
|
| 219 |
+
@app.get("/refresh_knowledge")
|
| 220 |
+
def refresh_endpoint():
|
| 221 |
+
count = reload_knowledge()
|
| 222 |
+
return {"status": "Cache aktualisiert", "docs_loaded": count}
|