mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 18:14:44 +00:00
Add coordinates to schema
This commit is contained in:
@@ -3,6 +3,10 @@
|
|||||||
* @brief OpenAIGenerator::CallChatCompletionsApi() -- builds and sends a
|
* @brief OpenAIGenerator::CallChatCompletionsApi() -- builds and sends a
|
||||||
* single OpenAI Chat Completions request with a Structured Outputs JSON
|
* single OpenAI Chat Completions request with a Structured Outputs JSON
|
||||||
* schema, and extracts the resulting message content.
|
* 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 <boost/json.hpp>
|
#include <boost/json.hpp>
|
||||||
@@ -16,23 +20,46 @@
|
|||||||
#include "data_generation/openai_generator.h"
|
#include "data_generation/openai_generator.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
// https://platform.openai.com/docs/api-reference/chat/create
|
||||||
constexpr std::string_view kChatCompletionsUrl =
|
constexpr std::string_view kChatCompletionsUrl =
|
||||||
"https://api.openai.com/v1/chat/completions";
|
"https://api.openai.com/v1/chat/completions";
|
||||||
} // namespace
|
} // 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(
|
std::string OpenAIGenerator::CallChatCompletionsApi(
|
||||||
const std::string& system_prompt, const std::string& user_prompt,
|
const std::string& system_prompt, const std::string& user_prompt,
|
||||||
std::string_view json_schema, std::string_view schema_name,
|
std::string_view json_schema, std::string_view schema_name,
|
||||||
int max_tokens) {
|
int max_tokens) {
|
||||||
|
|
||||||
boost::system::error_code schema_error;
|
boost::system::error_code schema_error;
|
||||||
boost::json::value schema_value =
|
boost::json::value schema_value =
|
||||||
boost::json::parse(json_schema, schema_error);
|
boost::json::parse(json_schema, schema_error);
|
||||||
if (schema_error) {
|
|
||||||
|
bool schema_is_invalid = static_cast<bool>(schema_error);
|
||||||
|
if (schema_is_invalid) {
|
||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
"OpenAIGenerator: invalid embedded JSON schema: " +
|
"OpenAIGenerator: invalid embedded JSON schema: " +
|
||||||
schema_error.message());
|
schema_error.message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// response_format: https://platform.openai.com/docs/guides/structured-outputs
|
||||||
boost::json::object json_schema_wrapper;
|
boost::json::object json_schema_wrapper;
|
||||||
json_schema_wrapper["name"] = std::string(schema_name);
|
json_schema_wrapper["name"] = std::string(schema_name);
|
||||||
json_schema_wrapper["strict"] = true;
|
json_schema_wrapper["strict"] = true;
|
||||||
@@ -63,76 +90,91 @@ std::string OpenAIGenerator::CallChatCompletionsApi(
|
|||||||
const std::string body = boost::json::serialize(request);
|
const std::string body = boost::json::serialize(request);
|
||||||
|
|
||||||
const std::vector<std::pair<std::string, std::string>> headers = {
|
const std::vector<std::pair<std::string, std::string>> headers = {
|
||||||
{"Authorization", "Bearer " + api_key_},
|
{"Authorization", std::format("Bearer {}", api_key_)},
|
||||||
};
|
};
|
||||||
|
|
||||||
const std::string response_body =
|
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::system::error_code response_error;
|
||||||
boost::json::value response_value =
|
boost::json::value response_value =
|
||||||
boost::json::parse(response_body, response_error);
|
boost::json::parse(response_body, response_error);
|
||||||
if (response_error) {
|
|
||||||
|
bool response_body_is_invalid = static_cast<bool>(response_error);
|
||||||
|
if (response_body_is_invalid) {
|
||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
"OpenAIGenerator: failed to parse API response: " +
|
"OpenAIGenerator: failed to parse API response: " +
|
||||||
response_error.message());
|
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(
|
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();
|
const auto& response_obj = response_value.get_object();
|
||||||
|
|
||||||
// A top-level "error" object means the request itself was rejected
|
// Error object: https://platform.openai.com/docs/api-reference/chat/create
|
||||||
// (bad API key, invalid model, rate limit, etc.) rather than the model
|
const boost::json::value* error_field = response_obj.if_contains("error");
|
||||||
// producing an unusable completion.
|
|
||||||
if (const boost::json::value* error_field = response_obj.if_contains("error");
|
bool top_level_error_occurred =
|
||||||
error_field != nullptr && error_field->is_object()) {
|
error_field != nullptr && error_field->is_object();
|
||||||
|
if (top_level_error_occurred) {
|
||||||
std::string error_message = "unknown error";
|
std::string error_message = "unknown error";
|
||||||
if (const boost::json::value* message_field =
|
if (const boost::json::value* message_field =
|
||||||
error_field->get_object().if_contains("message");
|
error_field->get_object().if_contains("message");
|
||||||
message_field != nullptr && message_field->is_string()) {
|
message_field != nullptr && message_field->is_string()) {
|
||||||
error_message = std::string(message_field->as_string());
|
error_message = std::string(message_field->as_string());
|
||||||
}
|
}
|
||||||
throw std::runtime_error(
|
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");
|
const boost::json::value* choices_field = response_obj.
|
||||||
if (choices_field == nullptr || !choices_field->is_array() ||
|
if_contains("choices");
|
||||||
choices_field->get_array().empty()) {
|
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(
|
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();
|
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(
|
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 auto& choice_obj = first_choice.get_object();
|
||||||
|
|
||||||
const boost::json::value* message_field = choice_obj.if_contains("message");
|
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(
|
throw std::runtime_error(
|
||||||
"OpenAIGenerator: API response choice missing message");
|
"OpenAIGenerator: API response choice missing message");
|
||||||
}
|
}
|
||||||
const auto& message_obj = message_field->get_object();
|
const auto& message_obj = message_field->get_object();
|
||||||
|
|
||||||
if (const boost::json::value* refusal_field =
|
// Refusal field: https://platform.openai.com/docs/guides/structured-outputs
|
||||||
message_obj.if_contains("refusal");
|
const boost::json::value* refusal_field = message_obj.if_contains("refusal");
|
||||||
refusal_field != nullptr && refusal_field->is_string() &&
|
bool model_refused_request =
|
||||||
!refusal_field->as_string().empty()) {
|
refusal_field != nullptr && refusal_field->is_string() &&
|
||||||
|
!refusal_field->as_string().empty();
|
||||||
|
if (model_refused_request) {
|
||||||
throw std::runtime_error(std::format(
|
throw std::runtime_error(std::format(
|
||||||
"OpenAIGenerator: request refused by safety policies: {}",
|
"OpenAIGenerator: request refused by safety policies: {}",
|
||||||
std::string(refusal_field->as_string())));
|
std::string(refusal_field->as_string())));
|
||||||
}
|
}
|
||||||
|
|
||||||
const boost::json::value* content_field = message_obj.if_contains("content");
|
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(
|
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());
|
return std::string(content_field->as_string());
|
||||||
|
|||||||
@@ -22,6 +22,6 @@
|
|||||||
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite" />
|
<None CopyToOutputDirectory="PreserveNewest" Include="SeedData\open-ai-test2.sqlite" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ public class ConnectionStrings
|
|||||||
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
|
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
|
||||||
SqlConnectionStringHelper.BuildConnectionString(configuration);
|
SqlConnectionStringHelper.BuildConnectionString(configuration);
|
||||||
|
|
||||||
public static string SqliteConnectionString =>
|
public static string SqliteConnectionString => "Data Source=SeedData/open-ai-test2.sqlite";
|
||||||
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
// "Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,8 @@ public sealed record BreweryAddress
|
|||||||
public required City City { get; init; }
|
public required City City { get; init; }
|
||||||
|
|
||||||
public required string PostalCode { get; init; }
|
public required string PostalCode { get; init; }
|
||||||
|
|
||||||
|
public required double Longitude { get; init; }
|
||||||
|
|
||||||
|
public required double Latitude { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public sealed class PipelineSeedDataReader
|
|||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT b.name_en, b.description_en, b.name_local, b.description_local,
|
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
|
FROM breweries b
|
||||||
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
|
JOIN brewery_addresses ba ON ba.brewery_id = b.id;
|
||||||
""";
|
""";
|
||||||
@@ -68,6 +68,8 @@ public sealed class PipelineSeedDataReader
|
|||||||
const int descriptionLocalIndex = 3;
|
const int descriptionLocalIndex = 3;
|
||||||
const int cityIdIndex = 4;
|
const int cityIdIndex = 4;
|
||||||
const int postalCodeIndex = 5;
|
const int postalCodeIndex = 5;
|
||||||
|
const int longitudeIndex = 6;
|
||||||
|
const int latitudeIndex = 7;
|
||||||
|
|
||||||
List<BreweryRecord> records = [];
|
List<BreweryRecord> records = [];
|
||||||
while (await reader.ReadAsync(cancellationToken))
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
@@ -85,6 +87,8 @@ public sealed class PipelineSeedDataReader
|
|||||||
{
|
{
|
||||||
City = cities[reader.GetInt64(cityIdIndex)],
|
City = cities[reader.GetInt64(cityIdIndex)],
|
||||||
PostalCode = reader.GetString(postalCodeIndex),
|
PostalCode = reader.GetString(postalCodeIndex),
|
||||||
|
Longitude = reader.GetDouble(longitudeIndex),
|
||||||
|
Latitude = reader.GetDouble(latitudeIndex),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public sealed class BreweryPost
|
|||||||
|
|
||||||
// FK References
|
// FK References
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
public string PostedBy { get; set; } = string.Empty;
|
||||||
|
|
||||||
// Attributes
|
// Attributes
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|||||||
78
web/generate-env.sh
Executable file
78
web/generate-env.sh
Executable file
@@ -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 <output> <template>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$OUTPUT_FILE" ]]; then
|
||||||
|
read -r -p "Warning: '$OUTPUT_FILE' already exists. Overwrite? [y/N] " confirm
|
||||||
|
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted. No file was written."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
gen_jwt_secret() {
|
||||||
|
openssl rand -base64 32 | tr -d '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
gen_password() {
|
||||||
|
printf '%s!' "$(openssl rand -hex 24)"
|
||||||
|
}
|
||||||
|
|
||||||
|
gen_username() {
|
||||||
|
openssl rand -hex 6
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_PASSWORD_VAL=$(gen_password)
|
||||||
|
ACCESS_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
||||||
|
REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
||||||
|
CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret)
|
||||||
|
SMTP_USERNAME_VAL="smtp_$(gen_username)"
|
||||||
|
SMTP_PASSWORD_VAL=$(gen_password)
|
||||||
|
|
||||||
|
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
TMP_FILE="$(mktemp)"
|
||||||
|
sed \
|
||||||
|
-e "s|^DB_PASSWORD=.*|DB_PASSWORD=${DB_PASSWORD_VAL}|" \
|
||||||
|
-e "s|^ACCESS_TOKEN_SECRET=.*|ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET_VAL}|" \
|
||||||
|
-e "s|^REFRESH_TOKEN_SECRET=.*|REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET_VAL}|" \
|
||||||
|
-e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \
|
||||||
|
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \
|
||||||
|
-e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \
|
||||||
|
"$OUTPUT_FILE" > "$TMP_FILE"
|
||||||
|
mv "$TMP_FILE" "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
chmod 600 "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
echo "Generated '$OUTPUT_FILE' with fresh random secrets (file permissions set to 600)."
|
||||||
|
echo "Reminder: SMTP_USERNAME/SMTP_PASSWORD were randomized as dev placeholders."
|
||||||
|
echo "For staging/production, replace them with real credentials from your SMTP provider."
|
||||||
Reference in New Issue
Block a user