Spaces:
Running
Running
Commit
Β·
239f45c
1
Parent(s):
acd0992
source
Browse files- app/ai/graph.py +12 -4
- app/ai/nodes/draft_node.py +39 -48
app/ai/graph.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# app/ai/graph.py β FINAL
|
| 2 |
from langgraph.graph import StateGraph, START, END
|
| 3 |
from app.ai.state import ChatState
|
| 4 |
from app.ai.nodes.intent_node import intent_node
|
|
@@ -44,7 +44,7 @@ workflow.add_conditional_edges("check_permissions", route_from_permissions,
|
|
| 44 |
|
| 45 |
workflow.add_edge("search_listings", END)
|
| 46 |
|
| 47 |
-
# ---------- POST-DRAFT
|
| 48 |
_KEYWORDS_PUBLISH = {"publish", "publier", "go live", "post it", "list it", "confirm", "yes", "ok", "okay"}
|
| 49 |
_KEYWORDS_EDIT = {"edit", "modifier", "change", "update", "correction", "fix"}
|
| 50 |
_KEYWORDS_DISCARD = {"discard", "delete", "cancel", "annuler", "remove", "start over"}
|
|
@@ -57,10 +57,18 @@ def route_after_draft(state: ChatState):
|
|
| 57 |
return "handle_edit"
|
| 58 |
if any(k in last for k in _KEYWORDS_DISCARD):
|
| 59 |
return "handle_discard"
|
| 60 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return "parse_intent"
|
| 62 |
|
| 63 |
-
workflow.add_conditional_edges(
|
|
|
|
|
|
|
|
|
|
| 64 |
{"handle_publish": "handle_publish",
|
| 65 |
"handle_edit": "handle_edit",
|
| 66 |
"handle_discard": "handle_discard",
|
|
|
|
| 1 |
+
# app/ai/graph.py β FINAL (LangGraph only)
|
| 2 |
from langgraph.graph import StateGraph, START, END
|
| 3 |
from app.ai.state import ChatState
|
| 4 |
from app.ai.nodes.intent_node import intent_node
|
|
|
|
| 44 |
|
| 45 |
workflow.add_edge("search_listings", END)
|
| 46 |
|
| 47 |
+
# ---------- POST-DRAFT ROUTING (language-agnostic) ----------
|
| 48 |
_KEYWORDS_PUBLISH = {"publish", "publier", "go live", "post it", "list it", "confirm", "yes", "ok", "okay"}
|
| 49 |
_KEYWORDS_EDIT = {"edit", "modifier", "change", "update", "correction", "fix"}
|
| 50 |
_KEYWORDS_DISCARD = {"discard", "delete", "cancel", "annuler", "remove", "start over"}
|
|
|
|
| 57 |
return "handle_edit"
|
| 58 |
if any(k in last for k in _KEYWORDS_DISCARD):
|
| 59 |
return "handle_discard"
|
| 60 |
+
return "parse_intent" # fall back to normal intent
|
| 61 |
+
|
| 62 |
+
def gate_before_intent(state: ChatState):
|
| 63 |
+
# if draft is on screen, go straight to post-draft router
|
| 64 |
+
if state.get("status") == "preview_shown":
|
| 65 |
+
return "route_after_draft"
|
| 66 |
return "parse_intent"
|
| 67 |
|
| 68 |
+
workflow.add_conditional_edges(START, gate_before_intent,
|
| 69 |
+
{"route_after_draft": "route_after_draft",
|
| 70 |
+
"parse_intent": "parse_intent"})
|
| 71 |
+
workflow.add_conditional_edges("route_after_draft", route_after_draft,
|
| 72 |
{"handle_publish": "handle_publish",
|
| 73 |
"handle_edit": "handle_edit",
|
| 74 |
"handle_discard": "handle_discard",
|
app/ai/nodes/draft_node.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# app/ai/nodes/draft_node.py
|
| 2 |
import datetime
|
| 3 |
from typing import Dict
|
| 4 |
from bson import ObjectId
|
|
@@ -14,7 +14,7 @@ logger = get_logger(__name__)
|
|
| 14 |
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
| 15 |
ml_extractor = get_ml_extractor()
|
| 16 |
|
| 17 |
-
# ========== AMENITY ICONS
|
| 18 |
AMENITY_ICONS = {
|
| 19 |
"wifi": "πΆ",
|
| 20 |
"parking": "π
ΏοΈ",
|
|
@@ -32,58 +32,49 @@ AMENITY_ICONS = {
|
|
| 32 |
}
|
| 33 |
|
| 34 |
def _add_amenity_icons(amenities: list) -> str:
|
| 35 |
-
"""Convert amenities list to string with icons."""
|
| 36 |
if not amenities:
|
| 37 |
return ""
|
| 38 |
-
|
| 39 |
icons_text = []
|
| 40 |
for amenity in amenities:
|
| 41 |
amenity_lower = amenity.lower().strip()
|
| 42 |
icon = AMENITY_ICONS.get(amenity_lower, "β")
|
| 43 |
icons_text.append(f"{icon} {amenity.title()}")
|
| 44 |
-
|
| 45 |
return " | ".join(icons_text)
|
| 46 |
|
| 47 |
def _generate_title(state: Dict) -> str:
|
| 48 |
-
"""Generate professional title from listing data."""
|
| 49 |
bedrooms = state.get("bedrooms", "")
|
| 50 |
-
# β
CRITICAL: Use current location from state, not regenerated
|
| 51 |
location = state.get("location", "").title()
|
| 52 |
listing_type = state.get("listing_type", "").title()
|
| 53 |
-
|
| 54 |
if bedrooms and location:
|
| 55 |
return f"{bedrooms}-Bedroom {listing_type} in {location}"
|
| 56 |
return f"Property in {location}"
|
| 57 |
|
| 58 |
def _generate_description(state: Dict) -> str:
|
| 59 |
-
"""Generate professional description from listing data."""
|
| 60 |
bedrooms = state.get("bedrooms", "")
|
| 61 |
bathrooms = state.get("bathrooms", "")
|
| 62 |
-
# β
CRITICAL: Use current location from state, not regenerated
|
| 63 |
location = state.get("location", "").title()
|
| 64 |
amenities = state.get("amenities", [])
|
| 65 |
price = state.get("price", "")
|
| 66 |
price_type = state.get("price_type", "").title()
|
| 67 |
listing_type = state.get("listing_type", "").title()
|
| 68 |
requirements = state.get("requirements", "")
|
| 69 |
-
|
| 70 |
desc = f"Spacious {bedrooms}-bedroom, {bathrooms}-bathroom {listing_type} "
|
| 71 |
desc += f"located in {location}. "
|
| 72 |
-
|
| 73 |
if price:
|
| 74 |
desc += f"Priced at {price:,} {price_type}. "
|
| 75 |
-
|
| 76 |
if amenities:
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
if requirements:
|
| 81 |
desc += f"Requirements: {requirements}. "
|
| 82 |
-
|
| 83 |
desc += "Perfect for large families or shared accommodation."
|
| 84 |
return desc
|
| 85 |
|
| 86 |
-
#
|
| 87 |
async def draft_node(state: Dict) -> Dict:
|
| 88 |
"""
|
| 89 |
LangGraph node:
|
|
@@ -92,106 +83,102 @@ async def draft_node(state: Dict) -> Dict:
|
|
| 92 |
- Move to "draft_ready"
|
| 93 |
- Or status == "draft_ready" β show preview
|
| 94 |
"""
|
| 95 |
-
|
| 96 |
# β
NEW: Check if amenities/requirements are missing
|
| 97 |
if state.get("status") == "checking_optional":
|
| 98 |
amenities = state.get("amenities", [])
|
| 99 |
requirements = state.get("requirements")
|
| 100 |
-
|
| 101 |
-
# Ask about missing optional fields
|
| 102 |
missing_optional = []
|
| 103 |
if not amenities:
|
| 104 |
missing_optional.append("amenities")
|
| 105 |
if not requirements:
|
| 106 |
missing_optional.append("requirements")
|
| 107 |
-
|
| 108 |
if missing_optional:
|
| 109 |
state["status"] = "collecting_optional"
|
| 110 |
state["missing_fields"] = missing_optional
|
| 111 |
-
|
| 112 |
questions = []
|
| 113 |
if "amenities" in missing_optional:
|
| 114 |
questions.append("Any amenities? (e.g., wifi, parking, balcony, pool, furnished, kitchen, dryer, garden, etc.)")
|
| 115 |
if "requirements" in missing_optional:
|
| 116 |
questions.append("Any special requirements for renters?")
|
| 117 |
-
|
| 118 |
state["ai_reply"] = "Just a couple more things...\n\n" + "\n".join([f"β’ {q}" for q in questions])
|
| 119 |
logger.info("βΉοΈ Asking for optional fields", missing=missing_optional)
|
| 120 |
return state
|
| 121 |
else:
|
| 122 |
# All optional fields provided, move to draft
|
| 123 |
state["status"] = "draft_ready"
|
| 124 |
-
|
| 125 |
# Only process if listing creation with all fields ready
|
| 126 |
if state.get("intent") != "list" or state.get("status") != "draft_ready":
|
| 127 |
return state
|
| 128 |
-
|
| 129 |
user_id = state.get("user_id")
|
| 130 |
-
|
| 131 |
# β
ML VALIDATION before drafting
|
| 132 |
try:
|
| 133 |
validation = ml_extractor.validate_all_fields(state, user_id)
|
| 134 |
-
|
| 135 |
if not validation["all_valid"]:
|
| 136 |
-
# Fields failed validation, go back to collecting
|
| 137 |
issues_text = "\n".join([f"β {issue}" for issue in validation["issues"]])
|
| 138 |
state["ai_reply"] = f"""I found some issues with your listing:
|
| 139 |
|
| 140 |
{issues_text}
|
| 141 |
|
| 142 |
Let me ask again - could you clarify these fields?"""
|
| 143 |
-
|
| 144 |
state["status"] = "collecting"
|
| 145 |
-
# Re-populate missing fields based on validation
|
| 146 |
state["missing_fields"] = [
|
| 147 |
field for field, result in validation["field_validations"].items()
|
| 148 |
if not result["is_valid"]
|
| 149 |
]
|
| 150 |
-
|
| 151 |
logger.warning("π« Fields failed ML validation", issues=validation["issues"])
|
| 152 |
return state
|
| 153 |
-
|
| 154 |
logger.info("β
All fields passed ML validation", user_id=user_id)
|
| 155 |
-
|
| 156 |
except Exception as e:
|
| 157 |
logger.error("β ML validation error", exc_info=e)
|
| 158 |
state["ai_reply"] = "Sorry, I couldn't validate your listing. Please try again."
|
| 159 |
state["status"] = "error"
|
| 160 |
return state
|
| 161 |
-
|
| 162 |
# Generate title and description
|
| 163 |
-
# β
CRITICAL: These use state's current location (corrected by user)
|
| 164 |
title = _generate_title(state)
|
| 165 |
description = _generate_description(state)
|
| 166 |
amenities_with_icons = _add_amenity_icons(state.get("amenities", []))
|
| 167 |
-
|
| 168 |
# β
Get images from state (if any were uploaded)
|
| 169 |
images = state.get("draft", {}).get("images", []) if isinstance(state.get("draft"), dict) else []
|
| 170 |
-
|
| 171 |
# Build draft preview with all fields including images and icons
|
| 172 |
draft_preview = {
|
| 173 |
"title": title,
|
| 174 |
"description": description,
|
| 175 |
-
"location": state.get("location", "").title(),
|
| 176 |
"bedrooms": state.get("bedrooms"),
|
| 177 |
"bathrooms": state.get("bathrooms"),
|
| 178 |
"price": state.get("price"),
|
| 179 |
"price_type": state.get("price_type"),
|
| 180 |
"listing_type": state.get("listing_type"),
|
| 181 |
"amenities": state.get("amenities", []),
|
| 182 |
-
"amenities_with_icons": amenities_with_icons,
|
| 183 |
"requirements": state.get("requirements"),
|
| 184 |
"currency": state.get("currency", "XOF"),
|
| 185 |
-
"images": images,
|
| 186 |
"field_confidences": validation["field_validations"],
|
| 187 |
}
|
| 188 |
-
|
| 189 |
logger.info("π― Draft preview generated",
|
| 190 |
title=title,
|
| 191 |
-
location=state.get("location"),
|
| 192 |
image_count=len(images),
|
| 193 |
amenities=state.get("amenities", []))
|
| 194 |
-
|
| 195 |
# Build nice preview message for user
|
| 196 |
images_section = ""
|
| 197 |
if images:
|
|
@@ -200,7 +187,7 @@ Let me ask again - could you clarify these fields?"""
|
|
| 200 |
images_section += f" {idx}. {img_url[:60]}...\n"
|
| 201 |
if len(images) > 3:
|
| 202 |
images_section += f" ... and {len(images) - 3} more\n"
|
| 203 |
-
|
| 204 |
preview_text = f"""
|
| 205 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
π LISTING PREVIEW
|
|
@@ -219,7 +206,7 @@ Let me ask again - could you clarify these fields?"""
|
|
| 219 |
{images_section}
|
| 220 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 221 |
"""
|
| 222 |
-
|
| 223 |
# β
Check if images uploaded, ask if not
|
| 224 |
if not images:
|
| 225 |
preview_text += """
|
|
@@ -233,8 +220,12 @@ Then say **publish** to make it live!
|
|
| 233 |
β
Perfect! Say **publish** to make your listing live!
|
| 234 |
"""
|
| 235 |
state["status"] = "preview_shown" # β
Ready to publish
|
| 236 |
-
|
| 237 |
state["draft_preview"] = draft_preview
|
| 238 |
state["ai_reply"] = preview_text
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
return state
|
|
|
|
| 1 |
+
# app/ai/nodes/draft_node.py β COMPLETE FILE (final)
|
| 2 |
import datetime
|
| 3 |
from typing import Dict
|
| 4 |
from bson import ObjectId
|
|
|
|
| 14 |
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
| 15 |
ml_extractor = get_ml_extractor()
|
| 16 |
|
| 17 |
+
# ========== AMENITY ICONS ==========
|
| 18 |
AMENITY_ICONS = {
|
| 19 |
"wifi": "πΆ",
|
| 20 |
"parking": "π
ΏοΈ",
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
def _add_amenity_icons(amenities: list) -> str:
|
|
|
|
| 35 |
if not amenities:
|
| 36 |
return ""
|
|
|
|
| 37 |
icons_text = []
|
| 38 |
for amenity in amenities:
|
| 39 |
amenity_lower = amenity.lower().strip()
|
| 40 |
icon = AMENITY_ICONS.get(amenity_lower, "β")
|
| 41 |
icons_text.append(f"{icon} {amenity.title()}")
|
|
|
|
| 42 |
return " | ".join(icons_text)
|
| 43 |
|
| 44 |
def _generate_title(state: Dict) -> str:
|
|
|
|
| 45 |
bedrooms = state.get("bedrooms", "")
|
|
|
|
| 46 |
location = state.get("location", "").title()
|
| 47 |
listing_type = state.get("listing_type", "").title()
|
|
|
|
| 48 |
if bedrooms and location:
|
| 49 |
return f"{bedrooms}-Bedroom {listing_type} in {location}"
|
| 50 |
return f"Property in {location}"
|
| 51 |
|
| 52 |
def _generate_description(state: Dict) -> str:
|
|
|
|
| 53 |
bedrooms = state.get("bedrooms", "")
|
| 54 |
bathrooms = state.get("bathrooms", "")
|
|
|
|
| 55 |
location = state.get("location", "").title()
|
| 56 |
amenities = state.get("amenities", [])
|
| 57 |
price = state.get("price", "")
|
| 58 |
price_type = state.get("price_type", "").title()
|
| 59 |
listing_type = state.get("listing_type", "").title()
|
| 60 |
requirements = state.get("requirements", "")
|
| 61 |
+
|
| 62 |
desc = f"Spacious {bedrooms}-bedroom, {bathrooms}-bathroom {listing_type} "
|
| 63 |
desc += f"located in {location}. "
|
| 64 |
+
|
| 65 |
if price:
|
| 66 |
desc += f"Priced at {price:,} {price_type}. "
|
| 67 |
+
|
| 68 |
if amenities:
|
| 69 |
+
desc += f"Fully furnished with modern amenities including {', '.join(amenities)}. "
|
| 70 |
+
|
|
|
|
| 71 |
if requirements:
|
| 72 |
desc += f"Requirements: {requirements}. "
|
| 73 |
+
|
| 74 |
desc += "Perfect for large families or shared accommodation."
|
| 75 |
return desc
|
| 76 |
|
| 77 |
+
# ========== MAIN NODE ==========
|
| 78 |
async def draft_node(state: Dict) -> Dict:
|
| 79 |
"""
|
| 80 |
LangGraph node:
|
|
|
|
| 83 |
- Move to "draft_ready"
|
| 84 |
- Or status == "draft_ready" β show preview
|
| 85 |
"""
|
| 86 |
+
|
| 87 |
# β
NEW: Check if amenities/requirements are missing
|
| 88 |
if state.get("status") == "checking_optional":
|
| 89 |
amenities = state.get("amenities", [])
|
| 90 |
requirements = state.get("requirements")
|
| 91 |
+
|
|
|
|
| 92 |
missing_optional = []
|
| 93 |
if not amenities:
|
| 94 |
missing_optional.append("amenities")
|
| 95 |
if not requirements:
|
| 96 |
missing_optional.append("requirements")
|
| 97 |
+
|
| 98 |
if missing_optional:
|
| 99 |
state["status"] = "collecting_optional"
|
| 100 |
state["missing_fields"] = missing_optional
|
| 101 |
+
|
| 102 |
questions = []
|
| 103 |
if "amenities" in missing_optional:
|
| 104 |
questions.append("Any amenities? (e.g., wifi, parking, balcony, pool, furnished, kitchen, dryer, garden, etc.)")
|
| 105 |
if "requirements" in missing_optional:
|
| 106 |
questions.append("Any special requirements for renters?")
|
| 107 |
+
|
| 108 |
state["ai_reply"] = "Just a couple more things...\n\n" + "\n".join([f"β’ {q}" for q in questions])
|
| 109 |
logger.info("βΉοΈ Asking for optional fields", missing=missing_optional)
|
| 110 |
return state
|
| 111 |
else:
|
| 112 |
# All optional fields provided, move to draft
|
| 113 |
state["status"] = "draft_ready"
|
| 114 |
+
|
| 115 |
# Only process if listing creation with all fields ready
|
| 116 |
if state.get("intent") != "list" or state.get("status") != "draft_ready":
|
| 117 |
return state
|
| 118 |
+
|
| 119 |
user_id = state.get("user_id")
|
| 120 |
+
|
| 121 |
# β
ML VALIDATION before drafting
|
| 122 |
try:
|
| 123 |
validation = ml_extractor.validate_all_fields(state, user_id)
|
| 124 |
+
|
| 125 |
if not validation["all_valid"]:
|
|
|
|
| 126 |
issues_text = "\n".join([f"β {issue}" for issue in validation["issues"]])
|
| 127 |
state["ai_reply"] = f"""I found some issues with your listing:
|
| 128 |
|
| 129 |
{issues_text}
|
| 130 |
|
| 131 |
Let me ask again - could you clarify these fields?"""
|
| 132 |
+
|
| 133 |
state["status"] = "collecting"
|
|
|
|
| 134 |
state["missing_fields"] = [
|
| 135 |
field for field, result in validation["field_validations"].items()
|
| 136 |
if not result["is_valid"]
|
| 137 |
]
|
| 138 |
+
|
| 139 |
logger.warning("π« Fields failed ML validation", issues=validation["issues"])
|
| 140 |
return state
|
| 141 |
+
|
| 142 |
logger.info("β
All fields passed ML validation", user_id=user_id)
|
| 143 |
+
|
| 144 |
except Exception as e:
|
| 145 |
logger.error("β ML validation error", exc_info=e)
|
| 146 |
state["ai_reply"] = "Sorry, I couldn't validate your listing. Please try again."
|
| 147 |
state["status"] = "error"
|
| 148 |
return state
|
| 149 |
+
|
| 150 |
# Generate title and description
|
|
|
|
| 151 |
title = _generate_title(state)
|
| 152 |
description = _generate_description(state)
|
| 153 |
amenities_with_icons = _add_amenity_icons(state.get("amenities", []))
|
| 154 |
+
|
| 155 |
# β
Get images from state (if any were uploaded)
|
| 156 |
images = state.get("draft", {}).get("images", []) if isinstance(state.get("draft"), dict) else []
|
| 157 |
+
|
| 158 |
# Build draft preview with all fields including images and icons
|
| 159 |
draft_preview = {
|
| 160 |
"title": title,
|
| 161 |
"description": description,
|
| 162 |
+
"location": state.get("location", "").title(),
|
| 163 |
"bedrooms": state.get("bedrooms"),
|
| 164 |
"bathrooms": state.get("bathrooms"),
|
| 165 |
"price": state.get("price"),
|
| 166 |
"price_type": state.get("price_type"),
|
| 167 |
"listing_type": state.get("listing_type"),
|
| 168 |
"amenities": state.get("amenities", []),
|
| 169 |
+
"amenities_with_icons": amenities_with_icons,
|
| 170 |
"requirements": state.get("requirements"),
|
| 171 |
"currency": state.get("currency", "XOF"),
|
| 172 |
+
"images": images,
|
| 173 |
"field_confidences": validation["field_validations"],
|
| 174 |
}
|
| 175 |
+
|
| 176 |
logger.info("π― Draft preview generated",
|
| 177 |
title=title,
|
| 178 |
+
location=state.get("location"),
|
| 179 |
image_count=len(images),
|
| 180 |
amenities=state.get("amenities", []))
|
| 181 |
+
|
| 182 |
# Build nice preview message for user
|
| 183 |
images_section = ""
|
| 184 |
if images:
|
|
|
|
| 187 |
images_section += f" {idx}. {img_url[:60]}...\n"
|
| 188 |
if len(images) > 3:
|
| 189 |
images_section += f" ... and {len(images) - 3} more\n"
|
| 190 |
+
|
| 191 |
preview_text = f"""
|
| 192 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 193 |
π LISTING PREVIEW
|
|
|
|
| 206 |
{images_section}
|
| 207 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
"""
|
| 209 |
+
|
| 210 |
# β
Check if images uploaded, ask if not
|
| 211 |
if not images:
|
| 212 |
preview_text += """
|
|
|
|
| 220 |
β
Perfect! Say **publish** to make your listing live!
|
| 221 |
"""
|
| 222 |
state["status"] = "preview_shown" # β
Ready to publish
|
| 223 |
+
|
| 224 |
state["draft_preview"] = draft_preview
|
| 225 |
state["ai_reply"] = preview_text
|
| 226 |
+
|
| 227 |
+
# ------------------------------------------------------------------
|
| 228 |
+
# FINAL FLAG β triggers the gate in graph.py
|
| 229 |
+
# ------------------------------------------------------------------
|
| 230 |
+
state["status"] = "preview_shown"
|
| 231 |
return state
|