Jodinho commited on
Commit
c60f0be
·
1 Parent(s): 7021daf

feat: migrate to Langfuse observability

Browse files
.gitignore CHANGED
@@ -71,4 +71,6 @@ Frontend_API_Updates.md
71
  *.npy
72
  section_titles.json
73
  test_rpcs.py
74
- test_api.py
 
 
 
71
  *.npy
72
  section_titles.json
73
  test_rpcs.py
74
+ test_api.py
75
+ misc
76
+ test_langfuse.py
main.py CHANGED
@@ -1,13 +1,28 @@
1
  import os
2
- import numpy as np
3
  from fastapi import FastAPI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from pydantic import BaseModel
6
  from sentence_transformers import SentenceTransformer
 
7
  from groq import Groq
8
- from dotenv import load_dotenv
9
  from kb_docs import KB_DOCS
10
-
11
  load_dotenv()
12
  app = FastAPI()
13
 
 
1
  import os
2
+ import uvicorn
3
  from fastapi import FastAPI
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ from langfuse import get_client
9
+ from openinference.instrumentation.groq import GroqInstrumentor
10
+
11
+ # Initialize Langfuse client and verify connectivity
12
+ langfuse = get_client()
13
+ if not langfuse.auth_check():
14
+ print("WARNING: Langfuse auth failed - check your keys ✋")
15
+
16
+ # Initialize OpenTelemetry instrumentation for Groq
17
+ GroqInstrumentor().instrument()
18
+
19
+ from services.observability import setup_logger
20
  from fastapi.middleware.cors import CORSMiddleware
21
  from pydantic import BaseModel
22
  from sentence_transformers import SentenceTransformer
23
+ import numpy as np
24
  from groq import Groq
 
25
  from kb_docs import KB_DOCS
 
26
  load_dotenv()
27
  app = FastAPI()
28
 
requirements.txt CHANGED
@@ -7,4 +7,6 @@ python-dotenv==1.0.1
7
  supabase==2.7.4
8
  appwrite==22.2.0
9
  pydantic==2.13.4
10
- httpx==0.27.2
 
 
 
7
  supabase==2.7.4
8
  appwrite==22.2.0
9
  pydantic==2.13.4
10
+ httpx==0.27.2
11
+ langfuse>=2.0.0
12
+ openinference-instrumentation-groq
routes/real_estate_chat.py CHANGED
@@ -5,7 +5,6 @@ from pydantic import BaseModel
5
 
6
  from services.groq_service import process_chat_message
7
  from services.rate_limiter import limiter
8
- from services.observability import log_conversation_turn, ConversationTurn
9
 
10
  router = APIRouter(prefix="/api/v1/real-estate", tags=["Real Estate Intelligence Layer"])
11
 
@@ -21,7 +20,7 @@ class ChatResponse(BaseModel):
21
  suggested_actions: Optional[List[str]] = []
22
 
23
  @router.post("/chat", response_model=ChatResponse)
24
- async def handle_real_estate_chat(payload: ChatRequest, request: Request, background_tasks: BackgroundTasks):
25
  # Enforce token bucket rate limiting by session ID or client IP
26
  client_key = payload.session_id or request.client.host
27
  limiter.check_rate_limit(client_key)
@@ -37,16 +36,7 @@ async def handle_real_estate_chat(payload: ChatRequest, request: Request, backgr
37
  reply="Unable to reach the intelligence layer. Please try again shortly.",
38
  path_used="ERROR",
39
  tools_called=[],
40
- suggested_actions=[]
41
  )
42
-
43
- if "telemetry" in result:
44
- try:
45
- turn_obj = ConversationTurn(**result["telemetry"])
46
- background_tasks.add_task(log_conversation_turn, turn_obj)
47
- except Exception as e:
48
- # Optionally log validation errors
49
- pass
50
 
51
  return ChatResponse(
52
  reply=result["reply"],
 
5
 
6
  from services.groq_service import process_chat_message
7
  from services.rate_limiter import limiter
 
8
 
9
  router = APIRouter(prefix="/api/v1/real-estate", tags=["Real Estate Intelligence Layer"])
10
 
 
20
  suggested_actions: Optional[List[str]] = []
21
 
22
  @router.post("/chat", response_model=ChatResponse)
23
+ async def handle_real_estate_chat(payload: ChatRequest, request: Request):
24
  # Enforce token bucket rate limiting by session ID or client IP
25
  client_key = payload.session_id or request.client.host
26
  limiter.check_rate_limit(client_key)
 
36
  reply="Unable to reach the intelligence layer. Please try again shortly.",
37
  path_used="ERROR",
38
  tools_called=[],
 
39
  )
 
 
 
 
 
 
 
 
40
 
41
  return ChatResponse(
42
  reply=result["reply"],
services/groq_service.py CHANGED
@@ -7,8 +7,10 @@ from groq import Groq
7
  from services.tools import REAL_ESTATE_TOOLS
8
  from services.supabase_service import execute_tool_rpc, search_methodology_rag
9
  from services.embedding_service import get_embedding_model
10
- from services.observability import setup_logger, ConversationTurn, RoutingDecision, EmbeddingsInfo, ToolExecution
11
  from config import GROQ_API_KEY
 
 
12
 
13
  logger = setup_logger(__name__)
14
 
@@ -65,14 +67,16 @@ OPERATIONAL RULES:
65
  ```
66
  """
67
 
 
68
  async def process_chat_message(user_query: str, session_id: str, session_context: dict) -> dict:
69
  global section_title_embeddings, section_titles
70
  start_time = time.time()
71
 
72
- embeddings_info = EmbeddingsInfo()
73
-
74
- if session_id not in session_history:
75
- session_history[session_id] = []
 
76
 
77
  # STEP 1: Pre-Router Local Vector Search
78
  pre_check_hint = ""
@@ -91,9 +95,8 @@ async def process_chat_message(user_query: str, session_id: str, session_context
91
  matched_titles = [section_titles[i] for i in top_idx if sims[i] >= 0.45]
92
 
93
  if matched_titles:
94
- embeddings_info.matched_section_titles = matched_titles
95
- embeddings_info.similarity_scores = [float(sims[i]) for i in top_idx if sims[i] >= 0.45]
96
  pre_check_hint = f"\n\nLocal Methodology Pre-Check: High similarity match with section titles: {matched_titles}. Consider classifying as PATH_B or BOTH."
 
97
 
98
  router_sys_prompt = ROUTER_PROMPT + pre_check_hint
99
  router_messages = [{"role": "system", "content": router_sys_prompt}]
@@ -122,42 +125,28 @@ async def process_chat_message(user_query: str, session_id: str, session_context
122
  if classification not in ["OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING"]:
123
  classification = "PATH_A"
124
 
125
- routing_decision = RoutingDecision(classification=classification, reason=reason)
126
  snapshot_history = session_history[session_id].copy() if session_id in session_history else []
127
 
128
- def build_telemetry(reply_text: str, tools_exec: list, docs: list, suggested: list) -> dict:
129
- return ConversationTurn(
130
- session_id=session_id,
131
- user_message=user_query,
132
- model_response=reply_text,
133
- time_taken_ms=int((time.time() - start_time) * 1000),
134
- routing_decision=routing_decision,
135
- embeddings_info=embeddings_info,
136
- docs_retrieved=docs,
137
- tools_executed=tools_exec,
138
- history_context=snapshot_history,
139
- suggested_actions=suggested
140
- ).model_dump()
141
-
142
  # Guardrail: Immediate short-circuit if Out of Scope
143
  if classification == "OUT_OF_SCOPE":
144
  reply_out = "I apologize, but I am currently scoped exclusively to the Real Estate Rate Monitor page. I cannot assist with other topics like lead generation, pricing automation, or general knowledge outside of real estate data."
 
145
  return {
146
  "reply": reply_out,
147
  "path_used": "OUT_OF_SCOPE",
148
  "tools_called": [],
149
- "suggested_actions": [],
150
- "telemetry": build_telemetry(reply_out, [], [], [])
151
  }
152
 
153
  if classification == "GREETING":
154
  reply_greeting = "Hello! I'm the Joule Dynamics Real Estate Intelligence Assistant. I can help you with rate spikes, market trends, and availability data. How can I assist you today?"
 
155
  return {
156
  "reply": reply_greeting,
157
  "path_used": "GREETING",
158
  "tools_called": [],
159
- "suggested_actions": [],
160
- "telemetry": build_telemetry(reply_greeting, [], [], [])
161
  }
162
 
163
  tool_results = []
@@ -209,7 +198,7 @@ async def process_chat_message(user_query: str, session_id: str, session_context
209
  else:
210
  db_result = await execute_tool_rpc(func_name, func_args)
211
 
212
- tool_results.append(ToolExecution(tool_name=func_name, args=func_args, db_response=db_result))
213
 
214
  messages.append({
215
  "tool_call_id": tool_call.id,
@@ -248,10 +237,11 @@ async def process_chat_message(user_query: str, session_id: str, session_context
248
  except json.JSONDecodeError:
249
  pass
250
 
 
 
251
  return {
252
  "reply": final_reply,
253
  "path_used": classification,
254
- "tools_called": [{"tool": t.tool_name, "args": t.args} for t in tool_results],
255
- "suggested_actions": suggested_actions,
256
- "telemetry": build_telemetry(final_reply, tool_results, rag_chunks, suggested_actions)
257
  }
 
7
  from services.tools import REAL_ESTATE_TOOLS
8
  from services.supabase_service import execute_tool_rpc, search_methodology_rag
9
  from services.embedding_service import get_embedding_model
10
+ from services.observability import setup_logger
11
  from config import GROQ_API_KEY
12
+ from langfuse import observe, propagate_attributes, get_client
13
+ import langfuse
14
 
15
  logger = setup_logger(__name__)
16
 
 
67
  ```
68
  """
69
 
70
+ @observe(name="process-chat")
71
  async def process_chat_message(user_query: str, session_id: str, session_context: dict) -> dict:
72
  global section_title_embeddings, section_titles
73
  start_time = time.time()
74
 
75
+ with propagate_attributes(session_id=session_id, tags=["real-estate-chat"]):
76
+ get_client().update_current_span(input=user_query)
77
+
78
+ if session_id not in session_history:
79
+ session_history[session_id] = []
80
 
81
  # STEP 1: Pre-Router Local Vector Search
82
  pre_check_hint = ""
 
95
  matched_titles = [section_titles[i] for i in top_idx if sims[i] >= 0.45]
96
 
97
  if matched_titles:
 
 
98
  pre_check_hint = f"\n\nLocal Methodology Pre-Check: High similarity match with section titles: {matched_titles}. Consider classifying as PATH_B or BOTH."
99
+ get_client().update_current_span(metadata={"matched_section_titles": matched_titles, "similarity_scores": [float(sims[i]) for i in top_idx if sims[i] >= 0.45]})
100
 
101
  router_sys_prompt = ROUTER_PROMPT + pre_check_hint
102
  router_messages = [{"role": "system", "content": router_sys_prompt}]
 
125
  if classification not in ["OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING"]:
126
  classification = "PATH_A"
127
 
128
+ get_client().update_current_span(metadata={"classification": classification, "reason": reason})
129
  snapshot_history = session_history[session_id].copy() if session_id in session_history else []
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  # Guardrail: Immediate short-circuit if Out of Scope
132
  if classification == "OUT_OF_SCOPE":
133
  reply_out = "I apologize, but I am currently scoped exclusively to the Real Estate Rate Monitor page. I cannot assist with other topics like lead generation, pricing automation, or general knowledge outside of real estate data."
134
+ get_client().update_current_span(output=reply_out)
135
  return {
136
  "reply": reply_out,
137
  "path_used": "OUT_OF_SCOPE",
138
  "tools_called": [],
139
+ "suggested_actions": []
 
140
  }
141
 
142
  if classification == "GREETING":
143
  reply_greeting = "Hello! I'm the Joule Dynamics Real Estate Intelligence Assistant. I can help you with rate spikes, market trends, and availability data. How can I assist you today?"
144
+ get_client().update_current_span(output=reply_greeting)
145
  return {
146
  "reply": reply_greeting,
147
  "path_used": "GREETING",
148
  "tools_called": [],
149
+ "suggested_actions": []
 
150
  }
151
 
152
  tool_results = []
 
198
  else:
199
  db_result = await execute_tool_rpc(func_name, func_args)
200
 
201
+ tool_results.append({"tool": func_name, "args": func_args})
202
 
203
  messages.append({
204
  "tool_call_id": tool_call.id,
 
237
  except json.JSONDecodeError:
238
  pass
239
 
240
+ get_client().update_current_span(output=final_reply)
241
+
242
  return {
243
  "reply": final_reply,
244
  "path_used": classification,
245
+ "tools_called": tool_results,
246
+ "suggested_actions": suggested_actions
 
247
  }
services/observability.py CHANGED
@@ -4,75 +4,30 @@ import logging
4
  from logging.handlers import RotatingFileHandler
5
  from datetime import datetime, timezone
6
  from typing import Any, Dict, List, Optional
7
- from pydantic import BaseModel, Field
8
 
9
- # Ensure logs directory exists
10
- LOGS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
11
- os.makedirs(LOGS_DIR, exist_ok=True)
12
-
13
- # -----------------------------------------------------------------------------
14
- # General Project Logger Setup
15
- # -----------------------------------------------------------------------------
16
  def setup_logger(name: str) -> logging.Logger:
17
- """Creates a rotating file logger for general project traceability."""
 
 
 
18
  logger = logging.getLogger(name)
 
19
  if not logger.handlers:
20
  logger.setLevel(logging.INFO)
21
 
22
- # Rotating File Handler (Max 5MB, keep 3 backups)
23
- log_file = os.path.join(LOGS_DIR, "app.log")
24
- file_handler = RotatingFileHandler(log_file, maxBytes=5 * 1024 * 1024, backupCount=3)
 
 
 
 
 
 
25
 
26
- # Console Handler
27
- console_handler = logging.StreamHandler()
28
-
29
  formatter = logging.Formatter('%(asctime)s | %(name)s | %(levelname)s | %(message)s')
30
  file_handler.setFormatter(formatter)
31
- console_handler.setFormatter(formatter)
32
-
33
  logger.addHandler(file_handler)
34
- logger.addHandler(console_handler)
35
 
36
  return logger
37
-
38
- # -----------------------------------------------------------------------------
39
- # JSONL Conversation Telemetry Setup
40
- # -----------------------------------------------------------------------------
41
- class RoutingDecision(BaseModel):
42
- classification: str
43
- reason: Optional[str] = None
44
-
45
- class EmbeddingsInfo(BaseModel):
46
- matched_section_titles: List[str] = Field(default_factory=list)
47
- similarity_scores: List[float] = Field(default_factory=list)
48
-
49
- class ToolExecution(BaseModel):
50
- tool_name: str
51
- args: Dict[str, Any]
52
- db_response: Any
53
-
54
- class ConversationTurn(BaseModel):
55
- timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
56
- session_id: str
57
- user_message: str
58
- model_response: str
59
- time_taken_ms: int
60
- routing_decision: RoutingDecision
61
- embeddings_info: EmbeddingsInfo
62
- docs_retrieved: List[str] = Field(default_factory=list)
63
- tools_executed: List[ToolExecution] = Field(default_factory=list)
64
- history_context: List[Dict[str, str]] = Field(default_factory=list)
65
- suggested_actions: List[str] = Field(default_factory=list)
66
-
67
- def log_conversation_turn(turn: ConversationTurn):
68
- """
69
- Appends a structured JSON string to the conversations.jsonl file.
70
- Designed to be run via FastAPI BackgroundTasks.
71
- """
72
- jsonl_file = os.path.join(LOGS_DIR, "conversations.jsonl")
73
- try:
74
- with open(jsonl_file, "a", encoding="utf-8") as f:
75
- f.write(turn.model_dump_json() + "\n")
76
- except Exception as e:
77
- logger = setup_logger(__name__)
78
- logger.error(f"Failed to write conversation telemetry to JSONL: {e}")
 
4
  from logging.handlers import RotatingFileHandler
5
  from datetime import datetime, timezone
6
  from typing import Any, Dict, List, Optional
 
7
 
 
 
 
 
 
 
 
8
  def setup_logger(name: str) -> logging.Logger:
9
+ """
10
+ Creates and returns a standard Python logger that writes to a rotating file.
11
+ Max file size: 5MB. Keeps up to 3 backups.
12
+ """
13
  logger = logging.getLogger(name)
14
+
15
  if not logger.handlers:
16
  logger.setLevel(logging.INFO)
17
 
18
+ log_dir = "logs"
19
+ if not os.path.exists(log_dir):
20
+ os.makedirs(log_dir)
21
+
22
+ file_handler = RotatingFileHandler(
23
+ os.path.join(log_dir, "app.log"),
24
+ maxBytes=5 * 1024 * 1024,
25
+ backupCount=3
26
+ )
27
 
 
 
 
28
  formatter = logging.Formatter('%(asctime)s | %(name)s | %(levelname)s | %(message)s')
29
  file_handler.setFormatter(formatter)
30
+
 
31
  logger.addHandler(file_handler)
 
32
 
33
  return logger