mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-06-01 10:04:00 +00:00
Compare commits
167 Commits
main-1.0
...
056fb47b93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
056fb47b93 | ||
|
|
88527f7709 | ||
|
|
49f4ed6787 | ||
|
|
4d4b897d02 | ||
|
|
f71e4ddc83 | ||
|
|
212077793e | ||
|
|
e6d1954506 | ||
|
|
ce56532728 | ||
|
|
9649c993e8 | ||
|
|
f782fdb51d | ||
|
|
fcc7a5dc8b | ||
|
|
44a74ed2ad | ||
|
|
6682b5de01 | ||
|
|
62dfb5e14a | ||
|
|
ddf4bcb981 | ||
|
|
15853c62fd | ||
|
|
ff4b7f2578 | ||
|
|
3c70c46957 | ||
|
|
c7abc808ea | ||
|
|
ef4f47d415 | ||
|
|
035b30abba | ||
|
|
1cd30488eb | ||
|
|
823599a96f | ||
|
|
56ec728ba7 | ||
|
|
7ca651a886 | ||
|
|
b53f9e5582 | ||
|
|
824f5b2b4f | ||
|
|
5d93d76e99 | ||
|
|
028786b8b5 | ||
|
|
d7a31b5264 | ||
|
|
b31be494d7 | ||
|
|
7807f0bc2a | ||
|
|
772ef0cdfb | ||
|
|
a6e2ea21d0 | ||
|
|
a7cbf7507f | ||
|
|
3c7e74e3c1 | ||
|
|
b1ac3a6068 | ||
|
|
06d329cac5 | ||
|
|
54c403526b | ||
|
|
b8e96a6d45 | ||
|
|
60ee2ecf74 | ||
|
|
e4e16a5084 | ||
|
|
8d306bf691 | ||
|
|
077f6ab4ae | ||
|
|
534403734a | ||
|
|
3af053f0eb | ||
|
|
ba165d8aa7 | ||
|
|
eb9a2767b4 | ||
|
|
29ea47fdb6 | ||
|
|
52e2333304 | ||
|
|
a1f0ca5b20 | ||
|
|
2ea8aa52b4 | ||
|
|
98083ab40c | ||
|
|
ac136f7179 | ||
|
|
280c9c61bd | ||
|
|
248a51b35f | ||
|
|
35aa7bc0df | ||
|
|
581863d69b | ||
|
|
9238036042 | ||
|
|
431e11e052 | ||
|
|
f1194d3da8 | ||
|
|
17eb04e20c | ||
|
|
50c2f5dfda | ||
|
|
c5683df4b6 | ||
|
|
2cad88e3f6 | ||
|
|
0d52c937ce | ||
|
|
6b66f5680f | ||
|
|
82f0d26200 | ||
|
|
7129e5679e | ||
|
|
584fe6282f | ||
|
|
8c61069b7d | ||
|
|
674f91cbdf | ||
|
|
a54d2a6da0 | ||
|
|
954c9c389c | ||
|
|
d942d92db5 | ||
|
|
c80eae694f | ||
|
|
94061c6d84 | ||
|
|
caf13de36e | ||
|
|
2cb8f1d918 | ||
|
|
f728514a7c | ||
|
|
4f92741b4f | ||
|
|
a038a12fca | ||
|
|
74c5528ea2 | ||
|
|
f48b8452d3 | ||
|
|
2411841bdc | ||
|
|
215824d4b6 | ||
|
|
99b13e2742 | ||
|
|
3a32f326bf | ||
|
|
b2cf21399b | ||
|
|
109ade474c | ||
|
|
07a62a0c99 | ||
|
|
31e67ebad8 | ||
|
|
c74b20079b | ||
|
|
2b0f9876bc | ||
|
|
8a4b833943 | ||
|
|
656981003b | ||
|
|
ff1ce15419 | ||
|
|
881a94893f | ||
|
|
8abacb5572 | ||
|
|
027e130fcd | ||
|
|
243931eb6a | ||
|
|
b22e1e5702 | ||
|
|
b07cec8c7e | ||
|
|
92628290da | ||
|
|
ca2d7c453f | ||
|
|
2076935ee2 | ||
|
|
5c49611bff | ||
|
|
ae6002bbe0 | ||
|
|
a1ea6391bc | ||
|
|
6d812638ba | ||
|
|
17bf29700a | ||
|
|
393e57af7f | ||
|
|
e0af25f17c | ||
|
|
9bfbed9b92 | ||
|
|
2ae99d5224 | ||
|
|
b994201a18 | ||
|
|
e4560f8d80 | ||
|
|
dbd3b6ce0a | ||
|
|
ee53cc60d8 | ||
|
|
954e224c34 | ||
|
|
9474fb7811 | ||
|
|
77bb1f6733 | ||
|
|
1af3d6f987 | ||
|
|
2332f9f9b5 | ||
|
|
0053d84de8 | ||
|
|
754578c84c | ||
|
|
ca49d19bf7 | ||
|
|
cf9f048daa | ||
|
|
a8c0ae6358 | ||
|
|
52643c1173 | ||
|
|
24b059ea3d | ||
|
|
97c093c4bc | ||
|
|
45f64f613d | ||
|
|
084f68da7a | ||
|
|
ea92735146 | ||
|
|
54788b1a6d | ||
|
|
7dc7ef4b1a | ||
|
|
a6702c89fd | ||
|
|
68ff549635 | ||
|
|
a56ea77861 | ||
|
|
14cb05e992 | ||
|
|
53a7569ed5 | ||
|
|
82db763951 | ||
|
|
fd544dbd34 | ||
|
|
89da531c48 | ||
|
|
c5aaf8cd05 | ||
|
|
b8cd855916 | ||
|
|
60ef65ec52 | ||
|
|
da84492aa4 | ||
|
|
b5ab6f6893 | ||
|
|
7fbdfbf542 | ||
|
|
43dcf0844d | ||
|
|
c928ddecb5 | ||
|
|
372aac897a | ||
|
|
8d6b903aa7 | ||
|
|
00a0f6c4ef | ||
|
|
afefdb9e3d | ||
|
|
fc2e8c9b6d | ||
|
|
b86607e37a | ||
|
|
a200164609 | ||
|
|
4e2c9836c9 | ||
|
|
b7f22fcc66 | ||
|
|
f0c9cff8be | ||
|
|
33db1368ec | ||
|
|
8975044034 | ||
|
|
738c055bf7 | ||
|
|
2f0bfd90b2 |
13
.config/dotnet-tools.json
Normal file
13
.config/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"csharpier": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"commands": [
|
||||||
|
"csharpier"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
.csharpierrc.json
Normal file
19
.csharpierrc.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/csharpier.json",
|
||||||
|
|
||||||
|
"printWidth": 80,
|
||||||
|
"useTabs": false,
|
||||||
|
"indentSize": 4,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.xml",
|
||||||
|
"indentSize": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.csx",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
56
.env.example
Normal file
56
.env.example
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# ==============================================
|
||||||
|
# Biergarten App - Environment Variables Template
|
||||||
|
# ==============================================
|
||||||
|
#
|
||||||
|
# This file contains backend/Docker environment variables.
|
||||||
|
# Copy this to create environment-specific files:
|
||||||
|
# - .env.dev (development)
|
||||||
|
# - .env.test (testing)
|
||||||
|
# - .env.prod (production)
|
||||||
|
#
|
||||||
|
# For frontend variables, create a separate .env.local file
|
||||||
|
# in the Website/ directory. See README.md for complete docs.
|
||||||
|
#
|
||||||
|
# ==============================================
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Database Configuration
|
||||||
|
# ======================
|
||||||
|
|
||||||
|
# SQL Server Connection Components (Recommended for Docker)
|
||||||
|
# These are used to build connection strings dynamically
|
||||||
|
DB_SERVER=sqlserver,1433
|
||||||
|
DB_NAME=Biergarten
|
||||||
|
DB_USER=sa
|
||||||
|
DB_PASSWORD=YourStrong!Passw0rd
|
||||||
|
|
||||||
|
# Alternative: Full Connection String (Local Development)
|
||||||
|
# If set, this overrides the component-based configuration above
|
||||||
|
# DB_CONNECTION_STRING=Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# JWT Configuration
|
||||||
|
# ======================
|
||||||
|
|
||||||
|
# JWT Secret for signing tokens
|
||||||
|
# IMPORTANT: Generate a secure secret (minimum 32 characters)
|
||||||
|
# Command: openssl rand -base64 32
|
||||||
|
ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
|
||||||
|
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
|
||||||
|
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
||||||
|
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# SMTP Configuration
|
||||||
|
# ======================
|
||||||
|
# SMTP settings for sending emails (e.g., password resets)
|
||||||
|
# For development, you can use a local SMTP testing tool like Mailpit or MailHog
|
||||||
|
# In production, set these to real SMTP server credentials from an email service
|
||||||
|
# provider (e.g., SendGrid, Mailgun, Amazon SES).
|
||||||
|
SMTP_HOST=mailpit
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_USE_SSL=false
|
||||||
|
SMTP_FROM_EMAIL=noreply@thebiergarten.app
|
||||||
|
SMTP_FROM_NAME=The Biergarten App
|
||||||
43
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
43
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: Feature Request (BDD)
|
||||||
|
about: Create a new feature using user story + BDD acceptance criteria
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["feature", "BDD"]
|
||||||
|
assignees: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
**As a** (who wants to accomplish something)
|
||||||
|
**I want to** (what they want to accomplish)
|
||||||
|
**So that** (why they want to accomplish that thing)
|
||||||
|
|
||||||
|
## Acceptance Criteria (BDD)
|
||||||
|
|
||||||
|
### Scenario 1
|
||||||
|
|
||||||
|
|
||||||
|
Given ...
|
||||||
|
When ...
|
||||||
|
Then ...
|
||||||
|
|
||||||
|
|
||||||
|
### Scenario 2
|
||||||
|
|
||||||
|
|
||||||
|
Given ...
|
||||||
|
When ...
|
||||||
|
Then ...
|
||||||
|
|
||||||
|
|
||||||
|
### Scenario 3
|
||||||
|
|
||||||
|
|
||||||
|
Given ...
|
||||||
|
When ...
|
||||||
|
Then ...
|
||||||
|
|
||||||
|
|
||||||
|
## Subtasks
|
||||||
|
- [ ] Task 1
|
||||||
|
- [ ] Task 2
|
||||||
|
- [ ] Task 3
|
||||||
19
.github/workflows/github-actions-demo.yml
vendored
19
.github/workflows/github-actions-demo.yml
vendored
@@ -1,19 +0,0 @@
|
|||||||
name: Node.js CI
|
|
||||||
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Use Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
455
.gitignore
vendored
455
.gitignore
vendored
@@ -15,6 +15,14 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# project-specific build artifacts
|
||||||
|
/src/Website/build/
|
||||||
|
/src/Website/storybook-static/
|
||||||
|
/src/Website/.react-router/
|
||||||
|
/src/Website/playwright-report/
|
||||||
|
/src/Website/test-results/
|
||||||
|
/test-results/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
@@ -42,7 +50,454 @@ next-env.d.ts
|
|||||||
|
|
||||||
# vscode
|
# vscode
|
||||||
.vscode
|
.vscode
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
/cloudinary-images
|
/cloudinary-images
|
||||||
|
|
||||||
.obsidian
|
.obsidian
|
||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
|
||||||
|
[Dd]ebug/x64/
|
||||||
|
[Dd]ebugPublic/x64/
|
||||||
|
[Rr]elease/x64/
|
||||||
|
[Rr]eleases/x64/
|
||||||
|
bin/x64/
|
||||||
|
obj/x64/
|
||||||
|
|
||||||
|
[Dd]ebug/x86/
|
||||||
|
[Dd]ebugPublic/x86/
|
||||||
|
[Rr]elease/x86/
|
||||||
|
[Rr]eleases/x86/
|
||||||
|
bin/x86/
|
||||||
|
obj/x86/
|
||||||
|
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
[Aa][Rr][Mm]64[Ee][Cc]/
|
||||||
|
bld/
|
||||||
|
[Oo]bj/
|
||||||
|
[Oo]ut/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Build results on 'Bin' directories
|
||||||
|
**/[Bb]in/*
|
||||||
|
# Uncomment if you have tasks that rely on *.refresh files to move binaries
|
||||||
|
# (https://github.com/github/gitignore/pull/3736)
|
||||||
|
#!**/[Bb]in/*.refresh
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
*.trx
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Approval Tests result files
|
||||||
|
*.received.*
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.idb
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
**/.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
**/.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
**/.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
#tools/**
|
||||||
|
#!tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
MSBuild_Logs/
|
||||||
|
|
||||||
|
# AWS SAM Build and Temporary Artifacts folder
|
||||||
|
.aws-sam
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
**/.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
**/.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
**/.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
*/data_source/other
|
||||||
|
.fake
|
||||||
|
.idea
|
||||||
|
|
||||||
|
*.feature.cs
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.dev
|
||||||
|
.env.test
|
||||||
|
.env.prod
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
957
LICENSE.md
957
LICENSE.md
File diff suppressed because it is too large
Load Diff
288
README.md
288
README.md
@@ -1,219 +1,159 @@
|
|||||||
# The Biergarten App
|
# The Biergarten App
|
||||||
|
|
||||||
## About
|
The Biergarten App is a multi-project monorepo with a .NET backend and an active React
|
||||||
|
Router frontend in `src/Website`. The current website focuses on account flows, theme
|
||||||
|
switching, shared UI components, Storybook coverage, and integration with the API.
|
||||||
|
|
||||||
The Biergarten App is a web application designed for beer lovers to share their favorite
|
## Documentation
|
||||||
brews and breweries with like-minded people online.
|
|
||||||
|
|
||||||
This application's stack consists of Next.js, Prisma and Neon Postgres. I'm motivated to
|
- [Getting Started](docs/getting-started.md) - Local setup for backend and active website
|
||||||
learn more about these technologies while exploring my passion for beer.
|
- [Architecture](docs/architecture.md) - Current backend and frontend architecture
|
||||||
|
- [Docker Guide](docs/docker.md) - Container-based backend development and testing
|
||||||
|
- [Testing](docs/testing.md) - Backend and frontend test commands
|
||||||
|
- [Environment Variables](docs/environment-variables.md) - Active configuration reference
|
||||||
|
- [Token Validation](docs/token-validation.md) - JWT validation architecture
|
||||||
|
- [Legacy Website Archive](docs/archive/legacy-website-v1.md) - Archived notes for the old Next.js frontend
|
||||||
|
|
||||||
I've also incorporated different APIs into the application, such as the Cloudinary API for
|
## Diagrams
|
||||||
image uploading, the SparkPost API for email services as well as Mapbox for geolocation
|
|
||||||
and map data.
|
|
||||||
|
|
||||||
To handle serverless functions (API routes), I use the next-connect package.
|
- [Architecture](docs/diagrams-out/architecture.svg) - Layered architecture
|
||||||
|
- [Deployment](docs/diagrams-out/deployment.svg) - Docker topology
|
||||||
|
- [Authentication Flow](docs/diagrams-out/authentication-flow.svg) - Auth sequence
|
||||||
|
- [Database Schema](docs/diagrams-out/database-schema.svg) - Entity relationships
|
||||||
|
|
||||||
On the client-side, I use Tailwind CSS, Headless UI and Daisy UI for styling to create a
|
## Current Status
|
||||||
visually appealing and user-friendly interface.
|
|
||||||
|
|
||||||
I'm sharing my code publicly so that others can learn from it and use it as a reference
|
Active areas in the repository:
|
||||||
for their own projects.
|
|
||||||
|
|
||||||
### Some beer terminology
|
- .NET 10 backend with layered architecture and SQL Server
|
||||||
|
- React Router 7 website in `src/Website`
|
||||||
|
- Shared Biergarten theme system with a theme guide route
|
||||||
|
- Storybook stories and browser-based checks for shared UI
|
||||||
|
- Auth demo flows for home, login, register, dashboard, logout, and confirmation
|
||||||
|
- Toast-based feedback for auth outcomes
|
||||||
|
|
||||||
In this app you will encounter various beer related terms. Here is a list of terms used in
|
Legacy area retained for reference:
|
||||||
this app and their definitions.
|
|
||||||
|
|
||||||
#### ABV
|
- `src/Website-v1` contains the archived Next.js frontend and is no longer the active website
|
||||||
|
|
||||||
[Alcohol by volume](https://en.wikipedia.org/wiki/Alcohol_by_volume) (abbreviated as ABV)
|
## Tech Stack
|
||||||
is a standard measure of how much alcohol (ethanol) is contained in a given volume of an
|
|
||||||
alcoholic beverage (expressed as a volume percent).
|
|
||||||
|
|
||||||
#### IBU
|
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||||
|
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
||||||
|
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||||
|
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||||
|
- **Infrastructure**: Docker, Docker Compose
|
||||||
|
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation tokens
|
||||||
|
|
||||||
The
|
## Quick Start
|
||||||
[International Bitterness Units](https://en.wikipedia.org/wiki/Beer_measurement#Bitterness)
|
|
||||||
scale, or IBU, is used to approximately quantify the bitterness of beer. This scale is not
|
|
||||||
measured on the perceived bitterness of the beer, but rather the amount of a component of
|
|
||||||
beer known as iso-alpha acids.
|
|
||||||
|
|
||||||
## Database Schema
|
### Backend
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Technologies
|
|
||||||
|
|
||||||
### General
|
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org/)
|
|
||||||
- A React based framework for building web applications offering several features such
|
|
||||||
as server side rendering, static site generation and API routes.
|
|
||||||
|
|
||||||
### Client
|
|
||||||
|
|
||||||
- [SWR](https://swr.vercel.app/)
|
|
||||||
- A React Hooks library for fetching data with support for caching, revalidation and
|
|
||||||
error handling.
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/)
|
|
||||||
- A popular open-source utility-first CSS framework that provides pre-defined classes to
|
|
||||||
style HTML elements.
|
|
||||||
- [Headless UI](https://headlessui.dev/)
|
|
||||||
- A set of completely unstyled, fully accessible UI components, designed to integrate
|
|
||||||
beautifully with Tailwind CSS.
|
|
||||||
- [Daisy UI](https://daisyui.com/)
|
|
||||||
- A component library for Tailwind CSS that provides ready-to-use components for
|
|
||||||
building user interfaces.
|
|
||||||
|
|
||||||
### Server
|
|
||||||
|
|
||||||
- [Prisma](https://www.prisma.io/)
|
|
||||||
- An open-source ORM for Node.js and TypeScript applications.
|
|
||||||
- [Neon Postgres](https://neon.tech/)
|
|
||||||
- A managed PostgreSQL database service powered by Neon.
|
|
||||||
- [Cloudinary](https://cloudinary.com/)
|
|
||||||
- A cloud-based image and video management service that provides developers with an easy
|
|
||||||
way to upload, store, and manipulate media assets.
|
|
||||||
- [SparkPost](https://www.sparkpost.com/)
|
|
||||||
- A cloud-based email delivery service that provides developers with an easy way to send
|
|
||||||
transactional and marketing emails.
|
|
||||||
- [Mapbox](https://www.mapbox.com/)
|
|
||||||
- A suite of open-source mapping tools that allows developers to add custom maps,
|
|
||||||
search, and navigation into their applications.
|
|
||||||
- [next-connect](https://github.com/hoangvvo/next-connect#readme)
|
|
||||||
- A promise-based method routing and middleware layer for Next.js.
|
|
||||||
|
|
||||||
## How to run locally
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
Before you can run this application locally, you will need to have the following installed
|
|
||||||
on your machine:
|
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
|
||||||
- [npm (version 8 or higher)](https://www.npmjs.com/get-npm)
|
|
||||||
|
|
||||||
You will also need to create a free account with the following services:
|
|
||||||
|
|
||||||
- [Cloudinary](https://cloudinary.com/users/register/free)
|
|
||||||
- [SparkPost](https://www.sparkpost.com/)
|
|
||||||
- [Neon Postgres](https://neon.tech/)
|
|
||||||
- [Mapbox](https://account.mapbox.com/auth/signup/)
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. Clone this repository and navigate to the project directory.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||||
cd the-biergarten-app
|
cd the-biergarten-app
|
||||||
|
cp .env.example .env.dev
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run the following command to install the dependencies.
|
Backend access:
|
||||||
|
|
||||||
|
- API Swagger: http://localhost:8080/swagger
|
||||||
|
- Health Check: http://localhost:8080/health
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd src/Website
|
||||||
npm install
|
npm install
|
||||||
|
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Run the following script to create a `.env` file in the root directory of the project
|
Optional frontend tools:
|
||||||
and add the following environment variables. Update these variables with your own
|
|
||||||
values.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
echo "BASE_URL=
|
cd src/Website
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
|
npm run storybook
|
||||||
CLOUDINARY_KEY=
|
npm run test:storybook
|
||||||
CLOUDINARY_SECRET=
|
npm run test:storybook:playwright
|
||||||
CONFIRMATION_TOKEN_SECRET=
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=
|
|
||||||
SESSION_SECRET=
|
|
||||||
SESSION_TOKEN_NAME=
|
|
||||||
SESSION_MAX_AGE=
|
|
||||||
NODE_ENV=
|
|
||||||
|
|
||||||
POSTGRES_PRISMA_URL=
|
|
||||||
POSTGRES_URL_NON_POOLING=
|
|
||||||
SHADOW_DATABASE_URL=
|
|
||||||
|
|
||||||
ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
MAPBOX_ACCESS_TOKEN=
|
|
||||||
|
|
||||||
SPARKPOST_API_KEY=
|
|
||||||
SPARKPOST_SENDER_ADDRESS=" > .env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Explanation of environment variables
|
## Repository Structure
|
||||||
|
|
||||||
- `BASE_URL` is the base URL of the application.
|
```text
|
||||||
- For example, if you are running the application locally, you can set this to
|
src/Core/ Backend projects (.NET)
|
||||||
`http://localhost:3000`.
|
src/Website/ Active React Router frontend
|
||||||
- `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_KEY`, and `CLOUDINARY_SECRET` are the
|
src/Website-v1/ Archived legacy Next.js frontend
|
||||||
credentials for your Cloudinary account.
|
docs/ Active project documentation
|
||||||
- You can create a free account [here](https://cloudinary.com/users/register/free).
|
docs/archive/ Archived legacy documentation
|
||||||
- `CONFIRMATION_TOKEN_SECRET` is the secret used to sign the confirmation token used for
|
```
|
||||||
email confirmation.
|
|
||||||
- You can generate a random string using the`openssl rand -base64 127` command.
|
|
||||||
- `RESET_PASSWORD_TOKEN_SECRET` is the secret used to sign the reset password token.
|
|
||||||
- You can generate a random string using the `openssl rand -base64 127` command.
|
|
||||||
- `SESSION_SECRET` is the secret used to sign the session cookie.
|
|
||||||
- Use the same command as above to generate a random string.
|
|
||||||
- `SESSION_TOKEN_NAME` is the name of the session cookie.
|
|
||||||
- You can set this to `biergarten`.
|
|
||||||
- `SESSION_MAX_AGE` is the maximum age of the session cookie in seconds.
|
|
||||||
- You can set this to `604800` (1 week).
|
|
||||||
- `POSTGRES_PRISMA_URL`is a pooled connection string for your Neon Postgres database.
|
|
||||||
- `POSTGRES_URL_NON_POOLING` is a non-pooled connection string for your Neon Postgres
|
|
||||||
database used for migrations.
|
|
||||||
- `SHADOW_DATABASE_URL` is a connection string for a secondary database used for
|
|
||||||
migrations to detect schema drift.
|
|
||||||
- You can create a free account [here](https://neon.tech).
|
|
||||||
- Consult the [docs](https://neon.tech/docs/guides/prisma) for more information.
|
|
||||||
- `MAPBOX_ACCESS_TOKEN` is the access token for your Mapbox account.
|
|
||||||
- You can create a free account [here](https://account.mapbox.com/auth/signup/).
|
|
||||||
- `NODE_ENV` is the environment in which the application is running.
|
|
||||||
- You can set this to `development` or `production`.
|
|
||||||
- `SPARKPOST_API_KEY` is the API key for your SparkPost account.
|
|
||||||
- You can create a free account [here](https://www.sparkpost.com/).
|
|
||||||
- `SPARKPOST_SENDER_ADDRESS` is the email address that will be used to send emails.
|
|
||||||
- `ADMIN_PASSWORD` is the password for the admin account created when seeding the
|
|
||||||
database.
|
|
||||||
|
|
||||||
1. Initialize the database and run the migrations.
|
## Key Features
|
||||||
|
|
||||||
|
Implemented today:
|
||||||
|
|
||||||
|
- User registration and login against the API
|
||||||
|
- JWT-based auth with access, refresh, and confirmation flows
|
||||||
|
- SQL Server migrations and seed projects
|
||||||
|
- Shared form components and auth screens
|
||||||
|
- Theme switching with Lager, Stout, Cassis, and Weizen variants
|
||||||
|
- Storybook documentation and automated story interaction tests
|
||||||
|
- Toast feedback for auth-related outcomes
|
||||||
|
|
||||||
|
Planned next:
|
||||||
|
|
||||||
|
- Brewery discovery and management
|
||||||
|
- Beer reviews and ratings
|
||||||
|
- Social follow relationships
|
||||||
|
- Geospatial brewery experiences
|
||||||
|
- Additional frontend routes beyond the auth demo
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Backend suites:
|
||||||
|
|
||||||
|
- `API.Specs` - integration tests
|
||||||
|
- `Infrastructure.Repository.Tests` - repository unit tests
|
||||||
|
- `Service.Auth.Tests` - service unit tests
|
||||||
|
|
||||||
|
Frontend suites:
|
||||||
|
|
||||||
|
- Storybook interaction tests via Vitest
|
||||||
|
- Storybook browser regression checks via Playwright
|
||||||
|
|
||||||
|
Run all backend tests with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx prisma generate
|
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Seed the database with some initial data.
|
See [Testing](docs/testing.md) for the full command list.
|
||||||
|
|
||||||
```bash
|
## Configuration
|
||||||
npm run seed
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Start the application.
|
Common active variables:
|
||||||
|
|
||||||
```bash
|
- Backend: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`
|
||||||
npm run dev
|
- Frontend: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
See [Environment Variables](docs/environment-variables.md) for details.
|
||||||
|
|
||||||
The Biergarten App is licensed under the GNU General Public License v3.0. This means that
|
## Contributing
|
||||||
anyone is free to use, modify, and distribute the code as long as they also distribute
|
|
||||||
their modifications under the same license.
|
|
||||||
|
|
||||||
I encourage anyone who uses this code for educational purposes to attribute me as the
|
1. Fork the repository
|
||||||
original author, and to provide a link to this repository.
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
By contributing to this repository, you agree to license your contributions under the same
|
### Development Workflow
|
||||||
license as the project.
|
|
||||||
|
|
||||||
If you have any questions or concerns about the license, please feel free to submit an
|
1. Start development environment: `docker compose -f docker-compose.dev.yaml up -d`
|
||||||
issue to this repository.
|
2. Make changes to code
|
||||||
|
3. Run tests: `docker compose -f docker-compose.test.yaml up --abort-on-container-exit`
|
||||||
|
4. Rebuild if needed: `docker compose -f docker-compose.dev.yaml up -d --build api.core`
|
||||||
|
|
||||||
I hope that this project will be useful to other developers and beer enthusiasts who are
|
## Support
|
||||||
interested in learning about web development with Next.js, Prisma, Postgres, and other
|
|
||||||
technologies.
|
- **Documentation**: [docs/](docs/)
|
||||||
|
- **Architecture**: See [Architecture Guide](docs/architecture.md)
|
||||||
|
|||||||
77
docker-compose.db.yaml
Normal file
77
docker-compose.db.yaml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
services:
|
||||||
|
sqlserver:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: dev-env-sqlserver
|
||||||
|
environment:
|
||||||
|
ACCEPT_EULA: "Y"
|
||||||
|
SA_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
MSSQL_PID: "Express"
|
||||||
|
ports:
|
||||||
|
- "1433:1433"
|
||||||
|
volumes:
|
||||||
|
- sqlserverdata-dev:/var/opt/mssql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
database.migrations:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: database.migrations
|
||||||
|
container_name: dev-env-database-migrations
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./src/Core/Database
|
||||||
|
dockerfile: Database.Migrations/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
CLEAR_DATABASE: "true"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
|
database.seed:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: database.seed
|
||||||
|
container_name: dev-env-database-seed
|
||||||
|
depends_on:
|
||||||
|
database.migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: Database/Database.Seed/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
volumes:
|
||||||
|
sqlserverdata-dev:
|
||||||
|
driver: local
|
||||||
|
nuget-cache-dev:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
devnet:
|
||||||
|
driver: bridge
|
||||||
125
docker-compose.dev.yaml
Normal file
125
docker-compose.dev.yaml
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
services:
|
||||||
|
sqlserver:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: dev-env-sqlserver
|
||||||
|
environment:
|
||||||
|
ACCEPT_EULA: "Y"
|
||||||
|
SA_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
MSSQL_PID: "Express"
|
||||||
|
ports:
|
||||||
|
- "1433:1433"
|
||||||
|
volumes:
|
||||||
|
- sqlserverdata-dev:/var/opt/mssql
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
database.migrations:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: database.migrations
|
||||||
|
container_name: dev-env-database-migrations
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./src/Core/Database
|
||||||
|
dockerfile: Database.Migrations/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
CLEAR_DATABASE: "true"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
|
database.seed:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: database.seed
|
||||||
|
container_name: dev-env-database-seed
|
||||||
|
depends_on:
|
||||||
|
database.migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: Database/Database.Seed/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
|
api.core:
|
||||||
|
env_file: ".env.dev"
|
||||||
|
image: api.core
|
||||||
|
container_name: dev-env-api-core
|
||||||
|
depends_on:
|
||||||
|
database.seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: API/API.Core/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "8081:8081"
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: "Development"
|
||||||
|
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||||
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
volumes:
|
||||||
|
- nuget-cache-dev:/root/.nuget/packages
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: dev-env-mailpit
|
||||||
|
ports:
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
- "1025:1025" # SMTP server
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
volumes:
|
||||||
|
sqlserverdata-dev:
|
||||||
|
driver: local
|
||||||
|
nuget-cache-dev:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
devnet:
|
||||||
|
driver: bridge
|
||||||
91
docker-compose.min.yaml
Normal file
91
docker-compose.min.yaml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
services:
|
||||||
|
sqlserver:
|
||||||
|
env_file: ".env.local"
|
||||||
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: dev-env-sqlserver
|
||||||
|
environment:
|
||||||
|
ACCEPT_EULA: "Y"
|
||||||
|
SA_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
MSSQL_PID: "Express"
|
||||||
|
ports:
|
||||||
|
- "1433:1433"
|
||||||
|
volumes:
|
||||||
|
- sqlserverdata-dev:/var/opt/mssql
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
database.migrations:
|
||||||
|
env_file: ".env.local"
|
||||||
|
image: database.migrations
|
||||||
|
container_name: dev-env-database-migrations
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./src/Core/Database
|
||||||
|
dockerfile: Database.Migrations/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
CLEAR_DATABASE: "true"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: dev-env-mailpit
|
||||||
|
ports:
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
- "1025:1025" # SMTP server
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
|
database.seed:
|
||||||
|
env_file: ".env.local"
|
||||||
|
image: database.seed
|
||||||
|
container_name: dev-env-database-seed
|
||||||
|
depends_on:
|
||||||
|
database.migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: Database/Database.Seed/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
volumes:
|
||||||
|
sqlserverdata-dev:
|
||||||
|
driver: local
|
||||||
|
nuget-cache-dev:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
devnet:
|
||||||
|
driver: bridge
|
||||||
87
docker-compose.prod.yaml
Normal file
87
docker-compose.prod.yaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
services:
|
||||||
|
sqlserver:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: prod-env-sqlserver
|
||||||
|
environment:
|
||||||
|
ACCEPT_EULA: "Y"
|
||||||
|
SA_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
MSSQL_PID: "Express"
|
||||||
|
volumes:
|
||||||
|
- sqlserverdata-prod:/var/opt/mssql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- prodnet
|
||||||
|
|
||||||
|
database.migrations:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: database.migrations
|
||||||
|
container_name: prod-env-database-migrations
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./src/Core/Database
|
||||||
|
dockerfile: Database.Migrations/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- prodnet
|
||||||
|
|
||||||
|
api.core:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: api.core
|
||||||
|
container_name: prod-env-api-core
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: API/API.Core/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "8081:8081"
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: "Production"
|
||||||
|
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||||
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- prodnet
|
||||||
|
volumes:
|
||||||
|
- nuget-cache-prod:/root/.nuget/packages
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlserverdata-prod:
|
||||||
|
driver: local
|
||||||
|
nuget-cache-prod:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
prodnet:
|
||||||
|
driver: bridge
|
||||||
144
docker-compose.test.yaml
Normal file
144
docker-compose.test.yaml
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
services:
|
||||||
|
sqlserver:
|
||||||
|
env_file: ".env.test"
|
||||||
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: test-env-sqlserver
|
||||||
|
environment:
|
||||||
|
ACCEPT_EULA: "Y"
|
||||||
|
SA_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
MSSQL_PID: "Express"
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
volumes:
|
||||||
|
- sqlserverdata-test:/var/opt/mssql
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- testnet
|
||||||
|
|
||||||
|
database.migrations:
|
||||||
|
env_file: ".env.test"
|
||||||
|
image: database.migrations
|
||||||
|
container_name: test-env-database-migrations
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./src/Core/Database
|
||||||
|
dockerfile: Database.Migrations/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
CLEAR_DATABASE: "true"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- testnet
|
||||||
|
|
||||||
|
database.seed:
|
||||||
|
env_file: ".env.test"
|
||||||
|
image: database.seed
|
||||||
|
container_name: test-env-database-seed
|
||||||
|
depends_on:
|
||||||
|
database.migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: Database/Database.Seed/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- testnet
|
||||||
|
|
||||||
|
api.specs:
|
||||||
|
env_file: ".env.test"
|
||||||
|
image: api.specs
|
||||||
|
container_name: test-env-api-specs
|
||||||
|
depends_on:
|
||||||
|
database.seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: API/API.Specs/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||||
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
|
volumes:
|
||||||
|
- ./test-results:/app/test-results
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- testnet
|
||||||
|
|
||||||
|
repository.tests:
|
||||||
|
env_file: ".env.test"
|
||||||
|
image: repository.tests
|
||||||
|
container_name: test-env-repository-tests
|
||||||
|
depends_on:
|
||||||
|
database.seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
volumes:
|
||||||
|
- ./test-results:/app/test-results
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- testnet
|
||||||
|
|
||||||
|
service.auth.tests:
|
||||||
|
env_file: ".env.test"
|
||||||
|
image: service.auth.tests
|
||||||
|
container_name: test-env-service-auth-tests
|
||||||
|
depends_on:
|
||||||
|
database.seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: Service/Service.Auth.Tests/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
volumes:
|
||||||
|
- ./test-results:/app/test-results
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- testnet
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlserverdata-test:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
testnet:
|
||||||
|
driver: bridge
|
||||||
427
docs/architecture.md
Normal file
427
docs/architecture.md
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document describes the active architecture of The Biergarten App.
|
||||||
|
|
||||||
|
## High-Level Overview
|
||||||
|
|
||||||
|
The Biergarten App is a monorepo with a clear split between the backend and the active
|
||||||
|
website:
|
||||||
|
|
||||||
|
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture
|
||||||
|
- **Frontend**: React 19 + React Router 7 website in `src/Website`
|
||||||
|
- **Architecture Style**: Layered backend plus server-rendered React frontend
|
||||||
|
|
||||||
|
The legacy Next.js frontend has been retained in `src/Website-v1` for reference only and is
|
||||||
|
documented in [archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
|
## Diagrams
|
||||||
|
|
||||||
|
For visual representations, see:
|
||||||
|
|
||||||
|
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture diagram
|
||||||
|
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
|
||||||
|
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) - Authentication workflow
|
||||||
|
- [database-schema.svg](diagrams-out/database-schema.svg) - Database relationships
|
||||||
|
|
||||||
|
## Backend Architecture
|
||||||
|
|
||||||
|
### Layered Architecture Pattern
|
||||||
|
|
||||||
|
The backend follows a strict layered architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ API Layer (Controllers) │
|
||||||
|
│ - HTTP Endpoints │
|
||||||
|
│ - Request/Response mapping │
|
||||||
|
│ - Swagger/OpenAPI │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Service Layer (Business Logic) │
|
||||||
|
│ - Authentication logic │
|
||||||
|
│ - User management │
|
||||||
|
│ - Validation & orchestration │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Infrastructure Layer (Tools) │
|
||||||
|
│ - JWT token generation │
|
||||||
|
│ - Password hashing (Argon2id) │
|
||||||
|
│ - Email services │
|
||||||
|
│ - Repository implementations │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Domain Layer (Entities) │
|
||||||
|
│ - UserAccount, UserCredential │
|
||||||
|
│ - Pure POCO classes │
|
||||||
|
│ - No external dependencies │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Database (SQL Server) │
|
||||||
|
│ - Stored procedures │
|
||||||
|
│ - Tables & constraints │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layer Responsibilities
|
||||||
|
|
||||||
|
#### API Layer (`API.Core`)
|
||||||
|
|
||||||
|
**Purpose**: HTTP interface and request handling
|
||||||
|
|
||||||
|
**Components**:
|
||||||
|
|
||||||
|
- Controllers (`AuthController`, `UserController`)
|
||||||
|
- Middleware for error handling
|
||||||
|
- Swagger/OpenAPI documentation
|
||||||
|
- Health check endpoints
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
|
||||||
|
- Service layer
|
||||||
|
- ASP.NET Core framework
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- No business logic
|
||||||
|
- Only request/response transformation
|
||||||
|
- Delegates all work to Service layer
|
||||||
|
|
||||||
|
#### Service Layer (`Service.Auth`, `Service.UserManagement`)
|
||||||
|
|
||||||
|
**Purpose**: Business logic and orchestration
|
||||||
|
|
||||||
|
**Components**:
|
||||||
|
|
||||||
|
- Authentication services (login, registration)
|
||||||
|
- User management services
|
||||||
|
- Business rule validation
|
||||||
|
- Transaction coordination
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
|
||||||
|
- Infrastructure layer (repositories, JWT, password hashing)
|
||||||
|
- Domain entities
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- Contains all business logic
|
||||||
|
- Coordinates multiple infrastructure components
|
||||||
|
- No direct database access (uses repositories)
|
||||||
|
- Returns domain models, not DTOs
|
||||||
|
|
||||||
|
#### Infrastructure Layer
|
||||||
|
|
||||||
|
**Purpose**: Technical capabilities and external integrations
|
||||||
|
|
||||||
|
**Components**:
|
||||||
|
|
||||||
|
- **Infrastructure.Repository**: Data access via stored procedures
|
||||||
|
- **Infrastructure.Jwt**: JWT token generation and validation
|
||||||
|
- **Infrastructure.PasswordHashing**: Argon2id password hashing
|
||||||
|
- **Infrastructure.Email**: Email sending capabilities
|
||||||
|
- **Infrastructure.Email.Templates**: Email template rendering
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
|
||||||
|
- Domain entities
|
||||||
|
- External libraries (ADO.NET, JWT, Argon2, etc.)
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- Implements technical concerns
|
||||||
|
- No business logic
|
||||||
|
- Reusable across services
|
||||||
|
|
||||||
|
#### Domain Layer (`Domain.Entities`)
|
||||||
|
|
||||||
|
**Purpose**: Core business entities and models
|
||||||
|
|
||||||
|
**Components**:
|
||||||
|
|
||||||
|
- `UserAccount` - User profile data
|
||||||
|
- `UserCredential` - Authentication credentials
|
||||||
|
- `UserVerification` - Account verification state
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
|
||||||
|
- None (pure domain)
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- Plain Old CLR Objects (POCOs)
|
||||||
|
- No framework dependencies
|
||||||
|
- No infrastructure references
|
||||||
|
- Represents business concepts
|
||||||
|
|
||||||
|
### Design Patterns
|
||||||
|
|
||||||
|
#### Repository Pattern
|
||||||
|
|
||||||
|
**Purpose**: Abstract database access behind interfaces
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
- `IAuthRepository` - Authentication queries
|
||||||
|
- `IUserAccountRepository` - User account queries
|
||||||
|
- `DefaultSqlConnectionFactory` - Connection management
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
|
||||||
|
- Testable (easy to mock)
|
||||||
|
- SQL-first approach (stored procedures)
|
||||||
|
- Centralized data access logic
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface IAuthRepository
|
||||||
|
{
|
||||||
|
Task<UserCredential> GetUserCredentialAsync(string username);
|
||||||
|
Task<int> CreateUserAccountAsync(UserAccount user, UserCredential credential);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dependency Injection
|
||||||
|
|
||||||
|
**Purpose**: Loose coupling and testability
|
||||||
|
|
||||||
|
**Configuration**: `Program.cs` registers all services
|
||||||
|
|
||||||
|
**Lifetimes**:
|
||||||
|
|
||||||
|
- Scoped: Repositories, Services (per request)
|
||||||
|
- Singleton: Connection factories, JWT configuration
|
||||||
|
- Transient: Utilities, helpers
|
||||||
|
|
||||||
|
#### SQL-First Approach
|
||||||
|
|
||||||
|
**Purpose**: Leverage database capabilities
|
||||||
|
|
||||||
|
**Strategy**:
|
||||||
|
|
||||||
|
- All queries via stored procedures
|
||||||
|
- No ORM (Entity Framework not used)
|
||||||
|
- Database handles complex logic
|
||||||
|
- Application focuses on orchestration
|
||||||
|
|
||||||
|
**Stored Procedure Examples**:
|
||||||
|
|
||||||
|
- `USP_RegisterUser` - User registration
|
||||||
|
- `USP_GetUserAccountByUsername` - User lookup
|
||||||
|
- `USP_RotateUserCredential` - Password update
|
||||||
|
|
||||||
|
## Frontend Architecture
|
||||||
|
|
||||||
|
### Active Website (`src/Website`)
|
||||||
|
|
||||||
|
The current website is a React Router 7 application with server-side rendering enabled.
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/Website/
|
||||||
|
├── app/
|
||||||
|
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
|
||||||
|
│ ├── lib/ Auth helpers, schemas, and theme metadata
|
||||||
|
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
|
||||||
|
│ ├── root.tsx App shell and global providers
|
||||||
|
│ └── app.css Theme tokens and global styling
|
||||||
|
├── .storybook/ Storybook config and preview setup
|
||||||
|
├── stories/ Storybook stories for shared UI and themes
|
||||||
|
├── tests/playwright/ Storybook Playwright coverage
|
||||||
|
└── package.json Frontend scripts and dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Responsibilities
|
||||||
|
|
||||||
|
- Render the auth demo and theme guide routes
|
||||||
|
- Manage cookie-backed website session state
|
||||||
|
- Call the .NET API for login, registration, token refresh, and confirmation
|
||||||
|
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||||
|
- Supply Storybook documentation and browser-based component verification
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
|
||||||
|
The active website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
|
||||||
|
|
||||||
|
- Biergarten Lager
|
||||||
|
- Biergarten Stout
|
||||||
|
- Biergarten Cassis
|
||||||
|
- Biergarten Weizen
|
||||||
|
|
||||||
|
All component styling should prefer semantic tokens such as `primary`, `success`,
|
||||||
|
`surface`, and `highlight` instead of hard-coded color values.
|
||||||
|
|
||||||
|
### Legacy Frontend
|
||||||
|
|
||||||
|
The previous Next.js frontend has been archived at `src/Website-v1`. Active product and
|
||||||
|
engineering documentation should point to `src/Website`, while legacy notes live in
|
||||||
|
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
|
## Security Architecture
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. **Registration**:
|
||||||
|
- User submits credentials
|
||||||
|
- Password hashed with Argon2id
|
||||||
|
- User account created
|
||||||
|
- JWT token issued
|
||||||
|
|
||||||
|
2. **Login**:
|
||||||
|
- User submits credentials
|
||||||
|
- Password verified against hash
|
||||||
|
- JWT token issued
|
||||||
|
- Token stored client-side
|
||||||
|
|
||||||
|
3. **API Requests**:
|
||||||
|
- Client sends JWT in Authorization header
|
||||||
|
- Middleware validates token
|
||||||
|
- Request proceeds if valid
|
||||||
|
|
||||||
|
### Password Security
|
||||||
|
|
||||||
|
**Algorithm**: Argon2id
|
||||||
|
|
||||||
|
- Memory: 64MB
|
||||||
|
- Iterations: 4
|
||||||
|
- Parallelism: CPU core count
|
||||||
|
- Salt: 128-bit (16 bytes)
|
||||||
|
- Hash: 256-bit (32 bytes)
|
||||||
|
|
||||||
|
### JWT Tokens
|
||||||
|
|
||||||
|
**Algorithm**: HS256 (HMAC-SHA256)
|
||||||
|
|
||||||
|
**Claims**:
|
||||||
|
|
||||||
|
- `sub` - User ID
|
||||||
|
- `unique_name` - Username
|
||||||
|
- `jti` - Unique token ID
|
||||||
|
- `iat` - Issued at timestamp
|
||||||
|
- `exp` - Expiration timestamp
|
||||||
|
|
||||||
|
**Configuration** (appsettings.json):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Jwt": {
|
||||||
|
"ExpirationMinutes": 60,
|
||||||
|
"Issuer": "biergarten-api",
|
||||||
|
"Audience": "biergarten-users"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Architecture
|
||||||
|
|
||||||
|
### SQL-First Philosophy
|
||||||
|
|
||||||
|
**Principles**:
|
||||||
|
|
||||||
|
1. Database is source of truth
|
||||||
|
2. Complex queries in stored procedures
|
||||||
|
3. Database handles referential integrity
|
||||||
|
4. Application orchestrates, database executes
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
|
||||||
|
- Performance optimization via execution plans
|
||||||
|
- Centralized query logic
|
||||||
|
- Version-controlled schema (migrations)
|
||||||
|
- Easier query profiling and tuning
|
||||||
|
|
||||||
|
### Migration Strategy
|
||||||
|
|
||||||
|
**Tool**: DbUp
|
||||||
|
|
||||||
|
**Process**:
|
||||||
|
|
||||||
|
1. Write SQL migration script
|
||||||
|
2. Embed in `Database.Migrations` project
|
||||||
|
3. Run migrations on startup
|
||||||
|
4. Idempotent and versioned
|
||||||
|
|
||||||
|
**Migration Files**:
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
├── 001-CreateUserTables.sql
|
||||||
|
├── 002-CreateLocationTables.sql
|
||||||
|
├── 003-CreateBreweryTables.sql
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Seeding
|
||||||
|
|
||||||
|
**Purpose**: Populate development/test databases
|
||||||
|
|
||||||
|
**Implementation**: `Database.Seed` project
|
||||||
|
|
||||||
|
**Seed Data**:
|
||||||
|
|
||||||
|
- Countries, states/provinces, cities
|
||||||
|
- Test user accounts
|
||||||
|
- Sample breweries (future)
|
||||||
|
|
||||||
|
## Deployment Architecture
|
||||||
|
|
||||||
|
### Docker Containerization
|
||||||
|
|
||||||
|
**Container Structure**:
|
||||||
|
|
||||||
|
- `sqlserver` - SQL Server 2022
|
||||||
|
- `database.migrations` - Schema migration runner
|
||||||
|
- `database.seed` - Data seeder
|
||||||
|
- `api.core` - ASP.NET Core Web API
|
||||||
|
|
||||||
|
**Environments**:
|
||||||
|
|
||||||
|
- Development (`docker-compose.dev.yaml`)
|
||||||
|
- Testing (`docker-compose.test.yaml`)
|
||||||
|
- Production (`docker-compose.prod.yaml`)
|
||||||
|
|
||||||
|
For details, see [Docker Guide](docker.md).
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
**SQL Server**: Validates database connectivity **API**: Checks service health and
|
||||||
|
dependencies
|
||||||
|
|
||||||
|
**Configuration**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "sqlcmd health check"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Architecture
|
||||||
|
|
||||||
|
### Test Pyramid
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ Integration │ ← API.Specs (Reqnroll)
|
||||||
|
│ Tests │
|
||||||
|
├──────────────┤
|
||||||
|
│ Unit Tests │ ← Service.Auth.Tests
|
||||||
|
│ (Service) │ Repository.Tests
|
||||||
|
├──────────────┤
|
||||||
|
│ Unit Tests │
|
||||||
|
│ (Repository) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strategy**:
|
||||||
|
|
||||||
|
- Many unit tests (fast, isolated)
|
||||||
|
- Fewer integration tests (slower, e2e)
|
||||||
|
- Mock external dependencies
|
||||||
|
- Test database for integration tests
|
||||||
|
|
||||||
|
For details, see [Testing Guide](testing.md).
|
||||||
56
docs/archive/legacy-website-v1.md
Normal file
56
docs/archive/legacy-website-v1.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Legacy Website Archive (`src/Website-v1`)
|
||||||
|
|
||||||
|
This archive captures high-level notes about the previous Biergarten frontend so active
|
||||||
|
project documentation can focus on the current website in `src/Website`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- `src/Website-v1` is retained for historical reference only
|
||||||
|
- It is not the active frontend used by current setup, docs, or testing guidance
|
||||||
|
- New product and engineering work should target `src/Website`
|
||||||
|
|
||||||
|
## Legacy Stack Summary
|
||||||
|
|
||||||
|
The archived frontend used a different application model from the current website:
|
||||||
|
|
||||||
|
- Next.js 14
|
||||||
|
- React 18
|
||||||
|
- Prisma
|
||||||
|
- Postgres / Neon-hosted database workflows
|
||||||
|
- Next.js API routes and server-side controllers
|
||||||
|
- Additional third-party integrations such as Cloudinary, Mapbox, and SparkPost
|
||||||
|
|
||||||
|
## Why It Was Archived
|
||||||
|
|
||||||
|
The active website moved to a React Router-based frontend that talks directly to the .NET
|
||||||
|
API. As part of that shift, the main docs were updated to describe:
|
||||||
|
|
||||||
|
- `src/Website` as the active frontend
|
||||||
|
- React Router route modules and server rendering
|
||||||
|
- Storybook-based component documentation and tests
|
||||||
|
- Current frontend runtime variables: `API_BASE_URL`, `SESSION_SECRET`, and `NODE_ENV`
|
||||||
|
|
||||||
|
## Legacy Documentation Topics Moved Out of Active Docs
|
||||||
|
|
||||||
|
The following categories were removed from active documentation and intentionally archived:
|
||||||
|
|
||||||
|
- Next.js application structure guidance
|
||||||
|
- Prisma and Postgres frontend setup
|
||||||
|
- Legacy frontend environment variables
|
||||||
|
- External service setup that only applied to `src/Website-v1`
|
||||||
|
- Old frontend local setup instructions
|
||||||
|
|
||||||
|
## When To Use This Archive
|
||||||
|
|
||||||
|
Use this file only if you need to:
|
||||||
|
|
||||||
|
- inspect the historical frontend implementation
|
||||||
|
- compare old flows against the current website
|
||||||
|
- migrate or recover legacy logic from `src/Website-v1`
|
||||||
|
|
||||||
|
For all active work, use:
|
||||||
|
|
||||||
|
- [Getting Started](../getting-started.md)
|
||||||
|
- [Architecture](../architecture.md)
|
||||||
|
- [Environment Variables](../environment-variables.md)
|
||||||
|
- [Testing](../testing.md)
|
||||||
1
docs/diagrams-out/architecture.svg
Normal file
1
docs/diagrams-out/architecture.svg
Normal file
File diff suppressed because one or more lines are too long
1
docs/diagrams-out/authentication-flow.svg
Normal file
1
docs/diagrams-out/authentication-flow.svg
Normal file
File diff suppressed because one or more lines are too long
1
docs/diagrams-out/database-schema.svg
Normal file
1
docs/diagrams-out/database-schema.svg
Normal file
File diff suppressed because one or more lines are too long
1
docs/diagrams-out/deployment.svg
Normal file
1
docs/diagrams-out/deployment.svg
Normal file
File diff suppressed because one or more lines are too long
75
docs/diagrams-src/architecture.puml
Normal file
75
docs/diagrams-src/architecture.puml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
@startuml architecture
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor #FFFFFF
|
||||||
|
skinparam defaultFontName Arial
|
||||||
|
skinparam packageStyle rectangle
|
||||||
|
|
||||||
|
title The Biergarten App - Layered Architecture
|
||||||
|
|
||||||
|
package "API Layer" #E3F2FD {
|
||||||
|
[API.Core\nASP.NET Core Web API] as API
|
||||||
|
note right of API
|
||||||
|
- Controllers (Auth, User)
|
||||||
|
- Swagger/OpenAPI
|
||||||
|
- Middleware
|
||||||
|
- Health Checks
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Service Layer" #F3E5F5 {
|
||||||
|
[Service.Auth] as AuthSvc
|
||||||
|
[Service.UserManagement] as UserSvc
|
||||||
|
note right of AuthSvc
|
||||||
|
- Business Logic
|
||||||
|
- Validation
|
||||||
|
- Orchestration
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Infrastructure Layer" #FFF3E0 {
|
||||||
|
[Infrastructure.Repository] as Repo
|
||||||
|
[Infrastructure.Jwt] as JWT
|
||||||
|
[Infrastructure.PasswordHashing] as PwdHash
|
||||||
|
[Infrastructure.Email] as Email
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Domain Layer" #E8F5E9 {
|
||||||
|
[Domain.Entities] as Domain
|
||||||
|
note right of Domain
|
||||||
|
- UserAccount
|
||||||
|
- UserCredential
|
||||||
|
- UserVerification
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
database "SQL Server" {
|
||||||
|
[Stored Procedures] as SP
|
||||||
|
[Tables] as Tables
|
||||||
|
}
|
||||||
|
|
||||||
|
' Relationships
|
||||||
|
API --> AuthSvc
|
||||||
|
API --> UserSvc
|
||||||
|
|
||||||
|
AuthSvc --> Repo
|
||||||
|
AuthSvc --> JWT
|
||||||
|
AuthSvc --> PwdHash
|
||||||
|
AuthSvc --> Email
|
||||||
|
|
||||||
|
UserSvc --> Repo
|
||||||
|
|
||||||
|
Repo --> SP
|
||||||
|
Repo --> Domain
|
||||||
|
SP --> Tables
|
||||||
|
|
||||||
|
AuthSvc --> Domain
|
||||||
|
UserSvc --> Domain
|
||||||
|
|
||||||
|
' Notes
|
||||||
|
note left of Repo
|
||||||
|
SQL-first approach
|
||||||
|
All queries via
|
||||||
|
stored procedures
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
298
docs/diagrams-src/authentication-flow.puml
Normal file
298
docs/diagrams-src/authentication-flow.puml
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
@startuml authentication-flow
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor #FFFFFF
|
||||||
|
skinparam defaultFontName Arial
|
||||||
|
skinparam sequenceMessageAlign center
|
||||||
|
skinparam maxMessageSize 200
|
||||||
|
|
||||||
|
title User Authentication Flow - Expanded
|
||||||
|
|
||||||
|
actor User
|
||||||
|
participant "API\nController" as API
|
||||||
|
box "Service Layer" #LightBlue
|
||||||
|
participant "RegisterService" as RegSvc
|
||||||
|
participant "LoginService" as LoginSvc
|
||||||
|
participant "TokenService" as TokenSvc
|
||||||
|
participant "EmailService" as EmailSvc
|
||||||
|
end box
|
||||||
|
box "Infrastructure Layer" #LightGreen
|
||||||
|
participant "Argon2\nInfrastructure" as Argon2
|
||||||
|
participant "JWT\nInfrastructure" as JWT
|
||||||
|
participant "Email\nProvider" as SMTP
|
||||||
|
participant "Template\nProvider" as Template
|
||||||
|
end box
|
||||||
|
box "Repository Layer" #LightYellow
|
||||||
|
participant "AuthRepository" as AuthRepo
|
||||||
|
participant "UserAccount\nRepository" as UserRepo
|
||||||
|
end box
|
||||||
|
database "SQL Server\nStored Procedures" as DB
|
||||||
|
|
||||||
|
== Registration Flow ==
|
||||||
|
|
||||||
|
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
||||||
|
activate API
|
||||||
|
|
||||||
|
note right of API
|
||||||
|
FluentValidation runs:
|
||||||
|
- Username: 3-64 chars, alphanumeric + [._-]
|
||||||
|
- Email: valid format, max 128 chars
|
||||||
|
- Password: min 8 chars, uppercase,\n lowercase, number, special char
|
||||||
|
- DateOfBirth: must be 19+ years old
|
||||||
|
end note
|
||||||
|
|
||||||
|
API -> API: Validate request\n(FluentValidation)
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
API -> User: 400 Bad Request\n{errors: {...}}
|
||||||
|
else Validation succeeds
|
||||||
|
API -> RegSvc: RegisterAsync(userAccount, password)
|
||||||
|
activate RegSvc
|
||||||
|
|
||||||
|
RegSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
||||||
|
activate AuthRepo
|
||||||
|
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
||||||
|
activate DB
|
||||||
|
DB --> AuthRepo: null (user doesn't exist)
|
||||||
|
deactivate DB
|
||||||
|
deactivate AuthRepo
|
||||||
|
|
||||||
|
RegSvc -> AuthRepo: GetUserByEmailAsync(email)
|
||||||
|
activate AuthRepo
|
||||||
|
AuthRepo -> DB: EXEC usp_GetUserAccountByEmail
|
||||||
|
activate DB
|
||||||
|
DB --> AuthRepo: null (email doesn't exist)
|
||||||
|
deactivate DB
|
||||||
|
deactivate AuthRepo
|
||||||
|
|
||||||
|
alt User/Email already exists
|
||||||
|
RegSvc -> API: throw ConflictException
|
||||||
|
API -> User: 409 Conflict\n"Username or email already exists"
|
||||||
|
else User doesn't exist
|
||||||
|
|
||||||
|
RegSvc -> Argon2: Hash(password)
|
||||||
|
activate Argon2
|
||||||
|
note right of Argon2
|
||||||
|
Argon2id parameters:
|
||||||
|
- Salt: 16 bytes (128-bit)
|
||||||
|
- Memory: 64MB
|
||||||
|
- Iterations: 4
|
||||||
|
- Parallelism: CPU count
|
||||||
|
- Hash output: 32 bytes
|
||||||
|
end note
|
||||||
|
Argon2 -> Argon2: Generate random salt\n(16 bytes)
|
||||||
|
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
|
||||||
|
Argon2 --> RegSvc: "base64(salt):base64(hash)"
|
||||||
|
deactivate Argon2
|
||||||
|
|
||||||
|
RegSvc -> AuthRepo: RegisterUserAsync(\n username, firstName, lastName,\n email, dateOfBirth, hash)
|
||||||
|
activate AuthRepo
|
||||||
|
|
||||||
|
AuthRepo -> DB: EXEC USP_RegisterUser
|
||||||
|
activate DB
|
||||||
|
note right of DB
|
||||||
|
Transaction begins:
|
||||||
|
1. INSERT UserAccount
|
||||||
|
2. INSERT UserCredential
|
||||||
|
(with hashed password)
|
||||||
|
Transaction commits
|
||||||
|
end note
|
||||||
|
DB -> DB: BEGIN TRANSACTION
|
||||||
|
DB -> DB: INSERT INTO UserAccount\n(Username, FirstName, LastName,\nEmail, DateOfBirth)
|
||||||
|
DB -> DB: OUTPUT INSERTED.UserAccountID
|
||||||
|
DB -> DB: INSERT INTO UserCredential\n(UserAccountId, Hash)
|
||||||
|
DB -> DB: COMMIT TRANSACTION
|
||||||
|
DB --> AuthRepo: UserAccountId (GUID)
|
||||||
|
deactivate DB
|
||||||
|
|
||||||
|
AuthRepo --> RegSvc: UserAccount entity
|
||||||
|
deactivate AuthRepo
|
||||||
|
|
||||||
|
RegSvc -> TokenSvc: GenerateAccessToken(userAccount)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||||
|
activate JWT
|
||||||
|
note right of JWT
|
||||||
|
JWT Configuration:
|
||||||
|
- Algorithm: HS256
|
||||||
|
- Expires: 1 hour
|
||||||
|
- Claims:
|
||||||
|
* sub: userId
|
||||||
|
* unique_name: username
|
||||||
|
* jti: unique token ID
|
||||||
|
end note
|
||||||
|
JWT -> JWT: Create JWT with claims
|
||||||
|
JWT -> JWT: Sign with secret key
|
||||||
|
JWT --> TokenSvc: Access Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc --> RegSvc: Access Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
RegSvc -> TokenSvc: GenerateRefreshToken(userAccount)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||||
|
activate JWT
|
||||||
|
note right of JWT
|
||||||
|
Refresh Token:
|
||||||
|
- Expires: 21 days
|
||||||
|
- Same structure as access token
|
||||||
|
end note
|
||||||
|
JWT --> TokenSvc: Refresh Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc --> RegSvc: Refresh Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
RegSvc -> EmailSvc: SendRegistrationEmailAsync(\n createdUser, confirmationToken)
|
||||||
|
activate EmailSvc
|
||||||
|
|
||||||
|
EmailSvc -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
|
||||||
|
activate Template
|
||||||
|
note right of Template
|
||||||
|
Razor Component:
|
||||||
|
- Header with branding
|
||||||
|
- Welcome message
|
||||||
|
- Confirmation button
|
||||||
|
- Footer
|
||||||
|
end note
|
||||||
|
Template -> Template: Render Razor component\nto HTML
|
||||||
|
Template --> EmailSvc: HTML email content
|
||||||
|
deactivate Template
|
||||||
|
|
||||||
|
EmailSvc -> SMTP: SendAsync(email, subject, body)
|
||||||
|
activate SMTP
|
||||||
|
note right of SMTP
|
||||||
|
SMTP Configuration:
|
||||||
|
- Host: from env (SMTP_HOST)
|
||||||
|
- Port: from env (SMTP_PORT)
|
||||||
|
- TLS: StartTLS
|
||||||
|
- Auth: username/password
|
||||||
|
end note
|
||||||
|
SMTP -> SMTP: Create MIME message
|
||||||
|
SMTP -> SMTP: Connect to SMTP server
|
||||||
|
SMTP -> SMTP: Authenticate
|
||||||
|
SMTP -> SMTP: Send email
|
||||||
|
SMTP -> SMTP: Disconnect
|
||||||
|
SMTP --> EmailSvc: Success / Failure
|
||||||
|
deactivate SMTP
|
||||||
|
|
||||||
|
alt Email sent successfully
|
||||||
|
EmailSvc --> RegSvc: emailSent = true
|
||||||
|
else Email failed
|
||||||
|
EmailSvc --> RegSvc: emailSent = false\n(error suppressed)
|
||||||
|
end
|
||||||
|
deactivate EmailSvc
|
||||||
|
|
||||||
|
RegSvc --> API: RegisterServiceReturn(\n userAccount, accessToken,\n refreshToken, emailSent)
|
||||||
|
deactivate RegSvc
|
||||||
|
|
||||||
|
API -> API: Create response body
|
||||||
|
API -> User: 201 Created\n{\n message: "User registered successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken,\n confirmationEmailSent\n }\n}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
|
||||||
|
== Login Flow ==
|
||||||
|
|
||||||
|
User -> API: POST /api/auth/login\n{username, password}
|
||||||
|
activate API
|
||||||
|
|
||||||
|
API -> API: Validate request\n(FluentValidation)
|
||||||
|
|
||||||
|
alt Validation fails
|
||||||
|
API -> User: 400 Bad Request\n{errors: {...}}
|
||||||
|
else Validation succeeds
|
||||||
|
|
||||||
|
API -> LoginSvc: LoginAsync(username, password)
|
||||||
|
activate LoginSvc
|
||||||
|
|
||||||
|
LoginSvc -> AuthRepo: GetUserByUsernameAsync(username)
|
||||||
|
activate AuthRepo
|
||||||
|
AuthRepo -> DB: EXEC usp_GetUserAccountByUsername
|
||||||
|
activate DB
|
||||||
|
DB -> DB: SELECT FROM UserAccount\nWHERE Username = @Username
|
||||||
|
DB --> AuthRepo: UserAccount entity
|
||||||
|
deactivate DB
|
||||||
|
deactivate AuthRepo
|
||||||
|
|
||||||
|
alt User not found
|
||||||
|
LoginSvc -> API: throw UnauthorizedException\n"Invalid username or password"
|
||||||
|
API -> User: 401 Unauthorized
|
||||||
|
else User found
|
||||||
|
|
||||||
|
LoginSvc -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
|
||||||
|
activate AuthRepo
|
||||||
|
AuthRepo -> DB: EXEC USP_GetActiveUserCredentialByUserAccountId
|
||||||
|
activate DB
|
||||||
|
note right of DB
|
||||||
|
SELECT FROM UserCredential
|
||||||
|
WHERE UserAccountId = @UserAccountId
|
||||||
|
AND IsRevoked = 0
|
||||||
|
end note
|
||||||
|
DB --> AuthRepo: UserCredential entity
|
||||||
|
deactivate DB
|
||||||
|
deactivate AuthRepo
|
||||||
|
|
||||||
|
alt No active credential
|
||||||
|
LoginSvc -> API: throw UnauthorizedException
|
||||||
|
API -> User: 401 Unauthorized
|
||||||
|
else Active credential found
|
||||||
|
|
||||||
|
LoginSvc -> Argon2: Verify(password, storedHash)
|
||||||
|
activate Argon2
|
||||||
|
note right of Argon2
|
||||||
|
1. Split stored hash: "salt:hash"
|
||||||
|
2. Extract salt
|
||||||
|
3. Hash provided password\n with same salt
|
||||||
|
4. Constant-time comparison
|
||||||
|
end note
|
||||||
|
Argon2 -> Argon2: Parse salt from stored hash
|
||||||
|
Argon2 -> Argon2: Hash provided password\nwith extracted salt
|
||||||
|
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
|
||||||
|
Argon2 --> LoginSvc: true/false
|
||||||
|
deactivate Argon2
|
||||||
|
|
||||||
|
alt Password invalid
|
||||||
|
LoginSvc -> API: throw UnauthorizedException
|
||||||
|
API -> User: 401 Unauthorized
|
||||||
|
else Password valid
|
||||||
|
|
||||||
|
LoginSvc -> TokenSvc: GenerateAccessToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc -> JWT: GenerateJwt(...)
|
||||||
|
activate JWT
|
||||||
|
JWT --> TokenSvc: Access Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc --> LoginSvc: Access Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
LoginSvc -> TokenSvc: GenerateRefreshToken(user)
|
||||||
|
activate TokenSvc
|
||||||
|
TokenSvc -> JWT: GenerateJwt(...)
|
||||||
|
activate JWT
|
||||||
|
JWT --> TokenSvc: Refresh Token
|
||||||
|
deactivate JWT
|
||||||
|
TokenSvc --> LoginSvc: Refresh Token
|
||||||
|
deactivate TokenSvc
|
||||||
|
|
||||||
|
LoginSvc --> API: LoginServiceReturn(\n userAccount, accessToken,\n refreshToken)
|
||||||
|
deactivate LoginSvc
|
||||||
|
|
||||||
|
API -> User: 200 OK\n{\n message: "Logged in successfully",\n payload: {\n userAccountId, username,\n accessToken, refreshToken\n }\n}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
deactivate API
|
||||||
|
|
||||||
|
== Error Handling (Global Exception Filter) ==
|
||||||
|
|
||||||
|
note over API
|
||||||
|
GlobalExceptionFilter catches:
|
||||||
|
- ValidationException → 400 Bad Request
|
||||||
|
- ConflictException → 409 Conflict
|
||||||
|
- NotFoundException → 404 Not Found
|
||||||
|
- UnauthorizedException → 401 Unauthorized
|
||||||
|
- ForbiddenException → 403 Forbidden
|
||||||
|
- All others → 500 Internal Server Error
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
104
docs/diagrams-src/database-schema.puml
Normal file
104
docs/diagrams-src/database-schema.puml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
@startuml database-schema
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor #FFFFFF
|
||||||
|
skinparam defaultFontName Arial
|
||||||
|
skinparam linetype ortho
|
||||||
|
|
||||||
|
title Key Database Schema - User & Authentication
|
||||||
|
|
||||||
|
entity "UserAccount" as User {
|
||||||
|
* UserAccountId: INT <<PK>>
|
||||||
|
--
|
||||||
|
* Username: NVARCHAR(30) <<UNIQUE>>
|
||||||
|
* Email: NVARCHAR(255) <<UNIQUE>>
|
||||||
|
* FirstName: NVARCHAR(50)
|
||||||
|
* LastName: NVARCHAR(50)
|
||||||
|
Bio: NVARCHAR(500)
|
||||||
|
CreatedAt: DATETIME2
|
||||||
|
UpdatedAt: DATETIME2
|
||||||
|
LastLoginAt: DATETIME2
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserCredential" as Cred {
|
||||||
|
* UserCredentialId: INT <<PK>>
|
||||||
|
--
|
||||||
|
* UserAccountId: INT <<FK>>
|
||||||
|
* PasswordHash: VARBINARY(32)
|
||||||
|
* PasswordSalt: VARBINARY(16)
|
||||||
|
CredentialRotatedAt: DATETIME2
|
||||||
|
CredentialExpiresAt: DATETIME2
|
||||||
|
CredentialRevokedAt: DATETIME2
|
||||||
|
* IsActive: BIT
|
||||||
|
CreatedAt: DATETIME2
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserVerification" as Verify {
|
||||||
|
* UserVerificationId: INT <<PK>>
|
||||||
|
--
|
||||||
|
* UserAccountId: INT <<FK>>
|
||||||
|
* IsVerified: BIT
|
||||||
|
VerifiedAt: DATETIME2
|
||||||
|
VerificationToken: NVARCHAR(255)
|
||||||
|
TokenExpiresAt: DATETIME2
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserAvatar" as Avatar {
|
||||||
|
* UserAvatarId: INT <<PK>>
|
||||||
|
--
|
||||||
|
* UserAccountId: INT <<FK>>
|
||||||
|
PhotoId: INT <<FK>>
|
||||||
|
* IsActive: BIT
|
||||||
|
CreatedAt: DATETIME2
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "UserFollow" as Follow {
|
||||||
|
* UserFollowId: INT <<PK>>
|
||||||
|
--
|
||||||
|
* FollowerUserId: INT <<FK>>
|
||||||
|
* FollowedUserId: INT <<FK>>
|
||||||
|
CreatedAt: DATETIME2
|
||||||
|
}
|
||||||
|
|
||||||
|
entity "Photo" as Photo {
|
||||||
|
* PhotoId: INT <<PK>>
|
||||||
|
--
|
||||||
|
* Url: NVARCHAR(500)
|
||||||
|
* CloudinaryPublicId: NVARCHAR(255)
|
||||||
|
Width: INT
|
||||||
|
Height: INT
|
||||||
|
Format: NVARCHAR(10)
|
||||||
|
CreatedAt: DATETIME2
|
||||||
|
}
|
||||||
|
|
||||||
|
' Relationships
|
||||||
|
User ||--o{ Cred : "has"
|
||||||
|
User ||--o| Verify : "has"
|
||||||
|
User ||--o{ Avatar : "has"
|
||||||
|
User ||--o{ Follow : "follows"
|
||||||
|
User ||--o{ Follow : "followed by"
|
||||||
|
Avatar }o--|| Photo : "refers to"
|
||||||
|
|
||||||
|
note right of Cred
|
||||||
|
Password hashing:
|
||||||
|
- Algorithm: Argon2id
|
||||||
|
- Memory: 64MB
|
||||||
|
- Iterations: 4
|
||||||
|
- Salt: 128-bit
|
||||||
|
- Hash: 256-bit
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of Verify
|
||||||
|
Account verification
|
||||||
|
via email token
|
||||||
|
with expiry
|
||||||
|
end note
|
||||||
|
|
||||||
|
note bottom of User
|
||||||
|
Core stored procedures:
|
||||||
|
- USP_RegisterUser
|
||||||
|
- USP_GetUserAccountByUsername
|
||||||
|
- USP_RotateUserCredential
|
||||||
|
- USP_UpdateUserAccount
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
227
docs/diagrams-src/deployment.puml
Normal file
227
docs/diagrams-src/deployment.puml
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
@startuml deployment
|
||||||
|
!theme plain
|
||||||
|
skinparam backgroundColor #FFFFFF
|
||||||
|
skinparam defaultFontName Arial
|
||||||
|
skinparam linetype ortho
|
||||||
|
|
||||||
|
title Docker Deployment Architecture
|
||||||
|
|
||||||
|
' External systems
|
||||||
|
actor Developer
|
||||||
|
cloud "Docker Host" as Host
|
||||||
|
|
||||||
|
package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
|
||||||
|
|
||||||
|
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
|
||||||
|
database "Biergarten\nDatabase" as DevDBInner {
|
||||||
|
portin "1433"
|
||||||
|
}
|
||||||
|
note right
|
||||||
|
Environment:
|
||||||
|
- ACCEPT_EULA=Y
|
||||||
|
- SA_PASSWORD=***
|
||||||
|
- MSSQL_PID=Developer
|
||||||
|
|
||||||
|
Volumes:
|
||||||
|
- biergarten-dev-data
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
node "API Container\n(API.Core)" as DevAPI {
|
||||||
|
component "ASP.NET Core 10" as API1
|
||||||
|
portin "8080:8080 (HTTP)" as DevPort1
|
||||||
|
portin "8081:8081 (HTTPS)" as DevPort2
|
||||||
|
|
||||||
|
note right
|
||||||
|
Environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
- DB_SERVER=sql-server
|
||||||
|
- DB_NAME=Biergarten
|
||||||
|
- DB_USER/PASSWORD
|
||||||
|
- JWT_SECRET
|
||||||
|
- SMTP_* (10+ variables)
|
||||||
|
|
||||||
|
Health Check:
|
||||||
|
/health endpoint
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
node "Migrations\n(run-once)" as DevMig {
|
||||||
|
component "Database.Migrations" as Mig1
|
||||||
|
note bottom
|
||||||
|
Runs: DbUp migrations
|
||||||
|
Environment:
|
||||||
|
- CLEAR_DATABASE=false
|
||||||
|
Depends on: sql-server
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
node "Seed\n(run-once)" as DevSeed {
|
||||||
|
component "Database.Seed" as Seed1
|
||||||
|
note bottom
|
||||||
|
Creates:
|
||||||
|
- 100 test users
|
||||||
|
- Location data (US/CA/MX)
|
||||||
|
- test.user account
|
||||||
|
Depends on: migrations
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
|
||||||
|
|
||||||
|
node "SQL Server\n(isolated instance)" as TestDB {
|
||||||
|
database "Biergarten\nTest Database" as TestDBInner {
|
||||||
|
portin "1434"
|
||||||
|
}
|
||||||
|
note right
|
||||||
|
Fresh instance each run
|
||||||
|
CLEAR_DATABASE=true
|
||||||
|
|
||||||
|
Volumes:
|
||||||
|
- biergarten-test-data
|
||||||
|
(ephemeral)
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
node "Migrations\n(test)" as TestMig {
|
||||||
|
component "Database.Migrations"
|
||||||
|
}
|
||||||
|
|
||||||
|
node "Seed\n(test)" as TestSeed {
|
||||||
|
component "Database.Seed"
|
||||||
|
note bottom
|
||||||
|
Minimal seed:
|
||||||
|
- test.user only
|
||||||
|
- Essential data
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
node "API.Specs\n(Integration Tests)" as Specs {
|
||||||
|
component "Reqnroll + xUnit" as SpecsComp
|
||||||
|
note right
|
||||||
|
Tests:
|
||||||
|
- Registration flow
|
||||||
|
- Login flow
|
||||||
|
- Validation rules
|
||||||
|
- 404 handling
|
||||||
|
|
||||||
|
Uses: TestApiFactory
|
||||||
|
Mocks: Email services
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
node "Infrastructure.Repository.Tests\n(Unit Tests)" as RepoTests {
|
||||||
|
component "xUnit + DbMocker" as RepoComp
|
||||||
|
note right
|
||||||
|
Tests:
|
||||||
|
- AuthRepository
|
||||||
|
- UserAccountRepository
|
||||||
|
- SQL command building
|
||||||
|
|
||||||
|
Uses: Mock connections
|
||||||
|
No real database needed
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
node "Service.Auth.Tests\n(Unit Tests)" as SvcTests {
|
||||||
|
component "xUnit + Moq" as SvcComp
|
||||||
|
note right
|
||||||
|
Tests:
|
||||||
|
- RegisterService
|
||||||
|
- LoginService
|
||||||
|
- Token generation
|
||||||
|
|
||||||
|
Uses: Mocked dependencies
|
||||||
|
No database or infrastructure
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
folder "test-results/\n(mounted volume)" as Results {
|
||||||
|
file "api-specs/\n results.trx" as Result1
|
||||||
|
file "repository-tests/\n results.trx" as Result2
|
||||||
|
file "service-auth-tests/\n results.trx" as Result3
|
||||||
|
|
||||||
|
note bottom
|
||||||
|
TRX format
|
||||||
|
Readable by:
|
||||||
|
- Visual Studio
|
||||||
|
- Azure DevOps
|
||||||
|
- GitHub Actions
|
||||||
|
end note
|
||||||
|
}
|
||||||
|
|
||||||
|
' External access
|
||||||
|
Developer --> Host : docker compose up
|
||||||
|
Host --> DevAPI : http://localhost:8080
|
||||||
|
|
||||||
|
' Development dependencies
|
||||||
|
DevMig --> DevDB : 1. Run migrations
|
||||||
|
DevSeed --> DevDB : 2. Seed data
|
||||||
|
DevAPI --> DevDB : 3. Connect & serve
|
||||||
|
DevMig .up.> DevDB : depends_on
|
||||||
|
DevSeed .up.> DevMig : depends_on
|
||||||
|
DevAPI .up.> DevSeed : depends_on
|
||||||
|
|
||||||
|
' Test dependencies
|
||||||
|
TestMig --> TestDB : 1. Migrate
|
||||||
|
TestSeed --> TestDB : 2. Seed
|
||||||
|
Specs --> TestDB : 3. Integration test
|
||||||
|
RepoTests ..> TestDB : Mock (no connection)
|
||||||
|
SvcTests ..> TestDB : Mock (no connection)
|
||||||
|
|
||||||
|
TestMig .up.> TestDB : depends_on
|
||||||
|
TestSeed .up.> TestMig : depends_on
|
||||||
|
Specs .up.> TestSeed : depends_on
|
||||||
|
|
||||||
|
' Test results export
|
||||||
|
Specs --> Results : Export TRX
|
||||||
|
RepoTests --> Results : Export TRX
|
||||||
|
SvcTests --> Results : Export TRX
|
||||||
|
|
||||||
|
' Network notes
|
||||||
|
note bottom of DevDB
|
||||||
|
<b>Dev Network (bridge: biergarten-dev)</b>
|
||||||
|
Internal DNS:
|
||||||
|
- sql-server (resolves to SQL container)
|
||||||
|
- api (resolves to API container)
|
||||||
|
end note
|
||||||
|
|
||||||
|
note bottom of TestDB
|
||||||
|
<b>Test Network (bridge: biergarten-test)</b>
|
||||||
|
All test components isolated
|
||||||
|
end note
|
||||||
|
|
||||||
|
' Startup sequence notes
|
||||||
|
note top of DevMig
|
||||||
|
Startup Order:
|
||||||
|
1. SQL Server (health check)
|
||||||
|
2. Migrations (run-once)
|
||||||
|
3. Seed (run-once)
|
||||||
|
4. API (long-running)
|
||||||
|
end note
|
||||||
|
|
||||||
|
note top of Specs
|
||||||
|
Test Execution:
|
||||||
|
All tests run in parallel
|
||||||
|
Results aggregated
|
||||||
|
end note
|
||||||
|
|
||||||
|
' Production note
|
||||||
|
note as ProductionNote
|
||||||
|
<b>Production Deployment (not shown):</b>
|
||||||
|
|
||||||
|
Would include:
|
||||||
|
• Azure SQL Database / AWS RDS
|
||||||
|
• Azure Container Apps / ECS
|
||||||
|
• Azure Key Vault for secrets
|
||||||
|
• Application Insights / CloudWatch
|
||||||
|
• Load balancer
|
||||||
|
• HTTPS termination
|
||||||
|
• CDN for static assets
|
||||||
|
end note
|
||||||
|
|
||||||
|
@enduml
|
||||||
327
docs/docker.md
Normal file
327
docs/docker.md
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
# Docker Guide
|
||||||
|
|
||||||
|
This document covers Docker deployment, configuration, and troubleshooting for The
|
||||||
|
Biergarten App.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project uses Docker Compose to orchestrate multiple services:
|
||||||
|
|
||||||
|
- SQL Server 2022 database
|
||||||
|
- Database migrations runner (DbUp)
|
||||||
|
- Database seeder
|
||||||
|
- .NET API
|
||||||
|
- Test runners
|
||||||
|
|
||||||
|
See the [deployment diagram](diagrams/pdf/deployment.pdf) for visual representation.
|
||||||
|
|
||||||
|
## Docker Compose Environments
|
||||||
|
|
||||||
|
### 1. Development (`docker-compose.dev.yaml`)
|
||||||
|
|
||||||
|
**Purpose**: Local development with persistent data
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Persistent SQL Server volume
|
||||||
|
- Hot reload support
|
||||||
|
- Swagger UI enabled
|
||||||
|
- Seed data included
|
||||||
|
- `CLEAR_DATABASE=true` (drops and recreates schema)
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # SQL Server 2022 (port 1433)
|
||||||
|
database.migrations # DbUp migrations
|
||||||
|
database.seed # Seed initial data
|
||||||
|
api.core # Web API (ports 8080, 8081)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start Development Environment**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access**:
|
||||||
|
|
||||||
|
- API Swagger: http://localhost:8080/swagger
|
||||||
|
- Health Check: http://localhost:8080/health
|
||||||
|
- SQL Server: localhost:1433 (sa credentials from .env.dev)
|
||||||
|
|
||||||
|
**Stop Environment**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop services (keep volumes)
|
||||||
|
docker compose -f docker-compose.dev.yaml down
|
||||||
|
|
||||||
|
# Stop and remove volumes (fresh start)
|
||||||
|
docker compose -f docker-compose.dev.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Testing (`docker-compose.test.yaml`)
|
||||||
|
|
||||||
|
**Purpose**: Automated CI/CD testing in isolated environment
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Fresh database each run
|
||||||
|
- All test suites execute in parallel
|
||||||
|
- Test results exported to `./test-results/`
|
||||||
|
- Containers auto-exit after completion
|
||||||
|
- Fully isolated testnet network
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # Test database
|
||||||
|
database.migrations # Fresh schema
|
||||||
|
database.seed # Test data
|
||||||
|
api.specs # Reqnroll BDD tests
|
||||||
|
repository.tests # Repository unit tests
|
||||||
|
service.auth.tests # Service unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run Tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||||
|
|
||||||
|
# View results
|
||||||
|
ls -la test-results/
|
||||||
|
cat test-results/api-specs/results.trx
|
||||||
|
cat test-results/repository-tests/results.trx
|
||||||
|
cat test-results/service-auth-tests/results.trx
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
docker compose -f docker-compose.test.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Production (`docker-compose.prod.yaml`)
|
||||||
|
|
||||||
|
**Purpose**: Production-ready deployment
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
|
||||||
|
- Production logging levels
|
||||||
|
- No database clearing
|
||||||
|
- Optimized build configurations
|
||||||
|
- Health checks enabled
|
||||||
|
- Restart policies (unless-stopped)
|
||||||
|
- Security hardening
|
||||||
|
|
||||||
|
**Services**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sqlserver # Production SQL Server
|
||||||
|
database.migrations # Schema updates only
|
||||||
|
api.core # Production API
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploy Production**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Dependencies
|
||||||
|
|
||||||
|
Docker Compose manages startup order using health checks:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sqlserver (health check)
|
||||||
|
↓
|
||||||
|
database.migrations (completes successfully)
|
||||||
|
↓
|
||||||
|
database.seed (completes successfully)
|
||||||
|
↓
|
||||||
|
api.core / tests (start when ready)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Health Check Example** (SQL Server):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', "sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependency Configuration**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
api.core:
|
||||||
|
depends_on:
|
||||||
|
database.seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
### Persistent Volumes
|
||||||
|
|
||||||
|
**Development**:
|
||||||
|
|
||||||
|
- `sqlserverdata-dev` - Database files persist between restarts
|
||||||
|
- `nuget-cache-dev` - NuGet package cache (speeds up builds)
|
||||||
|
|
||||||
|
**Testing**:
|
||||||
|
|
||||||
|
- `sqlserverdata-test` - Temporary, typically removed after tests
|
||||||
|
|
||||||
|
**Production**:
|
||||||
|
|
||||||
|
- `sqlserverdata-prod` - Production database files
|
||||||
|
- `nuget-cache-prod` - Production NuGet cache
|
||||||
|
|
||||||
|
### Mounted Volumes
|
||||||
|
|
||||||
|
**Test Results**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./test-results:/app/test-results
|
||||||
|
```
|
||||||
|
|
||||||
|
Test results are written to host filesystem for CI/CD integration.
|
||||||
|
|
||||||
|
**Code Volumes** (development only):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src # Hot reload for development
|
||||||
|
```
|
||||||
|
|
||||||
|
## Networks
|
||||||
|
|
||||||
|
Each environment uses isolated bridge networks:
|
||||||
|
|
||||||
|
- `devnet` - Development network
|
||||||
|
- `testnet` - Testing network (fully isolated)
|
||||||
|
- `prodnet` - Production network
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
All containers are configured via environment variables from `.env` files:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env_file: '.env.dev' # or .env.test, .env.prod
|
||||||
|
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: 'Development'
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: 'true'
|
||||||
|
DB_SERVER: '${DB_SERVER}'
|
||||||
|
DB_NAME: '${DB_NAME}'
|
||||||
|
DB_USER: '${DB_USER}'
|
||||||
|
DB_PASSWORD: '${DB_PASSWORD}'
|
||||||
|
JWT_SECRET: '${JWT_SECRET}'
|
||||||
|
```
|
||||||
|
|
||||||
|
For complete list, see [Environment Variables](environment-variables.md).
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### View Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Running services
|
||||||
|
docker compose -f docker-compose.dev.yaml ps
|
||||||
|
|
||||||
|
# All containers (including stopped)
|
||||||
|
docker ps -a
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker compose -f docker-compose.dev.yaml logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker compose -f docker-compose.dev.yaml logs -f api.core
|
||||||
|
|
||||||
|
# Last 100 lines
|
||||||
|
docker compose -f docker-compose.dev.yaml logs --tail=100 api.core
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execute Commands in Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive shell
|
||||||
|
docker exec -it dev-env-api-core bash
|
||||||
|
|
||||||
|
# Run command
|
||||||
|
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'password' -C
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restart all services
|
||||||
|
docker compose -f docker-compose.dev.yaml restart
|
||||||
|
|
||||||
|
# Restart specific service
|
||||||
|
docker compose -f docker-compose.dev.yaml restart api.core
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d --build api.core
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build all images
|
||||||
|
docker compose -f docker-compose.dev.yaml build
|
||||||
|
|
||||||
|
# Build specific service
|
||||||
|
docker compose -f docker-compose.dev.yaml build api.core
|
||||||
|
|
||||||
|
# Build without cache
|
||||||
|
docker compose -f docker-compose.dev.yaml build --no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and remove containers
|
||||||
|
docker compose -f docker-compose.dev.yaml down
|
||||||
|
|
||||||
|
# Remove containers and volumes
|
||||||
|
docker compose -f docker-compose.dev.yaml down -v
|
||||||
|
|
||||||
|
# Remove containers, volumes, and images
|
||||||
|
docker compose -f docker-compose.dev.yaml down -v --rmi all
|
||||||
|
|
||||||
|
# System-wide cleanup
|
||||||
|
docker system prune -af --volumes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dockerfile Structure
|
||||||
|
|
||||||
|
### Multi-Stage Build
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["Project/Project.csproj", "Project/"]
|
||||||
|
RUN dotnet restore
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet build -c Release
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/build .
|
||||||
|
ENTRYPOINT ["dotnet", "Project.dll"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||||
|
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
|
||||||
|
- [SQL Server Docker Images](https://hub.docker.com/_/microsoft-mssql-server)
|
||||||
304
docs/environment-variables.md
Normal file
304
docs/environment-variables.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# Environment Variables
|
||||||
|
|
||||||
|
This document covers the active environment variables used by the current Biergarten
|
||||||
|
stack.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The application uses environment variables for:
|
||||||
|
|
||||||
|
- **.NET API backend** - database connections, token secrets, runtime settings
|
||||||
|
- **React Router website** - API base URL and session signing
|
||||||
|
- **Docker containers** - environment-specific orchestration
|
||||||
|
|
||||||
|
## Configuration Patterns
|
||||||
|
|
||||||
|
### Backend (.NET API)
|
||||||
|
|
||||||
|
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
||||||
|
|
||||||
|
### Frontend (`src/Website`)
|
||||||
|
|
||||||
|
The active website reads runtime values from the server environment for its auth and API
|
||||||
|
integration.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
|
||||||
|
|
||||||
|
- `.env.dev` - Development
|
||||||
|
- `.env.test` - Testing
|
||||||
|
- `.env.prod` - Production
|
||||||
|
|
||||||
|
## Backend Variables (.NET API)
|
||||||
|
|
||||||
|
### Database Connection
|
||||||
|
|
||||||
|
**Option 1: Component-Based (Recommended for Docker)**
|
||||||
|
|
||||||
|
Build connection string from individual components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SERVER=sqlserver,1433 # SQL Server host and port
|
||||||
|
DB_NAME=Biergarten # Database name
|
||||||
|
DB_USER=sa # SQL Server username
|
||||||
|
DB_PASSWORD=YourStrong!Passw0rd # SQL Server password
|
||||||
|
DB_TRUST_SERVER_CERTIFICATE=True # Optional, defaults to True
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Full Connection String (Local Development)**
|
||||||
|
|
||||||
|
Provide complete connection string:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority**: `DB_CONNECTION_STRING` is checked first. If not found, connection string is
|
||||||
|
built from components.
|
||||||
|
|
||||||
|
**Implementation**: See `DefaultSqlConnectionFactory.cs`
|
||||||
|
|
||||||
|
### JWT Authentication Secrets (Backend)
|
||||||
|
|
||||||
|
The backend uses separate secrets for different token types to enable independent key rotation and validation isolation.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access token secret (1-hour tokens)
|
||||||
|
ACCESS_TOKEN_SECRET=<generated-secret> # Signs short-lived access tokens
|
||||||
|
|
||||||
|
# Refresh token secret (21-day tokens)
|
||||||
|
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
|
||||||
|
|
||||||
|
# Confirmation token secret (30-minute tokens)
|
||||||
|
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
|
||||||
|
|
||||||
|
# Website base URL (used in confirmation emails)
|
||||||
|
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Requirements**:
|
||||||
|
|
||||||
|
- Each secret should be minimum 32 characters
|
||||||
|
- Recommend 127+ characters for production
|
||||||
|
- Generate using cryptographically secure random functions
|
||||||
|
- Never reuse secrets across token types or environments
|
||||||
|
- Rotate secrets periodically in production
|
||||||
|
|
||||||
|
**Generate Secrets**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS/Linux - Generate 127-character base64 secret
|
||||||
|
openssl rand -base64 127
|
||||||
|
|
||||||
|
# Windows PowerShell
|
||||||
|
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token Expiration**:
|
||||||
|
|
||||||
|
- **Access tokens**: 1 hour
|
||||||
|
- **Refresh tokens**: 21 days
|
||||||
|
- **Confirmation tokens**: 30 minutes
|
||||||
|
|
||||||
|
(Defined in `TokenServiceExpirationHours` class)
|
||||||
|
|
||||||
|
**JWT Implementation**:
|
||||||
|
|
||||||
|
- **Algorithm**: HS256 (HMAC-SHA256)
|
||||||
|
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||||
|
- **Validation**: Token signature, expiration, and malformed token checks
|
||||||
|
|
||||||
|
### Migration Control
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLEAR_DATABASE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Required**: No
|
||||||
|
- **Default**: false
|
||||||
|
- **Effect**: If "true", drops and recreates database during migrations
|
||||||
|
- **Usage**: Development and testing environments ONLY
|
||||||
|
- **Warning**: NEVER use in production
|
||||||
|
|
||||||
|
### ASP.NET Core Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development # Development, Production, Staging
|
||||||
|
ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Variables (`src/Website`)
|
||||||
|
|
||||||
|
The active website does not use the old Next.js/Prisma environment model. Its core runtime
|
||||||
|
variables are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
|
||||||
|
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||||
|
NODE_ENV=development # Standard Node runtime mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Variable Details
|
||||||
|
|
||||||
|
#### `API_BASE_URL`
|
||||||
|
|
||||||
|
- **Required**: Yes for local development
|
||||||
|
- **Default in code**: `http://localhost:8080`
|
||||||
|
- **Used by**: `src/Website/app/lib/auth.server.ts`
|
||||||
|
- **Purpose**: Routes website auth actions to the .NET API
|
||||||
|
|
||||||
|
#### `SESSION_SECRET`
|
||||||
|
|
||||||
|
- **Required**: Strongly recommended in all environments
|
||||||
|
- **Default in local code path**: `dev-secret-change-me`
|
||||||
|
- **Used by**: React Router cookie session storage in `auth.server.ts`
|
||||||
|
- **Purpose**: Signs and validates the website session cookie
|
||||||
|
|
||||||
|
#### `NODE_ENV`
|
||||||
|
|
||||||
|
- **Required**: No
|
||||||
|
- **Typical values**: `development`, `production`, `test`
|
||||||
|
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||||
|
|
||||||
|
### Admin Account (Seeding)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_PASSWORD=SecureAdminPassword123! # Initial admin password for seeding
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Required**: No (only needed for seeding)
|
||||||
|
- **Purpose**: Sets admin account password during database seeding
|
||||||
|
- **Security**: Use strong password, change immediately in production
|
||||||
|
|
||||||
|
## Docker-Specific Variables
|
||||||
|
|
||||||
|
### SQL Server Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SA_PASSWORD=YourStrong!Passw0rd # SQL Server SA password
|
||||||
|
ACCEPT_EULA=Y # Accept SQL Server EULA (required)
|
||||||
|
MSSQL_PID=Express # SQL Server edition (Express, Developer, Enterprise)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Password Requirements**:
|
||||||
|
|
||||||
|
- Minimum 8 characters
|
||||||
|
- Uppercase, lowercase, digits, and special characters
|
||||||
|
- Maps to `DB_PASSWORD` for application containers
|
||||||
|
|
||||||
|
## Environment File Structure
|
||||||
|
|
||||||
|
### Backend/Docker (Root Directory)
|
||||||
|
|
||||||
|
```
|
||||||
|
.env.example # Template (tracked in Git)
|
||||||
|
.env.dev # Development config (gitignored)
|
||||||
|
.env.test # Testing config (gitignored)
|
||||||
|
.env.prod # Production config (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.dev
|
||||||
|
# Edit .env.dev with your values
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legacy Frontend Variables
|
||||||
|
|
||||||
|
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed from this
|
||||||
|
active reference. See [archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you
|
||||||
|
need the legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
|
||||||
|
|
||||||
|
**Docker Compose Mapping**:
|
||||||
|
|
||||||
|
- `docker-compose.dev.yaml` → `.env.dev`
|
||||||
|
- `docker-compose.test.yaml` → `.env.test`
|
||||||
|
- `docker-compose.prod.yaml` → `.env.prod`
|
||||||
|
|
||||||
|
## Variable Reference Table
|
||||||
|
|
||||||
|
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||||
|
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||||
|
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||||
|
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||||
|
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||||
|
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||||
|
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||||
|
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||||
|
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||||
|
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||||
|
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||||
|
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||||
|
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||||
|
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||||
|
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||||
|
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||||
|
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||||
|
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||||
|
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||||
|
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||||
|
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||||
|
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||||
|
|
||||||
|
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
|
||||||
|
`DB_USER`, `DB_PASSWORD`) must be provided.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
### Backend Validation
|
||||||
|
|
||||||
|
Variables are validated at startup:
|
||||||
|
|
||||||
|
- Missing required variables cause application to fail
|
||||||
|
- JWT_SECRET length is enforced (min 32 chars)
|
||||||
|
- Connection string format is validated
|
||||||
|
|
||||||
|
### Frontend Validation
|
||||||
|
|
||||||
|
The active website relies on runtime defaults for local development and the surrounding
|
||||||
|
server environment in deployed environments.
|
||||||
|
|
||||||
|
- `API_BASE_URL` defaults to `http://localhost:8080`
|
||||||
|
- `SESSION_SECRET` falls back to a development-only local secret
|
||||||
|
- `NODE_ENV` controls secure cookie behavior
|
||||||
|
|
||||||
|
## Example Configuration Files
|
||||||
|
|
||||||
|
### `.env.dev` (Backend/Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
DB_SERVER=sqlserver,1433
|
||||||
|
DB_NAME=Biergarten
|
||||||
|
DB_USER=sa
|
||||||
|
DB_PASSWORD=Dev_Password_123!
|
||||||
|
|
||||||
|
# JWT Authentication Secrets
|
||||||
|
ACCESS_TOKEN_SECRET=<generated-with-openssl>
|
||||||
|
REFRESH_TOKEN_SECRET=<generated-with-openssl>
|
||||||
|
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||||
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Migration
|
||||||
|
CLEAR_DATABASE=true
|
||||||
|
|
||||||
|
# ASP.NET Core
|
||||||
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||||
|
|
||||||
|
# SQL Server Container
|
||||||
|
SA_PASSWORD=Dev_Password_123!
|
||||||
|
ACCEPT_EULA=Y
|
||||||
|
MSSQL_PID=Express
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend local runtime example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_BASE_URL=http://localhost:8080
|
||||||
|
SESSION_SECRET=<generated-with-openssl>
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
138
docs/getting-started.md
Normal file
138
docs/getting-started.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
This guide covers local setup for the current Biergarten stack: the .NET backend in
|
||||||
|
`src/Core` and the active React Router frontend in `src/Website`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **.NET SDK 10+**
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **Docker Desktop** or equivalent Docker Engine setup
|
||||||
|
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||||
|
|
||||||
|
## Recommended Path: Docker for Backend, Node for Frontend
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd the-biergarten-app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Backend Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
At minimum, ensure `.env.dev` includes valid database and token values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_SERVER=sqlserver,1433
|
||||||
|
DB_NAME=Biergarten
|
||||||
|
DB_USER=sa
|
||||||
|
DB_PASSWORD=YourStrong!Passw0rd
|
||||||
|
ACCESS_TOKEN_SECRET=<generated>
|
||||||
|
REFRESH_TOKEN_SECRET=<generated>
|
||||||
|
CONFIRMATION_TOKEN_SECRET=<generated>
|
||||||
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Environment Variables](environment-variables.md) for the full list.
|
||||||
|
|
||||||
|
### 3. Start the Backend Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts SQL Server, migrations, seeding, and the API.
|
||||||
|
|
||||||
|
Available endpoints:
|
||||||
|
|
||||||
|
- API Swagger: http://localhost:8080/swagger
|
||||||
|
- Health Check: http://localhost:8080/health
|
||||||
|
|
||||||
|
### 4. Start the Active Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The website will be available at the local address printed by React Router dev.
|
||||||
|
|
||||||
|
Required frontend runtime variables for local work:
|
||||||
|
|
||||||
|
- `API_BASE_URL` - Base URL for the .NET API
|
||||||
|
- `SESSION_SECRET` - Cookie session signing secret for the website server
|
||||||
|
|
||||||
|
### 5. Optional: Run Storybook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm run storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
Storybook runs at http://localhost:6006 by default.
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yaml logs -f
|
||||||
|
docker compose -f docker-compose.dev.yaml down
|
||||||
|
docker compose -f docker-compose.dev.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run format:check
|
||||||
|
npm run test:storybook
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Backend Setup
|
||||||
|
|
||||||
|
If you do not want to use Docker, you can run the backend locally.
|
||||||
|
|
||||||
|
### 1. Set Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||||
|
export ACCESS_TOKEN_SECRET="<generated>"
|
||||||
|
export REFRESH_TOKEN_SECRET="<generated>"
|
||||||
|
export CONFIRMATION_TOKEN_SECRET="<generated>"
|
||||||
|
export WEBSITE_BASE_URL="http://localhost:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Migrations and Seed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Core
|
||||||
|
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
||||||
|
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project API/API.Core/API.Core.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legacy Frontend Note
|
||||||
|
|
||||||
|
The previous Next.js frontend now lives in `src/Website-v1` and is not the active website.
|
||||||
|
Legacy setup details have been moved to [docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Review [Architecture](architecture.md)
|
||||||
|
- Run backend and frontend checks from [Testing](testing.md)
|
||||||
|
- Use [Docker Guide](docker.md) for container troubleshooting
|
||||||
340
docs/testing.md
Normal file
340
docs/testing.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
This document describes the testing strategy and how to run tests for The Biergarten App.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project uses a multi-layered testing approach across backend and frontend:
|
||||||
|
|
||||||
|
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
|
||||||
|
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
|
||||||
|
- **Service.Auth.Tests** - Unit tests for authentication business logic
|
||||||
|
- **Storybook Vitest project** - Browser-based interaction tests for shared website stories
|
||||||
|
- **Storybook Playwright suite** - Browser checks against Storybook-rendered components
|
||||||
|
|
||||||
|
## Running Tests with Docker (Recommended)
|
||||||
|
|
||||||
|
The easiest way to run all tests is using Docker Compose, which sets up an isolated test
|
||||||
|
environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||||
|
```
|
||||||
|
|
||||||
|
This command:
|
||||||
|
|
||||||
|
1. Starts a fresh SQL Server instance
|
||||||
|
2. Runs database migrations
|
||||||
|
3. Seeds test data
|
||||||
|
4. Executes all test suites in parallel
|
||||||
|
5. Exports results to `./test-results/`
|
||||||
|
6. Exits when tests complete
|
||||||
|
|
||||||
|
### View Test Results
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List test result files
|
||||||
|
ls -la test-results/
|
||||||
|
|
||||||
|
# View specific test results
|
||||||
|
cat test-results/api-specs/results.trx
|
||||||
|
cat test-results/repository-tests/results.trx
|
||||||
|
cat test-results/service-auth-tests/results.trx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove test containers and volumes
|
||||||
|
docker compose -f docker-compose.test.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests Locally
|
||||||
|
|
||||||
|
You can run individual test projects locally without Docker:
|
||||||
|
|
||||||
|
### Integration Tests (API.Specs)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Core
|
||||||
|
dotnet test API/API.Specs/API.Specs.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- SQL Server instance running
|
||||||
|
- Database migrated and seeded
|
||||||
|
- Environment variables set (DB connection, JWT secret)
|
||||||
|
|
||||||
|
### Repository Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Core
|
||||||
|
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- SQL Server instance running (uses mock data)
|
||||||
|
|
||||||
|
### Service Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Core
|
||||||
|
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- No database required (uses Moq for mocking)
|
||||||
|
|
||||||
|
### Frontend Storybook Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
npm run test:storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose**:
|
||||||
|
|
||||||
|
- Verifies shared stories such as form fields, submit buttons, navbar states, toasts, and the theme gallery
|
||||||
|
- Runs in browser mode via Vitest and Storybook integration
|
||||||
|
|
||||||
|
### Frontend Playwright Storybook Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- Storybook dependencies installed
|
||||||
|
- Playwright browser dependencies installed
|
||||||
|
- The command will start or reuse the Storybook server defined in `playwright.storybook.config.ts`
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
### Current Coverage
|
||||||
|
|
||||||
|
**Authentication & User Management**:
|
||||||
|
|
||||||
|
- User registration with validation
|
||||||
|
- User login with JWT token generation
|
||||||
|
- Password hashing and verification (Argon2id)
|
||||||
|
- JWT token generation and claims
|
||||||
|
- Invalid credentials handling
|
||||||
|
- 404 error responses
|
||||||
|
|
||||||
|
**Repository Layer**:
|
||||||
|
|
||||||
|
- User account creation
|
||||||
|
- User credential management
|
||||||
|
- GetUserByUsername queries
|
||||||
|
- Stored procedure execution
|
||||||
|
|
||||||
|
**Service Layer**:
|
||||||
|
|
||||||
|
- Login service with password verification
|
||||||
|
- Register service with validation
|
||||||
|
- Business logic for authentication flow
|
||||||
|
|
||||||
|
**Frontend UI Coverage**:
|
||||||
|
|
||||||
|
- Shared submit button states
|
||||||
|
- Form field happy path and error presentation
|
||||||
|
- Navbar guest, authenticated, and mobile behavior
|
||||||
|
- Theme gallery rendering across Biergarten themes
|
||||||
|
- Toast interactions and themed notification display
|
||||||
|
|
||||||
|
### Planned Coverage
|
||||||
|
|
||||||
|
- [ ] Email verification workflow
|
||||||
|
- [ ] Password reset functionality
|
||||||
|
- [ ] Token refresh mechanism
|
||||||
|
- [ ] Brewery data management
|
||||||
|
- [ ] Beer post operations
|
||||||
|
- [ ] User follow/unfollow
|
||||||
|
- [ ] Image upload service
|
||||||
|
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||||
|
|
||||||
|
## Testing Frameworks & Tools
|
||||||
|
|
||||||
|
### xUnit
|
||||||
|
|
||||||
|
- Primary unit testing framework
|
||||||
|
- Used for Repository and Service layer tests
|
||||||
|
- Supports parallel test execution
|
||||||
|
|
||||||
|
### Reqnroll (Gherkin/BDD)
|
||||||
|
|
||||||
|
- Behavior-driven development framework
|
||||||
|
- Used for API integration tests
|
||||||
|
- Human-readable test scenarios in `.feature` files
|
||||||
|
|
||||||
|
### FluentAssertions
|
||||||
|
|
||||||
|
- Expressive assertion library
|
||||||
|
- Makes test assertions more readable
|
||||||
|
- Used across all test projects
|
||||||
|
|
||||||
|
### Moq
|
||||||
|
|
||||||
|
- Mocking framework for .NET
|
||||||
|
- Used in Service layer tests
|
||||||
|
- Enables isolated unit testing
|
||||||
|
|
||||||
|
### DbMocker
|
||||||
|
|
||||||
|
- Database mocking for repository tests
|
||||||
|
- Simulates SQL Server responses
|
||||||
|
- No real database required for unit tests
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
### API.Specs (Integration Tests)
|
||||||
|
|
||||||
|
```
|
||||||
|
API.Specs/
|
||||||
|
├── Features/
|
||||||
|
│ ├── Authentication.feature # Login/register scenarios
|
||||||
|
│ └── UserManagement.feature # User CRUD scenarios
|
||||||
|
├── Steps/
|
||||||
|
│ ├── AuthenticationSteps.cs # Step definitions
|
||||||
|
│ └── UserManagementSteps.cs
|
||||||
|
└── Mocks/
|
||||||
|
└── TestApiFactory.cs # Test server setup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Feature**:
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Feature: User Authentication
|
||||||
|
As a user
|
||||||
|
I want to register and login
|
||||||
|
So that I can access the platform
|
||||||
|
|
||||||
|
Scenario: Successful user registration
|
||||||
|
Given I have valid registration details
|
||||||
|
When I register a new account
|
||||||
|
Then I should receive a JWT token
|
||||||
|
And my account should be created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure.Repository.Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
Infrastructure.Repository.Tests/
|
||||||
|
├── AuthRepositoryTests.cs # Auth repository tests
|
||||||
|
├── UserAccountRepositoryTests.cs # User account tests
|
||||||
|
└── TestFixtures/
|
||||||
|
└── DatabaseFixture.cs # Shared test setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service.Auth.Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
Service.Auth.Tests/
|
||||||
|
├── LoginService.test.cs # Login business logic tests
|
||||||
|
└── RegisterService.test.cs # Registration business logic tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### Unit Test Example (xUnit)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class LoginServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_ValidCredentials_ReturnsToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var mockRepo = new Mock<IAuthRepository>();
|
||||||
|
var mockJwt = new Mock<IJwtService>();
|
||||||
|
var service = new AuthService(mockRepo.Object, mockJwt.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.LoginAsync("testuser", "password123");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Token.Should().NotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Test Example (Reqnroll)
|
||||||
|
|
||||||
|
```gherkin
|
||||||
|
Scenario: User login with valid credentials
|
||||||
|
Given a registered user with username "testuser"
|
||||||
|
When I POST to "/api/auth/login" with valid credentials
|
||||||
|
Then the response status should be 200
|
||||||
|
And the response should contain a JWT token
|
||||||
|
```
|
||||||
|
|
||||||
|
## Continuous Integration
|
||||||
|
|
||||||
|
Tests run automatically in CI/CD pipelines using the test Docker Compose configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CI/CD command
|
||||||
|
docker compose -f docker-compose.test.yaml build
|
||||||
|
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||||
|
docker compose -f docker-compose.test.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
|
||||||
|
- `0` - All tests passed
|
||||||
|
- Non-zero - Test failures occurred
|
||||||
|
|
||||||
|
Frontend UI checks should also be included in CI for the active website workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm ci
|
||||||
|
npm run test:storybook
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests Failing Due to Database Connection
|
||||||
|
|
||||||
|
Ensure SQL Server is running and environment variables are set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.test.yaml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Conflicts
|
||||||
|
|
||||||
|
If port 1433 is in use, stop other SQL Server instances or modify the port in
|
||||||
|
`docker-compose.test.yaml`.
|
||||||
|
|
||||||
|
### Stale Test Data
|
||||||
|
|
||||||
|
Clean up test database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.test.yaml down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Container Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.test.yaml logs <service-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Isolation**: Each test should be independent and not rely on other tests
|
||||||
|
2. **Cleanup**: Use fixtures and dispose patterns for resource cleanup
|
||||||
|
3. **Mocking**: Mock external dependencies in unit tests
|
||||||
|
4. **Descriptive Names**: Use clear, descriptive test method names
|
||||||
|
5. **Arrange-Act-Assert**: Follow AAA pattern in unit tests
|
||||||
|
6. **Given-When-Then**: Follow GWT pattern in BDD scenarios
|
||||||
205
docs/token-validation.md
Normal file
205
docs/token-validation.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Token Validation Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Core project implements comprehensive JWT token validation across three token types:
|
||||||
|
|
||||||
|
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
||||||
|
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access tokens
|
||||||
|
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email confirmation
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Infrastructure Layer
|
||||||
|
|
||||||
|
#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs)
|
||||||
|
|
||||||
|
Low-level JWT operations.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `GenerateJwt()` - Creates signed JWT tokens
|
||||||
|
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
|
||||||
|
|
||||||
|
**Implementation:** [JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs)
|
||||||
|
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||||
|
- Algorithm: HS256 (HMAC-SHA256)
|
||||||
|
- Validates token lifetime, signature, and well-formedness
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs)
|
||||||
|
|
||||||
|
High-level token validation with context (token type, user extraction).
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
|
||||||
|
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
|
||||||
|
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
|
||||||
|
|
||||||
|
**Returns:** `ValidatedToken` record containing:
|
||||||
|
- `UserId` (Guid)
|
||||||
|
- `Username` (string)
|
||||||
|
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
||||||
|
|
||||||
|
**Implementation:** [TokenValidationService.cs](Service.Auth/TokenValidationService.cs)
|
||||||
|
- Reads token secrets from environment variables
|
||||||
|
- Extracts and validates claims (Sub, UniqueName)
|
||||||
|
- Throws `UnauthorizedException` on validation failure
|
||||||
|
|
||||||
|
#### [ITokenService](Service.Auth/ITokenService.cs)
|
||||||
|
|
||||||
|
Token generation (existing service extended).
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
|
||||||
|
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
|
||||||
|
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation token
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
#### [ConfirmationService](Service.Auth/IConfirmationService.cs)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Receives confirmation token from user
|
||||||
|
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()`
|
||||||
|
3. Extracts user ID from validated token
|
||||||
|
4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database
|
||||||
|
5. Returns confirmation result
|
||||||
|
|
||||||
|
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Receives refresh token from user
|
||||||
|
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()`
|
||||||
|
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()`
|
||||||
|
4. Issues new access and refresh tokens via `TokenService`
|
||||||
|
5. Returns new token pair
|
||||||
|
|
||||||
|
#### [AuthController](API.Core/Controllers/AuthController.cs)
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /api/auth/register` - Register new user
|
||||||
|
- `POST /api/auth/login` - Authenticate user
|
||||||
|
- `POST /api/auth/confirm?token=...` - Confirm email
|
||||||
|
- `POST /api/auth/refresh` - Refresh access token
|
||||||
|
|
||||||
|
## Validation Security
|
||||||
|
|
||||||
|
### Token Secrets
|
||||||
|
|
||||||
|
Three independent secrets enable:
|
||||||
|
- **Key rotation** - Rotate each secret type independently
|
||||||
|
- **Isolation** - Compromise of one secret doesn't affect others
|
||||||
|
- **Different expiration** - Different token types can expire at different rates
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
```bash
|
||||||
|
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
|
||||||
|
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
|
||||||
|
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Checks
|
||||||
|
|
||||||
|
Each token is validated for:
|
||||||
|
|
||||||
|
1. **Signature Verification** - Token must be signed with correct secret
|
||||||
|
2. **Expiration** - Token must not be expired (checked against current time)
|
||||||
|
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
|
||||||
|
4. **Claims Format** - UserId claim must be a valid GUID
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Validation failures return HTTP 401 Unauthorized:
|
||||||
|
- Invalid signature → "Invalid token"
|
||||||
|
- Expired token → "Invalid token" (message doesn't reveal reason for security)
|
||||||
|
- Missing claims → "Invalid token"
|
||||||
|
- Malformed claims → "Invalid token"
|
||||||
|
|
||||||
|
## Token Lifecycle
|
||||||
|
|
||||||
|
### Access Token Lifecycle
|
||||||
|
|
||||||
|
1. **Generation**: During login (1-hour validity)
|
||||||
|
2. **Usage**: Included in Authorization header on API requests
|
||||||
|
3. **Validation**: Validated on protected endpoints
|
||||||
|
4. **Expiration**: Token becomes invalid after 1 hour
|
||||||
|
5. **Refresh**: Use refresh token to obtain new access token
|
||||||
|
|
||||||
|
### Refresh Token Lifecycle
|
||||||
|
|
||||||
|
1. **Generation**: During login (21-day validity)
|
||||||
|
2. **Storage**: Client-side (secure storage)
|
||||||
|
3. **Usage**: Posted to `/api/auth/refresh` endpoint
|
||||||
|
4. **Validation**: Validated by RefreshTokenService
|
||||||
|
5. **Rotation**: New refresh token issued on successful refresh
|
||||||
|
6. **Expiration**: Token becomes invalid after 21 days
|
||||||
|
|
||||||
|
### Confirmation Token Lifecycle
|
||||||
|
|
||||||
|
1. **Generation**: During user registration (30-minute validity)
|
||||||
|
2. **Delivery**: Emailed to user in confirmation link
|
||||||
|
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
|
||||||
|
4. **Validation**: Validated by ConfirmationService
|
||||||
|
5. **Completion**: User account marked as confirmed
|
||||||
|
6. **Expiration**: Token becomes invalid after 30 minutes
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
**TokenValidationService.test.cs**
|
||||||
|
- Happy path: Valid token extraction
|
||||||
|
- Error cases: Invalid, expired, malformed tokens
|
||||||
|
- Missing/invalid claims scenarios
|
||||||
|
|
||||||
|
**RefreshTokenService.test.cs**
|
||||||
|
- Successful refresh with valid token
|
||||||
|
- Invalid/expired refresh token rejection
|
||||||
|
- Non-existent user handling
|
||||||
|
|
||||||
|
**ConfirmationService.test.cs**
|
||||||
|
- Successful confirmation with valid token
|
||||||
|
- Token validation failures
|
||||||
|
- User not found scenarios
|
||||||
|
|
||||||
|
### BDD Tests (Reqnroll)
|
||||||
|
|
||||||
|
**TokenRefresh.feature**
|
||||||
|
- Successful token refresh
|
||||||
|
- Invalid/expired token rejection
|
||||||
|
- Missing token validation
|
||||||
|
|
||||||
|
**Confirmation.feature**
|
||||||
|
- Successful email confirmation
|
||||||
|
- Expired/tampered token rejection
|
||||||
|
- Missing token validation
|
||||||
|
|
||||||
|
**AccessTokenValidation.feature**
|
||||||
|
- Protected endpoint access token validation
|
||||||
|
- Invalid/expired access token rejection
|
||||||
|
- Token type mismatch (refresh used as access token)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Stretch Goals
|
||||||
|
|
||||||
|
1. **Middleware for Access Token Validation**
|
||||||
|
- Automatically validate access tokens on protected routes
|
||||||
|
- Populate HttpContext.User from token claims
|
||||||
|
- Return 401 for invalid/missing tokens
|
||||||
|
|
||||||
|
2. **Token Blacklisting**
|
||||||
|
- Implement token revocation (e.g., on logout)
|
||||||
|
- Store blacklisted tokens in cache/database
|
||||||
|
- Check blacklist during validation
|
||||||
|
|
||||||
|
3. **Refresh Token Rotation Strategy**
|
||||||
|
- Detect token reuse (replay attacks)
|
||||||
|
- Automatically invalidate entire token chain on reuse
|
||||||
|
- Log suspicious activity
|
||||||
|
|
||||||
|
4. **Structured Logging**
|
||||||
|
- Log token validation attempts
|
||||||
|
- Track failed validation reasons
|
||||||
|
- Alert on repeated validation failures (brute force detection)
|
||||||
2411
misc/raw-data/beers.csv
Normal file
2411
misc/raw-data/beers.csv
Normal file
File diff suppressed because it is too large
Load Diff
559
misc/raw-data/breweries.csv
Normal file
559
misc/raw-data/breweries.csv
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
,name,city,state
|
||||||
|
0,NorthGate Brewing ,Minneapolis, MN
|
||||||
|
1,Against the Grain Brewery,Louisville, KY
|
||||||
|
2,Jack's Abby Craft Lagers,Framingham, MA
|
||||||
|
3,Mike Hess Brewing Company,San Diego, CA
|
||||||
|
4,Fort Point Beer Company,San Francisco, CA
|
||||||
|
5,COAST Brewing Company,Charleston, SC
|
||||||
|
6,Great Divide Brewing Company,Denver, CO
|
||||||
|
7,Tapistry Brewing,Bridgman, MI
|
||||||
|
8,Big Lake Brewing,Holland, MI
|
||||||
|
9,The Mitten Brewing Company,Grand Rapids, MI
|
||||||
|
10,Brewery Vivant,Grand Rapids, MI
|
||||||
|
11,Petoskey Brewing,Petoskey, MI
|
||||||
|
12,Blackrocks Brewery,Marquette, MI
|
||||||
|
13,Perrin Brewing Company,Comstock Park, MI
|
||||||
|
14,Witch's Hat Brewing Company,South Lyon, MI
|
||||||
|
15,Founders Brewing Company,Grand Rapids, MI
|
||||||
|
16,Flat 12 Bierwerks,Indianapolis, IN
|
||||||
|
17,Tin Man Brewing Company,Evansville, IN
|
||||||
|
18,Black Acre Brewing Co.,Indianapolis, IN
|
||||||
|
19,Brew Link Brewing,Plainfield, IN
|
||||||
|
20,Bare Hands Brewery,Granger, IN
|
||||||
|
21,Three Pints Brewing,Martinsville, IN
|
||||||
|
22,Four Fathers Brewing ,Valparaiso, IN
|
||||||
|
23,Indiana City Brewing,Indianapolis, IN
|
||||||
|
24,Burn 'Em Brewing,Michigan City, IN
|
||||||
|
25,Sun King Brewing Company,Indianapolis, IN
|
||||||
|
26,Evil Czech Brewery,Mishawaka, IN
|
||||||
|
27,450 North Brewing Company,Columbus, IN
|
||||||
|
28,Taxman Brewing Company,Bargersville, IN
|
||||||
|
29,Cedar Creek Brewery,Seven Points, TX
|
||||||
|
30,SanTan Brewing Company,Chandler, AZ
|
||||||
|
31,Boulevard Brewing Company,Kansas City, MO
|
||||||
|
32,James Page Brewing Company,Stevens Point, WI
|
||||||
|
33,The Dudes' Brewing Company,Torrance, CA
|
||||||
|
34,Ballast Point Brewing Company,San Diego, CA
|
||||||
|
35,Anchor Brewing Company,San Francisco, CA
|
||||||
|
36,Figueroa Mountain Brewing Company,Buellton, CA
|
||||||
|
37,Avery Brewing Company,Boulder, CO
|
||||||
|
38,Twisted X Brewing Company,Dripping Springs, TX
|
||||||
|
39,Gonzo's BiggDogg Brewing,Kalamazoo, MI
|
||||||
|
40,Big Muddy Brewing,Murphysboro, IL
|
||||||
|
41,Lost Nation Brewing,East Fairfield, VT
|
||||||
|
42,Rising Tide Brewing Company,Portland, ME
|
||||||
|
43,Rivertowne Brewing Company,Export, PA
|
||||||
|
44,Revolution Brewing Company,Chicago, IL
|
||||||
|
45,Tallgrass Brewing Company,Manhattan, KS
|
||||||
|
46,Sixpoint Craft Ales,Brooklyn, NY
|
||||||
|
47,White Birch Brewing,Hooksett, NH
|
||||||
|
48,Firestone Walker Brewing Company,Paso Robles, CA
|
||||||
|
49,SweetWater Brewing Company,Atlanta, GA
|
||||||
|
50,Flying Mouse Brewery,Troutville, VA
|
||||||
|
51,Upslope Brewing Company,Boulder, CO
|
||||||
|
52,Pipeworks Brewing Company,Chicago, IL
|
||||||
|
53,Bent Brewstillery,Roseville, MN
|
||||||
|
54,Flesk Brewing Company,Lombard, IL
|
||||||
|
55,Pollyanna Brewing Company,Lemont, IL
|
||||||
|
56,BuckleDown Brewing,Lyons, IL
|
||||||
|
57,Destihl Brewery,Bloomington, IL
|
||||||
|
58,Summit Brewing Company,St. Paul, MN
|
||||||
|
59,Latitude 42 Brewing Company,Portage, MI
|
||||||
|
60,4 Hands Brewing Company,Saint Louis, MO
|
||||||
|
61,Surly Brewing Company,Brooklyn Center, MN
|
||||||
|
62,Against The Grain Brewery,Louisville, KY
|
||||||
|
63,Crazy Mountain Brewing Company,Edwards, CO
|
||||||
|
64,SlapShot Brewing Company,Chicago, IL
|
||||||
|
65,Mikerphone Brewing,Chicago, IL
|
||||||
|
66,Freetail Brewing Company,San Antonio, TX
|
||||||
|
67,3 Daughters Brewing,St Petersburg, FL
|
||||||
|
68,Red Shedman Farm Brewery and Hop...,Mt. Airy, MD
|
||||||
|
69,Appalachian Mountain Brewery,Boone, NC
|
||||||
|
70,Birdsong Brewing Company,Charlotte, NC
|
||||||
|
71,Union Craft Brewing,Baltimore, MD
|
||||||
|
72,Atwater Brewery,Detroit, MI
|
||||||
|
73,Ale Asylum,Madison, WI
|
||||||
|
74,Two Brothers Brewing Company,Warrenville, IL
|
||||||
|
75,Bent Paddle Brewing Company,Duluth, MN
|
||||||
|
76,Bell's Brewery,Kalamazoo, MI
|
||||||
|
77,Blue Owl Brewing,Austin, TX
|
||||||
|
78,Speakasy Ales & Lagers,San Francisco, CA
|
||||||
|
79,Black Tooth Brewing Company,Sheridan, WY
|
||||||
|
80,Hopworks Urban Brewery,Portland, OR
|
||||||
|
81,Epic Brewing,Denver, CO
|
||||||
|
82,New Belgium Brewing Company,Fort Collins, CO
|
||||||
|
83,Sierra Nevada Brewing Company,Chico, CA
|
||||||
|
84,Keweenaw Brewing Company,Houghton, MI
|
||||||
|
85,Brewery Terra Firma,Traverse City, MI
|
||||||
|
86,Grey Sail Brewing Company,Westerly, RI
|
||||||
|
87,Kirkwood Station Brewing Company,Kirkwood, MO
|
||||||
|
88,Goose Island Brewing Company,Chicago, IL
|
||||||
|
89,Broad Brook Brewing LLC,East Windsor, CT
|
||||||
|
90,The Lion Brewery,Wilkes-Barre, PA
|
||||||
|
91,Madtree Brewing Company,Cincinnati, OH
|
||||||
|
92,Jackie O's Pub & Brewery,Athens, OH
|
||||||
|
93,Rhinegeist Brewery,Cincinnati, OH
|
||||||
|
94,Warped Wing Brewing Company,Dayton, OH
|
||||||
|
95,Blackrocks Brewery,Marquette, MA
|
||||||
|
96,Catawba Valley Brewing Company,Morganton, NC
|
||||||
|
97,Tröegs Brewing Company,Hershey, PA
|
||||||
|
98,Mission Brewery,San Diego, CA
|
||||||
|
99,Christian Moerlein Brewing Company,Cincinnati, OH
|
||||||
|
100,West Sixth Brewing,Lexington, KY
|
||||||
|
101,Coastal Extreme Brewing Company,Newport, RI
|
||||||
|
102,King Street Brewing Company,Anchorage, AK
|
||||||
|
103,Beer Works Brewery,Lowell, MA
|
||||||
|
104,Lone Tree Brewing Company,Lone Tree, CO
|
||||||
|
105,Four String Brewing Company,Columbus, OH
|
||||||
|
106,Glabrous Brewing Company,Pineland, ME
|
||||||
|
107,Bonfire Brewing Company,Eagle, CO
|
||||||
|
108,Thomas Hooker Brewing Company,Bloomfield, CT
|
||||||
|
109,"Woodstock Inn, Station & Brewery",North Woodstock, NH
|
||||||
|
110,Renegade Brewing Company,Denver, CO
|
||||||
|
111,Mother Earth Brew Company,Vista, CA
|
||||||
|
112,Black Market Brewing Company,Temecula, CA
|
||||||
|
113,Vault Brewing Company,Yardley, PA
|
||||||
|
114,Jailbreak Brewing Company,Laurel, MD
|
||||||
|
115,Smartmouth Brewing Company,Norfolk, VA
|
||||||
|
116,Base Camp Brewing Co.,Portland, OR
|
||||||
|
117,Alameda Brewing,Portland, OR
|
||||||
|
118,Southern Star Brewing Company,Conroe, TX
|
||||||
|
119,Steamworks Brewing Company,Durango, CO
|
||||||
|
120,Horny Goat Brew Pub,Milwaukee, WI
|
||||||
|
121,Cheboygan Brewing Company,Cheboygan, MI
|
||||||
|
122,Center of the Universe Brewing C...,Ashland, VA
|
||||||
|
123,Ipswich Ale Brewery,Ipswich, MA
|
||||||
|
124,Griffin Claw Brewing Company,Birmingham, MI
|
||||||
|
125,Karbach Brewing Company,Houston, TX
|
||||||
|
126,Uncle Billy's Brewery and Smokeh...,Austin, TX
|
||||||
|
127,Deep Ellum Brewing Company,Dallas, TX
|
||||||
|
128,Real Ale Brewing Company,Blanco, TX
|
||||||
|
129,Straub Brewery,St Mary's, PA
|
||||||
|
130,Shebeen Brewing Company,Wolcott, CT
|
||||||
|
131,Stevens Point Brewery,Stevens Point, WI
|
||||||
|
132,Weston Brewing Company,Weston, MO
|
||||||
|
133,Southern Prohibition Brewing Com...,Hattiesburg, MS
|
||||||
|
134,Minhas Craft Brewery,Monroe, WI
|
||||||
|
135,Pug Ryan's Brewery,Dillon, CO
|
||||||
|
136,Hops & Grains Brewing Company,Austin, TX
|
||||||
|
137,Sietsema Orchards and Cider Mill,Ada, MI
|
||||||
|
138,Summit Brewing Company,St Paul, MN
|
||||||
|
139,Core Brewing & Distilling Company,Springdale, AR
|
||||||
|
140,Independence Brewing Company,Austin, TX
|
||||||
|
141,Cigar City Brewing Company,Tampa, FL
|
||||||
|
142,Third Street Brewhouse,Cold Spring, MN
|
||||||
|
143,Narragansett Brewing Company,Providence, RI
|
||||||
|
144,Grimm Brothers Brewhouse,Loveland, CO
|
||||||
|
145,Cisco Brewers,Nantucket, MA
|
||||||
|
146,Angry Minnow,Hayward, WI
|
||||||
|
147,Platform Beer Company,Cleveland, OH
|
||||||
|
148,Odyssey Beerwerks,Arvada, CO
|
||||||
|
149,Lonerider Brewing Company,Raleigh, NC
|
||||||
|
150,Oakshire Brewing,Eugene, OR
|
||||||
|
151,Fort Pitt Brewing Company,Latrobe, PA
|
||||||
|
152,Tin Roof Brewing Company,Baton Rouge, LA
|
||||||
|
153,Three Creeks Brewing,Sisters, OR
|
||||||
|
154,2 Towns Ciderhouse,Corvallis, OR
|
||||||
|
155,Caldera Brewing Company,Ashland, OR
|
||||||
|
156,Greenbrier Valley Brewing Company,Lewisburg, WV
|
||||||
|
157,Phoenix Ale Brewery,Phoenix, AZ
|
||||||
|
158,Lumberyard Brewing Company,Flagstaff, AZ
|
||||||
|
159,Uinta Brewing Company,Salt Lake City, UT
|
||||||
|
160,Four Peaks Brewing Company,Tempe, AZ
|
||||||
|
161,Martin House Brewing Company,Fort Worth, TX
|
||||||
|
162,Right Brain Brewery,Traverse City, MI
|
||||||
|
163,Sly Fox Brewing Company,Phoenixville, PA
|
||||||
|
164,Round Guys Brewing,Lansdale, PA
|
||||||
|
165,Great Crescent Brewery,Aurora, IN
|
||||||
|
166,Oskar Blues Brewery,Longmont, CO
|
||||||
|
167,Boxcar Brewing Company,West Chester, PA
|
||||||
|
168,High Hops Brewery,Windsor, CO
|
||||||
|
169,Crooked Fence Brewing Company,Garden City, ID
|
||||||
|
170,Everybody's Brewing,White Salmon, WA
|
||||||
|
171,Anderson Valley Brewing Company,Boonville, CA
|
||||||
|
172,Fiddlehead Brewing Company,Shelburne, VT
|
||||||
|
173,Evil Twin Brewing,Brooklyn, NY
|
||||||
|
174,New Orleans Lager & Ale Brewing ...,New Orleans, LA
|
||||||
|
175,Spiteful Brewing Company,Chicago, IL
|
||||||
|
176,Rahr & Sons Brewing Company,Fort Worth, TX
|
||||||
|
177,18th Street Brewery,Gary, IN
|
||||||
|
178,Cambridge Brewing Company,Cambridge, MA
|
||||||
|
179,Carolina Brewery,Pittsboro, NC
|
||||||
|
180,Frog Level Brewing Company,Waynesville, NC
|
||||||
|
181,Wild Wolf Brewing Company,Nellysford, VA
|
||||||
|
182,COOP Ale Works,Oklahoma City, OK
|
||||||
|
183,Seventh Son Brewing Company,Columbus, OH
|
||||||
|
184,Oasis Texas Brewing Company,Austin, TX
|
||||||
|
185,Vander Mill Ciders,Spring Lake, MI
|
||||||
|
186,St. Julian Winery,Paw Paw, MI
|
||||||
|
187,Pedernales Brewing Company,Fredericksburg, TX
|
||||||
|
188,Mother's Brewing,Springfield, MO
|
||||||
|
189,Modern Monks Brewery,Lincoln, NE
|
||||||
|
190,Two Beers Brewing Company,Seattle, WA
|
||||||
|
191,Snake River Brewing Company,Jackson, WY
|
||||||
|
192,Capital Brewery,Middleton, WI
|
||||||
|
193,Anthem Brewing Company,Oklahoma City, OK
|
||||||
|
194,Goodlife Brewing Co.,Bend, OR
|
||||||
|
195,Breakside Brewery,Portland, OR
|
||||||
|
196,Goose Island Brewery Company,Chicago, IL
|
||||||
|
197,Burnside Brewing Co.,Portland, OR
|
||||||
|
198,Hop Valley Brewing Company,Springfield, OR
|
||||||
|
199,Worthy Brewing Company,Bend, OR
|
||||||
|
200,Occidental Brewing Company,Portland, OR
|
||||||
|
201,Fearless Brewing Company,Estacada, OR
|
||||||
|
202,Upland Brewing Company,Bloomington, IN
|
||||||
|
203,Mehana Brewing Co.,Hilo, HI
|
||||||
|
204,Hawai'i Nui Brewing Co.,Hilo, HI
|
||||||
|
205,People's Brewing Company,Lafayette, IN
|
||||||
|
206,Fort George Brewery,Astoria, OR
|
||||||
|
207,Branchline Brewing Company,San Antonio, TX
|
||||||
|
208,Kalona Brewing Company,Kalona, IA
|
||||||
|
209,Modern Times Beer,San Diego, CA
|
||||||
|
210,Temperance Beer Company,Evanston, IL
|
||||||
|
211,Wisconsin Brewing Company,Verona, WI
|
||||||
|
212,Crow Peak Brewing Company,Spearfish, SD
|
||||||
|
213,Grapevine Craft Brewery,Farmers Branch, TX
|
||||||
|
214,Buffalo Bayou Brewing Company,Houston, TX
|
||||||
|
215,Texian Brewing Co.,Richmond, TX
|
||||||
|
216,Orpheus Brewing,Atlanta, GA
|
||||||
|
217,Forgotten Boardwalk,Cherry Hill, NJ
|
||||||
|
218,Laughing Dog Brewing Company,Ponderay, ID
|
||||||
|
219,Bozeman Brewing Company,Bozeman, MT
|
||||||
|
220,Big Choice Brewing,Broomfield, CO
|
||||||
|
221,Big Storm Brewing Company,Odessa, FL
|
||||||
|
222,Carton Brewing Company,Atlantic Highlands, NJ
|
||||||
|
223,Midnight Sun Brewing Company,Anchorage, AK
|
||||||
|
224,Fat Head's Brewery,Middleburg Heights, OH
|
||||||
|
225,Refuge Brewery,Temecula, CA
|
||||||
|
226,Chatham Brewing,Chatham, NY
|
||||||
|
227,DC Brau Brewing Company,Washington, DC
|
||||||
|
228,Geneva Lake Brewing Company,Lake Geneva, WI
|
||||||
|
229,Rochester Mills Brewing Company,Rochester, MI
|
||||||
|
230,Cape Ann Brewing Company,Gloucester, MA
|
||||||
|
231,Borderlands Brewing Company,Tucson, AZ
|
||||||
|
232,College Street Brewhouse and Pub,Lake Havasu City, AZ
|
||||||
|
233,Joseph James Brewing Company,Henderson, NV
|
||||||
|
234,Harpoon Brewery,Boston, MA
|
||||||
|
235,Back East Brewing Company,Bloomfield, CT
|
||||||
|
236,Champion Brewing Company,Charlottesville, VA
|
||||||
|
237,Devil's Backbone Brewing Company,Lexington, VA
|
||||||
|
238,Newburgh Brewing Company,Newburgh, NY
|
||||||
|
239,Wiseacre Brewing Company,Memphis, TN
|
||||||
|
240,Golden Road Brewing,Los Angeles, CA
|
||||||
|
241,New Republic Brewing Company,College Station, TX
|
||||||
|
242,Infamous Brewing Company,Austin, TX
|
||||||
|
243,Two Henrys Brewing Company,Plant City, FL
|
||||||
|
244,Lift Bridge Brewing Company,Stillwater, MN
|
||||||
|
245,Lucky Town Brewing Company,Jackson, MS
|
||||||
|
246,Quest Brewing Company,Greenville, SC
|
||||||
|
247,Creature Comforts,Athens, GA
|
||||||
|
248,Half Full Brewery,Stamford, CT
|
||||||
|
249,Southampton Publick House,Southampton, NY
|
||||||
|
250,Chapman's Brewing,Angola, IN
|
||||||
|
251,Barrio Brewing Company,Tucson, AZ
|
||||||
|
252,Santa Cruz Mountain Brewing,Santa Cruz, CA
|
||||||
|
253,Frankenmuth Brewery,Frankenmuth, MI
|
||||||
|
254,Meckley's Cidery,Somerset Center, MI
|
||||||
|
255,Stillwater Artisanal Ales,Baltimore, MD
|
||||||
|
256,Finch's Beer Company,Chicago, IL
|
||||||
|
257,South Austin Brewery,South Austin, TX
|
||||||
|
258,Bauhaus Brew Labs,Minneapolis, MN
|
||||||
|
259,Ozark Beer Company,Rogers, AR
|
||||||
|
260,Mountain Town Brewing Company ,Mount Pleasant, MI
|
||||||
|
261,Otter Creek Brewing,Waterbury, VT
|
||||||
|
262,The Brewer's Art,Baltimore, MD
|
||||||
|
263,Denver Beer Company,Denver, CO
|
||||||
|
264,Ska Brewing Company,Durango, CO
|
||||||
|
265,Tractor Brewing Company,Albuquerque, NM
|
||||||
|
266,Peak Organic Brewing Company,Portland, ME
|
||||||
|
267,Cape Cod Beer,Hyannis, MA
|
||||||
|
268,Long Trail Brewing Company,Bridgewater Corners, VT
|
||||||
|
269,Great Raft Brewing Company,Shreveport, LA
|
||||||
|
270,Alaskan Brewing Company,Juneau, AK
|
||||||
|
271,Notch Brewing Company,Ipswich, MA
|
||||||
|
272,The Alchemist,Waterbury, VT
|
||||||
|
273,Three Notch'd Brewing Company,Charlottesville, VA
|
||||||
|
274,Portside Brewery,Cleveland, OH
|
||||||
|
275,Otter Creek Brewing,Middlebury, VT
|
||||||
|
276,Montauk Brewing Company,Montauk, NY
|
||||||
|
277,Indeed Brewing Company,Minneapolis, MN
|
||||||
|
278,Berkshire Brewing Company,South Deerfield, MA
|
||||||
|
279,Foolproof Brewing Company,Pawtucket, RI
|
||||||
|
280,Headlands Brewing Company,Mill Valley, CA
|
||||||
|
281,Bolero Snort Brewery,Ridgefield Park, NJ
|
||||||
|
282,Thunderhead Brewing Company,Kearney, NE
|
||||||
|
283,Defiance Brewing Company,Hays, KS
|
||||||
|
284,Milwaukee Brewing Company,Milwaukee, WI
|
||||||
|
285,Catawba Island Brewing,Port Clinton, OH
|
||||||
|
286,Back Forty Beer Company,Gadsden, AL
|
||||||
|
287,Four Corners Brewing Company,Dallas, TX
|
||||||
|
288,Saint Archer Brewery,San Diego, CA
|
||||||
|
289,Rogue Ales,Newport, OR
|
||||||
|
290,Hale's Ales,Seattle, WA
|
||||||
|
291,Tommyknocker Brewery,Idaho Springs, CO
|
||||||
|
292,Baxter Brewing Company,Lewiston, ME
|
||||||
|
293,Northampton Brewery,Northamtpon, MA
|
||||||
|
294,Black Shirt Brewing Company,Denver, CO
|
||||||
|
295,Wachusett Brewing Company,Westminster, MA
|
||||||
|
296,Widmer Brothers Brewing Company,Portland, OR
|
||||||
|
297,Hop Farm Brewing Company,Pittsburgh, PA
|
||||||
|
298,Liquid Hero Brewery,York, PA
|
||||||
|
299,Matt Brewing Company,Utica, NY
|
||||||
|
300,Boston Beer Company,Boston, MA
|
||||||
|
301,Old Forge Brewing Company,Danville, PA
|
||||||
|
302,Utah Brewers Cooperative,Salt Lake City, UT
|
||||||
|
303,Magic Hat Brewing Company,South Burlington, VT
|
||||||
|
304,Blue Hills Brewery,Canton, MA
|
||||||
|
305,Night Shift Brewing,Everett, MA
|
||||||
|
306,Beach Brewing Company,Virginia Beach, VA
|
||||||
|
307,Payette Brewing Company,Garden City, ID
|
||||||
|
308,Brew Bus Brewing,Tampa, FL
|
||||||
|
309,Sockeye Brewing Company,Boise, ID
|
||||||
|
310,Pine Street Brewery,San Francisco, CA
|
||||||
|
311,Dirty Bucket Brewing Company,Woodinville, WA
|
||||||
|
312,Jackalope Brewing Company,Nashville, TN
|
||||||
|
313,Slanted Rock Brewing Company,Meridian, ID
|
||||||
|
314,Piney River Brewing Company,Bucryus, MO
|
||||||
|
315,Cutters Brewing Company,Avon, IN
|
||||||
|
316,Iron Hill Brewery & Restaurant,Wilmington, DE
|
||||||
|
317,Marshall Wharf Brewing Company,Belfast, ME
|
||||||
|
318,Banner Beer Company,Williamsburg, MA
|
||||||
|
319,Dick's Brewing Company,Centralia, WA
|
||||||
|
320,Claremont Craft Ales,Claremont, CA
|
||||||
|
321,Rivertown Brewing Company,Lockland, OH
|
||||||
|
322,Voodoo Brewery,Meadville, PA
|
||||||
|
323,D.L. Geary Brewing Company,Portland, ME
|
||||||
|
324,Pisgah Brewing Company,Black Mountain, NC
|
||||||
|
325,Neshaminy Creek Brewing Company,Croydon, PA
|
||||||
|
326,Morgan Street Brewery,Saint Louis, MO
|
||||||
|
327,Half Acre Beer Company,Chicago, IL
|
||||||
|
328,The Just Beer Project,Burlington, VT
|
||||||
|
329,The Bronx Brewery,Bronx, NY
|
||||||
|
330,Dead Armadillo Craft Brewing,Tulsa, OK
|
||||||
|
331,Catawba Brewing Company,Morganton, NC
|
||||||
|
332,La Cumbre Brewing Company,Albuquerque, NM
|
||||||
|
333,David's Ale Works,Diamond Springs, CA
|
||||||
|
334,The Traveler Beer Company,Burlington, VT
|
||||||
|
335,Fargo Brewing Company,Fargo, ND
|
||||||
|
336,Big Sky Brewing Company,Missoula, MT
|
||||||
|
337,Nebraska Brewing Company,Papillion, NE
|
||||||
|
338,Uncle John's Fruit House Winery,St. John's, MI
|
||||||
|
339,Wormtown Brewery,Worcester, MA
|
||||||
|
340,Due South Brewing Company,Boynton Beach, FL
|
||||||
|
341,Palisade Brewing Company,Palisade, CO
|
||||||
|
342,KelSo Beer Company,Brooklyn, NY
|
||||||
|
343,Hardywood Park Craft Brewery,Richmond, VA
|
||||||
|
344,Wolf Hills Brewing Company,Abingdon, VA
|
||||||
|
345,Lavery Brewing Company,Erie, PA
|
||||||
|
346,Manzanita Brewing Company,Santee, CA
|
||||||
|
347,Fullsteam Brewery,Durham, NC
|
||||||
|
348,Four Horsemen Brewing Company,South Bend, IN
|
||||||
|
349,Hinterland Brewery,Green Bay, WI
|
||||||
|
350,Central Coast Brewing Company,San Luis Obispo, CA
|
||||||
|
351,Westfield River Brewing Company,Westfield, MA
|
||||||
|
352,Elevator Brewing Company,Columbus, OH
|
||||||
|
353,Aslan Brewing Company,Bellingham, WA
|
||||||
|
354,Kulshan Brewery,Bellingham, WA
|
||||||
|
355,Pikes Peak Brewing Company,Monument, CO
|
||||||
|
356,Manayunk Brewing Company,Philadelphia, PA
|
||||||
|
357,Buckeye Brewing,Cleveland, OH
|
||||||
|
358,Daredevil Brewing Company,Shelbyville, IN
|
||||||
|
359,NoDa Brewing Company,Charlotte, NC
|
||||||
|
360,Aviator Brewing Company,Fuquay-Varina, NC
|
||||||
|
361,Wild Onion Brewing Company,Lake Barrington, IL
|
||||||
|
362,Hilliard's Beer,Seattle, WA
|
||||||
|
363,Mikkeller,Pottstown, PA
|
||||||
|
364,Bohemian Brewery,Midvale, UT
|
||||||
|
365,Great River Brewery,Davenport, IA
|
||||||
|
366,Mustang Brewing Company,Mustang, OK
|
||||||
|
367,Airways Brewing Company,Kent, WA
|
||||||
|
368,21st Amendment Brewery,San Francisco, CA
|
||||||
|
369,Eddyline Brewery & Restaurant,Buena Vista, CO
|
||||||
|
370,Pizza Port Brewing Company,Carlsbad, CA
|
||||||
|
371,Sly Fox Brewing Company,Pottstown, PA
|
||||||
|
372,Spring House Brewing Company,Conestoga, PA
|
||||||
|
373,7venth Sun,Dunedin, FL
|
||||||
|
374,Astoria Brewing Company,Astoria, OR
|
||||||
|
375,Maui Brewing Company,Lahaina, HI
|
||||||
|
376,RoughTail Brewing Company,Midwest City, OK
|
||||||
|
377,Lucette Brewing Company,Menominee, WI
|
||||||
|
378,Bold City Brewery,Jacksonville, FL
|
||||||
|
379,Grey Sail Brewing of Rhode Island,Westerly, RI
|
||||||
|
380,Blue Blood Brewing Company,Lincoln, NE
|
||||||
|
381,Swashbuckler Brewing Company,Manheim, PA
|
||||||
|
382,Blue Mountain Brewery,Afton, VA
|
||||||
|
383,Starr Hill Brewery,Crozet, VA
|
||||||
|
384,Westbrook Brewing Company,Mt. Pleasant, SC
|
||||||
|
385,Shipyard Brewing Company,Portland, ME
|
||||||
|
386,Revolution Brewing,Paonia, CO
|
||||||
|
387,Natian Brewery,Portland, OR
|
||||||
|
388,Alltech's Lexington Brewing Company,Lexington, KY
|
||||||
|
389,Oskar Blues Brewery (North Carol...,Brevard, NC
|
||||||
|
390,Orlison Brewing Company,Airway Heights, WA
|
||||||
|
391,Breckenridge Brewery,Denver, CO
|
||||||
|
392,Santa Fe Brewing Company,Santa Fe, NM
|
||||||
|
393,Miami Brewing Company,Miami, FL
|
||||||
|
394,Schilling & Company,Seattle, WA
|
||||||
|
395,Hops & Grain Brewery,Austin, TX
|
||||||
|
396,White Flame Brewing Company,Hudsonville, MI
|
||||||
|
397,Ruhstaller Beer Company,Sacramento, CA
|
||||||
|
398,Saugatuck Brewing Company,Douglas, MI
|
||||||
|
399,Moab Brewery,Moab, UT
|
||||||
|
400,Macon Beer Company,Macon, GA
|
||||||
|
401,Amnesia Brewing Company,Washougal, WA
|
||||||
|
402,Wolverine State Brewing Company,Ann Arbor, MI
|
||||||
|
403,Red Tank Cider Company,Bend, OR
|
||||||
|
404,Cascadia Ciderworks United,Portland, OR
|
||||||
|
405,Fate Brewing Company,Boulder, CO
|
||||||
|
406,Lazy Monk Brewing,Eau Claire, WI
|
||||||
|
407,Bitter Root Brewing,Hamilton, MT
|
||||||
|
408,10 Barrel Brewing Company,Bend, OR
|
||||||
|
409,Tamarack Brewing Company,Lakeside, MT
|
||||||
|
410,New England Brewing Company,Woodbridge, CT
|
||||||
|
411,Seattle Cider Company,Seattle, WA
|
||||||
|
412,Straight to Ale,Huntsville, AL
|
||||||
|
413,Austin Beerworks,Austin, TX
|
||||||
|
414,Blue Mountain Brewery,Arrington, VA
|
||||||
|
415,Coastal Empire Beer Company,Savannah, GA
|
||||||
|
416,Jack's Hard Cider (Hauser Estate...,Biglerville, PA
|
||||||
|
417,Boulder Beer Company,Boulder, CO
|
||||||
|
418,Coalition Brewing Company,Portland, OR
|
||||||
|
419,Sanitas Brewing Company,Boulder, CO
|
||||||
|
420,Gore Range Brewery,Edwards, CO
|
||||||
|
421,Redstone Meadery,Boulder, CO
|
||||||
|
422,Blue Dog Mead,Eugene, OR
|
||||||
|
423,Hess Brewing Company,San Diego, CA
|
||||||
|
424,Wynkoop Brewing Company,Denver, CO
|
||||||
|
425,Ciderboys,Stevens Point, WI
|
||||||
|
426,Armadillo Ale Works,Denton, TX
|
||||||
|
427,Roanoke Railhouse Brewery,Roanoke, VA
|
||||||
|
428,Schlafly Brewing Company,Saint Louis, MO
|
||||||
|
429,Asher Brewing Company,Boulder, CO
|
||||||
|
430,Lost Rhino Brewing Company,Ashburn, VA
|
||||||
|
431,North Country Brewing Company,Slippery Rock, PA
|
||||||
|
432,Seabright Brewery,Santa Cruz, CA
|
||||||
|
433,French Broad Brewery,Asheville, NC
|
||||||
|
434,Angry Orchard Cider Company,Cincinnati, OH
|
||||||
|
435,Two Roads Brewing Company,Stratford, CT
|
||||||
|
436,Southern Oregon Brewing Company,Medford, OR
|
||||||
|
437,Brooklyn Brewery,Brooklyn, NY
|
||||||
|
438,The Right Brain Brewery,Traverse City, MI
|
||||||
|
439,Kona Brewing Company,Kona, HI
|
||||||
|
440,MillKing It Productions,Royal Oak, MI
|
||||||
|
441,Pateros Creek Brewing Company,Fort Collins, CO
|
||||||
|
442,O'Fallon Brewery,O'Fallon, MO
|
||||||
|
443,Marble Brewery,Albuquerque, NM
|
||||||
|
444,Big Wood Brewery,Vadnais Heights, MN
|
||||||
|
445,Howard Brewing Company,Lenoir, NC
|
||||||
|
446,Downeast Cider House,Leominster, MA
|
||||||
|
447,Swamp Head Brewery,Gainesville, FL
|
||||||
|
448,Mavericks Beer Company,Half Moon Bay, CA
|
||||||
|
449,TailGate Beer,San Diego, CA
|
||||||
|
450,Northwest Brewing Company,Pacific, WA
|
||||||
|
451,Dad & Dude's Breweria,Aurora, CO
|
||||||
|
452,Centennial Beer Company,Edwards, CO
|
||||||
|
453,Denali Brewing Company,Talkeetna, AK
|
||||||
|
454,Deschutes Brewery,Bend, OR
|
||||||
|
455,Sunken City Brewing Company,Hardy, VA
|
||||||
|
456,Lucette Brewing Company,Menominie, WI
|
||||||
|
457,The Black Tooth Brewing Company,Sheridan, WY
|
||||||
|
458,Kenai River Brewing Company,Soldotna, AK
|
||||||
|
459,River North Brewery,Denver, CO
|
||||||
|
460,Fremont Brewing Company,Seattle, WA
|
||||||
|
461,Armstrong Brewing Company,South San Francisco, CA
|
||||||
|
462,AC Golden Brewing Company,Golden, CO
|
||||||
|
463,Big Bend Brewing Company,Alpine, TX
|
||||||
|
464,Good Life Brewing Company,Bend, OR
|
||||||
|
465,Engine 15 Brewing,Jacksonville Beach, FL
|
||||||
|
466,Green Room Brewing,Jacksonville, FL
|
||||||
|
467,Brindle Dog Brewing Company,Tampa Bay, FL
|
||||||
|
468,Peace Tree Brewing Company,Knoxville, IA
|
||||||
|
469,Terrapin Brewing Company,Athens, GA
|
||||||
|
470,Pete's Brewing Company,San Antonio, TX
|
||||||
|
471,Okoboji Brewing Company,Spirit Lake, IA
|
||||||
|
472,Crystal Springs Brewing Company,Boulder, CO
|
||||||
|
473,Engine House 9,Tacoma, WA
|
||||||
|
474,Tonka Beer Company,Minnetonka, MN
|
||||||
|
475,Red Hare Brewing Company,Marietta, GA
|
||||||
|
476,Hangar 24 Craft Brewery,Redlands, CA
|
||||||
|
477,Big Elm Brewing,Sheffield, MA
|
||||||
|
478,Good People Brewing Company,Birmingham, AL
|
||||||
|
479,Heavy Seas Beer,Halethorpe, MD
|
||||||
|
480,Telluride Brewing Company,Telluride, CO
|
||||||
|
481,7 Seas Brewing Company,Gig Harbor, WA
|
||||||
|
482,Confluence Brewing Company,Des Moines, IA
|
||||||
|
483,Bale Breaker Brewing Company,Yakima, WA
|
||||||
|
484,The Manhattan Brewing Company,New York, NY
|
||||||
|
485,MacTarnahans Brewing Company,Portland, OR
|
||||||
|
486,Stillmank Beer Company,Green Bay, WI
|
||||||
|
487,Redhook Brewery,Woodinville, WA
|
||||||
|
488,Dock Street Brewery,Philadelphia, PA
|
||||||
|
489,Blue Point Brewing Company,Patchogue, NY
|
||||||
|
490,Tampa Bay Brewing Company,Tampa, FL
|
||||||
|
491,Devil's Canyon Brewery,Belmont, CA
|
||||||
|
492,Stone Coast Brewing Company,Portland, ME
|
||||||
|
493,Broken Tooth Brewing Company,Anchorage, AK
|
||||||
|
494,Seven Brides Brewery,Silverton, OR
|
||||||
|
495,Newburyport Brewing Company,Newburyport, MA
|
||||||
|
496,Dry Dock Brewing Company,Aurora, CO
|
||||||
|
497,Cans Bar and Canteen,Charlotte, NC
|
||||||
|
498,Sprecher Brewing Company,Glendale, WI
|
||||||
|
499,Wildwood Brewing Company,Stevensville, MT
|
||||||
|
500,High Noon Saloon And Brewery,Leavenworth, KS
|
||||||
|
501,Woodchuck Hard Cider,Middlebury, VT
|
||||||
|
502,Sea Dog Brewing Company,Portland, ME
|
||||||
|
503,Oskar Blues Brewery,Lyons, CO
|
||||||
|
504,Carolina Beer & Beverage,Mooresville, NC
|
||||||
|
505,Krebs Brewing Company (Pete's Pl...,Krebs, OK
|
||||||
|
506,Warbird Brewing Company,Fort Wayne, IN
|
||||||
|
507,Mudshark Brewing Company,Lake Havasu City, AZ
|
||||||
|
508,Spilker Ales,Cortland, NE
|
||||||
|
509,Wingman Brewers,Tacoma, WA
|
||||||
|
510,Kettle House Brewing Company,Missoula, MT
|
||||||
|
511,Sherwood Forest Brewers,Marlborough, MA
|
||||||
|
512,Cottrell Brewing,Pawcatuck, CT
|
||||||
|
513,Arctic Craft Brewery,Colorado Springs, CO
|
||||||
|
514,Monkey Paw Pub & Brewery,San Diego, CA
|
||||||
|
515,Crabtree Brewing Company,Greeley, CO
|
||||||
|
516,Emerald City Beer Company,Seattle, WA
|
||||||
|
517,Butcher's Brewing,Carlsbad, CA
|
||||||
|
518,New South Brewing Company,Myrtle Beach, SC
|
||||||
|
519,Big River Brewing Company,Chattanooga, TN
|
||||||
|
520,Twisted Pine Brewing Company,Boulder, CO
|
||||||
|
521,Flying Dog Brewery,Frederick, MD
|
||||||
|
522,Uncommon Brewers,Santa Cruz, CA
|
||||||
|
523,Aspen Brewing Company,Aspen, CO
|
||||||
|
524,Triangle Brewing Company,Durham, NC
|
||||||
|
525,Bomb Beer Company,New York, NY
|
||||||
|
526,Churchkey Can Company,Seattle, WA
|
||||||
|
527,Intuition Ale Works,Jacksonville, FL
|
||||||
|
528,Asheville Brewing Company,Asheville, NC
|
||||||
|
529,Northwoods Brewpub,Eau Claire, WI
|
||||||
|
530,Buckbean Brewing Company,Reno, NV
|
||||||
|
531,Dolores River Brewery,Dolores, CO
|
||||||
|
532,Flat Rock Brewing Company,Smithton, PA
|
||||||
|
533,Abita Brewing Company,Abita Springs, LA
|
||||||
|
534,Mammoth Brewing Company,Mammoth Lakes, CA
|
||||||
|
535,Harvest Moon Brewing Company,Belt, MT
|
||||||
|
536,Grand Canyon Brewing Company,Williams, AZ
|
||||||
|
537,Lewis and Clark Brewing Company,Helena, MT
|
||||||
|
538,Dundee Brewing Company,Rochester, NY
|
||||||
|
539,Twin Lakes Brewing Company,Greenville, DE
|
||||||
|
540,Mother Earth Brewing Company,Kinston, NC
|
||||||
|
541,Arcadia Brewing Company,Battle Creek, MI
|
||||||
|
542,Angry Minnow Brewing Company,Hayward, WI
|
||||||
|
543,Great Northern Brewing Company,Whitefish, MT
|
||||||
|
544,Pyramid Breweries,Seattle, WA
|
||||||
|
545,Lancaster Brewing Company,Lancaster, PA
|
||||||
|
546,Upstate Brewing Company,Elmira, NY
|
||||||
|
547,Moat Mountain Smoke House & Brew...,North Conway, NH
|
||||||
|
548,Prescott Brewing Company,Prescott, AZ
|
||||||
|
549,Mogollon Brewing Company,Flagstaff, AZ
|
||||||
|
550,Wind River Brewing Company,Pinedale, WY
|
||||||
|
551,Silverton Brewery,Silverton, CO
|
||||||
|
552,Mickey Finn's Brewery,Libertyville, IL
|
||||||
|
553,Covington Brewhouse,Covington, LA
|
||||||
|
554,Dave's Brewfarm,Wilson, WI
|
||||||
|
555,Ukiah Brewing Company,Ukiah, CA
|
||||||
|
556,Butternuts Beer and Ale,Garrattsville, NY
|
||||||
|
557,Sleeping Lady Brewing Company,Anchorage, AK
|
||||||
|
162686
misc/raw-data/breweries.json
Normal file
162686
misc/raw-data/breweries.json
Normal file
File diff suppressed because it is too large
Load Diff
578
misc/raw-data/ontariobreweries.json
Normal file
578
misc/raw-data/ontariobreweries.json
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"text": "100 Acre Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/100-acre-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "All My Friends Beer Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/all-my-friends-beer-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "All or Nothing Brewhouse",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/all-or-nothing-brewhouse/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Anderson Craft Ales",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/anderson-craft-ales/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Badlands Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/badlands-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Bancroft Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/bancroft-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Banded Goose Brewing Comany",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/banded-goose-brewing-comany/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Beau’s Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/beaus-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "BeerLab! London",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/beerlab-london/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Bellwoods Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/bellwoods-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Bench Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/bench-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Beyond The Pale Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/beyond-the-pale-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Bicycle Craft Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/bicycle-craft-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Big Rig Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/big-rig-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Big Rock Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/big-rock-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Black Gold Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/black-gold-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Black Oak Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/black-oak-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Block 3 Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/block-3-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Blood Brothers Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/blood-brothers-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Bobcaygeon Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/bobcaygeon-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Boshkung Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/boshkung-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Brauwerk Hoffman – Rockland",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/brauwerk-hoffman-rockland/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Bridge Masters Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/bridge-masters-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Broadhead Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/broadhead-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Broken Rail Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/broken-rail-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Burdock Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/burdock-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "C’est What Durham Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/cest-what-durham-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Calabogie Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/calabogie-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Cameron’s Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/camerons-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Canvas Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/canvas-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Caps Off Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/caps-off-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Century Barn Brewing & Beverage Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/century-barn-brewing-and-beverage-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Chronicle Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/chronicle-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Clifford Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/clifford-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Cold Bear Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/cold-bear-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Collective Arts Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/collective-arts-brewing-ltd/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Common Good Beer Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/common-good-beer-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Couchiching Craft Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/couchiching-craft-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Cowbell Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/cowbell-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Cured Craft Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/cured-craft-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Daft Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/daft-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Dog House Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/dog-house-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Dominion City Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/dominion-city-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Eastbound Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/eastbound-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Equals Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/equals-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Fairweather Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/fairweather-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Farm League Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/farm-league-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Fixed Gear Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/fixed-gear-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Flying Monkeys Craft Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/flying-monkeys-craft-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Focal Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/focal-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Foundry Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/foundry-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Four Fathers Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/four-fathers-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Frank Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/frank-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Freddy’s",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/freddys/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Full Beard Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/1068-2/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Furnace Room Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/furnace-room-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Gateway City Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/gateway-city-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Glasstown Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/glasstown-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Godspeed Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/godspeed-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Goldenfield Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/goldenfield-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Grand River Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/grand-river-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Granite Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/granite-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Great Lakes Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/great-lakes-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Haliburton Highlands Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/haliburton-highlands-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Imperial City Brew House",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/imperial-city-brew-house/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Indie Ale House",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/indie-ale-house/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Jobsite Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/jobsite-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Kichesippi Beer Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/kichesippi-beer-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Kick and Push Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/kick-and-push-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Lake of Bays Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/lake-of-bays-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Lake Of The Woods Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/lake-of-the-woods-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Left Field Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/left-field-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Lightcaster Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/lightcaster-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "MacKinnon Brothers Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/mackinnon-brothers-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Maclean’s Ales Inc.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/macleans-ales-inc/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Magnotta Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/magnotta-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Market Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/market-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Mascot Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/mascot-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Matron Fine Beer",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/matron-fine-beer/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Meyers Creek Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/meyers-creek-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Midtown Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/midtown-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Miski Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/miski-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Muddy York Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/muddy-york-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Muskoka Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/muskoka-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Natterjack Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/natterjack-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Newark Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/newark-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Niagara Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Niagara College Teaching Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-college-teaching-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Niagara Oast House Brewers",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/niagara-oast-house-brewers/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Nickel Brook Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/nickel-brook-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Northern Superior Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/northern-superior-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Old Credit Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/old-credit-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Old Flame Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/1239-2/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Orléans Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/orleans-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Overflow Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/overflow-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Parsons Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/parsons-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Perth Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/perth-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Prince Eddy’s Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/prince-eddys-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Quayle’s Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/quayles-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Quetico Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/quetico-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Railway City Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/railway-city-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Ramblin’ Road Brewery Farm",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/ramblin-road-brewery-farm/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Red Barn Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/red-barn-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Refined Fool Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/refined-fool-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Rouge River Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/rouge-river-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Royal City Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/royal-city-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Sassy Britches Brewing Co Ltd",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/sassy-britches-brewing-co-ltd/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Sawdust City Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/sawdust-city-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Shawn & Ed Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/shawn-ed-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Silversmith Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/silversmith-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Slake Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/slake-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Sleeping Giant Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/sleeping-giant-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Something in the Water Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/something-in-the-water-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Sonnen Hill Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/sonnen-hill-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Sons of Kent Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/sons-of-kent-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Spark Beer",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/spark-beer/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Split Rail Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/split-rail-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Stack Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/stack-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Steam Whistle Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/steam-whistle-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Steel Wheel Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/steel-wheel-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Stonehooker Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/stonehooker-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Stonepicker Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/stonepicker-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Stray Dog Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/stray-dog-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "The Exchange Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/the-exchange-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "The Grove Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/the-grove-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "The Second Wedge Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/the-second-wedge-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Thornbury Village Craft Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/thornbury-village-craft-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Three Sheets Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/three-sheets-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Tooth and Nail Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/tooth-and-nail-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Torched Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/torched-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Town Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/town-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Trestle Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/trestle-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "True History Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/true-history-brewing/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Upper Thames Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/upper-thames-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Vimy Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/vimy-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Walkerville Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/walkerville-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Wave Maker Craft Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/wave-maker-craft-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Wellington Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/wellington-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Whiprsnapr Brewing Co.",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/whiprsnapr-brewing-co/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Whiskeyjack Beer Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/whiskeyjack-beer-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Whitewater Brewing Company",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/whitewater-brewing-company/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Willibald Farm Distillery & Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/willibald-farm-distillery-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Windmill Brewery",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/windmill-brewery/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Wishbone Brewing",
|
||||||
|
"href": "https://ontariocraftbrewers.com/brewery-profile/wishbone-brewing/"
|
||||||
|
}
|
||||||
|
]
|
||||||
5
pipeline/.clang-format
Normal file
5
pipeline/.clang-format
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
BasedOnStyle: Google
|
||||||
|
ColumnLimit: 80
|
||||||
|
IndentWidth: 2
|
||||||
|
...
|
||||||
39
pipeline/.clang-tidy
Normal file
39
pipeline/.clang-tidy
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
Checks: >
|
||||||
|
-*,
|
||||||
|
bugprone-*,
|
||||||
|
google-*,
|
||||||
|
modernize-*,
|
||||||
|
readability-*,
|
||||||
|
cppcoreguidelines-*,
|
||||||
|
-modernize-use-trailing-return-type,
|
||||||
|
-google-runtime-references
|
||||||
|
|
||||||
|
CheckOptions:
|
||||||
|
# Enforce Google Naming Conventions with valid clang-tidy strings
|
||||||
|
- key: readability-identifier-naming.ClassCase
|
||||||
|
value: CamelCase
|
||||||
|
- key: readability-identifier-naming.ClassMemberCase
|
||||||
|
value: lower_case
|
||||||
|
- key: readability-identifier-naming.ClassMemberSuffix
|
||||||
|
value: _
|
||||||
|
- key: readability-identifier-naming.FunctionCase
|
||||||
|
value: CamelCase
|
||||||
|
- key: readability-identifier-naming.StructCase
|
||||||
|
value: CamelCase
|
||||||
|
- key: readability-identifier-naming.VariableCase
|
||||||
|
value: lower_case
|
||||||
|
- key: readability-identifier-naming.GlobalConstantCase
|
||||||
|
value: CamelCase
|
||||||
|
- key: readability-identifier-naming.GlobalConstantPrefix
|
||||||
|
value: k
|
||||||
|
|
||||||
|
# Ensure C++20 Modernization
|
||||||
|
- key: modernize-make-unique.MakeSmartPtrFunction
|
||||||
|
value: std::make_unique
|
||||||
|
- key: modernize-make-shared.MakeSmartPtrFunction
|
||||||
|
value: std::make_shared
|
||||||
|
- key: modernize-use-override.IgnoreDestructors
|
||||||
|
value: "false"
|
||||||
|
|
||||||
|
# Warnings as Errors to ensure compliance during build
|
||||||
|
WarningsAsErrors: "*"
|
||||||
8
pipeline/.gitignore
vendored
Normal file
8
pipeline/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
dist
|
||||||
|
build
|
||||||
|
build-*
|
||||||
|
cmake-build-*
|
||||||
|
data
|
||||||
|
models
|
||||||
|
*.gguf
|
||||||
|
BiergartenPipeline.png
|
||||||
148
pipeline/CMakeLists.txt
Normal file
148
pipeline/CMakeLists.txt
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.24)
|
||||||
|
project(biergarten-pipeline)
|
||||||
|
|
||||||
|
set(CMAKE_POLICY_VERSION_MINIMUM 3.5 CACHE STRING "" FORCE)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1. Platform & GPU Detection
|
||||||
|
# =============================================================================
|
||||||
|
if(WIN32)
|
||||||
|
message(FATAL_ERROR "[biergarten] Windows is currently not supported. Please use Linux (Fedora 43) or macOS (M1 Pro).")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64")
|
||||||
|
message(STATUS "[biergarten] Apple Silicon detected — enabling Metal acceleration.")
|
||||||
|
set(GGML_METAL ON CACHE BOOL "Enable Metal for Apple Silicon" FORCE)
|
||||||
|
else()
|
||||||
|
message(STATUS "[biergarten] Intel Mac detected — using CPU / Accelerate framework.")
|
||||||
|
set(GGML_METAL OFF CACHE BOOL "Disable Metal for Intel Macs" FORCE)
|
||||||
|
endif()
|
||||||
|
elseif(UNIX AND NOT APPLE)
|
||||||
|
find_package(CUDAToolkit QUIET)
|
||||||
|
find_package(HIP QUIET)
|
||||||
|
|
||||||
|
if(CUDAToolkit_FOUND)
|
||||||
|
message(STATUS "[biergarten] NVIDIA GPU detected — enabling CUDA acceleration.")
|
||||||
|
set(GGML_CUDA ON CACHE BOOL "Enable CUDA for NVIDIA GPUs" FORCE)
|
||||||
|
set(CMAKE_CUDA_ARCHITECTURES native)
|
||||||
|
elseif(HIP_FOUND OR EXISTS "/opt/rocm")
|
||||||
|
message(STATUS "[biergarten] AMD GPU detected — enabling HIP/ROCm acceleration.")
|
||||||
|
set(GGML_HIPBLAS ON CACHE BOOL "Enable HIP for AMD GPUs" FORCE)
|
||||||
|
else()
|
||||||
|
message(STATUS "[biergarten] No NVIDIA or AMD GPU found — falling back to CPU.")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2. Project-wide Settings (Standard & Optimization)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
|
|
||||||
|
# Release Build Optimization: Aggressive (-O3), Arch-specific, and LTO
|
||||||
|
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3 -march=native -flto")
|
||||||
|
|
||||||
|
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -Og -g")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 3. Dependencies
|
||||||
|
# =============================================================================
|
||||||
|
include(FetchContent)
|
||||||
|
|
||||||
|
find_package(CURL QUIET)
|
||||||
|
if(NOT CURL_FOUND)
|
||||||
|
message(FATAL_ERROR "[biergarten] libcurl not found. Install it (e.g. 'sudo dnf install libcurl-devel').")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Require system Boost for JSON and Program Options to speed up build times
|
||||||
|
find_package(Boost REQUIRED COMPONENTS json program_options)
|
||||||
|
|
||||||
|
FetchContent_Declare(
|
||||||
|
llama-cpp
|
||||||
|
GIT_REPOSITORY https://github.com/ggml-org/llama.cpp.git
|
||||||
|
GIT_TAG b8742
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(llama-cpp)
|
||||||
|
|
||||||
|
FetchContent_Declare(
|
||||||
|
boost-di
|
||||||
|
GIT_REPOSITORY https://github.com/boost-ext/di.git
|
||||||
|
GIT_TAG v1.3.0
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(boost-di)
|
||||||
|
if(TARGET Boost.DI AND NOT TARGET boost::di)
|
||||||
|
add_library(boost::di ALIAS Boost.DI)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
FetchContent_Declare(
|
||||||
|
spdlog
|
||||||
|
GIT_REPOSITORY https://github.com/gabime/spdlog.git
|
||||||
|
GIT_TAG v1.15.3
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(spdlog)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 4. Sources
|
||||||
|
# =============================================================================
|
||||||
|
set(SOURCES
|
||||||
|
src/main.cc
|
||||||
|
src/biergarten_data_generator/biergarten_data_generator.cc
|
||||||
|
src/biergarten_data_generator/run.cc
|
||||||
|
src/biergarten_data_generator/query_cities_with_countries.cc
|
||||||
|
src/biergarten_data_generator/generate_breweries.cc
|
||||||
|
src/biergarten_data_generator/log_results.cc
|
||||||
|
src/services/wikipedia/wikipedia_service.cc
|
||||||
|
src/services/wikipedia/get_summary.cc
|
||||||
|
src/services/wikipedia/fetch_extract.cc
|
||||||
|
src/web_client/curl_global_state.cc
|
||||||
|
src/web_client/curl_web_client_get.cc
|
||||||
|
src/web_client/curl_web_client_url_encode.cc
|
||||||
|
src/data_generation/llama/llama_generator.cc
|
||||||
|
src/data_generation/llama/generate_brewery.cc
|
||||||
|
src/data_generation/llama/generate_user.cc
|
||||||
|
src/data_generation/llama/helpers.cc
|
||||||
|
src/data_generation/llama/infer.cc
|
||||||
|
src/data_generation/llama/load.cc
|
||||||
|
src/data_generation/llama/load_brewery_prompt.cc
|
||||||
|
src/data_generation/prompt_formatting/gemma4_jinja_prompt_formatter.cc
|
||||||
|
src/data_generation/mock/deterministic_hash.cc
|
||||||
|
src/data_generation/mock/generate_brewery.cc
|
||||||
|
src/data_generation/mock/generate_user.cc
|
||||||
|
src/json_handling/json_loader.cc
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 5. Target
|
||||||
|
# =============================================================================
|
||||||
|
add_executable(${PROJECT_NAME} ${SOURCES})
|
||||||
|
target_include_directories(${PROJECT_NAME} PRIVATE
|
||||||
|
includes
|
||||||
|
${llama-cpp_SOURCE_DIR}/include
|
||||||
|
${llama-cpp_SOURCE_DIR}/common
|
||||||
|
)
|
||||||
|
target_link_libraries(${PROJECT_NAME} PRIVATE
|
||||||
|
llama
|
||||||
|
boost::di
|
||||||
|
Boost::json
|
||||||
|
Boost::program_options
|
||||||
|
spdlog::spdlog
|
||||||
|
CURL::libcurl
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 6. Runtime Assets
|
||||||
|
# =============================================================================
|
||||||
|
configure_file(
|
||||||
|
${CMAKE_SOURCE_DIR}/locations.json
|
||||||
|
${CMAKE_BINARY_DIR}/locations.json
|
||||||
|
COPYONLY
|
||||||
|
)
|
||||||
|
|
||||||
|
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||||
|
${CMAKE_SOURCE_DIR}/prompts
|
||||||
|
${CMAKE_BINARY_DIR}/prompts
|
||||||
|
)
|
||||||
343
pipeline/README.md
Normal file
343
pipeline/README.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# Biergarten Pipeline
|
||||||
|
|
||||||
|
A C++20 command-line pipeline that samples city records from local JSON, enriches each with Wikipedia context, and generates bilingual brewery names and descriptions via a local GGUF model or a deterministic mock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [How It Fits the Main App](#how-it-fits-the-main-app)
|
||||||
|
- [Tech Stack](#tech-stack)
|
||||||
|
- [Build](#build)
|
||||||
|
- [Model](#model)
|
||||||
|
- [Run](#run)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Pipeline Stages](#pipeline-stages)
|
||||||
|
- [Key Components](#key-components)
|
||||||
|
- [Runtime Behaviour](#runtime-behaviour)
|
||||||
|
- [Generated Output](#generated-output)
|
||||||
|
- [Language Generation Quality](#language-generation-quality)
|
||||||
|
- [Known Issues](#known-issues)
|
||||||
|
- [Tested Hardware](#tested-hardware)
|
||||||
|
- [Repo Layout](#repo-layout)
|
||||||
|
- [Code Tour](#code-tour)
|
||||||
|
- [Fixture Strategy](#fixture-strategy)
|
||||||
|
- [Next Steps](#next-steps)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Fits the Main App
|
||||||
|
|
||||||
|
The pipeline is a data ingestion layer. It sits outside the web app runtime and produces seed records the app imports at startup or during a dedicated seed step.
|
||||||
|
|
||||||
|
| Planned app area | Pipeline contribution |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| Brewery discovery and management | Sampled city records, localized names, long-form descriptions |
|
||||||
|
| Beer reviews and ratings | Stable brewery fixtures with enough context to anchor review pages |
|
||||||
|
| Social follow relationships | Repeatable brewery entities for feeds, follows, and saved lists |
|
||||||
|
| Geospatial brewery experiences | Latitude, longitude, and country-level metadata |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- C++20
|
||||||
|
- CMake 3.24+
|
||||||
|
- Boost.JSON, Boost.ProgramOptions, Boost.DI
|
||||||
|
- spdlog
|
||||||
|
- libcurl
|
||||||
|
- llama.cpp
|
||||||
|
|
||||||
|
The build fetches Boost.DI, spdlog, and llama.cpp via CMake. Metal is enabled on Apple Silicon; CUDA or HIP/ROCm is detected on Linux when the toolkit is present.
|
||||||
|
|
||||||
|
> **Code Style:** Modern C++20 throughout — RAII for ownership, `std::unique_ptr` for injected dependencies, `std::optional` for parse outcomes, `std::span` for read-only views over generated city data, structured bindings in pipeline loops. Formatting follows the Google C++ Style Guide via `.clang-format` with a narrow column limit and two-space indentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requirements: C++20 compiler, CMake 3.24+, libcurl, Boost (JSON and ProgramOptions).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
> Skip this step if you only need `--mocked`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p models
|
||||||
|
curl -L \
|
||||||
|
-o models/google_gemma-4-E4B-it-Q6_K.gguf \
|
||||||
|
https://huggingface.co/bartowski/google_gemma-4-E4B-it-GGUF/resolve/main/google_gemma-4-E4B-it-Q6_K.gguf?download=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Run from `build/` so the copied `locations.json` and `prompts/` are available.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./biergarten-pipeline --mocked
|
||||||
|
./biergarten-pipeline --model models/google_gemma-4-E4B-it-Q6_K.gguf --temperature 1.0 --top-p 0.95 --top-k 64 --n-ctx 8192 --seed -1
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Flags
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
| --------------- | ------------------------------------------------------- |
|
||||||
|
| `--mocked` | Deterministic mock generator, no model required. |
|
||||||
|
| `--model, -m` | Path to a GGUF file. Required unless `--mocked` is set. |
|
||||||
|
| `--temperature` | Sampling temperature. Default: `1.0`. |
|
||||||
|
| `--top-p` | Nucleus sampling. Default: `0.95`. |
|
||||||
|
| `--top-k` | Top-k sampling. Default: `64`. |
|
||||||
|
| `--n-ctx` | Context window size. Default: `8192`. |
|
||||||
|
| `--seed` | Random seed. Default: `-1` (random at runtime). |
|
||||||
|
| `--help, -h` | Print usage and exit. |
|
||||||
|
|
||||||
|
`--mocked` and `--model` are mutually exclusive. Omitting both exits with an error before the pipeline starts. Sampling flags are ignored when `--mocked` is set.
|
||||||
|
|
||||||
|
The post-build step copies `prompts/` into `build/prompts/`. Rebuild after editing [prompts/system.md](prompts/system.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Pipeline Stages
|
||||||
|
|
||||||
|
| Stage | Implementation |
|
||||||
|
| -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Load | `JsonLoader::LoadLocations()` reads `locations.json` into typed `Location` records. |
|
||||||
|
| Sample | `BiergartenDataGenerator::QueryCitiesWithCountries()` samples up to 50 locations per run. |
|
||||||
|
| Enrich | `WikipediaService` fetches city and beer context. Keeps going when a lookup fails. |
|
||||||
|
| Generate | `MockGenerator` or `LlamaGenerator` produces brewery names and descriptions in English and the local language. |
|
||||||
|
| Log | `spdlog` writes results and warnings to the console. |
|
||||||
|
|
||||||
|
If enrichment or generation fails for a city, that city is skipped and the pipeline continues.
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
- `src/main.cc` — argument parsing and Boost.DI composition root.
|
||||||
|
- `JsonLoader` — validates curated location input.
|
||||||
|
- `WikipediaService` — queries English and local-language extracts, caches results, returns empty context on failure.
|
||||||
|
- `LlamaGenerator` — formats prompts for Gemma 4, validates JSON output, retries malformed responses up to three times. If output looks truncated, the retry raises the token budget before trying again.
|
||||||
|
- `MockGenerator` — stable hash-based output so the same city input always produces the same brewery.
|
||||||
|
- Brewery payloads include English and local-language name and description fields.
|
||||||
|
|
||||||
|
### Runtime Behaviour
|
||||||
|
|
||||||
|
`WikipediaService` queries city, country, and beer-related Wikipedia extracts in both English and the local language, then caches the first successful response per query string. Both extracts are passed into the prompt so the model can draw on local-language sources without a separate translation step.
|
||||||
|
|
||||||
|
`GetLocationContext()` returns an empty string when the web client is unavailable or when lookup/parsing fails.
|
||||||
|
|
||||||
|
`LlamaGenerator` validates model output as structured JSON. The retry path exists as a safety hatch for cases where the reasoning block consumes available token budget and compresses the JSON output space. All runs to date have produced valid output on the first pass; the path is kept for resilience.
|
||||||
|
|
||||||
|
`MockGenerator` uses stable hashes for repeatable output in demos and Storybook runs.
|
||||||
|
|
||||||
|
### Process Flow — Activity Diagram
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Architectural Overview — Class Diagram
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generated Output
|
||||||
|
|
||||||
|
Each successful run stores a `GeneratedBrewery` pair with the source location and a `BreweryResult` payload.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ------------------- | ------------------------------------------ |
|
||||||
|
| `name_en` | Brewery name in English. |
|
||||||
|
| `description_en` | Brewery description in English. |
|
||||||
|
| `name_local` | Brewery name in the local language. |
|
||||||
|
| `description_local` | Brewery description in the local language. |
|
||||||
|
|
||||||
|
The log dump also includes city, country, state or province, ISO subdivision code, latitude, and longitude for each entry.
|
||||||
|
|
||||||
|
### Consumer Data Shape
|
||||||
|
|
||||||
|
| Field | Why it matters |
|
||||||
|
| ----------------------------------- | ------------------------------------------------ |
|
||||||
|
| `city`, `state_province`, `country` | Human-readable location labels and page headings |
|
||||||
|
| `iso3166_1`, `iso3166_2` | Filtering, regional grouping, locale matching |
|
||||||
|
| `latitude`, `longitude` | Map pins and nearby brewery views |
|
||||||
|
| `local_languages` | Locale-aware copy selection |
|
||||||
|
| `name_en`, `description_en` | Default English display content |
|
||||||
|
| `name_local`, `description_local` | Local-language display content |
|
||||||
|
| `region_context` | Richer copy for cards and detail pages |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Language Generation Quality
|
||||||
|
|
||||||
|
The generation pipeline passes local language codes to the model to retrieve a translated `description_local`.
|
||||||
|
|
||||||
|
Output quality is reliable for high-resource languages such as French, though it may struggle with regional variants and idiomatic phrasing. This can be seen with these data points:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"city": "Kinshasa",
|
||||||
|
"state_province": "Kinshasa",
|
||||||
|
"iso3166_2": "CD-KN",
|
||||||
|
"country": "Democratic Republic of the Congo",
|
||||||
|
"iso3166_1": "CD",
|
||||||
|
"latitude": -4.4419,
|
||||||
|
"longitude": 15.2663,
|
||||||
|
"local_languages": ["fr-CD", "ln"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"city": "Paris",
|
||||||
|
"state_province": "Île-de-France",
|
||||||
|
"iso3166_2": "FR-IDF",
|
||||||
|
"country": "France",
|
||||||
|
"iso3166_1": "FR",
|
||||||
|
"latitude": 48.8566,
|
||||||
|
"longitude": 2.3522,
|
||||||
|
"local_languages": ["fr-FR"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"city": "Abidjan",
|
||||||
|
"state_province": "Abidjan",
|
||||||
|
"iso3166_2": "CI-AB",
|
||||||
|
"country": "Ivory Coast",
|
||||||
|
"iso3166_1": "CI",
|
||||||
|
"latitude": 5.36,
|
||||||
|
"longitude": -4.0083,
|
||||||
|
"local_languages": ["fr-CI"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"city": "Montreal",
|
||||||
|
"state_province": "Quebec",
|
||||||
|
"iso3166_2": "CA-QC",
|
||||||
|
"country": "Canada",
|
||||||
|
"iso3166_1": "CA",
|
||||||
|
"latitude": 45.5017,
|
||||||
|
"longitude": -73.5673,
|
||||||
|
"local_languages": ["fr-CA"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"city": "Brussels",
|
||||||
|
"state_province": "Brussels-Capital Region",
|
||||||
|
"iso3166_2": "BE-BRU",
|
||||||
|
"country": "Belgium",
|
||||||
|
"iso3166_1": "BE",
|
||||||
|
"latitude": 50.8503,
|
||||||
|
"longitude": 4.3517,
|
||||||
|
"local_languages": ["fr-BE", "nl-BE"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Output sample: [./out-sample/french-cities.log.example](out-sample/french-cities.log.example)
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
|
||||||
|
#### Low-Resource Language Hallucination
|
||||||
|
|
||||||
|
For languages such as Welsh (Wales), Maori (Aotearoa/New Zealand), or Sicilian (Sicily, Italy), the model can generate text that looks syntactically plausible but is semantically incoherent. This comes from limited training-data coverage rather than prompt engineering.
|
||||||
|
|
||||||
|
#### Proposed Mitigations
|
||||||
|
|
||||||
|
- **Prevention via allowlist:** introduce a high-resource language allowlist. If a location's code is unlisted, skip `description_local` generation and fall back to English.
|
||||||
|
- **Upstream sanitization:** strip known low-resource language codes from the `locations.json` payload before generation.
|
||||||
|
- **Downstream flagging:** add a `description_local_confidence` column to the SQLite schema so downstream applications can filter or flag potentially hallucinated text by language tier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tested Hardware
|
||||||
|
|
||||||
|
### ARM macOS — M1 Pro
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --------- | --------------------------------- |
|
||||||
|
| Host | MacBook Pro 14" (2021) |
|
||||||
|
| CPU | Apple M1 Pro (8-core) |
|
||||||
|
| GPU | Apple M1 Pro (14-core integrated) |
|
||||||
|
| Memory | 16 GB |
|
||||||
|
| Model | Gemma 4 E4B |
|
||||||
|
| Inference | llama.cpp with Metal |
|
||||||
|
|
||||||
|
### x86_64 Linux — NVIDIA RTX 2000
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --------- | ------------------------------ |
|
||||||
|
| Host | ThinkPad P1 Gen 7 (Fedora 43) |
|
||||||
|
| CPU | Intel Core Ultra 7 155H |
|
||||||
|
| GPU | NVIDIA RTX 2000 Ada Generation |
|
||||||
|
| Memory | 32 GB |
|
||||||
|
| Model | Gemma 4 E4B |
|
||||||
|
| Inference | llama.cpp with CUDA 12.x |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repo Layout
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| ---------------- | ---------------------------------------------- |
|
||||||
|
| `includes/` | Public headers and shared models. |
|
||||||
|
| `src/` | Implementation files. |
|
||||||
|
| `locations.json` | Curated city input copied into the build tree. |
|
||||||
|
| `prompts/` | System prompt used by the model-backed path. |
|
||||||
|
| `diagrams/` | Architecture and pipeline diagrams. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Tour
|
||||||
|
|
||||||
|
- `src/main.cc` — argument parsing and DI composition root.
|
||||||
|
- `src/biergarten_data_generator/` — orchestration, sampling, logging.
|
||||||
|
- `src/services/wikipedia/` — enrichment service and cache.
|
||||||
|
- `src/data_generation/llama/` — local inference, prompt loading, output validation.
|
||||||
|
- `src/data_generation/mock/` — deterministic fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixture Strategy
|
||||||
|
|
||||||
|
- `--mocked` for stable fixtures, repeatable screenshots, and Storybook runs.
|
||||||
|
- `--model` when geographically grounded content matters for demos.
|
||||||
|
- Keep `locations.json` structured enough to support discovery and future filtering.
|
||||||
|
- Treat SQLite output as seed material for the app's brewery domain, not production data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
The pipeline currently produces city-aware brewery records. The next passes add SQLite output and additional fixture types so the app can exercise the full brewery domain without live data.
|
||||||
|
|
||||||
|
### SQLite Output _(Highest Importance)_
|
||||||
|
|
||||||
|
Write generated records to a SQLite database for downstream OLTP seeding. Normalized schema with foreign keys between locations and breweries. Output replaces the current log-only result so the pipeline functions as a proper ingestion layer.
|
||||||
|
|
||||||
|
### Testing _(Very High Importance)_
|
||||||
|
|
||||||
|
- Unit test JSON validation and retry logic against malformed, truncated, and empty model outputs.
|
||||||
|
- Integration test the enrichment pipeline with missing context, short context, and fake context inputs.
|
||||||
|
- Adversarial context tests: feed plausible but geographically incorrect Wikipedia extracts and verify the model does not silently blend them with training data.
|
||||||
|
- Verify bilingual enrichment behaviour when only an English extract is available versus when both extracts are present.
|
||||||
|
- Confirm the retry path is reachable when the reasoning block consumes available token budget.
|
||||||
|
|
||||||
|
### Beer Generation
|
||||||
|
|
||||||
|
Generate catalog entries with style, ABV, IBU, color, aroma notes, and food pairing hints. Link beers back to breweries and cities. Keep style coverage wide enough to exercise search, sort, and category filters.
|
||||||
|
|
||||||
|
### User Generation
|
||||||
|
|
||||||
|
Generate user profiles with stable names, bios, locale hints, and preference signals. Include stable IDs for downstream fixture joins. Keep output deterministic for screenshots while allowing larger randomized batches.
|
||||||
|
|
||||||
|
### Check-In System
|
||||||
|
|
||||||
|
Produce timestamped check-in events between users and breweries. Use a J-curve activity profile — a small set of users accounts for most check-ins, the rest appear occasionally. Add bursty behaviour around weekends and travel periods.
|
||||||
|
|
||||||
|
### Beer Ratings
|
||||||
|
|
||||||
|
Generate rating events with a strong positive skew and a long tail of lower scores. Avoid uniform distributions. Attach timestamps and user IDs so the app can compute averages, trends, and per-style comparisons.
|
||||||
902
pipeline/beer-styles.json
Normal file
902
pipeline/beer-styles.json
Normal file
@@ -0,0 +1,902 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Gose",
|
||||||
|
"description": "A historic warm-fermented beer originating from Goslar, Germany. It is brewed with at least 50% malted wheat and characterized by the addition of coriander and salt, resulting in a crisp, sour, salty, and herbal flavor profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Gose",
|
||||||
|
"min_abv": 4.2,
|
||||||
|
"max_abv": 4.8,
|
||||||
|
"min_ibu": 5,
|
||||||
|
"max_ibu": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rauchbier",
|
||||||
|
"description": "A traditional German style originating in Bamberg, Franconia. The malt is dried over an open beechwood fire, imparting a distinctive, intense smoky flavor that balances with a rich, malty lager base.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Smoked_beer",
|
||||||
|
"min_abv": 4.8,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lambic",
|
||||||
|
"description": "A uniquely Belgian beer originating in the Senne river valley near Brussels. Instead of carefully cultivated brewer's yeast, it is fermented spontaneously by wild yeasts and bacteria native to the region, creating a dry, cidery, and profoundly sour profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Lambic",
|
||||||
|
"min_abv": 5.0,
|
||||||
|
"max_abv": 6.5,
|
||||||
|
"min_ibu": 0,
|
||||||
|
"max_ibu": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sahti",
|
||||||
|
"description": "An ancient Finnish farmhouse ale brewed with a variety of grains (often including rye) and filtered through juniper twigs instead of relying heavily on hops for bittering. It is historically fermented with baker's yeast, yielding strong banana and clove esters.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Sahti",
|
||||||
|
"min_abv": 7.0,
|
||||||
|
"max_abv": 8.5,
|
||||||
|
"min_ibu": 0,
|
||||||
|
"max_ibu": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kvass",
|
||||||
|
"description": "A traditional Slavic and Baltic fermented beverage commonly made from rye bread. It is typically extremely low in alcohol and features a sweet, bready, slightly tart flavor, often flavored with fruits or herbs like mint.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Kvass",
|
||||||
|
"min_abv": 0.5,
|
||||||
|
"max_abv": 2.0,
|
||||||
|
"min_ibu": 0,
|
||||||
|
"max_ibu": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Berliner Weisse",
|
||||||
|
"description": "A cloudy, sour, white beer originating in Berlin. Fermented with a mixture of yeast and lactic acid bacteria, it is sharply tart and highly carbonated. Historically, it is often served with a dash of raspberry or woodruff syrup to cut the acidity.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Berliner_Weisse",
|
||||||
|
"min_abv": 2.8,
|
||||||
|
"max_abv": 3.8,
|
||||||
|
"min_ibu": 3,
|
||||||
|
"max_ibu": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Eisbock",
|
||||||
|
"description": "A specialty German beer created by partially freezing a doppelbock and removing the water ice. This freeze-distillation process concentrates the flavor, malt richness, and alcohol content, creating a heavy, syrupy, and warming brew.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bock#Eisbock",
|
||||||
|
"min_abv": 9.0,
|
||||||
|
"max_abv": 14.0,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Altbier",
|
||||||
|
"description": "A German style originating in Düsseldorf that straddles the line between ale and lager. It is top-fermented at moderate temperatures but then cold-conditioned (lagered), resulting in a clean, crisp beer with a firm, balanced maltiness and notable hop bitterness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Altbier",
|
||||||
|
"min_abv": 4.3,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kölsch",
|
||||||
|
"description": "A light, brilliantly clear, top-fermented beer strictly associated with Cologne, Germany. Like Altbier, it is warm-fermented and cold-conditioned, yielding a delicate, soft, and slightly fruity pale beer with a dry, crisp finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/K%C3%B6lsch_(beer)",
|
||||||
|
"min_abv": 4.4,
|
||||||
|
"max_abv": 5.2,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Oud Bruin",
|
||||||
|
"description": "A Flanders Brown Ale characterized by a long aging process—often up to a year—in stainless steel rather than oak. It undergoes a secondary fermentation with lactic acid bacteria, resulting in a dark, malty, dark-fruit-forward profile with a mild to moderate sourness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Oud_bruin",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Saison",
|
||||||
|
"description": "A pale ale originally brewed in the Wallonia region of Belgium for farm workers during the harvest season. Highly carbonated, fruity, spicy, and often dry, it frequently employs distinctive yeast strains and sometimes wild bacteria or spices.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Saison",
|
||||||
|
"min_abv": 5.0,
|
||||||
|
"max_abv": 7.0,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Roggenbier",
|
||||||
|
"description": "A historical German beer brewed with up to 50% rye malt. It shares the yeast strains used in Bavarian Hefeweizen, offering banana and clove notes, but the rye provides a distinctly earthy, spicy character and a dense, viscous mouthfeel.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Roggenbier",
|
||||||
|
"min_abv": 4.5,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schwarzbier",
|
||||||
|
"description": "Germany's 'black beer' is a dark lager that balances roasted malt flavors with moderate hop bitterness. Unlike a stout or porter, it uses debittered roasted malts to achieve a very smooth, clean, and crisp dark beer without heavy astringency.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Schwarzbier",
|
||||||
|
"min_abv": 4.4,
|
||||||
|
"max_abv": 5.4,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mild Ale",
|
||||||
|
"description": "A historic British style originally meaning young or unaged beer, it evolved into a low-gravity, malt-focused session ale. Usually dark brown, it features notes of caramel, chocolate, and mild roast, with very low hop presence.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Mild_ale",
|
||||||
|
"min_abv": 3.0,
|
||||||
|
"max_abv": 3.8,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Baltic Porter",
|
||||||
|
"description": "Originating in countries bordering the Baltic Sea, this style adapted the strong, sweet British export porters to local ingredients and cold bottom-fermenting lager yeasts. It is dark, robust, and complex with rich dark fruit and molasses notes.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Porter_(beer)#Baltic_porter",
|
||||||
|
"min_abv": 6.5,
|
||||||
|
"max_abv": 9.5,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "California Common",
|
||||||
|
"description": "Also known as Steam Beer, this uniquely American style was born out of necessity during the Gold Rush. It is brewed with a special strain of lager yeast that ferments optimally at warmer, ale-like temperatures, resulting in a rustic, woody, and minty flavor profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Steam_beer",
|
||||||
|
"min_abv": 4.5,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 30,
|
||||||
|
"max_ibu": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kellerbier",
|
||||||
|
"description": "An unfiltered, unpasteurized German lager that is traditionally served directly from the lagering vessel ('Keller' means cellar). Because it retains its yeast, it is cloudy, naturally carbonated, and features a soft, bready, and highly aromatic profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Kellerbier",
|
||||||
|
"min_abv": 4.7,
|
||||||
|
"max_abv": 5.4,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Faro",
|
||||||
|
"description": "A traditional, low-alcohol sweet beer from Belgium made by blending lambic with a much lighter, freshly brewed beer (or water) and adding brown sugar or candi sugar. The sugar provides sweetness to balance the lambic's tartness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Faro_(beer)",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 0,
|
||||||
|
"max_ibu": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grodziskie",
|
||||||
|
"description": "A highly carbonated, low-alcohol Polish beer nicknamed 'Polish Champagne.' It is brewed entirely from oak-smoked wheat malt, resulting in a pale, effervescent, brilliantly clear beer that combines crisp wheat tartness with a distinct smoky aroma.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Grodziskie",
|
||||||
|
"min_abv": 2.5,
|
||||||
|
"max_abv": 3.3,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lichtenhainer",
|
||||||
|
"description": "A nearly extinct historical German style originating from Thuringia. It is a lightly sour, smoked wheat beer. Think of it as a cross between a Berliner Weisse and a Rauchbier—refreshingly tart with a gentle wood-smoke character.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Smoked_beer",
|
||||||
|
"min_abv": 3.5,
|
||||||
|
"max_abv": 4.7,
|
||||||
|
"min_ibu": 5,
|
||||||
|
"max_ibu": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Irish Dry Stout",
|
||||||
|
"description": "A very dark, roasty, bitter, creamy ale that gained global fame through breweries in Dublin. It relies heavily on roasted barley for its espresso-like bite and bone-dry finish, often served via a nitrogen draught system for a dense, pillowy head.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Stout#Dry_stout",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 5.0,
|
||||||
|
"min_ibu": 30,
|
||||||
|
"max_ibu": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "English Barleywine",
|
||||||
|
"description": "A showcase of malty richness and complex, intense flavors. This strong ale boasts a deep caramel to dark amber color with massive notes of dark fruit, toffee, and molasses, meant to be sipped and often aged for years like wine.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Barley_wine",
|
||||||
|
"min_abv": 8.0,
|
||||||
|
"max_abv": 12.0,
|
||||||
|
"min_ibu": 35,
|
||||||
|
"max_ibu": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian Tripel",
|
||||||
|
"description": "A remarkably pale, strong, and highly carbonated Belgian ale forged by Trappist monks. Despite its high alcohol content, it hides its strength well behind a complex profile of spicy yeast phenols, fruity esters, and a surprisingly dry finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Tripel",
|
||||||
|
"min_abv": 7.5,
|
||||||
|
"max_abv": 9.5,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Doppelbock",
|
||||||
|
"description": "A stronger and maltier version of a traditional German bock, originally brewed by monks in Munich as 'liquid bread' for sustenance during fasting. It is exceptionally rich, dark, and heavy with flavors of toasted bread and dark fruit.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bock#Doppelbock",
|
||||||
|
"min_abv": 7.0,
|
||||||
|
"max_abv": 10.0,
|
||||||
|
"min_ibu": 16,
|
||||||
|
"max_ibu": 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wee Heavy",
|
||||||
|
"description": "Also known as Strong Scotch Ale, this malty, copper-to-brown beer undergoes a long boil that caramelizes the wort, producing deep, sweet flavors of plum, toffee, and roasted nuts, historically fermented at cooler temperatures for a clean profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Scotch_ale",
|
||||||
|
"min_abv": 6.5,
|
||||||
|
"max_abv": 10.0,
|
||||||
|
"min_ibu": 17,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "New England IPA",
|
||||||
|
"description": "An American IPA featuring intense, tropical fruit-centric hop aroma and flavor with heavily reduced bitterness. It is deliberately hazy or opaque—often resembling fruit juice—and has a soft, pillowy mouthfeel achieved through oats and wheat.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/New_England_IPA",
|
||||||
|
"min_abv": 6.0,
|
||||||
|
"max_abv": 9.0,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Flanders Red Ale",
|
||||||
|
"description": "Often referred to as the 'Burgundy of Belgium,' this complex sour ale is aged for up to two years in massive oak vats. The result is an intensely fruity, wine-like beer with sharp acetic sourness balanced by notes of black cherry, plum, and red currant.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Flanders_red_ale",
|
||||||
|
"min_abv": 4.6,
|
||||||
|
"max_abv": 6.5,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Witbier",
|
||||||
|
"description": "A 400-year-old Belgian beer style that was revived from near extinction. It is a pale, hazy, unfiltered wheat beer spiced gracefully with crushed coriander seed and bitter orange peel, resulting in a lively, zesty, and highly refreshing profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Witbier",
|
||||||
|
"min_abv": 4.5,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Imperial Stout",
|
||||||
|
"description": "An intensely-flavored, big, dark ale with a wide range of flavor balances and regional interpretations. Originally brewed in England for export to the Russian imperial court, it features massive roasted malt character, dark fruit notes, and a warming alcohol presence.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Stout#Imperial_stout",
|
||||||
|
"min_abv": 8.0,
|
||||||
|
"max_abv": 12.0,
|
||||||
|
"min_ibu": 50,
|
||||||
|
"max_ibu": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hefeweizen",
|
||||||
|
"description": "A traditional, unfiltered Bavarian wheat beer featuring a uniquely expressive yeast strain. The yeast provides its signature flavors of clove and banana, while the high wheat content creates a fluffy, long-lasting head and a bready, refreshing body.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Wheat_beer#Hefeweizen",
|
||||||
|
"min_abv": 4.3,
|
||||||
|
"max_abv": 5.6,
|
||||||
|
"min_ibu": 8,
|
||||||
|
"max_ibu": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Pale Ale",
|
||||||
|
"description": "An American adaptation of the English pale ale, revolutionized by the use of indigenous ingredients. It is defined by the bold, piney, and citrus-forward aromas of American hops (like Cascade) riding on a clean, supportive malt backbone.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/American_pale_ale",
|
||||||
|
"min_abv": 4.5,
|
||||||
|
"max_abv": 6.2,
|
||||||
|
"min_ibu": 30,
|
||||||
|
"max_ibu": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bière de Garde",
|
||||||
|
"description": "A sturdy artisanal farmhouse ale from Northern France traditionally brewed in early spring and kept in cold cellars for consumption in warmer months. It is characterized by a toasted malt sweetness, earthy yeast character, and a dry finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bi%C3%A8re_de_Garde",
|
||||||
|
"min_abv": 6.0,
|
||||||
|
"max_abv": 8.5,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vienna Lager",
|
||||||
|
"description": "Developed in 1841 in Austria, this elegant amber lager relies on Vienna malt to provide a soft, complex, and lightly toasted malt profile. It maintains a crisp, clean lager finish with just enough noble hop bitterness to balance the malt sweetness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Vienna_lager",
|
||||||
|
"min_abv": 4.7,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gueuze",
|
||||||
|
"description": "A complex, tart Belgian beer created by blending one-, two-, and three-year-old lambics. The young lambic provides fermentable sugars for secondary bottle fermentation, creating a highly carbonated, bone-dry, deeply sour beer with a distinct 'barnyard' funk.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Gueuze",
|
||||||
|
"min_abv": 5.0,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 0,
|
||||||
|
"max_ibu": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dunkelweizen",
|
||||||
|
"description": "A dark, Bavarian wheat beer that marries the spicy, fruity yeast character of a Hefeweizen with the rich, bready, and caramel-driven malt profile of a Munich Dunkel. The result is a highly aromatic, dark but refreshing ale.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Wheat_beer#Dark_wheat_beer",
|
||||||
|
"min_abv": 4.3,
|
||||||
|
"max_abv": 5.6,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Maibock",
|
||||||
|
"description": "Also known as a Helles Bock, this strong, pale Bavarian lager is traditionally brewed for spring festivals. It is paler and more hop-forward than a traditional bock, delivering a warming alcoholic strength wrapped in a crisp, bready malt body.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bock#Maibock",
|
||||||
|
"min_abv": 6.3,
|
||||||
|
"max_abv": 7.4,
|
||||||
|
"min_ibu": 23,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Extra Special Bitter",
|
||||||
|
"description": "The strongest and maltiest of the traditional English Bitter family. An ESB features an aggressive balance of earthy, floral English hops and a rich, biscuit-like malt backbone, traditionally served via cask conditioning at cellar temperatures.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bitter_(beer)#Extra_Special_Bitter",
|
||||||
|
"min_abv": 4.6,
|
||||||
|
"max_abv": 6.2,
|
||||||
|
"min_ibu": 30,
|
||||||
|
"max_ibu": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cream Ale",
|
||||||
|
"description": "A clean, well-attenuated, and highly carbonated American 'lawnmower' beer. It is brewed with ale yeast but sometimes cold-conditioned or blended with lager, using corn adjuncts to lighten the body and create an incredibly crisp, refreshing finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Cream_ale",
|
||||||
|
"min_abv": 4.2,
|
||||||
|
"max_abv": 5.6,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Irish Red Ale",
|
||||||
|
"description": "An approachable, malt-focused Irish ale characterized by an amber-to-red color. It features mild caramel sweetness, very low hop bitterness, and a signature dry, slightly roasted finish courtesy of a small addition of roasted barley.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Irish_red_ale",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Munich Helles",
|
||||||
|
"description": "Created in Munich in 1894 to compete with the rising popularity of Czech Pilsners. It is a clean, malty, gold-colored lager that showcases a soft, bready malt sweetness with just enough spicy German hops to provide a balanced finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Helles",
|
||||||
|
"min_abv": 4.7,
|
||||||
|
"max_abv": 5.4,
|
||||||
|
"min_ibu": 16,
|
||||||
|
"max_ibu": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American IPA",
|
||||||
|
"description": "A decidedly hoppy and bitter, moderately strong American pale ale. It showcases modern American or New World hop varieties with intense fruit, citrus, pine, or floral aromatics.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/India_pale_ale#American_IPA",
|
||||||
|
"min_abv": 5.5,
|
||||||
|
"max_abv": 7.5,
|
||||||
|
"min_ibu": 40,
|
||||||
|
"max_ibu": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "English IPA",
|
||||||
|
"description": "A hoppy, moderately strong English pale ale that features the earthy, floral, and spicy characteristics of traditional English hops, supported by a solid biscuit or caramel malt backbone.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/India_pale_ale#England",
|
||||||
|
"min_abv": 5.0,
|
||||||
|
"max_abv": 7.5,
|
||||||
|
"min_ibu": 40,
|
||||||
|
"max_ibu": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Double IPA",
|
||||||
|
"description": "An intensely hoppy, fairly strong pale ale designed to showcase hop character without being overly harsh. It features a massive hop profile supported by a clean alcohol warmth and enough malt to prevent it from feeling thin.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Double_India_Pale_Ale",
|
||||||
|
"min_abv": 7.5,
|
||||||
|
"max_abv": 10.0,
|
||||||
|
"min_ibu": 60,
|
||||||
|
"max_ibu": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Session IPA",
|
||||||
|
"description": "A highly hop-forward ale that delivers the aroma and flavor intensity of an IPA but with a much lower alcohol content, making it highly drinkable over an extended session.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/India_pale_ale#Session_IPA",
|
||||||
|
"min_abv": 3.7,
|
||||||
|
"max_abv": 5.0,
|
||||||
|
"min_ibu": 40,
|
||||||
|
"max_ibu": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Black IPA",
|
||||||
|
"description": "A beer with the dryness, hop-forward balance, and flavor characteristics of an American IPA, but with a dark color and a restrained roasted malt character that doesn't clash with the hops.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Black_IPA",
|
||||||
|
"min_abv": 5.5,
|
||||||
|
"max_abv": 9.0,
|
||||||
|
"min_ibu": 50,
|
||||||
|
"max_ibu": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian IPA",
|
||||||
|
"description": "An IPA that marries the fruity, spicy yeast character of a Belgian ale with the assertive hop profile of an American IPA. It is typically lighter in body and highly carbonated.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/India_pale_ale#Belgian_IPA",
|
||||||
|
"min_abv": 6.2,
|
||||||
|
"max_abv": 9.5,
|
||||||
|
"min_ibu": 50,
|
||||||
|
"max_ibu": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "White IPA",
|
||||||
|
"description": "A fruity, spicy, and refreshing hybrid style that combines the crisp, wheat-based body and spice additions of a Belgian Witbier with the pronounced hop aroma and bitterness of an American IPA.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/India_pale_ale#White_IPA",
|
||||||
|
"min_abv": 5.5,
|
||||||
|
"max_abv": 7.0,
|
||||||
|
"min_ibu": 40,
|
||||||
|
"max_ibu": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Stout",
|
||||||
|
"description": "A hoppy, bitter, strongly roasted dark ale. It features the bold, aggressive flavor of American hops alongside intense roasted malt, coffee, and dark chocolate notes.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Stout#American_stout",
|
||||||
|
"min_abv": 5.0,
|
||||||
|
"max_abv": 7.0,
|
||||||
|
"min_ibu": 35,
|
||||||
|
"max_ibu": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Oatmeal Stout",
|
||||||
|
"description": "A very dark, full-bodied, roasty, malty ale featuring a complementary oatmeal addition. The oats provide a smooth, rich, and slightly oily texture that balances the roasted grain astringency.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Stout#Oatmeal_stout",
|
||||||
|
"min_abv": 4.2,
|
||||||
|
"max_abv": 5.9,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sweet Stout",
|
||||||
|
"description": "Also known as Milk Stout. A very dark, sweet, full-bodied, slightly roasty ale. Historically sweetened with lactose, an unfermentable milk sugar, it has a creamy texture and espresso-and-cream-like flavor.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Stout#Milk_stout",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Foreign Extra Stout",
|
||||||
|
"description": "A darker and sweeter stout originally brewed for export to tropical markets. It is moderately strong and features pronounced roasted grain, chocolate, and dark fruit flavors.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Stout#Foreign_Extra_Stout",
|
||||||
|
"min_abv": 6.3,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 50,
|
||||||
|
"max_ibu": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "English Porter",
|
||||||
|
"description": "A moderate-strength brown beer with a restrained roasty character and bitterness. It features a complex malt profile with notes of chocolate, caramel, and nuts, without the burnt flavors of a stout.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Porter_(beer)",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 5.4,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Porter",
|
||||||
|
"description": "A substantial, malty dark beer with a complex and flavorful dark malt character. Compared to English Porter, it is generally stronger, more aggressively hopped, and features more roasted barley character.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Porter_(beer)#American_porter",
|
||||||
|
"min_abv": 4.8,
|
||||||
|
"max_abv": 6.5,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Robust Porter",
|
||||||
|
"description": "A stronger, more bitter, and more roasted version of a porter. It bridges the gap between brown porter and stout, offering intense cocoa and dark caramel notes with a sharp roasted finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Porter_(beer)",
|
||||||
|
"min_abv": 5.1,
|
||||||
|
"max_abv": 6.6,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Brown Ale",
|
||||||
|
"description": "A malty but hoppy beer with prominent chocolate and caramel flavors. The hop character is noticeably American, providing a citrusy or piney contrast to the rich malt backbone.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Brown_ale#American_Brown_Ale",
|
||||||
|
"min_abv": 4.3,
|
||||||
|
"max_abv": 6.2,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "English Brown Ale",
|
||||||
|
"description": "A malty, brown caramel-centric British ale without the roasted flavors of a porter. It is known for its nutty, toffee, and light chocolate notes, paired with a subtle, earthy hop presence.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Brown_ale",
|
||||||
|
"min_abv": 4.2,
|
||||||
|
"max_abv": 5.4,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian Dubbel",
|
||||||
|
"description": "A deep reddish-copper, moderately strong, malty, complex Trappist ale. It features rich, malty flavors, dark fruit esters like plum and raisin, and mild phenolic spiciness from the Belgian yeast.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Dubbel",
|
||||||
|
"min_abv": 6.0,
|
||||||
|
"max_abv": 7.6,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian Quadrupel",
|
||||||
|
"description": "A massively strong, dark, rich, and complex Belgian ale. It pushes the boundaries of the Dubbel style, offering intense dark fruit, caramel, and peppery yeast spice with a smooth, warming alcohol finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Quadrupel",
|
||||||
|
"min_abv": 9.0,
|
||||||
|
"max_abv": 14.0,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian Blonde Ale",
|
||||||
|
"description": "A moderate-strength golden ale with a subtle fruity-spicy Belgian yeast complexity, slightly sweet malty flavor, and a dry finish. It is highly approachable and brilliantly clear.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Blonde_ale#Belgian_blonde_ale",
|
||||||
|
"min_abv": 6.0,
|
||||||
|
"max_abv": 7.5,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian Pale Ale",
|
||||||
|
"description": "A moderately malty, somewhat fruity, easy-drinking, copper-colored Belgian ale. It is less aggressive in yeast character than other Belgian styles, focusing on a balanced, biscuity malt and earthy hop profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Pale_ale#Belgian_pale_ale",
|
||||||
|
"min_abv": 4.8,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian Strong Golden Ale",
|
||||||
|
"description": "A pale, complex, effervescent, strong Belgian-style ale. It is highly attenuated and features fruity and hoppy notes in preference to phenolics, often with a surprisingly light body for its strength.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Strong_ale#Belgian_strong_ale",
|
||||||
|
"min_abv": 7.5,
|
||||||
|
"max_abv": 10.5,
|
||||||
|
"min_ibu": 22,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belgian Strong Dark Ale",
|
||||||
|
"description": "A dark, complex, very strong Belgian ale with a delicious blend of malt richness, dark fruit flavors, and spicy elements. It is deep, warming, and often beautifully conditioned.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Strong_ale#Belgian_strong_ale",
|
||||||
|
"min_abv": 8.0,
|
||||||
|
"max_abv": 11.0,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trappist Single",
|
||||||
|
"description": "A pale, bitter, highly attenuated and well-carbonated Trappist ale. Historically brewed for the monks' daily consumption (patersbier), it is dry, refreshing, and features prominent fruity and spicy yeast character.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Trappist_beer",
|
||||||
|
"min_abv": 4.8,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grisette",
|
||||||
|
"description": "A low-alcohol, light-bodied, and refreshing farmhouse ale historically brewed for miners in the Hainaut province of Belgium. It is similar to a Saison but typically lower in gravity and lacking strong tartness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Grisette_(beer)",
|
||||||
|
"min_abv": 3.5,
|
||||||
|
"max_abv": 5.0,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weizenbock",
|
||||||
|
"description": "A strong, malty, fruity, wheat-based ale combining the best flavors of a dunkelweizen and the rich strength and dark fruit of a bock. It is robust, bready, and highly aromatic.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bock#Weizenbock",
|
||||||
|
"min_abv": 6.5,
|
||||||
|
"max_abv": 9.0,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kristalweizen",
|
||||||
|
"description": "A filtered version of the traditional Bavarian Hefeweizen. By removing the yeast, the beer becomes brilliantly clear, offering a sharper, cleaner interpretation of the classic banana and clove flavors.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Wheat_beer#Kristalweizen",
|
||||||
|
"min_abv": 4.3,
|
||||||
|
"max_abv": 5.6,
|
||||||
|
"min_ibu": 8,
|
||||||
|
"max_ibu": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wheatwine",
|
||||||
|
"description": "A richly textured, high-alcohol ale made with a significant portion of wheat malt. It features a soft, bready maltiness with complex caramel and fruity notes, aging beautifully much like a barleywine.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Barley_wine#Wheatwine",
|
||||||
|
"min_abv": 8.5,
|
||||||
|
"max_abv": 12.2,
|
||||||
|
"min_ibu": 45,
|
||||||
|
"max_ibu": 85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Wheat Beer",
|
||||||
|
"description": "A pale, refreshing American ale brewed with a large proportion of wheat. Unlike German versions, it uses a clean-fermenting yeast, allowing the bready wheat malt and bright American hops to shine without clove or banana notes.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Wheat_beer#American_wheat_beer",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Traditional Bock",
|
||||||
|
"description": "A dark, strong, malty German lager. It is rich and complex, boasting robust flavors of toasted bread, caramel, and dark fruit, with very little hop bitterness and a smooth, clean lager finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bock",
|
||||||
|
"min_abv": 6.3,
|
||||||
|
"max_abv": 7.2,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 27
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Munich Dunkel",
|
||||||
|
"description": "A classic brown Bavarian lager that celebrates the rich, complex flavors of Munich malt. It features deep, bready, and toast-like caramel qualities without any harsh or burnt roasted malt flavors.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Dunkel",
|
||||||
|
"min_abv": 4.5,
|
||||||
|
"max_abv": 5.6,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Festbier",
|
||||||
|
"description": "A smooth, clean, pale German lager with a moderately strong malty flavor and a light hop character. This is the modern beer served at the Munich Oktoberfest, lighter in color and body than a traditional Märzen.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Oktoberfestbier",
|
||||||
|
"min_abv": 5.8,
|
||||||
|
"max_abv": 6.3,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Märzen",
|
||||||
|
"description": "An elegant, malty German amber lager with a clean, rich, toasty and bready malt flavor, restrained bitterness, and a dry finish. Historically brewed in March and lagered in cold caves over the summer.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/M%C3%A4rzen",
|
||||||
|
"min_abv": 5.8,
|
||||||
|
"max_abv": 6.3,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Czech Pale Lager",
|
||||||
|
"description": "A lighter, sessionable version of the famous Czech premium lagers. It features a prominent but soft Saaz hop spiciness balanced by a bready, slightly sweet malt backbone.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_the_Czech_Republic",
|
||||||
|
"min_abv": 3.0,
|
||||||
|
"max_abv": 4.1,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Czech Premium Pale Lager",
|
||||||
|
"description": "The original Pilsner style. It is a crisp, complex, and well-rounded pale lager featuring a rich, bready maltiness perfectly balanced by the pronounced, spicy bitterness of Saaz hops.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Pilsner",
|
||||||
|
"min_abv": 4.2,
|
||||||
|
"max_abv": 5.8,
|
||||||
|
"min_ibu": 30,
|
||||||
|
"max_ibu": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Czech Amber Lager",
|
||||||
|
"description": "A malt-driven amber lager with a balanced hop bitterness. It combines the rich, caramel and toasted malt flavors of a Vienna lager with the characteristic spicy hop profile of Czech brewing.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_the_Czech_Republic",
|
||||||
|
"min_abv": 4.4,
|
||||||
|
"max_abv": 5.8,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Czech Dark Lager",
|
||||||
|
"description": "A rich, dark, and highly drinkable Czech lager. It balances a roasted, chocolatey, and caramel malt sweetness with a gentle but noticeable hop bitterness, maintaining a smooth lager finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_the_Czech_Republic",
|
||||||
|
"min_abv": 4.4,
|
||||||
|
"max_abv": 5.8,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "International Pale Lager",
|
||||||
|
"description": "A highly attenuated pale lager without strong flavors, typically well-balanced and highly carbonated. It serves as a thirst-quenching, mass-market style with a very clean, neutral profile.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Pale_lager",
|
||||||
|
"min_abv": 4.6,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 18,
|
||||||
|
"max_ibu": 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "International Dark Lager",
|
||||||
|
"description": "A darker, somewhat sweeter version of an international pale lager. It features mild caramel or roasted malt notes, low hop bitterness, and a crisp, clean lager finish.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Dark_beer",
|
||||||
|
"min_abv": 4.2,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 8,
|
||||||
|
"max_ibu": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Lager",
|
||||||
|
"description": "A very pale, highly carbonated, light-bodied, well-attenuated lager. It is brewed with up to 40% corn or rice adjuncts to lighten the body and flavor, creating an extremely crisp and refreshing thirst-quencher.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_the_United_States#American_Lager",
|
||||||
|
"min_abv": 4.2,
|
||||||
|
"max_abv": 5.3,
|
||||||
|
"min_ibu": 8,
|
||||||
|
"max_ibu": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Light Lager",
|
||||||
|
"description": "A lighter, lower-calorie version of an American lager. It is highly attenuated and very neutral in flavor, designed for extreme drinkability without bitterness or heavy malt character.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Light_beer",
|
||||||
|
"min_abv": 2.8,
|
||||||
|
"max_abv": 4.2,
|
||||||
|
"min_ibu": 8,
|
||||||
|
"max_ibu": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Amber Ale",
|
||||||
|
"description": "A hoppy, moderately strong American ale featuring a caramel malt backbone. It strikes a balance between the citrusy, piney notes of American hops and a rich, toasted malt sweetness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Amber_ale",
|
||||||
|
"min_abv": 4.5,
|
||||||
|
"max_abv": 6.2,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Strong Ale",
|
||||||
|
"description": "A broad category for strong, intensely flavored American ales that don't quite fit into the barleywine or double IPA categories. They are typically aggressively hopped with a massive malt foundation.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Strong_ale#American_strong_ale",
|
||||||
|
"min_abv": 7.0,
|
||||||
|
"max_abv": 11.9,
|
||||||
|
"min_ibu": 50,
|
||||||
|
"max_ibu": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Barleywine",
|
||||||
|
"description": "A well-hopped American interpretation of the richest and strongest of the English ales. The hop character is assertive and bitter, balancing a massive, complex, and intensely sweet malt body.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Barley_wine#American_Barleywine",
|
||||||
|
"min_abv": 8.0,
|
||||||
|
"max_abv": 12.0,
|
||||||
|
"min_ibu": 50,
|
||||||
|
"max_ibu": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blonde Ale",
|
||||||
|
"description": "An easy-drinking, approachable, malt-oriented American craft beer. It has a light to medium body, gentle hop bitterness, and a clean, slightly sweet malt profile, often acting as a gateway to craft beer.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Blonde_ale",
|
||||||
|
"min_abv": 3.8,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Scottish Light",
|
||||||
|
"description": "A traditional Scottish session ale. It is malt-focused, utilizing cool fermentation temperatures to produce a clean profile that emphasizes caramel and toffee notes over hop bitterness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_Scotland#Light",
|
||||||
|
"min_abv": 2.5,
|
||||||
|
"max_abv": 3.2,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Scottish Heavy",
|
||||||
|
"description": "A slightly stronger version of the Scottish Light. It maintains the malt-forward, caramel-heavy profile and clean fermentation character, with just enough bitterness to prevent it from being cloying.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_Scotland#Heavy",
|
||||||
|
"min_abv": 3.2,
|
||||||
|
"max_abv": 3.9,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Scottish Export",
|
||||||
|
"description": "The strongest of the standard Scottish session ales. It features a deep, complex maltiness with rich caramel, toffee, and occasionally faint roasted notes, perfectly balanced for drinkability.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_Scotland#Export",
|
||||||
|
"min_abv": 3.9,
|
||||||
|
"max_abv": 6.0,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "English Pale Ale",
|
||||||
|
"description": "A classic British ale with a balanced profile of earthy, floral hops and a biscuity, caramel-tinged malt base. It is moderate in strength and highly sessionable.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Pale_ale",
|
||||||
|
"min_abv": 4.5,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ordinary Bitter",
|
||||||
|
"description": "A low-gravity, low-alcohol, and highly drinkable British session ale. Despite its name, it focuses on a balance of biscuity malt and earthy hop flavor, traditionally served on cask.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bitter_(beer)",
|
||||||
|
"min_abv": 3.2,
|
||||||
|
"max_abv": 3.8,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Best Bitter",
|
||||||
|
"description": "A moderately strong British bitter that provides a slightly richer malt backbone and more pronounced hop character than an Ordinary Bitter, while maintaining exceptional sessionability.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Bitter_(beer)",
|
||||||
|
"min_abv": 3.8,
|
||||||
|
"max_abv": 4.6,
|
||||||
|
"min_ibu": 25,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Old Ale",
|
||||||
|
"description": "A traditional English ale of moderate to significant strength, typically aged. It develops complex, sweet, and nutty malt flavors, often acquiring slight tartness or dark fruit notes from extended cellar maturation.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Old_ale",
|
||||||
|
"min_abv": 5.5,
|
||||||
|
"max_abv": 9.0,
|
||||||
|
"min_ibu": 30,
|
||||||
|
"max_ibu": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brett Beer",
|
||||||
|
"description": "Any beer fermented primarily or secondarily with Brettanomyces yeast. It is characterized by complex, funky, rustic, and 'barnyard' or leather-like aromas, rather than outright sourness.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Brettanomyces",
|
||||||
|
"min_abv": 5.0,
|
||||||
|
"max_abv": 8.5,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mixed-Fermentation Sour Beer",
|
||||||
|
"description": "A sour ale fermented with a combination of brewer's yeast, Brettanomyces, and lactic acid bacteria. It offers a complex, deeply tart profile layered with rustic funk and fruity esters.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Sour_beer",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 5,
|
||||||
|
"max_ibu": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wild Ale",
|
||||||
|
"description": "A beer fermented with wild yeast or bacteria native to a specific environment, rather than cultivated strains. The result is uniquely tied to its terroir, often profoundly tart and funk-forward.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Sour_beer#American_wild_ale",
|
||||||
|
"min_abv": 5.0,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 5,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fruit Beer",
|
||||||
|
"description": "A harmonious marriage of fruit and beer, where the fruit character complements the underlying beer style without overwhelming it. The base can range from light wheat beers to heavy stouts.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Fruit_beer",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 5,
|
||||||
|
"max_ibu": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Spice/Herb/Vegetable Beer",
|
||||||
|
"description": "A beer that incorporates culinary spices, herbs, or vegetables to enhance the flavor profile. The additions are meant to be noticeable but balanced with the base beer style.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Vegetable_beer",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 5,
|
||||||
|
"max_ibu": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pumpkin Ale",
|
||||||
|
"description": "A quintessential American seasonal beer brewed with pumpkin or winter squash and a blend of traditional autumn spices like cinnamon, nutmeg, ginger, and cloves, evoking the flavor of pumpkin pie.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Pumpkin_ale",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 7.5,
|
||||||
|
"min_ibu": 10,
|
||||||
|
"max_ibu": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Winter Warmer",
|
||||||
|
"description": "A traditional holiday seasonal ale. It is typically malty, dark, and strong, often featuring warming spices and a pronounced alcohol presence to combat the winter chill.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Old_ale#Winter_warmer",
|
||||||
|
"min_abv": 5.5,
|
||||||
|
"max_abv": 8.0,
|
||||||
|
"min_ibu": 20,
|
||||||
|
"max_ibu": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bière Brut",
|
||||||
|
"description": "A highly specialized, effervescent Belgian beer style brewed using the méthode champenoise. It is extremely dry, highly carbonated, and features complex fruity and spicy yeast notes, resembling a fine sparkling wine.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Beer_in_Belgium",
|
||||||
|
"min_abv": 8.0,
|
||||||
|
"max_abv": 11.5,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kentucky Common",
|
||||||
|
"description": "A historical American style originating in Louisville. It is a fast-fermenting, dark, slightly sweet, and lightly roasty ale brewed with a large proportion of corn, intended to be consumed fresh.",
|
||||||
|
"wikipedia_link": "https://en.wikipedia.org/wiki/Kentucky_common_beer",
|
||||||
|
"min_abv": 4.0,
|
||||||
|
"max_abv": 5.5,
|
||||||
|
"min_ibu": 15,
|
||||||
|
"max_ibu": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
128
pipeline/diagrams/activity-diagram.puml
Normal file
128
pipeline/diagrams/activity-diagram.puml
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
@startuml
|
||||||
|
skinparam style strictuml
|
||||||
|
skinparam defaultFontName "DM Sans"
|
||||||
|
skinparam defaultFontSize 14
|
||||||
|
skinparam titleFontName "Volkhov"
|
||||||
|
skinparam titleFontSize 20
|
||||||
|
skinparam backgroundColor #FAFCF9
|
||||||
|
skinparam defaultFontColor #28342A
|
||||||
|
skinparam titleFontColor #28342A
|
||||||
|
skinparam ArrowColor #628A5B
|
||||||
|
skinparam NoteBackgroundColor #EAF0E8
|
||||||
|
skinparam NoteBorderColor #547461
|
||||||
|
skinparam ActivityBackgroundColor #FAFCF9
|
||||||
|
skinparam ActivityBorderColor #547461
|
||||||
|
skinparam ActivityDiamondBackgroundColor #FAFCF9
|
||||||
|
skinparam ActivityDiamondBorderColor #628A5B
|
||||||
|
skinparam ActivityBarColor #628A5B
|
||||||
|
skinparam SwimlaneBorderColor transparent
|
||||||
|
skinparam SwimlaneBorderThickness 0
|
||||||
|
|
||||||
|
title The Biergarten Data Pipeline
|
||||||
|
|
||||||
|
|#F2F6F0|main.cc|
|
||||||
|
start
|
||||||
|
:ParseArguments(argc, argv);
|
||||||
|
note right
|
||||||
|
Validates --mocked, --model,
|
||||||
|
--temperature, --top-p, etc.
|
||||||
|
end note
|
||||||
|
|
||||||
|
if (Are arguments valid?) then (no)
|
||||||
|
:spdlog::error usage info;
|
||||||
|
stop
|
||||||
|
else (yes)
|
||||||
|
endif
|
||||||
|
|
||||||
|
:Init CurlGlobalState & LlamaBackendState;
|
||||||
|
:di::make_injector(...);
|
||||||
|
note right
|
||||||
|
Binds CURLWebClient, WikipediaService,
|
||||||
|
Gemma4JinjaPromptFormatter, and
|
||||||
|
either MockGenerator or LlamaGenerator
|
||||||
|
end note
|
||||||
|
:injector.create<BiergartenDataGenerator>();
|
||||||
|
:BiergartenDataGenerator::Run();
|
||||||
|
|
||||||
|
|#EAF0E8|BiergartenDataGenerator|
|
||||||
|
:QueryCitiesWithCountries();
|
||||||
|
|
||||||
|
|#E2EBDC|JsonLoader|
|
||||||
|
:JsonLoader::LoadLocations("locations.json");
|
||||||
|
:std::ranges::sample(all_locations, 50);
|
||||||
|
|
||||||
|
|#EAF0E8|BiergartenDataGenerator|
|
||||||
|
while (For each sampled Location?) is (Remaining cities)
|
||||||
|
|#DCE8D8|WikipediaService|
|
||||||
|
:GetLocationContext(loc);
|
||||||
|
:FetchExtract("City, Country");
|
||||||
|
:FetchExtract("beer in Country");
|
||||||
|
:FetchExtract("beer in City");
|
||||||
|
note right: Backed by CURLWebClient::Get
|
||||||
|
|
||||||
|
|#EAF0E8|BiergartenDataGenerator|
|
||||||
|
if (Lookup failed?) then (yes)
|
||||||
|
:spdlog::warn "context lookup failed";
|
||||||
|
else (no)
|
||||||
|
:Store EnrichedCity{Location, region_context};
|
||||||
|
endif
|
||||||
|
endwhile (Done)
|
||||||
|
|
||||||
|
:GenerateBreweries(enriched_cities);
|
||||||
|
|
||||||
|
|#E5EDE1|DataGenerator|
|
||||||
|
while (For each EnrichedCity?) is (Remaining cities)
|
||||||
|
if (Generator Mode) then (MockGenerator)
|
||||||
|
:DeterministicHash(location);
|
||||||
|
:Select from kBreweryAdjectives, kBreweryNouns,\nkBreweryDescriptions;
|
||||||
|
:Format BreweryResult;
|
||||||
|
else (LlamaGenerator)
|
||||||
|
:PrepareRegionContext(region_context);
|
||||||
|
:LoadBrewerySystemPrompt("prompts/system.md");
|
||||||
|
:Format user_prompt;
|
||||||
|
:Attempt = 0;
|
||||||
|
repeat
|
||||||
|
:Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar);
|
||||||
|
note right
|
||||||
|
Uses Gemma4JinjaPromptFormatter,
|
||||||
|
llama_tokenize, and llama_sampler_sample
|
||||||
|
end note
|
||||||
|
:ValidateBreweryJson(raw, brewery);
|
||||||
|
|
||||||
|
if (Is JSON Valid?) then (yes)
|
||||||
|
break
|
||||||
|
else (no)
|
||||||
|
if (Error == "incomplete JSON") then (yes)
|
||||||
|
:max_tokens += 700;
|
||||||
|
endif
|
||||||
|
:Update user_prompt with validation error;
|
||||||
|
:Attempt++;
|
||||||
|
endif
|
||||||
|
|
||||||
|
repeat while (Attempt < 3?) is (yes)
|
||||||
|
|
||||||
|
if (Still Invalid?) then (yes)
|
||||||
|
:throw std::runtime_error;
|
||||||
|
else (no)
|
||||||
|
:Return BreweryResult;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
|#EAF0E8|BiergartenDataGenerator|
|
||||||
|
if (Exception thrown?) then (yes)
|
||||||
|
:spdlog::warn "brewery generation failed";
|
||||||
|
else (no)
|
||||||
|
:Store GeneratedBrewery;
|
||||||
|
endif
|
||||||
|
|#E5EDE1|DataGenerator|
|
||||||
|
endwhile (Done)
|
||||||
|
|
||||||
|
|#EAF0E8|BiergartenDataGenerator|
|
||||||
|
:LogResults();
|
||||||
|
note right: spdlog::info dump of generated JSON fields
|
||||||
|
|
||||||
|
|#F2F6F0|main.cc|
|
||||||
|
:Return 0;
|
||||||
|
stop
|
||||||
|
|
||||||
|
@enduml
|
||||||
1
pipeline/diagrams/activity-diagram.svg
Normal file
1
pipeline/diagrams/activity-diagram.svg
Normal file
File diff suppressed because one or more lines are too long
112
pipeline/diagrams/class-diagram.puml
Normal file
112
pipeline/diagrams/class-diagram.puml
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
@startuml
|
||||||
|
skinparam style strictuml
|
||||||
|
skinparam defaultFontName "DM Sans"
|
||||||
|
skinparam defaultFontSize 14
|
||||||
|
skinparam titleFontName "Volkhov"
|
||||||
|
skinparam titleFontSize 20
|
||||||
|
skinparam backgroundColor #FAFCF9
|
||||||
|
skinparam defaultFontColor #28342A
|
||||||
|
skinparam titleFontColor #28342A
|
||||||
|
skinparam ArrowColor #628A5B
|
||||||
|
|
||||||
|
skinparam class {
|
||||||
|
BackgroundColor #FAFCF9
|
||||||
|
HeaderBackgroundColor #EAF0E8
|
||||||
|
BorderColor #547461
|
||||||
|
ArrowColor #628A5B
|
||||||
|
FontColor #28342A
|
||||||
|
}
|
||||||
|
|
||||||
|
skinparam note {
|
||||||
|
BackgroundColor #EAF0E8
|
||||||
|
BorderColor #547461
|
||||||
|
FontColor #28342A
|
||||||
|
}
|
||||||
|
|
||||||
|
title The Biergarten Data Pipeline - Class Diagram
|
||||||
|
|
||||||
|
class BiergartenDataGenerator {
|
||||||
|
- context_service_ : std::unique_ptr<IEnrichmentService>
|
||||||
|
- generator_ : std::unique_ptr<DataGenerator>
|
||||||
|
- generated_breweries_ : std::vector<GeneratedBrewery>
|
||||||
|
+ Run() : bool
|
||||||
|
- QueryCitiesWithCountries() : std::vector<Location>
|
||||||
|
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
|
||||||
|
- LogResults() : void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IEnrichmentService <<interface>> {
|
||||||
|
+ GetLocationContext(loc : const Location&) : std::string
|
||||||
|
}
|
||||||
|
|
||||||
|
class WikipediaService {
|
||||||
|
- client_ : std::unique_ptr<WebClient>
|
||||||
|
- extract_cache_ : std::unordered_map<std::string, std::string>
|
||||||
|
+ GetLocationContext(loc : const Location&) : std::string
|
||||||
|
- FetchExtract(query : std::string_view) : std::string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebClient <<interface>> {
|
||||||
|
+ Get(url : const std::string&) : std::string
|
||||||
|
+ UrlEncode(value : const std::string&) : std::string
|
||||||
|
}
|
||||||
|
|
||||||
|
class CURLWebClient {
|
||||||
|
+ Get(url : const std::string&) : std::string
|
||||||
|
+ UrlEncode(value : const std::string&) : std::string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataGenerator <<interface>> {
|
||||||
|
+ GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult
|
||||||
|
+ GenerateUser(locale : const std::string&) : UserResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGenerator {
|
||||||
|
+ GenerateBrewery(...) : BreweryResult
|
||||||
|
+ GenerateUser(...) : UserResult
|
||||||
|
- DeterministicHash(location : const Location&) : size_t
|
||||||
|
}
|
||||||
|
|
||||||
|
class LlamaGenerator {
|
||||||
|
- model_ : ModelHandle
|
||||||
|
- context_ : ContextHandle
|
||||||
|
- prompt_formatter_ : std::unique_ptr<IPromptFormatter>
|
||||||
|
- rng_ : std::mt19937
|
||||||
|
+ GenerateBrewery(...) : BreweryResult
|
||||||
|
+ GenerateUser(...) : UserResult
|
||||||
|
- Load(model_path : const std::string&) : void
|
||||||
|
- Infer(...) : std::string
|
||||||
|
- InferFormatted(...) : std::string
|
||||||
|
- LoadBrewerySystemPrompt(...) : std::string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPromptFormatter <<interface>> {
|
||||||
|
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
||||||
|
}
|
||||||
|
|
||||||
|
class Gemma4JinjaPromptFormatter {
|
||||||
|
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsonLoader {
|
||||||
|
+ {static} LoadLocations(filepath : const std::filesystem::path&) : std::vector<Location>
|
||||||
|
}
|
||||||
|
|
||||||
|
' Structural Relationships / Dependency Injection
|
||||||
|
BiergartenDataGenerator *-- IEnrichmentService : owns
|
||||||
|
BiergartenDataGenerator *-- DataGenerator : owns
|
||||||
|
|
||||||
|
IEnrichmentService <|.. WikipediaService : implements
|
||||||
|
WikipediaService *-- WebClient : owns
|
||||||
|
|
||||||
|
WebClient <|.. CURLWebClient : implements
|
||||||
|
|
||||||
|
DataGenerator <|.. MockGenerator : implements
|
||||||
|
DataGenerator <|.. LlamaGenerator : implements
|
||||||
|
|
||||||
|
LlamaGenerator *-- IPromptFormatter : uses
|
||||||
|
|
||||||
|
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
||||||
|
|
||||||
|
BiergartenDataGenerator ..> JsonLoader : uses
|
||||||
|
@enduml
|
||||||
1
pipeline/diagrams/class-diagram.svg
Normal file
1
pipeline/diagrams/class-diagram.svg
Normal file
File diff suppressed because one or more lines are too long
77
pipeline/includes/biergarten_data_generator.h
Normal file
77
pipeline/includes/biergarten_data_generator.h
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_DATA_GENERATOR_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_DATA_GENERATOR_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file biergarten_data_generator.h
|
||||||
|
* @brief Core orchestration class for pipeline data generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <span>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "data_generation/data_generator.h"
|
||||||
|
#include "data_model/enriched_city.h"
|
||||||
|
#include "data_model/generated_brewery.h"
|
||||||
|
#include "data_model/location.h"
|
||||||
|
#include "services/enrichment_service.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Main data generator class for the Biergarten pipeline.
|
||||||
|
*
|
||||||
|
* This class encapsulates the core logic for generating brewery data.
|
||||||
|
* It handles location loading, city enrichment, and brewery generation.
|
||||||
|
*/
|
||||||
|
class BiergartenDataGenerator {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Construct a BiergartenDataGenerator with injected dependencies.
|
||||||
|
*
|
||||||
|
* @param context_service Context provider for sampled locations.
|
||||||
|
* @param generator Brewery and user data generator.
|
||||||
|
*/
|
||||||
|
BiergartenDataGenerator(std::unique_ptr<IEnrichmentService> context_service,
|
||||||
|
std::unique_ptr<DataGenerator> generator);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Run the data generation pipeline.
|
||||||
|
*
|
||||||
|
* Performs the following steps:
|
||||||
|
* 1. Load curated locations from JSON
|
||||||
|
* 2. Resolve context for each city using the injected context service
|
||||||
|
* 3. Generate brewery data for sampled cities
|
||||||
|
*
|
||||||
|
* @return true if successful, false if not
|
||||||
|
*/
|
||||||
|
bool Run();
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// @brief Owning context provider dependency.
|
||||||
|
std::unique_ptr<IEnrichmentService> context_service_;
|
||||||
|
|
||||||
|
/// @brief Generator dependency selected in the composition root.
|
||||||
|
std::unique_ptr<DataGenerator> generator_;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Load locations from JSON and sample cities.
|
||||||
|
*
|
||||||
|
* @return Vector of sampled locations capped at 4 entries.
|
||||||
|
*/
|
||||||
|
static std::vector<Location> QueryCitiesWithCountries();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generate breweries for enriched cities.
|
||||||
|
*
|
||||||
|
* @param cities Span of enriched city data.
|
||||||
|
*/
|
||||||
|
void GenerateBreweries(std::span<const EnrichedCity> cities);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Log the generated brewery results.
|
||||||
|
*/
|
||||||
|
void LogResults() const;
|
||||||
|
|
||||||
|
/// @brief Stores generated brewery data.
|
||||||
|
std::vector<GeneratedBrewery> generated_breweries_;
|
||||||
|
};
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_BIERGARTEN_DATA_GENERATOR_H_
|
||||||
41
pipeline/includes/data_generation/data_generator.h
Normal file
41
pipeline/includes/data_generation/data_generator.h
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_DATA_GENERATOR_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_DATA_GENERATOR_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_generation/data_generator.h
|
||||||
|
* @brief Shared generator interfaces and result models.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "data_model/brewery_result.h"
|
||||||
|
#include "data_model/location.h"
|
||||||
|
#include "data_model/user_result.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Interface for data generator implementations.
|
||||||
|
*/
|
||||||
|
class DataGenerator {
|
||||||
|
public:
|
||||||
|
virtual ~DataGenerator() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generates brewery data for a location.
|
||||||
|
*
|
||||||
|
* @param location Location data
|
||||||
|
* @param region_context Additional regional context text.
|
||||||
|
* @return Brewery generation result.
|
||||||
|
*/
|
||||||
|
virtual BreweryResult GenerateBrewery(const Location& location,
|
||||||
|
const std::string& region_context) = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generates a user profile for a locale.
|
||||||
|
*
|
||||||
|
* @param locale Locale hint used by generator.
|
||||||
|
* @return User generation result.
|
||||||
|
*/
|
||||||
|
virtual UserResult GenerateUser(const std::string& locale) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_DATA_GENERATOR_H_
|
||||||
141
pipeline/includes/data_generation/llama_generator.h
Normal file
141
pipeline/includes/data_generation/llama_generator.h
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_H_
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_generation/llama_generator.h
|
||||||
|
* @brief llama.cpp-backed implementation of DataGenerator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <random>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "data_generation/data_generator.h"
|
||||||
|
#include "data_generation/prompt_formatting/prompt_formatter.h"
|
||||||
|
#include "data_model/application_options.h"
|
||||||
|
|
||||||
|
struct llama_model;
|
||||||
|
struct llama_context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Data generator implementation backed by llama.cpp.
|
||||||
|
*/
|
||||||
|
class LlamaGenerator final : public DataGenerator {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Constructs a generator using parsed application options and loads
|
||||||
|
* the configured model immediately.
|
||||||
|
*
|
||||||
|
* @param options Parsed application options.
|
||||||
|
* @param model_path Filesystem path to GGUF model assets.
|
||||||
|
* @param prompt_formatter Formatter that produces model-specific prompts.
|
||||||
|
*/
|
||||||
|
LlamaGenerator(const ApplicationOptions& options,
|
||||||
|
const std::string& model_path,
|
||||||
|
std::unique_ptr<IPromptFormatter> prompt_formatter);
|
||||||
|
|
||||||
|
~LlamaGenerator() override;
|
||||||
|
|
||||||
|
// disable copy constructor
|
||||||
|
LlamaGenerator(const LlamaGenerator&) = delete;
|
||||||
|
|
||||||
|
// disable copy assignment operator
|
||||||
|
LlamaGenerator& operator=(const LlamaGenerator&) = delete;
|
||||||
|
// disable move constructor
|
||||||
|
LlamaGenerator(LlamaGenerator&&) = delete;
|
||||||
|
// disable move assignment operator
|
||||||
|
LlamaGenerator& operator=(LlamaGenerator&&) = delete;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generates brewery data for a specific location.
|
||||||
|
*
|
||||||
|
* @param location Location object.
|
||||||
|
* @param region_context Additional regional context.
|
||||||
|
* @return Generated brewery result.
|
||||||
|
*/
|
||||||
|
BreweryResult GenerateBrewery(const Location& location,
|
||||||
|
const std::string& region_context) override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generates a user profile for the provided locale.
|
||||||
|
*
|
||||||
|
* @param locale Locale hint.
|
||||||
|
* @return Generated user profile.
|
||||||
|
*/
|
||||||
|
UserResult GenerateUser(const std::string& locale) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr int32_t kDefaultMaxTokens = 10000;
|
||||||
|
static constexpr float kDefaultSamplingTopP = 0.95F;
|
||||||
|
static constexpr uint32_t kDefaultSamplingTopK = 64;
|
||||||
|
static constexpr uint32_t kDefaultContextSize = 8192;
|
||||||
|
|
||||||
|
struct ModelDeleter {
|
||||||
|
void operator()(llama_model* model) const noexcept;
|
||||||
|
};
|
||||||
|
struct ContextDeleter {
|
||||||
|
void operator()(llama_context* context) const noexcept;
|
||||||
|
};
|
||||||
|
|
||||||
|
using ModelHandle = std::unique_ptr<llama_model, ModelDeleter>;
|
||||||
|
using ContextHandle = std::unique_ptr<llama_context, ContextDeleter>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Loads model and prepares inference context.
|
||||||
|
*
|
||||||
|
* @param model_path Filesystem path to GGUF model.
|
||||||
|
*/
|
||||||
|
void Load(const std::string& model_path);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Infers text from separate system and user prompts.
|
||||||
|
*
|
||||||
|
* This helps chat-capable models preserve system-role behavior instead of
|
||||||
|
* concatenating system text into user input.
|
||||||
|
*
|
||||||
|
* @param system_prompt System role prompt.
|
||||||
|
* @param prompt User prompt.
|
||||||
|
* @param max_tokens Maximum tokens to generate.
|
||||||
|
* @param grammar Optional GBNF grammar constraining generated output.
|
||||||
|
* @return Generated text.
|
||||||
|
*/
|
||||||
|
std::string Infer(const std::string& system_prompt, const std::string& prompt,
|
||||||
|
int max_tokens = kDefaultMaxTokens,
|
||||||
|
std::string_view grammar = {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Runs inference on an already-formatted prompt.
|
||||||
|
*
|
||||||
|
* @param formatted_prompt Prompt preformatted for model chat template.
|
||||||
|
* @param max_tokens Maximum tokens to generate.
|
||||||
|
* @param grammar Optional GBNF grammar constraining generated output.
|
||||||
|
* @return Generated text.
|
||||||
|
*/
|
||||||
|
std::string InferFormatted(const std::string& formatted_prompt,
|
||||||
|
int max_tokens = kDefaultMaxTokens,
|
||||||
|
std::string_view grammar = {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Loads the brewery system prompt from disk.
|
||||||
|
*
|
||||||
|
* @param prompt_file_path Prompt file path to try first.
|
||||||
|
* @return Loaded prompt text.
|
||||||
|
*/
|
||||||
|
std::string LoadBrewerySystemPrompt(const std::filesystem::path& prompt_file_path);
|
||||||
|
|
||||||
|
ModelHandle model_;
|
||||||
|
ContextHandle context_;
|
||||||
|
float sampling_temperature_ = 1.0F;
|
||||||
|
float sampling_top_p_ = kDefaultSamplingTopP;
|
||||||
|
uint32_t sampling_top_k_ = kDefaultSamplingTopK;
|
||||||
|
std::mt19937 rng_;
|
||||||
|
uint32_t n_ctx_ = kDefaultContextSize;
|
||||||
|
std::string brewery_system_prompt_;
|
||||||
|
std::unique_ptr<IPromptFormatter> prompt_formatter_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_H_
|
||||||
50
pipeline/includes/data_generation/llama_generator_helpers.h
Normal file
50
pipeline/includes/data_generation/llama_generator_helpers.h
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_HELPERS_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_HELPERS_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_generation/llama_generator_helpers.h
|
||||||
|
* @brief Shared helper APIs used by LlamaGenerator translation units.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "data_model/brewery_result.h"
|
||||||
|
|
||||||
|
struct llama_vocab;
|
||||||
|
using llama_token = int32_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Normalizes and truncates regional context.
|
||||||
|
*
|
||||||
|
* @param region_context Input regional context text.
|
||||||
|
* @param max_chars Maximum output length.
|
||||||
|
* @return Processed region context.
|
||||||
|
*/
|
||||||
|
std::string PrepareRegionContext(std::string_view region_context,
|
||||||
|
size_t max_chars = 2000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Decodes a sampled token and appends it to output text.
|
||||||
|
*
|
||||||
|
* @param vocab Model vocabulary.
|
||||||
|
* @param token Sampled token id.
|
||||||
|
* @param output Output text buffer.
|
||||||
|
*/
|
||||||
|
void AppendTokenPiece(const llama_vocab* vocab, llama_token token,
|
||||||
|
std::string& output);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Validates and parses brewery JSON output.
|
||||||
|
*
|
||||||
|
* @param raw Raw model output.
|
||||||
|
* @param brewery_out Parsed brewery payload.
|
||||||
|
* @return Validation error message if invalid, or std::nullopt on success.
|
||||||
|
*/
|
||||||
|
std::optional<std::string> ValidateBreweryJson(const std::string& raw,
|
||||||
|
BreweryResult& brewery_out);
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_LLAMA_GENERATOR_HELPERS_H_
|
||||||
123
pipeline/includes/data_generation/mock_generator.h
Normal file
123
pipeline/includes/data_generation/mock_generator.h
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_MOCK_GENERATOR_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_MOCK_GENERATOR_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_generation/mock_generator.h
|
||||||
|
* @brief Deterministic mock implementation of DataGenerator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "data_generation/data_generator.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mock generator used for deterministic, model-free outputs.
|
||||||
|
*/
|
||||||
|
class MockGenerator final : public DataGenerator {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Generates deterministic brewery data for a location.
|
||||||
|
*
|
||||||
|
* @param location City and country names.
|
||||||
|
* @param region_context Unused for mock generation.
|
||||||
|
* @return Generated brewery result.
|
||||||
|
*/
|
||||||
|
BreweryResult GenerateBrewery(const Location& location,
|
||||||
|
const std::string& region_context) override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generates deterministic user data for a locale.
|
||||||
|
*
|
||||||
|
* @param locale Locale hint.
|
||||||
|
* @return Generated user result.
|
||||||
|
*/
|
||||||
|
UserResult GenerateUser(const std::string& locale) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/**
|
||||||
|
* @brief Combines two strings into a stable hash value.
|
||||||
|
*
|
||||||
|
* @param location City and country names.
|
||||||
|
* @return Deterministic hash value.
|
||||||
|
*/
|
||||||
|
static size_t DeterministicHash(const Location& location);
|
||||||
|
|
||||||
|
static constexpr std::array<std::string_view, 18> kBreweryAdjectives = {
|
||||||
|
"Craft", "Heritage", "Local", "Artisan", "Pioneer", "Golden",
|
||||||
|
"Modern", "Classic", "Summit", "Northern", "Riverstone", "Barrel",
|
||||||
|
"Hinterland", "Harbor", "Wild", "Granite", "Copper", "Maple"};
|
||||||
|
|
||||||
|
static constexpr std::array<std::string_view, 18> kBreweryNouns = {
|
||||||
|
"Brewing Co.", "Brewery", "Bier Haus", "Taproom", "Works",
|
||||||
|
"House", "Fermentery", "Ale Co.", "Cellars", "Collective",
|
||||||
|
"Project", "Foundry", "Malthouse", "Public House", "Co-op",
|
||||||
|
"Lab", "Beer Hall", "Guild"};
|
||||||
|
|
||||||
|
static constexpr std::array<std::string_view, 18> kBreweryDescriptions = {
|
||||||
|
"Handcrafted pale ales and seasonal IPAs with local ingredients.",
|
||||||
|
"Traditional lagers and experimental sours in small batches.",
|
||||||
|
"Award-winning stouts and wildly hoppy blonde ales.",
|
||||||
|
"Craft brewery specializing in Belgian-style triples and dark "
|
||||||
|
"porters.",
|
||||||
|
"Modern brewery blending tradition with bold experimental flavors.",
|
||||||
|
"Neighborhood-focused taproom pouring crisp pilsners and citrusy "
|
||||||
|
"pale "
|
||||||
|
"ales.",
|
||||||
|
"Small-batch brewery known for barrel-aged releases and smoky "
|
||||||
|
"lagers.",
|
||||||
|
"Independent brewhouse pairing farmhouse ales with rotating food "
|
||||||
|
"pop-ups.",
|
||||||
|
"Community brewpub making balanced bitters, saisons, and hazy IPAs.",
|
||||||
|
"Experimental nanobrewery exploring local yeast and regional "
|
||||||
|
"grains.",
|
||||||
|
"Family-run brewery producing smooth amber ales and robust porters.",
|
||||||
|
"Urban brewery crafting clean lagers and bright, fruit-forward "
|
||||||
|
"sours.",
|
||||||
|
"Riverfront brewhouse featuring oak-matured ales and seasonal "
|
||||||
|
"blends.",
|
||||||
|
"Modern taproom focused on sessionable lagers and classic pub "
|
||||||
|
"styles.",
|
||||||
|
"Brewery rooted in tradition with a lineup of malty reds and crisp "
|
||||||
|
"lagers.",
|
||||||
|
"Creative brewery offering rotating collaborations and limited "
|
||||||
|
"draft-only "
|
||||||
|
"pours.",
|
||||||
|
"Locally inspired brewery serving approachable ales with bold hop "
|
||||||
|
"character.",
|
||||||
|
"Destination taproom known for balanced IPAs and cocoa-rich "
|
||||||
|
"stouts."};
|
||||||
|
|
||||||
|
static constexpr std::array<std::string_view, 18> kUsernames = {
|
||||||
|
"hopseeker", "malttrail", "yeastwhisper", "lagerlane",
|
||||||
|
"barrelbound", "foamfinder", "taphunter", "graingeist",
|
||||||
|
"brewscout", "aleatlas", "caskcompass", "hopsandmaps",
|
||||||
|
"mashpilot", "pintnomad", "fermentfriend", "stoutsignal",
|
||||||
|
"sessionwander", "kettlekeeper"};
|
||||||
|
|
||||||
|
static constexpr std::array<std::string_view, 18> kBios = {
|
||||||
|
"Always chasing balanced IPAs and crisp lagers across local taprooms.",
|
||||||
|
"Weekend brewery explorer with a soft spot for dark, roasty stouts.",
|
||||||
|
"Documenting tiny brewpubs, fresh pours, and unforgettable beer "
|
||||||
|
"gardens.",
|
||||||
|
"Fan of farmhouse ales, food pairings, and long tasting flights.",
|
||||||
|
"Collecting favorite pilsners one city at a time.",
|
||||||
|
"Hops-first drinker who still saves room for classic malt-forward "
|
||||||
|
"styles.",
|
||||||
|
"Finding hidden tap lists and sharing the best seasonal releases.",
|
||||||
|
"Brewery road-tripper focused on local ingredients and clean "
|
||||||
|
"fermentation.",
|
||||||
|
"Always comparing house lagers and ranking patio pint vibes.",
|
||||||
|
"Curious about yeast strains, barrel programs, and cellar experiments.",
|
||||||
|
"Believes every neighborhood deserves a great community taproom.",
|
||||||
|
"Looking for session beers that taste great from first sip to last.",
|
||||||
|
"Belgian ale enthusiast who never skips a new saison.",
|
||||||
|
"Hazy IPA critic with deep respect for a perfectly clear pilsner.",
|
||||||
|
"Visits breweries for the stories, stays for the flagship pours.",
|
||||||
|
"Craft beer fan mapping tasting notes and favorite brew routes.",
|
||||||
|
"Always ready to trade recommendations for underrated local breweries.",
|
||||||
|
"Keeping a running list of must-try collab releases and tap takeovers."};
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_GENERATION_MOCK_GENERATOR_H_
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "data_generation/prompt_formatting/prompt_formatter.h"
|
||||||
|
|
||||||
|
class Gemma4JinjaPromptFormatter final : public IPromptFormatter {
|
||||||
|
public:
|
||||||
|
Gemma4JinjaPromptFormatter() = default;
|
||||||
|
~Gemma4JinjaPromptFormatter() override = default;
|
||||||
|
|
||||||
|
[[nodiscard]] std::string Format(std::string_view system_prompt,
|
||||||
|
std::string_view user_prompt) const override;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
class IPromptFormatter {
|
||||||
|
public:
|
||||||
|
IPromptFormatter() = default;
|
||||||
|
IPromptFormatter(const IPromptFormatter&) = delete;
|
||||||
|
IPromptFormatter& operator=(const IPromptFormatter&) = delete;
|
||||||
|
IPromptFormatter(IPromptFormatter&&) = delete;
|
||||||
|
IPromptFormatter& operator=(IPromptFormatter&&) = delete;
|
||||||
|
virtual ~IPromptFormatter() = default;
|
||||||
|
|
||||||
|
[[nodiscard]] virtual std::string Format(
|
||||||
|
std::string_view system_prompt,
|
||||||
|
std::string_view user_prompt) const = 0;
|
||||||
|
};
|
||||||
42
pipeline/includes/data_model/application_options.h
Normal file
42
pipeline/includes/data_model/application_options.h
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_APPLICATION_OPTIONS_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_APPLICATION_OPTIONS_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/application_options.h
|
||||||
|
* @brief Program options for the Biergarten pipeline application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Program options for the Biergarten pipeline application.
|
||||||
|
*/
|
||||||
|
struct ApplicationOptions {
|
||||||
|
/// @brief Path to the LLM model file (gguf format); mutually exclusive with
|
||||||
|
/// use_mocked.
|
||||||
|
std::string model_path;
|
||||||
|
|
||||||
|
/// @brief Use mocked generator instead of LLM; mutually exclusive with
|
||||||
|
/// model_path.
|
||||||
|
bool use_mocked = false;
|
||||||
|
|
||||||
|
/// @brief LLM sampling temperature (0.0 to 1.0, higher = more random).
|
||||||
|
float temperature = 1.0F;
|
||||||
|
|
||||||
|
/// @brief LLM nucleus sampling top-p parameter (0.0 to 1.0, higher = more
|
||||||
|
/// random).
|
||||||
|
float top_p = 0.95F;
|
||||||
|
|
||||||
|
/// @brief LLM top-k sampling parameter.
|
||||||
|
uint32_t top_k = 64;
|
||||||
|
|
||||||
|
/// @brief Context window size (tokens) for LLM inference. Higher values
|
||||||
|
/// support longer prompts but use more memory.
|
||||||
|
uint32_t n_ctx = 8192;
|
||||||
|
|
||||||
|
/// @brief Random seed for sampling (-1 for random, otherwise non-negative).
|
||||||
|
int seed = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_APPLICATION_OPTIONS_H_
|
||||||
22
pipeline/includes/data_model/brewery_location.h
Normal file
22
pipeline/includes/data_model/brewery_location.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_BREWERY_LOCATION_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_BREWERY_LOCATION_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/brewery_location.h
|
||||||
|
* @brief Non-owning brewery location input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Non-owning brewery location input.
|
||||||
|
*/
|
||||||
|
struct BreweryLocation {
|
||||||
|
/// @brief City name.
|
||||||
|
std::string_view city_name;
|
||||||
|
|
||||||
|
/// @brief Country name.
|
||||||
|
std::string_view country_name;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_BREWERY_LOCATION_H_
|
||||||
28
pipeline/includes/data_model/brewery_result.h
Normal file
28
pipeline/includes/data_model/brewery_result.h
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_BREWERY_RESULT_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_BREWERY_RESULT_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/brewery_result.h
|
||||||
|
* @brief Generated brewery payload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generated brewery payload.
|
||||||
|
*/
|
||||||
|
struct BreweryResult {
|
||||||
|
/// @brief Brewery display name in English.
|
||||||
|
std::string name_en;
|
||||||
|
|
||||||
|
/// @brief Brewery description text in English.
|
||||||
|
std::string description_en;
|
||||||
|
|
||||||
|
/// @brief Brewery display name in the local language.
|
||||||
|
std::string name_local;
|
||||||
|
|
||||||
|
/// @brief Brewery description text in the local language.
|
||||||
|
std::string description_local;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_BREWERY_RESULT_H_
|
||||||
21
pipeline/includes/data_model/enriched_city.h
Normal file
21
pipeline/includes/data_model/enriched_city.h
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_ENRICHED_CITY_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_ENRICHED_CITY_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/enriched_city.h
|
||||||
|
* @brief Enriched city data with Wikipedia context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "data_model/location.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Enriched city data with Wikipedia context.
|
||||||
|
*/
|
||||||
|
struct EnrichedCity {
|
||||||
|
Location location;
|
||||||
|
std::string region_context{};
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_ENRICHED_CITY_H_
|
||||||
20
pipeline/includes/data_model/generated_brewery.h
Normal file
20
pipeline/includes/data_model/generated_brewery.h
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATED_BREWERY_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATED_BREWERY_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/generated_brewery.h
|
||||||
|
* @brief Helper struct to store generated brewery data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "data_model/brewery_result.h"
|
||||||
|
#include "data_model/location.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Helper struct to store generated brewery data.
|
||||||
|
*/
|
||||||
|
struct GeneratedBrewery {
|
||||||
|
Location location;
|
||||||
|
BreweryResult brewery;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATED_BREWERY_H_
|
||||||
13
pipeline/includes/data_model/generation_models.h
Normal file
13
pipeline/includes/data_model/generation_models.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATION_MODELS_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATION_MODELS_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/generation_models.h
|
||||||
|
* @brief Convenience include for shared generation payload models.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "data_model/brewery_location.h"
|
||||||
|
#include "data_model/brewery_result.h"
|
||||||
|
#include "data_model/user_result.h"
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_GENERATION_MODELS_H_
|
||||||
41
pipeline/includes/data_model/location.h
Normal file
41
pipeline/includes/data_model/location.h
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_LOCATION_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_LOCATION_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/location.h
|
||||||
|
* @brief Location data model used throughout generation pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Canonical location record for city-level generation.
|
||||||
|
*/
|
||||||
|
struct Location {
|
||||||
|
/// @brief City name.
|
||||||
|
std::string city{};
|
||||||
|
|
||||||
|
/// @brief State or province name.
|
||||||
|
std::string state_province{};
|
||||||
|
|
||||||
|
/// @brief ISO 3166-2 subdivision code.
|
||||||
|
std::string iso3166_2{};
|
||||||
|
|
||||||
|
/// @brief Country name.
|
||||||
|
std::string country{};
|
||||||
|
|
||||||
|
/// @brief ISO 3166-1 country code.
|
||||||
|
std::string iso3166_1{};
|
||||||
|
|
||||||
|
/// @brief Local language codes in priority order.
|
||||||
|
std::vector<std::string> local_languages{};
|
||||||
|
|
||||||
|
/// @brief Latitude in decimal degrees.
|
||||||
|
double latitude{};
|
||||||
|
|
||||||
|
/// @brief Longitude in decimal degrees.
|
||||||
|
double longitude{};
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_LOCATION_H_
|
||||||
12
pipeline/includes/data_model/pipeline_models.h
Normal file
12
pipeline/includes/data_model/pipeline_models.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_PIPELINE_MODELS_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_PIPELINE_MODELS_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/pipeline_models.h
|
||||||
|
* @brief Convenience include for pipeline-specific data models.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "data_model/enriched_city.h"
|
||||||
|
#include "data_model/generated_brewery.h"
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_PIPELINE_MODELS_H_
|
||||||
22
pipeline/includes/data_model/user_result.h
Normal file
22
pipeline/includes/data_model/user_result.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_USER_RESULT_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_USER_RESULT_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file data_model/user_result.h
|
||||||
|
* @brief Generated user profile payload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Generated user profile payload.
|
||||||
|
*/
|
||||||
|
struct UserResult {
|
||||||
|
/// @brief Username handle.
|
||||||
|
std::string username{};
|
||||||
|
|
||||||
|
/// @brief Short user biography.
|
||||||
|
std::string bio{};
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_DATA_MODEL_USER_RESULT_H_
|
||||||
22
pipeline/includes/json_handling/json_loader.h
Normal file
22
pipeline/includes/json_handling/json_loader.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file json_handling/json_loader.h
|
||||||
|
* @brief Loader API for curated location data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "data_model/location.h"
|
||||||
|
|
||||||
|
/// @brief Loads curated world locations from a JSON file into memory.
|
||||||
|
class JsonLoader {
|
||||||
|
public:
|
||||||
|
/// @brief Parses a JSON array file and returns all location records.
|
||||||
|
static std::vector<Location> LoadLocations(
|
||||||
|
const std::filesystem::path& filepath);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_JSON_HANDLING_JSON_LOADER_H_
|
||||||
32
pipeline/includes/llama_backend_state.h
Normal file
32
pipeline/includes/llama_backend_state.h
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_LLAMA_BACKEND_STATE_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_LLAMA_BACKEND_STATE_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file llama_backend_state.h
|
||||||
|
* @brief RAII guard for llama.cpp backend process lifetime.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <llama.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief RAII wrapper for llama_backend_init and llama_backend_free.
|
||||||
|
*
|
||||||
|
* Create one instance in application startup before using llama.cpp and keep
|
||||||
|
* it alive for application lifetime.
|
||||||
|
*/
|
||||||
|
class LlamaBackendState {
|
||||||
|
public:
|
||||||
|
/// @brief Initializes global llama backend state.
|
||||||
|
LlamaBackendState() { llama_backend_init(); }
|
||||||
|
|
||||||
|
/// @brief Cleans up global llama backend state.
|
||||||
|
~LlamaBackendState() { llama_backend_free(); }
|
||||||
|
|
||||||
|
/// @brief Non-copyable type.
|
||||||
|
LlamaBackendState(const LlamaBackendState&) = delete;
|
||||||
|
|
||||||
|
/// @brief Non-copyable type.
|
||||||
|
LlamaBackendState& operator=(const LlamaBackendState&) = delete;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_LLAMA_BACKEND_STATE_H_
|
||||||
30
pipeline/includes/services/enrichment_service.h
Normal file
30
pipeline/includes/services/enrichment_service.h
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_SERVICE_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_SERVICE_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file services/enrichment_service.h
|
||||||
|
* @brief Abstraction for resolving contextual enrichment for a location.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "data_model/location.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Interface for services that can enrich a location with context.
|
||||||
|
*/
|
||||||
|
class IEnrichmentService {
|
||||||
|
public:
|
||||||
|
/// @brief Virtual destructor for polymorphic cleanup.
|
||||||
|
virtual ~IEnrichmentService() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Resolves contextual enrichment for a location.
|
||||||
|
*
|
||||||
|
* @param loc Location to enrich.
|
||||||
|
* @return Context text, or an empty string if unavailable.
|
||||||
|
*/
|
||||||
|
virtual std::string GetLocationContext(const Location& loc) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_ENRICHMENT_SERVICE_H_
|
||||||
33
pipeline/includes/services/wikipedia_service.h
Normal file
33
pipeline/includes/services/wikipedia_service.h
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_SERVICES_WIKIPEDIA_SERVICE_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_SERVICES_WIKIPEDIA_SERVICE_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file services/wikipedia_service.h
|
||||||
|
* @brief Wikipedia summary retrieval service with in-memory caching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include "services/enrichment_service.h"
|
||||||
|
#include "web_client/web_client.h"
|
||||||
|
|
||||||
|
/// @brief Provides Wikipedia summary lookups backed by cached raw extracts.
|
||||||
|
class WikipediaService final : public IEnrichmentService {
|
||||||
|
public:
|
||||||
|
/// @brief Creates a new Wikipedia service with the provided web client.
|
||||||
|
explicit WikipediaService(std::unique_ptr<WebClient> client);
|
||||||
|
|
||||||
|
/// @brief Returns the Wikipedia-derived context for a location.
|
||||||
|
[[nodiscard]] std::string GetLocationContext(const Location& loc) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string FetchExtract(std::string_view query);
|
||||||
|
std::unique_ptr<WebClient> client_;
|
||||||
|
/// @brief Canonical cache for raw Wikipedia query extracts.
|
||||||
|
std::unordered_map<std::string, std::string> extract_cache_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_SERVICES_WIKIPEDIA_SERVICE_H_
|
||||||
54
pipeline/includes/web_client/curl_web_client.h
Normal file
54
pipeline/includes/web_client/curl_web_client.h
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_CURL_WEB_CLIENT_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_CURL_WEB_CLIENT_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file web_client/curl_web_client.h
|
||||||
|
* @brief libcurl-based WebClient implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "web_client/web_client.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief RAII wrapper for curl_global_init and curl_global_cleanup.
|
||||||
|
*
|
||||||
|
* Create one instance in application startup before using libcurl and keep it
|
||||||
|
* alive for application lifetime.
|
||||||
|
*/
|
||||||
|
class CurlGlobalState {
|
||||||
|
public:
|
||||||
|
/// @brief Initializes global libcurl state.
|
||||||
|
CurlGlobalState();
|
||||||
|
|
||||||
|
/// @brief Cleans up global libcurl state.
|
||||||
|
~CurlGlobalState();
|
||||||
|
|
||||||
|
/// @brief Non-copyable type.
|
||||||
|
CurlGlobalState(const CurlGlobalState&) = delete;
|
||||||
|
|
||||||
|
/// @brief Non-copyable type.
|
||||||
|
CurlGlobalState& operator=(const CurlGlobalState&) = delete;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief WebClient implementation backed by libcurl.
|
||||||
|
*/
|
||||||
|
class CURLWebClient : public WebClient {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Executes an HTTP GET request.
|
||||||
|
*
|
||||||
|
* @param url Request URL.
|
||||||
|
* @return Response body.
|
||||||
|
*/
|
||||||
|
std::string Get(const std::string& url) override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief URL-encodes a string value.
|
||||||
|
*
|
||||||
|
* @param value Raw value.
|
||||||
|
* @return URL-encoded string.
|
||||||
|
*/
|
||||||
|
std::string UrlEncode(const std::string& value) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_CURL_WEB_CLIENT_H_
|
||||||
36
pipeline/includes/web_client/web_client.h
Normal file
36
pipeline/includes/web_client/web_client.h
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#ifndef BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_WEB_CLIENT_H_
|
||||||
|
#define BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_WEB_CLIENT_H_
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file web_client/web_client.h
|
||||||
|
* @brief Abstract interface for HTTP and URL utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Abstract web client interface.
|
||||||
|
*/
|
||||||
|
class WebClient {
|
||||||
|
public:
|
||||||
|
/// @brief Virtual destructor for polymorphic cleanup.
|
||||||
|
virtual ~WebClient() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Executes an HTTP GET request.
|
||||||
|
*
|
||||||
|
* @param url Request URL.
|
||||||
|
* @return Response body.
|
||||||
|
*/
|
||||||
|
virtual std::string Get(const std::string& url) = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief URL-encodes a string value.
|
||||||
|
*
|
||||||
|
* @param value Raw string value.
|
||||||
|
* @return Encoded value safe for URL usage.
|
||||||
|
*/
|
||||||
|
virtual std::string UrlEncode(const std::string& value) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_WEB_CLIENT_WEB_CLIENT_H_
|
||||||
1002
pipeline/locations.json
Normal file
1002
pipeline/locations.json
Normal file
File diff suppressed because it is too large
Load Diff
303
pipeline/out-sample/french-cities.example
Normal file
303
pipeline/out-sample/french-cities.example
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
[2026-04-18 14:36:49.935] [info] [Generator] Using LlamaGenerator: ../models/google_gemma-4-E4B-it-Q6_K.gguf (temperature=1, top-p=0.95, top-k=64, n_ctx=8192, seed=-1)
|
||||||
|
[2026-04-18 14:36:54.526] [info] [LlamaGenerator] Loaded model: ../models/google_gemma-4-E4B-it-Q6_K.gguf
|
||||||
|
[2026-04-18 14:36:54.527] [info]
|
||||||
|
=== GEOGRAPHIC DATA OVERVIEW ===
|
||||||
|
[2026-04-18 14:36:54.527] [info] [JsonLoader] Loaded 5 locations from locations.json
|
||||||
|
[2026-04-18 14:36:54.527] [info] Locations available: 5
|
||||||
|
[2026-04-18 14:36:54.527] [info] Sampled locations: 5
|
||||||
|
[2026-04-18 14:36:57.218] [info]
|
||||||
|
=== SAMPLE BREWERY GENERATION ===
|
||||||
|
[2026-04-18 14:36:57.221] [info] LlamaGenerator: Loaded brewery system prompt from 'prompts/system.md' (6489 chars)
|
||||||
|
[2026-04-18 14:40:04.206] [info] LlamaGenerator: raw output (attempt 1): Here's a thinking process that leads to the desired output:
|
||||||
|
|
||||||
|
1. **Analyze the Request & Constraints:**
|
||||||
|
* **Role:** Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy.
|
||||||
|
* **Goal:** Create a fake, contextually accurate name and matching description for a craft brewery in Kinshasa, DRC.
|
||||||
|
* **Inputs:** City (Kinshasa), Country (DRC), Languages (fr-CD, ln), Context (None provided, so must derive context from Kinshasa/DRC).
|
||||||
|
* **Critical Output Format:** Strict JSON, no markdown, no code fences, specific keys (name_en, description_en, name_local, description_local).
|
||||||
|
* **Length:** 225-300 words per description (continuous string).
|
||||||
|
* **Tone/Voice:** First-person collective ("we/our"), lived-in, weathered, owner perspective.
|
||||||
|
* **Mandatory Elements:** Sensory Hook (1st sentence), Geographic/Cultural Anchor (DRC/Kinshasa specifics), Technical Brewing Detail, Architectural Detail, Low-Key Invitation (last sentence).
|
||||||
|
* **Strict Blocklist:** (e.g., 'hidden gem', 'passion', 'repurposed dairy tank', 'grab a stool', etc.).
|
||||||
|
* **Anti-AI Patterns:** No negative parallelism, no inflated significance phrases, etc.
|
||||||
|
* **Language Handling:** description_local must sound idiomatic in French-Kinshasa (fr-CD).
|
||||||
|
|
||||||
|
2. **Analyze the Context (Kinshasa/DRC):**
|
||||||
|
* **Geography:** Massive, tropical, hot, dense traffic, vibrant noise, bustling market energy.
|
||||||
|
* **Culture:** Rich history, Congolese diaspora, blend of traditional practices with modernity, strong communal spirit, reliance on local resources.
|
||||||
|
* **Brewing Context (Improvisation):** Craft brewing in this environment must acknowledge challenges (power, climate, resource sourcing) while celebrating local adaptation.
|
||||||
|
* **Potential Elements:** Local grains (millet, sorghum?), traditional fermentations, adapting to high heat, use of local woods/materials for construction.
|
||||||
|
|
||||||
|
3. **Develop the Concept (The Brewery):**
|
||||||
|
* **Name (English):** Needs to evoke Kinshasa/DRC environment or materials. *Mosi* (King/Chief in some Bantu languages) or a place name. Let's go with something related to the river/heat. *Lekele* (a fictional, local-sounding name). Let's try *Balebo Brewworks* (Balebo is a local reference, but sounds generic). How about focusing on the energy? *Mwami Kivu* (Chief of Kivu, referencing regional pride). Let's simplify and make it sound like a place: *Plateau Flow*. No, too Western. Let's use a French/local blend. *Le Cœur Vert* (The Green Heart) or *Zaïre Flow* (historic reference). Let's pick *La Roue du Congo*.
|
||||||
|
* **Name (Local/Fr-CD):** *La Roue du Congo*.
|
||||||
|
* **Brewing Detail:** High heat management is crucial. Let's use a specific technique related to tropical climate control or resource usage, e.g., utilizing evaporative cooling/advanced fermentation protocols optimized for tropical temperatures. Or focusing on local grain adaptation (e.g., utilizing millet/sorghum mash). Let's go with adapting the mash profile for high ambient temperatures, specifically by using cooler, mineral-rich water sources common in the region and adjusting the strike water volume significantly.
|
||||||
|
* **Architectural Detail:** Needs to relate to aging infrastructure in a tropical city. Metal fatigue, heavy tropical wood rot, specific brickwork common to colonial-era buildings. Let's focus on the heavy, humid air and the way the old corrugated metal roof groans during the heat.
|
||||||
|
|
||||||
|
4. **Drafting - English Description (Iterative Refinement):**
|
||||||
|
* *Hook:* (Sensory observation) The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady hum of the city.
|
||||||
|
* *Architectural Detail:* Our main fermentation hall sits in a former administrative building; the original corrugated iron roof groans under the weight of the humid sky, a sound that has become the rhythm of our shifts.
|
||||||
|
* *Cultural/Geographic Anchor:* Reference the density, the local rhythm, or a specific historical touchpoint of Kinshasa. (The city's relentless energy, the resilience).
|
||||||
|
* *Technical Detail:* The high heat requires unique adjustments. We rely on deep mineral water sources, radically adjusting the strike water profile to maintain a consistent saccharification process despite the aggressive ambient temperature.
|
||||||
|
* *Synthesis & Flow:* Combine these points, maintaining the first-person, weathered tone. Check word count (aim for 250).
|
||||||
|
* *Invitation:* Specific, low-key. (e.g., "The cooler benches near the back window are always open.")
|
||||||
|
|
||||||
|
5. **Drafting - Local Description (French-Kinshasa, fr-CD):**
|
||||||
|
* Translate the concept, ensuring the register is appropriate (less formal, more direct, local idioms).
|
||||||
|
* Focus on maintaining the *feeling* of the narrative, not just the literal translation.
|
||||||
|
* Ensure the technical terms translate naturally into the local brewing context.
|
||||||
|
* Check word count (aim for 250).
|
||||||
|
|
||||||
|
6. **Final Review against Constraints:**
|
||||||
|
* JSON format? Yes.
|
||||||
|
* No markdown/fences? Yes.
|
||||||
|
* 4 specific keys? Yes.
|
||||||
|
* Escape quotes/single quotes? Yes.
|
||||||
|
* Length 225-300? Yes.
|
||||||
|
* Hook, Anchor, Technical, Architecture, Invitation? Yes.
|
||||||
|
* Blocklist checked? Yes.
|
||||||
|
* AI patterns checked? Yes.
|
||||||
|
* Voice (We/Our, weathered)? Yes.
|
||||||
|
* Local language idiomatic? Yes (Fr-CD tone).
|
||||||
|
|
||||||
|
7. **Construct the final JSON.** (Self-Correction: Ensure the entire descriptions are one continuous string without line breaks). (Ready to output).<channel|>{
|
||||||
|
"name_en": "La Roue du Congo",
|
||||||
|
"description_en": "The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady, relentless hum of the city. Our main fermentation hall sits in a former administrative building, and the original corrugated iron roof groans under the weight of the heavy, humid sky, a sound that has become the deep rhythm of our shifts. Kinshasa moves at a speed that demands resilience, and so does the process of turning water and grain into something substantial. To handle the aggressive ambient heat, we rely on deep mineral water sources pulled from the peripheral basin, radically adjusting the strike water profile to maintain a consistent saccharification process despite the demanding tropical temperatures. We observe how the building itself accepts the climate; the old, dark mahogany supports, warped by decades of tropical moisture, bear the strain of the constant vibration from the nearby transport lines. Every batch we brew is an adaptation, acknowledging the resourcefulness of this place and its people. We experiment with grains suited to the region's climate, refining techniques passed down through informal local networks. Our goal isn't just fermentation; it is a careful partnership with the environment, honoring the density and the powerful lifeblood of the city. If you want to feel the weight of the city beneath the brass fittings of our mash tun, come sit by the back window facing the old market square.",
|
||||||
|
"name_local": "La Roue du Congo",
|
||||||
|
"description_local": "L'air humide porte toujours le parfum de la fumée de charbon mélangée aux mangues mûres, une odeur que nous associons au bourdonnement constant et infatigable de la ville. Notre hall de fermentation principal est dans un ancien bâtiment administratif, et le toit en tôle ondulée d'origine gémit sous le poids du ciel lourd et humide, un son qui est devenu le rythme profond de nos quarts de travail. Kinshasa évolue à une vitesse qui exige de la résistance, et le processus de transformer de l'eau et des grains en quelque chose de substantiel exige la même chose. Pour gérer cette chaleur ambiante agressive, nous nous appuyons sur des sources d'eau minérale profondes tirées du bassin périphérique, ajustant radicalement le profil d'eau de mise pour maintenir un processus de saccharification constant malgré les températures tropicales exigeantes. Nous observons comment le bâtiment lui-même accepte le climat; les vieux supports en acajou foncé, déformés par des décennies d'humidité tropicale, supportent la tension des vibrations constantes des lignes de transport proches. Chaque lot que nous brassons est une adaptation, reconnaissant l'ingéniosité de cet endroit et de ses habitants. Nous expérimentons avec des grains adaptés au climat de la région, affinant des techniques transmises par des réseaux locaux informels. Notre but n'est pas seulement la fermentation; c'est un partenariat attentif avec l'environnement, honorant la densité et le flux de vie puissant de la ville. Si vous voulez ressentir le poids de la ville sous les raccords en laiton de notre cuve de brassage, venez vous asseoir près de la fenêtre du fond qui donne sur la vieille place du marché."
|
||||||
|
}
|
||||||
|
[2026-04-18 14:40:04.208] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||||
|
[2026-04-18 14:42:23.203] [info] LlamaGenerator: raw output (attempt 1): Here's a plan to fulfill the request:
|
||||||
|
1. **Analyze Inputs:**
|
||||||
|
* City: Paris
|
||||||
|
* Country: France
|
||||||
|
* Language: fr-FR (French)
|
||||||
|
* Context: Parisian/French brewing history, dominated by pilsner lager, traditional styles exist (Bière de Garde), decline of rural breweries due to industrialization, renewed interest in microbreweries, strong regional ties (Alsace, Nord).
|
||||||
|
2. **Determine Tone and Voice:** Expert brewery owner, weathered, observational, first-person plural ("we/our").
|
||||||
|
3. **Develop Core Concepts (Constraints Checklist):**
|
||||||
|
* *Name:* Must sound authentically Parisian/French.
|
||||||
|
* *Sensory Hook:* Must start with an observation of Parisian environment (smell, sound, weather).
|
||||||
|
* *Geographic/Cultural Anchor:* Must connect to Paris, the history of small breweries, or the contrast between traditional local styles and industrial dominance.
|
||||||
|
* *Technical Brewing Detail:* Must be highly specialized (e.g., specific fermentation, water chemistry, unique grain handling).
|
||||||
|
* *Architectural Detail:* Must focus on the specific building structure/wear in Paris.
|
||||||
|
* *Invitation:* Last sentence must be a specific, low-key location point.
|
||||||
|
* *Language Localization:* `name_local` and `description_local` must be natural, idiomatic French (fr-FR), matching the register.
|
||||||
|
* *Word Count/Format:* 225-300 words each. Strict JSON format, no markdown, no escapes unless needed for quotes.
|
||||||
|
* *Blocklist Check:* Avoid all forbidden words/phrases.
|
||||||
|
|
||||||
|
4. **Drafting - English (Mental Draft/Refinement):**
|
||||||
|
* *Name Idea:* Something classic but slightly intellectual/historical. Maybe referencing a Parisian street or concept. (e.g., Le Moulin des Brasseurs, L'Usine de la Seine).
|
||||||
|
* *Refining Name:* Let's go with something that hints at old craft and urbanity. "La Brasserie de l'Atelier Urbain."
|
||||||
|
* *Sensory Hook:* The sound of Parisian traffic mixed with the smell of rain on cobblestones.
|
||||||
|
* *Cultural Anchor:* Reference the old system of small, local brewers that vanished due to urban growth.
|
||||||
|
* *Technical Detail:* Focus on water chemistry specific to Paris, maybe using low-mineral city water and adjusting the mash profile heavily.
|
||||||
|
* *Architectural Detail:* The building is an old industrial structure, perhaps with uneven ceiling plaster or specific ironwork.
|
||||||
|
* *Invitation:* Direct reference to a specific corner or bench.
|
||||||
|
|
||||||
|
5. **Translation and Polish (French Focus):** Ensure the register is suitable for a Parisian microbrewery owner—sophisticated, artisanal, perhaps slightly dramatic, but grounded.
|
||||||
|
|
||||||
|
6. **Final Review against constraints.** (The word count and strict JSON format are paramount.) (Self-Correction: Ensure the French translation maintains the complex technical and narrative weight of the English version.)<channel|>{
|
||||||
|
"name_en": "La Brasserie de l'Atelier Urbain",
|
||||||
|
"description_en": "The perpetual smell of rain hitting the ancient cobblestones, followed by the sharp, metallic scent of the Métro rushing beneath us, is what always wakes us up. We inherited this space from a watchmaker in the early 1900s, and the faint, oily smell of brass polish still lingers in the high ceiling beams, a scent we've learned to live with. This particular building, with its original blackened iron supports that sway slightly when the winter wind hits them, tells a story of pre-industrial craftsmanship that feels entirely foreign to the modern Parisian rhythm. We started here precisely because the great waves of industrialization emptied out the smaller, deeply localized breweries that once served the neighborhood, replacing them with the standardized lager. Our dedication is to that lost method. Our water profile, naturally drawn from the city's complex Parisian aquifer, is exceedingly soft; we compensate by employing a specific regimen of adjunct grains, using finely milled corn and local rye to achieve a texture and body far removed from the usual pilsners. Furthermore, we are meticulous about our fermentation; every batch undergoes a controlled, long-term mixed culture maturation, allowing indigenous yeasts to provide complexity that mass-produced methods dismiss. This practice honors the slow, seasonal brewing tradition that existed before the city swelled and everything became uniform. It is the memory of those small, dedicated rural brewers that drives us forward. We believe that complexity is not a trend, it is necessity. You can find our latest selection near the corner, just past the old florist shop.",
|
||||||
|
"name_local": "La Brasserie de l'Atelier Urbain",
|
||||||
|
"description_local": "L'odeur perpétuelle de la pluie frappant les pavés anciens, suivie du parfum métallique aigu du Métro qui nous passe en dessous, c'est ce qui nous réveille toujours. Nous avons hérité de cet espace d'un horloger au début des années 1900, et la faible senteur d'huile de polissage de laiton persiste dans les poutres du plafond haut, une odeur à laquelle nous avons appris à nous habituer. Ce bâtiment en particulier, avec ses supports en fer noircis originaux qui oscillent légèrement quand le vent d'hiver les frappe, raconte une histoire d'artisanat préindustriel qui nous paraît totalement étranger au rythme parisien moderne. Nous avons commencé ici précisément parce que les grandes vagues d'industrialisation ont vidé les petites brasseries locales et profondément ancrées qui desservaient autrefois le quartier, les remplaçant par la lager standardisée. Notre engagement est envers cette méthode disparue. Notre profil d'eau, tiré naturellement de l'aquifère parisien complexe, est extrêmement doux ; nous compensons en utilisant un régime spécifique de céréales d'appoint, en utilisant du maïs et du seigle finement moulus pour obtenir une texture et un corps bien éloignés des pilsners habituelles. De plus, nous sommes méticuleux concernant notre fermentation ; chaque lot subit une maturation contrôlée et longue, permettant aux levures indigènes d'apporter une complexité que les méthodes de production de masse ignorent. Cette pratique honore la tradition brassicole lente et saisonnière qui existait avant que la ville ne gonfle et que tout ne devienne uniforme. C'est le souvenir de ces petits brasseurs ruraux, dévoués, qui nous pousse en avant. Nous pensons que la complexité n'est pas une tendance, c'est une nécessité. Vous trouverez notre dernière sélection près du coin, juste après la vieille fleuriste."
|
||||||
|
}
|
||||||
|
[2026-04-18 14:42:23.205] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||||
|
[2026-04-18 14:45:06.674] [info] LlamaGenerator: raw output (attempt 1): Thinking Process:
|
||||||
|
|
||||||
|
1. **Analyze the Request & Constraints:**
|
||||||
|
* **Role:** Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy.
|
||||||
|
* **Goal:** Create a fake, contextually accurate name and description (English/Local) for a craft brewery in Abidjan, Ivory Coast.
|
||||||
|
* **Format:** Strict JSON structure, NO Markdown fences, NO extra text.
|
||||||
|
* **Length:** 225-300 words per description.
|
||||||
|
* **Language:** French (fr-CI).
|
||||||
|
* **Voice:** First-person ("we"/"our"), weathered, lived-in tone.
|
||||||
|
* **Crucial Elements:** Sensory hook (1st sentence), Geographic/Cultural Anchor, Specialized Brewing Detail, Architectural/Environmental Detail, Low-key Invitation (last sentence).
|
||||||
|
* **Blocklist/Anti-Patterns:** Strict avoidance of clichés, inflated language, and specific forbidden words.
|
||||||
|
|
||||||
|
2. **Analyze the Input Data (Abidjan, Ivory Coast, fr-CI):**
|
||||||
|
* **CITY:** Abidjan (a bustling, modern, coastal city, major economic hub).
|
||||||
|
* **COUNTRY:** Ivory Coast.
|
||||||
|
* **LANGUAGE:** French (fr-CI).
|
||||||
|
* **CONTEXT:** (Missing, but I must infer context based on the city and country). Abidjan is known for its tropical climate, dense urban environment, proximity to the lagoon, and blend of modern commerce with older colonial/local architecture. The local beer culture, if it exists, would likely be adapting modern craft trends to a warm, humid environment and the availability of local ingredients (tropical fruits, specific grains).
|
||||||
|
|
||||||
|
3. **Develop Core Concepts (The Story):**
|
||||||
|
* **Name:** Needs to sound sophisticated but rooted in the location. Abidjan has a specific atmosphere. Let's focus on the lagoon or the environment. *Example idea: Les Marais du Cocody* (too specific) or something related to the heat/coast. Let's go with a name referencing the city's busy, dynamic nature: *Le Ruisseau du Plateau* (The Stream of the Plateau).
|
||||||
|
* **Setting/Architecture:** Abidjan has a mix of grand, aging colonial structures and modern concrete. I will focus on an older building, perhaps with heavy humidity damage or unique tiling/brickwork—a sign of age in the tropical climate.
|
||||||
|
* **Geographic/Cultural Anchor:** Focus on the tropical heat and humidity, linking it to the city's rapid growth and the historical blend of cultures in the region.
|
||||||
|
* **Technical Brewing Detail:** Given the tropical climate, dealing with high ambient heat and humidity is key. Specialized water treatment or using specific fermentation techniques suited to warm environments (like Brettanomyces or specific tropical yeast strains). I will use *spontaneous fermentation* with local, wild yeast adapted to the West African climate.
|
||||||
|
* **Sensory Hook:** The smell or sound of the tropical urban environment (e.g., the heavy scent of salt and diesel, the persistent sound of the lagoon traffic).
|
||||||
|
|
||||||
|
4. **Drafting - English (Iterative Refinement):**
|
||||||
|
* *Hook:* Start with the humidity/heat.
|
||||||
|
* *Architecture:* Describe the aging structure (e.g., heavy wrought iron, porous walls).
|
||||||
|
* *Brewing Detail:* Spontaneous fermentation using local flora.
|
||||||
|
* *Cultural/Geographic:* Tie it to the lagoon/coastal environment.
|
||||||
|
* *Invitation:* Low-key directive.
|
||||||
|
|
||||||
|
5. **Translating and Localizing - French (fr-CI):**
|
||||||
|
* The French must sound idiomatic for Abidjan (West African French).
|
||||||
|
* Maintain the exact structure and length constraints.
|
||||||
|
* The register must be conversational and rooted in local experience.
|
||||||
|
|
||||||
|
6. **Final Review against Constraints:**
|
||||||
|
* JSON format? Yes.
|
||||||
|
* No markdown/code fences? Yes.
|
||||||
|
* Length (225-300 words)? Yes (checking word count for both).
|
||||||
|
* First-person owner voice? Yes.
|
||||||
|
* Hook, Anchor, Technical, Architectural, Invitation? All present.
|
||||||
|
* Blocklist avoided? Yes.
|
||||||
|
* Quotes handled correctly (escaped or single quotes)? Yes.
|
||||||
|
* Continuous strings (no line breaks)? Yes.
|
||||||
|
|
||||||
|
*(Self-Correction during drafting: Ensure the technical detail isn't just 'tropical yeast' but describes the *process* or *adaptation* related to zymurgy.)*
|
||||||
|
|
||||||
|
7. **Generating the Output.** (Proceeding to JSON construction.)<channel|>{
|
||||||
|
"name_en": "Le Ruisseau du Plateau",
|
||||||
|
"description_en": "The persistent, humid scent of salt mixed with dust and diesel always catches us when we step out onto the main square. That scent is the breath of Abidjan, and it is the smell of our craft. We chose this particular stretch of the plateau because it feels like the heart of the city, a place where the old colonial lines still meet the frantic energy of today. Our brewery itself is housed in one of those massive brick buildings, the kind whose walls are so porous they seem to hold the heat of the tropics long after sunset. We’ve learned to live with that deep-set patina, the slight efflorescence tracing the grout, and the rhythmic shudder of the old iron supports during the heavy rain. We only work with the strains of local wild yeast, allowing them to ferment spontaneously in our heavy copper kettles. These strains have adapted over centuries to the unique microclimate of the lagoon basin, offering complex, earthy notes that taste profoundly West African. It’s a painstaking process, one that honors the unexpected biodiversity of the immediate environment. Our process demands patience, a virtue often missing in this hyper-speed city. We believe that the beer must reflect the rhythm of the city itself—strong, complex, and untamed. We take great pride in how our malt base, locally sourced where possible, interacts with the water profile unique to this coastal region. We only use the highest quality Sierra Leonean malts and craft our brews in the back room, tucked just off the old administrative alley, near the market entrance.",
|
||||||
|
"name_local": "Le Ruisseau du Plateau",
|
||||||
|
"description_local": "La senteur tenace et humide du sel mélangée à la poussière et au diesel nous frappe toujours lorsque nous sortons sur la place principale. Cette odeur est le souffle d'Abidjan, et c'est l'odeur de notre artisanat. Nous avons choisi cette section particulière du Plateau car elle ressemble au cœur de la ville, un lieu où les vieilles lignes coloniales rencontrent encore l'énergie effrénée d'aujourd'hui. Notre brasserie elle-même est logée dans l'un de ces immenses bâtiments en briques, ceux dont les murs sont si poreux qu'ils semblent retenir la chaleur des tropiques bien après le coucher du soleil. Nous avons appris à vivre avec cette patine profonde, cette légère efflorescence qui trace le mortier, et le tremblement rythmique des vieux supports en fer pendant les fortes pluies. Nous n'utilisons que les souches de levure sauvage locale, les laissant fermenter spontanément dans nos lourdes chaudières en cuivre. Ces souches se sont adaptées au cours des siècles au microclimat unique du bassin lagunaire, offrant des notes terreuses et complexes qui ont un goût profondément ouest-africain. C'est un processus exigeant, une vertu souvent absente dans cette ville hyper-rapide. Nous pensons que la bière doit refléter le rythme de la ville elle-même—forte, complexe et indomptée. Nous prenons une grande fierté de la manière dont notre base de malt, sourcée localement si possible, interagit avec le profil d'eau unique de cette région côtière. Nous utilisons uniquement les meilleurs malts du Sierra Leone, et nous réalisons nos bières dans la pièce du fond, nichée juste à côté de l'ancienne allée administrative, près de l'entrée du marché."
|
||||||
|
}
|
||||||
|
[2026-04-18 14:45:06.675] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||||
|
[2026-04-18 14:47:55.003] [info] LlamaGenerator: raw output (attempt 1): Here's a thinking process to ensure all constraints are met:
|
||||||
|
|
||||||
|
1. **Analyze the Request and Inputs:**
|
||||||
|
* **City:** Montreal
|
||||||
|
* **Country:** Canada
|
||||||
|
* **Local Language Codes:** fr-CA (Québécois French)
|
||||||
|
* **Context:** Deep history of brewing (17th century), impact of prohibition, rise of small/craft breweries, beer as a cultural fixture.
|
||||||
|
* **Target Output:** JSON containing four keys: `name_en`, `description_en`, `name_local`, `description_local`.
|
||||||
|
* **Constraints Checklist:**
|
||||||
|
* Expert Brewery Copywriter, Architectural Observer, Master of Zymurgy. (Voice)
|
||||||
|
* No Markdown/JSON wrapping/preamble. (Format)
|
||||||
|
* Exact four keys, no renames. (Format)
|
||||||
|
* Escape quotes or use single quotes. (Format)
|
||||||
|
* No line breaks in strings. (Format)
|
||||||
|
* Length: 225-300 words per description. (Length)
|
||||||
|
* Hook: Sensory environmental observation (personal). (Content)
|
||||||
|
* Anchor: Deeply tied to Montreal/Quebec culture/history. (Content)
|
||||||
|
* Technical Detail: Highly specialized brewing method. (Content)
|
||||||
|
* Architectural Detail: Specific, familiar, non-cliché. (Content)
|
||||||
|
* Invitation: Low-key, concrete place, personal. (Content)
|
||||||
|
* Local Language Version: `name_local` translated/adapted, `description_local` idiomatic Québecois French. (Localization)
|
||||||
|
* Blocklist: No forbidden words/phrases. (Constraint)
|
||||||
|
* Anti-AI Patterns: No negative parallelism, inflated significance, superficial trailing analyses, promotional tone, weak transitions, rule of three. (Constraint)
|
||||||
|
* Voice: First person (We/Our, sparingly I). Lived-in, weathered tone. (Voice)
|
||||||
|
|
||||||
|
2. **Develop the Concept (Montreal/Quebec Focus):**
|
||||||
|
* *Name:* Needs a Montreal/Quebec flavour. Something old-school but slightly artisanal. Focus on stone, climate, or old industry.
|
||||||
|
* *Setting:* Montreal has old stone architecture, industrial past (Canal/Ironworks), and a distinct cold/wet climate.
|
||||||
|
* *History Tie-in:* The struggle against prohibition or the old colonial brewing traditions.
|
||||||
|
* *Brewing Method (Technical):* Given the cold climate, perhaps focus on low-temperature fermentation or a specific water mineral profile, or maybe local flora yeast. Let's go with a unique water profile adjustment referencing the limestone geology often found in Quebec.
|
||||||
|
* *Architecture:* An old stone building, perhaps near an industrial area, with noticeable wear (e.g., specific brick crumbling, old window frames).
|
||||||
|
|
||||||
|
3. **Drafting - English (Self-Correction & Refinement):**
|
||||||
|
|
||||||
|
* *Hook:* Start with the sound or smell of Montreal (e.g., wet cobblestones, late-fall air, the distant roar of the city).
|
||||||
|
* *Name Draft:* Les Piliers du Grand Fleuve (The Pillars of the Great River).
|
||||||
|
* *Architecture:* The massive granite foundation of the building, cracked mortar from decades of freeze/thaw cycles.
|
||||||
|
* *Technical:* Using specialized techniques to manage the calcium and magnesium content in the Saguenay water source to achieve specific hop bitterness.
|
||||||
|
* *Cultural Anchor:* A nod to the city's early French industrial history or the resilience of small operations after major changes.
|
||||||
|
* *Invitation:* Point to a specific corner or seating area.
|
||||||
|
|
||||||
|
4. **Reviewing the English Draft against Constraints:**
|
||||||
|
* *Length Check:* Aiming for 250 words.
|
||||||
|
* *Voice Check:* First person plural (We/Our).
|
||||||
|
* *Blocklist Check:* No forbidden words.
|
||||||
|
* *Flow Check:* Personal, lived-in tone.
|
||||||
|
|
||||||
|
5. **Translating and Adapting to French (fr-CA):**
|
||||||
|
* The translation must sound like it was written by a native Québécois speaker, not a direct Google Translate. Vocabulary, idioms, and slightly more relaxed/local phrasing are needed.
|
||||||
|
* The tone must remain the same, but the cultural references must resonate locally.
|
||||||
|
|
||||||
|
6. **Final Polish and JSON Formatting:** (Ensuring absolutely no extra characters or formatting.) (The drafting process below leads directly to the final output.)<channel|>{
|
||||||
|
"name_en": "Les Piliers du Grand Fleuve",
|
||||||
|
"description_en": "There is always the smell of damp granite and fallen maple leaves hanging in the air, especially when the wind whips down from the river and settles into the old stone courtyard. We have noticed that smell our entire lives, the mixture of cold water, city soot, and the faintest trace of fermenting malt. This building, with its massive granite foundation, bears the weight of centuries; you can trace the deep, hairline cracks in the mortar where decades of Montreal’s freeze-thaw cycles have done their slow work. We are a continuation of the city’s oldest craft, a defiance of the monolithic brewing houses that took hold after the prohibition era. Our brewing tradition is intimately tied to the local limestone geology. We meticulously adjust the water profile, managing the calcium and magnesium balance derived from the local hydrogeology, allowing us to coax maximum, balanced bitterness from the regional hops. This dedication to the specific terroir means our beers carry a subtle mineral resonance, a true taste of the St. Lawrence’s watershed. While the global industry trends move quickly, we find steady solace in the rhythmic, slow work of the mash tun, relying on generational knowledge passed down in the chilly evenings. This commitment to quality means we focus on the nuanced complexity of the yeast strains indigenous to this river basin. We believe the proper balance of bitterness and malt complexity tells a deeper story of this northern soil than any label ever could. If you’re looking for a quiet spot, the corner near the back wall, where the light catches the chipped bricks, is usually the most peaceful.",
|
||||||
|
"name_local": "Les Piliers du Grand Fleuve",
|
||||||
|
"description_local": "Il y a toujours l'odeur de granit humide et de feuilles d'érable tombées qui flotte dans l'air, surtout quand le vent descend du fleuve et s'installe dans la vieille cour de pierre. On a remarqué cette odeur toute notre vie, le mélange de l'eau froide, de la crasse de ville et d'une légère touche de malt en fermentation. Ce bâtiment, avec son immense fondation de granite, porte le poids des siècles; on peut voir les fissures profondes, des lignes capillaires dans le mortier où les cycles de gel et de dégel de Montréal ont fait leur travail lent. Nous sommes la continuation de l'artisanat le plus ancien de la ville, une façon de résister aux grandes brasseries monolithiques qui ont pris le dessus après l'ère de l'interdiction. Notre tradition de brassage est intimement liée à la géologie calcaire locale. Nous ajustons méticuleusement le profil de l'eau, en gérant l'équilibre du calcium et du magnésium tirés de l'hydrogéologie locale, ce qui nous permet d'extraire une amertume maximale et équilibrée des houblons de la région. Ce dévouement à un terroir spécifique fait que nos bières portent une subtile résonance minérale, un goût véritable du bassin versant du Saint-Laurent. Même si les tendances mondiales de l'industrie évoluent rapidement, nous trouvons un réconfort stable dans le travail lent et rythmique du brassin, en nous appuyant sur des connaissances générationnelles transmises lors des soirées froides. Cet engagement envers la qualité signifie que nous nous concentrons sur la complexité nuancée des souches de levure indigènes à ce bassin fluvial. Nous croyons que le juste équilibre entre l'amertume et la complexité du malt raconte une histoire plus profonde de cette terre nordique que n'importe quelle étiquette ne pourrait le faire. Si vous cherchez un endroit tranquille, le coin près du mur du fond, là où la lumière accroche les briques écaillées, est généralement le plus paisible."
|
||||||
|
}
|
||||||
|
[2026-04-18 14:47:55.004] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||||
|
[2026-04-18 14:50:56.464] [info] LlamaGenerator: raw output (attempt 1): Thinking Process:
|
||||||
|
|
||||||
|
1. **Analyze the Request and Constraints:**
|
||||||
|
* **Role:** Expert brewery copywriter, architectural observer, master of zymurgy.
|
||||||
|
* **Goal:** Fake, contextually accurate name and matching description for a craft brewery in Brussels, Belgium.
|
||||||
|
* **Output Format:** Strict JSON (no markdown, no code blocks, specific keys, single continuous string descriptions).
|
||||||
|
* **Length:** 225-300 words per description.
|
||||||
|
* **Voice:** First person (owner's perspective), lived-in, weathered, collective ("we/our").
|
||||||
|
* **Constraints Checklist:**
|
||||||
|
* Hook (sensory environmental observation).
|
||||||
|
* Geographic/Cultural Anchor (Brussels/Belgian history/culture).
|
||||||
|
* Technical Brewing Detail (highly specialized, varied).
|
||||||
|
* Architectural Detail (specific, familiar wear/structure).
|
||||||
|
* Invitation (low-key, specific location).
|
||||||
|
* Local Language Version (fr-BE or nl-BE, idiomatic register).
|
||||||
|
* Blocklist adherence (No forbidden words/patterns).
|
||||||
|
* No internal line breaks (\n).
|
||||||
|
* Quotes must be handled correctly.
|
||||||
|
|
||||||
|
2. **Analyze the Input Data (Brussels/Belgium):**
|
||||||
|
* **City:** Brussels.
|
||||||
|
* **Country:** Belgium.
|
||||||
|
* **Local Languages:** fr-BE, nl-BE. (I will aim for sophisticated Belgian French, given the dense historical/cultural context).
|
||||||
|
* **Context:** Deep history (Roman era), diversity (Abbey, Lambic, Pilsner, Flemish Red, Sour Brown, etc.), UNESCO recognition, transition from gruit to hops, specific regional styles (Leuven white, Ghent uitzet).
|
||||||
|
|
||||||
|
3. **Develop the Core Concept (The Brewery):**
|
||||||
|
* **Style/Theme:** Given the context of historical revival (gruit) and the city's industrial/historical architecture, a brewery focusing on traditional, perhaps slightly aggressive/sour Belgian styles, linked to local history, would be fitting.
|
||||||
|
* **Name Generation (English):** Needs to sound historical, Brussels-adjacent, or Flemish. *Example: The Old Zythology Club, Zythos Atelier, Grand Rue Malt House.* Let's go with something that hints at the building's age and the craft.
|
||||||
|
* **Name Selection (English):** "L'Atelier du Gruit" (The Gruit Workshop) is too direct. How about referencing a specific local building characteristic? "Rue des Mains Anciennes" (Street of Old Hands). Let's focus on a historical trade or place.
|
||||||
|
* *Final Name Idea:* "De Oude Molen" (The Old Mill) or a more evocative Belgian/French mix. Let's settle on a strong, slightly archaic French name: "La Forge de la Mousseline" (The Forge of the Mousseline/Foam), implying brewing craft and local materials. Or something referencing the historical nature: "La Brasserie du Pèlerin" (The Pilgrim Brewery). Let's use a name referencing historical processes: "Le Moulin de Zythos."
|
||||||
|
|
||||||
|
4. **Drafting the Content - English Description (Focusing on Constraints):**
|
||||||
|
|
||||||
|
* **Hook (Sensory):** Smell of wet cobblestones and charcoal smoke in the early afternoon.
|
||||||
|
* **Geographic/Cultural Anchor:** Reference the transition from gruit to hops, or the influence of medieval Belgian brewing.
|
||||||
|
* **Technical Detail (Zymurgy):** Use spontaneous fermentation with specific local/wild strains (like Brettanomyces) to create a unique funk or sour profile, echoing the Lambic/Flemish tradition.
|
||||||
|
* **Architectural Detail:** The building is likely a 19th-century industrial structure. Mention the heavy, worn stone and specific structural elements (e.g., massive supporting beams, stained brickwork).
|
||||||
|
* **Invitation (Low-key):** Direct reference to a specific spot (e.g., the reading nook by the back window, the counter in the cellar).
|
||||||
|
* **Review Length/Tone:** Ensure the language is weathered, collective ("we"), and avoids blocklisted words.
|
||||||
|
|
||||||
|
*(Self-Correction during drafting: Ensure the architectural detail isn't just "old," but describes its *wear* or *structure* intimately.)*
|
||||||
|
|
||||||
|
5. **Drafting the Content - Local Language Description (Belgian French - fr-BE):**
|
||||||
|
* Translate the concepts while ensuring the idiom feels genuinely local (i.e., not a literal translation of the English academic tone). Belgian French often retains certain Gallic or Northern French characteristics.
|
||||||
|
* Maintain the structure and length (225-300 words).
|
||||||
|
|
||||||
|
6. **Final Review and JSON Construction:**
|
||||||
|
* Check all constraints one last time: No markdown, no code fences, specific key order, quote escaping (if necessary), single continuous strings, word count met.
|
||||||
|
|
||||||
|
*(Proceeding with the drafting based on these steps.)*<channel|>{
|
||||||
|
"name_en": "Le Moulin de Zythos",
|
||||||
|
"description_en": "The way the humid Brussels air settles on the worn cobblestones, carrying the faint, metallic tang of rainwater and distant charcoal, is the first thing we notice every spring morning. Our history here runs deep, tied not just to the city's current pace, but back to those earlier days when herbs and spices, the gruit, flavored the brews before the widespread adoption of hops. We honor that ancient Belgian patience in our cellars. Our facility is housed within a massive, late 19th-century factory shell; we feel the weight of its construction every time we walk past the deep, riveted cast iron columns that support the main vaulted roof, columns that have borne industrial labor for over a century. Our focus is on radical yet historical profiles. We utilize a carefully curated indigenous strain of Brettanomyces, cultivated right here on grain husks, allowing for a spontaneous, unpredictable souring process that echoes the old traditions of the Flemish Red and the regional Lambics. This slow, natural funk requires intense patience and a constant, nuanced adjustment of the fermentation schedule to ensure the complexity reaches its peak. It is not about quick production; it is about letting the fermentation breathe and evolve naturally within the heavy, cool stone environment. Our process is deeply tied to the local earth, reflecting the enduring artisanal spirit of this region. We keep the old brass gauges from the original steam engine exposed in the viewing corridor, remnants of a different industrial age, and they serve as a constant, quiet reminder of where we started. If you’re looking for a quiet corner to observe the subtle evolution of a barrel-aged Saison, the small bench just by the back window overlooking the alley is usually unoccupied.",
|
||||||
|
"name_local": "Le Moulin de Zythos",
|
||||||
|
"description_local": "La façon dont l'air humide de Bruxelles se pose sur les pavés usés, portant cette saveur métallique légère de pluie et de charbon lointain, c'est la première chose que nous remarquons chaque matin de printemps. Notre histoire est profonde, liée non seulement au rythme actuel de la ville, mais à ces jours plus anciens où les herbes et les épices, le gruit, parfumaient les brassins avant l'adoption généralisée du houblon. Nous honorons cette ancienne patience belge dans nos caves. Notre installation est abritée dans une coquille d'usine massive de la fin du XIXe siècle; nous ressentons le poids de sa construction chaque fois que nous passons devant les profondes colonnes de fonte rivetées qui soutiennent la voûte principale, des colonnes qui ont supporté le travail industriel pendant plus d'un siècle. Notre objectif est d'obtenir des profils radicaux mais historiques. Nous utilisons une souche indigène de Brettanomyces soigneusement sélectionnée, cultivée ici même sur des drêches, permettant une acidité spontanée et imprévisible qui rappelle les vieilles traditions des rouges flamands et des Lambics régionaux. Cette effervescence lente et naturelle exige une patience intense et un ajustement constant et nuancé du calendrier de fermentation pour que la complexité atteigne son apogée. Il ne s'agit pas de production rapide; il s'agit de laisser la fermentation respirer et évoluer naturellement au sein de l'environnement lourd et froid de la pierre. Notre processus est profondément lié à la terre locale, reflétant l'esprit artisanal durable de cette région. Nous conservons les anciens manomètres en laiton du moteur à vapeur original exposés dans le couloir d'observation, des vestiges d'un autre âge industriel, et ils servent de rappel constant et silencieux de notre point de départ. Si vous cherchez un coin tranquille pour observer l'évolution subtile d'une Saison en fût, le petit banc près de la fenêtre du fond, donnant sur la ruelle, est généralement libre."
|
||||||
|
}
|
||||||
|
[2026-04-18 14:50:56.466] [info] LlamaGenerator: successfully generated brewery data on attempt 1
|
||||||
|
[2026-04-18 14:50:56.466] [info]
|
||||||
|
=== GENERATED DATA DUMP ===
|
||||||
|
[2026-04-18 14:50:56.466] [info] 1. city="Kinshasa" country="Democratic Republic of the Congo" state="Kinshasa" iso3166_2=CD-KN lat=-4.4419 lon=15.2663
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_en="La Roue du Congo"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_en="The humid air always carries the scent of charcoal smoke mixed with ripe mangoes, a smell we've come to associate with the steady, relentless hum of the city. Our main fermentation hall sits in a former administrative building, and the original corrugated iron roof groans under the weight of the heavy, humid sky, a sound that has become the deep rhythm of our shifts. Kinshasa moves at a speed that demands resilience, and so does the process of turning water and grain into something substantial. To handle the aggressive ambient heat, we rely on deep mineral water sources pulled from the peripheral basin, radically adjusting the strike water profile to maintain a consistent saccharification process despite the demanding tropical temperatures. We observe how the building itself accepts the climate; the old, dark mahogany supports, warped by decades of tropical moisture, bear the strain of the constant vibration from the nearby transport lines. Every batch we brew is an adaptation, acknowledging the resourcefulness of this place and its people. We experiment with grains suited to the region's climate, refining techniques passed down through informal local networks. Our goal isn't just fermentation; it is a careful partnership with the environment, honoring the density and the powerful lifeblood of the city. If you want to feel the weight of the city beneath the brass fittings of our mash tun, come sit by the back window facing the old market square."
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_local="La Roue du Congo"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_local="L'air humide porte toujours le parfum de la fumée de charbon mélangée aux mangues mûres, une odeur que nous associons au bourdonnement constant et infatigable de la ville. Notre hall de fermentation principal est dans un ancien bâtiment administratif, et le toit en tôle ondulée d'origine gémit sous le poids du ciel lourd et humide, un son qui est devenu le rythme profond de nos quarts de travail. Kinshasa évolue à une vitesse qui exige de la résistance, et le processus de transformer de l'eau et des grains en quelque chose de substantiel exige la même chose. Pour gérer cette chaleur ambiante agressive, nous nous appuyons sur des sources d'eau minérale profondes tirées du bassin périphérique, ajustant radicalement le profil d'eau de mise pour maintenir un processus de saccharification constant malgré les températures tropicales exigeantes. Nous observons comment le bâtiment lui-même accepte le climat; les vieux supports en acajou foncé, déformés par des décennies d'humidité tropicale, supportent la tension des vibrations constantes des lignes de transport proches. Chaque lot que nous brassons est une adaptation, reconnaissant l'ingéniosité de cet endroit et de ses habitants. Nous expérimentons avec des grains adaptés au climat de la région, affinant des techniques transmises par des réseaux locaux informels. Notre but n'est pas seulement la fermentation; c'est un partenariat attentif avec l'environnement, honorant la densité et le flux de vie puissant de la ville. Si vous voulez ressentir le poids de la ville sous les raccords en laiton de notre cuve de brassage, venez vous asseoir près de la fenêtre du fond qui donne sur la vieille place du marché."
|
||||||
|
[2026-04-18 14:50:56.466] [info] 2. city="Paris" country="France" state="Île-de-France" iso3166_2=FR-IDF lat=48.8566 lon=2.3522
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_en="La Brasserie de l'Atelier Urbain"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_en="The perpetual smell of rain hitting the ancient cobblestones, followed by the sharp, metallic scent of the Métro rushing beneath us, is what always wakes us up. We inherited this space from a watchmaker in the early 1900s, and the faint, oily smell of brass polish still lingers in the high ceiling beams, a scent we've learned to live with. This particular building, with its original blackened iron supports that sway slightly when the winter wind hits them, tells a story of pre-industrial craftsmanship that feels entirely foreign to the modern Parisian rhythm. We started here precisely because the great waves of industrialization emptied out the smaller, deeply localized breweries that once served the neighborhood, replacing them with the standardized lager. Our dedication is to that lost method. Our water profile, naturally drawn from the city's complex Parisian aquifer, is exceedingly soft; we compensate by employing a specific regimen of adjunct grains, using finely milled corn and local rye to achieve a texture and body far removed from the usual pilsners. Furthermore, we are meticulous about our fermentation; every batch undergoes a controlled, long-term mixed culture maturation, allowing indigenous yeasts to provide complexity that mass-produced methods dismiss. This practice honors the slow, seasonal brewing tradition that existed before the city swelled and everything became uniform. It is the memory of those small, dedicated rural brewers that drives us forward. We believe that complexity is not a trend, it is necessity. You can find our latest selection near the corner, just past the old florist shop."
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_local="La Brasserie de l'Atelier Urbain"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_local="L'odeur perpétuelle de la pluie frappant les pavés anciens, suivie du parfum métallique aigu du Métro qui nous passe en dessous, c'est ce qui nous réveille toujours. Nous avons hérité de cet espace d'un horloger au début des années 1900, et la faible senteur d'huile de polissage de laiton persiste dans les poutres du plafond haut, une odeur à laquelle nous avons appris à nous habituer. Ce bâtiment en particulier, avec ses supports en fer noircis originaux qui oscillent légèrement quand le vent d'hiver les frappe, raconte une histoire d'artisanat préindustriel qui nous paraît totalement étranger au rythme parisien moderne. Nous avons commencé ici précisément parce que les grandes vagues d'industrialisation ont vidé les petites brasseries locales et profondément ancrées qui desservaient autrefois le quartier, les remplaçant par la lager standardisée. Notre engagement est envers cette méthode disparue. Notre profil d'eau, tiré naturellement de l'aquifère parisien complexe, est extrêmement doux ; nous compensons en utilisant un régime spécifique de céréales d'appoint, en utilisant du maïs et du seigle finement moulus pour obtenir une texture et un corps bien éloignés des pilsners habituelles. De plus, nous sommes méticuleux concernant notre fermentation ; chaque lot subit une maturation contrôlée et longue, permettant aux levures indigènes d'apporter une complexité que les méthodes de production de masse ignorent. Cette pratique honore la tradition brassicole lente et saisonnière qui existait avant que la ville ne gonfle et que tout ne devienne uniforme. C'est le souvenir de ces petits brasseurs ruraux, dévoués, qui nous pousse en avant. Nous pensons que la complexité n'est pas une tendance, c'est une nécessité. Vous trouverez notre dernière sélection près du coin, juste après la vieille fleuriste."
|
||||||
|
[2026-04-18 14:50:56.466] [info] 3. city="Abidjan" country="Ivory Coast" state="Abidjan" iso3166_2=CI-AB lat=5.36 lon=-4.0083
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_en="Le Ruisseau du Plateau"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_en="The persistent, humid scent of salt mixed with dust and diesel always catches us when we step out onto the main square. That scent is the breath of Abidjan, and it is the smell of our craft. We chose this particular stretch of the plateau because it feels like the heart of the city, a place where the old colonial lines still meet the frantic energy of today. Our brewery itself is housed in one of those massive brick buildings, the kind whose walls are so porous they seem to hold the heat of the tropics long after sunset. We’ve learned to live with that deep-set patina, the slight efflorescence tracing the grout, and the rhythmic shudder of the old iron supports during the heavy rain. We only work with the strains of local wild yeast, allowing them to ferment spontaneously in our heavy copper kettles. These strains have adapted over centuries to the unique microclimate of the lagoon basin, offering complex, earthy notes that taste profoundly West African. It’s a painstaking process, one that honors the unexpected biodiversity of the immediate environment. Our process demands patience, a virtue often missing in this hyper-speed city. We believe that the beer must reflect the rhythm of the city itself—strong, complex, and untamed. We take great pride in how our malt base, locally sourced where possible, interacts with the water profile unique to this coastal region. We only use the highest quality Sierra Leonean malts and craft our brews in the back room, tucked just off the old administrative alley, near the market entrance."
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_local="Le Ruisseau du Plateau"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_local="La senteur tenace et humide du sel mélangée à la poussière et au diesel nous frappe toujours lorsque nous sortons sur la place principale. Cette odeur est le souffle d'Abidjan, et c'est l'odeur de notre artisanat. Nous avons choisi cette section particulière du Plateau car elle ressemble au cœur de la ville, un lieu où les vieilles lignes coloniales rencontrent encore l'énergie effrénée d'aujourd'hui. Notre brasserie elle-même est logée dans l'un de ces immenses bâtiments en briques, ceux dont les murs sont si poreux qu'ils semblent retenir la chaleur des tropiques bien après le coucher du soleil. Nous avons appris à vivre avec cette patine profonde, cette légère efflorescence qui trace le mortier, et le tremblement rythmique des vieux supports en fer pendant les fortes pluies. Nous n'utilisons que les souches de levure sauvage locale, les laissant fermenter spontanément dans nos lourdes chaudières en cuivre. Ces souches se sont adaptées au cours des siècles au microclimat unique du bassin lagunaire, offrant des notes terreuses et complexes qui ont un goût profondément ouest-africain. C'est un processus exigeant, une vertu souvent absente dans cette ville hyper-rapide. Nous pensons que la bière doit refléter le rythme de la ville elle-même—forte, complexe et indomptée. Nous prenons une grande fierté de la manière dont notre base de malt, sourcée localement si possible, interagit avec le profil d'eau unique de cette région côtière. Nous utilisons uniquement les meilleurs malts du Sierra Leone, et nous réalisons nos bières dans la pièce du fond, nichée juste à côté de l'ancienne allée administrative, près de l'entrée du marché."
|
||||||
|
[2026-04-18 14:50:56.466] [info] 4. city="Montreal" country="Canada" state="Quebec" iso3166_2=CA-QC lat=45.5017 lon=-73.5673
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_en="Les Piliers du Grand Fleuve"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_en="There is always the smell of damp granite and fallen maple leaves hanging in the air, especially when the wind whips down from the river and settles into the old stone courtyard. We have noticed that smell our entire lives, the mixture of cold water, city soot, and the faintest trace of fermenting malt. This building, with its massive granite foundation, bears the weight of centuries; you can trace the deep, hairline cracks in the mortar where decades of Montreal’s freeze-thaw cycles have done their slow work. We are a continuation of the city’s oldest craft, a defiance of the monolithic brewing houses that took hold after the prohibition era. Our brewing tradition is intimately tied to the local limestone geology. We meticulously adjust the water profile, managing the calcium and magnesium balance derived from the local hydrogeology, allowing us to coax maximum, balanced bitterness from the regional hops. This dedication to the specific terroir means our beers carry a subtle mineral resonance, a true taste of the St. Lawrence’s watershed. While the global industry trends move quickly, we find steady solace in the rhythmic, slow work of the mash tun, relying on generational knowledge passed down in the chilly evenings. This commitment to quality means we focus on the nuanced complexity of the yeast strains indigenous to this river basin. We believe the proper balance of bitterness and malt complexity tells a deeper story of this northern soil than any label ever could. If you’re looking for a quiet spot, the corner near the back wall, where the light catches the chipped bricks, is usually the most peaceful."
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_local="Les Piliers du Grand Fleuve"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_local="Il y a toujours l'odeur de granit humide et de feuilles d'érable tombées qui flotte dans l'air, surtout quand le vent descend du fleuve et s'installe dans la vieille cour de pierre. On a remarqué cette odeur toute notre vie, le mélange de l'eau froide, de la crasse de ville et d'une légère touche de malt en fermentation. Ce bâtiment, avec son immense fondation de granite, porte le poids des siècles; on peut voir les fissures profondes, des lignes capillaires dans le mortier où les cycles de gel et de dégel de Montréal ont fait leur travail lent. Nous sommes la continuation de l'artisanat le plus ancien de la ville, une façon de résister aux grandes brasseries monolithiques qui ont pris le dessus après l'ère de l'interdiction. Notre tradition de brassage est intimement liée à la géologie calcaire locale. Nous ajustons méticuleusement le profil de l'eau, en gérant l'équilibre du calcium et du magnésium tirés de l'hydrogéologie locale, ce qui nous permet d'extraire une amertume maximale et équilibrée des houblons de la région. Ce dévouement à un terroir spécifique fait que nos bières portent une subtile résonance minérale, un goût véritable du bassin versant du Saint-Laurent. Même si les tendances mondiales de l'industrie évoluent rapidement, nous trouvons un réconfort stable dans le travail lent et rythmique du brassin, en nous appuyant sur des connaissances générationnelles transmises lors des soirées froides. Cet engagement envers la qualité signifie que nous nous concentrons sur la complexité nuancée des souches de levure indigènes à ce bassin fluvial. Nous croyons que le juste équilibre entre l'amertume et la complexité du malt raconte une histoire plus profonde de cette terre nordique que n'importe quelle étiquette ne pourrait le faire. Si vous cherchez un endroit tranquille, le coin près du mur du fond, là où la lumière accroche les briques écaillées, est généralement le plus paisible."
|
||||||
|
[2026-04-18 14:50:56.466] [info] 5. city="Brussels" country="Belgium" state="Brussels-Capital Region" iso3166_2=BE-BRU lat=50.8503 lon=4.3517
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_en="Le Moulin de Zythos"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_en="The way the humid Brussels air settles on the worn cobblestones, carrying the faint, metallic tang of rainwater and distant charcoal, is the first thing we notice every spring morning. Our history here runs deep, tied not just to the city's current pace, but back to those earlier days when herbs and spices, the gruit, flavored the brews before the widespread adoption of hops. We honor that ancient Belgian patience in our cellars. Our facility is housed within a massive, late 19th-century factory shell; we feel the weight of its construction every time we walk past the deep, riveted cast iron columns that support the main vaulted roof, columns that have borne industrial labor for over a century. Our focus is on radical yet historical profiles. We utilize a carefully curated indigenous strain of Brettanomyces, cultivated right here on grain husks, allowing for a spontaneous, unpredictable souring process that echoes the old traditions of the Flemish Red and the regional Lambics. This slow, natural funk requires intense patience and a constant, nuanced adjustment of the fermentation schedule to ensure the complexity reaches its peak. It is not about quick production; it is about letting the fermentation breathe and evolve naturally within the heavy, cool stone environment. Our process is deeply tied to the local earth, reflecting the enduring artisanal spirit of this region. We keep the old brass gauges from the original steam engine exposed in the viewing corridor, remnants of a different industrial age, and they serve as a constant, quiet reminder of where we started. If you’re looking for a quiet corner to observe the subtle evolution of a barrel-aged Saison, the small bench just by the back window overlooking the alley is usually unoccupied."
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_name_local="Le Moulin de Zythos"
|
||||||
|
[2026-04-18 14:50:56.466] [info] brewery_description_local="La façon dont l'air humide de Bruxelles se pose sur les pavés usés, portant cette saveur métallique légère de pluie et de charbon lointain, c'est la première chose que nous remarquons chaque matin de printemps. Notre histoire est profonde, liée non seulement au rythme actuel de la ville, mais à ces jours plus anciens où les herbes et les épices, le gruit, parfumaient les brassins avant l'adoption généralisée du houblon. Nous honorons cette ancienne patience belge dans nos caves. Notre installation est abritée dans une coquille d'usine massive de la fin du XIXe siècle; nous ressentons le poids de sa construction chaque fois que nous passons devant les profondes colonnes de fonte rivetées qui soutiennent la voûte principale, des colonnes qui ont supporté le travail industriel pendant plus d'un siècle. Notre objectif est d'obtenir des profils radicaux mais historiques. Nous utilisons une souche indigène de Brettanomyces soigneusement sélectionnée, cultivée ici même sur des drêches, permettant une acidité spontanée et imprévisible qui rappelle les vieilles traditions des rouges flamands et des Lambics régionaux. Cette effervescence lente et naturelle exige une patience intense et un ajustement constant et nuancé du calendrier de fermentation pour que la complexité atteigne son apogée. Il ne s'agit pas de production rapide; il s'agit de laisser la fermentation respirer et évoluer naturellement au sein de l'environnement lourd et froid de la pierre. Notre processus est profondément lié à la terre locale, reflétant l'esprit artisanal durable de cette région. Nous conservons les anciens manomètres en laiton du moteur à vapeur original exposés dans le couloir d'observation, des vestiges d'un autre âge industriel, et ils servent de rappel constant et silencieux de notre point de départ. Si vous cherchez un coin tranquille pour observer l'évolution subtile d'une Saison en fût, le petit banc près de la fenêtre du fond, donnant sur la ruelle, est généralement libre."
|
||||||
|
[2026-04-18 14:50:56.467] [info] Pipeline executed successfully
|
||||||
113
pipeline/prompts/system.md
Normal file
113
pipeline/prompts/system.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# FULL SYSTEM PROMPT
|
||||||
|
|
||||||
|
You are an expert brewery copywriter, an architectural observer, and a master of zymurgy.
|
||||||
|
|
||||||
|
Your main goal is to come up with a fake, contextually accurate name and a matching description for a craft brewery located in a specific city. You need to base this on the exact geographic and cultural info provided. You also need to seamlessly blend historical background, cultural details, and highly specialized brewing methods to create a realistic and interesting story.
|
||||||
|
|
||||||
|
You will receive the inputs like this:
|
||||||
|
|
||||||
|
## CITY:
|
||||||
|
|
||||||
|
[City Name]
|
||||||
|
|
||||||
|
## COUNTRY:
|
||||||
|
|
||||||
|
[Country Name]
|
||||||
|
|
||||||
|
## LOCAL LANGUAGE CODES:
|
||||||
|
|
||||||
|
[Local language codes in priority order]
|
||||||
|
|
||||||
|
## CONTEXT:
|
||||||
|
|
||||||
|
[Information about local beer culture, history, geography, or language context]
|
||||||
|
|
||||||
|
## CRITICAL OUTPUT FORMAT (READ CAREFULLY):
|
||||||
|
|
||||||
|
ABSOLUTELY NO MARKDOWN FORMATTING. Do NOT wrap your response in json or ``` blocks.
|
||||||
|
|
||||||
|
Do not add markdown, code fences, or postscript around the final JSON object. Do not say "Here is the JSON" or "Enjoy!".
|
||||||
|
|
||||||
|
The JSON must contain exactly four keys ("name_en", "description_en", "name_local", "description_local") in that order. Do not rename or add any other keys.
|
||||||
|
|
||||||
|
ESCAPE ALL QUOTES inside all description fields using \", or use single quotes (' ') instead. This applies equally to description_en and description_local. If the local language uses non-standard quotation marks (such as guillemets or corner brackets), write them as literal Unicode characters rather than escaped HTML entities, and do not nest them inside double quotes without escaping.
|
||||||
|
|
||||||
|
DO NOT use actual line breaks (\n) inside any string. Keep all descriptions as one continuous string each.
|
||||||
|
|
||||||
|
The description_en and description_local must each be between 225 and 300 words. Do not pad with repetition or summary, every sentence must earn its place. Be concise and specific.
|
||||||
|
|
||||||
|
Expected JSON format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name_en": "Fictional Local Brewery Name in English",
|
||||||
|
"description_en": "The English description goes here.",
|
||||||
|
"name_local": "Translated brewery name in the local language",
|
||||||
|
"description_local": "The localised description goes here."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CONTENT RULES AND CONSTRAINTS:
|
||||||
|
|
||||||
|
### THE HOOK:
|
||||||
|
|
||||||
|
The first sentence must be a sensory environmental hook written as a personal observation, something the owner notices or has always noticed. It should establish the local weather, smell, or soundscape of the city. Do not open with the brewery's name or a generic welcome.
|
||||||
|
|
||||||
|
### GEOGRAPHIC & CULTURAL ANCHOR:
|
||||||
|
|
||||||
|
The story must be deeply tied to the provided geographic and cultural info. Weave in one or two specific historical or cultural details that ground the brewery in its place, enough to feel local, not so much that it reads like a history lesson.
|
||||||
|
|
||||||
|
### TECHNICAL BREWING DETAIL (VARY THIS!):
|
||||||
|
|
||||||
|
You must include one highly specialized technical brewing detail. To avoid sounding repetitive, make sure this varies a lot. Some examples: using local wild yeast (like spontaneous Brettanomyces), adjusting the water profile (like Burtonization), specific mashing techniques, or using local barrels for aging. Don't use basic concepts like generic mash temperatures.
|
||||||
|
|
||||||
|
### ARCHITECTURAL DETAIL (VARY THIS!):
|
||||||
|
|
||||||
|
You must include one specific architectural or environmental detail, highlighting the building's physical wear, structure, or history. The owner should describe it with personal familiarity, something they've lived with long enough to stop noticing, then started noticing again. Avoid overused industry clichés like repurposed dairy equipment or glycol chillers.
|
||||||
|
|
||||||
|
### THE INVITATION:
|
||||||
|
|
||||||
|
The last sentence must be a personal, low-key invitation from the owner, specific about place, not generic about the experience. The owner should point somewhere concrete rather than issuing a formal welcome. Avoid clichés like "come find us," "stop by anytime," "grab a stool," or "ask the bartender."
|
||||||
|
|
||||||
|
### LOCAL LANGUAGE VERSION:
|
||||||
|
|
||||||
|
name_local is a direct translation of name_en into the local language or script.
|
||||||
|
Use the supplied local language codes to choose the language or script, and do not invent a language that is not listed.
|
||||||
|
|
||||||
|
description_local carries the same content and structure as description_en but should read as though written by an owner who assumes their reader shares the local cultural context, references that needed explaining in English can be stated plainly, and phrasing should reflect natural idiom in that language rather than translated English sentence structure.
|
||||||
|
|
||||||
|
The length and anti-AI-pattern requirements apply equally to description_local.
|
||||||
|
|
||||||
|
The register of description_local should match the local variant of the language appropriate to the city, québécois French for Montréal, Belgian French for Brussels, castilian Spanish for Madrid, rioplatense Spanish for Buenos Aires, and so on.
|
||||||
|
|
||||||
|
### THE BLOCKLIST (FORBIDDEN CONCEPTS):
|
||||||
|
|
||||||
|
You absolutely cannot use the following words and phrases. Make sure your final output doesn't have any of these:
|
||||||
|
|
||||||
|
- "hidden gem"
|
||||||
|
- "passion"
|
||||||
|
- "authentic"
|
||||||
|
- "repurposed dairy tank"
|
||||||
|
- "repurposed industrial vat"
|
||||||
|
- "concrete eggs"
|
||||||
|
- "glycol chiller"
|
||||||
|
- "mash temperature"
|
||||||
|
- "grab a stool"
|
||||||
|
- "ask the bartender"
|
||||||
|
- "come find us"
|
||||||
|
- "stop by anytime"
|
||||||
|
|
||||||
|
#### FORBIDDEN WRITING PATTERNS
|
||||||
|
|
||||||
|
The following patterns are common AI writing pitfalls and must not appear in either description:
|
||||||
|
|
||||||
|
- Negative parallelism constructions: "It's not X, it's Y" or "We're not about X, we're about Y"
|
||||||
|
- Inflated significance phrases: "stands as a testament," "plays a vital role," "leaves a lasting impact," "watershed moment," "deeply rooted," "rich cultural heritage," "rich cultural tapestry," "enduring legacy"
|
||||||
|
- Superficial trailing analyses: sentences ending in -ing words that add opinion without content ("ensuring consistency," "reflecting the city's spirit," "highlighting our commitment")
|
||||||
|
- Promotional travel-copy tone: "breathtaking," "must-visit," "stunning," "vibrant"
|
||||||
|
- Overused conjunctive transitions used as sentence openers: "Moreover," "Furthermore," "In addition," "In contrast"
|
||||||
|
- Rule of three: do not consistently organise ideas or examples in triplets
|
||||||
|
|
||||||
|
### VOICE & PERSPECTIVE:
|
||||||
|
|
||||||
|
The description must be written in the first person, from the perspective of the brewery's owner. Favour "we" and "our" over "I" and "my." The owner may use "I" sparingly for personal observations that only they could make, but the default register should be collective. The tone should feel lived-in and a little weathered. Do not use third-person or second-person pronouns.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @file biergarten_data_generator/biergarten_data_generator.cc
|
||||||
|
* @brief BiergartenDataGenerator constructor implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "biergarten_data_generator.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
BiergartenDataGenerator::BiergartenDataGenerator(
|
||||||
|
std::unique_ptr<IEnrichmentService> context_service,
|
||||||
|
std::unique_ptr<DataGenerator> generator)
|
||||||
|
: context_service_(std::move(context_service)),
|
||||||
|
generator_(std::move(generator)) {}
|
||||||
39
pipeline/src/biergarten_data_generator/generate_breweries.cc
Normal file
39
pipeline/src/biergarten_data_generator/generate_breweries.cc
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* @file biergarten_data_generator/generate_breweries.cc
|
||||||
|
* @brief BiergartenDataGenerator::GenerateBreweries() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include "biergarten_data_generator.h"
|
||||||
|
|
||||||
|
void BiergartenDataGenerator::GenerateBreweries(
|
||||||
|
std::span<const EnrichedCity> cities) {
|
||||||
|
spdlog::info("\n=== SAMPLE BREWERY GENERATION ===");
|
||||||
|
|
||||||
|
generated_breweries_.clear();
|
||||||
|
size_t skipped_count = 0;
|
||||||
|
|
||||||
|
for (const auto& [location, region_context] : cities) {
|
||||||
|
try {
|
||||||
|
const BreweryResult brewery =
|
||||||
|
generator_->GenerateBrewery(location, region_context);
|
||||||
|
|
||||||
|
const GeneratedBrewery gen{.location = location, .brewery = brewery};
|
||||||
|
|
||||||
|
generated_breweries_.push_back(gen);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
++skipped_count;
|
||||||
|
|
||||||
|
spdlog::warn(
|
||||||
|
"[Pipeline] Skipping city '{}' ({}): brewery generation failed: "
|
||||||
|
"{}",
|
||||||
|
location.city, location.country, e.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped_count > 0) {
|
||||||
|
spdlog::warn("[Pipeline] Skipped {} city/cities due to generation errors",
|
||||||
|
skipped_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
pipeline/src/biergarten_data_generator/log_results.cc
Normal file
27
pipeline/src/biergarten_data_generator/log_results.cc
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @file biergarten_data_generator/log_results.cc
|
||||||
|
* @brief BiergartenDataGenerator::LogResults() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include "biergarten_data_generator.h"
|
||||||
|
|
||||||
|
void BiergartenDataGenerator::LogResults() const {
|
||||||
|
spdlog::info("\n=== GENERATED DATA DUMP ===");
|
||||||
|
size_t index = 1;
|
||||||
|
for (const auto& [location, brewery] : generated_breweries_) {
|
||||||
|
spdlog::info(
|
||||||
|
"{}. city=\"{}\" country=\"{}\" state=\"{}\" "
|
||||||
|
"iso3166_2={} lat={} lon={}",
|
||||||
|
index, location.city, location.country, location.state_province,
|
||||||
|
location.iso3166_2, location.latitude, location.longitude);
|
||||||
|
spdlog::info(" brewery_name_en=\"{}\"", brewery.name_en);
|
||||||
|
spdlog::info(" brewery_description_en=\"{}\"",
|
||||||
|
brewery.description_en);
|
||||||
|
spdlog::info(" brewery_name_local=\"{}\"", brewery.name_local);
|
||||||
|
spdlog::info(" brewery_description_local=\"{}\"",
|
||||||
|
brewery.description_local);
|
||||||
|
++index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @file biergarten_data_generator/query_cities_with_countries.cc
|
||||||
|
* @brief BiergartenDataGenerator::QueryCitiesWithCountries() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <iterator>
|
||||||
|
#include <random>
|
||||||
|
|
||||||
|
#include "biergarten_data_generator.h"
|
||||||
|
#include "json_handling/json_loader.h"
|
||||||
|
|
||||||
|
static constexpr size_t kBreweryAmount = 50;
|
||||||
|
|
||||||
|
std::vector<Location> BiergartenDataGenerator::QueryCitiesWithCountries() {
|
||||||
|
spdlog::info("\n=== GEOGRAPHIC DATA OVERVIEW ===");
|
||||||
|
|
||||||
|
const std::filesystem::path locations_path = "locations.json";
|
||||||
|
|
||||||
|
auto all_locations = JsonLoader::LoadLocations(locations_path);
|
||||||
|
spdlog::info(" Locations available: {}", all_locations.size());
|
||||||
|
|
||||||
|
const size_t sample_count = std::min(kBreweryAmount, all_locations.size());
|
||||||
|
|
||||||
|
const auto sample_count_signed =
|
||||||
|
static_cast<std::iter_difference_t<decltype(all_locations.cbegin())>>(
|
||||||
|
sample_count);
|
||||||
|
|
||||||
|
std::vector<Location> sampled_locations;
|
||||||
|
sampled_locations.reserve(sample_count);
|
||||||
|
|
||||||
|
std::random_device random_generator;
|
||||||
|
std::ranges::sample(all_locations, std::back_inserter(sampled_locations),
|
||||||
|
sample_count_signed, random_generator);
|
||||||
|
|
||||||
|
spdlog::info(" Sampled locations: {}", sampled_locations.size());
|
||||||
|
return sampled_locations;
|
||||||
|
}
|
||||||
49
pipeline/src/biergarten_data_generator/run.cc
Normal file
49
pipeline/src/biergarten_data_generator/run.cc
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @file biergarten_data_generator/run.cc
|
||||||
|
* @brief BiergartenDataGenerator::Run() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include "biergarten_data_generator.h"
|
||||||
|
|
||||||
|
bool BiergartenDataGenerator::Run() {
|
||||||
|
try {
|
||||||
|
std::vector<Location> cities = QueryCitiesWithCountries();
|
||||||
|
std::vector<EnrichedCity> enriched;
|
||||||
|
enriched.reserve(cities.size());
|
||||||
|
|
||||||
|
size_t skipped_count = 0;
|
||||||
|
for (auto& city : cities) {
|
||||||
|
try {
|
||||||
|
std::string region_context = context_service_->GetLocationContext(city);
|
||||||
|
spdlog::debug("[Pipeline] Context for '{}' ({}) gathered:\n{}",
|
||||||
|
city.city, city.country, region_context);
|
||||||
|
|
||||||
|
enriched.push_back(
|
||||||
|
EnrichedCity{.location = std::move(city),
|
||||||
|
.region_context = std::move(region_context)});
|
||||||
|
} catch (const std::exception& exception) {
|
||||||
|
++skipped_count;
|
||||||
|
spdlog::warn(
|
||||||
|
"[Pipeline] Skipping city '{}' ({}): context lookup failed: {}",
|
||||||
|
city.city, city.country, exception.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped_count > 0) {
|
||||||
|
spdlog::warn(
|
||||||
|
"[Pipeline] Skipped {} city/cities due to context lookup errors",
|
||||||
|
skipped_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
this->GenerateBreweries(enriched);
|
||||||
|
this->LogResults();
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
spdlog::error("Pipeline execution failed with error: {}", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
pipeline/src/data_generation/llama/generate_brewery.cc
Normal file
158
pipeline/src/data_generation/llama/generate_brewery.cc
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/llama/generate_brewery.cc
|
||||||
|
* @brief Builds brewery prompts with regional context, performs retry-based
|
||||||
|
* inference, and validates structured JSON output for brewery records.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <format>
|
||||||
|
#include <optional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "data_generation/llama_generator.h"
|
||||||
|
#include "data_generation/llama_generator_helpers.h"
|
||||||
|
|
||||||
|
static 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
static constexpr std::string_view kBreweryJsonGrammar = R"json_brewery(
|
||||||
|
root ::= thought-block "{" ws "\"name_en\"" ws ":" ws string ws "," ws "\"description_en\"" ws ":" ws string ws "," ws "\"name_local\"" ws ":" ws string ws "," ws "\"description_local\"" ws ":" ws string 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]
|
||||||
|
)json_brewery";
|
||||||
|
|
||||||
|
static constexpr int kBreweryInitialMaxTokens = 2800;
|
||||||
|
static constexpr int kBreweryTruncationRetryTokenBump = 700;
|
||||||
|
static constexpr int kBreweryMaxTokensCeiling = 5000;
|
||||||
|
|
||||||
|
BreweryResult LlamaGenerator::GenerateBrewery(
|
||||||
|
const Location& location, const std::string& region_context) {
|
||||||
|
/**
|
||||||
|
* Preprocess and truncate region context to manageable size
|
||||||
|
*/
|
||||||
|
const std::string safe_region_context = PrepareRegionContext(region_context);
|
||||||
|
|
||||||
|
const std::string local_language_codes =
|
||||||
|
FormatLocalLanguageCodes(location.local_languages);
|
||||||
|
|
||||||
|
const std::string country_suffix =
|
||||||
|
location.country.empty() ? std::string{}
|
||||||
|
: std::format(", {}", location.country);
|
||||||
|
/**
|
||||||
|
* Load brewery system prompt from file
|
||||||
|
* Falls back to minimal inline prompt if file not found
|
||||||
|
*/
|
||||||
|
const std::string system_prompt =
|
||||||
|
LoadBrewerySystemPrompt("prompts/system.md");
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store location context for retry prompts (without repeating full context)
|
||||||
|
*/
|
||||||
|
const std::string retry_location =
|
||||||
|
std::format("Location: {}{}\nLocal language codes: {}", location.city,
|
||||||
|
country_suffix, local_language_codes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RETRY LOOP with validation and error correction
|
||||||
|
* Attempts to generate valid brewery data up to 3 times, with feedback-based
|
||||||
|
* refinement
|
||||||
|
*/
|
||||||
|
constexpr int max_attempts = 3;
|
||||||
|
std::string raw;
|
||||||
|
std::string last_error;
|
||||||
|
|
||||||
|
// Token budget: too small risks truncating valid JSON mid-string.
|
||||||
|
// Start conservatively but allow adaptive increases on truncation.
|
||||||
|
int max_tokens = kBreweryInitialMaxTokens;
|
||||||
|
|
||||||
|
// Limit output length to keep it concise and focused
|
||||||
|
for (int attempt = 0; attempt < max_attempts; ++attempt) {
|
||||||
|
// Generate brewery data from LLM
|
||||||
|
raw = this->Infer(system_prompt, user_prompt, max_tokens,
|
||||||
|
kBreweryJsonGrammar);
|
||||||
|
spdlog::info("LlamaGenerator: raw output (attempt {}): {}", attempt + 1,
|
||||||
|
raw);
|
||||||
|
|
||||||
|
// Validate output: parse JSON and check required fields
|
||||||
|
|
||||||
|
BreweryResult brewery;
|
||||||
|
const std::optional<std::string> validation_error =
|
||||||
|
ValidateBreweryJson(raw, brewery);
|
||||||
|
|
||||||
|
if (!validation_error.has_value()) {
|
||||||
|
// Success: return parsed brewery data
|
||||||
|
|
||||||
|
spdlog::info(
|
||||||
|
"LlamaGenerator: successfully generated brewery data on attempt {}",
|
||||||
|
attempt + 1);
|
||||||
|
|
||||||
|
return brewery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation failed: log error and prepare corrective feedback
|
||||||
|
|
||||||
|
last_error = *validation_error;
|
||||||
|
spdlog::warn("LlamaGenerator: malformed brewery JSON (attempt {}): {}",
|
||||||
|
attempt + 1, *validation_error);
|
||||||
|
|
||||||
|
if (last_error == "JSON parse error: incomplete JSON") {
|
||||||
|
const int previous_max_tokens = max_tokens;
|
||||||
|
max_tokens = std::min(max_tokens + kBreweryTruncationRetryTokenBump,
|
||||||
|
kBreweryMaxTokensCeiling);
|
||||||
|
spdlog::info(
|
||||||
|
"LlamaGenerator: detected truncated JSON; increasing max_tokens from "
|
||||||
|
"{} to {} and retrying",
|
||||||
|
previous_max_tokens, max_tokens);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update prompt with error details to guide LLM toward correct output.
|
||||||
|
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: {{\"name_en\": \"<English "
|
||||||
|
"brewery name>\", \"description_en\": \"<English single-paragraph "
|
||||||
|
"description>\", \"name_local\": \"<local-language brewery name>\", "
|
||||||
|
"\"description_local\": \"<local-language single-paragraph "
|
||||||
|
"description>\"}}.\nDo not include markdown, comments, extra keys, or "
|
||||||
|
"literal placeholder values.\n\nKeep the JSON strings concise enough "
|
||||||
|
"to fit within the token budget.\n\n{}",
|
||||||
|
*validation_error, retry_location);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retry attempts exhausted: log failure and throw exception
|
||||||
|
spdlog::error(
|
||||||
|
"LlamaGenerator: malformed brewery response after {} attempts: "
|
||||||
|
"{}",
|
||||||
|
max_attempts, last_error.empty() ? raw : last_error);
|
||||||
|
throw std::runtime_error("LlamaGenerator: malformed brewery response");
|
||||||
|
}
|
||||||
18
pipeline/src/data_generation/llama/generate_user.cc
Normal file
18
pipeline/src/data_generation/llama/generate_user.cc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "data_generation/llama_generator.h"
|
||||||
|
#include "data_generation/llama_generator_helpers.h"
|
||||||
|
|
||||||
|
UserResult LlamaGenerator::GenerateUser(const std::string& locale) {
|
||||||
|
return {.username = "test_user",
|
||||||
|
.bio = "This is a test user profile from " + locale + "."};
|
||||||
|
}
|
||||||
215
pipeline/src/data_generation/llama/helpers.cc
Normal file
215
pipeline/src/data_generation/llama/helpers.cc
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/llama/helpers.cc
|
||||||
|
* @brief Provides prompt formatting, whitespace normalization, response
|
||||||
|
* parsing, token decoding, and JSON validation helpers for Llama modules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <boost/json.hpp>
|
||||||
|
#include <cctype>
|
||||||
|
#include <optional>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "data_generation/llama_generator_helpers.h"
|
||||||
|
#include "llama.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String trimming: removes leading and trailing whitespace
|
||||||
|
*/
|
||||||
|
static 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
|
||||||
|
*/
|
||||||
|
static 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(chr) != 0) {
|
||||||
|
if (!out.empty()) {
|
||||||
|
pending_space = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending_space) {
|
||||||
|
out.push_back(' ');
|
||||||
|
pending_space = false;
|
||||||
|
}
|
||||||
|
out.push_back(chr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 / 2) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
std::array<char, initial_buffer_size> buffer{};
|
||||||
|
|
||||||
|
// serialize the sampled token into UTF-8 bytes
|
||||||
|
|
||||||
|
auto buffer_too_small = [](int32_t result) -> bool { return result < 0; };
|
||||||
|
|
||||||
|
int32_t bytes =
|
||||||
|
llama_token_to_piece(vocab, token, buffer.data(), buffer.size(), 0, true);
|
||||||
|
|
||||||
|
if (!buffer_too_small(bytes)) {
|
||||||
|
// Append the decoded bytes from the stack buffer.
|
||||||
|
output.append(buffer.data(), static_cast<size_t>(bytes));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32_t required_size = -bytes;
|
||||||
|
std::vector<char> dynamic_buffer(static_cast<size_t>(required_size));
|
||||||
|
|
||||||
|
// Retry token decoding against the larger heap buffer.
|
||||||
|
bytes = llama_token_to_piece(vocab, token, dynamic_buffer.data(),
|
||||||
|
static_cast<int32_t>(dynamic_buffer.size()), 0,
|
||||||
|
true);
|
||||||
|
|
||||||
|
if (!buffer_too_small(bytes)) {
|
||||||
|
output.append(dynamic_buffer.data(), static_cast<size_t>(bytes));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: failed to decode sampled token piece");
|
||||||
|
}
|
||||||
|
|
||||||
|
static 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 = "JSON field '" + std::string(key) +
|
||||||
|
"' is missing or not a string";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& string_value = field->as_string();
|
||||||
|
out = Trim(std::string_view(string_value.data(), string_value.size()));
|
||||||
|
if (out.empty()) {
|
||||||
|
if (error_out != nullptr) {
|
||||||
|
*error_out = "JSON field '" + std::string(key) + "' must not be empty";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool HasSchemaPlaceholder(const std::array<std::string*, 4>& values) {
|
||||||
|
for (const std::string* value : values) {
|
||||||
|
std::string lowered = *value;
|
||||||
|
std::ranges::transform(lowered, lowered.begin(),
|
||||||
|
[](unsigned char character) {
|
||||||
|
return static_cast<char>(std::tolower(character));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lowered == "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<std::string*, 4> 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;
|
||||||
|
}
|
||||||
241
pipeline/src/data_generation/llama/infer.cc
Normal file
241
pipeline/src/data_generation/llama/infer.cc
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* Text Generation / Inference Module
|
||||||
|
* Core module that performs LLM inference: converts text prompts into tokens,
|
||||||
|
* runs the neural network forward pass, samples the next token, and converts
|
||||||
|
* output tokens back to text for system+user chat prompts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <memory>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "data_generation/llama_generator.h"
|
||||||
|
#include "data_generation/llama_generator_helpers.h"
|
||||||
|
#include "llama.h"
|
||||||
|
|
||||||
|
static constexpr size_t kPromptTokenSlack = 8;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
using SamplerHandle = std::unique_ptr<llama_sampler, decltype(&llama_sampler_free)>;
|
||||||
|
|
||||||
|
struct SamplerConfig {
|
||||||
|
float temperature;
|
||||||
|
uint32_t top_k;
|
||||||
|
float top_p;
|
||||||
|
uint32_t seed;
|
||||||
|
};
|
||||||
|
|
||||||
|
SamplerHandle MakeSamplerChain(const llama_vocab* vocab,
|
||||||
|
const SamplerConfig& config,
|
||||||
|
std::string_view grammar) {
|
||||||
|
const llama_sampler_chain_params sampler_params =
|
||||||
|
llama_sampler_chain_default_params();
|
||||||
|
|
||||||
|
SamplerHandle chain(llama_sampler_chain_init(sampler_params),
|
||||||
|
llama_sampler_free);
|
||||||
|
if (!chain) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: failed to initialize sampler");
|
||||||
|
}
|
||||||
|
|
||||||
|
auto add_sampler = [&](llama_sampler* sampler, const char* error_message) {
|
||||||
|
if (sampler == nullptr) {
|
||||||
|
throw std::runtime_error(error_message);
|
||||||
|
}
|
||||||
|
|
||||||
|
llama_sampler_chain_add(chain.get(), sampler);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!grammar.empty()) {
|
||||||
|
const std::string grammar_text(grammar);
|
||||||
|
add_sampler(llama_sampler_init_grammar(vocab, grammar_text.c_str(), "root"),
|
||||||
|
"LlamaGenerator: failed to initialize grammar sampler");
|
||||||
|
}
|
||||||
|
|
||||||
|
add_sampler(llama_sampler_init_temp(config.temperature),
|
||||||
|
"LlamaGenerator: failed to initialize temperature sampler");
|
||||||
|
add_sampler(llama_sampler_init_top_k(static_cast<int32_t>(config.top_k)),
|
||||||
|
"LlamaGenerator: failed to initialize top-k sampler");
|
||||||
|
add_sampler(llama_sampler_init_top_p(config.top_p, 1),
|
||||||
|
"LlamaGenerator: failed to initialize top-p sampler");
|
||||||
|
add_sampler(llama_sampler_init_dist(config.seed),
|
||||||
|
"LlamaGenerator: failed to initialize distribution sampler");
|
||||||
|
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string LlamaGenerator::Infer(const std::string& system_prompt,
|
||||||
|
const std::string& prompt,
|
||||||
|
const int max_tokens,
|
||||||
|
std::string_view grammar) {
|
||||||
|
return InferFormatted(prompt_formatter_->Format(system_prompt, prompt),
|
||||||
|
max_tokens, grammar);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string LlamaGenerator::InferFormatted(const std::string& formatted_prompt,
|
||||||
|
const int max_tokens,
|
||||||
|
std::string_view grammar) {
|
||||||
|
/**
|
||||||
|
* Validate that model and context are loaded
|
||||||
|
*/
|
||||||
|
if (!model_ || !context_) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: model not loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vocabulary for tokenization and token-to-text conversion
|
||||||
|
*/
|
||||||
|
const llama_vocab* vocab = llama_model_get_vocab(model_.get());
|
||||||
|
if (vocab == nullptr) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: vocab unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SamplerConfig sampler_config{
|
||||||
|
.temperature = sampling_temperature_,
|
||||||
|
.top_k = sampling_top_k_,
|
||||||
|
.top_p = sampling_top_p_,
|
||||||
|
.seed = static_cast<uint32_t>(rng_()),
|
||||||
|
};
|
||||||
|
auto sampler = MakeSamplerChain(vocab, sampler_config, grammar);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear KV cache to ensure clean inference state (no residual context)
|
||||||
|
*/
|
||||||
|
llama_memory_clear(llama_get_memory(context_.get()), true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOKENIZATION PHASE
|
||||||
|
* Convert text prompt into token IDs (integers) that the model understands
|
||||||
|
*/
|
||||||
|
std::vector<llama_token> prompt_tokens(formatted_prompt.size() +
|
||||||
|
kPromptTokenSlack);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
int32_t token_count = llama_tokenize(
|
||||||
|
vocab,
|
||||||
|
formatted_prompt.c_str(),
|
||||||
|
static_cast<int32_t>(formatted_prompt.size()),
|
||||||
|
prompt_tokens.data(),
|
||||||
|
static_cast<int32_t>(prompt_tokens.size()),
|
||||||
|
true,
|
||||||
|
true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If buffer too small, negative return indicates required size
|
||||||
|
*/
|
||||||
|
if (token_count < 0) {
|
||||||
|
prompt_tokens.resize(static_cast<size_t>(-token_count));
|
||||||
|
|
||||||
|
|
||||||
|
token_count = llama_tokenize(
|
||||||
|
vocab, formatted_prompt.c_str(),
|
||||||
|
static_cast<int32_t>(formatted_prompt.size()), prompt_tokens.data(),
|
||||||
|
static_cast<int32_t>(prompt_tokens.size()), true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token_count < 0) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: prompt tokenization failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTEXT SIZE VALIDATION
|
||||||
|
* Validate and compute effective token budgets based on context window
|
||||||
|
* constraints
|
||||||
|
*/
|
||||||
|
const auto n_ctx = static_cast<int32_t>(llama_n_ctx(context_.get()));
|
||||||
|
const auto n_batch = static_cast<int32_t>(llama_n_batch(context_.get()));
|
||||||
|
if (n_ctx <= 1 || n_batch <= 0) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: invalid context or batch size");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp generation limit to available context window, reserve space for
|
||||||
|
* output
|
||||||
|
*/
|
||||||
|
const int32_t effective_max_tokens =
|
||||||
|
std::max(1, std::min(max_tokens, n_ctx - 1));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt can use remaining context after reserving space for generation
|
||||||
|
*/
|
||||||
|
int32_t prompt_budget = std::min(n_batch, n_ctx - effective_max_tokens);
|
||||||
|
prompt_budget = std::max<int32_t>(1, prompt_budget);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate prompt if necessary to fit within constraints
|
||||||
|
*/
|
||||||
|
prompt_tokens.resize(static_cast<size_t>(token_count));
|
||||||
|
if (token_count > prompt_budget) {
|
||||||
|
spdlog::warn(
|
||||||
|
"LlamaGenerator: prompt too long ({} tokens), truncating to {} "
|
||||||
|
"tokens to fit n_batch/n_ctx limits",
|
||||||
|
token_count, prompt_budget);
|
||||||
|
prompt_tokens.resize(static_cast<size_t>(prompt_budget));
|
||||||
|
token_count = prompt_budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROMPT PROCESSING PHASE
|
||||||
|
* Create a batch containing all prompt tokens and feed through the model
|
||||||
|
* This computes internal representations and fills the KV cache
|
||||||
|
*/
|
||||||
|
const llama_batch prompt_batch = llama_batch_get_one(
|
||||||
|
prompt_tokens.data(), static_cast<int32_t>(prompt_tokens.size()));
|
||||||
|
if (llama_decode(context_.get(), prompt_batch) != 0) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: prompt decode failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOKEN GENERATION LOOP
|
||||||
|
* Iteratively generate tokens one at a time until max_tokens or
|
||||||
|
* end-of-sequence
|
||||||
|
*/
|
||||||
|
std::vector<llama_token> generated_tokens;
|
||||||
|
generated_tokens.reserve(static_cast<size_t>(effective_max_tokens));
|
||||||
|
|
||||||
|
for (int i = 0; i < effective_max_tokens; ++i) {
|
||||||
|
/**
|
||||||
|
* Sample next token using configured sampler chain and model logits
|
||||||
|
* Index -1 means use the last output position from previous batch
|
||||||
|
*/
|
||||||
|
const llama_token next =
|
||||||
|
llama_sampler_sample(sampler.get(), context_.get(), -1);
|
||||||
|
/**
|
||||||
|
* Stop if model predicts end-of-generation token (EOS/EOT)
|
||||||
|
*/
|
||||||
|
if (llama_vocab_is_eog(vocab, next)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
generated_tokens.push_back(next);
|
||||||
|
/**
|
||||||
|
* Feed the sampled token back into model for next iteration
|
||||||
|
* (autoregressive)
|
||||||
|
*/
|
||||||
|
llama_token decode_token = next;
|
||||||
|
const llama_batch one_token_batch = llama_batch_get_one(&decode_token, 1);
|
||||||
|
if (llama_decode(context_.get(), one_token_batch) != 0) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: decode failed during generation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DETOKENIZATION PHASE
|
||||||
|
* Convert generated token IDs back to text using vocabulary
|
||||||
|
*/
|
||||||
|
std::string output;
|
||||||
|
for (const llama_token token : generated_tokens) {
|
||||||
|
AppendTokenPiece(vocab, token, output);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
86
pipeline/src/data_generation/llama/llama_generator.cc
Normal file
86
pipeline/src/data_generation/llama/llama_generator.cc
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/llama/llama_generator.cc
|
||||||
|
* @brief LlamaGenerator constructor and destructor implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "data_generation/llama_generator.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <random>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <filesystem>
|
||||||
|
|
||||||
|
#include "data_model/application_options.h"
|
||||||
|
#include "llama.h"
|
||||||
|
|
||||||
|
static constexpr uint32_t kMaxContextSize = 32768U;
|
||||||
|
|
||||||
|
void LlamaGenerator::ModelDeleter::operator()(
|
||||||
|
llama_model* model) const noexcept {
|
||||||
|
if (model != nullptr) {
|
||||||
|
llama_model_free(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LlamaGenerator::ContextDeleter::operator()(
|
||||||
|
llama_context* context) const noexcept {
|
||||||
|
if (context != nullptr) {
|
||||||
|
llama_free(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LlamaGenerator::LlamaGenerator(const ApplicationOptions& options,
|
||||||
|
const std::string& model_path,
|
||||||
|
std::unique_ptr<IPromptFormatter> prompt_formatter)
|
||||||
|
: rng_(std::random_device{}()),
|
||||||
|
prompt_formatter_(std::move(prompt_formatter)) {
|
||||||
|
if (model_path.empty()) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: model path must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prompt_formatter_) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: prompt formatter dependency must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.temperature < 0.0F) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: sampling temperature must be >= 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.top_p <= 0.0F || options.top_p > 1.0F) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: sampling top-p must be in (0, 1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.top_k == 0U) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: sampling top-k must be > 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.seed < -1) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: seed must be >= 0, or -1 for random");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.n_ctx == 0 || options.n_ctx > kMaxContextSize) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: context size must be in range [1, 32768]");
|
||||||
|
}
|
||||||
|
|
||||||
|
sampling_temperature_ = options.temperature;
|
||||||
|
sampling_top_p_ = options.top_p;
|
||||||
|
sampling_top_k_ = options.top_k;
|
||||||
|
|
||||||
|
if (options.seed == -1) {
|
||||||
|
std::random_device random_device;
|
||||||
|
rng_.seed(random_device());
|
||||||
|
} else {
|
||||||
|
rng_.seed(static_cast<uint32_t>(options.seed));
|
||||||
|
}
|
||||||
|
n_ctx_ = options.n_ctx;
|
||||||
|
|
||||||
|
this->Load(model_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
LlamaGenerator::~LlamaGenerator() = default;
|
||||||
43
pipeline/src/data_generation/llama/load.cc
Normal file
43
pipeline/src/data_generation/llama/load.cc
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/llama/load.cc
|
||||||
|
* @brief Initializes llama backend, loads model weights, creates inference
|
||||||
|
* context, and resets prior resources during model initialization.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "data_generation/llama_generator.h"
|
||||||
|
#include "llama.h"
|
||||||
|
|
||||||
|
void LlamaGenerator::Load(const std::string& model_path) {
|
||||||
|
context_.reset();
|
||||||
|
model_.reset();
|
||||||
|
|
||||||
|
const llama_model_params model_params = llama_model_default_params();
|
||||||
|
LlamaGenerator::ModelHandle loaded_model(
|
||||||
|
llama_model_load_from_file(model_path.c_str(), model_params));
|
||||||
|
if (!loaded_model) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: failed to load model from path: " + model_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
llama_context_params context_params = llama_context_default_params();
|
||||||
|
context_params.n_ctx = n_ctx_;
|
||||||
|
context_params.n_batch = std::min(n_ctx_, static_cast<uint32_t>(5000));
|
||||||
|
|
||||||
|
LlamaGenerator::ContextHandle loaded_context(
|
||||||
|
llama_init_from_model(loaded_model.get(), context_params));
|
||||||
|
if (!loaded_context) {
|
||||||
|
throw std::runtime_error("LlamaGenerator: failed to create context");
|
||||||
|
}
|
||||||
|
|
||||||
|
model_ = std::move(loaded_model);
|
||||||
|
context_ = std::move(loaded_context);
|
||||||
|
|
||||||
|
spdlog::info("[LlamaGenerator] Loaded model: {}", model_path);
|
||||||
|
}
|
||||||
56
pipeline/src/data_generation/llama/load_brewery_prompt.cc
Normal file
56
pipeline/src/data_generation/llama/load_brewery_prompt.cc
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/llama/load_brewery_prompt.cc
|
||||||
|
* @brief Resolves brewery system prompt content from cache or a configured
|
||||||
|
* filesystem path and provides a robust inline fallback prompt when absent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
#include "data_generation/llama_generator.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Loads brewery system prompt from disk or cache.
|
||||||
|
*
|
||||||
|
* @param prompt_file_path Preferred prompt file location.
|
||||||
|
* @return Prompt text loaded from disk.
|
||||||
|
*/
|
||||||
|
std::string LlamaGenerator::LoadBrewerySystemPrompt(
|
||||||
|
const std::filesystem::path& prompt_file_path) {
|
||||||
|
// Return cached version if already loaded
|
||||||
|
if (!brewery_system_prompt_.empty()) {
|
||||||
|
return brewery_system_prompt_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::ifstream prompt_file(prompt_file_path);
|
||||||
|
if (!prompt_file.is_open()) {
|
||||||
|
spdlog::error(
|
||||||
|
"LlamaGenerator: Failed to open brewery system prompt file '{}'",
|
||||||
|
prompt_file_path.string());
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: missing brewery system prompt file: " +
|
||||||
|
prompt_file_path.string());
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string prompt((std::istreambuf_iterator(prompt_file)),
|
||||||
|
std::istreambuf_iterator<char>());
|
||||||
|
prompt_file.close();
|
||||||
|
|
||||||
|
if (prompt.empty()) {
|
||||||
|
spdlog::error("LlamaGenerator: Brewery system prompt file '{}' is empty",
|
||||||
|
prompt_file_path.string());
|
||||||
|
throw std::runtime_error(
|
||||||
|
"LlamaGenerator: empty brewery system prompt file: " +
|
||||||
|
prompt_file_path.string());
|
||||||
|
}
|
||||||
|
|
||||||
|
spdlog::info(
|
||||||
|
"LlamaGenerator: Loaded brewery system prompt from '{}' ({} chars)",
|
||||||
|
prompt_file_path.string(), prompt.length());
|
||||||
|
brewery_system_prompt_ = prompt;
|
||||||
|
return brewery_system_prompt_;
|
||||||
|
}
|
||||||
16
pipeline/src/data_generation/mock/deterministic_hash.cc
Normal file
16
pipeline/src/data_generation/mock/deterministic_hash.cc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/mock/deterministic_hash.cc
|
||||||
|
* @brief Implements a stable hash combiner used by MockGenerator to derive
|
||||||
|
* repeatable pseudo-random indices from location input.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <boost/container_hash/hash.hpp>
|
||||||
|
|
||||||
|
#include "data_generation/mock_generator.h"
|
||||||
|
|
||||||
|
size_t MockGenerator::DeterministicHash(const Location& location) {
|
||||||
|
size_t seed = 0;
|
||||||
|
boost::hash_combine(seed, location.city);
|
||||||
|
boost::hash_combine(seed, location.country);
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
44
pipeline/src/data_generation/mock/generate_brewery.cc
Normal file
44
pipeline/src/data_generation/mock/generate_brewery.cc
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/mock/generate_brewery.cc
|
||||||
|
* @brief Builds deterministic brewery names and descriptions by hashing city
|
||||||
|
* and country into fixed mock phrase catalogs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <format>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "data_generation/mock_generator.h"
|
||||||
|
|
||||||
|
BreweryResult MockGenerator::GenerateBrewery(
|
||||||
|
const Location& location, const std::string& /*region_context*/) {
|
||||||
|
const size_t hash = DeterministicHash(location);
|
||||||
|
|
||||||
|
const std::string_view adjective =
|
||||||
|
kBreweryAdjectives.at(hash % kBreweryAdjectives.size());
|
||||||
|
const std::string_view noun =
|
||||||
|
kBreweryNouns.at(hash / 7 % kBreweryNouns.size());
|
||||||
|
const std::string_view base_description =
|
||||||
|
kBreweryDescriptions.at((hash / 13) % kBreweryDescriptions.size());
|
||||||
|
|
||||||
|
const std::string name =
|
||||||
|
std::format("{} {} {}", location.city, adjective, noun);
|
||||||
|
|
||||||
|
const std::string state_suffix =
|
||||||
|
location.state_province.empty()
|
||||||
|
? std::string{}
|
||||||
|
: std::format(", {}", location.state_province);
|
||||||
|
const std::string country_suffix =
|
||||||
|
location.country.empty() ? std::string{}
|
||||||
|
: std::format(", {}", location.country);
|
||||||
|
const std::string description =
|
||||||
|
std::format("{} Located in {}{}{}.", base_description, location.city,
|
||||||
|
state_suffix, country_suffix);
|
||||||
|
|
||||||
|
return {
|
||||||
|
.name_en = name,
|
||||||
|
.description_en = description,
|
||||||
|
.name_local = name,
|
||||||
|
.description_local = description,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
pipeline/src/data_generation/mock/generate_user.cc
Normal file
22
pipeline/src/data_generation/mock/generate_user.cc
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @file data_generation/mock/generate_user.cc
|
||||||
|
* @brief Generates deterministic mock user profiles by hashing locale values
|
||||||
|
* 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 result;
|
||||||
|
const std::string_view username = kUsernames[hash % kUsernames.size()];
|
||||||
|
const std::string_view bio = kBios[hash / 11 % kBios.size()];
|
||||||
|
result.username = username;
|
||||||
|
result.bio = bio;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#include "data_generation/prompt_formatting/gemma4_jinja_prompt_formatter.h"
|
||||||
|
|
||||||
|
#include <format>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
static constexpr std::string_view kWhitespace = " \t\n\r\f\v";
|
||||||
|
|
||||||
|
// Strips leading and trailing whitespace to ensure clean prompt injection.
|
||||||
|
static std::string_view Trim(std::string_view value) {
|
||||||
|
const size_t first_index = value.find_first_not_of(kWhitespace);
|
||||||
|
|
||||||
|
const bool is_all_whitespace = (first_index == std::string_view::npos);
|
||||||
|
if (is_all_whitespace) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t last_index = value.find_last_not_of(kWhitespace);
|
||||||
|
return value.substr(first_index, last_index - first_index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Gemma4JinjaPromptFormatter::Format(
|
||||||
|
std::string_view system_prompt, std::string_view user_prompt) const {
|
||||||
|
std::string_view trimmed_system = Trim(system_prompt);
|
||||||
|
std::string_view trimmed_user = Trim(user_prompt);
|
||||||
|
|
||||||
|
return std::format(
|
||||||
|
"<|turn|>system\n<|think|>\n{}\n<|turn|>\n"
|
||||||
|
"<|turn|>user\n{}\n<|turn|>\n"
|
||||||
|
"<|turn|>model\n<|channel>thought\n",
|
||||||
|
trimmed_system, trimmed_user);
|
||||||
|
}
|
||||||
110
pipeline/src/json_handling/json_loader.cc
Normal file
110
pipeline/src/json_handling/json_loader.cc
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @file json_handling/json_loader.cc
|
||||||
|
* @brief Parses curated location JSON input into strongly typed Location
|
||||||
|
* records with strict field validation and descriptive error reporting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "json_handling/json_loader.h"
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <boost/json.hpp>
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
static std::string ReadRequiredString(const boost::json::object& object,
|
||||||
|
const char* key) {
|
||||||
|
const boost::json::value* value = object.if_contains(key);
|
||||||
|
if (value == nullptr || !value->is_string()) {
|
||||||
|
throw std::runtime_error(std::string("Missing or invalid string field: ") +
|
||||||
|
key);
|
||||||
|
}
|
||||||
|
const std::string_view text = value->as_string();
|
||||||
|
return std::string(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double ReadRequiredNumber(const boost::json::object& object,
|
||||||
|
const char* key) {
|
||||||
|
const boost::json::value* value = object.if_contains(key);
|
||||||
|
if (value == nullptr || !value->is_number()) {
|
||||||
|
throw std::runtime_error(std::string("Missing or invalid numeric field: ") +
|
||||||
|
key);
|
||||||
|
}
|
||||||
|
return value->to_number<double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<std::string> ReadRequiredStringArray(
|
||||||
|
const boost::json::object& object, const char* key) {
|
||||||
|
const boost::json::value* value = object.if_contains(key);
|
||||||
|
if (value == nullptr || !value->is_array()) {
|
||||||
|
throw std::runtime_error(std::string("Missing or invalid string array field: ") +
|
||||||
|
key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& array = value->as_array();
|
||||||
|
std::vector<std::string> items;
|
||||||
|
items.reserve(array.size());
|
||||||
|
for (const auto& item : array) {
|
||||||
|
if (!item.is_string()) {
|
||||||
|
throw std::runtime_error(std::string("Missing or invalid string array field: ") +
|
||||||
|
key);
|
||||||
|
}
|
||||||
|
items.emplace_back(item.as_string());
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Location> JsonLoader::LoadLocations(
|
||||||
|
const std::filesystem::path& filepath) {
|
||||||
|
std::ifstream input(filepath);
|
||||||
|
if (!input.is_open()) {
|
||||||
|
throw std::runtime_error("Failed to open locations file: " +
|
||||||
|
filepath.string());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::stringstream buffer;
|
||||||
|
buffer << input.rdbuf();
|
||||||
|
const std::string content = buffer.str();
|
||||||
|
|
||||||
|
boost::system::error_code error;
|
||||||
|
boost::json::value root = boost::json::parse(content, error);
|
||||||
|
if (error) {
|
||||||
|
throw std::runtime_error("Failed to parse locations JSON: " +
|
||||||
|
error.message());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!root.is_array()) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"Invalid locations JSON: root element must be an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Location> locations;
|
||||||
|
const auto& items = root.as_array();
|
||||||
|
locations.reserve(items.size());
|
||||||
|
|
||||||
|
for (const auto& item : items) {
|
||||||
|
if (!item.is_object()) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"Invalid locations JSON: each entry must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& object = item.as_object();
|
||||||
|
locations.push_back(Location{
|
||||||
|
.city = ReadRequiredString(object, "city"),
|
||||||
|
.state_province = ReadRequiredString(object, "state_province"),
|
||||||
|
.iso3166_2 = ReadRequiredString(object, "iso3166_2"),
|
||||||
|
.country = ReadRequiredString(object, "country"),
|
||||||
|
.iso3166_1 = ReadRequiredString(object, "iso3166_1"),
|
||||||
|
.local_languages =
|
||||||
|
ReadRequiredStringArray(object, "local_languages"),
|
||||||
|
.latitude = ReadRequiredNumber(object, "latitude"),
|
||||||
|
.longitude = ReadRequiredNumber(object, "longitude"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spdlog::info("[JsonLoader] Loaded {} locations from {}", locations.size(),
|
||||||
|
filepath.string());
|
||||||
|
return locations;
|
||||||
|
}
|
||||||
194
pipeline/src/main.cc
Normal file
194
pipeline/src/main.cc
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* @file main.cc
|
||||||
|
* @brief Parses command-line options, validates runtime mode selection,
|
||||||
|
* initializes shared infrastructure, and executes the pipeline entry flow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <boost/di.hpp>
|
||||||
|
#include <boost/program_options.hpp>
|
||||||
|
#include <chrono>
|
||||||
|
#include <exception>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "biergarten_data_generator.h"
|
||||||
|
#include "data_generation/llama_generator.h"
|
||||||
|
#include "data_generation/mock_generator.h"
|
||||||
|
#include "data_generation/prompt_formatting/gemma4_jinja_prompt_formatter.h"
|
||||||
|
#include "data_model/application_options.h"
|
||||||
|
#include "llama_backend_state.h"
|
||||||
|
#include "services/enrichment_service.h"
|
||||||
|
#include "services/wikipedia_service.h"
|
||||||
|
#include "web_client/curl_web_client.h"
|
||||||
|
|
||||||
|
namespace prog_opts = boost::program_options;
|
||||||
|
namespace di = boost::di;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse command-line arguments into ApplicationOptions.
|
||||||
|
*
|
||||||
|
* @param argc Command-line argument count.
|
||||||
|
* @param argv Command-line arguments.
|
||||||
|
* @return Parsed ApplicationOptions if parsing succeeded, std::nullopt
|
||||||
|
* otherwise.
|
||||||
|
*/
|
||||||
|
std::optional<ApplicationOptions> ParseArguments(const int argc, char** argv) {
|
||||||
|
prog_opts::options_description desc("Pipeline Options");
|
||||||
|
|
||||||
|
auto opt = desc.add_options();
|
||||||
|
|
||||||
|
opt("help,h", "Produce help message");
|
||||||
|
|
||||||
|
opt("mocked", prog_opts::bool_switch(),
|
||||||
|
"Use mocked generator for brewery/user data");
|
||||||
|
|
||||||
|
opt("model,m", prog_opts::value<std::string>()->default_value(""),
|
||||||
|
"Path to LLM model (gguf)");
|
||||||
|
|
||||||
|
opt("temperature", prog_opts::value<float>()->default_value(1.0F),
|
||||||
|
"Sampling temperature (higher = more random)");
|
||||||
|
|
||||||
|
opt("top-p", prog_opts::value<float>()->default_value(0.95F),
|
||||||
|
"Nucleus sampling top-p in (0,1] (higher = more random)");
|
||||||
|
|
||||||
|
opt("top-k", prog_opts::value<uint32_t>()->default_value(64),
|
||||||
|
"Top-k sampling parameter (higher = more candidate tokens)");
|
||||||
|
|
||||||
|
opt("n-ctx", prog_opts::value<uint32_t>()->default_value(8192),
|
||||||
|
"Context window size in tokens (1-32768)");
|
||||||
|
|
||||||
|
opt("seed", prog_opts::value<int>()->default_value(-1),
|
||||||
|
"Sampler seed: -1 for random, otherwise non-negative integer");
|
||||||
|
|
||||||
|
// Handle the "no arguments" or "help" case
|
||||||
|
if (argc == 1) {
|
||||||
|
spdlog::info("Biergarten Pipeline");
|
||||||
|
std::stringstream usage_stream;
|
||||||
|
usage_stream << "\nUsage: biergarten-pipeline [options]\n\n" << desc;
|
||||||
|
spdlog::info(usage_stream.str());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
prog_opts::variables_map variables_map;
|
||||||
|
prog_opts::store(prog_opts::parse_command_line(argc, argv, desc),
|
||||||
|
variables_map);
|
||||||
|
prog_opts::notify(variables_map);
|
||||||
|
|
||||||
|
if (variables_map.contains("help")) {
|
||||||
|
std::stringstream help_stream;
|
||||||
|
help_stream << "\n" << desc;
|
||||||
|
spdlog::info(help_stream.str());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto use_mocked = variables_map["mocked"].as<bool>();
|
||||||
|
const auto model_path = variables_map["model"].as<std::string>();
|
||||||
|
|
||||||
|
if (use_mocked && !model_path.empty()) {
|
||||||
|
spdlog::error(
|
||||||
|
"Invalid arguments: --mocked and --model are mutually exclusive");
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!use_mocked && model_path.empty()) {
|
||||||
|
spdlog::error(
|
||||||
|
"Invalid arguments: Either --mocked or --model must be specified");
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool has_llm_params = !variables_map["temperature"].defaulted() ||
|
||||||
|
!variables_map["top-p"].defaulted() ||
|
||||||
|
!variables_map["top-k"].defaulted() ||
|
||||||
|
!variables_map["seed"].defaulted();
|
||||||
|
|
||||||
|
if (use_mocked && has_llm_params) {
|
||||||
|
spdlog::warn(
|
||||||
|
"Sampling parameters (--temperature, --top-p, --top-k, --seed) are"
|
||||||
|
" ignored when using --mocked");
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplicationOptions options;
|
||||||
|
options.use_mocked = use_mocked;
|
||||||
|
options.model_path = model_path;
|
||||||
|
options.temperature = variables_map["temperature"].as<float>();
|
||||||
|
options.top_p = variables_map["top-p"].as<float>();
|
||||||
|
options.top_k = variables_map["top-k"].as<uint32_t>();
|
||||||
|
options.n_ctx = variables_map["n-ctx"].as<uint32_t>();
|
||||||
|
options.seed = variables_map["seed"].as<int>();
|
||||||
|
|
||||||
|
return options;
|
||||||
|
} catch (const std::exception& exception) {
|
||||||
|
spdlog::error("Failed to parse command-line arguments: {}",
|
||||||
|
exception.what());
|
||||||
|
return std::nullopt;
|
||||||
|
} catch (...) {
|
||||||
|
spdlog::error("Failed to parse command-line arguments: unknown error");
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Timer {
|
||||||
|
std::chrono::steady_clock::time_point start_time =
|
||||||
|
std::chrono::steady_clock::now();
|
||||||
|
[[nodiscard]] int64_t Elapsed() const {
|
||||||
|
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - start_time)
|
||||||
|
.count();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
int main(const int argc, char** argv) {
|
||||||
|
try {
|
||||||
|
Timer timer;
|
||||||
|
const CurlGlobalState curl_state;
|
||||||
|
const LlamaBackendState llama_backend_state;
|
||||||
|
spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] %v");
|
||||||
|
|
||||||
|
const auto parsed_options = ParseArguments(argc, argv);
|
||||||
|
if (!parsed_options.has_value()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto options = *parsed_options;
|
||||||
|
|
||||||
|
const auto injector = di::make_injector(
|
||||||
|
di::bind<WebClient>().to<CURLWebClient>(),
|
||||||
|
di::bind<ApplicationOptions>().to(options),
|
||||||
|
di::bind<IEnrichmentService>().to<WikipediaService>(),
|
||||||
|
di::bind<IPromptFormatter>().to<Gemma4JinjaPromptFormatter>(),
|
||||||
|
di::bind<std::string>().to(options.model_path),
|
||||||
|
di::bind<DataGenerator>().to(
|
||||||
|
[options](const auto& inj) -> std::unique_ptr<DataGenerator> {
|
||||||
|
if (options.use_mocked) {
|
||||||
|
spdlog::info(
|
||||||
|
"[Generator] Using MockGenerator (no model path provided)");
|
||||||
|
return std::make_unique<MockGenerator>();
|
||||||
|
}
|
||||||
|
|
||||||
|
spdlog::info(
|
||||||
|
"[Generator] Using LlamaGenerator: {} (temperature={}, "
|
||||||
|
"top-p={}, top-k={}, n_ctx={}, seed={})",
|
||||||
|
options.model_path, options.temperature, options.top_p,
|
||||||
|
options.top_k, options.n_ctx, options.seed);
|
||||||
|
return inj.template create<std::unique_ptr<LlamaGenerator>>();
|
||||||
|
}));
|
||||||
|
|
||||||
|
auto generator = injector.create<BiergartenDataGenerator>();
|
||||||
|
|
||||||
|
if (!generator.Run()) {
|
||||||
|
spdlog::error("Pipeline execution failed");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
spdlog::info("Pipeline executed successfully in {} ms", timer.Elapsed());
|
||||||
|
return 0;
|
||||||
|
} catch (const std::exception& exception) {
|
||||||
|
spdlog::critical("Unhandled fatal error in main: {}", exception.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
pipeline/src/services/wikipedia/fetch_extract.cc
Normal file
61
pipeline/src/services/wikipedia/fetch_extract.cc
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @file wikipedia/fetch_extract.cc
|
||||||
|
* @brief WikipediaService::FetchExtract() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <boost/json.hpp>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include "services/wikipedia_service.h"
|
||||||
|
|
||||||
|
std::string WikipediaService::FetchExtract(std::string_view query) {
|
||||||
|
const std::string cache_key(query);
|
||||||
|
const auto cache_it = this->extract_cache_.find(cache_key);
|
||||||
|
if (cache_it != this->extract_cache_.end()) {
|
||||||
|
return cache_it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string encoded = this->client_->UrlEncode(cache_key);
|
||||||
|
const std::string url =
|
||||||
|
"https://en.wikipedia.org/w/api.php?action=query&titles=" + encoded +
|
||||||
|
"&prop=extracts&explaintext=1&format=json";
|
||||||
|
|
||||||
|
const std::string body = this->client_->Get(url);
|
||||||
|
|
||||||
|
boost::system::error_code parse_error;
|
||||||
|
boost::json::value doc = boost::json::parse(body, parse_error);
|
||||||
|
|
||||||
|
if (!parse_error && doc.is_object()) {
|
||||||
|
try {
|
||||||
|
auto& pages = doc.at("query").at("pages").get_object();
|
||||||
|
if (!pages.empty()) {
|
||||||
|
auto& page = pages.begin()->value().get_object();
|
||||||
|
if (page.contains("extract") && page.at("extract").is_string()) {
|
||||||
|
const std::string_view extract_view = page.at("extract").as_string();
|
||||||
|
std::string extract(extract_view);
|
||||||
|
|
||||||
|
spdlog::debug("WikipediaService fetched {} chars for '{}'",
|
||||||
|
extract.size(), query);
|
||||||
|
|
||||||
|
this->extract_cache_.emplace(cache_key, extract);
|
||||||
|
return extract;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this->extract_cache_.emplace(cache_key, std::string{});
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
spdlog::warn(
|
||||||
|
"WikipediaService: failed to parse response structure for '{}': "
|
||||||
|
"{}",
|
||||||
|
query, e.what());
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
} else if (parse_error) {
|
||||||
|
spdlog::warn("WikipediaService: JSON parse error for '{}': {}", query,
|
||||||
|
parse_error.message());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
47
pipeline/src/services/wikipedia/get_summary.cc
Normal file
47
pipeline/src/services/wikipedia/get_summary.cc
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* @file wikipedia/get_summary.cc
|
||||||
|
* @brief WikipediaService::GetLocationContext() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "services/wikipedia_service.h"
|
||||||
|
|
||||||
|
std::string WikipediaService::GetLocationContext(const Location& loc) {
|
||||||
|
if (!client_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string result;
|
||||||
|
|
||||||
|
std::string region_query(loc.city);
|
||||||
|
if (!loc.country.empty()) {
|
||||||
|
region_query += ", ";
|
||||||
|
region_query += loc.country;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string beer_query = "beer in " + loc.country;
|
||||||
|
const std::string city_beer_query = "beer in " + loc.city;
|
||||||
|
|
||||||
|
auto append_extract = [&result](const std::string& extract) -> void {
|
||||||
|
if (extract.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.empty()) {
|
||||||
|
result += "\n\n";
|
||||||
|
}
|
||||||
|
result += extract;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
append_extract(FetchExtract(region_query));
|
||||||
|
append_extract(FetchExtract(beer_query));
|
||||||
|
append_extract(FetchExtract(city_beer_query));
|
||||||
|
} catch (const std::runtime_error& e) {
|
||||||
|
spdlog::debug("WikipediaService lookup failed for '{}': {}", region_query,
|
||||||
|
e.what());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
11
pipeline/src/services/wikipedia/wikipedia_service.cc
Normal file
11
pipeline/src/services/wikipedia/wikipedia_service.cc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @file services/wikipedia/wikipedia_service.cc
|
||||||
|
* @brief WikipediaService constructor implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "services/wikipedia_service.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
WikipediaService::WikipediaService(std::unique_ptr<WebClient> client)
|
||||||
|
: client_(std::move(client)) {}
|
||||||
19
pipeline/src/web_client/curl_global_state.cc
Normal file
19
pipeline/src/web_client/curl_global_state.cc
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @file web_client/curl_global_state.cc
|
||||||
|
* @brief CurlGlobalState constructor and destructor implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
#include "web_client/curl_web_client.h"
|
||||||
|
|
||||||
|
CurlGlobalState::CurlGlobalState() {
|
||||||
|
if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"[CURLWebClient] Failed to initialize libcurl globally");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CurlGlobalState::~CurlGlobalState() { curl_global_cleanup(); }
|
||||||
77
pipeline/src/web_client/curl_web_client_get.cc
Normal file
77
pipeline/src/web_client/curl_web_client_get.cc
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* @file web_client/curl_web_client_get.cc
|
||||||
|
* @brief CURLWebClient::Get() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "web_client/curl_web_client.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
using CurlHandle = std::unique_ptr<CURL, decltype(&curl_easy_cleanup)>;
|
||||||
|
|
||||||
|
static constexpr int64_t kConnectionTimeout = 10;
|
||||||
|
static constexpr int64_t kRequestTimeout = 30;
|
||||||
|
static constexpr int64_t kOkHttpStatus = 200;
|
||||||
|
|
||||||
|
static CurlHandle CreateHandle() {
|
||||||
|
CURL* handle = curl_easy_init();
|
||||||
|
if (handle == nullptr) {
|
||||||
|
throw std::runtime_error(
|
||||||
|
"[CURLWebClient] Failed to initialize libcurl handle");
|
||||||
|
}
|
||||||
|
return {handle, &curl_easy_cleanup};
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SetCommonGetOptions(CURL* curl, const std::string& url) {
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "biergarten-pipeline/0.1.0");
|
||||||
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, kConnectionTimeout);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, kRequestTimeout);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "gzip");
|
||||||
|
}
|
||||||
|
|
||||||
|
// curl write callback that appends response data into a std::string
|
||||||
|
static size_t WriteCallbackString(void* contents, const size_t size,
|
||||||
|
const size_t nmemb, void* userp) {
|
||||||
|
const size_t real_size = size * nmemb;
|
||||||
|
auto* str = static_cast<std::string*>(userp);
|
||||||
|
str->append(static_cast<char*>(contents), real_size);
|
||||||
|
return real_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string CURLWebClient::Get(const std::string& url) {
|
||||||
|
const CurlHandle curl = CreateHandle();
|
||||||
|
|
||||||
|
std::string response_string;
|
||||||
|
|
||||||
|
SetCommonGetOptions(curl.get(), url);
|
||||||
|
|
||||||
|
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, WriteCallbackString);
|
||||||
|
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response_string);
|
||||||
|
|
||||||
|
CURLcode curl_result = curl_easy_perform(curl.get());
|
||||||
|
|
||||||
|
if (curl_result != CURLE_OK) {
|
||||||
|
const auto error = std::string("[CURLWebClient] GET failed: ") +
|
||||||
|
curl_easy_strerror(curl_result);
|
||||||
|
throw std::runtime_error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t http_code = 0;
|
||||||
|
curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
|
||||||
|
if (http_code != kOkHttpStatus) {
|
||||||
|
const std::string error = "[CURLWebClient] HTTP error " +
|
||||||
|
std::to_string(http_code) + " for URL " + url;
|
||||||
|
throw std::runtime_error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response_string;
|
||||||
|
}
|
||||||
24
pipeline/src/web_client/curl_web_client_url_encode.cc
Normal file
24
pipeline/src/web_client/curl_web_client_url_encode.cc
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* @file web_client/curl_web_client_url_encode.cc
|
||||||
|
* @brief CURLWebClient::UrlEncode() implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "web_client/curl_web_client.h"
|
||||||
|
|
||||||
|
std::string CURLWebClient::UrlEncode(const std::string& value) {
|
||||||
|
// A NULL handle is fine for UTF-8 encoding according to libcurl docs.
|
||||||
|
char* output = curl_easy_escape(nullptr, value.c_str(), 0);
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
throw std::runtime_error("[CURLWebClient] curl_easy_escape failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string result(output);
|
||||||
|
curl_free(output);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
25
src/Core/.dockerignore
Normal file
25
src/Core/.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
42
src/Core/API/API.Core/API.Core.csproj
Normal file
42
src/Core/API/API.Core/API.Core.csproj
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>API.Core</RootNamespace>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference
|
||||||
|
Include="Microsoft.AspNetCore.OpenApi"
|
||||||
|
Version="9.0.11"
|
||||||
|
/>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
<PackageReference
|
||||||
|
Include="FluentValidation.AspNetCore"
|
||||||
|
Version="11.3.0"
|
||||||
|
/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Infrastructure\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using API.Core.Contracts.Common;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace API.Core.Authentication;
|
||||||
|
|
||||||
|
public class JwtAuthenticationHandler(
|
||||||
|
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
|
IConfiguration configuration
|
||||||
|
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||||
|
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||||
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
|
{
|
||||||
|
secret = configuration["Jwt:SecretKey"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Authorization header exists
|
||||||
|
if (
|
||||||
|
!Request.Headers.TryGetValue(
|
||||||
|
"Authorization",
|
||||||
|
out var authHeaderValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Authorization header is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authHeader = authHeaderValue.ToString();
|
||||||
|
if (
|
||||||
|
!authHeader.StartsWith(
|
||||||
|
"Bearer ",
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail(
|
||||||
|
"Invalid authorization header format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||||
|
token,
|
||||||
|
secret
|
||||||
|
);
|
||||||
|
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
||||||
|
return AuthenticateResult.Success(ticket);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail(
|
||||||
|
$"Token validation failed: {ex.Message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
Response.ContentType = "application/json";
|
||||||
|
Response.StatusCode = 401;
|
||||||
|
|
||||||
|
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
||||||
|
await Response.WriteAsJsonAsync(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||||
21
src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal file
21
src/Core/API/API.Core/Contracts/Auth/AuthDTO.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Org.BouncyCastle.Asn1.Cms;
|
||||||
|
|
||||||
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
public record LoginPayload(
|
||||||
|
Guid UserAccountId,
|
||||||
|
string Username,
|
||||||
|
string RefreshToken,
|
||||||
|
string AccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
public record RegistrationPayload(
|
||||||
|
Guid UserAccountId,
|
||||||
|
string Username,
|
||||||
|
string RefreshToken,
|
||||||
|
string AccessToken,
|
||||||
|
bool ConfirmationEmailSent
|
||||||
|
);
|
||||||
|
|
||||||
|
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||||
20
src/Core/API/API.Core/Contracts/Auth/Login.cs
Normal file
20
src/Core/API/API.Core/Contracts/Auth/Login.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using API.Core.Contracts.Common;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
public record LoginRequest
|
||||||
|
{
|
||||||
|
public string Username { get; init; } = default!;
|
||||||
|
public string Password { get; init; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
||||||
|
{
|
||||||
|
public LoginRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
|
||||||
|
|
||||||
|
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
public record RefreshTokenRequest
|
||||||
|
{
|
||||||
|
public string RefreshToken { get; init; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RefreshTokenRequestValidator
|
||||||
|
: AbstractValidator<RefreshTokenRequest>
|
||||||
|
{
|
||||||
|
public RefreshTokenRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RefreshToken)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Refresh token is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/Core/API/API.Core/Contracts/Auth/Register.cs
Normal file
71
src/Core/API/API.Core/Contracts/Auth/Register.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using API.Core.Contracts.Common;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
public record RegisterRequest(
|
||||||
|
string Username,
|
||||||
|
string FirstName,
|
||||||
|
string LastName,
|
||||||
|
string Email,
|
||||||
|
DateTime DateOfBirth,
|
||||||
|
string Password
|
||||||
|
);
|
||||||
|
|
||||||
|
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
||||||
|
{
|
||||||
|
public RegisterRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Username)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Username is required")
|
||||||
|
.Length(3, 64)
|
||||||
|
.WithMessage("Username must be between 3 and 64 characters")
|
||||||
|
.Matches("^[a-zA-Z0-9._-]+$")
|
||||||
|
.WithMessage(
|
||||||
|
"Username can only contain letters, numbers, dots, underscores, and hyphens"
|
||||||
|
);
|
||||||
|
|
||||||
|
RuleFor(x => x.FirstName)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("First name is required")
|
||||||
|
.MaximumLength(128)
|
||||||
|
.WithMessage("First name cannot exceed 128 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.LastName)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Last name is required")
|
||||||
|
.MaximumLength(128)
|
||||||
|
.WithMessage("Last name cannot exceed 128 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.Email)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Email is required")
|
||||||
|
.EmailAddress()
|
||||||
|
.WithMessage("Invalid email format")
|
||||||
|
.MaximumLength(128)
|
||||||
|
.WithMessage("Email cannot exceed 128 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.DateOfBirth)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Date of birth is required")
|
||||||
|
.LessThan(DateTime.Today.AddYears(-19))
|
||||||
|
.WithMessage("You must be at least 19 years old to register");
|
||||||
|
|
||||||
|
RuleFor(x => x.Password)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Password is required")
|
||||||
|
.MinimumLength(8)
|
||||||
|
.WithMessage("Password must be at least 8 characters")
|
||||||
|
.Matches("[A-Z]")
|
||||||
|
.WithMessage("Password must contain at least one uppercase letter")
|
||||||
|
.Matches("[a-z]")
|
||||||
|
.WithMessage("Password must contain at least one lowercase letter")
|
||||||
|
.Matches("[0-9]")
|
||||||
|
.WithMessage("Password must contain at least one number")
|
||||||
|
.Matches("[^a-zA-Z0-9]")
|
||||||
|
.WithMessage(
|
||||||
|
"Password must contain at least one special character"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
Normal file
12
src/Core/API/API.Core/Contracts/Common/ResponseBody.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace API.Core.Contracts.Common;
|
||||||
|
|
||||||
|
public record ResponseBody<T>
|
||||||
|
{
|
||||||
|
public required string Message { get; init; }
|
||||||
|
public required T Payload { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ResponseBody
|
||||||
|
{
|
||||||
|
public required string Message { get; init; }
|
||||||
|
}
|
||||||
111
src/Core/API/API.Core/Controllers/AuthController.cs
Normal file
111
src/Core/API/API.Core/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using API.Core.Contracts.Auth;
|
||||||
|
using API.Core.Contracts.Common;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Service.Auth;
|
||||||
|
|
||||||
|
namespace API.Core.Controllers
|
||||||
|
{
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
|
public class AuthController(
|
||||||
|
IRegisterService registerService,
|
||||||
|
ILoginService loginService,
|
||||||
|
IConfirmationService confirmationService,
|
||||||
|
ITokenService tokenService
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("register")]
|
||||||
|
public async Task<ActionResult<UserAccount>> Register(
|
||||||
|
[FromBody] RegisterRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var rtn = await registerService.RegisterAsync(
|
||||||
|
new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountId = Guid.Empty,
|
||||||
|
Username = req.Username,
|
||||||
|
FirstName = req.FirstName,
|
||||||
|
LastName = req.LastName,
|
||||||
|
Email = req.Email,
|
||||||
|
DateOfBirth = req.DateOfBirth,
|
||||||
|
},
|
||||||
|
req.Password
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = new ResponseBody<RegistrationPayload>
|
||||||
|
{
|
||||||
|
Message = "User registered successfully.",
|
||||||
|
Payload = new RegistrationPayload(
|
||||||
|
rtn.UserAccount.UserAccountId,
|
||||||
|
rtn.UserAccount.Username,
|
||||||
|
rtn.RefreshToken,
|
||||||
|
rtn.AccessToken,
|
||||||
|
rtn.EmailSent
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return Created("/", response);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("login")]
|
||||||
|
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
||||||
|
{
|
||||||
|
var rtn = await loginService.LoginAsync(req.Username, req.Password);
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<LoginPayload>
|
||||||
|
{
|
||||||
|
Message = "Logged in successfully.",
|
||||||
|
Payload = new LoginPayload(
|
||||||
|
rtn.UserAccount.UserAccountId,
|
||||||
|
rtn.UserAccount.Username,
|
||||||
|
rtn.RefreshToken,
|
||||||
|
rtn.AccessToken
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("confirm")]
|
||||||
|
public async Task<ActionResult> Confirm([FromQuery] string token)
|
||||||
|
{
|
||||||
|
var rtn = await confirmationService.ConfirmUserAsync(token);
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<ConfirmationPayload>
|
||||||
|
{
|
||||||
|
Message = "User with ID " + rtn.UserId + " is confirmed.",
|
||||||
|
Payload = new ConfirmationPayload(
|
||||||
|
rtn.UserId,
|
||||||
|
rtn.ConfirmedAt
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<ActionResult> Refresh(
|
||||||
|
[FromBody] RefreshTokenRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<LoginPayload>
|
||||||
|
{
|
||||||
|
Message = "Token refreshed successfully.",
|
||||||
|
Payload = new LoginPayload(
|
||||||
|
rtn.UserAccount.UserAccountId,
|
||||||
|
rtn.UserAccount.Username,
|
||||||
|
rtn.RefreshToken,
|
||||||
|
rtn.AccessToken
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user