From 9ff858bf2108d58bf87353825140cdd016f66c22 Mon Sep 17 00:00:00 2001 From: rfprod Date: Sun, 1 Nov 2026 00:49:46 +0400 Subject: [PATCH] feat(program): add `RAG` support with streaming output - [x] bump version to 0.4.0; - [x] add rag and skip_embedding parameters to argument functions and CLI; - [x] implement streaming mode for print() and print_message(); - [x] update loop increment style for consistency; - [x] replace printf calls with UserInterface and remove emoji from messages; - [x] rename config to ollama_config for clarity; - [x] add retrieving_utils with RetrievingUtils class and RetrievingUtilsConfig; - [x] implement retrieve(), generate_answer(), and run() methods in RetrievingUtils; - [x] initialize RetrievingUtils in RAG execution path; - [x] add get_config_value and get_url_value getter methods to clients; - [x] refactor streaming with callback-based approach for HTTP and LLM; - [x] add fetch_web_content_stream and curl_streaming_write_callback; - [x] add WebContentStream struct and process_chunk for JSON buffering and parsing; - [x] convert WriteCallback to member function curl_write_callback for streaming; - [x] move process_response to public section of OllamaClient; - [x] refactor process_response_stream to accept const reference; - [x] improve variable declaration ordering and extract inline map initialization; - [x] improve control flow with else-if pattern; --- CMakeLists.txt | 3 +- src/interface/user_interface.cpp | 38 ++++-- src/interface/user_interface.hpp | 9 +- src/main.cpp | 78 +++++++---- src/ollama/ollama_client.cpp | 139 +++++++++++-------- src/ollama/ollama_client.hpp | 13 +- src/storage/qdrant_client.cpp | 21 ++- src/storage/qdrant_client.hpp | 2 + src/utils/curl_utils.cpp | 228 +++++++++++++++++++++++++++---- src/utils/curl_utils.hpp | 25 ++++ src/utils/retrieving_utils.cpp | 114 ++++++++++++++++ src/utils/retrieving_utils.hpp | 48 +++++++ 12 files changed, 577 insertions(+), 141 deletions(-) create mode 100644 src/utils/retrieving_utils.cpp create mode 100644 src/utils/retrieving_utils.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 25ae094..1dedbac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(cpp-workspace VERSION 0.3.0 LANGUAGES CXX) +project(cpp-workspace VERSION 0.4.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -21,6 +21,7 @@ add_executable(cpp-workspace-app src/utils/text_utils.cpp src/utils/system_utils.cpp src/utils/embedding_utils.cpp + src/utils/retrieving_utils.cpp src/utils/http_utils.hpp ) diff --git a/src/interface/user_interface.cpp b/src/interface/user_interface.cpp index b1837c7..0a440bd 100644 --- a/src/interface/user_interface.cpp +++ b/src/interface/user_interface.cpp @@ -361,7 +361,7 @@ void UserInterface::draw_input_area() { */ void UserInterface::draw_output_area() { // Clear the output area - for (int i = 0; i < layout.output_height; i++) { + for (int i = 0; i < layout.output_height; i += 1) { mvhline(layout.output_y + i, 1, ' ', COLS - 2); } @@ -369,7 +369,7 @@ void UserInterface::draw_output_area() { int max_content_width = COLS - 4; // Iterate backwards through buffer (newest first) - for (auto it = output_buffer.rbegin(); it != output_buffer.rend(); ++it) { + for (auto it = output_buffer.rbegin(); it != output_buffer.rend(); it += 1) { if (current_line <= layout.output_y) { break; } @@ -387,7 +387,7 @@ void UserInterface::draw_output_area() { } // Render from bottom to top (reverse order) - for (auto line_it = display_lines.rbegin(); line_it != display_lines.rend(); ++line_it) { + for (auto line_it = display_lines.rbegin(); line_it != display_lines.rend(); line_it += 1) { if (current_line <= layout.output_y) break; if (type == UserInterface::MESSAGE_TYPE_ERROR) { @@ -712,8 +712,10 @@ void UserInterface::end_session(int exit_code) { * @param embedding_model Name of the embedding model in use. * @param prompt Initial prompt or query string provided by the user. * @param stream Boolean flag indicating whether streaming mode is enabled. + * @param rag Whether to run the program in the Retrieval Augmented Generation mode. * @param interactive Boolean flag indicating whether interactive mode is enabled. * @param dark_theme Boolean flag indicating whether dark theme is active. + * @param skip_embedding Whether to skip vectorizing documents and creating embeddings. * * Constructs a multi-line diagnostic string containing all provided configuration parameters. * Each parameter is formatted with a šŸ›ˆ (information) icon prefix for visual consistency. @@ -735,14 +737,16 @@ void UserInterface::end_session(int exit_code) { * models, modes, and theme are in effect. */ void UserInterface::print_args(const std::string& model, const std::string& embedding_model, const std::string& prompt, - bool stream, bool interactive, bool dark_theme) { + bool stream, bool rag, bool interactive, bool dark_theme, bool skip_embedding) { std::string buffer; buffer += "šŸ›ˆ model = " + model + "\n"; buffer += "šŸ›ˆ embedding_model = " + embedding_model + "\n"; buffer += "šŸ›ˆ prompt = " + prompt + "\n"; buffer += "šŸ›ˆ stream = " + std::string(stream ? "true" : "false") + "\n"; + buffer += "šŸ›ˆ rag = " + std::string(rag ? "true" : "false") + "\n"; buffer += "šŸ›ˆ interactive = " + std::string(interactive ? "true" : "false") + "\n"; buffer += "šŸ›ˆ dark_theme = " + std::string(dark_theme ? "true" : "false") + "\n"; + buffer += "šŸ›ˆ skip_embedding = " + std::string(skip_embedding ? "true" : "false") + "\n"; this->print(buffer, UserInterface::MESSAGE_TYPE_NORMAL); } @@ -754,8 +758,10 @@ void UserInterface::print_args(const std::string& model, const std::string& embe * @param embedding_model Name of the embedding model in use. * @param prompt Initial prompt or query string provided by the user. * @param stream Boolean flag indicating whether streaming mode is enabled. + * @param rag Whether to run the program in the Retrieval Augmented Generation mode. * @param interactive Boolean flag indicating whether interactive mode is enabled. * @param dark_theme Boolean flag indicating whether dark theme is active. + * @param skip_embedding Whether to skip vectorizing documents and creating embeddings. * * @return void * @@ -779,15 +785,17 @@ void UserInterface::print_args(const std::string& model, const std::string& embe * initialization or after cleanup, to avoid conflicts with the ncurses output buffer. */ void UserInterface::print_args_noninteractive(const std::string& model, const std::string& embedding_model, - const std::string& prompt, bool stream, bool interactive, - bool dark_theme) { + const std::string& prompt, bool stream, bool rag, bool interactive, + bool dark_theme, bool skip_embedding) { printf("\n"); printf("šŸ›ˆ model = %s\n", model.c_str()); printf("šŸ›ˆ embedding_model = %s\n", embedding_model.c_str()); printf("šŸ›ˆ prompt = %s\n", prompt.c_str()); printf("šŸ›ˆ stream = %s\n", stream ? "true" : "false"); + printf("šŸ›ˆ rag = %s\n", rag ? "true" : "false"); printf("šŸ›ˆ interactive = %s\n", interactive ? "true" : "false"); printf("šŸ›ˆ dark_theme = %s\n", dark_theme ? "true" : "false"); + printf("šŸ›ˆ skip_embedding = %s\n", skip_embedding ? "true" : "false"); printf("\n"); } @@ -978,7 +986,7 @@ bool UserInterface::next_prompt(std::string* prompt) { this->request_redraw(); } } else if (ch != ERR && isprint(ch)) { - if (input_buffer.length() < COLS - 40) { // Leave room for prompt + if (input_buffer.length() < COLS - 40) { input_buffer.push_back(ch); input_cursor_x++; this->request_redraw(); @@ -1030,16 +1038,22 @@ bool UserInterface::next_prompt_noninteractive(std::string* prompt) { * @param text The message content to be displayed. * @param type The message type (MESSAGE_TYPE_NORMAL, MESSAGE_TYPE_ERROR, MESSAGE_TYPE_SUCCESS) that controls styling. * Defaults to MESSAGE_TYPE_NORMAL if not specified. + * @param stream In the stream mode, every chunk of text after the first one is appended to the last buffer entry. + * Defaults to 'false'. * * Thread-safe: acquires ui_mutex before adding the message to output_buffer. * Automatically requests a redraw via request_redraw() to display the new message. * * @see draw_output_area() */ -void UserInterface::print(const std::string& text, int type) { +void UserInterface::print(const std::string& text, int type, bool stream) { { std::lock_guard lock(this->ui_mutex); - output_buffer.push_back({text, type}); + if (stream == true && !output_buffer.empty()) { + output_buffer.back().first += text; + } else { + output_buffer.push_back({text, type}); + } } this->request_redraw(); } @@ -1070,11 +1084,13 @@ void UserInterface::print_success(const std::string& text) { * @brief Queues a normal message (plain text) and requests a redraw. * * @param text The message content. + * @param stream In the stream mode, every chunk of text after the first one is appended to the last buffer entry. + * Defaults to 'false'. * * @see print() */ -void UserInterface::print_message(const std::string& text) { - this->print(text, UserInterface::MESSAGE_TYPE_NORMAL); +void UserInterface::print_message(const std::string& text, bool stream) { + this->print(text, UserInterface::MESSAGE_TYPE_NORMAL, stream); } /** diff --git a/src/interface/user_interface.hpp b/src/interface/user_interface.hpp index 42fcea7..0adc6e0 100644 --- a/src/interface/user_interface.hpp +++ b/src/interface/user_interface.hpp @@ -150,10 +150,11 @@ class UserInterface { void end_session(int exit_code); void print_args(const std::string& model, const std::string& embedding_model, const std::string& prompt, - bool stream, bool interactive, bool dark_theme); + bool stream, bool rag, bool interactive, bool dark_theme, bool skip_embedding); static void print_args_noninteractive(const std::string& model, const std::string& embedding_model, - const std::string& prompt, bool stream, bool interactive, bool dark_theme); + const std::string& prompt, bool stream, bool rag, bool interactive, + bool dark_theme, bool skip_embedding); void print_model_list(const std::vector& models); @@ -167,7 +168,7 @@ class UserInterface { static bool next_prompt_noninteractive(std::string* prompt); - void print(const std::string& text, int type = UserInterface::MESSAGE_TYPE_NORMAL); + void print(const std::string& text, int type = UserInterface::MESSAGE_TYPE_NORMAL, bool stream = false); void print_error(const std::string& text); @@ -177,7 +178,7 @@ class UserInterface { static void print_success_noninteractive(const std::string& text); - void print_message(const std::string& text); + void print_message(const std::string& text, bool stream = false); static void print_message_noninteractive(const std::string& text); diff --git a/src/main.cpp b/src/main.cpp index 06389a2..f529b97 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include "storage/qdrant_client.hpp" #include "utils/curl_utils.hpp" #include "utils/embedding_utils.hpp" +#include "utils/retrieving_utils.hpp" #include "utils/system_utils.hpp" #include "utils/text_utils.hpp" #include @@ -14,6 +15,7 @@ int main(int argc, const char* argv[]) { std::string embedding_model = "embeddinggemma:latest"; std::string prompt = "Hey! What's up!"; bool stream = false; + bool rag = false; bool interactive = false; bool dark_theme = true; bool skip_embedding = false; @@ -32,6 +34,8 @@ int main(int argc, const char* argv[]) { prompt = arg.substr(9); } else if (arg.starts_with("--stream=") == true) { stream = arg.substr(9) == "true" ? true : false; + } else if (arg.starts_with("--rag=") == true) { + rag = arg.substr(6) == "true" ? true : false; } else if (arg.starts_with("--interactive=") == true) { interactive = arg.substr(14) == "true" ? true : false; } else if (arg.starts_with("--dark_theme=") == true) { @@ -43,17 +47,23 @@ int main(int argc, const char* argv[]) { UserInterface ui(dark_theme); if (interactive == true) { - printf("\nInteractive mode debug info.\n"); - printf("---\n"); - printf("isatty(0)=%d, isatty(1)=%d, isatty(2)=%d\n", isatty(0), isatty(1), isatty(2)); - printf("TERM=%s\n", getenv("TERM")); - printf("TERMINFO=%s\n", getenv("TERMINFO") ? getenv("TERMINFO") : "(not set)"); - printf("LC_ALL=%s\n", getenv("LC_ALL") ? getenv("LC_ALL") : "(not set)"); - printf("LANG=%s\n", getenv("LANG") ? getenv("LANG") : "(not set)"); - printf("---\n"); + message = std::format("\nInteractive mode debug info.\n" + "---\n" + "isatty(0)={}, isatty(1)={}, isatty(2)={}\n" + "TERM={}\n" + "TERMINFO={}\n" + "LC_ALL={}\n" + "LANG={}\n" + "---\n", + isatty(0), isatty(1), isatty(2), getenv("TERM") ? getenv("TERM") : "(not set)", + getenv("TERMINFO") ? getenv("TERMINFO") : "(not set)", + getenv("LC_ALL") ? getenv("LC_ALL") : "(not set)", + getenv("LANG") ? getenv("LANG") : "(not set)"); + + UserInterface::print_message_noninteractive(message); ui.run(); - ui.print_args(model, embedding_model, prompt, stream, interactive, dark_theme); + ui.print_args(model, embedding_model, prompt, stream, rag, interactive, dark_theme, skip_embedding); while (prompt.length() == 0) { done = ui.next_prompt(&prompt); @@ -63,18 +73,19 @@ int main(int argc, const char* argv[]) { } } } else { - UserInterface::print_args_noninteractive(model, embedding_model, prompt, stream, interactive, dark_theme); + UserInterface::print_args_noninteractive(model, embedding_model, prompt, stream, rag, interactive, dark_theme, + skip_embedding); if (prompt.length() == 0) { done = UserInterface::next_prompt_noninteractive(&prompt); if (prompt.length() == 0) { - printf("\nšŸ Empty prompt. The session has ended.\n"); + UserInterface::print_message_noninteractive("\nšŸ Empty prompt. The session has ended.\n"); exit(0); } if (done == true) { - printf("\nšŸ The session has ended.\n"); + UserInterface::print_message_noninteractive("\nšŸ The session has ended.\n"); exit(0); } } @@ -86,12 +97,16 @@ int main(int argc, const char* argv[]) { QdrantClient storage_client(&ui, &curl, &system_utils); - struct OllamaClientConfig config {}; + struct OllamaClientConfig ollama_config {}; - OllamaClient ollama_client(&config, &ui, &curl, &system_utils); + OllamaClient ollama_client(&ollama_config, &ui, &curl, &system_utils); EmbeddingUtils embedding_utils(&ui, &curl, &ollama_client, &storage_client); + struct RetrievingUtilsConfig retrieving_config {}; + + RetrievingUtils retrieving_utils(&retrieving_config, &ui, &curl, &ollama_client, &storage_client); + std::vector models = ollama_client.query_model_list(); bool force_model_selection = true; for (size_t i = 0; i < models.size(); i += 1) { @@ -106,12 +121,13 @@ int main(int argc, const char* argv[]) { ui.print_message(message); ui.print_model_list(models); ui.select_model(models, &model); - ui.print_args(model, embedding_model, prompt, stream, interactive, dark_theme); + ui.print_args(model, embedding_model, prompt, stream, rag, interactive, dark_theme, skip_embedding); } else { UserInterface::print_message_noninteractive(message); UserInterface::print_model_list_noninteractive(models); UserInterface::select_model_noninteractive(models, &model); - UserInterface::print_args_noninteractive(model, embedding_model, prompt, stream, interactive, dark_theme); + UserInterface::print_args_noninteractive(model, embedding_model, prompt, stream, rag, interactive, + dark_theme, skip_embedding); } } @@ -128,21 +144,21 @@ int main(int argc, const char* argv[]) { ui.print_message(message); ui.print_model_list(models); ui.select_model(models, &embedding_model); - ui.print_args(model, embedding_model, prompt, stream, interactive, dark_theme); + ui.print_args(model, embedding_model, prompt, stream, rag, interactive, dark_theme, skip_embedding); } else { UserInterface::print_message_noninteractive(message); UserInterface::print_model_list_noninteractive(models); UserInterface::select_model_noninteractive(models, &embedding_model); - UserInterface::print_args_noninteractive(model, embedding_model, prompt, stream, interactive, - dark_theme); + UserInterface::print_args_noninteractive(model, embedding_model, prompt, stream, rag, interactive, + dark_theme, skip_embedding); } } } - config.model = model; - config.embedding_model = embedding_model; - config.prompt = prompt; - config.stream = stream; + ollama_config.model = model; + ollama_config.embedding_model = embedding_model; + ollama_config.prompt = prompt; + ollama_config.stream = stream; if (skip_embedding == false) { std::vector urls = { @@ -150,7 +166,11 @@ int main(int argc, const char* argv[]) { embedding_utils.create_embedings(urls); } - ollama_client.query_model(); + if (rag == false) { + ollama_client.query_model(); + } else { + retrieving_utils.run(); + } if (interactive == true) { while (done == false) { @@ -159,13 +179,17 @@ int main(int argc, const char* argv[]) { continue; } - config.prompt = prompt; - ollama_client.query_model(); + ollama_config.prompt = prompt; + if (rag == false) { + ollama_client.query_model(); + } else { + retrieving_utils.run(); + } ui.request_redraw(); } ui.end_session(EXIT_SUCCESS); } else { - printf("\nšŸ The session has ended.\n"); + UserInterface::print_message_noninteractive("\nšŸ The session has ended.\n"); } return 0; diff --git a/src/ollama/ollama_client.cpp b/src/ollama/ollama_client.cpp index 14fdec3..70fcb9b 100644 --- a/src/ollama/ollama_client.cpp +++ b/src/ollama/ollama_client.cpp @@ -47,6 +47,48 @@ OllamaClient::~OllamaClient() { this->system_utils->catch_error(status); } +template const T& OllamaClient::get_config_value(const std::string& property_name) const { + if (property_name == "model") { + if constexpr (std::is_same_v) { + return this->config->model; + } + } + + if (property_name == "embedding_model") { + if constexpr (std::is_same_v) { + return this->config->embedding_model; + } + } + + if (property_name == "prompt") { + if constexpr (std::is_same_v) { + return this->config->prompt; + } + } + + if (property_name == "stream") { + if constexpr (std::is_same_v) { + return this->config->stream; + } + } + + throw std::runtime_error("Property not supported: " + property_name); +} +template const std::string& OllamaClient::get_config_value(const std::string&) const; +template const bool& OllamaClient::get_config_value(const std::string&) const; + +const std::string& OllamaClient::get_url_value(const std::string& property_name) const { + if (property_name == "generate_url") { + return this->urls.generate_url; + } + + if (property_name == "embed_url") { + return this->urls.embed_url; + } + + throw std::runtime_error("Property not supported: " + property_name); +} + /** * Verifies that the Ollama binary is available on the system. * @@ -136,68 +178,37 @@ void OllamaClient::process_response(std::string response) { } } -/** - * Parses and displays a streamed JSON response from Ollama. - * - * Processes the response as newline-delimited JSON chunks. For each chunk: - * 1. Extracts the "response" field (text fragment) - * 2. Prints it incrementally to stdout - * 3. Checks the "done" field to determine if streaming is complete - * - * On JSON parse error, displays an error message and exits or ends the session. - * - * @param response The complete streamed response (newline-delimited JSON). - */ -void OllamaClient::process_response_stream(std::string response) { - std::istringstream stream(response); - std::string line; - - std::string accumulated{}; - - std::string text = "šŸ¦™ llama:\n"; +void OllamaClient::process_response_stream(const std::string& line) { +#ifdef DEBUG if (this->ui->is_running() == false) { - UserInterface::print_message_noninteractive(text); - } else { - accumulated += text; + UserInterface::print_message_noninteractive( + std::format("\t[DEBUG] process_response_stream called with line: {}\n", << line.substr(0, 100))); } +#endif - while (std::getline(stream, line)) { - if (!line.empty()) { - try { - json chunk = json::parse(line); - text = chunk["response"].get(); - if (this->ui->is_running() == false) { - UserInterface::print_message_noninteractive(text); - } else { - accumulated += text; - - if (text.find('\n') != std::string::npos || accumulated.length() > 256) { - this->ui->print_message(accumulated); - accumulated.clear(); - } - } - - if (chunk["done"].get()) { - break; - } - } catch (const json::exception& err) { - std::string message = std::format("JSON parse error: {}\n", err.what()); - if (this->ui->is_running() == false) { - UserInterface::print_error_noninteractive(message); - exit(1); - } else { - this->ui->print_error(message); - this->ui->end_session(EXIT_FAILURE); - } - } + try { + json chunk_json = json::parse(line); + std::string text = chunk_json["response"].get(); + + // First chunk: stream=false (new entry), subsequent chunks: stream=true (append) + static bool first_chunk = true; + bool is_streaming = !first_chunk; + first_chunk = false; + + if (this->ui->is_running() == false) { + UserInterface::print_message_noninteractive(text); + } else { + this->ui->print_message(text, is_streaming); } - } - text = "\n---\n"; - if (this->ui->is_running() == false) { - UserInterface::print_message_noninteractive(text); - } else { - this->ui->print_message(text); + if (chunk_json["done"].get()) { + first_chunk = true; // Reset for next response + return; + } + } catch (const json::exception& err) { + std::string message = std::format("JSON parse error in process_response_stream: {}\nError ID: {}\nLine was: {}", + err.what(), err.id, line); + UserInterface::print_error_noninteractive(message); } } @@ -275,12 +286,20 @@ void OllamaClient::query_model(std::optional config_opt) { std::string post_data = request_payload.dump(); - std::string response = this->curl->fetch_web_content(this->urls.generate_url, post_data); - bool stream = config_opt != std::nullopt ? config_opt->stream : config->stream; if (stream) { - this->process_response_stream(response); + WebContentStream response = + this->curl->fetch_web_content_stream(this->urls.generate_url, post_data, [this](const std::string& chunk) { + this->process_response_stream(chunk); + }); + message = std::format("Stream successful: {}, Status: {}\n", response.success, response.status); + if (this->ui->is_running() == false) { + UserInterface::print_message_noninteractive(message); + } else { + this->ui->print_message(message); + } } else { + std::string response = this->curl->fetch_web_content(this->urls.generate_url, post_data); this->process_response(response); } } diff --git a/src/ollama/ollama_client.hpp b/src/ollama/ollama_client.hpp index ae9e262..2fef7a0 100644 --- a/src/ollama/ollama_client.hpp +++ b/src/ollama/ollama_client.hpp @@ -12,6 +12,7 @@ * Configuration for sending a prompt to an Ollama model. * * @param model The name of the model to query (e.g., "llama2", "neural-chat"). + * @param embedding_model The name of the embedding model to query (e.g., "embeddinggemma"). * @param prompt The text prompt to send to the model. * @param stream Whether to stream the response (true) or receive it all at once (false). */ @@ -80,19 +81,23 @@ class OllamaClient { std::string trim_response(std::string response); - void process_response(std::string response); - - void process_response_stream(std::string response); - public: explicit OllamaClient(OllamaClientConfig* config, UserInterface* ui, CurlUtils* curl, SystemUtils* system_utils); ~OllamaClient(); + template const T& get_config_value(const std::string& property_name) const; + + const std::string& get_url_value(const std::string& property_name) const; + std::vector> generate_embeddings(const std::vector& texts, std::optional config_opt = std::nullopt); void query_model(std::optional config_opt = std::nullopt); + void process_response(std::string response); + + void process_response_stream(const std::string& chunk); + std::vector query_model_list(); }; diff --git a/src/storage/qdrant_client.cpp b/src/storage/qdrant_client.cpp index d2eaa62..cfd9002 100644 --- a/src/storage/qdrant_client.cpp +++ b/src/storage/qdrant_client.cpp @@ -73,6 +73,14 @@ QdrantClient::~QdrantClient() { } } +const std::string& QdrantClient::get_url_value(const std::string& property_name) const { + if (property_name == "collections_url") { + return this->urls.collections_url; + } + + throw std::runtime_error("Property not supported: " + property_name); +} + /** * Initializes and starts the Qdrant Docker container with necessary configuration. * Pulls the Qdrant image, validates the QDRANT_API_KEY and QDRANT_API_KEY_READ_ONLY environment variables, @@ -183,7 +191,9 @@ void QdrantClient::check_qdrant() { std::string message{}; const char* qdrant_key = std::getenv("QDRANT_API_KEY"); - long status = this->curl->wait_for_service(this->urls.collections_url, {{"api-key", qdrant_key}}); + std::map headers{{"api-key", qdrant_key}}; + + long status = this->curl->wait_for_service(this->urls.collections_url, headers); if (status == 200) { message = "Qdrant is healthy.\n"; @@ -217,9 +227,9 @@ void QdrantClient::check_qdrant() { bool QdrantClient::collection_exists(const std::string& collection_name) { std::string message{}; - const char* qdrant_key = std::getenv("QDRANT_API_KEY"); std::string url = std::format("{}/{}", this->urls.collections_url, collection_name); + const char* qdrant_key = std::getenv("QDRANT_API_KEY"); std::map headers{{"api-key", qdrant_key}}; try { @@ -247,9 +257,9 @@ bool QdrantClient::collection_exists(const std::string& collection_name) { * @param collection_name The name of the collection to delete. */ void QdrantClient::delete_collection(const std::string& collection_name) { - const char* qdrant_key = std::getenv("QDRANT_API_KEY"); std::string url = std::format("{}/{}", this->urls.collections_url, collection_name); + const char* qdrant_key = std::getenv("QDRANT_API_KEY"); std::map headers{{"api-key", qdrant_key}}; try { @@ -279,13 +289,12 @@ void QdrantClient::delete_collection(const std::string& collection_name) { * @param vector_size The dimensionality of the vectors (e.g., 384 for embeddinggemma:latest). */ void QdrantClient::create_collection(const std::string& collection_name, size_t vector_size) { - const char* qdrant_key = std::getenv("QDRANT_API_KEY"); - json config = {{"vectors", {{"size", vector_size}, {"distance", "Cosine"}}}}; std::string url = std::format("{}/{}", this->urls.collections_url, collection_name); std::string post_data = config.dump(); + const char* qdrant_key = std::getenv("QDRANT_API_KEY"); std::map headers{{"api-key", qdrant_key}}; try { @@ -322,7 +331,6 @@ void QdrantClient::create_collection(const std::string& collection_name, size_t */ void QdrantClient::store(const std::vector& texts, const std::vector>& embeddings) { std::string message{}; - const char* qdrant_key = std::getenv("QDRANT_API_KEY"); const std::string collection_name = "rag-app"; if (texts.empty() || embeddings.empty()) { @@ -382,6 +390,7 @@ void QdrantClient::store(const std::vector& texts, const std::vecto std::string url = std::format("{}/{}/points", this->urls.collections_url, collection_name); std::string post_data = request_payload.dump(); + const char* qdrant_key = std::getenv("QDRANT_API_KEY"); std::map headers{{"api-key", qdrant_key}}; #ifdef DEBUG diff --git a/src/storage/qdrant_client.hpp b/src/storage/qdrant_client.hpp index 869ef36..9b4de73 100644 --- a/src/storage/qdrant_client.hpp +++ b/src/storage/qdrant_client.hpp @@ -81,5 +81,7 @@ class QdrantClient { ~QdrantClient(); + const std::string& get_url_value(const std::string& property_name) const; + void store(const std::vector& texts, const std::vector>& embeddings); }; diff --git a/src/utils/curl_utils.cpp b/src/utils/curl_utils.cpp index db64e2d..b194fe9 100644 --- a/src/utils/curl_utils.cpp +++ b/src/utils/curl_utils.cpp @@ -2,24 +2,6 @@ #include #include -/** - * CURL write callback for accumulating response data. - * - * Called by CURL whenever data is received. Appends the chunk to the - * userp string buffer. - * - * @param contents Pointer to the received data chunk. - * @param size Size of each element (always 1 for this use case). - * @param nmemb Number of elements received. - * @param userp Pointer to the std::string buffer to append data to. - * - * @return Total bytes processed (size * nmemb). - */ -static size_t WriteCallback(char* contents, size_t size, size_t nmemb, std::string* userp) { - userp->append(contents, size * nmemb); - return size * nmemb; -} - /** * Constructs a CurlUtils instance with dependency injection. * @@ -56,7 +38,7 @@ void CurlUtils::initialize() { this->curl = curl_easy_init(); std::string message{}; if (!this->curl) { - message = "āŒ Failed to initialize CURL.\n"; + message = "Failed to initialize CURL.\n"; if (this->ui->is_running() == false) { UserInterface::print_error_noninteractive(message); exit(1); @@ -65,9 +47,9 @@ void CurlUtils::initialize() { this->ui->end_session(EXIT_FAILURE); } } else { - message = "āœ… CURL is initialized.\n"; + message = "CURL is initialized.\n"; if (this->ui->is_running() == false) { - UserInterface::print_message_noninteractive(message); + UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); } @@ -88,14 +70,117 @@ void CurlUtils::cleanup() { curl_easy_cleanup(this->curl); curl_slist_free_all(this->headers); this->headers = nullptr; - std::string message = "āœ… CURL cleanup is complete.\n"; + std::string message = "CURL cleanup is complete.\n"; if (this->ui->is_running() == false) { - UserInterface::print_message_noninteractive(message); + UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); } } +/** + * CURL write callback for accumulating response data. + * + * Called by CURL whenever data is received. Appends the chunk to the + * userp string buffer. + * + * @param contents Pointer to the received data chunk. + * @param size Size of each element (always 1 for this use case). + * @param nmemb Number of elements received. + * @param userp Pointer to the std::string buffer to append data to. + * + * @return Total bytes processed (size * nmemb). + */ +size_t CurlUtils::curl_write_callback(char* contents, size_t size, size_t nmemb, std::string* userp) { + userp->append(contents, size * nmemb); + return size * nmemb; +} + +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) { + UserInterface::print_message_noninteractive( + std::format("\t[DEBUG] šŸ”— curl_streaming_write_callback called, size: {}\n", realsize)); + } +#endif + + try { + std::string chunk(contents, realsize); + 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) { + UserInterface::print_error_noninteractive(message); + } else { + curl_obj->ui->print_error(message); + } + // Returning 0 aborts the transfer + return 0; + } + + return realsize; +} + +void CurlUtils::process_chunk(const std::string& chunk) { +#ifdef DEBUG + if (this->ui->is_running() == false) { + UserInterface::print_message_noninteractive( + std::format("\t[DEBUG] šŸ”„ process_chunk called with {}\n", chunk.size())); + } +#endif + + this->stream_buffer += chunk; + + // Extract complete lines (ending with \n). + size_t pos = 0; + while ((pos = this->stream_buffer.find('\n')) != std::string::npos) { + std::string line = this->stream_buffer.substr(0, pos); + this->stream_buffer.erase(0, pos + 1); + + // Trim trailing \r (HTTP line endings are \r\n). + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + +#ifdef DEBUG + if (this->ui->is_running() == false) { + UserInterface::print_message_noninteractive(std::format( + "\t[DEBUG] šŸ”„ Extracted line: {} {}\n", line.substr(0, 80), (line.size() > 80 ? "..." : ""))); + } +#endif + + if (line.empty() || line[0] != '{') { +#ifdef DEBUG + if (this->ui->is_running() == false) { + UserInterface::print_message_noninteractive(std::format("\t[DEBUG] šŸ”„ Skipping non-JSON line.\n")); + } +#endif + continue; + } + + // Pass only valid JSON lines to the callback. + if (stream_callback) { +#ifdef DEBUG + if (this->ui->is_running() == 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) { + UserInterface::print_message_noninteractive("āš ļø stream_callback is NULL!\n"); + } +#endif + } + } + // Remaining incomplete data stays in stream_buffer for next chunk. +} + /** * @brief Checks service availability by querying an HTTP endpoint and returning the HTTP status code. * @@ -180,9 +265,9 @@ long CurlUtils::wait_for_service(const std::string& url, const std::mapcheck_service_status(url, extra_headers); if (http_code != -1) { - message = std::format("āœ… Service available (HTTP {}) after {} attempt(s)\n", http_code, attempt + 1); + message = std::format("Service available (HTTP {}) after {} attempt(s)\n", http_code, attempt + 1); if (this->ui->is_running() == false) { - UserInterface::print_message_noninteractive(message); + UserInterface::print_success_noninteractive(message); } else { this->ui->print_success(message); } @@ -260,7 +345,7 @@ std::string CurlUtils::fetch_web_content(const std::string& url, std::optionalcurl, CURLOPT_CUSTOMREQUEST, custom_request_type->c_str()); } - curl_easy_setopt(this->curl, CURLOPT_WRITEFUNCTION, WriteCallback); + 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); @@ -303,8 +388,7 @@ std::string CurlUtils::fetch_web_content(const std::string& url, std::optionalui->end_session(EXIT_FAILURE); } - } - if (content.status != 200) { + } else if (content.status != 200) { message = std::format("Request HTTP status: {}\n", content.status); if (this->ui->is_running() == false) { UserInterface::print_error_noninteractive(message); @@ -325,3 +409,91 @@ std::string CurlUtils::fetch_web_content(const std::string& url, std::optional post_data, + std::function on_chunk, + std::optional custom_request_type, + std::optional> extra_headers, + bool fail_on_http_error) { + this->stream_callback = on_chunk; + + struct WebContentStream content {}; + std::string message{}; + + this->initialize(); + this->stream_buffer.clear(); + + curl_easy_setopt(this->curl, CURLOPT_URL, url.c_str()); + + if (post_data != std::nullopt) { + curl_easy_setopt(this->curl, 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, 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); + + this->headers = curl_slist_append(this->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()); + } + } + + curl_easy_setopt(this->curl, CURLOPT_HTTPHEADER, this->headers); + + CURLcode result = curl_easy_perform(this->curl); + + curl_easy_getinfo(this->curl, CURLINFO_RESPONSE_CODE, &content.status); + + this->cleanup(); + + content.success = true; + if (result != CURLE_OK) { + 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 (content.error_buffer[0] != '\0') { + message += std::format("Details: {}\n", content.error_buffer); + } + UserInterface::print_error_noninteractive(message); + exit(1); + } else { + this->ui->print_error(message); + if (content.error_buffer[0] != '\0') { + message = std::format("Details: {}\n", content.error_buffer); + this->ui->print_error(message); + } + this->ui->end_session(EXIT_FAILURE); + } + } else if (content.status != 200) { + 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) { + UserInterface::print_error_noninteractive(message); + if (fail_on_http_error == true) { + exit(1); + } else { + throw HttpException(content.status, url); + } + } else { + this->ui->print_error(message); + if (fail_on_http_error == true) { + this->ui->end_session(EXIT_FAILURE); + } else { + throw HttpException(content.status, url); + } + } + } + + return content; +} diff --git a/src/utils/curl_utils.hpp b/src/utils/curl_utils.hpp index 8b5baa7..2ff3df5 100644 --- a/src/utils/curl_utils.hpp +++ b/src/utils/curl_utils.hpp @@ -18,6 +18,16 @@ struct WebContent { long status = 0; }; +/** + * Web content stream result. + */ +struct WebContentStream { + bool success; + char error_buffer[CURL_ERROR_SIZE]; + std::string error_message; + int status = 0; +}; + /** * CURL HTTP client wrapper for making web requests to the Ollama server. * @@ -61,6 +71,14 @@ class CurlUtils { long check_service_status(const std::string& url, const std::map& extra_headers = {}, long timeout_seconds = 5L); + static size_t curl_write_callback(char* contents, size_t size, size_t nmemb, std::string* userp); + + // Streaming methods. + std::string stream_buffer; + static size_t curl_streaming_write_callback(char* contents, size_t size, size_t nmemb, void* userp); + void process_chunk(const std::string& chunk); + std::function stream_callback; + public: explicit CurlUtils(UserInterface* ui); @@ -69,8 +87,15 @@ class CurlUtils { long wait_for_service(const std::string& url, const std::map& extra_headers = {}, int max_retries = 3, int initial_delay_ms = 500); + // Non-streaming version (buffers entire response) std::string fetch_web_content(const std::string& url, std::optional post_data = std::nullopt, std::optional custom_request_type = std::nullopt, std::optional> extra_headers = std::nullopt, bool fail_on_http_error = true); + + // Streaming version (processes chunks as they arrive) + WebContentStream fetch_web_content_stream( + const std::string& url, std::optional post_data, std::function on_chunk, + std::optional custom_request_type = std::nullopt, + std::optional> extra_headers = std::nullopt, bool fail_on_http_error = true); }; diff --git a/src/utils/retrieving_utils.cpp b/src/utils/retrieving_utils.cpp new file mode 100644 index 0000000..8a088ca --- /dev/null +++ b/src/utils/retrieving_utils.cpp @@ -0,0 +1,114 @@ +#include "retrieving_utils.hpp" +#include +#include +#include + +using nlohmann::json_abi_v3_11_2::json; + +RetrievingUtils::RetrievingUtils(RetrievingUtilsConfig* config, UserInterface* ui, CurlUtils* curl, + OllamaClient* ollama_client, QdrantClient* storage_client) + : config(config), ui(ui), curl(curl), ollama_client(ollama_client), storage_client(storage_client) { +} + +std::vector RetrievingUtils::retrieve(const std::string& question) { + json request_payload = {{"model", this->ollama_client->get_config_value("embedding_model")}, + {"input", question}}; + + std::string post_data = request_payload.dump(); + + 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) { + UserInterface::print_message_noninteractive(std::format("\t[DEBUG] question embed response {}\n", response)); + } +#endif + + json data = json::parse(response); + + std::vector question_embedding = data["embeddings"][0].get>(); + + request_payload = {{"vector", question_embedding}, {"limit", this->config->k}, {"with_payload", true}}; + + post_data = request_payload.dump(); + + std::string url = + std::format("{}/{}", this->storage_client->get_url_value("collections_url"), "rag-app/points/search"); + + const char* qdrant_key = std::getenv("QDRANT_API_KEY"); + std::map headers{{"api-key", qdrant_key}}; + + response = this->curl->fetch_web_content(url, post_data, "POST", headers); + +#ifdef DEBUG + if (this->ui->is_running() == false) { + UserInterface::print_message_noninteractive(std::format("\t[DEBUG] search response {}\n", response)); + } +#endif + + data = json::parse(response); + + std::vector documents; + + std::transform(data["result"].begin(), data["result"].end(), std::back_inserter(documents), + [](const auto& result) { return result["payload"]["text"].template get(); }); + + return documents; +} + +void RetrievingUtils::generate_answer(const std::string& question, const std::vector& documents) { + std::string document_texts{}; + + for (size_t i = 0; i < documents.size(); i += 1) { + document_texts += documents[i]; + if (i < documents.size() - 1) document_texts += "\n"; + } + + std::string prompt = R"(You are an assistant for question-answering tasks. +Use the following documents to answer the question. +If you don't know the answer, just say that you don't know. +Use three sentences maximum and keep the answer concise: +Question: )" + question + R"( +Documents: )" + document_texts + + R"( +Answer: +)"; + + json request_payload = {{"model", this->ollama_client->get_config_value("model")}, + {"prompt", prompt}, + {"temperature", 0}, + {"stream", this->ollama_client->get_config_value("stream")}}; + + std::string post_data = request_payload.dump(); + + if (this->ollama_client->get_config_value("stream")) { + WebContentStream response = this->curl->fetch_web_content_stream( + 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) { + UserInterface::print_message_noninteractive(message); + } else { + this->ui->print_message(message); + } + } else { + std::string response = + this->curl->fetch_web_content(this->ollama_client->get_url_value("generate_url"), post_data); + this->ollama_client->process_response(response); + } +} + +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) { + UserInterface::print_message_noninteractive(message); + } else { + this->ui->print_message(message); + } + + auto documents = this->retrieve(prompt); + + this->generate_answer(prompt, documents); +} diff --git a/src/utils/retrieving_utils.hpp b/src/utils/retrieving_utils.hpp new file mode 100644 index 0000000..214da16 --- /dev/null +++ b/src/utils/retrieving_utils.hpp @@ -0,0 +1,48 @@ +#include "interface/user_interface.hpp" +#include "ollama/ollama_client.hpp" +#include "storage/qdrant_client.hpp" +#include "utils/curl_utils.hpp" +#include +#include + +struct RetrievingUtilsConfig { + int k = 4; +}; + +class RetrievingUtils { + private: + /** + * Pointer to the Retrieving Utils configuration. + */ + RetrievingUtilsConfig* config; + + /** + * Pointer to the UserInterface for displaying messages and errors. + */ + UserInterface* ui; + + /** + * Pointer to the CURL wrapper containing pre-configured internal logic. + */ + CurlUtils* curl; + + /** + * Pointer to the OllamaClient for LLM ops. + */ + OllamaClient* ollama_client; + + /** + * Pointer to the QdrantClient for vector database ops. + */ + QdrantClient* 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); + + void run(void); +};