Reynaldy Hardiyanto commited on
Commit
9012b52
·
1 Parent(s): c1f6e39

feat: add property search, intent routing, and intent summarization

Browse files
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 prompts import CONTEXT_TEMPLATE, QA_WITH_CONTEXT_PROMPT, SYSTEM_PROMPT
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
- vector_db = ElasticsearchStore(
44
- es_url=get_settings().es_url,
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
- qa_prompt = ChatPromptTemplate.from_messages(
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
- _filter = LLMChainFilter.from_llm(
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 format_contexts(docs: list[Document]) -> str:
114
- aggregate_context = ""
115
- for doc in docs:
116
- context = CONTEXT_TEMPLATE.format(
117
- source=doc.metadata["document_id"],
118
- title=doc.metadata["heading_title"],
119
- content=doc.page_content,
 
 
 
120
  )
121
 
122
- aggregate_context += context
123
-
124
- return aggregate_context
125
-
126
-
127
- docs_compression_retriever = ContextualCompressionRetriever(
128
- base_compressor=_filter, base_retriever=docs_retriever
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
- def get_prompt_response(message: str):
173
- try:
174
- response = rag_chain.invoke(message)
175
- answer, related_docs = parse_response(response)
 
 
 
 
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 pydantic_settings import BaseSettings, SettingsConfigDict
4
 
5
 
6
  class Settings(BaseSettings):
7
  openai_api_key: str
8
  es_url: str
9
-
10
- model_config = SettingsConfigDict(
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