AI-assisted timesheet analysis for IBEW Local 1245 employees covered by the Pinnacle Powers labor agreement. The application verifies pay calculations against union contract rules and answers plain-language questions about specific timesheets.
- Application Overview
- Calculator Functionality
- AI Process
- Architecture
- Prerequisites
- GCP Setup
- Service Account Permissions
- Configuration
- Local Development
- Deployment
The app has four sections:
| Route | Purpose |
|---|---|
/calculator |
Load a timesheet and see each job classified by pay type with totals |
/chat (Ask Gemini) |
Ask plain-language questions about a loaded timesheet |
/docs (Rules & Docs) |
Upload, edit, and delete contract documents; trigger Vertex AI re-index |
/about |
Explains how the system works |
The calculator applies IBEW Local 1245 pay rules mechanically to timesheet data pulled from a Cloud SQL (PostgreSQL) database.
| Code | Meaning | Rate |
|---|---|---|
| ST | Standard Time | 1× |
| PT | Premium Time | 1.5× |
| DT | Double Time | 2× |
- Default: 6:00 AM – 2:00 PM (configurable per company config)
- Extends by +30 minutes when a lunch break is taken
- Hours inside the STW on a regularly scheduled workday are ST
- RS (Regularly Scheduled) — normal workday shift
- ES (Emergency / call-out) — unplanned call-out outside normal schedule
PT applies when any of the following are true:
- Work falls on a weekend or holiday → all hours are DT
- ES shift, first 4 hours → PT regardless of time of day
- RS pre-shift work ≥ 6 hours before start → all hours are PT
- ES post-STW with no 8-hour break → PT
- RS pre-shift < 6 hours → PT for the pre-shift portion only
- Hours outside the STW on a normal RS shift → PT
- Hours exceeding the 8-hour ST cap → excess is PT
- RS shifts: penalty triggers after 10.5 hours, then every 4.5 hours after that
- ES shifts: penalty triggers every 4.5 hours from shift start
- One subsistence payment per employee per calendar day
- Applies when total hours worked exceed the configured threshold (default: 0.5 h)
- Enforced with a
UNIQUE(employee_id, award_date)constraint to prevent duplicates
The AI stack uses three components in sequence:
Union contract documents (PDF, text) are stored in the union-rules-docs GCS bucket. Administrators manage this library from the Rules & Docs page. After uploading or editing documents, clicking Sync to AI triggers a re-index.
When a user asks a question, the API first calls the Discovery Engine search endpoint. It searches only the union-rules-docs data store and returns the most relevant document excerpts — typically the specific contract clauses that apply to the question.
This avoids the context-length problem of sending the entire contract with every request, and ensures answers are grounded in the actual agreement rather than the public internet.
The retrieved excerpts are combined with the specific timesheet data and passed to Gemini (gemini-2.0-flash). Gemini reasons over the provided context and returns a precise, numeric answer referencing the actual contract language.
User question + timesheet
│
▼
Vertex AI Search ──► relevant contract excerpts
│
▼
Gemini (excerpts + timesheet JSON as context)
│
▼
Plain-language answer with arithmetic and rule citations
| Layer | Technology |
|---|---|
| Frontend | Angular 17+ (standalone components, Signals) |
| Backend | ASP.NET Core 8 Web API (C#) |
| Database | Google Cloud SQL — PostgreSQL 16 |
| AI Search | Google Vertex AI Search (Discovery Engine) |
| AI Reasoning | Google Gemini via Vertex AI (gemini-2.0-flash) |
| Document Storage | Google Cloud Storage (union-rules-docs bucket) |
| Container Registry | Google Artifact Registry |
| Hosting | Google Cloud Run (single container: nginx + Kestrel via supervisord) |
- Google Cloud project with billing enabled
gcloudCLI authenticated- Docker (for local builds)
- .NET 8 SDK
- Node.js 20 + Angular CLI
gcloud services enable \
run.googleapis.com \
artifactregistry.googleapis.com \
sqladmin.googleapis.com \
storage.googleapis.com \
discoveryengine.googleapis.com \
aiplatform.googleapis.com \
--project=YOUR_PROJECT_IDgsutil mb -l us-west1 gs://union-rules-docs- GCP Console → Agent Builder → Data Stores → Create
- Select Cloud Storage as the source
- Point to
gs://union-rules-docs/* - Content type: Unstructured documents
- Note the Data Store ID from the URL after creation
- Agent Builder → Apps → Create
- Type: Search
- Link to the data store created above
- Note the Engine ID (used in
VertexAiServiceasunion-rules-search)
gcloud artifacts repositories create docker-repo \
--repository-format=docker \
--location=us-central1 \
--project=YOUR_PROJECT_IDgcloud sql instances create union-pay-db \
--database-version=POSTGRES_16 \
--tier=db-f1-micro \
--region=us-west1 \
--project=YOUR_PROJECT_IDAll API calls use a single service account (referenced in VertexAi:ServiceAccountPath). The account requires the following roles:
| Role | Purpose |
|---|---|
roles/discoveryengine.editor |
Import documents into the data store (Sync to AI), manage index |
roles/discoveryengine.viewer |
Search the data store (Ask Gemini) |
roles/aiplatform.user |
Call Gemini via Vertex AI (aiplatform.endpoints.predict) |
roles/storage.objectCreator |
Upload files to GCS bucket from the API |
SA="your-service-account@your-project.iam.gserviceaccount.com"
PROJECT="your-project-id"
gcloud projects add-iam-policy-binding $PROJECT \
--member="serviceAccount:$SA" --role="roles/discoveryengine.editor"
gcloud projects add-iam-policy-binding $PROJECT \
--member="serviceAccount:$SA" --role="roles/discoveryengine.viewer"
gcloud projects add-iam-policy-binding $PROJECT \
--member="serviceAccount:$SA" --role="roles/aiplatform.user"
gcloud projects add-iam-policy-binding $PROJECT \
--member="serviceAccount:$SA" --role="roles/storage.objectCreator"objectCreator does not include delete. Grant objectAdmin directly on the bucket:
gsutil iam ch serviceAccount:$SA:roles/storage.objectAdmin gs://union-rules-docsNote:
objectAdminon the bucket supersedes the project-levelobjectCreatorfor that bucket. You can remove the project-levelobjectCreatorif the service account only needs access to this one bucket.
If you prefer fine-grained permissions instead of predefined roles:
| Permission | Required For |
|---|---|
discoveryengine.documents.import |
Sync to AI |
discoveryengine.documents.get |
Search |
discoveryengine.documents.list |
Search |
aiplatform.endpoints.predict |
Gemini (Ask Gemini, Chat) |
storage.objects.create |
Upload files |
storage.objects.get |
Download / serve files |
storage.objects.list |
List files in docs page |
storage.objects.delete |
Delete files from docs page |
{
"Gemini": {
"ProjectId": "your-gcp-project-id"
},
"VertexAi": {
"ServiceAccountPath": "/app/creds/vertexai.json",
"DataStoreId": "your-discovery-engine-data-store-id"
}
}{
"Gemini": {
"ProjectId": "your-gcp-project-id",
"ServiceAccountPath": "../../creds/service-account.json"
},
"VertexAi": {
"ServiceAccountPath": "../../creds/vertexai.json",
"DataStoreId": "your-discovery-engine-data-store-id"
}
}Place service account JSON files at:
creds/vertexai.json— used by VertexAiService and DocsServicecreds/service-account.json— used by GeminiService
The Dockerfile copies creds/vertexai.json into the image at /app/creds/vertexai.json. Do not commit credential files to source control.
export const environment = {
production: false,
chatServer: '', // empty = relative URLs (proxied by ng serve)
googleClientId: 'YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com'
};cd server/UnionRulesApi
dotnet run --launch-profile dev
# Runs on http://0.0.0.0:8080 with ASPNETCORE_ENVIRONMENT=Developmentcd client
npm install
ng serve
# Proxies /api/* to http://localhost:8080 via proxy.conf.jsonOpen http://localhost:4200.
PROJECT=your-project-id
IMAGE=us-central1-docker.pkg.dev/$PROJECT/docker-repo/timesheet-calculator:latest
gcloud auth configure-docker us-central1-docker.pkg.dev
docker build -t $IMAGE .
docker push $IMAGEgcloud run deploy timesheet-calculator \
--image $IMAGE \
--project $PROJECT \
--region us-west1 \
--allow-unauthenticatedAfter uploading or editing documents in the Rules & Docs page, click Sync to AI to trigger a full re-index. The Discovery Engine import runs asynchronously; the index is typically updated within 2–5 minutes.
Alternatively, run the shell script directly on a machine with gcloud credentials:
bash scripts/reindex-docs.sh