From 754bd6764e1189f88a4cfa06eceadf0125f6d1bb Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 23 Aug 2026 14:45:56 -0400 Subject: [PATCH] Add coordinates to schema --- .../openai/call_chat_completions_api.cc | 124 ++++++++++++------ .../Database.Seed/Database.Seed.csproj | 2 +- .../DatabaseHelpers/ConnectionStrings.cs | 4 +- .../PipelineData/BreweryAddress.cs | 4 + .../Sqlite/PipelineSeedDataReader.cs | 6 +- .../Domain.Entities/Entities/BreweryPost.cs | 1 + web/generate-env.sh | 78 +++++++++++ 7 files changed, 174 insertions(+), 45 deletions(-) create mode 100755 web/generate-env.sh diff --git a/tooling/pipeline/src/data_generation/openai/call_chat_completions_api.cc b/tooling/pipeline/src/data_generation/openai/call_chat_completions_api.cc index c22d959a..05ce3aaf 100644 --- a/tooling/pipeline/src/data_generation/openai/call_chat_completions_api.cc +++ b/tooling/pipeline/src/data_generation/openai/call_chat_completions_api.cc @@ -3,6 +3,10 @@ * @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 @@ -16,23 +20,46 @@ #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 + "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) { + 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) { + boost::json::parse(json_schema, schema_error); + + bool schema_is_invalid = static_cast(schema_error); + if (schema_is_invalid) { throw std::runtime_error( - "OpenAIGenerator: invalid embedded JSON schema: " + - schema_error.message()); + "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; @@ -63,76 +90,91 @@ std::string OpenAIGenerator::CallChatCompletionsApi( const std::string body = boost::json::serialize(request); const std::vector> headers = { - {"Authorization", "Bearer " + api_key_}, + {"Authorization", std::format("Bearer {}", api_key_)}, }; const std::string response_body = - web_client_->Post(std::string(kChatCompletionsUrl), body, headers); + 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) { + boost::json::parse(response_body, response_error); + + bool response_body_is_invalid = static_cast(response_error); + if (response_body_is_invalid) { throw std::runtime_error( - "OpenAIGenerator: failed to parse API response: " + - response_error.message()); + "OpenAIGenerator: failed to parse API response: " + + response_error.message()); } - if (!response_value.is_object()) { + 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"); + "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()) { + // 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_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)); + 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()) { + 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"); + "OpenAIGenerator: API response missing choices array"); } const boost::json::value& first_choice = choices_field->get_array().front(); - if (!first_choice.is_object()) { + 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"); + "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()) { + 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"); + "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()) { + // 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()))); + "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()) { + 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"); + "OpenAIGenerator: API response message had no text content"); } return std::string(content_field->as_string()); diff --git a/web/backend/Database/Database.Seed/Database.Seed.csproj b/web/backend/Database/Database.Seed/Database.Seed.csproj index a043b08a..ee222915 100644 --- a/web/backend/Database/Database.Seed/Database.Seed.csproj +++ b/web/backend/Database/Database.Seed/Database.Seed.csproj @@ -22,6 +22,6 @@ - + diff --git a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs index 6fcfcac5..535d1a0a 100644 --- a/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs +++ b/web/backend/Database/Database.Seed/DatabaseHelpers/ConnectionStrings.cs @@ -12,6 +12,6 @@ public class ConnectionStrings public static string GetSqlServerConnectionString(IConfiguration configuration) => SqlConnectionStringHelper.BuildConnectionString(configuration); - public static string SqliteConnectionString => - "Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"; + public static string SqliteConnectionString => "Data Source=SeedData/open-ai-test2.sqlite"; + // "Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite"; } diff --git a/web/backend/Database/Database.Seed/PipelineData/BreweryAddress.cs b/web/backend/Database/Database.Seed/PipelineData/BreweryAddress.cs index b6902bb1..7d373338 100644 --- a/web/backend/Database/Database.Seed/PipelineData/BreweryAddress.cs +++ b/web/backend/Database/Database.Seed/PipelineData/BreweryAddress.cs @@ -5,4 +5,8 @@ public sealed record BreweryAddress public required City City { get; init; } public required string PostalCode { get; init; } + + public required double Longitude { get; init; } + + public required double Latitude { get; init; } } diff --git a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs index 7bf69964..a896f07e 100644 --- a/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs +++ b/web/backend/Database/Database.Seed/Sqlite/PipelineSeedDataReader.cs @@ -54,7 +54,7 @@ public sealed class PipelineSeedDataReader { const string sql = """ SELECT b.name_en, b.description_en, b.name_local, b.description_local, - ba.city_id, ba.postal_code + ba.city_id, ba.postal_code, ba.longitude, ba.latitude FROM breweries b JOIN brewery_addresses ba ON ba.brewery_id = b.id; """; @@ -68,6 +68,8 @@ public sealed class PipelineSeedDataReader const int descriptionLocalIndex = 3; const int cityIdIndex = 4; const int postalCodeIndex = 5; + const int longitudeIndex = 6; + const int latitudeIndex = 7; List records = []; while (await reader.ReadAsync(cancellationToken)) @@ -85,6 +87,8 @@ public sealed class PipelineSeedDataReader { City = cities[reader.GetInt64(cityIdIndex)], PostalCode = reader.GetString(postalCodeIndex), + Longitude = reader.GetDouble(longitudeIndex), + Latitude = reader.GetDouble(latitudeIndex), }, } ); diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs index 4812ffa1..46a4efca 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -7,6 +7,7 @@ public sealed class BreweryPost // FK References public Guid PostedById { get; set; } + public string PostedBy { get; set; } = string.Empty; // Attributes public string BreweryName { get; set; } = string.Empty; diff --git a/web/generate-env.sh b/web/generate-env.sh new file mode 100755 index 00000000..89200b0e --- /dev/null +++ b/web/generate-env.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# generate_env.sh +# +# Generates a Biergarten App environment file with freshly randomized +# secrets (DB password, JWT signing secrets, SMTP credentials), based on +# the .env.example template. Non-secret configuration values are left +# untouched so the file still matches the documented template structure. +# +# Usage: +# ./generate_env.sh [output_file] [template_file] +# +# output_file Path to write the generated env file to (default: .env) +# template_file Path to the source template (default: .env.example) + +#!/usr/bin/env bash + +set -euo pipefail + +OUTPUT_FILE="${1:-.env}" +TEMPLATE_FILE="${2:-.env.example}" + +if ! command -v openssl >/dev/null 2>&1; then + echo "Error: openssl is required but was not found on PATH." >&2 + exit 1 +fi + +if [[ ! -f "$TEMPLATE_FILE" ]]; then + echo "Error: template file '$TEMPLATE_FILE' not found." >&2 + echo "Pass the template path explicitly: ./generate_env.sh