feat: ms-bot-framework with dynamic adaptive cards
This commit is contained in:
parent
3aa78be3d6
commit
f96f4af069
47
backend/app/bots/adaptive_cards.py
Normal file
47
backend/app/bots/adaptive_cards.py
Normal file
@ -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"
|
||||||
|
}
|
||||||
115
backend/app/bots/dayta.py
Normal file
115
backend/app/bots/dayta.py
Normal file
@ -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
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
23
backend/app/bots/intent_detector.py
Normal file
23
backend/app/bots/intent_detector.py
Normal file
@ -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})
|
||||||
31
backend/app/bots/slot_filler.py
Normal file
31
backend/app/bots/slot_filler.py
Normal file
@ -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 {}
|
||||||
@ -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]
|
|
||||||
)
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class HouseCreateResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
@ -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]
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class OwnerDetailResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
email: str
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class OwnerResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
user_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class OwnerListResponse(BaseModel):
|
|
||||||
owners: list[OwnerResponse]
|
|
||||||
33
backend/app/factories/bot_factory.py
Normal file
33
backend/app/factories/bot_factory.py
Normal file
@ -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
|
||||||
@ -7,14 +7,13 @@ from .middleware.authenticate import authenticate
|
|||||||
from .providers.db_provider import create_db_and_tables
|
from .providers.db_provider import create_db_and_tables
|
||||||
from .routers.houses import router as houses_router
|
from .routers.houses import router as houses_router
|
||||||
from .routers.owners import router as owners_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
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
create_db_and_tables()
|
create_db_and_tables()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Fair Housing API",
|
title="Fair Housing API",
|
||||||
description="Provides access to core functionality for the fair housing platform.",
|
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(houses_router, prefix="/houses", tags=["houses"])
|
||||||
app.include_router(owners_router, prefix="/owners", tags=["owners"])
|
app.include_router(owners_router, prefix="/owners", tags=["owners"])
|
||||||
|
app.include_router(bot_router, tags=["bot"])
|
||||||
|
app.include_router(direct_line_router, tags=["directline"])
|
||||||
23
backend/app/routers/bot.py
Normal file
23
backend/app/routers/bot.py
Normal file
@ -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 {}
|
||||||
86
backend/app/routers/direct_line.py
Normal file
86
backend/app/routers/direct_line.py
Normal file
@ -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()) }
|
||||||
Loading…
x
Reference in New Issue
Block a user