mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Update generate pipeline to add OpenAI api as an additional backend (for comparison testing), add brewery coordinates (#244)
* add brewery coordinates * Add openai mode * Add coordinates to schema * Fix C++ review findings in pipeline: k-prefixed enum values, include ordering, populated validation error messages, conventional const notation, and endl replaced with newline
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @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 <cctype>
|
||||
#include <format>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
|
||||
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()) {
|
||||
if (error_out != nullptr) {
|
||||
*error_out =
|
||||
std::format("Missing or invalid string field: {}", key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto& string_value = field->as_string();
|
||||
out = Trim(std::string_view(string_value.data(), string_value.size()));
|
||||
if (out.empty() && error_out != nullptr) {
|
||||
*error_out = std::format("Field must not be empty: {}", key);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -12,9 +12,9 @@
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "data_generation/generation_json_validation.h"
|
||||
#include "data_generation/json_grammars.h"
|
||||
#include "data_generation/llama_generator.h"
|
||||
#include "data_generation/llama_generator_helpers.h"
|
||||
|
||||
static std::string FormatLocalLanguageCodes(
|
||||
const std::vector<std::string>& codes) {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "data_generation/generation_json_validation.h"
|
||||
#include "data_generation/json_grammars.h"
|
||||
#include "data_generation/llama_generator.h"
|
||||
#include "data_generation/llama_generator_helpers.h"
|
||||
|
||||
static constexpr int kUserInitialMaxTokens = 1200;
|
||||
|
||||
|
||||
@@ -1,123 +1,17 @@
|
||||
/**
|
||||
* @file data_generation/llama/helpers.cc
|
||||
* @brief Provides prompt formatting, whitespace normalization, response
|
||||
* parsing, token decoding, and JSON validation helpers for Llama modules.
|
||||
* @brief Token decoding helper for LlamaGenerator modules.
|
||||
*/
|
||||
|
||||
#include <llama.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <boost/json.hpp>
|
||||
#include <cctype>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#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,
|
||||
std::string& output) {
|
||||
constexpr size_t initial_buffer_size = 256;
|
||||
@@ -153,116 +47,3 @@ void AppendTokenPiece(const llama_vocab* vocab, llama_token token,
|
||||
throw std::runtime_error(
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
* API docs last accessed 2026-08-23.
|
||||
* Docs: https://platform.openai.com/docs/api-reference/chat/create
|
||||
* Structured Outputs: https://platform.openai.com/docs/guides/structured-outputs
|
||||
*/
|
||||
|
||||
#include <format>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
|
||||
#include "data_generation/openai_generator.h"
|
||||
|
||||
namespace {
|
||||
// https://platform.openai.com/docs/api-reference/chat/create
|
||||
constexpr std::string_view kChatCompletionsUrl =
|
||||
"https://api.openai.com/v1/chat/completions";
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* @brief Sends a single Chat Completions request constrained by a JSON
|
||||
* Schema and returns the assistant message's text content.
|
||||
*
|
||||
* Request/response fields follow the Chat Completions API reference:
|
||||
* https://platform.openai.com/docs/api-reference/chat/create
|
||||
* Structured Outputs (response_format, strict mode, refusal field):
|
||||
* https://platform.openai.com/docs/guides/structured-outputs
|
||||
*
|
||||
* @param system_prompt Text sent as the "system" role message.
|
||||
* @param user_prompt Text sent as the "user" role message.
|
||||
* @param json_schema JSON Schema the response must conform to.
|
||||
* @param schema_name Identifier for response_format.json_schema.name.
|
||||
* @param max_tokens Value sent as "max_completion_tokens".
|
||||
* @return The raw JSON string from choices[0].message.content.
|
||||
* @throws std::runtime_error on invalid schema, API error, malformed
|
||||
* response, model refusal, or missing content.
|
||||
*/
|
||||
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);
|
||||
|
||||
bool schema_is_invalid = static_cast<bool>(schema_error);
|
||||
if (schema_is_invalid) {
|
||||
throw std::runtime_error(
|
||||
"OpenAIGenerator: invalid embedded JSON schema: " +
|
||||
schema_error.message());
|
||||
}
|
||||
|
||||
// response_format: https://platform.openai.com/docs/guides/structured-outputs
|
||||
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", std::format("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);
|
||||
|
||||
bool response_body_is_invalid = static_cast<bool>(response_error);
|
||||
if (response_body_is_invalid) {
|
||||
throw std::runtime_error(
|
||||
"OpenAIGenerator: failed to parse API response: " +
|
||||
response_error.message());
|
||||
}
|
||||
|
||||
bool response_root_is_not_object = !response_value.is_object();
|
||||
if (response_root_is_not_object) {
|
||||
throw std::runtime_error(
|
||||
"OpenAIGenerator: API response root is not an object");
|
||||
}
|
||||
const auto& response_obj = response_value.get_object();
|
||||
|
||||
// Error object: https://platform.openai.com/docs/api-reference/chat/create
|
||||
const boost::json::value* error_field = response_obj.if_contains("error");
|
||||
|
||||
bool top_level_error_occurred =
|
||||
error_field != nullptr && error_field->is_object();
|
||||
if (top_level_error_occurred) {
|
||||
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");
|
||||
bool choices_field_missing_or_invalid =
|
||||
choices_field == nullptr || !choices_field->is_array() ||
|
||||
choices_field->get_array().empty();
|
||||
|
||||
if (choices_field_missing_or_invalid) {
|
||||
throw std::runtime_error(
|
||||
"OpenAIGenerator: API response missing choices array");
|
||||
}
|
||||
|
||||
const boost::json::value& first_choice = choices_field->get_array().front();
|
||||
bool first_choice_is_not_object = !first_choice.is_object();
|
||||
if (first_choice_is_not_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");
|
||||
bool message_field_missing_or_invalid =
|
||||
message_field == nullptr || !message_field->is_object();
|
||||
if (message_field_missing_or_invalid) {
|
||||
throw std::runtime_error(
|
||||
"OpenAIGenerator: API response choice missing message");
|
||||
}
|
||||
const auto& message_obj = message_field->get_object();
|
||||
|
||||
// Refusal field: https://platform.openai.com/docs/guides/structured-outputs
|
||||
const boost::json::value* refusal_field = message_obj.if_contains("refusal");
|
||||
bool model_refused_request =
|
||||
refusal_field != nullptr && refusal_field->is_string() &&
|
||||
!refusal_field->as_string().empty();
|
||||
if (model_refused_request) {
|
||||
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");
|
||||
bool content_field_missing_or_invalid =
|
||||
content_field == nullptr || !content_field->is_string();
|
||||
if (content_field_missing_or_invalid) {
|
||||
throw std::runtime_error(
|
||||
"OpenAIGenerator: API response message had no text content");
|
||||
}
|
||||
|
||||
return std::string(content_field->as_string());
|
||||
}
|
||||
132
tooling/pipeline/src/data_generation/openai/generate_brewery.cc
Normal file
132
tooling/pipeline/src/data_generation/openai/generate_brewery.cc
Normal 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");
|
||||
}
|
||||
114
tooling/pipeline/src/data_generation/openai/generate_user.cc
Normal file
114
tooling/pipeline/src/data_generation/openai/generate_user.cc
Normal 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");
|
||||
}
|
||||
@@ -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)) {}
|
||||
Reference in New Issue
Block a user