1647 lines
57 KiB
Julia
1647 lines
57 KiB
Julia
module llmfunction
|
||
|
||
export virtualWineUserChatbox, jsoncorrection, search_wine_database!, # recommendbox,
|
||
virtualWineUserRecommendbox, userChatbox, userRecommendbox, extractWineAttributes_1,
|
||
extractWineAttributes_2, paraphrase, SQLexecution
|
||
|
||
using HTTP, JSON, URIs, Random, PrettyPrinting, UUIDs, Dates, DataFrames, DataStructures,
|
||
Base64, Serde, LibPQ, NATS
|
||
using GeneralUtils, SQLLLM
|
||
using ..type, ..util
|
||
|
||
# ---------------------------------------------- 100 --------------------------------------------- #
|
||
|
||
|
||
""" Chatbox for chatting with virtual wine customer.
|
||
|
||
# Arguments
|
||
- `a::T1`
|
||
one of Yiem's agent
|
||
- `input::T2`
|
||
text to be send to virtual wine customer
|
||
|
||
# Return
|
||
- `response::String`
|
||
response of virtual wine customer
|
||
# Example
|
||
```jldoctest
|
||
julia>
|
||
```
|
||
|
||
# TODO
|
||
- [] update docstring
|
||
- [] add reccommend() to compare wine
|
||
|
||
# Signature
|
||
"""
|
||
function virtualWineUserRecommendbox(a::T1, input
|
||
)::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:agent}
|
||
|
||
# put in model format
|
||
virtualWineCustomer = a.config["externalservice"]["virtualWineCustomer_1"]
|
||
llminfo = virtualWineCustomer["llminfo"]
|
||
prompt =
|
||
if llminfo["name"] == "llama3instruct"
|
||
formatLLMtext_llama3instruct("assistant", input)
|
||
else
|
||
error("llm model name is not defied yet $(@__LINE__)")
|
||
end
|
||
|
||
# send formatted input to user using GeneralUtils.sendReceiveMqttMsg
|
||
msgMeta = GeneralUtils.generate_msgMeta(
|
||
virtualWineCustomer["mqtttopic"],
|
||
senderName= "virtualWineUserRecommendbox",
|
||
senderId= a.id,
|
||
receiverName= "virtualWineCustomer",
|
||
mqttBroker= a.config["mqttServerInfo"]["broker"],
|
||
mqttBrokerPort= a.config["mqttServerInfo"]["port"],
|
||
msgId = "dummyid" #CHANGE remove after testing finished
|
||
)
|
||
|
||
outgoingMsg = Dict(
|
||
"msgMeta"=> msgMeta,
|
||
"payload"=> Dict(
|
||
"text"=> prompt,
|
||
)
|
||
)
|
||
|
||
result = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120)
|
||
response = result["response"]
|
||
|
||
return (response["text"], response["select"], response["reward"], response["isterminal"])
|
||
end
|
||
|
||
|
||
|
||
""" Chatbox for chatting with virtual wine customer.
|
||
|
||
# Arguments
|
||
- `a::T1`
|
||
one of Yiem's agent
|
||
- `input::T2`
|
||
text to be send to virtual wine customer
|
||
|
||
# Return
|
||
- `response::String`
|
||
response of virtual wine customer
|
||
# Example
|
||
```jldoctest
|
||
julia>
|
||
```
|
||
|
||
# TODO
|
||
- [] update docs
|
||
- [x] write a prompt for virtual customer
|
||
|
||
# Signature
|
||
"""
|
||
function virtualWineUserChatbox(config::T1, input::T2, virtualCustomerChatHistory
|
||
)::Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}} where {T1<:AbstractDict, T2<:AbstractString}
|
||
|
||
previouswines =
|
||
"""
|
||
You have the following wines previously:
|
||
|
||
"""
|
||
|
||
systemmsg =
|
||
"""
|
||
You find yourself in a well-stocked wine store, engaged in a conversation with the store's knowledgeable sommelier.
|
||
You're on a quest to find a bottle of wine that aligns with your specific preferences and requirements.
|
||
|
||
The ideal wine you're seeking should meet the following criteria:
|
||
1. It should fit within your budget.
|
||
2. It should be suitable for the occasion you're planning.
|
||
3. It should pair well with the food you intend to serve.
|
||
4. It should be of a particular type of wine you prefer.
|
||
5. It should possess certain characteristics, including:
|
||
- The level of sweetness.
|
||
- The intensity of its flavor.
|
||
- The amount of tannin it contains.
|
||
- Its acidity level.
|
||
|
||
Here's the criteria details:
|
||
{
|
||
"budget": 50,
|
||
"occasion": "graduation ceremony",
|
||
"food pairing": "Thai food",
|
||
"type of wine": "red",
|
||
"wine sweetness level": "dry",
|
||
"wine intensity level": "full-bodied",
|
||
"wine tannin level": "low",
|
||
"wine acidity level": "medium",
|
||
}
|
||
|
||
You should only respond with "text", "select", "reward", "isterminal" steps.
|
||
"text" is your conversation.
|
||
"select" is an integer. Choose an option when presented with choices, or leave it null if none of the options satisfy you or if no choices are available.
|
||
"reward" is an integer, it can be three number:
|
||
1) 1 if you find the right wine.
|
||
2) 0 if you don’t find the ideal wine.
|
||
3) -1 if you’re dissatisfied with the sommelier’s response.
|
||
"isterminal" can be false if you still want to talk with the sommelier, true otherwise.
|
||
|
||
You should only respond in JSON format as describe below:
|
||
{
|
||
"text": "your conversation",
|
||
"select": null,
|
||
"reward": 0,
|
||
"isterminal": false
|
||
}
|
||
|
||
Here are some examples:
|
||
|
||
sommelier: "What's your budget?
|
||
you:
|
||
{
|
||
"text": "My budget is 30 USD.",
|
||
"select": null,
|
||
"reward": 0,
|
||
"isterminal": false
|
||
}
|
||
|
||
sommelier: "The first option is Zena Crown and the second one is Buano Red."
|
||
you:
|
||
{
|
||
"text": "I like the 2nd option.",
|
||
"select": 2,
|
||
"reward": 1,
|
||
"isterminal": true
|
||
}
|
||
|
||
Let's begin!
|
||
"""
|
||
|
||
pushfirst!(virtualCustomerChatHistory, Dict("name"=> "system", "text"=> systemmsg))
|
||
|
||
# replace the :user key in chathistory to allow the virtual wine customer AI roleplay
|
||
chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}()
|
||
for i in virtualCustomerChatHistory
|
||
newdict = Dict()
|
||
newdict["name"] =
|
||
if i["name"] == "user"
|
||
"you"
|
||
elseif i["name"] == "assistant"
|
||
"sommelier"
|
||
else
|
||
i["name"]
|
||
end
|
||
|
||
newdict["text"] = i["text"]
|
||
push!(chathistory, newdict)
|
||
end
|
||
|
||
push!(chathistory, Dict("name"=> "assistant", "text"=> input))
|
||
|
||
# put in model format
|
||
prompt = formatLLMtext(chathistory, "llama3instruct")
|
||
prompt *=
|
||
"""
|
||
<|start_header_id|>you<|end_header_id|>
|
||
{"text"
|
||
"""
|
||
|
||
pprint(prompt)
|
||
externalService = config["externalservice"]["text2textinstruct"]
|
||
|
||
# send formatted input to user using GeneralUtils.sendReceiveMqttMsg
|
||
msgMeta = GeneralUtils.generate_msgMeta(
|
||
externalService["mqtttopic"],
|
||
senderName= "virtualWineUserChatbox",
|
||
senderId= string(uuid4()),
|
||
receiverName= "text2textinstruct",
|
||
mqttBroker= config["mqttServerInfo"]["broker"],
|
||
mqttBrokerPort= config["mqttServerInfo"]["port"],
|
||
msgId = string(uuid4()) # remove after testing finished
|
||
)
|
||
|
||
outgoingMsg = Dict(
|
||
"msgMeta"=> msgMeta,
|
||
"payload"=> Dict(
|
||
"text"=> prompt,
|
||
)
|
||
)
|
||
|
||
attempt = 0
|
||
for attempt in 1:5
|
||
try
|
||
response = GeneralUtils.sendReceiveMqttMsg(outgoingMsg; timeout=120)
|
||
_responseJsonStr = response["response"]["text"]
|
||
expectedJsonExample =
|
||
"""
|
||
Here is an expected JSON format:
|
||
{
|
||
"text": "...",
|
||
"select": "...",
|
||
"reward": "...",
|
||
"isterminal": "..."
|
||
}
|
||
"""
|
||
responseJsonStr = jsoncorrection(config, _responseJsonStr, expectedJsonExample)
|
||
responseDict = copy(JSON.parsefile(responseJsonStr))
|
||
|
||
text::AbstractString = responseDict["text"]
|
||
select::Union{Nothing, Number} = responseDict["select"] == "null" ? nothing : responseDict["select"]
|
||
reward::Number = responseDict["reward"]
|
||
isterminal::Bool = responseDict["isterminal"]
|
||
|
||
if text != ""
|
||
# pass test
|
||
else
|
||
error("virtual customer not answer correctly")
|
||
end
|
||
|
||
return (text, select, reward, isterminal)
|
||
catch e
|
||
io = IOBuffer()
|
||
showerror(io, e)
|
||
errorMsg = String(take!(io))
|
||
st = sprint((io, v) -> show(io, "text/plain", v), stacktrace(catch_backtrace()))
|
||
println("")
|
||
@warn "Error occurred: $errorMsg\n$st"
|
||
println("")
|
||
end
|
||
end
|
||
error("virtualWineUserChatbox failed to get a response")
|
||
end
|
||
|
||
""" Search wine in stock.
|
||
|
||
# Arguments
|
||
- `a::T1`
|
||
one of ChatAgent's agent.
|
||
- `thoughtdict::AbstractDict`
|
||
# Return
|
||
A JSON string of available wine
|
||
|
||
# Example
|
||
```jldoctest
|
||
julia> using ChatAgent
|
||
julia> agent = YiemAgent.sommelier(...)
|
||
julia> thoughtdict =
|
||
OrderedDict{String, Any}(
|
||
"plan" => "The user is asking a very specific question about a wine (Brunello di Montalcino from Tenuta CastelGiocondo). Although the policy suggests gathering budget, wine type, and occasion, the user has provided enough specific information (name, region, producer) to attempt a direct search in the database. I will use the SEARCH_WINE_DATABASE action to check if this specific wine is in our inventory.",
|
||
"action_name" => "SEARCH_WINE_DATABASE",
|
||
"action_input" => "Brunello di Montalcino from Tenuta CastelGiocondo")
|
||
```
|
||
"""
|
||
function search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false
|
||
)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent}
|
||
|
||
println("\ncheckinventory order: $(thoughtdict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
if useSQLLLM
|
||
# add suppport for similarSQLVectorDB
|
||
textresult, result_raw = SQLLLM.query(
|
||
inventoryquery,
|
||
a.context.executeSQL,
|
||
a.context.text2textInstructLLM;
|
||
insertSQLVectorDB=a.context.insertSQLVectorDB,
|
||
similarSQLVectorDB=a.context.similarSQLVectorDB,
|
||
llmFormatName="qwen3")
|
||
thoughtdict["action_result"] = textresult
|
||
else
|
||
|
||
# direct query with possible sql instead of SQLLLM.
|
||
hard_conditions, vector_search = wine_search_term_classification(a, thoughtdict["action_input"])
|
||
|
||
# do hard filter
|
||
# sql = generatesql(a, inventoryquery)
|
||
sql = predefined_wine_search_sql(hard_conditions)
|
||
@info "\nsql: $sql, \nvector_search: $vector_search"
|
||
textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql)
|
||
|
||
# do vector search
|
||
vector_search_str = ""
|
||
for i in vector_search
|
||
vector_search_str = vector_search_str * " " * i["value"]
|
||
end
|
||
vector_search_str = String(strip(vector_search_str))
|
||
vector_search_str = GeneralUtils.removestring(vector_search_str, ["%"])
|
||
@show vector_search
|
||
@show vector_search_str
|
||
|
||
|
||
config = a.context.agentconfig
|
||
host_url, _port = split(config["externalservice"]["sommpanion_db"]["url"], ':')
|
||
port = parse(Int, _port)
|
||
dbname = "winedb"
|
||
user = config["externalservice"]["sommpanion_db"]["user"]
|
||
password = config["externalservice"]["sommpanion_db"]["password"]
|
||
pg_conn_str = "host=$host_url port=$port dbname=$dbname user=$user password=$password"
|
||
|
||
#WORKING
|
||
# df = GeneralUtils.find_text_vector_similarity(
|
||
# vector_search_str,
|
||
# "wine",
|
||
# "tasting_notes_embedding",
|
||
# GeneralUtils.execute_postgres_sql(pg_conn_str, sql), #BUG input pair (F, arg)
|
||
# a.context.getTextEmbedding([vector_search_str]) #BUG input pair (F, arg)
|
||
# )
|
||
|
||
|
||
# @show df
|
||
# error(888888)
|
||
|
||
|
||
items = nothing
|
||
if sql_result_df !== nothing
|
||
result_vec = GeneralUtils.dfToVectorDict(sql_result_df)
|
||
|
||
# get image
|
||
for d in result_vec
|
||
image_url_json_str = d["image_url"]
|
||
image_url_json_obj = JSON.parse(image_url_json_str)
|
||
base_url = "http://192.168.88.106:8080/"
|
||
if haskey(image_url_json_obj, "bottle")
|
||
url = base_url * image_url_json_obj["bottle"]
|
||
image_data = HTTP.get(url) # vector{int} data
|
||
image_base64_string = base64encode(image_data.body)
|
||
d["image"] = image_base64_string
|
||
else
|
||
d["image"] = nothing
|
||
end
|
||
end
|
||
items = result_vec # image is added to each item
|
||
end
|
||
|
||
thoughtdict["action_result"] = textresult
|
||
end
|
||
|
||
return (thoughtdict=thoughtdict, result_raw=items)
|
||
end
|
||
|
||
|
||
function generatesql(a::T, searchterm::String,
|
||
; maxattempt=10
|
||
)::String where {T<:agent}
|
||
|
||
systemmsg =
|
||
"""
|
||
# database_search_guidelines
|
||
- Keep SQL queries focused only on the provided information.
|
||
- Use wildcard character (%) to search more effectively.
|
||
- Do not create any table in the database.
|
||
- A junction table can be used to link tables together. Another use case is for filtering data.
|
||
- If you can't find a single table that can be used to answer the user's search term, try joining multiple tables to see if you can obtain the answer.
|
||
- Text information in the database usually stored in lower case. If your search returns empty, try using lower case to search.
|
||
- Overly strict condition usually yields empth result
|
||
|
||
# situation
|
||
At each round of conversation, you will be given the following:
|
||
- user search term
|
||
|
||
# objective
|
||
Consult the database_search_guidelines. Then find the data from a database to satisfy the user's search term.
|
||
|
||
# your responsibility includes
|
||
Fulfill the objective.
|
||
|
||
# you should then respond to the user with interleaving plan, action_name, action_input
|
||
1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.
|
||
2) "action_name", Must be "RUNSQL"
|
||
3) "action_input", The input to the action you are about to perform according to your plan.
|
||
After the action is executed you gets "action_result". It is the output from the action you selected.
|
||
|
||
# you should only respond in JSON format as described below
|
||
"plan": "...",
|
||
"action_name": "...",
|
||
"action_input": "..."
|
||
|
||
# available_actions
|
||
"RUNSQL", which you can use to execute SQL against the database.
|
||
The input must be a single SQL query to be executed against the database.
|
||
For more effective text search, it's necessary to use case-insensitivity and the ILIKE operator.
|
||
Do not wrap the SQL as it will be executed against the database directly and SQL must be ended with ';'.
|
||
"""
|
||
|
||
# table_schema =
|
||
# """
|
||
# create table customer (
|
||
# customer_id uuid primary key default gen_random_uuid (),
|
||
# customer_firstname varchar(128),
|
||
# customer_lastname varchar(128),
|
||
# customer_displayname varchar(128) not null,
|
||
# customer_username varchar(128),
|
||
# customer_password varchar(128),
|
||
# customer_gender varchar(128),
|
||
# country varchar(128),
|
||
# telephone varchar(128),
|
||
# email varchar(128) not null,
|
||
# customer_birthdate varchar(128),
|
||
# note text,
|
||
|
||
# other_attributes jsonb,
|
||
# created_time timestamptz default current_timestamp,
|
||
# updated_time timestamptz default current_timestamp,
|
||
# description text
|
||
# );
|
||
|
||
# create table retailer (
|
||
# retailer_id uuid primary key default gen_random_uuid (),
|
||
# retailer_name varchar(128) not null,
|
||
# retailer_username varchar(128) not null,
|
||
# retailer_password varchar(128) not null,
|
||
# retailer_address text not null,
|
||
# country varchar(128) not null,
|
||
# contact_person varchar(128) not null,
|
||
# telephone varchar(128) not null,
|
||
# email varchar(128) not null,
|
||
# note text,
|
||
|
||
# other_attributes jsonb,
|
||
# created_time timestamptz default current_timestamp,
|
||
# updated_time timestamptz default current_timestamp,
|
||
# description text
|
||
# );
|
||
|
||
# create table food (
|
||
# food_id uuid primary key default gen_random_uuid (),
|
||
# food_name varchar(128) not null,
|
||
# country varchar(128),
|
||
# spiciness integer,
|
||
# sweetness integer,
|
||
# sourness integer,
|
||
# savoriness integer,
|
||
# bitterness integer,
|
||
# serving_temperature integer,
|
||
# image_url jsonb,
|
||
# note text,
|
||
# other_attributes jsonb,
|
||
|
||
# created_time timestamptz default current_timestamp,
|
||
# updated_time timestamptz default current_timestamp,
|
||
# description text
|
||
# );
|
||
|
||
# create table wine (
|
||
# wine_id uuid primary key default gen_random_uuid (),
|
||
# seo_name varchar(128) not null,
|
||
# wine_name varchar(128) not null,
|
||
# winery varchar(128) not null,
|
||
# vintage integer not null,
|
||
# region varchar(128) not null,
|
||
# country varchar(128) not null,
|
||
# wine_type varchar(128) not null,
|
||
# grape varchar(128) not null,
|
||
# serving_temperature varchar(128) not null,
|
||
# intensity integer,
|
||
# sweetness integer,
|
||
# tannin integer,
|
||
# acidity integer,
|
||
# fizziness integer,
|
||
# tasting_notes text,
|
||
# image_url jsonb,
|
||
# manufacturer_sku text,
|
||
# note text,
|
||
# other_attributes jsonb,
|
||
|
||
# created_time timestamptz default current_timestamp,
|
||
# updated_time timestamptz default current_timestamp,
|
||
# description text
|
||
# );
|
||
|
||
# create table wine_food (
|
||
# wine_id uuid references wine(wine_id),
|
||
# food_id uuid references food(food_id),
|
||
# constraint wine_food_id primary key (wine_id, food_id),
|
||
|
||
# created_time timestamptz default current_timestamp,
|
||
# updated_time timestamptz default current_timestamp
|
||
# );
|
||
|
||
# CREATE TABLE retailer_wine (
|
||
# retailer_id uuid references retailer(retailer_id),
|
||
# wine_id uuid references wine(wine_id),
|
||
# constraint retailer_wine_id primary key (retailer_id, wine_id),
|
||
# price NUMERIC(10, 2),
|
||
# currency varchar(3) not null,
|
||
|
||
# created_time timestamptz default current_timestamp,
|
||
# updated_time timestamptz default current_timestamp
|
||
# );
|
||
|
||
# CREATE TABLE retailer_food (
|
||
# retailer_id uuid references retailer(retailer_id),
|
||
# food_id uuid references food(food_id),
|
||
# constraint retailer_food_id primary key (retailer_id, food_id),
|
||
# price NUMERIC(10, 2),
|
||
# currency varchar(3) not null,
|
||
|
||
# created_time timestamptz default current_timestamp,
|
||
# updated_time timestamptz default current_timestamp
|
||
# );
|
||
# """
|
||
|
||
requiredKeys = ["plan", "action_name", "action_input"]
|
||
errornote = ""
|
||
# provide similar sql only for the first attempt
|
||
# sql, distance = a.context.similarSQLVectorDB(searchterm)
|
||
|
||
# similarSQL_ = sql !== nothing ? sql : "None"
|
||
# # if sql is really close, just use it
|
||
# if similarSQL_ != "None" && distance <= 0.1
|
||
# return similarSQL_
|
||
# end
|
||
|
||
#CHANGE use find_related_tables_for_user_question and inject only related table schema instead
|
||
# of hard code table schema. CPU embedding is too slow. use embedding service on GPU.
|
||
related_tables = a.context.find_related_tables_for_user_question(searchterm)
|
||
table_schema = ""
|
||
for table in related_tables
|
||
_table_schema_str = GeneralUtils.get_db_table_schema_simple(a.context.pg_conn_str, table)
|
||
table_schema_str = sprint(show, _table_schema_str) * "\n"
|
||
table_schema = table_schema * table_schema_str
|
||
end
|
||
|
||
context =
|
||
"""
|
||
<internal_context_for_assistant>
|
||
<database_table_schema>
|
||
$table_schema
|
||
</database_table_schema>
|
||
</internal_context_for_assistant>
|
||
"""
|
||
input = context * searchterm
|
||
|
||
msg = Dict(
|
||
"model" => "gemma-4-E4B-it-UD-Q4_K_XL",
|
||
"messages" => [
|
||
Dict(
|
||
"role" => "system",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => systemmsg),
|
||
]
|
||
),
|
||
Dict(
|
||
"role" => "user",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => input),
|
||
]
|
||
),
|
||
],
|
||
"temperature" => 0.7
|
||
)
|
||
|
||
for attempt in 1:maxattempt
|
||
response = a.context.text2textInstructLLM("random_id", msg)
|
||
|
||
response = GeneralUtils.clean_json_response(response)
|
||
|
||
think, response = GeneralUtils.extractthink(response)
|
||
responsedict = nothing
|
||
try
|
||
_responsedict = JSON.parse(response)
|
||
responsedict = GeneralUtils.dictify(_responsedict, keytype=String, sort_order=requiredKeys)
|
||
catch
|
||
println("\nERROR decisionMaker() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
continue
|
||
end
|
||
|
||
# check whether all answer's key points are in responsedict
|
||
ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys)
|
||
if !ispass
|
||
errornote = errormsg
|
||
println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n")
|
||
continue
|
||
end
|
||
|
||
# remove backticks Error occurred: MethodError: no method matching occursin(::String, ::Vector{String})
|
||
if occursin("```", responsedict["action_input"])
|
||
sql = GeneralUtils.extract_triple_backtick_text(responsedict["action_input"])[1]
|
||
if sql[1:4] == "sql\n"
|
||
sql = sql[5:end]
|
||
end
|
||
sql = split(sql, ';') # some time there are comments in the sql
|
||
sql = sql[1] * ';'
|
||
|
||
responsedict["action_input"] = sql
|
||
end
|
||
|
||
toollist = ["RUNSQL"]
|
||
if responsedict["action_name"] ∉ toollist
|
||
errornote = "Your previous attempt has action_name that is not in the tool list"
|
||
println("\nERROR SQLLLM decisionMaker(). Attempt $attempt/$maxattempt. $errornote --(not qualify response)--> $(responsedict["action_name"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
continue
|
||
end
|
||
|
||
for i in toollist
|
||
if occursin(i, responsedict["action_input"])
|
||
errornote = "Your previous attempt has action_name in action_input which is not allowed"
|
||
println("\nERROR SQLLLM decisionMaker(). Attempt $attempt/$maxattempt. $errornote --(not qualify response)--> $(responsedict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
continue
|
||
end
|
||
end
|
||
|
||
# println("\nSQLLLM decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
# pprintln(responsedict)
|
||
# println("---")
|
||
|
||
return responsedict["action_input"]
|
||
end
|
||
error("SQLLLM DecisionMaker() failed to generate a thought \n", response)
|
||
end
|
||
|
||
|
||
"""
|
||
# Example
|
||
```jldoctest
|
||
julia> using ChatAgent
|
||
julia> agent = YiemAgent.sommelier(...)
|
||
julia> thoughtdict =
|
||
OrderedDict{String, Any}(
|
||
"plan" => "The user is asking a very specific question about a wine (Brunello di Montalcino from Tenuta CastelGiocondo). Although the policy suggests gathering budget, wine type, and occasion, the user has provided enough specific information (name, region, producer) to attempt a direct search in the database. I will use the SEARCH_WINE_DATABASE action to check if this specific wine is in our inventory.",
|
||
"action_name" => "SEARCH_WINE_DATABASE",
|
||
"action_input" => "Brunello di Montalcino from Tenuta CastelGiocondo")
|
||
```
|
||
julia> predefined_wine_search_sql(agent, thoughtdict["action_input"])
|
||
"""
|
||
function wine_search_term_classification(a::T, searchterm::String,
|
||
; maxattempt=10
|
||
) where {T<:agent}
|
||
|
||
systemmsg =
|
||
"""
|
||
|
||
# situation
|
||
At each round of conversation, you will be given the following:
|
||
- user search term
|
||
- database tables schema
|
||
|
||
# objective
|
||
Consult the provided database schema (tables and columns), please map a user's natural-language search term to the appropriate database columns and tables—identify the relevant fields, operators, and values (e.g., for SQL filtering).
|
||
|
||
# your responsibility includes
|
||
Fulfill the objective.
|
||
|
||
# You must output your response as a JSON object containing a single key: "extracted_info".
|
||
The "extracted_info" key must contain an array of objects. Each object must contain:
|
||
1) "table_name": The name of the table.
|
||
2) "column_name": The specific column being filtered.
|
||
3) "operator": The comparison operator (e.g., "=", ">").
|
||
4) "value": The value to compare against.
|
||
|
||
If the user does not specify any filters, return an empty array for "extracted_info": {"extracted_info": []}.
|
||
|
||
# here are some example
|
||
<user>
|
||
4-wheel drive car with red color that will give me fast and furious emotion. No more than 7000 USD
|
||
</user>
|
||
<assistant>
|
||
{
|
||
"extracted_info": [
|
||
{
|
||
"table_name": "car_info",
|
||
"column_name": "drive_type",
|
||
"operator": "=",
|
||
"value": "4-wheel"
|
||
},
|
||
{
|
||
"table_name": "car_info",
|
||
"column_name": "color",
|
||
"operator": "=",
|
||
"value": "red"
|
||
},
|
||
{
|
||
"table_name": "car_info",
|
||
"column_name": "drive_feeling",
|
||
"operator": "=",
|
||
"value": "fast and furious"
|
||
},
|
||
{
|
||
"table_name": "price_list",
|
||
"column_name": "price",
|
||
"operator": "<",
|
||
"value": "7000"
|
||
}
|
||
}
|
||
</assistant>
|
||
"""
|
||
|
||
|
||
# use find_related_tables_for_user_question and inject only related table schema for a given search term
|
||
# to LLM instead of giving LLM all tables schema.
|
||
related_tables = a.context.find_related_tables_for_user_question(searchterm)
|
||
table_schema = ""
|
||
for table in related_tables
|
||
_table_schema_str = get_db_table_schema_simple_with_samples(a.context.pg_conn_str, table)
|
||
|
||
# _table_schema_str = GeneralUtils.get_db_table_schema_simple(a.context.pg_conn_str, table)
|
||
table_schema_str = sprint(show, _table_schema_str) * "\n"
|
||
table_schema = table_schema * table_schema_str
|
||
end
|
||
|
||
context =
|
||
"""
|
||
<internal_context_for_assistant>
|
||
<database_table_schema>
|
||
$table_schema
|
||
</database_table_schema>
|
||
</internal_context_for_assistant>
|
||
"""
|
||
input = context * searchterm
|
||
|
||
response_format = Dict(
|
||
"type" => "json_schema",
|
||
"json_schema" => Dict(
|
||
"name" => "extracted_conditions",
|
||
"strict" => true,
|
||
"schema" => Dict(
|
||
"type" => "object",
|
||
"properties" => Dict(
|
||
"extracted_info" => Dict(
|
||
"type" => "array",
|
||
"items" => Dict(
|
||
"type" => "object",
|
||
"properties" => Dict(
|
||
"table_name" => Dict(
|
||
"type" => "string",
|
||
"description" => "The name of the database table."
|
||
),
|
||
"column_name" => Dict(
|
||
"type" => "string",
|
||
"description" => "The name of the column to filter on."
|
||
),
|
||
"operator" => Dict(
|
||
"type" => "string",
|
||
"enum" => ["=", "!=", ">", "<", ">=", "<=", "LIKE", "IN", "IS NULL", "IS NOT NULL"],
|
||
"description" => "The SQL comparison operator."
|
||
),
|
||
"value" => Dict(
|
||
"type" => ["string", "null"],
|
||
"description" => "The value to compare against. Use null for IS NULL/IS NOT NULL."
|
||
)
|
||
),
|
||
"required" => ["table_name", "column_name", "operator", "value"],
|
||
"additionalProperties" => false
|
||
)
|
||
)
|
||
),
|
||
"required" => ["extracted_info"],
|
||
"additionalProperties" => false
|
||
)
|
||
)
|
||
)
|
||
|
||
msg = Dict(
|
||
"model" => "gemma-4-E4B-it-UD-Q4_K_XL",
|
||
"messages" => [
|
||
Dict(
|
||
"role" => "system",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => systemmsg),
|
||
]
|
||
),
|
||
Dict(
|
||
"role" => "user",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => input),
|
||
]
|
||
),
|
||
],
|
||
"temperature" => 0.7,
|
||
"response_format"=> response_format,
|
||
)
|
||
|
||
for attempt in 1:maxattempt
|
||
response = a.context.text2textInstructLLM("random_id", msg)
|
||
responsedict = JSON.parse(response)
|
||
# responsedict = nothing
|
||
# try
|
||
# responsedict = Serde.parse_yaml(response)
|
||
# catch e
|
||
# println("\nERROR YiemAgent predefined_wine_search_sql() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n")
|
||
# continue
|
||
# end
|
||
|
||
# println("\n ", table_schema)
|
||
println("\n ", responsedict)
|
||
@info "before BM25 " @__LINE__
|
||
|
||
# to ensure user input is correct
|
||
for entry in responsedict["extracted_info"]
|
||
table_name = entry["table_name"]::String
|
||
column_name = entry["column_name"]::String
|
||
|
||
bucket = classify_column(a.context.pg_conn_str, table_name, column_name)
|
||
|
||
if bucket == "fuzzy_correction"
|
||
words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, table_name, column_name)
|
||
resolved_word = GeneralUtils.resolve_entity(entry["value"], words_catalog; threshold=0.9)
|
||
entry["value"] = resolved_word
|
||
end
|
||
end
|
||
|
||
# filter for column that will be used for hard condition (SQL where clause)
|
||
# column with non-standard operator will be used in vector search
|
||
vector_search_words = ""
|
||
hard_operators = ["=","<>","!=",">","<",">=","<=","!<","!>","<=>"]
|
||
|
||
# Build new list of hard condition entries
|
||
hard_conditions = JSON.Object{String, Any}[]
|
||
vector_search = JSON.Object{String, Any}[]
|
||
for entry in responsedict["extracted_info"]
|
||
if entry["operator"] ∈ hard_operators
|
||
push!(hard_conditions, entry)
|
||
else
|
||
push!(vector_search, entry)
|
||
end
|
||
end
|
||
responsedict = hard_conditions
|
||
|
||
println("")
|
||
@show responsedict
|
||
@info "predefined_wine_search_sql() " @__LINE__
|
||
|
||
return (hard_conditions=hard_conditions, vector_search=vector_search)
|
||
end
|
||
error("SQLLLM DecisionMaker() failed to generate a thought \n", response)
|
||
end
|
||
|
||
function predefined_wine_search_sql(conditions::Vector{JSON.Object{String, Any}})::String
|
||
# 1. Base SQL structure
|
||
base_query =
|
||
"""
|
||
SELECT
|
||
w.winery,
|
||
w.wine_name,
|
||
w.wine_id,
|
||
w.vintage,
|
||
w.region,
|
||
w.country,
|
||
w.wine_type,
|
||
w.grape,
|
||
w.serving_temperature,
|
||
w.sweetness,
|
||
w.intensity,
|
||
w.tannin,
|
||
w.acidity,
|
||
w.tasting_notes,
|
||
rw.price,
|
||
rw.currency,
|
||
w.image_url,
|
||
r.retailer_name,
|
||
rw.retailer_id
|
||
FROM wine AS w
|
||
JOIN retailer_wine AS rw ON w.wine_id = rw.wine_id
|
||
JOIN retailer AS r ON rw.retailer_id = r.retailer_id
|
||
"""
|
||
|
||
# 2. Dynamic WHERE Clause Builder
|
||
where_clauses = String[]
|
||
|
||
# Iterate over each condition object in the array
|
||
for cond in conditions
|
||
table_name = String(cond["table_name"])
|
||
column_name = String(cond["column_name"])
|
||
op = String(cond["operator"])
|
||
raw_val = cond["value"]
|
||
|
||
# Determine table alias
|
||
alias = if table_name == "wine"
|
||
"w"
|
||
elseif table_name == "retailer_wine"
|
||
"rw"
|
||
else
|
||
continue
|
||
end
|
||
|
||
# --- Value Type Handling ---
|
||
final_val = raw_val
|
||
|
||
if op in ("=", "<", ">", "<=", ">=")
|
||
str_val = string(raw_val)
|
||
num_val = tryparse(Float64, str_val)
|
||
|
||
if !isnothing(num_val)
|
||
final_val = isinteger(num_val) ? round(Int, num_val) : num_val
|
||
end
|
||
end
|
||
|
||
# --- SQL Formatting ---
|
||
if isa(final_val, Number)
|
||
clause = "$(alias).$(column_name) $(op) $(final_val)"
|
||
else
|
||
escaped_val = replace(string(final_val), "'" => "''")
|
||
clause = "$(alias).$(column_name) $(op) '$(escaped_val)'"
|
||
end
|
||
|
||
push!(where_clauses, clause)
|
||
end
|
||
|
||
# 3. Assemble Final Query
|
||
where_sql = isempty(where_clauses) ? "" : "WHERE " * join(where_clauses, " AND ")
|
||
|
||
return string(base_query, where_sql, ";")
|
||
end
|
||
|
||
function SQLexecution(executeSQL::Function, sql::T
|
||
)::NamedTuple where {T<:AbstractString}
|
||
|
||
try
|
||
# add LIMIT to the SQL to prevent loading large data
|
||
sql = strip(sql)
|
||
|
||
# remove DISTINCT keyword because it is incompatible with RANDOM()
|
||
sql = replace(sql, "DISTINCT" => "")
|
||
|
||
if sql[end] == ';'
|
||
if !occursin("LIMIT", sql)
|
||
sql = sql[1:end-1] * " ORDER BY RANDOM() LIMIT 2;"
|
||
end
|
||
else
|
||
sql = sql * ";"
|
||
end
|
||
result = executeSQL(sql)
|
||
df = DataFrame(result)
|
||
tablesize = size(df)
|
||
row, column = tablesize
|
||
if row == 0
|
||
return (result_str="No records found. Try loosening your search criteria.", result_raw=nothing, success=true, errormsg=nothing)
|
||
elseif column > 30
|
||
return (result_str="There are more than 30 columns. Please be more specific.", result_raw=df, success=true, errormsg=nothing)
|
||
else
|
||
df1 =
|
||
if row > 2
|
||
# ramdom row to pick
|
||
df[sample(1:nrow(df), 2, replace=false), :] # random select 2 rows from df
|
||
else
|
||
df
|
||
end
|
||
result = GeneralUtils.dfToString(df1)
|
||
# println("\n~~~ SQLexecution() result: ", @__FILE__, " ", @__LINE__)
|
||
# println(sql)
|
||
# println(df1)
|
||
# println("\n")
|
||
return (result_str=result, result_raw=df1, success=true, errormsg=nothing)
|
||
end
|
||
catch e
|
||
io = IOBuffer()
|
||
showerror(io, e)
|
||
errorMsg = String(take!(io))
|
||
st = sprint((io, v) -> show(io, "text/plain", v), stacktrace(catch_backtrace()))
|
||
println(errorMsg)
|
||
return (result_str=nothing, result_raw=nothing, success=false, errormsg=errorMsg)
|
||
end
|
||
end
|
||
|
||
function DEPRECIATED_search_wine_database!(a::T, thoughtdict::AbstractDict; useSQLLLM::Bool=false
|
||
)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent}
|
||
|
||
# XXX
|
||
predefined_wine_search_sql(a, thoughtdict["action_input"])
|
||
|
||
println("\ncheckinventory order: $(thoughtdict["action_input"]) ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
wineattributes_1 = extractWineAttributes_1(a, thoughtdict["action_input"])
|
||
wineattributes_2 = extractWineAttributes_2(a, thoughtdict["action_input"])
|
||
|
||
retrieve_attributes = ["winery", "wine_name", "wine_id", "vintage", "region", "country", "wine_type", "grape", "serving_temperature", "sweetness", "intensity", "tannin", "acidity", "tasting_notes", "price", "currency", "image_url", "retailer_name", "retailer_id"]
|
||
_inventoryquery = "$(thoughtdict["action_input"]), $wineattributes_1, $wineattributes_2, retailer_name: $(a.retailername), retailerid: $(a.retailerid)"
|
||
inventoryquery = "Retrieves $retrieve_attributes of wines that match the following criteria - {$_inventoryquery}"
|
||
println("\ncheckinventory input: $inventoryquery ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
|
||
if useSQLLLM
|
||
# add suppport for similarSQLVectorDB
|
||
textresult, result_raw = SQLLLM.query(
|
||
inventoryquery,
|
||
a.context.executeSQL,
|
||
a.context.text2textInstructLLM;
|
||
insertSQLVectorDB=a.context.insertSQLVectorDB,
|
||
similarSQLVectorDB=a.context.similarSQLVectorDB,
|
||
llmFormatName="qwen3")
|
||
thoughtdict["action_result"] = textresult
|
||
else
|
||
|
||
# direct query with possible sql instead of SQLLLM.
|
||
sql = generatesql(a, inventoryquery)
|
||
println("\nSQL: $sql ", @__FILE__, ":", @__LINE__, " $(Dates.now()) \n")
|
||
textresult, sql_result_df, success, _ = SQLexecution(a.context.executeSQL, sql)
|
||
|
||
items = nothing
|
||
if sql_result_df !== nothing
|
||
result_vec = GeneralUtils.dfToVectorDict(sql_result_df)
|
||
|
||
# get image
|
||
for d in result_vec
|
||
image_url_json_str = d["image_url"]
|
||
image_url_json_obj = JSON.parse(image_url_json_str)
|
||
base_url = "http://192.168.88.106:8080/"
|
||
if haskey(image_url_json_obj, "bottle")
|
||
url = base_url * image_url_json_obj["bottle"]
|
||
image_data = HTTP.get(url) # vector{int} data
|
||
image_base64_string = base64encode(image_data.body)
|
||
d["image"] = image_base64_string
|
||
else
|
||
d["image"] = nothing
|
||
end
|
||
end
|
||
items = result_vec # image is added to each item
|
||
end
|
||
|
||
thoughtdict["action_result"] = textresult
|
||
end
|
||
|
||
return (thoughtdict=thoughtdict, result_raw=items)
|
||
end
|
||
|
||
"""
|
||
|
||
# Arguments
|
||
- `v::Integer`
|
||
dummy variable
|
||
|
||
# Return
|
||
|
||
# Example
|
||
```jldoctest
|
||
julia>
|
||
```
|
||
"""
|
||
function extractWineAttributes_1(a::T1, input::T2; maxattempt=10
|
||
)::String where {T1<:agent, T2<:AbstractString}
|
||
|
||
systemmsg =
|
||
"""
|
||
<situation>
|
||
At each round of conversation, the user provides the following:
|
||
- The query: the query provided by the user.
|
||
</situation>
|
||
<objective>
|
||
Extract information from the user's query as much as possible according to wine attributes extraction guidelines to fill out user's preference form.
|
||
</objective>
|
||
<your responsibility includes>
|
||
Fulfill the objective.
|
||
</your responsibility includes>
|
||
<wine attributes extraction guidelines>
|
||
- If specific information required in the preference form is not available in the query or there isn't any, mark with "N/A" to indicate this.
|
||
Additionally, words like 'any' or 'unlimited' mean no information is available.
|
||
- Do not generate other comments.
|
||
</wine attributes extraction guidelines>
|
||
<you should then respond to the user with>
|
||
wine_name: name of the wine
|
||
winery: name of the winery
|
||
vintage: the year of the wine
|
||
country: a country where wine is produced. Can be "Austria", "Australia", "France", "Germany", "Italy", "Portugal", "Spain", "United States". Use "or" if there are multiple countries.
|
||
wine_type: can be one of: "red", "white", "sparkling", "rose", "dessert" or "fortified"
|
||
grape_varietal: the name of the primary grape used to make the wine
|
||
tasting_notes: a word describe the wine's flavor, such as "butter", "oak", "fruity", "raspberry", "earthy", "floral", etc
|
||
wine_price_min: minimum price range of wine. Example: For wine price 20, wine_price_min will be 0. For wine price 10 to 100, wine_price_min will be 10.
|
||
wine_price_max: maximum price range of wine. Example: For wine price 20, wine_price_max will be 20. For wine price 10 to 100, wine_price_max will be 100.
|
||
occasion: the occasion the user is having the wine for
|
||
food_to_be_paired_with_wine: food that the user will be served with the wine such as poultry, fish, steak, etc
|
||
_keyword suffice is the related keyword that appears in user's query.
|
||
</you should then respond to the user with>
|
||
<you should only respond in JSON format as described below>
|
||
"wine_name": "...",
|
||
"winery": "...",
|
||
"vintage": "...",
|
||
"country": "...",
|
||
"wine_type": "...",
|
||
"grape_varietal": "...",
|
||
"tasting_notes": "...",
|
||
"wine_price_min": "...",
|
||
"wine_price_max": "...",
|
||
"occasion": "...",
|
||
"food_to_be_paired_with_wine": "..."
|
||
</you should only respond in JSON format as described below>
|
||
<here are some examples>
|
||
User's query: red, Chenin Blanc, Riesling, 20 USD from Tuscany, Italy or Napa Valley, USA
|
||
"wine_name": "N/A",
|
||
"winery": "N/A",
|
||
"vintage": "N/A",
|
||
"country": "Italy or United States",
|
||
"wine_type": "red or white",
|
||
"grape_varietal": "Chenin Blanc or Riesling",
|
||
"tasting_notes": "citrus",
|
||
"wine_price_min": "0",
|
||
"wine_price_max": "20",
|
||
"occasion": "N/A",
|
||
"food_to_be_paired_with_wine": "N/A"
|
||
|
||
User's query: Domaine du Collier Saumur Blanc 2019, France, white, Merlot
|
||
"wine_name": "Saumur Blanc",
|
||
"winery": "Domaine du Collier",
|
||
"vintage": "2019",
|
||
"country": "France",
|
||
"wine_type": "white",
|
||
"grape_varietal": "Merlot",
|
||
"tasting_notes": "N/A",
|
||
"wine_price_min": "N/A",
|
||
"wine_price_max": "N/A",
|
||
"occasion": "N/A",
|
||
"food_to_be_paired_with_wine": "N/A"
|
||
</here are some examples>
|
||
"""
|
||
requiredKeys = ["wine_name", "winery", "vintage", "country", "wine_type", "grape_varietal", "tasting_notes", "wine_price_min", "wine_price_max", "occasion", "food_to_be_paired_with_wine"]
|
||
errornote = ""
|
||
context =
|
||
"""
|
||
<internal_context_for_assistant>
|
||
$errornote
|
||
</internal_context_for_assistant>
|
||
"""
|
||
|
||
input = context * input
|
||
|
||
msg = Dict(
|
||
"model" => "gemma-4-E4B-it-UD-Q4_K_XL",
|
||
"messages" => [
|
||
Dict(
|
||
"role" => "system",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => systemmsg),
|
||
]
|
||
),
|
||
Dict(
|
||
"role" => "user",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => input),
|
||
]
|
||
),
|
||
],
|
||
"temperature" => 0.7
|
||
)
|
||
|
||
for attempt in 1:maxattempt
|
||
response = a.context.text2textInstructLLM(a.id, msg)
|
||
response = GeneralUtils.clean_json_response(response)
|
||
|
||
response = GeneralUtils.remove_french_accents(response)
|
||
think, response = GeneralUtils.extractthink(response)
|
||
responsedict = nothing
|
||
try
|
||
_responsedict = JSON.parse(response)
|
||
responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys)
|
||
catch
|
||
println("\nERROR YiemAgent extractWineAttributes_1() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
continue
|
||
end
|
||
# check whether all answer's key points are in responsedict
|
||
ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys)
|
||
if !ispass
|
||
errornote = errormsg
|
||
println("\nERROR YiemAgent extractWineAttributes_1() $errornote --(not qualify response)> $responsedict", @__FILE__, ":", @__LINE__, " $(Dates.now())\n")
|
||
continue
|
||
end
|
||
removekeys = ["thought", "tasting_notes", "occasion", "food_to_be_paired_with_wine", "vintage"]
|
||
for i in removekeys
|
||
delete!(responsedict, i)
|
||
end
|
||
# remove (some text)
|
||
for (k, v) in responsedict
|
||
_v = replace(v, r"\(.*?\)" => "")
|
||
responsedict[k] = _v
|
||
end
|
||
|
||
@info "YiemAgent extractWineAttributes_1() " @__LINE__
|
||
@show responsedict
|
||
@info "---\n" @__LINE__
|
||
|
||
# check each attributes against each column in a database table with BM25
|
||
for (k, v) in responsedict
|
||
if k ∉ ["wine_price_min", "wine_price_max"]
|
||
words_catalog = GeneralUtils.harvest_entity_catalog(a.context.pg_conn_str, "wine", k)
|
||
resolved_word = GeneralUtils.resolve_entity(v, words_catalog; threshold=0.9)
|
||
responsedict[k] = resolved_word
|
||
end
|
||
end
|
||
|
||
result = ""
|
||
for (k, v) in responsedict
|
||
# some time LLM generate text with "(some comment)". this line removes it
|
||
if !occursin("N/A", v) && v != "" && !occursin("none", v) && !occursin("None", v)
|
||
result *= "$k: $v, "
|
||
end
|
||
end
|
||
|
||
result = result[1:end-2] # remove the ending ", "
|
||
|
||
@info "YiemAgent extractWineAttributes_1() " @__LINE__
|
||
@show result
|
||
@info "---\n" @__LINE__
|
||
|
||
return result
|
||
end
|
||
error("extractWineAttributes_1() failed to get a response")
|
||
end
|
||
|
||
"""
|
||
- TODO "French dry white wines with medium bod" the LLM does not recognize sweetness. use LLM self questioning to solve.
|
||
- TODO French Syrah, Viognier, under 100. LLM extract intensiry of 3-5. why?
|
||
"""
|
||
function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2<:AbstractString}
|
||
|
||
conversiontable =
|
||
"""
|
||
<conversion_table>
|
||
Intensity level:
|
||
1 to 2: May correspond to "light-bodied" or a similar description.
|
||
2 to 3: May correspond to "med light bodied", "medium light" or a similar description.
|
||
3 to 4: May correspond to "medium bodied" or a similar description.
|
||
4 to 5: May correspond to "med full bodied", "medium full" or a similar description.
|
||
4 to 5: May correspond to "full bodied" or a similar description.
|
||
Sweetness level:
|
||
1 to 2: May correspond to "dry", "no sweet" or a similar description.
|
||
2 to 3: May correspond to "off dry", "less sweet" or a similar description.
|
||
3 to 4: May correspond to "semi sweet" or a similar description.
|
||
4 to 5: May correspond to "sweet" or a similar description.
|
||
4 to 5: May correspond to "very sweet" or a similar description.
|
||
Tannin level:
|
||
1 to 2: May correspond to "low tannin" or a similar description.
|
||
2 to 3: May correspond to "semi low tannin" or a similar description.
|
||
3 to 4: May correspond to "medium tannin" or a similar description.
|
||
4 to 5: May correspond to "semi high tannin" or a similar description.
|
||
4 to 5: May correspond to "high tannin" or a similar description.
|
||
Acidity level:
|
||
1 to 2: May correspond to "low acidity" or a similar description.
|
||
2 to 3: May correspond to "semi low acidity" or a similar description.
|
||
3 to 4: May correspond to "medium acidity" or a similar description.
|
||
4 to 5: May correspond to "semi high acidity" or a similar description.
|
||
4 to 5: May correspond to "high acidity" or a similar description.
|
||
</conversion_table>
|
||
"""
|
||
|
||
systemmsg =
|
||
"""
|
||
<situation>
|
||
At each round of conversation, you will be given the following information:
|
||
conversion_table: a conversion table that maps descriptive words to their corresponding integer levels
|
||
query: the words from the user's query that describe their preferences
|
||
</situation>
|
||
<objective>
|
||
Fill out the user's preference form based on the corresponding words from the user's query according to the guidelines.
|
||
</objective>
|
||
<your responsibility includes>
|
||
Fulfill the objective
|
||
</your responsibility includes>
|
||
<guidelines>
|
||
- The preference form requires sweetness, acidity, tannin, intensity infomation
|
||
- If specific information required in the preference form is not available in the query or there isn't any, mark with 'N/A' to indicate this.
|
||
Additionally, words like 'any' or 'unlimited' mean no information is available.
|
||
- Use the conversion table to convert the descriptive word level of sweetness, intensity, tannin, and acidity into a corresponding integer.
|
||
- Do not generate other comments.
|
||
</guidelines>
|
||
<you should then respond to the user with>
|
||
sweetness_keyword: The exact keywords in the user's query describing the sweetness level of the wine.
|
||
sweetness: ( S ), where ( S ) represents integers indicating the range of sweetness levels. Example: 1-2
|
||
acidity_keyword: The exact keywords in the user's query describing the acidity level of the wine.
|
||
acidity: ( A ), where ( A ) represents integers indicating the range of acidity level. Example: 3-5
|
||
tannin_keyword: The exact keywords in the user's query describing the tannin level of the wine.
|
||
tannin: ( T ), where ( T ) represents integers indicating the range of tannin level. Example: 1-3
|
||
intensity_keyword: The exact keywords in the user's query describing the intensity level of the wine.
|
||
intensity: ( I ), where ( I ) represents integers indicating the range of intensity level. Example: 2-4
|
||
</you should then respond to the user with>
|
||
<you should only respond in JSON format as described below>
|
||
"sweetness_keyword": "...",
|
||
"sweetness_min": "...",
|
||
"sweetness_max": "...",
|
||
"acidity_keyword": "...",
|
||
"acidity_min": "...",
|
||
"acidity_max": "...",
|
||
"tannin_keyword": "...",
|
||
"tannin_min": "...",
|
||
"tannin_max": "...",
|
||
"intensity_keyword": "...",
|
||
"intensity_min": "...",
|
||
"intensity_max": "..."
|
||
</you should only respond in JSON format as described below>
|
||
<here are some examples>
|
||
User's query: I want a wine with a medium-bodied, low acidity, medium tannin.
|
||
"sweetness_keyword": "N/A",
|
||
"sweetness_min": "N/A",
|
||
"sweetness_max": "N/A",
|
||
"acidity_keyword": "low acidity",
|
||
"acidity_min": 1,
|
||
"acidity_max": 2,
|
||
"tannin_keyword": "medium tannin",
|
||
"tannin_min": 3,
|
||
"tannin_max": 4,
|
||
"intensity_keyword": "medium-bodied",
|
||
"intensity_min": 3,
|
||
"intensity_max": 4
|
||
|
||
User's query: German red wine, under 100, pairs with spicy food.
|
||
"sweetness_keyword": "N/A",
|
||
"sweetness_min": "N/A",
|
||
"sweetness_max": "N/A",
|
||
"acidity_keyword": "N/A",
|
||
"acidity_min": "N/A",
|
||
"acidity_max": "N/A",
|
||
"tannin_keyword": "N/A",
|
||
"tannin_min": "N/A",
|
||
"tannin_max": "N/A",
|
||
"intensity_keyword": "N/A",
|
||
"intensity_min": "N/A",
|
||
"intensity_max": "N/A"
|
||
<here are some examples>
|
||
"""
|
||
requiredKeys = ["sweetness_keyword", "sweetness_min", "sweetness_max",
|
||
"acidity_keyword", "acidity_min", "acidity_max",
|
||
"tannin_keyword", "tannin_min", "tannin_max",
|
||
"intensity_keyword", "intensity_min", "intensity_max"]
|
||
errornote = ""
|
||
context =
|
||
"""
|
||
<internal_context_for_assistant>
|
||
$conversiontable
|
||
$errornote
|
||
</internal_context_for_assistant>
|
||
"""
|
||
|
||
input = context * input
|
||
|
||
msg = Dict(
|
||
"model" => "gemma-4-E4B-it-UD-Q4_K_XL",
|
||
"messages" => [
|
||
Dict(
|
||
"role" => "system",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => systemmsg),
|
||
]
|
||
),
|
||
Dict(
|
||
"role" => "user",
|
||
"content" => [
|
||
Dict("type" => "text", "text" => input),
|
||
]
|
||
),
|
||
],
|
||
"temperature" => 0.7
|
||
)
|
||
|
||
for attempt in 1:10
|
||
response = a.context.text2textInstructLLM(a.id, msg)
|
||
response = GeneralUtils.clean_json_response(response)
|
||
|
||
response = GeneralUtils.remove_french_accents(response)
|
||
think, response = GeneralUtils.extractthink(response)
|
||
responsedict = nothing
|
||
try
|
||
_responsedict = JSON.parse(response)
|
||
responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys)
|
||
catch
|
||
println("\nERROR YiemAgent extractWineAttributes_2() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())")
|
||
continue
|
||
end
|
||
|
||
# check whether all answer's key points are in responsedict
|
||
ispass, errormsg = GeneralUtils.checkAgentResponse_JSON(responsedict, requiredKeys)
|
||
if !ispass
|
||
errornote = errormsg
|
||
println("\nERROR YiemAgent extractWineAttributes_2() $errornote --(not qualify response)> $responsedict", @__FILE__, ":", @__LINE__, " $(Dates.now())\n")
|
||
continue
|
||
end
|
||
|
||
# delete some key words from responsedict
|
||
for (k, v) in responsedict
|
||
if k ∈ ["sweetness_keyword", "acidity_keyword", "tannin_keyword", "intensity_keyword"]
|
||
delete!(responsedict, k)
|
||
end
|
||
end
|
||
|
||
# get result in String. Reject "N/A" value
|
||
result = ""
|
||
for (k, v) in responsedict
|
||
if typeof(v) <: Number
|
||
result *= "$k: $v, "
|
||
elseif typeof(v) == String && !occursin("N/A", v)
|
||
result *= "$k: $v, "
|
||
end
|
||
end
|
||
result = result[1:end-2] # remove the ending ", "
|
||
|
||
@info "YiemAgent extractWineAttributes_2() " @__LINE__
|
||
@show result
|
||
@info "---\n" @__LINE__
|
||
|
||
return result
|
||
end
|
||
error("extractWineAttributes_2() failed to get a response")
|
||
end
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function get_db_table_schema_simple_with_samples(pg_conn_str::String, table_name::String;
|
||
schema_name::String="public")::String
|
||
conn = LibPQ.Connection(pg_conn_str)
|
||
return get_db_table_schema_simple_with_samples(conn, table_name; schema_name=schema_name)
|
||
end
|
||
|
||
function get_db_table_schema_simple_with_samples(conn, table_name::String; schema_name::String="public", sample_count::Int=3)::String
|
||
# 1. SQL query for catalog metadata
|
||
meta_sql = """
|
||
SELECT
|
||
a.attname AS column_name,
|
||
format_type(a.atttypid, a.atttypmod) AS data_type,
|
||
pg_get_expr(def.adbin, def.adrelid) AS default_value,
|
||
COALESCE(
|
||
(SELECT pg_get_constraintdef(p.oid)
|
||
FROM pg_catalog.pg_constraint p
|
||
WHERE p.conrelid = c.oid AND a.attnum = ANY(p.conkey)
|
||
LIMIT 1), ''
|
||
) AS constraint_definition
|
||
FROM pg_catalog.pg_attribute a
|
||
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
|
||
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
|
||
LEFT JOIN pg_catalog.pg_attrdef def ON def.adrelid = c.oid AND def.adnum = a.attnum
|
||
WHERE c.relname = \$1
|
||
AND n.nspname = \$2
|
||
AND a.attnum > 0
|
||
AND NOT a.attisdropped
|
||
ORDER BY a.attnum;
|
||
"""
|
||
|
||
meta_res = DataFrame(execute(conn, meta_sql, [table_name, schema_name]))
|
||
|
||
if nrow(meta_res) == 0
|
||
error("Table '$schema_name.$table_name' not found.")
|
||
end
|
||
|
||
# 2. Build single dynamic query to fetch non-null samples for all columns
|
||
sample_selects = String[]
|
||
for row in eachrow(meta_res)
|
||
c_name = row.column_name
|
||
push!(sample_selects, """
|
||
(SELECT json_agg(s."$c_name")
|
||
FROM (
|
||
SELECT "$c_name"
|
||
FROM "$schema_name"."$table_name"
|
||
WHERE "$c_name" IS NOT NULL
|
||
LIMIT $sample_count
|
||
) s
|
||
) AS "$c_name"
|
||
""")
|
||
end
|
||
|
||
sample_sql = "SELECT " * join(sample_selects, ",\n ") * ";"
|
||
sample_df = DataFrame(execute(conn, sample_sql))
|
||
|
||
# 3. Build DDL definitions with inline sample comments
|
||
ddl_lines = String[]
|
||
constraints = String[]
|
||
|
||
for row in eachrow(meta_res)
|
||
col_name = row.column_name
|
||
data_type = row.data_type
|
||
default_val = ismissing(row.default_value) ? "" : " DEFAULT " * row.default_value
|
||
|
||
col_def = " \"$col_name\" $data_type$default_val"
|
||
|
||
# Fetch sample data for this column from the single-row sample DataFrame
|
||
samples_comment = ""
|
||
if nrow(sample_df) > 0
|
||
raw_samples = sample_df[1, Symbol(col_name)]
|
||
samples_str = ismissing(raw_samples) || isnothing(raw_samples) ? "[]" : string(raw_samples)
|
||
samples_comment = " -- Samples: $samples_str"
|
||
end
|
||
|
||
push!(ddl_lines, col_def * samples_comment)
|
||
|
||
# Handle table-level constraints
|
||
con_def = ismissing(row.constraint_definition) ? "" : row.constraint_definition
|
||
if !isempty(con_def) && !(con_def in constraints)
|
||
push!(constraints, " " * con_def)
|
||
end
|
||
end
|
||
|
||
all_definitions = vcat(ddl_lines, constraints)
|
||
body = join(all_definitions, ",\n")
|
||
|
||
return "CREATE TABLE \"$schema_name\".\"$table_name\" (\n$body\n);"
|
||
end
|
||
|
||
|
||
|
||
function classify_column(pg_conn_str::String, table_name::String, column_name::String;
|
||
sample_size::Integer=1000)
|
||
conn = LibPQ.Connection(pg_conn_str)
|
||
return classify_column(conn, table_name, column_name; sample_size=sample_size)
|
||
end
|
||
|
||
|
||
function classify_column(conn::LibPQ.Connection, table_name::String, column_name::String; sample_size::Int=1000)
|
||
# 1. Fetch BOTH data_type and udt_name (User Defined Type name)
|
||
meta_query = """
|
||
SELECT data_type, udt_name
|
||
FROM information_schema.columns
|
||
WHERE table_name = lower('$(table_name)')
|
||
AND column_name = lower('$(column_name)');
|
||
"""
|
||
|
||
pg_type = "unknown"
|
||
udt_name = "unknown"
|
||
|
||
try
|
||
df = DataFrame(LibPQ.execute(conn, meta_query))
|
||
if !isempty(df)
|
||
pg_type = df[1, :data_type]
|
||
udt_name = df[1, :udt_name]
|
||
end
|
||
catch e
|
||
@error "Failed to fetch metadata for $table_name.$column_name" exception=e
|
||
return "error"
|
||
end
|
||
|
||
# 2. FAST-TRACK: Check for pgvector FIRST
|
||
# pgvector registers as "USER-DEFINED" in data_type, but "vector" in udt_name
|
||
if udt_name == "vector"
|
||
return "semantic_search"
|
||
end
|
||
|
||
# 3. FAST-TRACK: Hard rules for standard non-text Postgres types
|
||
if pg_type in ["integer", "bigint", "smallint", "numeric", "real",
|
||
"double precision", "boolean", "date",
|
||
"timestamp without time zone", "timestamp with time zone", "uuid"]
|
||
return "exact_or_range"
|
||
end
|
||
|
||
# 4. SAMPLE: Get text statistics for remaining text columns
|
||
stats_query = """
|
||
SELECT
|
||
COUNT(*)::int AS total_count,
|
||
COUNT(DISTINCT $(column_name)::text)::int AS unique_count,
|
||
COALESCE(AVG(LENGTH($(column_name)::text)), 0)::float AS avg_len,
|
||
COALESCE(STDDEV(LENGTH($(column_name)::text)), 0)::float AS std_len
|
||
FROM (
|
||
SELECT $(column_name)
|
||
FROM $(table_name)
|
||
WHERE $(column_name) IS NOT NULL
|
||
LIMIT $sample_size
|
||
) AS sampled_data;
|
||
"""
|
||
|
||
try
|
||
df = DataFrame(LibPQ.execute(conn, stats_query))
|
||
if isempty(df) || df[1, :total_count] == 0
|
||
return "unknown"
|
||
end
|
||
|
||
total = df[1, :total_count]
|
||
unique = df[1, :unique_count]
|
||
avg_len = df[1, :avg_len]
|
||
std_len = df[1, :std_len]
|
||
ratio = unique / total
|
||
|
||
# 5. HEURISTICS: Route the column_name to the correct text bucket
|
||
return classify_text_column(unique, ratio, avg_len, std_len)
|
||
|
||
catch e
|
||
@warn "Failed to sample column_name $table_name.$column_name" exception=e
|
||
return "unknown"
|
||
end
|
||
end
|
||
|
||
# The Decision Tree for Text Columns (Unchanged, but kept for completeness)
|
||
function classify_text_column(unique_count::Integer, ratio::Float64, avg_len::Float64, std_len::Float64)
|
||
if avg_len > 60 && std_len > 25
|
||
return "full_text_search"
|
||
end
|
||
if ratio > 0.90 && avg_len < 40
|
||
return "exact_or_regex"
|
||
end
|
||
if unique_count <= 100
|
||
return "fuzzy_correction"
|
||
end
|
||
if ratio > 0.10 && avg_len < 35
|
||
return "fuzzy_correction"
|
||
end
|
||
if avg_len < 60
|
||
return "fuzzy_correction"
|
||
end
|
||
return "full_text_search"
|
||
end
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
end # module llmfunction |