diff --git a/src/interface/progress_spinner.cpp b/src/interface/progress_spinner.cpp index 3f23e5b..c0ae0d5 100644 --- a/src/interface/progress_spinner.cpp +++ b/src/interface/progress_spinner.cpp @@ -25,7 +25,8 @@ ProgressSpinner::ProgressSpinner() : ui(nullptr), active(false) { * @note The UserInterface pointer must remain valid for the lifetime of * the ProgressSpinner object. */ -ProgressSpinner::ProgressSpinner(UserInterface* user_interface, bool activate) : ui(user_interface), active(false) { +ProgressSpinner::ProgressSpinner(std::shared_ptr user_interface, bool activate) + : ui(user_interface), active(false) { if (activate && ui != nullptr) { start(); } @@ -58,7 +59,7 @@ void ProgressSpinner::start() { } this->active = true; - if (this->ui == nullptr || this->ui->is_running() == false) { + if (this->ui == nullptr || UserInterface::ui_running(this->ui) == false) { this->spinner_thread = std::thread([this]() { const char spinner_chars[] = {'|', '/', '-', '\\'}; int i = 0; @@ -113,7 +114,7 @@ void ProgressSpinner::stop() { * @note This method is called frequently by CURL; keep it lightweight. */ int ProgressSpinner::progress_callback_impl(curl_off_t dltotal, curl_off_t dlnow) { - if (this->ui == nullptr || !this->ui->is_running()) { + if (this->ui == nullptr || !UserInterface::ui_running(this->ui)) { const char spinner_chars[] = {'|', '/', '-', '\\'}; if (dltotal > 0) { diff --git a/src/interface/progress_spinner.hpp b/src/interface/progress_spinner.hpp index 4fb2c42..46099fc 100644 --- a/src/interface/progress_spinner.hpp +++ b/src/interface/progress_spinner.hpp @@ -20,7 +20,7 @@ class ProgressSpinner { /** * Pointer to the associated UserInterface, or nullptr for manual mode. */ - UserInterface* ui; + std::shared_ptr ui; /** * Flag indicating whether the spinner is currently active. @@ -40,7 +40,7 @@ class ProgressSpinner { public: ProgressSpinner(); - explicit ProgressSpinner(UserInterface* user_interface, bool activate = false); + explicit ProgressSpinner(std::shared_ptr user_interface, bool activate = false); ~ProgressSpinner(); diff --git a/src/interface/user_interface.cpp b/src/interface/user_interface.cpp index 7e5bece..0f6b830 100644 --- a/src/interface/user_interface.cpp +++ b/src/interface/user_interface.cpp @@ -8,7 +8,46 @@ #include #include -UserInterface* UserInterface::instance = nullptr; +std::weak_ptr UserInterface::instance; + +volatile sig_atomic_t UserInterface::resize_flag = 0; + +volatile sig_atomic_t UserInterface::interrupt_flag = 0; + +/** + * Creates a UserInterface instance with the light theme as the default color scheme. + * + * @return: Shared pointer to the newly created UserInterface, also stored in the static instance member. + * + * Delegates to create(bool) with dark_theme set to false, so use_dark_theme is initialized to the light theme. + * Any previously stored instance is replaced by the new one. + * + * Note: This overload does not call init_ncurses(). The caller must invoke init_ncurses() explicitly on the returned + * instance to set up the terminal, color pairs, and input handling. + */ +std::shared_ptr UserInterface::create() { + return create(false); +} + +/** + * Creates a UserInterface instance with a caller-specified color theme preference and registers it as the singleton + * instance. + * + * @param dark_theme: Boolean flag to select color scheme; true for dark theme, false for light theme. + * @return: Shared pointer to the newly created UserInterface, also stored in the static instance member. + * + * Allocates a new UserInterface through the constructor, passing dark_theme so that use_dark_theme is set during + * construction. Assigns the new object to instance, replacing any previously stored value. Returns the shared pointer + * to the caller. + * + * Note: This method does not call init_ncurses(). The caller must invoke init_ncurses() explicitly on the returned + * instance to set up the terminal, color pairs, and input handling. + */ +std::shared_ptr UserInterface::create(bool dark_theme) { + std::shared_ptr ui(new UserInterface(dark_theme)); + instance = ui; + return ui; +} /** * Semantic color pair identifiers for terminal UI rendering. @@ -159,13 +198,6 @@ void UserInterface::init_ncurses(void) { } if (has_colors()) { -#ifdef DEBUG - if (this->is_running() == false) { - UserInterface::print_message_noninteractive( - std::format("\t[DEBUG] Checking theme - use_dark_theme = {}\n", this->use_dark_theme)); - } -#endif - if (this->use_dark_theme == true) { this->init_dark_theme(); } else { @@ -435,7 +467,7 @@ void UserInterface::start_render_thread(void) { void UserInterface::stop_render_thread(void) { this->thread_running = false; this->cv_redraw.notify_one(); - if (this->render_thread.joinable()) { + if (this->render_thread.joinable() && this->render_thread.get_id() != std::this_thread::get_id()) { this->render_thread.join(); } } @@ -460,9 +492,14 @@ void UserInterface::render_loop(void) { // Wait for redraw request or timeout (100ms) this->cv_redraw.wait_for(lock, std::chrono::milliseconds(1500), [this] { return this->should_redraw.load(); }); + if (UserInterface::interrupt_flag) { + this->end_session(); + break; + } + // Handle resize (under lock) - if (this->resize_flag) { - this->resize_flag = 0; + if (UserInterface::resize_flag) { + UserInterface::resize_flag = 0; endwin(); refresh(); this->update_layout(); @@ -490,22 +527,15 @@ void UserInterface::render_loop(void) { /** * Constructs a UserInterface instance and initializes member variables. * - * Initializes atomic flags resize_flag and running to 0 (false/inactive). * Initializes input_cursor_x to 0 (cursor at leftmost position). * Initializes layout struct to default-constructed values (all fields zeroed/default). * Sets use_dark_theme to true, establishing dark theme as the default color scheme preference. * - * Stores a static instance pointer (UserInterface::instance = this) to enable the singleton pattern, - * allowing static methods and signal handlers to access the active UI instance. - * - * Emits debug output to verify use_dark_theme initialization state. - * * Note: This constructor performs only member initialization and singleton registration. * Full ncurses initialization (terminal setup, color pair configuration, input handling) is deferred * to init_ncurses(), which must be called explicitly after construction to establish the UI environment. */ -UserInterface::UserInterface() : resize_flag(0), running(0), input_cursor_x(0), layout({}), use_dark_theme(true) { - UserInterface::instance = this; +UserInterface::UserInterface() : input_cursor_x(0), layout({}), use_dark_theme(true) { } /** @@ -513,24 +543,16 @@ UserInterface::UserInterface() : resize_flag(0), running(0), input_cursor_x(0), * * @param dark_theme: Boolean flag to select color scheme; true for dark theme, false for light theme. * - * Initializes atomic flags resize_flag and running to 0 (false/inactive). * Initializes input_cursor_x to 0 (cursor at leftmost position). * Initializes layout struct to default-constructed values (all fields zeroed/default). * Sets use_dark_theme to the caller-provided dark_theme value, establishing the preferred * color scheme that will be applied during init_ncurses(). * - * Stores a static instance pointer (UserInterface::instance = this) to enable the singleton pattern, - * allowing static methods and signal handlers to access the active UI instance. - * - * Emits debug output to verify use_dark_theme initialization state. - * * Note: This constructor performs only member initialization and singleton registration. * Full ncurses initialization (terminal setup, color pair configuration, input handling) is deferred * to init_ncurses(), which must be called explicitly after construction to establish the UI environment. */ -UserInterface::UserInterface(bool dark_theme) - : resize_flag(0), running(0), input_cursor_x(0), layout({}), use_dark_theme(dark_theme) { - UserInterface::instance = this; +UserInterface::UserInterface(bool dark_theme) : input_cursor_x(0), layout({}), use_dark_theme(dark_theme) { } /** @@ -548,27 +570,40 @@ UserInterface::UserInterface(bool dark_theme) * Note: Should be called when the UI environment is no longer needed, typically before program termination. */ UserInterface::~UserInterface() { - this->cleanup_ncurses(); - UserInterface::instance = nullptr; + this->end_session(); } /** * Queries the current running state of the UI application. * - * @return Boolean indicating whether the application is actively running. + * @return Boolean indicating whether the rendering thread is actively running. * - * - true if running == 1 (active/running); + * - true if thread_running == 1 (active/running); * - * - false if running == 0 (inactive/stopped). + * - false if thread_running == 0 (inactive/stopped). * * Performs a thread-safe atomic read of the running flag, allowing safe concurrent * queries from multiple threads without synchronization overhead. * - * Typically used in main event loops or background threads to determine whether - * to continue processing, poll for input, or exit gracefully. + * Typically used to determine what output method to use for debug output. */ bool UserInterface::is_running() { - return this->running == 1; + return this->thread_running == 1; +} + +/** + * Checks whether a UserInterface is attached and currently running. + * + * Used throughout the application to choose between UI-based output and + * non-interactive output (stdout and stderr). Returns false when no + * UserInterface is set, so callers take the non-interactive path. + * + * @param user_interface The UserInterface to check. May be nullptr. + * + * @return true if user_interface is non-null and its UI is running, false otherwise. + */ +bool UserInterface::ui_running(const std::shared_ptr& user_interface) { + return user_interface != nullptr && user_interface->is_running(); } /** @@ -593,9 +628,7 @@ bool UserInterface::is_running() { * signal-safe function violations. */ void UserInterface::handle_resize(int sig) { - if (UserInterface::instance) { - UserInterface::instance->resize_flag = 1; - } + UserInterface::resize_flag = 1; signal(SIGWINCH, UserInterface::handle_resize); } @@ -605,30 +638,22 @@ void UserInterface::handle_resize(int sig) { * * @param sig Signal number (SIGINT); unused but required by signal handler signature. * - * Handles graceful shutdown triggered by user interrupt (Ctrl+C). Checks if the singleton - * UserInterface instance exists. If it does, performs immediate cleanup: sets the atomic - * running flag to 0 to signal the main event loop to stop, calls endwin() to restore - * terminal settings and prevent terminal corruption, and exits with EXIT_SUCCESS. + * Requests a graceful shutdown when the user presses Ctrl+C. The handler only sets the + * volatile sig_atomic_t interrupt_flag, which is the only operation guaranteed to be safe + * inside a signal handler. It does not call ncurses functions, lock mutexes, touch the + * singleton instance, or call exit(). * - * Ensures the terminal is properly restored to its original state even when the program - * terminates abnormally via interrupt. This prevents the terminal from being left in a - * curses-altered state (e.g., hidden cursor, no echo) after exit. + * The render thread checks interrupt_flag on each iteration, sets thread_running to false, + * and exits its loop. The thread then ends before the UserInterface destructor runs, which + * performs the ncurses cleanup (endwin()) and restores the terminal to its original state. * - * Provides defensive null-check of the singleton instance in case the handler is invoked - * after the UI is destroyed. - * - * Does not require signal re-registration, as SIGINT handler persists across invocations. - * Terminates the process immediately with exit(); does not return to caller. + * Because the handler does not terminate the process, destructors run normally and resources + * are released through the usual RAII path. * - * Enables clean shutdown with guaranteed terminal restoration and prevents resource leaks - * by terminating the entire process rather than returning to potentially inconsistent state. + * Does not return a value and does not exit the process. */ void UserInterface::handle_interrupt(int sig) { - if (UserInterface::instance) { - UserInterface::instance->running = 0; - endwin(); - exit(EXIT_SUCCESS); - } + UserInterface::interrupt_flag = 1; } /** @@ -636,13 +661,10 @@ void UserInterface::handle_interrupt(int sig) { * * Performs full initialization pipeline: calls init_ncurses() to set up the ncurses * library, terminal settings, and signal handlers; calls draw_ui() to render the initial - * full-terminal Unicode box border; sets the atomic running flag to 1 to signal active - * state; and calls start_render_thread() to spawn the background rendering thread. + * full-terminal Unicode box border; and calls start_render_thread() to spawn the background rendering thread. * * The rendering thread continuously redraws the UI (output area, input area, spinner) - * in response to state changes and terminal resize events (resize_flag). The running - * flag controls the render thread's event loop; setting it to 1 enables the thread to - * proceed; setting it to 0 causes the thread to exit gracefully. + * in response to state changes and terminal resize events (resize_flag). * * No parameters; operates on the current UserInterface instance state. * @@ -663,15 +685,13 @@ void UserInterface::run(void) { this->draw_ui(); - this->running = 1; - this->start_render_thread(); } /** * Gracefully terminates the UserInterface subsystem and exits the application. * - * @param exit_code Exit status code passed to exit(); typically EXIT_SUCCESS (0) or EXIT_FAILURE (1). + * @param code Exit status code passed to exit(); typically EXIT_SUCCESS (0) or EXIT_FAILURE (1). * * Performs an orderly shutdown sequence: prints a farewell message ("šŸ The session has ended."), * sleeps for 1000 milliseconds to ensure the message is visible on screen, stops the background @@ -693,16 +713,18 @@ void UserInterface::run(void) { * * Enables clean, user-facing shutdown with guaranteed terminal restoration and thread cleanup. */ -void UserInterface::end_session(int exit_code) { +void UserInterface::end_session(int code) { this->print("šŸ The session has ended."); napms(1000); this->stop_render_thread(); - this->cleanup_ncurses(); + if (!this->terminal_restored.exchange(true)) { + this->cleanup_ncurses(); + } - exit(exit_code); + std::exit(code); } /** @@ -880,7 +902,7 @@ void UserInterface::select_model(const std::vector& models, std::st if (models.size() == 0) { this->print_error("There are no available models to select from.\n"); - exit(1); + std::exit(EXIT_FAILURE); } this->request_redraw(); @@ -942,7 +964,7 @@ void UserInterface::select_model_noninteractive(const std::vector& if (models.size() == 0) { UserInterface::print_error_noninteractive("There are no available models to select from.\n"); - exit(1); + std::exit(EXIT_FAILURE); } while (read_result != 1 || index < 0 || index >= models.size()) { @@ -1022,7 +1044,7 @@ bool UserInterface::next_prompt_noninteractive(std::string* prompt) { printf("ā³ Type your prompt (q/Q to quit): "); if (!std::getline(std::cin, *prompt, '\n')) { fprintf(stderr, "Error reading user input.\n"); - exit(1); + std::exit(EXIT_FAILURE); } if (*prompt == "q" || *prompt == "Q") { diff --git a/src/interface/user_interface.hpp b/src/interface/user_interface.hpp index 0adc6e0..e0df115 100644 --- a/src/interface/user_interface.hpp +++ b/src/interface/user_interface.hpp @@ -66,10 +66,10 @@ class UserInterface { // User Interface state. - volatile sig_atomic_t resize_flag; - volatile sig_atomic_t running; + static volatile sig_atomic_t resize_flag; + static volatile sig_atomic_t interrupt_flag; // A pointer to the user interface instance. - static UserInterface* instance; + static std::weak_ptr instance; // Layout state. struct Layout layout; @@ -88,6 +88,8 @@ class UserInterface { std::atomic should_redraw{true}; std::atomic thread_running{false}; + std::atomic terminal_restored{false}; + // Spinner state. std::atomic spinner_active{false}; std::atomic spinner_frame{0}; @@ -139,15 +141,21 @@ class UserInterface { ~UserInterface(); + static std::shared_ptr create(); + + static std::shared_ptr create(bool dark_theme); + bool is_running(); + static bool ui_running(const std::shared_ptr& user_interface); + static void handle_resize(int sig); static void handle_interrupt(int sig); void run(void); - void end_session(int exit_code); + void end_session(int code = EXIT_SUCCESS); void print_args(const std::string& model, const std::string& embedding_model, const std::string& prompt, bool stream, bool rag, bool interactive, bool dark_theme, bool skip_embedding); diff --git a/src/main.cpp b/src/main.cpp index 8a2e69b..49822f9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,7 +22,7 @@ int main(int argc, const char* argv[]) { args_utils.print_help(); } - UserInterface ui(parsed_args.dark_theme); + auto ui = UserInterface::create(parsed_args.dark_theme); if (parsed_args.interactive == true) { message = std::format("\nInteractive mode debug info.\n" "---\n" @@ -39,15 +39,15 @@ int main(int argc, const char* argv[]) { UserInterface::print_message_noninteractive(message); - ui.run(); - ui.print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, - parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, parsed_args.skip_embedding); + ui->run(); + ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, + parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, parsed_args.skip_embedding); while (parsed_args.prompt.length() == 0) { - done = ui.next_prompt(&parsed_args.prompt); + done = ui->next_prompt(&parsed_args.prompt); if (done == true) { - ui.end_session(EXIT_SUCCESS); + ui->end_session(EXIT_SUCCESS); } } } else { @@ -60,33 +60,34 @@ int main(int argc, const char* argv[]) { if (parsed_args.prompt.length() == 0) { UserInterface::print_message_noninteractive("\nšŸ Empty prompt. The session has ended.\n"); - exit(0); + std::exit(EXIT_SUCCESS); } if (done == true) { UserInterface::print_message_noninteractive("\nšŸ The session has ended.\n"); - exit(0); + std::exit(EXIT_SUCCESS); } } } - CurlUtils curl(&ui); + auto curl = std::make_shared(ui); - SystemUtils system_utils(&ui); + auto system_utils = std::make_shared(ui); - QdrantClient storage_client(&ui, &curl, &system_utils); + auto storage_client = std::make_shared(ui, curl, system_utils); - struct OllamaClientConfig ollama_config {}; + auto ollama_config = std::make_shared(); - OllamaClient ollama_client(&ollama_config, &ui, &curl, &system_utils); + auto ollama_client = std::make_shared(ollama_config, ui, curl, system_utils); - EmbeddingUtils embedding_utils(&ui, &curl, &ollama_client, &storage_client); + auto embedding_utils = std::make_shared(ui, curl, ollama_client, storage_client); - struct RetrievingUtilsConfig retrieving_config {}; + auto retrieving_config = std::make_shared(); - RetrievingUtils retrieving_utils(&retrieving_config, &ui, &curl, &ollama_client, &storage_client); + auto retrieving_utils = + std::make_shared(retrieving_config, ui, curl, ollama_client, storage_client); - std::vector models = ollama_client.query_model_list(); + std::vector models = ollama_client->query_model_list(); bool force_model_selection = true; for (size_t i = 0; i < models.size(); i += 1) { if (models[i] == parsed_args.model) { @@ -97,11 +98,12 @@ int main(int argc, const char* argv[]) { if (force_model_selection == true) { message = "⚠ A Language model is not selected.\n"; if (parsed_args.interactive == true) { - ui.print_message(message); - ui.print_model_list(models); - ui.select_model(models, &parsed_args.model); - ui.print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, - parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, parsed_args.skip_embedding); + ui->print_message(message); + ui->print_model_list(models); + ui->select_model(models, &parsed_args.model); + ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, + parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, + parsed_args.skip_embedding); } else { UserInterface::print_message_noninteractive(message); UserInterface::print_model_list_noninteractive(models); @@ -122,12 +124,12 @@ int main(int argc, const char* argv[]) { if (force_embedding_model_selection == true) { message = "⚠ An Embedding model is not selected.\n"; if (parsed_args.interactive == true) { - ui.print_message(message); - ui.print_model_list(models); - ui.select_model(models, &parsed_args.embedding_model); - ui.print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, - parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, - parsed_args.skip_embedding); + ui->print_message(message); + ui->print_model_list(models); + ui->select_model(models, &parsed_args.embedding_model); + ui->print_args(parsed_args.model, parsed_args.embedding_model, parsed_args.prompt, parsed_args.stream, + parsed_args.rag, parsed_args.interactive, parsed_args.dark_theme, + parsed_args.skip_embedding); } else { UserInterface::print_message_noninteractive(message); UserInterface::print_model_list_noninteractive(models); @@ -139,42 +141,42 @@ int main(int argc, const char* argv[]) { } } - ollama_config.model = parsed_args.model; - ollama_config.embedding_model = parsed_args.embedding_model; - ollama_config.prompt = parsed_args.prompt; - ollama_config.stream = parsed_args.stream; + ollama_config->model = parsed_args.model; + ollama_config->embedding_model = parsed_args.embedding_model; + ollama_config->prompt = parsed_args.prompt; + ollama_config->stream = parsed_args.stream; if (parsed_args.skip_embedding == false) { std::vector urls = { "https://raw.githubusercontent.com/rfprod/nx-ng-starter/refs/heads/main/README.md"}; - embedding_utils.create_embedings(urls); + embedding_utils->create_embedings(urls); } if (parsed_args.rag == false) { - ollama_client.query_model(); + ollama_client->query_model(); } else { - retrieving_utils.run(); + retrieving_utils->run(); } if (parsed_args.interactive == true) { while (done == false) { - done = ui.next_prompt(&parsed_args.prompt); + done = ui->next_prompt(&parsed_args.prompt); if (done == true) { continue; } - ollama_config.prompt = parsed_args.prompt; + ollama_config->prompt = parsed_args.prompt; if (parsed_args.rag == false) { - ollama_client.query_model(); + ollama_client->query_model(); } else { - retrieving_utils.run(); + retrieving_utils->run(); } - ui.request_redraw(); + ui->request_redraw(); } - ui.end_session(EXIT_SUCCESS); + ui->end_session(EXIT_SUCCESS); } else { UserInterface::print_message_noninteractive("\nšŸ The session has ended.\n"); } - return 0; + return EXIT_SUCCESS; } diff --git a/src/ollama/ollama_client.cpp b/src/ollama/ollama_client.cpp index 4b1a728..23707d8 100644 --- a/src/ollama/ollama_client.cpp +++ b/src/ollama/ollama_client.cpp @@ -23,30 +23,53 @@ using nlohmann::json_abi_v3_11_2::json; * * @note Calls check_ollama() during construction. Exits if Ollama is not found. */ -OllamaClient::OllamaClient(OllamaClientConfig* config, UserInterface* ui, CurlUtils* curl, SystemUtils* system_utils) { +OllamaClient::OllamaClient(std::shared_ptr config, std::shared_ptr ui, + std::shared_ptr curl, std::shared_ptr system_utils) + : config(config), ui(ui), curl(curl), system_utils(system_utils) { this->urls = { generate_url : "http://localhost:11434/api/generate", tags_url : "http://localhost:11434/api/tags", embed_url : "http://localhost:11434/api/embed" }; - this->config = config; - this->curl = curl; - this->ui = ui; - this->system_utils = system_utils; - this->check_ollama(); - if (ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive("\n"); } } +/** + * Destroys the OllamaClient instance and clears the Ollama history file. + * + * Runs a shell command that overwrites ~/.ollama/history with an empty string, truncating its contents. + * Passes the return status of the shell command to system_utils->catch_error() for handling. + * + * Note: This destructor depends on system_utils remaining valid for the duration of destruction, and it relies on + * the shell to expand the ~ path. Because it runs a shell command, it will also fail silently in environments + * where system() is unavailable or where the history file does not exist. + */ OllamaClient::~OllamaClient() { int status = system("echo \"\" > ~/.ollama/history"); this->system_utils->catch_error(status); } +/** + * Retrieves a configuration value by property name as a const reference of the requested type. + * + * @tparam T: Type of the configuration value to return. Supported types are std::string for "model", "embedding_model", + * and "prompt", and bool for "stream". + * @param property_name: Name of the configuration property to look up. + * @return: Const reference to the matching member of config, valid for as long as config is alive. + * @throws std::runtime_error: If property_name is not a supported property, or if T does not match the property's type. + * + * Compares property_name against each supported property in turn. For a matching name, returns a reference to the + * corresponding config member only when T matches that member's type. The type checks are resolved at compile time + * with if constexpr, so each instantiation contains only the branch for its type. + * + * Note: If the name matches but T does not, control falls through to the remaining checks and ends at the exception. + * The returned reference refers into config, so it must not outlive the OllamaClient instance. + */ template const T& OllamaClient::get_config_value(const std::string& property_name) const { if (property_name == "model") { if constexpr (std::is_same_v) { @@ -77,6 +100,20 @@ template const T& OllamaClient::get_config_value(const std::string& template const std::string& OllamaClient::get_config_value(const std::string&) const; template const bool& OllamaClient::get_config_value(const std::string&) const; +/** + * Retrieves a URL value by property name as a const reference to a std::string. + * + * @param property_name: Name of the URL property to look up; supported values are "generate_url" and "embed_url". + * @return: Const reference to the matching URL string stored in the urls member, valid for as long as this OllamaClient + * instance is alive. + * @throws std::runtime_error: If property_name is not a supported URL property. + * + * Compares property_name against "generate_url" and then "embed_url", returning a reference to the corresponding field + * of urls on a match. + * + * Note: The returned reference refers into urls, so it must not outlive the OllamaClient instance. Unlike + * get_config_value(), this method has no type parameter because every URL property is a std::string. + */ const std::string& OllamaClient::get_url_value(const std::string& property_name) const { if (property_name == "generate_url") { return this->urls.generate_url; @@ -90,7 +127,7 @@ const std::string& OllamaClient::get_url_value(const std::string& property_name) } /** - * Verifies that the Ollama binary is available on the system. + * Verifies that the `is available on the system. * * Uses the `which ollama` command to check if Ollama is in the PATH. * - If found: displays a checkmark and continues. @@ -106,16 +143,16 @@ void OllamaClient::check_ollama() { if (system("which ollama > /dev/null 2>&1")) { message = "Ollama binary does not exist.\nInstallation instructions: " "https://docs.ollama.com/linux\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); - exit(0); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); this->ui->end_session(EXIT_FAILURE); } } else { message = "Ollama binary exists.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -157,7 +194,7 @@ void OllamaClient::process_response(std::string response) { try { json response_json = json::parse(response); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { std::string message = std ::format( "šŸ¦™ llama:\n{}\n---\n", this->trim_response(response_json["response"].get()).c_str()); UserInterface::print_message_noninteractive(message); @@ -168,9 +205,9 @@ void OllamaClient::process_response(std::string response) { } } catch (const json::exception& err) { std::string message = std::format("JSON parse error: {}\n", err.what()); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); - exit(1); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); this->ui->end_session(EXIT_FAILURE); @@ -178,9 +215,28 @@ void OllamaClient::process_response(std::string response) { } } +/** + * Parses one line of a streamed Ollama response and displays its text fragment. + * + * @param line: One JSON-encoded chunk from the streaming response, expected to contain a "response" string and a "done" + * boolean. + * + * Parses line as JSON and extracts the "response" text fragment. A static flag tracks whether this is the first chunk + * of the current response. The first chunk starts a new message entry, and later chunks are appended to it. The text is + * shown through the UI when it is running, or printed non-interactively otherwise. When the "done" field is true, the + * flag is reset so the next response starts a new entry, and the method returns. + * + * Parse failures and other json::exception errors are caught and reported through print_error_noninteractive(), + * including the error ID and the offending line. + * + * Note: When compiled with DEBUG defined, the first 100 characters of each line are logged if the UI is not running. + * The first_chunk flag is static, so it is shared by all OllamaClient instances. If a stream ends or fails to parse + * before a chunk with "done" set to true, the flag is not reset and the next response will be appended to the previous + * message. + */ void OllamaClient::process_response_stream(const std::string& line) { #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] process_response_stream called with line: {}\n", line.substr(0, 100))); } @@ -195,7 +251,7 @@ void OllamaClient::process_response_stream(const std::string& line) { bool is_streaming = !first_chunk; first_chunk = false; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(text); } else { this->ui->print_message(text, is_streaming); @@ -242,7 +298,7 @@ std::vector> OllamaClient::generate_embeddings(const std::vec std::string response = this->curl->fetch_web_content(this->urls.embed_url, post_data); #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] generate_embeddings response {}\n", response)); } @@ -270,7 +326,7 @@ std::vector> OllamaClient::generate_embeddings(const std::vec void OllamaClient::query_model(std::optional config_opt) { std::string message = std::format("šŸ’¬ user: {}\n", config_opt != std::nullopt ? config_opt->prompt.c_str() : config->prompt.c_str()); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -293,7 +349,7 @@ void OllamaClient::query_model(std::optional config_opt) { this->process_response_stream(chunk); }); message = std::format("Stream successful: {}, Status: {}\n", response.success, response.status); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -319,6 +375,7 @@ void OllamaClient::query_model(std::optional config_opt) { * Returns an empty vector if an error occurs during fetch or parsing. */ std::vector OllamaClient::query_model_list() { + UserInterface::print_message_noninteractive("running query_model_list"); std::string message{}; std::vector models; @@ -329,9 +386,9 @@ std::vector OllamaClient::query_model_list() { if (!data.contains("models") || !data["models"].is_array()) { message = "Response has no models array.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); - exit(1); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); this->ui->end_session(EXIT_FAILURE); @@ -339,7 +396,7 @@ std::vector OllamaClient::query_model_list() { } message = "šŸ“ƒ available models:\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -357,7 +414,7 @@ std::vector OllamaClient::query_model_list() { } message = std::format("{}\t ({}) \t{}\n", modified_at, size, name); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -366,9 +423,9 @@ std::vector OllamaClient::query_model_list() { UserInterface::print_message_noninteractive("\n"); } catch (const json::exception& err) { message = std::format("JSON parse error: {}\n", err.what()); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); - exit(1); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); this->ui->end_session(EXIT_FAILURE); diff --git a/src/ollama/ollama_client.hpp b/src/ollama/ollama_client.hpp index 2fef7a0..2cdb100 100644 --- a/src/ollama/ollama_client.hpp +++ b/src/ollama/ollama_client.hpp @@ -60,29 +60,30 @@ class OllamaClient { /** * Pointer to the Ollama Client configuraion. */ - OllamaClientConfig* config; + std::shared_ptr config; /** * Pointer to the UserInterface for displaying messages and errors. */ - UserInterface* ui; + std::shared_ptr ui; /** * Pointer to the CURL wrapper containing pre-configured internal logic. */ - CurlUtils* curl; + std::shared_ptr curl; /** * Pointer to the SystemUtils for processing `system()` calls. */ - SystemUtils* system_utils; + std::shared_ptr system_utils; void check_ollama(); std::string trim_response(std::string response); public: - explicit OllamaClient(OllamaClientConfig* config, UserInterface* ui, CurlUtils* curl, SystemUtils* system_utils); + explicit OllamaClient(std::shared_ptr config, std::shared_ptr ui, + std::shared_ptr curl, std::shared_ptr system_utils); ~OllamaClient(); diff --git a/src/storage/qdrant_client.cpp b/src/storage/qdrant_client.cpp index cfd9002..6448847 100644 --- a/src/storage/qdrant_client.cpp +++ b/src/storage/qdrant_client.cpp @@ -17,10 +17,9 @@ using nlohmann::json_abi_v3_11_2::json; * * @note Constructor calls check_qdrant() as a side effect, which may produce console or UI output. */ -QdrantClient::QdrantClient(UserInterface* ui, CurlUtils* curl, SystemUtils* system_utils) { - this->curl = curl; - this->ui = ui; - this->system_utils = system_utils; +QdrantClient::QdrantClient(std::shared_ptr ui, std::shared_ptr curl, + std::shared_ptr system_utils) + : ui(ui), curl(curl), system_utils(system_utils) { this->setup(); @@ -33,7 +32,7 @@ QdrantClient::QdrantClient(UserInterface* ui, CurlUtils* curl, SystemUtils* syst this->check_qdrant(); - if (ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive("\n"); } } @@ -50,7 +49,7 @@ QdrantClient::~QdrantClient() { std::string message{}; message = "šŸ›ˆ Qdrant docker container cleanup...\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -66,13 +65,28 @@ QdrantClient::~QdrantClient() { this->system_utils->catch_error(status); message = "Done.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); } } +/** + * Retrieves a URL value by property name as a const reference to a std::string. + * + * @param property_name: Name of the URL property to look up; the only supported value is "collections_url". + * @return: Const reference to the matching URL string stored in the urls member, valid for as long as this QdrantClient + * instance is alive. + * @throws std::runtime_error: If property_name is not a supported URL property. + * + * Compares property_name against "collections_url" and returns a reference to the corresponding field of urls on a + * match. + * + * Note: The returned reference refers into urls, so it must not outlive the QdrantClient instance. Because this class + * exposes only one URL property, any other name throws, which makes the lookup a single comparison rather than a loop + * or map search. + */ const std::string& QdrantClient::get_url_value(const std::string& property_name) const { if (property_name == "collections_url") { return this->urls.collections_url; @@ -102,7 +116,7 @@ void QdrantClient::setup() { status = system("docker ps | grep -q qdrant"); if (status == 0) { message = "Qdrant is already running. Skipping docker run.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -111,7 +125,7 @@ void QdrantClient::setup() { } message = "šŸ›ˆ Starting Qdrant container...\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -122,24 +136,24 @@ void QdrantClient::setup() { if (!qdrant_key || std::string(qdrant_key).empty()) { message = "QDRANT_API_KEY not set.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); } - - exit(1); } if (!qdrant_key_readonly || std::string(qdrant_key_readonly).empty()) { message = "QDRANT_API_KEY_READ_ONLY not set.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); } - - exit(1); } const std::string data_dir = std::string(std::getenv("HOME")) + "/qdrant"; @@ -158,24 +172,24 @@ void QdrantClient::setup() { if (system(docker_run_cmd.c_str()) != 0) { message = "Failed to start Qdrant container.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); } - - exit(1); } message = std::format("šŸ›ˆ Qdrant container started with data dir {}\n", data_dir.c_str()); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); } message = "šŸ›ˆ Qdrant dashboard is available at http://localhost:6333/dashboard\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -197,21 +211,21 @@ void QdrantClient::check_qdrant() { if (status == 200) { message = "Qdrant is healthy.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); } } else if (status == -1) { message = "Failed to connect to Qdrant.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); } } else { message = std::format("āš ļø Qdrant returned {}.\n", status); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -235,7 +249,7 @@ bool QdrantClient::collection_exists(const std::string& collection_name) { try { this->curl->fetch_web_content(url, std::nullopt, "GET", headers, false); message = std::format("Collection '{}' exists", collection_name); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -243,7 +257,7 @@ bool QdrantClient::collection_exists(const std::string& collection_name) { return true; } catch (...) { message = std::format("āš ļø Collection '{}' does not exist", collection_name); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -266,7 +280,7 @@ void QdrantClient::delete_collection(const std::string& collection_name) { this->curl->fetch_web_content(url, std::nullopt, "DELETE", headers); std::string message = std::format("Deleted collection '{}'\n", collection_name); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -274,7 +288,7 @@ void QdrantClient::delete_collection(const std::string& collection_name) { } catch (const HttpException& e) { std::string message = std::format("[{}] Failed to delete collection '{}': {}\n", e.get_status_code(), collection_name, e.what()); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -302,7 +316,7 @@ void QdrantClient::create_collection(const std::string& collection_name, size_t std::string message = std::format("Created collection '{}' with vector size {}\n", collection_name, vector_size); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -310,7 +324,7 @@ void QdrantClient::create_collection(const std::string& collection_name, size_t } catch (const HttpException& e) { std::string message = std::format("[{}] Failed to create collection '{}': {}\n", e.get_status_code(), collection_name, e.what()); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -335,7 +349,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto if (texts.empty() || embeddings.empty()) { message = "Cannot store: texts or embeddings are empty\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -345,7 +359,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto if (texts.size() != embeddings.size()) { message = std::format("Mismatch: {} texts but {} embeddings\n", texts.size(), embeddings.size()); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -356,7 +370,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto size_t vector_size = embeddings[0].size(); #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Checking collection '{}'\n", collection_name)); } @@ -364,7 +378,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto if (this->collection_exists(collection_name)) { #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Collection '{}' exists, deleting...\n", collection_name)); } @@ -373,7 +387,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto } #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Creating collection '{}' with vector size {}\n", collection_name, vector_size)); } @@ -394,7 +408,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto std::map headers{{"api-key", qdrant_key}}; #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] Storing {} embeddings to URL: {}\n", texts.size(), url)); } @@ -402,7 +416,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto std::string response = this->curl->fetch_web_content(url, post_data, "PUT", headers); message = std::format("Stored {} embeddings in Qdrant.\nResponse: {}\n", texts.size(), response); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); diff --git a/src/storage/qdrant_client.hpp b/src/storage/qdrant_client.hpp index 9b4de73..0d5bdc9 100644 --- a/src/storage/qdrant_client.hpp +++ b/src/storage/qdrant_client.hpp @@ -54,17 +54,17 @@ class QdrantClient { * Pointer to the UserInterface for displaying messages and errors. * Used for both CLI and interactive UI output. */ - UserInterface* ui; + std::shared_ptr ui; /** * Pointer to the SystemUtils for processing `system()` calls. */ - SystemUtils* system_utils; + std::shared_ptr system_utils; /** * A CURL wrapper containing pre-configured internal logic. */ - CurlUtils* curl; + std::shared_ptr curl; void setup(); @@ -77,7 +77,8 @@ class QdrantClient { void create_collection(const std::string& collection_name, size_t vector_size); public: - explicit QdrantClient(UserInterface* ui, CurlUtils* curl, SystemUtils* system_utils); + explicit QdrantClient(std::shared_ptr ui, std::shared_ptr curl, + std::shared_ptr system_utils); ~QdrantClient(); diff --git a/src/utils/args_utils.cpp b/src/utils/args_utils.cpp index 2358e04..539d49b 100644 --- a/src/utils/args_utils.cpp +++ b/src/utils/args_utils.cpp @@ -91,5 +91,5 @@ const char* ArgsUtils::help_text() { void ArgsUtils::print_help() { printf("%s", this->help_text()); - std::exit(0); + std::exit(EXIT_SUCCESS); } diff --git a/src/utils/curl_utils.cpp b/src/utils/curl_utils.cpp index 28838e3..cb1cbf7 100644 --- a/src/utils/curl_utils.cpp +++ b/src/utils/curl_utils.cpp @@ -13,42 +13,54 @@ * * @param ui Pointer to the UserInterface for displaying messages and progress */ -CurlUtils::CurlUtils(UserInterface* ui) { +CurlUtils::CurlUtils(std::shared_ptr ui) : ui(ui) { this->curl = nullptr; this->headers = nullptr; - this->ui = ui; this->spinner = std::make_unique(ui, false); } +/** + * Destroys the CurlUtils instance and releases its CURL resources. + * + * Calls cleanup(), which resets the curl and headers unique_ptrs and runs their + * deleters. Because the unique_ptrs also release their resources when they are + * destroyed, this call is not strictly required. It is kept so that the handle + * is released in the same way as at the end of each request. + * + * @note cleanup() also prints a completion message. This output appears when the + * object is destroyed, even if no request was made. + */ CurlUtils::~CurlUtils() { this->cleanup(); } /** - * Initializes the CURL easy handle for HTTP operations. + * Creates a new CURL easy handle and stores it in the curl unique_ptr. * - * Attempts to create a CURL easy handle via curl_easy_init(). If initialization - * fails, logs an error message to either stderr or the UI based on the UI's - * running state and terminates the program with EXIT_FAILURE. On successful - * initialization, logs a confirmation message to either the console or UI. + * Calls curl_easy_init() and assigns the result with reset(), which releases any + * previously held handle through its custom deleter (curl_easy_cleanup). If + * initialization fails, the error is reported through the UI or to stdout, and + * the process exits (non-interactive mode) or the UI session ends with + * EXIT_FAILURE. On success, a confirmation message is printed. * - * @throws Indirectly exits the program if CURL initialization fails. + * @note Because reset() releases an existing handle, calling this method + * discards any state set on the previous handle. */ void CurlUtils::initialize() { - this->curl = curl_easy_init(); + this->curl.reset(curl_easy_init()); std::string message{}; if (!this->curl) { message = "Failed to initialize CURL.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); - exit(1); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); this->ui->end_session(EXIT_FAILURE); } } else { message = "CURL is initialized.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -57,44 +69,67 @@ void CurlUtils::initialize() { } /** - * Cleans up CURL resources and frees allocated memory. + * Releases the CURL handle and the header list held by this object. + * + * Calls reset() on the curl and headers unique_ptrs, which runs their custom + * deleters (curl_easy_cleanup and curl_slist_free_all). Each resource is only + * released if it is currently held, so calling this method more than once is safe. * - * Destroys the CURL easy handle and frees the headers list. Sets the headers - * pointer to nullptr to prevent use-after-free. Logs a success message to - * either the console or UI based on the UI's running state. Typically called - * during object destruction or when shutting down HTTP operations. + * In DEBUG builds, the pointer values are logged before they are released when + * no UI is running. A success message is printed at the end, either through the + * UI or to stdout. * - * @note Assumes cleanup operations succeed without error checking. + * @note Do not call curl_easy_cleanup or curl_slist_free_all manually for these + * resources. Doing so together with the deleters causes a double free. */ void CurlUtils::cleanup() { if (this->curl) { #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( - std::format("\t[DEBUG] curl pointer before cleanup: {}\n", static_cast(this->curl))); + std::format("\t[DEBUG] curl pointer before cleanup: {}\n", static_cast(this->curl.get()))); } #endif - curl_easy_cleanup(this->curl); - this->curl = nullptr; + this->curl.reset(); } if (this->headers) { #ifdef DEBUG - if (this->ui->is_running() == false) { - UserInterface::print_message_noninteractive( - std::format("\t[DEBUG] curl headers pointer before cleanup: {}\n", static_cast(this->headers))); + if (UserInterface::ui_running(this->ui) == false) { + UserInterface::print_message_noninteractive(std::format( + "\t[DEBUG] curl headers pointer before cleanup: {}\n", static_cast(this->headers.get()))); } #endif - curl_slist_free_all(this->headers); - this->headers = nullptr; + this->headers.reset(); } std::string message = "CURL cleanup is complete.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); } } +/** + * Appends a single header line to a curl_slist that is being built. + * + * Calls curl_slist_append() and, on success, updates the caller's list pointer + * to the new head. If the append fails, the pointer is left unchanged, so the + * previously built list remains valid and is not lost. + * + * @param headers Reference to the curl_slist pointer being built. May be nullptr + * to start a new list. + * @param line The header line to append, in the form "Name: value". + * + * @note On failure the header is silently dropped. Callers that must guarantee + * every header is sent should check the list afterwards. + */ +void CurlUtils::add_header(curl_slist*& headers, const std::string& line) { + curl_slist* updated = curl_slist_append(headers, line.c_str()); + if (updated != nullptr) { + headers = updated; + } +} + /** * CURL write callback for accumulating response data. * @@ -113,13 +148,33 @@ size_t CurlUtils::curl_write_callback(char* contents, size_t size, size_t nmemb, return size * nmemb; } +/** + * CURL write callback for streaming response data. + * + * Called by CURL whenever a chunk of the response body is received. The chunk + * is copied into a std::string and passed to CurlUtils::process_chunk() on the + * CurlUtils instance stored in userp. Output for errors and debug messages goes + * through the UserInterface when it is running, and to stdout or stderr + * otherwise. + * + * @param contents Pointer to the received data chunk. Not null-terminated. + * @param size Size of each element (always 1 for this use case). + * @param nmemb Number of elements received. + * @param userp Pointer to the CurlUtils instance that owns the transfer. + * + * @return Total bytes processed (size * nmemb). Returns 0 if process_chunk() + * throws, which makes CURL abort the transfer with CURLE_WRITE_ERROR. + * + * @note This method reads curl_obj->ui directly, so it is unsafe when no + * UserInterface is set. Consider using ui_running() before each access. + */ size_t CurlUtils::curl_streaming_write_callback(char* contents, size_t size, size_t nmemb, void* userp) { size_t realsize = size * nmemb; CurlUtils* curl_obj = static_cast(userp); #ifdef DEBUG - if (curl_obj->ui->is_running() == false) { + if (UserInterface::ui_running(curl_obj->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] šŸ”— curl_streaming_write_callback called, size: {}\n", realsize)); } @@ -130,7 +185,7 @@ size_t CurlUtils::curl_streaming_write_callback(char* contents, size_t size, siz curl_obj->process_chunk(chunk); } catch (const std::exception& e) { std::string message = std::format("Exception in curl_streaming_write_callback: {}", e.what()); - if (curl_obj->ui->is_running() == false) { + if (UserInterface::ui_running(curl_obj->ui) == false) { UserInterface::print_error_noninteractive(message); } else { curl_obj->ui->print_error(message); @@ -142,9 +197,27 @@ size_t CurlUtils::curl_streaming_write_callback(char* contents, size_t size, siz return realsize; } +/** + * Buffers streamed response data and dispatches complete JSON lines. + * + * Appends the incoming chunk to stream_buffer and extracts every complete line + * (terminated by '\n'). A trailing '\r' is removed, so \r\n line endings work. + * Only lines that start with '{' are treated as JSON and passed to + * stream_callback. Other lines are skipped. Any incomplete trailing data stays + * in stream_buffer until the next chunk arrives. If no stream_callback is set, + * the line is dropped. + * + * Debug output goes to stdout only when no UserInterface is running. + * + * @param chunk The raw data received from the current CURL write callback. It + * may contain zero, one, or several complete lines, or a partial line. + * + * @note Each complete line is passed to stream_callback as a copy, so the + * callback may keep it. Lines that are not JSON objects are discarded. + */ void CurlUtils::process_chunk(const std::string& chunk) { #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive( std::format("\t[DEBUG] šŸ”„ process_chunk called with {}\n", chunk.size())); } @@ -164,7 +237,7 @@ void CurlUtils::process_chunk(const std::string& chunk) { } #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(std::format( "\t[DEBUG] šŸ”„ Extracted line: {} {}\n", line.substr(0, 80), (line.size() > 80 ? "..." : ""))); } @@ -172,7 +245,7 @@ void CurlUtils::process_chunk(const std::string& chunk) { if (line.empty() || line[0] != '{') { #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] šŸ”„ Skipping non-JSON line.\n")); } #endif @@ -182,14 +255,14 @@ void CurlUtils::process_chunk(const std::string& chunk) { // Pass only valid JSON lines to the callback. if (stream_callback) { #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] šŸ”„ Calling stream_callback.\n")); } #endif stream_callback(line); } else { #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive("āš ļø stream_callback is NULL!\n"); } #endif @@ -220,39 +293,40 @@ long CurlUtils::check_service_status(const std::string& url, const std::mapcurl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(this->curl.get(), CURLOPT_URL, url.c_str()); - curl_easy_setopt(this->curl, CURLOPT_CUSTOMREQUEST, "GET"); + curl_easy_setopt(this->curl.get(), CURLOPT_CUSTOMREQUEST, "GET"); - curl_easy_setopt(this->curl, CURLOPT_WRITEFUNCTION, + curl_easy_setopt(this->curl.get(), CURLOPT_WRITEFUNCTION, [](void*, size_t size, size_t nmemb, void*) { return size * nmemb; }); - curl_easy_setopt(this->curl, CURLOPT_TIMEOUT, timeout_seconds); - curl_easy_setopt(this->curl, CURLOPT_CONNECTTIMEOUT, timeout_seconds); - curl_easy_setopt(this->curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(this->curl.get(), CURLOPT_TIMEOUT, timeout_seconds); + curl_easy_setopt(this->curl.get(), CURLOPT_CONNECTTIMEOUT, timeout_seconds); + curl_easy_setopt(this->curl.get(), CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(this->curl, CURLOPT_NOPROGRESS, 0L); - curl_easy_setopt(this->curl, CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); - curl_easy_setopt(this->curl, CURLOPT_XFERINFODATA, this->spinner.get()); + curl_easy_setopt(this->curl.get(), CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(this->curl.get(), CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); + curl_easy_setopt(this->curl.get(), CURLOPT_XFERINFODATA, this->spinner.get()); - this->headers = curl_slist_append(this->headers, "Content-Type: application/json"); + curl_slist* new_headers = nullptr; + this->add_header(new_headers, "Content-Type: application/json"); for (const auto& [key, value] : extra_headers) { - std::string header = key + ": " + value; - this->headers = curl_slist_append(this->headers, header.c_str()); + this->add_header(new_headers, key + ": " + value); } + this->headers.reset(new_headers); - curl_easy_setopt(this->curl, CURLOPT_HTTPHEADER, this->headers); + curl_easy_setopt(this->curl.get(), CURLOPT_HTTPHEADER, this->headers.get()); - CURLcode res = curl_easy_perform(this->curl); + CURLcode res = curl_easy_perform(this->curl.get()); this->spinner->stop(); - curl_easy_getinfo(this->curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_easy_getinfo(this->curl.get(), CURLINFO_RESPONSE_CODE, &http_code); this->cleanup(); if (res != CURLE_OK) { std::string message = std::format("Curl error: {}\n", curl_easy_strerror(res)); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -283,7 +357,7 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); @@ -294,7 +368,7 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -305,7 +379,7 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -334,9 +408,9 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapinitialize(); this->spinner->start(); - curl_easy_setopt(this->curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(this->curl.get(), CURLOPT_URL, url.c_str()); if (post_data != std::nullopt) { - curl_easy_setopt(this->curl, CURLOPT_POSTFIELDS, post_data->c_str()); + curl_easy_setopt(this->curl.get(), CURLOPT_POSTFIELDS, post_data->c_str()); } if (custom_request_type != std::nullopt) { - curl_easy_setopt(this->curl, CURLOPT_CUSTOMREQUEST, custom_request_type->c_str()); + curl_easy_setopt(this->curl.get(), CURLOPT_CUSTOMREQUEST, custom_request_type->c_str()); } - curl_easy_setopt(this->curl, CURLOPT_WRITEFUNCTION, CurlUtils::curl_write_callback); - curl_easy_setopt(this->curl, CURLOPT_WRITEDATA, &content.read_buffer); - curl_easy_setopt(this->curl, CURLOPT_ERRORBUFFER, content.error_buffer); - curl_easy_setopt(this->curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(this->curl.get(), CURLOPT_WRITEFUNCTION, CurlUtils::curl_write_callback); + curl_easy_setopt(this->curl.get(), CURLOPT_WRITEDATA, &content.read_buffer); + curl_easy_setopt(this->curl.get(), CURLOPT_ERRORBUFFER, content.error_buffer); + curl_easy_setopt(this->curl.get(), CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(this->curl, CURLOPT_NOPROGRESS, 0L); - curl_easy_setopt(this->curl, CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); - curl_easy_setopt(this->curl, CURLOPT_XFERINFODATA, this->spinner.get()); + curl_easy_setopt(this->curl.get(), CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(this->curl.get(), CURLOPT_XFERINFOFUNCTION, ProgressSpinner::progress_callback); + curl_easy_setopt(this->curl.get(), CURLOPT_XFERINFODATA, this->spinner.get()); - this->headers = curl_slist_append(this->headers, "Content-Type: application/json"); + curl_slist* new_headers = nullptr; + this->add_header(new_headers, "Content-Type: application/json"); if (extra_headers != std::nullopt) { for (const auto& [key, value] : extra_headers.value()) { - std::string header = key + ": " + value; - this->headers = curl_slist_append(this->headers, header.c_str()); + this->add_header(new_headers, key + ": " + value); } } + this->headers.reset(new_headers); - curl_easy_setopt(this->curl, CURLOPT_HTTPHEADER, this->headers); + curl_easy_setopt(this->curl.get(), CURLOPT_HTTPHEADER, this->headers.get()); - CURLcode result = curl_easy_perform(this->curl); + CURLcode result = curl_easy_perform(this->curl.get()); this->spinner->stop(); - curl_easy_getinfo(this->curl, CURLINFO_RESPONSE_CODE, &content.status); + curl_easy_getinfo(this->curl.get(), CURLINFO_RESPONSE_CODE, &content.status); this->cleanup(); if (result != CURLE_OK) { message = std::format("CURL error: {}\n", curl_easy_strerror(result)); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { if (content.error_buffer[0] != '\0') { message += std::format("Details: {}\n", content.error_buffer); } UserInterface::print_error_noninteractive(message); - exit(1); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); if (content.error_buffer[0] != '\0') { @@ -407,10 +482,10 @@ std::string CurlUtils::fetch_web_content(const std::string& url, std::optionalui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); if (fail_on_http_error == true) { - exit(1); + std::exit(EXIT_FAILURE); } else { throw HttpException(content.status, url); } @@ -427,6 +502,40 @@ std::string CurlUtils::fetch_web_content(const std::string& url, std::optional post_data, std::function on_chunk, std::optional custom_request_type, @@ -440,35 +549,37 @@ WebContentStream CurlUtils::fetch_web_content_stream(const std::string& url, std this->initialize(); this->stream_buffer.clear(); - curl_easy_setopt(this->curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(this->curl.get(), CURLOPT_URL, url.c_str()); if (post_data != std::nullopt) { - curl_easy_setopt(this->curl, CURLOPT_POSTFIELDS, post_data->c_str()); + curl_easy_setopt(this->curl.get(), CURLOPT_POSTFIELDS, post_data->c_str()); } if (custom_request_type != std::nullopt) { - curl_easy_setopt(this->curl, CURLOPT_CUSTOMREQUEST, custom_request_type->c_str()); + curl_easy_setopt(this->curl.get(), CURLOPT_CUSTOMREQUEST, custom_request_type->c_str()); } - curl_easy_setopt(this->curl, CURLOPT_WRITEFUNCTION, CurlUtils::curl_streaming_write_callback); - curl_easy_setopt(this->curl, CURLOPT_WRITEDATA, static_cast(this)); - curl_easy_setopt(this->curl, CURLOPT_ERRORBUFFER, content.error_buffer); - curl_easy_setopt(this->curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(this->curl.get(), CURLOPT_WRITEFUNCTION, CurlUtils::curl_streaming_write_callback); + curl_easy_setopt(this->curl.get(), CURLOPT_WRITEDATA, static_cast(this)); + curl_easy_setopt(this->curl.get(), CURLOPT_ERRORBUFFER, content.error_buffer); + curl_easy_setopt(this->curl.get(), CURLOPT_FOLLOWLOCATION, 1L); - this->headers = curl_slist_append(this->headers, "Content-Type: application/json"); + curl_slist* new_headers = nullptr; + this->add_header(new_headers, "Content-Type: application/json"); if (extra_headers != std::nullopt) { for (const auto& [key, value] : extra_headers.value()) { - std::string header = key + ": " + value; - this->headers = curl_slist_append(this->headers, header.c_str()); + this->add_header(new_headers, key + ": " + value); } } - curl_easy_setopt(this->curl, CURLOPT_HTTPHEADER, this->headers); + this->headers.reset(new_headers); + + curl_easy_setopt(this->curl.get(), CURLOPT_HTTPHEADER, this->headers.get()); - CURLcode result = curl_easy_perform(this->curl); + CURLcode result = curl_easy_perform(this->curl.get()); - curl_easy_getinfo(this->curl, CURLINFO_RESPONSE_CODE, &content.status); + curl_easy_getinfo(this->curl.get(), CURLINFO_RESPONSE_CODE, &content.status); this->cleanup(); @@ -477,12 +588,12 @@ WebContentStream CurlUtils::fetch_web_content_stream(const std::string& url, std content.success = false; content.error_message = curl_easy_strerror(result); message = std::format("CURL error: {}\n", curl_easy_strerror(result)); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { if (content.error_buffer[0] != '\0') { message += std::format("Details: {}\n", content.error_buffer); } UserInterface::print_error_noninteractive(message); - exit(1); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); if (content.error_buffer[0] != '\0') { @@ -495,10 +606,10 @@ WebContentStream CurlUtils::fetch_web_content_stream(const std::string& url, std content.success = false; content.error_message = std::format("HTTP {}", content.status); message = std::format("Request HTTP status: {}\n", content.status); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); if (fail_on_http_error == true) { - exit(1); + std::exit(EXIT_FAILURE); } else { throw HttpException(content.status, url); } diff --git a/src/utils/curl_utils.hpp b/src/utils/curl_utils.hpp index 2ff3df5..e343537 100644 --- a/src/utils/curl_utils.hpp +++ b/src/utils/curl_utils.hpp @@ -14,7 +14,7 @@ */ struct WebContent { std::string read_buffer; - char error_buffer[CURL_ERROR_SIZE]; + char error_buffer[CURL_ERROR_SIZE] = {}; long status = 0; }; @@ -44,19 +44,19 @@ class CurlUtils { /** * CURL handle for HTTP requests. Initialized in init_curl(). */ - CURL* curl; + std::unique_ptr curl{nullptr, curl_easy_cleanup}; /** * CURL headers list for HTTP requests (Content-Type, etc.). * Allocated in query methods and freed after each request. */ - struct curl_slist* headers; + std::unique_ptr headers{nullptr, curl_slist_free_all}; /** * Pointer to the UserInterface for displaying messages and errors. * Used for both CLI and interactive UI output. */ - UserInterface* ui; + std::shared_ptr ui; /** * Progress spinner for visual feedback during network operations. @@ -68,6 +68,8 @@ class CurlUtils { void cleanup(void); + void add_header(curl_slist*& headers, const std::string& line); + long check_service_status(const std::string& url, const std::map& extra_headers = {}, long timeout_seconds = 5L); @@ -80,7 +82,7 @@ class CurlUtils { std::function stream_callback; public: - explicit CurlUtils(UserInterface* ui); + explicit CurlUtils(std::shared_ptr ui); ~CurlUtils(); diff --git a/src/utils/embedding_utils.cpp b/src/utils/embedding_utils.cpp index 59ec74a..3d4790a 100644 --- a/src/utils/embedding_utils.cpp +++ b/src/utils/embedding_utils.cpp @@ -3,16 +3,50 @@ #include #include -EmbeddingUtils::EmbeddingUtils(UserInterface* ui, CurlUtils* curl, OllamaClient* ollama_client, - QdrantClient* storage_client) +/** + * Constructs an EmbeddingUtils instance with the collaborators it needs for generating and storing embeddings. + * + * @param ui: Shared pointer to the UserInterface used to display messages and status. + * @param curl: Shared pointer to the CurlUtils used to perform HTTP requests. + * @param ollama_client: Shared pointer to the OllamaClient used to request embeddings from Ollama. + * @param storage_client: Shared pointer to the QdrantClient used to store and retrieve embeddings. + * + * Initializes the ui, curl, ollama_client, and storage_client members by copying each shared pointer, so this instance + * shares ownership of every dependency with the caller. The constructor body is empty, so no other work is performed. + * + * Note: Because the dependencies are shared, they remain alive as long as any holder still references them. Passing a + * null pointer is not checked here, so any later use of a null dependency will fail at the point of use. + */ +EmbeddingUtils::EmbeddingUtils(std::shared_ptr ui, std::shared_ptr curl, + std::shared_ptr ollama_client, + std::shared_ptr storage_client) : ui(ui), curl(curl), ollama_client(ollama_client), storage_client(storage_client) { } +/** + * Fetches documents from the given URLs, splits them into chunks, generates embeddings for those chunks, and stores + * the chunks with their embeddings in Qdrant. + * + * @param urls: The web addresses whose content should be fetched and embedded. + * + * Runs a four-stage pipeline. First, each URL is fetched with the CurlUtils instance and its content is collected in + * order. Second, every fetched document is split with TextUtils::split_text using a chunk size of 250 and an overlap of + * 0, and the resulting chunks are appended to a single list. Third, the OllamaClient generates one embedding per chunk. + * Fourth, the QdrantClient stores the chunks together with their embeddings. Before each stage, a progress message is + * shown, using the interactive UI when it is running and the non-interactive output otherwise. When all stages finish, + * a success message is displayed through the same mechanism. + * + * Note: The method assumes that the embeddings returned by the OllamaClient correspond index for index to the chunks + * passed to it, because the storage call pairs them by position. No length check is performed. Fetch failures are not + * detected here, so an empty or error string returned by CurlUtils is split and embedded like any other content. + * Exceptions thrown by the curl, Ollama, Qdrant, or UI components are not caught and propagate to the caller, which + * means a failure in any stage stops the pipeline before the later stages run. + */ void EmbeddingUtils::create_embedings(const std::vector& urls) { std::string message{}; message = "ā³ Fetching documents from URLs...\n"; - if (this->ui->is_running() == true) { + if (UserInterface::ui_running(this->ui) == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -25,7 +59,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { } message = "ā³ Splitting documents into chunks...\n"; - if (this->ui->is_running() == true) { + if (UserInterface::ui_running(this->ui) == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -38,7 +72,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { } message = "ā³ Generating embeddings...\n"; - if (this->ui->is_running() == true) { + if (UserInterface::ui_running(this->ui) == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -46,7 +80,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { auto embeddings = this->ollama_client->generate_embeddings(document_chunks); message = "ā³ Storing embeddings in Qdrant...\n"; - if (this->ui->is_running() == true) { + if (UserInterface::ui_running(this->ui) == true) { this->ui->print_message(message); } else { UserInterface::print_message_noninteractive(message); @@ -54,7 +88,7 @@ void EmbeddingUtils::create_embedings(const std::vector& urls) { this->storage_client->store(document_chunks, embeddings); message = "Embeddings created.\n"; - if (this->ui->is_running() == true) { + if (UserInterface::ui_running(this->ui) == true) { this->ui->print_success(message); } else { UserInterface::print_success_noninteractive(message); diff --git a/src/utils/embedding_utils.hpp b/src/utils/embedding_utils.hpp index 2807fbe..be45602 100644 --- a/src/utils/embedding_utils.hpp +++ b/src/utils/embedding_utils.hpp @@ -9,25 +9,26 @@ class EmbeddingUtils { /** * Pointer to the UserInterface for displaying messages and errors. */ - UserInterface* ui; + std::shared_ptr ui; /** * Pointer to the CURL wrapper containing pre-configured internal logic. */ - CurlUtils* curl; + std::shared_ptr curl; /** * Pointer to the OllamaClient for LLM ops. */ - OllamaClient* ollama_client; + std::shared_ptr ollama_client; /** * Pointer to the QdrantClient for vector database ops. */ - QdrantClient* storage_client; + std::shared_ptr storage_client; public: - EmbeddingUtils(UserInterface* ui, CurlUtils* curl, OllamaClient* ollama_client, QdrantClient* storage_client); + EmbeddingUtils(std::shared_ptr ui, std::shared_ptr curl, + std::shared_ptr ollama_client, std::shared_ptr storage_client); void create_embedings(const std::vector& urls); }; diff --git a/src/utils/retrieving_utils.cpp b/src/utils/retrieving_utils.cpp index 8a088ca..012b8b3 100644 --- a/src/utils/retrieving_utils.cpp +++ b/src/utils/retrieving_utils.cpp @@ -5,11 +5,55 @@ using nlohmann::json_abi_v3_11_2::json; -RetrievingUtils::RetrievingUtils(RetrievingUtilsConfig* config, UserInterface* ui, CurlUtils* curl, - OllamaClient* ollama_client, QdrantClient* storage_client) +/** + * Constructs a RetrievingUtils instance with the configuration and collaborators it needs to retrieve stored + * embeddings. + * + * @param config: Shared pointer to the RetrievingUtilsConfig that supplies the retrieval settings. + * @param ui: Shared pointer to the UserInterface used to display messages and status. + * @param curl: Shared pointer to the CurlUtils used to perform HTTP requests. + * @param ollama_client: Shared pointer to the OllamaClient used to generate query embeddings. + * @param storage_client: Shared pointer to the QdrantClient used to search stored embeddings. + * + * Initializes the config, ui, curl, ollama_client, and storage_client members by copying each shared pointer, so this + * instance shares ownership of every dependency with the caller. The constructor body is empty, so no other work is + * performed. + * + * Note: Because the dependencies are shared, they remain alive as long as any holder still references them. Passing a + * null pointer is not checked here, so any later use of a null dependency will fail at the point of use. + */ +RetrievingUtils::RetrievingUtils(std::shared_ptr config, std::shared_ptr ui, + std::shared_ptr curl, std::shared_ptr ollama_client, + std::shared_ptr storage_client) : config(config), ui(ui), curl(curl), ollama_client(ollama_client), storage_client(storage_client) { } +/** + * Retrieves the stored document chunks that are most similar to a user question. + * + * @param question: The natural-language question to search for. + * @return The text payloads of the top matching chunks from Qdrant, ordered by similarity. The list has at most + * config->k entries. + * @throws std::runtime_error If the "embedding_model" config property or the "embed_url" or "collections_url" URL + * property is not supported, or if the "embedding_model" value is not a std::string. + * @throws nlohmann::json::parse_error If the embedding response or the search response is not valid JSON. + * @throws nlohmann::json::type_error If the embedding response does not contain a numeric "embeddings" array, or if a + * search result does not contain a string "payload" "text" field. + * + * Builds a JSON request with the embedding model name and the question, then posts it to the Ollama embed URL through + * the CurlUtils instance. The first vector in the returned "embeddings" array becomes the question embedding. That + * vector, the configured result limit k, and a flag requesting payloads are serialized into a search request. The + * request is posted to the "rag-app/points/search" endpoint under the Qdrant collections URL, with the api-key header + * set from the QDRANT_API_KEY environment variable. Each entry in the "result" array of the search response contributes + * its payload text to the returned list. In debug builds, when the UI is not running, both raw responses are printed + * to the non-interactive output. + * + * Note: The method depends on the QDRANT_API_KEY environment variable. If it is unset, the null pointer passed to the + * header map is not checked, which can cause undefined behavior or a std::logic_error at construction. The response + * is not checked for HTTP errors before parsing, so an error body from either service typically surfaces as a parse or + * type exception. The method reads only the first embedding, so it expects the embed service to return exactly one + * vector for the single question. + */ std::vector RetrievingUtils::retrieve(const std::string& question) { json request_payload = {{"model", this->ollama_client->get_config_value("embedding_model")}, {"input", question}}; @@ -19,7 +63,7 @@ std::vector RetrievingUtils::retrieve(const std::string& question) std::string response = this->curl->fetch_web_content(this->ollama_client->get_url_value("embed_url"), post_data); #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] question embed response {}\n", response)); } #endif @@ -41,7 +85,7 @@ std::vector RetrievingUtils::retrieve(const std::string& question) response = this->curl->fetch_web_content(url, post_data, "POST", headers); #ifdef DEBUG - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(std::format("\t[DEBUG] search response {}\n", response)); } #endif @@ -56,6 +100,32 @@ std::vector RetrievingUtils::retrieve(const std::string& question) return documents; } +/** + * Builds a retrieval-augmented prompt from a question and its supporting documents, sends it to the Ollama generate + * endpoint, and processes the model's answer. + * + * @param question: The user question that the model should answer. + * @param documents: The retrieved document texts that provide context for the answer. Each entry is joined with a + * newline. + * @throws std::runtime_error If the "model" or "stream" config property is not supported or has an unexpected type, or + * if the "generate_url" URL property is not supported. + * @throws nlohmann::json::exception If the streamed or non-streamed response cannot be parsed, as propagated from the + * OllamaClient response handlers. + * + * Concatenates the document texts into one string, separating entries with newlines, and places that string together + * with the question into a fixed instruction template. The template asks the model to answer in at most three + * sentences and to say when it does not know the answer. The prompt, the model name, a temperature of 0, and the + * stream flag are serialized into a JSON request. When streaming is enabled, the request is posted through + * fetch_web_content_stream, and each received chunk is forwarded to OllamaClient::process_response_stream. The + * resulting success flag and status are then reported through the UI or the non-interactive output. When streaming is + * disabled, the request is posted with fetch_web_content, and the full body is passed to + * OllamaClient::process_response. + * + * Note: The question and document text are inserted into the prompt without escaping or sanitizing, so instructions + * contained in retrieved documents can influence the model. The method does not check whether the documents vector is + * empty, although the join loop itself handles that case safely. Errors from the HTTP layer are not inspected beyond + * the stream status message, so a failed non-streamed request reaches the response handler as-is. + */ void RetrievingUtils::generate_answer(const std::string& question, const std::vector& documents) { std::string document_texts{}; @@ -86,7 +156,7 @@ Documents: )" + document_texts this->ollama_client->get_url_value("generate_url"), post_data, [this](const std::string& chunk) { this->ollama_client->process_response_stream(chunk); }); std::string message = std::format("Stream successful: {}, Status: {}\n", response.success, response.status); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); @@ -98,11 +168,31 @@ Documents: )" + document_texts } } +/** + * Runs one retrieval-augmented question-answering cycle using the prompt from the configuration. + * + * @throws std::runtime_error If the "prompt" config property is not supported or has an unexpected type, or if + * retrieve or generate_answer fails with a runtime error. + * @throws nlohmann::json::parse_error If a retrieval response cannot be parsed as JSON, as propagated from retrieve. + * @throws nlohmann::json::type_error If a retrieval response has an unexpected JSON structure, as propagated from + * retrieve. + * + * Reads the "prompt" string from the Ollama client configuration and keeps a constant reference to it. The prompt is + * then shown to the user as a message, either through the interactive UI when it is running or through non-interactive + * output otherwise. The same prompt is used as the search input for retrieve, which embeds it and returns the matching + * document texts from the vector store. Those documents, together with the prompt as the question, are passed to + * generate_answer, which builds the final prompt, queries the model, and processes the answer. + * + * Note: The prompt is both the displayed user message and the retrieval query, and it is also used as the question in + * generate_answer. Because the returned reference points into the configuration object, the configuration must outlive + * this method. The method performs no check on whether retrieve returned any documents, so generate_answer is called + * even with an empty list. + */ void RetrievingUtils::run() { const std::string& prompt = this->ollama_client->get_config_value("prompt"); std::string message = std::format("šŸ’¬ user: {}\n", prompt); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_message_noninteractive(message); } else { this->ui->print_message(message); diff --git a/src/utils/retrieving_utils.hpp b/src/utils/retrieving_utils.hpp index 214da16..903bedf 100644 --- a/src/utils/retrieving_utils.hpp +++ b/src/utils/retrieving_utils.hpp @@ -14,35 +14,36 @@ class RetrievingUtils { /** * Pointer to the Retrieving Utils configuration. */ - RetrievingUtilsConfig* config; + std::shared_ptr config; /** * Pointer to the UserInterface for displaying messages and errors. */ - UserInterface* ui; + std::shared_ptr ui; /** * Pointer to the CURL wrapper containing pre-configured internal logic. */ - CurlUtils* curl; + std::shared_ptr curl; /** * Pointer to the OllamaClient for LLM ops. */ - OllamaClient* ollama_client; + std::shared_ptr ollama_client; /** * Pointer to the QdrantClient for vector database ops. */ - QdrantClient* storage_client; + std::shared_ptr storage_client; std::vector retrieve(const std::string& question); void generate_answer(const std::string& question, const std::vector& documents); public: - RetrievingUtils(RetrievingUtilsConfig* config, UserInterface* ui, CurlUtils* curl, OllamaClient* ollama_client, - QdrantClient* storage_client); + RetrievingUtils(std::shared_ptr config, std::shared_ptr ui, + std::shared_ptr curl, std::shared_ptr ollama_client, + std::shared_ptr storage_client); void run(void); }; diff --git a/src/utils/system_utils.cpp b/src/utils/system_utils.cpp index 0518101..aa969e5 100644 --- a/src/utils/system_utils.cpp +++ b/src/utils/system_utils.cpp @@ -10,8 +10,7 @@ * @note The SystemUtils class does not take ownership of the ui pointer; * the caller is responsible for managing the UserInterface lifetime. */ -SystemUtils::SystemUtils(UserInterface* ui) { - this->ui = ui; +SystemUtils::SystemUtils(std::shared_ptr ui) : ui(ui) { } /** @@ -30,7 +29,7 @@ SystemUtils::SystemUtils(UserInterface* ui) { * - Abnormal termination: displays "Command terminated abnormally" and terminates the program. * * Output routing: - * - If UserInterface is in interactive mode (is_running() == true), errors are sent via ui->print_error(). + * - If UserInterface is in interactive mode, errors are sent via ui->print_error(). * - Otherwise, errors are printed non-interactively via UserInterface::print_message_noninteractive(). * * @param status The wait status code returned from a system() call. @@ -45,7 +44,7 @@ void SystemUtils::catch_error(int status) { std::string message{}; if (status == -1) { message = "system() failed.\n"; - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); } else { this->ui->print_error(message); @@ -55,20 +54,22 @@ void SystemUtils::catch_error(int status) { int exit_code = WEXITSTATUS(status); if (exit_code != 0) { message = std::format("Command failed with exit code {}.\n", exit_code); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); } - exit(1); } } else { message = std::format("Command terminated abnormally.\n"); - if (this->ui->is_running() == false) { + if (UserInterface::ui_running(this->ui) == false) { UserInterface::print_error_noninteractive(message); + std::exit(EXIT_FAILURE); } else { this->ui->print_error(message); + this->ui->end_session(EXIT_FAILURE); } - exit(1); } } diff --git a/src/utils/system_utils.hpp b/src/utils/system_utils.hpp index e81f7a6..564fc72 100644 --- a/src/utils/system_utils.hpp +++ b/src/utils/system_utils.hpp @@ -8,10 +8,10 @@ class SystemUtils { * Pointer to the UserInterface for displaying messages and errors. * Used for both CLI and interactive UI output. */ - UserInterface* ui; + std::shared_ptr ui; public: - explicit SystemUtils(UserInterface* ui); + explicit SystemUtils(std::shared_ptr ui); void catch_error(int status); };