mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
Add user generation feature
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @file biergarten_pipeline_orchestrator/generate_users.cc
|
||||
* @brief BiergartenDataGenerator::GenerateUsers() implementation.
|
||||
*/
|
||||
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "biergarten_pipeline_orchestrator.h"
|
||||
#include "json_handling/json_loader.h"
|
||||
#include "services/logging/logger.h"
|
||||
|
||||
namespace {
|
||||
|
||||
std::string Sanitize(std::string_view value) {
|
||||
std::string out;
|
||||
out.reserve(value.size());
|
||||
for (const char character : value) {
|
||||
if (std::isalnum(static_cast<unsigned char>(character)) != 0) {
|
||||
out.push_back(
|
||||
static_cast<char>(std::tolower(static_cast<unsigned char>(character))));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string BuildEmail(const Name& name,
|
||||
std::unordered_set<std::string>& used_local_parts) {
|
||||
const std::string base =
|
||||
std::format("{}.{}", Sanitize(name.first_name), Sanitize(name.last_name));
|
||||
|
||||
std::string local_part = base;
|
||||
for (int suffix = 1; used_local_parts.contains(local_part); ++suffix) {
|
||||
local_part = std::format("{}{}", base, suffix);
|
||||
}
|
||||
used_local_parts.insert(local_part);
|
||||
|
||||
return std::format("{}@thebiergarten.app", local_part);
|
||||
}
|
||||
|
||||
std::string GenerateDateOfBirth(std::mt19937& rng) {
|
||||
using namespace std::chrono;
|
||||
|
||||
constexpr int kMinAge = 19;
|
||||
constexpr int kMaxAge = 48;
|
||||
constexpr int kMaxDayOffset = 364;
|
||||
|
||||
std::uniform_int_distribution<int> age_dist(kMinAge, kMaxAge);
|
||||
std::uniform_int_distribution<int> day_offset_dist(0, kMaxDayOffset);
|
||||
|
||||
const year_month_day today{floor<days>(system_clock::now())};
|
||||
const year_month_day birth_year_anchor{today.year() - years{age_dist(rng)},
|
||||
today.month(), today.day()};
|
||||
const sys_days birth_date =
|
||||
sys_days{birth_year_anchor} - days{day_offset_dist(rng)};
|
||||
const year_month_day birth_ymd{birth_date};
|
||||
|
||||
return std::format("{:04}-{:02}-{:02}",
|
||||
static_cast<int>(birth_ymd.year()),
|
||||
static_cast<unsigned>(birth_ymd.month()),
|
||||
static_cast<unsigned>(birth_ymd.day()));
|
||||
}
|
||||
|
||||
std::string GenerateRandomPassword(std::mt19937& rng) {
|
||||
constexpr size_t kPasswordLength = 32;
|
||||
constexpr std::string_view kCharset =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
|
||||
|
||||
std::uniform_int_distribution<size_t> char_dist(0, kCharset.size() - 1);
|
||||
|
||||
std::string password;
|
||||
password.reserve(kPasswordLength);
|
||||
for (size_t i = 0; i < kPasswordLength; ++i) {
|
||||
password.push_back(kCharset[char_dist(rng)]);
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void BiergartenPipelineOrchestrator::GenerateUsers(
|
||||
std::span<const EnrichedCity> cities) {
|
||||
logger_->Log({.level = LogLevel::Info,
|
||||
.phase = PipelinePhase::UserGeneration,
|
||||
.message = "=== SAMPLE USER GENERATION ==="});
|
||||
|
||||
const std::vector<UserPersona> personas =
|
||||
JsonLoader::LoadPersonas("personas.json", logger_);
|
||||
if (personas.empty()) {
|
||||
throw std::runtime_error(
|
||||
"No personas available in personas.json for user generation");
|
||||
}
|
||||
|
||||
const NamesByCountry names_by_country = JsonLoader::LoadNamesByCountry(
|
||||
"forenames-by-country.json", "surnames-by-country.json", logger_);
|
||||
|
||||
std::mt19937 rng(std::random_device{}());
|
||||
std::uniform_int_distribution<size_t> persona_dist(0, personas.size() - 1);
|
||||
|
||||
generated_users_.clear();
|
||||
std::unordered_set<std::string> used_email_local_parts;
|
||||
size_t skipped_count = 0;
|
||||
|
||||
for (const auto& city : cities) {
|
||||
const std::optional<Name> sampled_name =
|
||||
names_by_country.SampleName(city.location.iso3166_1, rng);
|
||||
|
||||
if (!sampled_name.has_value()) {
|
||||
++skipped_count;
|
||||
logger_->Log(
|
||||
{.level = LogLevel::Warn,
|
||||
.phase = PipelinePhase::UserGeneration,
|
||||
.message = std::format(
|
||||
"[Pipeline] Skipping city '{}' ({}): no names available for "
|
||||
"country '{}'",
|
||||
city.location.city, city.location.country,
|
||||
city.location.iso3166_1)});
|
||||
continue;
|
||||
}
|
||||
|
||||
const UserPersona& persona = personas[persona_dist(rng)];
|
||||
|
||||
try {
|
||||
const UserResult user =
|
||||
generator_->GenerateUser(city, persona, *sampled_name);
|
||||
|
||||
generated_users_.push_back(GeneratedUser{
|
||||
.location = city.location,
|
||||
.user = user,
|
||||
.email = BuildEmail(*sampled_name, used_email_local_parts),
|
||||
.date_of_birth = GenerateDateOfBirth(rng),
|
||||
.password = GenerateRandomPassword(rng),
|
||||
});
|
||||
} catch (const std::exception& e) {
|
||||
++skipped_count;
|
||||
logger_->Log(
|
||||
{.level = LogLevel::Warn,
|
||||
.phase = PipelinePhase::UserGeneration,
|
||||
.message = std::format(
|
||||
"[Pipeline] Skipping city '{}' ({}): user generation failed: {}",
|
||||
city.location.city, city.location.country, e.what())});
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped_count > 0) {
|
||||
logger_->Log(
|
||||
{.level = LogLevel::Warn,
|
||||
.phase = PipelinePhase::UserGeneration,
|
||||
.message = std::format(
|
||||
"[Pipeline] Skipped {} city/cities during user generation",
|
||||
skipped_count)});
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,12 @@
|
||||
#include "../../includes/json_handling/pretty_print.h"
|
||||
#include "biergarten_pipeline_orchestrator.h"
|
||||
#include "services/logging/logger.h"
|
||||
|
||||
void BiergartenPipelineOrchestrator::LogResults() const {
|
||||
boost::json::array output;
|
||||
boost::json::array brewery_output;
|
||||
|
||||
for (const auto& [location, brewery] : generated_breweries_) {
|
||||
output.push_back(boost::json::object{
|
||||
brewery_output.push_back(boost::json::object{
|
||||
{"name_en", brewery.name_en},
|
||||
{"description_en", brewery.description_en},
|
||||
{"name_local", brewery.name_local},
|
||||
@@ -29,9 +30,31 @@ void BiergartenPipelineOrchestrator::LogResults() const {
|
||||
}}});
|
||||
}
|
||||
|
||||
std::ostringstream oss;
|
||||
PrettyPrint(oss, output);
|
||||
std::ostringstream brewery_oss;
|
||||
PrettyPrint(brewery_oss, brewery_output);
|
||||
logger_->Log({.level = LogLevel::Info,
|
||||
.phase = PipelinePhase::Teardown,
|
||||
.message = oss.str()});
|
||||
.message = brewery_oss.str()});
|
||||
|
||||
boost::json::array user_output;
|
||||
|
||||
|
||||
for (const auto& generated_user : generated_users_) {
|
||||
user_output.push_back(boost::json::object{
|
||||
{"first_name", generated_user.user.first_name},
|
||||
{"last_name", generated_user.user.last_name},
|
||||
{"gender", generated_user.user.gender},
|
||||
{"username", generated_user.user.username},
|
||||
{"bio", generated_user.user.bio},
|
||||
{"activity_weight", generated_user.user.activity_weight},
|
||||
{"email", generated_user.email},
|
||||
{"date_of_birth", generated_user.date_of_birth},
|
||||
});
|
||||
}
|
||||
|
||||
std::ostringstream user_oss;
|
||||
PrettyPrint(user_oss, user_output);
|
||||
logger_->Log({.level = LogLevel::Info,
|
||||
.phase = PipelinePhase::Teardown,
|
||||
.message = user_oss.str()});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ bool BiergartenPipelineOrchestrator::Run() {
|
||||
skipped_count)});
|
||||
}
|
||||
|
||||
this->GenerateUsers(enriched);
|
||||
this->GenerateBreweries(enriched);
|
||||
exporter_->Finalize();
|
||||
this->LogResults();
|
||||
|
||||
@@ -1,24 +1,121 @@
|
||||
/**
|
||||
* @file data_generation/llama/generate_user.cc
|
||||
* @brief Generates locale-aware user profiles with strict two-line formatting,
|
||||
* retry handling, and output sanitization for downstream parsing.
|
||||
* @brief Builds persona/name-grounded user prompts, performs retry-based
|
||||
* inference, and validates structured JSON output for user records.
|
||||
*/
|
||||
|
||||
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "data_generation/llama_generator.h"
|
||||
#include "data_generation/llama_generator_helpers.h"
|
||||
|
||||
// TODO: Implement locale-aware user profile generation.
|
||||
// Current implementation returns a hardcoded test value and ignores the
|
||||
// locale parameter. Future implementation should:
|
||||
// 1. Load a USER_GENERATION.md prompt template with locale context
|
||||
// 2. Perform LLM inference with locale-specific username/bio generation
|
||||
// 3. Parse and validate JSON output with retry handling (similar to brewery)
|
||||
// 4. Return locale-aware username and biography
|
||||
UserResult LlamaGenerator::GenerateUser(const std::string& locale) {
|
||||
return {.username = "test_user",
|
||||
.bio = std::format("This is a test user profile from {}.", locale)};
|
||||
// GBNF grammar for structured user JSON output.
|
||||
static constexpr std::string_view kUserJsonGrammar = R"json_user(
|
||||
root ::= thought-block "{" ws "\"username\"" ws ":" ws string ws "," ws "\"bio\"" ws ":" ws string ws "," ws "\"activity_weight\"" ws ":" ws number ws "}" ws
|
||||
thought-block ::= [^{]*
|
||||
ws ::= [ \t\n\r]*
|
||||
string ::= "\"" char+ "\""
|
||||
char ::= [^"\\\x7F\x00-\x1F] | [\\] escape
|
||||
escape ::= ["\\/bfnrt] | "u" hex hex hex hex
|
||||
hex ::= [0-9a-fA-F]
|
||||
number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)?
|
||||
)json_user";
|
||||
|
||||
static constexpr int kUserInitialMaxTokens = 1200;
|
||||
|
||||
UserResult LlamaGenerator::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## GENDER:\n{}\n\n## CITY:\n{}\n\n## COUNTRY:\n{}\n\n"
|
||||
"## PERSONA:\n{}\n\n## PERSONA DESCRIPTION:\n{}\n\n## STYLE "
|
||||
"AFFINITIES:\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);
|
||||
|
||||
constexpr int max_attempts = 3;
|
||||
std::string raw;
|
||||
std::string last_error;
|
||||
int max_tokens = kUserInitialMaxTokens;
|
||||
|
||||
for (int attempt = 0; attempt < max_attempts; ++attempt) {
|
||||
raw = this->Infer(system_prompt, user_prompt, max_tokens,
|
||||
kUserJsonGrammar);
|
||||
if (logger_) {
|
||||
logger_->Log(
|
||||
{.level = LogLevel::Debug,
|
||||
.phase = PipelinePhase::UserGeneration,
|
||||
.message = std::format("LlamaGenerator: 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("LlamaGenerator: 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("LlamaGenerator: malformed user JSON (attempt {}): {}",
|
||||
attempt + 1, *validation_error)});
|
||||
}
|
||||
|
||||
user_prompt = std::format(
|
||||
"Your previous response was invalid. Error: {}\nReturn the thought "
|
||||
"process before the JSON if needed, then return ONLY valid JSON with "
|
||||
"exactly these keys, in this exact order: {{\"username\": \"<handle "
|
||||
"derived from the given name>\", \"bio\": \"<first-person bio "
|
||||
"grounded in the persona>\", \"activity_weight\": <number between 0 "
|
||||
"and 1>}}.\nDo not include markdown, comments, extra keys, or "
|
||||
"literal placeholder values.\n\n{}",
|
||||
*validation_error, retry_context);
|
||||
}
|
||||
|
||||
if (logger_) {
|
||||
logger_->Log(
|
||||
{.level = LogLevel::Error,
|
||||
.phase = PipelinePhase::UserGeneration,
|
||||
.message = std::format(
|
||||
"LlamaGenerator: malformed user response after {} attempts: {}",
|
||||
max_attempts, last_error.empty() ? raw : last_error)});
|
||||
}
|
||||
throw std::runtime_error("LlamaGenerator: malformed user response");
|
||||
}
|
||||
|
||||
@@ -91,6 +91,22 @@ bool HasSchemaPlaceholder(const std::array<std::string*, 4>& values) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool HasSchemaPlaceholder(const std::array<std::string*, 2>& 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
|
||||
|
||||
/**
|
||||
@@ -207,3 +223,58 @@ std::optional<std::string> ValidateBreweryJson(const std::string& raw,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,13 @@ size_t MockGenerator::DeterministicHash(const Location& location) {
|
||||
boost::hash_combine(seed, location.country);
|
||||
return seed;
|
||||
}
|
||||
|
||||
size_t MockGenerator::DeterministicHash(const Location& location,
|
||||
const UserPersona& persona,
|
||||
const Name& name) {
|
||||
size_t seed = DeterministicHash(location);
|
||||
boost::hash_combine(seed, persona.name);
|
||||
boost::hash_combine(seed, name.first_name);
|
||||
boost::hash_combine(seed, name.last_name);
|
||||
return seed;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
/**
|
||||
* @file data_generation/mock/generate_user.cc
|
||||
* @brief Generates deterministic mock user profiles by hashing locale values
|
||||
* into predefined username and bio collections.
|
||||
* @brief Generates deterministic mock user profiles by hashing city, persona,
|
||||
* and sampled name into predefined username and bio collections.
|
||||
*/
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "data_generation/mock_generator.h"
|
||||
|
||||
UserResult MockGenerator::GenerateUser(const std::string& locale) {
|
||||
const size_t hash = std::hash<std::string>{}(locale);
|
||||
UserResult MockGenerator::GenerateUser(const EnrichedCity& city,
|
||||
const UserPersona& persona,
|
||||
const Name& name) {
|
||||
const size_t hash = DeterministicHash(city.location, persona, name);
|
||||
|
||||
UserResult result;
|
||||
const std::string_view username = kUsernames[hash % kUsernames.size()];
|
||||
const std::string_view bio = kBios[hash / kBioHashStride % kBios.size()];
|
||||
result.username = username;
|
||||
result.bio = bio;
|
||||
return result;
|
||||
const float activity_weight =
|
||||
static_cast<float>(hash / kActivityWeightHashStride %
|
||||
kActivityWeightHashRange) /
|
||||
static_cast<float>(kActivityWeightHashRange);
|
||||
|
||||
return {
|
||||
.first_name = name.first_name,
|
||||
.last_name = name.last_name,
|
||||
.gender = name.gender,
|
||||
.username = std::string(username),
|
||||
.bio = std::string(bio),
|
||||
.activity_weight = activity_weight,
|
||||
};
|
||||
}
|
||||
|
||||
41
tooling/pipeline/src/data_model/names_by_country.cc
Normal file
41
tooling/pipeline/src/data_model/names_by_country.cc
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file data_model/names_by_country.cc
|
||||
* @brief NamesByCountry::SampleName() implementation.
|
||||
*/
|
||||
|
||||
#include "data_model/names_by_country.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
NamesByCountry::NamesByCountry(
|
||||
std::unordered_map<std::string, std::vector<ForenameEntry>>
|
||||
forenames_by_country,
|
||||
std::unordered_map<std::string, std::vector<std::string>>
|
||||
surnames_by_country)
|
||||
: forenames_by_country_(std::move(forenames_by_country)),
|
||||
surnames_by_country_(std::move(surnames_by_country)) {}
|
||||
|
||||
std::optional<Name> NamesByCountry::SampleName(const std::string& iso3166_1,
|
||||
std::mt19937& rng) const {
|
||||
const auto forenames_it = forenames_by_country_.find(iso3166_1);
|
||||
const auto surnames_it = surnames_by_country_.find(iso3166_1);
|
||||
|
||||
if (forenames_it == forenames_by_country_.end() ||
|
||||
surnames_it == surnames_by_country_.end() ||
|
||||
forenames_it->second.empty() || surnames_it->second.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::vector<ForenameEntry>& forenames = forenames_it->second;
|
||||
const std::vector<std::string>& surnames = surnames_it->second;
|
||||
|
||||
std::uniform_int_distribution<size_t> forename_dist(0,
|
||||
forenames.size() - 1);
|
||||
std::uniform_int_distribution<size_t> surname_dist(0, surnames.size() - 1);
|
||||
|
||||
const ForenameEntry& forename = forenames[forename_dist(rng)];
|
||||
|
||||
return Name{.first_name = forename.name,
|
||||
.last_name = surnames[surname_dist(rng)],
|
||||
.gender = forename.gender};
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
static std::string ReadRequiredString(const boost::json::object& object,
|
||||
const char* key) {
|
||||
@@ -59,6 +61,50 @@ static std::vector<std::string> ReadRequiredStringArray(
|
||||
return items;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
boost::json::value ParseJsonFile(const std::filesystem::path& filepath,
|
||||
const char* what) {
|
||||
std::ifstream input(filepath);
|
||||
if (!input.is_open()) {
|
||||
throw std::runtime_error(std::format("Failed to open {} file: {}", what,
|
||||
filepath.string()));
|
||||
}
|
||||
|
||||
std::stringstream buffer;
|
||||
buffer << input.rdbuf();
|
||||
|
||||
boost::system::error_code error;
|
||||
boost::json::value root = boost::json::parse(buffer.str(), error);
|
||||
if (error) {
|
||||
throw std::runtime_error(
|
||||
std::format("Failed to parse {} JSON: {}", what, error.message()));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/// @brief Returns the first element of a string array field, falling back to
|
||||
/// `fallback_key` if `key` is missing/empty (some source entries only have a
|
||||
/// "localized" form and no "romanized" form).
|
||||
std::string ReadFirstOfStringArray(const boost::json::object& object,
|
||||
const char* key,
|
||||
const char* fallback_key) {
|
||||
for (const char* candidate_key : {key, fallback_key}) {
|
||||
const boost::json::value* value = object.if_contains(candidate_key);
|
||||
if (value == nullptr || !value->is_array()) {
|
||||
continue;
|
||||
}
|
||||
const auto& array = value->as_array();
|
||||
if (!array.empty() && array.front().is_string()) {
|
||||
return std::string(array.front().as_string());
|
||||
}
|
||||
}
|
||||
throw std::runtime_error(
|
||||
std::format("Missing or invalid string array field: {}", key));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<Location> JsonLoader::LoadLocations(
|
||||
const std::filesystem::path& filepath, std::shared_ptr<ILogger> logger) {
|
||||
std::ifstream input(filepath);
|
||||
@@ -108,3 +154,100 @@ std::vector<Location> JsonLoader::LoadLocations(
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
std::vector<UserPersona> JsonLoader::LoadPersonas(
|
||||
const std::filesystem::path& filepath, std::shared_ptr<ILogger> logger) {
|
||||
const boost::json::value root = ParseJsonFile(filepath, "personas");
|
||||
|
||||
if (!root.is_array()) {
|
||||
throw std::runtime_error(
|
||||
"Invalid personas JSON: root element must be an array");
|
||||
}
|
||||
|
||||
std::vector<UserPersona> personas;
|
||||
const auto& items = root.as_array();
|
||||
personas.reserve(items.size());
|
||||
|
||||
for (const auto& item : items) {
|
||||
if (!item.is_object()) {
|
||||
throw std::runtime_error(
|
||||
"Invalid personas JSON: each entry must be an object");
|
||||
}
|
||||
|
||||
const auto& object = item.as_object();
|
||||
personas.push_back(UserPersona{
|
||||
.name = ReadRequiredString(object, "name"),
|
||||
.description = ReadRequiredString(object, "description"),
|
||||
.style_affinities =
|
||||
ReadRequiredStringArray(object, "style_affinities"),
|
||||
});
|
||||
}
|
||||
|
||||
return personas;
|
||||
}
|
||||
|
||||
NamesByCountry JsonLoader::LoadNamesByCountry(
|
||||
const std::filesystem::path& forenames_filepath,
|
||||
const std::filesystem::path& surnames_filepath,
|
||||
std::shared_ptr<ILogger> logger) {
|
||||
const boost::json::value forenames_root =
|
||||
ParseJsonFile(forenames_filepath, "forenames-by-country");
|
||||
const boost::json::value surnames_root =
|
||||
ParseJsonFile(surnames_filepath, "surnames-by-country");
|
||||
|
||||
if (!forenames_root.is_object() || !surnames_root.is_object()) {
|
||||
throw std::runtime_error(
|
||||
"Invalid names-by-country JSON: root element must be an object "
|
||||
"keyed by ISO 3166-1 country code");
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::vector<ForenameEntry>>
|
||||
forenames_by_country;
|
||||
for (const auto& [country_code, regions] : forenames_root.as_object()) {
|
||||
if (!regions.is_array()) {
|
||||
continue;
|
||||
}
|
||||
std::vector<ForenameEntry> entries;
|
||||
for (const auto& region : regions.as_array()) {
|
||||
if (!region.is_object()) {
|
||||
continue;
|
||||
}
|
||||
const boost::json::value* names = region.as_object().if_contains("names");
|
||||
if (names == nullptr || !names->is_array()) {
|
||||
continue;
|
||||
}
|
||||
for (const auto& name_value : names->as_array()) {
|
||||
if (!name_value.is_object()) {
|
||||
continue;
|
||||
}
|
||||
const auto& name_object = name_value.as_object();
|
||||
entries.push_back(ForenameEntry{
|
||||
.name = ReadFirstOfStringArray(name_object, "romanized",
|
||||
"localized"),
|
||||
.gender = ReadRequiredString(name_object, "gender"),
|
||||
});
|
||||
}
|
||||
}
|
||||
forenames_by_country.emplace(country_code, std::move(entries));
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::vector<std::string>>
|
||||
surnames_by_country;
|
||||
for (const auto& [country_code, name_entries] : surnames_root.as_object()) {
|
||||
if (!name_entries.is_array()) {
|
||||
continue;
|
||||
}
|
||||
std::vector<std::string> surnames;
|
||||
for (const auto& name_value : name_entries.as_array()) {
|
||||
if (!name_value.is_object()) {
|
||||
continue;
|
||||
}
|
||||
surnames.push_back(ReadFirstOfStringArray(name_value.as_object(),
|
||||
"romanized", "localized"));
|
||||
}
|
||||
surnames_by_country.emplace(country_code, std::move(surnames));
|
||||
}
|
||||
|
||||
return NamesByCountry(std::move(forenames_by_country),
|
||||
std::move(surnames_by_country));
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ int main(const int argc, char** argv) {
|
||||
std::make_shared<LogProducer>(log_channel);
|
||||
|
||||
std::thread log_thread([&log_dispatcher] { log_dispatcher->Run(); });
|
||||
|
||||
auto shutdown = [&](const int exit_code) {
|
||||
log_channel.Close();
|
||||
log_thread.join();
|
||||
|
||||
Reference in New Issue
Block a user