Spaces:
Sleeping
Sleeping
Commit ·
ce17d86
0
Parent(s):
Auto deploy Chat API
Browse files- Dockerfile +25 -0
- README.md +10 -0
- __init__.py +0 -0
- __pycache__/__init__.cpython-312.pyc +0 -0
- __pycache__/__init__.cpython-313.pyc +0 -0
- __pycache__/agent.cpython-312.pyc +0 -0
- __pycache__/agent.cpython-313.pyc +0 -0
- __pycache__/main.cpython-312.pyc +0 -0
- __pycache__/main.cpython-313.pyc +0 -0
- __pycache__/neighbourhoods.cpython-312.pyc +0 -0
- __pycache__/neighbourhoods.cpython-313.pyc +0 -0
- agent.py +188 -0
- main.py +107 -0
- neighbourhoods.py +28 -0
- requirements.txt +11 -0
- services.json +7 -0
- services.py +0 -0
- supabase_client.py +11 -0
- system_prompt.txt +28 -0
- tests/__init__.py +0 -0
- tests/__pycache__/__init__.cpython-312.pyc +0 -0
- tests/__pycache__/__init__.cpython-313.pyc +0 -0
- tests/__pycache__/test_agent.cpython-312-pytest-7.4.4.pyc +0 -0
- tests/__pycache__/test_agent.cpython-313-pytest-8.4.1.pyc +0 -0
- tests/__pycache__/test_main.cpython-312-pytest-7.4.4.pyc +0 -0
- tests/__pycache__/test_main.cpython-313-pytest-8.4.1.pyc +0 -0
- tests/__pycache__/test_neighbourhoods.cpython-312-pytest-7.4.4.pyc +0 -0
- tests/__pycache__/test_neighbourhoods.cpython-313-pytest-8.4.1.pyc +0 -0
- tests/test_agent.py +32 -0
- tests/test_main.py +28 -0
- tests/test_neighbourhoods.py +20 -0
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official lightweight Python image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PYTHONUNBUFFERED=1
|
| 7 |
+
|
| 8 |
+
# Set working directory
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install system dependencies
|
| 12 |
+
RUN apt-get update && apt-get install -y build-essential
|
| 13 |
+
|
| 14 |
+
# Copy requirements and install Python dependencies
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy the source code
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Expose the FastAPI port
|
| 22 |
+
EXPOSE 8000
|
| 23 |
+
|
| 24 |
+
# Start FastAPI app using uvicorn
|
| 25 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Fourways Chat Api
|
| 3 |
+
emoji: 🌖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
__init__.py
ADDED
|
File without changes
|
__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (154 Bytes). View file
|
|
|
__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (154 Bytes). View file
|
|
|
__pycache__/agent.cpython-312.pyc
ADDED
|
Binary file (7.77 kB). View file
|
|
|
__pycache__/agent.cpython-313.pyc
ADDED
|
Binary file (7.92 kB). View file
|
|
|
__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (5.29 kB). View file
|
|
|
__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (5.42 kB). View file
|
|
|
__pycache__/neighbourhoods.cpython-312.pyc
ADDED
|
Binary file (2.06 kB). View file
|
|
|
__pycache__/neighbourhoods.cpython-313.pyc
ADDED
|
Binary file (2.11 kB). View file
|
|
|
agent.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional, List, TypedDict
|
| 3 |
+
from langchain_core.tools import tool, Tool
|
| 4 |
+
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
|
| 5 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 6 |
+
from langgraph.graph import END, StateGraph
|
| 7 |
+
from langgraph.prebuilt import ToolNode
|
| 8 |
+
from supabase import create_client
|
| 9 |
+
from neighbourhoods import neighbourhood_lookup
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
google_api_key = os.getenv("GOOGLE_API_KEY")
|
| 14 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 15 |
+
supabase_key = os.getenv("SUPABASE_ANON_KEY")
|
| 16 |
+
supabase = create_client(supabase_url, supabase_key)
|
| 17 |
+
|
| 18 |
+
neighbourhoods = supabase.table('neighbourhoods').select('id,name').execute()
|
| 19 |
+
|
| 20 |
+
class PropertySearchInput(BaseModel):
|
| 21 |
+
maxPrice: Optional[float] = None
|
| 22 |
+
minPrice: Optional[float] = None
|
| 23 |
+
minBedrooms: Optional[int] = None
|
| 24 |
+
maxBedrooms: Optional[int] = None
|
| 25 |
+
minBathrooms: Optional[int] = None
|
| 26 |
+
maxBathrooms: Optional[int] = None
|
| 27 |
+
propertyType: Optional[str] = None
|
| 28 |
+
neighbourhood: Optional[str] = None
|
| 29 |
+
status: Optional[str] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@tool("search_for_properties", args_schema=PropertySearchInput)
|
| 33 |
+
def search_for_properties_tool(maxPrice: int = None, minPrice: int = None,
|
| 34 |
+
minBedrooms: int = None, maxBedrooms: int = None,
|
| 35 |
+
minBathrooms: int = None, maxBathrooms: int = None,
|
| 36 |
+
propertyType: str = None,
|
| 37 |
+
neighbourhood: str = None, status: str = None
|
| 38 |
+
) -> List[dict]:
|
| 39 |
+
"""
|
| 40 |
+
Search for properties using a combination of filters provided by the user.
|
| 41 |
+
|
| 42 |
+
This tool queries the 'properties' table in the database and returns all listings
|
| 43 |
+
that match the specified filters. All filters are optional and can be combined to
|
| 44 |
+
narrow down the results. It supports searching by location, price range, bedrooms,
|
| 45 |
+
bathrooms, property type, and status (for sale or rent).
|
| 46 |
+
|
| 47 |
+
Parameters (all optional):
|
| 48 |
+
- maxPrice: Maximum price of the property.
|
| 49 |
+
- minPrice: Minimum price of the property.
|
| 50 |
+
- minBedrooms: Minimum number of bedrooms required.
|
| 51 |
+
- maxBedrooms: Maximum number of bedrooms required.
|
| 52 |
+
- minBathrooms: Minimum number of bathrooms required.
|
| 53 |
+
- maxBathrooms: Maximum number of bathrooms required.
|
| 54 |
+
- propertyType: The type of property (e.g. house, apartment).
|
| 55 |
+
- neighbourhood: The neighbourhood or area to search in.
|
| 56 |
+
- status: Whether the property is for sale or for rent.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
A list of property records (dictionaries) that match the search criteria.
|
| 60 |
+
|
| 61 |
+
Usage example:
|
| 62 |
+
The assistant may call:
|
| 63 |
+
search_for_properties({
|
| 64 |
+
"bedrooms": 2,
|
| 65 |
+
"status": "for_rent",
|
| 66 |
+
"maxPrice": 800,
|
| 67 |
+
"neighbourhood": "Kacyiru"
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
Notes:
|
| 71 |
+
- The assistant should never guess or fabricate property data.
|
| 72 |
+
- Always use this tool when the user is asking to see or search for properties.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
query = supabase.table("properties").select("*")
|
| 76 |
+
|
| 77 |
+
if neighbourhood:
|
| 78 |
+
neighbourhood_id = neighbourhood_lookup.get_neighbourhood_id(neighbourhood)
|
| 79 |
+
|
| 80 |
+
# If the neighbourhood id does not exist (i.e is None) then the database does not have
|
| 81 |
+
# properties in that neighbourhood, so we return an empty list
|
| 82 |
+
if neighbourhood_id is None:
|
| 83 |
+
return []
|
| 84 |
+
|
| 85 |
+
# If id exists, then search database using neighbourhood_id
|
| 86 |
+
query = query.eq("neighbourhood", neighbourhood_id)
|
| 87 |
+
if status:
|
| 88 |
+
query = query.eq("status", status)
|
| 89 |
+
if propertyType:
|
| 90 |
+
query = query.eq("property_type", propertyType)
|
| 91 |
+
if minBedrooms:
|
| 92 |
+
query = query.gte("bedrooms", minBedrooms)
|
| 93 |
+
if maxBedrooms:
|
| 94 |
+
query = query.gte("bedrooms", maxBedrooms)
|
| 95 |
+
if minBathrooms:
|
| 96 |
+
query = query.gte("bathrooms", minBathrooms)
|
| 97 |
+
if maxBathrooms:
|
| 98 |
+
query = query.gte("bathrooms", maxBathrooms)
|
| 99 |
+
if minPrice:
|
| 100 |
+
query = query.gte("price", minPrice)
|
| 101 |
+
if maxPrice:
|
| 102 |
+
query = query.lte("price", maxPrice)
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
response = query.execute()
|
| 106 |
+
except e:
|
| 107 |
+
print(f"Error executing query: {e}")
|
| 108 |
+
return []
|
| 109 |
+
|
| 110 |
+
return response.data
|
| 111 |
+
|
| 112 |
+
tools = [search_for_properties_tool]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
llm = ChatGoogleGenerativeAI(
|
| 116 |
+
model="gemini-2.5-flash-lite-preview-06-17",
|
| 117 |
+
temperature=0,
|
| 118 |
+
google_api_key=google_api_key
|
| 119 |
+
)
|
| 120 |
+
llm = llm.bind_tools(tools)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class AgentState(TypedDict):
|
| 124 |
+
messages: List[BaseMessage]
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def llm_node(state: AgentState) -> AgentState:
|
| 128 |
+
"""Invokes the LLM to reason and decide on tool use."""
|
| 129 |
+
response = llm.invoke(state['messages'])
|
| 130 |
+
|
| 131 |
+
tool_calls = getattr(response, "tool_calls", None)
|
| 132 |
+
|
| 133 |
+
# If tool call was made, store it in AIMessage
|
| 134 |
+
if tool_calls:
|
| 135 |
+
# Always provide an empty string for content when there are tool calls
|
| 136 |
+
state['messages'].append(
|
| 137 |
+
AIMessage(content="", tool_calls=tool_calls)
|
| 138 |
+
)
|
| 139 |
+
else:
|
| 140 |
+
content = response.content or "I'm sorry, I wasn't able to generate a response."
|
| 141 |
+
state['messages'].append(AIMessage(content=content.strip()))
|
| 142 |
+
|
| 143 |
+
return state
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def should_continue(state: AgentState) -> str:
|
| 147 |
+
last_message = state['messages'][-1]
|
| 148 |
+
# Check for tool_calls attribute and if the list is not empty
|
| 149 |
+
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
|
| 150 |
+
return "continue"
|
| 151 |
+
return "end"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def tool_node(state: AgentState) -> AgentState:
|
| 155 |
+
# Get the most recent AIMessage that called the tool
|
| 156 |
+
last_message = state["messages"][-1]
|
| 157 |
+
tool_call = last_message.tool_calls[0] # Only handling one tool call here for simplicity
|
| 158 |
+
|
| 159 |
+
tool_name = tool_call["name"]
|
| 160 |
+
tool_args = tool_call["args"]
|
| 161 |
+
tool_call_id = tool_call["id"]
|
| 162 |
+
|
| 163 |
+
# Find and execute the tool
|
| 164 |
+
tool: Tool = next(t for t in tools if t.name == tool_name)
|
| 165 |
+
tool_result = tool.invoke(tool_args)
|
| 166 |
+
|
| 167 |
+
# Add the result to the messages as a ToolMessage
|
| 168 |
+
state["messages"].append(
|
| 169 |
+
ToolMessage(content=str(tool_result), tool_call_id=tool_call_id)
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
return state
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
graph = StateGraph(AgentState)
|
| 176 |
+
graph.add_node("llm_node", llm_node)
|
| 177 |
+
graph.add_node("tool_node", tool_node)
|
| 178 |
+
graph.set_entry_point("llm_node")
|
| 179 |
+
graph.add_conditional_edges(
|
| 180 |
+
"llm_node",
|
| 181 |
+
should_continue,
|
| 182 |
+
{
|
| 183 |
+
"continue": "tool_node",
|
| 184 |
+
"end": END
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
graph.add_edge("tool_node", "llm_node")
|
| 188 |
+
agent = graph.compile()
|
main.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, Header
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
| 4 |
+
from typing import Optional, Literal
|
| 5 |
+
from agent import agent
|
| 6 |
+
from supabase_client import client
|
| 7 |
+
from neighbourhoods import neighbourhood_lookup
|
| 8 |
+
from json import load
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
# Load service URLs
|
| 12 |
+
with open('services.json') as f:
|
| 13 |
+
services = load(f)
|
| 14 |
+
|
| 15 |
+
SUPABASE_URL = services.get('SUPABASE', '')
|
| 16 |
+
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY")
|
| 17 |
+
|
| 18 |
+
# Load system prompt content
|
| 19 |
+
with open('system_prompt.txt') as f:
|
| 20 |
+
system_prompt_content = f.read()
|
| 21 |
+
|
| 22 |
+
system_prompt = SystemMessage(content=system_prompt_content)
|
| 23 |
+
|
| 24 |
+
app = FastAPI()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MessageRequest(BaseModel):
|
| 28 |
+
message: str
|
| 29 |
+
chat_id: str
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class WebhookRequest(BaseModel):
|
| 33 |
+
type: Literal["INSERT", "UPDATE", "DELETE"]
|
| 34 |
+
table: str
|
| 35 |
+
schema: str
|
| 36 |
+
record: dict
|
| 37 |
+
old_record: Optional[dict] = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.post("/chat")
|
| 41 |
+
async def chat(request: MessageRequest, authorization: str = Header(...)):
|
| 42 |
+
access_token = authorization.replace("Bearer ", "")
|
| 43 |
+
user_response = client.auth.get_user(access_token)
|
| 44 |
+
user = user_response.user if user_response else None
|
| 45 |
+
|
| 46 |
+
if not user:
|
| 47 |
+
raise HTTPException(status_code=401, detail="Invalid or expired token.")
|
| 48 |
+
|
| 49 |
+
# Insert user message into chat_messages
|
| 50 |
+
client.table("chat_messages").insert({
|
| 51 |
+
"chat_session_id": request.chat_id,
|
| 52 |
+
"message": request.message,
|
| 53 |
+
"sent_by": user.id
|
| 54 |
+
}).execute()
|
| 55 |
+
|
| 56 |
+
# Retrieve chat session details
|
| 57 |
+
chat_session = client.table("chat_sessions").select("*").eq("id", request.chat_id).execute()
|
| 58 |
+
if not chat_session.data:
|
| 59 |
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
| 60 |
+
|
| 61 |
+
is_ai = not chat_session.data[0].get("user_two")
|
| 62 |
+
if not is_ai:
|
| 63 |
+
return { "success": True, "message": "Message added to chat." }
|
| 64 |
+
|
| 65 |
+
# Fetch full chat history
|
| 66 |
+
messages = client.table("chat_messages")\
|
| 67 |
+
.select("*")\
|
| 68 |
+
.eq("chat_session_id", request.chat_id)\
|
| 69 |
+
.order("created_at", desc=False)\
|
| 70 |
+
.execute()
|
| 71 |
+
|
| 72 |
+
# Construct message history
|
| 73 |
+
history = [system_prompt]
|
| 74 |
+
for msg in messages.data:
|
| 75 |
+
if msg["sent_by"] == user.id:
|
| 76 |
+
history.append(HumanMessage(content=msg["message"]))
|
| 77 |
+
else:
|
| 78 |
+
history.append(AIMessage(content=msg["message"]))
|
| 79 |
+
|
| 80 |
+
# Generate AI response
|
| 81 |
+
state = { "messages": history }
|
| 82 |
+
result = agent.invoke(state)
|
| 83 |
+
final_message = result["messages"][-1].content
|
| 84 |
+
|
| 85 |
+
# Insert AI response into chat_messages
|
| 86 |
+
client.table("chat_messages").insert({
|
| 87 |
+
"chat_session_id": request.chat_id,
|
| 88 |
+
"message": final_message,
|
| 89 |
+
"sent_by": None
|
| 90 |
+
}).execute()
|
| 91 |
+
|
| 92 |
+
return { "success": True, "ai_response": final_message }
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.post("/on-neighbourhoods-change")
|
| 96 |
+
def on_neighbourhoods_change(request: WebhookRequest):
|
| 97 |
+
id = request.record.get("id")
|
| 98 |
+
name = request.record.get("name")
|
| 99 |
+
|
| 100 |
+
if request.type == "INSERT":
|
| 101 |
+
neighbourhood_lookup.add_neighbourhood(name, id)
|
| 102 |
+
elif request.type == "UPDATE":
|
| 103 |
+
neighbourhood_lookup.update_neighbourhood(name, id)
|
| 104 |
+
elif request.type == "DELETE":
|
| 105 |
+
neighbourhood_lookup.delete_neighbourhood(id)
|
| 106 |
+
|
| 107 |
+
return { "success": True }
|
neighbourhoods.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from supabase_client import client
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Neighbourhoods:
|
| 5 |
+
def __init__(self):
|
| 6 |
+
self.neighbourhoods = {}
|
| 7 |
+
neighbourhoods = client.table('neighbourhoods').select('id,name').execute().data
|
| 8 |
+
|
| 9 |
+
for neighbourhood in neighbourhoods:
|
| 10 |
+
id = neighbourhood['id']
|
| 11 |
+
name = neighbourhood['name']
|
| 12 |
+
self.add_neighourhood(name, id)
|
| 13 |
+
|
| 14 |
+
def delete_neighbourhood(self, id):
|
| 15 |
+
self.neighbourhoods = { k: v for k, v in self.neighbourhoods if v != id }
|
| 16 |
+
|
| 17 |
+
def update_neighourhood(self, name, id):
|
| 18 |
+
self.delete_neighbourhood(id)
|
| 19 |
+
self.add_neighourhood(name, id)
|
| 20 |
+
|
| 21 |
+
def add_neighourhood(self, name, id):
|
| 22 |
+
self.neighbourhoods[name.lower()] = id
|
| 23 |
+
|
| 24 |
+
def get_neighbourhood_id(self, name):
|
| 25 |
+
return self.neighbourhoods.get(name.lower())
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
neighbourhood_lookup = Neighbourhoods()
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
pydantic
|
| 4 |
+
langgraph
|
| 5 |
+
langchain
|
| 6 |
+
langchain-core
|
| 7 |
+
langchain-google-genai
|
| 8 |
+
google-generativeai
|
| 9 |
+
supabase
|
| 10 |
+
python-dotenv
|
| 11 |
+
pytest
|
services.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"EMBEDDINGS": "https://akafesu-fourwalls-embeddings-api.hf.space",
|
| 3 |
+
"RECOMMENDATIONS": "https://akafesu-fourwalls-recommendations-api.hf.space",
|
| 4 |
+
"CHAT": "https://akafesu-fourwalls-chat-api.hf.space",
|
| 5 |
+
"SUPABASE": "https://nhwwhpbxlqsdbirdrxmc.supabase.co",
|
| 6 |
+
"MIGRATIONS": "https://akafesu-fourwalls-migrations-api.hf.space"
|
| 7 |
+
}
|
services.py
ADDED
|
File without changes
|
supabase_client.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from supabase import create_client
|
| 2 |
+
from os import getenv
|
| 3 |
+
from json import load
|
| 4 |
+
|
| 5 |
+
with open('./services.json') as f:
|
| 6 |
+
services = load(f)
|
| 7 |
+
|
| 8 |
+
SUPABASE_URL = services['SUPABASE']
|
| 9 |
+
SUPABASE_ANON_KEY = getenv('SUPABASE_ANON_KEY')
|
| 10 |
+
|
| 11 |
+
client = create_client(SUPABASE_URL, SUPABASE_ANON_KEY)
|
system_prompt.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are Scout, an AI assistant who helps users find houses on our real estate platform.
|
| 2 |
+
Your job is to understand the user's needs and search for matching properties using the available filters.
|
| 3 |
+
These filters include location, price, number of bedrooms, property type, and status (e.g. for sale or for rent).
|
| 4 |
+
You should always use the available tools to perform searches, rather than making up information.
|
| 5 |
+
If a user asks about what properties are available, or describes what they’re looking for, call the search tool with the appropriate filters.
|
| 6 |
+
The search tool allows you to search for properties that are both for sale and for rent.
|
| 7 |
+
When presenting search results, it is important that you embed the property ID in your responses using this format: <%propertyId%>.
|
| 8 |
+
For example, if you’re referencing a house with ID `abc123`, embed it as <%abc123%>.
|
| 9 |
+
The propertyId is contained in the id field of the property record.
|
| 10 |
+
This format helps link the properties with our frontend display system.
|
| 11 |
+
This helps the user view more details on the property, so it is vital that you embed property ids.
|
| 12 |
+
When embedding properties, you should provide a short description of the property.
|
| 13 |
+
The description of the property should be in natural language.
|
| 14 |
+
Keep the description concise, informative and natural.
|
| 15 |
+
You can embed multiple properties in the same message.
|
| 16 |
+
You can use markdown to format messages in a more readable way.
|
| 17 |
+
But, do not overuse markdown as the messages should feel like a natural conversation, not a blog post.
|
| 18 |
+
Keep your responses friendly, helpful, and concise.
|
| 19 |
+
If you are responding with search results, explain the filters you applied to the user.
|
| 20 |
+
Do not answer questions unless you have used the tool to retrieve relevant results.
|
| 21 |
+
If you cannot find any matching properties, kindly let the user know and suggest adjusting the search criteria.
|
| 22 |
+
Always behave like a knowledgeable assistant focused on helping users find their ideal home.
|
| 23 |
+
If a user is asking for a particular property type (e.g. apartment), and you cannot find any listings of that type in the database, broaden the search
|
| 24 |
+
to include other property types but let them know in the final message that you had to adjust their query, and how you adjusted their query.
|
| 25 |
+
Sometimes, house can be taken to refer to all property types.
|
| 26 |
+
The database currency is RWF. If the user quotes prices in USD ask them to restate in RWF.
|
| 27 |
+
If you cannot find properties in the user's budget, suggest that they should adjust their budget.
|
| 28 |
+
If you cannot find properties within a particular neighbourhood, broaden your search to all neighbourhoods.
|
tests/__init__.py
ADDED
|
File without changes
|
tests/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (160 Bytes). View file
|
|
|
tests/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (160 Bytes). View file
|
|
|
tests/__pycache__/test_agent.cpython-312-pytest-7.4.4.pyc
ADDED
|
Binary file (7.31 kB). View file
|
|
|
tests/__pycache__/test_agent.cpython-313-pytest-8.4.1.pyc
ADDED
|
Binary file (8 kB). View file
|
|
|
tests/__pycache__/test_main.cpython-312-pytest-7.4.4.pyc
ADDED
|
Binary file (4.93 kB). View file
|
|
|
tests/__pycache__/test_main.cpython-313-pytest-8.4.1.pyc
ADDED
|
Binary file (5.27 kB). View file
|
|
|
tests/__pycache__/test_neighbourhoods.cpython-312-pytest-7.4.4.pyc
ADDED
|
Binary file (2.3 kB). View file
|
|
|
tests/__pycache__/test_neighbourhoods.cpython-313-pytest-8.4.1.pyc
ADDED
|
Binary file (2.39 kB). View file
|
|
|
tests/test_agent.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from chat.agent import PropertySearchInput, search_for_properties_tool, AgentState, llm_node, should_continue, tool_node
|
| 3 |
+
|
| 4 |
+
# Test PropertySearchInput model
|
| 5 |
+
def test_property_search_input():
|
| 6 |
+
data = {'maxPrice': 1000, 'minPrice': 500, 'location': 'Harare'}
|
| 7 |
+
obj = PropertySearchInput(**data)
|
| 8 |
+
assert obj.maxPrice == 1000
|
| 9 |
+
assert obj.minPrice == 500
|
| 10 |
+
assert obj.location == 'Harare'
|
| 11 |
+
|
| 12 |
+
# Test search_for_properties_tool function
|
| 13 |
+
def test_search_for_properties_tool():
|
| 14 |
+
result = search_for_properties_tool(maxPrice=2000, minPrice=1000, location='Bulawayo')
|
| 15 |
+
assert isinstance(result, list) or result is None
|
| 16 |
+
|
| 17 |
+
# Test AgentState and llm_node, should_continue, tool_node
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def agent_state():
|
| 20 |
+
return {'step': 1, 'history': []}
|
| 21 |
+
|
| 22 |
+
def test_llm_node(agent_state):
|
| 23 |
+
result = llm_node(agent_state)
|
| 24 |
+
assert isinstance(result, dict)
|
| 25 |
+
|
| 26 |
+
def test_should_continue(agent_state):
|
| 27 |
+
result = should_continue(agent_state)
|
| 28 |
+
assert isinstance(result, str)
|
| 29 |
+
|
| 30 |
+
def test_tool_node(agent_state):
|
| 31 |
+
result = tool_node(agent_state)
|
| 32 |
+
assert isinstance(result, dict)
|
tests/test_main.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from chat.main import MessageRequest, WebhookRequest, chat, on_neighbourhoods_change
|
| 2 |
+
import pytest
|
| 3 |
+
from fastapi import Header
|
| 4 |
+
import asyncio
|
| 5 |
+
|
| 6 |
+
# Test MessageRequest and WebhookRequest models
|
| 7 |
+
def test_message_request():
|
| 8 |
+
req = MessageRequest(message="Hello", session_id="abc123")
|
| 9 |
+
assert req.message == "Hello"
|
| 10 |
+
assert req.session_id == "abc123"
|
| 11 |
+
|
| 12 |
+
def test_webhook_request():
|
| 13 |
+
req = WebhookRequest(event="update", data={"id": 1})
|
| 14 |
+
assert req.event == "update"
|
| 15 |
+
assert req.data == {"id": 1}
|
| 16 |
+
|
| 17 |
+
# Test chat endpoint (sync call for test)
|
| 18 |
+
@pytest.mark.asyncio
|
| 19 |
+
def test_chat():
|
| 20 |
+
req = MessageRequest(message="Test", session_id="test1")
|
| 21 |
+
result = asyncio.run(chat(req, authorization="Bearer test"))
|
| 22 |
+
assert result is not None
|
| 23 |
+
|
| 24 |
+
# Test on_neighbourhoods_change
|
| 25 |
+
def test_on_neighbourhoods_change():
|
| 26 |
+
req = WebhookRequest(event="delete", data={"id": 2})
|
| 27 |
+
result = on_neighbourhoods_change(req)
|
| 28 |
+
assert result is not None
|
tests/test_neighbourhoods.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from chat.neighbourhoods import Neighbourhoods
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
@pytest.fixture
|
| 5 |
+
def neighbourhoods():
|
| 6 |
+
return Neighbourhoods()
|
| 7 |
+
|
| 8 |
+
def test_add_and_get_neighbourhood(neighbourhoods):
|
| 9 |
+
neighbourhoods.add_neighourhood('Avondale', 1)
|
| 10 |
+
assert neighbourhoods.get_neighbourhood_id('Avondale') == 1
|
| 11 |
+
|
| 12 |
+
def test_update_neighbourhood(neighbourhoods):
|
| 13 |
+
neighbourhoods.add_neighourhood('Borrowdale', 2)
|
| 14 |
+
neighbourhoods.update_neighourhood('Borrowdale Updated', 2)
|
| 15 |
+
# Depending on implementation, check if update is reflected
|
| 16 |
+
|
| 17 |
+
def test_delete_neighbourhood(neighbourhoods):
|
| 18 |
+
neighbourhoods.add_neighourhood('Greendale', 3)
|
| 19 |
+
neighbourhoods.delete_neighbourhood(3)
|
| 20 |
+
# Depending on implementation, check if deletion is reflected
|