Spaces:
Build error
Build error
Reynaldy Hardiyanto commited on
Commit ·
9012b52
1
Parent(s): c1f6e39
feat: add property search, intent routing, and intent summarization
Browse files- app.py +39 -165
- config.py +13 -4
- local_dists/backend_clients-4.5.0-py3-none-any.whl +0 -0
- local_dists/data_model-2.9.0-py3-none-any.whl +0 -0
- local_dists/standard_logger-1.1.0-py3-none-any.whl +0 -0
- poetry.lock +0 -0
- prompts.py +512 -4
- pyproject.toml +5 -2
- schemas/__init__.py +0 -0
- schemas/chatbot.py +122 -0
- services/__init__.py +0 -0
- services/intent_routing.py +84 -0
- services/property_knowledge.py +150 -0
- services/property_search.py +537 -0
- services/utils.py +95 -0
app.py
CHANGED
|
@@ -1,198 +1,72 @@
|
|
| 1 |
import json
|
| 2 |
-
import re
|
| 3 |
|
| 4 |
# Suppress all UserWarnings
|
| 5 |
import warnings
|
| 6 |
-
from typing import Any
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
-
from langchain.retrievers import ContextualCompressionRetriever
|
| 10 |
-
from langchain.retrievers.document_compressors import LLMChainFilter
|
| 11 |
-
from langchain.retrievers.document_compressors.chain_filter_prompt import (
|
| 12 |
-
prompt_template,
|
| 13 |
-
)
|
| 14 |
-
from langchain.schema import StrOutputParser
|
| 15 |
-
from langchain_community.vectorstores.elasticsearch import ElasticsearchStore
|
| 16 |
-
from langchain_core.documents import Document
|
| 17 |
-
from langchain_core.output_parsers import BaseOutputParser
|
| 18 |
-
from langchain_core.prompts import ChatPromptTemplate, PromptTemplate
|
| 19 |
-
from langchain_core.runnables import RunnablePassthrough
|
| 20 |
-
from langchain_core.runnables.base import RunnableSerializable
|
| 21 |
-
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
| 22 |
|
| 23 |
from config import get_settings
|
| 24 |
-
from
|
| 25 |
-
|
| 26 |
-
warnings.filterwarnings("ignore", category=UserWarning)
|
| 27 |
-
|
| 28 |
-
KNOWLEDGE_BASE_INDEX_NAME = "pinsmart_revamp_prototype"
|
| 29 |
-
EMBEDDING_MODEL = "text-embedding-3-large"
|
| 30 |
-
RAG_LLM_MODEL = "gpt-3.5-turbo-0125"
|
| 31 |
-
NROF_RETRIEVED_CONTEXT = 5
|
| 32 |
-
|
| 33 |
-
ERROR_MESSAGE = "Terjadi kesalahan, silakan coba kembali"
|
| 34 |
-
|
| 35 |
-
# ES only support up to 2048 embedding dimension
|
| 36 |
-
embeddings = OpenAIEmbeddings(
|
| 37 |
-
model=EMBEDDING_MODEL,
|
| 38 |
-
api_key=get_settings().openai_api_key,
|
| 39 |
-
dimensions=2048,
|
| 40 |
-
timeout=20,
|
| 41 |
)
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
index_name=KNOWLEDGE_BASE_INDEX_NAME,
|
| 46 |
-
embedding=embeddings,
|
| 47 |
-
)
|
| 48 |
-
|
| 49 |
-
llm = ChatOpenAI(
|
| 50 |
-
model_name=RAG_LLM_MODEL,
|
| 51 |
-
temperature=0,
|
| 52 |
-
api_key=get_settings().openai_api_key,
|
| 53 |
-
timeout=20,
|
| 54 |
)
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
[
|
| 58 |
-
("system", SYSTEM_PROMPT),
|
| 59 |
-
("human", QA_WITH_CONTEXT_PROMPT),
|
| 60 |
-
]
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
class ImprovedBooleanOutputParser(BaseOutputParser[bool]):
|
| 65 |
-
"""Parse the output of an LLM call to a boolean."""
|
| 66 |
-
|
| 67 |
-
true_val: str = "YES"
|
| 68 |
-
"""The string value that should be parsed as True."""
|
| 69 |
-
false_val: str = "NO"
|
| 70 |
-
"""The string value that should be parsed as False."""
|
| 71 |
-
|
| 72 |
-
def parse(self, text: str) -> bool:
|
| 73 |
-
"""Parse the output of an LLM call to a boolean.
|
| 74 |
-
|
| 75 |
-
Args:
|
| 76 |
-
text: output of a language model
|
| 77 |
-
|
| 78 |
-
Returns:
|
| 79 |
-
boolean
|
| 80 |
-
|
| 81 |
-
"""
|
| 82 |
-
cleaned_text = text.strip()
|
| 83 |
-
if (self.true_val.upper() not in cleaned_text.upper()) and (
|
| 84 |
-
self.false_val.upper() not in cleaned_text.upper()
|
| 85 |
-
):
|
| 86 |
-
raise ValueError(
|
| 87 |
-
f"ImprovedBooleanOutputParser expected output value to either "
|
| 88 |
-
f"contains {self.true_val} or {self.false_val}. "
|
| 89 |
-
f"Received {cleaned_text}."
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
return self.true_val.upper() in cleaned_text.upper()
|
| 93 |
-
|
| 94 |
-
@property
|
| 95 |
-
def _type(self) -> str:
|
| 96 |
-
"""Snake-case string identifier for an output parser type."""
|
| 97 |
-
return "improved_boolean_output_parser"
|
| 98 |
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
llm,
|
| 102 |
-
prompt=PromptTemplate(
|
| 103 |
-
template=prompt_template,
|
| 104 |
-
input_variables=["question", "context"],
|
| 105 |
-
output_parser=ImprovedBooleanOutputParser(),
|
| 106 |
-
),
|
| 107 |
-
)
|
| 108 |
-
docs_retriever = vector_db.as_retriever(
|
| 109 |
-
search_kwargs={"k": NROF_RETRIEVED_CONTEXT}
|
| 110 |
-
)
|
| 111 |
|
| 112 |
|
| 113 |
-
def
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
| 120 |
)
|
| 121 |
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def get_end_device_target(*args: Any) -> str:
|
| 133 |
-
return "mobile"
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
rag_chain: RunnableSerializable = (
|
| 137 |
-
{
|
| 138 |
-
"context": docs_compression_retriever | format_contexts,
|
| 139 |
-
"device": get_end_device_target,
|
| 140 |
-
"question": RunnablePassthrough(),
|
| 141 |
-
}
|
| 142 |
-
| qa_prompt
|
| 143 |
-
| llm
|
| 144 |
-
| StrOutputParser()
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def parse_response(response: str) -> tuple[str, list[dict] | None]:
|
| 149 |
-
answer_match = re.search(
|
| 150 |
-
r"\[ANSWER\]\n(.+?)(?=\n\[(.*?)\])", response, re.DOTALL
|
| 151 |
-
)
|
| 152 |
-
related_docs_match = re.search(r"\[REFERENCE\]\n(.+)", response, re.DOTALL)
|
| 153 |
-
|
| 154 |
-
if answer_match:
|
| 155 |
-
answer = answer_match.group(1).strip()
|
| 156 |
-
related_docs = None
|
| 157 |
-
|
| 158 |
-
if related_docs_match:
|
| 159 |
-
related_docs_str = (
|
| 160 |
-
related_docs_match.group(1).strip().replace("```", "")
|
| 161 |
)
|
| 162 |
-
try:
|
| 163 |
-
related_docs = json.loads(related_docs_str)
|
| 164 |
-
except Exception:
|
| 165 |
-
print(f"failed to parse related docs: {related_docs_str}")
|
| 166 |
-
|
| 167 |
-
return answer, related_docs
|
| 168 |
-
|
| 169 |
-
raise ValueError(f"failed to parse response: {response}")
|
| 170 |
-
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
except Exception as e:
|
| 177 |
print(f"Error : {e}")
|
| 178 |
return ERROR_MESSAGE
|
| 179 |
|
| 180 |
-
if related_docs:
|
| 181 |
-
reference_str = "Referensi: \n"
|
| 182 |
-
for doc in related_docs:
|
| 183 |
-
reference_title = doc["title"]
|
| 184 |
-
reference_str += f"- Artikel: {reference_title}\n"
|
| 185 |
-
|
| 186 |
-
answer += "\n\n" + reference_str
|
| 187 |
-
|
| 188 |
return answer
|
| 189 |
|
| 190 |
|
| 191 |
def chat(message, history):
|
| 192 |
history = history or []
|
| 193 |
-
response = get_prompt_response(message)
|
| 194 |
|
| 195 |
history.append((message, response))
|
|
|
|
|
|
|
| 196 |
|
| 197 |
return history, history
|
| 198 |
|
|
|
|
| 1 |
import json
|
|
|
|
| 2 |
|
| 3 |
# Suppress all UserWarnings
|
| 4 |
import warnings
|
|
|
|
| 5 |
|
| 6 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
from config import get_settings
|
| 9 |
+
from schemas.chatbot import (
|
| 10 |
+
IntentCategories,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
)
|
| 12 |
+
from services.intent_routing import (
|
| 13 |
+
generate_intent_routing,
|
| 14 |
+
generate_user_question_intent,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
)
|
| 16 |
+
from services.property_knowledge import generate_property_knowledge_answer
|
| 17 |
+
from services.property_search import search_property_listing
|
| 18 |
+
from services.utils import parse_histories
|
| 19 |
|
| 20 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
N_HISTORIES = 10
|
| 23 |
|
| 24 |
+
ERROR_MESSAGE = "Terjadi kesalahan, silakan coba kembali"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
+
def get_prompt_response(message: str, histories: list[str, str]):
|
| 28 |
+
try:
|
| 29 |
+
conversational_histories = parse_histories(histories)
|
| 30 |
+
response = generate_user_question_intent(
|
| 31 |
+
conversational_histories, message
|
| 32 |
+
)
|
| 33 |
+
summarized_user_question_intent = response.choices[0].message.content
|
| 34 |
+
user_intent = generate_intent_routing(
|
| 35 |
+
summarized_user_question_intent,
|
| 36 |
+
model=get_settings().intent_routing__chat_completion_model_name,
|
| 37 |
)
|
| 38 |
|
| 39 |
+
if user_intent == IntentCategories.property_knowledge_qa:
|
| 40 |
+
answer = generate_property_knowledge_answer(
|
| 41 |
+
summarized_user_question_intent,
|
| 42 |
+
)
|
| 43 |
+
else:
|
| 44 |
+
answer = search_property_listing(
|
| 45 |
+
user_question=summarized_user_question_intent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
+
# Note: for debugging
|
| 49 |
+
conversational_histories = json.dumps(
|
| 50 |
+
conversational_histories, indent=4
|
| 51 |
+
)
|
| 52 |
+
print(f"{'='*7} {message} {'='*7}")
|
| 53 |
+
print(f"User Conversation: {conversational_histories}")
|
| 54 |
+
print(f"Generated Intent: {summarized_user_question_intent}")
|
| 55 |
+
print("=" * 30)
|
| 56 |
except Exception as e:
|
| 57 |
print(f"Error : {e}")
|
| 58 |
return ERROR_MESSAGE
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
return answer
|
| 61 |
|
| 62 |
|
| 63 |
def chat(message, history):
|
| 64 |
history = history or []
|
| 65 |
+
response = get_prompt_response(message, history)
|
| 66 |
|
| 67 |
history.append((message, response))
|
| 68 |
+
if len(history) >= N_HISTORIES:
|
| 69 |
+
history = history[-N_HISTORIES:]
|
| 70 |
|
| 71 |
return history, history
|
| 72 |
|
config.py
CHANGED
|
@@ -1,15 +1,24 @@
|
|
| 1 |
from functools import lru_cache
|
| 2 |
|
| 3 |
-
from
|
| 4 |
|
| 5 |
|
| 6 |
class Settings(BaseSettings):
|
| 7 |
openai_api_key: str
|
| 8 |
es_url: str
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
| 12 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
@lru_cache
|
|
|
|
| 1 |
from functools import lru_cache
|
| 2 |
|
| 3 |
+
from pydantic import BaseSettings
|
| 4 |
|
| 5 |
|
| 6 |
class Settings(BaseSettings):
|
| 7 |
openai_api_key: str
|
| 8 |
es_url: str
|
| 9 |
+
collection_index_name: str = (
|
| 10 |
+
"property_knowledge_references_collection__v1_0" # noqa
|
|
|
|
| 11 |
)
|
| 12 |
+
intent_routing__chat_completion_model_name: str = "gpt-3.5-turbo-0125"
|
| 13 |
+
property_search__chat_completion_model_name: str = "gpt-3.5-turbo-1106"
|
| 14 |
+
property_search__location_similarity_thres: int = 60
|
| 15 |
+
property_search__limit_search_size: int = 3
|
| 16 |
+
property_search__exact_price_range_thres: float = 0.1
|
| 17 |
+
property_search__limit_price_range_thres: float = 0.25
|
| 18 |
+
property_search__value_range_thres: float = 0.1
|
| 19 |
+
|
| 20 |
+
class Config:
|
| 21 |
+
env_file = ".env"
|
| 22 |
|
| 23 |
|
| 24 |
@lru_cache
|
local_dists/backend_clients-4.5.0-py3-none-any.whl
ADDED
|
Binary file (40 kB). View file
|
|
|
local_dists/data_model-2.9.0-py3-none-any.whl
ADDED
|
Binary file (17.2 kB). View file
|
|
|
local_dists/standard_logger-1.1.0-py3-none-any.whl
ADDED
|
Binary file (3.72 kB). View file
|
|
|
poetry.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
prompts.py
CHANGED
|
@@ -3,6 +3,7 @@ SYSTEM_PROMPT = (
|
|
| 3 |
" as PinSmart. You work for Pinhome.id and hence cannot mention other "
|
| 4 |
"competitor such as rumah.com, rumah123.com, lamudi.id, 99.co, and so on."
|
| 5 |
)
|
|
|
|
| 6 |
QA_WITH_CONTEXT_PROMPT = """
|
| 7 |
## Instructions
|
| 8 |
|
|
@@ -21,12 +22,10 @@ QA_WITH_CONTEXT_PROMPT = """
|
|
| 21 |
[REFERENCE]
|
| 22 |
[
|
| 23 |
{{
|
| 24 |
-
"source" : "context_source1"
|
| 25 |
-
"title" : "context_title1"
|
| 26 |
}},
|
| 27 |
{{
|
| 28 |
-
"source" : "context_source2"
|
| 29 |
-
"title" : "context_title2"
|
| 30 |
}},
|
| 31 |
...
|
| 32 |
]
|
|
@@ -60,3 +59,512 @@ title: {title}
|
|
| 60 |
|
| 61 |
{content}
|
| 62 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
" as PinSmart. You work for Pinhome.id and hence cannot mention other "
|
| 4 |
"competitor such as rumah.com, rumah123.com, lamudi.id, 99.co, and so on."
|
| 5 |
)
|
| 6 |
+
|
| 7 |
QA_WITH_CONTEXT_PROMPT = """
|
| 8 |
## Instructions
|
| 9 |
|
|
|
|
| 22 |
[REFERENCE]
|
| 23 |
[
|
| 24 |
{{
|
| 25 |
+
"source" : "context_source1"
|
|
|
|
| 26 |
}},
|
| 27 |
{{
|
| 28 |
+
"source" : "context_source2"
|
|
|
|
| 29 |
}},
|
| 30 |
...
|
| 31 |
]
|
|
|
|
| 59 |
|
| 60 |
{content}
|
| 61 |
"""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
SUMMARIZED_USER_QUESTION_INTENT_SYSTEM_PROMPT = """
|
| 65 |
+
You are a linguist that processes last user-chat conversations and the
|
| 66 |
+
given previous conversations into a summary intent for the last question.
|
| 67 |
+
The system should analyze the content of the conversations and identify
|
| 68 |
+
the main topic or query expressed by the user. The context is set in
|
| 69 |
+
Indonesian, there for 10m means 10 milyar or 5m means 5 milyar
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
SUMMARIZED_USER_QUESTION_INTENT_USER_PROMPT = """
|
| 74 |
+
## Instructions
|
| 75 |
+
|
| 76 |
+
- Formulate a concise user intent for the given question,
|
| 77 |
+
it should consider context from previous interactions
|
| 78 |
+
if available to produce more accurate summaries
|
| 79 |
+
- You MUST not interpret any abbreviation
|
| 80 |
+
- The generated intent MUST BE on Bahasa Indonesia
|
| 81 |
+
- It should also highlight the main topic and the keyword of the conversation
|
| 82 |
+
- You may excluded irrelevant contexts
|
| 83 |
+
- The output MUST only the generated intent in string format
|
| 84 |
+
|
| 85 |
+
Here's the given example that you can use as reference:
|
| 86 |
+
|
| 87 |
+
-----
|
| 88 |
+
### Example Contexts
|
| 89 |
+
[
|
| 90 |
+
{{
|
| 91 |
+
"role": "user",
|
| 92 |
+
"content": "Hi min, mau nanya dong apa itu kpr?"
|
| 93 |
+
}},
|
| 94 |
+
{{
|
| 95 |
+
"role": "assistant",
|
| 96 |
+
"content": "KPR adalah singkatan dari Kredit Kepemilikan Rumah,
|
| 97 |
+
yaitu jenis pinjaman yang diberikan oleh bank
|
| 98 |
+
atau lembaga keuangan lainnya kepada individu untuk membeli rumah.",
|
| 99 |
+
}}
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
### Example Question
|
| 103 |
+
sorii, yang takeover maksudnya?
|
| 104 |
+
|
| 105 |
+
### Example Intent
|
| 106 |
+
User menanyakan tentang kpr takeover
|
| 107 |
+
|
| 108 |
+
----
|
| 109 |
+
|
| 110 |
+
### Example Contexts
|
| 111 |
+
[
|
| 112 |
+
{{
|
| 113 |
+
"role": "user",
|
| 114 |
+
"content": "Saya ingin mencari rumah tapak di
|
| 115 |
+
Yogyakarta dengan budget 2M"
|
| 116 |
+
}},
|
| 117 |
+
{{
|
| 118 |
+
"role": "assistant",
|
| 119 |
+
"content": "{{'location': 'Yogyakarta', 'maxPrice': '2000000000',
|
| 120 |
+
'buildingType': 'building_type.house'}}",
|
| 121 |
+
}},
|
| 122 |
+
{{
|
| 123 |
+
"role": "user",
|
| 124 |
+
"content": "Kalo yang kamar tidur 2 ada?"
|
| 125 |
+
}},
|
| 126 |
+
{{
|
| 127 |
+
"role": "assistant",
|
| 128 |
+
"content": "{{'location': 'Yogyakarta', 'maxPrice': '2000000000',
|
| 129 |
+
'buildingType': 'building_type.house', 'bedroom': '2'}}",
|
| 130 |
+
}}
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
### Example Question
|
| 134 |
+
Apa itu kpr?
|
| 135 |
+
|
| 136 |
+
### Example Intent
|
| 137 |
+
User menanyakan tentang definisi kpr
|
| 138 |
+
|
| 139 |
+
-----
|
| 140 |
+
|
| 141 |
+
## Contexts
|
| 142 |
+
{contexts}
|
| 143 |
+
|
| 144 |
+
## Question
|
| 145 |
+
{question}
|
| 146 |
+
|
| 147 |
+
## Intent
|
| 148 |
+
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
PROPERTY_SEARCH_FUNCTION_CALLING_PROMPT = [
|
| 152 |
+
{
|
| 153 |
+
"type": "function",
|
| 154 |
+
"function": {
|
| 155 |
+
"name": "get_listing_by_search_param",
|
| 156 |
+
"description": "Function to search property by text"
|
| 157 |
+
"listing by parsed text param",
|
| 158 |
+
"parameters": {
|
| 159 |
+
"type": "object",
|
| 160 |
+
"properties": {
|
| 161 |
+
"keyword": {
|
| 162 |
+
"type": "string",
|
| 163 |
+
"description": (
|
| 164 |
+
"The term that indicates the location or "
|
| 165 |
+
"point of interest. E.g. Tangsel, Depok, Jaksel, "
|
| 166 |
+
"SCBD, BSD, Cendana Spring, Vasa Jagakarsa"
|
| 167 |
+
),
|
| 168 |
+
},
|
| 169 |
+
"maxBuildingArea": {
|
| 170 |
+
"type": "integer",
|
| 171 |
+
"description": (
|
| 172 |
+
"The maximum size of property building area in "
|
| 173 |
+
"square meter"
|
| 174 |
+
),
|
| 175 |
+
},
|
| 176 |
+
"minBuildingArea": {
|
| 177 |
+
"type": "integer",
|
| 178 |
+
"description": (
|
| 179 |
+
"The minimum size of property building area in "
|
| 180 |
+
"square meter"
|
| 181 |
+
),
|
| 182 |
+
},
|
| 183 |
+
"maxSurfaceArea": {
|
| 184 |
+
"type": "integer",
|
| 185 |
+
"description": (
|
| 186 |
+
"The maximum size of property land surface area in"
|
| 187 |
+
" square meter"
|
| 188 |
+
),
|
| 189 |
+
},
|
| 190 |
+
"minSurfaceArea": {
|
| 191 |
+
"type": "integer",
|
| 192 |
+
"description": (
|
| 193 |
+
"The minimum size of property land surface area in"
|
| 194 |
+
" square meter"
|
| 195 |
+
),
|
| 196 |
+
},
|
| 197 |
+
"maxPrice": {
|
| 198 |
+
"type": "integer",
|
| 199 |
+
"description": (
|
| 200 |
+
"The maximum property's price limit in Indonesian "
|
| 201 |
+
"Rupiah context, E.g 1m means 1000000000 or "
|
| 202 |
+
"10M means 10000000000 or 100M means 100000000000"
|
| 203 |
+
),
|
| 204 |
+
},
|
| 205 |
+
"minPrice": {
|
| 206 |
+
"type": "integer",
|
| 207 |
+
"description": (
|
| 208 |
+
"The minimum property's price limit Indonesian "
|
| 209 |
+
"Rupiah context, E.g 1m means 1000000000 or "
|
| 210 |
+
"10M means 10000000000 or 100M means 100000000000"
|
| 211 |
+
),
|
| 212 |
+
},
|
| 213 |
+
"maxBedroom": {
|
| 214 |
+
"type": "integer",
|
| 215 |
+
"description": (
|
| 216 |
+
"The maximum property's bedroom limit"
|
| 217 |
+
),
|
| 218 |
+
},
|
| 219 |
+
"minBedroom": {
|
| 220 |
+
"type": "integer",
|
| 221 |
+
"description": (
|
| 222 |
+
"The minimum property's bedroom limit"
|
| 223 |
+
),
|
| 224 |
+
},
|
| 225 |
+
"minToilet": {
|
| 226 |
+
"type": "integer",
|
| 227 |
+
"description": ("The minimum property's toilet limit"),
|
| 228 |
+
},
|
| 229 |
+
"maxToilet": {
|
| 230 |
+
"type": "integer",
|
| 231 |
+
"description": ("The maximum property's toilet limit"),
|
| 232 |
+
},
|
| 233 |
+
"marketType": {
|
| 234 |
+
"type": "string",
|
| 235 |
+
"enum": [
|
| 236 |
+
"property_market_type.primary",
|
| 237 |
+
"property_market_type.secondary",
|
| 238 |
+
"property_market_type.auction",
|
| 239 |
+
],
|
| 240 |
+
"description": (
|
| 241 |
+
"The property type of market categories, "
|
| 242 |
+
"For e.g: property_market_type.primary "
|
| 243 |
+
"refers to new property, "
|
| 244 |
+
"property_market_type.secondary refers "
|
| 245 |
+
"to used property, "
|
| 246 |
+
"and property_market_type.auction refers "
|
| 247 |
+
"to auction property"
|
| 248 |
+
),
|
| 249 |
+
},
|
| 250 |
+
"buildingType": {
|
| 251 |
+
"type": "string",
|
| 252 |
+
"enum": [
|
| 253 |
+
"building_type.apartement",
|
| 254 |
+
"building_type.house",
|
| 255 |
+
"building_type.ruko",
|
| 256 |
+
"building_type.land_residential",
|
| 257 |
+
"building_type.land_commercial",
|
| 258 |
+
],
|
| 259 |
+
"description": (
|
| 260 |
+
"The property building type categories. "
|
| 261 |
+
"For e.g: rumah (building_type.house), "
|
| 262 |
+
"apartemen (building_type.apartement), and etc"
|
| 263 |
+
),
|
| 264 |
+
},
|
| 265 |
+
"listingType": {
|
| 266 |
+
"type": "string",
|
| 267 |
+
"enum": [
|
| 268 |
+
"property_listing_type.sell",
|
| 269 |
+
"property_listing_type.rent",
|
| 270 |
+
],
|
| 271 |
+
"description": (
|
| 272 |
+
"The property type of listing categories. "
|
| 273 |
+
"For e.g: dijual or membeli refers to "
|
| 274 |
+
"property_listing_type.sell, "
|
| 275 |
+
"and disewa or kontrak refers to "
|
| 276 |
+
"property_listing_type.rent"
|
| 277 |
+
),
|
| 278 |
+
},
|
| 279 |
+
},
|
| 280 |
+
},
|
| 281 |
+
},
|
| 282 |
+
},
|
| 283 |
+
]
|
| 284 |
+
|
| 285 |
+
PROPERTY_TEXT_PARSER_SYSTEM_MSG = """
|
| 286 |
+
You are an expert property consultant in Pinhome, Indonesia.
|
| 287 |
+
In answering question related to property education (e.g. mortgage
|
| 288 |
+
process, documents needed), you will always need to retrieve relevant
|
| 289 |
+
context in our database.
|
| 290 |
+
Also make sure the the answer provided in Indonesia context,
|
| 291 |
+
for example: property price, which letter m refers to billion not million.
|
| 292 |
+
Don't make assumptions about what values to plug into functions.
|
| 293 |
+
Tell user what parameters required to make the function call
|
| 294 |
+
"""
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# FAQ QUERY BY LLM
|
| 298 |
+
|
| 299 |
+
FAQ_QUERY_BY_LLM_SYSTEM_MESSAGE = """
|
| 300 |
+
You are an expert linguistic assistant in evaluating set of questions
|
| 301 |
+
to extract its subjects and contexts and evaluate it's answer similarity.
|
| 302 |
+
"""
|
| 303 |
+
|
| 304 |
+
SAME_QUESTIONS_EVALUATION_PROMPT_NO_EXPLAIN = """
|
| 305 |
+
# ADDITIONAL INFORMATION
|
| 306 |
+
|
| 307 |
+
- KPR (Kredit Kepemilikan Rumah): a type of mortgage or home loan used to
|
| 308 |
+
finance the purchase of a house or residential property
|
| 309 |
+
- KPR TAKE OVER: a specific process to the transfer or refinancing of an
|
| 310 |
+
existing KPR, so you need to already have a KPR to do KPR TAKE OVER and
|
| 311 |
+
the process is different with KPR
|
| 312 |
+
- KMG (Kredit Multi Guna): a more versatile type of loan that can be used for
|
| 313 |
+
various purposes
|
| 314 |
+
|
| 315 |
+
# INSTRUCTIONS
|
| 316 |
+
|
| 317 |
+
Given a query and target question and it's extracted subjects and contexts,
|
| 318 |
+
evaluate in english if they are the same question or different based on the
|
| 319 |
+
subjects, contexts, and any related additional information provided above.
|
| 320 |
+
|
| 321 |
+
- It is expected that if the query and target question is evaluated to be SAME,
|
| 322 |
+
we can safely return EXACT SAME answer. For example:
|
| 323 |
+
- Query question asking for definition of KPR and target question asking
|
| 324 |
+
for explanation of KPR, this can be considered SAME, we can answer it
|
| 325 |
+
with the process and description of KPR
|
| 326 |
+
- Query question asking for definition and target question asking for
|
| 327 |
+
documents needed for KPR, this can be considered DIFFERENT, one asking
|
| 328 |
+
for definition and process, the latter asking specifically what
|
| 329 |
+
documents is required
|
| 330 |
+
- Query question asking for definition of KPR and target question asking
|
| 331 |
+
for definition of KPR TAKE OVER, this can be considered DIFFERENT,
|
| 332 |
+
one asking for KPR, the latter asking for KPR TAKE OVER, and these
|
| 333 |
+
are different subjects
|
| 334 |
+
|
| 335 |
+
ONLY give the final verdict without any explanation, e.g. SAME or DIFFERENT
|
| 336 |
+
verdict
|
| 337 |
+
|
| 338 |
+
- ONLY RETURN THE VERDICT USING THE FOLLOWING FORMAT
|
| 339 |
+
|
| 340 |
+
[VERDICT]: SAME or DIFFERENT
|
| 341 |
+
|
| 342 |
+
===
|
| 343 |
+
|
| 344 |
+
[QUERY QUESTION]: {query_question}
|
| 345 |
+
[SUBJECTS]: {query_subjects}
|
| 346 |
+
[CONTEXTS]: {query_contexts}
|
| 347 |
+
|
| 348 |
+
[TARGET QUESTION]: {target_question}
|
| 349 |
+
[SUBJECTS]: {target_subjects}
|
| 350 |
+
[CONTEXTS]: {target_contexts}
|
| 351 |
+
|
| 352 |
+
[VERDICT]:
|
| 353 |
+
"""
|
| 354 |
+
|
| 355 |
+
EXTRACT_QUESTION_CONTEXTS_SUBJECTS_PROMPT = """
|
| 356 |
+
Given a question, evaluate the question in english and extract the user
|
| 357 |
+
subjects (in english ) and contexts. If encountered an unknown subjects
|
| 358 |
+
in english (e.g. abbreviation), return it as it is. In asking the question and
|
| 359 |
+
return the extracted purpose strictly following the examples format below.
|
| 360 |
+
|
| 361 |
+
# EXAMPLES
|
| 362 |
+
|
| 363 |
+
[QUESTION]: apa itu kpr
|
| 364 |
+
[RESULT]: {{
|
| 365 |
+
"subjects":["kpr"],
|
| 366 |
+
"contexts":"user want to know the definition of kpr"
|
| 367 |
+
}}
|
| 368 |
+
|
| 369 |
+
===
|
| 370 |
+
|
| 371 |
+
[QUESTION]: apa itu take over kpr
|
| 372 |
+
[RESULT]: {{
|
| 373 |
+
"subjects":["take over kpr"],
|
| 374 |
+
"contexts":"user want to know the definition of take over kpr"
|
| 375 |
+
}}
|
| 376 |
+
|
| 377 |
+
===
|
| 378 |
+
|
| 379 |
+
[QUESTION]: apa yang membedakan kpr dan kmg?
|
| 380 |
+
[RESULT]: {{
|
| 381 |
+
"subjects":["kpr","kmg"],
|
| 382 |
+
"contexts":"user want to know the difference of kpr and kmg"
|
| 383 |
+
}}
|
| 384 |
+
|
| 385 |
+
===
|
| 386 |
+
|
| 387 |
+
[QUESTION]: apakah saya bisa mengajukan KPR tanpa membayar DP atau DP 0%?
|
| 388 |
+
[RESULT]: {{
|
| 389 |
+
"subjects":["kpr","dp 0%"],
|
| 390 |
+
"contexts":"user want to know whether it is possible to "
|
| 391 |
+
"apply for kpr without paying dp or dp 0%"
|
| 392 |
+
}}
|
| 393 |
+
|
| 394 |
+
===
|
| 395 |
+
|
| 396 |
+
[QUESTION]: {question}
|
| 397 |
+
[RESULT]:
|
| 398 |
+
"""
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
PROPERTY_SEARCH_ROUTING_SYSTEM_PROMPT = (
|
| 402 |
+
"You are an expert property consultant in Pinhome, Indonesia. "
|
| 403 |
+
"You can correctly identify questions related to property intents. "
|
| 404 |
+
"If user query shown any interest in a particular type property, "
|
| 405 |
+
"you also can derived the property search intent from it"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
PROPERTY_SEARCH_ROUTING_USER_PROMPT = """
|
| 409 |
+
## INSTRUCTIONS
|
| 410 |
+
- You have capabilities to query our property listing database and show it to
|
| 411 |
+
user.
|
| 412 |
+
- Your task is to evaluate and understand the given user query and give verdict
|
| 413 |
+
whether it is correct and appropriate to respond user query by giving those
|
| 414 |
+
property listing.
|
| 415 |
+
- The query might be in Bahasa Indonesia language, however you MUST reason and
|
| 416 |
+
answer in English
|
| 417 |
+
- Think carefully and explain your understanding about the user query, then
|
| 418 |
+
state your verdict at the end
|
| 419 |
+
|
| 420 |
+
## EXAMPLE
|
| 421 |
+
|
| 422 |
+
query : Saya ingin tahu lebih banyak tentang rumah tapak di Yogyakarta
|
| 423 |
+
dengan harga di bawah 500 juta
|
| 424 |
+
response : this query show that user want to find property in particular type
|
| 425 |
+
house in Yogyakarta under 500 million, hence it is appropriate to return
|
| 426 |
+
property listing to the user. [VERDICT]:YES
|
| 427 |
+
|
| 428 |
+
---
|
| 429 |
+
|
| 430 |
+
query : cara menjual rumah seken
|
| 431 |
+
response : this query shows that user want to know how to sell secondary house
|
| 432 |
+
on our platform, so it is not appropriate to return property listing to the
|
| 433 |
+
user. [VERDICT]:NO
|
| 434 |
+
|
| 435 |
+
---
|
| 436 |
+
|
| 437 |
+
query : rumah dp 0%
|
| 438 |
+
response : this query shows that user is looking for particular type of house
|
| 439 |
+
which can provide 0 percent down payment, hence it is correct to return
|
| 440 |
+
property listing. [VERDICT]:YES
|
| 441 |
+
|
| 442 |
+
---
|
| 443 |
+
|
| 444 |
+
query : syarat rumah dp 0%
|
| 445 |
+
response : this query shows that user want to know the requirements for
|
| 446 |
+
particular type of house which can provide 0 percent down payment,
|
| 447 |
+
hence it is not appropriate to return property listing to the
|
| 448 |
+
user. [VERDICT]:NO
|
| 449 |
+
|
| 450 |
+
---
|
| 451 |
+
|
| 452 |
+
query : kontrakan bulanan
|
| 453 |
+
response : this query, even if it short,shows interest to find a particular
|
| 454 |
+
property of monthly rental type, hence it is appropriate to return property
|
| 455 |
+
listing to the user. [VERDICT]:YES
|
| 456 |
+
|
| 457 |
+
---
|
| 458 |
+
|
| 459 |
+
query : coba sebutkan pilihan properti
|
| 460 |
+
response : this query, even if it's rather unclear,shows that user ask for
|
| 461 |
+
property recommendations, hence it is appropriate to return property
|
| 462 |
+
listing to the user. [VERDICT]:YES
|
| 463 |
+
|
| 464 |
+
---
|
| 465 |
+
|
| 466 |
+
query : apa itu pinvalue?
|
| 467 |
+
response : this query, ask about the definition of specific term called
|
| 468 |
+
`pinvalue` term which might be a Pinhome product, hence it is not correct
|
| 469 |
+
to return property lising to the user. [VERDICT]:NO
|
| 470 |
+
|
| 471 |
+
---
|
| 472 |
+
|
| 473 |
+
## TASK
|
| 474 |
+
|
| 475 |
+
query : {question}
|
| 476 |
+
response :
|
| 477 |
+
"""
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
LOCATION_LEVEL_FINDER_SYSTEM_MSG = (
|
| 481 |
+
"You're a location-based search engine for real estate in Indonesia. "
|
| 482 |
+
"Please make sure the answer is consistent. "
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
LOCATION_LEVEL_FINDER_USER_PROMPT = """
|
| 486 |
+
Given a user query for a location using the string,
|
| 487 |
+
You need to identify and return all relevant locations in Indonesia.
|
| 488 |
+
|
| 489 |
+
The structured format for the results should be only in JSON format,
|
| 490 |
+
adhering to the following criteria:
|
| 491 |
+
---------------------
|
| 492 |
+
[
|
| 493 |
+
{{
|
| 494 |
+
"name": "location name",
|
| 495 |
+
"level": "location level"
|
| 496 |
+
}}
|
| 497 |
+
]
|
| 498 |
+
---------------------
|
| 499 |
+
|
| 500 |
+
Where "level" is represented by the following integers:
|
| 501 |
+
|
| 502 |
+
---------------------
|
| 503 |
+
1: Province level (provinsi)
|
| 504 |
+
2: City or Regency level (kota/kabupaten)
|
| 505 |
+
3: District level (kecamatan)
|
| 506 |
+
4: Subdistrict level (kelurahan)
|
| 507 |
+
---------------------
|
| 508 |
+
|
| 509 |
+
If the location level is at level 2,
|
| 510 |
+
provide an exact prefix indicating whether it is a Kota or Kabupaten.
|
| 511 |
+
If the location level is at level 3 and 4,
|
| 512 |
+
remove the prefix regarding to Kelurahan (`Kel.`) or Kecamatan (`Kec.`).
|
| 513 |
+
|
| 514 |
+
If there are similar location name,
|
| 515 |
+
you only return the most popular one based on Indonesia
|
| 516 |
+
real estate trends context.
|
| 517 |
+
|
| 518 |
+
For example:
|
| 519 |
+
|
| 520 |
+
-------
|
| 521 |
+
Given the query Bekasi
|
| 522 |
+
Expected result: [
|
| 523 |
+
{{"name": "Kota Bekasi", "level": 2}},
|
| 524 |
+
{{"name": "Jawa Barat", "level": 1}}
|
| 525 |
+
]
|
| 526 |
+
-------
|
| 527 |
+
|
| 528 |
+
-------
|
| 529 |
+
Given the query Cikarang
|
| 530 |
+
Expected result: [
|
| 531 |
+
{{"name": "Cikarang Selatan", "level": 3}},
|
| 532 |
+
{{"name": "Kab. Bekasi", "level": 2}},
|
| 533 |
+
{{"name": "Jawa Barat", "level": 1}}
|
| 534 |
+
]
|
| 535 |
+
-------
|
| 536 |
+
-------
|
| 537 |
+
Given the query Jakut
|
| 538 |
+
Expected result: [
|
| 539 |
+
{{"name": "Kota Jakarta Utara", "level": 2}},
|
| 540 |
+
{{"name": "DKI Jakarta", "level": 1}}
|
| 541 |
+
]
|
| 542 |
+
-------
|
| 543 |
+
-------
|
| 544 |
+
Given the query Bintaro
|
| 545 |
+
Expected result: [
|
| 546 |
+
{{"name": "Bintaro", "level": 4}},
|
| 547 |
+
{{"name": "Pesanggrahan", "level": 3}},
|
| 548 |
+
{{"name": "Kota Jakarta Selatan", "level": 2}},
|
| 549 |
+
{{"name": "DKI Jakarta", "level": 1}}
|
| 550 |
+
]
|
| 551 |
+
-------
|
| 552 |
+
-------
|
| 553 |
+
Given the query Kemang
|
| 554 |
+
Expected result: [
|
| 555 |
+
{{"name": "Mampang Prapatan", "level": 3}},
|
| 556 |
+
{{"name": "Kota Jakarta Selatan","level": 2}},
|
| 557 |
+
{{"name": "DKI Jakarta","level": 1}}
|
| 558 |
+
]
|
| 559 |
+
-------
|
| 560 |
+
-------
|
| 561 |
+
Given the query Bali
|
| 562 |
+
Expected result: [
|
| 563 |
+
{{"name": "Bali", "level": 1}}
|
| 564 |
+
]
|
| 565 |
+
-------
|
| 566 |
+
|
| 567 |
+
# task
|
| 568 |
+
Given query `{location}`
|
| 569 |
+
Expected result:
|
| 570 |
+
"""
|
pyproject.toml
CHANGED
|
@@ -9,12 +9,15 @@ packages = []
|
|
| 9 |
[tool.poetry.dependencies]
|
| 10 |
python = "^3.10"
|
| 11 |
gradio = { version = "3.44.3", source = "pypi" }
|
| 12 |
-
pydantic-settings = { version = "^2.0.3", extras = ["dotenv"], source = "pypi" }
|
| 13 |
langchain = {version = "^0.1.7", source = "pypi"}
|
| 14 |
langchain-openai = {version = "^0.0.6", source = "pypi"}
|
| 15 |
tiktoken = {version = "^0.6.0", source = "pypi"}
|
| 16 |
elasticsearch = {version = "^8.12.0", source = "pypi"}
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
[tool.ruff]
|
| 20 |
# Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default.
|
|
|
|
| 9 |
[tool.poetry.dependencies]
|
| 10 |
python = "^3.10"
|
| 11 |
gradio = { version = "3.44.3", source = "pypi" }
|
|
|
|
| 12 |
langchain = {version = "^0.1.7", source = "pypi"}
|
| 13 |
langchain-openai = {version = "^0.0.6", source = "pypi"}
|
| 14 |
tiktoken = {version = "^0.6.0", source = "pypi"}
|
| 15 |
elasticsearch = {version = "^8.12.0", source = "pypi"}
|
| 16 |
+
fuzzywuzzy = {version = "^0.18.0", source = "pypi"}
|
| 17 |
+
python-levenshtein = {version = "^0.25.1", source = "pypi"}
|
| 18 |
+
standard-logger = {path = "local_dists/standard_logger-1.1.0-py3-none-any.whl"}
|
| 19 |
+
data-model = {path = "local_dists/data_model-2.9.0-py3-none-any.whl"}
|
| 20 |
+
backend-clients = {path = "local_dists/backend_clients-4.5.0-py3-none-any.whl"}
|
| 21 |
|
| 22 |
[tool.ruff]
|
| 23 |
# Enable the pycodestyle (`E`) and Pyflakes (`F`) rules by default.
|
schemas/__init__.py
ADDED
|
File without changes
|
schemas/chatbot.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from enum import Enum
|
| 3 |
+
|
| 4 |
+
from backend_clients.dto.omnisearch import CardLocation
|
| 5 |
+
from data_model.enums.property import BuildingType, ListingType, MarketType
|
| 6 |
+
from langchain_core.output_parsers import BaseOutputParser
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PropertySearchRequest(BaseModel):
|
| 11 |
+
question: str
|
| 12 |
+
model_name: str | None = Field(None, alias="modelName")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SearchParams(BaseModel):
|
| 16 |
+
location: CardLocation | None = Field(None, alias="location")
|
| 17 |
+
building_type: BuildingType | None = Field(None, alias="buildingType")
|
| 18 |
+
listing_type: ListingType = Field(None, alias="listingType")
|
| 19 |
+
market_type: MarketType | None = Field(None, alias="marketType")
|
| 20 |
+
min_surface_area: int | None = Field(None, alias="minSurfaceArea")
|
| 21 |
+
max_surface_area: int | None = Field(None, alias="maxSurfaceArea")
|
| 22 |
+
max_price: int | None = Field(None, alias="maxPrice")
|
| 23 |
+
min_price: int | None = Field(None, alias="minPrice")
|
| 24 |
+
max_building_area: int | None = Field(None, alias="maxBuildingArea")
|
| 25 |
+
min_building_area: int | None = Field(None, alias="minBuildingArea")
|
| 26 |
+
max_bedroom: int | None = Field(None, alias="maxBedroom")
|
| 27 |
+
min_bedroom: int | None = Field(None, alias="minBedroom")
|
| 28 |
+
min_toilet: int | None = Field(None, alias="minToilet")
|
| 29 |
+
max_toilet: int | None = Field(None, alias="maxToilet")
|
| 30 |
+
keyword: str | None = Field(None)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PropertySearchObject(BaseModel):
|
| 34 |
+
search_params: SearchParams | None = Field(None, alias="searchParams")
|
| 35 |
+
search_results: list[dict] = Field([], alias="searchResults")
|
| 36 |
+
search_results_total: int = Field(0, alias="searchResultsTotal")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class PropertySearchResponse(BaseModel):
|
| 40 |
+
data: PropertySearchObject = Field(None, alias="data")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class IntentRoutingRequest(BaseModel):
|
| 44 |
+
question: str
|
| 45 |
+
model_name: str | None = Field(None, alias="modelName")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class IntentRoutingObject(BaseModel):
|
| 49 |
+
intent: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class IntentRoutingResponse(BaseModel):
|
| 53 |
+
data: IntentRoutingObject | None = Field(None)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CardLocation(BaseModel):
|
| 57 |
+
id: int | None = Field(None, alias="id")
|
| 58 |
+
title: str | None = Field(None, alias="title")
|
| 59 |
+
alias: str | None = Field(None, alias="alias")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class IntentCategories(str, Enum):
|
| 63 |
+
property_knowledge_qa = "property_knowledge_qa"
|
| 64 |
+
property_search = "property_search"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ImprovedBooleanOutputParser(BaseOutputParser[bool]):
|
| 68 |
+
"""Parse the output of an LLM call to a boolean."""
|
| 69 |
+
|
| 70 |
+
true_val: str = "YES"
|
| 71 |
+
"""The string value that should be parsed as True."""
|
| 72 |
+
false_val: str = "NO"
|
| 73 |
+
"""The string value that should be parsed as False."""
|
| 74 |
+
|
| 75 |
+
def parse(self, text: str) -> bool:
|
| 76 |
+
"""Parse the output of an LLM call to a boolean.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
text: output of a language model
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
boolean
|
| 83 |
+
|
| 84 |
+
"""
|
| 85 |
+
cleaned_text = text.strip()
|
| 86 |
+
if (self.true_val.upper() not in cleaned_text.upper()) and (
|
| 87 |
+
self.false_val.upper() not in cleaned_text.upper()
|
| 88 |
+
):
|
| 89 |
+
raise ValueError(
|
| 90 |
+
f"ImprovedBooleanOutputParser expected output value to either "
|
| 91 |
+
f"contains {self.true_val} or {self.false_val}. "
|
| 92 |
+
f"Received {cleaned_text}."
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
return self.true_val.upper() in cleaned_text.upper()
|
| 96 |
+
|
| 97 |
+
@property
|
| 98 |
+
def _type(self) -> str:
|
| 99 |
+
"""Snake-case string identifier for an output parser type."""
|
| 100 |
+
return "improved_boolean_output_parser"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class RouterOutputParser:
|
| 104 |
+
@staticmethod
|
| 105 |
+
def _marshal_output_to_json(output: str) -> str:
|
| 106 |
+
output = output.replace("```json", "")
|
| 107 |
+
output = output.replace("```", "")
|
| 108 |
+
output = output.strip()
|
| 109 |
+
return output
|
| 110 |
+
|
| 111 |
+
def parse(self, output: str) -> list[dict]:
|
| 112 |
+
"""Parse string."""
|
| 113 |
+
try:
|
| 114 |
+
json_output = RouterOutputParser._marshal_output_to_json(output)
|
| 115 |
+
json_dicts = json.loads(json_output)
|
| 116 |
+
return json_dicts
|
| 117 |
+
except Exception as err:
|
| 118 |
+
print(
|
| 119 |
+
f"[RouterOutputParser] got error when parse {output}, "
|
| 120 |
+
f"message: {err}"
|
| 121 |
+
)
|
| 122 |
+
return []
|
services/__init__.py
ADDED
|
File without changes
|
services/intent_routing.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
from openai import OpenAI
|
| 4 |
+
|
| 5 |
+
from config import get_settings
|
| 6 |
+
from prompts import (
|
| 7 |
+
PROPERTY_SEARCH_ROUTING_SYSTEM_PROMPT,
|
| 8 |
+
PROPERTY_SEARCH_ROUTING_USER_PROMPT,
|
| 9 |
+
SUMMARIZED_USER_QUESTION_INTENT_SYSTEM_PROMPT,
|
| 10 |
+
SUMMARIZED_USER_QUESTION_INTENT_USER_PROMPT,
|
| 11 |
+
)
|
| 12 |
+
from schemas.chatbot import (
|
| 13 |
+
IntentCategories,
|
| 14 |
+
)
|
| 15 |
+
from services.utils import get_chat_model_response, parse_message
|
| 16 |
+
|
| 17 |
+
RAG_LLM_MODEL = "gpt-3.5-turbo-0125"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
LLM_CLIENT = OpenAI(
|
| 21 |
+
api_key=get_settings().openai_api_key,
|
| 22 |
+
timeout=20,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def generate_intent_routing(
|
| 27 |
+
question: str,
|
| 28 |
+
model: str | None = None,
|
| 29 |
+
) -> str | None:
|
| 30 |
+
messages = [
|
| 31 |
+
{
|
| 32 |
+
"role": "system",
|
| 33 |
+
"content": PROPERTY_SEARCH_ROUTING_SYSTEM_PROMPT,
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"role": "user",
|
| 37 |
+
"content": PROPERTY_SEARCH_ROUTING_USER_PROMPT.format(
|
| 38 |
+
question=question,
|
| 39 |
+
),
|
| 40 |
+
},
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
if model is None:
|
| 44 |
+
model = get_settings().intent_routing__chat_completion_model_name
|
| 45 |
+
|
| 46 |
+
response = get_chat_model_response(model=model, messages=messages)
|
| 47 |
+
message_content = parse_message(response=response)
|
| 48 |
+
if message_content is None:
|
| 49 |
+
return IntentCategories.property_knowledge_qa
|
| 50 |
+
|
| 51 |
+
triage_regex = r"(YES|NO)"
|
| 52 |
+
triage_match = re.search(triage_regex, message_content)
|
| 53 |
+
if triage_match is None:
|
| 54 |
+
print(
|
| 55 |
+
"improper property search intent routing prediction -> user "
|
| 56 |
+
f"question : {question}, response : {message_content}"
|
| 57 |
+
)
|
| 58 |
+
return IntentCategories.property_knowledge_qa
|
| 59 |
+
|
| 60 |
+
if triage_match.group(0) == "YES":
|
| 61 |
+
return IntentCategories.property_search
|
| 62 |
+
|
| 63 |
+
return IntentCategories.property_knowledge_qa
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def generate_user_question_intent(previous_user_conversations, question):
|
| 67 |
+
messages = [
|
| 68 |
+
{
|
| 69 |
+
"role": "system",
|
| 70 |
+
"content": SUMMARIZED_USER_QUESTION_INTENT_SYSTEM_PROMPT,
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"role": "user",
|
| 74 |
+
"content": SUMMARIZED_USER_QUESTION_INTENT_USER_PROMPT.format_map(
|
| 75 |
+
{"contexts": previous_user_conversations, "question": question}
|
| 76 |
+
),
|
| 77 |
+
},
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
response = LLM_CLIENT.chat.completions.create(
|
| 81 |
+
model=RAG_LLM_MODEL, messages=messages, temperature=0
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return response
|
services/property_knowledge.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Suppress all UserWarnings
|
| 2 |
+
import warnings
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from langchain.retrievers import ContextualCompressionRetriever
|
| 6 |
+
from langchain.retrievers.document_compressors import LLMChainFilter
|
| 7 |
+
from langchain.retrievers.document_compressors.chain_filter_prompt import (
|
| 8 |
+
prompt_template,
|
| 9 |
+
)
|
| 10 |
+
from langchain.schema import StrOutputParser
|
| 11 |
+
from langchain_community.vectorstores.elasticsearch import ElasticsearchStore
|
| 12 |
+
from langchain_core.documents import Document
|
| 13 |
+
from langchain_core.prompts import ChatPromptTemplate, PromptTemplate
|
| 14 |
+
from langchain_core.runnables import RunnablePassthrough
|
| 15 |
+
from langchain_core.runnables.base import RunnableSerializable
|
| 16 |
+
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
| 17 |
+
|
| 18 |
+
from config import get_settings
|
| 19 |
+
from prompts import (
|
| 20 |
+
CONTEXT_TEMPLATE,
|
| 21 |
+
QA_WITH_CONTEXT_PROMPT,
|
| 22 |
+
SYSTEM_PROMPT,
|
| 23 |
+
)
|
| 24 |
+
from schemas.chatbot import ImprovedBooleanOutputParser
|
| 25 |
+
from services.utils import parse_response
|
| 26 |
+
|
| 27 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
| 28 |
+
|
| 29 |
+
EMBEDDING_MODEL = "text-embedding-3-large"
|
| 30 |
+
RAG_LLM_MODEL = "gpt-3.5-turbo-0125"
|
| 31 |
+
NROF_RETRIEVED_CONTEXT = 5
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ES only support up to 2048 embedding dimension
|
| 35 |
+
embeddings = OpenAIEmbeddings(
|
| 36 |
+
model=EMBEDDING_MODEL,
|
| 37 |
+
api_key=get_settings().openai_api_key,
|
| 38 |
+
dimensions=2048,
|
| 39 |
+
timeout=20,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
vector_db = ElasticsearchStore(
|
| 43 |
+
es_url=get_settings().es_url,
|
| 44 |
+
index_name=get_settings().collection_index_name,
|
| 45 |
+
embedding=embeddings,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
llm = ChatOpenAI(
|
| 49 |
+
model_name=RAG_LLM_MODEL,
|
| 50 |
+
temperature=0,
|
| 51 |
+
api_key=get_settings().openai_api_key,
|
| 52 |
+
timeout=20,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
qa_prompt = ChatPromptTemplate.from_messages(
|
| 57 |
+
[
|
| 58 |
+
("system", SYSTEM_PROMPT),
|
| 59 |
+
("human", QA_WITH_CONTEXT_PROMPT),
|
| 60 |
+
]
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
_filter = LLMChainFilter.from_llm(
|
| 65 |
+
llm,
|
| 66 |
+
prompt=PromptTemplate(
|
| 67 |
+
template=prompt_template,
|
| 68 |
+
input_variables=["question", "context"],
|
| 69 |
+
output_parser=ImprovedBooleanOutputParser(),
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
docs_retriever = vector_db.as_retriever(
|
| 73 |
+
search_kwargs={"k": NROF_RETRIEVED_CONTEXT}
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def format_contexts(docs: list[Document]) -> str:
|
| 78 |
+
aggregate_context = ""
|
| 79 |
+
for doc in docs:
|
| 80 |
+
context = CONTEXT_TEMPLATE.format(
|
| 81 |
+
source=doc.metadata["content_id"],
|
| 82 |
+
title=doc.metadata["content_title"],
|
| 83 |
+
content=doc.page_content,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
aggregate_context += context
|
| 87 |
+
|
| 88 |
+
return aggregate_context
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
docs_compression_retriever = ContextualCompressionRetriever(
|
| 92 |
+
base_compressor=_filter, base_retriever=docs_retriever
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def get_end_device_target(*args: Any) -> str:
|
| 97 |
+
return "mobile"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
rag_chain: RunnableSerializable = (
|
| 101 |
+
{
|
| 102 |
+
"context": RunnablePassthrough(),
|
| 103 |
+
"device": get_end_device_target,
|
| 104 |
+
"question": RunnablePassthrough(),
|
| 105 |
+
"chat_histories": RunnablePassthrough(),
|
| 106 |
+
"question_intent": RunnablePassthrough(),
|
| 107 |
+
}
|
| 108 |
+
| qa_prompt
|
| 109 |
+
| llm
|
| 110 |
+
| StrOutputParser()
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def compress_knowledge_docs(
|
| 115 |
+
collection: ElasticsearchStore,
|
| 116 |
+
question: str,
|
| 117 |
+
):
|
| 118 |
+
docs_retriever = collection.as_retriever(search_kwargs={"k": 5})
|
| 119 |
+
|
| 120 |
+
_filter = LLMChainFilter.from_llm(
|
| 121 |
+
llm,
|
| 122 |
+
prompt=PromptTemplate(
|
| 123 |
+
template=prompt_template,
|
| 124 |
+
input_variables=["question", "context"],
|
| 125 |
+
output_parser=ImprovedBooleanOutputParser(),
|
| 126 |
+
),
|
| 127 |
+
)
|
| 128 |
+
docs_compression_retriever = ContextualCompressionRetriever(
|
| 129 |
+
base_retriever=docs_retriever, base_compressor=_filter
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
relevant_docs = docs_compression_retriever.invoke(question)
|
| 133 |
+
return relevant_docs
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def generate_property_knowledge_answer(summarized_user_question_intent):
|
| 137 |
+
relevant_docs = compress_knowledge_docs(
|
| 138 |
+
vector_db, summarized_user_question_intent
|
| 139 |
+
)
|
| 140 |
+
formatted_contexts = format_contexts(relevant_docs)
|
| 141 |
+
|
| 142 |
+
response = rag_chain.invoke(
|
| 143 |
+
{
|
| 144 |
+
"context": formatted_contexts,
|
| 145 |
+
"question": summarized_user_question_intent,
|
| 146 |
+
}
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
answer, _ = parse_response(response)
|
| 150 |
+
return answer
|
services/property_search.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Suppress all UserWarnings
|
| 2 |
+
import json
|
| 3 |
+
import warnings
|
| 4 |
+
from http import HTTPStatus
|
| 5 |
+
|
| 6 |
+
from backend_clients.services.bff.omnisearch import (
|
| 7 |
+
OmnisearchClient,
|
| 8 |
+
OmnisearchWithFallbackRequest,
|
| 9 |
+
OmnisearchWithFallbackResponse,
|
| 10 |
+
)
|
| 11 |
+
from backend_clients.services.location_api.es import (
|
| 12 |
+
ESClient,
|
| 13 |
+
ESSuggestLocationListRequest,
|
| 14 |
+
LocationSuggestionResponse,
|
| 15 |
+
)
|
| 16 |
+
from data_model.enums.property import (
|
| 17 |
+
BuildingType,
|
| 18 |
+
DeveloperProjectStatus,
|
| 19 |
+
EntityType,
|
| 20 |
+
ListingType,
|
| 21 |
+
MarketType,
|
| 22 |
+
PropertyUnitStatus,
|
| 23 |
+
)
|
| 24 |
+
from fuzzywuzzy import fuzz
|
| 25 |
+
|
| 26 |
+
from config import get_settings
|
| 27 |
+
from prompts import (
|
| 28 |
+
LOCATION_LEVEL_FINDER_SYSTEM_MSG,
|
| 29 |
+
LOCATION_LEVEL_FINDER_USER_PROMPT,
|
| 30 |
+
PROPERTY_SEARCH_FUNCTION_CALLING_PROMPT,
|
| 31 |
+
PROPERTY_TEXT_PARSER_SYSTEM_MSG,
|
| 32 |
+
)
|
| 33 |
+
from schemas.chatbot import CardLocation, RouterOutputParser, SearchParams
|
| 34 |
+
from services.utils import (
|
| 35 |
+
get_chat_model_response,
|
| 36 |
+
parse_message,
|
| 37 |
+
parse_tools_message,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
DEFAULT_SIZE = 20
|
| 44 |
+
LISTED_LOCATION_LEVEL_FIELDS = [
|
| 45 |
+
"provinceTitle",
|
| 46 |
+
"cityTitle",
|
| 47 |
+
"districtTitle",
|
| 48 |
+
"subDistrictTitle",
|
| 49 |
+
]
|
| 50 |
+
FILTERED_PARAMS = [
|
| 51 |
+
"name",
|
| 52 |
+
"minPrice",
|
| 53 |
+
"maxPrice",
|
| 54 |
+
"city",
|
| 55 |
+
"district",
|
| 56 |
+
"subDistrict",
|
| 57 |
+
"specifications",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def parse_text_to_user_preference(
|
| 62 |
+
user_question: str,
|
| 63 |
+
model: str = get_settings().property_search__chat_completion_model_name,
|
| 64 |
+
) -> dict | None:
|
| 65 |
+
prompt_messages = [
|
| 66 |
+
{
|
| 67 |
+
"role": "system",
|
| 68 |
+
"content": PROPERTY_TEXT_PARSER_SYSTEM_MSG,
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"role": "user",
|
| 72 |
+
"content": user_question,
|
| 73 |
+
},
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
parsed_user_preference_response = get_chat_model_response(
|
| 77 |
+
model=model,
|
| 78 |
+
messages=prompt_messages,
|
| 79 |
+
tools=PROPERTY_SEARCH_FUNCTION_CALLING_PROMPT,
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
parsed_user_preference = parse_tools_message(
|
| 83 |
+
parsed_user_preference_response
|
| 84 |
+
)
|
| 85 |
+
return parsed_user_preference
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def get_location_detail_by_keyword(
|
| 89 |
+
location_keyword: str,
|
| 90 |
+
model="gpt-3.5-turbo-1106",
|
| 91 |
+
):
|
| 92 |
+
"""
|
| 93 |
+
Retrieves the location details based on a given keyword.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
location_keyword (str):
|
| 97 |
+
The keyword used to search for location details.
|
| 98 |
+
model (str, optional):
|
| 99 |
+
The model to use for generating the response.
|
| 100 |
+
Defaults to "gpt-3.5-turbo-1106".
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
dict: The location details retrieved based on the keyword.
|
| 104 |
+
"""
|
| 105 |
+
if location_keyword is None or location_keyword == "":
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
prompt_messages = [
|
| 109 |
+
{
|
| 110 |
+
"role": "system",
|
| 111 |
+
"content": LOCATION_LEVEL_FINDER_SYSTEM_MSG,
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"role": "user",
|
| 115 |
+
"content": LOCATION_LEVEL_FINDER_USER_PROMPT.format(
|
| 116 |
+
location=location_keyword
|
| 117 |
+
),
|
| 118 |
+
},
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
response = get_chat_model_response(model=model, messages=prompt_messages)
|
| 122 |
+
|
| 123 |
+
parsed_content_message = parse_message(response)
|
| 124 |
+
location_details = RouterOutputParser().parse(parsed_content_message)
|
| 125 |
+
return location_details
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _get_list_of_location_candidates(
|
| 129 |
+
detail_locations: list[dict[str, str | int]],
|
| 130 |
+
) -> LocationSuggestionResponse | None:
|
| 131 |
+
"""
|
| 132 |
+
Retrieves a list of location candidates based on
|
| 133 |
+
the given list of detailed locations.
|
| 134 |
+
|
| 135 |
+
Note: we skip the first level (province),
|
| 136 |
+
because it is too broad/wide
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
detail_locations (list[dict[str, str | int]]):
|
| 140 |
+
A list of dictionaries representing detailed locations.
|
| 141 |
+
Each dictionary should have the following keys:
|
| 142 |
+
- "name" (str): The name of the location.
|
| 143 |
+
- "level" (str | int): The level of the location.
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
LocationSuggestionResponse | None:
|
| 147 |
+
A response object containing the location candidates.
|
| 148 |
+
Returns None if no candidates are found.
|
| 149 |
+
"""
|
| 150 |
+
location_api_es_client = ESClient()
|
| 151 |
+
|
| 152 |
+
location_candidates = None
|
| 153 |
+
for location_obj in reversed(detail_locations):
|
| 154 |
+
search_keyword = location_obj["name"].split(".")[-1]
|
| 155 |
+
|
| 156 |
+
location_search_level = location_obj["level"]
|
| 157 |
+
if location_search_level == 1:
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
response = location_api_es_client.get_location_suggestions(
|
| 161 |
+
request=ESSuggestLocationListRequest(
|
| 162 |
+
search=search_keyword,
|
| 163 |
+
levels=location_search_level,
|
| 164 |
+
size=DEFAULT_SIZE,
|
| 165 |
+
)
|
| 166 |
+
)
|
| 167 |
+
if response.status_code == HTTPStatus.OK and response.data is not None:
|
| 168 |
+
location_candidates: LocationSuggestionResponse = response.data
|
| 169 |
+
break
|
| 170 |
+
|
| 171 |
+
return location_candidates
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _get_most_similar_location(
|
| 175 |
+
location_candidates: list[LocationSuggestionResponse],
|
| 176 |
+
detail_locations: list[dict[str, str | int]],
|
| 177 |
+
) -> dict[str, str | int]:
|
| 178 |
+
"""
|
| 179 |
+
Find the most similar location candidate based on
|
| 180 |
+
a list of location candidates and a list of detail locations.
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
location_candidates (list[LocationSuggestionResponse]):
|
| 184 |
+
A list of location candidates.
|
| 185 |
+
detail_locations (list[dict[str, str | int]]):
|
| 186 |
+
A list of detail locations.
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
dict[str, str | int]:
|
| 190 |
+
The best location candidate with the highest similarity score.
|
| 191 |
+
"""
|
| 192 |
+
best_similarity_score = 0
|
| 193 |
+
best_location_candidate = None
|
| 194 |
+
|
| 195 |
+
merged_location_title_ref = "-".join(
|
| 196 |
+
detail_location["name"] for detail_location in detail_locations
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
for location_obj_candidate in location_candidates:
|
| 200 |
+
location_candidate_dict = location_obj_candidate.dict(
|
| 201 |
+
exclude_none=True, by_alias=True
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
level = location_candidate_dict["level"]
|
| 205 |
+
filtered_location_fields = LISTED_LOCATION_LEVEL_FIELDS[:level]
|
| 206 |
+
|
| 207 |
+
merged_location_title = ""
|
| 208 |
+
for location_field in filtered_location_fields:
|
| 209 |
+
location_field_value = location_candidate_dict[location_field]
|
| 210 |
+
if location_field_value is not None:
|
| 211 |
+
merged_location_title += location_field_value + "-"
|
| 212 |
+
|
| 213 |
+
similarity_score = fuzz.ratio(
|
| 214 |
+
merged_location_title_ref, merged_location_title
|
| 215 |
+
)
|
| 216 |
+
if similarity_score > best_similarity_score:
|
| 217 |
+
best_similarity_score = similarity_score
|
| 218 |
+
best_location_candidate = location_candidate_dict
|
| 219 |
+
|
| 220 |
+
if (
|
| 221 |
+
best_similarity_score
|
| 222 |
+
< get_settings().property_search__location_similarity_thres
|
| 223 |
+
):
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
return best_location_candidate
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def get_most_similar_pinhome_location(
|
| 230 |
+
detail_locations: list[dict[str, str | int]]
|
| 231 |
+
) -> dict[str, str | int]:
|
| 232 |
+
"""
|
| 233 |
+
Get the most similar pinhome location based on a list of detail locations.
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
detail_locations (list[dict[str, str | int]]):
|
| 237 |
+
A list of detail locations, each represented as a dictionary with
|
| 238 |
+
keys "level" (int), "title" (str), and "alias" (str).
|
| 239 |
+
Returns:
|
| 240 |
+
dict[str, str | int]:
|
| 241 |
+
The most similar pinhome location, represented as a dictionary with
|
| 242 |
+
keys "level" (int), "title" (str), and "alias" (str).
|
| 243 |
+
"""
|
| 244 |
+
detail_locations = sorted(
|
| 245 |
+
detail_locations,
|
| 246 |
+
key=lambda x: x["level"],
|
| 247 |
+
reverse=False,
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
location_candidates = _get_list_of_location_candidates(detail_locations)
|
| 251 |
+
if location_candidates is None:
|
| 252 |
+
return None
|
| 253 |
+
|
| 254 |
+
most_similar_location_obj = _get_most_similar_location(
|
| 255 |
+
location_candidates, detail_locations
|
| 256 |
+
)
|
| 257 |
+
return most_similar_location_obj
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def construct_pinhome_card_location(most_similar_location_obj: dict):
|
| 261 |
+
"""
|
| 262 |
+
Constructs a `CardLocation` object based on the provided
|
| 263 |
+
`most_similar_location_obj` and `parsed_location_param`.
|
| 264 |
+
|
| 265 |
+
Parameters:
|
| 266 |
+
most_similar_location_obj (dict):
|
| 267 |
+
A dictionary representing the most similar location object.
|
| 268 |
+
It should contain the following keys:
|
| 269 |
+
- matchId (str): The ID of the location.
|
| 270 |
+
- title (str): The title of the location.
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
CardLocation: A `CardLocation` object with the following attributes:
|
| 274 |
+
- id (str): The ID of the location.
|
| 275 |
+
- title (str): The title of the location.
|
| 276 |
+
- alias (str): The alias location.
|
| 277 |
+
"""
|
| 278 |
+
location_id = most_similar_location_obj.get("matchId", None)
|
| 279 |
+
location_title = most_similar_location_obj.get("title", None)
|
| 280 |
+
location_alias = most_similar_location_obj.get("match", None)
|
| 281 |
+
|
| 282 |
+
return CardLocation(
|
| 283 |
+
id=location_id,
|
| 284 |
+
title=location_title,
|
| 285 |
+
alias=location_alias,
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _set_base_query_params(
|
| 290 |
+
omnisearch_request: OmnisearchWithFallbackRequest,
|
| 291 |
+
) -> OmnisearchWithFallbackRequest:
|
| 292 |
+
"""
|
| 293 |
+
Set base query based on market type.
|
| 294 |
+
|
| 295 |
+
Args:
|
| 296 |
+
omnisearch_request (OmnisearchWithFallbackRequest):
|
| 297 |
+
The omnisearch parameters.
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
OmnisearchWithFallbackRequest:
|
| 301 |
+
The Omnisearch response data.
|
| 302 |
+
"""
|
| 303 |
+
match omnisearch_request.market_type:
|
| 304 |
+
case MarketType.primary:
|
| 305 |
+
omnisearch_request.entity_type = EntityType.project
|
| 306 |
+
omnisearch_request.status = DeveloperProjectStatus.active
|
| 307 |
+
case MarketType.secondary:
|
| 308 |
+
omnisearch_request.entity_type = EntityType.propertyunit
|
| 309 |
+
omnisearch_request.status = PropertyUnitStatus.active
|
| 310 |
+
case _:
|
| 311 |
+
omnisearch_request.entity_type = ",".join(
|
| 312 |
+
[EntityType.project, EntityType.propertyunit]
|
| 313 |
+
)
|
| 314 |
+
omnisearch_request.status = ",".join(
|
| 315 |
+
[DeveloperProjectStatus.active, PropertyUnitStatus.active]
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
return omnisearch_request
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def get_omnisearch_exact_with_fallback(
|
| 322 |
+
search_params: SearchParams,
|
| 323 |
+
) -> OmnisearchWithFallbackResponse | None:
|
| 324 |
+
"""
|
| 325 |
+
Get the exact Omnisearch result with fallback if necessary.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
search_params (SearchParams):
|
| 329 |
+
The search parameters.
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
OmnisearchWithFallbackResponse | None:
|
| 333 |
+
The Omnisearch response data if available,
|
| 334 |
+
or None if no data is found.
|
| 335 |
+
"""
|
| 336 |
+
omnisearch_client = OmnisearchClient()
|
| 337 |
+
|
| 338 |
+
search_limit = get_settings().property_search__limit_search_size
|
| 339 |
+
|
| 340 |
+
parsed_location = search_params.keyword
|
| 341 |
+
if (
|
| 342 |
+
search_params.location is not None
|
| 343 |
+
and search_params.location.id is not None
|
| 344 |
+
):
|
| 345 |
+
search_params.keyword = None
|
| 346 |
+
|
| 347 |
+
omnisearch_request = OmnisearchWithFallbackRequest(
|
| 348 |
+
**search_params.dict(exclude_none=True, by_alias=True),
|
| 349 |
+
)
|
| 350 |
+
omnisearch_request = _set_base_query_params(omnisearch_request)
|
| 351 |
+
|
| 352 |
+
response = omnisearch_client.get_omnisearch_exact_with_fallback(
|
| 353 |
+
omnisearch_request, search_limit
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
search_params.keyword = parsed_location
|
| 357 |
+
return response.data
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def enrich_parsed_location_params(
|
| 361 |
+
parsed_params: dict,
|
| 362 |
+
) -> dict:
|
| 363 |
+
"""
|
| 364 |
+
Enriches the parsed location parameters.
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
parsed_params (dict):
|
| 368 |
+
The dictionary containing the parsed parameters.
|
| 369 |
+
|
| 370 |
+
Returns:
|
| 371 |
+
dict: The enriched parsed parameters.
|
| 372 |
+
"""
|
| 373 |
+
parsed_location_param = parsed_params.get("keyword", None)
|
| 374 |
+
|
| 375 |
+
if parsed_location_param is not None:
|
| 376 |
+
detail_locations = get_location_detail_by_keyword(
|
| 377 |
+
location_keyword=parsed_location_param
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
if detail_locations is not None:
|
| 381 |
+
most_similar_pinhome_location = get_most_similar_pinhome_location(
|
| 382 |
+
detail_locations
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
if most_similar_pinhome_location is not None:
|
| 386 |
+
location_params = construct_pinhome_card_location(
|
| 387 |
+
most_similar_location_obj=most_similar_pinhome_location,
|
| 388 |
+
)
|
| 389 |
+
parsed_params["location"] = location_params
|
| 390 |
+
|
| 391 |
+
return parsed_params
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _generate_price_range(search_params: SearchParams) -> SearchParams:
|
| 395 |
+
"""
|
| 396 |
+
Generates a price range for the given search parameters.
|
| 397 |
+
|
| 398 |
+
Args:
|
| 399 |
+
search_params (SearchParams):
|
| 400 |
+
The search parameters.
|
| 401 |
+
|
| 402 |
+
Returns:
|
| 403 |
+
SearchParams:
|
| 404 |
+
The search parameters with the generated price range.
|
| 405 |
+
"""
|
| 406 |
+
if search_params.min_price is None and search_params.max_price is None:
|
| 407 |
+
return search_params
|
| 408 |
+
|
| 409 |
+
if search_params.min_price == search_params.max_price:
|
| 410 |
+
search_params.min_price = round(
|
| 411 |
+
search_params.min_price
|
| 412 |
+
* (1 - get_settings().property_search__exact_price_range_thres)
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
if search_params.min_price is not None and search_params.max_price is None:
|
| 416 |
+
price = search_params.min_price
|
| 417 |
+
|
| 418 |
+
search_params.max_price = round(
|
| 419 |
+
price
|
| 420 |
+
* (1 + get_settings().property_search__limit_price_range_thres)
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
if search_params.min_price is None and search_params.max_price is not None:
|
| 424 |
+
price = search_params.max_price
|
| 425 |
+
|
| 426 |
+
search_params.min_price = round(
|
| 427 |
+
price
|
| 428 |
+
* (1 - get_settings().property_search__limit_price_range_thres)
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
return search_params
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def _generate_value_range(
|
| 435 |
+
min_value: float | None,
|
| 436 |
+
max_value: float | None,
|
| 437 |
+
value_threshold: float = 0.1,
|
| 438 |
+
) -> tuple[float, float]:
|
| 439 |
+
"""
|
| 440 |
+
Generates a value range for the given search parameters.
|
| 441 |
+
|
| 442 |
+
Args:
|
| 443 |
+
min_value (float | None):
|
| 444 |
+
The mininum search parameters value.
|
| 445 |
+
max_value (float | None):
|
| 446 |
+
The maximum search parameters value.
|
| 447 |
+
value_threshold (float | None):
|
| 448 |
+
The value threshold range.
|
| 449 |
+
|
| 450 |
+
Returns:
|
| 451 |
+
tuple[float, float]:
|
| 452 |
+
The search parameters with the generated value range.
|
| 453 |
+
"""
|
| 454 |
+
if min_value is None and max_value is None:
|
| 455 |
+
return min_value, max_value
|
| 456 |
+
|
| 457 |
+
if min_value == max_value:
|
| 458 |
+
value = min_value
|
| 459 |
+
|
| 460 |
+
min_value = round(value * (1 - value_threshold))
|
| 461 |
+
max_value = round(value * (1 + value_threshold))
|
| 462 |
+
|
| 463 |
+
return min_value, max_value
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def construct_search_params(parsed_params: dict):
|
| 467 |
+
"""
|
| 468 |
+
Constructs the search parameters object from the parsed parameters.
|
| 469 |
+
|
| 470 |
+
Parameters:
|
| 471 |
+
parsed_params (dict):
|
| 472 |
+
A dictionary containing the parsed parameters.
|
| 473 |
+
|
| 474 |
+
Returns:
|
| 475 |
+
search_params (SearchParams):
|
| 476 |
+
The constructed search parameters object.
|
| 477 |
+
|
| 478 |
+
"""
|
| 479 |
+
search_params = SearchParams(**parsed_params)
|
| 480 |
+
|
| 481 |
+
if search_params.building_type is None:
|
| 482 |
+
search_params.building_type = BuildingType.house
|
| 483 |
+
|
| 484 |
+
if search_params.listing_type is None:
|
| 485 |
+
search_params.listing_type = ListingType.sell
|
| 486 |
+
|
| 487 |
+
search_params = _generate_price_range(search_params=search_params)
|
| 488 |
+
|
| 489 |
+
value_threshold = get_settings().property_search__value_range_thres
|
| 490 |
+
(
|
| 491 |
+
search_params.min_surface_area,
|
| 492 |
+
search_params.max_surface_area,
|
| 493 |
+
) = _generate_value_range(
|
| 494 |
+
search_params.min_surface_area,
|
| 495 |
+
search_params.max_surface_area,
|
| 496 |
+
value_threshold,
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
(
|
| 500 |
+
search_params.min_building_area,
|
| 501 |
+
search_params.max_building_area,
|
| 502 |
+
) = _generate_value_range(
|
| 503 |
+
search_params.min_building_area,
|
| 504 |
+
search_params.max_building_area,
|
| 505 |
+
value_threshold,
|
| 506 |
+
)
|
| 507 |
+
return search_params
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def search_property_listing(user_question):
|
| 511 |
+
parsed_params = parse_text_to_user_preference(
|
| 512 |
+
user_question=user_question,
|
| 513 |
+
)
|
| 514 |
+
if parsed_params is None:
|
| 515 |
+
parsed_params = {}
|
| 516 |
+
else:
|
| 517 |
+
parsed_params = enrich_parsed_location_params(
|
| 518 |
+
parsed_params=parsed_params
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
search_params = construct_search_params(parsed_params=parsed_params)
|
| 522 |
+
|
| 523 |
+
omnisearch_result = get_omnisearch_exact_with_fallback(
|
| 524 |
+
search_params=search_params
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
filter_results = []
|
| 528 |
+
for result in omnisearch_result.result_list:
|
| 529 |
+
filtered_values = {}
|
| 530 |
+
for filtered_param in FILTERED_PARAMS:
|
| 531 |
+
if filtered_param in result.keys():
|
| 532 |
+
filtered_values.update(
|
| 533 |
+
{filtered_param: result[filtered_param]}
|
| 534 |
+
)
|
| 535 |
+
filter_results.append(filtered_values)
|
| 536 |
+
|
| 537 |
+
return json.dumps(filter_results, indent=4)
|
services/utils.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
# Suppress all UserWarnings
|
| 5 |
+
from openai import OpenAI
|
| 6 |
+
|
| 7 |
+
from config import get_settings
|
| 8 |
+
|
| 9 |
+
LLM_CLIENT = OpenAI(
|
| 10 |
+
api_key=get_settings().openai_api_key,
|
| 11 |
+
timeout=20,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_response(response: str) -> tuple[str, list[dict] | None]:
|
| 16 |
+
answer_match = re.search(
|
| 17 |
+
r"\[ANSWER\]\n(.+?)(?=\n\[(.*?)\])", response, re.DOTALL
|
| 18 |
+
)
|
| 19 |
+
related_docs_match = re.search(r"\[REFERENCE\]\n(.+)", response, re.DOTALL)
|
| 20 |
+
|
| 21 |
+
if answer_match:
|
| 22 |
+
answer = answer_match.group(1).strip()
|
| 23 |
+
related_docs = None
|
| 24 |
+
|
| 25 |
+
if related_docs_match:
|
| 26 |
+
related_docs_str = (
|
| 27 |
+
related_docs_match.group(1).strip().replace("```", "")
|
| 28 |
+
)
|
| 29 |
+
try:
|
| 30 |
+
related_docs = json.loads(related_docs_str)
|
| 31 |
+
except Exception:
|
| 32 |
+
print(f"failed to parse related docs: {related_docs_str}")
|
| 33 |
+
|
| 34 |
+
return answer, related_docs
|
| 35 |
+
|
| 36 |
+
raise ValueError(f"failed to parse response: {response}")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def parse_histories(histories: list[str, str]):
|
| 40 |
+
conversational_histories = []
|
| 41 |
+
for history in histories:
|
| 42 |
+
user_message, assistant_message = history
|
| 43 |
+
conversational_histories.extend(
|
| 44 |
+
[
|
| 45 |
+
{"role": "user", "content": user_message},
|
| 46 |
+
{"role": "assistant", "content": assistant_message},
|
| 47 |
+
]
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
return conversational_histories
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def get_chat_model_response(
|
| 54 |
+
model: str,
|
| 55 |
+
messages: list[str],
|
| 56 |
+
temperature: float = 0,
|
| 57 |
+
tools: list | None = None,
|
| 58 |
+
):
|
| 59 |
+
if tools is not None:
|
| 60 |
+
chat_model_response = LLM_CLIENT.chat.completions.create(
|
| 61 |
+
model=model,
|
| 62 |
+
messages=messages,
|
| 63 |
+
temperature=temperature,
|
| 64 |
+
tools=tools,
|
| 65 |
+
)
|
| 66 |
+
else:
|
| 67 |
+
chat_model_response = LLM_CLIENT.chat.completions.create(
|
| 68 |
+
model=model,
|
| 69 |
+
messages=messages,
|
| 70 |
+
temperature=temperature,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
return chat_model_response
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def parse_tools_message(response) -> dict:
|
| 77 |
+
response_choices = response.choices
|
| 78 |
+
if len(response_choices) == 0:
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
tool_calls_response = response_choices[0].message.tool_calls
|
| 82 |
+
if not tool_calls_response:
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
response_choice_argument = tool_calls_response[0].function.arguments
|
| 86 |
+
response_choice_argument_dict = json.loads(response_choice_argument)
|
| 87 |
+
return response_choice_argument_dict
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def parse_message(response) -> str | None:
|
| 91 |
+
response_choices = response.choices
|
| 92 |
+
if len(response.choices) == 0:
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
return response_choices[0].message.content
|