Add openai mode

This commit is contained in:
Aaron Po
2026-08-23 06:39:21 -04:00
parent a5f93e33c5
commit 9576f76fda
23 changed files with 1035 additions and 304 deletions

View File

@@ -192,6 +192,19 @@ target_sources(${PROJECT_NAME} PRIVATE
src/data_generation/mock/deterministic_hash.cc src/data_generation/mock/deterministic_hash.cc
) )
# --- data_generation: shared JSON validation (always built — no llama.cpp dependency) ---
target_sources(${PROJECT_NAME} PRIVATE
src/data_generation/generation_json_validation.cc
)
# --- data_generation: openai (always built — no llama.cpp dependency) ---
target_sources(${PROJECT_NAME} PRIVATE
src/data_generation/openai/openai_generator.cc
src/data_generation/openai/call_chat_completions_api.cc
src/data_generation/openai/generate_brewery.cc
src/data_generation/openai/generate_user.cc
)
# --- data_generation: llama (skipped for mock-only builds) --- # --- data_generation: llama (skipped for mock-only builds) ---
if(NOT BIERGARTEN_MOCK_ONLY) if(NOT BIERGARTEN_MOCK_ONLY)
target_sources(${PROJECT_NAME} PRIVATE target_sources(${PROJECT_NAME} PRIVATE

View File

@@ -15,12 +15,15 @@
// --- data_generation --- // --- data_generation ---
#include "data_generation/data_generator.h" #include "data_generation/data_generator.h"
#include "data_generation/generation_json_validation.h"
#include "data_generation/json_grammars.h" #include "data_generation/json_grammars.h"
#ifndef BIERGARTEN_MOCK_ONLY #ifndef BIERGARTEN_MOCK_ONLY
#include "data_generation/llama_generator.h" #include "data_generation/llama_generator.h"
#include "data_generation/llama_generator_helpers.h" #include "data_generation/llama_generator_helpers.h"
#endif #endif
#include "data_generation/mock_generator.h" #include "data_generation/mock_generator.h"
#include "data_generation/openai_generator.h"
#include "data_generation/openai_json_schemas.h"
#include "data_generation/prompt_formatting/gemma4_jinja_prompt_formatter.h" #include "data_generation/prompt_formatting/gemma4_jinja_prompt_formatter.h"
#include "data_generation/prompt_formatting/prompt_formatter.h" #include "data_generation/prompt_formatting/prompt_formatter.h"

View File

@@ -0,0 +1,52 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_GENERATION_JSON_VALIDATION_H_
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_GENERATION_JSON_VALIDATION_H_
/**
* @file data_generation/generation_json_validation.h
* @brief Generator-agnostic helpers for preparing prompt context and
* validating structured JSON output. Shared across all DataGenerator
* implementations (Llama, OpenAI, ...).
*/
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include "data_model/generated_models.h"
/**
* @brief Normalizes and truncates regional context.
*
* @param region_context Input regional context text.
* @param max_chars Maximum output length.
* @return Processed region context.
*/
std::string PrepareRegionContext(std::string_view region_context,
size_t max_chars = 2000);
/**
* @brief Validates and parses brewery JSON output.
*
* @param raw Raw model output.
* @param brewery_out Parsed brewery payload.
* @return Validation error message if invalid, or std::nullopt on success.
*/
std::optional<std::string> ValidateBreweryJson(const std::string& raw,
BreweryResult& brewery_out);
/**
* @brief Validates and parses user JSON output.
*
* Only populates `username`, `bio`, and `activity_weight` -- `first_name`
* and `last_name` are not LLM-authored and are set separately from the
* sampled Name.
*
* @param raw Raw model output.
* @param user_out Parsed user payload.
* @return Validation error message if invalid, or std::nullopt on success.
*/
std::optional<std::string> ValidateUserJson(const std::string& raw,
UserResult& user_out);
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_GENERATION_JSON_VALIDATION_H_

View File

@@ -3,30 +3,17 @@
/** /**
* @file data_generation/llama_generator_helpers.h * @file data_generation/llama_generator_helpers.h
* @brief Shared helper APIs used by LlamaGenerator translation units. * @brief Llama.cpp-specific helper APIs used by LlamaGenerator translation
* units. Generator-agnostic helpers (prompt-context prep, JSON validation)
* live in data_generation/generation_json_validation.h instead.
*/ */
#include <cstddef>
#include <cstdint> #include <cstdint>
#include <optional>
#include <string> #include <string>
#include <string_view>
#include "data_model/generated_models.h"
struct llama_vocab; struct llama_vocab;
using llama_token = int32_t; using llama_token = int32_t;
/**
* @brief Normalizes and truncates regional context.
*
* @param region_context Input regional context text.
* @param max_chars Maximum output length.
* @return Processed region context.
*/
std::string PrepareRegionContext(std::string_view region_context,
size_t max_chars = 2000);
/** /**
* @brief Decodes a sampled token and appends it to output text. * @brief Decodes a sampled token and appends it to output text.
* *
@@ -37,28 +24,4 @@ std::string PrepareRegionContext(std::string_view region_context,
void AppendTokenPiece(const llama_vocab* vocab, llama_token token, void AppendTokenPiece(const llama_vocab* vocab, llama_token token,
std::string& output); std::string& output);
/**
* @brief Validates and parses brewery JSON output.
*
* @param raw Raw model output.
* @param brewery_out Parsed brewery payload.
* @return Validation error message if invalid, or std::nullopt on success.
*/
std::optional<std::string> ValidateBreweryJson(const std::string& raw,
BreweryResult& brewery_out);
/**
* @brief Validates and parses user JSON output.
*
* Only populates `username`, `bio`, and `activity_weight` -- `first_name`
* and `last_name` are not LLM-authored and are set separately from the
* sampled Name.
*
* @param raw Raw model output.
* @param user_out Parsed user payload.
* @return Validation error message if invalid, or std::nullopt on success.
*/
std::optional<std::string> ValidateUserJson(const std::string& raw,
UserResult& user_out);
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_HELPERS_H_ #endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_HELPERS_H_

View File

@@ -0,0 +1,104 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_OPENAI_GENERATOR_H_
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_OPENAI_GENERATOR_H_
/**
* @file data_generation/openai_generator.h
* @brief OpenAI Chat Completions API-backed implementation of DataGenerator.
*/
#include <memory>
#include <string>
#include <string_view>
#include "data_generation/data_generator.h"
#include "data_model/models.h"
#include "services/logging/logger.h"
#include "services/prompting/prompt_directory.h"
#include "web_client/web_client.h"
/**
* @brief Data generator implementation backed by the OpenAI Chat
* Completions API.
*
* Unlike LlamaGenerator, this generator has no local model state and no
* chat-template formatting step -- Chat Completions takes system/user
* messages directly, and Structured Outputs (response_format: json_schema,
* strict: true) guarantee schema-valid JSON, replacing llama.cpp's GBNF
* grammar constraint.
*/
class OpenAIGenerator final : public DataGenerator {
public:
/**
* @brief Constructs a generator that calls the OpenAI Chat Completions
* API.
*
* @param api_key OpenAI API key (from the OPENAI_API_KEY environment
* variable -- never accepted as a CLI argument).
* @param model OpenAI model ID, e.g. "gpt-4o-mini".
* @param logger Sink for pipeline diagnostics.
* @param prompt_directory Directory service for loading named prompt
* files (the same BREWERY_GENERATION/USER_GENERATION prompts Llama
* uses -- they're backend-agnostic).
* @param web_client HTTP client used to reach the Chat Completions API.
*/
OpenAIGenerator(std::string api_key, std::string model,
std::shared_ptr<ILogger> logger,
std::unique_ptr<IPromptDirectory> prompt_directory,
std::unique_ptr<WebClient> web_client);
~OpenAIGenerator() override = default;
OpenAIGenerator(const OpenAIGenerator&) = delete;
OpenAIGenerator& operator=(const OpenAIGenerator&) = delete;
OpenAIGenerator(OpenAIGenerator&&) = delete;
OpenAIGenerator& operator=(OpenAIGenerator&&) = delete;
/**
* @brief Generates brewery data for a specific location.
*
* @param enriched_city Enriched city the brewery is associated with.
* @return Generated brewery result.
*/
BreweryResult GenerateBrewery(const EnrichedCity& enriched_city) override;
/**
* @brief Generates a user profile grounded in a sampled name and persona.
*
* @param city Enriched city the user is associated with.
* @param persona Persona archetype grounding the generated bio.
* @param name Sampled first/last name -- not LLM-invented.
* @return Generated user profile.
*/
UserResult GenerateUser(const EnrichedCity& city, const UserPersona& persona,
const Name& name) override;
private:
/**
* @brief Sends a single Chat Completions request with a Structured
* Outputs schema and returns the raw JSON text of the response.
*
* @param system_prompt System role message content.
* @param user_prompt User role message content.
* @param json_schema Raw JSON Schema text constraining the response
* shape (see data_generation/openai_json_schemas.h).
* @param schema_name Identifier for the schema, required by the
* response_format.json_schema wrapper.
* @param max_tokens Maximum completion tokens to generate.
* @return Raw JSON text produced by the model.
* @throws std::runtime_error on a request/HTTP failure, a safety-policy
* refusal, or a response with no message content.
*/
std::string CallChatCompletionsApi(const std::string& system_prompt,
const std::string& user_prompt,
std::string_view json_schema,
std::string_view schema_name,
int max_tokens);
std::string api_key_;
std::string model_;
std::shared_ptr<ILogger> logger_;
std::unique_ptr<IPromptDirectory> prompt_directory_;
std::unique_ptr<WebClient> web_client_;
};
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_OPENAI_GENERATOR_H_

View File

@@ -0,0 +1,46 @@
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_OPENAI_JSON_SCHEMAS_H_
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_OPENAI_JSON_SCHEMAS_H_
/**
* @file data_generation/openai_json_schemas.h
* @brief JSON Schemas constraining structured output from OpenAIGenerator
* Chat Completions requests, via response_format.json_schema (Structured
* Outputs, strict mode). This is OpenAI's equivalent of the GBNF grammars
* in json_grammars.h -- the API guarantees the response message content is
* a single JSON string that validates against the schema, so there's no
* grammar-level "thought-block" prefix to account for.
*
* OpenAI's strict mode requires "additionalProperties": false on every
* object in the schema, which these already have.
*/
#include <string_view>
inline constexpr std::string_view kBreweryJsonSchema = R"json_brewery(
{
"type": "object",
"properties": {
"name_en": {"type": "string"},
"description_en": {"type": "string"},
"name_local": {"type": "string"},
"description_local": {"type": "string"}
},
"required": ["name_en", "description_en", "name_local", "description_local"],
"additionalProperties": false
}
)json_brewery";
inline constexpr std::string_view kUserJsonSchema = R"json_user(
{
"type": "object",
"properties": {
"username": {"type": "string"},
"bio": {"type": "string"},
"activity_weight": {"type": "number"}
},
"required": ["username", "bio", "activity_weight"],
"additionalProperties": false
}
)json_user";
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_OPENAI_JSON_SCHEMAS_H_

View File

@@ -187,25 +187,47 @@ struct SamplingOptions {
int n_gpu_layers = 0; int n_gpu_layers = 0;
}; };
/**
* @brief Selects which DataGenerator backend the pipeline uses.
*/
enum class GeneratorMode {
Mock,
Llama,
OpenAI,
};
/** /**
* @brief Configuration for the LLM generator component. * @brief Configuration for the LLM generator component.
*/ */
struct GeneratorOptions { struct GeneratorOptions {
/** /**
* @brief Path to the LLM model file (gguf format). * @brief Path to the LLM model file (gguf format). Only meaningful for
* GeneratorMode::Llama.
*/ */
std::filesystem::path model_path; std::filesystem::path model_path;
/** /**
* @brief Use mocked generator instead of actual LLM inference. * @brief Which generator backend to use.
*/ */
bool use_mocked = false; GeneratorMode mode = GeneratorMode::Mock;
/** /**
* @brief Specific sampling parameters for this generator. * @brief Specific sampling parameters for this generator. Only
* If nullopt, the application should use global defaults. * meaningful for GeneratorMode::Llama; if nullopt, LlamaGenerator uses
* its own defaults.
*/ */
std::optional<SamplingOptions> sampling; std::optional<SamplingOptions> sampling;
/**
* @brief OpenAI API key, read from the OPENAI_API_KEY environment
* variable. Only populated for GeneratorMode::OpenAI.
*/
std::string openai_api_key;
/**
* @brief OpenAI model ID used by OpenAIGenerator.
*/
std::string openai_model = "gpt-4o-mini";
}; };
/** /**

View File

@@ -9,6 +9,7 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector>
#include "services/logging/logger.h" #include "services/logging/logger.h"
#include "web_client/web_client.h" #include "web_client/web_client.h"
@@ -39,6 +40,22 @@ class HttpWebClient final : public WebClient {
*/ */
std::string Get(const std::string& url) override; std::string Get(const std::string& url) override;
/**
* @brief Executes a blocking HTTP/HTTPS POST request against a full URL.
*
* Sends @p body with a "Content-Type: application/json" header, plus any
* additional headers supplied by the caller (e.g. authentication).
*
* @param url Fully-qualified URL.
* @param body Request body, sent as-is.
* @param headers Additional request headers.
* @return Response body on HTTP 2xx; throws std::runtime_error otherwise.
*/
std::string Post(
const std::string& url, const std::string& body,
const std::vector<std::pair<std::string, std::string>>& headers)
override;
/** /**
* @brief Percent-encodes a single URI component (query parameter value or * @brief Percent-encodes a single URI component (query parameter value or
* path segment). Delegates to httplib::encode_uri_component(). * path segment). Delegates to httplib::encode_uri_component().

View File

@@ -7,6 +7,8 @@
*/ */
#include <string> #include <string>
#include <utility>
#include <vector>
/** /**
* @brief Abstract web client interface. * @brief Abstract web client interface.
@@ -23,6 +25,20 @@ class WebClient {
*/ */
virtual std::string Get(const std::string& url) = 0; virtual std::string Get(const std::string& url) = 0;
/**
* @brief Executes an HTTP POST request with a JSON body.
*
* @param url Request URL.
* @param body Request body (sent as-is; callers are responsible for
* providing valid JSON when the target API expects it).
* @param headers Additional request headers (e.g. authentication),
* sent alongside a fixed "Content-Type: application/json" header.
* @return Response body.
*/
virtual std::string Post(
const std::string& url, const std::string& body,
const std::vector<std::pair<std::string, std::string>>& headers) = 0;
/** /**
* @brief URL-encodes a string value. * @brief URL-encodes a string value.
* *

View File

@@ -1,4 +1,5 @@
#include <chrono> #include <chrono>
#include <cstdlib>
#include <format> #include <format>
#include <iostream> #include <iostream>
#include <optional> #include <optional>
@@ -37,13 +38,20 @@ std::optional<ApplicationOptions> ParseArguments(
"Number of layers to offload to GPU"); "Number of layers to offload to GPU");
}; };
// --mocked and --model are mutually exclusive; validation is enforced below // --mocked, --model, and --openai are mutually exclusive; validation is
// rather than at registration to produce a clear diagnostic message. // enforced below rather than at registration to produce a clear
// diagnostic message.
auto add_generator_options = [&]() -> void { auto add_generator_options = [&]() -> void {
opt("mocked", prog_opts::bool_switch(), opt("mocked", prog_opts::bool_switch(),
"Use mocked generator for brewery/user data"); "Use mocked generator for brewery/user data");
opt("model,m", prog_opts::value<std::string>()->default_value(""), opt("model,m", prog_opts::value<std::string>()->default_value(""),
"Path to LLM model (gguf)"); "Path to LLM model (gguf)");
opt("openai", prog_opts::bool_switch(),
"Use the OpenAI API (Chat Completions) for brewery/user data. "
"Requires the OPENAI_API_KEY environment variable.");
opt("openai-model",
prog_opts::value<std::string>()->default_value("gpt-4o-mini"),
"OpenAI model ID used when --openai is set");
}; };
auto add_pipeline_options = [&]() -> void { auto add_pipeline_options = [&]() -> void {
@@ -109,14 +117,19 @@ std::optional<ApplicationOptions> ParseArguments(
const bool use_mocked = var_map["mocked"].as<bool>(); const bool use_mocked = var_map["mocked"].as<bool>();
const std::string model_path = var_map["model"].as<std::string>(); const std::string model_path = var_map["model"].as<std::string>();
const bool use_openai = var_map["openai"].as<bool>();
const int n_gpu_layers = var_map["n-gpu-layers"].as<int>(); const int n_gpu_layers = var_map["n-gpu-layers"].as<int>();
// Enforce mutual exclusivity before any further configuration is // Enforce mutual exclusivity before any further configuration is
// applied. // applied.
if (use_mocked && !model_path.empty()) { const int selected_generator_count =
(use_mocked ? 1 : 0) + (!model_path.empty() ? 1 : 0) +
(use_openai ? 1 : 0);
if (selected_generator_count > 1) {
const std::string msg = const std::string msg =
"Invalid arguments: --mocked and --model are mutually " "Invalid arguments: --mocked, --model, and --openai are "
"exclusive"; "mutually exclusive";
if (logger) { if (logger) {
logger->Log(LogDTO{.level = LogLevel::Error, logger->Log(LogDTO{.level = LogLevel::Error,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
@@ -127,10 +140,10 @@ std::optional<ApplicationOptions> ParseArguments(
return std::nullopt; return std::nullopt;
} }
if (!use_mocked && model_path.empty()) { if (selected_generator_count == 0) {
const std::string msg = const std::string msg =
"Invalid arguments: either --mocked or --model must be " "Invalid arguments: exactly one of --mocked, --model, or "
"specified"; "--openai must be specified";
if (logger) { if (logger) {
logger->Log(LogDTO{.level = LogLevel::Error, logger->Log(LogDTO{.level = LogLevel::Error,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
@@ -141,8 +154,9 @@ std::optional<ApplicationOptions> ParseArguments(
return std::nullopt; return std::nullopt;
} }
// Prompt directory is only meaningful for live inference — the mock // Prompt directory is only meaningful for live inference (Llama or
// generator has no use for it and should not require it to be present. // OpenAI) — the mock generator has no use for it and should not
// require it to be present.
if (!use_mocked && options.pipeline.prompt_dir.empty()) { if (!use_mocked && options.pipeline.prompt_dir.empty()) {
const std::string msg = const std::string msg =
"Invalid arguments: --prompt-dir is required when not using " "Invalid arguments: --prompt-dir is required when not using "
@@ -157,8 +171,29 @@ std::optional<ApplicationOptions> ParseArguments(
return std::nullopt; return std::nullopt;
} }
options.generator.use_mocked = use_mocked; if (use_openai) {
const char* api_key_env = std::getenv("OPENAI_API_KEY");
if (api_key_env == nullptr || api_key_env[0] == '\0') {
const std::string msg =
"Invalid arguments: the OPENAI_API_KEY environment "
"variable must be set when using --openai";
if (logger) {
logger->Log({.level = LogLevel::Error,
.phase = PipelinePhase::Startup,
.message = msg});
} else {
std::cerr << msg << std::endl;
}
return std::nullopt;
}
options.generator.openai_api_key = api_key_env;
}
options.generator.mode = use_mocked ? GeneratorMode::Mock
: use_openai ? GeneratorMode::OpenAI
: GeneratorMode::Llama;
options.generator.model_path = model_path; options.generator.model_path = model_path;
options.generator.openai_model = var_map["openai-model"].as<std::string>();
// Only populate sampling config when the user explicitly overrides at // Only populate sampling config when the user explicitly overrides at
// least one value. Leaving it as std::nullopt lets LlamaGenerator fall // least one value. Leaving it as std::nullopt lets LlamaGenerator fall
@@ -172,10 +207,11 @@ std::optional<ApplicationOptions> ParseArguments(
if (user_provided_sampling) { if (user_provided_sampling) {
// Warn but do not fail — the run is still valid, the flags are just // Warn but do not fail — the run is still valid, the flags are just
// silently irrelevant when no model is loaded. // silently irrelevant when no local model is loaded.
if (use_mocked) { if (options.generator.mode != GeneratorMode::Llama) {
const std::string msg = const std::string msg =
"Sampling parameters are ignored when using --mocked"; "Sampling parameters are ignored unless using --model "
"(Llama)";
if (logger) { if (logger) {
logger->Log(LogDTO{.level = LogLevel::Warn, logger->Log(LogDTO{.level = LogLevel::Warn,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,

View File

@@ -43,13 +43,6 @@ std::vector<City> BiergartenPipelineOrchestrator::QueryLocations() {
" Locations available: {}\n Sampled Locations: {}", " Locations available: {}\n Sampled Locations: {}",
all_locations.size(), sampled_locations.size())}); all_locations.size(), sampled_locations.size())});
for (const auto& location : all_locations) {
logger_->Log({.level = LogLevel::Info,
.phase = PipelinePhase::Startup,
.message = std::format("{} {} {} {}", location.city,
location.country, location.longitude,
location.latitude)});
}
return sampled_locations; return sampled_locations;
} }

View File

@@ -0,0 +1,227 @@
/**
* @file data_generation/generation_json_validation.cc
* @brief Generator-agnostic prompt-context preparation and structured JSON
* validation, shared across all DataGenerator implementations.
*/
#include "data_generation/generation_json_validation.h"
#include <algorithm>
#include <array>
#include <boost/json.hpp>
#include <cctype>
#include <span>
#include <string>
#include <string_view>
namespace {
/**
* String trimming: removes leading and trailing whitespace
*/
std::string Trim(std::string_view value) {
constexpr std::string_view whitespace = " \t\n\r\f\v";
const size_t first_index = value.find_first_not_of(whitespace);
if (first_index == std::string_view::npos) {
return {};
}
const size_t last_index = value.find_last_not_of(whitespace);
return std::string(value.substr(first_index, last_index - first_index + 1));
}
/**
* Normalize whitespace: collapses multiple spaces/tabs/newlines into single
* spaces
*/
std::string CondenseWhitespace(std::string_view text) {
std::string out;
out.reserve(text.size());
bool pending_space = false;
for (const char chr : text) {
if (std::isspace(static_cast<unsigned char>(chr)) != 0) {
if (!out.empty()) {
pending_space = true;
}
continue;
}
if (pending_space) {
out.push_back(' ');
pending_space = false;
}
out.push_back(chr);
}
return out;
}
// Guard against truncating in the first half of the string.
// This preserves the critical opening content and avoids cutting critical
// context words early in the region description.
constexpr size_t kTruncationGuardDivisor = 2;
bool ReadRequiredTrimmedStringField(const boost::json::object& obj,
std::string_view key, std::string& out,
std::string* error_out) {
const boost::json::value* field = obj.if_contains(key);
if (field == nullptr || !field->is_string()) {
return false;
}
const auto& string_value = field->as_string();
out = Trim(std::string_view(string_value.data(), string_value.size()));
return !out.empty();
}
bool HasSchemaPlaceholder(std::span<std::string* const> values) {
for (const std::string* value : values) {
std::string lowered = *value;
std::ranges::transform(
lowered, lowered.begin(), [](const unsigned char character) {
return static_cast<char>(std::tolower(character));
});
if (lowered == "string") {
return true;
}
}
return false;
}
} // namespace
/**
* Truncate region context to fit within max length while preserving word
* boundaries
*/
std::string PrepareRegionContext(std::string_view region_context,
const size_t max_chars) {
std::string normalized = CondenseWhitespace(region_context);
if (normalized.size() <= max_chars) {
return normalized;
}
normalized.resize(max_chars);
const size_t last_space = normalized.find_last_of(' ');
if (last_space != std::string::npos &&
last_space > max_chars / kTruncationGuardDivisor) {
normalized.resize(last_space);
}
normalized += "...";
return normalized;
}
std::optional<std::string> ValidateBreweryJson(const std::string& raw,
BreweryResult& brewery_out) {
boost::system::error_code error_code;
const std::string_view raw_view(raw);
const size_t opening_brace = raw_view.find('{');
if (opening_brace == std::string_view::npos) {
return "JSON parse error: missing opening brace '{'";
}
const std::string_view json_payload = raw_view.substr(opening_brace);
boost::json::value json_value = boost::json::parse(json_payload, error_code);
if (error_code) {
return "JSON parse error: " + error_code.message();
}
if (!json_value.is_object()) {
return "JSON root must be an object";
}
const auto& obj = json_value.get_object();
if (obj.size() != 4) {
return "JSON object must contain exactly four keys";
}
std::string validation_error;
if (!ReadRequiredTrimmedStringField(obj, "name_en", brewery_out.name_en,
&validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(obj, "description_en",
brewery_out.description_en,
&validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(
obj, "name_local", brewery_out.name_local, &validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(obj, "description_local",
brewery_out.description_local,
&validation_error)) {
return validation_error;
}
const std::array schema_placeholders = {
&brewery_out.name_en, &brewery_out.description_en,
&brewery_out.name_local, &brewery_out.description_local};
if (HasSchemaPlaceholder(schema_placeholders)) {
return "JSON appears to be a schema placeholder, not content";
}
return std::nullopt;
}
std::optional<std::string> ValidateUserJson(const std::string& raw,
UserResult& user_out) {
boost::system::error_code error_code;
const std::string_view raw_view(raw);
const size_t opening_brace = raw_view.find('{');
if (opening_brace == std::string_view::npos) {
return "JSON parse error: missing opening brace '{'";
}
const std::string_view json_payload = raw_view.substr(opening_brace);
boost::json::value json_value = boost::json::parse(json_payload, error_code);
if (error_code) {
return "JSON parse error: " + error_code.message();
}
if (!json_value.is_object()) {
return "JSON root must be an object";
}
const auto& obj = json_value.get_object();
if (obj.size() != 3) {
return "JSON object must contain exactly three keys";
}
std::string validation_error;
if (!ReadRequiredTrimmedStringField(obj, "username", user_out.username,
&validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(obj, "bio", user_out.bio,
&validation_error)) {
return validation_error;
}
const boost::json::value* activity_weight_field =
obj.if_contains("activity_weight");
if (activity_weight_field == nullptr ||
!activity_weight_field->is_number()) {
return "Missing or invalid numeric field: activity_weight";
}
const double activity_weight = activity_weight_field->to_number<double>();
if (activity_weight < 0.0 || activity_weight > 1.0) {
return "activity_weight must be between 0 and 1";
}
user_out.activity_weight = static_cast<float>(activity_weight);
const std::array schema_placeholders = {&user_out.username, &user_out.bio};
if (HasSchemaPlaceholder(schema_placeholders)) {
return "JSON appears to be a schema placeholder, not content";
}
return std::nullopt;
}

View File

@@ -12,9 +12,9 @@
#include <string_view> #include <string_view>
#include <vector> #include <vector>
#include "data_generation/generation_json_validation.h"
#include "data_generation/json_grammars.h" #include "data_generation/json_grammars.h"
#include "data_generation/llama_generator.h" #include "data_generation/llama_generator.h"
#include "data_generation/llama_generator_helpers.h"
static std::string FormatLocalLanguageCodes( static std::string FormatLocalLanguageCodes(
const std::vector<std::string>& codes) { const std::vector<std::string>& codes) {

View File

@@ -10,9 +10,9 @@
#include <string> #include <string>
#include <string_view> #include <string_view>
#include "data_generation/generation_json_validation.h"
#include "data_generation/json_grammars.h" #include "data_generation/json_grammars.h"
#include "data_generation/llama_generator.h" #include "data_generation/llama_generator.h"
#include "data_generation/llama_generator_helpers.h"
static constexpr int kUserInitialMaxTokens = 1200; static constexpr int kUserInitialMaxTokens = 1200;

View File

@@ -1,123 +1,17 @@
/** /**
* @file data_generation/llama/helpers.cc * @file data_generation/llama/helpers.cc
* @brief Provides prompt formatting, whitespace normalization, response * @brief Token decoding helper for LlamaGenerator modules.
* parsing, token decoding, and JSON validation helpers for Llama modules.
*/ */
#include <llama.h> #include <llama.h>
#include <algorithm>
#include <array> #include <array>
#include <boost/json.hpp>
#include <cctype>
#include <optional>
#include <span>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <string_view>
#include <vector> #include <vector>
#include "data_generation/llama_generator_helpers.h" #include "data_generation/llama_generator_helpers.h"
namespace {
/**
* String trimming: removes leading and trailing whitespace
*/
std::string Trim(std::string_view value) {
constexpr std::string_view whitespace = " \t\n\r\f\v";
const size_t first_index = value.find_first_not_of(whitespace);
if (first_index == std::string_view::npos) {
return {};
}
const size_t last_index = value.find_last_not_of(whitespace);
return std::string(value.substr(first_index, last_index - first_index + 1));
}
/**
* Normalize whitespace: collapses multiple spaces/tabs/newlines into single
* spaces
*/
std::string CondenseWhitespace(std::string_view text) {
std::string out;
out.reserve(text.size());
bool pending_space = false;
for (const char chr : text) {
if (std::isspace(static_cast<unsigned char>(chr)) != 0) {
if (!out.empty()) {
pending_space = true;
}
continue;
}
if (pending_space) {
out.push_back(' ');
pending_space = false;
}
out.push_back(chr);
}
return out;
}
// Guard against truncating in the first half of the string.
// This preserves the critical opening content and avoids cutting critical
// context words early in the region description.
constexpr size_t kTruncationGuardDivisor = 2;
bool ReadRequiredTrimmedStringField(const boost::json::object& obj,
std::string_view key, std::string& out,
std::string* error_out) {
const boost::json::value* field = obj.if_contains(key);
if (field == nullptr || !field->is_string()) {
return false;
}
const auto& string_value = field->as_string();
out = Trim(std::string_view(string_value.data(), string_value.size()));
return !out.empty();
}
bool HasSchemaPlaceholder(std::span<std::string* const> values) {
for (const std::string* value : values) {
std::string lowered = *value;
std::ranges::transform(
lowered, lowered.begin(), [](const unsigned char character) {
return static_cast<char>(std::tolower(character));
});
if (lowered == "string") {
return true;
}
}
return false;
}
} // namespace
/**
* Truncate region context to fit within max length while preserving word
* boundaries
*/
std::string PrepareRegionContext(std::string_view region_context,
const size_t max_chars) {
std::string normalized = CondenseWhitespace(region_context);
if (normalized.size() <= max_chars) {
return normalized;
}
normalized.resize(max_chars);
const size_t last_space = normalized.find_last_of(' ');
if (last_space != std::string::npos &&
last_space > max_chars / kTruncationGuardDivisor) {
normalized.resize(last_space);
}
normalized += "...";
return normalized;
}
void AppendTokenPiece(const llama_vocab* vocab, llama_token token, void AppendTokenPiece(const llama_vocab* vocab, llama_token token,
std::string& output) { std::string& output) {
constexpr size_t initial_buffer_size = 256; constexpr size_t initial_buffer_size = 256;
@@ -153,116 +47,3 @@ void AppendTokenPiece(const llama_vocab* vocab, llama_token token,
throw std::runtime_error( throw std::runtime_error(
"LlamaGenerator: failed to decode sampled token piece"); "LlamaGenerator: failed to decode sampled token piece");
} }
std::optional<std::string> ValidateBreweryJson(const std::string& raw,
BreweryResult& brewery_out) {
boost::system::error_code error_code;
const std::string_view raw_view(raw);
const size_t opening_brace = raw_view.find('{');
if (opening_brace == std::string_view::npos) {
return "JSON parse error: missing opening brace '{'";
}
const std::string_view json_payload = raw_view.substr(opening_brace);
boost::json::value json_value = boost::json::parse(json_payload, error_code);
if (error_code) {
return "JSON parse error: " + error_code.message();
}
if (!json_value.is_object()) {
return "JSON root must be an object";
}
const auto& obj = json_value.get_object();
if (obj.size() != 4) {
return "JSON object must contain exactly four keys";
}
std::string validation_error;
if (!ReadRequiredTrimmedStringField(obj, "name_en", brewery_out.name_en,
&validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(obj, "description_en",
brewery_out.description_en,
&validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(
obj, "name_local", brewery_out.name_local, &validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(obj, "description_local",
brewery_out.description_local,
&validation_error)) {
return validation_error;
}
const std::array schema_placeholders = {
&brewery_out.name_en, &brewery_out.description_en,
&brewery_out.name_local, &brewery_out.description_local};
if (HasSchemaPlaceholder(schema_placeholders)) {
return "JSON appears to be a schema placeholder, not content";
}
return std::nullopt;
}
std::optional<std::string> ValidateUserJson(const std::string& raw,
UserResult& user_out) {
boost::system::error_code error_code;
const std::string_view raw_view(raw);
const size_t opening_brace = raw_view.find('{');
if (opening_brace == std::string_view::npos) {
return "JSON parse error: missing opening brace '{'";
}
const std::string_view json_payload = raw_view.substr(opening_brace);
boost::json::value json_value = boost::json::parse(json_payload, error_code);
if (error_code) {
return "JSON parse error: " + error_code.message();
}
if (!json_value.is_object()) {
return "JSON root must be an object";
}
const auto& obj = json_value.get_object();
if (obj.size() != 3) {
return "JSON object must contain exactly three keys";
}
std::string validation_error;
if (!ReadRequiredTrimmedStringField(obj, "username", user_out.username,
&validation_error)) {
return validation_error;
}
if (!ReadRequiredTrimmedStringField(obj, "bio", user_out.bio,
&validation_error)) {
return validation_error;
}
const boost::json::value* activity_weight_field =
obj.if_contains("activity_weight");
if (activity_weight_field == nullptr ||
!activity_weight_field->is_number()) {
return "Missing or invalid numeric field: activity_weight";
}
const double activity_weight = activity_weight_field->to_number<double>();
if (activity_weight < 0.0 || activity_weight > 1.0) {
return "activity_weight must be between 0 and 1";
}
user_out.activity_weight = static_cast<float>(activity_weight);
const std::array schema_placeholders = {&user_out.username, &user_out.bio};
if (HasSchemaPlaceholder(schema_placeholders)) {
return "JSON appears to be a schema placeholder, not content";
}
return std::nullopt;
}

View File

@@ -0,0 +1,139 @@
/**
* @file data_generation/openai/call_chat_completions_api.cc
* @brief OpenAIGenerator::CallChatCompletionsApi() -- builds and sends a
* single OpenAI Chat Completions request with a Structured Outputs JSON
* schema, and extracts the resulting message content.
*/
#include <boost/json.hpp>
#include <format>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "data_generation/openai_generator.h"
namespace {
constexpr std::string_view kChatCompletionsUrl =
"https://api.openai.com/v1/chat/completions";
} // namespace
std::string OpenAIGenerator::CallChatCompletionsApi(
const std::string& system_prompt, const std::string& user_prompt,
std::string_view json_schema, std::string_view schema_name,
int max_tokens) {
boost::system::error_code schema_error;
boost::json::value schema_value =
boost::json::parse(json_schema, schema_error);
if (schema_error) {
throw std::runtime_error(
"OpenAIGenerator: invalid embedded JSON schema: " +
schema_error.message());
}
boost::json::object json_schema_wrapper;
json_schema_wrapper["name"] = std::string(schema_name);
json_schema_wrapper["strict"] = true;
json_schema_wrapper["schema"] = std::move(schema_value);
boost::json::object response_format;
response_format["type"] = "json_schema";
response_format["json_schema"] = std::move(json_schema_wrapper);
boost::json::object system_message;
system_message["role"] = "system";
system_message["content"] = system_prompt;
boost::json::object user_message;
user_message["role"] = "user";
user_message["content"] = user_prompt;
boost::json::array messages;
messages.push_back(std::move(system_message));
messages.push_back(std::move(user_message));
boost::json::object request;
request["model"] = model_;
request["max_completion_tokens"] = max_tokens;
request["messages"] = std::move(messages);
request["response_format"] = std::move(response_format);
const std::string body = boost::json::serialize(request);
const std::vector<std::pair<std::string, std::string>> headers = {
{"Authorization", "Bearer " + api_key_},
};
const std::string response_body =
web_client_->Post(std::string(kChatCompletionsUrl), body, headers);
boost::system::error_code response_error;
boost::json::value response_value =
boost::json::parse(response_body, response_error);
if (response_error) {
throw std::runtime_error(
"OpenAIGenerator: failed to parse API response: " +
response_error.message());
}
if (!response_value.is_object()) {
throw std::runtime_error(
"OpenAIGenerator: API response root is not an object");
}
const auto& response_obj = response_value.get_object();
// A top-level "error" object means the request itself was rejected
// (bad API key, invalid model, rate limit, etc.) rather than the model
// producing an unusable completion.
if (const boost::json::value* error_field = response_obj.if_contains("error");
error_field != nullptr && error_field->is_object()) {
std::string error_message = "unknown error";
if (const boost::json::value* message_field =
error_field->get_object().if_contains("message");
message_field != nullptr && message_field->is_string()) {
error_message = std::string(message_field->as_string());
}
throw std::runtime_error(
std::format("OpenAIGenerator: API request failed: {}", error_message));
}
const boost::json::value* choices_field = response_obj.if_contains("choices");
if (choices_field == nullptr || !choices_field->is_array() ||
choices_field->get_array().empty()) {
throw std::runtime_error(
"OpenAIGenerator: API response missing choices array");
}
const boost::json::value& first_choice = choices_field->get_array().front();
if (!first_choice.is_object()) {
throw std::runtime_error(
"OpenAIGenerator: API response choice is not an object");
}
const auto& choice_obj = first_choice.get_object();
const boost::json::value* message_field = choice_obj.if_contains("message");
if (message_field == nullptr || !message_field->is_object()) {
throw std::runtime_error(
"OpenAIGenerator: API response choice missing message");
}
const auto& message_obj = message_field->get_object();
if (const boost::json::value* refusal_field =
message_obj.if_contains("refusal");
refusal_field != nullptr && refusal_field->is_string() &&
!refusal_field->as_string().empty()) {
throw std::runtime_error(std::format(
"OpenAIGenerator: request refused by safety policies: {}",
std::string(refusal_field->as_string())));
}
const boost::json::value* content_field = message_obj.if_contains("content");
if (content_field == nullptr || !content_field->is_string()) {
throw std::runtime_error(
"OpenAIGenerator: API response message had no text content");
}
return std::string(content_field->as_string());
}

View File

@@ -0,0 +1,132 @@
/**
* @file data_generation/openai/generate_brewery.cc
* @brief Builds brewery prompts with regional context, calls the OpenAI
* Chat Completions API, and validates structured JSON output for brewery
* records.
*/
#include <format>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
#include "data_generation/generation_json_validation.h"
#include "data_generation/openai_generator.h"
#include "data_generation/openai_json_schemas.h"
namespace {
std::string FormatLocalLanguageCodes(
const std::vector<std::string>& codes) {
if (codes.empty()) {
return "Not provided";
}
std::string formatted;
for (const std::string& code : codes) {
if (!formatted.empty()) {
formatted += ", ";
}
formatted += code;
}
return formatted;
}
constexpr int kBreweryMaxTokens = 2800;
// Structured outputs already guarantee schema-valid JSON, so retries here
// are only for transient failures or the model returning placeholder text
// -- not for malformed JSON shape, unlike LlamaGenerator's grammar-assisted
// retry loop.
constexpr int kMaxAttempts = 2;
} // namespace
BreweryResult OpenAIGenerator::GenerateBrewery(
const EnrichedCity& enriched_city) {
const City& location = enriched_city.location;
const std::string& region_context = enriched_city.region_context;
const std::string safe_region_context = PrepareRegionContext(region_context);
const std::string local_language_codes =
FormatLocalLanguageCodes(location.local_languages);
/**
* Load brewery system prompt via the injected prompt directory. The
* same file LlamaGenerator uses -- the prompt content is backend
* agnostic.
*/
const std::string system_prompt =
prompt_directory_->Load("BREWERY_GENERATION");
std::string user_prompt = std::format(
"## CITY:\n{}\n\n## COUNTRY:\n{}\n\n## LOCAL LANGUAGE CODES:\n{}\n\n## "
"CONTEXT:\n{}",
location.city, location.country, local_language_codes,
safe_region_context);
const std::string retry_location =
std::format("City: {}, {}\nLocal language codes: {}", location.city,
location.country, local_language_codes);
std::string raw;
std::string last_error;
for (int attempt = 0; attempt < kMaxAttempts; ++attempt) {
raw = CallChatCompletionsApi(system_prompt, user_prompt,
kBreweryJsonSchema, "brewery_result",
kBreweryMaxTokens);
if (logger_) {
logger_->Log({.level = LogLevel::Debug,
.phase = PipelinePhase::BreweryAndBeerGeneration,
.message = std::format(
"OpenAIGenerator: raw output (attempt {}): {}",
attempt + 1, raw)});
}
BreweryResult brewery;
const std::optional<std::string> validation_error =
ValidateBreweryJson(raw, brewery);
if (!validation_error.has_value()) {
if (logger_) {
logger_->Log({.level = LogLevel::Info,
.phase = PipelinePhase::BreweryAndBeerGeneration,
.message = std::format(
"OpenAIGenerator: successfully generated "
"brewery data on attempt {}",
attempt + 1)});
}
return brewery;
}
last_error = *validation_error;
if (logger_) {
logger_->Log(
{.level = LogLevel::Warn,
.phase = PipelinePhase::BreweryAndBeerGeneration,
.message = std::format(
"OpenAIGenerator: malformed brewery JSON (attempt {}): {}",
attempt + 1, *validation_error)});
}
user_prompt = std::format(
"Your previous response was invalid. Error: {}\nReturn real "
"content matching the required schema -- do not return "
"placeholder values.\n\n{}",
*validation_error, retry_location);
}
if (logger_) {
logger_->Log({.level = LogLevel::Error,
.phase = PipelinePhase::BreweryAndBeerGeneration,
.message = std::format(
"OpenAIGenerator: malformed brewery "
"response after {} attempts: {}",
kMaxAttempts, last_error.empty() ? raw : last_error)});
}
throw std::runtime_error("OpenAIGenerator: malformed brewery response");
}

View File

@@ -0,0 +1,114 @@
/**
* @file data_generation/openai/generate_user.cc
* @brief Builds persona/name-grounded user prompts, calls the OpenAI Chat
* Completions API, and validates structured JSON output for user records.
*/
#include <format>
#include <optional>
#include <stdexcept>
#include <string>
#include "data_generation/generation_json_validation.h"
#include "data_generation/openai_generator.h"
#include "data_generation/openai_json_schemas.h"
namespace {
constexpr int kUserMaxTokens = 1200;
// Structured outputs already guarantee schema-valid JSON, so retries here
// are only for transient failures or placeholder text -- not JSON shape.
constexpr int kMaxAttempts = 2;
} // namespace
UserResult OpenAIGenerator::GenerateUser(const EnrichedCity& city,
const UserPersona& persona,
const Name& name) {
std::string style_affinities;
for (const std::string& style : persona.style_affinities) {
if (!style_affinities.empty()) {
style_affinities += ", ";
}
style_affinities += style;
}
const std::string system_prompt = prompt_directory_->Load("USER_GENERATION");
std::string user_prompt = std::format(
"## NAME:\n\n{} {}\n\n"
"## GENDER:\n\n{}\n\n"
"## CITY:\n\n{}\n\n"
"## COUNTRY:\n\n{}\n\n"
"## PERSONA:\n\n{}\n\n"
"## PERSONA DESCRIPTION:\n\n{}\n\n"
"## STYLE AFFINITIES:\n\n{}",
name.first_name, name.last_name, name.gender, city.location.city,
city.location.country, persona.name, persona.description,
style_affinities);
const std::string retry_context = std::format(
"Name: {} {}\nCity: {}, {}\nPersona: {}", name.first_name,
name.last_name, city.location.city, city.location.country,
persona.name);
std::string raw;
std::string last_error;
for (int attempt = 0; attempt < kMaxAttempts; ++attempt) {
raw = CallChatCompletionsApi(system_prompt, user_prompt, kUserJsonSchema,
"user_result", kUserMaxTokens);
if (logger_) {
logger_->Log({.level = LogLevel::Debug,
.phase = PipelinePhase::UserGeneration,
.message = std::format(
"OpenAIGenerator: raw output (attempt {}): {}",
attempt + 1, raw)});
}
UserResult user;
const std::optional<std::string> validation_error =
ValidateUserJson(raw, user);
if (!validation_error.has_value()) {
if (logger_) {
logger_->Log(
{.level = LogLevel::Info,
.phase = PipelinePhase::UserGeneration,
.message = std::format("OpenAIGenerator: successfully "
"generated user data on attempt {}",
attempt + 1)});
}
user.first_name = name.first_name;
user.last_name = name.last_name;
user.gender = name.gender;
return user;
}
last_error = *validation_error;
if (logger_) {
logger_->Log(
{.level = LogLevel::Warn,
.phase = PipelinePhase::UserGeneration,
.message = std::format(
"OpenAIGenerator: malformed user JSON (attempt {}): {}",
attempt + 1, *validation_error)});
}
user_prompt = std::format(
"Your previous response was invalid. Error: {}\nReturn real "
"content matching the required schema -- do not return "
"placeholder values.\n\n{}",
*validation_error, retry_context);
}
if (logger_) {
logger_->Log({.level = LogLevel::Error,
.phase = PipelinePhase::UserGeneration,
.message = std::format(
"OpenAIGenerator: malformed user response "
"after {} attempts: {}",
kMaxAttempts, last_error.empty() ? raw : last_error)});
}
throw std::runtime_error("OpenAIGenerator: malformed user response");
}

View File

@@ -0,0 +1,20 @@
/**
* @file data_generation/openai/openai_generator.cc
* @brief OpenAIGenerator constructor.
*/
#include <memory>
#include <string>
#include <utility>
#include "data_generation/openai_generator.h"
OpenAIGenerator::OpenAIGenerator(
std::string api_key, std::string model, std::shared_ptr<ILogger> logger,
std::unique_ptr<IPromptDirectory> prompt_directory,
std::unique_ptr<WebClient> web_client)
: api_key_(std::move(api_key)),
model_(std::move(model)),
logger_(std::move(logger)),
prompt_directory_(std::move(prompt_directory)),
web_client_(std::move(web_client)) {}

View File

@@ -67,7 +67,7 @@ int main(const int argc, char** argv) {
std::unique_ptr<IPromptDirectory> prompt_directory; std::unique_ptr<IPromptDirectory> prompt_directory;
if (!options.generator.use_mocked) { if (options.generator.mode != GeneratorMode::Mock) {
try { try {
prompt_directory = std::make_unique<PromptDirectory>( prompt_directory = std::make_unique<PromptDirectory>(
options.pipeline.prompt_dir, log_producer); options.pipeline.prompt_dir, log_producer);
@@ -90,7 +90,7 @@ int main(const int argc, char** argv) {
di::bind<IPostalCodeService>().to( di::bind<IPostalCodeService>().to(
[options, [options,
&log_producer]() -> std::unique_ptr<IPostalCodeService> { &log_producer]() -> std::unique_ptr<IPostalCodeService> {
if (options.generator.use_mocked) { if (options.generator.mode == GeneratorMode::Mock) {
log_producer->Log( log_producer->Log(
{.level = LogLevel::Info, {.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
@@ -110,7 +110,7 @@ int main(const int argc, char** argv) {
di::bind<ICuratedDataService>().to( di::bind<ICuratedDataService>().to(
[options, [options,
&log_producer]() -> std::unique_ptr<ICuratedDataService> { &log_producer]() -> std::unique_ptr<ICuratedDataService> {
// if (options.generator.use_mocked) { // if (options.generator.mode == GeneratorMode::Mock) {
// log_producer->Log({.level = LogLevel::Info, // log_producer->Log({.level = LogLevel::Info,
// .phase = PipelinePhase::Startup, // .phase = PipelinePhase::Startup,
// .message = "Curated data: mock"}); // .message = "Curated data: mock"});
@@ -131,13 +131,12 @@ int main(const int argc, char** argv) {
}); });
}), }),
di::bind<IPromptFormatter>().to([options, log_producer] { di::bind<IPromptFormatter>().to([options, log_producer] {
if (options.generator.use_mocked) { if (options.generator.mode != GeneratorMode::Llama) {
{
log_producer->Log( log_producer->Log(
{.level = LogLevel::Info, {.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
.message = "Prompt formatter: none (mock mode)"}); .message = "Prompt formatter: none (not needed outside "
} "Llama mode)"});
return std::unique_ptr<IPromptFormatter>(nullptr); return std::unique_ptr<IPromptFormatter>(nullptr);
} }
{ {
@@ -151,7 +150,7 @@ int main(const int argc, char** argv) {
std::make_unique<Gemma4JinjaPromptFormatter>()); std::make_unique<Gemma4JinjaPromptFormatter>());
}), }),
di::bind<WebClient>().to([options, log_producer] { di::bind<WebClient>().to([options, log_producer] {
if (options.generator.use_mocked) { if (options.generator.mode == GeneratorMode::Mock) {
{ {
log_producer->Log( log_producer->Log(
{.level = LogLevel::Info, {.level = LogLevel::Info,
@@ -171,7 +170,7 @@ int main(const int argc, char** argv) {
di::bind<IEnrichmentService>().to( di::bind<IEnrichmentService>().to(
[options, &log_producer]( [options, &log_producer](
const auto& inj) -> std::unique_ptr<IEnrichmentService> { const auto& inj) -> std::unique_ptr<IEnrichmentService> {
if (options.generator.use_mocked) { if (options.generator.mode == GeneratorMode::Mock) {
log_producer->Log({.level = LogLevel::Info, log_producer->Log({.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
.message = "Enrichment: mock"}); .message = "Enrichment: mock"});
@@ -191,7 +190,7 @@ int main(const int argc, char** argv) {
[&options, &model_path, &sampling, &prompt_directory, [&options, &model_path, &sampling, &prompt_directory,
&log_producer]( &log_producer](
const auto& inj) -> std::unique_ptr<DataGenerator> { const auto& inj) -> std::unique_ptr<DataGenerator> {
if (options.generator.use_mocked) { if (options.generator.mode == GeneratorMode::Mock) {
log_producer->Log({.level = LogLevel::Info, log_producer->Log({.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
.message = "Generator: mock"}); .message = "Generator: mock"});
@@ -199,10 +198,25 @@ int main(const int argc, char** argv) {
return std::make_unique<MockGenerator>(); return std::make_unique<MockGenerator>();
} }
if (options.generator.mode == GeneratorMode::OpenAI) {
log_producer->Log(
{.level = LogLevel::Info,
.phase = PipelinePhase::Startup,
.message = std::format(
"Generator: OpenAIGenerator | model={}",
options.generator.openai_model)});
return std::make_unique<OpenAIGenerator>(
options.generator.openai_api_key,
options.generator.openai_model, log_producer,
std::move(prompt_directory),
inj.template create<std::unique_ptr<WebClient>>());
}
#ifdef BIERGARTEN_MOCK_ONLY #ifdef BIERGARTEN_MOCK_ONLY
// Unreachable: ParseArguments requires --mocked when // Only reachable when mode == Llama: Mock and OpenAI are
// BIERGARTEN_MOCK_ONLY is compiled in, so LlamaGenerator is // handled above and don't need llama.cpp, but LlamaGenerator
// never constructed and is not linked into this binary. // itself is not linked into a BIERGARTEN_MOCK_ONLY build.
throw std::runtime_error( throw std::runtime_error(
"LlamaGenerator is unavailable in a BIERGARTEN_MOCK_ONLY " "LlamaGenerator is unavailable in a BIERGARTEN_MOCK_ONLY "
"build"); "build");

View File

@@ -13,6 +13,7 @@
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector>
#include "services/logging/logger.h" #include "services/logging/logger.h"
@@ -67,6 +68,44 @@ std::string HttpWebClient::Get(const std::string& url) {
return result->body; return result->body;
} }
std::string HttpWebClient::Post(
const std::string& url, const std::string& body,
const std::vector<std::pair<std::string, std::string>>& headers) {
const auto [origin, path] = SplitUrl(url);
httplib::Client client(origin);
client.set_follow_location(true);
client.set_connection_timeout(kConnectionTimeoutSeconds);
client.set_read_timeout(kReadTimeoutSeconds);
httplib::Headers httplib_headers;
for (const auto& [name, value] : headers) {
httplib_headers.emplace(name, value);
}
const httplib::Result result =
client.Post(path, httplib_headers, body, "application/json");
if (!result) {
throw std::runtime_error(
std::format("[HttpWebClient] Request failed for URL: {} — {}", url,
httplib::to_string(result.error())));
}
if (result->status < kSuccessMin || result->status >= kSuccessMax) {
if (logger_) {
logger_->Log({.level = LogLevel::Error,
.phase = PipelinePhase::Enrichment,
.message = std::format(
"[HttpWebClient] Request failed for URL: {}", url)});
}
throw std::runtime_error(std::format(
"[HttpWebClient] HTTP {} for URL: {}", result->status, url));
}
return result->body;
}
std::string HttpWebClient::EncodeURL(const std::string& value) { std::string HttpWebClient::EncodeURL(const std::string& value) {
return httplib::encode_uri_component(value); return httplib::encode_uri_component(value);
} }