This commit is contained in:
2026-06-30 21:18:59 +07:00
parent d076d5f912
commit b4f2a6185b
7 changed files with 443 additions and 510 deletions
+189 -322
View File
@@ -281,13 +281,7 @@ julia> input = "{\"food\": \"pizza\", \"occasion\": \"anniversary\"}"
julia> result = checkinventory(agent, input)
"{"wine 1": {\"Winery\": \"Pichon Baron\", \"wine name\": \"Pauillac (Grand Cru Classé)\", \"grape variety\": \"Cabernet Sauvignon\", \"year\": 2010, \"price\": \"125 USD\", \"stock ID\": \"ar-17\"}, }"
```
# TODO
- [] update docs
- [x] implement the function
# Signature
"""
"""
function checkwine(a::T1, input::T2; maxattempt::Int=3
) where {T1<:agent, T2<:AbstractString}
@@ -309,6 +303,7 @@ function checkwine(a::T1, input::T2; maxattempt::Int=3
inventoryquery = "Retrieves $retrieve_attributes of wines that match the following criteria - {$_inventoryquery}"
println("\ncheckinventory input: $inventoryquery ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# add suppport for similarSQLVectorDB
#WORKING
textresult, rawresponse = SQLLLM.query(
inventoryquery,
a.context.executeSQL,
@@ -316,6 +311,7 @@ function checkwine(a::T1, input::T2; maxattempt::Int=3
insertSQLVectorDB=a.context.insertSQLVectorDB,
similarSQLVectorDB=a.context.similarSQLVectorDB,
llmFormatName="qwen3")
error(5555)
# check if all of retrieve_attributes appears in textresult
isin = [occursin(x, textresult) for x in retrieve_attributes]
# check if rawresponse type is DataFrame so that I can check for column
@@ -349,29 +345,28 @@ end
```jldoctest
julia>
```
# TODO
- [] update docstring
- implement the function
# Signature
"""
function extractWineAttributes_1(a::T1, input::T2; maxattempt=10
)::String where {T1<:agent, T2<:AbstractString}
systemmsg =
"""
As a helpful sommelier, your task is to extract the user information from the user's query as much as possible to fill out user's preference form.
At each round of conversation, the user will give you the following:
- The query: the query provided by the user.
You must follow the following guidelines:
<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.
You should then respond to the user with:
</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
@@ -384,27 +379,23 @@ function extractWineAttributes_1(a::T1, input::T2; maxattempt=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
You should only respond in JSON format as described below:
{
"wine_name": "...",
"winery": "...",
"vintage": "...",
"region": "...",
"country": "...",
"wine_type": "...",
"grape_varietal": "...",
"tasting_notes": "...",
"wine_price_min": "...",
"wine_price_max": "...",
"occasion": "...",
"food_to_be_paired_with_wine": "..."
}
Here are some example:
</you should then respond to the user with>
<you should only respond in JSON format as described below>
"wine_name": "...",
"winery": "...",
"vintage": "...",
"region": "...",
"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",
@@ -417,10 +408,8 @@ function extractWineAttributes_1(a::T1, input::T2; maxattempt=10
"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",
@@ -433,171 +422,71 @@ function extractWineAttributes_1(a::T1, input::T2; maxattempt=10
"wine_price_max": "N/A",
"occasion": "N/A",
"food_to_be_paired_with_wine": "N/A"
}
Let's begin!
</here are some examples>
"""
requiredKeys = ["wine_name", "winery", "vintage", "region", "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>
"""
requiredKeys = [:wine_name, :winery, :vintage, :region, :country, :wine_type, :grape_varietal, :tasting_notes, :wine_price_min, :wine_price_max, :occasion, :food_to_be_paired_with_wine]
errornote = "N/A"
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
usermsg =
"""
$input
"""
context =
"""
<context>
P.S. $errornote
</context>
/no_think
"""
response = a.context.text2textInstructLLM(a.id, msg)
response = GeneralUtils.clean_json_response(response)
println("\n--- extractWineAttributes_1-1()")
println(response)
println("--- \n")
unformatPrompt =
[
Dict("name"=> "system", "text"=> systemmsg),
Dict("name"=> "user", "text"=> usermsg)
]
# put in model format
prompt = GeneralUtils.formatLLMtext(unformatPrompt, a.llmFormatName)
# add info
prompt = prompt * context
response = a.context.text2textInstructLLM(prompt; modelsize="medium", senderId=a.id)
response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName)
response = GeneralUtils.remove_french_accents(response)
think, response = GeneralUtils.extractthink(response)
responsedict = nothing
try
responsedict = copy(JSON.parsefile(response))
_responsedict = JSON.parse(response)
responsedict = GeneralUtils.dictify(_responsedict, keytype=String)
catch
println("\nERROR YiemAgent extractWineAttributes_1() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
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
_responsedictKey = keys(responsedict)
responsedictKey = [i for i in _responsedictKey] # convert into a list
is_requiredKeys_in_responsedictKey = [i responsedictKey for i in requiredKeys]
if length(is_requiredKeys_in_responsedictKey) > length(requiredKeys)
errornote = "Your previous attempt has more key points than answer's required key points."
println("\nERROR YiemAgent extractWineAttributes_1() $errornote --> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
continue
elseif !all(is_requiredKeys_in_responsedictKey)
zeroind = findall(x -> x == 0, is_requiredKeys_in_responsedictKey)
missingkeys = [requiredKeys[i] for i in zeroind]
errornote = "$missingkeys are missing from your previous response"
println("\nERROR YiemAgent extractWineAttributes_1() $errornote --> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
ispass, errormsg = 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
# # check whether response has all header
# detected_kw = GeneralUtils.detect_keyword(header, response)
# kwvalue = [i for i in values(detected_kw)]
# zeroind = findall(x -> x == 0, kwvalue)
# missingkeys = [header[i] for i in zeroind]
# if 0 ∈ values(detected_kw)
# errornote = "$missingkeys are missing from your previous response"
# println("\nERROR YiemAgent decisionMaker() $errornote:\n$response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# continue
# elseif sum(values(detected_kw)) > length(header)
# errornote = "Your previous attempt has duplicated points"
# println("\nERROR YiemAgent decisionMaker() $errornote:\n$response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# continue
# end
# # check whether response has all answer's key points
# detected_kw = GeneralUtils.detect_keyword(header, response)
# if 0 ∈ values(detected_kw)
# errornote = "In your previous attempts, the response does not have all answer's key points"
# println("\nYiemAgent extractWineAttributes_1() Attempt $attempt $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# continue
# elseif sum(values(detected_kw)) > length(header)
# errornote = "In your previous attempts, the response has duplicated answer's key points"
# println("\nYiemAgent extractWineAttributes_1() Attempt $attempt $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# println(response)
# continue
# end
# responsedict = GeneralUtils.textToDict(response, header;
# dictKey=dictkey, symbolkey=true)
removekeys = [:thought, :tasting_notes, :occasion, :food_to_be_paired_with_wine, :vintage]
removekeys = ["thought", "tasting_notes", "occasion", "food_to_be_paired_with_wine", "vintage"]
for i in removekeys
delete!(responsedict, i)
end
delete!(responsedict, :thought)
delete!(responsedict, :tasting_notes)
delete!(responsedict, :occasion)
delete!(responsedict, :food_to_be_paired_with_wine)
delete!(responsedict, :vintage)
# check if winery, wine_name, region, country, wine_type, grape_varietal's value are in the query because sometime AI halucinates
checkFlag = false
for i in requiredKeys
j = Symbol(i)
if j removekeys
# in case j is wine_price it needs to be checked differently because its value is ranged
if j == :wine_price
if responsedict["wine_price"] != "N/A"
if !occursin("to", responsedict["wine_price"])
errornote = "In your previous attempt, the 'wine_price' was set to $(responsedict["wine_price"]) which is not a correct format. Please adjust it accordingly."
println("\nERROR YiemAgent extractWineAttributes_1() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
checkFlag = true
break
end
# # check whether max wine_price is in the input
# pricerange = split(responsedict["wine_price"], '-')
# minprice = pricerange[1]
# maxprice = pricerange[end]
# if !occursin(maxprice, input)
# responsedict["wine_price"] = "N/A"
# end
# # price range like 100-100 is not good
# if minprice == maxprice
# errornote = "In your previous attempt, you inputted 'wine_price' with a 'minimum' value equaling the 'maximum', which is not valid."
# println("\nERROR YiemAgent extractWineAttributes_1() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# checkFlag = true
# break
# end
end
else
content = responsedict[j]
if typeof(content) <: AbstractVector
content = strip.(content)
elseif occursin(',', content)
content = split(content, ",") # sometime AI generates multiple values e.g. "Chenin Blanc, Riesling"
content = strip.(content)
else
content = [content]
end
# for x in content #check whether price are mentioned in the input
# if !occursin("NA", responsedict[j]) && !occursin(x, input)
# errornote = "$x is not mentioned in the user query, you must only use the info from the query."
# println("ERROR YiemAgent extractWineAttributes_1() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
# checkFlag == true
# break
# end
# end
end
end
end
checkFlag == true ? continue : nothing # skip the rest code if true
# remove (some text)
for (k, v) in responsedict
_v = replace(v, r"\(.*?\)" => "")
responsedict[k] = _v
end
result = ""
for (k, v) in responsedict
# some time LLM generate text with "(some comment)". this line removes it
@@ -605,20 +494,18 @@ if responsedict["wine_price"] != "N/A"
result *= "$k: $v, "
end
end
#[PENDING] remove halucination. "highend dry white wine" --> "wine_type: white, occasion: special occasion, food_to_be_paired_with_wine: seafood, fish, country: France, Italy, USA, grape_varietal: Chardonnay, Sauvignon Blanc, Pinot Grigio\nwine_notes: citrus, green apple, floral"
result = result[1:end-2] # remove the ending ", "
println("\n--- extractWineAttributes_1-2()")
println(result)
println("--- \n")
return result
end
error("wineattributes_wordToNumber() failed to get a response")
error("extractWineAttributes_1() failed to get a response")
end
"""
# TODO
- [PENDING] "French dry white wines with medium bod" the LLM does not recognize sweetness. use LLM self questioning to solve.
- [PENDING] French Syrah, Viognier, under 100. LLM extract intensiry of 3-5. why?
- 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}
@@ -654,21 +541,25 @@ function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2<
systemmsg =
"""
As an helpful sommelier, your task is to fill out the user's preference form based on the corresponding words from the user's query.
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
The preference form requires the following information:
sweetness, acidity, tannin, intensity
You must follow the following guidelines:
1) 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.
<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.
2) Use the conversion table to convert the descriptive word level of sweetness, intensity, tannin, and acidity into a corresponding integer.
3) Do not generate other comments.
You should then respond to the user with:
- 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.
@@ -677,157 +568,133 @@ function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2<
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 only respond in JSON format as described below:
{
</you should then respond to the user with>
<you should only respond in JSON format as described below>
"sweetness_keyword": "...",
"sweetness": "...",
"sweetness_min": "...",
"sweetness_max": "...",
"acidity_keyword": "...",
"acidity": "...",
"acidity_min": "...",
"acidity_max": "...",
"tannin_keyword": "...",
"tannin": "...",
"tannin_min": "...",
"tannin_max": "...",
"intensity_keyword": "...",
"intensity": "..."
}
Here are some examples:
"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": "N/A",
"acidity_keyword": "low acidity",
"acidity": "1-2",
"tannin_keyword": "medium tannin",
"tannin": "3-4",
"intensity_keyword": "medium-bodied",
"intensity": "3-4"
}
"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": "N/A",
"acidity_keyword": "N/A",
"acidity": "N/A",
"tannin_keyword": "N/A",
"tannin": "N/A",
"intensity_keyword": "N/A",
"intensity": "N/A"
}
Let's begin!
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>
"""
requiredKeys = [:sweetness_keyword, :sweetness, :acidity_keyword, :acidity, :tannin_keyword, :tannin, :intensity_keyword, :intensity]
# header = ["Sweetness_keyword:", "Sweetness:", "Acidity_keyword:", "Acidity:", "Tannin_keyword:", "Tannin:", "Intensity_keyword:", "Intensity:"]
# dictkey = ["sweetness_keyword", "sweetness", "acidity_keyword", "acidity", "tannin_keyword", "tannin", "intensity_keyword", "intensity"]
errornote = "N/A"
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
context =
"""
$conversiontable
<query>
$input
</query>
P.S. $errornote
/no_think
"""
response = a.context.text2textInstructLLM(a.id, msg)
response = GeneralUtils.clean_json_response(response)
println("\n--- extractWineAttributes_2-1()")
println(response)
println("--- \n")
unformatPrompt =
[
Dict("name"=> "system", "text"=> systemmsg),
]
# put in model format
prompt = GeneralUtils.formatLLMtext(unformatPrompt, a.llmFormatName)
# add info
prompt = prompt * context
response = a.context.text2textInstructLLM(prompt; modelsize="medium", senderId=a.id)
response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName)
response = GeneralUtils.remove_french_accents(response)
think, response = GeneralUtils.extractthink(response)
responsedict = nothing
try
responsedict = copy(JSON.parsefile(response))
_responsedict = JSON.parse(response)
responsedict = GeneralUtils.dictify(_responsedict, keytype=String)
catch
println("\nERROR YiemAgent extractWineAttributes_2() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
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
_responsedictKey = keys(responsedict)
responsedictKey = [i for i in _responsedictKey] # convert into a list
is_requiredKeys_in_responsedictKey = [i responsedictKey for i in requiredKeys]
if length(is_requiredKeys_in_responsedictKey) > length(requiredKeys)
errornote = "Your previous attempt has more key points than answer's required key points."
println("\nERROR YiemAgent extractWineAttributes_2() $errornote --> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
ispass, errormsg = checkAgentResponse_JSON(responsedict, requiredKeys)
if !ispass
errornote = errormsg
println("\nERROR YiemAgent extractWineAttributes_2() $errornote --(not qualify response)> $responsedict", @__FILE__, ":", @__LINE__, " $(Dates.now())\n")
continue
elseif !all(is_requiredKeys_in_responsedictKey)
zeroind = findall(x -> x == 0, is_requiredKeys_in_responsedictKey)
missingkeys = [requiredKeys[i] for i in zeroind]
errornote = "$missingkeys are missing from your previous response"
println("\nERROR YiemAgent extractWineAttributes_2() $errornote --> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
continue
end
# check whether each describing keyword is in the input to prevent halucination
for i in ["sweetness", "acidity", "tannin", "intensity"]
keyword = Symbol(i * "_keyword") # e.g. sweetness_keyword
value = responsedict[keyword]
if value != "N/A" && !occursin(value, input)
errornote = "In your previous attempt, keyword $keyword: $value does not appear in the input. You must use information from the input only"
println("\nERROR YiemAgent extractWineAttributes_2() Attempt $attempt $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
continue
end
# if value == "N/A" then responsedict[i] = "N/A"
# e.g. if sweetness_keyword == "N/A" then sweetness = "N/A"
if value == "N/A"
responsedict[Symbol(i)] = "N/A"
end
end
# some time LLM not put integer range
for (k, v) in responsedict
if !occursin("keyword", string(k))
if v !== "N/A" && (!occursin('-', v) || length(v) > 5)
errornote = "WARNING: The non-range value {$k: $v} is not allowed. It should be specified in a range format, i.e. min-max."
println("\nERROR YiemAgent extractWineAttributes_2() Attempt $attempt $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())")
continue
end
end
end
# some time LLM says N/A-2. Need to convert N/A to 1
for (k, v) in responsedict
if occursin("N/A", v) && occursin("-", v)
new_v = replace(v, "N/A"=>"1")
responsedict[k] = new_v
end
end
# delete some key words from responsedict
for (k, v) in responsedict
if k [:sweetness_keyword, :acidity_keyword, :tannin_keyword, :intensity_keyword]
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
# some time LLM generate text with "(some comment)". this line removes it
if !occursin("N/A", v)
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 ", "
println("\n--- extractWineAttributes_2-2()")
println(result)
println("--- \n")
return result
end
error("wineattributes_wordToNumber() failed to get a response")
error("extractWineAttributes_2() failed to get a response")
end