mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Refactor documentation and diagrams for web/pipeline. (#272)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> for assistance with diagram creation and documentation prose updates.
This commit is contained in:
11
docs/pipeline/diagrams/current/activity.md
Normal file
11
docs/pipeline/diagrams/current/activity.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# The Biergarten Data Pipeline (Current — Synchronous Data Path)
|
||||
|
||||
The only concurrency in the current pipeline is a dedicated log dispatcher
|
||||
thread that drains `log_channel` and forwards entries to spdlog for the entire
|
||||
run (joined during shutdown after `log_channel` closes).
|
||||
`BiergartenPipelineOrchestrator::Run()` itself is synchronous — sampling,
|
||||
enrichment, generation, and export all happen on the main thread.
|
||||
|
||||
```mermaid
|
||||
--8<-- "pipeline/diagrams/current/activity.mmd"
|
||||
```
|
||||
86
docs/pipeline/diagrams/current/activity.mmd
Normal file
86
docs/pipeline/diagrams/current/activity.mmd
Normal file
@@ -0,0 +1,86 @@
|
||||
flowchart TD
|
||||
Start([start]) --> ParseArgs["main.cc: ParseArguments(argc, argv)"]
|
||||
ParseArgs --> ArgsValid{Arguments valid?}
|
||||
ArgsValid -- no --> LogErr[Log error / usage info] --> Stop1([stop])
|
||||
ArgsValid -- yes --> Init["main.cc: Init OpenSSL global state and LlamaBackendState"]
|
||||
Init --> DI["main.cc: di::make_injector(...)<br/>binds ILogger, IEnrichmentService, DataGenerator, IExportService, IPromptFormatter, WebClient based on --mocked vs. model-backed mode"]
|
||||
DI --> LogThread["main.cc: create BoundedChannel log_channel, spawn log dispatcher thread (LogDispatcher::Run)"]
|
||||
LogThread --> CreateOrch["main.cc: injector.create BiergartenPipelineOrchestrator"]
|
||||
CreateOrch --> Run["BiergartenPipelineOrchestrator::Run()"]
|
||||
|
||||
Run --> SqliteInit["SqliteExportService.Initialize()<br/>builds biergarten_seed_<UTC datetime>.sqlite (numeric suffix if it exists), opens DB connection, executes schema DDL, begins transaction"]
|
||||
SqliteInit --> QueryLoc[Orchestrator: QueryLocations]
|
||||
QueryLoc --> LoadLoc["CuratedJsonDataService.LoadLocations()"]
|
||||
LoadLoc --> SampleLoc["std::ranges::sample(all_locations, --location-count) — defaults to 10"]
|
||||
|
||||
SampleLoc --> EnrichLoop{For each sampled Location}
|
||||
EnrichLoop --> GetContext["IEnrichmentService.GetLocationContext(loc)<br/>WikipediaEnrichmentService fetches a generic 'brewing' extract and a 'beer in {country}' extract (not city/region-specific); MockEnrichmentService (--mocked) returns an empty string"]
|
||||
GetContext --> LookupFailed{Lookup failed?}
|
||||
LookupFailed -- yes --> SkipCity1[Log warning, skip city] --> EnrichLoop
|
||||
LookupFailed -- no --> StoreEnriched["Store EnrichedCity{Location, region_context}"] --> EnrichLoop
|
||||
EnrichLoop -- done --> GenUsers[Orchestrator: GenerateUsers enriched_cities]
|
||||
SkipCity1 ~~~ GenUsers
|
||||
StoreEnriched ~~~ GenUsers
|
||||
|
||||
GenUsers --> LoadUserData["CuratedJsonDataService: LoadPersonas / LoadForenamesByCountry / LoadSurnamesByCountry<br/>each memoized after first parse; MockCuratedDataService (--mocked) returns a fixed in-memory dataset instead, skipping these JSON files"]
|
||||
|
||||
LoadUserData --> UserLoop{For each EnrichedCity}
|
||||
UserLoop --> SampleName["SampleName(forenames_by_country, surnames_by_country, location.iso3166_1)"]
|
||||
SampleName --> NamesAvail{Names available for country?}
|
||||
NamesAvail -- no --> SkipCity2[Log warning, skip city] --> UserLoop
|
||||
NamesAvail -- yes --> SamplePersona["Sample UserPersona (uniform random)"]
|
||||
SamplePersona --> UserGenMode{Generator mode}
|
||||
|
||||
UserGenMode -- MockGenerator --> UserMockGen[DeterministicHash and Format]
|
||||
UserGenMode -- LlamaGenerator --> UserLlamaLoad["prompt_directory_.Load(USER_GENERATION) → USER_GENERATION.md in --prompt-dir"]
|
||||
UserLlamaLoad --> UserLlamaRetry["Infer(...) with kUserJsonGrammar → ValidateUserJson<br/>retry up to 3 attempts, appending validation error to the retry prompt on failure"]
|
||||
UserGenMode -- OpenAIGenerator --> UserOAILoad["prompt_directory_.Load(USER_GENERATION) — same prompt file as LlamaGenerator, content is backend-agnostic"]
|
||||
UserOAILoad --> UserOAIRetry["CallChatCompletionsApi(...) with kUserJsonSchema → ValidateUserJson<br/>retry up to 2 attempts — Structured Outputs (json_schema, strict) already guarantees schema shape, so retries only cover transient failures / placeholder text"]
|
||||
|
||||
UserMockGen --> UserGenDone{Generation successful?}
|
||||
UserLlamaRetry --> UserGenDone
|
||||
UserOAIRetry --> UserGenDone
|
||||
|
||||
UserGenDone -- yes --> UserPostProc["GetRandomCoordsWithinRange (5km jitter), BuildEmail, GenerateDateOfBirth<br/>email and date_of_birth are generated programmatically, never LLM-authored; generation + coordinate jitter run inside one try/catch per record"]
|
||||
UserGenDone -- no --> UserGenFail["Log warning: generation failed, skipping"] --> UserLoop
|
||||
|
||||
UserPostProc --> UserExport["SqliteExportService.ProcessRecord(UserRecord)<br/>reuse cached city_id or insert City and cache its id; insert User + UserAddress (FK city_id)"]
|
||||
UserExport --> UserExportFail{Exception during insert?}
|
||||
UserExportFail -- yes --> UserExportWarn["Log warning: SQLite export failed"] --> UserLoop
|
||||
UserExportFail -- no --> UserLoop
|
||||
UserLoop -- done --> GenBrew[Orchestrator: GenerateBreweries enriched_cities]
|
||||
SkipCity2 ~~~ GenBrew
|
||||
UserGenFail ~~~ GenBrew
|
||||
UserExportWarn ~~~ GenBrew
|
||||
UserExportFail ~~~ GenBrew
|
||||
|
||||
GenBrew --> BrewLoop{For each EnrichedCity}
|
||||
BrewLoop --> BrewGenMode{Generator mode}
|
||||
|
||||
BrewGenMode -- MockGenerator --> BrewMockGen[DeterministicHash and Format]
|
||||
BrewGenMode -- LlamaGenerator --> BrewLlamaLoad["PrepareRegionContext, prompt_directory_.Load(BREWERY_GENERATION) → BREWERY_GENERATION.md"]
|
||||
BrewLlamaLoad --> BrewLlamaRetry["Infer(...) with kBreweryJsonGrammar → ValidateBreweryJson<br/>retry up to 3 attempts; max_tokens is fixed across retries, not raised on truncation"]
|
||||
BrewGenMode -- OpenAIGenerator --> BrewOAILoad["PrepareRegionContext, prompt_directory_.Load(BREWERY_GENERATION) — same prompt file as LlamaGenerator"]
|
||||
BrewOAILoad --> BrewOAIRetry["CallChatCompletionsApi(...) with kBreweryJsonSchema → ValidateBreweryJson<br/>retry up to 2 attempts — Structured Outputs already guarantees schema shape"]
|
||||
|
||||
BrewMockGen --> BrewGenDone{Generation successful?}
|
||||
BrewLlamaRetry --> BrewGenDone
|
||||
BrewOAIRetry --> BrewGenDone
|
||||
|
||||
BrewGenDone -- yes --> BrewPostProc["GetRandomCoordsWithinRange (5km jitter)<br/>generation + jitter run inside one try/catch per record"]
|
||||
BrewGenDone -- no --> BrewGenFail["Log warning: generation failed, skipping"] --> BrewLoop
|
||||
|
||||
BrewPostProc --> BrewGeocode["IAddressService.ReverseGeocode(longitude, latitude)<br/>NominatimAddressService hits the live Nominatim API (1 req/sec) and returns nullopt on failure; MockAddressService (--mocked) returns a fixed placeholder address instead"]
|
||||
BrewGeocode --> BrewGeocodeFailed{Geocode resolved?}
|
||||
BrewGeocodeFailed -- no --> BrewPlaceholder["Log warning: could not reverse geocode; use placeholder address_line1, empty postal_code"] --> BrewExport
|
||||
BrewGeocodeFailed -- yes --> BrewExport["SqliteExportService.ProcessRecord(BreweryRecord)<br/>reuse cached city_id or insert City and cache its id; insert Brewery + BreweryAddress (FK city_id)"]
|
||||
BrewExport --> BrewExportFail{Exception during insert?}
|
||||
BrewExportFail -- yes --> BrewExportWarn["Log warning: SQLite export failed (data loss prevented per-record; pipeline continues)"] --> BrewLoop
|
||||
BrewExportFail -- no --> BrewLoop
|
||||
BrewLoop -- done --> Finalize["SqliteExportService.Finalize()<br/>commits transaction, closes database connection"]
|
||||
BrewGenFail ~~~ Finalize
|
||||
BrewExportWarn ~~~ Finalize
|
||||
BrewExportFail ~~~ Finalize
|
||||
|
||||
Finalize --> CloseLog["main.cc: close log_channel, join log dispatcher thread"]
|
||||
CloseLog --> Stop2(["return 0 / stop"])
|
||||
@@ -1,293 +0,0 @@
|
||||
@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 #547461
|
||||
skinparam SwimlaneBorderThickness 0.3
|
||||
|
||||
title The Biergarten Data Pipeline (Current — Synchronous Data Path)
|
||||
|
||||
|#F2F6F0|main.cc|
|
||||
start
|
||||
:Create BoundedChannel<LogEntry> log_channel;
|
||||
:Spawn log dispatcher thread\n(LogDispatcher::Run());
|
||||
note right
|
||||
The only concurrency in the current pipeline:
|
||||
a dedicated thread drains log_channel and
|
||||
forwards entries to spdlog for the entire run.
|
||||
Joined during shutdown after log_channel closes.
|
||||
end note
|
||||
:ParseArguments(argc, argv);
|
||||
if (Are arguments valid?) then (no)
|
||||
:Log error / usage info;
|
||||
stop
|
||||
else (yes)
|
||||
endif
|
||||
|
||||
:Init OpenSSL global state & LlamaBackendState;
|
||||
:di::make_injector(...);
|
||||
note right
|
||||
Binds ILogger, IEnrichmentService, DataGenerator,
|
||||
IExportService, IPromptFormatter, WebClient based
|
||||
on --mocked vs. model-backed mode.
|
||||
end note
|
||||
:injector.create<std::unique_ptr<BiergartenPipelineOrchestrator>>();
|
||||
:BiergartenPipelineOrchestrator::Run();
|
||||
note right
|
||||
Run() itself is synchronous — sampling, enrichment,
|
||||
generation, and export all happen on this thread.
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:Initialize SQLite export;
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:GetUtcTimestamp() from SystemDateTimeProvider;
|
||||
:Initialize();
|
||||
note right
|
||||
Builds a fresh biergarten_seed_<UTC datetime>.sqlite filename
|
||||
Appends a numeric suffix if the timestamp already exists
|
||||
Opens DB Connection
|
||||
Executes Schema DDL
|
||||
Begins Transaction
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:QueryLocations();
|
||||
|
||||
|#E2EBDC|JsonLoader|
|
||||
:JsonLoader::LoadLocations("locations.json");
|
||||
:std::ranges::sample(all_locations, --location-count);
|
||||
note right
|
||||
--location-count defaults to 10.
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
while (For each sampled Location?) is (Remaining cities)
|
||||
|#DCE8D8|IEnrichmentService|
|
||||
:GetLocationContext(loc);
|
||||
note right
|
||||
WikipediaEnrichmentService fetches a generic
|
||||
"brewing" extract and a "beer in {country}" extract
|
||||
(not a city/region-specific page). MockEnrichmentService
|
||||
(--mocked) returns an empty string instead.
|
||||
end note
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
if (Lookup failed?) then (yes)
|
||||
:Log warning, skip city;
|
||||
else (no)
|
||||
:Store EnrichedCity{Location, region_context};
|
||||
endif
|
||||
endwhile (Done)
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:GenerateUsers(enriched_cities);
|
||||
|
||||
|#E2EBDC|JsonLoader|
|
||||
:LoadPersonas("personas.json");
|
||||
:LoadForenamesByCountry("forenames-by-country.json");
|
||||
:LoadSurnamesByCountry("surnames-by-country.json");
|
||||
note right
|
||||
Each call is cached after its first parse.
|
||||
MockCuratedDataService (--mocked) returns a
|
||||
fixed in-memory dataset instead and skips
|
||||
these three JSON files entirely.
|
||||
end note
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
while (For each EnrichedCity?) is (Remaining cities)
|
||||
:SampleName(forenames_by_country,\n surnames_by_country, location.iso3166_1);
|
||||
if (Names available for country?) then (no)
|
||||
:Log warning, skip city;
|
||||
else (yes)
|
||||
:Sample UserPersona (uniform random);
|
||||
|
||||
|#E5EDE1|DataGenerator|
|
||||
if (Generator Mode) then (MockGenerator)
|
||||
:DeterministicHash & Format;
|
||||
elseif (Generator Mode) then (LlamaGenerator)
|
||||
:prompt_directory_->Load("USER_GENERATION");
|
||||
note right
|
||||
Resolves to USER_GENERATION.md inside --prompt-dir.
|
||||
end note
|
||||
repeat
|
||||
:Infer(system_prompt, user_prompt, max_tokens, kUserJsonGrammar);
|
||||
:ValidateUserJson(raw, user);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 3?) is (yes)
|
||||
else (OpenAIGenerator)
|
||||
:prompt_directory_->Load("USER_GENERATION");
|
||||
note right
|
||||
Same prompt file as LlamaGenerator - content
|
||||
is backend-agnostic.
|
||||
end note
|
||||
repeat
|
||||
:CallChatCompletionsApi(system_prompt, user_prompt,\nkUserJsonSchema, max_tokens);
|
||||
:ValidateUserJson(raw, user);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 2?) is (yes)
|
||||
note right
|
||||
Structured Outputs (response_format:
|
||||
json_schema, strict: true) already
|
||||
guarantees schema shape, so only 2
|
||||
attempts are needed - retries here cover
|
||||
transient failures / placeholder text,
|
||||
not malformed JSON.
|
||||
end note
|
||||
endif
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
if (Generation successful?) then (yes)
|
||||
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
|
||||
:BuildEmail(name, used_email_local_parts);
|
||||
:GenerateDateOfBirth(rng);
|
||||
note right
|
||||
email and date_of_birth are generated
|
||||
programmatically, never LLM-authored.
|
||||
Generation + coordinate jitter run inside
|
||||
one try/catch per record. UserRecord.address
|
||||
now carries {city, longitude, latitude}.
|
||||
end note
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:ProcessRecord(UserRecord);
|
||||
if (Location in cache?) then (yes)
|
||||
:Reuse location_id;
|
||||
else (no)
|
||||
:Insert Location & Cache ID;
|
||||
endif
|
||||
:Insert User (FK: location_id);
|
||||
|
||||
if (Exception caught during insert?) then (yes)
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:Log warning "SQLite export failed";
|
||||
else (no)
|
||||
endif
|
||||
else (no)
|
||||
:Log warning "Generation failed, skipping...";
|
||||
endif
|
||||
endif
|
||||
endwhile (Done)
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:GenerateBreweries(enriched_cities);
|
||||
|
||||
|#E5EDE1|DataGenerator|
|
||||
while (For each EnrichedCity?) is (Remaining cities)
|
||||
if (Generator Mode) then (MockGenerator)
|
||||
:DeterministicHash & Format;
|
||||
elseif (Generator Mode) then (LlamaGenerator)
|
||||
:PrepareRegionContext;
|
||||
:prompt_directory_->Load("BREWERY_GENERATION");
|
||||
note right
|
||||
Resolves to BREWERY_GENERATION.md inside --prompt-dir.
|
||||
end note
|
||||
repeat
|
||||
:Infer(system_prompt, user_prompt, max_tokens, kBreweryJsonGrammar);
|
||||
:ValidateBreweryJson(raw, brewery);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 3?) is (yes)
|
||||
note right
|
||||
max_tokens is fixed across retries —
|
||||
it is not raised on truncation.
|
||||
end note
|
||||
else (OpenAIGenerator)
|
||||
:PrepareRegionContext;
|
||||
:prompt_directory_->Load("BREWERY_GENERATION");
|
||||
note right
|
||||
Same prompt file as LlamaGenerator - content
|
||||
is backend-agnostic.
|
||||
end note
|
||||
repeat
|
||||
:CallChatCompletionsApi(system_prompt, user_prompt,\nkBreweryJsonSchema, max_tokens);
|
||||
:ValidateBreweryJson(raw, brewery);
|
||||
if (Is JSON Valid?) then (yes)
|
||||
break
|
||||
else (no)
|
||||
:Attempt++;
|
||||
:Append validation error to retry prompt;
|
||||
endif
|
||||
repeat while (Attempt < 2?) is (yes)
|
||||
note right
|
||||
Structured Outputs already guarantees schema
|
||||
shape, so only 2 attempts are needed - retries
|
||||
here cover transient failures / placeholder
|
||||
text, not malformed JSON.
|
||||
end note
|
||||
endif
|
||||
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
if (Generation successful?) then (yes)
|
||||
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
|
||||
note right
|
||||
Generation + coordinate jitter run inside one
|
||||
try/catch per record. BreweryRecord.address
|
||||
now carries {city, longitude, latitude}.
|
||||
end note
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:ProcessRecord(GeneratedBrewery);
|
||||
if (Location in cache?) then (yes)
|
||||
:Reuse location_id;
|
||||
else (no)
|
||||
:Insert Location & Cache ID;
|
||||
endif
|
||||
:Insert Brewery (FK: location_id);
|
||||
|
||||
if (Exception caught during insert?) then (yes)
|
||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||
:Log warning "SQLite export failed";
|
||||
note right
|
||||
Data loss is prevented per-record.
|
||||
The pipeline continues running.
|
||||
end note
|
||||
else (no)
|
||||
endif
|
||||
else (no)
|
||||
:Log warning "Generation failed, skipping...";
|
||||
endif
|
||||
|#E5EDE1|DataGenerator|
|
||||
endwhile (Done)
|
||||
|
||||
|#E0EAE0|SqliteExportService|
|
||||
:Finalize();
|
||||
note right
|
||||
Commits Transaction
|
||||
Closes Database Connection
|
||||
end note
|
||||
|
||||
|#F2F6F0|main.cc|
|
||||
:Close log_channel;
|
||||
:Join log dispatcher thread;
|
||||
:Return 0;
|
||||
stop
|
||||
|
||||
@enduml
|
||||
5
docs/pipeline/diagrams/current/class.md
Normal file
5
docs/pipeline/diagrams/current/class.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# The Biergarten Data Pipeline — Class Diagram (Current)
|
||||
|
||||
```mermaid
|
||||
--8<-- "pipeline/diagrams/current/class.mmd"
|
||||
```
|
||||
285
docs/pipeline/diagrams/current/class.mmd
Normal file
285
docs/pipeline/diagrams/current/class.mmd
Normal file
@@ -0,0 +1,285 @@
|
||||
classDiagram
|
||||
class BiergartenPipelineOrchestrator {
|
||||
-shared_ptr~ILogger~ logger_
|
||||
-unique_ptr~IEnrichmentService~ context_service_
|
||||
-unique_ptr~DataGenerator~ generator_
|
||||
-unique_ptr~IExportService~ exporter_
|
||||
-unique_ptr~ICuratedDataService~ curated_data_service_
|
||||
-unique_ptr~IAddressService~ address_service_
|
||||
-ApplicationOptions application_options_
|
||||
-vector~BreweryRecord~ generated_breweries_
|
||||
-vector~UserRecord~ generated_users_
|
||||
+Run() bool
|
||||
-QueryLocations() vector~City~
|
||||
-GenerateBreweries(cities) void
|
||||
-GenerateUsers(cities) void
|
||||
-LogResults() void
|
||||
}
|
||||
note for BiergartenPipelineOrchestrator "GenerateBreweries/GenerateUsers jitter each address via GetRandomCoordsWithinRange(), uniformly within 5km of the city centre. GenerateBreweries additionally reverse-geocodes the jittered coordinates via address_service_."
|
||||
|
||||
class LogLevel {
|
||||
<<enumeration>>
|
||||
Debug
|
||||
Info
|
||||
Warn
|
||||
Error
|
||||
}
|
||||
|
||||
class PipelinePhase {
|
||||
<<enumeration>>
|
||||
Startup
|
||||
Enrichment
|
||||
UserGeneration
|
||||
BreweryAndBeerGeneration
|
||||
CheckinGeneration
|
||||
RatingGeneration
|
||||
FollowGeneration
|
||||
Teardown
|
||||
}
|
||||
|
||||
class LogDTO {
|
||||
<<struct>>
|
||||
+LogLevel level
|
||||
+PipelinePhase phase
|
||||
+string message
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
<<struct>>
|
||||
+time_point timestamp
|
||||
+source_location origin
|
||||
+thread::id thread_id
|
||||
+LogLevel level
|
||||
+PipelinePhase phase
|
||||
+string message
|
||||
}
|
||||
|
||||
class ILogger {
|
||||
<<interface>>
|
||||
+Log(payload) void
|
||||
-DoLog(entry)* void
|
||||
}
|
||||
|
||||
class LogProducer {
|
||||
-BoundedChannel~LogEntry~ channel_
|
||||
-DoLog(entry) void
|
||||
}
|
||||
|
||||
class LogDispatcher {
|
||||
-BoundedChannel~LogEntry~ channel_
|
||||
+Run() void
|
||||
-ToSpdlogLevel(level) level_enum
|
||||
}
|
||||
|
||||
class IEnrichmentService {
|
||||
<<interface>>
|
||||
+GetLocationContext(loc) string
|
||||
}
|
||||
|
||||
class MockEnrichmentService {
|
||||
+GetLocationContext(loc) string
|
||||
}
|
||||
|
||||
class WikipediaEnrichmentService {
|
||||
-unique_ptr~WebClient~ client_
|
||||
-unordered_map~string, string~ extract_cache_
|
||||
+GetLocationContext(loc) string
|
||||
-FetchExtract(query) string
|
||||
}
|
||||
|
||||
class WebClient {
|
||||
<<interface>>
|
||||
+Get(url) string
|
||||
+UrlEncode(value) string
|
||||
}
|
||||
|
||||
class HttpWebClient {
|
||||
+Get(url) string
|
||||
+UrlEncode(value) string
|
||||
}
|
||||
|
||||
class DataGenerator {
|
||||
<<interface>>
|
||||
+GenerateBrewery(city) BreweryResult
|
||||
+GenerateUser(city, persona, name) UserResult
|
||||
}
|
||||
|
||||
class MockGenerator {
|
||||
+GenerateBrewery(city) BreweryResult
|
||||
+GenerateUser(city, persona, name) UserResult
|
||||
-DeterministicHash(location) size_t
|
||||
-DeterministicHash(location, persona, name) size_t
|
||||
}
|
||||
|
||||
class LlamaGenerator {
|
||||
-ModelHandle model_
|
||||
-ContextHandle context_
|
||||
-unique_ptr~IPromptFormatter~ prompt_formatter_
|
||||
-unique_ptr~IPromptDirectory~ prompt_directory_
|
||||
-mt19937 rng_
|
||||
+GenerateBrewery(...) BreweryResult
|
||||
+GenerateUser(...) UserResult
|
||||
-Load(model_path) void
|
||||
-Infer(...) string
|
||||
-InferFormatted(...) string
|
||||
}
|
||||
|
||||
class OpenAIGenerator {
|
||||
-string api_key_
|
||||
-string model_
|
||||
-shared_ptr~ILogger~ logger_
|
||||
-unique_ptr~IPromptDirectory~ prompt_directory_
|
||||
-unique_ptr~WebClient~ web_client_
|
||||
+GenerateBrewery(city) BreweryResult
|
||||
+GenerateUser(city, persona, name) UserResult
|
||||
-CallChatCompletionsApi(...) string
|
||||
}
|
||||
note for OpenAIGenerator "Chat Completions with Structured Outputs (json_schema, strict) guarantees schema-valid JSON, replacing llama.cpp's GBNF grammar — no IPromptFormatter needed"
|
||||
|
||||
class IPromptFormatter {
|
||||
<<interface>>
|
||||
+Format(system_prompt, user_prompt) string
|
||||
}
|
||||
|
||||
class Gemma4JinjaPromptFormatter {
|
||||
+Format(system_prompt, user_prompt) string
|
||||
}
|
||||
|
||||
class IPromptDirectory {
|
||||
<<interface>>
|
||||
+Load(key) string
|
||||
}
|
||||
|
||||
class PromptDirectory {
|
||||
-path prompt_dir_
|
||||
-unordered_map~string, string~ cache_
|
||||
+Load(key) string
|
||||
}
|
||||
|
||||
class ICuratedDataService {
|
||||
<<interface>>
|
||||
+LoadLocations() vector~Location~
|
||||
+LoadPersonas() vector~UserPersona~
|
||||
+LoadForenamesByCountry() unordered_map~string, forename_list~
|
||||
+LoadSurnamesByCountry() unordered_map~string, surname_list~
|
||||
}
|
||||
|
||||
class CuratedJsonDataService {
|
||||
-CuratedDataFilePaths filepaths_
|
||||
-cache cache_
|
||||
+LoadLocations() vector~Location~
|
||||
+LoadPersonas() vector~UserPersona~
|
||||
+LoadForenamesByCountry() unordered_map~string, forename_list~
|
||||
+LoadSurnamesByCountry() unordered_map~string, surname_list~
|
||||
}
|
||||
note for CuratedJsonDataService "Takes a CuratedDataFilePaths DTO (locations/personas/forenames/surnames paths) once at construction, not per Load* call. Each Load* method memoizes its result in cache_ after its first call."
|
||||
|
||||
class MockCuratedDataService {
|
||||
-vector~Location~ locations_
|
||||
-vector~UserPersona~ personas_
|
||||
-unordered_map~string, forename_list~ forenames_by_country_
|
||||
-unordered_map~string, surname_list~ surnames_by_country_
|
||||
+LoadLocations() vector~Location~
|
||||
+LoadPersonas() vector~UserPersona~
|
||||
+LoadForenamesByCountry() unordered_map~string, forename_list~
|
||||
+LoadSurnamesByCountry() unordered_map~string, surname_list~
|
||||
}
|
||||
note for MockCuratedDataService "Fixed 4-location/3-persona dataset (US, DE, FR, BE) for --mocked runs"
|
||||
|
||||
class IAddressService {
|
||||
<<interface>>
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
|
||||
class NominatimAddressService {
|
||||
-unique_ptr~WebClient~ client_
|
||||
-shared_ptr~ILogger~ logger_
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
note for NominatimAddressService "Queries the public Nominatim API; sleeps 1s per request per its usage policy; returns nullopt on failure or when no usable street address is found"
|
||||
|
||||
class MockAddressService {
|
||||
+ReverseGeocode(longitude, latitude) optional~AddressLookupResult~
|
||||
}
|
||||
note for MockAddressService "Returns a fixed placeholder address ('123 Mock Street', '00000') for --mocked runs, no network call"
|
||||
|
||||
class IExportService {
|
||||
<<interface>>
|
||||
+Initialize() void
|
||||
+ProcessRecord(brewery) uint64_t
|
||||
+ProcessRecord(user) uint64_t
|
||||
+Finalize() void
|
||||
}
|
||||
|
||||
class SqliteExportService {
|
||||
-unique_ptr~IDateTimeProvider~ date_time_provider_
|
||||
-string run_timestamp_utc_
|
||||
-path database_path_
|
||||
-SqliteDatabaseHandle db_handle_
|
||||
-SqliteStatementHandle insert_city_stmt_
|
||||
-SqliteStatementHandle insert_brewery_stmt_
|
||||
-SqliteStatementHandle insert_brewery_address_stmt_
|
||||
-SqliteStatementHandle insert_user_stmt_
|
||||
-SqliteStatementHandle insert_user_address_stmt_
|
||||
-bool transaction_open_
|
||||
-unordered_map~string, sqlite3_int64~ city_cache_
|
||||
+Initialize() void
|
||||
+ProcessRecord(brewery) uint64_t
|
||||
+ProcessRecord(user) uint64_t
|
||||
+Finalize() void
|
||||
-InitializeSchema() void
|
||||
-ResolveCityId(city) sqlite3_int64
|
||||
}
|
||||
|
||||
class IDateTimeProvider {
|
||||
<<interface>>
|
||||
+GetUtcTimestamp() string
|
||||
}
|
||||
|
||||
class SystemDateTimeProvider {
|
||||
+GetUtcTimestamp() string
|
||||
}
|
||||
|
||||
BiergartenPipelineOrchestrator *-- ILogger : owns
|
||||
BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
|
||||
BiergartenPipelineOrchestrator *-- DataGenerator : owns
|
||||
BiergartenPipelineOrchestrator *-- IExportService : owns
|
||||
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
|
||||
BiergartenPipelineOrchestrator *-- IAddressService : owns
|
||||
|
||||
LogEntry *-- LogLevel
|
||||
LogEntry *-- PipelinePhase
|
||||
LogDTO *-- LogLevel
|
||||
LogDTO *-- PipelinePhase
|
||||
ILogger <|.. LogProducer : implements
|
||||
LogProducer ..> LogEntry : emits
|
||||
LogDispatcher ..> LogEntry : consumes
|
||||
|
||||
IEnrichmentService <|.. WikipediaEnrichmentService : implements
|
||||
IEnrichmentService <|.. MockEnrichmentService : implements
|
||||
WikipediaEnrichmentService *-- WebClient : owns
|
||||
|
||||
WebClient <|.. HttpWebClient : implements
|
||||
|
||||
DataGenerator <|.. MockGenerator : implements
|
||||
DataGenerator <|.. LlamaGenerator : implements
|
||||
DataGenerator <|.. OpenAIGenerator : implements
|
||||
|
||||
LlamaGenerator *-- IPromptFormatter : uses
|
||||
LlamaGenerator *-- IPromptDirectory : uses
|
||||
|
||||
OpenAIGenerator *-- IPromptDirectory : uses
|
||||
OpenAIGenerator *-- WebClient : uses
|
||||
|
||||
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
||||
IPromptDirectory <|.. PromptDirectory : implements
|
||||
|
||||
ICuratedDataService <|.. CuratedJsonDataService : implements
|
||||
ICuratedDataService <|.. MockCuratedDataService : implements
|
||||
|
||||
IAddressService <|.. NominatimAddressService : implements
|
||||
IAddressService <|.. MockAddressService : implements
|
||||
|
||||
IExportService <|.. SqliteExportService : implements
|
||||
SqliteExportService *-- IDateTimeProvider : owns
|
||||
IDateTimeProvider <|.. SystemDateTimeProvider : implements
|
||||
@@ -1,301 +0,0 @@
|
||||
@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 BiergartenPipelineOrchestrator {
|
||||
- logger_ : std::shared_ptr<ILogger>
|
||||
- context_service_ : std::unique_ptr<IEnrichmentService>
|
||||
- generator_ : std::unique_ptr<DataGenerator>
|
||||
- exporter_ : std::unique_ptr<IExportService>
|
||||
- curated_data_service_ : std::unique_ptr<ICuratedDataService>
|
||||
- application_options_ : ApplicationOptions
|
||||
- generated_breweries_ : std::vector<BreweryRecord>
|
||||
- generated_users_ : std::vector<UserRecord>
|
||||
+ Run() : bool
|
||||
- QueryLocations() : std::vector<City>
|
||||
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
|
||||
- GenerateUsers(cities : std::span<const EnrichedCity>) : void
|
||||
- LogResults() const : void
|
||||
}
|
||||
|
||||
note right of BiergartenPipelineOrchestrator
|
||||
GenerateBreweries and GenerateUsers both call the
|
||||
free function GetRandomCoordsWithinRange()
|
||||
(biergarten_pipeline_orchestrator/coords.h) to jitter
|
||||
a coordinate pair uniformly within 5km of the city
|
||||
centre for each generated record's address.
|
||||
end note
|
||||
|
||||
class LogLevel <<enumeration>> {
|
||||
Debug
|
||||
Info
|
||||
Warn
|
||||
Error
|
||||
}
|
||||
|
||||
class PipelinePhase <<enumeration>> {
|
||||
Startup
|
||||
Enrichment
|
||||
UserGeneration
|
||||
BreweryAndBeerGeneration
|
||||
CheckinGeneration
|
||||
RatingGeneration
|
||||
FollowGeneration
|
||||
Teardown
|
||||
}
|
||||
|
||||
struct LogDTO {
|
||||
+ level : LogLevel
|
||||
+ phase : PipelinePhase
|
||||
+ message : std::string
|
||||
}
|
||||
|
||||
struct LogEntry {
|
||||
+ timestamp : std::chrono::system_clock::time_point
|
||||
+ origin : std::source_location
|
||||
+ thread_id : std::thread::id
|
||||
+ level : LogLevel
|
||||
+ phase : PipelinePhase
|
||||
+ message : std::string
|
||||
}
|
||||
|
||||
interface ILogger <<interface>> {
|
||||
+ Log(payload : LogDTO) : void
|
||||
- {abstract} DoLog(entry : LogEntry) : void
|
||||
}
|
||||
|
||||
class LogProducer {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
- DoLog(entry : LogEntry) : void
|
||||
}
|
||||
|
||||
class LogDispatcher {
|
||||
- channel_ : BoundedChannel<LogEntry>&
|
||||
+ Run() : void
|
||||
- ToSpdlogLevel(level) : spdlog::level::level_enum
|
||||
}
|
||||
|
||||
interface IEnrichmentService <<interface>> {
|
||||
+ GetLocationContext(loc : const Location&) : std::string
|
||||
}
|
||||
|
||||
class MockEnrichmentService {
|
||||
+ GetLocationContext(loc : const Location&) : std::string
|
||||
}
|
||||
|
||||
class WikipediaEnrichmentService {
|
||||
- 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 HttpWebClient {
|
||||
+ Get(url : const std::string&) : std::string
|
||||
+ UrlEncode(value : const std::string&) : std::string
|
||||
}
|
||||
|
||||
interface DataGenerator <<interface>> {
|
||||
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
}
|
||||
|
||||
class MockGenerator {
|
||||
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
- DeterministicHash(location : const Location&) : size_t
|
||||
- DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t
|
||||
}
|
||||
|
||||
class LlamaGenerator {
|
||||
- model_ : ModelHandle
|
||||
- context_ : ContextHandle
|
||||
- prompt_formatter_ : std::unique_ptr<IPromptFormatter>
|
||||
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
|
||||
- rng_ : std::mt19937
|
||||
+ GenerateBrewery(...) : BreweryResult
|
||||
+ GenerateUser(...) : UserResult
|
||||
- Load(model_path : const std::string&) : void
|
||||
- Infer(...) : std::string
|
||||
- InferFormatted(...) : std::string
|
||||
}
|
||||
|
||||
class OpenAIGenerator {
|
||||
- api_key_ : std::string
|
||||
- model_ : std::string
|
||||
- logger_ : std::shared_ptr<ILogger>
|
||||
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
|
||||
- web_client_ : std::unique_ptr<WebClient>
|
||||
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
|
||||
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||
- CallChatCompletionsApi(...) : std::string
|
||||
}
|
||||
|
||||
note right of OpenAIGenerator
|
||||
Chat Completions with Structured Outputs
|
||||
(response_format: json_schema, strict: true)
|
||||
guarantees schema-valid JSON, replacing
|
||||
llama.cpp's GBNF grammar constraint -
|
||||
no IPromptFormatter needed.
|
||||
end note
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
interface IPromptDirectory <<interface>> {
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
class PromptDirectory {
|
||||
- prompt_dir_ : std::filesystem::path
|
||||
- cache_ : std::unordered_map<std::string, std::string>
|
||||
+ Load(key : std::string_view) : std::string
|
||||
}
|
||||
|
||||
interface ICuratedDataService <<interface>> {
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
class JsonLoader {
|
||||
- cache_ : cache
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
note right of JsonLoader
|
||||
Each Load* method memoizes its result in
|
||||
cache_ on first call; later calls on the
|
||||
same instance return the cached value
|
||||
without re-parsing.
|
||||
end note
|
||||
|
||||
class MockCuratedDataService {
|
||||
- locations_ : std::vector<Location>
|
||||
- personas_ : std::vector<UserPersona>
|
||||
- forenames_by_country_ : std::unordered_map<std::string, forename_list>
|
||||
- surnames_by_country_ : std::unordered_map<std::string, surname_list>
|
||||
+ LoadLocations(filepath : const std::filesystem::path&) : const std::vector<Location>&
|
||||
+ LoadPersonas(filepath : const std::filesystem::path&) : const std::vector<UserPersona>&
|
||||
+ LoadForenamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, forename_list>&
|
||||
+ LoadSurnamesByCountry(filepath : const std::filesystem::path&) : const std::unordered_map<std::string, surname_list>&
|
||||
}
|
||||
|
||||
note right of MockCuratedDataService
|
||||
Fixed 4-location / 3-persona dataset
|
||||
(US, DE, FR, BE) used in --mocked runs;
|
||||
filepath arguments are ignored.
|
||||
end note
|
||||
|
||||
interface IExportService <<interface>> {
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
|
||||
+ ProcessRecord(user : const UserRecord&) : uint64_t
|
||||
+ Finalize() : void
|
||||
}
|
||||
|
||||
class SqliteExportService {
|
||||
- date_time_provider_ : std::unique_ptr<IDateTimeProvider>
|
||||
- run_timestamp_utc_ : std::string
|
||||
- database_path_ : std::filesystem::path
|
||||
- db_handle_ : SqliteDatabaseHandle
|
||||
- insert_location_stmt_ : SqliteStatementHandle
|
||||
- insert_brewery_stmt_ : SqliteStatementHandle
|
||||
- insert_user_stmt_ : SqliteStatementHandle
|
||||
- transaction_open_ : bool
|
||||
- location_cache_ : std::unordered_map<std::string, sqlite3_int64>
|
||||
+ Initialize() : void
|
||||
+ ProcessRecord(brewery : const BreweryRecord&) : uint64_t
|
||||
+ ProcessRecord(user : const UserRecord&) : uint64_t
|
||||
+ Finalize() : void
|
||||
- InitializeSchema() : void
|
||||
- ResolveLocationId(location : const Location&) : sqlite3_int64
|
||||
}
|
||||
|
||||
interface IDateTimeProvider <<interface>> {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
class SystemDateTimeProvider {
|
||||
+ GetUtcTimestamp() : std::string
|
||||
}
|
||||
|
||||
' Structural Relationships / Dependency Injection
|
||||
BiergartenPipelineOrchestrator *-- ILogger : owns
|
||||
BiergartenPipelineOrchestrator *-- IEnrichmentService : owns
|
||||
BiergartenPipelineOrchestrator *-- DataGenerator : owns
|
||||
BiergartenPipelineOrchestrator *-- IExportService : owns
|
||||
BiergartenPipelineOrchestrator *-- ICuratedDataService : owns
|
||||
|
||||
LogEntry *-- LogLevel
|
||||
LogEntry *-- PipelinePhase
|
||||
LogDTO *-- LogLevel
|
||||
LogDTO *-- PipelinePhase
|
||||
ILogger <|.. LogProducer : implements
|
||||
LogProducer ..> LogEntry : emits
|
||||
LogDispatcher ..> LogEntry : consumes
|
||||
|
||||
IEnrichmentService <|.. WikipediaEnrichmentService : implements
|
||||
IEnrichmentService <|.. MockEnrichmentService : implements
|
||||
WikipediaEnrichmentService *-- WebClient : owns
|
||||
|
||||
WebClient <|.. HttpWebClient : implements
|
||||
|
||||
DataGenerator <|.. MockGenerator : implements
|
||||
DataGenerator <|.. LlamaGenerator : implements
|
||||
DataGenerator <|.. OpenAIGenerator : implements
|
||||
|
||||
LlamaGenerator *-- IPromptFormatter : uses
|
||||
LlamaGenerator *-- IPromptDirectory : uses
|
||||
|
||||
OpenAIGenerator *-- IPromptDirectory : uses
|
||||
OpenAIGenerator *-- WebClient : uses
|
||||
|
||||
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
||||
IPromptDirectory <|.. PromptDirectory : implements
|
||||
|
||||
ICuratedDataService <|.. JsonLoader : implements
|
||||
ICuratedDataService <|.. MockCuratedDataService : implements
|
||||
|
||||
IExportService <|.. SqliteExportService : implements
|
||||
SqliteExportService *-- IDateTimeProvider : owns
|
||||
IDateTimeProvider <|.. SystemDateTimeProvider : implements
|
||||
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user