diff --git a/src/YiemAgent.jl b/src/YiemAgent.jl index 11de2b9..599b859 100644 --- a/src/YiemAgent.jl +++ b/src/YiemAgent.jl @@ -14,6 +14,7 @@ module YiemAgent include("tools/getWeather.jl") include("tools/getTime.jl") + include("tools/searchWine.jl") include("tools/writeTool.jl") include("toolRegistry.jl") @@ -22,6 +23,7 @@ module YiemAgent function register_all_tools(store::toolRegistry.toolStore) registerTool(store, getWeatherTool()) registerTool(store, getTimeTool()) + registerTool(store, searchWineTool()) registerTool(store, writeToolTool()) registerTool(store, listTool(store)) return store.tools diff --git a/src/agentCore.jl b/src/agentCore.jl index 58670f6..e02ce2d 100644 --- a/src/agentCore.jl +++ b/src/agentCore.jl @@ -5,7 +5,7 @@ export yiemAgent, _agentLoop, OpenAiToUserMessage, _extractToolCalls, executeToolCallsParallel, executeToolCalls using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames, Base.Threads, NATS + DataFrames, Base.Threads, NATS, LibPQ using GeneralUtils using ..type, ..utils, ..toolRegistry @@ -379,7 +379,7 @@ function _processMessage( # call prepareContext() state = agentState(systemPrompt, nothing, tools, agentMsgHistory) agentEventSink("_processMessage 8 _state.messages length $(length(agentMsgHistory))") - preparedContext = prepareContext(state, agentEventSink) + preparedContext = prepareContext(state, agentEventSink, llmCall) agentEventSink("_processMessage 9 _state.messages length $(length(agentMsgHistory))") # Call formatMessagesForLLM() to format for LLM formattedMessages = formatMessagesForLLM(preparedContext, agentEventSink) @@ -409,6 +409,7 @@ function _processMessage( beforeToolCall, afterToolCall, parallelToolExecute ? "parallel" : "sequential", + llmCall, ) signal = abortSignal(false) @@ -1000,13 +1001,14 @@ function executePreparedToolCall( prep::preparedToolCall, signal::Union{Nothing,abortSignal}, agentEventSink, + llmCall::Union{Any,Nothing}=nothing, )::executedOutcome agentEventSink("executePreparedToolCall 1") agentEventSink("executePreparedToolCall 2") agentEventSink("executePreparedToolCall 3") try - result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink) + result = prep.tool.execute(prep.toolCall.id, prep.args, signal, agentEventSink, llmCall) agentEventSink(result.content[1].text) agentEventSink("executePreparedToolCall 4") return executedOutcome(result, false) @@ -1184,6 +1186,7 @@ function executeToolCallsSequential( signal::abortSignal, agentEventSink, )::agentToolCallBatch + llmCall = config.llmCall agentEventSink("executeToolCallsSequential 1") finalizedCalls = finalizedOutcome[] messages = toolResultMessage[] @@ -1199,8 +1202,7 @@ function executeToolCallsSequential( agentEventSink("executeToolCallsSequential 2-2") else agentEventSink("executeToolCallsSequential 3") - #XXX - executed = executePreparedToolCall(prep, signal, agentEventSink) + executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall) agentEventSink("executeToolCallsSequential 3-1") finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal, agentEventSink) @@ -1287,6 +1289,7 @@ function executeToolCallsParallel( )::agentToolCallBatch entries = Union{finalizedOutcome,Task}[] + llmCall = config.llmCall for tc in toolCalls agentEventSink(toolExecStartEvent(tc.id, tc.name, tc.arguments)) @@ -1300,7 +1303,7 @@ function executeToolCallsParallel( push!(entries, finalized) else t = Task() do - executed = executePreparedToolCall(prep, signal, agentEventSink) + executed = executePreparedToolCall(prep, signal, agentEventSink, llmCall) finalized = finalizeExecutedToolCall(context, assistantMsg, prep, executed, config, signal) agentEventSink(toolExecEndEvent(finalized.toolCall.id, finalized.toolCall.name, finalized.result, finalized.isError)) @@ -1388,6 +1391,7 @@ function executeToolCalls( agentEventSink, )::agentToolCallBatch + llmCall = config.llmCall agentEventSink("_executeToolCalls 1") hasSequential = false for tc in toolCalls @@ -1400,11 +1404,11 @@ function executeToolCalls( agentEventSink("_executeToolCalls 2") if config.toolExecution == "sequential" || hasSequential agentEventSink("_executeToolCalls 3") - return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, + return executeToolCallsSequential(context, assistantMsg, toolCalls, config, signal, agentEventSink) else agentEventSink("_executeToolCalls 4") - return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, + return executeToolCallsParallel(context, assistantMsg, toolCalls, config, signal, agentEventSink) end end diff --git a/src/tools/getTime.jl b/src/tools/getTime.jl index 748d5e5..09ea31f 100644 --- a/src/tools/getTime.jl +++ b/src/tools/getTime.jl @@ -45,7 +45,7 @@ Execute the getTime tool. Returns mock time data for the given timezone or city. """ function getTimeExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - onPartialResult) + onPartialResult, llmCall=nothing) tz = get(args, "timezone", nothing) city = get(args, "city", "") if tz !== nothing diff --git a/src/tools/getWeather.jl b/src/tools/getWeather.jl index 5cca9cd..4f73239 100644 --- a/src/tools/getWeather.jl +++ b/src/tools/getWeather.jl @@ -7,7 +7,7 @@ Execute the getWeather tool. Returns mock weather data for the given city and temperature units. """ function getWeatherExecute(toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, - agentEventSink) + agentEventSink, llmCall=nothing) agentEventSink("Getting weather...") diff --git a/src/tools/searchWine.jl b/src/tools/searchWine.jl new file mode 100644 index 0000000..ca1a8fc --- /dev/null +++ b/src/tools/searchWine.jl @@ -0,0 +1,397 @@ +using .type +using LibPQ, DataFrames, JSON, DataStructures +using Dates, Random, HTTP +using GeneralUtils + +# ── Database config — update for your environment ─────────────────────── +const DB_CONFIG = Dict{String,Any}( + "host" => "localhost", + "port" => 5432, + "dbname" => "winedb", + "user" => "postgres", + "password" => "", +) + +""" +Execute the search_wine_database! tool. + +Uses the agent's LLM to generate SQL from the free-form text query, +then executes it against the wine database and returns formatted results. +""" +function searchWineExecute( + toolCallId::String, + args::Dict{String,Any}, + signal::Union{Nothing,abortSignal}, + agentEventSink, + llmCall, +)::agentToolResult + #WORKING + search_query = get(args, "searchQuery", "")::String + + if isempty(search_query) + return agentToolResult( + [textContent("Please provide a search query for the wine database.")], + Dict{Any,Any}(), nothing, false + ) + end + + agentEventSink("searchWineExecute: query=$search_query") + + # ── SQL generation prompt ─────────────────────────────────────────── + 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. + - Text information in the database is usually stored in lower case. + If your search returns empty, try using lower case to search. + - Overly strict conditions usually yield empty results. + - Use ILIKE for case-insensitive text matching. + - Only output the SQL query — do not wrap it in backticks or add comments. + + # situation + You are a wine store database assistant. You will be given a user's + natural language search query and the database table schema. + + # objective + Generate a single SQL query to find wines matching the user's request. + + # your responsibility includes + Fulfill the objective. + + # you should respond with ONLY the SQL query string, ending with ';' + """ + + table_schema = """ + CREATE TABLE wine ( + wine_id uuid primary key default gen_random_uuid (), + 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 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 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 + ); + """ + + context = "\n\n$table_schema\n\n\n\n" + input = context * "User query: $search_query\n\nGenerate the SQL query:" + + # ── Call LLM for SQL generation ──────────────────────────────────── + max_attempts = 5 + generated_sql = nothing + + for attempt in 1:max_attempts + msg = Dict( + "messages" => [ + Dict( + "role" => "system", + "content" => [Dict("type" => "text", "text" => systemmsg)], + ), + Dict( + "role" => "user", + "content" => [Dict("type" => "text", "text" => input)], + ), + ], + "temperature" => 0.7, + ) + + llm_response = llmCall(msg) + + # Clean the response — extract SQL from potential markdown/code blocks + sql_text = _clean_sql_response(llm_response) + + # Validate it looks like SQL + if _is_valid_sql(sql_text) + generated_sql = sql_text + agentEventSink("searchWine: generated SQL (attempt $attempt)\n$sql_text") + break + else + agentEventSink("searchWine: invalid SQL attempt $attempt: $sql_text") + end + end + + if generated_sql === nothing + return agentToolResult( + [textContent("Failed to generate a valid SQL query for your search. Please try rephrasing.")], + Dict{Any,Any}("error" => "sql_generation_failed"), nothing, false + ) + end + + # ── Execute SQL ──────────────────────────────────────────────────── + try + conn = LibPQ.Connection(DB_CONFIG) + + # Ensure LIMIT to prevent large result sets + sanitized_sql = _ensure_limit(generated_sql) + agentEventSink("searchWine: executing\n$sanitized_sql") + + result = LibPQ.execute(conn, sanitized_sql) + close(conn) + + if !LibPQ.hasdata(result) + return agentToolResult( + [textContent("No wines found matching your search. Try loosening your criteria.")], + Dict{Any,Any}("count" => 0), nothing, false + ) + end + + df = DataFrame(result) + num_rows, num_cols = size(df) + + if num_cols > 30 + return agentToolResult( + [textContent("The result has more than 30 columns. Please be more specific in your search.")], + Dict{Any,Any}("error" => "too_many_columns"), nothing, false + ) + end + + # Randomly sample up to 2 rows for display if more than 2 results + display_df = df + if num_rows > 2 + idx = sample(1:num_rows, min(2, num_rows), replace=false) + display_df = df[idx, :] + end + + # Convert to vector of dicts + result_vec = GeneralUtils.dfToVectorDict(display_df) + + # Fetch bottle images if available + for d in result_vec + image_url_json_str = get(d, "image_url", nothing) + if image_url_json_str !== nothing && !isempty(string(image_url_json_str)) + try + image_url_json_obj = JSON.parse(string(image_url_json_str)) + base_url = "http://192.168.88.106:8080/" + if haskey(image_url_json_obj, "bottle") + url = base_url * string(image_url_json_obj["bottle"]) + image_data = HTTP.get(url) + image_base64_string = base64encode(image_data.body) + d["image"] = image_base64_string + end + catch + # Skip image fetch on error + end + end + end + + # Format results as readable text + result_str = _format_wine_results(display_df) + + return agentToolResult( + [textContent(result_str)], + Dict{Any,Any}( + "count" => num_rows, + "displayed" => size(display_df, 1), + ), + nothing, false + ) + + catch e + errMsg = sprint(showerror, e) + return agentToolResult( + [textContent("Database error: $errMsg")], + Dict{Any,Any}("error" => errMsg), nothing, false + ) + end +end + +""" +Extract a SQL query string from the LLM response, handling potential +markdown code blocks, extra text, or JSON wrapping. +""" +function _clean_sql_response(response)::String + text = string(response) + + # Try to extract from code block + if occursin("```", text) + extracted = GeneralUtils.extract_triple_backtick_text(text) + if !isempty(extracted) + text = extracted[1] + # Remove "sql\n" prefix if present + if startswith(text, "sql\n") || startswith(text, "SQL\n") + text = text[5:end] + end + end + end + + # Remove JSON wrapping if present + text = strip(text) + if startswith(text, "{") && occursin("action_input", text) + # Parse as JSON and extract action_input + try + parsed = JSON.parse(text) + if parsed isa Dict + text = get(parsed, "action_input", text) + end + catch + # Keep original + end + end + + # Extract SQL keywords to find the actual query + lines = split(strip(text), '\n') + sql_lines = String[] + for line in lines + stripped = strip(line) + if occursin(r"(?i)(SELECT|FROM|WHERE|JOIN|ORDER|LIMIT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)", stripped) + # Take everything from this line to the end + push!(sql_lines, line) + elseif !isempty(sql_lines) + # Continue collecting if we already found SQL + push!(sql_lines, line) + end + end + + result = join(sql_lines, "\n") + + # Ensure it ends with semicolon + result = strip(result) + if !endswith(result, ";") + result *= ";" + end + + return result +end + +""" +Check if a string looks like a valid SQL query. +""" +function _is_valid_sql(sql::String)::Bool + sql = strip(sql) + # Must start with a SQL keyword + has_sql_keyword = occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s", sql) || + occursin(r"(?i)(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP|WITH)\s*;", sql) + # Must end with semicolon + has_semicolon = endswith(sql, ";") + # Must not be too short (reject single words) + reasonable_length = length(sql) > 10 + return has_sql_keyword && has_semicolon && reasonable_length +end + +""" +Ensure the SQL query has a LIMIT clause to prevent loading excessive data. +""" +function _ensure_limit(sql::String)::String + sql = strip(sql) + if !occursin(r"(?i)LIMIT", sql) + # Remove existing semicolon, add LIMIT, re-add semicolon + if endswith(sql, ";") + sql = sql[1:end-1] + end + sql *= " ORDER BY RANDOM() LIMIT 2;" + end + return sql +end + +""" +Format wine database results as human-readable text. +""" +function _format_wine_results(df::DataFrame)::String + lines = String[] + num_rows = size(df, 1) + + for i in 1:num_rows + row = df[i, :] + push!(lines, "$(i). $(get(row, :wine_name, "Unknown")) $(get(row, :vintage, ""))") + + winery = get(row, :winery, "Unknown") + region = get(row, :region, "Unknown") + country = get(row, :country, "Unknown") + push!(lines, " Winery: $winery") + push!(lines, " Region: $region, $country") + + grape = get(row, :grape, "Unknown") + wtype = get(row, :wine_type, "Unknown") + push!(lines, " Grape: $grape") + push!(lines, " Type: $wtype") + + sweetness = get(row, :sweetness, "N/A") + intensity = get(row, :intensity, "N/A") + tannin_val = get(row, :tannin, "N/A") + acidity = get(row, :acidity, "N/A") + push!(lines, " Profile: Sweetness: $sweetness, Intensity: $intensity, Tannin: $tannin_val, Acidity: $acidity") + + tasting = get(row, :tasting_notes, nothing) + if tasting !== nothing && !isempty(string(tasting)) + tn = string(tasting) + limit = min(200, length(tn)) + push!(lines, " Notes: $(tn[1:limit])$(length(tn) > limit ? "..." : "")") + end + + price = get(row, :price, "N/A") + currency = get(row, :currency, "") + retailer = get(row, :retailer_name, "N/A") + push!(lines, " Price: $price $currency at $retailer") + push!(lines, "") + end + + return join(lines, "\n") +end + +""" +Define and return the searchWine agentTool. +""" +function searchWineTool()::agentTool + return agentTool( + name = "searchWine", + label = "Search Wine Database", + description = "Search the wine database for wines matching a free-text query. Uses the LLM to generate SQL and execute it against the database. Returns wine details including name, winery, vintage, tasting notes, and price.", + inputSchema = Dict{String,Any}( + "type" => "object", + "properties" => Dict( + "searchQuery" => Dict( + "type" => "string", + "description" => "Free-text description of the wine you're looking for, e.g., 'a light-bodied red wine from France under 50 dollars'", + ), + ), + "required" => ["searchQuery"], + ), + execute = searchWineExecute, + prepareArguments = nothing, + validateRequiredArgs = nothing, + parallelToolExecute = false, + ) +end diff --git a/src/tools/writeTool.jl b/src/tools/writeTool.jl index 8524772..6a913b3 100644 --- a/src/tools/writeTool.jl +++ b/src/tools/writeTool.jl @@ -130,7 +130,7 @@ function writeToolTool()::agentTool ), "required" => ["name", "label", "description", "inputSchema", "executeCode"] ), - execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult) -> begin + execute = (toolCallId::String, args::Dict{String,Any}, signal::Union{Nothing,abortSignal}, onPartialResult, llmCall=nothing) -> begin tool_name = get(args, "name", "")::String tool_label = get(args, "label", tool_name)::String tool_description = get(args, "description", "")::String diff --git a/src/type.jl b/src/type.jl index bc224af..0411748 100644 --- a/src/type.jl +++ b/src/type.jl @@ -358,6 +358,7 @@ struct agentContext # Snapshot of the agent's conversa systemPrompt::String # System prompt for the agent messages::Vector{agentMessage} # Conversation messages tools::Union{OrderedDict{String, agentTool}, Nothing} # Available tools keyed by name + llmCall::Union{Any, Nothing} # LLM call function (for tools that need it) end @@ -451,6 +452,7 @@ struct agentLoopConfig beforeToolCall::Union{Function, Nothing} afterToolCall::Union{Function, Nothing} toolExecution::String + llmCall::Union{Any, Nothing} # LLM call function (for tools like searchWine) end """ diff --git a/src/utils.jl b/src/utils.jl index 29ac6a2..d7aa1fd 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -109,7 +109,7 @@ prepareContext(state).messages == deepcopy(state.messages) # end ``` """ -function prepareContext(state::agentState, agentEventSink)::agentContext +function prepareContext(state::agentState, agentEventSink, llmCall=nothing)::agentContext #TODO filter tools from state.tools based on user intend in user message and tool description filteredTools = state.tools @@ -120,7 +120,7 @@ function prepareContext(state::agentState, agentEventSink)::agentContext #TODO add system prompt, adjust/modify and inject additional context into messages preparedMessages = deepcopy(state.messages) # messages that will be send to LLM - agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools) + agentCtx = agentContext(preparedSystemPrompt, preparedMessages, filteredTools, llmCall) return agentCtx end diff --git a/src_OLD/OLD_interface.jl b/src_OLD/OLD_interface.jl deleted file mode 100644 index fc74a9f..0000000 --- a/src_OLD/OLD_interface.jl +++ /dev/null @@ -1,1400 +0,0 @@ -module interface - -export addNewMessage, conversation, decisionMaker, reflector, generatechat, - generalconversation, detectWineryName, generateSituationReport - -using JSON, DataStructures, Dates, UUIDs, HTTP, Random, PrettyPrinting, Serialization, - DataFrames -using GeneralUtils -using ..type, ..util, ..llmfunction - -# ------------------------------------------------------------------------------------------------ # -# pythoncall setting # -# ------------------------------------------------------------------------------------------------ # -# Ref: https://github.com/JuliaPy/PythonCall.jl/issues/252 -# by setting the following variables, PythonCall.jl will use: -# 1. system's python and packages installed by system (via apt install) -# or 2. conda python and packages installed by conda -# if these setting are not set (comment out), PythonCall will use its own python and packages that -# installed by CondaPkg.jl (from env_preparation.jl) -# ENV["JULIA_CONDAPKG_BACKEND"] = "Null" # set condapkg backend = none -# systemPython = split(read(`which python`, String), "\n")[1] # system's python path -# ENV["JULIA_PYTHONCALL_EXE"] = systemPython # find python location with $> which python ex. raw"/root/conda/bin/python" - -# using PythonCall -# const py_agents = PythonCall.pynew() -# const py_llms = PythonCall.pynew() -# function __init__() -# # PythonCall.pycopy!(py_cv2, pyimport("cv2")) - -# # equivalent to from urllib.request import urlopen in python -# PythonCall.pycopy!(py_agents, pyimport("langchain.agents")) -# PythonCall.pycopy!(py_llms, pyimport("langchain.llms")) -# end - -# ---------------------------------------------- 100 --------------------------------------------- # - - -macro executeStringFunction(functionStr, args...) - # Parse the function string into an expression - func_expr = Meta.parse(functionStr) - - # Create a new function with the parsed expression - function_to_call = eval(Expr(:function, - Expr(:call, func_expr, args...), func_expr.args[2:end]...)) - - # Call the newly created function with the provided arguments - function_to_call(args...) -end - - - -""" Think and choose action - -# Arguments - - `config::T1` - config - - `state::T2` - a game state - -# Keyword Arguments - -# Return - - `thoughtdict::Dict` - -# Example -```jldoctest -julia> result = decisionMaker(agent) - -OrderedDict{String, Any} with 4 entries: - "plan" => "The user provided an image of a sparkling white wine (Asolo Prosecco Bella Principessa from Italy) and requested a search for similar wines in the inventory. According to store guidelines, I must st… - "action_name" => "SEARCH_WINE_DATABASE" - "action_input" => "Sparkling white wine from Italy" - "action_result" => "1) winery: Terrazze dell Etna, wine_name: Rose Brut. -``` -""" -function decisionMaker(a::T; recentevents::Integer=20, maxattempt=3 - ) where {T<:agent} - @info "YiemAgent decisionMaker() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - requiredKeys = ["plan", "action_name", "action_input"] - context = - """ - - - """ - - # add context to text of the latest message (in the front). - # use for loop because in openai format, each msg may contain both text and image. - for d in a.chathistory[end]["content"] - if d["type"] == "text" - d["text"] = context * d["text"] - break - end - end - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - """ - { - "model": "your-model.gguf", - "messages": [ ... ], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "agent_action", - "strict": true, - "schema": { - "type": "object", - "properties": { - "think": { - "type": "string", - "description": "Your step-by-step reasoning process. Explain why you are choosing this action." - }, - "action_name": { - "type": "string", - "enum": ["search_web", "getWeather", "calculate_math"], - "description": "The exact name of the tool to execute." - }, - "action_input": { - "type": "object", - "properties": { - "query": { "type": ["string", "null"], "description": "For search_web" }, - "location": { "type": ["string", "null"], "description": "For getWeather" }, - "equation": { "type": ["string", "null"], "description": "For calculate_math" } - }, - "required": ["query", "location", "equation"], - "additionalProperties": false - } - }, - "required": ["think", "action_name", "action_input"], - "additionalProperties": false - } - } - } - } - """ - - # strict output format - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict("type"=> "string"), - "action_name"=> Dict("type"=> "string"), - "action_input"=> Dict("type"=> "string"), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model"=> "gemma-4-E4B-it-UD-Q4_K_XL", - "messages"=> a.chathistory, - "temperature"=> 0.7, - "response_format"=> response_format, - ) - - for attempt in 1:maxattempt - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.remove_french_accents(response) - # think, response = GeneralUtils.extractthink(response) - - # dollar sign in Julia means string interpolation - while occursin('$', response) - response = replace(response, '$' => "USD") - end - - # responsedict = nothing - # try - # responsedict = Serde.parse_yaml(response) - # catch e - # println("\nERROR YiemAgent decisionMaker() Error: $e --(not qualify response)-> $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - # continue - # end - - # # check whether all answer's key points are in responsedict - # println("\n---") - # println(responsedict) - # println("---\n") - # 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 - - responsedict = JSON.parse(response) - - if responsedict["action_input"] == "CHAT_BOX" && - occursin("similar", responsedict["action_input"]) - - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - @info "YiemAgent decisionMaker() end " @__LINE__ - return responsedict - end - - # in case decisionMaker failed, force to use generatechat!() - responsedict = OrderedDict( - "plan"=> "N/A", - "action_name"=> "CHAT_BOX", - "action_input"=> "N/A" - ) - return responsedict -end - - - -""" Assigns a scalar value to each new child node to be used for selec- -tion and backpropagation. This value effectively quantifies the agent's progress in task completion, -serving as a heuristic to steer the search algorithm towards the most promising regions of the tree. - -# Arguments - - `state<:AbstractDict` - one of Yiem's agent - - `text2textInstructLLM::Function` - A function that handles communication to LLM service - -# Return - - `score::Integer` - -# Example -```jldoctest -julia> -``` - -# Signature -""" -function evaluator(a::T1, timeline, decisiondict, evaluateecontext - ) where {T1<:agent} - - systemmsg = - """ - - - You are a master sommelier of an online wine store. - - - - Under your supervision, a trainee sommelier is engaging with a store customer. Each time the customer speaks, the trainee will assess the situation, determine the next course of action, and pause to await your guidance before proceeding. - - - - Improve a trainee sommelier decision based on the store policy and guidelines while ensuring seamless interactions between the trainee and customers. - - - - trajectory: A conversation between your trainee and the customer that have occurred up until now - - evaluatee_context: The context that evaluatee use to make a decision - - evaluatee_decision: The decision made by the evaluatee, consists of the following elements: - "plan" is the trainee's plan - "action_name" is the name of the action taken, which can be one of the available tool name. - "action_input" is the input to the action. - - - - Use only infomation provided by the store policy and guidelines as a bedrocks for your response. - - - - The trainee's plan, action_name, and action_input must be logically consistent - - The trainee's action_input should be in a proper format as specified by the tools. - - The trainee's action name and action input should make sense. For example, if the trainee isn't finished talking, he shouldn't use the END_CONVER_GUIDELINE tool. - - - 1) trajectory_evaluation: Analyze the trajectory of a solution to answer the user's original question. - - Evaluate the correctness of each section and the overall trajectory based on the given question. - - Provide detailed reasoning and analysis, focusing on the latest thought, action, and observation. - - Incomplete trajectory are acceptable if the thoughts and actions up to that point are correct, even if the final answer isn't reached. - - Do not generate additional thoughts or actions. - 2) decision_evaluation: - - Examine how the trainee's decisions align with the store's policies and guidelines before proceeding. - 3) suggestion: Based store policy and guidelines, provide a suggestion for the immediate decision step only. - 4) approval: Can be "yes" or "no". "no" if the suggestion contradict the trainee's decision; otherwise, it is "yes". - - - - { - "trajectory_evaluation": "...", - "decision_evaluation": "...", - "suggestion": "...", - "approval": "...", - } - - - Let's begin! - """ - requiredKeys = [:trajectory_evaluation, :decision_evaluation, :approval, :suggestion] - errornote = "N/A" - - for attempt in 1:10 - evaluateecontext = replace(evaluateecontext, "" => "") - evaluateecontext = replace(evaluateecontext, "" => "") - - context = - """ - - - $timeline - - - $evaluateecontext - - - {plan: $(decisiondict["plan"]), action_name: $(decisiondict["action_name"]), action_input: $(decisiondict["action_input"])} - - P.S. $errornote - - """ - - 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; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - response = GeneralUtils.remove_french_accents(response) - # response = replace(response, '$'=>"USD") - think, response = GeneralUtils.extractthink(response) - - responsedict = nothing - try - responsedict = copy(JSON.parsefile(response)) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether all answer's key points are in responsedict - ispass, errormsg = checkAgentResponse_JSON(responsedict, requiredKeys) - if !ispass - errornote = errormsg - println("\nERROR YiemAgent evaluator() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if accepted_as_answer ∉ ["yes", "no"] # [PENDING] add errornote into the prompt - # error("generated accepted_as_answer has wrong format") - # end - - println("\nEvaluator() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - pprintln(Dict(responsedict)) - return responsedict - end - error("Evaluator failed to generate an evaluation, Response: \n$response\n<|End of error|>") -end - -""" Chat with llm. - -# Example userinput - -image_path = "test/large_image.png" -image_bytes = read(image_path) -base64_string = base64encode(image_bytes) - -# 2. Match the MIME type according to your file extension (e.g., png, jpeg) -mime_type = "image/png" -data1_uri = "data:;base64," - -# 3. Construct payload with the Data URI -message => Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Describe this image for me"), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data_uri) - ) - ] - ) - -""" -function conversation(a::sommelier; userinput::Union{Dict{String, Any}, JSON.Object{String, Any}}, - maximumMsg=50, max_think_loop::Integer=3) - - @info "YiemAgent conversation() start " @__LINE__ - userinput = GeneralUtils.dictify(userinput; keytype=String, sort_order=["text"]) - - # find text in usermsg - usertext = nothing - for (i, d) in enumerate(userinput["content"]) - if d["type"] == "text" - d["text"] = GeneralUtils.remove_french_accents(d["text"]) - usertext = d["text"] - end - end - - if usertext == "newtopic" - clearhistory(a) - return "Okay. What shall we talk about?" - else - - # add usermsg to a.chathistory but how do I handle images? - addNewMessage(a, "user", userinput; maximumMsg=maximumMsg) - - # thinking loop until AI wants to communicate with the user - loopcount = 0 - while true - loopcount += 1 - if loopcount > max_think_loop - - thoughtdict, result_raw = generatechat!(a) - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - return response_to_frontend - end - - - thoughtdict, result_raw = think(a) - - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - assistant_response = Dict{String, Any}( - "role" => "assistant", - "content" => [Dict("type" => "text", "text" => thoughtdict["action_input"]),] - ) - addNewMessage(a, "assistant", assistant_response; maximumMsg=maximumMsg) - - items_info = [] - send_item_ind = [] # index of the item being send to frontend - if haskey(a.memory["shortmem"], "items_info") - for (i, item) in enumerate(a.memory["shortmem"]["items_info"]) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)), item name: $(item["wine_name"]) " @__LINE__ - if haskey(item, "wine_name") && occursin(item["wine_name"], thoughtdict["action_input"]) - push!(items_info, deepcopy(item)) - push!(send_item_ind, i) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - end - # remove sent items - deleteat!(a.memory["shortmem"]["items_info"], send_item_ind) - @info "YiemAgent conversation() shortmem: $(length(a.memory["shortmem"]["items_info"])), items_info: $(length(items_info)) " @__LINE__ - end - - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => thoughtdict["action_input"]), - Dict( - "type" => "items_info", - "items_info" => items_info - ), - ] - ) - - """ intended message to send to frontend should have the following format. - response_to_frontend = Dict{String, Any}( - "role" => "assistant", - "content" => [ - Dict("type" => "text", "text" => "assistant_text_response"), - Dict( - "type" => "items_info", - "items_info" => [ - Dict( - "wine_name"=> "wine name 1", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - Dict( - "wine_name"=> "wine name 2", - "wine_id"=> "...", - "image"=> base64 encoded image, - ... - ), - ] - ), - ] - ) - """ - - - return response_to_frontend - else # still in action - - action_name = thoughtdict["action_name"] - action_input = thoughtdict["action_input"] - - action_call = Dict{String, Any}( - "role" => "action_call", - "content" => [Dict("type" => "text", "text" => "{action_name: $action_name, action_input: $action_input}"),] - ) - - addNewMessage(a, "action_call", action_call; maximumMsg=maximumMsg) - - action_result = thoughtdict["action_result"] - actionresult = Dict{String, Any}( - "role" => "action_result", - "content" => [Dict("type" => "text", "text" => "$action_result"),] - ) - - addNewMessage(a, "actionresult", actionresult; maximumMsg=maximumMsg) - @info "YiemAgent conversation() end think count $loopcount " @__LINE__ - end - end - end -end - - -""" -# Arguments - -# Return - -# Example -```jldoctest -julia> -``` - -""" -function think(a::T)::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - # a.memory[:recap] = generateSituationReport(a, a.context["text"2textInstructLLM]; skiprecent=0) - @info "YiemAgent think() start " @__LINE__ - thoughtdict = decisionMaker(a) - @info "YiemAgent think() 1 " @__LINE__ - @show thoughtdict - println("---\n") - - result_raw = nothing - if thoughtdict["action_name"] ∈ ["CHAT_BOX"] - - # sometime CHAT_BOX input is too short. - # if thoughtdict["action_input] < 20 character, use generatechat!() - if length(thoughtdict["action_input"]) < 20 - thoughtdict, result_raw = generatechat!(a) - else - thoughtdict["action_result"] = "Action result is the next user dialogue." - result_raw = thoughtdict["action_input"] - end - - elseif thoughtdict["action_name"] == "END_CONVER_GUIDELINE" - - thoughtdict, result_raw = end_conversation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] ∈ ["WINE_PRESENTATION_GUIDELINE"] - - thoughtdict, result_raw = wine_presentation_guideline!(a, thoughtdict) - - elseif thoughtdict["action_name"] == "SEARCH_WINE_DATABASE" - - thoughtdict, result_raw = search_wine_database!(a, thoughtdict; useSQLLLM=false) - if result_raw !== nothing && result_raw isa Vector - if haskey(a.memory["shortmem"], "items_info") - append!(a.memory["shortmem"]["items_info"], result_raw) - else - a.memory["shortmem"]["items_info"] = result_raw - end - end - - else - - error("condition is not defined ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - @info "YiemAgent think() end " @__LINE__ - # @show thoughtdict - println("---\n") - return (thoughtdict=thoughtdict, result_raw=result_raw) -end - -function chatbox!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - thoughtdict["action_result"] = "Action result is the next user dialogue." - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function end_conversation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide customer with store contact info and business hours - - Invite customer to comeback - - Business Hours: everyday 9.00-20.00 - Tel. 0863055790 - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - -function wine_presentation_guideline!(a::T, thoughtdict::AbstractDict - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - - guideline = - """ - - - Provide detailed introductions of the wines you've found to the user. - - Explain how the wine could match the user's intention and what its effects might mean for the user's experience. - - If multiple wines are available, highlight their differences and provide a comprehensive comparison of how each option aligns with the user's intention and what the potential effects of each option could mean for the user's experience. - - Provide your personal recommendation and provide a brief explanation of why you recommend it. - - People don't describe wine quality level in numbers so use convertion_table if neccessary - - 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. - - - """ - thoughtdict["action_result"] = guideline - - return (thoughtdict=thoughtdict, result_raw=nothing) -end - - -#PENDING -function generatechat!(a::T; maxattempt::Integer=10 - )::NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}} where {T<:agent} - @info "YiemAgent generatechat!() start " @__LINE__ - # lessonDict = copy(JSON.parsefile("lesson.json")) - - # lesson = - # if isempty(lessonDict) - # "" - # else - # lessons = Dict{String, Any}() - # for (k, v) in lessonDict - # lessons[k] = lessonDict[k][:lesson] - # end - - # """ - # You have attempted to help the user before and failed, either because your reasoning for the - # recommendation was incorrect or your response did not exactly match the user expectation. - # The following lesson(s) give a plan to avoid failing to help the user in the same way you - # did previously. Use them to improve your strategy to help the user. - - # Here are some lessons in JSON format: - # $(JSON.json(lessons)) - - # When providing the thought and action for the current trial, that into account these failed - # trajectories and make sure not to repeat the same mistakes and incorrect answers. - # """ - # end - - # recentevents_ind = GeneralUtils.recentElementsIndex( - # length(a.memory["events"]), recentevents; includelatest=true) - - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user immediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - # situation - You are continuing the conversation with the user. - - # your role - Your name is $(a.name). You are a helpful sommelier for website-based $(a.retailername)'s wine store. You are working under your mentor supervision. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, continuing conversation with the customer using CHAT_BOX action. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", action_name must be CHAT_BOX. - 3) "action_input", Dialogue you want to chat with the user according to your plan. - After the action is executed you gets "action_result". It is the output from the action you selected. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - chathistory = deepcopy(a.chathistory[2:end]) # use deep copy because I want to replace system msg - pushfirst!(chathistory, system_msg) - - requiredKeys = ["plan", "action_name", "action_input"] - - errornote = "N/A" - response = nothing # placeholder for show when error msg show up - - for attempt in 1:maxattempt - if attempt > 1 - println("\nYiemAgent generatechat() attempt $attempt/$maxattempt ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - response_format = Dict( - "type"=> "json_schema", - "json_schema"=> Dict( - "name"=> "user_profile", - "strict"=> true, - "schema"=> Dict( - "type"=> "object", - "properties"=> Dict( - "plan"=> Dict( - "type"=> "string", - "description" => "Based on the current situation, state a complete action plan to complete the task and rationale. Be specific.", - ), - "action_name"=> Dict( - "type"=> "string", - "description" => "action_name must be CHAT_BOX", - ), - "action_input"=> Dict( - "type"=> "string", - "description" => "Dialogue you want to chat with the user according to your plan.", - ), - ), - "required"=> ["plan", "action_name", "action_input"], - "additionalProperties"=> false - ) - ) - ) - - msg = Dict( - "model" => "gemma-4-E4B-it-UD-Q4_K_XL", - "messages" => chathistory, - "temperature" => 0.7, - "response_format"=> response_format, - ) - - response = a.context.text2textInstructLLM(a.id, msg) - response = GeneralUtils.clean_json_response(response) - response = GeneralUtils.remove_french_accents(response) - think, response = GeneralUtils.extractthink(response) - - response = strip(response) - - responsedict = nothing - if occursin(requiredKeys[2], response) - try - _responsedict = JSON.parse(response) - responsedict = GeneralUtils.dictify(_responsedict; keytype=String, sort_order=requiredKeys) - catch - println("\nERROR YiemAgent generatechat() failed to parse response: $response ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - else - println("\nERROR YiemAgent generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - 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 generatechat() $errornote --(not qualify response)> $responsedict ", @__FILE__, ":", @__LINE__, " $(Dates.now())\n") - continue - end - - # if responsedict["action_name"] ∉ ["CHAT_BOX", "SEARCH_WINE_DATABASE", "PRESENT_WINE_GUIDELINE", "END_CONVER_GUIDELINE"] - # errornote = "Your previous attempt didn't use the given functions" - # println("\nERROR YiemAgent decisionMaker() $errornote --(not qualify response)--> $(responsedict["action_name"])", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # continue - # end - - # println("\nYiem decisionMaker() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - # pprintln(responsedict) - responsedict["action_result"] = "Action result is the next user dialogue." - @info "YiemAgent generatechat!() end " @__LINE__ - return (thoughtdict=responsedict, result_raw=responsedict["action_input"]) - end - @info "YiemAgent generatechat() failed to generate a thought " @__LINE__ - error("YiemAgent generatechat() failed to generate a thought ", response) -end - - -function generatequestion(a, text2textInstructLLM::Function, timeline)::String - systemmsg = - """ - Your role: - Your name is $(a.name). You are a helpful English-speaking, website-based sommelier for $(a.retailername)'s online store currently talking with the user. - Your goal includes: - 1) Help the user select the best wines from your inventory that align with the user's preferences - 2) Thanks the user when they don't need any further assistance and invite them to comeback next time - - Your responsibility includes: - 1) From your point of view as a sommelier helping the user, ask yourself multiple questions based on the current situation - - Your responsibility does NOT includes: - 1) Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - 2) Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - 3) Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - At each round of conversation, you will be given the info: - Additional info: ... - Your recent events: latest 5 events of the situation - - You must follow the following guidelines: - - Your question should be specific, self-contained and not require any additional context. - - Once the user has chose their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - You should follow the following guidelines: - - Focus on the latest conversation - - If the user interrupts, prioritize the user - - If you don't already know, find out the user's budget - - If you don't already know, find out the type of wine the user is looking for, such as red, white, sparkling, rose, dessert, fortified - - If you don't already know, find out the occasion for which the user is buying wine - - If you don't already know, find out the characteristics of wine the user is looking for, such as tannin, sweetness, intensity, acidity - - If you don't already know, find out what food will be served with wine - - If you haven't already, introduce the wines you found in the database to the user first - - Generally speaking, your inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - All wines in your inventory are always in stock. - - Engage in conversation to indirectly investigate the customer's intention, budget and preferences before checking your inventory. - - Do not ask the user about wine's flavor e.g. floral, citrusy, nutty or some thing similar as these terms cannot be used to search the database. - - Once the user has selected their wine, ask the user if they need any further assistance. Do not offer any additional services. If the user doesn't need any further assistance, say goodbye and invite them to come back next time. - - Medium and full-bodied red wines are bad with spicy foods. - - If a customer requests information about discounts, quantity, rewards programs, promotions, delivery options, boxes, gift wrapping, packaging, or personalized messages, please inform them that they can contact our sales team at the store. - - You should then respond to the user with: - 1) Thought: State your thought about the current situation - 2) Q: "Ask yourself" at least three, but no more than five, questions about the situation from your perspective. - 3) A: Given the situation, "answer to yourself" the best you can. Do not generate any extra text after you finish answering all questions - - You must only respond in format as described below: - Q1: ... - A1: ... - Q2: ... - A2: ... - ... - - Here are some examples: - Q: What the user is looking for? - A: The user is asking for a MPV car with 7-seat - Q: What do I know? - A: The user is looking for a car with 7-seat. Our dealer sell these kind of cars - Q: What brands the user prefer? - A: I don't know. The user didn't mentioned that. Let's find out. - Q: What else do I need to know before proceeding? - A: I don't know about the user budget, car's color, and other user's preferences yet. Let's find out more about the user's preferences. - Q: I'm still lacking information regarding the user's preferences for the powertrain. I've asked the user twice already, but perhaps they're not familiar with this. What should I do. - A: I'll proceed without asking the user about the powertrain. - Q: The user is buying for her husband, should I dig in to get more information? - A: Yes, I should. So that I have better idea about the user's preferences. - Q: Why the user saying this? - A: The user does not want an SUV because it does not have sliding doors - Q: The user is asking for a cappuccino. Do I have it at my cafe? - A: No I don't have. - Q: Since I don't have a cappuccino but I have a Late, should I ask if they are okay with that? - A: Yes, I should. - Q: Are they allergic to milk? - A: Since they mentioned a cappuccino before, it seems they are not allergic to milk. - Q: Have I checked the inventory yet? - A: No. I need more information from the user including ... - Q: What else do I need to know? - A: ... - Q: Should I present my item to the user? - A: Not yet, I will need to check my inventory first. - Q: Should I check our inventory now? - A: ... - Q: What the user intend to do with the car? - A: I don't know yet. Let's ask the user. - Q: What do I have in our inventory? - A: ... - Q: Which items are within the user price range? And which items are out of the user price rance? - A: ... - Q: Do I have what the user is looking for in our stock? - A: ... - Q: Am I certain about the information I'm going to share with the user, or should I verify the information first? - A: ... - Q: What should I do? - A: ... - Q: What shouldn't I do? - A: ... - Q: what kind of car suitable for off-road trip? - A: A four-wheel drive SUV is a good choice for off-road trips. - Q: What car specification would satisfy the user's needs? - A: The user is seeking an eco-friendly vehicle that accommodates seven passengers, including seniors and children, with prioritized accessibility and efficient refueling. While electric vehicles (EVs) offer eco-friendly benefits, their long charging times make hybrid models more practical for fast refueling. Additionally, a lower ground level is essential for ease of entry/exit for seniors and children. A hybrid multi-purpose vehicle (MPV) emerges as the optimal solution, balancing sustainability, seating capacity, accessibility, and refueling efficiency. - - Let's begin! - """ - - header = ["Q1:"] - dictkey = ["q1"] - - # context = - # if length(a.memory["shortmem"]["available_wine"]) != 0 - # "Available wines you've found in your inventory so far: $(availableWineToText(a.memory["shortmem"]["available_wine"]))" - # else - # "N/A" - # end - database_search_result = a.memory["shortmem"]["db_search_result"] - - # recent_ind = GeneralUtils.recentElementsIndex(length(a.memory[:events]), recent) - # recentevents = a.memory[:events][recent_ind] - # timeline = createTimeline(recentevents; eventindex=recent_ind) - errornote = "N/A" - response = nothing # store for show when error msg show up - - # recap = - # if length(a.memory[:recap]) <= recent - # "N/A" - # else - # recapkeys = keys(a.memory[:recap]) - # recapkeys_vec = [i for i in recapkeys] - # recapkeys_vec = recapkeys_vec[1:end-recent] - # tempmem = OrderedDict() - # for (k, v) in a.memory[:recap] - # if k ∈ recapkeys_vec - # tempmem[k] = v - # end - # end - - # GeneralUtils.dictToString(tempmem) - # end - - llmkwargs=Dict( - :num_ctx => 32768, - :temperature => 0.5, - ) - - for attempt in 1:10 - if attempt > 1 - println("\nYiemAgent generatequestion() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = - """ - Additional info: $database_search_result - Your recent events: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = text2textInstructLLM(prompt; - modelsize="medium", llmkwargs=llmkwargs, senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - - # make sure generatequestion() don't have wine name that is not from retailer inventory - # check whether an agent recommend wines before checking inventory or recommend wines - # outside its inventory - # ask LLM whether there are any winery mentioned in the response - mentioned_winery = detectWineryName(a, response) - if mentioned_winery != "None" - mentioned_winery = String.(strip.(split(mentioned_winery, ","))) - - # check whether the wine is in event - isWineInEvent = false - for winename in mentioned_winery - for event in a.memory["events"] - if event["observation"] !== nothing && occursin(winename, event["observation"]) - isWineInEvent = true - break - end - end - end - - # if wine is mentioned but not in timeline or shortmem, - # then the agent is not supposed to recommend the wine - if isWineInEvent == false - errornote = "Your previous attempt mentioned wines that are not in your inventory which is not allowed." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - end - - q_number = count("Q", response) - - # check for valid response - if q_number < 1 - errornote = "Your previous attempt has too few questions." - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - # check whether "A1" is in the response, if not error. - elseif !occursin("A1:", response) - errornote = "Your previous attempt does not have A1:" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - if 0 ∈ values(detected_kw) - errornote = "\nYour previous attempt did not have all points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent generatequestion() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - response = "Q1: " * responsedict["q1"] - println("\nYiemAgent generatequestion() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return response - end - error("YiemAgent generatequestion() failed to generate a response ", response) -end - - -function generateSituationReport(a, text2textInstructLLM::Function; skiprecent::Integer=0 - )::OrderedDict - - systemmsg = - """ - You are an assistant being in the given events. - Your task is to writes a summary for each event seperately into an ongoing, interleaving series. - - At each round of conversation, you will be given the situation: - Total events: number of events you need to summarize. - Events timeline: ... - Context: ... - - You should follow the following guidelines: - - Use the word "user" and "assistant" instead of their name in the report - - You should then respond to the user with the following: - Event: a detailed summary for each event without exaggerated details. - - You must only respond in format as described below: - Event_1: ... - Event_2: ... - ... - - Here are some examples: - Event_1: The user ask me about where to buy a toy. - Event_2: I told the user to go to the store at 2nd floor. - - Event_1: The user greets the assistant by saying 'hello'. - Event_2: The assistant respond warmly and inquire about how he can assist the user. - - Let's begin! - """ - - header = ["Event_$i:" for i in eachindex(a.memory["events"])] - dictkey = lowercase.(["Event_$i" for i in eachindex(a.memory["events"])]) - - ind = GeneralUtils.nonRecentElementsIndex(length(a.memory["events"]), skiprecent) - events = a.memory["events"][ind] - timeline = createTimeline(events) - - errornote = "N/A" - response = nothing # store for show when error msg show up - for attempt in 1:10 - if attempt > 1 # use to prevent LLM generate the same respond over and over - println("\nYiemAgent generateSituationReport() attempt $attempt/10 ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - end - - usermsg = """ - Total events: $(length(events)) - Events timeline: $timeline - P.S. $errornote - """ - - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, "qwen3") - - response = text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, "qwen3") - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(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 in your previous attempt" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum(values(detected_kw)) > length(header) - errornote = "Your previous response has duplicated events" - println("\nERROR YiemAgent generateSituationReport() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - println("\ngenerateSituationReport() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - return responsedict - end - error("generateSituationReport failed to generate a response ", response) -end - - -function detectWineryName(a, text) - systemmsg = - """ - You are a sommelier of a wine store. - Your task is to identify and list any winery names mentioned in the provided text. - - At each round of conversation, you will be given the situation: - Text: a text describing the situation. - - Tips: - - Winery usually contains Château, Chateau, Domaine, Côte, Cotes, St. de, or a combination of these words. - - You should then respond to the user with: - Winery_names: A list of winery names mentioned in the text or "None" if no winery name is mentioned. - - You must only respond in format as described below: - Winery_names: ... - - Here are some examples: - Winery_names: Domaine Courbis, Chateau Lafite Rothschild, Matarromera Domaine Roulot, Château, Cotes - - Let's begin! - """ - - header = ["Winery_names:"] - dictkey = ["winery_names"] - - response = nothing # placeholder for show when error msg show up - - for attempt in 1:10 - usermsg = """ - Text: $text - """ - _prompt = - [ - Dict("name" => "system", "text" => systemmsg), - Dict("name" => "user", "text" => usermsg) - ] - - # put in model format - prompt = GeneralUtils.formatLLMtext(_prompt, a.llmFormatName) - - response = a.context.text2textInstructLLM(prompt; senderId=a.id) - response = GeneralUtils.deFormatLLMtext(response, a.llmFormatName) - think, response = GeneralUtils.extractthink(response) - println("\ndetectWineryName() ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - try pprintln(response) catch e println(response) end - - # check whether response has all header - detected_kw = GeneralUtils.detectKeywordVariation(header, response) - missingkeys = [k for (k, v) in detected_kw if v === nothing] - - if !isempty(missingkeys) - errornote = "$missingkeys are missing from your previous response" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - elseif sum([length(i) for i in values(detected_kw)]) > length(header) - errornote = "\nYour previous attempt has duplicated points according to the required response format" - println("\nERROR YiemAgent rolegenerator() $errornote ", @__FILE__, ":", @__LINE__, " $(Dates.now())") - continue - end - - responsedict = GeneralUtils.textToDict(response, header; - dictKey=dictkey, symbolkey=true) - - result = responsedict["winery_names"] - - return result - end - error("detectWineryName failed to generate a response") - end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module interface - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src_OLD/OLD_type.jl b/src_OLD/OLD_type.jl deleted file mode 100644 index d554f2a..0000000 --- a/src_OLD/OLD_type.jl +++ /dev/null @@ -1,375 +0,0 @@ -module type - -export agent, sommelier, companion, virtualcustomer, agentcontext - -using Dates, UUIDs, DataStructures, JSON, NATS -using GeneralUtils - -# ---------------------------------------------- 100 --------------------------------------------- # - - -mutable struct agentcontext - text2textInstructLLM::Function - getTextEmbedding::Function - executeSQL::Function - similarSQLVectorDB::Function - insertSQLVectorDB::Function - similarSommelierDecision::Function - insertSommelierDecision::Function - find_related_tables_for_user_question::Function - pg_conn_str::String - agentconfig::AbstractDict -end - -abstract type agent end - -mutable struct sommelier <: agent - name::String # agent name - id::String # agent id - retailername::String - retailerid::String - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context::agentcontext - llmFormatName::String -end - -""" A sommelier agent. - -# Arguments - - `context::agentcontext` - Application context containing shared functions for LLM, SQL, and vector database operations. - -# Keyword Arguments - - `name::String` - Agent's name. Default: `"Assistant"` - - `id::String` - Agent's ID. Default: generated UUID string. - - `retailername::String` - Retailer name associated with the sommelier. Default: `"retailer_name"` - - `maxHistoryMsg::Integer` - Maximum history messages. Default: `20` - - `chathistory::Vector{Dict{String, String}}` - Chat history. Default: empty vector. - - `llmFormatName::String` - LLM format name. Default: `"granite3"` - -# Return - - `sommelier`: An instantiated sommelier agent. - -# Example -```julia -julia> using YiemAgent -julia> context = agentcontext( - text2textInstructLLM, - getTextEmbedding, - executeSQL, - similarSQLVectorDB, - insertSQLVectorDB, - similarSommelierDecision, - insertSommelierDecision - ) -julia> agent = sommelier(context, name="WineExpert", id="123", retailername="MyWineShop") -``` -""" -function sommelier( - context::agentcontext, # agent functions, db connect and other context - ; - name::String= "Assistant", - id::String= string(uuid4()), - retailername::String= "not specified", - retailerid::String= "not specified", - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, Any}} = Vector{Dict{String, Any}}(), - llmFormatName::String= "granite3" - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - "winestock"=> Dict( - "description" => "A handy tool for searching wine in your inventory that match the user preferences.", - "input" => """Input is a JSON-formatted string that contains a detailed and precise search query.{\"wine type\": \"rose\", \"price\": \"max 35\", \"sweetness level\": \"sweet\", \"intensity level\": \"light bodied\", \"Tannin level\": \"low\", \"Acidity level\": \"low\"}""", - "output" => """Output are wines that match the search query in JSON format.""", - ), - ) - - """ Memory - - Chat history use openai format as follow: - - image1_path = "test/large_image.png" --- - image1_bytes = read(image1_path) | this part must be done - image1_base64_string = base64encode(image1_bytes) | in frontend - mime_type = "image/png" | not in agent code - data1_uri = "data:;base64," --- - - chathistory= [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => "You are a helpful assistant"), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => " - LLM context here... - - Do you know this wine? Just give me brief intro." - ), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ), - ] - ), - ] - - shortmem = Dict( - "1"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - "2"=> Dict("plan"=> "...", "action_name"=> "...", "action_input"=> "...", "action_result"=> "..."), - ... - ) - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}(), - "scratchpad"=> "", - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = sommelier( - name, - id, - retailername, - retailerid, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - systemmsg = - """ - # store_policy - - Generally speaking, the store inventory has some wines from France, the United States, Australia, Spain, and Italy, but you won't know exactly until you check your inventory. - - If you found wines in the store's database, they are in stock. - - You can only recommend wines that are currently in our inventory - - Before searching the database for wine, ensure you have at least the following information: 1) budget, 2) wine type, and 3) occasion. Additional details are always helpful. If the user is unsure, provide relevant information and gather insights to make reasonable inferences. - - Ask the user one question at a time. - - Once the user has selected their wine, if you haven't already, ask the user whether they need any further assistance. Do not offer any additional services. - - Only end the conversation when the user explicitly intends to do so. When ending, ensure a polite farewell and an invitation to return in the future. - - Spicy foods should be paired only with light red wines. - - We do not sell organic, sustainable, gluten-free, and sulfite-free wine. Inform the user imediately if they are looking for these types of wines. Do not sell our wines as such. - - Gift box, gift card, and custom messages are available. Inform the user to contact our sales team. - - # store_guidelines - - Greeting the customer warmly by ask them how could you help. Do not ask any other questions during this greeting. - - Customer may provide images for you to look up. - - Encourage the customer to explore different options and try new things. - - If you are unable to locate the desired item in the database after 2 attempts, it may not be available in your inventory. In such cases, inform the user that the item is unavailable and suggest an alternative instead. - - Your store carries only wine. - - Vintage 0 means non-vintage. - - Start searching the database as broadly as possible within the given information boundary to maximize the chances of finding. Avoid unnecessary parameters unless specified by the user. Refine the search subsequently. - - User usually ask for something similar. This means you should use the search term based on the profile they like. - - # situation - You are having conversation with a customer. - - # your role - Your name is $(newAgent.name). You are a helpful sommelier for website-based $(newAgent.retailername)'s wine store. - - # objective - - Establish a connection with the customer by talking to them politely and showing your enthusiasm for their wine preferences. - - Provide relevant information and guide them to select the best wines only from your store's inventory that align with their preferences. - - # your responsibility includes - - According to the store's policy and guidelines, and make an informed decision about what available_actions you need to use to achieve the objective. - - Keep the conversation with the customer going smoothly - - # your responsibility does NOT includes - - Requesting the user to place an order, make a purchase, or confirm the order. These are the job of our sales team at the store. - - Processing sales orders or engaging in any other sales-related activities. These are the job of our sales team at the store. - - Answering questions or offering additional services beyond those related to your store's wine recommendations such as discounts, quantity, rewards programs, promotions, delivery options, shipping, boxes, gift wrapping, packaging, personalized messages or something similar. These are the job of our sales team at the store. - - # you should then respond to the user with interleaving plan, action_name, action_input in JSON format - 1) "plan", Based on the current situation, state a complete action plan to complete the task and rationale. Be specific. - 2) "action_name", (Typically corresponds to the execution of the first step in your plan) Can be one of the available_actions name - 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. - - # available actions - "CHAT_BOX", which you can use to talk with the user. The input is dialogue you want to chat with the user according to your plan. - "SEARCH_WINE_DATABASE", allows you to search information about wines you want in your inventory's database. The input is strictly supported search term including: retailer_name, wine price, winery, name, vintage, region, country, type of wine, grape varietal, tasting notes, occasion, food pairing, intensity, tannin, sweetness, and acidity. - Example query 1: "Dry, full-bodied red wine from Burgundy, France. Grape varietal could be Merlot or Syrah. price 100 to 1000 USD." - Example query 2: "Red or white wine, medium tannin, price under 700 USD" - Example query 3: "white wine from Tuscany, Italy or Bordeaux, France - "WINE_PRESENTATION_GUIDELINE", which you can use to check the store guidelines about how to present wines you have found to the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - "END_CONVER_GUIDELINE", which you can use to check the store guidelines about how to end the conversation with the user. The input is "nothing" keyword. The output is the guidelines that you can follow. - """ - - system_msg = Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => systemmsg), - ] - ) - - push!(newAgent.chathistory, system_msg) - - return newAgent -end - - -mutable struct virtualcustomer <: agent - name::String # agent name - id::String # agent id - systemmsg::String # system message - tools::Dict - maxHistoryMsg::Integer # e.g. 21th and earlier messages will get summarized - chathistory::Vector{Dict{String, Any}} - memory::Dict{String, Any} - context # NamedTuple of functions - llmFormatName::String -end - -function virtualcustomer( - context, # NamedTuple of functions - ; - name::String= "Assistant", - id::String= string(uuid4()), - maxHistoryMsg::Integer= 20, - chathistory::Vector{Dict{String, String}} = Vector{Dict{String, String}}(), - llmFormatName::String= "granite3", - systemmsg::String= - """ - Your name: $name - Your sex: Female - Your role: You are a helpful assistant. - You should follow the following guidelines: - - Focus on the latest conversation. - - Your like to be short and concise. - - Let's begin! - """, - ) - - tools = Dict( # update input format - "chatbox"=> Dict( - "description" => "Useful for when you need to ask the user for more context. Do not ask the user their own question.", - "input" => """Input is a text in JSON format.{\"Q1\": \"How are you doing?\", \"Q2\": \"How may I help you?\"}""", - "output" => "" , - ), - ) - - """ Memory - Ref: Chat prompt format is openai - chathistory = [ - Dict( - "role" => "system", - "content" => [ - Dict("type" => "text", "text" => system_msg), - ] - ), - Dict( - "role" => "user", - "content" => [ - Dict("type" => "text", "text" => "Do you know this wine? Just give me brief intro."), - Dict( - "type" => "image_url", - "image_url" => Dict("url" => data1_uri) - ) - ] - ) - ] - """ - memory = Dict{String, Any}( - "shortmem"=> OrderedDict{String, Any}( - ), - "scratchpad"=> "", - "events"=> Vector{Dict{String, Any}}(), - "state"=> Dict{String, Any}( - ), - "recap"=> OrderedDict{String, Any}(), - ) - - newAgent = virtualcustomer( - name, - id, - systemmsg, - tools, - maxHistoryMsg, - chathistory, - memory, - context, - llmFormatName - ) - - return newAgent -end - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -end # module type \ No newline at end of file diff --git a/src_OLD/llmfunction.jl b/src_OLD/llmfunction.jl deleted file mode 100644 index e7c8903..0000000 --- a/src_OLD/llmfunction.jl +++ /dev/null @@ -1,1925 +0,0 @@ -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, LibPQ, NATS -using GeneralUtils, SQLLLM -using ..type, ..util - -# ---------------------------------------------- 100 --------------------------------------------- # - - -""" -Chat with a virtual wine customer for recommendations. - -Formats the input for the configured LLM model and communicates via MQTT -to receive a response tuple containing text, selection, reward, and terminal status. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with `config` containing `externalservice` - and `mqttServerInfo` keys -- `input::T2`: Text to send to the virtual wine customer LLM - -# Returns -- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of - `(response_text, select, reward, isterminal)` where `select` may be `Nothing` - -# Notes -- Requires `a.config["externalservice"]["virtualWineCustomer_1"]` with `llminfo` and `mqtttopic`. -- Requires `a.config["mqttServerInfo"]` with `broker` and `port`. -- Only supports `llama3instruct` model name (other models throw an error). -- Uses `GeneralUtils.sendReceiveMqttMsg` with a 120-second timeout. - -# TODO -- Add `recommend()` to compare wines -""" -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 conversing with a virtual wine customer AI. - -Formats the chat history with a system prompt, sends it via MQTT to a text2text instruct -LLM service, and parses the JSON response. Retries up to 5 times on failure. - -# Arguments -- `config::T1`: Configuration dictionary (subtype of `AbstractDict`) containing: - - `externalservice["text2text"]["mqtttopic"]`: MQTT topic for the LLM service - - `mqttServerInfo["broker"]`: MQTT broker address - - `mqttServerInfo["port"]`: MQTT broker port -- `input::T2`: Current sommelier message text (subtype of `AbstractString`) -- `virtualCustomerChatHistory`: Chat history vector of dictionaries with `"name"` and `"text"` keys - -# Returns -- `Union{Tuple{String, Number, Number, Bool}, Tuple{String, Nothing, Number, Bool}}`: A tuple of - `(text, select, reward, isterminal)` representing the virtual customer's response - -# Notes -- The system prompt defines the virtual customer's persona and response format. -- Chat history role names are transformed: "user" → "you", "assistant" → "sommelier". -- Uses `jsoncorrection` to fix malformed LLM JSON responses. -- Retries up to 5 times on error before throwing. -- Uses `formatLLMtext` with `"llama3instruct"` format. - -# Examples -```jldoctest -julia> result = YiemAgent.virtualWineUserChatbox(config, sommelier_msg, history) -("I'd like something under $50", nothing, 0, false) -``` -""" -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 for wines in stock. - -Executes a wine search via SQL (either through SQLLLM or direct query), optionally fetching -and base64-encoding bottle images for each result. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing `executeSQL`, - `pg_conn_str`, and `agentconfig` -- `thoughtdict::AbstractDict`: A dictionary containing `action_input` (the search query string) - -# Keyword Arguments -- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query instead of direct SQL generation - -# Returns -- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}`: A named tuple with: - - `thoughtdict`: The input thoughtdict with `action_result` populated - - `result_raw`: Vector of wine dictionaries with image data, or `nothing` - -# Notes -- When `useSQLLLM=false`, performs hard SQL filtering followed by vector search. -- Fetches bottle images from `http://192.168.88.106:8080/` and encodes as base64. -- Uses `wine_search_term_classification` to extract search conditions. -- Requires PostgreSQL connection info from `a.context.agentconfig["externalservice"]["sommpanion_db"]`. - -# Examples -```jldoctest -julia> thoughtdict = OrderedDict("action_input" => "red wine under 50"); -julia> result = YiemAgent.search_wine_database!(agent, thoughtdict) -(thoughtdict=OrderedDict{String, Any}(...), result_raw=[Dict(...), ...]) -``` -""" -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 - - -""" -Generate an SQL query from a natural language search term. - -Uses an LLM to generate SQL based on the database schema relevant to the search term, -with validation and retry logic. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing: - - `find_related_tables_for_user_question`: Function to find relevant database tables - - `pg_conn_str`: PostgreSQL connection string - - `text2textInstructLLM`: LLM function for text generation -- `searchterm::String`: Natural language search term to convert to SQL - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid SQL response from the LLM - -# Returns -- `String`: A valid SQL query string - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model for SQL generation. -- Dynamically fetches only relevant table schemas for the search term. -- Validates LLM response contains required keys: "plan", "action_name", "action_input". -- `action_name` must be "RUNSQL"; `action_input` must not contain "RUNSQL". -- Strips triple backticks and extracts SQL from fenced code blocks. -- Throws on failure after `maxattempt` retries. - -# Examples -```jldoctest -julia> sql = YiemAgent.generatesql(agent, "red wine under 50") -"SELECT ... WHERE w.wine_type = 'red' AND rw.price < 50;" -``` -""" -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 = - """ - - - $table_schema - - - """ - 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"]) -""" -""" -Classify a wine search term into hard SQL conditions and vector search words. - -Uses an LLM to extract database column conditions from a natural language search term, -then classifies them into hard conditions (for SQL WHERE clauses) and vector search -entries (for approximate matching). - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) with context containing: - - `find_related_tables_for_user_question`: Function to find relevant tables - - `pg_conn_str`: PostgreSQL connection string - - `text2textInstructLLM`: LLM function for text generation -- `searchterm::String`: Natural language search term to classify - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid classification from the LLM - -# Returns -- `NamedTuple{(:hard_conditions, :vector_search), Tuple{Vector{JSON.Object{String, Any}}, Vector{JSON.Object{String, Any}}}}`: - - `hard_conditions`: Entries with standard operators (=, <>, !=, >, <, >=, <=) for SQL WHERE clauses - - `vector_search`: Entries with non-standard operators (LIKE, IN, IS NULL, etc.) for vector search - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with JSON schema response format. -- Applies fuzzy correction for "fuzzy_correction" bucket columns via `resolve_entity`. -- Uses `classify_column` to determine the column type bucket. -- Uses `harvest_entity_catalog` and `resolve_entity` (threshold=0.9) for fuzzy matching. - -# Examples -```jldoctest -julia> result = YiemAgent.wine_search_term_classification(agent, "dry red wine under 30") -(hard_conditions=[...], vector_search=[...]) -``` -""" -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 - - 4-wheel drive car with red color that will give me fast and furious emotion. No more than 7000 USD - - - { - "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" - } - } - - """ - - - # 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 = - """ - - - $table_schema - - - """ - 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 - -""" -Build a SQL query from pre-classified wine search conditions. - -Constructs a JOIN query across `wine`, `retailer_wine`, and `retailer` tables with -dynamic WHERE clauses based on the provided conditions. - -# Arguments -- `conditions::Vector{JSON.Object{String, Any}}`: Vector of condition objects, each containing: - - `table_name`: One of "wine", "retailer_wine" (other tables are skipped) - - `column_name`: The column to filter on - - `operator`: SQL comparison operator (=, <>, !=, >, <, >=, <=) - - `value`: The value to compare against (number or string) - -# Returns -- `String`: A complete SQL query with WHERE clause - -# Notes -- Supports table aliases: "wine" → "w", "retailer_wine" → "rw" -- Automatically handles numeric vs string value types in SQL formatting -- Strings are single-quote escaped (replaces "'" with "''") -- Returns a query with no WHERE clause if conditions vector is empty - -# Examples -```jldoctest -julia> cond = [JSON.Object{String, Any}("table_name"=>"wine", "column_name"=>"wine_type", "operator"=>"=", "value"=>"red")]; -julia> YiemAgent.predefined_wine_search_sql(cond) -"SELECT ... FROM wine AS w JOIN ... WHERE w.wine_type = 'red';" -``` -""" -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 - -""" -Execute a SQL query against the database and return formatted results. - -Adds `ORDER BY RANDOM() LIMIT 2` for non-LIMITed queries, removes `DISTINCT`, and returns -either a formatted string result or the DataFrame. - -# Arguments -- `executeSQL::Function`: A function that executes SQL and returns results (e.g., PostgreSQL connection) -- `sql::T`: The SQL query string to execute (subtype of `AbstractString`) - -# Returns -- `NamedTuple{(:result_str, :result_raw, :success, :errormsg)}`: A named tuple with: - - `result_str::Union{String, Nothing}`: Formatted string representation of results - - `result_raw::Union{DataFrame, Nothing}`: The DataFrame result, or `nothing` - - `success::Bool`: Whether the query executed successfully - - `errormsg::Union{String, Nothing}`: Error message if failed, or `nothing` - -# Notes -- Removes `DISTINCT` keyword before execution (incompatible with `RANDOM()`) -- Appends `ORDER BY RANDOM() LIMIT 2` if query doesn't have `LIMIT` and ends with `;` -- Returns "No records found" message if zero rows -- Returns column count warning if more than 30 columns -- Randomly samples 2 rows if result has more than 2 rows - -# Examples -```jldoctest -julia> result = YiemAgent.SQLexecution(execute_fn, "SELECT * FROM wine;") -(result_str="...", result_raw=DataFrame(...), success=true, errormsg=nothing) -``` -""" -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 - -""" -DEPRECATED: Search for wines in stock (legacy implementation). - -Use `search_wine_database!` instead. This function uses the older approach with -`extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. - -# Arguments -- `a::T`: An agent instance (subtype of `agent`) -- `thoughtdict::AbstractDict`: Dictionary containing `action_input` (search query) - -# Keyword Arguments -- `useSQLLLM::Bool=false`: Whether to use SQLLLM for the query - -# Returns -- `NamedTuple{(:thoughtdict, :result_raw), Tuple{OrderedDict, Any}}` - -# Notes -- DEPRECATED: Use `search_wine_database!` for the current implementation. -- Calls `extractWineAttributes_1` and `extractWineAttributes_2` for attribute extraction. -""" -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 - -""" -Extract wine attributes from a user's search query. - -Uses an LLM to parse natural language input into structured wine attributes including -name, winery, vintage, country, type, grape, price range, occasion, and food pairing. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with context containing: - - `text2textInstructLLM`: LLM function for text generation - - `pg_conn_str`: PostgreSQL connection string - - `id`: Agent identifier -- `input::T2`: User's search query string (subtype of `AbstractString`) - -# Keyword Arguments -- `maxattempt::Int=10`: Maximum number of attempts to get a valid response from the LLM - -# Returns -- `String`: Comma-separated list of extracted attributes in the format `"key: value, key: value"` - Attributes with "N/A", empty, or "none" values are excluded. - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. -- Extracts: wine_name, winery, vintage, country, wine_type, grape_varietal, tasting_notes, - wine_price_min, wine_price_max, occasion, food_to_be_paired_with_wine -- Validates response contains all required keys via `checkAgentResponse_JSON`. -- Applies fuzzy entity resolution via `harvest_entity_catalog` and `resolve_entity` (threshold=0.9). -- Strips "(some comment)" patterns from values. -- Removes keys: thought, tasting_notes, occasion, food_to_be_paired_with_wine, vintage from final output. - -# Examples -```jldoctest -julia> YiemAgent.extractWineAttributes_1(agent, "red wine from Napa under 50") -"wine_name:N/A, winery:N/A, vintage:N/A, country:United States, wine_type:red, grape_varietal:N/A, wine_price_min:0, wine_price_max:50" -``` -""" -function extractWineAttributes_1(a::T1, input::T2; maxattempt=10 - )::String where {T1<:agent, T2<:AbstractString} - - systemmsg = - """ - - At each round of conversation, the user provides the following: - - The query: the query provided by the user. - - - Extract information from the user's query as much as possible according to wine attributes extraction guidelines to fill out user's preference form. - - - Fulfill the objective. - - - - 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_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. - - - "wine_name": "...", - "winery": "...", - "vintage": "...", - "country": "...", - "wine_type": "...", - "grape_varietal": "...", - "tasting_notes": "...", - "wine_price_min": "...", - "wine_price_max": "...", - "occasion": "...", - "food_to_be_paired_with_wine": "..." - - - 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" - - """ - 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 = - """ - - $errornote - - """ - - 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 - -""" -Extract wine intensity, sweetness, tannin, and acidity attributes from a query. - -Uses an LLM with a conversion table to map descriptive words (e.g., "medium-bodied", "low acidity") -to integer ranges on a 1-5 scale. - -# Arguments -- `a::T1`: An agent instance (subtype of `agent`) with context containing: - - `text2textInstructLLM`: LLM function for text generation - - `id`: Agent identifier -- `input::T2`: User's query string containing descriptive wine preferences - (subtype of `AbstractString`) - -# Returns -- `String`: Comma-separated list of extracted attributes in the format - `"key: value, key: value"`. Only includes numeric values or non-N/A strings. - -# Notes -- Uses `gemma-4-E4B-it-UD-Q4_K_XL` model with temperature 0.7. -- Extracts: sweetness, acidity, tannin, intensity (each with min/max values). -- Applies `remove_french_accents` to the LLM response. -- Extracts thinking via `extractthink` before JSON parsing. -- Validates response contains all required keys via `checkAgentResponse_JSON`. -- Removes keyword fields (sweetness_keyword, acidity_keyword, etc.) from final output. -- Only includes values that are numbers or non-"N/A" strings. - -# TODO -- "French dry white wines with medium bod" — the LLM does not recognize sweetness. Use LLM self-questioning to solve. -- French Syrah, Viognier, under 100 — LLM extracts intensity of 3-5. Investigate why. - -# Examples -```jldoctest -julia> YiemAgent.extractWineAttributes_2(agent, "medium-bodied, low acidity, medium tannin") -"acidity_min:1, acidity_max:2, tannin_min:3, tannin_max:4, intensity_min:3, intensity_max:4" -``` -""" -function extractWineAttributes_2(a::T1, input::T2)::String where {T1<:agent, T2<:AbstractString} - - conversiontable = - """ - - 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. - - """ - - systemmsg = - """ - - 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 - - - Fill out the user's preference form based on the corresponding words from the user's query according to the guidelines. - - - Fulfill the objective - - - - 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. - - - 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 - - - "sweetness_keyword": "...", - "sweetness_min": "...", - "sweetness_max": "...", - "acidity_keyword": "...", - "acidity_min": "...", - "acidity_max": "...", - "tannin_keyword": "...", - "tannin_min": "...", - "tannin_max": "...", - "intensity_keyword": "...", - "intensity_min": "...", - "intensity_max": "..." - - - 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" - - """ - 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 = - """ - - $conversiontable - $errornote - - """ - - 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 - - - - - - -""" -Get a simplified DDL schema for a PostgreSQL table with sample values. - -Establishes a new connection and delegates to the connection-based overload. - -# Arguments -- `pg_conn_str::String`: PostgreSQL connection string -- `table_name::String`: Name of the table to get schema for - -# Keyword Arguments -- `schema_name::String="public"`: PostgreSQL schema name - -# Returns -- `String`: Formatted DDL schema string with sample values - -# Notes -- Delegates to `get_db_table_schema_simple_with_samples(conn, table_name, ...)` after creating - a new `LibPQ.Connection`. - -# Examples -```jldoctest -julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn_str, "wine") -"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" -``` -""" -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 - -""" -Get a simplified DDL schema for a PostgreSQL table with sample values. - -Queries the PostgreSQL catalog for column metadata, fetches sample values, and builds -a DDL string with inline comments for each column. - -# Arguments -- `conn`: An active PostgreSQL connection (e.g., `LibPQ.Connection`) -- `table_name::String`: Name of the table to get schema for - -# Keyword Arguments -- `schema_name::String="public"`: PostgreSQL schema name -- `sample_count::Int=3`: Number of non-null sample values to fetch per column - -# Returns -- `String`: Formatted DDL schema string in the style of `CREATE TABLE` statements - with sample values as inline comments - -# Notes -- Queries `pg_attribute`, `pg_class`, `pg_namespace`, `pg_attrdef`, and `pg_constraint` - for column metadata, defaults, and constraints. -- Fetches up to `sample_count` non-null values per column via `json_agg`. -- Throws an error if the table is not found. - -# Examples -```jldoctest -julia> schema = YiemAgent.get_db_table_schema_simple_with_samples(conn, "wine") -"CREATE TABLE public.wine (\n wine_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n ...\n);" -``` -""" -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 \ No newline at end of file