diff --git a/backend/app/bots/adaptive_cards.py b/backend/app/bots/adaptive_cards.py new file mode 100644 index 0000000..60fb316 --- /dev/null +++ b/backend/app/bots/adaptive_cards.py @@ -0,0 +1,47 @@ +from typing import Dict, Any +from langchain.chat_models import ChatOpenAI +from langchain.prompts import ChatPromptTemplate +from langchain.chains import LLMChain +import asyncio + +class AdaptiveCards: + def __init__(self): + self.llm = ChatOpenAI(temperature=0) + self.prompt = ChatPromptTemplate.from_template(""" + You are a Microsoft Adaptive Card generator. Given a data schema and known values, + generate an Adaptive Card (v1.3) that asks the user only for missing fields. + + Use this schema: https://adaptivecards.io/schemas/adaptive-card.json + Respond only with valid Adaptive Card JSON. Do not include explanations. + Always include isRequired, and errorMessage in the schema. + Always include a submit button at the bottom of the card as defined in the schema. + + ### Schema: + {schema} + + ### Known values: + {known_values} + + """) + self.chain = LLMChain(llm=self.llm, prompt=self.prompt) + + async def generate_card(self, schema: Dict[str, Any], known_values: Dict[str, Any]) -> Dict[str, Any]: + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self.chain.run, { + "schema": schema, + "known_values": known_values + }) + + def create_welcome_card(self): + """Create a welcome card""" + return { + "type": "AdaptiveCard", + "body": [ + { + "type": "TextBlock", + "text": "Welcome to the Housing Bot!", + "size": "large" + } + ], + "version": "1.0" + } diff --git a/backend/app/bots/dayta.py b/backend/app/bots/dayta.py new file mode 100644 index 0000000..d0ec9f7 --- /dev/null +++ b/backend/app/bots/dayta.py @@ -0,0 +1,115 @@ +import json +from typing import Annotated +from fastapi import Depends +from langchain.chat_models import ChatOpenAI +from botbuilder.core import ActivityHandler, TurnContext +from botbuilder.schema import Activity, Attachment, ActivityTypes +import asyncio + +from pydantic import ValidationError + +from backend.app.bots.adaptive_cards import AdaptiveCards +from backend.app.bots.intent_detector import IntentDetector +from backend.app.bots.slot_filler import SlotFiller +from backend.app.dtos.house.house_features import HouseFeatures +from backend.app.services.house_price_predictor import HousePricePredictor + +class Dayta(ActivityHandler): + def __init__( + self, + intent_detector: Annotated[IntentDetector, Depends()], + card_bot: Annotated[AdaptiveCards, Depends()], + slot_filler: Annotated[SlotFiller, Depends()], + price_predictor: Annotated[HousePricePredictor, Depends()],): + + self.intent_detector = intent_detector + self.card_bot = card_bot + self.slot_filler = slot_filler + self.price_predictor = price_predictor + self.chat_llm = ChatOpenAI(temperature=0.7) + self.user_sessions = {} + + async def on_message_activity(self, turn_context: TurnContext): + user_message = turn_context.activity.text + user_id = turn_context.activity.from_property.id + submitted_values = turn_context.activity.value + + known_values = self.user_sessions.get(user_id, {}) + schema = HouseFeatures.model_json_schema() + #required_fields = list(HouseFeatures.model_fields.keys()) + required_fields = [ + name for name, field in HouseFeatures.model_fields.items() + if field.is_required() + ] + print(f"required_fields: {required_fields}") + + # Update known values + if submitted_values is not None: + known_values.update(submitted_values) + else: + extracted = await self.slot_filler.extract_slots(schema, user_message) + known_values.update(extracted) + + self.user_sessions[user_id] = known_values + + # Detect intent only if message-based + if not submitted_values: + intent = await self.intent_detector.detect_intent(user_message) + if intent.strip().lower() in ("unknown", ""): + response = await asyncio.get_event_loop().run_in_executor( + None, + lambda: self.chat_llm.predict(f"The user said: '{user_message}'. Respond helpfully.") + ) + await turn_context.send_activity(response) + return + + # Delegate to common logic + await self._handle_collected_data(turn_context, user_id, known_values, required_fields, schema) + + async def _handle_collected_data( + self, + turn_context: TurnContext, + user_id: str, + known_values: dict, + required_fields: list[str], + full_schema: dict + ): + missing_fields = [f for f in required_fields if f not in known_values] + print(f"Missing fields: {missing_fields}") + + if not missing_fields: + try: + features = HouseFeatures(**known_values) + price = self.price_predictor.predict(features) + await turn_context.send_activity(f"The estimated price of the house is ${price:.2f}") + del self.user_sessions[user_id] + return + except ValidationError as e: + await turn_context.send_activity(f"Validation failed: {e}") + return + + + # Generate adaptive card for missing fields + filtered_schema = { + **full_schema, + "properties": { + k: v for k, v in full_schema["properties"].items() if k in missing_fields + }, + "required": missing_fields + } + + card_json = await self.card_bot.generate_card(filtered_schema, known_values) + if isinstance(card_json, str): + card_json = json.loads(card_json) + print(f"card_json: {card_json}") + await turn_context.send_activity( + Activity( + type=ActivityTypes.message, + attachments=[ + Attachment( + content_type="application/vnd.microsoft.card.adaptive", + content=card_json + ) + ] + ) + ) diff --git a/backend/app/bots/intent_detector.py b/backend/app/bots/intent_detector.py new file mode 100644 index 0000000..60e6672 --- /dev/null +++ b/backend/app/bots/intent_detector.py @@ -0,0 +1,23 @@ +from langchain.chat_models import ChatOpenAI +from langchain.prompts import ChatPromptTemplate +from langchain.chains import LLMChain +import asyncio + +class IntentDetector: + def __init__(self, temperature: float = 0.0): + self.llm = ChatOpenAI(temperature=temperature) + self.prompt = ChatPromptTemplate.from_template(""" + You are an intent detection bot. Classify the user input into one of the following intents: + + - Information about house prices + - unknown + + If you're unsure, respond with `unknown`. + + User: {message} + Intent:""") + self.chain = LLMChain(llm=self.llm, prompt=self.prompt) + + async def detect_intent(self, message: str) -> str: + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self.chain.run, {"message": message}) diff --git a/backend/app/bots/slot_filler.py b/backend/app/bots/slot_filler.py new file mode 100644 index 0000000..921b50e --- /dev/null +++ b/backend/app/bots/slot_filler.py @@ -0,0 +1,31 @@ +from langchain.chat_models import ChatOpenAI +from langchain.prompts import ChatPromptTemplate +from langchain.chains import LLMChain +from typing import Dict, Any +import asyncio + +class SlotFiller: + def __init__(self): + self.llm = ChatOpenAI(temperature=0) + self.prompt = ChatPromptTemplate.from_template(""" + You are a helpful assistant. Given a message and a schema, extract all known values. + + Only return a JSON object containing the extracted values and no extra text. + + Schema: {schema} + Message: {message} + """) + self.chain = LLMChain(llm=self.llm, prompt=self.prompt) + + async def extract_slots(self, schema: Dict[str, Any], message: str) -> Dict[str, Any]: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, self.chain.run, { + "schema": schema, + "message": message + }) + + import json + try: + return json.loads(result) + except Exception: + return {} diff --git a/backend/app/dtos/house_create_request.py b/backend/app/dtos/house_create_request.py deleted file mode 100644 index ca07085..0000000 --- a/backend/app/dtos/house_create_request.py +++ /dev/null @@ -1,34 +0,0 @@ -from pydantic import BaseModel, Field - - -class HouseCreateRequest(BaseModel): - address: str = Field( - ..., - min_length=1, - max_length=255, - description="House address", - examples=["123 Main St"], - ) - city: str = Field( - ..., description="City where the house is located", examples=["Springfield"] - ) - country: str = Field( - ..., description="Country where the house is located", examples=["USA"] - ) - price: float = Field(..., description="Price of the house", examples=[250000.00]) - description: str = Field( - ..., - description="Description of the house", - examples=["A beautiful 3-bedroom house"], - ) - square_feet: float = Field( - ..., - description="Square footage of the house", - examples=[1500.00], - ) - bedrooms: int = Field( - ..., description="Number of bedrooms in the house", examples=[3] - ) - bathrooms: float = Field( - ..., description="Number of bathrooms in the house", examples=[2.5] - ) diff --git a/backend/app/dtos/house_create_response.py b/backend/app/dtos/house_create_response.py deleted file mode 100644 index 5f74154..0000000 --- a/backend/app/dtos/house_create_response.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel - - -class HouseCreateResponse(BaseModel): - id: str diff --git a/backend/app/dtos/houses_list_response.py b/backend/app/dtos/houses_list_response.py deleted file mode 100644 index 6b55b6e..0000000 --- a/backend/app/dtos/houses_list_response.py +++ /dev/null @@ -1,14 +0,0 @@ -from pydantic import BaseModel - - -class HouseResponse(BaseModel): - id: str - description: str - address: str - city: str - country: str - price: float - - -class HousesListResponse(BaseModel): - houses: list[HouseResponse] diff --git a/backend/app/dtos/owner_detail_response.py b/backend/app/dtos/owner_detail_response.py deleted file mode 100644 index b928c01..0000000 --- a/backend/app/dtos/owner_detail_response.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class OwnerDetailResponse(BaseModel): - id: str - user_id: str - email: str diff --git a/backend/app/dtos/owner_list_response.py b/backend/app/dtos/owner_list_response.py deleted file mode 100644 index 03d1aba..0000000 --- a/backend/app/dtos/owner_list_response.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel - - -class OwnerResponse(BaseModel): - id: str - user_id: str - - -class OwnerListResponse(BaseModel): - owners: list[OwnerResponse] diff --git a/backend/app/factories/bot_factory.py b/backend/app/factories/bot_factory.py new file mode 100644 index 0000000..531160c --- /dev/null +++ b/backend/app/factories/bot_factory.py @@ -0,0 +1,33 @@ +from typing import Dict +from backend.app.bots.dayta import Dayta +from backend.app.bots.intent_detector import IntentDetector +from backend.app.bots.slot_filler import SlotFiller +from backend.app.bots.adaptive_cards import AdaptiveCards +from backend.app.services.house_price_predictor import HousePricePredictor +from botbuilder.core import BotFrameworkAdapter, BotFrameworkAdapterSettings + +class BotFactory: + def __init__(self): + self._bots: Dict[str, object] = {} + self.adapter_settings = BotFrameworkAdapterSettings(app_id="", app_password="") + self.adapter = BotFrameworkAdapter(self.adapter_settings) + + + # Shared services + self.intent_detector = IntentDetector() + self.slot_filler = SlotFiller() + self.card_bot = AdaptiveCards() + self.price_predictor = HousePricePredictor() + # Register all bots + self._bots["dayta"] = Dayta( + intent_detector=self.intent_detector, + card_bot=self.card_bot, + slot_filler=self.slot_filler, + price_predictor=self.price_predictor + ) + + def get_bot(self, name: str): + return self._bots.get(name) + + def get_adapter(self): + return self.adapter \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 2164464..3eb1266 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,14 +7,13 @@ from .middleware.authenticate import authenticate from .providers.db_provider import create_db_and_tables from .routers.houses import router as houses_router from .routers.owners import router as owners_router - - +from .routers.direct_line import router as direct_line_router +from .routers.bot import router as bot_router @asynccontextmanager async def lifespan(_app: FastAPI): - create_db_and_tables() + create_db_and_tables() yield - app = FastAPI( title="Fair Housing API", description="Provides access to core functionality for the fair housing platform.", @@ -33,3 +32,5 @@ app.add_middleware( app.include_router(houses_router, prefix="/houses", tags=["houses"]) app.include_router(owners_router, prefix="/owners", tags=["owners"]) +app.include_router(bot_router, tags=["bot"]) +app.include_router(direct_line_router, tags=["directline"]) \ No newline at end of file diff --git a/backend/app/routers/bot.py b/backend/app/routers/bot.py new file mode 100644 index 0000000..d2b30a4 --- /dev/null +++ b/backend/app/routers/bot.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Request, Depends +from botbuilder.schema import Activity +from botbuilder.core import TurnContext +from backend.app.factories.bot_factory import BotFactory + +router = APIRouter() + +@router.post("/api/messages", response_model=None) +async def messages( + req: Request, + bot = Depends(lambda: BotFactory().get_bot("dayta")), + adapter = Depends(lambda: BotFactory().get_adapter() ) + ): + body = await req.json() + activity = Activity().deserialize(body) + + async def call_bot_logic(turn_context: TurnContext): + await bot.on_turn(turn_context) + + auth_header = req.headers.get("Authorization", "") + await adapter.process_activity(activity, auth_header, call_bot_logic) + + return {} diff --git a/backend/app/routers/direct_line.py b/backend/app/routers/direct_line.py new file mode 100644 index 0000000..8ffc5ea --- /dev/null +++ b/backend/app/routers/direct_line.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, HTTPException +from typing import Dict, Any +from uuid import uuid4 +from botbuilder.core import TurnContext +from botbuilder.schema import Activity, ActivityTypes +from backend.app.factories.bot_factory import BotFactory + +router = APIRouter(prefix="/v3/directline") + +# In-memory conversation store +conversations: Dict[str, Dict[str, Any]] = {} +# Each conversation will look like: +# { "activities": [ { id, type, text, from } ], "watermark": int } + +@router.post("/conversations") +async def start_conversation(): + conversation_id = str(uuid4()) + conversations[conversation_id] = { + "activities": [], + "watermark": 0 + } + + return { + "conversationId": conversation_id, + "token": "mock-token", # Optional for dev use + "streamUrl": f"/v3/directline/conversations/{conversation_id}/stream" + } + +@router.get("/conversations/{conversation_id}/activities") +async def get_activities(conversation_id: str, watermark: int = 0): + if conversation_id not in conversations: + raise HTTPException(status_code=404, detail="Conversation not found") + + activities = conversations[conversation_id]["activities"] + return { + "activities": activities[watermark:], + "watermark": len(activities) + } + +@router.post("/conversations/{conversation_id}/activities") +async def post_activity(conversation_id: str, activity: Dict[str, Any]): + if conversation_id not in conversations: + raise HTTPException(status_code=404, detail="Conversation not found") + + # Starting with deserializing the activity + act = Activity().deserialize(activity) + + # Store my responses in this list please + bot_responses = [] + + #Patch TurnContext.send_activity to capture output + async def call_bot_logic(turn_context: TurnContext): + async def capture_response(response): + + # If it's a string, wrap it into an Activity + if isinstance(response, str): + bot_activity = Activity( + type=ActivityTypes.message, + text=response, + from_property={"id": "bot"} + ) + else: + bot_activity = response + + bot_responses.append(bot_activity) + + turn_context.send_activity = capture_response + await bot.on_turn(turn_context) + # 4. Call the adapter with the activity + adapter = BotFactory().get_adapter() + bot = BotFactory().get_bot("dayta") + auth_header = "" + + await adapter.process_activity(act, auth_header, call_bot_logic) + + # 5. Store bot responses into conversation memory + for act in bot_responses: + conversations[conversation_id]["activities"].append({ + "id": str(uuid4()), + "type": act.type, + "text": act.text, + "from": {"id": "bot"}, + "attachments": [a.serialize() for a in (act.attachments or [])] + }) + + return { "id": str(uuid4()) } \ No newline at end of file