Compare commits

...

2 Commits

12 changed files with 208 additions and 69 deletions

View File

@@ -191,9 +191,9 @@ struct SamplingOptions {
* @brief Selects which DataGenerator backend the pipeline uses. * @brief Selects which DataGenerator backend the pipeline uses.
*/ */
enum class GeneratorMode { enum class GeneratorMode {
Mock, kMock,
Llama, kLlama,
OpenAI, kOpenAI,
}; };
/** /**
@@ -202,25 +202,25 @@ enum class GeneratorMode {
struct GeneratorOptions { struct GeneratorOptions {
/** /**
* @brief Path to the LLM model file (gguf format). Only meaningful for * @brief Path to the LLM model file (gguf format). Only meaningful for
* GeneratorMode::Llama. * GeneratorMode::kLlama.
*/ */
std::filesystem::path model_path; std::filesystem::path model_path;
/** /**
* @brief Which generator backend to use. * @brief Which generator backend to use.
*/ */
GeneratorMode mode = GeneratorMode::Mock; GeneratorMode mode = GeneratorMode::kMock;
/** /**
* @brief Specific sampling parameters for this generator. Only * @brief Specific sampling parameters for this generator. Only
* meaningful for GeneratorMode::Llama; if nullopt, LlamaGenerator uses * meaningful for GeneratorMode::kLlama; if nullopt, LlamaGenerator uses
* its own defaults. * its own defaults.
*/ */
std::optional<SamplingOptions> sampling; std::optional<SamplingOptions> sampling;
/** /**
* @brief OpenAI API key, read from the OPENAI_API_KEY environment * @brief OpenAI API key, read from the OPENAI_API_KEY environment
* variable. Only populated for GeneratorMode::OpenAI. * variable. Only populated for GeneratorMode::kOpenAI.
*/ */
std::string openai_api_key; std::string openai_api_key;

View File

@@ -182,16 +182,16 @@ std::optional<ApplicationOptions> ParseArguments(
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
.message = msg}); .message = msg});
} else { } else {
std::cerr << msg << std::endl; std::cerr << msg << '\n';
} }
return std::nullopt; return std::nullopt;
} }
options.generator.openai_api_key = api_key_env; options.generator.openai_api_key = api_key_env;
} }
options.generator.mode = use_mocked ? GeneratorMode::Mock options.generator.mode = use_mocked ? GeneratorMode::kMock
: use_openai ? GeneratorMode::OpenAI : use_openai ? GeneratorMode::kOpenAI
: GeneratorMode::Llama; : GeneratorMode::kLlama;
options.generator.model_path = model_path; options.generator.model_path = model_path;
options.generator.openai_model = var_map["openai-model"].as<std::string>(); options.generator.openai_model = var_map["openai-model"].as<std::string>();
@@ -208,7 +208,7 @@ std::optional<ApplicationOptions> ParseArguments(
if (user_provided_sampling) { if (user_provided_sampling) {
// Warn but do not fail — the run is still valid, the flags are just // Warn but do not fail — the run is still valid, the flags are just
// silently irrelevant when no local model is loaded. // silently irrelevant when no local model is loaded.
if (options.generator.mode != GeneratorMode::Llama) { if (options.generator.mode != GeneratorMode::kLlama) {
const std::string msg = const std::string msg =
"Sampling parameters are ignored unless using --model " "Sampling parameters are ignored unless using --model "
"(Llama)"; "(Llama)";

View File

@@ -31,8 +31,8 @@ double RadiansToDegrees(double radians) {
} }
} // namespace } // namespace
static Coords GetRandomCoordsWithinRange(Coords const& centre, static Coords GetRandomCoordsWithinRange(const Coords& centre,
int const distance_km, const int distance_km,
std::mt19937& rng) { std::mt19937& rng) {
std::uniform_real_distribution<double> angle_dist(0.0, std::uniform_real_distribution<double> angle_dist(0.0,
2.0 * std::numbers::pi); 2.0 * std::numbers::pi);

View File

@@ -8,12 +8,14 @@
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <boost/json.hpp>
#include <cctype> #include <cctype>
#include <format>
#include <span> #include <span>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <boost/json.hpp>
namespace { namespace {
/** /**
* String trimming: removes leading and trailing whitespace * String trimming: removes leading and trailing whitespace
@@ -66,11 +68,18 @@ bool ReadRequiredTrimmedStringField(const boost::json::object& obj,
std::string* error_out) { std::string* error_out) {
const boost::json::value* field = obj.if_contains(key); const boost::json::value* field = obj.if_contains(key);
if (field == nullptr || !field->is_string()) { if (field == nullptr || !field->is_string()) {
if (error_out != nullptr) {
*error_out =
std::format("Missing or invalid string field: {}", key);
}
return false; return false;
} }
const auto& string_value = field->as_string(); const auto& string_value = field->as_string();
out = Trim(std::string_view(string_value.data(), string_value.size())); 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(); return !out.empty();
} }

View File

@@ -3,9 +3,12 @@
* @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 <format> #include <format>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
@@ -13,26 +16,51 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include <boost/json.hpp>
#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 +91,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());

View File

@@ -67,7 +67,7 @@ int main(const int argc, char** argv) {
std::unique_ptr<IPromptDirectory> prompt_directory; std::unique_ptr<IPromptDirectory> prompt_directory;
if (options.generator.mode != GeneratorMode::Mock) { if (options.generator.mode != GeneratorMode::kMock) {
try { try {
prompt_directory = std::make_unique<PromptDirectory>( prompt_directory = std::make_unique<PromptDirectory>(
options.pipeline.prompt_dir, log_producer); options.pipeline.prompt_dir, log_producer);
@@ -90,7 +90,7 @@ int main(const int argc, char** argv) {
di::bind<IPostalCodeService>().to( di::bind<IPostalCodeService>().to(
[options, [options,
&log_producer]() -> std::unique_ptr<IPostalCodeService> { &log_producer]() -> std::unique_ptr<IPostalCodeService> {
if (options.generator.mode == GeneratorMode::Mock) { if (options.generator.mode == GeneratorMode::kMock) {
log_producer->Log( log_producer->Log(
{.level = LogLevel::Info, {.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
@@ -110,7 +110,7 @@ int main(const int argc, char** argv) {
di::bind<ICuratedDataService>().to( di::bind<ICuratedDataService>().to(
[options, [options,
&log_producer]() -> std::unique_ptr<ICuratedDataService> { &log_producer]() -> std::unique_ptr<ICuratedDataService> {
// if (options.generator.mode == GeneratorMode::Mock) { // if (options.generator.mode == GeneratorMode::kMock) {
// log_producer->Log({.level = LogLevel::Info, // log_producer->Log({.level = LogLevel::Info,
// .phase = PipelinePhase::Startup, // .phase = PipelinePhase::Startup,
// .message = "Curated data: mock"}); // .message = "Curated data: mock"});
@@ -131,7 +131,7 @@ int main(const int argc, char** argv) {
}); });
}), }),
di::bind<IPromptFormatter>().to([options, log_producer] { di::bind<IPromptFormatter>().to([options, log_producer] {
if (options.generator.mode != GeneratorMode::Llama) { if (options.generator.mode != GeneratorMode::kLlama) {
log_producer->Log( log_producer->Log(
{.level = LogLevel::Info, {.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
@@ -150,7 +150,7 @@ int main(const int argc, char** argv) {
std::make_unique<Gemma4JinjaPromptFormatter>()); std::make_unique<Gemma4JinjaPromptFormatter>());
}), }),
di::bind<WebClient>().to([options, log_producer] { di::bind<WebClient>().to([options, log_producer] {
if (options.generator.mode == GeneratorMode::Mock) { if (options.generator.mode == GeneratorMode::kMock) {
{ {
log_producer->Log( log_producer->Log(
{.level = LogLevel::Info, {.level = LogLevel::Info,
@@ -170,7 +170,7 @@ int main(const int argc, char** argv) {
di::bind<IEnrichmentService>().to( di::bind<IEnrichmentService>().to(
[options, &log_producer]( [options, &log_producer](
const auto& inj) -> std::unique_ptr<IEnrichmentService> { const auto& inj) -> std::unique_ptr<IEnrichmentService> {
if (options.generator.mode == GeneratorMode::Mock) { if (options.generator.mode == GeneratorMode::kMock) {
log_producer->Log({.level = LogLevel::Info, log_producer->Log({.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
.message = "Enrichment: mock"}); .message = "Enrichment: mock"});
@@ -190,7 +190,7 @@ int main(const int argc, char** argv) {
[&options, &model_path, &sampling, &prompt_directory, [&options, &model_path, &sampling, &prompt_directory,
&log_producer]( &log_producer](
const auto& inj) -> std::unique_ptr<DataGenerator> { const auto& inj) -> std::unique_ptr<DataGenerator> {
if (options.generator.mode == GeneratorMode::Mock) { if (options.generator.mode == GeneratorMode::kMock) {
log_producer->Log({.level = LogLevel::Info, log_producer->Log({.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,
.message = "Generator: mock"}); .message = "Generator: mock"});
@@ -198,7 +198,7 @@ int main(const int argc, char** argv) {
return std::make_unique<MockGenerator>(); return std::make_unique<MockGenerator>();
} }
if (options.generator.mode == GeneratorMode::OpenAI) { if (options.generator.mode == GeneratorMode::kOpenAI) {
log_producer->Log( log_producer->Log(
{.level = LogLevel::Info, {.level = LogLevel::Info,
.phase = PipelinePhase::Startup, .phase = PipelinePhase::Startup,

View File

@@ -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>

View File

@@ -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";
} }

View File

@@ -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; }
} }

View File

@@ -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),
}, },
} }
); );

View File

@@ -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
View 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."