refactor: split groq_service into focused modules + SSE streaming
Browse filesArchitecture improvements (fixes #1-#8 from ai_architecture_review):
New modules:
- services/groq_client.py - Groq SDK + retry w/ exp backoff + jitter (#7)
- services/prompts.py - Prompt composition by classification (#3)
- services/session.py - Session history w/ TTL eviction + write-trim (#6)
- services/tool_compressor.py - compress_tool_output + geocode handler
- services/tool_executor.py - Tool dispatch + normalize_assistant_message (#5)
- services/agent_loop.py - Agentic loop (max 4 rounds) + SSE streaming (#1)
- services/groq_service.py - Thin facade re-exporting public functions
Fixes:
- #1: True agentic tool loop (finish_reason=stop ends loop, not fixed 1-call)
- #2: Universal failed_generation recovery for any tool, not just exports
- #3: Prompt composition - PATH_A gets ~900 fewer tokens than monolith
- #4: Router now uses small fast model (llama-3.1-8b-instant) by default
- #5: normalize_assistant_message() strips reasoning field from history
- #6: Session history trimmed at write time + 1hr TTL eviction
- #7: Exponential backoff with jitter for 429/5xx errors
- #8: Structural errors (400) not retried with fallback model
New endpoint:
- POST /api/v1/real-estate/chat/stream (SSE)
Events: status | tool_call | token | done | error
Updated:
- config.py: GROQ_ROUTE_MODEL defaults to llama-3.1-8b-instant
- routes/v1/real_estate.py: SSE endpoint + shared exception handler
- misc/Frontend_API_Updates.md: Full SSE integration guide v2
- docs/ai_architecture_review.md: Production architecture review
- config.py +13 -7
- docs/ai_architecture_review.md +463 -0
- routes/v1/real_estate.py +170 -62
- services/agent_loop.py +523 -0
- services/groq_client.py +132 -0
- services/groq_service.py +16 -614
- services/prompts.py +124 -0
- services/session.py +59 -0
- services/tool_compressor.py +160 -0
- services/tool_executor.py +149 -0
|
@@ -4,18 +4,24 @@ from dotenv import load_dotenv
|
|
| 4 |
load_dotenv()
|
| 5 |
|
| 6 |
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
GROQ_SYNTHESIS_MODEL = os.environ.get("GROQ_SYNTHESIS_MODEL", "openai/gpt-oss-120b")
|
| 10 |
GROQ_FALLBACK_SYNTHESIS_MODEL = os.environ.get("GROQ_FALLBACK_SYNTHESIS_MODEL", "openai/gpt-oss-20b")
|
|
|
|
| 11 |
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
|
| 12 |
SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
|
| 13 |
|
| 14 |
-
APP_WRITE_PROJECT_ID
|
| 15 |
APP_WRITE_API_ENDPOINT = os.environ.get("APP_WRITE_API_ENDPOINT", "")
|
| 16 |
-
APP_WRITE_API_KEY
|
| 17 |
-
APP_WRITE_BUCKET_ID
|
| 18 |
|
| 19 |
MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "")
|
| 20 |
-
CONTACT_EMAIL
|
| 21 |
-
CONTACT_WHATSAPP
|
|
|
|
| 4 |
load_dotenv()
|
| 5 |
|
| 6 |
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 7 |
+
|
| 8 |
+
# ββ Routing models (Fix #4: Use small, fast non-reasoning model for classification)
|
| 9 |
+
# Default: llama-3.1-8b-instant (~120ms, sufficient for 6-class text classification)
|
| 10 |
+
GROQ_ROUTE_MODEL = os.environ.get("GROQ_ROUTE_MODEL", "llama-3.1-8b-instant")
|
| 11 |
+
GROQ_FALLBACK_ROUTE_MODEL = os.environ.get("GROQ_FALLBACK_ROUTE_MODEL", "llama-3.1-70b-versatile")
|
| 12 |
+
|
| 13 |
+
# ββ Synthesis models (reasoning models for multi-step data retrieval)
|
| 14 |
GROQ_SYNTHESIS_MODEL = os.environ.get("GROQ_SYNTHESIS_MODEL", "openai/gpt-oss-120b")
|
| 15 |
GROQ_FALLBACK_SYNTHESIS_MODEL = os.environ.get("GROQ_FALLBACK_SYNTHESIS_MODEL", "openai/gpt-oss-20b")
|
| 16 |
+
|
| 17 |
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
|
| 18 |
SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
|
| 19 |
|
| 20 |
+
APP_WRITE_PROJECT_ID = os.environ.get("APP_WRITE_PROJECT_ID", "")
|
| 21 |
APP_WRITE_API_ENDPOINT = os.environ.get("APP_WRITE_API_ENDPOINT", "")
|
| 22 |
+
APP_WRITE_API_KEY = os.environ.get("APP_WRITE_API_KEY", "")
|
| 23 |
+
APP_WRITE_BUCKET_ID = os.environ.get("APP_WRITE_BUCKET_ID", "")
|
| 24 |
|
| 25 |
MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "")
|
| 26 |
+
CONTACT_EMAIL = os.environ.get("CONTACT_EMAIL", "hello@example.com")
|
| 27 |
+
CONTACT_WHATSAPP = os.environ.get("CONTACT_WHATSAPP", "1234567890")
|
|
@@ -0,0 +1,463 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pulse AI β Production Architecture Review
|
| 2 |
+
**System:** Joule Dynamics Real Estate Intelligence Layer (Pulse AI / SentimentScope)**
|
| 3 |
+
**Trace:** `trace-a4448bd199b8a371b18faa20b77fbba1`
|
| 4 |
+
**User Turn:** `"What do you think about today's rates?"`
|
| 5 |
+
**Review Date:** 2026-08-21
|
| 6 |
+
**Reviewer:** AI Systems Architecture Analysis
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## π§ Quick Summary (Plain-English First)
|
| 11 |
+
|
| 12 |
+
Imagine Pulse AI as a three-person relay race team:
|
| 13 |
+
1. **The Classifier (Router)** β reads the user's question and decides which department to forward it to.
|
| 14 |
+
2. **The Brain (Main LLM)** β decides which database tool to call to fetch the answer.
|
| 15 |
+
3. **The Synthesizer (Same Brain, second call)** β takes the data back from the database and writes the final user-readable reply.
|
| 16 |
+
|
| 17 |
+
In this trace, the race collapsed at the handoff from step 2 β step 3. The Brain fetched data for Miami, then in step 3 tried to _also_ fetch NYC/NJ data β but the second API call didn't include the "tools" parameter, so Groq rejected it with a 400 error. Both fallback attempts also failed. The user got the worst possible outcome: a generic fallback message instead of a real answer.
|
| 18 |
+
|
| 19 |
+
Below is the full post-mortem across every dimension of the system.
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## π Trace Execution Timeline
|
| 24 |
+
|
| 25 |
+
| Obs | Type | Model | Latency | Status | Action |
|
| 26 |
+
|---|---|---|---|---|---|
|
| 27 |
+
| 1 | SPAN | β | 3,355ms | β
OK | `process-chat` root span |
|
| 28 |
+
| 2 | GENERATION | `gpt-oss-20b` | 484ms | β
OK | Router classified β `PATH_A` |
|
| 29 |
+
| 3 | GENERATION | `gpt-oss-120b` | 707ms | β
OK | Brain called `get_market_averages(Miami)` |
|
| 30 |
+
| 4 | GENERATION | `gpt-oss-120b` | 398ms | β 400 | Synthesis attempted second tool call β REJECTED |
|
| 31 |
+
| 5 | GENERATION | `gpt-oss-20b` | 334ms | β 400 | Fallback synthesis also attempted tool call β REJECTED |
|
| 32 |
+
|
| 33 |
+
**Total wall-clock time: 3.3 seconds.** The user waited 3.3 seconds to receive a generic, unhelpful fallback message.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## Issue #1: The Synthesis Call Has No Tool Schema
|
| 38 |
+
|
| 39 |
+
### Issue Identified & Exact Trace Location
|
| 40 |
+
|
| 41 |
+
**Trace Location:** Observations 4 and 5.
|
| 42 |
+
|
| 43 |
+
```
|
| 44 |
+
BadRequestError: Error code: 400 - {
|
| 45 |
+
'error': {
|
| 46 |
+
'message': 'Tool choice is none, but model called a tool',
|
| 47 |
+
'type': 'invalid_request_error',
|
| 48 |
+
'code': 'tool_use_failed',
|
| 49 |
+
'failed_generation': '{"name": "get_market_averages", "arguments": {"p_market":"NYC/NJ Metro"}}'
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
After fetching Miami rates (Observation 3), the synthesis call at Observation 4 was made **without any `tools` parameter** (`tool_choice` was effectively `none`). However, `gpt-oss-120b` β seeing the tool-enriched conversation history β wanted to call `get_market_averages` a second time for NYC/NJ Metro (its internal reasoning said: "I need both markets to compare"). Since the second call had no tool definitions, Groq rejected it.
|
| 55 |
+
|
| 56 |
+
**Code path in `groq_service.py` (line 554β563):**
|
| 57 |
+
```python
|
| 58 |
+
# Second Brain Call for final response synthesis
|
| 59 |
+
for model_candidate in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
|
| 60 |
+
try:
|
| 61 |
+
final_res = groq_client.chat.completions.create(
|
| 62 |
+
model=model_candidate,
|
| 63 |
+
messages=messages, # history includes tool calls, but...
|
| 64 |
+
temperature=0.2,
|
| 65 |
+
max_tokens=1000,
|
| 66 |
+
# NO `tools=` parameter! Model CANNOT call tools here.
|
| 67 |
+
)
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Underlying Mechanism & Root Cause
|
| 71 |
+
|
| 72 |
+
The model was given a conversation history that clearly shows a tool-enriched task context (it already made one tool call, got data back). A reasoning model (`gpt-oss-120b`) continues its chain-of-thought from previous output β and since it got Miami but not NYC/NJ, it logically wants to call the tool again. **But the synthesis call removes all tool definitions.** The model sees the tool call structure in history, tries to replicate it, and Groq rejects it because no tool definitions exist in this call.
|
| 73 |
+
|
| 74 |
+
The root cause is an architectural assumption: **"the model will synthesize after one tool call."** Reality: reasoning models may want multiple tool calls to complete an analytical task.
|
| 75 |
+
|
| 76 |
+
### Architectural Anti-Pattern Analysis
|
| 77 |
+
|
| 78 |
+
This is the **"One-Shot Tool Execution" anti-pattern** β assuming a single tool call is always sufficient, then stripping tool access from the synthesis step. Problems:
|
| 79 |
+
|
| 80 |
+
- **Brittleness:** Any vague query ("What do you think about rates?") causes the model to want both markets. The system was not designed to handle multi-round tool needs.
|
| 81 |
+
- **Reasoning model mismatch:** `gpt-oss-120b` is a reasoning model. It computes a multi-step plan internally. Cutting it off mid-plan causes it to leak its next action into the output.
|
| 82 |
+
- **Dual 400 failures waste ~730ms** β both model candidates fail on the exact same structural problem (no tools in synthesis), burning tokens and time before falling back.
|
| 83 |
+
|
| 84 |
+
### Hardened Production Solution
|
| 85 |
+
|
| 86 |
+
**Implement a true agentic loop with a max_iterations guard:**
|
| 87 |
+
|
| 88 |
+
```python
|
| 89 |
+
# HARDENED: Agentic Tool Loop (up to N tool rounds)
|
| 90 |
+
MAX_TOOL_ROUNDS = 4
|
| 91 |
+
tool_rounds = 0
|
| 92 |
+
final_reply = None
|
| 93 |
+
|
| 94 |
+
while tool_rounds < MAX_TOOL_ROUNDS:
|
| 95 |
+
tool_rounds += 1
|
| 96 |
+
|
| 97 |
+
response = await _groq_call_with_retry(
|
| 98 |
+
messages=messages,
|
| 99 |
+
tools=active_tools, # ALWAYS pass tools while in agentic loop
|
| 100 |
+
tool_choice="auto",
|
| 101 |
+
max_tokens=600,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
if response.choices[0].finish_reason == "stop":
|
| 105 |
+
# Model decided to respond directly β synthesis complete
|
| 106 |
+
final_reply = response.choices[0].message.content
|
| 107 |
+
break
|
| 108 |
+
|
| 109 |
+
elif response.choices[0].finish_reason == "tool_calls":
|
| 110 |
+
# Execute all tool calls in this round, append results, continue loop
|
| 111 |
+
response_message = response.choices[0].message
|
| 112 |
+
messages.append(normalize_assistant_message(response_message))
|
| 113 |
+
|
| 114 |
+
for tool_call in response_message.tool_calls:
|
| 115 |
+
result = await execute_tool_by_name(tool_call.function.name,
|
| 116 |
+
json.loads(tool_call.function.arguments))
|
| 117 |
+
messages.append({
|
| 118 |
+
"tool_call_id": tool_call.id,
|
| 119 |
+
"role": "tool",
|
| 120 |
+
"name": tool_call.function.name,
|
| 121 |
+
"content": compress_tool_output(tool_call.function.name, result),
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
if not final_reply:
|
| 125 |
+
# Exhausted max rounds β force a synthesis-only call
|
| 126 |
+
messages.append({
|
| 127 |
+
"role": "system",
|
| 128 |
+
"content": "Synthesize the real estate data gathered above into a concise Markdown reply. Do NOT call any more tools."
|
| 129 |
+
})
|
| 130 |
+
final_reply = (await _groq_call_with_retry(
|
| 131 |
+
messages=messages, tools=None, tool_choice="none", max_tokens=1000,
|
| 132 |
+
)).choices[0].message.content
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
**Key design changes:**
|
| 136 |
+
- Tools stay in the loop until the model itself chooses to stop calling them (`finish_reason == "stop"`).
|
| 137 |
+
- `MAX_TOOL_ROUNDS = 4` prevents infinite agent loops.
|
| 138 |
+
- Only the **final** synthesis call (after `stop`) strips tools β because by then the model has genuinely finished gathering data.
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## Issue #2: The Recovery Handler Ignores Non-Export Tool Calls
|
| 143 |
+
|
| 144 |
+
### Issue Identified & Exact Trace Location
|
| 145 |
+
|
| 146 |
+
**Code path in `groq_service.py` (lines 565β580):**
|
| 147 |
+
```python
|
| 148 |
+
except Exception as synth_err:
|
| 149 |
+
...
|
| 150 |
+
target_fn, fn_args = parse_failed_generation(failed_gen)
|
| 151 |
+
if target_fn == "generate_data_export" and isinstance(fn_args, dict):
|
| 152 |
+
# Only handles generate_data_export!
|
| 153 |
+
# All other tools (get_market_averages, etc.) are silently dropped.
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
The `failed_generation` in this trace was `get_market_averages(p_market: "NYC/NJ Metro")` β not `generate_data_export`. The recovery handler **only handles export tools**, so the NYC data fetch was silently discarded and the user got the fallback message.
|
| 157 |
+
|
| 158 |
+
### Hardened Production Solution
|
| 159 |
+
|
| 160 |
+
**Universal tool recovery in synthesis failure:**
|
| 161 |
+
```python
|
| 162 |
+
target_fn, fn_args = parse_failed_generation(failed_gen)
|
| 163 |
+
if target_fn and isinstance(fn_args, dict):
|
| 164 |
+
# Re-execute whichever tool the model wanted to call
|
| 165 |
+
tool_result = await execute_tool_by_name(target_fn, fn_args)
|
| 166 |
+
messages.append({
|
| 167 |
+
"tool_call_id": f"recov_{int(time.time())}",
|
| 168 |
+
"role": "tool",
|
| 169 |
+
"name": target_fn,
|
| 170 |
+
"content": compress_tool_output(target_fn, tool_result),
|
| 171 |
+
})
|
| 172 |
+
# Retry synthesis with the enriched context, tools stripped
|
| 173 |
+
final_res = await _groq_call_with_retry(messages=messages, tools=None)
|
| 174 |
+
final_reply = final_res.choices[0].message.content
|
| 175 |
+
break
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## Issue #3: Prompt Size β 3,535 Tokens Just to Fetch Rates
|
| 181 |
+
|
| 182 |
+
### Issue Identified & Exact Trace Location
|
| 183 |
+
|
| 184 |
+
**Observation 3 (Brain Call):** `prompt_tokens: 3,535`
|
| 185 |
+
|
| 186 |
+
The entire SYNTHESIS_PROMPT is sent on every single request, even for trivially simple queries like "What do you think about today's rates?" β which only needs `get_market_averages`. The system prompt includes sections only relevant to other paths:
|
| 187 |
+
|
| 188 |
+
### Token Economy Breakdown
|
| 189 |
+
|
| 190 |
+
| Prompt Section | Est. Tokens | Needed for PATH_A? |
|
| 191 |
+
|---|---|---|
|
| 192 |
+
| Identity + Immutable Boundaries | ~180 | β
Yes |
|
| 193 |
+
| Operational Rules (no SQL, no guessing) | ~120 | β
Yes |
|
| 194 |
+
| Advisory format instructions | ~80 | β
Yes |
|
| 195 |
+
| Dashboard UI visual guidance section | ~400 | β No (PATH_B only) |
|
| 196 |
+
| Commercial Handoff 5-step protocol | ~250 | β No |
|
| 197 |
+
| Report/Export workflow | ~180 | β No |
|
| 198 |
+
| Conversation Recap instructions | ~80 | β No |
|
| 199 |
+
| **Wasteful tokens this turn** | **~910** | β **26% overhead** |
|
| 200 |
+
|
| 201 |
+
### Hardened Production Solution
|
| 202 |
+
|
| 203 |
+
**Prompt composition by classification:**
|
| 204 |
+
|
| 205 |
+
```python
|
| 206 |
+
PROMPT_CORE = """
|
| 207 |
+
You are Pulse AI, a Real Estate Intelligence Assistant for Joule Dynamics.
|
| 208 |
+
[Identity, immutable boundaries, operational rules β ~400 tokens]
|
| 209 |
+
"""
|
| 210 |
+
PROMPT_PATH_A = """
|
| 211 |
+
ADVISORY FORMAT: Present data using the two-part structure:
|
| 212 |
+
1. What the data shows (facts from tools only)
|
| 213 |
+
2. Suggested approach (data-informed)
|
| 214 |
+
Close every advisory with: β οΈ This is a data-informed observation...
|
| 215 |
+
"""
|
| 216 |
+
PROMPT_PATH_B = """DASHBOARD UI GUIDANCE: ..."""
|
| 217 |
+
PROMPT_COMMERCIAL = """COMMERCIAL HANDOFFS: ..."""
|
| 218 |
+
PROMPT_REPORTS = """REPORT EXPORTS: ..."""
|
| 219 |
+
|
| 220 |
+
def build_system_prompt(classification: str) -> str:
|
| 221 |
+
prompt = PROMPT_CORE
|
| 222 |
+
if classification in ["PATH_A", "BOTH"]:
|
| 223 |
+
prompt += PROMPT_PATH_A + PROMPT_REPORTS
|
| 224 |
+
if classification in ["PATH_B", "BOTH"]:
|
| 225 |
+
prompt += PROMPT_PATH_B
|
| 226 |
+
if classification == "COMMERCIAL_HANDOFF":
|
| 227 |
+
prompt += PROMPT_COMMERCIAL
|
| 228 |
+
return prompt
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
**Savings: ~600β900 tokens per PATH_A turn β about 25% reduction per request.**
|
| 232 |
+
|
| 233 |
+
---
|
| 234 |
+
|
| 235 |
+
## Issue #4: The Router Uses an Overkill Reasoning Model
|
| 236 |
+
|
| 237 |
+
### Issue Identified & Exact Trace Location
|
| 238 |
+
|
| 239 |
+
**Observation 2:** 484ms latency, `gpt-oss-20b` (a reasoning model).
|
| 240 |
+
|
| 241 |
+
The routing output is simple JSON with 6 possible values. A 20B reasoning model spending 484ms on a 7-word sentence is expensive overkill.
|
| 242 |
+
|
| 243 |
+
### Architectural Anti-Pattern Analysis
|
| 244 |
+
|
| 245 |
+
**"Uniform Compute" anti-pattern** β same large expensive model used for all tasks regardless of cognitive complexity. Routing is a classification task, not a reasoning task.
|
| 246 |
+
|
| 247 |
+
### Hardened Production Solution
|
| 248 |
+
|
| 249 |
+
```python
|
| 250 |
+
# Use the smallest capable model for routing
|
| 251 |
+
GROQ_ROUTER_MODEL = "llama-3.1-8b-instant"
|
| 252 |
+
|
| 253 |
+
router_res = groq_client.chat.completions.create(
|
| 254 |
+
model=GROQ_ROUTER_MODEL,
|
| 255 |
+
messages=[{"role": "system", "content": ROUTER_PROMPT},
|
| 256 |
+
{"role": "user", "content": user_query}],
|
| 257 |
+
response_format={"type": "json_object"},
|
| 258 |
+
temperature=0.0,
|
| 259 |
+
max_tokens=80, # Classification output is always tiny
|
| 260 |
+
)
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
**Expected improvement:** Router latency ~120ms vs ~480ms. Token cost drops ~85%.
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## Issue #5: The `reasoning` Field Leaks Into Context History
|
| 268 |
+
|
| 269 |
+
### Issue Identified & Exact Trace Location
|
| 270 |
+
|
| 271 |
+
**Observation 4 Input β message at index 2 (actual text from trace):**
|
| 272 |
+
|
| 273 |
+
```
|
| 274 |
+
"ChatCompletionMessage(content=None, role='assistant', function_call=None,
|
| 275 |
+
tool_calls=[...], reasoning='We need to answer: \"What do you think about today\\'s rates?\"
|
| 276 |
+
This is a request for commentary on today\\'s rates. We need to provide data-informed
|
| 277 |
+
observation: get current rates. Could use get_market_averages for both markets...')"
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
The `response_message` object from the Brain call is appended directly to `messages` as a Python object, which includes the model's internal `reasoning` chain-of-thought via its `__repr__`. This reasoning text:
|
| 281 |
+
1. Wastes tokens in every subsequent API call.
|
| 282 |
+
2. Can contaminate the model's future reasoning by making it "remember" its own intermediate thoughts.
|
| 283 |
+
3. Accumulates across session turns if history is retained.
|
| 284 |
+
|
| 285 |
+
### Hardened Production Solution
|
| 286 |
+
|
| 287 |
+
**Always normalize message objects to dicts before appending:**
|
| 288 |
+
|
| 289 |
+
```python
|
| 290 |
+
def normalize_assistant_message(msg) -> dict:
|
| 291 |
+
"""Convert ChatCompletionMessage to a clean dict without internal fields."""
|
| 292 |
+
if hasattr(msg, 'tool_calls') and msg.tool_calls:
|
| 293 |
+
return {
|
| 294 |
+
"role": "assistant",
|
| 295 |
+
"content": msg.content or "",
|
| 296 |
+
"tool_calls": [
|
| 297 |
+
{
|
| 298 |
+
"id": tc.id,
|
| 299 |
+
"type": "function",
|
| 300 |
+
"function": {
|
| 301 |
+
"name": tc.function.name,
|
| 302 |
+
"arguments": tc.function.arguments,
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
for tc in msg.tool_calls
|
| 306 |
+
]
|
| 307 |
+
}
|
| 308 |
+
return {"role": "assistant", "content": msg.content or ""}
|
| 309 |
+
|
| 310 |
+
# Replace: messages.append(response_message)
|
| 311 |
+
# With: messages.append(normalize_assistant_message(response_message))
|
| 312 |
+
```
|
| 313 |
+
|
| 314 |
+
---
|
| 315 |
+
|
| 316 |
+
## Issue #6: Session History Is Unbounded In-Memory
|
| 317 |
+
|
| 318 |
+
### Issue Identified & Exact Trace Location
|
| 319 |
+
|
| 320 |
+
**`groq_service.py`:**
|
| 321 |
+
```python
|
| 322 |
+
session_history = defaultdict(list) # Global in-memory dict, grows forever
|
| 323 |
+
|
| 324 |
+
messages.extend(session_history[session_id][-6:]) # Cap at read time only
|
| 325 |
+
```
|
| 326 |
+
|
| 327 |
+
The cap of `[-6:]` only applies when **reading** β the underlying list still grows without bound, leaking memory across the process lifetime. On HuggingFace Spaces restart, all history is wiped silently.
|
| 328 |
+
|
| 329 |
+
### Hardened Production Solution
|
| 330 |
+
|
| 331 |
+
**Trim at write time + add TTL eviction:**
|
| 332 |
+
|
| 333 |
+
```python
|
| 334 |
+
MAX_HISTORY_MESSAGES = 10
|
| 335 |
+
SESSION_TTL_SECONDS = 3600
|
| 336 |
+
|
| 337 |
+
_sessions: dict[str, dict] = {}
|
| 338 |
+
|
| 339 |
+
def get_session(session_id: str) -> list:
|
| 340 |
+
now = time.time()
|
| 341 |
+
entry = _sessions.get(session_id)
|
| 342 |
+
if entry is None or (now - entry["ts"]) > SESSION_TTL_SECONDS:
|
| 343 |
+
_sessions[session_id] = {"messages": [], "ts": now}
|
| 344 |
+
else:
|
| 345 |
+
_sessions[session_id]["ts"] = now
|
| 346 |
+
return _sessions[session_id]["messages"]
|
| 347 |
+
|
| 348 |
+
def append_to_session(session_id: str, message: dict):
|
| 349 |
+
history = get_session(session_id)
|
| 350 |
+
history.append(message)
|
| 351 |
+
# Trim at write time β history list never grows beyond MAX
|
| 352 |
+
if len(history) > MAX_HISTORY_MESSAGES:
|
| 353 |
+
_sessions[session_id]["messages"] = history[-MAX_HISTORY_MESSAGES:]
|
| 354 |
+
```
|
| 355 |
+
|
| 356 |
+
---
|
| 357 |
+
|
| 358 |
+
## Issue #7: No Retry for 429/503 Transient Errors
|
| 359 |
+
|
| 360 |
+
### Issue Identified & Exact Trace Location
|
| 361 |
+
|
| 362 |
+
The current fallback logic only handles `400` structural errors. Rate limits (`429`), server overloads (`503`), and network timeouts are not retried β they cause immediate failure.
|
| 363 |
+
|
| 364 |
+
### Hardened Production Solution
|
| 365 |
+
|
| 366 |
+
```python
|
| 367 |
+
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
| 368 |
+
|
| 369 |
+
async def _groq_call_with_retry(model, messages, tools=None, tool_choice="none",
|
| 370 |
+
max_tokens=1000, temperature=0.2, max_retries=3):
|
| 371 |
+
for attempt in range(max_retries):
|
| 372 |
+
try:
|
| 373 |
+
kwargs = dict(model=model, messages=messages,
|
| 374 |
+
temperature=temperature, max_tokens=max_tokens)
|
| 375 |
+
if tools:
|
| 376 |
+
kwargs.update(tools=tools, tool_choice=tool_choice)
|
| 377 |
+
return groq_client.chat.completions.create(**kwargs)
|
| 378 |
+
|
| 379 |
+
except Exception as e:
|
| 380 |
+
status = getattr(getattr(e, "response", None), "status_code", None)
|
| 381 |
+
if status == 400:
|
| 382 |
+
raise # Never retry structural errors
|
| 383 |
+
if status in RETRYABLE_STATUS_CODES or status is None:
|
| 384 |
+
delay = (2 ** attempt) + random.uniform(0, 0.5) # Exponential backoff + jitter
|
| 385 |
+
logger.warning(f"{model} attempt {attempt+1} failed ({status}). Retry in {delay:.1f}s")
|
| 386 |
+
await asyncio.sleep(delay)
|
| 387 |
+
else:
|
| 388 |
+
raise
|
| 389 |
+
raise RuntimeError(f"All {max_retries} retries exhausted for {model}")
|
| 390 |
+
```
|
| 391 |
+
|
| 392 |
+
---
|
| 393 |
+
|
| 394 |
+
## Issue #8: Fallback Model Retries the Same Broken Payload
|
| 395 |
+
|
| 396 |
+
### Issue Identified & Exact Trace Location
|
| 397 |
+
|
| 398 |
+
**Observations 4 and 5 β both fail with identical errors:**
|
| 399 |
+
- Obs 4: `gpt-oss-120b` β 400 (structural: no tools in synthesis)
|
| 400 |
+
- Obs 5: `gpt-oss-20b` β 400 (same structural error)
|
| 401 |
+
|
| 402 |
+
The fallback does not distinguish between **transient** errors (try a different model) and **structural** errors (fix the payload first). Since both calls have the same broken structure, both fail identically, wasting 334ms on the fallback.
|
| 403 |
+
|
| 404 |
+
### Hardened Production Solution
|
| 405 |
+
|
| 406 |
+
```python
|
| 407 |
+
def is_structural_error(exception) -> bool:
|
| 408 |
+
body = getattr(exception, "body", {})
|
| 409 |
+
if isinstance(body, dict):
|
| 410 |
+
code = body.get("error", {}).get("code", "")
|
| 411 |
+
return code in {"tool_use_failed", "context_length_exceeded", "invalid_request_error"}
|
| 412 |
+
return False
|
| 413 |
+
|
| 414 |
+
# In the synthesis loop:
|
| 415 |
+
for model_candidate in [PRIMARY_MODEL, FALLBACK_MODEL]:
|
| 416 |
+
try:
|
| 417 |
+
return await _groq_call_with_retry(model=model_candidate, messages=messages, ...)
|
| 418 |
+
except Exception as e:
|
| 419 |
+
if is_structural_error(e):
|
| 420 |
+
# Structural error: fix the payload, not the model
|
| 421 |
+
raise StructuralOrchestrationError(e)
|
| 422 |
+
# Transient error: try next model
|
| 423 |
+
logger.warning(f"{model_candidate} failed transiently, trying fallback...")
|
| 424 |
+
```
|
| 425 |
+
|
| 426 |
+
---
|
| 427 |
+
|
| 428 |
+
## Summary: Issue Priority Matrix
|
| 429 |
+
|
| 430 |
+
| # | Issue | Severity | Impact | Effort |
|
| 431 |
+
|---|---|---|---|---|
|
| 432 |
+
| 1 | No tools in synthesis call β 400 errors | π΄ Critical | Silent failures, fallback messages | Medium |
|
| 433 |
+
| 2 | Recovery only handles `generate_data_export` | π΄ Critical | Data silently lost for non-export tools | Low |
|
| 434 |
+
| 3 | Full prompt sent every request (3,535 tokens) | π High | 25-30% token waste per request | Medium |
|
| 435 |
+
| 8 | Fallback retries identical broken payload | π High | Double failure + 334ms wasted | Low |
|
| 436 |
+
| 4 | Reasoning model used for routing (overkill) | π‘ Medium | 480ms overhead per turn | Low |
|
| 437 |
+
| 5 | `reasoning` field leaks into context history | π‘ Medium | Token waste + context contamination | Low |
|
| 438 |
+
| 6 | Session history unbounded in-memory | π‘ Medium | Memory leak, state loss on restart | Medium |
|
| 439 |
+
| 7 | No retry for 429/503 transient errors | π‘ Medium | Silent failures on rate limits | Low |
|
| 440 |
+
|
| 441 |
+
---
|
| 442 |
+
|
| 443 |
+
## Ideal vs. Actual Execution Comparison
|
| 444 |
+
|
| 445 |
+
**What actually happened (3.3 seconds, useless reply):**
|
| 446 |
+
```
|
| 447 |
+
Router β PATH_A (484ms β 20B reasoning model overkill)
|
| 448 |
+
Brain β get_market_averages(Miami) (707ms β Miami data fetched)
|
| 449 |
+
Synth 1 β 400 ERROR: tool_use_failed (398ms wasted β no tools schema)
|
| 450 |
+
Synth 2 β 400 ERROR: same broken payload (334ms wasted β fallback not smarter)
|
| 451 |
+
Result β "I retrieved the real estate intelligence data." β useless generic fallback
|
| 452 |
+
```
|
| 453 |
+
|
| 454 |
+
**What should happen with agentic loop (1.4 seconds, real answer):**
|
| 455 |
+
```
|
| 456 |
+
Router β PATH_A (120ms β small 8B classifier)
|
| 457 |
+
Loop 1 β get_market_averages(Miami) (350ms β Miami data)
|
| 458 |
+
Loop 2 β get_market_averages(NYC/NJ Metro) (350ms β NYC data)
|
| 459 |
+
Loop 3 β finish_reason: "stop" β Synthesis reply (500ms β real Markdown answer)
|
| 460 |
+
Result β Formatted table comparing Miami vs NYC/NJ rates with data-informed advisory
|
| 461 |
+
```
|
| 462 |
+
|
| 463 |
+
The agentic loop is **2.5Γ faster** and produces a genuinely useful answer.
|
|
@@ -2,21 +2,24 @@
|
|
| 2 |
Real Estate Intelligence Layer β v1 API routes.
|
| 3 |
|
| 4 |
Endpoints:
|
| 5 |
-
POST /api/v1/real-estate/chat β
|
|
|
|
| 6 |
GET /api/v1/real-estate/chat/starters β suggested prompt starters
|
| 7 |
"""
|
|
|
|
| 8 |
import logging
|
| 9 |
-
from typing import Any, Dict, List, Optional
|
| 10 |
|
| 11 |
from fastapi import APIRouter, Request, status
|
| 12 |
-
from fastapi.responses import JSONResponse
|
| 13 |
from pydantic import BaseModel, Field
|
| 14 |
-
from groq import BadRequestError
|
| 15 |
|
| 16 |
-
from services.groq_service import process_chat_message
|
|
|
|
| 17 |
from services.rate_limiter import limiter
|
|
|
|
| 18 |
|
| 19 |
-
logger =
|
| 20 |
|
| 21 |
router = APIRouter(
|
| 22 |
prefix="/api/v1/real-estate",
|
|
@@ -24,24 +27,24 @@ router = APIRouter(
|
|
| 24 |
)
|
| 25 |
|
| 26 |
|
| 27 |
-
# ββ Request / Response models βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
|
| 29 |
class ChatRequest(BaseModel):
|
| 30 |
-
message:
|
| 31 |
-
session_id: str
|
| 32 |
-
context:
|
| 33 |
|
| 34 |
|
| 35 |
class ChatResponse(BaseModel):
|
| 36 |
-
reply:
|
| 37 |
-
path_used:
|
| 38 |
-
tools_called:
|
| 39 |
-
suggested_actions: List[str]
|
| 40 |
|
| 41 |
|
| 42 |
class ErrorDetail(BaseModel):
|
| 43 |
-
code:
|
| 44 |
-
message:
|
| 45 |
retryable: bool = False
|
| 46 |
|
| 47 |
|
|
@@ -49,7 +52,7 @@ class ErrorResponse(BaseModel):
|
|
| 49 |
error: ErrorDetail
|
| 50 |
|
| 51 |
|
| 52 |
-
# ββ Error
|
| 53 |
|
| 54 |
def _error(code: str, message: str, retryable: bool, http_status: int) -> JSONResponse:
|
| 55 |
return JSONResponse(
|
|
@@ -58,7 +61,20 @@ def _error(code: str, message: str, retryable: bool, http_status: int) -> JSONRe
|
|
| 58 |
)
|
| 59 |
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
@router.post(
|
| 64 |
"/chat",
|
|
@@ -69,15 +85,13 @@ def _error(code: str, message: str, retryable: bool, http_status: int) -> JSONRe
|
|
| 69 |
503: {"model": ErrorResponse, "description": "Upstream AI or database service unavailable"},
|
| 70 |
500: {"model": ErrorResponse, "description": "Unexpected server error"},
|
| 71 |
},
|
| 72 |
-
summary="Send a message to the Real Estate Intelligence Assistant",
|
| 73 |
)
|
| 74 |
async def handle_real_estate_chat(payload: ChatRequest, request: Request):
|
| 75 |
-
# ββ Rate limiting βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
client_key = payload.session_id or request.client.host
|
| 77 |
try:
|
| 78 |
-
|
| 79 |
except Exception as rate_exc:
|
| 80 |
-
# limiter raises HTTPException(429) directly; re-surface as our schema
|
| 81 |
return _error(
|
| 82 |
code="RATE_LIMIT_EXCEEDED",
|
| 83 |
message=str(getattr(rate_exc, "detail", "Too many requests. Please wait before trying again.")),
|
|
@@ -85,7 +99,6 @@ async def handle_real_estate_chat(payload: ChatRequest, request: Request):
|
|
| 85 |
http_status=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 86 |
)
|
| 87 |
|
| 88 |
-
# ββ Core processing βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
try:
|
| 90 |
result = await process_chat_message(
|
| 91 |
user_query=payload.message,
|
|
@@ -99,48 +112,102 @@ async def handle_real_estate_chat(payload: ChatRequest, request: Request):
|
|
| 99 |
suggested_actions=result.get("suggested_actions", []),
|
| 100 |
)
|
| 101 |
|
| 102 |
-
except
|
| 103 |
-
|
| 104 |
-
logger.error(f"Groq 400 in chat [{payload.session_id}]: {e}")
|
| 105 |
-
return _error(
|
| 106 |
-
code="UPSTREAM_BAD_REQUEST",
|
| 107 |
-
message="The AI service rejected a request due to a configuration issue. Our team has been notified.",
|
| 108 |
-
retryable=False,
|
| 109 |
-
http_status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 110 |
-
)
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
)
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
@router.get(
|
| 146 |
"/chat/starters",
|
|
@@ -158,3 +225,44 @@ async def get_starter_prompts():
|
|
| 158 |
"Compare the top 3 available Airbnb properties.",
|
| 159 |
]
|
| 160 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
Real Estate Intelligence Layer β v1 API routes.
|
| 3 |
|
| 4 |
Endpoints:
|
| 5 |
+
POST /api/v1/real-estate/chat β non-streaming JSON response (backwards compat)
|
| 6 |
+
POST /api/v1/real-estate/chat/stream β SSE streaming response (preferred)
|
| 7 |
GET /api/v1/real-estate/chat/starters β suggested prompt starters
|
| 8 |
"""
|
| 9 |
+
import json
|
| 10 |
import logging
|
| 11 |
+
from typing import Any, AsyncIterator, Dict, List, Optional
|
| 12 |
|
| 13 |
from fastapi import APIRouter, Request, status
|
| 14 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 15 |
from pydantic import BaseModel, Field
|
|
|
|
| 16 |
|
| 17 |
+
from services.groq_service import process_chat_message, stream_chat_message
|
| 18 |
+
from services.groq_client import is_structural_error
|
| 19 |
from services.rate_limiter import limiter
|
| 20 |
+
from services.observability import setup_logger
|
| 21 |
|
| 22 |
+
logger = setup_logger(__name__)
|
| 23 |
|
| 24 |
router = APIRouter(
|
| 25 |
prefix="/api/v1/real-estate",
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
| 30 |
+
# ββ Request / Response models ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
|
| 32 |
class ChatRequest(BaseModel):
|
| 33 |
+
message: str = Field(..., min_length=1, max_length=2000)
|
| 34 |
+
session_id: str = Field(..., min_length=1, max_length=128)
|
| 35 |
+
context: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
| 36 |
|
| 37 |
|
| 38 |
class ChatResponse(BaseModel):
|
| 39 |
+
reply: str
|
| 40 |
+
path_used: str
|
| 41 |
+
tools_called: List[Dict[str, Any]] = []
|
| 42 |
+
suggested_actions: List[str] = []
|
| 43 |
|
| 44 |
|
| 45 |
class ErrorDetail(BaseModel):
|
| 46 |
+
code: str
|
| 47 |
+
message: str
|
| 48 |
retryable: bool = False
|
| 49 |
|
| 50 |
|
|
|
|
| 52 |
error: ErrorDetail
|
| 53 |
|
| 54 |
|
| 55 |
+
# ββ Error helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
|
| 57 |
def _error(code: str, message: str, retryable: bool, http_status: int) -> JSONResponse:
|
| 58 |
return JSONResponse(
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
|
| 64 |
+
def _sse_error_stream(code: str, message: str):
|
| 65 |
+
"""Yield a single SSE error event then close."""
|
| 66 |
+
payload = json.dumps({"type": "error", "code": code, "message": message})
|
| 67 |
+
yield f"event: error\ndata: {payload}\n\n"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ββ Rate limit helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
|
| 72 |
+
def _check_rate_limit(client_key: str):
|
| 73 |
+
"""Raises HTTPException(429) via the limiter if limit exceeded."""
|
| 74 |
+
limiter.check_rate_limit(client_key)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# βββ Route 1: Non-streaming JSON (backwards compatible) βββββββββββββββββββββββ
|
| 78 |
|
| 79 |
@router.post(
|
| 80 |
"/chat",
|
|
|
|
| 85 |
503: {"model": ErrorResponse, "description": "Upstream AI or database service unavailable"},
|
| 86 |
500: {"model": ErrorResponse, "description": "Unexpected server error"},
|
| 87 |
},
|
| 88 |
+
summary="Send a message to the Real Estate Intelligence Assistant (non-streaming)",
|
| 89 |
)
|
| 90 |
async def handle_real_estate_chat(payload: ChatRequest, request: Request):
|
|
|
|
| 91 |
client_key = payload.session_id or request.client.host
|
| 92 |
try:
|
| 93 |
+
_check_rate_limit(client_key)
|
| 94 |
except Exception as rate_exc:
|
|
|
|
| 95 |
return _error(
|
| 96 |
code="RATE_LIMIT_EXCEEDED",
|
| 97 |
message=str(getattr(rate_exc, "detail", "Too many requests. Please wait before trying again.")),
|
|
|
|
| 99 |
http_status=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 100 |
)
|
| 101 |
|
|
|
|
| 102 |
try:
|
| 103 |
result = await process_chat_message(
|
| 104 |
user_query=payload.message,
|
|
|
|
| 112 |
suggested_actions=result.get("suggested_actions", []),
|
| 113 |
)
|
| 114 |
|
| 115 |
+
except Exception as exc:
|
| 116 |
+
return _handle_exception(exc, payload.session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
|
| 119 |
+
# βββ Route 2: SSE Streaming βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 120 |
+
|
| 121 |
+
@router.post(
|
| 122 |
+
"/chat/stream",
|
| 123 |
+
summary="Send a message to Pulse AI β receives Server-Sent Events stream",
|
| 124 |
+
response_class=StreamingResponse,
|
| 125 |
+
responses={
|
| 126 |
+
200: {
|
| 127 |
+
"description": "SSE stream. Events: status | tool_call | token | done | error",
|
| 128 |
+
"content": {"text/event-stream": {}},
|
| 129 |
+
},
|
| 130 |
+
429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
|
| 131 |
+
},
|
| 132 |
+
)
|
| 133 |
+
async def handle_real_estate_chat_stream(payload: ChatRequest, request: Request):
|
| 134 |
+
"""
|
| 135 |
+
SSE stream endpoint.
|
| 136 |
+
|
| 137 |
+
The frontend connects with:
|
| 138 |
+
const evtSource = new EventSource(...) // OR via fetch with ReadableStream
|
| 139 |
+
POST /api/v1/real-estate/chat/stream
|
| 140 |
+
|
| 141 |
+
Event stream format:
|
| 142 |
+
event: status
|
| 143 |
+
data: {"type":"status","classification":"PATH_A"}
|
| 144 |
+
|
| 145 |
+
event: tool_call
|
| 146 |
+
data: {"type":"tool_call","tool":"get_market_averages","args":{"p_market":"Miami"}}
|
| 147 |
+
|
| 148 |
+
event: token
|
| 149 |
+
data: {"type":"token","token":"## Miami Market\\n"}
|
| 150 |
+
|
| 151 |
+
event: done
|
| 152 |
+
data: {"type":"done","path_used":"PATH_A","tools_called":[...],"suggested_actions":[]}
|
| 153 |
+
|
| 154 |
+
event: error
|
| 155 |
+
data: {"type":"error","code":"RATE_LIMIT_EXCEEDED","message":"..."}
|
| 156 |
+
"""
|
| 157 |
+
client_key = payload.session_id or request.client.host
|
| 158 |
+
try:
|
| 159 |
+
_check_rate_limit(client_key)
|
| 160 |
+
except Exception as rate_exc:
|
| 161 |
+
msg = str(getattr(rate_exc, "detail", "Too many requests. Please wait before trying again."))
|
| 162 |
+
return StreamingResponse(
|
| 163 |
+
_sse_error_stream("RATE_LIMIT_EXCEEDED", msg),
|
| 164 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 165 |
+
media_type="text/event-stream",
|
| 166 |
)
|
| 167 |
|
| 168 |
+
async def event_generator() -> AsyncIterator[str]:
|
| 169 |
+
try:
|
| 170 |
+
async for event in stream_chat_message(
|
| 171 |
+
user_query=payload.message,
|
| 172 |
+
session_id=payload.session_id,
|
| 173 |
+
session_context=payload.context or {},
|
| 174 |
+
):
|
| 175 |
+
event_type = event.get("type", "token")
|
| 176 |
+
data = json.dumps(event)
|
| 177 |
+
|
| 178 |
+
# Map type -> SSE event name for frontend selectivity
|
| 179 |
+
if event_type == "status":
|
| 180 |
+
yield f"event: status\ndata: {data}\n\n"
|
| 181 |
+
elif event_type == "tool_call":
|
| 182 |
+
yield f"event: tool_call\ndata: {data}\n\n"
|
| 183 |
+
elif event_type == "token":
|
| 184 |
+
yield f"event: token\ndata: {data}\n\n"
|
| 185 |
+
elif event_type == "done":
|
| 186 |
+
yield f"event: done\ndata: {data}\n\n"
|
| 187 |
+
else:
|
| 188 |
+
yield f"event: {event_type}\ndata: {data}\n\n"
|
| 189 |
+
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
logger.error(f"SSE stream error [{payload.session_id}]: {exc}", exc_info=True)
|
| 192 |
+
err_payload = json.dumps({
|
| 193 |
+
"type": "error",
|
| 194 |
+
"code": "STREAM_ERROR",
|
| 195 |
+
"message": "An error occurred while generating the response. Please try again.",
|
| 196 |
+
})
|
| 197 |
+
yield f"event: error\ndata: {err_payload}\n\n"
|
| 198 |
+
|
| 199 |
+
return StreamingResponse(
|
| 200 |
+
event_generator(),
|
| 201 |
+
media_type="text/event-stream",
|
| 202 |
+
headers={
|
| 203 |
+
"Cache-Control": "no-cache",
|
| 204 |
+
"X-Accel-Buffering": "no", # Disable Nginx buffering for HuggingFace Spaces
|
| 205 |
+
"Connection": "keep-alive",
|
| 206 |
+
},
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# βββ Route 3: Starter prompts βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 211 |
|
| 212 |
@router.get(
|
| 213 |
"/chat/starters",
|
|
|
|
| 225 |
"Compare the top 3 available Airbnb properties.",
|
| 226 |
]
|
| 227 |
}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# βββ Exception handler (shared) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
|
| 232 |
+
def _handle_exception(exc: Exception, session_id: str) -> JSONResponse:
|
| 233 |
+
err_str = str(exc).lower()
|
| 234 |
+
|
| 235 |
+
if is_structural_error(exc):
|
| 236 |
+
logger.error(f"Structural Groq error [{session_id}]: {exc}")
|
| 237 |
+
return _error(
|
| 238 |
+
code="UPSTREAM_BAD_REQUEST",
|
| 239 |
+
message="The AI service rejected a request due to a configuration issue.",
|
| 240 |
+
retryable=False,
|
| 241 |
+
http_status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
if any(k in err_str for k in ("503", "connection", "timeout", "service unavailable")):
|
| 245 |
+
logger.warning(f"Upstream unavailable [{session_id}]: {exc}")
|
| 246 |
+
return _error(
|
| 247 |
+
code="UPSTREAM_UNAVAILABLE",
|
| 248 |
+
message="The AI service is temporarily unavailable. Please try again in a moment.",
|
| 249 |
+
retryable=True,
|
| 250 |
+
http_status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
if "429" in err_str or "rate limit" in err_str:
|
| 254 |
+
logger.warning(f"LLM rate limit hit [{session_id}]: {exc}")
|
| 255 |
+
return _error(
|
| 256 |
+
code="LLM_RATE_LIMIT",
|
| 257 |
+
message="The AI service is under high load. Please wait a few seconds and try again.",
|
| 258 |
+
retryable=True,
|
| 259 |
+
http_status=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
logger.error(f"Unexpected error [{session_id}]: {exc}", exc_info=True)
|
| 263 |
+
return _error(
|
| 264 |
+
code="INTERNAL_ERROR",
|
| 265 |
+
message="An unexpected error occurred. Please try again shortly.",
|
| 266 |
+
retryable=True,
|
| 267 |
+
http_status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 268 |
+
)
|
|
@@ -0,0 +1,523 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/agent_loop.py
|
| 3 |
+
βββββββββββββββββββββββ
|
| 4 |
+
Agentic orchestration: routing, RAG, and the multi-round tool execution loop.
|
| 5 |
+
|
| 6 |
+
Architectural fixes implemented here:
|
| 7 |
+
#1 - True agentic tool loop (up to MAX_TOOL_ROUNDS rounds). Tools remain
|
| 8 |
+
available until the model emits finish_reason="stop". This eliminates
|
| 9 |
+
the "One-Shot Tool Execution" anti-pattern that caused 400 errors when
|
| 10 |
+
reasoning models needed multiple tool calls.
|
| 11 |
+
#2 - Universal failed_generation recovery: any tool extracted from a Groq
|
| 12 |
+
400 error body is executed and its result is fed back into the context
|
| 13 |
+
before re-attempting synthesis.
|
| 14 |
+
#3 - Prompt composition by classification (via build_system_prompt).
|
| 15 |
+
#4 - Small fast model for routing (llama-3.1-8b-instant via GROQ_ROUTE_MODEL
|
| 16 |
+
env var, default is the smaller non-reasoning model).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import time
|
| 21 |
+
import asyncio
|
| 22 |
+
import re
|
| 23 |
+
from typing import AsyncIterator
|
| 24 |
+
|
| 25 |
+
from langfuse import observe, propagate_attributes, get_client
|
| 26 |
+
|
| 27 |
+
from services.observability import setup_logger
|
| 28 |
+
from services.groq_client import (
|
| 29 |
+
groq_call,
|
| 30 |
+
is_structural_error,
|
| 31 |
+
extract_failed_generation,
|
| 32 |
+
GROQ_ROUTE_MODEL,
|
| 33 |
+
GROQ_FALLBACK_ROUTE_MODEL,
|
| 34 |
+
GROQ_SYNTHESIS_MODEL,
|
| 35 |
+
GROQ_FALLBACK_SYNTHESIS_MODEL,
|
| 36 |
+
)
|
| 37 |
+
from services.prompts import ROUTER_PROMPT, build_system_prompt
|
| 38 |
+
from services.session import get_context_window, append_message
|
| 39 |
+
from services.tool_executor import (
|
| 40 |
+
execute_tool_by_name,
|
| 41 |
+
normalize_assistant_message,
|
| 42 |
+
parse_tool_args,
|
| 43 |
+
parse_failed_generation,
|
| 44 |
+
)
|
| 45 |
+
from services.tool_compressor import compress_tool_output
|
| 46 |
+
from services.supabase_service import search_methodology_rag
|
| 47 |
+
from services.tools import REAL_ESTATE_TOOLS, COMMERCIAL_TOOLS
|
| 48 |
+
|
| 49 |
+
logger = setup_logger(__name__)
|
| 50 |
+
|
| 51 |
+
MAX_TOOL_ROUNDS = 4 # Maximum tool-call rounds before forcing synthesis
|
| 52 |
+
|
| 53 |
+
# βββ STATIC RESPONSES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
+
|
| 55 |
+
_REPLY_OUT_OF_SCOPE = (
|
| 56 |
+
"I apologize, but I am currently scoped exclusively to the Real Estate Rate Monitor. "
|
| 57 |
+
"I cannot assist with topics outside real estate data."
|
| 58 |
+
)
|
| 59 |
+
_REPLY_GREETING = (
|
| 60 |
+
"Hello! I'm Pulse AI, a Real Estate Intelligence Assistant for Joule Dynamics. "
|
| 61 |
+
"I can help you analyze rate spikes, market trends, pricing volatility, and availability "
|
| 62 |
+
"data across our tracked markets. How can I assist you today?"
|
| 63 |
+
)
|
| 64 |
+
_REPLY_FALLBACK = (
|
| 65 |
+
"I was unable to retrieve the data needed to answer your question. "
|
| 66 |
+
"Please try rephrasing, or ask about a specific market (Miami or NYC/NJ Metro)."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# βββ ROUTER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
|
| 72 |
+
async def classify_query(user_query: str) -> str:
|
| 73 |
+
"""
|
| 74 |
+
Use a small, fast model to classify the query into one of 6 route tags.
|
| 75 |
+
Fix #4: Previously used a 20B reasoning model (484ms). Now uses the
|
| 76 |
+
lightweight GROQ_ROUTE_MODEL (default: llama-3.1-8b-instant, ~120ms).
|
| 77 |
+
"""
|
| 78 |
+
messages = [
|
| 79 |
+
{"role": "system", "content": ROUTER_PROMPT},
|
| 80 |
+
{"role": "user", "content": user_query},
|
| 81 |
+
]
|
| 82 |
+
# Try primary route model, fall back to secondary
|
| 83 |
+
for model in [GROQ_ROUTE_MODEL, GROQ_FALLBACK_ROUTE_MODEL]:
|
| 84 |
+
try:
|
| 85 |
+
res = await groq_call(
|
| 86 |
+
model=model,
|
| 87 |
+
messages=messages,
|
| 88 |
+
max_tokens=80,
|
| 89 |
+
temperature=0.0,
|
| 90 |
+
response_format={"type": "json_object"},
|
| 91 |
+
)
|
| 92 |
+
raw = res.choices[0].message.content or "{}"
|
| 93 |
+
data = json.loads(raw)
|
| 94 |
+
classification = data.get("classification", "PATH_A")
|
| 95 |
+
logger.info(f"[router] {model} -> {classification}: {data.get('reason', '')}")
|
| 96 |
+
return classification
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
if is_structural_error(exc):
|
| 99 |
+
logger.warning(f"[router] structural error on {model}: {exc}")
|
| 100 |
+
else:
|
| 101 |
+
logger.warning(f"[router] {model} failed, trying fallback: {exc}")
|
| 102 |
+
return "PATH_A" # Safe default
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# βββ AGENTIC TOOL LOOP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 106 |
+
|
| 107 |
+
async def run_agent_loop(
|
| 108 |
+
messages: list[dict],
|
| 109 |
+
active_tools: list | None,
|
| 110 |
+
tool_results: list,
|
| 111 |
+
) -> str | None:
|
| 112 |
+
"""
|
| 113 |
+
Core agentic loop (Fix #1).
|
| 114 |
+
|
| 115 |
+
Keeps tools available until the model emits finish_reason='stop'.
|
| 116 |
+
Handles failed_generation recovery (Fix #2) for any tool, not just exports.
|
| 117 |
+
Returns the final text reply or None if all attempts failed.
|
| 118 |
+
"""
|
| 119 |
+
for round_num in range(1, MAX_TOOL_ROUNDS + 1):
|
| 120 |
+
response_message = None
|
| 121 |
+
|
| 122 |
+
# οΏ½οΏ½οΏ½β Brain call (primary + fallback) βββββββββββββββββββββββββββββββββββ
|
| 123 |
+
for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
|
| 124 |
+
try:
|
| 125 |
+
brain_res = await groq_call(
|
| 126 |
+
model=model,
|
| 127 |
+
messages=messages,
|
| 128 |
+
tools=active_tools if active_tools else None,
|
| 129 |
+
tool_choice="auto" if active_tools else "none",
|
| 130 |
+
max_tokens=600 if active_tools else 1000,
|
| 131 |
+
temperature=0.2,
|
| 132 |
+
)
|
| 133 |
+
response_message = brain_res.choices[0].message
|
| 134 |
+
logger.info(
|
| 135 |
+
f"[agent_loop] round={round_num} model={model} "
|
| 136 |
+
f"finish_reason={brain_res.choices[0].finish_reason}"
|
| 137 |
+
)
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
except Exception as exc:
|
| 141 |
+
failed_gen = extract_failed_generation(exc)
|
| 142 |
+
if failed_gen and is_structural_error(exc):
|
| 143 |
+
# Model tried to call a tool but tools weren't in this call,
|
| 144 |
+
# or schema mismatch β recover by executing the tool directly.
|
| 145 |
+
fn, fn_args = parse_failed_generation(failed_gen)
|
| 146 |
+
if fn and isinstance(fn_args, dict):
|
| 147 |
+
logger.info(f"[agent_loop] recovering tool={fn} from failed_generation")
|
| 148 |
+
tool_result = await execute_tool_by_name(fn, fn_args)
|
| 149 |
+
tool_results.append({"tool": fn, "args": fn_args})
|
| 150 |
+
messages.append({
|
| 151 |
+
"tool_call_id": f"recov_{int(time.time())}",
|
| 152 |
+
"role": "tool",
|
| 153 |
+
"name": fn,
|
| 154 |
+
"content": compress_tool_output(fn, tool_result),
|
| 155 |
+
})
|
| 156 |
+
# After injecting result, break to top of outer loop to retry
|
| 157 |
+
break
|
| 158 |
+
logger.warning(f"[agent_loop] {model} error round={round_num}: {exc}")
|
| 159 |
+
|
| 160 |
+
if response_message is None:
|
| 161 |
+
# Recovery injected a tool result β retry the loop
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
finish_reason = ""
|
| 165 |
+
if hasattr(response_message, "tool_calls") and response_message.tool_calls:
|
| 166 |
+
finish_reason = "tool_calls"
|
| 167 |
+
elif hasattr(response_message, "content") and response_message.content:
|
| 168 |
+
finish_reason = "stop"
|
| 169 |
+
|
| 170 |
+
# ββ Tool call round βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 171 |
+
if finish_reason == "tool_calls":
|
| 172 |
+
messages.append(normalize_assistant_message(response_message)) # Fix #5
|
| 173 |
+
for tc in response_message.tool_calls:
|
| 174 |
+
fn = tc.function.name
|
| 175 |
+
args = parse_tool_args(tc.function.arguments)
|
| 176 |
+
logger.info(f"[agent_loop] executing tool={fn} args={args}")
|
| 177 |
+
|
| 178 |
+
result = await execute_tool_by_name(fn, args)
|
| 179 |
+
tool_results.append({"tool": fn, "args": args})
|
| 180 |
+
messages.append({
|
| 181 |
+
"tool_call_id": tc.id,
|
| 182 |
+
"role": "tool",
|
| 183 |
+
"name": fn,
|
| 184 |
+
"content": compress_tool_output(fn, result),
|
| 185 |
+
})
|
| 186 |
+
# Continue loop β model may want more tools or synthesis next round
|
| 187 |
+
|
| 188 |
+
# ββ Direct text reply β done ββββββββββββββββββββββββββββββββββββββββββ
|
| 189 |
+
elif finish_reason == "stop":
|
| 190 |
+
return response_message.content
|
| 191 |
+
|
| 192 |
+
# Loop exhausted without finish_reason=stop β force synthesis
|
| 193 |
+
logger.warning("[agent_loop] max rounds reached, forcing synthesis")
|
| 194 |
+
for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
|
| 195 |
+
try:
|
| 196 |
+
synth_messages = list(messages) + [{
|
| 197 |
+
"role": "system",
|
| 198 |
+
"content": (
|
| 199 |
+
"Synthesize the real estate data gathered above into a clear, "
|
| 200 |
+
"concise Markdown reply for the user. Do NOT call any more tools."
|
| 201 |
+
),
|
| 202 |
+
}]
|
| 203 |
+
final_res = await groq_call(
|
| 204 |
+
model=model,
|
| 205 |
+
messages=synth_messages,
|
| 206 |
+
tools=None,
|
| 207 |
+
tool_choice="none",
|
| 208 |
+
max_tokens=1000,
|
| 209 |
+
temperature=0.2,
|
| 210 |
+
)
|
| 211 |
+
return final_res.choices[0].message.content
|
| 212 |
+
except Exception as exc:
|
| 213 |
+
# Fix #2: Even here, recover generate_data_export from failed_generation
|
| 214 |
+
failed_gen = extract_failed_generation(exc)
|
| 215 |
+
fn, fn_args = parse_failed_generation(failed_gen)
|
| 216 |
+
if fn == "generate_data_export" and isinstance(fn_args, dict):
|
| 217 |
+
from services.appwrite_service import upload_document_to_appwrite
|
| 218 |
+
content = fn_args.get("content", "")
|
| 219 |
+
upload_res = await upload_document_to_appwrite(content, "md")
|
| 220 |
+
dl_url = upload_res.get("download_url", "")
|
| 221 |
+
if dl_url:
|
| 222 |
+
return f"{content}\n\n[Download Markdown Report]({dl_url})"
|
| 223 |
+
return content
|
| 224 |
+
logger.warning(f"[agent_loop] forced synthesis failed on {model}: {exc}")
|
| 225 |
+
|
| 226 |
+
return None
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# βββ STREAMING AGENT LOOP (for SSE) βββββββββββββββββββββββββββββββββββββββββββ
|
| 230 |
+
|
| 231 |
+
async def run_agent_loop_streaming(
|
| 232 |
+
messages: list[dict],
|
| 233 |
+
active_tools: list | None,
|
| 234 |
+
tool_results: list,
|
| 235 |
+
) -> AsyncIterator[dict]:
|
| 236 |
+
"""
|
| 237 |
+
Streaming version of run_agent_loop for SSE delivery.
|
| 238 |
+
|
| 239 |
+
Yields dicts:
|
| 240 |
+
{"type": "tool_call", "tool": str, "args": dict}
|
| 241 |
+
{"type": "token", "token": str}
|
| 242 |
+
{"type": "done", "tools_called": list}
|
| 243 |
+
|
| 244 |
+
Tool-call rounds run identically to the non-streaming loop. Only the
|
| 245 |
+
final synthesis step streams tokens chunk-by-chunk.
|
| 246 |
+
"""
|
| 247 |
+
loop = asyncio.get_event_loop()
|
| 248 |
+
|
| 249 |
+
# Run all tool rounds (non-streaming, same as normal loop)
|
| 250 |
+
for round_num in range(1, MAX_TOOL_ROUNDS + 1):
|
| 251 |
+
response_message = None
|
| 252 |
+
|
| 253 |
+
for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
|
| 254 |
+
try:
|
| 255 |
+
brain_res = await groq_call(
|
| 256 |
+
model=model,
|
| 257 |
+
messages=messages,
|
| 258 |
+
tools=active_tools if active_tools else None,
|
| 259 |
+
tool_choice="auto" if active_tools else "none",
|
| 260 |
+
max_tokens=600,
|
| 261 |
+
temperature=0.2,
|
| 262 |
+
)
|
| 263 |
+
response_message = brain_res.choices[0].message
|
| 264 |
+
break
|
| 265 |
+
except Exception as exc:
|
| 266 |
+
failed_gen = extract_failed_generation(exc)
|
| 267 |
+
if failed_gen and is_structural_error(exc):
|
| 268 |
+
fn, fn_args = parse_failed_generation(failed_gen)
|
| 269 |
+
if fn and isinstance(fn_args, dict):
|
| 270 |
+
tool_result = await execute_tool_by_name(fn, fn_args)
|
| 271 |
+
tool_results.append({"tool": fn, "args": fn_args})
|
| 272 |
+
messages.append({
|
| 273 |
+
"tool_call_id": f"recov_{int(time.time())}",
|
| 274 |
+
"role": "tool",
|
| 275 |
+
"name": fn,
|
| 276 |
+
"content": compress_tool_output(fn, tool_result),
|
| 277 |
+
})
|
| 278 |
+
yield {"type": "tool_call", "tool": fn, "args": fn_args}
|
| 279 |
+
break
|
| 280 |
+
logger.warning(f"[stream_loop] {model} error round={round_num}: {exc}")
|
| 281 |
+
|
| 282 |
+
if response_message is None:
|
| 283 |
+
continue
|
| 284 |
+
|
| 285 |
+
has_tool_calls = bool(getattr(response_message, "tool_calls", None))
|
| 286 |
+
if has_tool_calls:
|
| 287 |
+
messages.append(normalize_assistant_message(response_message))
|
| 288 |
+
for tc in response_message.tool_calls:
|
| 289 |
+
fn = tc.function.name
|
| 290 |
+
args = parse_tool_args(tc.function.arguments)
|
| 291 |
+
yield {"type": "tool_call", "tool": fn, "args": args}
|
| 292 |
+
result = await execute_tool_by_name(fn, args)
|
| 293 |
+
tool_results.append({"tool": fn, "args": args})
|
| 294 |
+
messages.append({
|
| 295 |
+
"tool_call_id": tc.id,
|
| 296 |
+
"role": "tool",
|
| 297 |
+
"name": fn,
|
| 298 |
+
"content": compress_tool_output(fn, result),
|
| 299 |
+
})
|
| 300 |
+
else:
|
| 301 |
+
# Model returned a direct text reply β stream it token by token
|
| 302 |
+
if response_message.content:
|
| 303 |
+
for token in _split_tokens(response_message.content):
|
| 304 |
+
yield {"type": "token", "token": token}
|
| 305 |
+
yield {"type": "done", "tools_called": tool_results}
|
| 306 |
+
return
|
| 307 |
+
|
| 308 |
+
# Forced synthesis with streaming
|
| 309 |
+
for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
|
| 310 |
+
try:
|
| 311 |
+
synth_messages = list(messages) + [{
|
| 312 |
+
"role": "system",
|
| 313 |
+
"content": "Synthesize the real estate data gathered above into a clear Markdown reply. Do NOT call any more tools.",
|
| 314 |
+
}]
|
| 315 |
+
|
| 316 |
+
def _stream_call():
|
| 317 |
+
return groq_client.chat.completions.create(
|
| 318 |
+
model=model,
|
| 319 |
+
messages=synth_messages,
|
| 320 |
+
temperature=0.2,
|
| 321 |
+
max_tokens=1000,
|
| 322 |
+
stream=True,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
from services.groq_client import groq_client as _gc
|
| 326 |
+
stream = await loop.run_in_executor(None, lambda: _gc.chat.completions.create(
|
| 327 |
+
model=model,
|
| 328 |
+
messages=synth_messages,
|
| 329 |
+
temperature=0.2,
|
| 330 |
+
max_tokens=1000,
|
| 331 |
+
stream=True,
|
| 332 |
+
))
|
| 333 |
+
for chunk in stream:
|
| 334 |
+
delta = chunk.choices[0].delta.content or ""
|
| 335 |
+
if delta:
|
| 336 |
+
yield {"type": "token", "token": delta}
|
| 337 |
+
yield {"type": "done", "tools_called": tool_results}
|
| 338 |
+
return
|
| 339 |
+
except Exception as exc:
|
| 340 |
+
logger.warning(f"[stream_loop] forced synthesis failed on {model}: {exc}")
|
| 341 |
+
|
| 342 |
+
yield {"type": "token", "token": _REPLY_FALLBACK}
|
| 343 |
+
yield {"type": "done", "tools_called": tool_results}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _split_tokens(text: str, chunk_size: int = 4):
|
| 347 |
+
"""Split text into small chunks to simulate streaming for non-stream calls."""
|
| 348 |
+
words = text.split(" ")
|
| 349 |
+
buf = []
|
| 350 |
+
for word in words:
|
| 351 |
+
buf.append(word)
|
| 352 |
+
if len(buf) >= chunk_size:
|
| 353 |
+
yield " ".join(buf) + " "
|
| 354 |
+
buf = []
|
| 355 |
+
if buf:
|
| 356 |
+
yield " ".join(buf)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
# βββ CLARIFICATION PARSER βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 360 |
+
|
| 361 |
+
def extract_clarification_options(reply: str) -> tuple[str, list[str]]:
|
| 362 |
+
"""Strip embedded JSON clarification options from the reply text."""
|
| 363 |
+
suggested_actions: list[str] = []
|
| 364 |
+
json_match = re.search(
|
| 365 |
+
r'```json\s*(\{.*?"clarification_options".*?\})\s*```', reply, re.DOTALL
|
| 366 |
+
)
|
| 367 |
+
if not json_match:
|
| 368 |
+
json_match = re.search(r'(\{.*?"clarification_options".*?\})', reply, re.DOTALL)
|
| 369 |
+
|
| 370 |
+
if json_match:
|
| 371 |
+
try:
|
| 372 |
+
data = json.loads(json_match.group(1))
|
| 373 |
+
suggested_actions = data.get("clarification_options", [])
|
| 374 |
+
reply = reply.replace(json_match.group(0), "").strip()
|
| 375 |
+
except json.JSONDecodeError:
|
| 376 |
+
pass
|
| 377 |
+
return reply, suggested_actions
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# βββ PUBLIC ENTRY POINT βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 381 |
+
|
| 382 |
+
@observe(name="process-chat")
|
| 383 |
+
async def process_chat_message(
|
| 384 |
+
user_query: str,
|
| 385 |
+
session_id: str,
|
| 386 |
+
session_context: dict,
|
| 387 |
+
) -> dict:
|
| 388 |
+
"""
|
| 389 |
+
Main non-streaming handler. Returns a complete dict:
|
| 390 |
+
{reply, path_used, tools_called, suggested_actions}
|
| 391 |
+
"""
|
| 392 |
+
with propagate_attributes(session_id=session_id, tags=["real-estate-chat"]):
|
| 393 |
+
get_client().update_current_span(input=user_query)
|
| 394 |
+
|
| 395 |
+
# STEP 1: Route
|
| 396 |
+
classification = await classify_query(user_query)
|
| 397 |
+
|
| 398 |
+
if classification == "OUT_OF_SCOPE":
|
| 399 |
+
get_client().update_current_span(output=_REPLY_OUT_OF_SCOPE)
|
| 400 |
+
return {"reply": _REPLY_OUT_OF_SCOPE, "path_used": "OUT_OF_SCOPE",
|
| 401 |
+
"tools_called": [], "suggested_actions": []}
|
| 402 |
+
|
| 403 |
+
if classification == "GREETING":
|
| 404 |
+
get_client().update_current_span(output=_REPLY_GREETING)
|
| 405 |
+
return {"reply": _REPLY_GREETING, "path_used": "GREETING",
|
| 406 |
+
"tools_called": [], "suggested_actions": []}
|
| 407 |
+
|
| 408 |
+
# STEP 2: RAG retrieval for knowledge-base paths
|
| 409 |
+
rag_chunks: list[str] = []
|
| 410 |
+
if classification in ("PATH_B", "BOTH", "COMMERCIAL_HANDOFF"):
|
| 411 |
+
rag_chunks = await search_methodology_rag(user_query)
|
| 412 |
+
|
| 413 |
+
# STEP 3: Build message context
|
| 414 |
+
system_prompt = build_system_prompt(classification) # Fix #3
|
| 415 |
+
messages: list[dict] = [{"role": "system", "content": system_prompt}]
|
| 416 |
+
messages.extend(get_context_window(session_id)) # Fix #6 (trimmed history)
|
| 417 |
+
|
| 418 |
+
user_msg = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
|
| 419 |
+
messages.append({"role": "user", "content": user_msg})
|
| 420 |
+
append_message(session_id, {"role": "user", "content": user_msg})
|
| 421 |
+
|
| 422 |
+
if rag_chunks:
|
| 423 |
+
messages.append({
|
| 424 |
+
"role": "system",
|
| 425 |
+
"content": "Retrieved Methodology Context:\n" + "\n---\n".join(rag_chunks),
|
| 426 |
+
})
|
| 427 |
+
|
| 428 |
+
# STEP 4: Select tools
|
| 429 |
+
active_tools = None
|
| 430 |
+
if classification in ("PATH_A", "BOTH"):
|
| 431 |
+
active_tools = REAL_ESTATE_TOOLS
|
| 432 |
+
elif classification == "COMMERCIAL_HANDOFF":
|
| 433 |
+
active_tools = COMMERCIAL_TOOLS
|
| 434 |
+
|
| 435 |
+
# STEP 5: Run agentic loop (Fix #1)
|
| 436 |
+
tool_results: list[dict] = []
|
| 437 |
+
final_reply = await run_agent_loop(messages, active_tools, tool_results)
|
| 438 |
+
|
| 439 |
+
if not final_reply or final_reply.strip().startswith('{"name":') or final_reply.strip().startswith("<function="):
|
| 440 |
+
final_reply = _REPLY_FALLBACK
|
| 441 |
+
|
| 442 |
+
# STEP 6: Store assistant reply in session
|
| 443 |
+
append_message(session_id, {"role": "assistant", "content": final_reply})
|
| 444 |
+
|
| 445 |
+
# STEP 7: Strip clarification options from reply text
|
| 446 |
+
final_reply, suggested_actions = extract_clarification_options(final_reply)
|
| 447 |
+
|
| 448 |
+
get_client().update_current_span(output=final_reply)
|
| 449 |
+
|
| 450 |
+
return {
|
| 451 |
+
"reply": final_reply,
|
| 452 |
+
"path_used": classification,
|
| 453 |
+
"tools_called": tool_results,
|
| 454 |
+
"suggested_actions": suggested_actions,
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
@observe(name="stream-chat")
|
| 459 |
+
async def stream_chat_message(
|
| 460 |
+
user_query: str,
|
| 461 |
+
session_id: str,
|
| 462 |
+
session_context: dict,
|
| 463 |
+
) -> AsyncIterator[dict]:
|
| 464 |
+
"""
|
| 465 |
+
SSE streaming handler.
|
| 466 |
+
Yields: {"type": "status"|"tool_call"|"token"|"done", ...}
|
| 467 |
+
"""
|
| 468 |
+
with propagate_attributes(session_id=session_id, tags=["real-estate-chat", "stream"]):
|
| 469 |
+
|
| 470 |
+
# STEP 1: Route
|
| 471 |
+
classification = await classify_query(user_query)
|
| 472 |
+
yield {"type": "status", "classification": classification}
|
| 473 |
+
|
| 474 |
+
if classification == "OUT_OF_SCOPE":
|
| 475 |
+
yield {"type": "token", "token": _REPLY_OUT_OF_SCOPE}
|
| 476 |
+
yield {"type": "done", "tools_called": [], "suggested_actions": [],
|
| 477 |
+
"path_used": "OUT_OF_SCOPE"}
|
| 478 |
+
return
|
| 479 |
+
|
| 480 |
+
if classification == "GREETING":
|
| 481 |
+
yield {"type": "token", "token": _REPLY_GREETING}
|
| 482 |
+
yield {"type": "done", "tools_called": [], "suggested_actions": [],
|
| 483 |
+
"path_used": "GREETING"}
|
| 484 |
+
return
|
| 485 |
+
|
| 486 |
+
# STEP 2: RAG
|
| 487 |
+
rag_chunks: list[str] = []
|
| 488 |
+
if classification in ("PATH_B", "BOTH", "COMMERCIAL_HANDOFF"):
|
| 489 |
+
rag_chunks = await search_methodology_rag(user_query)
|
| 490 |
+
|
| 491 |
+
# STEP 3: Messages
|
| 492 |
+
system_prompt = build_system_prompt(classification)
|
| 493 |
+
messages: list[dict] = [{"role": "system", "content": system_prompt}]
|
| 494 |
+
messages.extend(get_context_window(session_id))
|
| 495 |
+
user_msg = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
|
| 496 |
+
messages.append({"role": "user", "content": user_msg})
|
| 497 |
+
append_message(session_id, {"role": "user", "content": user_msg})
|
| 498 |
+
if rag_chunks:
|
| 499 |
+
messages.append({
|
| 500 |
+
"role": "system",
|
| 501 |
+
"content": "Retrieved Methodology Context:\n" + "\n---\n".join(rag_chunks),
|
| 502 |
+
})
|
| 503 |
+
|
| 504 |
+
# STEP 4: Tools
|
| 505 |
+
active_tools = None
|
| 506 |
+
if classification in ("PATH_A", "BOTH"):
|
| 507 |
+
active_tools = REAL_ESTATE_TOOLS
|
| 508 |
+
elif classification == "COMMERCIAL_HANDOFF":
|
| 509 |
+
active_tools = COMMERCIAL_TOOLS
|
| 510 |
+
|
| 511 |
+
# STEP 5: Streaming agentic loop
|
| 512 |
+
tool_results: list[dict] = []
|
| 513 |
+
full_reply_parts: list[str] = []
|
| 514 |
+
|
| 515 |
+
async for event in run_agent_loop_streaming(messages, active_tools, tool_results):
|
| 516 |
+
if event["type"] == "token":
|
| 517 |
+
full_reply_parts.append(event["token"])
|
| 518 |
+
yield event
|
| 519 |
+
|
| 520 |
+
# STEP 6: Persist assembled reply to session
|
| 521 |
+
full_reply = "".join(full_reply_parts)
|
| 522 |
+
if full_reply:
|
| 523 |
+
append_message(session_id, {"role": "assistant", "content": full_reply})
|
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/groq_client.py
|
| 3 |
+
ββββββββββββββββββββββββ
|
| 4 |
+
Groq SDK singleton, model constants, and a retry-aware API wrapper.
|
| 5 |
+
|
| 6 |
+
Architectural fixes:
|
| 7 |
+
#7 - Exponential backoff with jitter for transient 429/5xx errors.
|
| 8 |
+
#8 - Structural error classification so fallback models are only tried
|
| 9 |
+
for transient failures, never for schema/payload errors.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import random
|
| 14 |
+
import time
|
| 15 |
+
import json
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from groq import Groq
|
| 19 |
+
from services.observability import setup_logger
|
| 20 |
+
from config import (
|
| 21 |
+
GROQ_API_KEY,
|
| 22 |
+
GROQ_ROUTE_MODEL,
|
| 23 |
+
GROQ_FALLBACK_ROUTE_MODEL,
|
| 24 |
+
GROQ_SYNTHESIS_MODEL,
|
| 25 |
+
GROQ_FALLBACK_SYNTHESIS_MODEL,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
logger = setup_logger(__name__)
|
| 29 |
+
|
| 30 |
+
# ββ Singleton Groq client ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
groq_client = Groq(api_key=GROQ_API_KEY)
|
| 32 |
+
|
| 33 |
+
# ββ Error classification βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
_STRUCTURAL_ERROR_CODES = frozenset({
|
| 35 |
+
"tool_use_failed",
|
| 36 |
+
"invalid_request_error",
|
| 37 |
+
"context_length_exceeded",
|
| 38 |
+
"json_validate_failed",
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
_RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504})
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def is_structural_error(exc: Exception) -> bool:
|
| 45 |
+
"""Return True if error is caused by bad payload/schema, NOT transient."""
|
| 46 |
+
body = getattr(exc, "body", {}) or {}
|
| 47 |
+
if isinstance(body, dict):
|
| 48 |
+
code = body.get("error", {}).get("code", "") or ""
|
| 49 |
+
msg = body.get("error", {}).get("message", "") or ""
|
| 50 |
+
if code in _STRUCTURAL_ERROR_CODES:
|
| 51 |
+
return True
|
| 52 |
+
if "tool choice is none" in msg.lower():
|
| 53 |
+
return True
|
| 54 |
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
| 55 |
+
return status == 400
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def is_retryable_error(exc: Exception) -> bool:
|
| 59 |
+
"""Return True if error is transient and worth retrying."""
|
| 60 |
+
if is_structural_error(exc):
|
| 61 |
+
return False
|
| 62 |
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
| 63 |
+
if status in _RETRYABLE_HTTP_STATUSES:
|
| 64 |
+
return True
|
| 65 |
+
return status is None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def groq_call(
|
| 69 |
+
*,
|
| 70 |
+
model: str,
|
| 71 |
+
messages: list,
|
| 72 |
+
tools: list = None,
|
| 73 |
+
tool_choice: str = "none",
|
| 74 |
+
max_tokens: int = 1000,
|
| 75 |
+
temperature: float = 0.2,
|
| 76 |
+
max_retries: int = 3,
|
| 77 |
+
base_delay: float = 1.0,
|
| 78 |
+
response_format: dict = None,
|
| 79 |
+
):
|
| 80 |
+
"""
|
| 81 |
+
Async Groq completion call with exponential backoff + jitter.
|
| 82 |
+
- Raises immediately on structural 400 errors (do not waste retries).
|
| 83 |
+
- Returns the raw Groq ChatCompletion object on success.
|
| 84 |
+
"""
|
| 85 |
+
last_exc = None
|
| 86 |
+
|
| 87 |
+
for attempt in range(max_retries):
|
| 88 |
+
try:
|
| 89 |
+
kwargs = dict(
|
| 90 |
+
model=model,
|
| 91 |
+
messages=messages,
|
| 92 |
+
temperature=temperature,
|
| 93 |
+
max_tokens=max_tokens,
|
| 94 |
+
)
|
| 95 |
+
if tools:
|
| 96 |
+
kwargs["tools"] = tools
|
| 97 |
+
kwargs["tool_choice"] = tool_choice
|
| 98 |
+
if response_format:
|
| 99 |
+
kwargs["response_format"] = response_format
|
| 100 |
+
|
| 101 |
+
loop = asyncio.get_event_loop()
|
| 102 |
+
result = await loop.run_in_executor(
|
| 103 |
+
None,
|
| 104 |
+
lambda: groq_client.chat.completions.create(**kwargs),
|
| 105 |
+
)
|
| 106 |
+
return result
|
| 107 |
+
|
| 108 |
+
except Exception as exc:
|
| 109 |
+
last_exc = exc
|
| 110 |
+
if is_structural_error(exc):
|
| 111 |
+
raise # Never retry structural errors
|
| 112 |
+
if is_retryable_error(exc):
|
| 113 |
+
delay = (base_delay * (2 ** attempt)) + random.uniform(0, 0.5)
|
| 114 |
+
logger.warning(
|
| 115 |
+
f"[groq_call] {model} attempt {attempt + 1}/{max_retries} "
|
| 116 |
+
f"retrying in {delay:.1f}s. Error: {exc}"
|
| 117 |
+
)
|
| 118 |
+
await asyncio.sleep(delay)
|
| 119 |
+
else:
|
| 120 |
+
raise
|
| 121 |
+
|
| 122 |
+
raise RuntimeError(
|
| 123 |
+
f"groq_call: all {max_retries} retries exhausted for model={model}"
|
| 124 |
+
) from last_exc
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def extract_failed_generation(exc: Exception) -> str:
|
| 128 |
+
"""Pull the failed_generation string from a Groq 400 body, or return empty string."""
|
| 129 |
+
body = getattr(exc, "body", {}) or {}
|
| 130 |
+
if isinstance(body, dict):
|
| 131 |
+
return body.get("error", {}).get("failed_generation", "") or ""
|
| 132 |
+
return ""
|
|
@@ -1,618 +1,20 @@
|
|
| 1 |
-
import csv
|
| 2 |
-
import io
|
| 3 |
-
import json
|
| 4 |
-
import re
|
| 5 |
-
import os
|
| 6 |
-
import time
|
| 7 |
-
import requests
|
| 8 |
-
import numpy as np
|
| 9 |
-
from groq import Groq
|
| 10 |
-
from services.tools import REAL_ESTATE_TOOLS, COMMERCIAL_TOOLS
|
| 11 |
-
from services.supabase_service import execute_tool_rpc, search_methodology_rag
|
| 12 |
-
from services.embedding_service import get_embedding_model
|
| 13 |
-
from services.observability import setup_logger
|
| 14 |
-
from config import (
|
| 15 |
-
GROQ_FALLBACK_ROUTE_MODEL,
|
| 16 |
-
GROQ_FALLBACK_SYNTHESIS_MODEL,
|
| 17 |
-
GROQ_API_KEY,
|
| 18 |
-
MAPBOX_ACCESS_TOKEN,
|
| 19 |
-
CONTACT_EMAIL,
|
| 20 |
-
CONTACT_WHATSAPP,
|
| 21 |
-
GROQ_ROUTE_MODEL,
|
| 22 |
-
GROQ_SYNTHESIS_MODEL,
|
| 23 |
-
)
|
| 24 |
-
from langfuse import observe, propagate_attributes, get_client
|
| 25 |
-
import langfuse
|
| 26 |
-
|
| 27 |
-
logger = setup_logger(__name__)
|
| 28 |
-
|
| 29 |
-
session_history = {}
|
| 30 |
-
|
| 31 |
-
groq_client = Groq(api_key=GROQ_API_KEY)
|
| 32 |
-
embedder = get_embedding_model()
|
| 33 |
-
|
| 34 |
-
try:
|
| 35 |
-
if os.path.exists("section_title_embeddings.npy") and os.path.exists(
|
| 36 |
-
"section_titles.json"
|
| 37 |
-
):
|
| 38 |
-
section_title_embeddings = np.load("section_title_embeddings.npy")
|
| 39 |
-
with open("section_titles.json", "r", encoding="utf-8") as f:
|
| 40 |
-
section_titles = json.load(f)
|
| 41 |
-
logger.info(
|
| 42 |
-
f"Loaded {len(section_titles)} local section titles for pre-routing."
|
| 43 |
-
)
|
| 44 |
-
else:
|
| 45 |
-
section_title_embeddings = None
|
| 46 |
-
section_titles = []
|
| 47 |
-
except Exception as e:
|
| 48 |
-
logger.error(f"Failed to load local section title embeddings: {e}")
|
| 49 |
-
section_title_embeddings = None
|
| 50 |
-
section_titles = []
|
| 51 |
-
|
| 52 |
-
ROUTER_PROMPT = """You are the classification router for the Joule Dynamics Real Estate Intelligence Layer.
|
| 53 |
-
Analyze the user query and classify it into EXACTLY ONE of six classifications:
|
| 54 |
-
|
| 55 |
-
1. "OUT_OF_SCOPE": Query asks about topics completely unrelated to real estate, data monitoring, or the current conversation (e.g. sports, general coding, cooking, recipes). IMPORTANT: Questions asking who you are ("who are you?"), what model you are, or asking to summarize/recap what was discussed in the current chat ("what have we discussed so far?", "summarize our chat") are ALWAYS IN-SCOPE and must NEVER be classified as OUT_OF_SCOPE.
|
| 56 |
-
2. "PATH_A": Query asks a live-data question (prices, spikes, availability, market averages, KPIs, specific listing rates).
|
| 57 |
-
3. "PATH_B": Query asks a dashboard visual/UI question (e.g. "What do the top 4 metric cards mean?", "Why does the table say 'Was Available'?", "What do the green/red map pins mean?", "Why is the chart line dotted?", "What does the sparkline mean?", "Why is a row dimmed?"), OR asks a real estate methodology/business logic question (7-day average definition, 2-night check-in window, 4x daily scrape cadence, booked vs host-blocked, temporal UX states), OR asks a meta-conversational question (e.g. "What have we discussed so far?", "Can you summarize our conversation?", "Recap what we talked about").
|
| 58 |
-
4. "BOTH": Query requires BOTH explaining a dashboard UI/methodology concept AND fetching live data metrics via tools.
|
| 59 |
-
5. "GREETING": User is saying hello, thanking the assistant, asking "who are you?", or making casual conversation.
|
| 60 |
-
6. "COMMERCIAL_HANDOFF": Query asks about getting started, hiring Joule Dynamics, custom builds, custom dashboards, pricing for software, or requests tracking for their own specific portfolio outside the demo scope.
|
| 61 |
-
|
| 62 |
-
Respond ONLY with valid JSON matching this schema:
|
| 63 |
-
{"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING" | "COMMERCIAL_HANDOFF", "reason": "1-sentence justification"}
|
| 64 |
"""
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
3. ZERO GUESSING: If data or methodology is missing, state plainly: "I don't have that information in the current real estate scope."
|
| 80 |
-
4. FORMAT: Always format your final output in valid Markdown. Use tables, bold headers, and bulleted lists. NEVER include technical debugging headers or metadata in your response.
|
| 81 |
-
5. CLARIFICATION & ERRORS: If a tool is missing parameters or returns an error, DO NOT hallucinate inputs. Provide a human-friendly response asking for clarification. To provide clickable options, include this exact JSON block at the very end of your response:
|
| 82 |
-
```json
|
| 83 |
-
{"clarification_options": ["Option A", "Option B"]}
|
| 84 |
-
ADVISORY & STRATEGY RESPONSES: When a user asks for pricing recommendations, use a strict two-part structure:
|
| 85 |
-
What the data shows: Present only facts derived directly from tool outputs.
|
| 86 |
-
Suggested approach (data-informed): Offer strategic interpretation based on the data.
|
| 87 |
-
Every advisory response MUST close with this disclaimer on its own line:
|
| 88 |
-
|
| 89 |
-
β οΈ This is a data-informed observation, not professional pricing or financial advice.
|
| 90 |
-
|
| 91 |
-
REPORTS & DOWNLOADABLE EXPORTS:
|
| 92 |
-
- When a user asks for a report, analysis, or rate breakdown (e.g. "Prepare a report for me on the rate changes in the last 14 days", "Give me a market summary"):
|
| 93 |
-
1. Retrieve the necessary data using live real estate tools (e.g. `get_market_snapshot`, `get_property_rate_changes`, `get_dashboard_kpis`).
|
| 94 |
-
2. If the user explicitly asks to download, export, or save a report file, invoke `generate_data_export` with format "md" containing the complete Markdown report content.
|
| 95 |
-
3. Synthesize the findings directly into your response using clear Markdown tables, headers, and bullet points. If a download URL is returned from `generate_data_export`, include it at the top or bottom as a clean link: `[Download Markdown Report](<download_url>)`.
|
| 96 |
-
|
| 97 |
-
DASHBOARD UI & VISUAL GUIDANCE: When a user asks questions about what they see on the Real Estate Intelligence Dashboard:
|
| 98 |
-
- Explain visual elements in simple, non-technical language tailored to property managers and investors.
|
| 99 |
-
- Top KPI Cards: Explain that the top cards dynamically calculate metrics over the currently filtered properties (Properties Tracked, 7-day Rate Changes, 25%+ Spikes, and Scrape Health).
|
| 100 |
-
- Nightly Rate History Chart: Clarify that the solid line is the actual recorded nightly rate and the dotted line is the 7-day rolling trailing average baseline.
|
| 101 |
-
- Temporal UX States & Badges: Explain that changing the Stay Dates filter triggers Present Mode ('YES'/'NO', relative times, 'STALE' warning), Historical Mode (past stay dates: 'Was Available'/'Was Booked', recorded dates), or Future Mode ('Pre-open'/'Pre-booked', projected anomalies).
|
| 102 |
-
- Property Rate Table: Clarify percentage deviations vs 7d avg, inline mini sparklines on unavailable properties showing the last 5 known prices, and 60% row opacity dimming for stale data older than 24 hours.
|
| 103 |
-
- Interactive Map: Explain color-coded pins (green=available, red=booked/unavailable, grey=no rate), circular numbered cluster badges, and popup cards.
|
| 104 |
-
- Troubleshooting Missing Listings: Guide the user to check active Global Filters (Market, Bedrooms, Status, Stay Date range) or map zoom bounds.
|
| 105 |
-
|
| 106 |
-
CONVERSATION RECAPS & SUMMARIES: When a user asks to summarize what was discussed, recap findings, or review earlier turns (e.g. "What have we discussed so far?"), review the conversation history in context and provide a structured, bullet-point summary of all properties, metrics, markets, and questions discussed in the session.
|
| 107 |
-
|
| 108 |
-
COMMERCIAL HANDOFFS (JOULE DYNAMICS BESPOKE BUILDS):
|
| 109 |
-
When a user asks about custom builds, deploying this system for their business, getting custom dashboards, pricing, hiring us, or tracking their own portfolio outside this sandbox:
|
| 110 |
-
1. Clarify that this rate monitor is a live sandbox demonstration built by Joule Dynamics. Pulse AI is just one specialized component of Joule Dynamics' broader data architecture and engineering capabilities.
|
| 111 |
-
2. Frame the solution around Joule Dynamics designing and building private, bespoke data architectures, dedicated scrapers, custom dashboards, and automated pipelines tailored to the client's specific problem (e.g. their portfolio size, target markets, or custom metrics).
|
| 112 |
-
3. Focus your answer directly on solving the client's specific problem. Do NOT refer to Joule Dynamics or the custom build as "Pulse AI".
|
| 113 |
-
4. Invoke the generate_contact_buttons tool with a personalized greeting message for WhatsApp referencing Joule Dynamics and the client's specific inquiry (e.g. "Hi John, I would like to discuss a custom build by Joule Dynamics for my 50 units in Brickell.").
|
| 114 |
-
5. Include the exact markdown buttons returned by the tool at the end of your response to render the action buttons correctly.
|
| 115 |
"""
|
| 116 |
|
|
|
|
| 117 |
|
| 118 |
-
|
| 119 |
-
# MAX rows the LLM receives from any single tool call. Beyond this, data is
|
| 120 |
-
# sliced and the LLM is told to recommend narrowing filters.
|
| 121 |
-
_MAX_ROWS = 50
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def parse_failed_generation(failed_gen: str):
|
| 125 |
-
"""Extracts tool name and arguments from Groq 400 failed_generation string."""
|
| 126 |
-
if not failed_gen:
|
| 127 |
-
return None, None
|
| 128 |
-
try:
|
| 129 |
-
data = json.loads(failed_gen)
|
| 130 |
-
if isinstance(data, dict) and "name" in data:
|
| 131 |
-
args = data.get("arguments", {})
|
| 132 |
-
if isinstance(args, str):
|
| 133 |
-
try:
|
| 134 |
-
args = json.loads(args)
|
| 135 |
-
except Exception:
|
| 136 |
-
pass
|
| 137 |
-
return data["name"], args
|
| 138 |
-
except Exception:
|
| 139 |
-
pass
|
| 140 |
-
|
| 141 |
-
match = re.search(r'\{\s*"name"\s*:\s*"([a-zA-Z0-9_]+)"\s*,\s*"arguments"\s*:\s*(\{.*?\})\s*\}', failed_gen, re.DOTALL)
|
| 142 |
-
if match:
|
| 143 |
-
try:
|
| 144 |
-
return match.group(1), json.loads(match.group(2))
|
| 145 |
-
except Exception:
|
| 146 |
-
pass
|
| 147 |
-
|
| 148 |
-
match = re.search(r'<function=([a-zA-Z0-9_]+)[>\s]*(\{.*?\})', failed_gen, re.DOTALL)
|
| 149 |
-
if match:
|
| 150 |
-
try:
|
| 151 |
-
return match.group(1), json.loads(match.group(2))
|
| 152 |
-
except Exception:
|
| 153 |
-
pass
|
| 154 |
-
|
| 155 |
-
return None, None
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
def compress_tool_output(func_name: str, db_result: dict) -> str:
|
| 159 |
-
"""
|
| 160 |
-
Converts raw tool RPC responses into a token-efficient string for LLM context.
|
| 161 |
-
|
| 162 |
-
Three-step compression pipeline:
|
| 163 |
-
1. Metadata hoisting β keys with identical values across all rows extracted
|
| 164 |
-
to a single header line, removing them from every row.
|
| 165 |
-
2. Null stripping β any key with a null/None value in a row is omitted.
|
| 166 |
-
3. CSV rendering β remaining data written as CSV (headers once, values compact).
|
| 167 |
-
|
| 168 |
-
For non-tabular (scalar/dict) results, returns a minimal string representation.
|
| 169 |
-
Estimated reduction: 70β90% vs raw JSON for time-series data.
|
| 170 |
-
"""
|
| 171 |
-
if db_result.get("status") != "success":
|
| 172 |
-
msg = db_result.get("message", "unknown error")
|
| 173 |
-
return f"Tool '{func_name}' error: {msg}"
|
| 174 |
-
|
| 175 |
-
if "data" in db_result:
|
| 176 |
-
data = db_result.get("data")
|
| 177 |
-
else:
|
| 178 |
-
data = {k: v for k, v in db_result.items() if k != "status"}
|
| 179 |
-
|
| 180 |
-
# ββ Scalar / single-object results ββββββββββββββββββββββββββββββββββββββ
|
| 181 |
-
if data is None or (isinstance(data, dict) and len(data) == 0):
|
| 182 |
-
return f"Tool '{func_name}': no data returned."
|
| 183 |
-
|
| 184 |
-
if isinstance(data, (str, int, float, bool)):
|
| 185 |
-
return f"Tool '{func_name}' result: {data}"
|
| 186 |
-
|
| 187 |
-
if isinstance(data, dict):
|
| 188 |
-
# Already a compact object (e.g. get_rate_anomaly_report, get_market_trend)
|
| 189 |
-
# Just serialise cleanly β these are already small
|
| 190 |
-
return f"Tool '{func_name}' result:\n{json.dumps(data, default=str)}"
|
| 191 |
-
|
| 192 |
-
if not isinstance(data, list) or len(data) == 0:
|
| 193 |
-
return f"Tool '{func_name}': empty result."
|
| 194 |
-
|
| 195 |
-
# ββ List of rows βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 196 |
-
# Filter out rows where everything is None (pure null rows add no signal)
|
| 197 |
-
data = [
|
| 198 |
-
row
|
| 199 |
-
for row in data
|
| 200 |
-
if isinstance(row, dict) and any(v is not None for v in row.values())
|
| 201 |
-
]
|
| 202 |
-
|
| 203 |
-
if not data:
|
| 204 |
-
return f"Tool '{func_name}': all returned rows were empty."
|
| 205 |
-
|
| 206 |
-
# Truncate before processing to cap token exposure
|
| 207 |
-
truncated = False
|
| 208 |
-
if len(data) > _MAX_ROWS:
|
| 209 |
-
data = data[:_MAX_ROWS]
|
| 210 |
-
truncated = True
|
| 211 |
-
|
| 212 |
-
# ββ Step 1: Metadata hoisting ββββββββββββββββββββββββββββββββββββββββββββ
|
| 213 |
-
all_keys = list(data[0].keys())
|
| 214 |
-
hoisted = {}
|
| 215 |
-
row_keys = []
|
| 216 |
-
for key in all_keys:
|
| 217 |
-
unique_values = {str(row.get(key)) for row in data}
|
| 218 |
-
if len(unique_values) == 1:
|
| 219 |
-
val = data[0].get(key)
|
| 220 |
-
if val is not None:
|
| 221 |
-
hoisted[key] = val
|
| 222 |
-
else:
|
| 223 |
-
row_keys.append(key)
|
| 224 |
-
|
| 225 |
-
header_parts = [f"{k}={v}" for k, v in hoisted.items()]
|
| 226 |
-
header_str = f"[{', '.join(header_parts)}]\n" if header_parts else ""
|
| 227 |
-
|
| 228 |
-
# ββ Step 2 & 3: Null-strip + CSV βββββββββββββββββββββββββββββββββββββββββ
|
| 229 |
-
# Determine which row-level keys have at least one non-null value across all rows
|
| 230 |
-
active_keys = [k for k in row_keys if any(row.get(k) is not None for row in data)]
|
| 231 |
-
|
| 232 |
-
buf = io.StringIO()
|
| 233 |
-
writer = csv.writer(buf)
|
| 234 |
-
writer.writerow(active_keys)
|
| 235 |
-
for row in data:
|
| 236 |
-
writer.writerow([row.get(k) for k in active_keys])
|
| 237 |
-
|
| 238 |
-
truncation_notice = (
|
| 239 |
-
f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
|
| 240 |
-
if truncated
|
| 241 |
-
else ""
|
| 242 |
-
)
|
| 243 |
-
return f"{header_str}{buf.getvalue().strip()}{truncation_notice}"
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# βββ GEOCODE HANDLER (Python-side, Mapbox API) ββββββββββββββββββββββββββββββββ
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
def geocode_address_handler(address: str) -> dict:
|
| 250 |
-
"""
|
| 251 |
-
Resolves a free-text address to lat/lng via the Mapbox Geocoding API.
|
| 252 |
-
Handles network failures, API errors, and empty results gracefully.
|
| 253 |
-
US-only results, single best match returned.
|
| 254 |
-
"""
|
| 255 |
-
if not MAPBOX_ACCESS_TOKEN:
|
| 256 |
-
return {
|
| 257 |
-
"status": "error",
|
| 258 |
-
"message": "We are unable to geocode addresses at this time β the mapping service is not configured.",
|
| 259 |
-
}
|
| 260 |
-
if not address or not address.strip():
|
| 261 |
-
return {"status": "error", "message": "No address was provided to geocode."}
|
| 262 |
-
|
| 263 |
-
url = "https://api.mapbox.com/search/geocode/v6/forward"
|
| 264 |
-
params = {
|
| 265 |
-
"q": address.strip(),
|
| 266 |
-
"access_token": MAPBOX_ACCESS_TOKEN,
|
| 267 |
-
"country": "US",
|
| 268 |
-
"limit": 1,
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
try:
|
| 272 |
-
response = requests.get(url, params=params, timeout=6)
|
| 273 |
-
response.raise_for_status()
|
| 274 |
-
data = response.json()
|
| 275 |
-
|
| 276 |
-
features = data.get("features", [])
|
| 277 |
-
if not features:
|
| 278 |
-
return {
|
| 279 |
-
"status": "error",
|
| 280 |
-
"message": f"We are unable to locate coordinates for '{address}' at this time. Please try a more specific address or city name.",
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
feature = features[0]
|
| 284 |
-
lon, lat = feature["geometry"]["coordinates"]
|
| 285 |
-
resolved = feature.get("properties", {}).get("full_address", address)
|
| 286 |
-
return {
|
| 287 |
-
"status": "success",
|
| 288 |
-
"latitude": round(lat, 6),
|
| 289 |
-
"longitude": round(lon, 6),
|
| 290 |
-
"resolved_address": resolved,
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
except requests.exceptions.Timeout:
|
| 294 |
-
logger.error(f"Mapbox geocode timeout for address: {address}")
|
| 295 |
-
return {
|
| 296 |
-
"status": "error",
|
| 297 |
-
"message": "We are unable to geocode this address at this time β the mapping service timed out. Please try again shortly.",
|
| 298 |
-
}
|
| 299 |
-
except requests.exceptions.HTTPError as e:
|
| 300 |
-
logger.error(
|
| 301 |
-
f"Mapbox geocode HTTP error {e.response.status_code} for: {address}"
|
| 302 |
-
)
|
| 303 |
-
return {
|
| 304 |
-
"status": "error",
|
| 305 |
-
"message": "We are unable to geocode this address at this time due to a service error. Please try again later.",
|
| 306 |
-
}
|
| 307 |
-
except requests.exceptions.RequestException as e:
|
| 308 |
-
logger.error(f"Mapbox geocode request failed for '{address}': {e}")
|
| 309 |
-
return {
|
| 310 |
-
"status": "error",
|
| 311 |
-
"message": "We are unable to reach the mapping service at this time. Please try again later.",
|
| 312 |
-
}
|
| 313 |
-
except (KeyError, IndexError, ValueError) as e:
|
| 314 |
-
logger.error(f"Mapbox geocode parsing error for '{address}': {e}")
|
| 315 |
-
return {
|
| 316 |
-
"status": "error",
|
| 317 |
-
"message": f"We are unable to parse the location for '{address}'. Please try a more specific address.",
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
# βββ MAIN CHAT HANDLER ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
@observe(name="process-chat")
|
| 325 |
-
async def process_chat_message(
|
| 326 |
-
user_query: str, session_id: str, session_context: dict
|
| 327 |
-
) -> dict:
|
| 328 |
-
global section_title_embeddings, section_titles
|
| 329 |
-
start_time = time.time()
|
| 330 |
-
|
| 331 |
-
with propagate_attributes(session_id=session_id, tags=["real-estate-chat"]):
|
| 332 |
-
get_client().update_current_span(input=user_query)
|
| 333 |
-
|
| 334 |
-
if session_id not in session_history:
|
| 335 |
-
session_history[session_id] = []
|
| 336 |
-
|
| 337 |
-
# STEP 1: Pre-Router Local Vector Search
|
| 338 |
-
pre_check_hint = ""
|
| 339 |
-
if section_title_embeddings is None and os.path.exists(
|
| 340 |
-
"section_title_embeddings.npy"
|
| 341 |
-
):
|
| 342 |
-
try:
|
| 343 |
-
section_title_embeddings = np.load("section_title_embeddings.npy")
|
| 344 |
-
with open("section_titles.json", "r", encoding="utf-8") as f:
|
| 345 |
-
section_titles = json.load(f)
|
| 346 |
-
except Exception as e:
|
| 347 |
-
logger.error(f"Lazy load failed: {e}")
|
| 348 |
-
|
| 349 |
-
if section_title_embeddings is not None and len(section_titles) > 0:
|
| 350 |
-
query_emb = embedder.encode([user_query], normalize_embeddings=True)[0]
|
| 351 |
-
sims = section_title_embeddings @ query_emb
|
| 352 |
-
top_idx = np.argsort(sims)[::-1][:3]
|
| 353 |
-
matched_titles = [section_titles[i] for i in top_idx if sims[i] >= 0.45]
|
| 354 |
-
|
| 355 |
-
if matched_titles:
|
| 356 |
-
pre_check_hint = f"\n\nLocal Methodology Pre-Check: High similarity match with section titles: {matched_titles}. Consider classifying as PATH_B or BOTH."
|
| 357 |
-
get_client().update_current_span(
|
| 358 |
-
metadata={
|
| 359 |
-
"matched_section_titles": matched_titles,
|
| 360 |
-
"similarity_scores": [
|
| 361 |
-
float(sims[i]) for i in top_idx if sims[i] >= 0.45
|
| 362 |
-
],
|
| 363 |
-
}
|
| 364 |
-
)
|
| 365 |
-
|
| 366 |
-
router_sys_prompt = ROUTER_PROMPT + pre_check_hint
|
| 367 |
-
router_messages = [{"role": "system", "content": router_sys_prompt}]
|
| 368 |
-
|
| 369 |
-
# Router only needs last 4 history messages β cheap model, keep it lean
|
| 370 |
-
router_messages.extend(session_history[session_id][-4:])
|
| 371 |
-
router_messages.append({"role": "user", "content": user_query})
|
| 372 |
-
|
| 373 |
-
try:
|
| 374 |
-
router_res = groq_client.chat.completions.create(
|
| 375 |
-
model=GROQ_ROUTE_MODEL,
|
| 376 |
-
messages=router_messages,
|
| 377 |
-
temperature=0.0,
|
| 378 |
-
max_tokens=600,
|
| 379 |
-
response_format={"type": "json_object"},
|
| 380 |
-
)
|
| 381 |
-
routing = json.loads(router_res.choices[0].message.content)
|
| 382 |
-
except Exception as router_err:
|
| 383 |
-
logger.warning(f"Router model {GROQ_ROUTE_MODEL} failed: {router_err}. Trying fallback {GROQ_FALLBACK_ROUTE_MODEL}...")
|
| 384 |
-
try:
|
| 385 |
-
router_res = groq_client.chat.completions.create(
|
| 386 |
-
model=GROQ_FALLBACK_ROUTE_MODEL,
|
| 387 |
-
messages=router_messages,
|
| 388 |
-
temperature=0.0,
|
| 389 |
-
max_tokens=600,
|
| 390 |
-
)
|
| 391 |
-
raw_content = router_res.choices[0].message.content
|
| 392 |
-
cleaned_content = re.sub(r'<think>.*?</think>', '', raw_content, flags=re.DOTALL).strip()
|
| 393 |
-
# If JSON parseable, load it; otherwise extract tag directly
|
| 394 |
-
try:
|
| 395 |
-
routing = json.loads(cleaned_content)
|
| 396 |
-
except Exception:
|
| 397 |
-
for tag in ["OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING", "COMMERCIAL_HANDOFF"]:
|
| 398 |
-
if tag in cleaned_content:
|
| 399 |
-
routing = {"classification": tag, "reason": "Fallback extracted"}
|
| 400 |
-
break
|
| 401 |
-
else:
|
| 402 |
-
routing = {"classification": "PATH_A"}
|
| 403 |
-
except Exception as fb_err:
|
| 404 |
-
logger.error(f"Fallback router {GROQ_FALLBACK_ROUTE_MODEL} also failed: {fb_err}. Defaulting to PATH_A.")
|
| 405 |
-
routing = {"classification": "PATH_A"}
|
| 406 |
-
|
| 407 |
-
if isinstance(routing, list) and len(routing) > 0:
|
| 408 |
-
routing = routing[0]
|
| 409 |
-
elif not isinstance(routing, dict):
|
| 410 |
-
routing = {}
|
| 411 |
-
|
| 412 |
-
classification = routing.get("classification", "PATH_A")
|
| 413 |
-
reason = routing.get("reason")
|
| 414 |
-
if classification not in [
|
| 415 |
-
"OUT_OF_SCOPE",
|
| 416 |
-
"PATH_A",
|
| 417 |
-
"PATH_B",
|
| 418 |
-
"BOTH",
|
| 419 |
-
"GREETING",
|
| 420 |
-
"COMMERCIAL_HANDOFF",
|
| 421 |
-
]:
|
| 422 |
-
classification = "PATH_A"
|
| 423 |
-
|
| 424 |
-
get_client().update_current_span(
|
| 425 |
-
metadata={"classification": classification, "reason": reason}
|
| 426 |
-
)
|
| 427 |
-
|
| 428 |
-
# Guardrail: Immediate short-circuit if Out of Scope
|
| 429 |
-
if classification == "OUT_OF_SCOPE":
|
| 430 |
-
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."
|
| 431 |
-
get_client().update_current_span(output=reply_out)
|
| 432 |
-
return {
|
| 433 |
-
"reply": reply_out,
|
| 434 |
-
"path_used": "OUT_OF_SCOPE",
|
| 435 |
-
"tools_called": [],
|
| 436 |
-
"suggested_actions": [],
|
| 437 |
-
}
|
| 438 |
-
|
| 439 |
-
if classification == "GREETING":
|
| 440 |
-
reply_greeting = "Hello! I'm Pulse AI, a Real Estate Intelligence Assistant for Joule Dynamics. I can help you analyze rate spikes, market trends, pricing volatility, and availability data across our tracked markets. How can I assist you today?"
|
| 441 |
-
get_client().update_current_span(output=reply_greeting)
|
| 442 |
-
return {
|
| 443 |
-
"reply": reply_greeting,
|
| 444 |
-
"path_used": "GREETING",
|
| 445 |
-
"tools_called": [],
|
| 446 |
-
"suggested_actions": [],
|
| 447 |
-
}
|
| 448 |
-
|
| 449 |
-
tool_results = []
|
| 450 |
-
rag_chunks = []
|
| 451 |
-
|
| 452 |
-
# STEP 2: Execute Vector Search if Path B, Both, or Commercial Handoff
|
| 453 |
-
if classification in ["PATH_B", "BOTH", "COMMERCIAL_HANDOFF"]:
|
| 454 |
-
rag_chunks = await search_methodology_rag(user_query)
|
| 455 |
-
|
| 456 |
-
messages = [{"role": "system", "content": SYNTHESIS_PROMPT}]
|
| 457 |
-
|
| 458 |
-
# Cap history at 6 messages to avoid token inflation in multi-turn sessions
|
| 459 |
-
messages.extend(session_history[session_id][-6:])
|
| 460 |
-
|
| 461 |
-
user_msg_content = (
|
| 462 |
-
f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
|
| 463 |
-
)
|
| 464 |
-
messages.append({"role": "user", "content": user_msg_content})
|
| 465 |
-
session_history[session_id].append({"role": "user", "content": user_msg_content})
|
| 466 |
-
|
| 467 |
-
if rag_chunks:
|
| 468 |
-
messages.append(
|
| 469 |
-
{
|
| 470 |
-
"role": "system",
|
| 471 |
-
"content": "Retrieved Methodology Context:\n"
|
| 472 |
-
+ "\n---\n".join(rag_chunks),
|
| 473 |
-
}
|
| 474 |
-
)
|
| 475 |
-
|
| 476 |
-
# Select active tools based on classification
|
| 477 |
-
active_tools = None
|
| 478 |
-
if classification in ["PATH_A", "BOTH"]:
|
| 479 |
-
active_tools = REAL_ESTATE_TOOLS
|
| 480 |
-
elif classification == "COMMERCIAL_HANDOFF":
|
| 481 |
-
active_tools = COMMERCIAL_TOOLS
|
| 482 |
-
|
| 483 |
-
# STEP 3 & 4: Multi-Step Brain Completion & Tool Execution
|
| 484 |
-
final_reply = None
|
| 485 |
-
response_message = None
|
| 486 |
-
|
| 487 |
-
for model_candidate in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
|
| 488 |
-
try:
|
| 489 |
-
brain_res = groq_client.chat.completions.create(
|
| 490 |
-
model=model_candidate,
|
| 491 |
-
messages=messages,
|
| 492 |
-
tools=active_tools if active_tools else None,
|
| 493 |
-
tool_choice="auto" if active_tools else "none",
|
| 494 |
-
temperature=0.2,
|
| 495 |
-
max_tokens=1000,
|
| 496 |
-
)
|
| 497 |
-
response_message = brain_res.choices[0].message
|
| 498 |
-
break
|
| 499 |
-
except Exception as e:
|
| 500 |
-
logger.warning(f"Groq completion on {model_candidate} failed: {e}")
|
| 501 |
-
err_body = getattr(e, "body", {})
|
| 502 |
-
if isinstance(err_body, dict):
|
| 503 |
-
failed_gen = err_body.get("error", {}).get("failed_generation", "")
|
| 504 |
-
target_fn, fn_args = parse_failed_generation(failed_gen)
|
| 505 |
-
if target_fn and isinstance(fn_args, dict):
|
| 506 |
-
from types import SimpleNamespace
|
| 507 |
-
tool_call_obj = SimpleNamespace(
|
| 508 |
-
id=f"recov_{int(time.time())}",
|
| 509 |
-
function=SimpleNamespace(name=target_fn, arguments=json.dumps(fn_args))
|
| 510 |
-
)
|
| 511 |
-
response_message = SimpleNamespace(content=None, tool_calls=[tool_call_obj])
|
| 512 |
-
logger.info(f"Recovered tool call: {target_fn}")
|
| 513 |
-
break
|
| 514 |
-
|
| 515 |
-
if getattr(response_message, "tool_calls", None):
|
| 516 |
-
messages.append(response_message)
|
| 517 |
-
for tool_call in response_message.tool_calls:
|
| 518 |
-
func_name = tool_call.function.name
|
| 519 |
-
try:
|
| 520 |
-
func_args = json.loads(tool_call.function.arguments) if isinstance(tool_call.function.arguments, str) else (tool_call.function.arguments or {})
|
| 521 |
-
except Exception:
|
| 522 |
-
func_args = {}
|
| 523 |
-
|
| 524 |
-
# Execute tool
|
| 525 |
-
if func_name == "generate_data_export":
|
| 526 |
-
from services.appwrite_service import upload_document_to_appwrite
|
| 527 |
-
content = func_args.get("content", "")
|
| 528 |
-
db_result = await upload_document_to_appwrite(content, "md")
|
| 529 |
-
|
| 530 |
-
elif func_name == "geocode_address":
|
| 531 |
-
db_result = geocode_address_handler(func_args.get("address", ""))
|
| 532 |
-
|
| 533 |
-
elif func_name == "generate_contact_buttons":
|
| 534 |
-
import urllib.parse
|
| 535 |
-
raw_message = func_args.get("message", "Hi, I'd like to discuss a custom build.")
|
| 536 |
-
encoded_message = urllib.parse.quote(raw_message)
|
| 537 |
-
db_result = {
|
| 538 |
-
"status": "success",
|
| 539 |
-
"email_button_markdown": f"[Get in touch via Email](mailto:{CONTACT_EMAIL})",
|
| 540 |
-
"whatsapp_button_markdown": f"[Chat on WhatsApp](https://wa.me/{CONTACT_WHATSAPP}?text={encoded_message})",
|
| 541 |
-
}
|
| 542 |
-
else:
|
| 543 |
-
db_result = await execute_tool_rpc(func_name, func_args)
|
| 544 |
-
|
| 545 |
-
tool_results.append({"tool": func_name, "args": func_args})
|
| 546 |
-
compressed_content = compress_tool_output(func_name, db_result)
|
| 547 |
-
messages.append({
|
| 548 |
-
"tool_call_id": tool_call.id,
|
| 549 |
-
"role": "tool",
|
| 550 |
-
"name": func_name,
|
| 551 |
-
"content": compressed_content,
|
| 552 |
-
})
|
| 553 |
-
|
| 554 |
-
# Second Brain Call for final response synthesis
|
| 555 |
-
for model_candidate in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
|
| 556 |
-
try:
|
| 557 |
-
final_res = groq_client.chat.completions.create(
|
| 558 |
-
model=model_candidate,
|
| 559 |
-
messages=messages,
|
| 560 |
-
temperature=0.2,
|
| 561 |
-
max_tokens=1000,
|
| 562 |
-
)
|
| 563 |
-
final_reply = final_res.choices[0].message.content
|
| 564 |
-
break
|
| 565 |
-
except Exception as synth_err:
|
| 566 |
-
logger.warning(f"Synthesis on {model_candidate} failed: {synth_err}")
|
| 567 |
-
err_body = getattr(synth_err, "body", {})
|
| 568 |
-
if isinstance(err_body, dict):
|
| 569 |
-
failed_gen = err_body.get("error", {}).get("failed_generation", "")
|
| 570 |
-
target_fn, fn_args = parse_failed_generation(failed_gen)
|
| 571 |
-
if target_fn == "generate_data_export" and isinstance(fn_args, dict):
|
| 572 |
-
from services.appwrite_service import upload_document_to_appwrite
|
| 573 |
-
content = fn_args.get("content", "")
|
| 574 |
-
upload_res = await upload_document_to_appwrite(content, "md")
|
| 575 |
-
download_url = upload_res.get("download_url", "")
|
| 576 |
-
if download_url:
|
| 577 |
-
final_reply = f"{content}\n\n[Download Markdown Report]({download_url})"
|
| 578 |
-
else:
|
| 579 |
-
final_reply = content
|
| 580 |
-
break
|
| 581 |
-
|
| 582 |
-
elif response_message and response_message.content:
|
| 583 |
-
final_reply = response_message.content
|
| 584 |
-
|
| 585 |
-
if not final_reply or final_reply.strip().startswith('{"name":') or final_reply.strip().startswith('<function='):
|
| 586 |
-
final_reply = "I retrieved the real estate intelligence data. Please let me know if you would like to view specific metrics or export this summary."
|
| 587 |
-
|
| 588 |
-
# Track assistant reply in history β cap at 6 to prevent token creep
|
| 589 |
-
session_history[session_id].append({"role": "assistant", "content": final_reply})
|
| 590 |
-
if len(session_history[session_id]) > 6:
|
| 591 |
-
session_history[session_id] = session_history[session_id][-6:]
|
| 592 |
-
|
| 593 |
-
# Parse clarification options
|
| 594 |
-
suggested_actions = []
|
| 595 |
-
json_match = re.search(
|
| 596 |
-
r'```json\s*(\{.*"clarification_options".*\})\s*```', final_reply, re.DOTALL
|
| 597 |
-
)
|
| 598 |
-
if not json_match:
|
| 599 |
-
json_match = re.search(
|
| 600 |
-
r'(\{.*"clarification_options".*\})', final_reply, re.DOTALL
|
| 601 |
-
)
|
| 602 |
-
|
| 603 |
-
if json_match:
|
| 604 |
-
try:
|
| 605 |
-
clarification_data = json.loads(json_match.group(1))
|
| 606 |
-
suggested_actions = clarification_data.get("clarification_options", [])
|
| 607 |
-
final_reply = final_reply.replace(json_match.group(0), "").strip()
|
| 608 |
-
except json.JSONDecodeError:
|
| 609 |
-
pass
|
| 610 |
-
|
| 611 |
-
get_client().update_current_span(output=final_reply)
|
| 612 |
-
|
| 613 |
-
return {
|
| 614 |
-
"reply": final_reply,
|
| 615 |
-
"path_used": classification,
|
| 616 |
-
"tools_called": tool_results,
|
| 617 |
-
"suggested_actions": suggested_actions,
|
| 618 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
services/groq_service.py
|
| 3 |
+
βββββββββββββββββββββββββ
|
| 4 |
+
Public facade β re-exports the two main entry points that external code imports.
|
| 5 |
+
|
| 6 |
+
This file is intentionally thin. All implementation lives in the split modules:
|
| 7 |
+
groq_client.py - Groq SDK + retry wrapper
|
| 8 |
+
prompts.py - Prompt strings + composition by classification
|
| 9 |
+
session.py - Session history with TTL eviction
|
| 10 |
+
tool_compressor.py - compress_tool_output + geocode_address_handler
|
| 11 |
+
tool_executor.py - Tool dispatch + message normalization
|
| 12 |
+
agent_loop.py - Agentic loop, router, orchestration, SSE streaming
|
| 13 |
+
|
| 14 |
+
Imports from external code should use this facade for stability:
|
| 15 |
+
from services.groq_service import process_chat_message, stream_chat_message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
+
from services.agent_loop import process_chat_message, stream_chat_message
|
| 19 |
|
| 20 |
+
__all__ = ["process_chat_message", "stream_chat_message"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/prompts.py
|
| 3 |
+
βββββββββββββββββββ
|
| 4 |
+
All LLM prompt strings for the Joule Dynamics Real Estate Intelligence Layer.
|
| 5 |
+
|
| 6 |
+
Architectural fix #3 - Prompt composition by classification.
|
| 7 |
+
Instead of sending the full ~3500-token monolith on every request, only the
|
| 8 |
+
sections relevant to the active classification path are assembled.
|
| 9 |
+
|
| 10 |
+
Estimated savings: 600-900 tokens per PATH_A turn (~25% reduction).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
# βββ ROUTER PROMPT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
|
| 15 |
+
ROUTER_PROMPT = """You are the classification router for the Joule Dynamics Real Estate Intelligence Layer.
|
| 16 |
+
Analyze the user query and classify it into EXACTLY ONE of six classifications:
|
| 17 |
+
|
| 18 |
+
1. "OUT_OF_SCOPE": Query asks about topics completely unrelated to real estate, data monitoring, or the current conversation (e.g. sports, general coding, cooking, recipes). IMPORTANT: Questions asking who you are ("who are you?"), what model you are, or asking to summarize/recap what was discussed in the current chat ("what have we discussed so far?", "summarize our chat") are ALWAYS IN-SCOPE and must NEVER be classified as OUT_OF_SCOPE.
|
| 19 |
+
2. "PATH_A": Query asks a live-data question (prices, spikes, availability, market averages, KPIs, specific listing rates).
|
| 20 |
+
3. "PATH_B": Query asks a dashboard visual/UI question (e.g. "What do the top 4 metric cards mean?", "Why does the table say 'Was Available'?", "What do the green/red map pins mean?", "Why is the chart line dotted?", "What does the sparkline mean?", "Why is a row dimmed?"), OR asks a real estate methodology/business logic question (7-day average definition, 2-night check-in window, 4x daily scrape cadence, booked vs host-blocked, temporal UX states), OR asks a meta-conversational question (e.g. "What have we discussed so far?", "Can you summarize our conversation?", "Recap what we talked about").
|
| 21 |
+
4. "BOTH": Query requires BOTH explaining a dashboard UI/methodology concept AND fetching live data metrics via tools.
|
| 22 |
+
5. "GREETING": User is saying hello, thanking the assistant, asking "who are you?", or making casual conversation.
|
| 23 |
+
6. "COMMERCIAL_HANDOFF": Query asks about getting started, hiring Joule Dynamics, custom builds, custom dashboards, pricing for software, or requests tracking for their own specific portfolio outside the demo scope.
|
| 24 |
+
|
| 25 |
+
Respond ONLY with valid JSON matching this schema:
|
| 26 |
+
{"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING" | "COMMERCIAL_HANDOFF", "reason": "1-sentence justification"}
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
# βββ SYNTHESIS PROMPT β CORE (always included) ββββββββββββββββββββββββββββββββ
|
| 30 |
+
|
| 31 |
+
_PROMPT_CORE = """You are Pulse AI, a Real Estate Intelligence Assistant for Joule Dynamics.
|
| 32 |
+
You provide precise data analysis to real estate investors and property managers reviewing short-term rental market performance. If asked about your identity or underlying model, identify yourself as Pulse AI, developed for Joule Dynamics.
|
| 33 |
+
|
| 34 |
+
IMMUTABLE SYSTEM BOUNDARIES & HARD FACTS:
|
| 35 |
+
1. SANDBOX SCOPE: The data you have access to is a proof-of-concept showcase tracking a curated sample of properties. It is NOT a complete city-wide market census. You must clarify this if a user attempts to use this data for macro-market investment decisions.
|
| 36 |
+
2. TRACKED MARKETS: You ONLY track two markets: 'NYC/NJ Metro' and 'Miami'.
|
| 37 |
+
3. TRACKED PLATFORMS: You ONLY track 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
|
| 38 |
+
4. ABSOLUTE FORBIDDEN ENTITIES: You must NEVER list, suggest, or mention any other cities or other booking platforms.
|
| 39 |
+
5. ZERO FABRICATION: Every single price, rate change percentage, property count, and availability status MUST come directly from a returned tool output.
|
| 40 |
+
|
| 41 |
+
OPERATIONAL RULES:
|
| 42 |
+
1. CONVERSATIONAL ELEGANCE (NO RAW TOOLS): NEVER output raw technical tool names (e.g., `get_market_averages`, `geocode_address`). NEVER dump long, robotic lists of your capabilities. If asked what you can do, reply conversationally and naturally (e.g., "I can track live rate spikes, calculate trailing averages, and analyze pricing volatility.").
|
| 43 |
+
2. NO RAW SQL: Never attempt to write or generate SQL queries. Rely strictly on the registered tool RPCs provided.
|
| 44 |
+
3. ZERO GUESSING: If data or methodology is missing, state plainly: "I don't have that information in the current real estate scope."
|
| 45 |
+
4. FORMAT: Always format your final output in valid Markdown. Use tables, bold headers, and bulleted lists. NEVER include technical debugging headers or metadata in your response.
|
| 46 |
+
5. CLARIFICATION & ERRORS: If a tool is missing parameters or returns an error, DO NOT hallucinate inputs. Provide a human-friendly response asking for clarification. To provide clickable options, include this exact JSON block at the very end of your response:
|
| 47 |
+
```json
|
| 48 |
+
{"clarification_options": ["Option A", "Option B"]}
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
# βββ PATH A EXTENSION (live data + advisory format) βββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
_PROMPT_PATH_A = """
|
| 54 |
+
ADVISORY & STRATEGY RESPONSES: When a user asks for pricing recommendations, use a strict two-part structure:
|
| 55 |
+
What the data shows: Present only facts derived directly from tool outputs.
|
| 56 |
+
Suggested approach (data-informed): Offer strategic interpretation based on the data.
|
| 57 |
+
Every advisory response MUST close with this disclaimer on its own line:
|
| 58 |
+
|
| 59 |
+
\u26a0\ufe0f This is a data-informed observation, not professional pricing or financial advice.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
# βββ REPORTS EXTENSION (only for PATH_A / BOTH) βββββββββββββββββββββββββββββββ
|
| 63 |
+
|
| 64 |
+
_PROMPT_REPORTS = """
|
| 65 |
+
REPORTS & DOWNLOADABLE EXPORTS:
|
| 66 |
+
- When a user asks for a report, analysis, or rate breakdown (e.g. "Prepare a report for me on the rate changes in the last 14 days", "Give me a market summary"):
|
| 67 |
+
1. Retrieve the necessary data using live real estate tools (e.g. `get_market_snapshot`, `get_property_rate_changes`, `get_dashboard_kpis`).
|
| 68 |
+
2. If the user explicitly asks to download, export, or save a report file, invoke `generate_data_export` with format "md" containing the complete Markdown report content.
|
| 69 |
+
3. Synthesize the findings directly into your response using clear Markdown tables, headers, and bullet points. If a download URL is returned from `generate_data_export`, include it at the top or bottom as a clean link: `[Download Markdown Report](<download_url>)`.
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
# βββ PATH B EXTENSION (dashboard UI + methodology) ββββββββββββββββββββββββββββ
|
| 73 |
+
|
| 74 |
+
_PROMPT_PATH_B = """
|
| 75 |
+
DASHBOARD UI & VISUAL GUIDANCE: When a user asks questions about what they see on the Real Estate Intelligence Dashboard:
|
| 76 |
+
- Explain visual elements in simple, non-technical language tailored to property managers and investors.
|
| 77 |
+
- Top KPI Cards: Explain that the top cards dynamically calculate metrics over the currently filtered properties (Properties Tracked, 7-day Rate Changes, 25%+ Spikes, and Scrape Health).
|
| 78 |
+
- Nightly Rate History Chart: Clarify that the solid line is the actual recorded nightly rate and the dotted line is the 7-day rolling trailing average baseline.
|
| 79 |
+
- Temporal UX States & Badges: Explain that changing the Stay Dates filter triggers Present Mode ('YES'/'NO', relative times, 'STALE' warning), Historical Mode (past stay dates: 'Was Available'/'Was Booked', recorded dates), or Future Mode ('Pre-open'/'Pre-booked', projected anomalies).
|
| 80 |
+
- Property Rate Table: Clarify percentage deviations vs 7d avg, inline mini sparklines on unavailable properties showing the last 5 known prices, and 60% row opacity dimming for stale data older than 24 hours.
|
| 81 |
+
- Interactive Map: Explain color-coded pins (green=available, red=booked/unavailable, grey=no rate), circular numbered cluster badges, and popup cards.
|
| 82 |
+
- Troubleshooting Missing Listings: Guide the user to check active Global Filters (Market, Bedrooms, Status, Stay Date range) or map zoom bounds.
|
| 83 |
+
"""
|
| 84 |
+
|
| 85 |
+
# βββ RECAP EXTENSION (PATH_B / BOTH) βββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
|
| 87 |
+
_PROMPT_RECAP = """
|
| 88 |
+
CONVERSATION RECAPS & SUMMARIES: When a user asks to summarize what was discussed, recap findings, or review earlier turns (e.g. "What have we discussed so far?"), review the conversation history in context and provide a structured, bullet-point summary of all properties, metrics, markets, and questions discussed in the session.
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
# βββ COMMERCIAL EXTENSION βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 92 |
+
|
| 93 |
+
_PROMPT_COMMERCIAL = """
|
| 94 |
+
COMMERCIAL HANDOFFS (JOULE DYNAMICS BESPOKE BUILDS):
|
| 95 |
+
When a user asks about custom builds, deploying this system for their business, getting custom dashboards, pricing, hiring us, or tracking their own portfolio outside this sandbox:
|
| 96 |
+
1. Clarify that this rate monitor is a live sandbox demonstration built by Joule Dynamics. Pulse AI is just one specialized component of Joule Dynamics' broader data architecture and engineering capabilities.
|
| 97 |
+
2. Frame the solution around Joule Dynamics designing and building private, bespoke data architectures, dedicated scrapers, custom dashboards, and automated pipelines tailored to the client's specific problem (e.g. their portfolio size, target markets, or custom metrics).
|
| 98 |
+
3. Focus your answer directly on solving the client's specific problem. Do NOT refer to Joule Dynamics or the custom build as "Pulse AI".
|
| 99 |
+
4. Invoke the generate_contact_buttons tool with a personalized greeting message for WhatsApp referencing Joule Dynamics and the client's specific inquiry (e.g. "Hi John, I would like to discuss a custom build by Joule Dynamics for my 50 units in Brickell.").
|
| 100 |
+
5. Include the exact markdown buttons returned by the tool at the end of your response to render the action buttons correctly.
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# βββ COMPOSER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
+
|
| 106 |
+
def build_system_prompt(classification: str) -> str:
|
| 107 |
+
"""
|
| 108 |
+
Compose the system prompt from core + relevant extension blocks only.
|
| 109 |
+
Saves 600-900 tokens per PATH_A turn vs. always sending the full monolith.
|
| 110 |
+
"""
|
| 111 |
+
prompt = _PROMPT_CORE
|
| 112 |
+
|
| 113 |
+
if classification in ("PATH_A", "BOTH"):
|
| 114 |
+
prompt += _PROMPT_PATH_A
|
| 115 |
+
prompt += _PROMPT_REPORTS
|
| 116 |
+
|
| 117 |
+
if classification in ("PATH_B", "BOTH"):
|
| 118 |
+
prompt += _PROMPT_PATH_B
|
| 119 |
+
prompt += _PROMPT_RECAP
|
| 120 |
+
|
| 121 |
+
if classification == "COMMERCIAL_HANDOFF":
|
| 122 |
+
prompt += _PROMPT_COMMERCIAL
|
| 123 |
+
|
| 124 |
+
return prompt
|
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/session.py
|
| 3 |
+
ββββββββββββββββββββ
|
| 4 |
+
In-memory session history store with write-time trimming and TTL eviction.
|
| 5 |
+
|
| 6 |
+
Architectural fix #6 - Session history was a plain defaultdict(list) that
|
| 7 |
+
grew without bound. Now:
|
| 8 |
+
- Entries are trimmed at write time (never grow beyond MAX_HISTORY_MESSAGES).
|
| 9 |
+
- Entries older than SESSION_TTL_SECONDS are evicted on next access.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import time
|
| 13 |
+
from services.observability import setup_logger
|
| 14 |
+
|
| 15 |
+
logger = setup_logger(__name__)
|
| 16 |
+
|
| 17 |
+
MAX_HISTORY_MESSAGES = 10 # stored per session (user + assistant turns)
|
| 18 |
+
SESSION_TTL_SECONDS = 3600 # 1 hour of inactivity = session cleared
|
| 19 |
+
|
| 20 |
+
# Internal store: {session_id: {"messages": [...], "ts": float}}
|
| 21 |
+
_sessions: dict[str, dict] = {}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_history(session_id: str) -> list[dict]:
|
| 25 |
+
"""Return mutable message list for this session, creating if needed."""
|
| 26 |
+
now = time.time()
|
| 27 |
+
entry = _sessions.get(session_id)
|
| 28 |
+
if entry is None or (now - entry["ts"]) > SESSION_TTL_SECONDS:
|
| 29 |
+
_sessions[session_id] = {"messages": [], "ts": now}
|
| 30 |
+
else:
|
| 31 |
+
_sessions[session_id]["ts"] = now
|
| 32 |
+
return _sessions[session_id]["messages"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def append_message(session_id: str, message: dict) -> None:
|
| 36 |
+
"""Append a message and immediately trim if over limit (fix #6)."""
|
| 37 |
+
history = get_history(session_id)
|
| 38 |
+
history.append(message)
|
| 39 |
+
if len(history) > MAX_HISTORY_MESSAGES:
|
| 40 |
+
_sessions[session_id]["messages"] = history[-MAX_HISTORY_MESSAGES:]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_context_window(session_id: str, limit: int = 6) -> list[dict]:
|
| 44 |
+
"""Return the last `limit` messages for injection into an LLM context."""
|
| 45 |
+
return list(get_history(session_id)[-limit:])
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def clear_session(session_id: str) -> None:
|
| 49 |
+
"""Explicitly clear a session (e.g. user logout)."""
|
| 50 |
+
_sessions.pop(session_id, None)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def active_session_count() -> int:
|
| 54 |
+
"""Returns number of active (non-expired) sessions, for monitoring."""
|
| 55 |
+
now = time.time()
|
| 56 |
+
return sum(
|
| 57 |
+
1 for e in _sessions.values()
|
| 58 |
+
if (now - e["ts"]) <= SESSION_TTL_SECONDS
|
| 59 |
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/tool_compressor.py
|
| 3 |
+
ββββββββββββββββββββββββββββ
|
| 4 |
+
Token-efficient tool output compression and the geocode_address_handler.
|
| 5 |
+
|
| 6 |
+
compress_tool_output(): Converts raw RPC dicts into compact strings for LLM
|
| 7 |
+
context. Uses metadata hoisting, null-stripping, and CSV rendering.
|
| 8 |
+
Estimated reduction: 70-90% vs raw JSON for time-series data.
|
| 9 |
+
|
| 10 |
+
geocode_address_handler(): Resolves addresses to lat/lng via Mapbox.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import csv
|
| 14 |
+
import io
|
| 15 |
+
import json
|
| 16 |
+
import requests
|
| 17 |
+
from services.observability import setup_logger
|
| 18 |
+
from config import MAPBOX_ACCESS_TOKEN
|
| 19 |
+
|
| 20 |
+
logger = setup_logger(__name__)
|
| 21 |
+
|
| 22 |
+
_MAX_ROWS = 50 # Maximum rows delivered to LLM from any single tool call
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compress_tool_output(func_name: str, db_result: dict) -> str:
|
| 26 |
+
"""
|
| 27 |
+
Three-step compression pipeline:
|
| 28 |
+
1. Metadata hoisting: keys with identical values across all rows are
|
| 29 |
+
extracted to a single header line, removing them from every row.
|
| 30 |
+
2. Null stripping: any key with a null/None value in a row is omitted.
|
| 31 |
+
3. CSV rendering: remaining data written as CSV (headers once, values compact).
|
| 32 |
+
|
| 33 |
+
For non-tabular (scalar/dict) results, returns a minimal string.
|
| 34 |
+
"""
|
| 35 |
+
if db_result.get("status") != "success":
|
| 36 |
+
msg = db_result.get("message", "unknown error")
|
| 37 |
+
return f"Tool '{func_name}' error: {msg}"
|
| 38 |
+
|
| 39 |
+
if "data" in db_result:
|
| 40 |
+
data = db_result.get("data")
|
| 41 |
+
else:
|
| 42 |
+
data = {k: v for k, v in db_result.items() if k != "status"}
|
| 43 |
+
|
| 44 |
+
# ββ Scalar / single-object results ββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
if data is None or (isinstance(data, dict) and len(data) == 0):
|
| 46 |
+
return f"Tool '{func_name}': no data returned."
|
| 47 |
+
|
| 48 |
+
if isinstance(data, (str, int, float, bool)):
|
| 49 |
+
return f"Tool '{func_name}' result: {data}"
|
| 50 |
+
|
| 51 |
+
if isinstance(data, dict):
|
| 52 |
+
return f"Tool '{func_name}' result:\n{json.dumps(data, default=str)}"
|
| 53 |
+
|
| 54 |
+
if not isinstance(data, list) or len(data) == 0:
|
| 55 |
+
return f"Tool '{func_name}': empty result."
|
| 56 |
+
|
| 57 |
+
# ββ Filter out all-null rows βββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
data = [
|
| 59 |
+
row for row in data
|
| 60 |
+
if isinstance(row, dict) and any(v is not None for v in row.values())
|
| 61 |
+
]
|
| 62 |
+
if not data:
|
| 63 |
+
return f"Tool '{func_name}': all returned rows were empty."
|
| 64 |
+
|
| 65 |
+
# Truncate before processing
|
| 66 |
+
truncated = False
|
| 67 |
+
if len(data) > _MAX_ROWS:
|
| 68 |
+
data = data[:_MAX_ROWS]
|
| 69 |
+
truncated = True
|
| 70 |
+
|
| 71 |
+
# ββ Step 1: Metadata hoisting ββββββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
all_keys = list(data[0].keys())
|
| 73 |
+
hoisted = {}
|
| 74 |
+
row_keys = []
|
| 75 |
+
for key in all_keys:
|
| 76 |
+
unique_values = {str(row.get(key)) for row in data}
|
| 77 |
+
if len(unique_values) == 1:
|
| 78 |
+
val = data[0].get(key)
|
| 79 |
+
if val is not None:
|
| 80 |
+
hoisted[key] = val
|
| 81 |
+
else:
|
| 82 |
+
row_keys.append(key)
|
| 83 |
+
|
| 84 |
+
header_parts = [f"{k}={v}" for k, v in hoisted.items()]
|
| 85 |
+
header_str = f"[{', '.join(header_parts)}]\n" if header_parts else ""
|
| 86 |
+
|
| 87 |
+
# ββ Step 2 & 3: Null-strip + CSV βββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
active_keys = [k for k in row_keys if any(row.get(k) is not None for row in data)]
|
| 89 |
+
|
| 90 |
+
buf = io.StringIO()
|
| 91 |
+
writer = csv.writer(buf)
|
| 92 |
+
writer.writerow(active_keys)
|
| 93 |
+
for row in data:
|
| 94 |
+
writer.writerow([row.get(k) for k in active_keys])
|
| 95 |
+
|
| 96 |
+
truncation_notice = (
|
| 97 |
+
f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
|
| 98 |
+
if truncated else ""
|
| 99 |
+
)
|
| 100 |
+
return f"{header_str}{buf.getvalue().strip()}{truncation_notice}"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# βββ GEOCODE HANDLER (Mapbox API) βββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
+
|
| 105 |
+
def geocode_address_handler(address: str) -> dict:
|
| 106 |
+
"""
|
| 107 |
+
Resolves a free-text address to lat/lng via the Mapbox Geocoding API.
|
| 108 |
+
Handles network failures, API errors, and empty results gracefully.
|
| 109 |
+
US-only results, single best match returned.
|
| 110 |
+
"""
|
| 111 |
+
if not MAPBOX_ACCESS_TOKEN:
|
| 112 |
+
return {
|
| 113 |
+
"status": "error",
|
| 114 |
+
"message": "We are unable to geocode addresses at this time β the mapping service is not configured.",
|
| 115 |
+
}
|
| 116 |
+
if not address or not address.strip():
|
| 117 |
+
return {"status": "error", "message": "No address was provided to geocode."}
|
| 118 |
+
|
| 119 |
+
url = "https://api.mapbox.com/search/geocode/v6/forward"
|
| 120 |
+
params = {
|
| 121 |
+
"q": address.strip(),
|
| 122 |
+
"access_token": MAPBOX_ACCESS_TOKEN,
|
| 123 |
+
"country": "US",
|
| 124 |
+
"limit": 1,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
response = requests.get(url, params=params, timeout=6)
|
| 129 |
+
response.raise_for_status()
|
| 130 |
+
data = response.json()
|
| 131 |
+
|
| 132 |
+
features = data.get("features", [])
|
| 133 |
+
if not features:
|
| 134 |
+
return {
|
| 135 |
+
"status": "error",
|
| 136 |
+
"message": f"We are unable to locate coordinates for '{address}'. Please try a more specific address.",
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
feature = features[0]
|
| 140 |
+
lon, lat = feature["geometry"]["coordinates"]
|
| 141 |
+
resolved = feature.get("properties", {}).get("full_address", address)
|
| 142 |
+
return {
|
| 143 |
+
"status": "success",
|
| 144 |
+
"latitude": round(lat, 6),
|
| 145 |
+
"longitude": round(lon, 6),
|
| 146 |
+
"resolved_address": resolved,
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
except requests.exceptions.Timeout:
|
| 150 |
+
logger.error(f"Mapbox geocode timeout for address: {address}")
|
| 151 |
+
return {"status": "error", "message": "Mapping service timed out. Please try again shortly."}
|
| 152 |
+
except requests.exceptions.HTTPError as e:
|
| 153 |
+
logger.error(f"Mapbox geocode HTTP error {e.response.status_code} for: {address}")
|
| 154 |
+
return {"status": "error", "message": "Mapping service returned an error. Please try again later."}
|
| 155 |
+
except requests.exceptions.RequestException as e:
|
| 156 |
+
logger.error(f"Mapbox geocode request failed for '{address}': {e}")
|
| 157 |
+
return {"status": "error", "message": "Unable to reach the mapping service. Please try again later."}
|
| 158 |
+
except (KeyError, IndexError, ValueError) as e:
|
| 159 |
+
logger.error(f"Mapbox geocode parsing error for '{address}': {e}")
|
| 160 |
+
return {"status": "error", "message": f"Unable to parse the location for '{address}'. Please try a more specific address."}
|
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/tool_executor.py
|
| 3 |
+
ββββββββββββββββββββββββββ
|
| 4 |
+
Tool dispatch layer β executes tool calls and normalizes assistant messages.
|
| 5 |
+
|
| 6 |
+
Architectural fixes:
|
| 7 |
+
#2 - Universal tool recovery: any tool in failed_generation is executed,
|
| 8 |
+
not just generate_data_export.
|
| 9 |
+
#5 - normalize_assistant_message() strips the internal `reasoning` field
|
| 10 |
+
and converts ChatCompletionMessage objects to clean dicts so they
|
| 11 |
+
never contaminate the conversation context.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import urllib.parse
|
| 16 |
+
from services.observability import setup_logger
|
| 17 |
+
from services.supabase_service import execute_tool_rpc
|
| 18 |
+
from services.tool_compressor import compress_tool_output, geocode_address_handler
|
| 19 |
+
from config import CONTACT_EMAIL, CONTACT_WHATSAPP
|
| 20 |
+
|
| 21 |
+
logger = setup_logger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# βββ MESSAGE NORMALIZER βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
|
| 26 |
+
def normalize_assistant_message(msg) -> dict:
|
| 27 |
+
"""
|
| 28 |
+
Convert a ChatCompletionMessage SDK object to a clean dict.
|
| 29 |
+
|
| 30 |
+
Strips internal fields: `reasoning`, `logprobs`, `function_call`, etc.
|
| 31 |
+
These fields must NEVER appear in the messages[] sent to subsequent
|
| 32 |
+
LLM calls β they inflate token usage and contaminate context.
|
| 33 |
+
|
| 34 |
+
Fix #5: reasoning field was leaking into history via Python __repr__.
|
| 35 |
+
"""
|
| 36 |
+
if getattr(msg, "tool_calls", None):
|
| 37 |
+
return {
|
| 38 |
+
"role": "assistant",
|
| 39 |
+
"content": msg.content or "",
|
| 40 |
+
"tool_calls": [
|
| 41 |
+
{
|
| 42 |
+
"id": tc.id,
|
| 43 |
+
"type": "function",
|
| 44 |
+
"function": {
|
| 45 |
+
"name": tc.function.name,
|
| 46 |
+
"arguments": tc.function.arguments,
|
| 47 |
+
},
|
| 48 |
+
}
|
| 49 |
+
for tc in msg.tool_calls
|
| 50 |
+
],
|
| 51 |
+
}
|
| 52 |
+
return {
|
| 53 |
+
"role": "assistant",
|
| 54 |
+
"content": getattr(msg, "content", "") or "",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# βββ TOOL EXECUTOR ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 59 |
+
|
| 60 |
+
async def execute_tool_by_name(func_name: str, func_args: dict) -> dict:
|
| 61 |
+
"""
|
| 62 |
+
Dispatch a tool call by name. Returns a result dict with {"status": ...}.
|
| 63 |
+
|
| 64 |
+
Python-side tools (generate_contact_buttons, geocode_address,
|
| 65 |
+
generate_data_export) are handled here. All other tool names are forwarded
|
| 66 |
+
to the Supabase RPC layer.
|
| 67 |
+
"""
|
| 68 |
+
if func_name == "generate_contact_buttons":
|
| 69 |
+
raw_message = func_args.get("message", "Hi, I'd like to discuss a custom build.")
|
| 70 |
+
encoded_message = urllib.parse.quote(raw_message)
|
| 71 |
+
return {
|
| 72 |
+
"status": "success",
|
| 73 |
+
"email_button_markdown": f"[Get in touch via Email](mailto:{CONTACT_EMAIL})",
|
| 74 |
+
"whatsapp_button_markdown": f"[Chat on WhatsApp](https://wa.me/{CONTACT_WHATSAPP}?text={encoded_message})",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if func_name == "geocode_address":
|
| 78 |
+
return geocode_address_handler(func_args.get("address", ""))
|
| 79 |
+
|
| 80 |
+
if func_name == "generate_data_export":
|
| 81 |
+
from services.appwrite_service import upload_document_to_appwrite
|
| 82 |
+
content = func_args.get("content", "")
|
| 83 |
+
return await upload_document_to_appwrite(content, "md")
|
| 84 |
+
|
| 85 |
+
# Default: Supabase RPC
|
| 86 |
+
return await execute_tool_rpc(func_name, func_args)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def parse_tool_args(raw_arguments) -> dict:
|
| 90 |
+
"""Safely parse tool call arguments β handles both str and dict."""
|
| 91 |
+
if isinstance(raw_arguments, str):
|
| 92 |
+
try:
|
| 93 |
+
return json.loads(raw_arguments)
|
| 94 |
+
except Exception:
|
| 95 |
+
return {}
|
| 96 |
+
return raw_arguments or {}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def parse_failed_generation(failed_gen: str) -> tuple[str | None, dict | None]:
|
| 100 |
+
"""
|
| 101 |
+
Extract tool name and arguments from a Groq 400 failed_generation string.
|
| 102 |
+
|
| 103 |
+
The model may format the failed call in two ways:
|
| 104 |
+
- {"name": "func", "arguments": {...}} (JSON dict)
|
| 105 |
+
- <function=func_name>{...} (legacy template format)
|
| 106 |
+
|
| 107 |
+
Fix #2: Previously only generate_data_export was handled. Now all tools
|
| 108 |
+
recovered from failed_generation are dispatched through execute_tool_by_name.
|
| 109 |
+
"""
|
| 110 |
+
import re
|
| 111 |
+
|
| 112 |
+
if not failed_gen:
|
| 113 |
+
return None, None
|
| 114 |
+
|
| 115 |
+
# Strategy 1: Direct JSON parse
|
| 116 |
+
try:
|
| 117 |
+
data = json.loads(failed_gen)
|
| 118 |
+
if isinstance(data, dict) and "name" in data:
|
| 119 |
+
args = data.get("arguments", {})
|
| 120 |
+
if isinstance(args, str):
|
| 121 |
+
try:
|
| 122 |
+
args = json.loads(args)
|
| 123 |
+
except Exception:
|
| 124 |
+
pass
|
| 125 |
+
return data["name"], args if isinstance(args, dict) else {}
|
| 126 |
+
except Exception:
|
| 127 |
+
pass
|
| 128 |
+
|
| 129 |
+
# Strategy 2: Regex for {"name": "...", "arguments": {...}}
|
| 130 |
+
match = re.search(
|
| 131 |
+
r'\{\s*"name"\s*:\s*"([a-zA-Z0-9_]+)"\s*,\s*"arguments"\s*:\s*(\{.*?\})\s*\}',
|
| 132 |
+
failed_gen,
|
| 133 |
+
re.DOTALL,
|
| 134 |
+
)
|
| 135 |
+
if match:
|
| 136 |
+
try:
|
| 137 |
+
return match.group(1), json.loads(match.group(2))
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
# Strategy 3: Regex for <function=name>{args}
|
| 142 |
+
match = re.search(r"<function=([a-zA-Z0-9_]+)[>\s]*(\{.*?\})", failed_gen, re.DOTALL)
|
| 143 |
+
if match:
|
| 144 |
+
try:
|
| 145 |
+
return match.group(1), json.loads(match.group(2))
|
| 146 |
+
except Exception:
|
| 147 |
+
pass
|
| 148 |
+
|
| 149 |
+
return None, None
|